Skip to content

Commit 785cc73

Browse files
committed
Add multi-file specs and richer help support
1 parent b25e99c commit 785cc73

6 files changed

Lines changed: 639 additions & 8 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,18 @@ 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+
81+
### Multi-file specs and richer help
82+
83+
`ocli` now works better with larger, more structured API descriptions:
84+
85+
- external `$ref` resolution across multiple local or remote OpenAPI / Swagger documents
86+
- support for multi-document specs that split paths, parameters, and request bodies into separate files
87+
- richer `--help` output with schema hints such as `enum`, `default`, `nullable`, and `oneOf`
88+
- better handling of composed schemas that use `allOf` for shared request object structure
89+
90+
In practice this improves compatibility with modular specs and makes generated commands easier to use without opening the original OpenAPI document.
91+
8092
### Command search
8193

8294
```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");

0 commit comments

Comments
 (0)