From 6d4c30345277c323d7450dd86fa375eb7578a36d Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Thu, 22 Oct 2026 12:35:15 +0800 Subject: [PATCH 1/3] feat(cmd/api): add `api graphql` for raw GraphQL requests Add `zeabur api graphql`, which sends an arbitrary GraphQL request to the Zeabur API with the logged-in user's credentials and prints the raw JSON response, similar to `gh api graphql`. Scripts and coding agents can then read fields that no dedicated command exposes without handling the token. - `-f key=value` adds a string field, `-F key=value` a typed one (true/false/null/integers, `@file` / `@-`); `query` and `operationName` are top-level keys, everything else becomes a variable. - `--input file|-` reads a full JSON request body; fields merge on top. - Exits non-zero on HTTP failures or GraphQL errors while still printing the response body. - Adds `GraphQLRaw` to the API client, reusing the authenticated HTTP client. Co-authored-by: Yi-Jyun Pan Co-Authored-By: Claude Opus 5.5 --- README.md | 19 ++ internal/cmd/api/api.go | 20 ++ internal/cmd/api/graphql/graphql.go | 241 +++++++++++++++++++++++ internal/cmd/api/graphql/graphql_test.go | 183 +++++++++++++++++ internal/cmd/root/root.go | 2 + pkg/api/client.go | 19 +- pkg/api/interface.go | 8 + pkg/api/raw.go | 40 ++++ 8 files changed, 527 insertions(+), 5 deletions(-) create mode 100644 internal/cmd/api/api.go create mode 100644 internal/cmd/api/graphql/graphql.go create mode 100644 internal/cmd/api/graphql/graphql_test.go create mode 100644 pkg/api/raw.go diff --git a/README.md b/README.md index b12016f..5cfac66 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,25 @@ npx zeabur --workspace acme project list `switch personal` is **not** a way to return to personal — it always looks for a team literally named `personal` (team names are unconstrained). Use `workspace clear` to go back. +## Raw GraphQL requests + +`zeabur api graphql` sends any GraphQL query to the Zeabur API with the credentials of the logged-in user and prints the raw JSON response — useful for fields that no dedicated command exposes yet, and for scripts or coding agents that should never handle the token themselves. + +```shell +# the query is passed as the "query" field +npx zeabur api graphql -f query='query { me { _id username } }' + +# every other field becomes a variable: -f adds a string, -F adds a typed value +# (true / false / null / integers; @file or @- reads the value from a file / stdin) +npx zeabur api graphql -f query='query($id: ObjectID!) { project(_id: $id) { name } }' -F id= +npx zeabur api graphql -F query=@query.graphql -F limit=10 + +# or pass a complete JSON request body +echo '{"query": "query { me { _id } }"}' | npx zeabur api graphql --input - +``` + +The command exits non-zero when the request fails or the response contains GraphQL errors; the response body is still printed. The request is sent as-is — `--workspace` and the saved context do not rewrite it, so pass team or project IDs as variables. + ## Development Guide [Development Guide](docs/development_guide.md) diff --git a/internal/cmd/api/api.go b/internal/cmd/api/api.go new file mode 100644 index 0000000..1565ddb --- /dev/null +++ b/internal/cmd/api/api.go @@ -0,0 +1,20 @@ +// Package api provides the `zeabur api` command for raw Zeabur API requests. +package api + +import ( + "github.com/spf13/cobra" + + graphqlCmd "github.com/zeabur/cli/internal/cmd/api/graphql" + "github.com/zeabur/cli/internal/cmdutil" +) + +func NewCmdAPI(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "api ", + Short: "Make authenticated requests to the Zeabur API", + } + + cmd.AddCommand(graphqlCmd.NewCmdGraphQL(f)) + + return cmd +} diff --git a/internal/cmd/api/graphql/graphql.go b/internal/cmd/api/graphql/graphql.go new file mode 100644 index 0000000..81e8703 --- /dev/null +++ b/internal/cmd/api/graphql/graphql.go @@ -0,0 +1,241 @@ +// Package graphql provides `zeabur api graphql`, which sends an arbitrary +// GraphQL request to the Zeabur API using the CLI's stored credentials. +package graphql + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/zeabur/cli/internal/cmdutil" +) + +type Options struct { + rawFields []string + typedFields []string + input string + + stdin io.Reader +} + +func NewCmdGraphQL(f *cmdutil.Factory) *cobra.Command { + opts := &Options{} + + cmd := &cobra.Command{ + Use: "graphql", + Short: "Send a GraphQL request to the Zeabur API", + Long: heredoc.Doc(` + Send an arbitrary GraphQL query or mutation to the Zeabur API with the + credentials of the logged-in user, and print the raw JSON response. + + The query is passed as the "query" field; every other field becomes a + GraphQL variable. "operationName" selects the operation to run when the + document contains several. + + -f/--raw-field key=value adds a string value. + -F/--field key=value adds a typed value: true, false, null and + integers are converted to JSON types, and a + value starting with "@" is read from a file + ("@-" reads standard input). + + --input reads a complete JSON request body ({"query": ..., "variables": ...}) + from a file, or from standard input with "-". Fields given with -f/-F are + merged on top of it. + + The command exits with a non-zero status when the request fails or the + response contains GraphQL errors; the response body is still printed. + + The request is sent as-is: --workspace and the saved context do not rewrite + it, so pass team or project IDs explicitly as variables. + `), + Example: heredoc.Doc(` + $ zeabur api graphql -f query='query { me { _id username } }' + $ zeabur api graphql -f query='query($id: ObjectID!) { project(_id: $id) { name } }' -F id= + $ zeabur api graphql -F query=@query.graphql -F limit=10 + $ echo '{"query": "query { me { _id } }"}' | zeabur api graphql --input - + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + opts.stdin = cmd.InOrStdin() + return runGraphQL(cmd.Context(), f, opts, cmd.OutOrStdout()) + }, + } + + cmd.Flags().StringArrayVarP(&opts.rawFields, "raw-field", "f", nil, "Add a string field in key=value format") + cmd.Flags().StringArrayVarP(&opts.typedFields, "field", "F", nil, "Add a typed field in key=value format (use @file or @- to read a value)") + cmd.Flags().StringVar(&opts.input, "input", "", "Read the JSON request body from a file (use \"-\" for standard input)") + + return cmd +} + +func runGraphQL(ctx context.Context, f *cmdutil.Factory, opts *Options, out io.Writer) error { + if ctx == nil { + ctx = context.Background() + } + + body, err := buildRequestBody(opts) + if err != nil { + return err + } + + resp, reqErr := f.ApiClient.GraphQLRaw(ctx, body) + if len(resp) > 0 { + if _, err := out.Write(resp); err != nil { + return fmt.Errorf("write response: %w", err) + } + if resp[len(resp)-1] != '\n' { + _, _ = io.WriteString(out, "\n") + } + } + + var parsed struct { + Errors []json.RawMessage `json:"errors"` + } + parseErr := json.Unmarshal(resp, &parsed) + + if reqErr != nil { + if n := len(parsed.Errors); parseErr == nil && n > 0 { + return fmt.Errorf("%w: response contains %d GraphQL error(s)", reqErr, n) + } + return reqErr + } + if parseErr != nil { + return fmt.Errorf("parse response: %w", parseErr) + } + if n := len(parsed.Errors); n > 0 { + return fmt.Errorf("GraphQL response contains %d error(s)", n) + } + + return nil +} + +// buildRequestBody assembles the JSON request body from --input and the +// -f/-F fields. "query" and "operationName" are top-level keys; every other +// field is a variable. +func buildRequestBody(opts *Options) ([]byte, error) { + stdinUsed := false + readStdin := func(what string) ([]byte, error) { + if stdinUsed { + return nil, fmt.Errorf("%s: standard input can only be read once", what) + } + stdinUsed = true + return io.ReadAll(opts.stdin) + } + + req := map[string]any{} + variables := map[string]any{} + + if opts.input != "" { + var ( + raw []byte + err error + ) + if opts.input == "-" { + raw, err = readStdin("--input") + } else { + raw, err = os.ReadFile(opts.input) + } + if err != nil { + return nil, fmt.Errorf("read --input: %w", err) + } + if err := json.Unmarshal(raw, &req); err != nil { + return nil, fmt.Errorf("--input must be a JSON object: %w", err) + } + if v, ok := req["variables"]; ok && v != nil { + vars, ok := v.(map[string]any) + if !ok { + return nil, errors.New("--input: \"variables\" must be a JSON object") + } + variables = vars + } + } + + set := func(key string, value any) { + switch key { + case "query", "operationName": + req[key] = value + default: + variables[key] = value + } + } + + for _, field := range opts.rawFields { + key, value, err := splitField(field) + if err != nil { + return nil, err + } + set(key, value) + } + + for _, field := range opts.typedFields { + key, value, err := splitField(field) + if err != nil { + return nil, err + } + typed, err := parseTypedValue(value, readStdin) + if err != nil { + return nil, fmt.Errorf("field %q: %w", key, err) + } + set(key, typed) + } + + query, ok := req["query"].(string) + if !ok || strings.TrimSpace(query) == "" { + return nil, errors.New("a query is required: pass -f query='...', -F query=@file, or --input") + } + + if len(variables) > 0 { + req["variables"] = variables + } + + return json.Marshal(req) +} + +func splitField(field string) (string, string, error) { + key, value, ok := strings.Cut(field, "=") + if !ok || key == "" { + return "", "", fmt.Errorf("field %q must be in key=value format", field) + } + return key, value, nil +} + +func parseTypedValue(value string, readStdin func(string) ([]byte, error)) (any, error) { + switch value { + case "true": + return true, nil + case "false": + return false, nil + case "null": + return nil, nil + } + + if path, ok := strings.CutPrefix(value, "@"); ok { + var ( + content []byte + err error + ) + if path == "-" { + content, err = readStdin("@-") + } else { + content, err = os.ReadFile(path) + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", value, err) + } + return string(content), nil + } + + if n, err := strconv.ParseInt(value, 10, 64); err == nil { + return n, nil + } + + return value, nil +} diff --git a/internal/cmd/api/graphql/graphql_test.go b/internal/cmd/api/graphql/graphql_test.go new file mode 100644 index 0000000..8ba6389 --- /dev/null +++ b/internal/cmd/api/graphql/graphql_test.go @@ -0,0 +1,183 @@ +package graphql_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zeabur/cli/internal/cmd/api/graphql" + "github.com/zeabur/cli/internal/cmdutil" + "github.com/zeabur/cli/pkg/api" +) + +// stubClient records the request body and returns a canned response. Other +// Client methods inherit the embedded nil interface and panic if called. +type stubClient struct { + api.Client + + gotBody []byte + resp []byte + err error +} + +func (c *stubClient) GraphQLRaw(_ context.Context, body []byte) ([]byte, error) { + c.gotBody = body + return c.resp, c.err +} + +type request struct { + Query string `json:"query"` + OperationName string `json:"operationName"` + Variables map[string]any `json:"variables"` +} + +func run(t *testing.T, client *stubClient, stdin string, args ...string) (string, error) { + t.Helper() + + f := &cmdutil.Factory{ApiClient: client} + cmd := graphql.NewCmdGraphQL(f) + out := &bytes.Buffer{} + cmd.SilenceUsage = true // mirrors the root command + cmd.SetOut(out) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetIn(strings.NewReader(stdin)) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err +} + +func decode(t *testing.T, body []byte) request { + t.Helper() + + var req request + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("request body is not JSON: %v (%s)", err, body) + } + return req +} + +func TestGraphQL_FieldsBuildRequest(t *testing.T) { + t.Parallel() + + client := &stubClient{resp: []byte(`{"data":{"me":{"_id":"1"}}}`)} + out, err := run(t, client, "", + "-f", "query=query($id: ObjectID!) { project(_id: $id) { name } }", + "-f", "operationName=Q", + "-f", "id=123", + "-F", "limit=10", + "-F", "flag=true", + "-F", "none=null", + "-F", "name=abc", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "{\"data\":{\"me\":{\"_id\":\"1\"}}}\n" { + t.Fatalf("unexpected output: %q", out) + } + + req := decode(t, client.gotBody) + if req.Query != "query($id: ObjectID!) { project(_id: $id) { name } }" || req.OperationName != "Q" { + t.Fatalf("unexpected query/operationName: %+v", req) + } + want := map[string]any{"id": "123", "limit": float64(10), "flag": true, "none": nil, "name": "abc"} + if len(req.Variables) != len(want) { + t.Fatalf("unexpected variables: %#v", req.Variables) + } + for k, v := range want { + if got, ok := req.Variables[k]; !ok || got != v { + t.Fatalf("variable %q = %#v, want %#v", k, got, v) + } + } +} + +func TestGraphQL_QueryFromFileAndNoVariables(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "q.graphql") + if err := os.WriteFile(path, []byte("query { me { _id } }"), 0o600); err != nil { + t.Fatal(err) + } + + client := &stubClient{resp: []byte(`{"data":{}}`)} + if _, err := run(t, client, "", "-F", "query=@"+path); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(client.gotBody) != `{"query":"query { me { _id } }"}` { + t.Fatalf("unexpected body: %s", client.gotBody) + } +} + +func TestGraphQL_InputFromStdinMergesFields(t *testing.T) { + t.Parallel() + + client := &stubClient{resp: []byte(`{"data":{}}`)} + stdin := `{"query":"query { me { _id } }","variables":{"a":1,"b":"x"}}` + if _, err := run(t, client, stdin, "--input", "-", "-f", "b=y"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + req := decode(t, client.gotBody) + if req.Query != "query { me { _id } }" || req.Variables["a"] != float64(1) || req.Variables["b"] != "y" { + t.Fatalf("unexpected request: %+v", req) + } +} + +func TestGraphQL_GraphQLErrorsExitNonZero(t *testing.T) { + t.Parallel() + + resp := `{"errors":[{"message":"boom"}],"data":null}` + client := &stubClient{resp: []byte(resp)} + out, err := run(t, client, "", "-f", "query=query { me { _id } }") + if err == nil || !strings.Contains(err.Error(), "1 error(s)") { + t.Fatalf("expected GraphQL error, got %v", err) + } + if out != resp+"\n" { + t.Fatalf("response body should still be printed, got %q", out) + } +} + +func TestGraphQL_HTTPErrorPrintsBody(t *testing.T) { + t.Parallel() + + client := &stubClient{resp: []byte(`{"errors":[{"message":"unauthorized"}]}`), err: errors.New("graphql request failed: HTTP 401")} + out, err := run(t, client, "", "-f", "query=query { me { _id } }") + if err == nil || !strings.Contains(err.Error(), "HTTP 401") || !strings.Contains(err.Error(), "1 GraphQL error(s)") { + t.Fatalf("expected HTTP error, got %v", err) + } + if !strings.Contains(out, "unauthorized") { + t.Fatalf("response body should still be printed, got %q", out) + } +} + +func TestGraphQL_InvalidInput(t *testing.T) { + t.Parallel() + + cases := map[string][]string{ + "missing query": {"-f", "id=1"}, + "malformed field": {"-f", "query"}, + "empty key": {"-f", "=x"}, + "stdin read twice": {"--input", "-", "-F", "query=@-"}, + "positional args": {"extra"}, + "missing file": {"-F", "query=@/nonexistent/q.graphql"}, + "variables not obj": {"--input", "-"}, + } + for name, args := range cases { + stdin := `{"query":"query { me { _id } }"}` + if name == "variables not obj" { + stdin = `{"query":"query { me { _id } }","variables":[1]}` + } + client := &stubClient{} + if _, err := run(t, client, stdin, args...); err == nil { + t.Errorf("%s: expected an error", name) + } + if client.gotBody != nil { + t.Errorf("%s: request must not be sent", name) + } + } +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 581049b..64b5c0c 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/pflag" aihubCmd "github.com/zeabur/cli/internal/cmd/ai-hub" + apiCmd "github.com/zeabur/cli/internal/cmd/api" authCmd "github.com/zeabur/cli/internal/cmd/auth" completionCmd "github.com/zeabur/cli/internal/cmd/completion" helpCmd "github.com/zeabur/cli/internal/cmd/help" @@ -187,6 +188,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Comman cmd.AddCommand(fileCmd.NewCmdFile(f)) cmd.AddCommand(aihubCmd.NewCmdAIHub(f)) cmd.AddCommand(workspaceCmd.NewCmdWorkspace(f)) + cmd.AddCommand(apiCmd.NewCmdAPI(f)) // replace default help command with our custom one that supports --all cmd.SetHelpCommand(helpCmd.NewCmdHelp(cmd)) diff --git a/pkg/api/client.go b/pkg/api/client.go index d48e017..f317dbd 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -3,6 +3,7 @@ package api import ( "context" + "net/http" "github.com/zeabur/cli/pkg/constant" @@ -19,24 +20,32 @@ const ( type client struct { *graphql.Client sub *graphql.SubscriptionClient + + // httpClient is the authenticated HTTP client shared with the GraphQL + // client; used by GraphQLRaw to send caller-supplied request bodies. + httpClient *http.Client } // New returns a new Zeabur API client. func New(token string) Client { + httpClient := newHTTPClientWithToken(token) return &client{ - NewGraphQLClientWithToken(token), - NewSubscriptionClient(token), + Client: graphql.NewClient(ZeaburGraphQLAPIEndpoint, httpClient), + sub: NewSubscriptionClient(token), + httpClient: httpClient, } } // NewGraphQLClientWithToken returns a new GraphQL client with the given token. func NewGraphQLClientWithToken(token string) *graphql.Client { + return graphql.NewClient(ZeaburGraphQLAPIEndpoint, newHTTPClientWithToken(token)) +} + +func newHTTPClientWithToken(token string) *http.Client { src := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: token}, ) - httpClient := oauth2.NewClient(context.Background(), src) - - return graphql.NewClient(ZeaburGraphQLAPIEndpoint, httpClient) + return oauth2.NewClient(context.Background(), src) } func NewSubscriptionClient(token string) *graphql.SubscriptionClient { diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 6c0c45a..9785391 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -25,9 +25,17 @@ type Client interface { ZSendAPI RegisteredDomainAPI FileAPI + RawAPI } type ( + // RawAPI sends caller-supplied GraphQL requests (`zeabur api graphql`). + RawAPI interface { + // GraphQLRaw posts a JSON-encoded GraphQL request body and returns + // the response body verbatim. + GraphQLRaw(ctx context.Context, body []byte) ([]byte, error) + } + UserAPI interface { GetUserInfo(ctx context.Context) (*model.User, error) } diff --git a/pkg/api/raw.go b/pkg/api/raw.go new file mode 100644 index 0000000..24cb764 --- /dev/null +++ b/pkg/api/raw.go @@ -0,0 +1,40 @@ +package api + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" +) + +// GraphQLRaw sends a pre-encoded GraphQL request body ({"query": ..., "variables": ...}) +// to the Zeabur GraphQL API with the client's credentials and returns the +// response body verbatim, so callers can inspect `data` and `errors` as-is. +// +// A non-2xx HTTP status is returned as an error together with the body. +func (c *client) GraphQLRaw(ctx context.Context, body []byte) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ZeaburGraphQLAPIEndpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return respBody, fmt.Errorf("graphql request failed: HTTP %d", resp.StatusCode) + } + + return respBody, nil +} From 1999f2a1bece6bf1bebfab01b059b6a2b8a37e19 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Thu, 22 Oct 2026 12:38:33 +0800 Subject: [PATCH 2/3] fix(cmd/api): preserve numbers and reject invalid --input Decode --input with UseNumber so integers beyond float64 precision are forwarded verbatim, and reject `null` or trailing data instead of panicking when fields are merged into a nil map. Co-authored-by: Yi-Jyun Pan Co-Authored-By: Claude Opus 5.5 --- internal/cmd/api/graphql/graphql.go | 13 ++++++++++++- internal/cmd/api/graphql/graphql_test.go | 22 +++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/internal/cmd/api/graphql/graphql.go b/internal/cmd/api/graphql/graphql.go index 81e8703..1ae4411 100644 --- a/internal/cmd/api/graphql/graphql.go +++ b/internal/cmd/api/graphql/graphql.go @@ -3,6 +3,7 @@ package graphql import ( + "bytes" "context" "encoding/json" "errors" @@ -146,9 +147,19 @@ func buildRequestBody(opts *Options) ([]byte, error) { if err != nil { return nil, fmt.Errorf("read --input: %w", err) } - if err := json.Unmarshal(raw, &req); err != nil { + // UseNumber keeps numeric tokens verbatim, so integers beyond + // float64 precision are forwarded unchanged. + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(&req); err != nil { return nil, fmt.Errorf("--input must be a JSON object: %w", err) } + if dec.More() { + return nil, errors.New("--input must contain a single JSON object") + } + if req == nil { + return nil, errors.New("--input must be a JSON object, not null") + } if v, ok := req["variables"]; ok && v != nil { vars, ok := v.(map[string]any) if !ok { diff --git a/internal/cmd/api/graphql/graphql_test.go b/internal/cmd/api/graphql/graphql_test.go index 8ba6389..2a7b4fc 100644 --- a/internal/cmd/api/graphql/graphql_test.go +++ b/internal/cmd/api/graphql/graphql_test.go @@ -128,6 +128,19 @@ func TestGraphQL_InputFromStdinMergesFields(t *testing.T) { } } +func TestGraphQL_InputPreservesLargeIntegers(t *testing.T) { + t.Parallel() + + client := &stubClient{resp: []byte(`{"data":{}}`)} + stdin := `{"query":"query { me { _id } }","variables":{"n":9007199254740993}}` + if _, err := run(t, client, stdin, "--input", "-"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(client.gotBody), `"n":9007199254740993`) { + t.Fatalf("large integer was not preserved: %s", client.gotBody) + } +} + func TestGraphQL_GraphQLErrorsExitNonZero(t *testing.T) { t.Parallel() @@ -166,11 +179,18 @@ func TestGraphQL_InvalidInput(t *testing.T) { "positional args": {"extra"}, "missing file": {"-F", "query=@/nonexistent/q.graphql"}, "variables not obj": {"--input", "-"}, + "null input": {"--input", "-", "-f", "query=query { me { _id } }"}, + "trailing data": {"--input", "-"}, } for name, args := range cases { stdin := `{"query":"query { me { _id } }"}` - if name == "variables not obj" { + switch name { + case "variables not obj": stdin = `{"query":"query { me { _id } }","variables":[1]}` + case "null input": + stdin = `null` + case "trailing data": + stdin = `{"query":"query { me { _id } }"} {}` } client := &stubClient{} if _, err := run(t, client, stdin, args...); err == nil { From 2fa73403eea0b2bb79ec2c30015bd668f6303ba2 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Thu, 22 Oct 2026 12:49:24 +0800 Subject: [PATCH 3/3] fix(cmd/api): send typed integers of any size as JSON numbers `-F` previously fell back to a string for integers outside the int64 range. Send any integer literal as a JSON number verbatim; values with leading zeros such as `007` stay strings. Co-authored-by: Yi-Jyun Pan Co-Authored-By: Claude Opus 5.5 --- internal/cmd/api/graphql/graphql.go | 9 ++++++--- internal/cmd/api/graphql/graphql_test.go | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/internal/cmd/api/graphql/graphql.go b/internal/cmd/api/graphql/graphql.go index 1ae4411..bdf5294 100644 --- a/internal/cmd/api/graphql/graphql.go +++ b/internal/cmd/api/graphql/graphql.go @@ -10,7 +10,7 @@ import ( "fmt" "io" "os" - "strconv" + "regexp" "strings" "github.com/MakeNowJust/heredoc" @@ -19,6 +19,8 @@ import ( "github.com/zeabur/cli/internal/cmdutil" ) +var integerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`) + type Options struct { rawFields []string typedFields []string @@ -244,8 +246,9 @@ func parseTypedValue(value string, readStdin func(string) ([]byte, error)) (any, return string(content), nil } - if n, err := strconv.ParseInt(value, 10, 64); err == nil { - return n, nil + // Integers of any size are sent as JSON numbers, verbatim. + if integerPattern.MatchString(value) { + return json.Number(value), nil } return value, nil diff --git a/internal/cmd/api/graphql/graphql_test.go b/internal/cmd/api/graphql/graphql_test.go index 2a7b4fc..b8f320d 100644 --- a/internal/cmd/api/graphql/graphql_test.go +++ b/internal/cmd/api/graphql/graphql_test.go @@ -73,6 +73,7 @@ func TestGraphQL_FieldsBuildRequest(t *testing.T) { "-F", "flag=true", "-F", "none=null", "-F", "name=abc", + "-F", "zip=007", ) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -85,7 +86,7 @@ func TestGraphQL_FieldsBuildRequest(t *testing.T) { if req.Query != "query($id: ObjectID!) { project(_id: $id) { name } }" || req.OperationName != "Q" { t.Fatalf("unexpected query/operationName: %+v", req) } - want := map[string]any{"id": "123", "limit": float64(10), "flag": true, "none": nil, "name": "abc"} + want := map[string]any{"id": "123", "limit": float64(10), "flag": true, "none": nil, "name": "abc", "zip": "007"} if len(req.Variables) != len(want) { t.Fatalf("unexpected variables: %#v", req.Variables) } @@ -141,6 +142,18 @@ func TestGraphQL_InputPreservesLargeIntegers(t *testing.T) { } } +func TestGraphQL_TypedFieldPreservesLargeIntegers(t *testing.T) { + t.Parallel() + + client := &stubClient{resp: []byte(`{"data":{}}`)} + if _, err := run(t, client, "", "-f", "query=query { me { _id } }", "-F", "n=-92233720368547758080"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(client.gotBody), `"n":-92233720368547758080`) { + t.Fatalf("large integer was not sent as a number: %s", client.gotBody) + } +} + func TestGraphQL_GraphQLErrorsExitNonZero(t *testing.T) { t.Parallel()