// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package app import ( "bytes" "cmp" "encoding/json" "errors" "fmt" "io" "maps" "mime" "net/http" "net/url" "os" "slices" "strings" "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/services" "gitea.dev/tea/internal/terminal" "github.com/spf13/cobra" ) func apiFields(raw, typed []string) (map[string]any, error) { fields := map[string]any{} for kind, arguments := range [][]string{raw, typed} { for _, argument := range arguments { key, value, ok := strings.Cut(argument, "=") if !ok || strings.TrimSpace(key) == "" { return nil, errors.New("API fields require name=value") } if _, exists := fields[key]; exists { return nil, errors.New("API field names must be unique") } var parsed any = value if kind == 1 { decoder := json.NewDecoder(strings.NewReader(value)) decoder.UseNumber() var candidate any if err := decoder.Decode(&candidate); err == nil { var extra any if decoder.Decode(&extra) == io.EOF { parsed = candidate } } } fields[key] = parsed } } return fields, nil } func apiFieldFiles(fields map[string]any, typed []string, stdin io.Reader) error { consumers := 0 for _, argument := range typed { _, value, _ := strings.Cut(argument, "=") if value == "@" { return errors.New("file-backed API fields require @PATH or @-") } if value == "@-" { consumers++ } } if consumers > 1 { return errors.New("only one API field may read stdin") } for _, argument := range typed { key, value, _ := strings.Cut(argument, "=") if !strings.HasPrefix(value, "@") { continue } filename := strings.TrimPrefix(value, "@") if filename == "" { return errors.New("file-backed API fields require @PATH or @-") } data, err := readFileOrStdin(stdin, filename) if err != nil { return err } fields[key] = string(data) } return nil } func apiHeaders(values []string) (http.Header, error) { headers := http.Header{} for _, argument := range values { name, value, ok := strings.Cut(argument, ":") if !ok || name == "" { return nil, errors.New("API headers require Name: value") } for _, char := range name { if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("!#$%&'*+-.^_`|~", char)) { return nil, errors.New("invalid API header name") } } for _, char := range value { if (char < 32 && char != '\t') || char == 127 { return nil, errors.New("invalid API header value") } } switch strings.ToLower(name) { case "authorization", "proxy-authorization", "host", "content-length", "transfer-encoding", "connection", "upgrade", "trailer", "te": return nil, errors.New("API headers cannot override authentication, host, or transport framing") } headers.Add(name, strings.TrimSpace(value)) } return headers, nil } // apiLong documents tea api like gh api: endpoints, placeholders, fields, pagination and output. const apiLong = `Make an authenticated request to the Gitea API of the selected instance and print the response. The endpoint is a path under /api/v1, such as repos/{owner}/{repo}/issues, or a URL of the instance's API. The placeholders {owner} and {repo} take the repository of the current directory or --repo, and {branch} the checked-out Git branch. The method is GET, or POST when fields or --input supply a request body; -X selects another. Fields become query parameters of GET and HEAD requests and a JSON body otherwise: -f, --raw-field name=value a string value -F, --field name=value a typed value: true, false, null, numbers, arrays and objects are JSON, @FILE reads a file and @- stdin --paginate follows the next-page links of a GET request and prints every page on its own line. --jq filters each JSON response with a jq expression and prints strings without quotes; jq does not need to be installed. On a terminal, JSON responses are indented and colored. A response outside 2xx prints its body, then fails with the HTTP status.` // apiExamples are the examples of tea api. const apiExamples = `# The login of the authenticated account $ tea api user --jq .login # Open issues of the current repository, as number and title $ tea api 'repos/{owner}/{repo}/issues?state=open' --jq '.[] | "\(.number) \(.title)"' # Create an issue: -f sends strings, -F typed values $ tea api repos/{owner}/{repo}/issues -f title="Crash on start" -F labels='[3]' # Every page of the account's repositories $ tea api user/repos --paginate --jq '.[].full_name' # Send a JSON body from a file $ tea api -X PATCH repos/alice/project --input settings.json` func (a *application) apiCommand() *cobra.Command { var method, input, output string var raw, typed, headerValues []string var include, paginate, silent bool var maxPages int cmd := &cobra.Command{Use: "api ", Short: "Make an authenticated Gitea API request", Long: apiLong, Example: apiExamples, Annotations: map[string]string{"tea.text": "api returns raw responses", "tea.rawjson": "true"}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { usage := func(err error) error { return clierr.Wrap(clierr.Usage, err) } if silent && a.options.jq != "" { return usage(errors.New("--silent and --jq are mutually exclusive")) } headers, err := apiHeaders(headerValues) if err != nil { return usage(err) } fields, err := apiFields(raw, typed) if err != nil { return usage(err) } if cmd.Flags().Changed("input") && input == "" { return usage(errors.New("--input requires a file path or -")) } if input != "" && len(fields) > 0 { return usage(errors.New("--input conflicts with API fields")) } if !cmd.Flags().Changed("method") && (input != "" || len(fields) > 0) { method = http.MethodPost } method = strings.ToUpper(method) if _, err := http.NewRequest(method, "http://localhost", nil); err != nil || method == "" { return usage(errors.New("invalid HTTP method")) } if paginate && (method != http.MethodGet || input != "") { return usage(errors.New("--paginate requires a body-free GET request")) } if maxPages < 1 { return usage(errors.New("--max-pages must be positive")) } if output != "" { if err := validateDownloadDestination(output); err != nil { return err } } if err := apiFieldFiles(fields, typed, a.deps.Streams.In); err != nil { return usage(err) } var body io.Reader if input == "-" { body = a.deps.Streams.In } else if input != "" { file, err := os.Open(input) if err != nil { return err } defer file.Close() body = file } endpoint := args[0] if strings.ContainsAny(strings.NewReplacer("{owner}", "owner", "{repo}", "repo", "{branch}", "branch").Replace(endpoint), "{}") { return usage(errors.New("unsupported API placeholder; use {owner}, {repo}, and {branch}")) } if strings.Contains(endpoint, "{branch}") { branch, err := a.currentBranch(cmd.Context()) if err != nil || branch == "" { return usage(errors.New("{branch} requires a checked-out Git branch")) } endpoint = strings.ReplaceAll(endpoint, "{branch}", url.PathEscape(branch)) } requireRepo := strings.Contains(endpoint, "{owner}") || strings.Contains(endpoint, "{repo}") target, err := a.resolve(cmd.Context(), requireRepo) if err != nil { return err } if requireRepo { pieces := strings.SplitN(target.Repo, "/", 2) endpoint = strings.NewReplacer("{owner}", url.PathEscape(pieces[0]), "{repo}", url.PathEscape(pieces[1])).Replace(endpoint) } if len(fields) > 0 { if method == http.MethodGet || method == http.MethodHead { parsed, err := url.Parse(endpoint) if err != nil { return usage(errors.New("invalid API endpoint")) } query := parsed.Query() for key, value := range fields { if text, ok := value.(string); ok { query.Set(key, text) } else { data, err := json.Marshal(value) if err != nil { return err } query.Set(key, string(data)) } } parsed.RawQuery = query.Encode() endpoint = parsed.String() } else { data, err := json.Marshal(fields) if err != nil { return err } body = bytes.NewReader(data) } } return writeOutput(cmd, output, func(destination io.Writer) error { response := apiResponse{paginate: paginate, query: a.options.jq} if output == "" || output == "-" { response.tty = a.deps.Streams.OutTTY response.color = response.tty && a.colorEnabled() } return (services.API{Client: target.client}).Visit(cmd.Context(), services.APIOptions{Endpoint: endpoint, Method: method, Body: body, Headers: headers, Paginate: paginate, MaxPages: maxPages}, func(r *http.Response) error { if include { if err := writeResponseHead(cmd.ErrOrStderr(), r); err != nil { return err } } if silent { _, err := io.Copy(io.Discard, r.Body) return err } return response.write(destination, r) }) }) }} cmd.Flags().StringVarP(&method, "method", "X", "GET", "HTTP `method`") cmd.Flags().StringVar(&input, "input", "", "Read the request body from a `file`, or stdin with -") cmd.Flags().StringArrayVarP(&headerValues, "header", "H", nil, "Add a request header: `\"Name: value\"`") cmd.Flags().StringArrayVarP(&raw, "raw-field", "f", nil, "Add a string field: `name=value`") cmd.Flags().StringArrayVarP(&typed, "field", "F", nil, "Add a typed field: `name=value`; @FILE or @- reads a file or stdin") cmd.Flags().StringVar(&output, "output-file", "", "Write the complete response to a new `file`") cmd.Flags().BoolVarP(&include, "include", "i", false, "Print the response status and headers to stderr") cmd.Flags().BoolVar(&silent, "silent", false, "Do not print the response body") cmd.Flags().BoolVar(&paginate, "paginate", false, "Follow next-page links and print every page") cmd.Flags().IntVar(&maxPages, "max-pages", 1000, "Stop --paginate after this many pages") return cmd } // apiResponse writes response bodies for tea api: filtered by the jq query, as indented and colored // JSON on a terminal, otherwise as received, without terminal controls on a terminal. Output to a // terminal, JSON and pages end with a newline. type apiResponse struct { tty, color, paginate bool query string } func (o apiResponse) write(w io.Writer, r *http.Response) error { mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) isJSON := mediaType == "application/json" || strings.HasSuffix(mediaType, "+json") succeeded := r.StatusCode >= 200 && r.StatusCode < 300 if (o.query != "" && succeeded) || (o.tty && isJSON) { data, err := io.ReadAll(r.Body) if err != nil { return err } if o.query != "" && succeeded { if !json.Valid(data) { return fmt.Errorf("--jq needs a JSON response, but the response is %s", cmp.Or(mediaType, "not JSON")) } return terminal.JSONOutput{Query: o.query, Pretty: o.tty, Color: o.color}.Write(w, json.RawMessage(data)) } if pretty, err := terminal.IndentJSON(data, o.color); err == nil { _, err = io.WriteString(w, pretty) return err } r.Body = io.NopCloser(bytes.NewReader(data)) } last := &lastByte{w: terminal.SafeWriter(w, o.tty, false)} if _, err := io.Copy(last, r.Body); err != nil { return err } if last.written && last.b != '\n' && (o.tty || isJSON || o.paginate) { _, err := io.WriteString(w, "\n") return err } return nil } // lastByte remembers the last byte written through it. type lastByte struct { w io.Writer b byte written bool } func (l *lastByte) Write(p []byte) (int, error) { if len(p) > 0 { l.b, l.written = p[len(p)-1], true } return l.w.Write(p) } // writeResponseHead prints the status line and the sorted headers of r, then a blank line. func writeResponseHead(w io.Writer, r *http.Response) error { var b strings.Builder fmt.Fprintf(&b, "%s %s\n", r.Proto, r.Status) for _, key := range slices.Sorted(maps.Keys(r.Header)) { for _, value := range r.Header.Values(key) { fmt.Fprintf(&b, "%s: %s\n", key, value) } } b.WriteString("\n") _, err := io.WriteString(w, b.String()) return err }