diff --git a/.changeset/thirty-clowns-call.md b/.changeset/thirty-clowns-call.md new file mode 100644 index 0000000..ba8d856 --- /dev/null +++ b/.changeset/thirty-clowns-call.md @@ -0,0 +1,8 @@ +--- +"@pactflow/openapi-pact-comparator": minor +--- + +Support URLs for OAS and Pact file arguments + +- CLI now accepts http:// and https:// URLs for both OAS and Pact files +- Fixed exit code overflow by capping at 255 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9d07dc..9f46876 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,4 +12,4 @@ npm run test Then, when you're ready for a PR, use [changesets](https://github.com/changesets/changesets) to describe your PR. -Simply `npm run changesets:add` and follow the prompts. +Simply `npm run changeset:add` and follow the prompts. diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts new file mode 100644 index 0000000..30b3bcb --- /dev/null +++ b/src/__tests__/cli.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import { spawn } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const fixturesDir = path.join(__dirname, "fixtures"); +const cliPath = path.join(__dirname, "..", "cli.ts"); +const tsxCli = require.resolve("tsx/cli"); + +let server: Server; +let baseUrl: string; + +beforeAll(async () => { + server = createServer((req, res) => { + const filePath = path.join(fixturesDir, req.url!); + try { + const content = readFileSync(filePath); + res.writeHead(200); + res.end(content); + } catch { + res.writeHead(404); + res.end("Not Found"); + } + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address() as { port: number }; + baseUrl = `http://127.0.0.1:${addr.port}`; +}); + +afterAll(() => server.close()); + +const runCli = ( + oasPath: string, + pactPath: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> => { + return new Promise((resolve) => { + const child = spawn( + process.execPath, + [tsxCli, cliPath, oasPath, pactPath], + { + cwd: fixturesDir, + }, + ); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (data) => { + stdout += data.toString(); + }); + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("close", (exitCode) => { + resolve({ exitCode: exitCode ?? 0, stdout, stderr }); + }); + }); +}; + +describe("CLI integration", () => { + it("should exit with code 0 when no errors are found", async () => { + const { exitCode, stdout } = await runCli( + path.join("example-petstore-valid", "oas.yaml"), + path.join("example-petstore-valid", "pact.json"), + ); + + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe("[]"); + }); + + it("should exit with non-zero code when errors are found", async () => { + const { exitCode, stdout } = await runCli( + path.join("example-petstore-invalid", "oas.yaml"), + path.join("example-petstore-invalid", "pact.json"), + ); + + expect(exitCode).toBe(1); + expect(stdout).toContain('"type":"error"'); + expect(stdout).toContain('"code":"response.status.unknown"'); + }); + + it("should fetch OAS and Pact from URLs", async () => { + const oasUrl = `${baseUrl}/example-petstore-valid/oas.yaml`; + const pactUrl = `${baseUrl}/example-petstore-valid/pact.json`; + + const { exitCode, stdout } = await runCli(oasUrl, pactUrl); + + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe("[]"); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 01eb3ec..fa89739 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,23 +1,8 @@ #!/usr/bin/env node import { program } from "commander"; -import yaml from "js-yaml"; -import fs from "node:fs"; -import { Comparator } from "./index"; import packageJson from "../package.json"; - -const readAndParse = async (filename: string) => { - const file = await fs.promises.readFile(filename, { encoding: "utf-8" }); - try { - return JSON.parse(file); - } catch (error) { - try { - return yaml.load(file); - } catch (_err) { - throw error; - } - } -}; +import { Runner } from "./cli/runner"; program .version(packageJson.version) @@ -29,25 +14,11 @@ Comparison output is presented as ND-JSON, with one line per Pact file. The exit code equals the number of Pact files with errors (not the number of errors in one comparison).`, ) - .argument("", "path to OAS file") - .argument("", "path(s) to Pact file(s)") - .action(async (oasPath, pactPaths) => { - const oas = await readAndParse(oasPath); - const comparator = new Comparator(oas); - - let errors = 0; - for (const pactPath of pactPaths) { - const pact = await readAndParse(pactPath); - - const results = []; - for await (const result of comparator.compare(pact)) { - results.push(result); - } - - errors += results.some((r) => r.type === "error") ? 1 : 0; - console.log(JSON.stringify(results)); - } - - process.exit(errors); + .argument("", "path or URL to OAS file") + .argument("", "path(s) or URL(s) to Pact file(s)") + .action(async (oasPath: string, pactPaths: string[]) => { + const runner = new Runner(); + const exitCode = await runner.run(oasPath, pactPaths); + process.exit(exitCode); }) .parse(process.argv); diff --git a/src/cli/runner.test.ts b/src/cli/runner.test.ts new file mode 100644 index 0000000..bb73952 --- /dev/null +++ b/src/cli/runner.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it, vi, beforeEach, type Mock } from "vitest"; +import yaml from "js-yaml"; +import { Runner } from "./runner"; +import type { Result } from "#results/index"; + +describe("Runner", () => { + const defaultOasContent = JSON.stringify({ openapi: "3.0.0" }); + const defaultPactContent = JSON.stringify({ interactions: [] }); + + let readFileMock: Mock; + let fetchMock: Mock; + let outputMock: Mock; + let compareMock: Mock; + let createComparatorMock: Mock; + + const givenReadFileReturns = (...contents: string[]) => { + readFileMock.mockReset(); + for (const content of contents) { + readFileMock.mockResolvedValueOnce(content); + } + }; + + const givenFetchReturns = (...contents: string[]) => { + fetchMock.mockReset(); + for (const content of contents) { + fetchMock.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(content), + }); + } + }; + + const givenCompareReturns = (...resultsPerCall: Result[][]) => { + let callCount = 0; + compareMock.mockImplementation(async function* () { + const results = resultsPerCall[callCount] ?? []; + callCount++; + for (const result of results) { + yield result; + } + }); + }; + + beforeEach(() => { + readFileMock = vi.fn(); + fetchMock = vi.fn(); + outputMock = vi.fn(); + compareMock = vi.fn(); + createComparatorMock = vi.fn().mockReturnValue({ compare: compareMock }); + givenReadFileReturns(defaultOasContent, defaultPactContent); + givenCompareReturns([]); + }); + + const whenRunIsCalled = (oasPath: string, pactPaths: string[]) => { + const runner = new Runner({ + readFile: readFileMock, + fetch: fetchMock, + output: outputMock, + createComparator: createComparatorMock, + }); + return runner.run(oasPath, pactPaths); + }; + + describe("reading and parsing inputs", () => { + it("should parse JSON content", async () => { + const oasDocument = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0" }, + }; + const pactDocument = { interactions: [] }; + const oasJsonContent = JSON.stringify(oasDocument); + const pactJsonContent = JSON.stringify(pactDocument); + givenReadFileReturns(oasJsonContent, pactJsonContent); + + await whenRunIsCalled("oas.json", ["pact.json"]); + + expect(createComparatorMock).toHaveBeenCalledWith(oasDocument); + expect(compareMock).toHaveBeenCalledWith(pactDocument); + }); + + it("should parse YAML content when JSON parsing fails", async () => { + const oasDocument = { + openapi: "3.0.0", + info: { title: "Test API", version: "1.0" }, + }; + const yamlContent = yaml.dump(oasDocument); + givenReadFileReturns(yamlContent, defaultPactContent); + + await whenRunIsCalled("oas.yaml", ["pact.json"]); + + expect(createComparatorMock).toHaveBeenCalledWith(oasDocument); + }); + + it("should throw JSON error when both JSON and YAML parsing fail", async () => { + givenReadFileReturns("{ invalid json and not valid yaml either: ["); + + await expect( + whenRunIsCalled("invalid.txt", ["pact.json"]), + ).rejects.toThrow(SyntaxError); + }); + + it.each([ + ["https URL", "https://example.com/oas.yaml"], + ["http URL", "http://example.com/oas.yaml"], + ["HTTPS URL (uppercase)", "HTTPS://example.com/oas.yaml"], + ])( + "should fetch content when OAS path is a %s", + async (_description, oasUrl) => { + const oasDocument = { openapi: "3.0.0" }; + givenFetchReturns(JSON.stringify(oasDocument)); + + await whenRunIsCalled(oasUrl, ["pact.json"]); + + expect(fetchMock).toHaveBeenCalledWith(oasUrl); + expect(readFileMock).not.toHaveBeenCalledWith(oasUrl); + expect(createComparatorMock).toHaveBeenCalledWith(oasDocument); + }, + ); + + it("should fetch content when Pact path is a URL", async () => { + const pactUrl = "https://example.com/pact.json"; + const pactDocument = { interactions: [] }; + givenFetchReturns(JSON.stringify(pactDocument)); + + await whenRunIsCalled("oas.json", [pactUrl]); + + expect(fetchMock).toHaveBeenCalledWith(pactUrl); + expect(readFileMock).not.toHaveBeenCalledWith(pactUrl); + expect(compareMock).toHaveBeenCalledWith(pactDocument); + }); + + it("should throw error when URL response status is not ok", async () => { + const oasUrl = "https://example.com/oas.yaml"; + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + text: () => Promise.resolve("Not Found"), + }); + + await expect(whenRunIsCalled(oasUrl, ["pact.json"])).rejects.toThrow( + "HTTP 404: Not Found", + ); + }); + }); + + describe("Comparator initialization", () => { + it("should initialize Comparator with parsed OAS document", async () => { + const oasDocument = { openapi: "3.0.0", paths: {} }; + givenReadFileReturns(JSON.stringify(oasDocument), defaultPactContent); + + await whenRunIsCalled("oas.json", ["pact.json"]); + + expect(createComparatorMock).toHaveBeenCalledTimes(1); + expect(createComparatorMock).toHaveBeenCalledWith(oasDocument); + }); + + it("should reuse same Comparator instance for multiple pact files", async () => { + const oasDocument = { openapi: "3.0.0" }; + const pactDocument1 = { interactions: [], name: "pact1" }; + const pactDocument2 = { interactions: [], name: "pact2" }; + givenReadFileReturns( + JSON.stringify(oasDocument), + JSON.stringify(pactDocument1), + JSON.stringify(pactDocument2), + ); + + await whenRunIsCalled("oas.json", ["pact1.json", "pact2.json"]); + + expect(createComparatorMock).toHaveBeenCalledTimes(1); + expect(compareMock).toHaveBeenCalledTimes(2); + expect(compareMock).toHaveBeenNthCalledWith(1, pactDocument1); + expect(compareMock).toHaveBeenNthCalledWith(2, pactDocument2); + }); + }); + + describe("compare results handling", () => { + it("should output results as JSON to stdout", async () => { + const mockResults: Result[] = [ + { + type: "warning", + code: "request.header.unknown", + message: "Test warning", + }, + ]; + givenCompareReturns(mockResults); + + await whenRunIsCalled("oas.json", ["pact.json"]); + + expect(outputMock).toHaveBeenCalledWith(JSON.stringify(mockResults)); + }); + + it("should output separate JSON lines for each pact file", async () => { + const results1: Result[] = [ + { type: "warning", code: "request.header.unknown", message: "warn 1" }, + ]; + const results2: Result[] = [ + { type: "warning", code: "request.query.unknown", message: "warn 2" }, + ]; + givenCompareReturns(results1, results2); + givenReadFileReturns( + defaultOasContent, + defaultPactContent, + defaultPactContent, + ); + + await whenRunIsCalled("oas.json", ["pact1.json", "pact2.json"]); + + expect(outputMock).toHaveBeenCalledTimes(2); + expect(outputMock).toHaveBeenNthCalledWith(1, JSON.stringify(results1)); + expect(outputMock).toHaveBeenNthCalledWith(2, JSON.stringify(results2)); + }); + }); + + describe("exit codes", () => { + it("should return 0 when no errors are found", async () => { + givenCompareReturns([ + { type: "warning", code: "request.header.unknown", message: "warning" }, + ]); + + const exitCode = await whenRunIsCalled("oas.json", ["pact.json"]); + + expect(exitCode).toBe(0); + }); + + it("should return 1 when one pact file has errors", async () => { + givenCompareReturns([ + { + type: "error", + code: "request.body.incompatible", + message: "An error", + }, + ]); + + const exitCode = await whenRunIsCalled("oas.json", ["pact.json"]); + + expect(exitCode).toBe(1); + }); + + it("should return count of pact files that have errors", async () => { + givenCompareReturns( + [ + { + type: "error", + code: "request.body.incompatible", + message: "error", + }, + ], + [ + { + type: "warning", + code: "request.header.unknown", + message: "warning", + }, + ], + [ + { + type: "error", + code: "request.body.incompatible", + message: "error", + }, + ], + ); + givenReadFileReturns( + defaultOasContent, + defaultPactContent, + defaultPactContent, + defaultPactContent, + ); + + const exitCode = await whenRunIsCalled("oas.json", [ + "pact1.json", + "pact2.json", + "pact3.json", + ]); + + expect(exitCode).toBe(2); + }); + + it("should count only one error per pact file even with multiple errors", async () => { + givenCompareReturns([ + { + type: "error", + code: "request.body.incompatible", + message: "First error", + }, + { + type: "error", + code: "request.header.incompatible", + message: "Second error", + }, + { + type: "error", + code: "request.query.incompatible", + message: "Third error", + }, + ]); + + const exitCode = await whenRunIsCalled("oas.json", ["pact.json"]); + + expect(exitCode).toBe(1); + }); + + it("should cap exit code at 255 to avoid shell overflow", async () => { + const pactCount = 300; + const errorResults: Result[] = [ + { type: "error", code: "request.body.incompatible", message: "error" }, + ]; + givenCompareReturns(...Array(pactCount).fill(errorResults)); + givenReadFileReturns( + defaultOasContent, + ...Array(pactCount).fill(defaultPactContent), + ); + + const exitCode = await whenRunIsCalled( + "oas.json", + Array(pactCount).fill("pact.json"), + ); + + expect(exitCode).toBe(255); + }); + }); + + describe("default dependencies", () => { + it("should use default dependencies when none provided", () => { + const runner = new Runner(); + expect(runner).toBeInstanceOf(Runner); + }); + }); +}); diff --git a/src/cli/runner.ts b/src/cli/runner.ts new file mode 100644 index 0000000..09c5fd2 --- /dev/null +++ b/src/cli/runner.ts @@ -0,0 +1,92 @@ +import type { OpenAPIV2, OpenAPIV3 } from "openapi-types"; +import yaml from "js-yaml"; +import fs from "node:fs"; +import { Comparator } from "../index"; +import type { Result } from "#results/index"; + +type OASDocument = OpenAPIV2.Document | OpenAPIV3.Document; + +const MAX_EXIT_CODE = 255; + +export interface ComparatorLike { + compare(pact: unknown): AsyncGenerator; +} + +export interface RunnerDependencies { + readFile: (path: string) => Promise; + fetch: (url: string) => Promise; + output: (message: string) => void; + createComparator: (oas: OASDocument) => ComparatorLike; +} + +const defaultDependencies: RunnerDependencies = { + readFile: (path: string) => fs.promises.readFile(path, { encoding: "utf-8" }), + fetch: (url: string) => fetch(url), + output: (message: string) => console.log(message), + createComparator: (oas: OASDocument) => new Comparator(oas), +}; + +export class Runner { + private deps: RunnerDependencies; + + constructor(deps: Partial = {}) { + this.deps = { ...defaultDependencies, ...deps }; + } + + private isUrl(path: string): boolean { + try { + const url = new URL(path); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } + } + + private async readContent(path: string): Promise { + if (this.isUrl(path)) { + const response = await this.deps.fetch(path); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.text(); + } + return this.deps.readFile(path); + } + + private parseContent(content: string): unknown { + try { + return JSON.parse(content); + } catch (error) { + try { + return yaml.load(content); + } catch (_err) { + throw error; + } + } + } + + private async readAndParse(path: string): Promise { + const content = await this.readContent(path); + return this.parseContent(content); + } + + async run(oasPath: string, pactPaths: string[]): Promise { + const oas = (await this.readAndParse(oasPath)) as OASDocument; + const comparator = this.deps.createComparator(oas); + + let errors = 0; + for (const pactPath of pactPaths) { + const pact = await this.readAndParse(pactPath); + + const results: Result[] = []; + for await (const result of comparator.compare(pact)) { + results.push(result); + } + + errors += results.some((r) => r.type === "error") ? 1 : 0; + this.deps.output(JSON.stringify(results)); + } + + return Math.min(errors, MAX_EXIT_CODE); + } +}