commands/api/api.go (499 lines of code) (raw):

package api import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "regexp" "sort" "strconv" "strings" "gitlab.com/gitlab-org/cli/pkg/iostreams" "gitlab.com/gitlab-org/cli/api" "github.com/MakeNowJust/heredoc/v2" "github.com/spf13/cobra" jsonPretty "github.com/tidwall/pretty" gitlab "gitlab.com/gitlab-org/api/client-go" "gitlab.com/gitlab-org/cli/commands/cmdutils" "gitlab.com/gitlab-org/cli/internal/config" "gitlab.com/gitlab-org/cli/internal/glrepo" "gitlab.com/gitlab-org/cli/pkg/glinstance" ) type ApiOptions struct { IO *iostreams.IOStreams HttpClient func() (*gitlab.Client, error) BaseRepo func() (glrepo.Interface, error) Branch func() (string, error) Config config.Config Hostname string RequestMethod string RequestMethodPassed bool RequestPath string RequestInputFile string MagicFields []string RawFields []string RequestHeaders []string ShowResponseHeaders bool Paginate bool Silent bool } func NewCmdApi(f *cmdutils.Factory, runF func(*ApiOptions) error) *cobra.Command { opts := ApiOptions{ IO: f.IO, HttpClient: f.HttpClient, BaseRepo: f.BaseRepo, Branch: f.Branch, } cmd := &cobra.Command{ Use: "api <endpoint>", Short: "Make an authenticated request to the GitLab API.", Long: heredoc.Docf(` Makes an authenticated HTTP request to the GitLab API, and prints the response. The endpoint argument should either be a path of a GitLab API v4 endpoint, or "graphql" to access the GitLab GraphQL API. - [GitLab REST API documentation](https://docs.gitlab.com/api/) - [GitLab GraphQL documentation](https://docs.gitlab.com/api/graphql/) If the current directory is a Git directory, uses the GitLab authenticated host in the current directory. Otherwise, %[1]sgitlab.com%[1]s will be used. To override the GitLab hostname, use '--hostname'. These placeholder values, when used in the endpoint argument, are replaced with values from the repository of the current directory: - %[1]s:branch%[1]s - %[1]s:fullpath%[1]s - %[1]s:group%[1]s - %[1]s:id%[1]s - %[1]s:namespace%[1]s - %[1]s:repo%[1]s - %[1]s:user%[1]s - %[1]s:username%[1]s Methods: the default HTTP request method is "GET", if no parameters are added, and "POST" otherwise. Override the method with '--method'. Pass one or more '--raw-field' values in "key=value" format to add JSON-encoded string parameters to the POST body. The '--field' flag behaves like '--raw-field' with magic type conversion based on the format of the value: - Literal values "true", "false", "null", and integer numbers are converted to appropriate JSON types. - Placeholder values ":namespace", ":repo", and ":branch" are populated with values from the repository of the current directory. - If the value starts with "@", the rest of the value is interpreted as a filename to read the value from. Pass "-" to read from standard input. For GraphQL requests, all fields other than "query" and "operationName" are interpreted as GraphQL variables. Raw request body can be passed from the outside via a file specified by '--input'. Pass "-" to read from standard input. In this mode, parameters specified with '--field' flags are serialized into URL query parameters. In '--paginate' mode, all pages of results are requested sequentially until no more pages of results remain. For GraphQL requests: - The original query must accept an '$endCursor: String' variable. - The query must fetch the 'pageInfo{ hasNextPage, endCursor }' set of fields from a collection. `, "`"), Example: heredoc.Doc(` - glab api projects/:fullpath/releases - glab api projects/gitlab-com%2Fwww-gitlab-com/issues - glab api issues --paginate $ glab api graphql -f query=' query { project(fullPath: "gitlab-org/gitlab-docs") { name forksCount statistics { wikiSize } issuesEnabled boards { nodes { id name } } } } ' $ glab api graphql --paginate -f query=' query($endCursor: String) { project(fullPath: "gitlab-org/graphql-sandbox") { name issues(first: 2, after: $endCursor) { edges { node { title } } pageInfo { endCursor hasNextPage } } } }' `), Annotations: map[string]string{ "help:environment": heredoc.Doc(` GITLAB_TOKEN, OAUTH_TOKEN (in order of precedence): an authentication token for API requests. GITLAB_HOST, GITLAB_URI, GITLAB_URL: specify a GitLab host to make request to. `), }, Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { opts.RequestPath = args[0] opts.RequestMethodPassed = c.Flags().Changed("method") opts.Config, _ = f.Config() if c.Flags().Changed("hostname") { if err := glinstance.HostnameValidator(opts.Hostname); err != nil { return &cmdutils.FlagError{Err: fmt.Errorf("error parsing --hostname: %w.", err)} } } if opts.Paginate && !strings.EqualFold(opts.RequestMethod, http.MethodGet) && opts.RequestPath != "graphql" { return &cmdutils.FlagError{Err: errors.New(`the '--paginate' option is not supported for non-GET requests.`)} } if opts.Paginate && opts.RequestInputFile != "" { return &cmdutils.FlagError{Err: errors.New(`the '--paginate' option is not supported with '--input'.`)} } if runF != nil { return runF(&opts) } return apiRun(&opts) }, } cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "The GitLab hostname for the request. Defaults to \"gitlab.com\", or the authenticated host in the current Git directory.") cmd.Flags().StringVarP(&opts.RequestMethod, "method", "X", "GET", "The HTTP method for the request.") cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a parameter of inferred type. Changes the default HTTP method to \"POST\".") cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter.") cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add an additional HTTP request header.") cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output.") cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results.") cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The file to use as the body for the HTTP request.") cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Do not print the response body.") return cmd } func apiRun(opts *ApiOptions) error { params, err := parseFields(opts) if err != nil { return err } isGraphQL := opts.RequestPath == "graphql" requestPath, err := fillPlaceholders(opts.RequestPath, opts) if err != nil { return fmt.Errorf("unable to expand placeholder in path: %w", err) } method := opts.RequestMethod requestHeaders := opts.RequestHeaders var requestBody any = params if !opts.RequestMethodPassed && (len(params) > 0 || opts.RequestInputFile != "") { method = http.MethodPost } if opts.Paginate && !isGraphQL { requestPath = addPerPage(requestPath, 100, params) } if opts.RequestInputFile != "" { file, size, err := openUserFile(opts.RequestInputFile, opts.IO.In) if err != nil { return err } defer file.Close() requestPath, err = parseQuery(requestPath, params) if err != nil { return err } requestBody = file if size >= 0 { requestHeaders = append([]string{fmt.Sprintf("Content-Length: %d", size)}, requestHeaders...) } } httpClient, err := opts.HttpClient() if err != nil { return err } headersOutputStream := opts.IO.StdOut if opts.Silent { opts.IO.StdOut = io.Discard } else { err := opts.IO.StartPager() if err != nil { return err } defer opts.IO.StopPager() } host := httpClient.BaseURL().Host if opts.Hostname != "" { host = opts.Hostname } hasNextPage := true for hasNextPage { resp, err := httpRequest(api.GetClient(), opts.Config, host, method, requestPath, requestBody, requestHeaders) if err != nil { return err } endCursor, err := processResponse(resp, opts, headersOutputStream) if err != nil { return err } if !opts.Paginate { break } if isGraphQL { hasNextPage = endCursor != "" if hasNextPage { params["endCursor"] = endCursor } } else { requestPath, hasNextPage = findNextPage(resp) } if hasNextPage && opts.ShowResponseHeaders { fmt.Fprint(opts.IO.StdOut, "\n") } } return nil } func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream io.Writer) (endCursor string, err error) { if opts.ShowResponseHeaders { fmt.Fprintln(headersOutputStream, resp.Proto, resp.Status) printHeaders(headersOutputStream, resp.Header, opts.IO.ColorEnabled()) fmt.Fprint(headersOutputStream, "\r\n") } if resp.StatusCode == http.StatusNoContent { return } var responseBody io.Reader = resp.Body isJSON, _ := regexp.MatchString(`[/+]json(;|$)`, resp.Header.Get("Content-Type")) var serverError string if isJSON && (opts.RequestPath == "graphql" || resp.StatusCode >= http.StatusBadRequest) { responseBody, serverError, err = parseErrorResponse(responseBody, resp.StatusCode) if err != nil { return } } var bodyCopy *bytes.Buffer isGraphQLPaginate := isJSON && resp.StatusCode == http.StatusOK && opts.Paginate && opts.RequestPath == "graphql" if isGraphQLPaginate { bodyCopy = &bytes.Buffer{} responseBody = io.TeeReader(responseBody, bodyCopy) } if isJSON && opts.IO.ColorEnabled() { out := &bytes.Buffer{} _, err = io.Copy(out, responseBody) if err == nil { result := jsonPretty.Color(jsonPretty.Pretty(out.Bytes()), nil) _, err = fmt.Fprintln(opts.IO.StdOut, string(result)) } } else { _, err = io.Copy(opts.IO.StdOut, responseBody) } if err != nil { return } if serverError != "" { fmt.Fprintf(opts.IO.StdErr, "glab: %s\n", serverError) err = cmdutils.SilentError return } else if resp.StatusCode > 299 { fmt.Fprintf(opts.IO.StdErr, "glab: HTTP %d\n", resp.StatusCode) err = cmdutils.SilentError return } if isGraphQLPaginate { endCursor = findEndCursor(bodyCopy) } return } var placeholderRE = regexp.MustCompile(`:(group/:namespace/:repo|namespace/:repo|fullpath|id|user|username|group|namespace|repo|branch)\b`) // fillPlaceholders populates `:namespace` and `:repo` placeholders with values from the current repository func fillPlaceholders(value string, opts *ApiOptions) (string, error) { if !placeholderRE.MatchString(value) { return value, nil } baseRepo, err := opts.BaseRepo() if err != nil { return value, err } filled := placeholderRE.ReplaceAllStringFunc(value, func(m string) string { switch m { case ":id": h, _ := opts.HttpClient() project, e := baseRepo.Project(h) if e == nil && project != nil { return strconv.Itoa(project.ID) } err = e return "" case ":group/:namespace/:repo", ":fullpath": return url.PathEscape(baseRepo.FullName()) case ":namespace/:repo": return url.PathEscape(baseRepo.RepoNamespace() + "/" + baseRepo.RepoName()) case ":group": return baseRepo.RepoGroup() case ":user", ":username": h, _ := opts.HttpClient() u, e := api.CurrentUser(h) if e == nil && u != nil { return u.Username } err = e return m case ":namespace": return baseRepo.RepoNamespace() case ":repo": return baseRepo.RepoName() case ":branch": branch, e := opts.Branch() if e != nil { err = e } return branch default: err = fmt.Errorf("invalid placeholder: %q", m) return "" } }) if err != nil { return value, err } return filled, nil } func printHeaders(w io.Writer, headers http.Header, colorize bool) { var names []string for name := range headers { if name == "Status" { continue } names = append(names, name) } sort.Strings(names) var headerColor, headerColorReset string if colorize { headerColor = "\x1b[1;34m" // bright blue headerColorReset = "\x1b[m" } for _, name := range names { fmt.Fprintf(w, "%s%s%s: %s\r\n", headerColor, name, headerColorReset, strings.Join(headers[name], ", ")) } } func parseFields(opts *ApiOptions) (map[string]any, error) { params := make(map[string]any) for _, f := range opts.RawFields { key, value, err := parseField(f) if err != nil { return params, err } params[key] = value } for _, f := range opts.MagicFields { key, strValue, err := parseField(f) if err != nil { return params, err } value, err := magicFieldValue(strValue, opts) if err != nil { return params, fmt.Errorf("error parsing %q value: %w", key, err) } params[key] = value } return params, nil } func parseField(f string) (string, string, error) { idx := strings.IndexRune(f, '=') if idx == -1 { return f, "", fmt.Errorf("field %q requires a value separated by an '=' sign.", f) } return f[0:idx], f[idx+1:], nil } func magicFieldValue(v string, opts *ApiOptions) (any, error) { if strings.HasPrefix(v, "@") { return readUserFile(v[1:], opts.IO.In) } if n, err := strconv.Atoi(v); err == nil { return n, nil } switch v { case "true": return true, nil case "false": return false, nil case "null": return nil, nil default: return fillPlaceholders(v, opts) } } func readUserFile(fn string, stdin io.ReadCloser) ([]byte, error) { var r io.ReadCloser if fn == "-" { r = stdin } else { var err error r, err = os.Open(fn) if err != nil { return nil, err } } defer r.Close() return io.ReadAll(r) } func openUserFile(fn string, stdin io.ReadCloser) (io.ReadCloser, int64, error) { if fn == "-" { return stdin, -1, nil } r, err := os.Open(fn) if err != nil { return r, -1, err } s, err := os.Stat(fn) if err != nil { return r, -1, err } return r, s.Size(), nil } func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error) { bodyCopy := &bytes.Buffer{} b, err := io.ReadAll(io.TeeReader(r, bodyCopy)) if err != nil { return r, "", err } var parsedBody struct { Message string Errors []json.RawMessage } err = json.Unmarshal(b, &parsedBody) if err != nil { // in cases where it's an object within an object we can try to parse it as is var t any err = json.Unmarshal(b, &t) if err != nil { return r, "", err } return bodyCopy, fmt.Sprintf("%v+", t), nil } if parsedBody.Message != "" { return bodyCopy, fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil } type errorMessage struct { Message string } var respErrors []string for _, rawErr := range parsedBody.Errors { if len(rawErr) == 0 { continue } if rawErr[0] == '{' { var objectError errorMessage err := json.Unmarshal(rawErr, &objectError) if err != nil { return r, "", err } respErrors = append(respErrors, objectError.Message) } else if rawErr[0] == '"' { var stringError string err := json.Unmarshal(rawErr, &stringError) if err != nil { return r, "", err } respErrors = append(respErrors, stringError) } } if len(respErrors) > 0 { return bodyCopy, strings.Join(respErrors, "\n"), nil } return bodyCopy, "", nil }