Skip to content

Commit 8858ae2

Browse files
committed
Add multi-file specs and richer help support
1 parent 2018d33 commit 8858ae2

6 files changed

Lines changed: 415 additions & 8 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ In practice this improves compatibility with APIs that define inputs outside sim
8989

9090
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.
9191

92+
### Multi-file specs and richer help
93+
94+
`ocli` now works better with larger, more structured API descriptions:
95+
96+
- external `$ref` resolution across multiple local or remote OpenAPI / Swagger documents
97+
- support for multi-document specs that split paths, parameters, and request bodies into separate files
98+
- richer `--help` output with schema hints such as `enum`, `default`, `nullable`, and `oneOf`
99+
- better handling of composed schemas that use `allOf` for shared request object structure
100+
101+
In practice this improves compatibility with modular specs and makes generated commands easier to use without opening the original OpenAPI document.
102+
92103
### Command search
93104

94105
```bash

src/cli.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,21 @@ async function runApiCommand(
9797
} else if (baseType === "boolean") {
9898
typeLabel = "boolean";
9999
}
100-
const descriptionPart = opt.description ?? "";
100+
const hintParts: string[] = [];
101+
if (opt.enumValues && opt.enumValues.length > 0) {
102+
hintParts.push(`enum: ${opt.enumValues.join(", ")}`);
103+
}
104+
if (opt.defaultValue !== undefined) {
105+
hintParts.push(`default: ${opt.defaultValue}`);
106+
}
107+
if (opt.nullable) {
108+
hintParts.push("nullable");
109+
}
110+
if (opt.oneOfTypes && opt.oneOfTypes.length > 0) {
111+
hintParts.push(`oneOf: ${opt.oneOfTypes.join(" | ")}`);
112+
}
113+
114+
const descriptionPart = [opt.description ?? "", ...hintParts].filter(Boolean).join("; ");
101115
const descPrefix = opt.required ? "(required)" : "(optional)";
102116
const desc = descriptionPart ? `${descPrefix} ${descriptionPart}` : descPrefix;
103117

src/openapi-loader.ts

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export class OpenapiLoader {
3636
return JSON.parse(cached);
3737
}
3838

39-
const spec = await this.loadFromSource(profile.openapiSpecSource);
39+
const spec = await this.loadAndResolveSpec(profile.openapiSpecSource);
4040
this.ensureCacheDir(cachePath);
4141

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

48+
private async loadAndResolveSpec(source: string): Promise<unknown> {
49+
const rawDocCache = new Map<string, unknown>();
50+
const root = await this.loadDocument(source, rawDocCache);
51+
return this.resolveRefs(root, {
52+
currentSource: source,
53+
currentDocument: root,
54+
rawDocCache,
55+
resolvingRefs: new Set<string>(),
56+
});
57+
}
58+
4859
private async loadFromSource(source: string): Promise<unknown> {
4960
if (source.startsWith("http://") || source.startsWith("https://")) {
5061
const response = await axios.get(source, { responseType: "text" });
@@ -55,6 +66,16 @@ export class OpenapiLoader {
5566
return this.parseSpec(raw, source);
5667
}
5768

69+
private async loadDocument(source: string, rawDocCache: Map<string, unknown>): Promise<unknown> {
70+
if (rawDocCache.has(source)) {
71+
return rawDocCache.get(source);
72+
}
73+
74+
const loaded = await this.loadFromSource(source);
75+
rawDocCache.set(source, loaded);
76+
return loaded;
77+
}
78+
5879
private parseSpec(content: string | object, source: string): unknown {
5980
if (typeof content !== "string") {
6081
return content;
@@ -65,6 +86,128 @@ export class OpenapiLoader {
6586
return JSON.parse(content);
6687
}
6788

89+
private async resolveRefs(
90+
value: unknown,
91+
context: {
92+
currentSource: string;
93+
currentDocument: unknown;
94+
rawDocCache: Map<string, unknown>;
95+
resolvingRefs: Set<string>;
96+
}
97+
): Promise<unknown> {
98+
if (Array.isArray(value)) {
99+
const items = await Promise.all(value.map((item) => this.resolveRefs(item, context)));
100+
return items;
101+
}
102+
103+
if (!value || typeof value !== "object") {
104+
return value;
105+
}
106+
107+
const record = value as Record<string, unknown>;
108+
const ref = record.$ref;
109+
110+
if (typeof ref === "string") {
111+
const siblingEntries = Object.entries(record).filter(([key]) => key !== "$ref");
112+
const resolvedRef = await this.resolveRef(ref, context);
113+
const resolvedSiblings = Object.fromEntries(
114+
await Promise.all(
115+
siblingEntries.map(async ([key, siblingValue]) => [key, await this.resolveRefs(siblingValue, context)] as const)
116+
)
117+
);
118+
119+
if (resolvedRef && typeof resolvedRef === "object" && !Array.isArray(resolvedRef)) {
120+
return {
121+
...(resolvedRef as Record<string, unknown>),
122+
...resolvedSiblings,
123+
};
124+
}
125+
126+
return Object.keys(resolvedSiblings).length > 0 ? resolvedSiblings : resolvedRef;
127+
}
128+
129+
const resolvedEntries = await Promise.all(
130+
Object.entries(record).map(async ([key, nested]) => [key, await this.resolveRefs(nested, context)] as const)
131+
);
132+
return Object.fromEntries(resolvedEntries);
133+
}
134+
135+
private async resolveRef(
136+
ref: string,
137+
context: {
138+
currentSource: string;
139+
currentDocument: unknown;
140+
rawDocCache: Map<string, unknown>;
141+
resolvingRefs: Set<string>;
142+
}
143+
): Promise<unknown> {
144+
const { source, pointer } = this.splitRef(ref, context.currentSource);
145+
const cacheKey = `${source}#${pointer}`;
146+
147+
if (context.resolvingRefs.has(cacheKey)) {
148+
return { $ref: ref };
149+
}
150+
151+
context.resolvingRefs.add(cacheKey);
152+
153+
const targetDocument = source === context.currentSource
154+
? context.currentDocument
155+
: await this.loadDocument(source, context.rawDocCache);
156+
157+
const targetValue = this.resolvePointer(targetDocument, pointer);
158+
const resolvedValue = await this.resolveRefs(targetValue, {
159+
currentSource: source,
160+
currentDocument: targetDocument,
161+
rawDocCache: context.rawDocCache,
162+
resolvingRefs: context.resolvingRefs,
163+
});
164+
165+
context.resolvingRefs.delete(cacheKey);
166+
return resolvedValue;
167+
}
168+
169+
private splitRef(ref: string, currentSource: string): { source: string; pointer: string } {
170+
const [refSource, pointer = ""] = ref.split("#", 2);
171+
if (!refSource) {
172+
return { source: currentSource, pointer };
173+
}
174+
175+
if (refSource.startsWith("http://") || refSource.startsWith("https://")) {
176+
return { source: refSource, pointer };
177+
}
178+
179+
if (currentSource.startsWith("http://") || currentSource.startsWith("https://")) {
180+
return { source: new URL(refSource, currentSource).toString(), pointer };
181+
}
182+
183+
return { source: path.resolve(path.dirname(currentSource), refSource), pointer };
184+
}
185+
186+
private resolvePointer(document: unknown, pointer: string): unknown {
187+
if (!pointer) {
188+
return document;
189+
}
190+
191+
if (!pointer.startsWith("/")) {
192+
return document;
193+
}
194+
195+
const parts = pointer
196+
.slice(1)
197+
.split("/")
198+
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));
199+
200+
let current: unknown = document;
201+
for (const part of parts) {
202+
if (!current || typeof current !== "object" || !(part in (current as Record<string, unknown>))) {
203+
return undefined;
204+
}
205+
current = (current as Record<string, unknown>)[part];
206+
}
207+
208+
return current;
209+
}
210+
68211
private isYamlSource(source: string): boolean {
69212
const lower = source.toLowerCase().split("?")[0];
70213
return lower.endsWith(".yaml") || lower.endsWith(".yml");

src/openapi-to-commands.ts

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export interface CliCommandOption {
1111
style?: string;
1212
explode?: boolean;
1313
collectionFormat?: string;
14+
enumValues?: string[];
15+
defaultValue?: string;
16+
nullable?: boolean;
17+
oneOfTypes?: string[];
1418
}
1519

1620
export interface CliCommand {
@@ -62,6 +66,13 @@ interface SchemaLike {
6266
properties?: Record<string, SchemaLike>;
6367
items?: SchemaLike;
6468
$ref?: string;
69+
enum?: unknown[];
70+
default?: unknown;
71+
nullable?: boolean;
72+
oneOf?: SchemaLike[];
73+
anyOf?: SchemaLike[];
74+
allOf?: SchemaLike[];
75+
format?: string;
6576
}
6677

6778
interface RequestBodyLike {
@@ -235,6 +246,7 @@ export class OpenapiToCommands {
235246
style: param.style,
236247
explode: param.explode,
237248
collectionFormat: param.collectionFormat,
249+
...this.extractSchemaHints(this.resolveSchema(param.schema, spec)),
238250
});
239251
}
240252

@@ -320,7 +332,8 @@ export class OpenapiToCommands {
320332
if (!schema) {
321333
return undefined;
322334
}
323-
return this.resolveValue(schema, spec) as SchemaLike;
335+
const resolved = this.resolveValue(schema, spec) as SchemaLike;
336+
return this.normalizeSchema(resolved, spec);
324337
}
325338

326339
private resolveValue(value: unknown, spec: OpenapiSpecLike, seenRefs?: Set<string>): unknown {
@@ -405,8 +418,9 @@ export class OpenapiToCommands {
405418
name: context.fallbackName,
406419
location: context.location,
407420
required: context.required,
408-
schemaType: resolvedSchema.type,
421+
schemaType: this.describeSchemaType(resolvedSchema),
409422
description: resolvedSchema.description,
423+
...this.extractSchemaHints(resolvedSchema),
410424
}];
411425
}
412426

@@ -417,8 +431,9 @@ export class OpenapiToCommands {
417431
name: propertyName,
418432
location: context.location,
419433
required: required.has(propertyName),
420-
schemaType: propertySchema?.type,
434+
schemaType: this.describeSchemaType(propertySchema),
421435
description: propertySchema?.description,
436+
...this.extractSchemaHints(propertySchema),
422437
};
423438
});
424439
}
@@ -427,18 +442,104 @@ export class OpenapiToCommands {
427442
name: context.fallbackName,
428443
location: context.location,
429444
required: context.required,
430-
schemaType: resolvedSchema.type,
445+
schemaType: this.describeSchemaType(resolvedSchema),
431446
description: resolvedSchema.description,
447+
...this.extractSchemaHints(resolvedSchema),
432448
}];
433449
}
434450

435451
private getParameterSchemaType(param: ParameterLike): string | undefined {
436-
if (param.schema?.type) {
437-
return param.schema.type;
452+
if (param.schema) {
453+
return this.describeSchemaType(param.schema);
438454
}
439455
return param.type;
440456
}
441457

458+
private extractSchemaHints(schema: SchemaLike | undefined): Pick<CliCommandOption, "enumValues" | "defaultValue" | "nullable" | "oneOfTypes"> {
459+
if (!schema) {
460+
return {};
461+
}
462+
463+
const enumValues = Array.isArray(schema.enum)
464+
? schema.enum.map((value) => JSON.stringify(value))
465+
: undefined;
466+
const defaultValue = schema.default === undefined ? undefined : JSON.stringify(schema.default);
467+
const oneOfTypes = Array.isArray(schema.oneOf)
468+
? schema.oneOf
469+
.map((item) => this.describeSchemaType(item))
470+
.filter((value): value is string => Boolean(value))
471+
: undefined;
472+
473+
return {
474+
...(enumValues && enumValues.length > 0 ? { enumValues } : {}),
475+
...(defaultValue !== undefined ? { defaultValue } : {}),
476+
...(schema.nullable ? { nullable: true } : {}),
477+
...(oneOfTypes && oneOfTypes.length > 0 ? { oneOfTypes } : {}),
478+
};
479+
}
480+
481+
private describeSchemaType(schema: SchemaLike | undefined): string | undefined {
482+
if (!schema) {
483+
return undefined;
484+
}
485+
486+
if (schema.type) {
487+
return schema.format ? `${schema.type}:${schema.format}` : schema.type;
488+
}
489+
490+
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
491+
return "oneOf";
492+
}
493+
494+
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
495+
return "anyOf";
496+
}
497+
498+
if (Object.keys(schema.properties ?? {}).length > 0) {
499+
return "object";
500+
}
501+
502+
return undefined;
503+
}
504+
505+
private normalizeSchema(schema: SchemaLike | undefined, spec: OpenapiSpecLike): SchemaLike | undefined {
506+
if (!schema) {
507+
return undefined;
508+
}
509+
510+
if (!Array.isArray(schema.allOf) || schema.allOf.length === 0) {
511+
return schema;
512+
}
513+
514+
const normalizedParts = schema.allOf
515+
.map((item) => this.normalizeSchema(this.resolveValue(item, spec) as SchemaLike, spec))
516+
.filter((item): item is SchemaLike => Boolean(item));
517+
518+
const mergedProperties: Record<string, SchemaLike> = {};
519+
const mergedRequired = new Set<string>();
520+
let mergedType = schema.type;
521+
let mergedDescription = schema.description;
522+
523+
for (const part of normalizedParts) {
524+
if (!mergedType && part.type) {
525+
mergedType = part.type;
526+
}
527+
if (!mergedDescription && part.description) {
528+
mergedDescription = part.description;
529+
}
530+
Object.assign(mergedProperties, part.properties ?? {});
531+
(part.required ?? []).forEach((required) => mergedRequired.add(required));
532+
}
533+
534+
return {
535+
...schema,
536+
type: mergedType,
537+
description: mergedDescription,
538+
properties: Object.keys(mergedProperties).length > 0 ? mergedProperties : schema.properties,
539+
required: mergedRequired.size > 0 ? Array.from(mergedRequired) : schema.required,
540+
};
541+
}
542+
442543
private resolveOperationServerUrl(spec: OpenapiSpecLike, op: PathOperation): string | undefined {
443544
const rootBase = this.resolveServers(Array.isArray(spec?.servers) ? spec.servers : undefined);
444545
const operationServer = this.resolveServers(op.operation.servers, rootBase);

0 commit comments

Comments
 (0)