From 41362c698940ed39f2279f0d0de8f7008fbbc740 Mon Sep 17 00:00:00 2001 From: snow-ghost Date: Wed, 18 Mar 2026 00:51:48 +0500 Subject: [PATCH] - Added query and path parameter serialization from OpenAPI / Swagger metadata - Added support for array and object-style query parameters such as `deepObject`, `pipeDelimited`, and Swagger 2 collection formats - Added automatic API base URL detection from OAS `servers` - Added automatic API base URL detection from Swagger 2 `host`, `basePath`, and `schemes` - Added operation-level and path-level server overrides when the spec defines endpoint-specific targets --- README.md | 10 ++ src/cli.ts | 144 +++++++++++++++++++++++++-- src/openapi-to-commands.ts | 80 +++++++++++++++ tests/cli.test.ts | 160 ++++++++++++++++++++++++++++++ tests/openapi-to-commands.test.ts | 42 ++++++++ 5 files changed, 427 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8c49773..be244f8 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,16 @@ npx openapi-to-cli onboard \ In practice this improves compatibility with APIs that define inputs outside simple path/query parameters, especially for `POST`, `PUT`, and `PATCH` operations. +### Better request generation + +`ocli` now uses more request metadata from the specification when building real HTTP calls: + +- query and path parameter serialization from OpenAPI / Swagger metadata +- support for array and object-style query parameters such as `deepObject`, `pipeDelimited`, and Swagger 2 collection formats +- operation-level and path-level server overrides when the spec defines different targets for different endpoints + +In practice this improves compatibility with APIs that rely on non-trivial parameter encoding or per-operation server definitions. + ### Command search ```bash diff --git a/src/cli.ts b/src/cli.ts index 9c5b8e5..4b7b4ea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -229,26 +229,25 @@ function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record opt.location === "query") .forEach((opt) => { const value = flags[opt.name]; if (value !== undefined) { - queryParams.set(opt.name, value); + queryParts.push(...serializeQueryParameter(opt, value)); } }); - const queryString = queryParams.toString(); - if (queryString) { - url += url.includes("?") ? `&${queryString}` : `?${queryString}`; + if (queryParts.length > 0) { + url += url.includes("?") ? `&${queryParts.join("&")}` : `?${queryParts.join("&")}`; } return url; @@ -351,6 +350,129 @@ function buildRequestPayload( return {}; } +function serializePathParameter(option: CliCommandOption, rawValue: string): string { + const value = parseStructuredParameterValue(option, rawValue); + + if (Array.isArray(value)) { + const encoded = value.map((item) => encodeURIComponent(String(item))); + const style = option.style ?? "simple"; + const explode = option.explode ?? false; + + if (style === "label") { + return explode ? `.${encoded.join(".")}` : `.${encoded.join(",")}`; + } + + if (style === "matrix") { + return explode + ? encoded.map((item) => `;${encodeURIComponent(option.name)}=${item}`).join("") + : `;${encodeURIComponent(option.name)}=${encoded.join(",")}`; + } + + return encoded.join(","); + } + + if (value && typeof value === "object") { + const entries = Object.entries(value as Record).map( + ([key, item]) => [encodeURIComponent(key), encodeURIComponent(String(item))] as const + ); + const style = option.style ?? "simple"; + const explode = option.explode ?? false; + + if (style === "label") { + return explode + ? `.${entries.map(([key, item]) => `${key}=${item}`).join(".")}` + : `.${entries.flat().join(",")}`; + } + + if (style === "matrix") { + return explode + ? entries.map(([key, item]) => `;${key}=${item}`).join("") + : `;${encodeURIComponent(option.name)}=${entries.flat().join(",")}`; + } + + return explode + ? entries.map(([key, item]) => `${key}=${item}`).join(",") + : entries.flat().join(","); + } + + return encodeURIComponent(String(value)); +} + +function serializeQueryParameter(option: CliCommandOption, rawValue: string): string[] { + const value = parseStructuredParameterValue(option, rawValue); + const encodedName = encodeURIComponent(option.name); + + if (Array.isArray(value)) { + const encodedValues = value.map((item) => encodeURIComponent(String(item))); + + if (option.collectionFormat === "multi") { + return encodedValues.map((item) => `${encodedName}=${item}`); + } + + const joiner = option.collectionFormat === "ssv" + ? " " + : option.collectionFormat === "tsv" + ? "\t" + : option.collectionFormat === "pipes" + ? "|" + : option.style === "spaceDelimited" + ? " " + : option.style === "pipeDelimited" + ? "|" + : ","; + + const explode = option.collectionFormat + ? option.collectionFormat === "multi" + : option.explode ?? true; + + if (explode && joiner === ",") { + return encodedValues.map((item) => `${encodedName}=${item}`); + } + + return [`${encodedName}=${encodedValues.join(encodeURIComponent(joiner))}`]; + } + + if (value && typeof value === "object") { + const entries = Object.entries(value as Record).map( + ([key, item]) => [encodeURIComponent(key), encodeURIComponent(String(item))] as const + ); + const style = option.style ?? "form"; + const explode = option.explode ?? true; + + if (style === "deepObject") { + return entries.map(([key, item]) => `${encodedName}%5B${key}%5D=${item}`); + } + + if (explode) { + return entries.map(([key, item]) => `${key}=${item}`); + } + + return [`${encodedName}=${entries.flat().join(",")}`]; + } + + return [`${encodedName}=${encodeURIComponent(String(value))}`]; +} + +function parseStructuredParameterValue(option: CliCommandOption, rawValue: string): unknown { + if (option.schemaType === "array") { + const trimmed = rawValue.trim(); + if (trimmed.startsWith("[")) { + return parseBodyFlagValue(rawValue); + } + return rawValue.split(",").map((item) => item.trim()).filter((item) => item.length > 0); + } + + if (option.schemaType === "object") { + const trimmed = rawValue.trim(); + if (!trimmed.startsWith("{")) { + throw new Error(`Object parameter --${option.name} expects JSON object value`); + } + return parseBodyFlagValue(rawValue); + } + + return rawValue; +} + export async function run(argv: string[], options?: RunOptions): Promise { const cwd = options?.cwd ?? process.cwd(); const configLocator = options?.configLocator ?? new ConfigLocator(); @@ -411,7 +533,11 @@ export async function run(argv: string[], options?: RunOptions): Promise { const addProfileOptions = (y: ReturnType) => y - .option("api-base-url", { type: "string", demandOption: true }) + .option("api-base-url", { + type: "string", + demandOption: true, + description: "Base URL for API requests.", + }) .option("openapi-spec", { type: "string", demandOption: true }) .option("api-basic-auth", { type: "string", default: "" }) .option("api-bearer-token", { type: "string", default: "" }) diff --git a/src/openapi-to-commands.ts b/src/openapi-to-commands.ts index 186872b..ea66df4 100644 --- a/src/openapi-to-commands.ts +++ b/src/openapi-to-commands.ts @@ -8,6 +8,9 @@ export interface CliCommandOption { required: boolean; schemaType?: string; description?: string; + style?: string; + explode?: boolean; + collectionFormat?: string; } export interface CliCommand { @@ -17,6 +20,7 @@ export interface CliCommand { options: CliCommandOption[]; description?: string; requestContentType?: string; + serverUrl?: string; } type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "head" | "options" | "trace"; @@ -28,12 +32,14 @@ interface PathOperation { path: string; method: HttpMethod; pathParameters?: unknown[]; + pathServers?: unknown[]; operation: { summary?: string; description?: string; parameters?: unknown[]; requestBody?: unknown; consumes?: string[]; + servers?: unknown[]; }; } @@ -44,6 +50,9 @@ interface ParameterLike { schema?: SchemaLike; type?: string; description?: string; + style?: string; + explode?: boolean; + collectionFormat?: string; } interface SchemaLike { @@ -60,6 +69,11 @@ interface RequestBodyLike { content?: Record; } +interface ServerLike { + url?: string; + variables?: Record; +} + export class OpenapiToCommands { buildCommands(spec: OpenapiSpecLike, profile: Profile): CliCommand[] { const operations = this.collectOperations(spec); @@ -94,6 +108,7 @@ export class OpenapiToCommands { for (const pathKey of Object.keys(paths)) { const pathItem = paths[pathKey]; const pathParameters = Array.isArray(pathItem?.parameters) ? pathItem.parameters : []; + const pathServers = Array.isArray(pathItem?.servers) ? pathItem.servers : []; for (const method of methods) { const op = pathItem[method]; if (op) { @@ -101,6 +116,7 @@ export class OpenapiToCommands { path: pathKey, method, pathParameters, + pathServers, operation: op, }); } @@ -158,6 +174,7 @@ export class OpenapiToCommands { const name = multipleMethods ? `${baseName}_${op.method}` : baseName; const { options, requestContentType } = this.extractOptions(op, spec); const description = op.operation.summary ?? op.operation.description; + const serverUrl = this.resolveOperationServerUrl(spec, op); commands.push({ name, @@ -166,6 +183,7 @@ export class OpenapiToCommands { options, description, requestContentType, + serverUrl, }); } } @@ -214,6 +232,9 @@ export class OpenapiToCommands { required: param.in === "path" ? true : Boolean(param.required), schemaType: this.getParameterSchemaType(param), description: param.description, + style: param.style, + explode: param.explode, + collectionFormat: param.collectionFormat, }); } @@ -418,6 +439,65 @@ export class OpenapiToCommands { return param.type; } + private resolveOperationServerUrl(spec: OpenapiSpecLike, op: PathOperation): string | undefined { + const rootBase = this.resolveServers(Array.isArray(spec?.servers) ? spec.servers : undefined); + const operationServer = this.resolveServers(op.operation.servers, rootBase); + if (operationServer) { + return operationServer; + } + + const pathServer = this.resolveServers(op.pathServers, rootBase); + if (pathServer) { + return pathServer; + } + + if (rootBase) { + return rootBase; + } + + return this.resolveSwagger2BaseUrl(spec); + } + + private resolveServers(rawServers?: unknown[], relativeTo?: string): string | undefined { + if (!Array.isArray(rawServers) || rawServers.length === 0) { + return undefined; + } + + const server = this.resolveValue(rawServers[0], {}) as ServerLike; + if (!server?.url) { + return undefined; + } + + let url = server.url; + const variables = server.variables ?? {}; + for (const [name, variable] of Object.entries(variables)) { + url = url.replace(new RegExp(`\\{${name}\\}`, "g"), variable.default ?? ""); + } + + if (/^https?:\/\//i.test(url)) { + return url.replace(/\/+$/, ""); + } + + if (relativeTo) { + return new URL(url, relativeTo.endsWith("/") ? relativeTo : `${relativeTo}/`).toString().replace(/\/+$/, ""); + } + + return undefined; + } + + private resolveSwagger2BaseUrl(spec: OpenapiSpecLike): string | undefined { + const host = typeof spec?.host === "string" ? spec.host : ""; + if (!host) { + return undefined; + } + + const schemes = Array.isArray(spec?.schemes) && spec.schemes.length > 0 + ? spec.schemes + : ["https"]; + const basePath = typeof spec?.basePath === "string" ? spec.basePath : ""; + return `${schemes[0]}://${host}${basePath}`.replace(/\/+$/, ""); + } + private pickRequestBodyContentType(requestBody: RequestBodyLike): string | undefined { const content = requestBody.content ?? {}; const contentTypes = Object.keys(content); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 1fe2dd6..3d25481 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -948,6 +948,166 @@ describe("cli", () => { expect(config.headers.Cookie).toBe("session_id=cookie-abc"); }); + it("serializes query arrays and deepObject parameters from spec metadata", async () => { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cachePath = `${localDir}/specs/serialization-api.json`; + + const spec = { + openapi: "3.0.0", + paths: { + "/reports/{report_id}": { + get: { + parameters: [ + { + name: "report_id", + in: "path", + required: true, + schema: { type: "array" }, + style: "label", + explode: true, + }, + { + name: "tags", + in: "query", + schema: { type: "array" }, + style: "pipeDelimited", + explode: false, + }, + { + name: "filter", + in: "query", + schema: { type: "object" }, + style: "deepObject", + explode: true, + }, + ], + }, + }, + }, + }; + + const iniContent = [ + "[serialization-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`]: "serialization-api", + }); + + await run( + [ + "reports_report_id", + "--report_id", '["r1","r2"]', + "--tags", '["daily","ops"]', + "--filter", '{"status":"open","owner":"alice"}', + ], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + const config = capturedConfigs[0] as { url: string }; + expect(config.url).toBe( + "https://api.example.com/reports/.r1.r2?tags=daily%7Cops&filter%5Bstatus%5D=open&filter%5Bowner%5D=alice" + ); + }); + + it("requires --api-base-url when onboarding", async () => { + const spec = { + openapi: "3.0.0", + servers: [{ url: "https://api.example.com/root" }], + paths: {}, + }; + + const fs = new MemoryFs({ + "/project/openapi.json": JSON.stringify(spec), + }); + const locator = new ConfigLocator({ fs, homeDir }); + const profileStore = new ProfileStore({ fs, locator }); + const openapiLoader = new OpenapiLoader({ fs }); + + await expect( + run( + [ + "onboard", + "--openapi-spec", + "/project/openapi.json", + ], + { cwd, profileStore, openapiLoader } + ) + ).rejects.toThrow(/api-base-url/); + }); + + it("uses operation-level server URL override when executing commands", async () => { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cachePath = `${localDir}/specs/server-override-api.json`; + + const spec = { + openapi: "3.0.0", + servers: [{ url: "https://root.example.com/api" }], + paths: { + "/messages": { + get: { + servers: [{ url: "https://ops.example.com/custom" }], + summary: "List messages", + }, + }, + }, + }; + + const iniContent = [ + "[server-override-api]", + "api_base_url = https://fallback.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`]: "server-override-api", + }); + + await run( + ["messages"], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + const config = capturedConfigs[0] as { url: string }; + expect(config.url).toBe("https://ops.example.com/custom/messages"); + }); + it("sends custom headers from profile in API requests", async () => { const localDir = `${cwd}/.ocli`; const profilesPath = `${localDir}/profiles.ini`; diff --git a/tests/openapi-to-commands.test.ts b/tests/openapi-to-commands.test.ts index 0883f1a..5648d69 100644 --- a/tests/openapi-to-commands.test.ts +++ b/tests/openapi-to-commands.test.ts @@ -321,4 +321,46 @@ describe("OpenapiToCommands", () => { "path:file_id", ]); }); + + it("extracts serialization metadata and effective server URL", () => { + const spec: OpenapiSpecLike = { + openapi: "3.0.0", + servers: [{ url: "https://root.example.com/api" }], + paths: { + "/reports/{report_id}": { + servers: [{ url: "/v2" }], + get: { + servers: [{ url: "https://ops.example.com/custom" }], + parameters: [ + { + name: "report_id", + in: "path", + required: true, + schema: { type: "array" }, + style: "label", + explode: true, + }, + { + name: "filters", + in: "query", + schema: { type: "object" }, + style: "deepObject", + explode: true, + }, + ], + }, + }, + }, + }; + + const commands = openapiToCommands.buildCommands(spec, baseProfile); + expect(commands).toHaveLength(1); + expect(commands[0].serverUrl).toBe("https://ops.example.com/custom"); + + const reportId = commands[0].options.find((o) => o.name === "report_id"); + const filters = commands[0].options.find((o) => o.name === "filters"); + expect(reportId?.style).toBe("label"); + expect(reportId?.explode).toBe(true); + expect(filters?.style).toBe("deepObject"); + }); });