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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ In practice this improves compatibility with APIs that define inputs outside sim

In practice this improves compatibility with APIs that rely on non-trivial parameter encoding or per-operation server definitions.


### Multi-file specs and richer help

`ocli` now works better with larger, more structured API descriptions:

- external `$ref` resolution across multiple local or remote OpenAPI / Swagger documents
- support for multi-document specs that split paths, parameters, and request bodies into separate files
- richer `--help` output with schema hints such as `enum`, `default`, `nullable`, and `oneOf`
- better handling of composed schemas that use `allOf` for shared request object structure

In practice this improves compatibility with modular specs and makes generated commands easier to use without opening the original OpenAPI document.

### Command search

```bash
Expand Down
16 changes: 15 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,21 @@ async function runApiCommand(
} else if (baseType === "boolean") {
typeLabel = "boolean";
}
const descriptionPart = opt.description ?? "";
const hintParts: string[] = [];
if (opt.enumValues && opt.enumValues.length > 0) {
hintParts.push(`enum: ${opt.enumValues.join(", ")}`);
}
if (opt.defaultValue !== undefined) {
hintParts.push(`default: ${opt.defaultValue}`);
}
if (opt.nullable) {
hintParts.push("nullable");
}
if (opt.oneOfTypes && opt.oneOfTypes.length > 0) {
hintParts.push(`oneOf: ${opt.oneOfTypes.join(" | ")}`);
}

const descriptionPart = [opt.description ?? "", ...hintParts].filter(Boolean).join("; ");
const descPrefix = opt.required ? "(required)" : "(optional)";
const desc = descriptionPart ? `${descPrefix} ${descriptionPart}` : descPrefix;

Expand Down
145 changes: 144 additions & 1 deletion src/openapi-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class OpenapiLoader {
return JSON.parse(cached);
}

const spec = await this.loadFromSource(profile.openapiSpecSource);
const spec = await this.loadAndResolveSpec(profile.openapiSpecSource);
this.ensureCacheDir(cachePath);

const serialized = JSON.stringify(spec, null, 2);
Expand All @@ -45,6 +45,17 @@ export class OpenapiLoader {
return spec;
}

private async loadAndResolveSpec(source: string): Promise<unknown> {
const rawDocCache = new Map<string, unknown>();
const root = await this.loadDocument(source, rawDocCache);
return this.resolveRefs(root, {
currentSource: source,
currentDocument: root,
rawDocCache,
resolvingRefs: new Set<string>(),
});
}

private async loadFromSource(source: string): Promise<unknown> {
if (source.startsWith("http://") || source.startsWith("https://")) {
const response = await axios.get(source, { responseType: "text" });
Expand All @@ -55,6 +66,16 @@ export class OpenapiLoader {
return this.parseSpec(raw, source);
}

private async loadDocument(source: string, rawDocCache: Map<string, unknown>): Promise<unknown> {
if (rawDocCache.has(source)) {
return rawDocCache.get(source);
}

const loaded = await this.loadFromSource(source);
rawDocCache.set(source, loaded);
return loaded;
}

private parseSpec(content: string | object, source: string): unknown {
if (typeof content !== "string") {
return content;
Expand All @@ -65,6 +86,128 @@ export class OpenapiLoader {
return JSON.parse(content);
}

private async resolveRefs(
value: unknown,
context: {
currentSource: string;
currentDocument: unknown;
rawDocCache: Map<string, unknown>;
resolvingRefs: Set<string>;
}
): Promise<unknown> {
if (Array.isArray(value)) {
const items = await Promise.all(value.map((item) => this.resolveRefs(item, context)));
return items;
}

if (!value || typeof value !== "object") {
return value;
}

const record = value as Record<string, unknown>;
const ref = record.$ref;

if (typeof ref === "string") {
const siblingEntries = Object.entries(record).filter(([key]) => key !== "$ref");
const resolvedRef = await this.resolveRef(ref, context);
const resolvedSiblings = Object.fromEntries(
await Promise.all(
siblingEntries.map(async ([key, siblingValue]) => [key, await this.resolveRefs(siblingValue, context)] as const)
)
);

if (resolvedRef && typeof resolvedRef === "object" && !Array.isArray(resolvedRef)) {
return {
...(resolvedRef as Record<string, unknown>),
...resolvedSiblings,
};
}

return Object.keys(resolvedSiblings).length > 0 ? resolvedSiblings : resolvedRef;
}

const resolvedEntries = await Promise.all(
Object.entries(record).map(async ([key, nested]) => [key, await this.resolveRefs(nested, context)] as const)
);
return Object.fromEntries(resolvedEntries);
}

private async resolveRef(
ref: string,
context: {
currentSource: string;
currentDocument: unknown;
rawDocCache: Map<string, unknown>;
resolvingRefs: Set<string>;
}
): Promise<unknown> {
const { source, pointer } = this.splitRef(ref, context.currentSource);
const cacheKey = `${source}#${pointer}`;

if (context.resolvingRefs.has(cacheKey)) {
return { $ref: ref };
}

context.resolvingRefs.add(cacheKey);

const targetDocument = source === context.currentSource
? context.currentDocument
: await this.loadDocument(source, context.rawDocCache);

const targetValue = this.resolvePointer(targetDocument, pointer);
const resolvedValue = await this.resolveRefs(targetValue, {
currentSource: source,
currentDocument: targetDocument,
rawDocCache: context.rawDocCache,
resolvingRefs: context.resolvingRefs,
});

context.resolvingRefs.delete(cacheKey);
return resolvedValue;
}

private splitRef(ref: string, currentSource: string): { source: string; pointer: string } {
const [refSource, pointer = ""] = ref.split("#", 2);
if (!refSource) {
return { source: currentSource, pointer };
}

if (refSource.startsWith("http://") || refSource.startsWith("https://")) {
return { source: refSource, pointer };
}

if (currentSource.startsWith("http://") || currentSource.startsWith("https://")) {
return { source: new URL(refSource, currentSource).toString(), pointer };
}

return { source: path.resolve(path.dirname(currentSource), refSource), pointer };
}

private resolvePointer(document: unknown, pointer: string): unknown {
if (!pointer) {
return document;
}

if (!pointer.startsWith("/")) {
return document;
}

const parts = pointer
.slice(1)
.split("/")
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));

let current: unknown = document;
for (const part of parts) {
if (!current || typeof current !== "object" || !(part in (current as Record<string, unknown>))) {
return undefined;
}
current = (current as Record<string, unknown>)[part];
}

return current;
}

private isYamlSource(source: string): boolean {
const lower = source.toLowerCase().split("?")[0];
return lower.endsWith(".yaml") || lower.endsWith(".yml");
Expand Down
113 changes: 107 additions & 6 deletions src/openapi-to-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export interface CliCommandOption {
style?: string;
explode?: boolean;
collectionFormat?: string;
enumValues?: string[];
defaultValue?: string;
nullable?: boolean;
oneOfTypes?: string[];
}

export interface CliCommand {
Expand Down Expand Up @@ -62,6 +66,13 @@ interface SchemaLike {
properties?: Record<string, SchemaLike>;
items?: SchemaLike;
$ref?: string;
enum?: unknown[];
default?: unknown;
nullable?: boolean;
oneOf?: SchemaLike[];
anyOf?: SchemaLike[];
allOf?: SchemaLike[];
format?: string;
}

interface RequestBodyLike {
Expand Down Expand Up @@ -235,6 +246,7 @@ export class OpenapiToCommands {
style: param.style,
explode: param.explode,
collectionFormat: param.collectionFormat,
...this.extractSchemaHints(this.resolveSchema(param.schema, spec)),
});
}

Expand Down Expand Up @@ -320,7 +332,8 @@ export class OpenapiToCommands {
if (!schema) {
return undefined;
}
return this.resolveValue(schema, spec) as SchemaLike;
const resolved = this.resolveValue(schema, spec) as SchemaLike;
return this.normalizeSchema(resolved, spec);
}

private resolveValue(value: unknown, spec: OpenapiSpecLike, seenRefs?: Set<string>): unknown {
Expand Down Expand Up @@ -405,8 +418,9 @@ export class OpenapiToCommands {
name: context.fallbackName,
location: context.location,
required: context.required,
schemaType: resolvedSchema.type,
schemaType: this.describeSchemaType(resolvedSchema),
description: resolvedSchema.description,
...this.extractSchemaHints(resolvedSchema),
}];
}

Expand All @@ -417,8 +431,9 @@ export class OpenapiToCommands {
name: propertyName,
location: context.location,
required: required.has(propertyName),
schemaType: propertySchema?.type,
schemaType: this.describeSchemaType(propertySchema),
description: propertySchema?.description,
...this.extractSchemaHints(propertySchema),
};
});
}
Expand All @@ -427,18 +442,104 @@ export class OpenapiToCommands {
name: context.fallbackName,
location: context.location,
required: context.required,
schemaType: resolvedSchema.type,
schemaType: this.describeSchemaType(resolvedSchema),
description: resolvedSchema.description,
...this.extractSchemaHints(resolvedSchema),
}];
}

private getParameterSchemaType(param: ParameterLike): string | undefined {
if (param.schema?.type) {
return param.schema.type;
if (param.schema) {
return this.describeSchemaType(param.schema);
}
return param.type;
}

private extractSchemaHints(schema: SchemaLike | undefined): Pick<CliCommandOption, "enumValues" | "defaultValue" | "nullable" | "oneOfTypes"> {
if (!schema) {
return {};
}

const enumValues = Array.isArray(schema.enum)
? schema.enum.map((value) => JSON.stringify(value))
: undefined;
const defaultValue = schema.default === undefined ? undefined : JSON.stringify(schema.default);
const oneOfTypes = Array.isArray(schema.oneOf)
? schema.oneOf
.map((item) => this.describeSchemaType(item))
.filter((value): value is string => Boolean(value))
: undefined;

return {
...(enumValues && enumValues.length > 0 ? { enumValues } : {}),
...(defaultValue !== undefined ? { defaultValue } : {}),
...(schema.nullable ? { nullable: true } : {}),
...(oneOfTypes && oneOfTypes.length > 0 ? { oneOfTypes } : {}),
};
}

private describeSchemaType(schema: SchemaLike | undefined): string | undefined {
if (!schema) {
return undefined;
}

if (schema.type) {
return schema.format ? `${schema.type}:${schema.format}` : schema.type;
}

if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
return "oneOf";
}

if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
return "anyOf";
}

if (Object.keys(schema.properties ?? {}).length > 0) {
return "object";
}

return undefined;
}

private normalizeSchema(schema: SchemaLike | undefined, spec: OpenapiSpecLike): SchemaLike | undefined {
if (!schema) {
return undefined;
}

if (!Array.isArray(schema.allOf) || schema.allOf.length === 0) {
return schema;
}

const normalizedParts = schema.allOf
.map((item) => this.normalizeSchema(this.resolveValue(item, spec) as SchemaLike, spec))
.filter((item): item is SchemaLike => Boolean(item));

const mergedProperties: Record<string, SchemaLike> = {};
const mergedRequired = new Set<string>();
let mergedType = schema.type;
let mergedDescription = schema.description;

for (const part of normalizedParts) {
if (!mergedType && part.type) {
mergedType = part.type;
}
if (!mergedDescription && part.description) {
mergedDescription = part.description;
}
Object.assign(mergedProperties, part.properties ?? {});
(part.required ?? []).forEach((required) => mergedRequired.add(required));
}

return {
...schema,
type: mergedType,
description: mergedDescription,
properties: Object.keys(mergedProperties).length > 0 ? mergedProperties : schema.properties,
required: mergedRequired.size > 0 ? Array.from(mergedRequired) : schema.required,
};
}

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