package cmd import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fj/internal/git" "github.com/spf13/cobra" ) // maxAPIResponseBytes caps response bodies for `fj api`. Forgejo responses // are normally <1 MB; 64 MB is enough for any sane payload while preventing // a runaway body from OOMing the CLI when combined with the 30 s client // timeout. const maxAPIResponseBytes = 64 << 20 var apiCmd = &cobra.Command{ Use: "api [flags]", Short: "Make an authenticated API request", Long: `Makes an authenticated HTTP request to the Forgejo API and prints the response. The endpoint argument should be a path like "/repos/{owner}/{repo}/pulls". Placeholders {owner} and {repo} are automatically replaced with values detected from the current git repository. If --field is used and no --method is specified, the method defaults to POST.`, Example: ` # List pull requests for the current repository fj api /repos/{owner}/{repo}/pulls # Create an issue fj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke" # Get a specific user fj api /users/johndoe # Use raw body from stdin 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 fj api /repos/{owner}/{repo} --json-fields full_name,description,private`, Args: cobra.ExactArgs(1), RunE: runAPI, } func init() { rootCmd.AddCommand(apiCmd) apiCmd.Flags().StringP("method", "X", "", "HTTP method (default: GET, or POST if --field is used)") apiCmd.Flags().StringArrayP("field", "f", nil, "Add a typed field to the request body (key=value)") apiCmd.Flags().StringArrayP("raw-field", "F", nil, "Add a string field to the request body (key=value)") apiCmd.Flags().String("input", "", "Read request body from file (use \"-\" for stdin)") 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().BoolP("include", "i", false, "Include HTTP response headers in the output") apiCmd.Flags().Bool("paginate", false, "Follow rel=\"next\" Link headers and concatenate JSON array pages (gh-compatible)") addJSONFlags(apiCmd, "Output the response as JSON") } // parseLinkHeaderNext extracts the URL with rel="next" from an RFC 5988 // Link header. Returns "" if not present. func parseLinkHeaderNext(link string) string { for _, segment := range strings.Split(link, ",") { segment = strings.TrimSpace(segment) if !strings.Contains(segment, `rel="next"`) { continue } start := strings.Index(segment, "<") end := strings.Index(segment, ">") if start >= 0 && end > start { return segment[start+1 : end] } } return "" } // concatPaginatedJSON parses each body as a JSON array and merges them. // Errors if any body isn't an array (e.g. an object response means the // endpoint isn't paginated and --paginate doesn't apply). func concatPaginatedJSON(bodies [][]byte) ([]byte, error) { merged := make([]json.RawMessage, 0) for i, b := range bodies { var page []json.RawMessage if err := json.Unmarshal(b, &page); err != nil { return nil, fmt.Errorf("--paginate requires JSON array responses; page %d wasn't an array: %w", i+1, err) } merged = append(merged, page...) } return json.Marshal(merged) } func runAPI(cmd *cobra.Command, args []string) error { endpoint := args[0] method, _ := cmd.Flags().GetString("method") fields, _ := cmd.Flags().GetStringArray("field") rawFields, _ := cmd.Flags().GetStringArray("raw-field") inputFile, _ := cmd.Flags().GetString("input") headers, _ := cmd.Flags().GetStringArray("header") hostname, _ := cmd.Flags().GetString("hostname") silent, _ := cmd.Flags().GetBool("silent") include, _ := cmd.Flags().GetBool("include") // Resolve hostname and token from config cfg, err := config.Load() if err != nil { return err } detectedHost := getDetectedHost() host, err := cfg.GetHost(hostname, detectedHost, getCwd()) if err != nil { return err } // Interpolate {owner} and {repo} placeholders if strings.Contains(endpoint, "{owner}") || strings.Contains(endpoint, "{repo}") { owner, repo, detectErr := git.DetectRepo() if detectErr != nil { return fmt.Errorf("cannot determine repository for path interpolation: %w", detectErr) } endpoint = strings.ReplaceAll(endpoint, "{owner}", owner) endpoint = strings.ReplaceAll(endpoint, "{repo}", repo) } // Determine HTTP method hasBody := len(fields) > 0 || len(rawFields) > 0 || inputFile != "" if method == "" { if hasBody { method = http.MethodPost } else { method = http.MethodGet } } method = strings.ToUpper(method) // Build request body var body io.Reader if inputFile != "" { if len(fields) > 0 || len(rawFields) > 0 { return fmt.Errorf("--input cannot be combined with --field or --raw-field") } if inputFile == "-" { body = os.Stdin } else { f, openErr := os.Open(inputFile) if openErr != nil { return fmt.Errorf("failed to open input file: %w", openErr) } defer func() { _ = f.Close() }() body = f } } else if len(fields) > 0 || len(rawFields) > 0 { bodyMap := make(map[string]any) for _, f := range fields { key, value, parseErr := parseField(f, false) if parseErr != nil { return parseErr } bodyMap[key] = value } for _, f := range rawFields { key, value, parseErr := parseField(f, true) if parseErr != nil { return parseErr } bodyMap[key] = value } bodyBytes, marshalErr := json.Marshal(bodyMap) if marshalErr != nil { return fmt.Errorf("failed to marshal request body: %w", marshalErr) } body = bytes.NewReader(bodyBytes) } // Build the request URL safely. Naive concatenation lets endpoints like // "/../admin/users" escape the /api/v1 base via Go's URL normalization // of `..` segments — silently sending authenticated traffic to non-API // paths. Parse the endpoint, reject `..`, then JoinPath onto the base. endpointURL, err := url.Parse(endpoint) if err != nil { return fmt.Errorf("invalid endpoint %q: %w", endpoint, err) } if endpointURL.Scheme != "" || endpointURL.Host != "" { return fmt.Errorf("endpoint must be a path, not a full URL: %s", endpoint) } for _, seg := range strings.Split(strings.Trim(endpointURL.Path, "/"), "/") { if seg == ".." { return fmt.Errorf("endpoint contains forbidden '..' segment: %s", endpoint) } } base := &url.URL{Scheme: "https", Host: host.Hostname, Path: "/api/v1"} final := base.JoinPath(endpointURL.Path) final.RawQuery = endpointURL.RawQuery // Create HTTP request req, err := http.NewRequest(method, final.String(), body) if err != nil { return fmt.Errorf("failed to create request: %w", err) } // Set auth header if host.Token != "" { req.Header.Set("Authorization", "token "+host.Token) } req.Header.Set("Accept", "application/json") if hasBody { req.Header.Set("Content-Type", "application/json") } // Apply custom headers for _, h := range headers { key, value, found := strings.Cut(h, ":") if !found { return fmt.Errorf("invalid header format %q (expected key:value)", h) } req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value)) } paginate, _ := cmd.Flags().GetBool("paginate") if paginate && method != http.MethodGet { return fmt.Errorf("--paginate only supports GET requests") } // doOnce executes a single request via the shared client (30 s timeout, // pooled connections), reads the body bounded by maxAPIResponseBytes, // and closes the body before returning. Previous zero-value http.Client{} // had no timeout, pinning the CLI on a hung Forgejo indefinitely. doOnce := func(r *http.Request) (body []byte, header http.Header, status int, proto string, statusText string, retErr error) { ios.StartSpinner("Requesting...") resp, err := api.SharedHTTPClient.Do(r) ios.StopSpinner() if err != nil { return nil, nil, 0, "", "", fmt.Errorf("failed to perform request: %w", err) } defer func() { _ = resp.Body.Close() }() body, err = io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1)) if err != nil { return nil, nil, 0, "", "", fmt.Errorf("failed to read response body: %w", err) } if int64(len(body)) > maxAPIResponseBytes { return nil, nil, 0, "", "", fmt.Errorf("response body exceeded %d bytes (use a different tool for bulk transfers)", maxAPIResponseBytes) } return body, resp.Header, resp.StatusCode, resp.Proto, resp.Status, nil } respBody, respHeader, statusCode, proto, status, err := doOnce(req) if err != nil { return err } if include { fmt.Fprintf(ios.Out, "%s %s\n", proto, status) for key, values := range respHeader { for _, v := range values { fmt.Fprintf(ios.Out, "%s: %s\n", key, v) } } fmt.Fprintln(ios.Out) } if statusCode < 200 || statusCode >= 300 { if !silent { fmt.Fprint(ios.ErrOut, string(respBody)) if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' { fmt.Fprintln(ios.ErrOut) } } return fmt.Errorf("API request failed with status %d", statusCode) } // Follow `Link: rel="next"` headers when --paginate is set, accumulating // each page's body. After the loop, concatPaginatedJSON merges them into // a single JSON array. Endpoint must be paginatable (returns an array). if paginate { bodies := [][]byte{respBody} nextURL := parseLinkHeaderNext(respHeader.Get("Link")) for nextURL != "" { nextReq, err := http.NewRequest(http.MethodGet, nextURL, nil) if err != nil { return fmt.Errorf("failed to build paginated request: %w", err) } if host.Token != "" { nextReq.Header.Set("Authorization", "token "+host.Token) } nextReq.Header.Set("Accept", "application/json") for _, h := range headers { key, value, found := strings.Cut(h, ":") if !found { continue } nextReq.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value)) } pageBody, pageHeader, pageStatus, _, _, err := doOnce(nextReq) if err != nil { return err } if pageStatus < 200 || pageStatus >= 300 { return fmt.Errorf("paginated request to %s failed with status %d", nextURL, pageStatus) } bodies = append(bodies, pageBody) nextURL = parseLinkHeaderNext(pageHeader.Get("Link")) } merged, err := concatPaginatedJSON(bodies) if err != nil { return err } respBody = merged } if silent || len(respBody) == 0 { return nil } contentType := respHeader.Get("Content-Type") 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 if err := json.Unmarshal(respBody, &parsed); err == nil { return writeJSON(parsed) } } _, err = ios.Out.Write(respBody) return err } // parseField parses a "key=value" string. When rawString is true, the value is // always treated as a string. Otherwise, the function attempts JSON type // inference: booleans ("true"/"false"), null, numbers, and falls back to string. func parseField(field string, rawString bool) (string, any, error) { key, value, found := strings.Cut(field, "=") if !found { return "", nil, fmt.Errorf("invalid field format %q (expected key=value)", field) } if rawString { return key, value, nil } // JSON type inference switch { case value == "true": return key, true, nil case value == "false": return key, false, nil case value == "null": return key, nil, nil default: // Try number if n, err := strconv.ParseInt(value, 10, 64); err == nil { return key, n, nil } if f, err := strconv.ParseFloat(value, 64); err == nil { return key, f, nil } return key, value, nil } }