mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
tea api /version --jq .version failed with "--jq requires --json", and --json is rejected for raw responses. api is annotated tea.rawjson now: --jq filters each successful JSON response, page by page with --paginate, and prints strings without quotes. A non-JSON response is an error with --jq; an error response prints its body unfiltered. JSON responses end with a newline, so an error no longer starts on the line of the body; on a terminal JSON is indented and colored in the key order of the response, and other text is sanitized as before. Piped non-JSON bodies stay byte-exact. api gains -i for --include and --silent, a Long help modeled on gh api (placeholders, -f/-F typing, pagination, --jq) and examples, and its help lists --jq. Signed-off-by: bircni <bircni@icloud.com>
353 lines
12 KiB
Go
353 lines
12 KiB
Go
// 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 <endpoint>", 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
|
|
}
|