diff --git a/cmd/api.go b/cmd/api.go index d50c925..6041fe3 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -4,6 +4,7 @@ package cmd import ( + "bytes" stdctx "context" "encoding/json" "fmt" @@ -20,23 +21,11 @@ import ( "golang.org/x/term" ) -// CmdApi represents the api command -var CmdApi = cli.Command{ - Name: "api", - Usage: "Make an authenticated API request", - Description: `Makes an authenticated HTTP request to the Gitea API and prints the response. - -The endpoint argument is the path to the API endpoint, which will be prefixed -with /api/v1/ if it doesn't start with /api/ or http(s)://. - -Placeholders like {owner} and {repo} in the endpoint will be replaced with -values from the current repository context. - -Use -f for string fields and -F for typed fields (numbers, booleans, null). -With -F, prefix value with @ to read from file (@- for stdin).`, - ArgsUsage: "", - Action: runApi, - Flags: append([]cli.Flag{ +// apiFlags returns a fresh set of flag instances for the api command. +// This is a factory function so that each invocation gets independent flag +// objects, avoiding shared hasBeenSet state across tests. +func apiFlags() []cli.Flag { + return []cli.Flag{ &cli.StringFlag{ Name: "method", Aliases: []string{"X"}, @@ -58,6 +47,11 @@ With -F, prefix value with @ to read from file (@- for stdin).`, Aliases: []string{"H"}, Usage: "Add a custom header (key:value)", }, + &cli.StringFlag{ + Name: "data", + Aliases: []string{"d"}, + Usage: "Raw JSON request body (use @file to read from file, @- for stdin)", + }, &cli.BoolFlag{ Name: "include", Aliases: []string{"i"}, @@ -68,7 +62,39 @@ With -F, prefix value with @ to read from file (@- for stdin).`, Aliases: []string{"o"}, Usage: "Write response body to file instead of stdout (use '-' for stdout)", }, - }, flags.LoginRepoFlags...), + } +} + +// CmdApi represents the api command +var CmdApi = cli.Command{ + Name: "api", + Category: catHelpers, + DisableSliceFlagSeparator: true, + Usage: "Make an authenticated API request", + Description: `Makes an authenticated HTTP request to the Gitea API and prints the response. + +The endpoint argument is the path to the API endpoint, which will be prefixed +with /api/v1/ if it doesn't start with /api/ or http(s)://. + +Placeholders like {owner} and {repo} in the endpoint will be replaced with +values from the current repository context. + +Use -f for string fields and -F for typed fields (numbers, booleans, null). +With -F, prefix value with @ to read from file (@- for stdin). Values starting +with [ or { are parsed as JSON arrays/objects. Wrap values in quotes to force +string type (e.g., -F key="null" for literal string "null"). + +Use -d/--data to send a raw JSON body. Use @file to read from a file, or @- +to read from stdin. The -d flag cannot be combined with -f or -F. + +When a request body is provided via -f, -F, or -d, the method defaults to POST +unless explicitly set with -X/--method. + +Note: if your endpoint contains ? or &, quote it to prevent shell expansion +(e.g., '/repos/{owner}/{repo}/issues?state=open').`, + ArgsUsage: "", + Action: runApi, + Flags: append(apiFlags(), flags.LoginRepoFlags...), } func runApi(_ stdctx.Context, cmd *cli.Command) error { @@ -97,8 +123,39 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { var body io.Reader stringFields := cmd.StringSlice("field") typedFields := cmd.StringSlice("Field") + dataRaw := cmd.String("data") - if len(stringFields) > 0 || len(typedFields) > 0 { + if dataRaw != "" && (len(stringFields) > 0 || len(typedFields) > 0) { + return fmt.Errorf("--data/-d cannot be combined with --field/-f or --Field/-F") + } + + if dataRaw != "" { + var dataBytes []byte + var dataSource string + if strings.HasPrefix(dataRaw, "@") { + filename := dataRaw[1:] + var err error + if filename == "-" { + dataBytes, err = io.ReadAll(os.Stdin) + dataSource = "stdin" + } else { + dataBytes, err = os.ReadFile(filename) + dataSource = filename + } + if err != nil { + return fmt.Errorf("failed to read %q: %w", dataRaw, err) + } + } else { + dataBytes = []byte(dataRaw) + } + if !json.Valid(dataBytes) { + if dataSource != "" { + return fmt.Errorf("--data/-d value from %s is not valid JSON", dataSource) + } + return fmt.Errorf("--data/-d value is not valid JSON") + } + body = bytes.NewReader(dataBytes) + } else if len(stringFields) > 0 || len(typedFields) > 0 { bodyMap := make(map[string]any) // Process string fields (-f) @@ -107,7 +164,14 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { if len(parts) != 2 { return fmt.Errorf("invalid field format: %q (expected key=value)", f) } - bodyMap[parts[0]] = parts[1] + key := parts[0] + if key == "" { + return fmt.Errorf("field key cannot be empty in %q", f) + } + if _, exists := bodyMap[key]; exists { + return fmt.Errorf("duplicate field key %q", key) + } + bodyMap[key] = parts[1] } // Process typed fields (-F) @@ -117,6 +181,12 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("invalid field format: %q (expected key=value)", f) } key := parts[0] + if key == "" { + return fmt.Errorf("field key cannot be empty in %q", f) + } + if _, exists := bodyMap[key]; exists { + return fmt.Errorf("duplicate field key %q", key) + } value := parts[1] parsedValue, err := parseTypedValue(value) @@ -130,12 +200,19 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { if err != nil { return fmt.Errorf("failed to encode request body: %w", err) } - body = strings.NewReader(string(bodyBytes)) + body = bytes.NewReader(bodyBytes) } // Create API client and make request client := api.NewClient(ctx.Login) method := strings.ToUpper(cmd.String("method")) + if !cmd.IsSet("method") { + if body != nil { + method = "POST" + } else { + method = "GET" + } + } resp, err := client.Do(method, endpoint, body, headers) if err != nil { @@ -193,12 +270,16 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { // parseTypedValue parses a value for -F flag, handling: // - @filename: read content from file // - @-: read content from stdin +// - "quoted": literal string (prevents type parsing) // - true/false: boolean // - null: nil // - numbers: int or float +// - []/{}: JSON arrays/objects // - otherwise: string func parseTypedValue(value string) (any, error) { - // Handle file references + // Handle file references. + // Note: if multiple fields use @- (stdin), only the first will get data; + // subsequent reads will return empty since stdin is consumed once. if strings.HasPrefix(value, "@") { filename := value[1:] var content []byte @@ -215,6 +296,16 @@ func parseTypedValue(value string) (any, error) { return strings.TrimSuffix(string(content), "\n"), nil } + // Handle quoted strings (literal strings, no type parsing). + // Uses strconv.Unquote so escape sequences like \" are handled correctly. + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + unquoted, err := strconv.Unquote(value) + if err != nil { + return nil, fmt.Errorf("invalid quoted string %s: %w", value, err) + } + return unquoted, nil + } + // Handle null if value == "null" { return nil, nil @@ -238,6 +329,14 @@ func parseTypedValue(value string) (any, error) { return f, nil } + // Handle JSON arrays and objects + if len(value) > 0 && (value[0] == '[' || value[0] == '{') { + var jsonVal any + if err := json.Unmarshal([]byte(value), &jsonVal); err == nil { + return jsonVal, nil + } + } + // Default to string return value, nil } diff --git a/cmd/api_test.go b/cmd/api_test.go new file mode 100644 index 0000000..62449f3 --- /dev/null +++ b/cmd/api_test.go @@ -0,0 +1,645 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "code.gitea.io/tea/modules/config" + "code.gitea.io/tea/modules/context" + tea_git "code.gitea.io/tea/modules/git" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestParseTypedValue(t *testing.T) { + t.Run("null", func(t *testing.T) { + v, err := parseTypedValue("null") + require.NoError(t, err) + assert.Nil(t, v) + }) + + t.Run("bool true", func(t *testing.T) { + v, err := parseTypedValue("true") + require.NoError(t, err) + assert.Equal(t, true, v) + }) + + t.Run("bool false", func(t *testing.T) { + v, err := parseTypedValue("false") + require.NoError(t, err) + assert.Equal(t, false, v) + }) + + t.Run("integer", func(t *testing.T) { + v, err := parseTypedValue("42") + require.NoError(t, err) + assert.Equal(t, int64(42), v) + }) + + t.Run("float", func(t *testing.T) { + v, err := parseTypedValue("3.14") + require.NoError(t, err) + assert.Equal(t, 3.14, v) + }) + + t.Run("string", func(t *testing.T) { + v, err := parseTypedValue("hello") + require.NoError(t, err) + assert.Equal(t, "hello", v) + }) + + t.Run("JSON array", func(t *testing.T) { + v, err := parseTypedValue("[1,2,3]") + require.NoError(t, err) + assert.Equal(t, []any{float64(1), float64(2), float64(3)}, v) + }) + + t.Run("JSON object", func(t *testing.T) { + v, err := parseTypedValue(`{"key":"val"}`) + require.NoError(t, err) + assert.Equal(t, map[string]any{"key": "val"}, v) + }) + + t.Run("invalid JSON array falls back to string", func(t *testing.T) { + v, err := parseTypedValue("[not json") + require.NoError(t, err) + assert.Equal(t, "[not json", v) + }) + + t.Run("invalid JSON object falls back to string", func(t *testing.T) { + v, err := parseTypedValue("{not json") + require.NoError(t, err) + assert.Equal(t, "{not json", v) + }) + + t.Run("file reference", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "test.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("file content\n"), 0o644)) + v, err := parseTypedValue("@" + tmpFile) + require.NoError(t, err) + assert.Equal(t, "file content", v) + }) + + t.Run("file reference without trailing newline", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "test.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("no newline"), 0o644)) + v, err := parseTypedValue("@" + tmpFile) + require.NoError(t, err) + assert.Equal(t, "no newline", v) + }) + + t.Run("empty file reference", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "empty.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte(""), 0o644)) + v, err := parseTypedValue("@" + tmpFile) + require.NoError(t, err) + assert.Equal(t, "", v) + }) + + t.Run("nonexistent file reference", func(t *testing.T) { + _, err := parseTypedValue("@/nonexistent/file.txt") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read") + }) + + t.Run("negative integer", func(t *testing.T) { + v, err := parseTypedValue("-42") + require.NoError(t, err) + assert.Equal(t, int64(-42), v) + }) + + t.Run("negative float", func(t *testing.T) { + v, err := parseTypedValue("-3.14") + require.NoError(t, err) + assert.Equal(t, -3.14, v) + }) + + t.Run("scientific notation", func(t *testing.T) { + v, err := parseTypedValue("1.5e10") + require.NoError(t, err) + assert.Equal(t, 1.5e10, v) + }) + + t.Run("empty string", func(t *testing.T) { + v, err := parseTypedValue("") + require.NoError(t, err) + assert.Equal(t, "", v) + }) + + t.Run("string starting with number", func(t *testing.T) { + v, err := parseTypedValue("123abc") + require.NoError(t, err) + assert.Equal(t, "123abc", v) + }) + + t.Run("nested JSON object", func(t *testing.T) { + v, err := parseTypedValue(`{"user":{"name":"alice","id":1}}`) + require.NoError(t, err) + expected := map[string]any{ + "user": map[string]any{ + "name": "alice", + "id": float64(1), + }, + } + assert.Equal(t, expected, v) + }) + + t.Run("complex JSON array", func(t *testing.T) { + v, err := parseTypedValue(`[{"id":1},{"id":2}]`) + require.NoError(t, err) + expected := []any{ + map[string]any{"id": float64(1)}, + map[string]any{"id": float64(2)}, + } + assert.Equal(t, expected, v) + }) + + t.Run("quoted string prevents type parsing", func(t *testing.T) { + v, err := parseTypedValue(`"null"`) + require.NoError(t, err) + assert.Equal(t, "null", v) + }) + + t.Run("quoted true becomes string", func(t *testing.T) { + v, err := parseTypedValue(`"true"`) + require.NoError(t, err) + assert.Equal(t, "true", v) + }) + + t.Run("quoted false becomes string", func(t *testing.T) { + v, err := parseTypedValue(`"false"`) + require.NoError(t, err) + assert.Equal(t, "false", v) + }) + + t.Run("quoted number becomes string", func(t *testing.T) { + v, err := parseTypedValue(`"123"`) + require.NoError(t, err) + assert.Equal(t, "123", v) + }) + + t.Run("quoted empty string", func(t *testing.T) { + v, err := parseTypedValue(`""`) + require.NoError(t, err) + assert.Equal(t, "", v) + }) + + t.Run("quoted string with spaces", func(t *testing.T) { + v, err := parseTypedValue(`"hello world"`) + require.NoError(t, err) + assert.Equal(t, "hello world", v) + }) + + t.Run("single quote not treated as quote", func(t *testing.T) { + v, err := parseTypedValue(`'hello'`) + require.NoError(t, err) + assert.Equal(t, "'hello'", v) + }) + + t.Run("unmatched quote at start only", func(t *testing.T) { + v, err := parseTypedValue(`"hello`) + require.NoError(t, err) + assert.Equal(t, `"hello`, v) + }) + + t.Run("unmatched quote at end only", func(t *testing.T) { + v, err := parseTypedValue(`hello"`) + require.NoError(t, err) + assert.Equal(t, `hello"`, v) + }) + + t.Run("quoted string with escaped quote", func(t *testing.T) { + v, err := parseTypedValue(`"hello \"world\""`) + require.NoError(t, err) + assert.Equal(t, `hello "world"`, v) + }) + + t.Run("quoted string with backslash-n", func(t *testing.T) { + v, err := parseTypedValue(`"line1\nline2"`) + require.NoError(t, err) + assert.Equal(t, "line1\nline2", v) + }) + + t.Run("quoted string with tab escape", func(t *testing.T) { + v, err := parseTypedValue(`"col1\tcol2"`) + require.NoError(t, err) + assert.Equal(t, "col1\tcol2", v) + }) + + t.Run("quoted string with backslash", func(t *testing.T) { + v, err := parseTypedValue(`"path\\to\\file"`) + require.NoError(t, err) + assert.Equal(t, `path\to\file`, v) + }) + + t.Run("invalid escape sequence in quoted string", func(t *testing.T) { + _, err := parseTypedValue(`"bad \z escape"`) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid quoted string") + }) +} + +// runApiWithArgs sets up a test server that captures requests, configures the +// login to point at it, and runs the api command with the given CLI args. +// Returns the captured HTTP method, body bytes, and any error from the command. +func runApiWithArgs(t *testing.T, args []string) (method string, body []byte, err error) { + t.Helper() + + var mu sync.Mutex + var capturedMethod string + var capturedBody []byte + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, readErr := io.ReadAll(r.Body) + if readErr != nil { + t.Fatalf("failed to read request body: %v", readErr) + } + mu.Lock() + capturedMethod = r.Method + capturedBody = b + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + t.Cleanup(server.Close) + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "testLogin", + URL: server.URL, + Token: "test-token", + User: "testUser", + Default: true, + }}, + }) + + // Use the apiFlags factory to get fresh flag instances, avoiding shared + // hasBeenSet state between tests. Append minimal login/repo flags needed + // for the test harness. + cmd := cli.Command{ + Name: "api", + DisableSliceFlagSeparator: true, + Action: runApi, + Flags: append(apiFlags(), []cli.Flag{ + &cli.StringFlag{Name: "login", Aliases: []string{"l"}}, + &cli.StringFlag{Name: "repo", Aliases: []string{"r"}}, + &cli.StringFlag{Name: "remote", Aliases: []string{"R"}}, + }...), + Writer: io.Discard, + ErrWriter: io.Discard, + } + + fullArgs := append([]string{"api", "--login", "testLogin"}, args...) + runErr := cmd.Run(stdctx.Background(), fullArgs) + + mu.Lock() + defer mu.Unlock() + return capturedMethod, capturedBody, runErr +} + +func TestApiCommaInFieldValue(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{"-f", "body=hello, world", "-X", "POST", "/test"}) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "hello, world", parsed["body"]) +} + +func TestApiRawDataFlag(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{"-d", `{"title":"test","body":"hello"}`, "/test"}) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "test", parsed["title"]) + assert.Equal(t, "hello", parsed["body"]) +} + +func TestApiDataFieldMutualExclusion(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "-f", "key=val", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--data/-d cannot be combined with --field/-f or --Field/-F") +} + +func TestApiMethodAutoDefault(t *testing.T) { + t.Run("POST when body provided without explicit method", func(t *testing.T) { + method, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "/test"}) + require.NoError(t, err) + assert.Equal(t, "POST", method) + }) + + t.Run("explicit method overrides auto-POST", func(t *testing.T) { + method, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "-X", "PATCH", "/test"}) + require.NoError(t, err) + assert.Equal(t, "PATCH", method) + }) + + t.Run("GET when no body", func(t *testing.T) { + method, _, err := runApiWithArgs(t, []string{"/test"}) + require.NoError(t, err) + assert.Equal(t, "GET", method) + }) +} + +func TestApiMultipleFields(t *testing.T) { + t.Run("multiple -f flags", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-f", "title=Test Issue", + "-f", "body=Description here", + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "Test Issue", parsed["title"]) + assert.Equal(t, "Description here", parsed["body"]) + }) + + t.Run("multiple -F flags with different types", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-F", "milestone=5", + "-F", "closed=true", + "-F", "title=Test", + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, float64(5), parsed["milestone"]) + assert.Equal(t, true, parsed["closed"]) + assert.Equal(t, "Test", parsed["title"]) + }) + + t.Run("combining -f and -F flags", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-f", "title=Test", + "-F", "milestone=3", + "-F", "closed=false", + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "Test", parsed["title"]) + assert.Equal(t, float64(3), parsed["milestone"]) + assert.Equal(t, false, parsed["closed"]) + }) + + t.Run("-F with JSON array", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-F", `labels=["bug","enhancement"]`, + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, []any{"bug", "enhancement"}, parsed["labels"]) + }) + + t.Run("-F with JSON object", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-F", `assignee={"login":"alice","id":123}`, + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assignee, ok := parsed["assignee"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "alice", assignee["login"]) + assert.Equal(t, float64(123), assignee["id"]) + }) + + t.Run("-F with quoted string to prevent type parsing", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-F", `status="null"`, + "-F", `enabled="true"`, + "-F", `count="42"`, + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "null", parsed["status"]) + assert.Equal(t, "true", parsed["enabled"]) + assert.Equal(t, "42", parsed["count"]) + }) +} + +func TestApiDataFromFile(t *testing.T) { + t.Run("read JSON from file", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "data.json") + jsonData := `{"title":"From File","body":"File content"}` + require.NoError(t, os.WriteFile(tmpFile, []byte(jsonData), 0o644)) + + _, body, err := runApiWithArgs(t, []string{"-d", "@" + tmpFile, "/test"}) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "From File", parsed["title"]) + assert.Equal(t, "File content", parsed["body"]) + }) + + t.Run("invalid JSON in --data flag", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-d", `{invalid json}`, "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not valid JSON") + }) + + t.Run("invalid JSON from file includes filename", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "bad.json") + require.NoError(t, os.WriteFile(tmpFile, []byte("not json"), 0o644)) + + _, _, err := runApiWithArgs(t, []string{"-d", "@" + tmpFile, "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not valid JSON") + assert.Contains(t, err.Error(), "bad.json") + }) +} + +func TestApiErrorHandling(t *testing.T) { + t.Run("missing endpoint argument", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "endpoint argument required") + }) + + t.Run("invalid field format", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-f", "invalidformat", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid field format") + }) + + t.Run("invalid Field format", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-F", "noequalsign", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid field format") + }) + + t.Run("empty field key with -f", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-f", "=value", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "field key cannot be empty") + }) + + t.Run("empty field key with -F", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-F", "=123", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "field key cannot be empty") + }) + + t.Run("duplicate field key in -f flags", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-f", "key=first", "-f", "key=second", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate field key") + }) + + t.Run("duplicate field key in -F flags", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-F", "key=1", "-F", "key=2", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate field key") + }) + + t.Run("duplicate field key across -f and -F flags", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-f", "key=string", "-F", "key=123", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate field key") + }) +} + +func TestExpandPlaceholders(t *testing.T) { + t.Run("replaces owner and repo", func(t *testing.T) { + ctx := &context.TeaContext{ + Owner: "myorg", + Repo: "myrepo", + } + result := expandPlaceholders("/repos/{owner}/{repo}/issues", ctx) + assert.Equal(t, "/repos/myorg/myrepo/issues", result) + }) + + t.Run("replaces multiple occurrences", func(t *testing.T) { + ctx := &context.TeaContext{ + Owner: "alice", + Repo: "proj", + } + result := expandPlaceholders("/repos/{owner}/{repo}/branches?owner={owner}", ctx) + assert.Equal(t, "/repos/alice/proj/branches?owner=alice", result) + }) + + t.Run("no placeholders returns unchanged", func(t *testing.T) { + ctx := &context.TeaContext{ + Owner: "alice", + Repo: "proj", + } + result := expandPlaceholders("/api/v1/version", ctx) + assert.Equal(t, "/api/v1/version", result) + }) + + t.Run("empty owner and repo produce empty replacements", func(t *testing.T) { + ctx := &context.TeaContext{} + result := expandPlaceholders("/repos/{owner}/{repo}", ctx) + assert.Equal(t, "/repos//", result) + }) + + t.Run("branch left unreplaced when no local repo", func(t *testing.T) { + ctx := &context.TeaContext{ + Owner: "alice", + Repo: "proj", + } + result := expandPlaceholders("/repos/{owner}/{repo}/branches/{branch}", ctx) + assert.Equal(t, "/repos/alice/proj/branches/{branch}", result) + }) + + t.Run("replaces branch from local repo HEAD", func(t *testing.T) { + tmpDir := t.TempDir() + repo, err := gogit.PlainInit(tmpDir, false) + require.NoError(t, err) + + // Create an initial commit so HEAD points to a branch. + wt, err := repo.Worktree() + require.NoError(t, err) + tmpFile := filepath.Join(tmpDir, "init.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("init"), 0o644)) + _, err = wt.Add("init.txt") + require.NoError(t, err) + _, err = wt.Commit("initial commit", &gogit.CommitOptions{ + Author: &object.Signature{Name: "test", Email: "test@test.com"}, + }) + require.NoError(t, err) + + // Create and checkout a feature branch. + headRef, err := repo.Head() + require.NoError(t, err) + branchRef := plumbing.NewBranchReferenceName("feature/my-branch") + ref := plumbing.NewHashReference(branchRef, headRef.Hash()) + require.NoError(t, repo.Storer.SetReference(ref)) + require.NoError(t, wt.Checkout(&gogit.CheckoutOptions{Branch: branchRef})) + + ctx := &context.TeaContext{ + Owner: "alice", + Repo: "proj", + LocalRepo: &tea_git.TeaRepo{Repository: repo}, + } + result := expandPlaceholders("/repos/{owner}/{repo}/branches/{branch}", ctx) + assert.Equal(t, "/repos/alice/proj/branches/feature/my-branch", result) + }) +} + +func TestIsTextContentType(t *testing.T) { + tests := []struct { + name string + contentType string + want bool + }{ + {"empty string defaults to text", "", true}, + {"plain text", "text/plain", true}, + {"html", "text/html", true}, + {"json", "application/json", true}, + {"json with charset", "application/json; charset=utf-8", true}, + {"xml", "application/xml", true}, + {"javascript", "application/javascript", true}, + {"yaml", "application/yaml", true}, + {"toml", "application/toml", true}, + {"binary", "application/octet-stream", false}, + {"image", "image/png", false}, + {"pdf", "application/pdf", false}, + {"zip", "application/zip", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isTextContentType(tt.contentType) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/docs/CLI.md b/docs/CLI.md index c5ab769..f79580f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1819,6 +1819,8 @@ Make an authenticated API request **--Field, -F**="": Add a typed field to the request body (key=value, @file, or @- for stdin) +**--data, -d**="": Raw JSON request body (use @file to read from file, @- for stdin) + **--field, -f**="": Add a string field to the request body (key=value) **--header, -H**="": Add a custom header (key:value)