feat(api): add --json, --json-fields, --jq to fj api
Some checks are pending
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / functional (push) Blocked by required conditions

`fj api` was the only command that returned raw API JSON without exposing
the same projection/filtering knobs that `fj repo list`, `fj pr list`,
etc. already provide. Callers had to pipe to `python -m json.tool` or
`jq` to extract fields, which is inconsistent and discoverable only
after hitting the gap.

Wire the existing addJSONFlags / wantJSON / outputJSON helpers from
cmd/json.go so the API command participates in the same JSON output
pipeline. No behavioral change when none of the new flags are set —
default still pretty-prints JSON and writes raw bytes for non-JSON
responses.

Verified against live forgejo:

  $ fj api repos/public/claude-code-proxy --jq .full_name
  public/claude-code-proxy

  $ fj api repos/public/claude-code-proxy --json=full_name,description
  { "description": "...", "full_name": "public/claude-code-proxy" }

  $ fj api 'repos/public/claude-code-proxy/commits?limit=3' \
        --jq '.[] | "\(.sha[0:8]) \(.commit.message | split("\n")[0])"'
  8e550b97 Local fork: hardening + ops improvements ...
  b9da198e Harden proxy auth, storage, and conversation access
  6cda3631 Harden streaming, pagination, and config loading

Note: `--json=fields` requires the equals sign because the flag has
NoOptDefVal=" " (so `--json` alone is valid for "everything as JSON").
The Example block in --help documents both the `--json=` form and the
`--json-fields` alias which doesn't have that quirk.
This commit is contained in:
sid 2026-05-02 15:22:44 -06:00
parent 0fda0b8679
commit f75b831a53

View file

@ -35,7 +35,17 @@ If --field is used and no --method is specified, the method defaults to POST.`,
fj api /users/johndoe fj api /users/johndoe
# Use raw body from stdin # Use raw body from stdin
echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues --input -`, echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues --input -
# Filter the response with a jq expression
fj api /repos/{owner}/{repo}/issues --jq '.[].title'
# Project the response down to specific fields (requires "=" because --json
# also accepts an empty value to mean "all fields as JSON")
fj api /repos/{owner}/{repo} --json=full_name,description,private
# Same projection without the "=" quirk
fj api /repos/{owner}/{repo} --json-fields full_name,description,private`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runAPI, RunE: runAPI,
} }
@ -50,6 +60,7 @@ func init() {
apiCmd.Flags().StringArrayP("header", "H", nil, "Add an HTTP request header (key:value)") apiCmd.Flags().StringArrayP("header", "H", nil, "Add an HTTP request header (key:value)")
apiCmd.Flags().Bool("silent", false, "Do not print the response body") apiCmd.Flags().Bool("silent", false, "Do not print the response body")
apiCmd.Flags().BoolP("include", "i", false, "Include HTTP response headers in the output") apiCmd.Flags().BoolP("include", "i", false, "Include HTTP response headers in the output")
addJSONFlags(apiCmd, "Output the response as JSON; pass a comma-separated field list to project specific keys")
} }
func runAPI(cmd *cobra.Command, args []string) error { func runAPI(cmd *cobra.Command, args []string) error {
@ -212,18 +223,31 @@ func runAPI(cmd *cobra.Command, args []string) error {
return nil return nil
} }
// Pretty-print JSON, or output raw if not JSON
contentType := resp.Header.Get("Content-Type") contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "json") || json.Valid(respBody) { isJSON := strings.Contains(contentType, "json") || json.Valid(respBody)
// If the user asked for JSON projection or jq filtering, route through
// the shared JSON output helpers so the API command is consistent with
// `fj repo list`, `fj pr list`, etc.
if wantJSON(cmd) {
if !isJSON {
return fmt.Errorf("--json/--json-fields/--jq requires a JSON response, but the server returned %s", contentType)
}
var parsed any
if err := json.Unmarshal(respBody, &parsed); err != nil {
return fmt.Errorf("response is not valid JSON: %w", err)
}
return outputJSON(cmd, parsed)
}
// Pretty-print JSON by default, otherwise emit raw bytes.
if isJSON {
var parsed any var parsed any
if err := json.Unmarshal(respBody, &parsed); err == nil { if err := json.Unmarshal(respBody, &parsed); err == nil {
enc := json.NewEncoder(ios.Out) return writeJSON(parsed)
enc.SetIndent("", " ")
return enc.Encode(parsed)
} }
} }
// Raw output for non-JSON responses
_, err = ios.Out.Write(respBody) _, err = ios.Out.Write(respBody)
return err return err
} }