From a6ba2388e2422df26295e8fdf1af7da07ab28960 Mon Sep 17 00:00:00 2001 From: snow-ghost Date: Wed, 18 Mar 2026 01:10:49 +0500 Subject: [PATCH] Add multi-file specs and richer help support --- README.md | 12 ++ src/cli.ts | 16 ++- src/openapi-loader.ts | 145 +++++++++++++++++++++++- src/openapi-to-commands.ts | 113 ++++++++++++++++++- tests/cli.test.ts | 210 +++++++++++++++++++++++++++++++++++ tests/openapi-loader.test.ts | 62 +++++++++++ 6 files changed, 550 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index be244f8..47da421 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/cli.ts b/src/cli.ts index 4b7b4ea..e3675a5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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; diff --git a/src/openapi-loader.ts b/src/openapi-loader.ts index 854f54b..fa57d33 100644 --- a/src/openapi-loader.ts +++ b/src/openapi-loader.ts @@ -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); @@ -45,6 +45,17 @@ export class OpenapiLoader { return spec; } + private async loadAndResolveSpec(source: string): Promise { + const rawDocCache = new Map(); + const root = await this.loadDocument(source, rawDocCache); + return this.resolveRefs(root, { + currentSource: source, + currentDocument: root, + rawDocCache, + resolvingRefs: new Set(), + }); + } + private async loadFromSource(source: string): Promise { if (source.startsWith("http://") || source.startsWith("https://")) { const response = await axios.get(source, { responseType: "text" }); @@ -55,6 +66,16 @@ export class OpenapiLoader { return this.parseSpec(raw, source); } + private async loadDocument(source: string, rawDocCache: Map): Promise { + 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; @@ -65,6 +86,128 @@ export class OpenapiLoader { return JSON.parse(content); } + private async resolveRefs( + value: unknown, + context: { + currentSource: string; + currentDocument: unknown; + rawDocCache: Map; + resolvingRefs: Set; + } + ): Promise { + 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; + 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), + ...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; + resolvingRefs: Set; + } + ): Promise { + 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))) { + return undefined; + } + current = (current as Record)[part]; + } + + return current; + } + private isYamlSource(source: string): boolean { const lower = source.toLowerCase().split("?")[0]; return lower.endsWith(".yaml") || lower.endsWith(".yml"); diff --git a/src/openapi-to-commands.ts b/src/openapi-to-commands.ts index ea66df4..804289f 100644 --- a/src/openapi-to-commands.ts +++ b/src/openapi-to-commands.ts @@ -11,6 +11,10 @@ export interface CliCommandOption { style?: string; explode?: boolean; collectionFormat?: string; + enumValues?: string[]; + defaultValue?: string; + nullable?: boolean; + oneOfTypes?: string[]; } export interface CliCommand { @@ -62,6 +66,13 @@ interface SchemaLike { properties?: Record; items?: SchemaLike; $ref?: string; + enum?: unknown[]; + default?: unknown; + nullable?: boolean; + oneOf?: SchemaLike[]; + anyOf?: SchemaLike[]; + allOf?: SchemaLike[]; + format?: string; } interface RequestBodyLike { @@ -235,6 +246,7 @@ export class OpenapiToCommands { style: param.style, explode: param.explode, collectionFormat: param.collectionFormat, + ...this.extractSchemaHints(this.resolveSchema(param.schema, spec)), }); } @@ -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): unknown { @@ -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), }]; } @@ -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), }; }); } @@ -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 { + 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 = {}; + const mergedRequired = new Set(); + 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); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 3d25481..8b14c8c 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1108,6 +1108,216 @@ describe("cli", () => { expect(config.url).toBe("https://ops.example.com/custom/messages"); }); + it("shows schema hints in command help output", async () => { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cachePath = `${localDir}/specs/help-hints-api.json`; + + const spec = { + openapi: "3.1.0", + paths: { + "/reports": { + post: { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + format: { + type: "string", + enum: ["csv", "json"], + default: "json", + }, + note: { + type: "string", + nullable: true, + }, + filter: { + oneOf: [ + { type: "string" }, + { type: "integer" }, + ], + }, + }, + required: ["format"], + }, + }, + }, + }, + }, + }, + }, + }; + + const iniContent = [ + "[help-hints-api]", + "api_base_url = https://api.example.com", + "api_basic_auth = ", + "api_bearer_token = tok", + "openapi_spec_source = /spec.json", + `openapi_spec_cache = ${cachePath}`, + "include_endpoints = ", + "exclude_endpoints = ", + "", + ].join("\n"); + + const log: string[] = []; + const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, { + [profilesPath]: iniContent, + [cachePath]: JSON.stringify(spec), + [`${localDir}/current`]: "help-hints-api", + }); + + await run(["reports", "--help"], { + cwd, + profileStore, + openapiLoader, + stdout: (msg: string) => log.push(msg), + }); + + const out = log.join(""); + expect(out).toContain('enum: "csv", "json"'); + expect(out).toContain('default: "json"'); + expect(out).toContain("nullable"); + expect(out).toContain("oneOf: string | integer"); + }); + + it("serializes query arrays and deepObject parameters from spec metadata", async () => { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cachePath = `${localDir}/specs/serialization-api.json`; + + const spec = { + openapi: "3.0.0", + paths: { + "/reports/{report_id}": { + get: { + parameters: [ + { + name: "report_id", + in: "path", + required: true, + schema: { type: "array" }, + style: "label", + explode: true, + }, + { + name: "tags", + in: "query", + schema: { type: "array" }, + style: "pipeDelimited", + explode: false, + }, + { + name: "filter", + in: "query", + schema: { type: "object" }, + style: "deepObject", + explode: true, + }, + ], + }, + }, + }, + }; + + const iniContent = [ + "[serialization-api]", + "api_base_url = https://api.example.com", + "api_basic_auth = ", + "api_bearer_token = tok", + "openapi_spec_source = /spec.json", + `openapi_spec_cache = ${cachePath}`, + "include_endpoints = ", + "exclude_endpoints = ", + "", + ].join("\n"); + + const capturedConfigs: unknown[] = []; + const fakeHttpClient: HttpClient = { + request: async (config: any) => { + capturedConfigs.push(config); + return { status: 200, statusText: "OK", headers: {}, config, data: { ok: true } }; + }, + }; + + const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, { + [profilesPath]: iniContent, + [cachePath]: JSON.stringify(spec), + [`${localDir}/current`]: "serialization-api", + }); + + await run( + [ + "reports_report_id", + "--report_id", '["r1","r2"]', + "--tags", '["daily","ops"]', + "--filter", '{"status":"open","owner":"alice"}', + ], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + const config = capturedConfigs[0] as { url: string }; + expect(config.url).toBe( + "https://api.example.com/reports/.r1.r2?tags=daily%7Cops&filter%5Bstatus%5D=open&filter%5Bowner%5D=alice" + ); + }); + + it("uses operation-level server URL override when executing commands", async () => { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cachePath = `${localDir}/specs/server-override-api.json`; + + const spec = { + openapi: "3.0.0", + servers: [{ url: "https://root.example.com/api" }], + paths: { + "/messages": { + get: { + servers: [{ url: "https://ops.example.com/custom" }], + summary: "List messages", + }, + }, + }, + }; + + const iniContent = [ + "[server-override-api]", + "api_base_url = https://fallback.example.com", + "api_basic_auth = ", + "api_bearer_token = tok", + "openapi_spec_source = /spec.json", + `openapi_spec_cache = ${cachePath}`, + "include_endpoints = ", + "exclude_endpoints = ", + "", + ].join("\n"); + + const capturedConfigs: unknown[] = []; + const fakeHttpClient: HttpClient = { + request: async (config: any) => { + capturedConfigs.push(config); + return { status: 200, statusText: "OK", headers: {}, config, data: { ok: true } }; + }, + }; + + const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, { + [profilesPath]: iniContent, + [cachePath]: JSON.stringify(spec), + [`${localDir}/current`]: "server-override-api", + }); + + await run( + ["messages"], + { cwd, profileStore, openapiLoader, httpClient: fakeHttpClient, stdout: () => {} } + ); + + const config = capturedConfigs[0] as { url: string }; + expect(config.url).toBe("https://ops.example.com/custom/messages"); + }); + it("sends custom headers from profile in API requests", async () => { const localDir = `${cwd}/.ocli`; const profilesPath = `${localDir}/profiles.ini`; diff --git a/tests/openapi-loader.test.ts b/tests/openapi-loader.test.ts index 3d8af8a..f236948 100644 --- a/tests/openapi-loader.test.ts +++ b/tests/openapi-loader.test.ts @@ -88,6 +88,10 @@ describe("OpenapiLoader", () => { customHeaders: {}, }; + beforeEach(() => { + mockedAxios.get.mockReset(); + }); + it("downloads spec from HTTP URL and caches it when cache is missing", async () => { const spec = { openapi: "3.0.0", info: { title: "API", version: "1.0.0" } }; mockedAxios.get.mockResolvedValueOnce({ data: spec }); @@ -207,4 +211,62 @@ describe("OpenapiLoader", () => { expect((loaded as any).info.title).toBe("Auto Detect"); }); + + it("resolves local external refs across multiple files", async () => { + const rootSpec = `openapi: "3.0.0"\npaths:\n /jobs:\n $ref: "./paths/jobs.yaml#/jobsPath"\n`; + const jobsPath = `jobsPath:\n post:\n requestBody:\n $ref: "./components/request-bodies.yaml#/CreateJob"\n`; + const requestBodies = `CreateJob:\n required: true\n content:\n application/json:\n schema:\n type: object\n required: [name]\n properties:\n name:\n type: string\n`; + + const profile: Profile = { + ...baseProfile, + openapiSpecSource: "/project/root.yaml", + }; + + const fs = new MemoryFs({ + "/project/root.yaml": rootSpec, + "/project/paths/jobs.yaml": jobsPath, + "/project/paths/components/request-bodies.yaml": requestBodies, + }); + + const loader = new OpenapiLoader({ fs }); + const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; + + expect(loaded.paths["/jobs"].post.requestBody.content["application/json"].schema.properties.name.type).toBe("string"); + }); + + it("resolves remote external refs across multiple documents", async () => { + mockedAxios.get.mockImplementation(async (source: string) => { + if (source === "https://example.com/root.yaml") { + return { + data: `openapi: "3.0.0"\npaths:\n /jobs:\n $ref: "./paths/jobs.yaml#/jobsPath"\n`, + }; + } + + if (source === "https://example.com/paths/jobs.yaml") { + return { + data: `jobsPath:\n get:\n parameters:\n - $ref: "../components/params.yaml#/JobId"\n`, + }; + } + + if (source === "https://example.com/components/params.yaml") { + return { + data: `JobId:\n name: job_id\n in: query\n required: true\n schema:\n type: string\n`, + }; + } + + throw new Error(`Unexpected URL: ${source}`); + }); + + const profile: Profile = { + ...baseProfile, + openapiSpecSource: "https://example.com/root.yaml", + }; + + const fs = new MemoryFs(); + const loader = new OpenapiLoader({ fs }); + const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; + + expect(loaded.paths["/jobs"].get.parameters[0].name).toBe("job_id"); + expect(loaded.paths["/jobs"].get.parameters[0].in).toBe("query"); + }); });