Skip to content

Commit 406f7a1

Browse files
Merge pull request #10 from EvilFreelancer/feat/openapi-better-impact
Feat/openapi better impact
2 parents b25e99c + 41362c6 commit 406f7a1

5 files changed

Lines changed: 427 additions & 9 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ 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+
- operation-level and path-level server overrides when the spec defines different targets for different endpoints
87+
88+
In practice this improves compatibility with APIs that rely on non-trivial parameter encoding or per-operation server definitions.
89+
8090
### Command search
8191

8292
```bash

src/cli.ts

Lines changed: 135 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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,129 @@ 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+
354476
export async function run(argv: string[], options?: RunOptions): Promise<void> {
355477
const cwd = options?.cwd ?? process.cwd();
356478
const configLocator = options?.configLocator ?? new ConfigLocator();
@@ -411,7 +533,11 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
411533

412534
const addProfileOptions = (y: ReturnType<typeof yargs>) =>
413535
y
414-
.option("api-base-url", { type: "string", demandOption: true })
536+
.option("api-base-url", {
537+
type: "string",
538+
demandOption: true,
539+
description: "Base URL for API requests.",
540+
})
415541
.option("openapi-spec", { type: "string", demandOption: true })
416542
.option("api-basic-auth", { type: "string", default: "" })
417543
.option("api-bearer-token", { type: "string", default: "" })

src/openapi-to-commands.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export interface CliCommandOption {
88
required: boolean;
99
schemaType?: string;
1010
description?: string;
11+
style?: string;
12+
explode?: boolean;
13+
collectionFormat?: string;
1114
}
1215

1316
export interface CliCommand {
@@ -17,6 +20,7 @@ export interface CliCommand {
1720
options: CliCommandOption[];
1821
description?: string;
1922
requestContentType?: string;
23+
serverUrl?: string;
2024
}
2125

2226
type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "head" | "options" | "trace";
@@ -28,12 +32,14 @@ interface PathOperation {
2832
path: string;
2933
method: HttpMethod;
3034
pathParameters?: unknown[];
35+
pathServers?: unknown[];
3136
operation: {
3237
summary?: string;
3338
description?: string;
3439
parameters?: unknown[];
3540
requestBody?: unknown;
3641
consumes?: string[];
42+
servers?: unknown[];
3743
};
3844
}
3945

@@ -44,6 +50,9 @@ interface ParameterLike {
4450
schema?: SchemaLike;
4551
type?: string;
4652
description?: string;
53+
style?: string;
54+
explode?: boolean;
55+
collectionFormat?: string;
4756
}
4857

4958
interface SchemaLike {
@@ -60,6 +69,11 @@ interface RequestBodyLike {
6069
content?: Record<string, { schema?: SchemaLike }>;
6170
}
6271

72+
interface ServerLike {
73+
url?: string;
74+
variables?: Record<string, { default?: string }>;
75+
}
76+
6377
export class OpenapiToCommands {
6478
buildCommands(spec: OpenapiSpecLike, profile: Profile): CliCommand[] {
6579
const operations = this.collectOperations(spec);
@@ -94,13 +108,15 @@ export class OpenapiToCommands {
94108
for (const pathKey of Object.keys(paths)) {
95109
const pathItem = paths[pathKey];
96110
const pathParameters = Array.isArray(pathItem?.parameters) ? pathItem.parameters : [];
111+
const pathServers = Array.isArray(pathItem?.servers) ? pathItem.servers : [];
97112
for (const method of methods) {
98113
const op = pathItem[method];
99114
if (op) {
100115
result.push({
101116
path: pathKey,
102117
method,
103118
pathParameters,
119+
pathServers,
104120
operation: op,
105121
});
106122
}
@@ -158,6 +174,7 @@ export class OpenapiToCommands {
158174
const name = multipleMethods ? `${baseName}_${op.method}` : baseName;
159175
const { options, requestContentType } = this.extractOptions(op, spec);
160176
const description = op.operation.summary ?? op.operation.description;
177+
const serverUrl = this.resolveOperationServerUrl(spec, op);
161178

162179
commands.push({
163180
name,
@@ -166,6 +183,7 @@ export class OpenapiToCommands {
166183
options,
167184
description,
168185
requestContentType,
186+
serverUrl,
169187
});
170188
}
171189
}
@@ -214,6 +232,9 @@ export class OpenapiToCommands {
214232
required: param.in === "path" ? true : Boolean(param.required),
215233
schemaType: this.getParameterSchemaType(param),
216234
description: param.description,
235+
style: param.style,
236+
explode: param.explode,
237+
collectionFormat: param.collectionFormat,
217238
});
218239
}
219240

@@ -418,6 +439,65 @@ export class OpenapiToCommands {
418439
return param.type;
419440
}
420441

442+
private resolveOperationServerUrl(spec: OpenapiSpecLike, op: PathOperation): string | undefined {
443+
const rootBase = this.resolveServers(Array.isArray(spec?.servers) ? spec.servers : undefined);
444+
const operationServer = this.resolveServers(op.operation.servers, rootBase);
445+
if (operationServer) {
446+
return operationServer;
447+
}
448+
449+
const pathServer = this.resolveServers(op.pathServers, rootBase);
450+
if (pathServer) {
451+
return pathServer;
452+
}
453+
454+
if (rootBase) {
455+
return rootBase;
456+
}
457+
458+
return this.resolveSwagger2BaseUrl(spec);
459+
}
460+
461+
private resolveServers(rawServers?: unknown[], relativeTo?: string): string | undefined {
462+
if (!Array.isArray(rawServers) || rawServers.length === 0) {
463+
return undefined;
464+
}
465+
466+
const server = this.resolveValue(rawServers[0], {}) as ServerLike;
467+
if (!server?.url) {
468+
return undefined;
469+
}
470+
471+
let url = server.url;
472+
const variables = server.variables ?? {};
473+
for (const [name, variable] of Object.entries(variables)) {
474+
url = url.replace(new RegExp(`\\{${name}\\}`, "g"), variable.default ?? "");
475+
}
476+
477+
if (/^https?:\/\//i.test(url)) {
478+
return url.replace(/\/+$/, "");
479+
}
480+
481+
if (relativeTo) {
482+
return new URL(url, relativeTo.endsWith("/") ? relativeTo : `${relativeTo}/`).toString().replace(/\/+$/, "");
483+
}
484+
485+
return undefined;
486+
}
487+
488+
private resolveSwagger2BaseUrl(spec: OpenapiSpecLike): string | undefined {
489+
const host = typeof spec?.host === "string" ? spec.host : "";
490+
if (!host) {
491+
return undefined;
492+
}
493+
494+
const schemes = Array.isArray(spec?.schemes) && spec.schemes.length > 0
495+
? spec.schemes
496+
: ["https"];
497+
const basePath = typeof spec?.basePath === "string" ? spec.basePath : "";
498+
return `${schemes[0]}://${host}${basePath}`.replace(/\/+$/, "");
499+
}
500+
421501
private pickRequestBodyContentType(requestBody: RequestBodyLike): string | undefined {
422502
const content = requestBody.content ?? {};
423503
const contentTypes = Object.keys(content);

0 commit comments

Comments
 (0)