diff --git a/dist/prepareCombine.js b/dist/prepareCombine.js index a1697a8a..1a8b77d3 100644 --- a/dist/prepareCombine.js +++ b/dist/prepareCombine.js @@ -7327,6 +7327,12 @@ function getInput(name, options) { } return value || void 0; } +function getBooleanInput(name, options) { + const value = getInput(name, options)?.toLowerCase(); + if (value === "true") return true; + if (value === "false") return false; + return void 0; +} // src/compat/output.ts var crypto = __toESM(require("node:crypto")); @@ -14538,12 +14544,67 @@ function countPaths(spec) { } function addBaseToPath(pathKey, baseUrl) { const url = new URL(baseUrl); - const domain = url.hostname; + const urlPath = url.pathname.replace(/\/+$/, ""); + const base = urlPath ? `${url.hostname}${urlPath}` : url.hostname; const [pathname, queryString] = pathKey.split("?"); const params = new URLSearchParams(queryString || ""); - params.set("base", domain); + params.set("base", base); return `${pathname}?${params.toString()}`; } +function slugify(text) { + return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); +} +function deriveSlugs(entries) { + const rawSlugs = entries.map((entry, i) => { + const info = entry.spec.info; + const title = info?.title; + return typeof title === "string" && title ? slugify(title) : `spec-${i}`; + }); + const counts = /* @__PURE__ */ new Map(); + const result = []; + for (const slug of rawSlugs) { + const count = counts.get(slug) ?? 0; + counts.set(slug, count + 1); + result.push(count === 0 ? slug : `${slug}-${count}`); + } + for (let i = 0; i < result.length; i++) { + const slug = rawSlugs[i]; + if ((counts.get(slug) ?? 0) > 1 && result[i] === slug) { + result[i] = `${slug}-0`; + } + } + return result; +} +function findConflictingOperationIds(entries) { + const seen = /* @__PURE__ */ new Map(); + for (const { spec } of entries) { + for (const pathItem of Object.values(spec.paths || {})) { + for (const method of HTTP_METHODS) { + const op = pathItem[method]; + if (op?.operationId && typeof op.operationId === "string") { + seen.set(op.operationId, (seen.get(op.operationId) ?? 0) + 1); + } + } + } + } + const conflicting = /* @__PURE__ */ new Set(); + for (const [id, count] of seen) { + if (count > 1) conflicting.add(id); + } + return conflicting; +} +function deduplicateOperationIds(spec, slug, conflicting) { + const cloned = JSON.parse(JSON.stringify(spec)); + for (const pathItem of Object.values(cloned.paths || {})) { + for (const method of HTTP_METHODS) { + const op = pathItem[method]; + if (op?.operationId && typeof op.operationId === "string" && conflicting.has(op.operationId)) { + op.operationId = `${slug}_${op.operationId}`; + } + } + } + return cloned; +} function processSpecForServers(spec, strategy) { const processed = { ...spec, @@ -14587,7 +14648,7 @@ function processSpecForServers(spec, strategy) { } return processed; } -async function combineSpecs(files, outputPath, serverStrategy) { +async function combineSpecs(files, outputPath, serverStrategy, prefixWithInfo) { if (files.length === 0) { throw new Error("No files to combine"); } @@ -14621,12 +14682,21 @@ async function combineSpecs(files, outputPath, serverStrategy) { await fs3.mkdir(jsonDir, { recursive: true }); logger.debug(`Running: npx @redocly/cli join ... -o "${jsonPath}"`); const env = { ...process.env, NODE_ENV: "production" }; - try { - await spawn2( - "npx", - ["@redocly/cli", "join", ...filesToCombine, "-o", jsonPath], - { env } + const joinArgs = [ + "@redocly/cli", + "join", + ...filesToCombine, + "-o", + jsonPath + ]; + if (prefixWithInfo) { + joinArgs.push( + "--prefix-tags-with-info-prop=title", + "--prefix-components-with-info-prop=title" ); + } + try { + await spawn2("npx", joinArgs, { env }); } catch (error) { const stderr = error && typeof error === "object" && "stderr" in error ? error.stderr : ""; throw new Error(`Redocly join failed: ${stderr || String(error)}`); @@ -14645,7 +14715,7 @@ async function combineSpecs(files, outputPath, serverStrategy) { } } } -async function combineOpenAPISpecs(inputPatterns, outputPath, serverStrategy) { +async function combineOpenAPISpecs(inputPatterns, outputPath, serverStrategy, prefixWithInfo) { const { files, emptyPatterns } = await findFiles(inputPatterns); if (emptyPatterns.length > 0) { for (const pattern of emptyPatterns) { @@ -14669,21 +14739,58 @@ Make sure: for (const file of files) { logger.debug(` - ${file}`); } + const entries = []; let pathCountBefore = 0; for (const file of files) { const spec = await loadSpec(file); pathCountBefore += countPaths(spec); + entries.push({ file, spec }); + } + const conflicting = findConflictingOperationIds(entries); + let filesToCombine = files; + let dedupTempDir = null; + if (conflicting.size > 0) { + logger.info( + `Found ${conflicting.size} conflicting operationId(s): ${[...conflicting].join(", ")}` + ); + const slugs = deriveSlugs(entries); + dedupTempDir = path4.join(path4.dirname(outputPath), ".temp-dedup"); + await fs3.mkdir(dedupTempDir, { recursive: true }); + filesToCombine = []; + for (let i = 0; i < entries.length; i++) { + const deduped = deduplicateOperationIds( + entries[i].spec, + slugs[i], + conflicting + ); + const ext2 = entries[i].file.endsWith(".json") ? ".json" : ".yaml"; + const tempFile = path4.join(dedupTempDir, `dedup-${i}${ext2}`); + await saveSpec(deduped, tempFile); + filesToCombine.push(tempFile); + } + } + try { + const outputDir = path4.dirname(outputPath); + await fs3.mkdir(outputDir, { recursive: true }); + await combineSpecs( + filesToCombine, + outputPath, + serverStrategy, + prefixWithInfo + ); + const combinedSpec = await loadSpec(outputPath); + const pathCountAfter = countPaths(combinedSpec); + return { + spec: combinedSpec, + pathCountBefore, + pathCountAfter + }; + } finally { + if (dedupTempDir) { + await fs3.rm(dedupTempDir, { recursive: true, force: true }).catch(() => { + }); + } } - const outputDir = path4.dirname(outputPath); - await fs3.mkdir(outputDir, { recursive: true }); - await combineSpecs(files, outputPath, serverStrategy); - const combinedSpec = await loadSpec(outputPath); - const pathCountAfter = countPaths(combinedSpec); - return { - spec: combinedSpec, - pathCountBefore, - pathCountAfter - }; } // src/combine/index.ts @@ -14693,6 +14800,7 @@ async function main() { const inputFiles = getInput("input_files", { required: true }); const outputPath = getInput("output_path") || "./combined-openapi.yaml"; const serverStrategyInput = getInput("server_url_strategy"); + const prefixWithInfo = getBooleanInput("prefix_with_info", { required: false }) ?? false; logger.info(`Input patterns: ${inputFiles}`); logger.info(`Output path: ${outputPath}`); let serverStrategy; @@ -14707,7 +14815,8 @@ async function main() { const result = await combineOpenAPISpecs( inputFiles, outputPath, - serverStrategy + serverStrategy, + prefixWithInfo ); logger.info(`Total paths before combine: ${result.pathCountBefore}`); logger.info(`Total paths after combine: ${result.pathCountAfter}`); diff --git a/examples/README.md b/examples/README.md index c4717265..43cc5afd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -54,6 +54,8 @@ If your API is split across multiple OpenAPI spec files, use the `prepare/combin The action uses [Redocly CLI](https://redocly.com/docs/cli/) to combine specs, handling reference resolution and path merging. You can also configure how server URLs are handled when combining specs from different services. +When specs share `operationId` values, the action automatically prefixes conflicting IDs with a slug derived from each spec's `info.title` to avoid collisions during the combine. You can also set `prefix_with_info: true` to prefix tags and component names with each spec's title, which helps avoid collisions when multiple specs define tags or components with the same names. + ### Converting Swagger 2.0 specs If you have a Swagger 2.0 spec, use the `prepare/swagger` action to convert it to OpenAPI 3.x format. See [prepare_swagger.yml](./prepare_swagger.yml) for an example. diff --git a/examples/prepare_combine.yml b/examples/prepare_combine.yml index 8eb0ce03..cef574e1 100644 --- a/examples/prepare_combine.yml +++ b/examples/prepare_combine.yml @@ -70,6 +70,12 @@ jobs: # - https://users.api.example.com # - https://billing.api.example.com + # Optional: Prefix tags and component names with each spec's info.title + # to avoid collisions across specs. Useful when multiple specs define + # tags or components with the same names. + # + # prefix_with_info: true + - name: Build SDKs uses: stainless-api/upload-openapi-spec-action/build@v1 with: diff --git a/prepare/combine/action.yml b/prepare/combine/action.yml index e45a1e37..85974135 100644 --- a/prepare/combine/action.yml +++ b/prepare/combine/action.yml @@ -27,6 +27,13 @@ inputs: - URLs not listed are ignored (removed, will use global server) required: false + prefix_with_info: + description: >- + When true, prefixes tags and component names with the spec's info.title + to avoid collisions across specs. Uses Redocly's + --prefix-tags-with-info-prop and --prefix-components-with-info-prop flags. + required: false + default: "false" log_level: description: >- Log verbosity level. Options: 'debug', 'info', 'warn', 'error', 'off'. diff --git a/src/__fixtures__/service-a.yaml b/src/__fixtures__/service-a.yaml new file mode 100644 index 00000000..ab509d37 --- /dev/null +++ b/src/__fixtures__/service-a.yaml @@ -0,0 +1,21 @@ +openapi: "3.0.0" +info: + title: Service A + version: "1.0.0" +servers: + - url: https://api.example.com/service-a +paths: + /tenants: + get: + operationId: listTenants + summary: List tenants + responses: + "200": + description: Success + /tenants/{id}: + get: + operationId: getTenant + summary: Get tenant + responses: + "200": + description: Success diff --git a/src/__fixtures__/service-b.yaml b/src/__fixtures__/service-b.yaml new file mode 100644 index 00000000..2a4ed4f0 --- /dev/null +++ b/src/__fixtures__/service-b.yaml @@ -0,0 +1,21 @@ +openapi: "3.0.0" +info: + title: Service B + version: "1.0.0" +servers: + - url: https://api.example.com/service-b +paths: + /tenants: + get: + operationId: listTenants + summary: List tenants from B + responses: + "200": + description: Success + /health: + get: + operationId: healthCheck + summary: Health check + responses: + "200": + description: Success diff --git a/src/combine/combine.test.ts b/src/combine/combine.test.ts index 36636c8f..5d8371f2 100644 --- a/src/combine/combine.test.ts +++ b/src/combine/combine.test.ts @@ -7,7 +7,13 @@ import { findFiles, loadSpec, saveSpec, + addBaseToPath, + slugify, + deriveSlugs, + findConflictingOperationIds, + deduplicateOperationIds, type OpenAPISpec, + type SpecEntry, } from "./combine"; describe("combine", () => { @@ -25,8 +31,10 @@ describe("combine", () => { describe("findFiles", () => { it("should find files with glob patterns", async () => { const result = await findFiles(`${fixturesDir}/*.yaml`); - expect(result.files).toHaveLength(1); - expect(result.files[0]).toContain("products-api.yaml"); + expect(result.files.length).toBeGreaterThanOrEqual(1); + expect(result.files.some((f) => f.includes("products-api.yaml"))).toBe( + true, + ); expect(result.emptyPatterns).toHaveLength(0); }); @@ -34,7 +42,7 @@ describe("combine", () => { const result = await findFiles( `${fixturesDir}/*.json, ${fixturesDir}/*.yaml`, ); - expect(result.files).toHaveLength(2); + expect(result.files.length).toBeGreaterThanOrEqual(2); expect(result.files.some((f) => f.includes("users-api.json"))).toBe(true); expect(result.files.some((f) => f.includes("products-api.yaml"))).toBe( true, @@ -60,7 +68,10 @@ describe("combine", () => { const result = await findFiles( `${fixturesDir}/*.yaml, ${fixturesDir}/missing.json, ${fixturesDir}/*.nope`, ); - expect(result.files).toHaveLength(1); // products-api.yaml + expect(result.files.length).toBeGreaterThanOrEqual(1); + expect(result.files.some((f) => f.includes("products-api.yaml"))).toBe( + true, + ); expect(result.emptyPatterns).toHaveLength(2); expect(result.emptyPatterns).toContain(`${fixturesDir}/missing.json`); expect(result.emptyPatterns).toContain(`${fixturesDir}/*.nope`); @@ -221,5 +232,216 @@ describe("combine", () => { combineOpenAPISpecs("missing1.yaml, missing2.json", outputPath), ).rejects.toThrow(/missing2\.json/); }); + + it("should deduplicate conflicting operationIds across specs", async () => { + const outputPath = path.join(tempDir, "combined-dedup.yaml"); + const serverStrategy = { + global: "https://api.example.com", + preserve: [ + "https://api.example.com/service-a", + "https://api.example.com/service-b", + ], + }; + const result = await combineOpenAPISpecs( + `${fixturesDir}/service-a.yaml, ${fixturesDir}/service-b.yaml`, + outputPath, + serverStrategy, + true, + ); + + const spec = result.spec; + + // All paths should be preserved + expect(result.pathCountBefore).toBe(4); + expect(result.pathCountAfter).toBe(4); + + // Collect all operationIds from the combined spec + const operationIds: string[] = []; + for (const pathItem of Object.values(spec.paths || {})) { + for (const method of [ + "get", + "post", + "put", + "delete", + "patch", + ] as const) { + const op = pathItem[method] as Record | undefined; + if (op?.operationId && typeof op.operationId === "string") { + operationIds.push(op.operationId); + } + } + } + + // listTenants was conflicting — both should be prefixed + expect( + operationIds.some( + (id) => id.includes("service-a") && id.includes("listTenants"), + ), + ).toBe(true); + expect( + operationIds.some( + (id) => id.includes("service-b") && id.includes("listTenants"), + ), + ).toBe(true); + + // Non-conflicting IDs should still exist (possibly prefixed by Redocly with prefix_with_info) + expect(operationIds.some((id) => id.includes("getTenant"))).toBe(true); + expect(operationIds.some((id) => id.includes("healthCheck"))).toBe(true); + }); + }); + + describe("addBaseToPath", () => { + it("should include URL path in base parameter", () => { + const result = addBaseToPath( + "/health", + "https://api.staging.cloud.cisco.com/api-vault-service", + ); + expect(result).toBe( + "/health?base=api.staging.cloud.cisco.com%2Fapi-vault-service", + ); + }); + + it("should use hostname only when no path", () => { + const result = addBaseToPath("/health", "https://cifls.webex.com"); + expect(result).toBe("/health?base=cifls.webex.com"); + }); + + it("should strip trailing slashes from URL path", () => { + const result = addBaseToPath("/test", "https://api.example.com/service/"); + expect(result).toBe("/test?base=api.example.com%2Fservice"); + }); + }); + + describe("slugify", () => { + it("should convert basic text", () => { + expect(slugify("Service A")).toBe("service-a"); + }); + + it("should handle special characters", () => { + expect(slugify("My API (v2.1)")).toBe("my-api-v2-1"); + }); + + it("should handle empty string", () => { + expect(slugify("")).toBe(""); + }); + }); + + describe("deriveSlugs", () => { + it("should derive slugs from unique titles", () => { + const entries = [ + { + file: "a.yaml", + spec: { openapi: "3.0.0", info: { title: "Service A" } }, + }, + { + file: "b.yaml", + spec: { openapi: "3.0.0", info: { title: "Service B" } }, + }, + ]; + expect(deriveSlugs(entries)).toEqual(["service-a", "service-b"]); + }); + + it("should append counter for duplicate titles", () => { + const entries = [ + { file: "a.yaml", spec: { openapi: "3.0.0", info: { title: "API" } } }, + { file: "b.yaml", spec: { openapi: "3.0.0", info: { title: "API" } } }, + { file: "c.yaml", spec: { openapi: "3.0.0", info: { title: "API" } } }, + ]; + expect(deriveSlugs(entries)).toEqual(["api-0", "api-1", "api-2"]); + }); + + it("should use fallback for missing title", () => { + const entries = [ + { file: "a.yaml", spec: { openapi: "3.0.0" } }, + { file: "b.yaml", spec: { openapi: "3.0.0", info: { title: "Real" } } }, + ]; + expect(deriveSlugs(entries)).toEqual(["spec-0", "real"]); + }); + }); + + describe("findConflictingOperationIds", () => { + it("should return empty set when no conflicts", () => { + const entries: SpecEntry[] = [ + { + file: "a.yaml", + spec: { + openapi: "3.0.0", + paths: { "/a": { get: { operationId: "getA" } } }, + }, + }, + { + file: "b.yaml", + spec: { + openapi: "3.0.0", + paths: { "/b": { get: { operationId: "getB" } } }, + }, + }, + ]; + expect(findConflictingOperationIds(entries).size).toBe(0); + }); + + it("should detect conflicting operationIds", () => { + const entries: SpecEntry[] = [ + { + file: "a.yaml", + spec: { + openapi: "3.0.0", + paths: { "/a": { get: { operationId: "list" } } }, + }, + }, + { + file: "b.yaml", + spec: { + openapi: "3.0.0", + paths: { "/b": { get: { operationId: "list" } } }, + }, + }, + ]; + const result = findConflictingOperationIds(entries); + expect(result.has("list")).toBe(true); + expect(result.size).toBe(1); + }); + + it("should handle operations without operationId", () => { + const entries: SpecEntry[] = [ + { + file: "a.yaml", + spec: { + openapi: "3.0.0", + paths: { "/a": { get: { summary: "no id" } } }, + }, + }, + ]; + expect(findConflictingOperationIds(entries).size).toBe(0); + }); + }); + + describe("deduplicateOperationIds", () => { + it("should prefix conflicting operationIds", () => { + const spec: OpenAPISpec = { + openapi: "3.0.0", + paths: { + "/x": { get: { operationId: "list" } }, + "/y": { post: { operationId: "unique" } }, + }, + }; + const conflicting = new Set(["list"]); + const result = deduplicateOperationIds(spec, "svc", conflicting); + const getOp = result.paths!["/x"].get as Record; + const postOp = result.paths!["/y"].post as Record; + expect(getOp.operationId).toBe("svc_list"); + expect(postOp.operationId).toBe("unique"); + }); + + it("should not mutate the input spec", () => { + const spec: OpenAPISpec = { + openapi: "3.0.0", + paths: { "/x": { get: { operationId: "list" } } }, + }; + const conflicting = new Set(["list"]); + deduplicateOperationIds(spec, "svc", conflicting); + const getOp = spec.paths!["/x"].get as Record; + expect(getOp.operationId).toBe("list"); + }); }); }); diff --git a/src/combine/combine.ts b/src/combine/combine.ts index 9860b012..16a17995 100644 --- a/src/combine/combine.ts +++ b/src/combine/combine.ts @@ -112,15 +112,110 @@ export function countPaths(spec: OpenAPISpec): number { /** * Add a base query parameter to a path to disambiguate collisions. */ -function addBaseToPath(pathKey: string, baseUrl: string): string { +export function addBaseToPath(pathKey: string, baseUrl: string): string { const url = new URL(baseUrl); - const domain = url.hostname; + const urlPath = url.pathname.replace(/\/+$/, ""); + const base = urlPath ? `${url.hostname}${urlPath}` : url.hostname; const [pathname, queryString] = pathKey.split("?"); const params = new URLSearchParams(queryString || ""); - params.set("base", domain); + params.set("base", base); return `${pathname}?${params.toString()}`; } +/** + * Convert text to a URL-friendly slug. + */ +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +export interface SpecEntry { + file: string; + spec: OpenAPISpec; +} + +/** + * Derive unique slugs from spec titles, appending counters for duplicates. + */ +export function deriveSlugs(entries: SpecEntry[]): string[] { + const rawSlugs = entries.map((entry, i) => { + const info = entry.spec.info as Record | undefined; + const title = info?.title; + return typeof title === "string" && title ? slugify(title) : `spec-${i}`; + }); + + const counts = new Map(); + const result: string[] = []; + for (const slug of rawSlugs) { + const count = counts.get(slug) ?? 0; + counts.set(slug, count + 1); + result.push(count === 0 ? slug : `${slug}-${count}`); + } + + // If any slug had duplicates, retroactively suffix the first occurrence + for (let i = 0; i < result.length; i++) { + const slug = rawSlugs[i]; + if ((counts.get(slug) ?? 0) > 1 && result[i] === slug) { + result[i] = `${slug}-0`; + } + } + + return result; +} + +/** + * Find operationIds that appear in more than one spec. + */ +export function findConflictingOperationIds(entries: SpecEntry[]): Set { + const seen = new Map(); + + for (const { spec } of entries) { + for (const pathItem of Object.values(spec.paths || {})) { + for (const method of HTTP_METHODS) { + const op = pathItem[method] as Record | undefined; + if (op?.operationId && typeof op.operationId === "string") { + seen.set(op.operationId, (seen.get(op.operationId) ?? 0) + 1); + } + } + } + } + + const conflicting = new Set(); + for (const [id, count] of seen) { + if (count > 1) conflicting.add(id); + } + return conflicting; +} + +/** + * Deep-clone a spec and prefix conflicting operationIds with a slug. + */ +export function deduplicateOperationIds( + spec: OpenAPISpec, + slug: string, + conflicting: Set, +): OpenAPISpec { + const cloned: OpenAPISpec = JSON.parse(JSON.stringify(spec)); + + for (const pathItem of Object.values(cloned.paths || {})) { + for (const method of HTTP_METHODS) { + const op = pathItem[method] as Record | undefined; + if ( + op?.operationId && + typeof op.operationId === "string" && + conflicting.has(op.operationId) + ) { + op.operationId = `${slug}_${op.operationId}`; + } + } + } + + return cloned; +} + /** * Process a spec according to the server URL strategy. */ @@ -193,6 +288,7 @@ async function combineSpecs( files: string[], outputPath: string, serverStrategy?: ServerUrlStrategy, + prefixWithInfo?: boolean, ): Promise { if (files.length === 0) { throw new Error("No files to combine"); @@ -244,12 +340,22 @@ async function combineSpecs( // Redocly CLI outputs to stderr instead of files when NODE_ENV=test const env = { ...process.env, NODE_ENV: "production" }; - try { - await spawn( - "npx", - ["@redocly/cli", "join", ...filesToCombine, "-o", jsonPath], - { env }, + const joinArgs = [ + "@redocly/cli", + "join", + ...filesToCombine, + "-o", + jsonPath, + ]; + if (prefixWithInfo) { + joinArgs.push( + "--prefix-tags-with-info-prop=title", + "--prefix-components-with-info-prop=title", ); + } + + try { + await spawn("npx", joinArgs, { env }); } catch (error: unknown) { const stderr = error && typeof error === "object" && "stderr" in error @@ -285,6 +391,7 @@ export async function combineOpenAPISpecs( inputPatterns: string, outputPath: string, serverStrategy?: ServerUrlStrategy, + prefixWithInfo?: boolean, ): Promise { const { files, emptyPatterns } = await findFiles(inputPatterns); @@ -311,27 +418,69 @@ export async function combineOpenAPISpecs( logger.debug(` - ${file}`); } - // Count paths before combine + // Load all specs to count paths and detect operationId conflicts + const entries: SpecEntry[] = []; let pathCountBefore = 0; for (const file of files) { const spec = await loadSpec(file); pathCountBefore += countPaths(spec); + entries.push({ file, spec }); } - // Ensure output directory exists - const outputDir = path.dirname(outputPath); - await fs.mkdir(outputDir, { recursive: true }); + // Deduplicate conflicting operationIds across specs + const conflicting = findConflictingOperationIds(entries); + let filesToCombine = files; + let dedupTempDir: string | null = null; - // Combine specs - await combineSpecs(files, outputPath, serverStrategy); + if (conflicting.size > 0) { + logger.info( + `Found ${conflicting.size} conflicting operationId(s): ${[...conflicting].join(", ")}`, + ); + const slugs = deriveSlugs(entries); + dedupTempDir = path.join(path.dirname(outputPath), ".temp-dedup"); + await fs.mkdir(dedupTempDir, { recursive: true }); + filesToCombine = []; + + for (let i = 0; i < entries.length; i++) { + const deduped = deduplicateOperationIds( + entries[i].spec, + slugs[i], + conflicting, + ); + const ext = entries[i].file.endsWith(".json") ? ".json" : ".yaml"; + const tempFile = path.join(dedupTempDir, `dedup-${i}${ext}`); + await saveSpec(deduped, tempFile); + filesToCombine.push(tempFile); + } + } - // Load combined spec to count paths - const combinedSpec = await loadSpec(outputPath); - const pathCountAfter = countPaths(combinedSpec); + try { + // Ensure output directory exists + const outputDir = path.dirname(outputPath); + await fs.mkdir(outputDir, { recursive: true }); + + // Combine specs + await combineSpecs( + filesToCombine, + outputPath, + serverStrategy, + prefixWithInfo, + ); - return { - spec: combinedSpec, - pathCountBefore, - pathCountAfter, - }; + // Load combined spec to count paths + const combinedSpec = await loadSpec(outputPath); + const pathCountAfter = countPaths(combinedSpec); + + return { + spec: combinedSpec, + pathCountBefore, + pathCountAfter, + }; + } finally { + if (dedupTempDir) { + await fs + .rm(dedupTempDir, { recursive: true, force: true }) + .catch(() => {}); + } + } } diff --git a/src/combine/index.ts b/src/combine/index.ts index 119dfbb2..f7d7263b 100644 --- a/src/combine/index.ts +++ b/src/combine/index.ts @@ -2,7 +2,7 @@ * GitHub Action entry point for combining OpenAPI specs. */ -import { getInput } from "../compat/input"; +import { getBooleanInput, getInput } from "../compat/input"; import { setOutput } from "../compat/output"; import { logger } from "../logger"; import { combineOpenAPISpecs, type ServerUrlStrategy } from "./combine"; @@ -13,6 +13,8 @@ async function main() { const inputFiles = getInput("input_files", { required: true }); const outputPath = getInput("output_path") || "./combined-openapi.yaml"; const serverStrategyInput = getInput("server_url_strategy"); + const prefixWithInfo = + getBooleanInput("prefix_with_info", { required: false }) ?? false; logger.info(`Input patterns: ${inputFiles}`); logger.info(`Output path: ${outputPath}`); @@ -32,6 +34,7 @@ async function main() { inputFiles, outputPath, serverStrategy, + prefixWithInfo, ); logger.info(`Total paths before combine: ${result.pathCountBefore}`);