diff --git a/.surface b/.surface index 1f9c8d5..49fb863 100644 --- a/.surface +++ b/.surface @@ -4,6 +4,7 @@ ARG vector account secret show 0 id ARG vector account secret update 0 id ARG vector account ssh-key delete 0 key-id ARG vector account ssh-key show 0 key-id +ARG vector api 0 endpoint ARG vector archive import 0 site-id ARG vector archive import 1 file ARG vector backup download create 0 backup-id @@ -98,6 +99,7 @@ CMD vector account ssh-key create CMD vector account ssh-key delete CMD vector account ssh-key list CMD vector account ssh-key show +CMD vector api CMD vector archive CMD vector archive import CMD vector auth @@ -218,6 +220,14 @@ FLAG vector account ssh-key create --name type=string FLAG vector account ssh-key create --public-key type=string FLAG vector account ssh-key list --page type=int FLAG vector account ssh-key list --per-page type=int +FLAG vector api --field type=stringArray +FLAG vector api --header type=stringArray +FLAG vector api --include type=bool +FLAG vector api --input type=string +FLAG vector api --method type=string +FLAG vector api --paginate type=bool +FLAG vector api --raw-field type=stringArray +FLAG vector api --verbose type=bool FLAG vector archive import --disable-foreign-keys type=bool FLAG vector archive import --drop-tables type=bool FLAG vector archive import --search-replace-from type=string diff --git a/e2e/api.bats b/e2e/api.bats new file mode 100644 index 0000000..235d5a5 --- /dev/null +++ b/e2e/api.bats @@ -0,0 +1,125 @@ +#!/usr/bin/env bats +# api.bats - E2E tests for the vector api passthrough command + +load test_helper + + +# --- GET passthrough --- + +@test "api sites returns valid JSON" { + create_credentials "test-token" + run vector api sites + assert_success + is_valid_json +} + +@test "api with bare endpoint prepends the vector base path" { + create_credentials "test-token" + run vector api sites + assert_success + # The standard envelope carries a data array for list endpoints. + assert_output_contains "data" +} + +@test "api with leading-slash endpoint is sent verbatim" { + create_credentials "test-token" + run vector api /api/v1/vector/sites + assert_success + is_valid_json +} + +@test "api sites --jq filters the full envelope" { + create_credentials "test-token" + run vector api sites --jq '.data' + assert_success +} + + +# --- method selection & request body --- + +@test "api POST with typed fields auto-selects POST and creates a site" { + create_credentials "test-token" + run vector api sites -f your_customer_id=cust_123 -f dev_php_version=8.3 + assert_success + is_valid_json +} + +@test "api POST with a raw body from stdin succeeds" { + create_credentials "test-token" + run bash -c 'echo "{\"your_customer_id\":\"cust_123\",\"dev_php_version\":\"8.3\"}" | '"$VECTOR_BINARY"' api sites -X POST --input -' + assert_success + is_valid_json +} + +@test "api with both --input and -f fails with exit code 3" { + create_credentials "test-token" + run vector api sites --input - -f name=a + assert_failure + assert_exit_code 3 +} + + +# --- custom request headers --- + +@test "api sends a custom request header" { + create_credentials "test-token" + run vector api sites -H 'X-Custom: hello' + assert_success + is_valid_json +} + +@test "api with a malformed header fails with exit code 3" { + create_credentials "test-token" + run vector api sites -H no-colon-here + assert_failure + assert_exit_code 3 +} + + +# --- response inspection --- + +@test "api --include prints the status line and headers" { + create_credentials "test-token" + run vector api sites -i + assert_success + assert_output_contains "HTTP/" + assert_output_contains "Content-Type:" + assert_output_contains "data" +} + +@test "api --verbose echoes the resolved request" { + create_credentials "test-token" + run vector api sites --verbose + assert_success + assert_output_contains "GET" + assert_output_contains "/api/v1/vector/sites" +} + + +# --- pagination --- + +@test "api --paginate returns a merged JSON array" { + create_credentials "test-token" + run vector api sites --paginate + assert_success + is_valid_json + # The merged result is a top-level array, filterable as such. + run vector api sites --paginate --jq '. | length' + assert_success +} + +@test "api with both --paginate and -i fails with exit code 3" { + create_credentials "test-token" + run vector api sites --paginate -i + assert_failure + assert_exit_code 3 +} + + +# --- auth required --- + +@test "api without auth fails with exit code 2" { + run vector api sites + assert_failure + assert_exit_code 2 +} diff --git a/internal/api/client.go b/internal/api/client.go index 2243cb5..80a2ee3 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -143,13 +143,43 @@ func (c *Client) jsonRequest(ctx context.Context, method, path string, body any) return c.do(req) } +// Do performs an arbitrary HTTP request through the standard auth/base-URL +// pipeline. The path is appended to the client's BaseURL. Caller-supplied +// headers override the default Authorization, Accept, and User-Agent headers. +// When a body is supplied and no Content-Type header is given, it defaults to +// "application/json". Non-2xx responses return an *APIError. +func (c *Client) Do(ctx context.Context, method, path string, headers http.Header, body io.Reader) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, body) + if err != nil { + return nil, fmt.Errorf("creating %s request: %w", method, err) + } + + for key, values := range headers { + for _, value := range values { + req.Header.Add(key, value) + } + } + + if body != nil && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + return c.do(req) +} + // do executes a request, adding standard headers and handling error responses. +// Default Authorization, Accept, and User-Agent headers are only applied when +// not already present, so callers (e.g. Do) can override them. func (c *Client) do(req *http.Request) (*http.Response, error) { - if c.Token != "" { + if c.Token != "" && req.Header.Get("Authorization") == "" { req.Header.Set("Authorization", "Bearer "+c.Token) } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", c.UserAgent) + if req.Header.Get("Accept") == "" { + req.Header.Set("Accept", "application/json") + } + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", c.UserAgent) + } resp, err := c.httpClient.Do(req) if err != nil { diff --git a/internal/api/client_test.go b/internal/api/client_test.go index bafe83e..0b1517e 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -316,6 +316,139 @@ func TestClient_ServerErrorResponse(t *testing.T) { assert.Equal(t, 5, apiErr.ExitCode) } +func TestClient_Do_MethodAndURL(t *testing.T) { + var gotMethod, gotPath, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":"ok"}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok", "") + resp, err := c.Do(context.Background(), http.MethodGet, "/api/v1/items?page=2", nil, nil) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, http.MethodGet, gotMethod) + assert.Equal(t, "/api/v1/items", gotPath) + assert.Equal(t, "page=2", gotQuery) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestClient_Do_DefaultHeaders(t *testing.T) { + var gotHeaders http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeaders = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token", "vector-cli/test") + resp, err := c.Do(context.Background(), http.MethodGet, "/test", nil, nil) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, "Bearer test-token", gotHeaders.Get("Authorization")) + assert.Equal(t, "application/json", gotHeaders.Get("Accept")) + assert.Equal(t, "vector-cli/test", gotHeaders.Get("User-Agent")) +} + +func TestClient_Do_CallerHeadersOverrideDefaults(t *testing.T) { + var gotHeaders http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeaders = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token", "vector-cli/test") + headers := http.Header{} + headers.Set("Accept", "text/plain") + resp, err := c.Do(context.Background(), http.MethodGet, "/test", headers, nil) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, "text/plain", gotHeaders.Get("Accept")) + // Unspecified defaults are still applied. + assert.Equal(t, "Bearer test-token", gotHeaders.Get("Authorization")) + assert.Equal(t, "vector-cli/test", gotHeaders.Get("User-Agent")) +} + +func TestClient_Do_BodyPassthroughDefaultContentType(t *testing.T) { + var gotContentType string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotContentType = r.Header.Get("Content-Type") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok", "") + resp, err := c.Do(context.Background(), http.MethodPost, "/test", nil, bytes.NewReader([]byte(`{"name":"x"}`))) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, "application/json", gotContentType) + assert.JSONEq(t, `{"name":"x"}`, string(gotBody)) +} + +func TestClient_Do_PreservesCallerContentType(t *testing.T) { + var gotContentType string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotContentType = r.Header.Get("Content-Type") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok", "") + headers := http.Header{} + headers.Set("Content-Type", "text/csv") + resp, err := c.Do(context.Background(), http.MethodPost, "/test", headers, bytes.NewReader([]byte("a,b,c"))) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, "text/csv", gotContentType) +} + +func TestClient_Do_NoContentTypeWithoutBody(t *testing.T) { + var gotContentType string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotContentType = r.Header.Get("Content-Type") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok", "") + resp, err := c.Do(context.Background(), http.MethodGet, "/test", nil, nil) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Empty(t, gotContentType) +} + +func TestClient_Do_ErrorResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"data":{},"message":"Not found.","http_status":404}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok", "") + _, err := c.Do(context.Background(), http.MethodGet, "/api/v1/missing", nil, nil) + require.Error(t, err) + + apiErr, ok := err.(*APIError) + require.True(t, ok, "error should be *APIError") + assert.Equal(t, 404, apiErr.HTTPStatus) + assert.Equal(t, 4, apiErr.ExitCode) + assert.Equal(t, "Not found.", apiErr.Message) +} + func TestClient_PutFileErrorResponse(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) diff --git a/internal/cli/root.go b/internal/cli/root.go index 222500c..b30df0e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -129,6 +129,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(commands.NewArchiveCmd()) cmd.AddCommand(commands.NewMcpCmd()) cmd.AddCommand(commands.NewSkillCmd()) + cmd.AddCommand(commands.NewAPICmd()) return cmd } diff --git a/internal/commands/api.go b/internal/commands/api.go new file mode 100644 index 0000000..ede23af --- /dev/null +++ b/internal/commands/api.go @@ -0,0 +1,576 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "sort" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/built-fast/vector-cli/internal/api" + "github.com/built-fast/vector-cli/internal/appctx" + "github.com/built-fast/vector-cli/internal/output" +) + +const apiBasePath = "/api/v1/vector/" + +// apiStdinReader is the source for fields and bodies read from stdin (the "@-" +// and "--input -" forms). It is a variable so tests can override it, mirroring +// confirmReader and stdinReader elsewhere in this package. +var apiStdinReader io.Reader = os.Stdin + +// methodsWithBody are the HTTP methods that carry collected fields as a JSON +// request body. Any other method appends them to the URL query string instead. +var methodsWithBody = map[string]bool{ + http.MethodPost: true, + http.MethodPut: true, + http.MethodPatch: true, + http.MethodDelete: true, +} + +// NewAPICmd creates the api passthrough command. +func NewAPICmd() *cobra.Command { + var ( + method string + rawFields []string + fields []string + input string + reqHeaders []string + include bool + verbose bool + paginate bool + ) + + cmd := &cobra.Command{ + Use: "api ", + Short: "Make an authenticated request to the Vector Pro API", + Long: "Send an authenticated HTTP request to any Vector Pro API endpoint and " + + "print the raw response.\n\n" + + "An beginning with \"/\" is sent verbatim against the base URL. " + + "Any other value has \"/api/v1/vector/\" prepended, so \"sites\" resolves to " + + "\"/api/v1/vector/sites\".\n\n" + + "The method defaults to GET, or POST when any field or --input is given. " + + "Fields supplied with -f/-F are encoded as a JSON body for " + + "POST/PUT/PATCH/DELETE, or as query parameters for GET/HEAD. Use the " + + "key[]=value suffix to build arrays; reusing a plain key is an error.\n\n" + + "Use -i/--include to print the response status line and headers before " + + "the body, and --verbose to echo the resolved request to stderr before " + + "sending it.", + Example: ` # GET a resource that has no dedicated subcommand + vector api php-versions + + # Equivalent to the line above, with an absolute path + vector api /api/v1/vector/php-versions + + # Filter the response with built-in jq + vector api sites --jq '.data[].id' + + # Create a resource with typed fields (auto-POST) + vector api sites -f customer_id=cust_123 -F dev_php_version=8.3 + + # Send a raw request body from a file or stdin + vector api sites --method POST --input body.json + echo '{"customer_id":"cust_123"}' | vector api sites -X POST --input - + + # Send a custom request header + vector api sites -H 'Accept: text/plain' + + # Inspect the response status line and headers + vector api sites -i + + # Echo the resolved request to stderr before sending + vector api sites --verbose`, + Args: cobra.ExactArgs(1), + RunE: apiRunE(&method, &rawFields, &fields, &input, &reqHeaders, &include, &verbose, &paginate), + } + + cmd.Flags().StringVarP(&method, "method", "X", http.MethodGet, + "HTTP method (GET, POST, PUT, PATCH, DELETE)") + cmd.Flags().StringArrayVarP(&rawFields, "raw-field", "f", nil, + "add a string parameter in key=value format (repeatable)") + cmd.Flags().StringArrayVarP(&fields, "field", "F", nil, + "add a typed parameter in key=value format; @file/@- load the value (repeatable)") + cmd.Flags().StringVar(&input, "input", "", + "send a raw request body read from a file, or from stdin when set to -") + cmd.Flags().StringArrayVarP(&reqHeaders, "header", "H", nil, + "add a request header in key:value format; overrides defaults (repeatable)") + cmd.Flags().BoolVarP(&include, "include", "i", false, + "print the response status line and headers before the body") + cmd.Flags().BoolVar(&verbose, "verbose", false, + "echo the resolved request (method, URL, body) to stderr before sending") + cmd.Flags().BoolVar(&paginate, "paginate", false, + "fetch every page of a paginated collection, merging results into one JSON array") + + return cmd +} + +// apiRunE returns the RunE for the api passthrough command. +func apiRunE(method *string, rawFields, fields *[]string, input *string, reqHeaders *[]string, include, verbose, paginate *bool) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + app, err := requireApp(cmd) + if err != nil { + return err + } + + customHeaders, err := parseHeaders(*reqHeaders) + if err != nil { + return err + } + + hasFields := len(*rawFields) > 0 || len(*fields) > 0 + hasInput := cmd.Flags().Changed("input") + + if hasInput && hasFields { + return &api.APIError{ + Message: "--input cannot be combined with -f/--raw-field or -F/--field", + ExitCode: 3, + } + } + + if *paginate && *include { + return &api.APIError{ + Message: "--paginate cannot be combined with -i/--include", + ExitCode: 3, + } + } + + // Default to POST when a body source is supplied without an explicit method. + resolvedMethod := strings.ToUpper(*method) + if !cmd.Flags().Changed("method") && (hasFields || hasInput) { + resolvedMethod = http.MethodPost + } + + path := resolveAPIPath(args[0]) + + var ( + bodyBytes []byte + headers http.Header + ) + + switch { + case hasInput: + bodyBytes, err = readInputBody(*input) + if err != nil { + return err + } + headers = jsonContentTypeHeader() + + case hasFields: + collected, err := collectFields(*rawFields, *fields) + if err != nil { + return err + } + + if methodsWithBody[resolvedMethod] { + bodyBytes, err = json.Marshal(collected) + if err != nil { + return fmt.Errorf("failed to encode request body: %w", err) + } + headers = jsonContentTypeHeader() + } else { + path = appendQueryParams(path, collected) + } + } + + // Custom -H headers override any default we set above (e.g. the JSON + // Content-Type) as well as the client's default Authorization/Accept. + headers = mergeHeaders(headers, customHeaders) + + if *verbose { + writeVerboseRequest(cmd.ErrOrStderr(), resolvedMethod, app.Client.BaseURL+path, bodyBytes) + } + + if *paginate { + return runAPIPaginate(cmd, app, resolvedMethod, path, headers, bodyBytes) + } + + var reqBody io.Reader + if bodyBytes != nil { + reqBody = bytes.NewReader(bodyBytes) + } + + resp, err := app.Client.Do(cmd.Context(), resolvedMethod, path, headers, reqBody) + if err != nil { + return fmt.Errorf("failed to make API request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to make API request: %w", err) + } + + if *include { + writeResponseHead(app.Output.Underlying(), resp) + } + + return writeAPIResponse(app.Output, body) + } +} + +// resolveAPIPath maps an endpoint argument to a request path. A value starting +// with "/" is returned verbatim; any other value is appended to the Vector Pro +// base path "/api/v1/vector/". +func resolveAPIPath(endpoint string) string { + if strings.HasPrefix(endpoint, "/") { + return endpoint + } + return apiBasePath + endpoint +} + +// jsonContentTypeHeader returns a header set declaring a JSON request body. +func jsonContentTypeHeader() http.Header { + h := http.Header{} + h.Set("Content-Type", "application/json") + return h +} + +// parseHeaders converts -H "key:value" specs into an http.Header. The value is +// trimmed of leading whitespace after the colon, matching gh api. A spec without +// a colon is a loud client-side error (exit code 3). A nil/empty input returns a +// nil header so no custom headers are sent. +func parseHeaders(specs []string) (http.Header, error) { + if len(specs) == 0 { + return nil, nil + } + + headers := http.Header{} + for _, spec := range specs { + key, value, ok := strings.Cut(spec, ":") + if !ok || key == "" { + return nil, &api.APIError{ + Message: fmt.Sprintf("invalid header %q: expected key:value", spec), + ExitCode: 3, + } + } + headers.Add(key, strings.TrimSpace(value)) + } + return headers, nil +} + +// mergeHeaders layers custom headers over base headers. A custom header replaces +// any same-named base header so an explicit -H wins over a default we set. Either +// argument may be nil. +func mergeHeaders(base, custom http.Header) http.Header { + if len(custom) == 0 { + return base + } + + merged := http.Header{} + for key, values := range base { + merged[key] = values + } + for key, values := range custom { + merged[http.CanonicalHeaderKey(key)] = values + } + return merged +} + +// collectFields merges -f (raw string) and -F (typed) fields into a single +// ordered map suitable for JSON or query encoding. Raw fields are always +// strings; typed fields coerce true/false/null and numeric literals to JSON +// types and load @file/@- values. A key without the "[]" suffix may appear only +// once across all fields; reusing it is a loud error (exit code 3). Keys with +// the "[]" suffix accumulate into an array. +func collectFields(rawFields, fields []string) (map[string]any, error) { + collected := map[string]any{} + // scalarKeys tracks plain (non-array) keys that have been set, so a reuse + // can be rejected. + scalarKeys := map[string]bool{} + arrayKeys := map[string]bool{} + + add := func(spec string, typed bool) error { + key, rawValue, ok := strings.Cut(spec, "=") + if !ok || key == "" { + return &api.APIError{ + Message: fmt.Sprintf("invalid field %q: expected key=value", spec), + ExitCode: 3, + } + } + + isArray := strings.HasSuffix(key, "[]") + name := strings.TrimSuffix(key, "[]") + + var value any = rawValue + if typed { + coerced, err := coerceFieldValue(rawValue) + if err != nil { + return err + } + value = coerced + } + + if isArray { + if scalarKeys[name] { + return reusedKeyError(name) + } + arr, _ := collected[name].([]any) + collected[name] = append(arr, value) + arrayKeys[name] = true + return nil + } + + if scalarKeys[name] || arrayKeys[name] { + return reusedKeyError(name) + } + collected[name] = value + scalarKeys[name] = true + return nil + } + + for _, spec := range rawFields { + if err := add(spec, false); err != nil { + return nil, err + } + } + for _, spec := range fields { + if err := add(spec, true); err != nil { + return nil, err + } + } + + return collected, nil +} + +// reusedKeyError reports a key used more than once without the "[]" suffix. +func reusedKeyError(name string) error { + return &api.APIError{ + Message: fmt.Sprintf("unexpected override existing field under %q", name), + ExitCode: 3, + } +} + +// coerceFieldValue maps a -F value to a JSON type. true/false/null become their +// JSON equivalents, integer and float literals become numbers, an @filename +// loads the value from a file, @- reads it from stdin, and everything else is +// kept as a string. +func coerceFieldValue(value string) (any, error) { + switch value { + case "true": + return true, nil + case "false": + return false, nil + case "null": + return nil, nil + } + + if strings.HasPrefix(value, "@") { + raw, err := readFileOrStdin(value[1:]) + if err != nil { + return nil, err + } + return string(raw), nil + } + + if i, err := strconv.ParseInt(value, 10, 64); err == nil { + return i, nil + } + if f, err := strconv.ParseFloat(value, 64); err == nil { + return f, nil + } + + return value, nil +} + +// appendQueryParams encodes collected fields onto a path's query string. Array +// values are emitted as repeated key entries; scalars are stringified. +func appendQueryParams(path string, collected map[string]any) string { + query := url.Values{} + for key, value := range collected { + if arr, ok := value.([]any); ok { + for _, item := range arr { + query.Add(key, fmt.Sprint(item)) + } + continue + } + query.Set(key, fmt.Sprint(value)) + } + + if len(query) == 0 { + return path + } + + sep := "?" + if strings.Contains(path, "?") { + sep = "&" + } + return path + sep + query.Encode() +} + +// readInputBody resolves the --input value into raw request body bytes. A value +// of "-" reads from stdin; anything else is treated as a file path. +func readInputBody(input string) ([]byte, error) { + return readFileOrStdin(input) +} + +// readFileOrStdin reads from stdin when source is "-", otherwise from the file +// at the given path. +func readFileOrStdin(source string) ([]byte, error) { + if source == "-" { + raw, err := io.ReadAll(apiStdinReader) + if err != nil { + return nil, fmt.Errorf("failed to read from stdin: %w", err) + } + return raw, nil + } + + raw, err := os.ReadFile(source) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + return raw, nil +} + +// writeVerboseRequest echoes the resolved request (method, URL, and body when +// present) to the given writer, which is always stderr so stdout stays pipeable. +func writeVerboseRequest(w io.Writer, method, url string, body []byte) { + fmt.Fprintf(w, "> %s %s\n", method, url) + if len(body) > 0 { + fmt.Fprintf(w, "> %s\n", body) + } +} + +// writeResponseHead prints the HTTP status line and response headers, sorted by +// name, followed by a blank line. It runs before the body when -i/--include is +// set. +func writeResponseHead(w io.Writer, resp *http.Response) { + fmt.Fprintf(w, "%s %s\n", resp.Proto, resp.Status) + + names := make([]string, 0, len(resp.Header)) + for name := range resp.Header { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + for _, value := range resp.Header[name] { + fmt.Fprintf(w, "%s: %s\n", name, value) + } + } + fmt.Fprintln(w) +} + +// writeAPIResponse prints the response body. When the body parses as JSON it is +// pretty-printed (and the --jq filter, if set, is applied); otherwise the raw +// bytes are written verbatim. +func writeAPIResponse(w *output.Writer, body []byte) error { + var parsed any + if json.Unmarshal(body, &parsed) == nil { + return w.JSON(parsed) + } + + _, err := w.Underlying().Write(body) + if err != nil { + return fmt.Errorf("failed to write response: %w", err) + } + return nil +} + +// maxAPIPages caps how many pages --paginate fetches, guarding against an +// unbounded loop when a server never reports a final page. Server rate limits +// are the backstop for legitimately larger result sets. +const maxAPIPages = 100 + +// apiPageEnvelope is the {data, meta} shape that --paginate follows. A response +// that does not match this shape is treated as a single, non-paginated result. +type apiPageEnvelope struct { + Data json.RawMessage `json:"data"` + Meta *struct { + CurrentPage int `json:"current_page"` + LastPage int `json:"last_page"` + } `json:"meta"` +} + +// runAPIPaginate follows pagination (meta.current_page / meta.last_page), +// incrementing the page query parameter until the last page is reached, and +// emits every page's data array merged into a single JSON array. A response +// without the {data, meta} shape yields a single request returned unchanged. +// Fetching stops after maxAPIPages with a warning to stderr. +func runAPIPaginate(cmd *cobra.Command, app *appctx.App, method, path string, headers http.Header, bodyBytes []byte) error { + merged := []json.RawMessage{} + + for page := 1; ; page++ { + pagePath, err := withPageParam(path, page) + if err != nil { + return err + } + + var reqBody io.Reader + if bodyBytes != nil { + reqBody = bytes.NewReader(bodyBytes) + } + + resp, err := app.Client.Do(cmd.Context(), method, pagePath, headers, reqBody) + if err != nil { + return fmt.Errorf("failed to make API request: %w", err) + } + + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + return fmt.Errorf("failed to make API request: %w", err) + } + + var envelope apiPageEnvelope + if err := json.Unmarshal(body, &envelope); err != nil || envelope.Meta == nil { + // Not a paginated {data, meta} collection: return the first response + // unchanged. A later page lacking the shape just stops the loop. + if page == 1 { + return writeAPIResponse(app.Output, body) + } + break + } + + if len(envelope.Data) > 0 { + items, err := pageItems(envelope.Data) + if err != nil { + return err + } + merged = append(merged, items...) + } + + if envelope.Meta.CurrentPage >= envelope.Meta.LastPage { + break + } + + if page >= maxAPIPages { + fmt.Fprintf(cmd.ErrOrStderr(), + "warning: --paginate stopped after %d pages before reaching the last page\n", maxAPIPages) + break + } + } + + return app.Output.JSON(merged) +} + +// withPageParam returns path with its "page" query parameter set to page, +// preserving any other query parameters already present. +func withPageParam(path string, page int) (string, error) { + u, err := url.Parse(path) + if err != nil { + return "", &api.APIError{ + Message: fmt.Sprintf("invalid path %q: %v", path, err), + ExitCode: 3, + } + } + q := u.Query() + q.Set("page", strconv.Itoa(page)) + u.RawQuery = q.Encode() + return u.String(), nil +} + +// pageItems decodes a page's data array into individual JSON elements. +func pageItems(data json.RawMessage) ([]json.RawMessage, error) { + var items []json.RawMessage + if err := json.Unmarshal(data, &items); err != nil { + return nil, fmt.Errorf("failed to parse paginated response: %w", err) + } + return items, nil +} diff --git a/internal/commands/api_test.go b/internal/commands/api_test.go new file mode 100644 index 0000000..bc27301 --- /dev/null +++ b/internal/commands/api_test.go @@ -0,0 +1,956 @@ +package commands + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/itchyny/gojq" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/built-fast/vector-cli/internal/api" + "github.com/built-fast/vector-cli/internal/appctx" + "github.com/built-fast/vector-cli/internal/config" + "github.com/built-fast/vector-cli/internal/output" +) + +// mustCompileJQ parses and compiles a jq expression for use in tests. +func mustCompileJQ(t *testing.T, expr string) *gojq.Code { + t.Helper() + query, err := gojq.Parse(expr) + require.NoError(t, err) + code, err := gojq.Compile(query) + require.NoError(t, err) + return code +} + +var apiSitesResponse = map[string]any{ + "data": []map[string]any{ + {"id": 1, "name": "example.com"}, + {"id": 2, "name": "example.org"}, + }, + "meta": map[string]any{"current_page": 1, "last_page": 1, "total": 2}, + "http_status": 200, +} + +func newAPITestServer(validToken string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+validToken { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{ + "message": "Unauthenticated.", + "http_status": 401, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == "GET" && r.URL.Path == "/api/v1/vector/sites": + _ = json.NewEncoder(w).Encode(apiSitesResponse) + case r.Method == "GET" && r.URL.Path == "/api/v1/vector/raw": + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("plain text body, not json")) + case r.Method == "GET" && r.URL.Path == "/api/v1/vector/missing": + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "message": "Site not found", + "http_status": 404, + }) + case r.Method == "GET" && r.URL.Path == "/api/v1/vector/boom": + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "message": "Internal Server Error", + "http_status": 500, + }) + default: + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "message": "Not Found", + "http_status": 404, + }) + } + })) +} + +func buildAPICmd(baseURL, token string, format output.Format, opts ...output.WriterOption) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + stdout := new(bytes.Buffer) + + root := &cobra.Command{ + Use: "vector", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + client := api.NewClient(baseURL, token, "test-agent") + app := appctx.NewApp( + config.DefaultConfig(), + client, + "", + ) + app.Output = output.NewWriter(stdout, format, opts...) + cmd.SetContext(appctx.WithApp(cmd.Context(), app)) + return nil + }, + SilenceUsage: true, + SilenceErrors: true, + } + + root.AddCommand(NewAPICmd()) + + stderr := new(bytes.Buffer) + root.SetOut(stdout) + root.SetErr(stderr) + + return root, stdout, stderr +} + +func buildAPICmdNoAuth(format output.Format) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + stdout := new(bytes.Buffer) + + root := &cobra.Command{ + Use: "vector", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + client := api.NewClient("http://localhost", "", "test-agent") + app := appctx.NewApp( + config.DefaultConfig(), + client, + "", + ) + app.Output = output.NewWriter(stdout, format) + cmd.SetContext(appctx.WithApp(cmd.Context(), app)) + return nil + }, + SilenceUsage: true, + SilenceErrors: true, + } + + root.AddCommand(NewAPICmd()) + + stderr := new(bytes.Buffer) + root.SetOut(stdout) + root.SetErr(stderr) + + return root, stdout, stderr +} + +// --- Path resolution --- + +func TestResolveAPIPath(t *testing.T) { + tests := []struct { + name string + endpoint string + want string + }{ + {"bare resource", "sites", "/api/v1/vector/sites"}, + {"bare nested resource", "sites/123/environments", "/api/v1/vector/sites/123/environments"}, + {"leading slash verbatim", "/api/v1/vector/sites", "/api/v1/vector/sites"}, + {"leading slash non-vector path", "/account", "/account"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, resolveAPIPath(tt.endpoint)) + }) + } +} + +func TestAPICmd_BarePathPrependsBase(t *testing.T) { + var receivedMethod, receivedPath string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMethod = r.Method + receivedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(apiSitesResponse) + })) + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "GET", receivedMethod) + assert.Equal(t, "/api/v1/vector/sites", receivedPath) +} + +func TestAPICmd_LeadingSlashPathSentVerbatim(t *testing.T) { + var receivedPath string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(apiSitesResponse) + })) + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "/account"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "/account", receivedPath) +} + +// --- Output --- + +func TestAPICmd_PrettyPrintsJSON(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + cmd, stdout, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites"}) + + require.NoError(t, cmd.Execute()) + + out := stdout.String() + // Pretty-printed JSON is indented and preserves the full envelope. + assert.Contains(t, out, "\n ") + assert.Contains(t, out, "\"data\"") + assert.Contains(t, out, "\"meta\"") + + var result map[string]any + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + assert.Contains(t, result, "data") + assert.Contains(t, result, "meta") +} + +func TestAPICmd_RawBodyWhenNotJSON(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + cmd, stdout, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "raw"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "plain text body, not json", stdout.String()) +} + +func TestAPICmd_JQFiltersFullEnvelope(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + code := mustCompileJQ(t, ".data[].id") + cmd, stdout, _ := buildAPICmd(ts.URL, "valid-token", output.JSON, output.WithJQ(".data[].id", code)) + cmd.SetArgs([]string{"api", "sites"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "1\n2\n", stdout.String()) +} + +// --- Errors --- + +func TestAPICmd_NotFoundExitCode(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "missing"}) + + err := cmd.Execute() + require.Error(t, err) + + var apiErr *api.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 4, apiErr.ExitCode) + assert.Contains(t, apiErr.Error(), "Site not found") +} + +func TestAPICmd_ServerErrorExitCode(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "boom"}) + + err := cmd.Execute() + require.Error(t, err) + + var apiErr *api.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 5, apiErr.ExitCode) +} + +func TestAPICmd_AuthError(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "bad-token", output.JSON) + cmd.SetArgs([]string{"api", "sites"}) + + err := cmd.Execute() + require.Error(t, err) + + var apiErr *api.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 2, apiErr.ExitCode) +} + +func TestAPICmd_NoAuthToken(t *testing.T) { + cmd, _, _ := buildAPICmdNoAuth(output.JSON) + cmd.SetArgs([]string{"api", "sites"}) + + err := cmd.Execute() + require.Error(t, err) + + var apiErr *api.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 2, apiErr.ExitCode) +} + +func TestAPICmd_RequiresEndpointArg(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api"}) + + require.Error(t, cmd.Execute()) +} + +func TestAPICmd_HelpText(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + cmd, stdout, _ := buildAPICmd(ts.URL, "valid-token", output.Table) + cmd.SetArgs([]string{"api", "--help"}) + + require.NoError(t, cmd.Execute()) + + out := stdout.String() + assert.Contains(t, out, "api ") + assert.Contains(t, out, "Vector Pro API") +} + +// --- Method selection & request body (US-003) --- + +// captured records what an echo test server received. +type captured struct { + method string + path string + rawQuery string + contentType string + body []byte +} + +// newAPIEchoServer returns a server that records the request and echoes a +// minimal JSON envelope, capturing the request into c. +func newAPIEchoServer(t *testing.T, validToken string, c *captured) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+validToken { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "Unauthenticated.", "http_status": 401}) + return + } + + c.method = r.Method + c.path = r.URL.Path + c.rawQuery = r.URL.RawQuery + c.contentType = r.Header.Get("Content-Type") + c.body, _ = io.ReadAll(r.Body) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"ok": true}, "http_status": 201}) + })) +} + +func TestAPICmd_MethodOverride(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites/1", "--method", "delete"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "DELETE", c.method) + assert.Equal(t, "/api/v1/vector/sites/1", c.path) +} + +func TestAPICmd_AutoPOSTWhenFieldsGiven(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-f", "customer_id=cust_1"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "POST", c.method) + assert.Equal(t, "application/json", c.contentType) +} + +func TestAPICmd_AutoPOSTWhenInputGiven(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + dir := t.TempDir() + bodyFile := filepath.Join(dir, "body.json") + require.NoError(t, os.WriteFile(bodyFile, []byte(`{"a":1}`), 0o600)) + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "--input", bodyFile}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "POST", c.method) +} + +func TestAPICmd_RawFieldIsString(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-X", "POST", "-f", "count=42", "-f", "flag=true"}) + + require.NoError(t, cmd.Execute()) + assert.JSONEq(t, `{"count":"42","flag":"true"}`, string(c.body)) +} + +func TestAPICmd_TypedFieldCoercion(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{ + "api", "sites", "-X", "POST", + "-F", "count=42", + "-F", "ratio=1.5", + "-F", "flag=true", + "-F", "off=false", + "-F", "empty=null", + "-F", "name=hello", + }) + + require.NoError(t, cmd.Execute()) + assert.JSONEq(t, `{"count":42,"ratio":1.5,"flag":true,"off":false,"empty":null,"name":"hello"}`, string(c.body)) +} + +func TestAPICmd_TypedFieldFromFile(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + dir := t.TempDir() + valueFile := filepath.Join(dir, "value.txt") + require.NoError(t, os.WriteFile(valueFile, []byte("from-file"), 0o600)) + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-X", "POST", "-F", "note=@" + valueFile}) + + require.NoError(t, cmd.Execute()) + assert.JSONEq(t, `{"note":"from-file"}`, string(c.body)) +} + +func TestAPICmd_TypedFieldFromStdin(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + orig := apiStdinReader + apiStdinReader = strings.NewReader("from-stdin") + t.Cleanup(func() { apiStdinReader = orig }) + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-X", "POST", "-F", "note=@-"}) + + require.NoError(t, cmd.Execute()) + assert.JSONEq(t, `{"note":"from-stdin"}`, string(c.body)) +} + +func TestAPICmd_FieldsAsQueryForGET(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-X", "GET", "-f", "status=active", "-F", "page=2"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "GET", c.method) + assert.Empty(t, c.body) + + values, err := url.ParseQuery(c.rawQuery) + require.NoError(t, err) + assert.Equal(t, "active", values.Get("status")) + assert.Equal(t, "2", values.Get("page")) +} + +func TestAPICmd_ReusedScalarKeyIsError(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-X", "POST", "-f", "name=a", "-F", "name=b"}) + + err := cmd.Execute() + require.Error(t, err) + + var apiErr *api.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 3, apiErr.ExitCode) + assert.Contains(t, apiErr.Error(), `under "name"`) + assert.Empty(t, c.method, "request should not have been sent") +} + +func TestAPICmd_ArrayKeyAppends(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-X", "POST", "-F", "tag[]=a", "-F", "tag[]=b"}) + + require.NoError(t, cmd.Execute()) + assert.JSONEq(t, `{"tag":["a","b"]}`, string(c.body)) +} + +func TestAPICmd_ArrayThenScalarSameKeyIsError(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-X", "POST", "-f", "tag[]=a", "-f", "tag=b"}) + + err := cmd.Execute() + require.Error(t, err) + + var apiErr *api.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 3, apiErr.ExitCode) +} + +func TestAPICmd_InputFromFile(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + dir := t.TempDir() + bodyFile := filepath.Join(dir, "body.json") + require.NoError(t, os.WriteFile(bodyFile, []byte(`{"customer_id":"cust_9"}`), 0o600)) + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-X", "POST", "--input", bodyFile}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "application/json", c.contentType) + assert.JSONEq(t, `{"customer_id":"cust_9"}`, string(c.body)) +} + +func TestAPICmd_InputFromStdin(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + orig := apiStdinReader + apiStdinReader = strings.NewReader(`{"customer_id":"cust_stdin"}`) + t.Cleanup(func() { apiStdinReader = orig }) + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-X", "POST", "--input", "-"}) + + require.NoError(t, cmd.Execute()) + assert.JSONEq(t, `{"customer_id":"cust_stdin"}`, string(c.body)) +} + +func TestAPICmd_InputMissingFileError(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + missing := filepath.Join(t.TempDir(), "nope.json") + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-X", "POST", "--input", missing}) + + err := cmd.Execute() + require.Error(t, err) + + // A missing input file is a general error (exit code 1), not an *api.APIError. + var apiErr *api.APIError + require.NotErrorAs(t, err, &apiErr, "missing file should be a general error, not an APIError") +} + +func TestAPICmd_InputAndFieldsMutuallyExclusive(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "--input", "-", "-f", "name=a"}) + + err := cmd.Execute() + require.Error(t, err) + + var apiErr *api.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 3, apiErr.ExitCode) +} + +func TestAPICmd_InvalidFieldFormat(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-X", "POST", "-f", "noequals"}) + + err := cmd.Execute() + require.Error(t, err) + + var apiErr *api.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 3, apiErr.ExitCode) +} + +// --- Custom request headers (US-004) --- + +// newAPIHeaderEchoServer returns a server that captures the request's Accept and +// X-Custom headers into the provided pointers and echoes a minimal JSON envelope. +func newAPIHeaderEchoServer(t *testing.T, validToken string, gotAccept, gotCustom *string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+validToken { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "Unauthenticated.", "http_status": 401}) + return + } + + *gotAccept = r.Header.Get("Accept") + *gotCustom = r.Header.Get("X-Custom") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"ok": true}, "http_status": 200}) + })) +} + +func TestAPICmd_CustomHeaderIsSent(t *testing.T) { + var accept, custom string + ts := newAPIHeaderEchoServer(t, "valid-token", &accept, &custom) + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-H", "X-Custom: hello"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "hello", custom) +} + +func TestAPICmd_CustomHeaderOverridesDefault(t *testing.T) { + var accept, custom string + ts := newAPIHeaderEchoServer(t, "valid-token", &accept, &custom) + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-H", "Accept: text/plain"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "text/plain", accept) +} + +func TestAPICmd_CustomHeaderOverridesJSONContentType(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-X", "POST", "-f", "name=a", "-H", "Content-Type: application/yaml"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "application/yaml", c.contentType) +} + +func TestAPICmd_MalformedHeaderIsError(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-H", "no-colon-here"}) + + err := cmd.Execute() + require.Error(t, err) + + var apiErr *api.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 3, apiErr.ExitCode) +} + +func TestParseHeaders(t *testing.T) { + got, err := parseHeaders([]string{"X-One: a", "X-Two:b", "Accept: application/json"}) + require.NoError(t, err) + assert.Equal(t, "a", got.Get("X-One")) + assert.Equal(t, "b", got.Get("X-Two")) + // Leading whitespace after the colon is trimmed. + assert.Equal(t, "application/json", got.Get("Accept")) +} + +func TestParseHeaders_Empty(t *testing.T) { + got, err := parseHeaders(nil) + require.NoError(t, err) + assert.Nil(t, got) +} + +func TestParseHeaders_Malformed(t *testing.T) { + _, err := parseHeaders([]string{"missing-colon"}) + require.Error(t, err) + + var apiErr *api.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 3, apiErr.ExitCode) +} + +// --- Response inspection: -i/--include and --verbose (US-005) --- + +func TestAPICmd_IncludePrintsStatusAndHeaders(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Request-Id", "req-123") + _ = json.NewEncoder(w).Encode(apiSitesResponse) + })) + defer ts.Close() + + cmd, stdout, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-i"}) + + require.NoError(t, cmd.Execute()) + + out := stdout.String() + // Status line and headers precede the body on stdout. + assert.Contains(t, out, "HTTP/1.1 200 OK") + assert.Contains(t, out, "X-Request-Id: req-123") + assert.Contains(t, out, "Content-Type: application/json") + assert.Contains(t, out, `"data"`) + + // Headers come before the JSON body. + assert.Less(t, strings.Index(out, "X-Request-Id"), strings.Index(out, `"data"`)) +} + +func TestAPICmd_VerboseWritesRequestToStderr(t *testing.T) { + var c captured + ts := newAPIEchoServer(t, "valid-token", &c) + defer ts.Close() + + cmd, stdout, stderr := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "-X", "POST", "-f", "name=a", "--verbose"}) + + require.NoError(t, cmd.Execute()) + + errOut := stderr.String() + assert.Contains(t, errOut, "POST "+ts.URL+"/api/v1/vector/sites") + assert.Contains(t, errOut, `{"name":"a"}`) + + // --verbose must not leak the request echo into stdout. + out := stdout.String() + assert.NotContains(t, out, "POST "+ts.URL) + // stdout still carries the response body. + assert.Contains(t, out, `"ok"`) +} + +func TestAPICmd_VerboseGETHasNoBodyLine(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + cmd, _, stderr := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "--verbose"}) + + require.NoError(t, cmd.Execute()) + + errOut := stderr.String() + assert.Equal(t, "> GET "+ts.URL+"/api/v1/vector/sites\n", errOut) +} + +// --- collectFields (unit) --- + +func TestCollectFields(t *testing.T) { + got, err := collectFields( + []string{"name=alice"}, + []string{"age=30", "active=true", "tag[]=x", "tag[]=y"}, + ) + require.NoError(t, err) + assert.Equal(t, map[string]any{ + "name": "alice", + "age": int64(30), + "active": true, + "tag": []any{"x", "y"}, + }, got) +} + +func TestCollectFields_ReusedScalarKey(t *testing.T) { + _, err := collectFields(nil, []string{"k=1", "k=2"}) + require.Error(t, err) + + var apiErr *api.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 3, apiErr.ExitCode) +} + +// --- Auto-pagination: --paginate (US-006) --- + +// newAPIPaginateServer returns a server that serves totalPages pages of a +// paginated {data, meta} collection. Each page's data holds a single object +// identifying its page number. It records the highest page requested in +// maxPageSeen so the cap test can assert how many requests were made. +func newAPIPaginateServer(t *testing.T, validToken string, totalPages int, maxPageSeen *int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+validToken { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "Unauthenticated.", "http_status": 401}) + return + } + + page := 1 + if p := r.URL.Query().Get("page"); p != "" { + if n, err := strconv.Atoi(p); err == nil { + page = n + } + } + if maxPageSeen != nil && page > *maxPageSeen { + *maxPageSeen = page + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{{"id": "item", "page": page}}, + "meta": map[string]any{ + "current_page": page, + "last_page": totalPages, + "total": totalPages, + }, + }) + })) +} + +func TestAPICmd_PaginateMergesPages(t *testing.T) { + ts := newAPIPaginateServer(t, "valid-token", 3, nil) + defer ts.Close() + + cmd, stdout, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "--paginate"}) + + require.NoError(t, cmd.Execute()) + + var merged []map[string]any + require.NoError(t, json.Unmarshal(stdout.Bytes(), &merged)) + require.Len(t, merged, 3) + assert.EqualValues(t, 1, merged[0]["page"]) + assert.EqualValues(t, 2, merged[1]["page"]) + assert.EqualValues(t, 3, merged[2]["page"]) +} + +func TestAPICmd_PaginateSinglePage(t *testing.T) { + ts := newAPIPaginateServer(t, "valid-token", 1, nil) + defer ts.Close() + + cmd, stdout, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "--paginate"}) + + require.NoError(t, cmd.Execute()) + + var merged []map[string]any + require.NoError(t, json.Unmarshal(stdout.Bytes(), &merged)) + require.Len(t, merged, 1) +} + +func TestAPICmd_PaginatePreservesExistingQuery(t *testing.T) { + var lastReq *http.Request + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lastReq = r + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{{"id": "item"}}, + "meta": map[string]any{"current_page": 1, "last_page": 1}, + }) + })) + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + // A query string embedded in the endpoint must be preserved alongside the + // injected page parameter. + cmd.SetArgs([]string{"api", "/api/v1/vector/sites?status=active", "--paginate"}) + + require.NoError(t, cmd.Execute()) + require.NotNil(t, lastReq) + assert.Equal(t, "active", lastReq.URL.Query().Get("status")) + assert.Equal(t, "1", lastReq.URL.Query().Get("page")) +} + +func TestAPICmd_PaginateStopsAtCap(t *testing.T) { + maxPageSeen := 0 + // last_page is far beyond the cap, so the loop must stop at maxAPIPages. + ts := newAPIPaginateServer(t, "valid-token", 10000, &maxPageSeen) + defer ts.Close() + + cmd, stdout, stderr := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "--paginate"}) + + require.NoError(t, cmd.Execute()) + + assert.Equal(t, maxAPIPages, maxPageSeen, "should fetch exactly the page cap") + + var merged []map[string]any + require.NoError(t, json.Unmarshal(stdout.Bytes(), &merged)) + assert.Len(t, merged, maxAPIPages) + + assert.Contains(t, stderr.String(), "warning") + assert.Contains(t, stderr.String(), strconv.Itoa(maxAPIPages)) +} + +func TestAPICmd_PaginateSingleRequestFallback(t *testing.T) { + requests := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + // No {data, meta} shape: a bare object. + _ = json.NewEncoder(w).Encode(map[string]any{"id": "single", "name": "no-envelope"}) + })) + defer ts.Close() + + cmd, stdout, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "--paginate"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, 1, requests, "non-paginated response should issue a single request") + + var result map[string]any + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + assert.Equal(t, "single", result["id"]) +} + +func TestAPICmd_PaginateAppliesJQAfterMerge(t *testing.T) { + ts := newAPIPaginateServer(t, "valid-token", 2, nil) + defer ts.Close() + + code := mustCompileJQ(t, ".[].page") + cmd, stdout, _ := buildAPICmd(ts.URL, "valid-token", output.JSON, output.WithJQ(".[].page", code)) + cmd.SetArgs([]string{"api", "sites", "--paginate"}) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, "1\n2\n", stdout.String()) +} + +func TestAPICmd_PaginateAndIncludeMutuallyExclusive(t *testing.T) { + ts := newAPITestServer("valid-token") + defer ts.Close() + + cmd, _, _ := buildAPICmd(ts.URL, "valid-token", output.JSON) + cmd.SetArgs([]string{"api", "sites", "--paginate", "-i"}) + + err := cmd.Execute() + require.Error(t, err) + + var apiErr *api.APIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, 3, apiErr.ExitCode) +} diff --git a/man/man1/vector.1 b/man/man1/vector.1 index 16b144e..ba069f0 100644 --- a/man/man1/vector.1 +++ b/man/man1/vector.1 @@ -427,6 +427,69 @@ Update a webhook. .TP .B webhook delete \fIWEBHOOK_ID\fR Delete a webhook. +.SS api \- Make an authenticated API request +.TP +.B api \fIENDPOINT\fR \fR[\fIOPTIONS\fR] +Send an authenticated request to any Vector Pro API endpoint and print the +raw response. An +.I ENDPOINT +beginning with +.B / +is sent verbatim against the base URL; any other value has +.B /api/v1/vector/ +prepended, so +.B sites +resolves to +.BR /api/v1/vector/sites . +The method defaults to GET, or POST when any field or +.B \-\-input +is given. +JSON responses are pretty\-printed (and honor +.BR \-\-jq ); +other bodies are written verbatim. +.TP +.B \-X, \-\-method +HTTP method (GET, POST, PUT, PATCH, DELETE). +.TP +.B \-f, \-\-raw\-field +Add a string parameter in \fIkey=value\fR format (repeatable). +.TP +.B \-F, \-\-field +Add a typed parameter in \fIkey=value\fR format (repeatable): +.BR true , +.BR false , +and +.B null +plus numeric literals are coerced to JSON types; +.B @file +loads the value from a file and +.B @- +reads it from stdin. Reusing a plain key is an error; use the +.B key[]=value +suffix to build an array. For body\-bearing methods, collected fields are sent +as a JSON body; for GET they are appended to the query string. +.TP +.B \-\-input +Send a raw request body read from a file, or from stdin when set to +.BR - . +Mutually exclusive with +.B \-f +and +.BR \-F . +.TP +.B \-H, \-\-header +Add a request header in \fIkey:value\fR format (repeatable). A custom header +overrides the matching default header. +.TP +.B \-i, \-\-include +Print the response status line and headers before the body. +.TP +.B \-\-verbose +Echo the resolved request (method, URL, and body) to stderr before sending it; +stdout is unchanged. +.TP +.B \-\-paginate +Fetch every page of a paginated collection, merging results into one JSON array. .SS php\-versions \- List available PHP versions .TP .B php\-versions diff --git a/skills/vector/SKILL.md b/skills/vector/SKILL.md index 92f9fbc..d15c347 100644 --- a/skills/vector/SKILL.md +++ b/skills/vector/SKILL.md @@ -648,6 +648,71 @@ vector account secret delete ### Utilities +#### vector api + +``` +vector api [--method ] [--raw-field ] [--field ] [--input ] [--json] [--jq ] +``` + +Sends an authenticated request to any Vector Pro API endpoint and prints the +raw response. Use it to reach endpoints that have no dedicated subcommand. An +`` beginning with `/` is sent verbatim against the base URL; any other +value has `/api/v1/vector/` prepended, so `sites` resolves to +`/api/v1/vector/sites`. JSON responses are pretty-printed and honor `--jq` +(which operates on the full envelope, including `data`/`meta`); non-JSON bodies +are written verbatim. + +Flags: + +- `--method`, `-X` — HTTP method. Defaults to GET, or POST when any field or + `--input` is given. +- `--raw-field`, `-f` — add a **string** parameter in `key=value` form + (repeatable). +- `--field`, `-F` — add a **typed** parameter in `key=value` form (repeatable): + `true`/`false`/`null` and numeric literals become JSON types; `@file` loads + the value from a file and `@-` reads it from stdin. Reusing a plain key is an + error (exit code 3); use the `key[]=value` suffix to build an array. For + POST/PUT/PATCH/DELETE, fields are sent as a JSON body; for GET they become + query parameters. +- `--input` — send a raw request body from a file, or from stdin when set to + `-`. Mutually exclusive with `-f`/`-F`. +- `--include`, `-i` — print the response status line and headers before the + body. +- `--verbose` — echo the resolved request (method, URL, body) to stderr before + sending; stdout is unchanged. +- `--paginate` — follow `meta.current_page`/`meta.last_page`, merging every + page's `data` array into a single JSON array (so `--jq` applies after the + merge). Stops after a 100-page cap with a warning to stderr; a response + without the `{data, meta}` shape is a single request returned unchanged. + Mutually exclusive with `-i/--include`. + +```bash +# GET an endpoint with no dedicated subcommand +vector api php-versions + +# Equivalent absolute path +vector api /api/v1/vector/php-versions + +# Filter the full envelope with built-in jq +vector api sites --jq '.data[].id' + +# Create a resource with typed fields (auto-POST) +vector api sites -f your_customer_id=cust_123 -F dev_php_version=8.3 + +# Send a raw JSON body from a file or stdin +vector api sites --method POST --input body.json +echo '{"your_customer_id":"cust_123"}' | vector api sites -X POST --input - + +# Inspect the response status line and headers +vector api sites -i + +# Echo the resolved request to stderr before sending +vector api sites --verbose + +# Fetch every page of a list, merged into one array +vector api sites --paginate --jq '.[].id' +``` + #### vector php-versions ```