From 7a55f7670e9beb580a9bd19f53d9fbf9ffc8f99b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 23 Jan 2026 19:27:47 +0000 Subject: [PATCH 1/6] fix(api): use query params for --field with GET requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET/HEAD requests cannot have a body, so --field values were causing 'Request with GET/HEAD method cannot have body' errors. Now fields are routed based on HTTP method: - GET: fields become URL query parameters - POST/PUT/PATCH/DELETE: fields become JSON request body Arrays use repeated keys per HTTP standard (tags=[1,2,3] → ?tags=1&tags=2&tags=3). --- src/commands/api.ts | 48 ++++++++++++++++++++++++++++++++++++--- src/lib/api-client.ts | 16 ++++++++++--- test/commands/api.test.ts | 47 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index d06094cd6..b8c36d98d 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -133,6 +133,42 @@ export function parseFields(fields: string[]): Record { return result; } +/** + * Build query parameters from field strings for GET requests. + * Unlike parseFields(), this produces a flat structure suitable for URL query strings. + * Arrays are represented as string[] for repeated keys (e.g., tags=1&tags=2&tags=3). + * + * @param fields - Array of "key=value" strings + * @returns Record suitable for URLSearchParams + * @throws {Error} When field doesn't contain "=" + * @internal Exported for testing + */ +export function buildQueryParams( + fields: string[] +): Record { + const result: Record = {}; + + for (const field of fields) { + const eqIndex = field.indexOf("="); + if (eqIndex === -1) { + throw new Error(`Invalid field format: ${field}. Expected key=value`); + } + + const key = field.substring(0, eqIndex); + const rawValue = field.substring(eqIndex + 1); + const value = parseFieldValue(rawValue); + + // Handle arrays by creating string[] for repeated keys + if (Array.isArray(value)) { + result[key] = value.map(String); + } else { + result[key] = String(value); + } + } + + return result; +} + /** * Parse header arguments into headers object. * @@ -259,10 +295,15 @@ export const apiCommand = buildCommand({ ): Promise { const { stdout } = this; + const hasFields = flags.field && flags.field.length > 0; + const isBodyMethod = flags.method !== "GET"; + + // For GET: fields become query params; for other methods: fields become body const body = - flags.field && flags.field.length > 0 - ? parseFields(flags.field) - : undefined; + hasFields && isBodyMethod ? parseFields(flags.field) : undefined; + const params = + hasFields && !isBodyMethod ? buildQueryParams(flags.field) : undefined; + const headers = flags.header && flags.header.length > 0 ? parseHeaders(flags.header) @@ -271,6 +312,7 @@ export const apiCommand = buildCommand({ const response = await rawApiRequest(endpoint, { method: flags.method, body, + params, headers, }); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index c7ce7816a..4cf4f6c7a 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -58,7 +58,8 @@ function normalizePath(endpoint: string): string { type ApiRequestOptions = { method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; body?: unknown; - params?: Record; + /** Query parameters. String arrays create repeated keys (e.g., tags=1&tags=2) */ + params?: Record; /** Optional Zod schema for runtime validation of response data */ schema?: z.ZodType; }; @@ -129,12 +130,13 @@ async function createApiClient(): Promise { /** * Build URLSearchParams from an options object, filtering out undefined values. + * Supports string arrays for repeated keys (e.g., { tags: ["a", "b"] } → tags=a&tags=b). * * @param params - Key-value pairs to convert to search params * @returns URLSearchParams instance, or undefined if no valid params */ function buildSearchParams( - params?: Record + params?: Record ): URLSearchParams | undefined { if (!params) { return; @@ -142,7 +144,15 @@ function buildSearchParams( const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { + if (value === undefined) { + continue; + } + if (Array.isArray(value)) { + // Repeated keys for arrays: tags=1&tags=2&tags=3 + for (const item of value) { + searchParams.append(key, item); + } + } else { searchParams.set(key, String(value)); } } diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index bfed9232b..208fc3890 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -7,6 +7,7 @@ import { describe, expect, test } from "bun:test"; import { + buildQueryParams, parseFields, parseFieldValue, parseHeaders, @@ -192,3 +193,49 @@ describe("parseHeaders", () => { expect(parseHeaders([])).toEqual({}); }); }); + +describe("buildQueryParams", () => { + test("builds simple key=value params", () => { + expect(buildQueryParams(["status=resolved", "limit=10"])).toEqual({ + status: "resolved", + limit: "10", + }); + }); + + test("handles arrays as repeated keys", () => { + expect(buildQueryParams(["tags=[1,2,3]"])).toEqual({ + tags: ["1", "2", "3"], + }); + }); + + test("handles arrays of strings", () => { + expect(buildQueryParams(['names=["alice","bob"]'])).toEqual({ + names: ["alice", "bob"], + }); + }); + + test("converts all values to strings", () => { + expect(buildQueryParams(["count=42", "active=true", "value=null"])).toEqual( + { + count: "42", + active: "true", + value: "null", + } + ); + }); + + test("handles value with equals sign", () => { + expect(buildQueryParams(["query=a=b"])).toEqual({ query: "a=b" }); + }); + + test("throws for invalid field format", () => { + expect(() => buildQueryParams(["invalid"])).toThrow(/Invalid field format/); + expect(() => buildQueryParams(["no-equals"])).toThrow( + /Invalid field format/ + ); + }); + + test("returns empty object for empty array", () => { + expect(buildQueryParams([])).toEqual({}); + }); +}); From c61d930f10c032fc2d6f3410b0982b77fda32411 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 23 Jan 2026 20:35:36 +0000 Subject: [PATCH 2/6] test: add coverage for buildSearchParams array handling --- src/lib/api-client.ts | 3 +- test/lib/api-client.test.ts | 72 ++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 4cf4f6c7a..627fa2355 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -134,8 +134,9 @@ async function createApiClient(): Promise { * * @param params - Key-value pairs to convert to search params * @returns URLSearchParams instance, or undefined if no valid params + * @internal Exported for testing */ -function buildSearchParams( +export function buildSearchParams( params?: Record ): URLSearchParams | undefined { if (!params) { diff --git a/test/lib/api-client.test.ts b/test/lib/api-client.test.ts index 843eaa5d0..20cb53c39 100644 --- a/test/lib/api-client.test.ts +++ b/test/lib/api-client.test.ts @@ -6,7 +6,10 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { listOrganizations } from "../../src/lib/api-client.js"; +import { + buildSearchParams, + listOrganizations, +} from "../../src/lib/api-client.js"; import { CONFIG_DIR_ENV_VAR, setAuthToken } from "../../src/lib/config.js"; import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; @@ -250,3 +253,70 @@ describe("401 retry behavior", () => { expect(oauthRequests).toHaveLength(0); }); }); + +describe("buildSearchParams", () => { + test("returns undefined for undefined input", () => { + expect(buildSearchParams(undefined)).toBeUndefined(); + }); + + test("returns undefined for empty object", () => { + expect(buildSearchParams({})).toBeUndefined(); + }); + + test("returns undefined when all values are undefined", () => { + expect(buildSearchParams({ a: undefined, b: undefined })).toBeUndefined(); + }); + + test("builds params from simple key-value pairs", () => { + const result = buildSearchParams({ status: "resolved", limit: 10 }); + expect(result).toBeDefined(); + expect(result?.get("status")).toBe("resolved"); + expect(result?.get("limit")).toBe("10"); + }); + + test("skips undefined values", () => { + const result = buildSearchParams({ + status: "resolved", + query: undefined, + limit: 10, + }); + expect(result).toBeDefined(); + expect(result?.get("status")).toBe("resolved"); + expect(result?.get("limit")).toBe("10"); + expect(result?.has("query")).toBe(false); + }); + + test("handles boolean values", () => { + const result = buildSearchParams({ active: true, archived: false }); + expect(result).toBeDefined(); + expect(result?.get("active")).toBe("true"); + expect(result?.get("archived")).toBe("false"); + }); + + test("handles string arrays as repeated keys", () => { + const result = buildSearchParams({ tags: ["error", "warning", "info"] }); + expect(result).toBeDefined(); + // URLSearchParams.getAll returns all values for repeated keys + expect(result?.getAll("tags")).toEqual(["error", "warning", "info"]); + // toString shows repeated keys + expect(result?.toString()).toBe("tags=error&tags=warning&tags=info"); + }); + + test("handles mixed simple values and arrays", () => { + const result = buildSearchParams({ + status: "unresolved", + tags: ["critical", "backend"], + limit: 25, + }); + expect(result).toBeDefined(); + expect(result?.get("status")).toBe("unresolved"); + expect(result?.getAll("tags")).toEqual(["critical", "backend"]); + expect(result?.get("limit")).toBe("25"); + }); + + test("handles empty array", () => { + const result = buildSearchParams({ tags: [] }); + // Empty array produces no entries, so result should be undefined + expect(result).toBeUndefined(); + }); +}); From 6b03592947e64a5166fc02f0db82416177b2e987 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 23 Jan 2026 20:40:47 +0000 Subject: [PATCH 3/6] test: add E2E tests for --field with GET and POST requests --- test/e2e/api.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/e2e/api.test.ts b/test/e2e/api.test.ts index 188713831..23f9c456a 100644 --- a/test/e2e/api.test.ts +++ b/test/e2e/api.test.ts @@ -161,4 +161,50 @@ describe("sentry api", () => { }, { timeout: 15_000 } ); + + test( + "GET request with --field uses query parameters (not body)", + async () => { + await setAuthToken(TEST_TOKEN); + + // Use issues endpoint with query parameter - this tests that --field + // with GET request properly converts fields to query params instead of body + // (GET requests cannot have a body, so this would fail if fields went to body) + const result = await runCli( + ["api", "projects/", "--field", "query=platform:javascript"], + { + env: { [CONFIG_DIR_ENV_VAR]: testConfigDir }, + } + ); + + // Should succeed (not throw "GET/HEAD method cannot have body" error) + expect(result.exitCode).toBe(0); + const data = JSON.parse(result.stdout); + expect(Array.isArray(data)).toBe(true); + }, + { timeout: 15_000 } + ); + + test( + "POST request with --field uses request body", + async () => { + await setAuthToken(TEST_TOKEN); + + // POST to a read-only endpoint will return 405, but the important thing + // is that it doesn't fail with a client-side error about body/params + const result = await runCli( + ["api", "organizations/", "--method", "POST", "--field", "name=test"], + { + env: { [CONFIG_DIR_ENV_VAR]: testConfigDir }, + } + ); + + // Should get a server error (405 Method Not Allowed or 400 Bad Request), + // not a client-side error about body handling + expect(result.exitCode).toBe(1); + // The error should be from the API, not a TypeError about body + expect(result.stdout + result.stderr).not.toMatch(/cannot have body/i); + }, + { timeout: 15_000 } + ); }); From 6e8d848d5a64e227cf714c9749125fcb6c1d666c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 23 Jan 2026 20:46:07 +0000 Subject: [PATCH 4/6] test: extract prepareRequestOptions and add unit tests for 100% patch coverage --- src/commands/api.ts | 35 +++++++++++++++----- test/commands/api.test.ts | 70 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index b8c36d98d..4debb9aea 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -169,6 +169,31 @@ export function buildQueryParams( return result; } +/** + * Prepare request options from command flags. + * Routes fields to either query params (GET) or request body (other methods). + * + * @param method - HTTP method + * @param fields - Optional array of "key=value" field strings + * @returns Object with body and params, one of which will be undefined + * @internal Exported for testing + */ +export function prepareRequestOptions( + method: HttpMethod, + fields?: string[] +): { + body?: Record; + params?: Record; +} { + const hasFields = fields && fields.length > 0; + const isBodyMethod = method !== "GET"; + + return { + body: hasFields && isBodyMethod ? parseFields(fields) : undefined, + params: hasFields && !isBodyMethod ? buildQueryParams(fields) : undefined, + }; +} + /** * Parse header arguments into headers object. * @@ -295,15 +320,7 @@ export const apiCommand = buildCommand({ ): Promise { const { stdout } = this; - const hasFields = flags.field && flags.field.length > 0; - const isBodyMethod = flags.method !== "GET"; - - // For GET: fields become query params; for other methods: fields become body - const body = - hasFields && isBodyMethod ? parseFields(flags.field) : undefined; - const params = - hasFields && !isBodyMethod ? buildQueryParams(flags.field) : undefined; - + const { body, params } = prepareRequestOptions(flags.method, flags.field); const headers = flags.header && flags.header.length > 0 ? parseHeaders(flags.header) diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 208fc3890..6dc8204c2 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -12,6 +12,7 @@ import { parseFieldValue, parseHeaders, parseMethod, + prepareRequestOptions, setNestedValue, } from "../../src/commands/api.js"; @@ -239,3 +240,72 @@ describe("buildQueryParams", () => { expect(buildQueryParams([])).toEqual({}); }); }); + +describe("prepareRequestOptions", () => { + test("GET with no fields returns undefined for both body and params", () => { + const result = prepareRequestOptions("GET", undefined); + expect(result.body).toBeUndefined(); + expect(result.params).toBeUndefined(); + }); + + test("GET with empty fields returns undefined for both body and params", () => { + const result = prepareRequestOptions("GET", []); + expect(result.body).toBeUndefined(); + expect(result.params).toBeUndefined(); + }); + + test("GET with fields returns params (not body)", () => { + const result = prepareRequestOptions("GET", [ + "status=resolved", + "limit=10", + ]); + expect(result.body).toBeUndefined(); + expect(result.params).toEqual({ + status: "resolved", + limit: "10", + }); + }); + + test("POST with fields returns body (not params)", () => { + const result = prepareRequestOptions("POST", ["status=resolved"]); + expect(result.body).toEqual({ status: "resolved" }); + expect(result.params).toBeUndefined(); + }); + + test("PUT with fields returns body (not params)", () => { + const result = prepareRequestOptions("PUT", ["name=test"]); + expect(result.body).toEqual({ name: "test" }); + expect(result.params).toBeUndefined(); + }); + + test("PATCH with fields returns body (not params)", () => { + const result = prepareRequestOptions("PATCH", ["active=true"]); + expect(result.body).toEqual({ active: true }); + expect(result.params).toBeUndefined(); + }); + + test("DELETE with fields returns body (not params)", () => { + const result = prepareRequestOptions("DELETE", ["force=true"]); + expect(result.body).toEqual({ force: true }); + expect(result.params).toBeUndefined(); + }); + + test("POST with no fields returns undefined for both body and params", () => { + const result = prepareRequestOptions("POST", undefined); + expect(result.body).toBeUndefined(); + expect(result.params).toBeUndefined(); + }); + + test("GET with array field converts to string array in params", () => { + const result = prepareRequestOptions("GET", ["tags=[1,2,3]"]); + expect(result.params).toEqual({ tags: ["1", "2", "3"] }); + }); + + test("POST with nested fields creates nested body object", () => { + const result = prepareRequestOptions("POST", [ + "user.name=John", + "user.age=30", + ]); + expect(result.body).toEqual({ user: { name: "John", age: 30 } }); + }); +}); From 43829fd2f2079bb1140c07d28579d5cd831f62a0 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 23 Jan 2026 23:05:30 +0000 Subject: [PATCH 5/6] fix: JSON stringify objects in query params to avoid '[object Object]' --- src/commands/api.ts | 3 +++ test/commands/api.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/commands/api.ts b/src/commands/api.ts index 4debb9aea..242451fdc 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -159,8 +159,11 @@ export function buildQueryParams( const value = parseFieldValue(rawValue); // Handle arrays by creating string[] for repeated keys + // Handle objects by JSON stringifying them (avoid "[object Object]") if (Array.isArray(value)) { result[key] = value.map(String); + } else if (typeof value === "object" && value !== null) { + result[key] = JSON.stringify(value); } else { result[key] = String(value); } diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 6dc8204c2..05028d6db 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -239,6 +239,18 @@ describe("buildQueryParams", () => { test("returns empty object for empty array", () => { expect(buildQueryParams([])).toEqual({}); }); + + test("handles objects by JSON stringifying them", () => { + expect(buildQueryParams(['data={"key":"value"}'])).toEqual({ + data: '{"key":"value"}', + }); + }); + + test("handles nested objects by JSON stringifying them", () => { + expect(buildQueryParams(['filter={"user":{"name":"john"}}'])).toEqual({ + filter: '{"user":{"name":"john"}}', + }); + }); }); describe("prepareRequestOptions", () => { From 4796ca5a1548a6cdbd3e3f2a8174c07f466cc703 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 23 Jan 2026 23:14:49 +0000 Subject: [PATCH 6/6] fix: JSON stringify objects within arrays to avoid '[object Object]' --- src/commands/api.ts | 19 ++++++++++++++----- test/commands/api.test.ts | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index 242451fdc..2e1046f00 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -133,6 +133,17 @@ export function parseFields(fields: string[]): Record { return result; } +/** + * Convert a value to string, JSON-stringifying objects to avoid "[object Object]". + * @internal + */ +function stringifyValue(value: unknown): string { + if (typeof value === "object" && value !== null) { + return JSON.stringify(value); + } + return String(value); +} + /** * Build query parameters from field strings for GET requests. * Unlike parseFields(), this produces a flat structure suitable for URL query strings. @@ -159,13 +170,11 @@ export function buildQueryParams( const value = parseFieldValue(rawValue); // Handle arrays by creating string[] for repeated keys - // Handle objects by JSON stringifying them (avoid "[object Object]") + // Use stringifyValue to handle objects (avoid "[object Object]") if (Array.isArray(value)) { - result[key] = value.map(String); - } else if (typeof value === "object" && value !== null) { - result[key] = JSON.stringify(value); + result[key] = value.map(stringifyValue); } else { - result[key] = String(value); + result[key] = stringifyValue(value); } } diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 05028d6db..4df059bbe 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -251,6 +251,20 @@ describe("buildQueryParams", () => { filter: '{"user":{"name":"john"}}', }); }); + + test("handles arrays of objects by JSON stringifying each element", () => { + expect( + buildQueryParams(['filters=[{"key":"value"},{"key2":"value2"}]']) + ).toEqual({ + filters: ['{"key":"value"}', '{"key2":"value2"}'], + }); + }); + + test("handles mixed arrays with objects and primitives", () => { + expect(buildQueryParams(['data=[1,{"obj":true},"string"]'])).toEqual({ + data: ["1", '{"obj":true}', "string"], + }); + }); }); describe("prepareRequestOptions", () => {