Parse multiple values in api subcommand (#911)

Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
This commit is contained in:
techknowlogick
2026-02-26 17:43:46 +00:00
committed by techknowlogick
parent e3c550ff22
commit c031db2413
3 changed files with 768 additions and 22 deletions

View File

@@ -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: "<endpoint>",
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: "<endpoint>",
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
}

645
cmd/api_test.go Normal file
View File

@@ -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)
})
}
}