diff --git a/.github/issue-proposals/config-add-version-field-to-spec-config.md b/.github/issue-proposals/config-add-version-field-to-spec-config.md new file mode 100644 index 000000000..098ffafa0 --- /dev/null +++ b/.github/issue-proposals/config-add-version-field-to-spec-config.md @@ -0,0 +1,45 @@ +--- +title: "config: add `version` field to SpecConfig and YAML config" +parentIssue: 1937 +labels: + - enhancement + - config +assignees: [] +milestone: +--- + +Add an optional `version` field to `SpecConfig` (and the YAML config format) so users can declare multiple versioned specs under the same group. This is the first foundational step for multi-version support. + +## Context + +The current `SpecConfig` interface has `source`, `prefix`, and `group`. The multi-version feature needs a `version` field so that two specs with the same `group` but different `version` values can coexist. For example: + +```yaml +spec: + - source: ./specs/my-api-v1.yaml + group: my-api + version: v1 + - source: ./specs/my-api-v2.yaml + group: my-api + version: v2 +destination: out +``` + +Without this field, specifying two specs in the same group is treated as a duplicate-group error (which is correct today, but needs to be relaxed when versions are present). + +## Proposed change + +1. Add an optional `version: string` field (default `""`) to the `SpecConfig` interface in `src/app.ts`. +2. Update `normalizeSpecs` / `normalizeSpecOption` (in `src/cli/run.ts`) to pass `version` through from the YAML config or CLI when provided. +3. Relax the duplicate-group validation in `validateSpecGroups` (in `src/app.ts`): two specs may share the same `group` as long as they each carry a distinct non-empty `version`. Mixed configurations (same-group entries with and without `version`) should produce a clear validation error. +4. Update the `counterfact.yaml` JSON Schema (if one exists) to allow `version` on each spec entry. +5. Update the TypeScript types for the CLI's `SpecOption` / `SpecEntry` objects to include the optional `version` field. + +## Acceptance criteria + +- [ ] `SpecConfig` has an optional `version: string` field that defaults to `""` +- [ ] A YAML config with two specs sharing the same `group` but different non-empty `version` values is accepted without error +- [ ] A YAML config with two specs sharing the same `group` and no `version` (or one with and one without) still produces a clear validation error +- [ ] `normalizeSpecOption` passes `version` through for both single-object and array inputs +- [ ] Unit tests cover the new valid and invalid group+version combinations in `validateSpecGroups` +- [ ] Single-spec usage without a `version` field continues to work unchanged diff --git a/.github/issue-proposals/docs-multiple-version-support.md b/.github/issue-proposals/docs-multiple-version-support.md new file mode 100644 index 000000000..692fc73e7 --- /dev/null +++ b/.github/issue-proposals/docs-multiple-version-support.md @@ -0,0 +1,46 @@ +--- +title: "docs: document multi-version API support" +parentIssue: 1937 +labels: + - documentation +assignees: [] +milestone: +--- + +Add user-facing documentation that explains how to configure and use multi-version API support: YAML config syntax, generated code layout, the `Versioned` type, route-handler authoring patterns, and REPL usage. + +## Context + +Multi-version support introduces several new concepts that need clear documentation for users: + +- How to declare multiple versions of the same API group in the config +- The URL layout produced by the server (`///...`) +- The generated code layout (`types//...`, shared `types/paths/...`, `types/versions.ts`) +- How to write a route handler that serves multiple versions using the `Versioned` type +- How the REPL addresses versioned context, routes, and scenarios + +Without documentation, users will not know the feature exists or how to adopt it. + +## Proposed change + +1. **`docs/features/multiple-versions.md`** – New dedicated feature page covering: + - YAML config syntax with `group` and `version` fields (with a realistic example that is not specific to any particular API) + - How the server derives URL prefixes from `group` + `version` + - Generated code layout diagram (shared `types/paths/`, per-version `types//`, `types/versions.ts`) + - How to write a route handler using `Versioned<…>` and `minVersion()` + - How to use `.scenario ` in the REPL + +2. **`docs/usage.md`** or **`docs/getting-started.md`** – Add a brief "Multiple versions" section that links to the feature page. + +3. **`docs/reference.md`** – Document the `version` field on `SpecConfig`, the `Versioned` type, and the `types/versions.ts` output file. + +4. **`src/counterfact-types/` JSDoc** – Ensure `Versioned`, `Versions`, and `VersionsGTE` have accurate JSDoc comments that appear in IDE tooltips. + +## Acceptance criteria + +- [ ] `docs/features/multiple-versions.md` exists and covers config syntax, URL layout, code layout, handler authoring, and REPL usage +- [ ] The example in the documentation uses a generic API, not a specific real-world API, to avoid implying the feature is limited to a particular use case +- [ ] `docs/usage.md` or `docs/getting-started.md` links to the new feature page +- [ ] `docs/reference.md` documents the `version` field and the `Versioned` type +- [ ] `Versioned`, `Versions`, and `VersionsGTE` have JSDoc comments +- [ ] A code example shows a route handler using `minVersion()` to branch on API version diff --git a/.github/issue-proposals/generator-emit-versions-ts.md b/.github/issue-proposals/generator-emit-versions-ts.md new file mode 100644 index 000000000..8d401b07e --- /dev/null +++ b/.github/issue-proposals/generator-emit-versions-ts.md @@ -0,0 +1,65 @@ +--- +title: "generator: emit versions.ts with Versions, VersionsGTE, and Versioned types" +parentIssue: 1937 +labels: + - enhancement + - typescript-generator +assignees: [] +milestone: +--- + +When multi-version specs are configured, the code generator should emit a `types/versions.ts` file containing the `Versions` union, the `VersionsGTE` map, and the `Versioned` utility type that route handlers use to narrow their `$` argument to a specific API version. + +## Context + +The `Versioned` type allows a single route-handler function to serve multiple API versions while the TypeScript type system enforces that each version's parameters and responses are used correctly. It is the public contract between the generated types and the user-authored route handlers. + +The file should be generated (not hand-written) because the `Versions` union and `VersionsGTE` map must be derived from the `version` strings declared in the user's spec config. + +## Proposed shape + +```ts +// types/versions.ts (generated, do not edit) + +export type Versions = "v1" | "v2"; + +/** + * Maps each version to the set of versions that are greater than or equal to it. + * Used by `Versioned.minVersion()` to narrow which versions a handler must support. + */ +export type VersionsGTE = { + v1: "v1" | "v2"; + v2: "v2"; +}; + +type VersionMap = Partial>; + +export type Versioned< + T extends VersionMap, + V extends keyof T & Versions = keyof T & Versions, +> = T[V] & { + version: V; + minVersion( + min: M, + ): this is Versioned>; +}; +``` + +The version ordering used to build `VersionsGTE` should follow the order in which versions appear in the spec config (first entry = oldest). The `Versioned` type body itself is a fixed template; only `Versions` and `VersionsGTE` vary per configuration. + +## Proposed change + +1. Add a new code-generation step (or extend the repository's post-processing phase) that reads all unique non-empty `version` strings from the spec configs in declaration order. +2. Emit `types/versions.ts` with the `Versions` union, the `VersionsGTE` map, and the fixed `Versioned` utility type. +3. When no spec carries a `version` field, skip emitting `types/versions.ts` entirely to avoid changing single-spec output. +4. Export `Versioned` from `src/counterfact-types/index.ts` so route-handler authors can import it directly alongside other shared types. + +## Acceptance criteria + +- [ ] `types/versions.ts` is generated when at least one spec has a non-empty `version` +- [ ] `Versions` is a union of all distinct version strings in config-declaration order +- [ ] `VersionsGTE[V]` includes `V` and all later-declared versions (i.e. versions >= V) +- [ ] The `Versioned` utility type compiles without errors and correctly narrows `$` in a test route handler +- [ ] `Versioned` is exported from `src/counterfact-types/index.ts` +- [ ] No `types/versions.ts` is emitted for single-spec (no version) configurations +- [ ] Unit tests cover `Versions` and `VersionsGTE` generation for 1-, 2-, and 3-version configs diff --git a/.github/issue-proposals/generator-operation-type-coder-version-map.md b/.github/issue-proposals/generator-operation-type-coder-version-map.md new file mode 100644 index 000000000..b3539a00b --- /dev/null +++ b/.github/issue-proposals/generator-operation-type-coder-version-map.md @@ -0,0 +1,51 @@ +--- +title: "generator: update OperationTypeCoder to emit version-mapped handler types" +parentIssue: 1937 +labels: + - enhancement + - typescript-generator +assignees: [] +milestone: +--- + +`OperationTypeCoder` currently emits a single flat handler type per operation. For multi-version APIs it must instead emit a *version map* that collects each version's strongly-typed `$` argument under a keyed object, so that the shared route handler can be narrowed to a specific version at call time. + +## Context + +When two or more versioned specs share the same operation path (e.g. `GET /pets`), the generated `types/paths/pets.types.ts` file cannot contain two conflicting `HTTP_GET` type definitions. Instead, `OperationTypeCoder` should emit a single merged type that covers all versions: + +```ts +// types/paths/pets.types.ts (generated, do not edit) + +import type { MaybePromise, COUNTERFACT_RESPONSE } from "../../counterfact-types/index.js"; +import type { Versioned } from "../../types/versions.js"; +import type { HTTP_GET_$ as HTTP_GET_$_v1 } from "../../types/v1/paths/pets.types.js"; +import type { HTTP_GET_$ as HTTP_GET_$_v2 } from "../../types/v2/paths/pets.types.js"; + +type HTTP_GET_$_Versions = { + v1: HTTP_GET_$_v1; + v2: HTTP_GET_$_v2; +}; + +export type HTTP_GET = ( + $: Versioned, +) => MaybePromise; +``` + +Each version's `$` argument type lives in its own subdirectory file (produced by the per-version coder invocations). `OperationTypeCoder` is responsible only for assembling the union wrapper at the shared path. + +## Proposed change + +1. When `version` is non-empty, `OperationTypeCoder` should write a **per-version $-argument type** (the current flat type body) to `types//paths/.types.ts` instead of the shared path. +2. The shared `types/paths/.types.ts` script should use `Script.declareVersion()` to accumulate version entries and ultimately emit the merged `HTTP__$_Versions` map and the `HTTP_` wrapper that uses `Versioned<…>`. +3. `modulePath()` should return the shared `types/paths/.types.ts` path (unchanged), while the per-version file path is handled by a helper or a dedicated `VersionedOperationTypeCoder`. +4. Imports of per-version types in the shared file are generated relative to the file's location in the output tree. +5. When only one version is configured (or `version` is empty), the existing flat-type output is preserved for full backwards compatibility. + +## Acceptance criteria + +- [ ] With two versioned specs sharing a path, the shared `types/paths/.types.ts` exports an `HTTP_` type whose `$` argument is `Versioned<{ v1: …, v2: … }>` +- [ ] Each version's `$`-argument type is emitted to `types//paths/.types.ts` and imported by the shared file +- [ ] With a single spec (no version), the generated output is identical to today +- [ ] `OperationTypeCoder` unit tests are updated/extended to cover the versioned output shape +- [ ] The generated TypeScript compiles without errors (`tsc --noEmit`) against a sample versioned spec pair diff --git a/.github/issue-proposals/generator-version-subdirectories-for-remaining-coders.md b/.github/issue-proposals/generator-version-subdirectories-for-remaining-coders.md new file mode 100644 index 000000000..2358ef201 --- /dev/null +++ b/.github/issue-proposals/generator-version-subdirectories-for-remaining-coders.md @@ -0,0 +1,57 @@ +--- +title: "generator: update remaining TypeCoders to emit output under version subdirectories" +parentIssue: 1937 +labels: + - enhancement + - typescript-generator +assignees: [] +milestone: +--- + +`SchemaTypeCoder` and `ResponseTypeCoder` already emit their output under `types//...` when a non-empty `version` is set. The remaining `TypeCoder` subclasses that produce version-specific type files must be updated to follow the same convention. + +## Context + +The multi-version code generation strategy is: + +> Every `TypeCoder` except `OperationTypeCoder` writes its output into a `types//` subdirectory when a `version` is present, so that the types for each version are fully isolated. `OperationTypeCoder` is the exception: it lives at the shared path and imports/merges types from all version subdirectories (see companion issue). + +`SchemaTypeCoder` and `ResponseTypeCoder` already implement this: + +``` +types/v1/components/MySchema.ts +types/v1/responses/MyResponse.ts +types/v2/components/MySchema.ts +types/v2/responses/MyResponse.ts +``` + +The following coders still need to be updated: + +- `ParametersTypeCoder` – emits `types/paths/.parameters.ts` +- `ResponsesTypeCoder` – emits types for the combined response object +- `ParameterExportTypeCoder` – emits individual exported parameter types + +## Proposed change + +For each coder listed above, update `modulePath()` to insert the `version` segment between `types/` and the remainder of the path when `this.version` is non-empty, mirroring the pattern already used in `SchemaTypeCoder` and `ResponseTypeCoder`: + +```ts +// before +return `types/paths/${pathString}.parameters.ts`; + +// after +return pathJoin("types", this.version, "paths", `${pathString}.parameters.ts`); +// → "types/v1/paths/pets.parameters.ts" when version = "v1" +// → "types/paths/pets.parameters.ts" when version = "" +``` + +No other behaviour should change; only the output file location is affected. + +## Acceptance criteria + +- [ ] `ParametersTypeCoder.modulePath()` returns `types//paths/...` when `version` is non-empty and `types/paths/...` when empty +- [ ] `ResponsesTypeCoder.modulePath()` (if it declares one) follows the same convention +- [ ] `ParameterExportTypeCoder.modulePath()` follows the same convention +- [ ] Existing unit tests for `modulePath()` on each affected coder continue to pass +- [ ] New unit tests assert the versioned path for a non-empty `version` on each affected coder +- [ ] Single-spec (no version) generation produces identical output to today diff --git a/.github/issue-proposals/repl-version-aware-context-routes-scenario.md b/.github/issue-proposals/repl-version-aware-context-routes-scenario.md new file mode 100644 index 000000000..a929dff49 --- /dev/null +++ b/.github/issue-proposals/repl-version-aware-context-routes-scenario.md @@ -0,0 +1,53 @@ +--- +title: "repl: version-aware context, routes, and .scenario for multi-version APIs" +parentIssue: 1937 +labels: + - enhancement + - repl +assignees: [] +milestone: +--- + +Extend the REPL so that users interacting with a multi-version API server can address context, routes, and scenarios by both group and version, not just by group. + +## Context + +When the same group has multiple versions (e.g. `my-api/v1` and `my-api/v2`), each version is backed by its own `ApiRunner` with its own `contextRegistry` and `scenarioRegistry`. The REPL currently keys its helpers by group name alone. With multiple runners per group, the group key is no longer sufficient to uniquely identify a runner. + +## Proposed change + +### Context and routes helpers + +In multi-version mode, extend the grouped helper objects with a second level keyed by version: + +``` +context.myApi.v1 // context for my-api v1 +context.myApi.v2 // context for my-api v2 +routes.myApi.v1 // route helpers for my-api v1 +``` + +When a group has only one version (or no version), the existing flat `context.myApi` / `routes.myApi` shape is preserved. + +### `.scenario` command + +Extend `.scenario` to accept an optional version qualifier: + +- Single-runner (no version): `.scenario ` (unchanged) +- Multi-runner, no version: `.scenario ` (existing multi-runner syntax, unchanged) +- Multi-runner, with versions: `.scenario ` + +Unknown group or version combinations should produce a descriptive error that lists available groups and versions. + +### Tab completion + +Update the `.scenario` completer to suggest version keys after a valid group key has been typed, in the same two-stage pattern used for group keys today. + +## Acceptance criteria + +- [ ] In multi-version mode, `context..` and `routes..` are available in the REPL +- [ ] In multi-version mode, `.scenario ` executes scenarios against the correct runner +- [ ] Tab completion after `.scenario ` suggests available version keys for that group +- [ ] Tab completion after `.scenario ` suggests scenario paths for that group/version +- [ ] Unknown group or version produces a clear error listing available options +- [ ] Single-runner and multi-runner-without-version behaviors are unchanged +- [ ] Unit tests cover context/routes wiring and `.scenario` dispatch for all modes diff --git a/.github/issue-proposals/scenarios-version-aware-loading.md b/.github/issue-proposals/scenarios-version-aware-loading.md new file mode 100644 index 000000000..11c15a475 --- /dev/null +++ b/.github/issue-proposals/scenarios-version-aware-loading.md @@ -0,0 +1,54 @@ +--- +title: "scenarios: version-aware scenario loading and startup scenario for multi-version APIs" +parentIssue: 1937 +labels: + - enhancement + - scenarios +assignees: [] +milestone: +--- + +Update the scenario system so that each versioned API runner loads scenarios from a version-scoped directory, and the startup scenario receives version information so it can initialise per-version state. + +## Context + +Scenarios are currently loaded from `//scenarios/`. When a group has multiple versions (`v1`, `v2`, …), scenarios that are specific to one version should live under `///scenarios/` while shared scenarios can remain at `//scenarios/`. + +The startup scenario (`scenarios/index.ts` → `startup()`) runs once per runner and is given a `$` object with `context`, `loadContext`, and `route` helpers. In multi-version mode, the startup scenario may also need to know which version it is initialising. + +## Proposed change + +### Scenario directory resolution + +When a runner's `SpecConfig` has a non-empty `version`, resolve scenario scripts from: + +1. `///scenarios/` (version-specific, searched first) +2. `//scenarios/` (shared fallback) + +When `version` is empty, use only `//scenarios/` as today. + +### Startup scenario `$` argument + +Pass `$.version` (the version string, or `""` for unversioned runners) into the startup scenario's `$` argument so authors can branch initialisation logic: + +```ts +// scenarios/index.ts +export const startup = ($: { context: …; version: string }) => { + if ($.version === "v2") { + $.context.featureFlags.newPagination = true; + } +}; +``` + +### `.scenario` REPL command + +No changes are needed here beyond those tracked in the REPL version-awareness issue; scenario loading uses the same directory resolution rules described above. + +## Acceptance criteria + +- [ ] When `version` is set, scenario files in `//scenarios/` are loaded and take precedence over same-named files in `/scenarios/` +- [ ] Files in `/scenarios/` remain available as a shared fallback in multi-version mode +- [ ] The startup scenario's `$` argument includes a `version` property containing the runner's version string +- [ ] The startup scenario's `$` argument has `version = ""` for unversioned runners (backwards compatible) +- [ ] Single-spec / single-version scenario loading is unchanged +- [ ] Unit tests cover version-scoped directory resolution and the fallback behavior diff --git a/.github/issue-proposals/server-route-group-version-url-prefixes.md b/.github/issue-proposals/server-route-group-version-url-prefixes.md new file mode 100644 index 000000000..f6f1fde3b --- /dev/null +++ b/.github/issue-proposals/server-route-group-version-url-prefixes.md @@ -0,0 +1,50 @@ +--- +title: "server: route versioned specs to // URL prefixes" +parentIssue: 1937 +labels: + - enhancement + - server +assignees: [] +milestone: +--- + +When a spec declares both a `group` and a `version`, the server should automatically mount that spec's routes under `//`, so two versions of the same API are addressable at distinct URL prefixes without the user manually configuring `prefix`. + +## Context + +For specs that carry `group` and `version`, the intended URL layout is: + +``` +https://localhost:3100///... +``` + +For example, a config with `group: my-api, version: v1` and `group: my-api, version: v2` should produce: + +``` +GET /my-api/v1/pets +GET /my-api/v2/pets +``` + +Today, an explicit `prefix` is required on every `SpecConfig` entry. The new behavior should derive the prefix automatically from `group + version` when both are present, while preserving backward compatibility for specs that supply an explicit `prefix` or have no version. + +## Proposed change + +Update `normalizeSpecs` (or the `counterfact()` wiring in `src/app.ts`) to apply the following prefix derivation rule for each `SpecConfig` entry: + +| `prefix` provided? | `group` set? | `version` set? | Derived prefix | +|---------------------|--------------|----------------|-----------------------| +| Yes | any | any | use the explicit prefix | +| No | Yes | Yes | `//` | +| No | Yes | No | `/` | +| No | No | No | `""` (root, single-spec legacy) | + +This keeps backwards compatibility: specs without `version` continue to behave exactly as today. + +## Acceptance criteria + +- [ ] A spec with `group: "my-api"` and `version: "v1"` is served under `/my-api/v1` when no explicit `prefix` is provided +- [ ] A spec with an explicit `prefix` always uses that prefix, regardless of `group`/`version` +- [ ] Two specs with the same `group` but different `version` values are each reachable at their respective `//` prefixes on a single server instance +- [ ] Single-spec usage (no `group`, no `version`) continues to work at the root or the supplied `prefix` +- [ ] The Swagger UI dashboard lists each mounted API with its derived prefix +- [ ] Unit and/or black-box tests verify that routes for both versions respond correctly at their expected URLs