From 3936aa7cf3ba1d4e5c5731118d2fa4eca6cfc1c9 Mon Sep 17 00:00:00 2001 From: Sergey Berezhnoy Date: Mon, 16 Mar 2026 20:51:00 +0300 Subject: [PATCH] fix: parse nested JSON values for body flags Body flag values starting with `{` or `[` are now parsed as JSON objects/arrays instead of being sent as raw strings. Boolean literals (`true`/`false`) and `null` are also parsed into their JSON equivalents. Invalid JSON-like values throw a clear error. Plain string values are preserved unchanged. This fixes endpoints that expect nested JSON payloads (e.g. objects, arrays) in request bodies assembled from CLI flags. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli.ts | 22 +++++- tests/cli.test.ts | 174 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index cdde035..a0187aa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -154,11 +154,11 @@ async function runApiCommand( const headers = buildHeaders(profile); const knownOptionNames = new Set(command.options.map((o) => o.name)); - const body: Record = {}; + const body: Record = {}; Object.keys(flags).forEach((key) => { if (!knownOptionNames.has(key)) { - body[key] = flags[key]; + body[key] = parseBodyFlagValue(flags[key]); } }); @@ -179,6 +179,24 @@ async function runApiCommand( stdout(`${JSON.stringify(response.data, null, 2)}\n`); } +function parseBodyFlagValue(value: string): unknown { + const trimmed = value.trim(); + + if (trimmed === "true") return true; + if (trimmed === "false") return false; + if (trimmed === "null") return null; + + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return JSON.parse(trimmed); + } catch { + throw new Error(`Invalid JSON body value: ${trimmed}`); + } + } + + return value; +} + function parseArgs(args: string[]): { flags: Record; positional: string[] } { const flags: Record = {}; const positional: string[] = []; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 7212268..07b2e00 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -576,6 +576,180 @@ describe("cli", () => { expect(out).toContain('"ok": true'); }); + // --- Body flag JSON parsing tests --- + + function createPostApiDeps() { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cachePath = `${localDir}/specs/body-api.json`; + + const spec = { + openapi: "3.0.0", + paths: { + "/{org_slug}/{repo_slug}/ci/workflows/{workflow_name}/trigger": { + post: { + summary: "Trigger workflow", + parameters: [ + { name: "org_slug", in: "path", required: true, schema: { type: "string" } }, + { name: "repo_slug", in: "path", required: true, schema: { type: "string" } }, + { name: "workflow_name", in: "path", required: true, schema: { type: "string" } }, + ], + }, + }, + }, + }; + + const iniContent = [ + "[body-api]", + "api_base_url = https://api.example.com", + "api_basic_auth = ", + "api_bearer_token = tok", + "openapi_spec_source = /spec.json", + `openapi_spec_cache = ${cachePath}`, + "include_endpoints = ", + "exclude_endpoints = ", + "", + ].join("\n"); + + const capturedConfigs: unknown[] = []; + const fakeHttpClient: HttpClient = { + request: async (config: any) => { + capturedConfigs.push(config); + return { status: 200, statusText: "OK", headers: {}, config, data: { ok: true } }; + }, + }; + + const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, { + [profilesPath]: iniContent, + [cachePath]: JSON.stringify(spec), + [`${localDir}/current`]: "body-api", + }); + + return { profileStore, openapiLoader, fakeHttpClient, capturedConfigs }; + } + + it("parses JSON object body flags before sending request", async () => { + const { profileStore, openapiLoader, fakeHttpClient, capturedConfigs } = createPostApiDeps(); + + await run( + [ + "org_slug_repo_slug_ci_workflows_workflow_name_trigger", + "--org_slug", "myorg", + "--repo_slug", "myrepo", + "--workflow_name", "deploy", + "--revision", "main", + "--workflow_revision", "main", + "--input", '{"values":[{"name":"FOO","value":"bar"}]}', + ], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + expect(capturedConfigs).toHaveLength(1); + const config = capturedConfigs[0] as { data: Record }; + expect(config.data).toEqual({ + revision: "main", + workflow_revision: "main", + input: { + values: [{ name: "FOO", value: "bar" }], + }, + }); + }); + + it("parses boolean body flags before sending request", async () => { + const { profileStore, openapiLoader, fakeHttpClient, capturedConfigs } = createPostApiDeps(); + + await run( + [ + "org_slug_repo_slug_ci_workflows_workflow_name_trigger", + "--org_slug", "myorg", + "--repo_slug", "myrepo", + "--workflow_name", "deploy", + "--shared", "true", + "--draft", "false", + ], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + expect(capturedConfigs).toHaveLength(1); + const config = capturedConfigs[0] as { data: Record }; + expect(config.data.shared).toBe(true); + expect(config.data.draft).toBe(false); + }); + + it("parses null body flags before sending request", async () => { + const { profileStore, openapiLoader, fakeHttpClient, capturedConfigs } = createPostApiDeps(); + + await run( + [ + "org_slug_repo_slug_ci_workflows_workflow_name_trigger", + "--org_slug", "myorg", + "--repo_slug", "myrepo", + "--workflow_name", "deploy", + "--description", "null", + ], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + expect(capturedConfigs).toHaveLength(1); + const config = capturedConfigs[0] as { data: Record }; + expect(config.data.description).toBeNull(); + }); + + it("parses JSON array body flags before sending request", async () => { + const { profileStore, openapiLoader, fakeHttpClient, capturedConfigs } = createPostApiDeps(); + + await run( + [ + "org_slug_repo_slug_ci_workflows_workflow_name_trigger", + "--org_slug", "myorg", + "--repo_slug", "myrepo", + "--workflow_name", "deploy", + "--tags", '["ci","deploy"]', + ], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + expect(capturedConfigs).toHaveLength(1); + const config = capturedConfigs[0] as { data: Record }; + expect(config.data.tags).toEqual(["ci", "deploy"]); + }); + + it("preserves plain string body flags", async () => { + const { profileStore, openapiLoader, fakeHttpClient, capturedConfigs } = createPostApiDeps(); + + await run( + [ + "org_slug_repo_slug_ci_workflows_workflow_name_trigger", + "--org_slug", "myorg", + "--repo_slug", "myrepo", + "--workflow_name", "deploy", + "--revision", "main", + ], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + expect(capturedConfigs).toHaveLength(1); + const config = capturedConfigs[0] as { data: Record }; + expect(config.data.revision).toBe("main"); + }); + + it("throws on invalid JSON-like body flags", async () => { + const { profileStore, openapiLoader, fakeHttpClient } = createPostApiDeps(); + + await expect( + run( + [ + "org_slug_repo_slug_ci_workflows_workflow_name_trigger", + "--org_slug", "myorg", + "--repo_slug", "myrepo", + "--workflow_name", "deploy", + "--input", '{"values":', + ], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ) + ).rejects.toThrow("Invalid JSON body value"); + }); + it("sends custom headers from profile in API requests", async () => { const localDir = `${cwd}/.ocli`; const profilesPath = `${localDir}/profiles.ini`;