From b6d3cbb63abdf84c6d0d2face7c59241417ad87f Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Wed, 16 Sep 2026 22:36:51 +0530 Subject: [PATCH 1/6] fix: encode --query filters with real JSON-to-query semantics The --query encoder rendered every value through fmt.Sprintf("%v", val) and url.Values.Set, so an array became the literal "[a b]", a repeated value overwrote instead of accumulating, and large numbers could turn into scientific notation. Any structured filter silently built the wrong request. Decode with UseNumber and encode per JSON type: scalars become a single value, arrays of scalars become repeated params (?k=a&k=b), numbers keep their literal, and a nested object or array-of-objects is rejected with a clear error naming the key. --- CHANGELOG.md | 8 +++ internal/cli/query_json.go | 74 ++++++++++++++++++++++++-- internal/cli/query_json_test.go | 92 +++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef13cf32..9f28cab8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Encode `--query` filters with real JSON-to-query semantics. An array value now + sends repeated parameters (`?fields=a&fields=b`) instead of the literal + `[a b]`, numbers keep their original literal (no more `1e+06` for `1000000`), + and a nested object is rejected with a clear error rather than silently + building a wrong request. + # [v1.35.0](https://github.com/auth0/auth0-cli/tree/v1.35.0) (September 10, 2026) [Full Changelog](https://github.com/auth0/auth0-cli/compare/v1.34.0...v1.35.0) diff --git a/internal/cli/query_json.go b/internal/cli/query_json.go index 3634d65e9..860cba54d 100644 --- a/internal/cli/query_json.go +++ b/internal/cli/query_json.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "github.com/spf13/cobra" @@ -27,10 +28,71 @@ type jsonQuerySpec struct { SchemaCmd string } +// encodeQueryParams turns a decoded JSON object of query parameters into URL +// query values with real JSON-to-query semantics, so structured filters build the +// request an agent actually intends: +// - a scalar (string, number, bool) becomes a single value; +// - an array of scalars becomes repeated params (?k=a&k=b), which is how the +// Management API expects multi-valued filters, instead of the old +// fmt.Sprintf("%v", …) that rendered an array as the literal "[a b]"; +// - a nested object or an array containing objects/arrays is rejected, since +// there is no unambiguous query encoding for it (better a clear error than a +// silently wrong request). +// +// Numbers must arrive as json.Number (decode with UseNumber) so their original +// literal is preserved. +func encodeQueryParams(query url.Values, params map[string]interface{}) (url.Values, error) { + for key, val := range params { + switch v := val.(type) { + case []interface{}: + for _, item := range v { + s, err := queryScalarString(key, item) + if err != nil { + return nil, err + } + query.Add(key, s) + } + default: + s, err := queryScalarString(key, v) + if err != nil { + return nil, err + } + query.Add(key, s) + } + } + + return query, nil +} + +// queryScalarString renders a single JSON scalar as a query-parameter string. A +// nested object or array is not a scalar and is rejected, naming the offending key. +func queryScalarString(key string, val interface{}) (string, error) { + switch v := val.(type) { + case nil: + return "", nil + case string: + return v, nil + case bool: + return strconv.FormatBool(v), nil + case json.Number: + return v.String(), nil + default: + return "", fmt.Errorf( + "query parameter %q must be a scalar or an array of scalars; "+ + "nested objects and arrays are not supported", key, + ) + } +} + // runJSONQuery executes a GET request against the Management API with query parameters parsed from queryJSON. func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON string) error { + // UseNumber keeps numeric filters as their original literal (e.g. "5", not + // "5" reformatted through float64, which would turn 1000000 into "1e+06"). + decoder := json.NewDecoder(strings.NewReader(queryJSON)) + decoder.UseNumber() + var queryParams map[string]interface{} - if err := json.Unmarshal([]byte(queryJSON), &queryParams); err != nil { + if err := decoder.Decode(&queryParams); err != nil { cli.renderer.Infof("Run '%s --schema' to see the expected query parameters.", spec.SchemaCmd) return fmt.Errorf("invalid --query value: must be a JSON object: %w", err) } @@ -39,11 +101,13 @@ func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON st if err != nil { return fmt.Errorf("failed to parse URI: %w", err) } - q := u.Query() - for key, val := range queryParams { - q.Set(key, fmt.Sprintf("%v", val)) + + query, err := encodeQueryParams(u.Query(), queryParams) + if err != nil { + cli.renderer.Infof("Run '%s --schema' to see the expected query parameters.", spec.SchemaCmd) + return fmt.Errorf("invalid --query value: %w", err) } - u.RawQuery = q.Encode() + u.RawQuery = query.Encode() var response *http.Response if err := ansi.Waiting(func() error { diff --git a/internal/cli/query_json_test.go b/internal/cli/query_json_test.go index 8f9b610d1..658ee164a 100644 --- a/internal/cli/query_json_test.go +++ b/internal/cli/query_json_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" @@ -125,6 +126,97 @@ func TestRunJSONQuery_APIError(t *testing.T) { assert.Error(t, err) } +// decodeQueryParams mirrors runJSONQuery's decode step (UseNumber) so a unit test +// feeds encodeQueryParams the same value types the real path does. +func decodeQueryParams(t *testing.T, s string) map[string]interface{} { + t.Helper() + decoder := json.NewDecoder(strings.NewReader(s)) + decoder.UseNumber() + var m map[string]interface{} + require.NoError(t, decoder.Decode(&m)) + return m +} + +func TestEncodeQueryParams(t *testing.T) { + t.Run("array of scalars becomes repeated params", func(t *testing.T) { + q, err := encodeQueryParams(url.Values{}, decodeQueryParams(t, `{"fields":["a","b","c"]}`)) + require.NoError(t, err) + assert.Equal(t, []string{"a", "b", "c"}, q["fields"]) + assert.Equal(t, "fields=a&fields=b&fields=c", q.Encode()) + }) + + t.Run("numbers keep their literal, no scientific notation", func(t *testing.T) { + q, err := encodeQueryParams(url.Values{}, decodeQueryParams(t, `{"per_page":1000000,"page":0}`)) + require.NoError(t, err) + assert.Equal(t, "1000000", q.Get("per_page")) + assert.Equal(t, "0", q.Get("page")) + }) + + t.Run("booleans stringify", func(t *testing.T) { + q, err := encodeQueryParams(url.Values{}, decodeQueryParams(t, `{"include_totals":true,"deployed":false}`)) + require.NoError(t, err) + assert.Equal(t, "true", q.Get("include_totals")) + assert.Equal(t, "false", q.Get("deployed")) + }) + + t.Run("nested object is rejected", func(t *testing.T) { + _, err := encodeQueryParams(url.Values{}, decodeQueryParams(t, `{"filter":{"k":"v"}}`)) + require.Error(t, err) + assert.ErrorContains(t, err, "filter") + assert.ErrorContains(t, err, "scalar") + }) + + t.Run("array containing an object is rejected", func(t *testing.T) { + _, err := encodeQueryParams(url.Values{}, decodeQueryParams(t, `{"q":[{"nested":true}]}`)) + require.Error(t, err) + assert.ErrorContains(t, err, "q") + }) +} + +func TestRunJSONQuery_ArrayParamsSendRepeated(t *testing.T) { + var capturedURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedURL = r.URL.String() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"actions":[]}`)) + })) + defer server.Close() + + cli := &cli{ + renderer: &display.Renderer{MessageWriter: io.Discard, ResultWriter: io.Discard}, + api: &auth0.API{HTTPClient: &mockHTTPClientAPI{baseURL: server.URL}}, + } + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + err := runJSONQuery(cli, cmd, jsonQuerySpec{ + Path: "actions/actions", + SchemaCmd: "auth0 actions list", + }, `{"fields":["id","name"]}`) + + require.NoError(t, err) + assert.Contains(t, capturedURL, "fields=id") + assert.Contains(t, capturedURL, "fields=name") +} + +func TestRunJSONQuery_NestedObjectIsError(t *testing.T) { + cli := &cli{ + renderer: &display.Renderer{MessageWriter: io.Discard, ResultWriter: io.Discard}, + api: &auth0.API{HTTPClient: &mockHTTPClientAPI{baseURL: "http://example.invalid"}}, + } + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + err := runJSONQuery(cli, cmd, jsonQuerySpec{ + Path: "actions/actions", + SchemaCmd: "auth0 actions list", + }, `{"filter":{"nested":"value"}}`) + + require.Error(t, err) + assert.ErrorContains(t, err, "invalid --query value") + assert.ErrorContains(t, err, "filter") +} + func TestRunJSONQuery_BuildsURLWithQueryParams(t *testing.T) { var capturedURL string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 2751881ebfc6de1e54b67d0c1d0cf4fe993c503b Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Wed, 16 Sep 2026 22:53:25 +0530 Subject: [PATCH 2/6] feat: honor --json-compact and signal pagination in --query output Emit compact single-line JSON from --query list commands when --json-compact is set (default stays pretty-printed), and print a stderr diagnostic when the response is one page of a larger result set. The truncation hint covers checkpoint (next token), offset with totals, and offset with only a total, so callers don't mistake a page for the full set. The records fetched are unchanged. --- CHANGELOG.md | 8 +++ internal/cli/query_json.go | 96 ++++++++++++++++++++++++++++++ internal/cli/query_json_test.go | 102 ++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f28cab8b..9606237ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `--query` list output now honors `--json-compact`, emitting a single dense + JSON line when that flag is set (the default stays pretty-printed). +- `--query` list commands now print a diagnostic to stderr when the response is + a page of a larger result set, so the returned records aren't mistaken for the + full set. The output itself is unchanged. + ### Fixed - Encode `--query` filters with real JSON-to-query semantics. An array value now diff --git a/internal/cli/query_json.go b/internal/cli/query_json.go index 860cba54d..1bd7bc92c 100644 --- a/internal/cli/query_json.go +++ b/internal/cli/query_json.go @@ -131,6 +131,25 @@ func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON st return newAPIResponseError(response.StatusCode, response.Header, rawJSON) } + // A single --query call fetches one page. If the envelope reports more + // records than were returned, tell the caller so they don't mistake a page + // for the whole result set; the output itself is left untouched. + if hint := paginationHint(rawJSON); hint != "" { + cli.renderer.Warnf("%s", hint) + } + + // Honor --json-compact by emitting a single dense line; otherwise pretty-print. + // --csv is intentionally not supported here: the response is raw API JSON with + // no fixed column shape to flatten. + if cli.jsonCompact { + var compactJSON bytes.Buffer + if err := json.Compact(&compactJSON, rawJSON); err != nil { + return fmt.Errorf("failed to format response: %w", err) + } + cli.renderer.Output(compactJSON.String()) + return nil + } + var prettyJSON bytes.Buffer if err := json.Indent(&prettyJSON, rawJSON, "", " "); err != nil { return fmt.Errorf("failed to format response: %w", err) @@ -138,3 +157,80 @@ func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON st cli.renderer.Output(ansi.ColorizeJSON(prettyJSON.String())) return nil } + +// paginationHint returns a diagnostic when the response shows that more records +// exist than were returned on this page, or "" when the response is complete (or +// carries no pagination metadata to reason about). It covers all of the +// Management API's pagination models rather than a single envelope shape: +// +// - Checkpoint pagination returns a "next" token (and no "total"); a non-empty +// token means there are further pages, fetched by passing it as "from". +// - Offset pagination returns a numeric "total"; when the number of records +// actually returned (plus the page's "start" offset, if present) is short of +// "total", further pages exist. This handles both the "include_totals" +// envelope (start/limit/total) and endpoints that report only "total". +// +// A bare array or any body without "next"/"total" yields no hint, because there +// is then no reliable signal that the result set was truncated. +func paginationHint(rawJSON []byte) string { + decoder := json.NewDecoder(bytes.NewReader(rawJSON)) + decoder.UseNumber() + + var envelope map[string]interface{} + if err := decoder.Decode(&envelope); err != nil { + // A bare array or any non-object body carries no pagination metadata. + return "" + } + + // Checkpoint pagination: a non-empty "next" token means more pages exist. + if next, ok := envelope["next"].(string); ok && next != "" { + return "This is one page of a larger result set (checkpoint pagination). " + + "More results exist; pass \"from\" set to the response's \"next\" token " + + "(and optionally \"take\") in --query to fetch the next page." + } + + // Offset pagination: compare records returned against the reported total. + total, hasTotal := jsonNumberInt(envelope["total"]) + if !hasTotal { + return "" + } + start, _ := jsonNumberInt(envelope["start"]) // Absent "start" means offset 0. + returned := longestArrayLen(envelope) + if start+returned >= total { + return "" + } + + return fmt.Sprintf( + "Showing %d of %d results (page starts at %d). More results exist; "+ + "pass a higher \"page\" or \"per_page\" in --query to fetch the rest.", + returned, total, start, + ) +} + +// longestArrayLen returns the length of the longest array-valued field in the +// envelope. That field is the resource collection, since pagination metadata +// ("total", "start", "limit", "next", …) is always scalar. +func longestArrayLen(envelope map[string]interface{}) int { + longest := 0 + for _, v := range envelope { + if arr, ok := v.([]interface{}); ok && len(arr) > longest { + longest = len(arr) + } + } + return longest +} + +// jsonNumberInt reports the integer value of a decoded JSON field when it is a +// json.Number holding an integer, and false when the field is absent or not an +// integer number. +func jsonNumberInt(val interface{}) (int, bool) { + n, ok := val.(json.Number) + if !ok { + return 0, false + } + v, err := n.Int64() + if err != nil { + return 0, false + } + return int(v), true +} diff --git a/internal/cli/query_json_test.go b/internal/cli/query_json_test.go index 658ee164a..5514b363c 100644 --- a/internal/cli/query_json_test.go +++ b/internal/cli/query_json_test.go @@ -217,6 +217,108 @@ func TestRunJSONQuery_NestedObjectIsError(t *testing.T) { assert.ErrorContains(t, err, "filter") } +func TestRunJSONQuery_CompactOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"actions":[{"id":"a"}],"total":1}`)) + })) + defer server.Close() + + var resultBuf strings.Builder + cli := &cli{ + jsonCompact: true, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: &resultBuf, + }, + api: &auth0.API{HTTPClient: &mockHTTPClientAPI{baseURL: server.URL}}, + } + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + err := runJSONQuery(cli, cmd, jsonQuerySpec{ + Path: "actions/actions", + SchemaCmd: "auth0 actions list", + }, `{}`) + + require.NoError(t, err) + out := strings.TrimSpace(resultBuf.String()) + // Compact output is a single dense line with no indentation newlines. + assert.Equal(t, `{"actions":[{"id":"a"}],"total":1}`, out) + assert.NotContains(t, out, "\n") +} + +func TestPaginationHint(t *testing.T) { + t.Run("offset with totals, more results signals a hint", func(t *testing.T) { + hint := paginationHint([]byte(`{"clients":[{"id":"a"},{"id":"b"}],"total":100,"start":0,"limit":50}`)) + assert.Contains(t, hint, "Showing 2 of 100") + }) + + t.Run("offset without start/limit, only total, signals a hint", func(t *testing.T) { + // Actions-style envelope: reports "total" but no "start"/"limit". + hint := paginationHint([]byte(`{"actions":[{"id":"a"},{"id":"b"}],"total":10}`)) + assert.Contains(t, hint, "Showing 2 of 10") + }) + + t.Run("checkpoint pagination signals a hint", func(t *testing.T) { + hint := paginationHint([]byte(`{"logs":[{"id":"a"}],"next":"tok_abc"}`)) + assert.Contains(t, hint, "checkpoint") + assert.Contains(t, hint, "next") + }) + + t.Run("complete result set has no hint", func(t *testing.T) { + hint := paginationHint([]byte(`{"clients":[{"id":"a"},{"id":"b"}],"total":2,"start":0,"limit":50}`)) + assert.Empty(t, hint) + }) + + t.Run("last page has no hint", func(t *testing.T) { + hint := paginationHint([]byte(`{"clients":[{"id":"a"}],"total":100,"start":99}`)) + assert.Empty(t, hint) + }) + + t.Run("empty checkpoint token has no hint", func(t *testing.T) { + hint := paginationHint([]byte(`{"logs":[{"id":"a"}],"next":""}`)) + assert.Empty(t, hint) + }) + + t.Run("no total and no next has no hint", func(t *testing.T) { + hint := paginationHint([]byte(`{"actions":[{"id":"a"}]}`)) + assert.Empty(t, hint) + }) + + t.Run("bare array has no hint", func(t *testing.T) { + hint := paginationHint([]byte(`[{"id":"a"}]`)) + assert.Empty(t, hint) + }) +} + +func TestRunJSONQuery_TruncationWarning(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"clients":[{"id":"a"},{"id":"b"}],"total":100,"start":0,"limit":50}`)) + })) + defer server.Close() + + var msgBuf strings.Builder + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: &msgBuf, + ResultWriter: io.Discard, + }, + api: &auth0.API{HTTPClient: &mockHTTPClientAPI{baseURL: server.URL}}, + } + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + err := runJSONQuery(cli, cmd, jsonQuerySpec{ + Path: "clients", + SchemaCmd: "auth0 apps list", + }, `{}`) + + require.NoError(t, err) + assert.Contains(t, msgBuf.String(), "More results exist") +} + func TestRunJSONQuery_BuildsURLWithQueryParams(t *testing.T) { var capturedURL string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 95f3ecfae09b4c0299a43e22134c81272597f5a4 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Thu, 17 Sep 2026 12:08:53 +0530 Subject: [PATCH 3/6] refactor: align pagination hint with go-auth0 List.HasNext contract Base the --query pagination hint on the Management API's standard list envelope (start/limit/length/total/next) and mirror management.List.HasNext rather than guessing the records array by length: - Offset: signal more pages when total > start + limit, and report the count from the envelope's length field instead of the longest top-level array. A bare total without limit is no longer treated as a pagination signal, so an unrelated total field cannot trigger a spurious hint. - Checkpoint: keep firing on a non-empty next token, but suppress it on an empty page (length == 0) so we never claim more results for a page that returned nothing. Also colorize --json-compact --query output to match the other compact commands (ColorizeJSON is already TTY-guarded, so piped output stays plain). --- internal/cli/query_json.go | 64 ++++++++++++++++++--------------- internal/cli/query_json_test.go | 29 +++++++++++---- 2 files changed, 57 insertions(+), 36 deletions(-) diff --git a/internal/cli/query_json.go b/internal/cli/query_json.go index 1bd7bc92c..4d9691e7c 100644 --- a/internal/cli/query_json.go +++ b/internal/cli/query_json.go @@ -146,7 +146,7 @@ func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON st if err := json.Compact(&compactJSON, rawJSON); err != nil { return fmt.Errorf("failed to format response: %w", err) } - cli.renderer.Output(compactJSON.String()) + cli.renderer.Output(ansi.ColorizeJSON(compactJSON.String())) return nil } @@ -160,18 +160,23 @@ func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON st // paginationHint returns a diagnostic when the response shows that more records // exist than were returned on this page, or "" when the response is complete (or -// carries no pagination metadata to reason about). It covers all of the -// Management API's pagination models rather than a single envelope shape: +// carries no pagination metadata to reason about). // -// - Checkpoint pagination returns a "next" token (and no "total"); a non-empty -// token means there are further pages, fetched by passing it as "from". -// - Offset pagination returns a numeric "total"; when the number of records -// actually returned (plus the page's "start" offset, if present) is short of -// "total", further pages exist. This handles both the "include_totals" -// envelope (start/limit/total) and endpoints that report only "total". +// It reads the Management API's standard list envelope, whose fields go-auth0 +// models as management.List (start/limit/length/total/next), and mirrors that +// type's own HasNext() contract so the hint agrees with how the SDK defines +// "more pages": // -// A bare array or any body without "next"/"total" yields no hint, because there -// is then no reliable signal that the result set was truncated. +// - Checkpoint pagination: a non-empty "next" token means further pages exist, +// fetched by passing it as "from". The hint is suppressed on an empty page +// ("length" == 0) so it never claims "more results" for a page that returned +// nothing. +// - Offset pagination: more pages exist when "total" > "start" + "limit". Both +// "start" and "limit" come from the standard include_totals envelope; without +// "limit" and "total" there is no reliable offset signal, so no hint is given. +// +// A bare array or any body without these fields yields no hint, because there is +// then no reliable signal that the result set was truncated. func paginationHint(rawJSON []byte) string { decoder := json.NewDecoder(bytes.NewReader(rawJSON)) decoder.UseNumber() @@ -182,24 +187,38 @@ func paginationHint(rawJSON []byte) string { return "" } - // Checkpoint pagination: a non-empty "next" token means more pages exist. + length, hasLength := jsonNumberInt(envelope["length"]) + + // Checkpoint pagination: a non-empty "next" token means more pages exist, but + // never signal "more results" for a page that came back empty. if next, ok := envelope["next"].(string); ok && next != "" { + if hasLength && length == 0 { + return "" + } return "This is one page of a larger result set (checkpoint pagination). " + "More results exist; pass \"from\" set to the response's \"next\" token " + "(and optionally \"take\") in --query to fetch the next page." } - // Offset pagination: compare records returned against the reported total. + // Offset pagination: mirror management.List.HasNext() — more pages exist when + // total > start + limit. total, hasTotal := jsonNumberInt(envelope["total"]) - if !hasTotal { + limit, hasLimit := jsonNumberInt(envelope["limit"]) + if !hasTotal || !hasLimit { return "" } start, _ := jsonNumberInt(envelope["start"]) // Absent "start" means offset 0. - returned := longestArrayLen(envelope) - if start+returned >= total { + if start+limit >= total { return "" } + // "length" is the count actually returned on this page; fall back to the page + // window ("limit") when the field is absent. + returned := length + if !hasLength { + returned = limit + } + return fmt.Sprintf( "Showing %d of %d results (page starts at %d). More results exist; "+ "pass a higher \"page\" or \"per_page\" in --query to fetch the rest.", @@ -207,19 +226,6 @@ func paginationHint(rawJSON []byte) string { ) } -// longestArrayLen returns the length of the longest array-valued field in the -// envelope. That field is the resource collection, since pagination metadata -// ("total", "start", "limit", "next", …) is always scalar. -func longestArrayLen(envelope map[string]interface{}) int { - longest := 0 - for _, v := range envelope { - if arr, ok := v.([]interface{}); ok && len(arr) > longest { - longest = len(arr) - } - } - return longest -} - // jsonNumberInt reports the integer value of a decoded JSON field when it is a // json.Number holding an integer, and false when the field is absent or not an // integer number. diff --git a/internal/cli/query_json_test.go b/internal/cli/query_json_test.go index 5514b363c..411ec8c51 100644 --- a/internal/cli/query_json_test.go +++ b/internal/cli/query_json_test.go @@ -250,14 +250,23 @@ func TestRunJSONQuery_CompactOutput(t *testing.T) { func TestPaginationHint(t *testing.T) { t.Run("offset with totals, more results signals a hint", func(t *testing.T) { - hint := paginationHint([]byte(`{"clients":[{"id":"a"},{"id":"b"}],"total":100,"start":0,"limit":50}`)) - assert.Contains(t, hint, "Showing 2 of 100") + // Standard include_totals envelope: first of two pages. + hint := paginationHint([]byte(`{"clients":[],"total":100,"start":0,"limit":50,"length":50}`)) + assert.Contains(t, hint, "Showing 50 of 100") + assert.Contains(t, hint, "More results exist") }) - t.Run("offset without start/limit, only total, signals a hint", func(t *testing.T) { - // Actions-style envelope: reports "total" but no "start"/"limit". + t.Run("offset uses length for the returned count", func(t *testing.T) { + // "length" is the number actually returned; the hint reports it, not "limit". + hint := paginationHint([]byte(`{"clients":[],"total":100,"start":0,"limit":50,"length":42}`)) + assert.Contains(t, hint, "Showing 42 of 100") + }) + + t.Run("offset without limit yields no hint (spurious total is ignored)", func(t *testing.T) { + // A "total" without the rest of the include_totals envelope is not a + // reliable pagination signal, so no hint is emitted. hint := paginationHint([]byte(`{"actions":[{"id":"a"},{"id":"b"}],"total":10}`)) - assert.Contains(t, hint, "Showing 2 of 10") + assert.Empty(t, hint) }) t.Run("checkpoint pagination signals a hint", func(t *testing.T) { @@ -266,13 +275,19 @@ func TestPaginationHint(t *testing.T) { assert.Contains(t, hint, "next") }) + t.Run("checkpoint with an empty page has no hint", func(t *testing.T) { + // A "next" token on a page that returned nothing must not claim more results. + hint := paginationHint([]byte(`{"users":[],"next":"tok_abc","length":0}`)) + assert.Empty(t, hint) + }) + t.Run("complete result set has no hint", func(t *testing.T) { - hint := paginationHint([]byte(`{"clients":[{"id":"a"},{"id":"b"}],"total":2,"start":0,"limit":50}`)) + hint := paginationHint([]byte(`{"clients":[{"id":"a"},{"id":"b"}],"total":2,"start":0,"limit":50,"length":2}`)) assert.Empty(t, hint) }) t.Run("last page has no hint", func(t *testing.T) { - hint := paginationHint([]byte(`{"clients":[{"id":"a"}],"total":100,"start":99}`)) + hint := paginationHint([]byte(`{"clients":[{"id":"a"}],"total":100,"start":99,"limit":50,"length":1}`)) assert.Empty(t, hint) }) From 9f3e8d48eb979167ff23970268e80cc2ec8f5b0a Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Thu, 17 Sep 2026 16:33:43 +0530 Subject: [PATCH 4/6] fix: address --query output review (csv guard, honest pagination hint, single-parse) Reject --csv when combined with --query instead of silently ignoring it, soften the checkpoint hint wording so a trailing next token no longer claims results definitively exist, report only the total when the envelope omits length, decode just the scalar pagination fields to avoid allocating the result array, and document the include_totals requirement on the --query flag. --- CHANGELOG.md | 3 ++ docs/auth0_actions_list.md | 2 +- docs/auth0_roles_list.md | 2 +- internal/cli/query_json.go | 83 +++++++++++++++++++-------------- internal/cli/query_json_test.go | 32 +++++++++++++ 5 files changed, 85 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9606237ae..a98d3c3c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `--query` list commands now print a diagnostic to stderr when the response is a page of a larger result set, so the returned records aren't mistaken for the full set. The output itself is unchanged. +- `--csv` combined with `--query` now returns a clear error instead of being + silently ignored, since the raw API JSON has no fixed columns to flatten. Use + `--json` or `--json-compact` instead. ### Fixed diff --git a/docs/auth0_actions_list.md b/docs/auth0_actions_list.md index 00826ffed..5e2aa9596 100644 --- a/docs/auth0_actions_list.md +++ b/docs/auth0_actions_list.md @@ -36,7 +36,7 @@ auth0 actions list [flags] --csv Output in csv format. --json Output in json format. --json-compact Output in compact json format. - -q, --query string Filter results with a JSON object of query parameters. Any API-supported parameter works immediately. Run '--schema' to see documented parameters. + -q, --query string Filter results with a JSON object of query parameters. Any API-supported parameter works immediately. Run '--schema' to see documented parameters. On offset-paginated endpoints, add "include_totals":true to receive total counts and a pagination hint (without it the API returns a bare array and no hint can be given). --schema Print the request payload schema for this command and exit. Use with --json or --json-compact for machine-readable output. ``` diff --git a/docs/auth0_roles_list.md b/docs/auth0_roles_list.md index 00910a2df..615d78d28 100644 --- a/docs/auth0_roles_list.md +++ b/docs/auth0_roles_list.md @@ -38,7 +38,7 @@ auth0 roles list [flags] --json Output in json format. --json-compact Output in compact json format. -n, --number int Number of roles to retrieve. Minimum 1, maximum 1000. (default 100) - -q, --query string Filter results with a JSON object of query parameters. Any API-supported parameter works immediately. Run '--schema' to see documented parameters. + -q, --query string Filter results with a JSON object of query parameters. Any API-supported parameter works immediately. Run '--schema' to see documented parameters. On offset-paginated endpoints, add "include_totals":true to receive total counts and a pagination hint (without it the API returns a bare array and no hint can be given). --schema Print the request payload schema for this command and exit. Use with --json or --json-compact for machine-readable output. ``` diff --git a/internal/cli/query_json.go b/internal/cli/query_json.go index 4d9691e7c..5dc44363a 100644 --- a/internal/cli/query_json.go +++ b/internal/cli/query_json.go @@ -19,7 +19,7 @@ var listQueryFlag = Flag{ Name: "Query", LongForm: "query", ShortForm: "q", - Help: "Filter results with a JSON object of query parameters. Any API-supported parameter works immediately. Run '--schema' to see documented parameters.", + Help: "Filter results with a JSON object of query parameters. Any API-supported parameter works immediately. Run '--schema' to see documented parameters. On offset-paginated endpoints, add \"include_totals\":true to receive total counts and a pagination hint (without it the API returns a bare array and no hint can be given).", } // jsonQuerySpec describes a list operation driven by a --query JSON payload. @@ -86,6 +86,12 @@ func queryScalarString(key string, val interface{}) (string, error) { // runJSONQuery executes a GET request against the Management API with query parameters parsed from queryJSON. func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON string) error { + // --csv has no meaning here: the response is raw API JSON with no fixed column + // shape to flatten. Fail loudly instead of silently ignoring the flag. + if cli.csv { + return fmt.Errorf("--csv is not supported with --query: the response is raw API JSON with no fixed columns to flatten; use --json or --json-compact instead") + } + // UseNumber keeps numeric filters as their original literal (e.g. "5", not // "5" reformatted through float64, which would turn 1000000 into "1e+06"). decoder := json.NewDecoder(strings.NewReader(queryJSON)) @@ -139,8 +145,7 @@ func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON st } // Honor --json-compact by emitting a single dense line; otherwise pretty-print. - // --csv is intentionally not supported here: the response is raw API JSON with - // no fixed column shape to flatten. + // (--csv is rejected up front.) if cli.jsonCompact { var compactJSON bytes.Buffer if err := json.Compact(&compactJSON, rawJSON); err != nil { @@ -167,71 +172,79 @@ func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON st // type's own HasNext() contract so the hint agrees with how the SDK defines // "more pages": // -// - Checkpoint pagination: a non-empty "next" token means further pages exist, -// fetched by passing it as "from". The hint is suppressed on an empty page -// ("length" == 0) so it never claims "more results" for a page that returned -// nothing. +// - Checkpoint pagination: a non-empty "next" token means the SDK would attempt +// another fetch, so more results may exist. The hint is suppressed on an empty +// page ("length" == 0) so it never points onward from a page that returned +// nothing. The wording stays tentative ("may be more") because a final page can +// still carry a "next" token that yields an empty page when followed. // - Offset pagination: more pages exist when "total" > "start" + "limit". Both // "start" and "limit" come from the standard include_totals envelope; without // "limit" and "total" there is no reliable offset signal, so no hint is given. // // A bare array or any body without these fields yields no hint, because there is -// then no reliable signal that the result set was truncated. +// then no reliable signal that the result set was truncated. Only the scalar +// pagination fields are decoded (into a narrow struct) so a large result array is +// never allocated just to read them; json.Number preserves the original literals. func paginationHint(rawJSON []byte) string { - decoder := json.NewDecoder(bytes.NewReader(rawJSON)) - decoder.UseNumber() - - var envelope map[string]interface{} - if err := decoder.Decode(&envelope); err != nil { + var envelope struct { + Start *json.Number `json:"start"` + Limit *json.Number `json:"limit"` + Length *json.Number `json:"length"` + Total *json.Number `json:"total"` + Next *string `json:"next"` + } + if err := json.Unmarshal(rawJSON, &envelope); err != nil { // A bare array or any non-object body carries no pagination metadata. return "" } - length, hasLength := jsonNumberInt(envelope["length"]) + length, hasLength := jsonNumberInt(envelope.Length) - // Checkpoint pagination: a non-empty "next" token means more pages exist, but - // never signal "more results" for a page that came back empty. - if next, ok := envelope["next"].(string); ok && next != "" { + // Checkpoint pagination: mirror management.List.HasNext() (Next != ""), but + // never point onward from a page that came back empty. + if envelope.Next != nil && *envelope.Next != "" { if hasLength && length == 0 { return "" } - return "This is one page of a larger result set (checkpoint pagination). " + - "More results exist; pass \"from\" set to the response's \"next\" token " + - "(and optionally \"take\") in --query to fetch the next page." + return "This is one page of a checkpoint-paginated result set. There may be " + + "more results; pass \"from\" set to the response's \"next\" token (and " + + "optionally \"take\") in --query to fetch the next page." } // Offset pagination: mirror management.List.HasNext() — more pages exist when // total > start + limit. - total, hasTotal := jsonNumberInt(envelope["total"]) - limit, hasLimit := jsonNumberInt(envelope["limit"]) + total, hasTotal := jsonNumberInt(envelope.Total) + limit, hasLimit := jsonNumberInt(envelope.Limit) if !hasTotal || !hasLimit { return "" } - start, _ := jsonNumberInt(envelope["start"]) // Absent "start" means offset 0. + start, _ := jsonNumberInt(envelope.Start) // Absent "start" means offset 0. if start+limit >= total { return "" } - // "length" is the count actually returned on this page; fall back to the page - // window ("limit") when the field is absent. - returned := length - if !hasLength { - returned = limit + // Report the count actually returned ("length") when the envelope carries it; + // otherwise state only the total so the message never overstates the page size. + if hasLength { + return fmt.Sprintf( + "Showing %d of %d results (page starts at %d). More results exist; "+ + "pass a higher \"page\" or \"per_page\" in --query to fetch the rest.", + length, total, start, + ) } return fmt.Sprintf( - "Showing %d of %d results (page starts at %d). More results exist; "+ + "This is one page of %d total results (page starts at %d). More results exist; "+ "pass a higher \"page\" or \"per_page\" in --query to fetch the rest.", - returned, total, start, + total, start, ) } -// jsonNumberInt reports the integer value of a decoded JSON field when it is a -// json.Number holding an integer, and false when the field is absent or not an +// jsonNumberInt reports the integer value of a json.Number field when it is +// present and holds an integer, and false when the field is absent (nil) or not an // integer number. -func jsonNumberInt(val interface{}) (int, bool) { - n, ok := val.(json.Number) - if !ok { +func jsonNumberInt(n *json.Number) (int, bool) { + if n == nil { return 0, false } v, err := n.Int64() diff --git a/internal/cli/query_json_test.go b/internal/cli/query_json_test.go index 411ec8c51..26123518f 100644 --- a/internal/cli/query_json_test.go +++ b/internal/cli/query_json_test.go @@ -248,6 +248,27 @@ func TestRunJSONQuery_CompactOutput(t *testing.T) { assert.NotContains(t, out, "\n") } +func TestRunJSONQuery_CSVIsRejected(t *testing.T) { + cli := &cli{ + csv: true, + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + api: &auth0.API{}, + } + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + err := runJSONQuery(cli, cmd, jsonQuerySpec{ + Path: "actions/actions", + SchemaCmd: "auth0 actions list", + }, `{}`) + + require.Error(t, err) + assert.ErrorContains(t, err, "--csv is not supported with --query") +} + func TestPaginationHint(t *testing.T) { t.Run("offset with totals, more results signals a hint", func(t *testing.T) { // Standard include_totals envelope: first of two pages. @@ -273,6 +294,17 @@ func TestPaginationHint(t *testing.T) { hint := paginationHint([]byte(`{"logs":[{"id":"a"}],"next":"tok_abc"}`)) assert.Contains(t, hint, "checkpoint") assert.Contains(t, hint, "next") + // Wording stays tentative: a final page can still carry a "next" token. + assert.Contains(t, hint, "There may be") + assert.NotContains(t, hint, "More results exist") + }) + + t.Run("offset without length states the total but no page count", func(t *testing.T) { + // No "length" field: the hint must not invent a returned count from "limit". + hint := paginationHint([]byte(`{"clients":[{"id":"a"}],"total":100,"start":0,"limit":50}`)) + assert.Contains(t, hint, "one page of 100 total results") + assert.Contains(t, hint, "More results exist") + assert.NotContains(t, hint, "Showing") }) t.Run("checkpoint with an empty page has no hint", func(t *testing.T) { From 2359dbe7fe15fffc80f623c9d674e79bb9f9f710 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Fri, 18 Sep 2026 10:50:34 +0530 Subject: [PATCH 5/6] fix: classify --query input errors as validation and omit null params Wrap the --query decode and encode failures in validationError so a malformed or nested filter classifies as "validation" instead of "unknown", matching the --data path. A JSON null now omits the parameter instead of sending an empty value, and queryScalarString gained a float64 fallback so the encoder stays correct if a caller decodes without UseNumber. --- CHANGELOG.md | 2 +- internal/cli/query_json.go | 22 +++++++-- internal/cli/query_json_test.go | 84 +++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad61729cf..7b6571c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Quit the `auth0 logs tail` follow loop and decline confirmation prompts cleanly when no interactive terminal is available (an agent or piped run), instead of panicking - Report a recovered panic on stderr (as a JSON error envelope in JSON or agent mode) and exit non-zero, so a crash never masquerades as success or corrupts JSON written to stdout - `--data` input is now checked for well-formed JSON even when the operation has no local schema, so malformed payloads fail locally with a clear message instead of being sent to the API as-is. -- Encode `--query` filters with real JSON-to-query semantics. An array value now sends repeated parameters (`?fields=a&fields=b`) instead of the literal `[a b]`, numbers keep their original literal (no more `1e+06` for `1000000`), and a nested object is rejected with a clear error rather than silently building a wrong request. +- Encode `--query` filters with real JSON-to-query semantics. An array value now sends repeated parameters (`?fields=a&fields=b`) instead of the literal `[a b]`, numbers keep their original literal (no more `1e+06` for `1000000`), a JSON `null` omits the parameter instead of sending an empty value, and a nested object is rejected with a clear error rather than silently building a wrong request. A malformed or nested `--query` now classifies as a `validation` error in the JSON error envelope instead of `unknown`. # [v1.35.0](https://github.com/auth0/auth0-cli/tree/v1.35.0) (September 10, 2026) diff --git a/internal/cli/query_json.go b/internal/cli/query_json.go index 90dff4b08..401cbf10d 100644 --- a/internal/cli/query_json.go +++ b/internal/cli/query_json.go @@ -37,15 +37,22 @@ type jsonQuerySpec struct { // fmt.Sprintf("%v", …) that rendered an array as the literal "[a b]"; // - a nested object or an array containing objects/arrays is rejected, since // there is no unambiguous query encoding for it (better a clear error than a -// silently wrong request). +// silently wrong request); +// - a JSON null omits the parameter rather than sending an empty value, so +// {"page":null} drops the key instead of building "?page=". // // Numbers must arrive as json.Number (decode with UseNumber) so their original // literal is preserved. func encodeQueryParams(query url.Values, params map[string]interface{}) (url.Values, error) { for key, val := range params { switch v := val.(type) { + case nil: + continue case []interface{}: for _, item := range v { + if item == nil { + continue + } s, err := queryScalarString(key, item) if err != nil { return nil, err @@ -66,16 +73,21 @@ func encodeQueryParams(query url.Values, params map[string]interface{}) (url.Val // queryScalarString renders a single JSON scalar as a query-parameter string. A // nested object or array is not a scalar and is rejected, naming the offending key. +// Callers skip JSON null before reaching here (see encodeQueryParams). func queryScalarString(key string, val interface{}) (string, error) { switch v := val.(type) { - case nil: - return "", nil case string: return v, nil case bool: return strconv.FormatBool(v), nil case json.Number: return v.String(), nil + case float64: + // The sole caller decodes with UseNumber, so numbers arrive as + // json.Number. This keeps encodeQueryParams correct for a future caller + // that decodes without UseNumber: 'f' formatting avoids scientific + // notation, so 1000000 stays "1000000". + return strconv.FormatFloat(v, 'f', -1, 64), nil default: return "", fmt.Errorf( "query parameter %q must be a scalar or an array of scalars; "+ @@ -94,7 +106,7 @@ func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON st var queryParams map[string]interface{} if err := decoder.Decode(&queryParams); err != nil { cli.renderer.Infof("Run '%s --schema' to see the expected query parameters.", spec.SchemaCmd) - return fmt.Errorf("invalid --query value: must be a JSON object: %w", err) + return validationError{fmt.Errorf("invalid --query value: must be a JSON object: %w", err)} } u, err := url.Parse(cli.api.HTTPClient.URI(strings.Split(spec.Path, "/")...)) @@ -105,7 +117,7 @@ func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON st query, err := encodeQueryParams(u.Query(), queryParams) if err != nil { cli.renderer.Infof("Run '%s --schema' to see the expected query parameters.", spec.SchemaCmd) - return fmt.Errorf("invalid --query value: %w", err) + return validationError{fmt.Errorf("invalid --query value: %w", err)} } u.RawQuery = query.Encode() diff --git a/internal/cli/query_json_test.go b/internal/cli/query_json_test.go index 658ee164a..0c911601f 100644 --- a/internal/cli/query_json_test.go +++ b/internal/cli/query_json_test.go @@ -171,6 +171,56 @@ func TestEncodeQueryParams(t *testing.T) { require.Error(t, err) assert.ErrorContains(t, err, "q") }) + + t.Run("a Lucene-style string round-trips intact", func(t *testing.T) { + q, err := encodeQueryParams(url.Values{}, decodeQueryParams(t, `{"q":"identities.connection:\"conn\" AND email:a@b.com"}`)) + require.NoError(t, err) + assert.Equal(t, `identities.connection:"conn" AND email:a@b.com`, q.Get("q")) + // Encode() must escape the colon, quotes, and spaces so the value survives + // the wire and decodes back to the original. + decoded, err := url.ParseQuery(q.Encode()) + require.NoError(t, err) + assert.Equal(t, `identities.connection:"conn" AND email:a@b.com`, decoded.Get("q")) + }) + + t.Run("a null value omits the parameter", func(t *testing.T) { + q, err := encodeQueryParams(url.Values{}, decodeQueryParams(t, `{"page":null,"per_page":10}`)) + require.NoError(t, err) + _, hasPage := q["page"] + assert.False(t, hasPage, "null value should be omitted, not sent as an empty param") + assert.Equal(t, "10", q.Get("per_page")) + assert.Equal(t, "per_page=10", q.Encode()) + }) + + t.Run("an empty string is kept as an empty-valued param", func(t *testing.T) { + q, err := encodeQueryParams(url.Values{}, decodeQueryParams(t, `{"q":""}`)) + require.NoError(t, err) + values, hasQ := q["q"] + assert.True(t, hasQ, "an explicit empty string is a value, unlike null") + assert.Equal(t, []string{""}, values) + }) + + t.Run("an empty array produces no param", func(t *testing.T) { + q, err := encodeQueryParams(url.Values{}, decodeQueryParams(t, `{"fields":[]}`)) + require.NoError(t, err) + _, hasFields := q["fields"] + assert.False(t, hasFields) + assert.Empty(t, q.Encode()) + }) + + t.Run("null elements in an array are skipped", func(t *testing.T) { + q, err := encodeQueryParams(url.Values{}, decodeQueryParams(t, `{"fields":["a",null,"b"]}`)) + require.NoError(t, err) + assert.Equal(t, []string{"a", "b"}, q["fields"]) + }) + + t.Run("a float64 number formats without scientific notation", func(t *testing.T) { + // A caller that decodes without UseNumber yields float64 rather than + // json.Number; encodeQueryParams must still format the literal. + q, err := encodeQueryParams(url.Values{}, map[string]interface{}{"per_page": float64(1000000)}) + require.NoError(t, err) + assert.Equal(t, "1000000", q.Get("per_page")) + }) } func TestRunJSONQuery_ArrayParamsSendRepeated(t *testing.T) { @@ -217,6 +267,40 @@ func TestRunJSONQuery_NestedObjectIsError(t *testing.T) { assert.ErrorContains(t, err, "filter") } +// TestRunJSONQuery_InvalidQueryClassifiesAsValidation covers that client-side +// --query failures classify as "validation" (not "unknown") in the error +// envelope, matching how a server-side 400/422 and the --data path classify. +func TestRunJSONQuery_InvalidQueryClassifiesAsValidation(t *testing.T) { + newCLI := func() *cli { + return &cli{ + renderer: &display.Renderer{MessageWriter: io.Discard, ResultWriter: io.Discard}, + api: &auth0.API{HTTPClient: &mockHTTPClientAPI{baseURL: "http://example.invalid"}}, + } + } + + t.Run("malformed JSON", func(t *testing.T) { + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + err := runJSONQuery(newCLI(), cmd, jsonQuerySpec{ + Path: "actions/actions", + SchemaCmd: "auth0 actions list", + }, `{not json`) + require.Error(t, err) + assert.Equal(t, "validation", errorClass(err)) + }) + + t.Run("nested object", func(t *testing.T) { + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + err := runJSONQuery(newCLI(), cmd, jsonQuerySpec{ + Path: "actions/actions", + SchemaCmd: "auth0 actions list", + }, `{"filter":{"nested":"value"}}`) + require.Error(t, err) + assert.Equal(t, "validation", errorClass(err)) + }) +} + func TestRunJSONQuery_BuildsURLWithQueryParams(t *testing.T) { var capturedURL string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 9c8e2d3efd0ccca5e7f917b2342e9420a6498404 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Fri, 18 Sep 2026 11:59:52 +0530 Subject: [PATCH 6/6] refactor: use named field for --query validationError literals Keeps the literals compilable once validationError gains additional fields, independent of merge order with the error-envelope change. --- internal/cli/query_json.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cli/query_json.go b/internal/cli/query_json.go index 401cbf10d..3443825e0 100644 --- a/internal/cli/query_json.go +++ b/internal/cli/query_json.go @@ -106,7 +106,7 @@ func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON st var queryParams map[string]interface{} if err := decoder.Decode(&queryParams); err != nil { cli.renderer.Infof("Run '%s --schema' to see the expected query parameters.", spec.SchemaCmd) - return validationError{fmt.Errorf("invalid --query value: must be a JSON object: %w", err)} + return validationError{err: fmt.Errorf("invalid --query value: must be a JSON object: %w", err)} } u, err := url.Parse(cli.api.HTTPClient.URI(strings.Split(spec.Path, "/")...)) @@ -117,7 +117,7 @@ func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON st query, err := encodeQueryParams(u.Query(), queryParams) if err != nil { cli.renderer.Infof("Run '%s --schema' to see the expected query parameters.", spec.SchemaCmd) - return validationError{fmt.Errorf("invalid --query value: %w", err)} + return validationError{err: fmt.Errorf("invalid --query value: %w", err)} } u.RawQuery = query.Encode()