diff --git a/README.md b/README.md index 7753840..404eaeb 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ CIO_TOKEN=sa_live_xxx cio api /v1/environments/{environment_id}/campaigns --para Use `cio api ` for any API endpoint. Path placeholders are resolved from `--params`. The HTTP method defaults to GET (or POST if `--json` is provided); override with `-X`: +`--params` takes a JSON object; URL-query syntax (`--params 'type=event&size=200'`) is also accepted. In the query form a bare `+` is rejected as ambiguous — write `%20` for a space and `%2B` for a plus (`email=a%2Btag@b.com`), or use the JSON form, where both are literal. + ```bash # List campaigns in workspace 123 cio api /v1/environments/{environment_id}/campaigns --params '{"environment_id": "123"}' diff --git a/cmd/api.go b/cmd/api.go index f1a8e7d..4dbb639 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -176,8 +176,9 @@ func extractPathParamNames(pathTemplate string) map[string]bool { // parseAPIParams separates path template params from query params. // Path params are those matching {placeholder} in the path template. -// Input is validated through validate.ValidateParams (keys must match -// [a-zA-Z0-9_]+, values must not contain control characters, see +// Input is validated through validate.ValidateParams (JSON object or +// query-string sugar; keys must match [a-zA-Z0-9_]+ with an optional [] +// suffix, values must not contain control characters, see // MaxParamValueLength). Path params are validated as safe URL path segments; // the API remains the source of truth for endpoint-specific ID semantics. func parseAPIParams(pathTemplate, paramsJSON string) (pathParams, queryParams map[string]string, err error) { diff --git a/cmd/api_test.go b/cmd/api_test.go index caa2efd..f86f601 100644 --- a/cmd/api_test.go +++ b/cmd/api_test.go @@ -481,6 +481,45 @@ func TestAPI_QueryParams(t *testing.T) { } } +func TestAPI_QueryStringParams(t *testing.T) { + server, cleanup := setupAPITest(t) + defer cleanup() + + stdout, _, err := executeCommand("api", "/v1/environments/{environment_id}/campaigns", + "--api-url", server.URL, + "--params", `environment_id=456&name=credit%20builder%20-%20purchase%20made`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if path := result["path"].(string); path != "/v1/environments/456/campaigns" { + t.Errorf("want path param substituted, got %q", path) + } + query := result["query"].(map[string]any) + if name := query["name"].([]any)[0].(string); name != "credit builder - purchase made" { + t.Errorf("want decoded name, got %q", name) + } +} + +func TestAPI_QueryStringParamsPathParamStillValidated(t *testing.T) { + server, cleanup := setupAPITest(t) + defer cleanup() + + _, _, err := executeCommand("api", "/v1/environments/{environment_id}/test_users/{test_user_id}", + "--api-url", server.URL, + "--params", `environment_id=217838&test_user_id=eea50d000102%2F..%2Fdeliveries`) + if err == nil { + t.Fatal("expected error for reserved path character") + } + if !strings.Contains(err.Error(), "reserved path character") { + t.Errorf("expected reserved path character error, got: %v", err) + } +} + func TestAPI_PostWithBody(t *testing.T) { server, cleanup := setupAPITest(t) defer cleanup() diff --git a/cmd/prime_context.md b/cmd/prime_context.md index 23002f9..34ddf27 100644 --- a/cmd/prime_context.md +++ b/cmd/prime_context.md @@ -95,7 +95,7 @@ cio skills read design-studio/nodes.md # node creation, component markup | Flag | Description | |------|-------------| -| `--params ` | Path + query parameters as JSON object | +| `--params ` | Path + query parameters as a JSON object (query-string form `k=v&k2=v2` also accepted; there encode a space as `%20`, a plus as `%2B`, a literal `&` as `%26`) | | `--json ` | JSON request body (`@filename` / `-` for stdin). With `--arg`/`--argjson` present, it is evaluated as a jq program that builds the body. | | `--jq ` | Filter output with a jq expression (bundled gojq) | | `-r, --raw-output` | With `--jq`, print string results unquoted, like `jq -r` (no external jq) | diff --git a/cmd/root.go b/cmd/root.go index 637fd39..a8993e5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -66,7 +66,7 @@ func init() { flags := rootCmd.PersistentFlags() flags.String("json", "", "Raw JSON request body, @filename to read from a file, or - to read from stdin") - flags.String("params", "", "Query parameters as JSON, converted to query string for GET") + flags.String("params", "", `Query parameters as a JSON object, e.g. '{"email":"a@b.com"}' (query-string form 'email=a@b.com' also accepted; encode spaces as %20 and a plus as %2B)`) flags.String("jq", "", "jq expression filter (via gojq)") flags.BoolP("raw-output", "r", false, "Print string results unquoted, like jq -r (no external jq needed)") flags.StringArray("arg", nil, "Bind a string variable for --json's jq program: --arg name=value, or name=@file to read the value from a file (repeatable). Makes --json a jq -n program — no external jq needed") diff --git a/internal/validate/params.go b/internal/validate/params.go index bde5e39..e95d77d 100644 --- a/internal/validate/params.go +++ b/internal/validate/params.go @@ -3,18 +3,30 @@ package validate import ( "encoding/json" "fmt" + "net/url" "regexp" "strings" ) -var validParamKeyRe = regexp.MustCompile(`^[a-zA-Z0-9_]+$`) +// A trailing [] is the encoding fly binds for array query params +// (searchTagIds[]=1); the unbracketed name is silently ignored. +// A comma, semicolon, or space where '&' belongs: 'a=1,b=2' would otherwise +// parse as one param whose value carries the rest. +var misseparatedPairsRe = regexp.MustCompile(`[,;\s]\s*[a-zA-Z0-9_]+(\[\])?=`) + +var validParamKeyRe = regexp.MustCompile(`^[a-zA-Z0-9_]+(\[\])?$`) + +// paramsUsageHint is appended to parse failures so callers see the expected +// shape, not just the parser's complaint. +const paramsUsageHint = `--params expects a JSON object, e.g. --params '{"email":"a@b.com"}' (query-string form 'email=a@b.com' is also accepted)` // MaxParamValueLength caps the length of a single query param value to // bound request size and reject obviously abusive inputs. API query // params are typically short (IDs, filters, search terms). const MaxParamValueLength = 1024 -// ValidateParams validates a raw JSON string as query parameters. +// ValidateParams validates raw query parameters supplied as a JSON object, or +// as URL-query syntax (k=v&k2=v2) which is accepted as sugar for it. func ValidateParams(raw string) (map[string]string, error) { if strings.TrimSpace(raw) == "" { return nil, &ParamsValidationError{Reason: "params must not be empty"} @@ -22,21 +34,104 @@ func ValidateParams(raw string) (map[string]string, error) { var parsed any if err := json.Unmarshal([]byte(raw), &parsed); err != nil { - return nil, &ParamsValidationError{ - Reason: fmt.Sprintf("params is not valid JSON: %s", err.Error()), + if !looksLikeQueryString(raw) { + hint := paramsUsageHint + // A segment without '=' is usually an unencoded separator, not JSON. + if strings.Contains(raw, "=") && strings.Contains(raw, "&") { + hint = "in the query-string form, encode a literal & as %26. " + hint + } + return nil, &ParamsValidationError{ + Reason: fmt.Sprintf("params is not valid JSON: %s. %s", err.Error(), hint), + } + } + obj, qsErr := parseQueryString(raw) + if qsErr != nil { + return nil, qsErr } + return validateParamMap(obj) } obj, ok := parsed.(map[string]any) if !ok { - return nil, &ParamsValidationError{Reason: "params must be a JSON object"} + return nil, &ParamsValidationError{ + Reason: fmt.Sprintf("params must be a JSON object. %s", paramsUsageHint), + } + } + + return validateParamMap(obj) +} + +// looksLikeQueryString reports whether the input is unambiguously URL-query +// syntax, so that malformed JSON still reports as malformed JSON. +func looksLikeQueryString(raw string) bool { + raw = strings.TrimSpace(raw) + if strings.HasPrefix(raw, "{") || strings.HasPrefix(raw, "[") { + return false + } + seen := false + for _, pair := range strings.Split(raw, "&") { + if pair == "" { + continue + } + key, _, found := strings.Cut(pair, "=") + if !found || key == "" { + return false + } + seen = true + } + return seen +} + +// parseQueryString accepts URL-query syntax (k=v&k2=v2) as sugar for the JSON +// object form. +func parseQueryString(raw string) (map[string]any, error) { + pairs := strings.Split(strings.TrimSpace(raw), "&") + obj := make(map[string]any, len(pairs)) + for _, pair := range pairs { + if pair == "" { + continue + } + key, value, _ := strings.Cut(pair, "=") + if misseparatedPairsRe.MatchString(value) { + return nil, &ParamsValidationError{ + Reason: fmt.Sprintf("params query string value for %q looks like several pairs run together; separate parameters with &, or write %%2C for a literal comma, %%3B for a semicolon, %%20 for a space", key), + } + } + // '+' is ambiguous here: query semantics say space, but agents reach for + // it meaning a literal plus (a+tag@b.com). Refuse rather than guess. + if strings.Contains(value, "+") { + return nil, &ParamsValidationError{ + Reason: fmt.Sprintf("params query string value for %q contains a bare '+', which is ambiguous: write %%2B for a literal plus or %%20 for a space, or pass --params as JSON", key), + } + } + decodedKey, err := url.QueryUnescape(key) + if err != nil { + return nil, &ParamsValidationError{ + Reason: fmt.Sprintf("params query string has an invalid percent-escape in key %q. %s", key, paramsUsageHint), + } + } + decodedValue, err := url.QueryUnescape(value) + if err != nil { + return nil, &ParamsValidationError{ + Reason: fmt.Sprintf("params query string has an invalid percent-escape in the value for %q. %s", decodedKey, paramsUsageHint), + } + } + if _, dup := obj[decodedKey]; dup { + return nil, &ParamsValidationError{ + Reason: fmt.Sprintf("param key %q appears more than once; --params carries a single value per key, so multi-value filters cannot be sent", decodedKey), + } + } + obj[decodedKey] = decodedValue } + return obj, nil +} +func validateParamMap(obj map[string]any) (map[string]string, error) { result := make(map[string]string, len(obj)) for key, val := range obj { if !validParamKeyRe.MatchString(key) { return nil, &ParamsValidationError{ - Reason: fmt.Sprintf("param key %q contains invalid characters (must be alphanumeric and underscores only)", key), + Reason: fmt.Sprintf("param key %q contains invalid characters (alphanumerics and underscores, with an optional [] suffix for array params); run 'cio schema' for the parameters an endpoint accepts", key), } } diff --git a/internal/validate/params_test.go b/internal/validate/params_test.go index 8b28d45..231f867 100644 --- a/internal/validate/params_test.go +++ b/internal/validate/params_test.go @@ -20,6 +20,22 @@ func TestValidateParams_Valid(t *testing.T) { {"value with unicode", `{"name":"café"}`, map[string]string{"name": "café"}}, {"value with reserved url chars", `{"q":"a&b=c#d?e"}`, map[string]string{"q": "a&b=c#d?e"}}, {"value with emoji", `{"note":"hi 👋"}`, map[string]string{"note": "hi 👋"}}, + {"query string single pair", `email=a@b.com`, map[string]string{"email": "a@b.com"}}, + {"query string multiple pairs", `type=event&size=200`, map[string]string{"type": "event", "size": "200"}}, + {"query string encoded space", `event_type=credit%20builder%20-%20purchase%20made`, map[string]string{"event_type": "credit builder - purchase made"}}, + {"query string percent escapes", `q=a%20b%26c`, map[string]string{"q": "a b&c"}}, + {"query string empty value", `q=`, map[string]string{"q": ""}}, + {"query string value with equals", `filter=a=b`, map[string]string{"filter": "a=b"}}, + {"comma inside a plain value is fine", `name=Smith%2C%20Jane`, map[string]string{"name": "Smith, Jane"}}, + {"comma separated list value is fine", `ids=1%2C2%2C3`, map[string]string{"ids": "1,2,3"}}, + {"escaped semicolon in a value is fine", `q=a%3Bb`, map[string]string{"q": "a;b"}}, + {"escaped pair-looking value is fine", `q=rock%2C%20paper%3Dscissors`, map[string]string{"q": "rock, paper=scissors"}}, + {"bare word after a space is fine", `q=hello%20world`, map[string]string{"q": "hello world"}}, + {"query string trailing ampersand", `type=event&`, map[string]string{"type": "event"}}, + {"query string literal plus via %2B", `email=a%2Btag@b.com`, map[string]string{"email": "a+tag@b.com"}}, + {"json value keeps literal plus", `{"email":"a+tag@b.com"}`, map[string]string{"email": "a+tag@b.com"}}, + {"bracketed key in json", `{"searchTagIds[]":"99"}`, map[string]string{"searchTagIds[]": "99"}}, + {"bracketed key in query string", `searchTagIds[]=99`, map[string]string{"searchTagIds[]": "99"}}, {"value at max length", `{"q":"` + strings.Repeat("a", MaxParamValueLength) + `"}`, map[string]string{"q": strings.Repeat("a", MaxParamValueLength)}}, } for _, tc := range cases { @@ -49,6 +65,24 @@ func TestValidateParams_Invalid(t *testing.T) { {"empty", ``, "must not be empty"}, {"whitespace only", ` `, "must not be empty"}, {"not json", `not json`, "not valid JSON"}, + {"not json shows usage hint", `not json`, `--params expects a JSON object`}, + {"malformed json is not treated as query string", `{"a"="b"}`, "not valid JSON"}, + {"query string duplicate key", `id=1&id=2`, "appears more than once"}, + {"query string bad percent escape", `q=%zz`, "invalid percent-escape"}, + {"query string bad key", `bad-key=1`, "invalid characters"}, + {"unencoded ampersand hints at %26", `q=tom&jerry`, "encode a literal & as %26"}, + {"query string bare plus rejected", `email=a+tag@b.com`, "ambiguous"}, + {"comma separated pairs rejected", `environment_id=198048,email=a@b.com`, "separate parameters with &"}, + {"comma separated pairs with space", `type=event, size=200`, "separate parameters with &"}, + {"semicolon separated pairs rejected", `type=seg_attr;size=200`, "separate parameters with &"}, + {"space separated pairs rejected", `environment_id=198048 tag_id=1`, "separate parameters with &"}, + {"misseparated error names the escapes", `type=event;size=200`, "%2C for a literal comma, %3B for a semicolon, %20 for a space"}, + {"query string bare plus as space rejected", `q=hello+world`, "%2B for a literal plus or %20 for a space"}, + {"query string control character", "q=a%00b", "control character"}, + {"json array shows usage hint", `["a","b"]`, `--params expects a JSON object`}, + {"indexed bracket key", `{"searchTagIds[0]":"99"}`, "invalid characters"}, + {"nested bracket key", `{"a[]b":"x"}`, "invalid characters"}, + {"invalid key points at cio schema", `{"bad-key":"x"}`, "cio schema"}, {"not object", `["a","b"]`, "must be a JSON object"}, {"bad key with dash", `{"bad-key":"x"}`, "invalid characters"}, {"bad key with space", `{"bad key":"x"}`, "invalid characters"},