Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
144 changes: 135 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,26 +229,25 @@ function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record<st
const value = flags[opt.name];
if (value !== undefined) {
const token = `{${opt.name}}`;
pathValue = pathValue.replace(token, encodeURIComponent(value));
pathValue = pathValue.replace(token, serializePathParameter(opt, value));
}
});

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

const queryParams = new URLSearchParams();
const queryParts: string[] = [];
command.options
.filter((opt) => 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;
Expand Down Expand Up @@ -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<string, unknown>).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<string, unknown>).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<void> {
const cwd = options?.cwd ?? process.cwd();
const configLocator = options?.configLocator ?? new ConfigLocator();
Expand Down Expand Up @@ -411,7 +533,11 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {

const addProfileOptions = (y: ReturnType<typeof yargs>) =>
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: "" })
Expand Down
80 changes: 80 additions & 0 deletions src/openapi-to-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export interface CliCommandOption {
required: boolean;
schemaType?: string;
description?: string;
style?: string;
explode?: boolean;
collectionFormat?: string;
}

export interface CliCommand {
Expand All @@ -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";
Expand All @@ -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[];
};
}

Expand All @@ -44,6 +50,9 @@ interface ParameterLike {
schema?: SchemaLike;
type?: string;
description?: string;
style?: string;
explode?: boolean;
collectionFormat?: string;
}

interface SchemaLike {
Expand All @@ -60,6 +69,11 @@ interface RequestBodyLike {
content?: Record<string, { schema?: SchemaLike }>;
}

interface ServerLike {
url?: string;
variables?: Record<string, { default?: string }>;
}

export class OpenapiToCommands {
buildCommands(spec: OpenapiSpecLike, profile: Profile): CliCommand[] {
const operations = this.collectOperations(spec);
Expand Down Expand Up @@ -94,13 +108,15 @@ 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) {
result.push({
path: pathKey,
method,
pathParameters,
pathServers,
operation: op,
});
}
Expand Down Expand Up @@ -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,
Expand All @@ -166,6 +183,7 @@ export class OpenapiToCommands {
options,
description,
requestContentType,
serverUrl,
});
}
}
Expand Down Expand Up @@ -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,
});
}

Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading