Skip to content

Commit cfbc395

Browse files
Merge pull request #11 from EvilFreelancer/feat/openapi-multifile-spec
Feat/openapi multifile spec
2 parents 406f7a1 + a6ba238 commit cfbc395

File tree

6 files changed

+550
-8
lines changed

6 files changed

+550
-8
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@ In practice this improves compatibility with APIs that define inputs outside sim
8787

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

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

92104
```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)