Skip to content

Commit 99bf3c4

Browse files
committed
- 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
1 parent b25e99c commit 99bf3c4

5 files changed

Lines changed: 475 additions & 12 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,18 @@ npx openapi-to-cli onboard \
7777

7878
In practice this improves compatibility with APIs that define inputs outside simple path/query parameters, especially for `POST`, `PUT`, and `PATCH` operations.
7979

80+
### Better request generation
81+
82+
`ocli` now uses more request metadata from the specification when building real HTTP calls:
83+
84+
- query and path parameter serialization from OpenAPI / Swagger metadata
85+
- support for array and object-style query parameters such as `deepObject`, `pipeDelimited`, and Swagger 2 collection formats
86+
- automatic API base URL detection from OAS `servers`
87+
- automatic API base URL detection from Swagger 2 `host`, `basePath`, and `schemes`
88+
- operation-level and path-level server overrides when the spec defines different targets for different endpoints
89+
90+
In practice this reduces the amount of manual profile setup and improves compatibility with APIs that rely on non-trivial parameter encoding or per-operation server definitions.
91+
8092
### Command search
8193

8294
```bash

src/cli.ts

Lines changed: 177 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const defaultHttpClient: HttpClient = {
3434
};
3535

3636
interface AddProfileArgs {
37-
"api-base-url": string;
37+
"api-base-url"?: string;
3838
"openapi-spec": string;
3939
"api-basic-auth"?: string;
4040
"api-bearer-token"?: string;
@@ -229,26 +229,25 @@ function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record<st
229229
const value = flags[opt.name];
230230
if (value !== undefined) {
231231
const token = `{${opt.name}}`;
232-
pathValue = pathValue.replace(token, encodeURIComponent(value));
232+
pathValue = pathValue.replace(token, serializePathParameter(opt, value));
233233
}
234234
});
235235

236-
const baseUrl = profile.apiBaseUrl.replace(/\/+$/, "");
237-
let url = `${baseUrl}${pathValue}`;
236+
const baseUrl = (command.serverUrl ?? profile.apiBaseUrl).replace(/\/+$/, "");
237+
let url = baseUrl ? `${baseUrl}${pathValue}` : pathValue;
238238

239-
const queryParams = new URLSearchParams();
239+
const queryParts: string[] = [];
240240
command.options
241241
.filter((opt) => opt.location === "query")
242242
.forEach((opt) => {
243243
const value = flags[opt.name];
244244
if (value !== undefined) {
245-
queryParams.set(opt.name, value);
245+
queryParts.push(...serializeQueryParameter(opt, value));
246246
}
247247
});
248248

249-
const queryString = queryParams.toString();
250-
if (queryString) {
251-
url += url.includes("?") ? `&${queryString}` : `?${queryString}`;
249+
if (queryParts.length > 0) {
250+
url += url.includes("?") ? `&${queryParts.join("&")}` : `?${queryParts.join("&")}`;
252251
}
253252

254253
return url;
@@ -351,6 +350,164 @@ function buildRequestPayload(
351350
return {};
352351
}
353352

353+
function serializePathParameter(option: CliCommandOption, rawValue: string): string {
354+
const value = parseStructuredParameterValue(option, rawValue);
355+
356+
if (Array.isArray(value)) {
357+
const encoded = value.map((item) => encodeURIComponent(String(item)));
358+
const style = option.style ?? "simple";
359+
const explode = option.explode ?? false;
360+
361+
if (style === "label") {
362+
return explode ? `.${encoded.join(".")}` : `.${encoded.join(",")}`;
363+
}
364+
365+
if (style === "matrix") {
366+
return explode
367+
? encoded.map((item) => `;${encodeURIComponent(option.name)}=${item}`).join("")
368+
: `;${encodeURIComponent(option.name)}=${encoded.join(",")}`;
369+
}
370+
371+
return encoded.join(",");
372+
}
373+
374+
if (value && typeof value === "object") {
375+
const entries = Object.entries(value as Record<string, unknown>).map(
376+
([key, item]) => [encodeURIComponent(key), encodeURIComponent(String(item))] as const
377+
);
378+
const style = option.style ?? "simple";
379+
const explode = option.explode ?? false;
380+
381+
if (style === "label") {
382+
return explode
383+
? `.${entries.map(([key, item]) => `${key}=${item}`).join(".")}`
384+
: `.${entries.flat().join(",")}`;
385+
}
386+
387+
if (style === "matrix") {
388+
return explode
389+
? entries.map(([key, item]) => `;${key}=${item}`).join("")
390+
: `;${encodeURIComponent(option.name)}=${entries.flat().join(",")}`;
391+
}
392+
393+
return explode
394+
? entries.map(([key, item]) => `${key}=${item}`).join(",")
395+
: entries.flat().join(",");
396+
}
397+
398+
return encodeURIComponent(String(value));
399+
}
400+
401+
function serializeQueryParameter(option: CliCommandOption, rawValue: string): string[] {
402+
const value = parseStructuredParameterValue(option, rawValue);
403+
const encodedName = encodeURIComponent(option.name);
404+
405+
if (Array.isArray(value)) {
406+
const encodedValues = value.map((item) => encodeURIComponent(String(item)));
407+
408+
if (option.collectionFormat === "multi") {
409+
return encodedValues.map((item) => `${encodedName}=${item}`);
410+
}
411+
412+
const joiner = option.collectionFormat === "ssv"
413+
? " "
414+
: option.collectionFormat === "tsv"
415+
? "\t"
416+
: option.collectionFormat === "pipes"
417+
? "|"
418+
: option.style === "spaceDelimited"
419+
? " "
420+
: option.style === "pipeDelimited"
421+
? "|"
422+
: ",";
423+
424+
const explode = option.collectionFormat
425+
? option.collectionFormat === "multi"
426+
: option.explode ?? true;
427+
428+
if (explode && joiner === ",") {
429+
return encodedValues.map((item) => `${encodedName}=${item}`);
430+
}
431+
432+
return [`${encodedName}=${encodedValues.join(encodeURIComponent(joiner))}`];
433+
}
434+
435+
if (value && typeof value === "object") {
436+
const entries = Object.entries(value as Record<string, unknown>).map(
437+
([key, item]) => [encodeURIComponent(key), encodeURIComponent(String(item))] as const
438+
);
439+
const style = option.style ?? "form";
440+
const explode = option.explode ?? true;
441+
442+
if (style === "deepObject") {
443+
return entries.map(([key, item]) => `${encodedName}%5B${key}%5D=${item}`);
444+
}
445+
446+
if (explode) {
447+
return entries.map(([key, item]) => `${key}=${item}`);
448+
}
449+
450+
return [`${encodedName}=${entries.flat().join(",")}`];
451+
}
452+
453+
return [`${encodedName}=${encodeURIComponent(String(value))}`];
454+
}
455+
456+
function parseStructuredParameterValue(option: CliCommandOption, rawValue: string): unknown {
457+
if (option.schemaType === "array") {
458+
const trimmed = rawValue.trim();
459+
if (trimmed.startsWith("[")) {
460+
return parseBodyFlagValue(rawValue);
461+
}
462+
return rawValue.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
463+
}
464+
465+
if (option.schemaType === "object") {
466+
const trimmed = rawValue.trim();
467+
if (!trimmed.startsWith("{")) {
468+
throw new Error(`Object parameter --${option.name} expects JSON object value`);
469+
}
470+
return parseBodyFlagValue(rawValue);
471+
}
472+
473+
return rawValue;
474+
}
475+
476+
function deriveApiBaseUrlFromSpec(spec: unknown): string {
477+
if (!spec || typeof spec !== "object") {
478+
return "";
479+
}
480+
481+
const record = spec as Record<string, unknown>;
482+
const servers = record.servers;
483+
if (Array.isArray(servers) && servers.length > 0) {
484+
const server = servers[0] as Record<string, unknown>;
485+
if (typeof server?.url === "string" && server.url.length > 0) {
486+
let url = server.url;
487+
const variables = server.variables as Record<string, { default?: string }> | undefined;
488+
if (variables) {
489+
Object.entries(variables).forEach(([name, variable]) => {
490+
url = url.replace(new RegExp(`\\{${name}\\}`, "g"), variable?.default ?? "");
491+
});
492+
}
493+
if (/^https?:\/\//i.test(url)) {
494+
return url.replace(/\/+$/, "");
495+
}
496+
}
497+
}
498+
499+
const host = typeof record.host === "string" ? record.host : "";
500+
if (!host) {
501+
return "";
502+
}
503+
504+
const schemes = Array.isArray(record.schemes) && record.schemes.length > 0
505+
? record.schemes as string[]
506+
: ["https"];
507+
const basePath = typeof record.basePath === "string" ? record.basePath : "";
508+
return `${schemes[0]}://${host}${basePath}`.replace(/\/+$/, "");
509+
}
510+
354511
export async function run(argv: string[], options?: RunOptions): Promise<void> {
355512
const cwd = options?.cwd ?? process.cwd();
356513
const configLocator = options?.configLocator ?? new ConfigLocator();
@@ -394,7 +551,7 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
394551

395552
const profile: Profile = {
396553
name: profileName,
397-
apiBaseUrl: args["api-base-url"],
554+
apiBaseUrl: args["api-base-url"] ?? "",
398555
apiBasicAuth: args["api-basic-auth"] ?? "",
399556
apiBearerToken: args["api-bearer-token"] ?? "",
400557
openapiSpecSource: args["openapi-spec"],
@@ -405,13 +562,21 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
405562
customHeaders,
406563
};
407564

408-
await openapiLoader.loadSpec(profile, { refresh: true });
565+
const spec = await openapiLoader.loadSpec(profile, { refresh: true });
566+
profile.apiBaseUrl = profile.apiBaseUrl || deriveApiBaseUrlFromSpec(spec);
567+
if (!profile.apiBaseUrl) {
568+
throw new Error("Unable to determine API base URL. Provide --api-base-url explicitly.");
569+
}
409570
profileStore.saveProfile(cwd, profile, { makeCurrent: true });
410571
};
411572

412573
const addProfileOptions = (y: ReturnType<typeof yargs>) =>
413574
y
414-
.option("api-base-url", { type: "string", demandOption: true })
575+
.option("api-base-url", {
576+
type: "string",
577+
demandOption: false,
578+
description: "Base URL for API requests. If omitted, derived from spec servers/host/basePath when possible.",
579+
})
415580
.option("openapi-spec", { type: "string", demandOption: true })
416581
.option("api-basic-auth", { type: "string", default: "" })
417582
.option("api-bearer-token", { type: "string", default: "" })

0 commit comments

Comments
 (0)