Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/openapi-overlays-support.md
Original file line number Diff line number Diff line change
@@ -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 <path>` 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
```
77 changes: 77 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand All @@ -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 <path>` | _(positional arg)_ | Path or URL to the OpenAPI document |
| `--overlay <path>` | _(none)_ | Path or URL to an OpenAPI overlay file (repeatable; applied in order) |
| `--proxy-url <url>` | _(none)_ | Default upstream for the proxy |
| `--prefix <path>` | _(none)_ | Global path prefix (e.g. `/api/v1`) |
| `--no-validate-request` | — | Disable OpenAPI request validation |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/api-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export class ApiRunner {
config.basePath + this.subdirectory,
config.generate,
version,
config.overlays ?? [],
);

this.dispatcher = new Dispatcher(
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$ = {
Expand Down Expand Up @@ -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) ?? [],
Expand Down
13 changes: 12 additions & 1 deletion src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
prefix?: string;
group?: string;
version?: string;
overlays?: string[];
};
type SpecOption = string | SpecOptionEntry | SpecOptionEntry[] | undefined;

Expand All @@ -36,7 +37,7 @@
* 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
Expand All @@ -55,6 +56,7 @@
prefix: entry.prefix,
group: entry.group ?? "",
version: entry.version,
overlays: entry.overlays,
}));
}

Expand All @@ -69,6 +71,7 @@
prefix: specOption.prefix,
group: specOption.group ?? "",
version: specOption.version,
overlays: specOption.overlays,
},
];
}
Expand Down Expand Up @@ -99,6 +102,7 @@
generateRoutes?: boolean;
generateTypes?: boolean;
open?: boolean;
overlay?: string[];
port: number;
prefix: string;
prune?: boolean;
Expand Down Expand Up @@ -134,7 +138,7 @@
const optionSource = program.getOptionValueSource(key);

if (optionSource !== "cli") {
(options as Record<string, unknown>)[key] = value;

Check warning on line 141 in src/cli/run.ts

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-object-injection] Generic Object Injection Sink
}
}

Expand Down Expand Up @@ -174,7 +178,7 @@
)
) {
for (const action of actions) {
(options as Record<string, unknown>)[action] = true;

Check warning on line 181 in src/cli/run.ts

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-object-injection] Generic Object Injection Sink
}
}

Expand Down Expand Up @@ -214,6 +218,7 @@
},

openApiPath: source,
overlays: options.overlay ?? [],
port: options.port,
proxyPaths: new Map([["", Boolean(options.proxyUrl)]]),
proxyUrl: options.proxyUrl ?? "",
Expand Down Expand Up @@ -418,6 +423,12 @@
"--spec <string>",
"path or URL to OpenAPI document (alternative to the positional [openapi.yaml] argument)",
)
.option(
"--overlay <path>",
"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",
Expand Down
5 changes: 5 additions & 0 deletions src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
7 changes: 5 additions & 2 deletions src/server/load-openapi-document.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down
18 changes: 17 additions & 1 deletion src/server/openapi-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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: {
Expand All @@ -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;
}

/**
Expand All @@ -51,6 +59,14 @@ export class OpenApiDocument extends EventTarget {
};
produces?: string[];
};

if (this.overlays.length > 0) {
await applyOverlays(
data as unknown as Record<string, unknown>,
this.overlays,
);
}

this.basePath = data.basePath;
this.paths = data.paths;
this.produces = data.produces;
Expand Down
9 changes: 8 additions & 1 deletion src/typescript-generator/code-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading