diff --git a/.changeset/openapi-overlays-support.md b/.changeset/openapi-overlays-support.md new file mode 100644 index 000000000..0efb223bb --- /dev/null +++ b/.changeset/openapi-overlays-support.md @@ -0,0 +1,32 @@ +--- +"counterfact": minor +--- + +Add support for OpenAPI Overlays (v1.0.0). Overlays allow you to apply targeted modifications to an OpenAPI document without editing the original file. + +- New `--overlay ` CLI flag (repeatable) applies overlay files in order before code generation and server startup. +- `SpecConfig` now accepts an `overlays?: string[]` field for programmatic use and multi-spec config files. +- Each overlay file is a YAML/JSON document with an `overlay` version field and an `actions` array. Each action targets nodes with a JSONPath expression and either merges an `update` object or removes matched nodes. +- Overlays are applied to both the code-generator pipeline (`Specification.fromFile`) and the runtime server pipeline (`OpenApiDocument.load`). +- The new `applyOverlays` / `applyOverlayActions` / `loadOverlay` utilities are exported from `src/util/apply-overlay.ts`. + +Example overlay file (`my-overlay.yaml`): + +```yaml +overlay: 1.0.0 +info: + title: My Overlay + version: 1.0.0 +actions: + - target: $.info + update: + description: Patched by overlay + - target: $.paths['/internal'] + remove: true +``` + +Usage: + +```bash +counterfact openapi.yaml ./out --overlay my-overlay.yaml +``` diff --git a/docs/reference.md b/docs/reference.md index 9142f0515..8a0877ba0 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -350,6 +350,82 @@ See the [Multiple versions feature page](./features/multiple-versions.md) for a --- +## OpenAPI Overlays + +[OpenAPI Overlays](https://spec.openapis.org/overlay/v1.0.0.html) let you apply targeted modifications to an OpenAPI document without editing the original file. Counterfact loads overlay files, evaluates their JSONPath targets against the spec, and applies each action before code generation and server startup. + +### Overlay file format + +An overlay file is a YAML or JSON document with three top-level fields: + +| Field | Required | Description | +|-------|----------|-------------| +| `overlay` | ✓ | Overlay version string (must be `"1.0.0"`) | +| `info` | | Metadata (`title`, `version`) | +| `actions` | ✓ | Ordered list of actions to apply | + +Each action has: + +| Field | Description | +|-------|-------------| +| `target` | JSONPath expression selecting the nodes to act on | +| `update` | Object deep-merged into each matched node | +| `remove` | `true` to delete each matched node from its parent | + +Example overlay (`my-overlay.yaml`): + +```yaml +overlay: 1.0.0 +info: + title: My Overlay + version: 1.0.0 +actions: + - target: $.info + update: + description: Patched by overlay + - target: $.paths['/internal'] + remove: true +``` + +### CLI usage + +Pass `--overlay` one or more times. Overlays are applied in the order they appear: + +```bash +npx counterfact@latest openapi.yaml ./out --overlay base-overlay.yaml --overlay env-overlay.yaml +``` + +### Programmatic usage + +Pass `overlays` on each `SpecConfig` entry: + +```ts +import { counterfact } from "counterfact"; + +await counterfact(config, [ + { + source: "openapi.yaml", + group: "", + overlays: ["base-overlay.yaml", "env-overlay.yaml"], + }, +]); +``` + +### Config file usage (`counterfact.yaml`) + +When using a config file with the `spec` key, add `overlays` to each spec entry: + +```yaml +spec: + - source: openapi.yaml + group: "" + overlays: + - base-overlay.yaml + - env-overlay.yaml +``` + +--- + ## CLI reference ``` @@ -366,6 +442,7 @@ npx counterfact@latest [spec] [output] [options] | `-r, --repl` | `false` | Start the REPL | | `-b, --build-cache` | `false` | Build the cache of compiled routes and types | | `--spec ` | _(positional arg)_ | Path or URL to the OpenAPI document | +| `--overlay ` | _(none)_ | Path or URL to an OpenAPI overlay file (repeatable; applied in order) | | `--proxy-url ` | _(none)_ | Default upstream for the proxy | | `--prefix ` | _(none)_ | Global path prefix (e.g. `/api/v1`) | | `--no-validate-request` | — | Disable OpenAPI request validation | diff --git a/package.json b/package.json index dd24997d4..0e071961f 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "http-terminator": "3.2.0", "js-yaml": "4.1.1", "json-schema-faker": "0.6.1", + "jsonpath-plus": "10.4.0", "jsonwebtoken": "9.0.3", "koa": "3.2.0", "koa-bodyparser": "4.4.1", diff --git a/src/api-runner.ts b/src/api-runner.ts index 0c27f817b..3baa627e5 100644 --- a/src/api-runner.ts +++ b/src/api-runner.ts @@ -134,6 +134,7 @@ export class ApiRunner { config.basePath + this.subdirectory, config.generate, version, + config.overlays ?? [], ); this.dispatcher = new Dispatcher( @@ -195,7 +196,7 @@ export class ApiRunner { const openApiDocument = config.openApiPath === "_" ? undefined - : await loadOpenApiDocument(config.openApiPath); + : await loadOpenApiDocument(config.openApiPath, config.overlays ?? []); return new ApiRunner( config, diff --git a/src/app.ts b/src/app.ts index 8402c4ade..0e0f23094 100644 --- a/src/app.ts +++ b/src/app.ts @@ -65,6 +65,14 @@ export interface SpecConfig { * `VersionsGTE`, and `Versioned` types. */ version?: string; + /** + * Optional ordered list of OpenAPI overlay file paths/URLs to apply to the + * spec after it is loaded. Overlays are applied in the order listed. + * + * Each entry is a path or URL to an OpenAPI overlay document (version 1.0.0) + * containing `actions` that modify the loaded spec via JSONPath targeting. + */ + overlays?: string[]; } type Scenario$ = { @@ -243,7 +251,14 @@ export async function counterfact(config: Config, specs?: SpecConfig[]) { const runners = await Promise.all( normalizedSpecs.map((spec) => ApiRunner.create( - { ...config, openApiPath: spec.source, prefix: spec.prefix }, + { + ...config, + openApiPath: spec.source, + // Per-spec overlays take precedence; fall back to config-level overlays + // so that the --overlay CLI flag works in single-spec mode. + overlays: spec.overlays ?? config.overlays ?? [], + prefix: spec.prefix, + }, spec.group, spec.version ?? "", versionsByGroup.get(spec.group) ?? [], diff --git a/src/cli/run.ts b/src/cli/run.ts index 354744f39..0adadad27 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -28,6 +28,7 @@ type SpecOptionEntry = { prefix?: string; group?: string; version?: string; + overlays?: string[]; }; type SpecOption = string | SpecOptionEntry | SpecOptionEntry[] | undefined; @@ -36,7 +37,7 @@ type SpecOption = string | SpecOptionEntry | SpecOptionEntry[] | undefined; * CLI flag) into an array of {@link SpecConfig} objects, or `undefined` when * the option is a plain string (single OpenAPI document path). * - * - **Array**: each entry is mapped to `{source, prefix, group, version}` with defaults. + * - **Array**: each entry is mapped to `{source, prefix, group, version, overlays}` with defaults. * - **Object**: wrapped in a single-element array. * - **String / undefined**: returns `undefined` — caller handles the string * case (it shifts the positional argument) and the `undefined` case @@ -55,6 +56,7 @@ export function normalizeSpecOption( prefix: entry.prefix, group: entry.group ?? "", version: entry.version, + overlays: entry.overlays, })); } @@ -69,6 +71,7 @@ export function normalizeSpecOption( prefix: specOption.prefix, group: specOption.group ?? "", version: specOption.version, + overlays: specOption.overlays, }, ]; } @@ -99,6 +102,7 @@ function buildProgram(version: string, taglines: string[]): Command { generateRoutes?: boolean; generateTypes?: boolean; open?: boolean; + overlay?: string[]; port: number; prefix: string; prune?: boolean; @@ -214,6 +218,7 @@ function buildProgram(version: string, taglines: string[]): Command { }, openApiPath: source, + overlays: options.overlay ?? [], port: options.port, proxyPaths: new Map([["", Boolean(options.proxyUrl)]]), proxyUrl: options.proxyUrl ?? "", @@ -418,6 +423,12 @@ function buildProgram(version: string, taglines: string[]): Command { "--spec ", "path or URL to OpenAPI document (alternative to the positional [openapi.yaml] argument)", ) + .option( + "--overlay ", + "path or URL to an OpenAPI overlay file to apply (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) .option("--no-update-check", "disable the npm update check on startup") .option( "--no-validate-request", diff --git a/src/server/config.ts b/src/server/config.ts index 07d97f1dc..5413fe104 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -19,6 +19,11 @@ export interface Config { }; /** Path or URL to the OpenAPI document. Use `"_"` to skip spec loading. */ openApiPath: string; + /** + * Optional ordered list of overlay file paths/URLs to apply to the OpenAPI + * document after loading. Overlays are applied in the order listed. + */ + overlays?: readonly string[]; /** TCP port the HTTP server listens on. */ port: number; /** diff --git a/src/server/load-openapi-document.ts b/src/server/load-openapi-document.ts index 620e1ce05..9e265a38a 100644 --- a/src/server/load-openapi-document.ts +++ b/src/server/load-openapi-document.ts @@ -1,7 +1,10 @@ import { OpenApiDocument } from "./openapi-document.js"; -export async function loadOpenApiDocument(source: string) { - const document = new OpenApiDocument(source); +export async function loadOpenApiDocument( + source: string, + overlays: readonly string[] = [], +) { + const document = new OpenApiDocument(source, overlays); await document.load(); diff --git a/src/server/openapi-document.ts b/src/server/openapi-document.ts index 612389065..7c498a237 100644 --- a/src/server/openapi-document.ts +++ b/src/server/openapi-document.ts @@ -3,6 +3,7 @@ import createDebug from "debug"; import { dereference } from "@apidevtools/json-schema-ref-parser"; import type { OpenApiOperation } from "../counterfact-types/index.js"; +import { applyOverlays } from "../util/apply-overlay.js"; import { waitForEvent } from "../util/wait-for-event.js"; import { CHOKIDAR_OPTIONS } from "./constants.js"; import type { HttpMethods } from "./registry.js"; @@ -19,6 +20,12 @@ export class OpenApiDocument extends EventTarget { /** The path or URL of the OpenAPI source file. */ public readonly source: string; + /** + * Optional ordered list of overlay file paths/URLs to apply after each + * load of the document. + */ + public readonly overlays: readonly string[]; + public basePath?: string; public paths: { @@ -31,9 +38,10 @@ export class OpenApiDocument extends EventTarget { private watcher: FSWatcher | undefined; - public constructor(source: string) { + public constructor(source: string, overlays: readonly string[] = []) { super(); this.source = source; + this.overlays = overlays; } /** @@ -51,6 +59,14 @@ export class OpenApiDocument extends EventTarget { }; produces?: string[]; }; + + if (this.overlays.length > 0) { + await applyOverlays( + data as unknown as Record, + this.overlays, + ); + } + this.basePath = data.basePath; this.paths = data.paths; this.produces = data.produces; diff --git a/src/typescript-generator/code-generator.ts b/src/typescript-generator/code-generator.ts index cb28f85b0..fe1481262 100644 --- a/src/typescript-generator/code-generator.ts +++ b/src/typescript-generator/code-generator.ts @@ -32,6 +32,8 @@ export class CodeGenerator extends EventTarget { private readonly version: string; + private readonly overlays: readonly string[]; + private readonly generateOptions: { prune?: boolean; routes?: boolean; @@ -45,11 +47,13 @@ export class CodeGenerator extends EventTarget { destination: string, generateOptions: { prune?: boolean; routes?: boolean; types?: boolean }, version = "", + overlays: readonly string[] = [], ) { super(); this.openapiPath = openApiPath; this.destination = destination; this.version = version; + this.overlays = overlays; this.generateOptions = generateOptions; } @@ -130,7 +134,10 @@ export class CodeGenerator extends EventTarget { debug("creating specification from %s", this.openapiPath); - const specification = await Specification.fromFile(this.openapiPath); + const specification = await Specification.fromFile( + this.openapiPath, + this.overlays, + ); debug("created specification: $o", specification); diff --git a/src/typescript-generator/specification.ts b/src/typescript-generator/specification.ts index 0e48f5c77..fb0b25890 100644 --- a/src/typescript-generator/specification.ts +++ b/src/typescript-generator/specification.ts @@ -1,6 +1,7 @@ import { bundle } from "@apidevtools/json-schema-ref-parser"; import createDebug from "debug"; +import { applyOverlays } from "../util/apply-overlay.js"; import { Requirement, type RequirementData } from "./requirement.js"; const debug = createDebug("counterfact:typescript-generator:specification"); @@ -28,12 +29,17 @@ export class Specification { * Loads the OpenAPI document at `urlOrPath`, bundles all external `$ref` * references, and returns a fully initialised {@link Specification}. * - * @param urlOrPath - A local file path or HTTP(S) URL. + * @param urlOrPath - A local file path or HTTP(S) URL. + * @param overlays - Optional ordered list of overlay file paths/URLs to + * apply after loading the document. * @throws When the document cannot be found or parsed. */ - public static async fromFile(urlOrPath: string): Promise { + public static async fromFile( + urlOrPath: string, + overlays: readonly string[] = [], + ): Promise { const specification = new Specification(); - await specification.load(urlOrPath); + await specification.load(urlOrPath, overlays); return specification; } @@ -50,20 +56,30 @@ export class Specification { } /** - * Loads (or reloads) the specification from `urlOrPath`. + * Loads (or reloads) the specification from `urlOrPath`, then applies any + * overlay files listed in `overlays` in order. * * @param urlOrPath - A local file path or HTTP(S) URL. + * @param overlays - Optional ordered list of overlay file paths/URLs. * @throws When the document cannot be found or parsed. */ - public async load(urlOrPath: string): Promise { + public async load( + urlOrPath: string, + overlays: readonly string[] = [], + ): Promise { try { - this.rootRequirement = new Requirement( - (await bundle(urlOrPath, { - resolve: { http: { safeUrlResolver: false } }, - })) as RequirementData, - urlOrPath, - this, - ); + const document = (await bundle(urlOrPath, { + resolve: { http: { safeUrlResolver: false } }, + })) as RequirementData; + + if (overlays.length > 0) { + await applyOverlays( + document as unknown as Record, + overlays, + ); + } + + this.rootRequirement = new Requirement(document, urlOrPath, this); } catch (error) { const details = error instanceof Error ? error.message : String(error); throw new Error( diff --git a/src/util/apply-overlay.ts b/src/util/apply-overlay.ts new file mode 100644 index 000000000..17a70148d --- /dev/null +++ b/src/util/apply-overlay.ts @@ -0,0 +1,172 @@ +import { load as loadYaml } from "js-yaml"; +import { JSONPath } from "jsonpath-plus"; + +import { readFile } from "./read-file.js"; + +interface OverlayAction { + target: string; + update?: Record; + remove?: boolean; +} + +interface Overlay { + overlay: string; + info?: { title?: string; version?: string }; + actions: OverlayAction[]; +} + +/** + * Deeply merges `source` into `target`, overwriting scalar values and + * recursively merging plain objects. Arrays and non-plain-object values in + * `source` always overwrite the corresponding entry in `target`. + */ +function deepMerge( + target: Record, + source: Record, +): void { + for (const [key, value] of Object.entries(source)) { + // Guard against prototype pollution attacks. + if (key === "__proto__" || key === "constructor" || key === "prototype") { + continue; + } + + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + typeof target[key] === "object" && + target[key] !== null && + !Array.isArray(target[key]) + ) { + deepMerge( + target[key] as Record, + value as Record, + ); + } else { + target[key] = value; + } + } +} + +/** + * Applies a list of overlay actions to `document` in place. + * + * Each action may either: + * - **update**: deep-merge the `action.update` object into every node matched + * by the JSONPath `action.target`. + * - **remove**: delete every node matched by `action.target` from its parent. + * + * @param document - The OpenAPI document object to mutate. + * @param actions - The ordered list of overlay actions to apply. + */ +export function applyOverlayActions( + document: Record, + actions: OverlayAction[], +): void { + for (const action of actions) { + type PathResult = { + path: string; + value: unknown; + parent: Record | unknown[]; + parentProperty: string | number; + }; + + const results = JSONPath({ + path: action.target, + json: document, + resultType: "all", + }) as PathResult[]; + + if (action.remove === true) { + // Iterate in reverse so that removing by numeric index doesn't shift + // subsequent items in the same parent array. + for (const result of [...results].reverse()) { + const { parent, parentProperty } = result; + if (Array.isArray(parent)) { + parent.splice(Number(parentProperty), 1); + } else { + delete (parent as Record)[String(parentProperty)]; + } + } + } else if (action.update !== undefined) { + for (const result of results) { + if ( + typeof result.value === "object" && + result.value !== null && + !Array.isArray(result.value) + ) { + deepMerge(result.value as Record, action.update); + } + } + } + } +} + +/** + * Loads and parses an overlay file (YAML or JSON), validates that it looks + * like a valid OpenAPI overlay document, and returns the parsed object. + * + * @param overlayPath - Path or URL to the overlay file. + * @throws When the file cannot be read, parsed, or does not contain an + * `overlay` version field and an `actions` array. + */ +export async function loadOverlay(overlayPath: string): Promise { + let content: string; + + try { + content = await readFile(overlayPath); + } catch (error) { + const details = error instanceof Error ? error.message : String(error); + throw new Error( + `Could not read overlay file "${overlayPath}".\n${details}`, + { cause: error }, + ); + } + + let parsed: unknown; + + try { + parsed = loadYaml(content); + } catch (error) { + const details = error instanceof Error ? error.message : String(error); + throw new Error( + `Could not parse overlay file "${overlayPath}".\n${details}`, + { cause: error }, + ); + } + + if ( + typeof parsed !== "object" || + parsed === null || + !("overlay" in parsed) || + !("actions" in parsed) || + !Array.isArray((parsed as { actions: unknown }).actions) + ) { + throw new Error( + `"${overlayPath}" does not appear to be a valid OpenAPI overlay file. ` + + `Expected an object with "overlay" and "actions" fields.`, + ); + } + + return parsed as Overlay; +} + +/** + * Applies all overlays listed in `overlayPaths` to `document` in order. + * + * Each overlay is loaded from disk (or a URL), parsed, and its actions are + * applied sequentially. The document is mutated in place. + * + * @param document - The OpenAPI document object to mutate. + * @param overlayPaths - Ordered list of paths/URLs to overlay files. + */ +export async function applyOverlays( + document: Record, + overlayPaths: readonly string[], +): Promise { + for (const overlayPath of overlayPaths) { + const overlay = await loadOverlay(overlayPath); + + applyOverlayActions(document, overlay.actions); + } +} diff --git a/test/util/apply-overlay.test.ts b/test/util/apply-overlay.test.ts new file mode 100644 index 000000000..e749e78a6 --- /dev/null +++ b/test/util/apply-overlay.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from "@jest/globals"; + +import { usingTemporaryFiles } from "using-temporary-files"; + +import { + applyOverlayActions, + applyOverlays, + loadOverlay, +} from "../../src/util/apply-overlay.js"; + +describe("applyOverlayActions", () => { + it("merges an update into a matched node", () => { + const document = { + info: { title: "Original", version: "1.0.0" }, + }; + + applyOverlayActions(document, [ + { target: "$.info", update: { title: "Updated" } }, + ]); + + expect(document.info.title).toBe("Updated"); + expect(document.info.version).toBe("1.0.0"); + }); + + it("deep-merges nested objects", () => { + const document: Record = { + info: { + contact: { name: "Alice", email: "alice@example.com" }, + }, + }; + + applyOverlayActions(document, [ + { + target: "$.info", + update: { contact: { name: "Bob" } }, + }, + ]); + + const info = document.info as { contact: { name: string; email: string } }; + expect(info.contact.name).toBe("Bob"); + expect(info.contact.email).toBe("alice@example.com"); + }); + + it("removes a matched node from an object", () => { + const document: Record = { + paths: { + "/pets": { get: {} }, + "/users": { get: {} }, + }, + }; + + applyOverlayActions(document, [ + { target: "$.paths['/pets']", remove: true }, + ]); + + const paths = document.paths as Record; + expect(paths["/pets"]).toBeUndefined(); + expect(paths["/users"]).toBeDefined(); + }); + + it("applies multiple actions in order", () => { + const document: Record = { + info: { title: "Original", description: "Keep this" }, + }; + + applyOverlayActions(document, [ + { target: "$.info", update: { title: "Step 1" } }, + { target: "$.info", update: { title: "Step 2" } }, + ]); + + const info = document.info as { title: string; description: string }; + expect(info.title).toBe("Step 2"); + expect(info.description).toBe("Keep this"); + }); + + it("does nothing when no nodes match the target", () => { + const document = { info: { title: "Original" } }; + + applyOverlayActions(document, [ + { target: "$.nonexistent", update: { title: "Should not apply" } }, + ]); + + expect(document.info.title).toBe("Original"); + }); + + it("does not throw for an empty actions array", () => { + const document = { info: { title: "Original" } }; + + expect(() => { + applyOverlayActions(document, []); + }).not.toThrow(); + }); + + it("ignores __proto__ keys to prevent prototype pollution", () => { + const document: Record = { info: { title: "Safe" } }; + + // Simulate a malicious overlay action that tries to set __proto__ + applyOverlayActions(document, [ + { + target: "$.info", + update: { __proto__: { polluted: true } } as Record, + }, + ]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- checking prototype pollution + expect((Object.prototype as any).polluted).toBeUndefined(); + expect(document.info).not.toHaveProperty("polluted"); + }); + + it("adds a new key when the update introduces a property not present in target", () => { + const document: Record = { info: { title: "Original" } }; + + applyOverlayActions(document, [ + { target: "$.info", update: { license: { name: "MIT" } } }, + ]); + + const info = document.info as { license?: { name: string } }; + expect(info.license?.name).toBe("MIT"); + }); +}); + +describe("loadOverlay", () => { + it("parses a valid YAML overlay file", async () => { + await usingTemporaryFiles(async ($) => { + await $.add( + "overlay.yaml", + [ + "overlay: 1.0.0", + "info:", + " title: My Overlay", + " version: 1.0.0", + "actions:", + " - target: $.info", + " update:", + " title: Updated Title", + ].join("\n"), + ); + + const overlay = await loadOverlay($.path("overlay.yaml")); + + expect(overlay.actions).toHaveLength(1); + expect(overlay.actions[0]?.target).toBe("$.info"); + expect(overlay.actions[0]?.update).toStrictEqual({ + title: "Updated Title", + }); + }); + }); + + it("parses a valid JSON overlay file", async () => { + await usingTemporaryFiles(async ($) => { + await $.add( + "overlay.json", + JSON.stringify({ + overlay: "1.0.0", + info: { title: "JSON Overlay", version: "1.0.0" }, + actions: [{ target: "$.info", update: { title: "From JSON" } }], + }), + ); + + const overlay = await loadOverlay($.path("overlay.json")); + + expect(overlay.actions[0]?.update?.title).toBe("From JSON"); + }); + }); + + it("throws when the overlay file does not exist", async () => { + await expect(loadOverlay("/nonexistent/overlay.yaml")).rejects.toThrow( + "Could not read overlay file", + ); + }); + + it("throws when the overlay file is missing the 'overlay' field", async () => { + await usingTemporaryFiles(async ($) => { + await $.add( + "bad.yaml", + "actions:\n - target: $.info\n update:\n title: Bad", + ); + + await expect(loadOverlay($.path("bad.yaml"))).rejects.toThrow( + "does not appear to be a valid OpenAPI overlay file", + ); + }); + }); + + it("throws when the overlay file is missing the 'actions' field", async () => { + await usingTemporaryFiles(async ($) => { + await $.add("bad.yaml", "overlay: 1.0.0\ninfo:\n title: No actions"); + + await expect(loadOverlay($.path("bad.yaml"))).rejects.toThrow( + "does not appear to be a valid OpenAPI overlay file", + ); + }); + }); + + it("throws when the overlay file contains invalid YAML", async () => { + await usingTemporaryFiles(async ($) => { + await $.add("bad.yaml", "overlay: 1.0.0\nactions: [unclosed"); + + await expect(loadOverlay($.path("bad.yaml"))).rejects.toThrow( + "Could not parse overlay file", + ); + }); + }); +}); + +describe("applyOverlays", () => { + it("applies overlays from files to the document", async () => { + await usingTemporaryFiles(async ($) => { + await $.add( + "overlay.yaml", + [ + "overlay: 1.0.0", + "info:", + " title: My Overlay", + " version: 1.0.0", + "actions:", + " - target: $.info", + " update:", + " title: Applied Title", + ].join("\n"), + ); + + const document: Record = { + info: { title: "Original", version: "1.0.0" }, + }; + + await applyOverlays(document, [$.path("overlay.yaml")]); + + expect((document.info as { title: string }).title).toBe("Applied Title"); + }); + }); + + it("applies multiple overlay files in order", async () => { + await usingTemporaryFiles(async ($) => { + await $.add( + "overlay1.yaml", + [ + "overlay: 1.0.0", + "info:", + " title: Overlay 1", + " version: 1.0.0", + "actions:", + " - target: $.info", + " update:", + " title: First", + ].join("\n"), + ); + + await $.add( + "overlay2.yaml", + [ + "overlay: 1.0.0", + "info:", + " title: Overlay 2", + " version: 1.0.0", + "actions:", + " - target: $.info", + " update:", + " title: Second", + ].join("\n"), + ); + + const document: Record = { + info: { title: "Original" }, + }; + + await applyOverlays(document, [ + $.path("overlay1.yaml"), + $.path("overlay2.yaml"), + ]); + + expect((document.info as { title: string }).title).toBe("Second"); + }); + }); + + it("is a no-op when overlayPaths is empty", async () => { + const document: Record = { + info: { title: "Original" }, + }; + + await applyOverlays(document, []); + + expect((document.info as { title: string }).title).toBe("Original"); + }); +}); diff --git a/yarn.lock b/yarn.lock index 30a3be438..eb405a5ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1355,6 +1355,16 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jsep-plugin/assignment@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@jsep-plugin/assignment/-/assignment-1.3.0.tgz#fcfc5417a04933f7ceee786e8ab498aa3ce2b242" + integrity sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ== + +"@jsep-plugin/regex@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@jsep-plugin/regex/-/regex-1.0.4.tgz#cb2fc423220fa71c609323b9ba7f7d344a755fcc" + integrity sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg== + "@manypkg/find-root@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@manypkg/find-root/-/find-root-1.1.0.tgz#a62d8ed1cd7e7d4c11d9d52a8397460b5d4ad29f" @@ -5615,6 +5625,11 @@ jsdoc-type-pratt-parser@^7.0.0: resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.0.tgz#f2d63cbbc3d0d4eaea257eb5f847e8ebc5908dd5" integrity sha512-SX7q7XyCwzM/MEDCYz0l8GgGbJAACGFII9+WfNYr5SLEKukHWRy2Jk3iWRe7P+lpYJNs7oQ+OSei4JtKGUjd7A== +jsep@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/jsep/-/jsep-1.4.0.tgz#19feccbfa51d8a79f72480b4b8e40ce2e17152f0" + integrity sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw== + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -5703,6 +5718,15 @@ jsonify@^0.0.1: resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978" integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg== +jsonpath-plus@10.4.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz#73cf545c231afda21452150b7a2a58e48e109702" + integrity sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA== + dependencies: + "@jsep-plugin/assignment" "^1.3.0" + "@jsep-plugin/regex" "^1.0.4" + jsep "^1.4.0" + jsonwebtoken@9.0.3: version "9.0.3" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2"