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