Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ CIO_TOKEN=sa_live_xxx cio api /v1/environments/{environment_id}/campaigns --para

Use `cio api <path>` 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"}'
Expand Down
5 changes: 3 additions & 2 deletions cmd/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
39 changes: 39 additions & 0 deletions cmd/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion cmd/prime_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ cio skills read design-studio/nodes.md # node creation, component markup

| Flag | Description |
|------|-------------|
| `--params <json>` | Path + query parameters as JSON object |
| `--params <json>` | 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 <payload>` | JSON request body (`@filename` / `-` for stdin). With `--arg`/`--argjson` present, it is evaluated as a jq program that builds the body. |
| `--jq <expr>` | Filter output with a jq expression (bundled gojq) |
| `-r, --raw-output` | With `--jq`, print string results unquoted, like `jq -r` (no external jq) |
Expand Down
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
107 changes: 101 additions & 6 deletions internal/validate/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,135 @@ 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"}
}

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

Expand Down
34 changes: 34 additions & 0 deletions internal/validate/params_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"},
Expand Down