fix(api): filter raw responses with --jq and end output with a newline

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>
This commit is contained in:
bircni committed 2026-10-11 23:49:54 +02:00
1 parent 5e8aa1011f
commit 60de407eca
5 files changed
+209 -40

No files matched your search

+130 -36
View File
@@ -5,14 +5,17 @@ package app
import (
"bytes"
"cmp"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"mime"
"net/http"
"net/url"
"os"
"sort"
"slices"
"strings"
"gitea.dev/tea/internal/clierr"
@@ -108,13 +111,51 @@ func apiHeaders(values []string) (http.Header, error) {
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 bool
var include, paginate, silent bool
var maxPages int
cmd := &cobra.Command{Use: "api <endpoint>", Short: "Make an authenticated Gitea API request", Annotations: textOutput("api returns raw responses"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
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)
@@ -210,49 +251,102 @@ func (a *application) apiCommand() *cobra.Command {
}
}
return writeOutput(cmd, output, func(destination io.Writer) error {
response := apiResponse{paginate: paginate, query: a.options.jq}
if output == "" || output == "-" {
destination = terminal.SafeWriter(destination, a.deps.Streams.OutTTY, false)
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(response *http.Response) error {
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 := fmt.Fprintf(cmd.ErrOrStderr(), "%s %s\n", response.Proto, response.Status); err != nil {
return err
}
keys := make([]string, 0, len(response.Header))
for key := range response.Header {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
for _, value := range response.Header.Values(key) {
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %s\n", key, value); err != nil {
return err
}
}
}
if _, err := fmt.Fprintln(cmd.ErrOrStderr()); err != nil {
if err := writeResponseHead(cmd.ErrOrStderr(), r); err != nil {
return err
}
}
if _, err := io.Copy(destination, response.Body); err != nil {
if silent {
_, err := io.Copy(io.Discard, r.Body)
return err
}
if paginate {
_, err := fmt.Fprintln(destination)
return err
}
return nil
return response.write(destination, r)
})
})
}}
cmd.Flags().StringVarP(&method, "method", "X", "GET", "HTTP method")
cmd.Flags().StringVar(&input, "input", "", "Read request body from a file or stdin (-)")
cmd.Flags().StringArrayVarP(&headerValues, "header", "H", nil, "Request header: Name: value")
cmd.Flags().StringArrayVarP(&raw, "raw-field", "f", nil, "String request field: name=value")
cmd.Flags().StringArrayVarP(&typed, "field", "F", nil, "JSON request field: name=value; @PATH or @- reads file text")
cmd.Flags().StringVar(&output, "output-file", "", "Write the complete response to a new file")
cmd.Flags().BoolVar(&include, "include", false, "Print response status and headers to stderr")
cmd.Flags().BoolVar(&paginate, "paginate", false, "Follow scoped next-page links; separate raw pages with newlines")
cmd.Flags().IntVar(&maxPages, "max-pages", 1000, "Maximum pages with --paginate")
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
}