github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/api/api.go (about) 1 package api 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "fmt" 7 "io" 8 "net/http" 9 "os" 10 "regexp" 11 "sort" 12 "strconv" 13 "strings" 14 "time" 15 16 "github.com/MakeNowJust/heredoc" 17 "github.com/ungtb10d/cli/v2/api" 18 "github.com/ungtb10d/cli/v2/internal/config" 19 "github.com/ungtb10d/cli/v2/internal/ghinstance" 20 "github.com/ungtb10d/cli/v2/internal/ghrepo" 21 "github.com/ungtb10d/cli/v2/pkg/cmd/factory" 22 "github.com/ungtb10d/cli/v2/pkg/cmdutil" 23 "github.com/ungtb10d/cli/v2/pkg/iostreams" 24 "github.com/ungtb10d/cli/v2/pkg/jsoncolor" 25 "github.com/cli/go-gh/pkg/jq" 26 "github.com/cli/go-gh/pkg/template" 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 74 argument will get replaced with values from the repository of the current 75 directory or the repository specified in the GH_REPO environment variable. 76 Note that in some shells, for example PowerShell, you may need to enclose 77 any value that contains "{...}" in quotes to prevent the shell from 78 applying special meaning to curly braces. 79 80 The default HTTP request method is "GET" normally and "POST" if any parameters 81 were added. Override the method with %[1]s--method%[1]s. 82 83 Pass one or more %[1]s-f/--raw-field%[1]s values in "key=value" format to add static string 84 parameters to the request payload. To add non-string or otherwise dynamic values, see 85 %[1]s--field%[1]s below. Note that adding request parameters will automatically switch the 86 request method to POST. To send the parameters as a GET query string instead, use 87 %[1]s--method GET%[1]s. 88 89 The %[1]s-F/--field%[1]s flag has magic type conversion based on the format of the value: 90 91 - literal values "true", "false", "null", and integer numbers get converted to 92 appropriate JSON types; 93 - placeholder values "{owner}", "{repo}", and "{branch}" get populated with values 94 from the repository of the current directory; 95 - if the value starts with "@", the rest of the value is interpreted as a 96 filename to read the value from. Pass "-" to read from standard input. 97 98 For GraphQL requests, all fields other than "query" and "operationName" are 99 interpreted as GraphQL variables. 100 101 Raw request body may be passed from the outside via a file specified by %[1]s--input%[1]s. 102 Pass "-" to read from standard input. In this mode, parameters specified via 103 %[1]s--field%[1]s flags are serialized into URL query parameters. 104 105 In %[1]s--paginate%[1]s mode, all pages of results will sequentially be requested until 106 there are no more pages of results. For GraphQL requests, this requires that the 107 original query accepts an %[1]s$endCursor: String%[1]s variable and that it fetches the 108 %[1]spageInfo{ hasNextPage, endCursor }%[1]s set of fields from a collection. 109 `, "`"), 110 Example: heredoc.Doc(` 111 # list releases in the current repository 112 $ gh api repos/{owner}/{repo}/releases 113 114 # post an issue comment 115 $ gh api repos/{owner}/{repo}/issues/123/comments -f body='Hi from CLI' 116 117 # add parameters to a GET request 118 $ gh api -X GET search/issues -f q='repo:ungtb10d/cli is:open remote' 119 120 # set a custom HTTP header 121 $ gh api -H 'Accept: application/vnd.github.v3.raw+json' ... 122 123 # opt into GitHub API previews 124 $ gh api --preview baptiste,nebula ... 125 126 # print only specific fields from the response 127 $ gh api repos/{owner}/{repo}/issues --jq '.[].title' 128 129 # use a template for the output 130 $ gh api repos/{owner}/{repo}/issues --template \ 131 '{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " | color "yellow"}}){{"\n"}}{{end}}' 132 133 # list releases with GraphQL 134 $ gh api graphql -F owner='{owner}' -F name='{repo}' -f query=' 135 query($name: String!, $owner: String!) { 136 repository(owner: $owner, name: $name) { 137 releases(last: 3) { 138 nodes { tagName } 139 } 140 } 141 } 142 ' 143 144 # list all repositories for a user 145 $ gh api graphql --paginate -f query=' 146 query($endCursor: String) { 147 viewer { 148 repositories(first: 100, after: $endCursor) { 149 nodes { nameWithOwner } 150 pageInfo { 151 hasNextPage 152 endCursor 153 } 154 } 155 } 156 } 157 ' 158 `), 159 Annotations: map[string]string{ 160 "help:environment": heredoc.Doc(` 161 GH_TOKEN, GITHUB_TOKEN (in order of precedence): an authentication token for 162 github.com API requests. 163 164 GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN (in order of precedence): an 165 authentication token for API requests to GitHub Enterprise. 166 167 GH_HOST: make the request to a GitHub host other than github.com. 168 `), 169 }, 170 Args: cobra.ExactArgs(1), 171 PreRun: func(c *cobra.Command, args []string) { 172 opts.BaseRepo = cmdutil.OverrideBaseRepoFunc(f, "") 173 }, 174 RunE: func(c *cobra.Command, args []string) error { 175 opts.RequestPath = args[0] 176 opts.RequestMethodPassed = c.Flags().Changed("method") 177 178 if c.Flags().Changed("hostname") { 179 if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { 180 return cmdutil.FlagErrorf("error parsing `--hostname`: %w", err) 181 } 182 } 183 184 if opts.Paginate && !strings.EqualFold(opts.RequestMethod, "GET") && opts.RequestPath != "graphql" { 185 return cmdutil.FlagErrorf("the `--paginate` option is not supported for non-GET requests") 186 } 187 188 if err := cmdutil.MutuallyExclusive( 189 "the `--paginate` option is not supported with `--input`", 190 opts.Paginate, 191 opts.RequestInputFile != "", 192 ); err != nil { 193 return err 194 } 195 196 if err := cmdutil.MutuallyExclusive( 197 "only one of `--template`, `--jq`, or `--silent` may be used", 198 opts.Silent, 199 opts.FilterOutput != "", 200 opts.Template != "", 201 ); err != nil { 202 return err 203 } 204 205 if runF != nil { 206 return runF(&opts) 207 } 208 return apiRun(&opts) 209 }, 210 } 211 212 cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "The GitHub hostname for the request (default \"github.com\")") 213 cmd.Flags().StringVarP(&opts.RequestMethod, "method", "X", "GET", "The HTTP method for the request") 214 cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a typed parameter in `key=value` format") 215 cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format") 216 cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add a HTTP request header in `key:value` format") 217 cmd.Flags().StringSliceVarP(&opts.Previews, "preview", "p", nil, "GitHub API preview `names` to request (without the \"-preview\" suffix)") 218 cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response status line and headers in the output") 219 cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results") 220 cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request (use \"-\" to read from standard input)") 221 cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Do not print the response body") 222 cmd.Flags().StringVarP(&opts.Template, "template", "t", "", "Format JSON output using a Go template; see \"gh help formatting\"") 223 cmd.Flags().StringVarP(&opts.FilterOutput, "jq", "q", "", "Query to select values from the response using jq syntax") 224 cmd.Flags().DurationVar(&opts.CacheTTL, "cache", 0, "Cache the response, e.g. \"3600s\", \"60m\", \"1h\"") 225 return cmd 226 } 227 228 func apiRun(opts *ApiOptions) error { 229 params, err := parseFields(opts) 230 if err != nil { 231 return err 232 } 233 234 isGraphQL := opts.RequestPath == "graphql" 235 requestPath, err := fillPlaceholders(opts.RequestPath, opts) 236 if err != nil { 237 return fmt.Errorf("unable to expand placeholder in path: %w", err) 238 } 239 method := opts.RequestMethod 240 requestHeaders := opts.RequestHeaders 241 var requestBody interface{} = params 242 243 if !opts.RequestMethodPassed && (len(params) > 0 || opts.RequestInputFile != "") { 244 method = "POST" 245 } 246 247 if opts.Paginate && !isGraphQL { 248 requestPath = addPerPage(requestPath, 100, params) 249 } 250 251 if opts.RequestInputFile != "" { 252 file, size, err := openUserFile(opts.RequestInputFile, opts.IO.In) 253 if err != nil { 254 return err 255 } 256 defer file.Close() 257 requestPath = addQuery(requestPath, params) 258 requestBody = file 259 if size >= 0 { 260 requestHeaders = append([]string{fmt.Sprintf("Content-Length: %d", size)}, requestHeaders...) 261 } 262 } 263 264 if len(opts.Previews) > 0 { 265 requestHeaders = append(requestHeaders, "Accept: "+previewNamesToMIMETypes(opts.Previews)) 266 } 267 268 httpClient, err := opts.HttpClient() 269 if err != nil { 270 return err 271 } 272 if opts.CacheTTL > 0 { 273 httpClient = api.NewCachedHTTPClient(httpClient, opts.CacheTTL) 274 } 275 276 if !opts.Silent { 277 if err := opts.IO.StartPager(); err == nil { 278 defer opts.IO.StopPager() 279 } else { 280 fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) 281 } 282 } 283 284 var bodyWriter io.Writer = opts.IO.Out 285 var headersWriter io.Writer = opts.IO.Out 286 if opts.Silent { 287 bodyWriter = io.Discard 288 } 289 290 cfg, err := opts.Config() 291 if err != nil { 292 return err 293 } 294 295 host, _ := cfg.DefaultHost() 296 297 if opts.Hostname != "" { 298 host = opts.Hostname 299 } 300 301 tmpl := template.New(bodyWriter, opts.IO.TerminalWidth(), opts.IO.ColorEnabled()) 302 err = tmpl.Parse(opts.Template) 303 if err != nil { 304 return err 305 } 306 307 hasNextPage := true 308 for hasNextPage { 309 resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders) 310 if err != nil { 311 return err 312 } 313 314 endCursor, err := processResponse(resp, opts, bodyWriter, headersWriter, &tmpl) 315 if err != nil { 316 return err 317 } 318 319 if !opts.Paginate { 320 break 321 } 322 323 if isGraphQL { 324 hasNextPage = endCursor != "" 325 if hasNextPage { 326 params["endCursor"] = endCursor 327 } 328 } else { 329 requestPath, hasNextPage = findNextPage(resp) 330 requestBody = nil // prevent repeating GET parameters 331 } 332 333 if hasNextPage && opts.ShowResponseHeaders { 334 fmt.Fprint(opts.IO.Out, "\n") 335 } 336 } 337 338 return tmpl.Flush() 339 } 340 341 func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersWriter io.Writer, template *template.Template) (endCursor string, err error) { 342 if opts.ShowResponseHeaders { 343 fmt.Fprintln(headersWriter, resp.Proto, resp.Status) 344 printHeaders(headersWriter, resp.Header, opts.IO.ColorEnabled()) 345 fmt.Fprint(headersWriter, "\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 != "" && serverError == "" { 372 // TODO: reuse parsed query across pagination invocations 373 err = jq.Evaluate(responseBody, bodyWriter, opts.FilterOutput) 374 if err != nil { 375 return 376 } 377 } else if opts.Template != "" && serverError == "" { 378 err = template.Execute(responseBody) 379 if err != nil { 380 return 381 } 382 } else if isJSON && opts.IO.ColorEnabled() { 383 err = jsoncolor.Write(bodyWriter, responseBody, " ") 384 } else { 385 _, err = io.Copy(bodyWriter, responseBody) 386 } 387 if err != nil { 388 return 389 } 390 391 if serverError == "" && resp.StatusCode > 299 { 392 serverError = fmt.Sprintf("HTTP %d", resp.StatusCode) 393 } 394 if serverError != "" { 395 fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", serverError) 396 if msg := api.ScopesSuggestion(resp); msg != "" { 397 fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", msg) 398 } 399 if u := factory.SSOURL(); u != "" { 400 fmt.Fprintf(opts.IO.ErrOut, "Authorize in your web browser: %s\n", u) 401 } 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|\{[a-z]+\})`) 414 415 // fillPlaceholders replaces placeholders with values from the current repository 416 func fillPlaceholders(value string, opts *ApiOptions) (string, error) { 417 var err error 418 return placeholderRE.ReplaceAllStringFunc(value, func(m string) string { 419 var name string 420 if m[0] == ':' { 421 name = m[1:] 422 } else { 423 name = m[1 : len(m)-1] 424 } 425 426 switch name { 427 case "owner": 428 if baseRepo, e := opts.BaseRepo(); e == nil { 429 return baseRepo.RepoOwner() 430 } else { 431 err = e 432 } 433 case "repo": 434 if baseRepo, e := opts.BaseRepo(); e == nil { 435 return baseRepo.RepoName() 436 } else { 437 err = e 438 } 439 case "branch": 440 if branch, e := opts.Branch(); e == nil { 441 return branch 442 } else { 443 err = e 444 } 445 } 446 return m 447 }), err 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 := io.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 bodyCopy, "", err 554 } 555 556 if len(parsedBody.Errors) > 0 && parsedBody.Errors[0] == '"' { 557 var stringError string 558 if err := json.Unmarshal(parsedBody.Errors, &stringError); err != nil { 559 return bodyCopy, "", err 560 } 561 if stringError != "" { 562 if parsedBody.Message != "" { 563 return bodyCopy, fmt.Sprintf("%s (%s)", stringError, parsedBody.Message), nil 564 } 565 return bodyCopy, stringError, nil 566 } 567 } 568 569 if parsedBody.Message != "" { 570 return bodyCopy, fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil 571 } 572 573 if len(parsedBody.Errors) == 0 || parsedBody.Errors[0] != '[' { 574 return bodyCopy, "", nil 575 } 576 577 var errorObjects []json.RawMessage 578 if err := json.Unmarshal(parsedBody.Errors, &errorObjects); err != nil { 579 return bodyCopy, "", err 580 } 581 582 var objectError struct { 583 Message string 584 } 585 var errors []string 586 for _, rawErr := range errorObjects { 587 if len(rawErr) == 0 { 588 continue 589 } 590 if rawErr[0] == '{' { 591 err := json.Unmarshal(rawErr, &objectError) 592 if err != nil { 593 return bodyCopy, "", err 594 } 595 errors = append(errors, objectError.Message) 596 } else if rawErr[0] == '"' { 597 var stringError string 598 err := json.Unmarshal(rawErr, &stringError) 599 if err != nil { 600 return bodyCopy, "", err 601 } 602 errors = append(errors, stringError) 603 } 604 } 605 606 if len(errors) > 0 { 607 return bodyCopy, strings.Join(errors, "\n"), nil 608 } 609 610 return bodyCopy, "", nil 611 } 612 613 func previewNamesToMIMETypes(names []string) string { 614 types := []string{fmt.Sprintf("application/vnd.github.%s-preview+json", names[0])} 615 for _, p := range names[1:] { 616 types = append(types, fmt.Sprintf("application/vnd.github.%s-preview", p)) 617 } 618 return strings.Join(types, ", ") 619 }