github.com/abdfnx/gh-api@v0.0.0-20210414084727-f5432eec23b8/pkg/cmd/api/api.go (about) 1 package api 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "io" 9 "io/ioutil" 10 "net/http" 11 "os" 12 "regexp" 13 "sort" 14 "strconv" 15 "strings" 16 "syscall" 17 "time" 18 19 "github.com/MakeNowJust/heredoc" 20 "github.com/abdfnx/gh-api/api" 21 "github.com/abdfnx/gh-api/internal/config" 22 "github.com/abdfnx/gh-api/internal/ghinstance" 23 "github.com/abdfnx/gh-api/internal/ghrepo" 24 "github.com/abdfnx/gh-api/pkg/cmdutil" 25 "github.com/abdfnx/gh-api/pkg/iostreams" 26 "github.com/abdfnx/gh-api/pkg/jsoncolor" 27 "github.com/spf13/cobra" 28 ) 29 30 type ApiOptions struct { 31 IO *iostreams.IOStreams 32 33 Hostname string 34 RequestMethod string 35 RequestMethodPassed bool 36 RequestPath string 37 RequestInputFile string 38 MagicFields []string 39 RawFields []string 40 RequestHeaders []string 41 Previews []string 42 ShowResponseHeaders bool 43 Paginate bool 44 Silent bool 45 Template string 46 CacheTTL time.Duration 47 FilterOutput string 48 49 Config func() (config.Config, error) 50 HttpClient func() (*http.Client, error) 51 BaseRepo func() (ghrepo.Interface, error) 52 Branch func() (string, error) 53 } 54 55 func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command { 56 opts := ApiOptions{ 57 IO: f.IOStreams, 58 Config: f.Config, 59 HttpClient: f.HttpClient, 60 BaseRepo: f.BaseRepo, 61 Branch: f.Branch, 62 } 63 64 cmd := &cobra.Command{ 65 Use: "api <endpoint>", 66 Short: "Make an authenticated GitHub API request", 67 Long: heredoc.Docf(` 68 Makes an authenticated HTTP request to the GitHub API and prints the response. 69 70 The endpoint argument should either be a path of a GitHub API v3 endpoint, or 71 "graphql" to access the GitHub API v4. 72 73 Placeholder values ":owner", ":repo", and ":branch" in the endpoint argument will 74 get replaced with values from the repository of the current directory. 75 76 The default HTTP request method is "GET" normally and "POST" if any parameters 77 were added. Override the method with %[1]s--method%[1]s. 78 79 Pass one or more %[1]s--raw-field%[1]s values in "key=value" format to add 80 JSON-encoded string parameters to the POST body. 81 82 The %[1]s--field%[1]s flag behaves like %[1]s--raw-field%[1]s with magic type conversion based 83 on the format of the value: 84 85 - literal values "true", "false", "null", and integer numbers get converted to 86 appropriate JSON types; 87 - placeholder values ":owner", ":repo", and ":branch" get populated with values 88 from the repository of the current directory; 89 - if the value starts with "@", the rest of the value is interpreted as a 90 filename to read the value from. Pass "-" to read from standard input. 91 92 For GraphQL requests, all fields other than "query" and "operationName" are 93 interpreted as GraphQL variables. 94 95 Raw request body may be passed from the outside via a file specified by %[1]s--input%[1]s. 96 Pass "-" to read from standard input. In this mode, parameters specified via 97 %[1]s--field%[1]s flags are serialized into URL query parameters. 98 99 In %[1]s--paginate%[1]s mode, all pages of results will sequentially be requested until 100 there are no more pages of results. For GraphQL requests, this requires that the 101 original query accepts an %[1]s$endCursor: String%[1]s variable and that it fetches the 102 %[1]spageInfo{ hasNextPage, endCursor }%[1]s set of fields from a collection. 103 104 The %[1]s--jq%[1]s option accepts a query in jq syntax and will print only the resulting 105 values that match the query. This is equivalent to piping the output to %[1]sjq -r%[1]s, 106 but does not require the jq utility to be installed on the system. To learn more 107 about the query syntax, see: https://stedolan.github.io/jq/manual/v1.6/ 108 109 With %[1]s--template%[1]s, the provided Go template is rendered using the JSON data as input. 110 For the syntax of Go templates, see: https://golang.org/pkg/text/template/ 111 112 The following functions are available in templates: 113 - %[1]scolor <style>, <input>%[1]s: colorize input using https://github.com/mgutz/ansi 114 - %[1]sautocolor%[1]s: like %[1]scolor%[1]s, but only emits color to terminals 115 - %[1]stimefmt <format> <time>%[1]s: formats a timestamp using Go's Time.Format function 116 - %[1]stimeago <time>%[1]s: renders a timestamp as relative to now 117 - %[1]spluck <field> <list>%[1]s: collects values of a field from all items in the input 118 - %[1]sjoin <sep> <list>%[1]s: joins values in the list using a separator 119 `, "`"), 120 Example: heredoc.Doc(` 121 # list releases in the current repository 122 $ gh api repos/:owner/:repo/releases 123 124 # post an issue comment 125 $ gh api repos/:owner/:repo/issues/123/comments -f body='Hi from CLI' 126 127 # add parameters to a GET request 128 $ gh api -X GET search/issues -f q='repo:abdfnx/gh-api is:open remote' 129 130 # set a custom HTTP header 131 $ gh api -H 'Accept: application/vnd.github.v3.raw+json' ... 132 133 # opt into GitHub API previews 134 $ gh api --preview baptiste,nebula ... 135 136 # print only specific fields from the response 137 $ gh api repos/:owner/:repo/issues --jq '.[].title' 138 139 # use a template for the output 140 $ gh api repos/:owner/:repo/issues --template \ 141 '{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " | color "yellow"}}){{"\n"}}{{end}}' 142 143 # list releases with GraphQL 144 $ gh api graphql -F owner=':owner' -F name=':repo' -f query=' 145 query($name: String!, $owner: String!) { 146 repository(owner: $owner, name: $name) { 147 releases(last: 3) { 148 nodes { tagName } 149 } 150 } 151 } 152 ' 153 154 # list all repositories for a user 155 $ gh api graphql --paginate -f query=' 156 query($endCursor: String) { 157 viewer { 158 repositories(first: 100, after: $endCursor) { 159 nodes { nameWithOwner } 160 pageInfo { 161 hasNextPage 162 endCursor 163 } 164 } 165 } 166 } 167 ' 168 `), 169 Annotations: map[string]string{ 170 "help:environment": heredoc.Doc(` 171 GH_TOKEN, GITHUB_TOKEN (in order of precedence): an authentication token for 172 github.com API requests. 173 174 GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN (in order of precedence): an 175 authentication token for API requests to GitHub Enterprise. 176 177 GH_HOST: make the request to a GitHub host other than github.com. 178 `), 179 }, 180 Args: cobra.ExactArgs(1), 181 RunE: func(c *cobra.Command, args []string) error { 182 opts.RequestPath = args[0] 183 opts.RequestMethodPassed = c.Flags().Changed("method") 184 185 if c.Flags().Changed("hostname") { 186 if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { 187 return &cmdutil.FlagError{Err: fmt.Errorf("error parsing `--hostname`: %w", err)} 188 } 189 } 190 191 if opts.Paginate && !strings.EqualFold(opts.RequestMethod, "GET") && opts.RequestPath != "graphql" { 192 return &cmdutil.FlagError{Err: errors.New("the `--paginate` option is not supported for non-GET requests")} 193 } 194 195 if err := cmdutil.MutuallyExclusive( 196 "the `--paginate` option is not supported with `--input`", 197 opts.Paginate, 198 opts.RequestInputFile != "", 199 ); err != nil { 200 return err 201 } 202 203 if err := cmdutil.MutuallyExclusive( 204 "only one of `--template`, `--jq`, or `--silent` may be used", 205 opts.Silent, 206 opts.FilterOutput != "", 207 opts.Template != "", 208 ); err != nil { 209 return err 210 } 211 212 if runF != nil { 213 return runF(&opts) 214 } 215 return apiRun(&opts) 216 }, 217 } 218 219 cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "The GitHub hostname for the request (default \"github.com\")") 220 cmd.Flags().StringVarP(&opts.RequestMethod, "method", "X", "GET", "The HTTP method for the request") 221 cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a typed parameter in `key=value` format") 222 cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format") 223 cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add a HTTP request header in `key:value` format") 224 cmd.Flags().StringSliceVarP(&opts.Previews, "preview", "p", nil, "Opt into GitHub API previews") 225 cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output") 226 cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results") 227 cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request") 228 cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Do not print the response body") 229 cmd.Flags().StringVarP(&opts.Template, "template", "t", "", "Format the response using a Go template") 230 cmd.Flags().StringVarP(&opts.FilterOutput, "jq", "q", "", "Query to select values from the response using jq syntax") 231 cmd.Flags().DurationVar(&opts.CacheTTL, "cache", 0, "Cache the response, e.g. \"3600s\", \"60m\", \"1h\"") 232 return cmd 233 } 234 235 func apiRun(opts *ApiOptions) error { 236 params, err := parseFields(opts) 237 if err != nil { 238 return err 239 } 240 241 isGraphQL := opts.RequestPath == "graphql" 242 requestPath, err := fillPlaceholders(opts.RequestPath, opts) 243 if err != nil { 244 return fmt.Errorf("unable to expand placeholder in path: %w", err) 245 } 246 method := opts.RequestMethod 247 requestHeaders := opts.RequestHeaders 248 var requestBody interface{} = params 249 250 if !opts.RequestMethodPassed && (len(params) > 0 || opts.RequestInputFile != "") { 251 method = "POST" 252 } 253 254 if opts.Paginate && !isGraphQL { 255 requestPath = addPerPage(requestPath, 100, params) 256 } 257 258 if opts.RequestInputFile != "" { 259 file, size, err := openUserFile(opts.RequestInputFile, opts.IO.In) 260 if err != nil { 261 return err 262 } 263 defer file.Close() 264 requestPath = addQuery(requestPath, params) 265 requestBody = file 266 if size >= 0 { 267 requestHeaders = append([]string{fmt.Sprintf("Content-Length: %d", size)}, requestHeaders...) 268 } 269 } 270 271 if len(opts.Previews) > 0 { 272 requestHeaders = append(requestHeaders, "Accept: "+previewNamesToMIMETypes(opts.Previews)) 273 } 274 275 httpClient, err := opts.HttpClient() 276 if err != nil { 277 return err 278 } 279 if opts.CacheTTL > 0 { 280 httpClient = api.NewCachedClient(httpClient, opts.CacheTTL) 281 } 282 283 headersOutputStream := opts.IO.Out 284 if opts.Silent { 285 opts.IO.Out = ioutil.Discard 286 } else { 287 err := opts.IO.StartPager() 288 if err != nil { 289 return err 290 } 291 defer opts.IO.StopPager() 292 } 293 294 cfg, err := opts.Config() 295 if err != nil { 296 return err 297 } 298 299 host, err := cfg.DefaultHost() 300 if err != nil { 301 return err 302 } 303 304 if opts.Hostname != "" { 305 host = opts.Hostname 306 } 307 308 hasNextPage := true 309 for hasNextPage { 310 resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders) 311 if err != nil { 312 return err 313 } 314 315 endCursor, err := processResponse(resp, opts, headersOutputStream) 316 if err != nil { 317 return err 318 } 319 320 if !opts.Paginate { 321 break 322 } 323 324 if isGraphQL { 325 hasNextPage = endCursor != "" 326 if hasNextPage { 327 params["endCursor"] = endCursor 328 } 329 } else { 330 requestPath, hasNextPage = findNextPage(resp) 331 } 332 333 if hasNextPage && opts.ShowResponseHeaders { 334 fmt.Fprint(opts.IO.Out, "\n") 335 } 336 } 337 338 return nil 339 } 340 341 func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream io.Writer) (endCursor string, err error) { 342 if opts.ShowResponseHeaders { 343 fmt.Fprintln(headersOutputStream, resp.Proto, resp.Status) 344 printHeaders(headersOutputStream, resp.Header, opts.IO.ColorEnabled()) 345 fmt.Fprint(headersOutputStream, "\r\n") 346 } 347 348 if resp.StatusCode == 204 { 349 return 350 } 351 var responseBody io.Reader = resp.Body 352 defer resp.Body.Close() 353 354 isJSON, _ := regexp.MatchString(`[/+]json(;|$)`, resp.Header.Get("Content-Type")) 355 356 var serverError string 357 if isJSON && (opts.RequestPath == "graphql" || resp.StatusCode >= 400) { 358 responseBody, serverError, err = parseErrorResponse(responseBody, resp.StatusCode) 359 if err != nil { 360 return 361 } 362 } 363 364 var bodyCopy *bytes.Buffer 365 isGraphQLPaginate := isJSON && resp.StatusCode == 200 && opts.Paginate && opts.RequestPath == "graphql" 366 if isGraphQLPaginate { 367 bodyCopy = &bytes.Buffer{} 368 responseBody = io.TeeReader(responseBody, bodyCopy) 369 } 370 371 if opts.FilterOutput != "" { 372 // TODO: reuse parsed query across pagination invocations 373 err = filterJSON(opts.IO.Out, responseBody, opts.FilterOutput) 374 if err != nil { 375 return 376 } 377 } else if opts.Template != "" { 378 // TODO: reuse parsed template across pagination invocations 379 err = executeTemplate(opts.IO.Out, responseBody, opts.Template, opts.IO.ColorEnabled()) 380 if err != nil { 381 return 382 } 383 } else if isJSON && opts.IO.ColorEnabled() { 384 err = jsoncolor.Write(opts.IO.Out, responseBody, " ") 385 } else { 386 _, err = io.Copy(opts.IO.Out, responseBody) 387 } 388 if err != nil { 389 if errors.Is(err, syscall.EPIPE) { 390 err = nil 391 } else { 392 return 393 } 394 } 395 396 if serverError != "" { 397 fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", serverError) 398 err = cmdutil.SilentError 399 return 400 } else if resp.StatusCode > 299 { 401 fmt.Fprintf(opts.IO.ErrOut, "gh: HTTP %d\n", resp.StatusCode) 402 err = cmdutil.SilentError 403 return 404 } 405 406 if isGraphQLPaginate { 407 endCursor = findEndCursor(bodyCopy) 408 } 409 410 return 411 } 412 413 var placeholderRE = regexp.MustCompile(`\:(owner|repo|branch)\b`) 414 415 // fillPlaceholders populates `:owner` and `:repo` placeholders with values from the current repository 416 func fillPlaceholders(value string, opts *ApiOptions) (string, error) { 417 if !placeholderRE.MatchString(value) { 418 return value, nil 419 } 420 421 baseRepo, err := opts.BaseRepo() 422 if err != nil { 423 return value, err 424 } 425 426 filled := placeholderRE.ReplaceAllStringFunc(value, func(m string) string { 427 switch m { 428 case ":owner": 429 return baseRepo.RepoOwner() 430 case ":repo": 431 return baseRepo.RepoName() 432 case ":branch": 433 branch, e := opts.Branch() 434 if e != nil { 435 err = e 436 } 437 return branch 438 default: 439 panic(fmt.Sprintf("invalid placeholder: %q", m)) 440 } 441 }) 442 443 if err != nil { 444 return value, err 445 } 446 447 return filled, nil 448 } 449 450 func printHeaders(w io.Writer, headers http.Header, colorize bool) { 451 var names []string 452 for name := range headers { 453 if name == "Status" { 454 continue 455 } 456 names = append(names, name) 457 } 458 sort.Strings(names) 459 460 var headerColor, headerColorReset string 461 if colorize { 462 headerColor = "\x1b[1;34m" // bright blue 463 headerColorReset = "\x1b[m" 464 } 465 for _, name := range names { 466 fmt.Fprintf(w, "%s%s%s: %s\r\n", headerColor, name, headerColorReset, strings.Join(headers[name], ", ")) 467 } 468 } 469 470 func parseFields(opts *ApiOptions) (map[string]interface{}, error) { 471 params := make(map[string]interface{}) 472 for _, f := range opts.RawFields { 473 key, value, err := parseField(f) 474 if err != nil { 475 return params, err 476 } 477 params[key] = value 478 } 479 for _, f := range opts.MagicFields { 480 key, strValue, err := parseField(f) 481 if err != nil { 482 return params, err 483 } 484 value, err := magicFieldValue(strValue, opts) 485 if err != nil { 486 return params, fmt.Errorf("error parsing %q value: %w", key, err) 487 } 488 params[key] = value 489 } 490 return params, nil 491 } 492 493 func parseField(f string) (string, string, error) { 494 idx := strings.IndexRune(f, '=') 495 if idx == -1 { 496 return f, "", fmt.Errorf("field %q requires a value separated by an '=' sign", f) 497 } 498 return f[0:idx], f[idx+1:], nil 499 } 500 501 func magicFieldValue(v string, opts *ApiOptions) (interface{}, error) { 502 if strings.HasPrefix(v, "@") { 503 return opts.IO.ReadUserFile(v[1:]) 504 } 505 506 if n, err := strconv.Atoi(v); err == nil { 507 return n, nil 508 } 509 510 switch v { 511 case "true": 512 return true, nil 513 case "false": 514 return false, nil 515 case "null": 516 return nil, nil 517 default: 518 return fillPlaceholders(v, opts) 519 } 520 } 521 522 func openUserFile(fn string, stdin io.ReadCloser) (io.ReadCloser, int64, error) { 523 if fn == "-" { 524 return stdin, -1, nil 525 } 526 527 r, err := os.Open(fn) 528 if err != nil { 529 return r, -1, err 530 } 531 532 s, err := os.Stat(fn) 533 if err != nil { 534 return r, -1, err 535 } 536 537 return r, s.Size(), nil 538 } 539 540 func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error) { 541 bodyCopy := &bytes.Buffer{} 542 b, err := ioutil.ReadAll(io.TeeReader(r, bodyCopy)) 543 if err != nil { 544 return r, "", err 545 } 546 547 var parsedBody struct { 548 Message string 549 Errors []json.RawMessage 550 } 551 err = json.Unmarshal(b, &parsedBody) 552 if err != nil { 553 return r, "", err 554 } 555 if parsedBody.Message != "" { 556 return bodyCopy, fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil 557 } 558 559 type errorMessage struct { 560 Message string 561 } 562 var errors []string 563 for _, rawErr := range parsedBody.Errors { 564 if len(rawErr) == 0 { 565 continue 566 } 567 if rawErr[0] == '{' { 568 var objectError errorMessage 569 err := json.Unmarshal(rawErr, &objectError) 570 if err != nil { 571 return r, "", err 572 } 573 errors = append(errors, objectError.Message) 574 } else if rawErr[0] == '"' { 575 var stringError string 576 err := json.Unmarshal(rawErr, &stringError) 577 if err != nil { 578 return r, "", err 579 } 580 errors = append(errors, stringError) 581 } 582 } 583 584 if len(errors) > 0 { 585 return bodyCopy, strings.Join(errors, "\n"), nil 586 } 587 588 return bodyCopy, "", nil 589 } 590 591 func previewNamesToMIMETypes(names []string) string { 592 types := []string{fmt.Sprintf("application/vnd.github.%s-preview+json", names[0])} 593 for _, p := range names[1:] { 594 types = append(types, fmt.Sprintf("application/vnd.github.%s-preview", p)) 595 } 596 return strings.Join(types, ", ") 597 }