From dee85efa9108c6625678a2422fbf4dfc63ccb427 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 19:33:57 +0200 Subject: [PATCH 01/16] Phase 1: add filter config schema, types & minimatch dependency Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 1 + package.json | 1 + src/codegen/types.ts | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/package-lock.json b/package-lock.json index 19f13e17..ee4ee9a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "cosmiconfig": "^9.0.0", "graphology": "^0.26.0", "inquirer": "^8.2.6", + "minimatch": "^9.0.5", "openapi-types": "^12.1.3", "picocolors": "^1.1.1", "uuid": "^11.1.0", diff --git a/package.json b/package.json index fed8e705..0d496490 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "cosmiconfig": "^9.0.0", "graphology": "^0.26.0", "inquirer": "^8.2.6", + "minimatch": "^9.0.5", "openapi-types": "^12.1.3", "picocolors": "^1.1.1", "uuid": "^11.1.0", diff --git a/src/codegen/types.ts b/src/codegen/types.ts index e2b75791..c8a47154 100644 --- a/src/codegen/types.ts +++ b/src/codegen/types.ts @@ -256,6 +256,43 @@ const INPUT_AUTH_DESCRIPTION = 'Authentication for fetching remote input specifications via http(s). Ignored for local file paths. ' + 'WARNING: these credentials are sent to every URL the loader fetches, including external $ref targets on other hosts. ' + 'See https://the-codegen-project.org/docs/configurations#auth-scope-and-security-considerations for details.'; +const FILTER_DESCRIPTION = + 'Restrict code generation to a subset of the input document using glob patterns. ' + + 'For AsyncAPI, patterns are matched against channel address, channel id, or operation id. ' + + 'For OpenAPI, patterns are matched against the path template or operationId. ' + + 'Component schemas/messages left orphaned by filtering are pruned. ' + + 'Omitting this field (or leaving both lists empty) generates everything, unchanged. ' + + '[Read more about configurations here](https://the-codegen-project.org/docs/configurations)'; +const FILTER_INCLUDE_DESCRIPTION = + 'Glob patterns (minimatch) selecting which channels/operations (AsyncAPI) or paths/operations (OpenAPI) to include. ' + + 'An empty list includes everything.'; +const FILTER_EXCLUDE_DESCRIPTION = + 'Glob patterns (minimatch) selecting which channels/operations (AsyncAPI) or paths/operations (OpenAPI) to exclude. ' + + 'Exclude is applied after include, so an excluded item is always dropped. An empty list excludes nothing.'; + +/** + * Shared glob-based filter applied while loading an input document, restricting + * generation to a subset of channels/operations (AsyncAPI) or paths/operations + * (OpenAPI). Not valid for JSON Schema input (no channels/paths to filter). + */ +const zodInputFilter = z + .object({ + include: z + .array(z.string()) + .optional() + .default([]) + .describe(FILTER_INCLUDE_DESCRIPTION), + exclude: z + .array(z.string()) + .optional() + .default([]) + .describe(FILTER_EXCLUDE_DESCRIPTION) + }) + .optional() + .default({}) + .describe(FILTER_DESCRIPTION); + +export type InputFilter = z.infer; /** * Authentication configuration for fetching remote input documents. @@ -345,6 +382,7 @@ export const zodAsyncAPITypescriptConfig = z.object({ inputType: z.literal('asyncapi').describe(DOCUMENT_TYPE_DESCRIPTION), inputPath: z.string().describe(INPUT_PATH_DESCRIPTION), auth: zodInputAuth, + filter: zodInputFilter, ...zodTypeScriptConfigOptions, generators: z .array(zodAsyncAPITypeScriptGenerators) @@ -371,6 +409,7 @@ export const zodOpenAPITypescriptConfig = z.object({ inputType: z.literal('openapi').describe(DOCUMENT_TYPE_DESCRIPTION), inputPath: z.string().describe(INPUT_PATH_DESCRIPTION), auth: zodInputAuth, + filter: zodInputFilter, ...zodTypeScriptConfigOptions, generators: z .array(zodOpenAPITypeScriptGenerators) From 6281eec79e00e249f48b59f48af6f5ab6450802b Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 19:34:56 +0200 Subject: [PATCH 02/16] Phase 2: failing tests for matchesFilter glob utility (TDD RED) Co-Authored-By: Claude Opus 4.8 (1M context) --- test/codegen/filter.test.ts | 151 ++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 test/codegen/filter.test.ts diff --git a/test/codegen/filter.test.ts b/test/codegen/filter.test.ts new file mode 100644 index 00000000..1beb0ea8 --- /dev/null +++ b/test/codegen/filter.test.ts @@ -0,0 +1,151 @@ +import {matchesFilter} from '../../src/codegen/filter'; + +describe('matchesFilter', () => { + describe('no-filter passthrough', () => { + it('returns true when include and exclude are both empty', () => { + expect( + matchesFilter({ + candidates: ['user/created'], + include: [], + exclude: [] + }) + ).toBe(true); + }); + + it('returns true for empty candidates when no filter is set', () => { + expect(matchesFilter({candidates: [], include: [], exclude: []})).toBe( + true + ); + }); + }); + + describe('include only', () => { + it('matches when a candidate matches an include glob', () => { + expect( + matchesFilter({ + candidates: ['user/created'], + include: ['user/**'], + exclude: [] + }) + ).toBe(true); + }); + + it('does not match when no candidate matches any include glob', () => { + expect( + matchesFilter({ + candidates: ['orders/created'], + include: ['user/**'], + exclude: [] + }) + ).toBe(false); + }); + + it('matches an exact string include', () => { + expect( + matchesFilter({ + candidates: ['orders/created'], + include: ['orders/created'], + exclude: [] + }) + ).toBe(true); + }); + }); + + describe('exclude only', () => { + it('returns false when a candidate matches an exclude glob', () => { + expect( + matchesFilter({ + candidates: ['user/internal'], + include: [], + exclude: ['**/internal'] + }) + ).toBe(false); + }); + + it('returns true when no candidate matches any exclude glob', () => { + expect( + matchesFilter({ + candidates: ['user/created'], + include: [], + exclude: ['**/internal'] + }) + ).toBe(true); + }); + }); + + describe('include + exclude (exclude wins)', () => { + it('returns false when a candidate matches both include and exclude', () => { + expect( + matchesFilter({ + candidates: ['user/internal'], + include: ['user/**'], + exclude: ['**/internal'] + }) + ).toBe(false); + }); + + it('returns true when a candidate matches include and none matches exclude', () => { + expect( + matchesFilter({ + candidates: ['user/created'], + include: ['user/**'], + exclude: ['**/internal'] + }) + ).toBe(true); + }); + }); + + describe('multiple candidates (match against any)', () => { + it('matches when any one of several candidates matches include', () => { + expect( + matchesFilter({ + candidates: ['createUser', 'user/created', 'user'], + include: ['user'], + exclude: [] + }) + ).toBe(true); + }); + + it('excludes when any one of several candidates matches exclude', () => { + expect( + matchesFilter({ + candidates: ['createUser', 'user/created', 'user'], + include: [], + exclude: ['createUser'] + }) + ).toBe(false); + }); + }); + + describe('glob forms', () => { + it('supports single-level wildcards with **', () => { + expect( + matchesFilter({ + candidates: ['orders/created/internal'], + include: ['orders/**'], + exclude: [] + }) + ).toBe(true); + }); + + it('matches literal braces in path templates', () => { + expect( + matchesFilter({ + candidates: ['/users/{id}'], + include: ['/users/{id}'], + exclude: [] + }) + ).toBe(true); + }); + + it('matches path templates with wildcards', () => { + expect( + matchesFilter({ + candidates: ['/users/{id}/audit'], + include: ['/users/**'], + exclude: ['/users/{id}/audit'] + }) + ).toBe(false); + }); + }); +}); From 9ff47566b175f977d553b3e825014f1c0c6a7ef0 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 19:36:08 +0200 Subject: [PATCH 03/16] Phase 3: implement matchesFilter glob utility (TDD GREEN) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/codegen/filter.ts | 50 +++++++++++++++++++++++++++++++++++++ test/codegen/filter.test.ts | 1 + 2 files changed, 51 insertions(+) create mode 100644 src/codegen/filter.ts diff --git a/src/codegen/filter.ts b/src/codegen/filter.ts new file mode 100644 index 00000000..9daecccf --- /dev/null +++ b/src/codegen/filter.ts @@ -0,0 +1,50 @@ +import {minimatch} from 'minimatch'; +import {InputFilter} from './types'; + +export {InputFilter}; + +/** + * Determine whether a document surface (channel, operation, path, ...) should be + * retained given an include/exclude glob filter. + * + * A surface is described by one or more `candidates` — e.g. an AsyncAPI channel + * contributes its address and id, an operation additionally contributes its + * operationId. The surface matches an include/exclude pattern when *any* of its + * candidates matches the pattern (minimatch semantics). + * + * Semantics: + * - Empty `include` includes everything. + * - `exclude` is applied after `include`; an excluded surface is always dropped. + * - Empty `exclude` excludes nothing. + * + * @returns `true` when the surface should be kept, `false` when it should be dropped. + */ +export function matchesFilter({ + candidates, + include, + exclude +}: { + candidates: string[]; + include: string[]; + exclude: string[]; +}): boolean { + const included = + include.length === 0 || + include.some((pattern) => candidates.some((c) => minimatch(c, pattern))); + const excluded = exclude.some((pattern) => + candidates.some((c) => minimatch(c, pattern)) + ); + return included && !excluded; +} + +/** + * Whether a filter actually restricts anything. When both lists are empty the + * loaders short-circuit and leave the document untouched, guaranteeing the + * no-filter path is byte-identical to today. + */ +export function isFilterActive(filter?: InputFilter): boolean { + return ( + filter !== undefined && + (filter.include.length > 0 || filter.exclude.length > 0) + ); +} diff --git a/test/codegen/filter.test.ts b/test/codegen/filter.test.ts index 1beb0ea8..a99aa2d0 100644 --- a/test/codegen/filter.test.ts +++ b/test/codegen/filter.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable sonarjs/no-duplicate-string */ import {matchesFilter} from '../../src/codegen/filter'; describe('matchesFilter', () => { From 690d1939fb3a92298258a52be532292090f98a95 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 19:43:33 +0200 Subject: [PATCH 04/16] Phase 4: expected-output-first runtime filter fixtures & specs AsyncAPI (asyncapi-regular) and OpenAPI (new openapi-filter.json) filter configs plus filter.spec.ts asserting kept surfaces exist and dropped surfaces + orphaned models are absent. Specs currently RED against the unfiltered generation, as intended. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/runtime/openapi-filter.json | 91 +++++++++++++++++++ .../typescript/codegen-asyncapi-filter.mjs | 20 ++++ .../typescript/codegen-openapi-filter.mjs | 19 ++++ test/runtime/typescript/package.json | 8 +- test/runtime/typescript/test/filter.spec.ts | 58 ++++++++++++ 5 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 test/runtime/openapi-filter.json create mode 100644 test/runtime/typescript/codegen-asyncapi-filter.mjs create mode 100644 test/runtime/typescript/codegen-openapi-filter.mjs create mode 100644 test/runtime/typescript/test/filter.spec.ts diff --git a/test/runtime/openapi-filter.json b/test/runtime/openapi-filter.json new file mode 100644 index 00000000..af2f4992 --- /dev/null +++ b/test/runtime/openapi-filter.json @@ -0,0 +1,91 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Filter fixture API", + "version": "1.0.0" + }, + "paths": { + "/users": { + "get": { + "operationId": "listUsers", + "summary": "List users", + "responses": { + "200": { + "description": "A list of users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + } + }, + "/orders": { + "get": { + "operationId": "listOrders", + "summary": "List orders", + "responses": { + "200": { + "description": "A list of orders", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Order" + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "address": { + "$ref": "#/components/schemas/Address" + } + } + }, + "Address": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + } + } + }, + "Order": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "total": { + "type": "number" + } + } + } + } + } +} diff --git a/test/runtime/typescript/codegen-asyncapi-filter.mjs b/test/runtime/typescript/codegen-asyncapi-filter.mjs new file mode 100644 index 00000000..78ac25ac --- /dev/null +++ b/test/runtime/typescript/codegen-asyncapi-filter.mjs @@ -0,0 +1,20 @@ +/** @type {import("../../../dist").TheCodegenConfiguration} **/ +export default { + inputType: 'asyncapi', + inputPath: '../asyncapi-regular.json', + language: 'typescript', + // Keep the string/array/union payload channels, then drop union again via + // exclude (exclude wins over include). Every other channel — and therefore + // its payload model — is left out entirely, exercising orphan pruning. + filter: { + include: ['string/payload', 'array/payload', 'union/payload'], + exclude: ['union/payload'] + }, + generators: [ + { + preset: 'payloads', + outputPath: './src/asyncapi-filter/payloads', + serializationType: 'json' + } + ] +}; diff --git a/test/runtime/typescript/codegen-openapi-filter.mjs b/test/runtime/typescript/codegen-openapi-filter.mjs new file mode 100644 index 00000000..9eac0eb0 --- /dev/null +++ b/test/runtime/typescript/codegen-openapi-filter.mjs @@ -0,0 +1,19 @@ +/** @type {import("../../../dist").TheCodegenConfiguration} **/ +export default { + inputType: 'openapi', + inputPath: '../openapi-filter.json', + language: 'typescript', + // Keep only the /users path. The /orders operation is dropped, and the Order + // schema — referenced solely by that dropped operation — must be pruned as an + // orphan. User (kept) and Address (referenced by the kept User) survive. + filter: { + include: ['/users'] + }, + generators: [ + { + preset: 'payloads', + outputPath: './src/openapi-filter/payloads', + serializationType: 'json' + } + ] +}; diff --git a/test/runtime/typescript/package.json b/test/runtime/typescript/package.json index 0a1f41ef..42963a34 100644 --- a/test/runtime/typescript/package.json +++ b/test/runtime/typescript/package.json @@ -1,6 +1,6 @@ { "scripts": { - "test": "npm run test:kafka && npm run test:nats && npm run test:mqtt && npm run test:amqp && npm run test:eventsource && npm run test:websocket && npm run test:http && npm run test:organization", + "test": "npm run test:kafka && npm run test:nats && npm run test:mqtt && npm run test:amqp && npm run test:eventsource && npm run test:websocket && npm run test:http && npm run test:organization && npm run test:filter", "test:organization": "jest -- ./test/channels/organization/", "test:regular": "jest -- ./test/headers.spec.ts ./test/parameters.spec.ts ./test/payloads.spec.ts ./test/types.spec.ts", "test:kafka": "jest -- ./test/channels/regular/kafka.spec.ts", @@ -13,7 +13,11 @@ "test:websocket": "jest -- ./test/channels/regular/websocket.spec.ts", "test:http": "jest -- ./test/channels/request_reply/http_client/", "test:payload-types": "jest -- ./test/payload-types/", - "generate": "npm run generate:regular && npm run generate:request:reply && npm run generate:openapi && npm run generate:openapi-primitive && npm run generate:payload-types && npm run generate:organization", + "test:filter": "jest -- ./test/filter.spec.ts", + "generate": "npm run generate:regular && npm run generate:request:reply && npm run generate:openapi && npm run generate:openapi-primitive && npm run generate:payload-types && npm run generate:organization && npm run generate:filter", + "generate:filter": "npm run generate:filter:asyncapi && npm run generate:filter:openapi", + "generate:filter:asyncapi": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-asyncapi-filter.mjs", + "generate:filter:openapi": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-openapi-filter.mjs", "generate:organization": "npm run generate:organization:openapi-tag && npm run generate:organization:openapi-path && npm run generate:organization:asyncapi-tag && npm run generate:organization:asyncapi-path", "generate:organization:openapi-tag": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-openapi-tag.mjs", "generate:organization:openapi-path": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-openapi-path.mjs", diff --git a/test/runtime/typescript/test/filter.spec.ts b/test/runtime/typescript/test/filter.spec.ts new file mode 100644 index 00000000..557cec4e --- /dev/null +++ b/test/runtime/typescript/test/filter.spec.ts @@ -0,0 +1,58 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const asyncapiPayloads = path.join( + __dirname, + '../src/asyncapi-filter/payloads' +); +const openapiPayloads = path.join(__dirname, '../src/openapi-filter/payloads'); + +const exists = (dir: string, file: string): boolean => + fs.existsSync(path.join(dir, file)); + +describe('filter: AsyncAPI channels/operations', () => { + it('generates payload models for the kept channels', () => { + expect(exists(asyncapiPayloads, 'StringMessage.ts')).toBe(true); + expect(exists(asyncapiPayloads, 'ArrayMessage.ts')).toBe(true); + }); + + it('does not generate the excluded channel (exclude wins over include)', () => { + // union/payload is included then excluded — exclude must win. + expect(exists(asyncapiPayloads, 'UnionMessage.ts')).toBe(false); + }); + + it('prunes payload models for channels left out of include (orphans)', () => { + expect(exists(asyncapiPayloads, 'UserSignedUp.ts')).toBe(false); + expect(exists(asyncapiPayloads, 'LegacyNotification.ts')).toBe(false); + }); + + it('a kept payload model still serializes correctly', async () => { + const StringMessage = await import( + '../src/asyncapi-filter/payloads/StringMessage' + ); + const serialized = StringMessage.marshal('hello world'); + const roundTripped = StringMessage.unmarshal(serialized); + expect(StringMessage.marshal(roundTripped)).toEqual(serialized); + }); +}); + +describe('filter: OpenAPI paths/operations + orphan pruning', () => { + it('generates models for the kept path', () => { + expect(exists(openapiPayloads, 'User.ts')).toBe(true); + expect(exists(openapiPayloads, 'ListUsersResponse_200.ts')).toBe(true); + }); + + it('keeps a component schema referenced by a kept schema (nested)', () => { + // Address is referenced only via User; keeping /users must keep Address. + expect(exists(openapiPayloads, 'Address.ts')).toBe(true); + }); + + it('drops the response model of the excluded operation', () => { + expect(exists(openapiPayloads, 'ListOrdersResponse_200.ts')).toBe(false); + }); + + it('prunes a component schema referenced only by a dropped operation', () => { + // Order is referenced solely by /orders (dropped) — it must be pruned. + expect(exists(openapiPayloads, 'Order.ts')).toBe(false); + }); +}); From b1144fe85904ff6ad391505d2d9519c499a3de44 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 19:50:37 +0200 Subject: [PATCH 05/16] Phase 5: failing tests for AsyncAPI filter helper v2+v3 (TDD RED) Co-Authored-By: Claude Opus 4.8 (1M context) --- test/codegen/inputs/asyncapi/filter.test.ts | 210 ++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 test/codegen/inputs/asyncapi/filter.test.ts diff --git a/test/codegen/inputs/asyncapi/filter.test.ts b/test/codegen/inputs/asyncapi/filter.test.ts new file mode 100644 index 00000000..10a35646 --- /dev/null +++ b/test/codegen/inputs/asyncapi/filter.test.ts @@ -0,0 +1,210 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import {Parser} from '@asyncapi/parser'; +import {AsyncAPIDocumentInterface} from '@asyncapi/parser'; +import {filterAsyncapiJson} from '../../../../src/codegen/inputs/asyncapi/filter'; + +const parser = new Parser({ + ruleset: {core: false, recommended: false} +}); + +async function parse(spec: unknown): Promise { + const {document, diagnostics} = await parser.parse( + typeof spec === 'string' ? spec : JSON.stringify(spec) + ); + if (!document) { + throw new Error(JSON.stringify(diagnostics)); + } + return document; +} + +async function applyFilter( + spec: unknown, + filter: {include?: string[]; exclude?: string[]} +): Promise { + const document = await parse(spec); + const filtered = filterAsyncapiJson({ + document, + filter: {include: [], exclude: [], ...filter} + }); + return parse(filtered); +} + +const channelIds = (d: AsyncAPIDocumentInterface): string[] => + d + .allChannels() + .all() + .map((c) => c.id()) + .sort(); +const operationIds = (d: AsyncAPIDocumentInterface): string[] => + d + .allOperations() + .all() + .map((o) => o.id() ?? '') + .sort(); +const schemaNames = (d: AsyncAPIDocumentInterface): string[] => + Object.keys((d.json().components as any)?.schemas ?? {}).sort(); + +const v3Spec = { + asyncapi: '3.0.0', + info: {title: 'v3', version: '1.0.0'}, + channels: { + userChannel: {$ref: '#/components/channels/userChannel'}, + orderChannel: {$ref: '#/components/channels/orderChannel'} + }, + operations: { + sendUser: {$ref: '#/components/operations/sendUser'}, + receiveUser: {$ref: '#/components/operations/receiveUser'}, + sendOrder: {$ref: '#/components/operations/sendOrder'} + }, + components: { + channels: { + userChannel: { + address: 'user/signedup', + messages: {UserMessage: {$ref: '#/components/messages/UserMessage'}} + }, + orderChannel: { + address: 'order/created', + messages: {OrderMessage: {$ref: '#/components/messages/OrderMessage'}} + } + }, + operations: { + sendUser: {action: 'send', channel: {$ref: '#/components/channels/userChannel'}}, + receiveUser: { + action: 'receive', + channel: {$ref: '#/components/channels/userChannel'} + }, + sendOrder: {action: 'send', channel: {$ref: '#/components/channels/orderChannel'}} + }, + messages: { + UserMessage: {payload: {$ref: '#/components/schemas/User'}}, + OrderMessage: {payload: {$ref: '#/components/schemas/Order'}} + }, + schemas: { + User: { + type: 'object', + properties: { + id: {type: 'string'}, + address: {$ref: '#/components/schemas/Address'} + } + }, + Address: {type: 'object', properties: {city: {type: 'string'}}}, + Order: {type: 'object', properties: {total: {type: 'number'}}} + } + } +}; + +const v2Spec = { + asyncapi: '2.6.0', + info: {title: 'v2', version: '1.0.0'}, + channels: { + 'user/signedup': { + publish: { + operationId: 'sendUser', + message: {payload: {$ref: '#/components/schemas/User'}} + }, + subscribe: { + operationId: 'receiveUser', + message: {payload: {$ref: '#/components/schemas/Ack'}} + } + }, + 'order/created': { + publish: { + operationId: 'sendOrder', + message: {payload: {$ref: '#/components/schemas/Order'}} + } + } + }, + components: { + schemas: { + User: {type: 'object', properties: {id: {type: 'string'}}}, + Ack: {type: 'object', properties: {ok: {type: 'boolean'}}}, + Order: {type: 'object', properties: {total: {type: 'number'}}} + } + } +}; + +describe('filterAsyncapiJson (v3)', () => { + it('includes by channel id', async () => { + const d = await applyFilter(v3Spec, {include: ['userChannel']}); + expect(channelIds(d)).toEqual(['userChannel']); + expect(operationIds(d)).toEqual(['receiveUser', 'sendUser']); + }); + + it('includes by channel address', async () => { + const d = await applyFilter(v3Spec, {include: ['user/signedup']}); + expect(channelIds(d)).toEqual(['userChannel']); + }); + + it('includes by operation id', async () => { + const d = await applyFilter(v3Spec, {include: ['sendUser']}); + // The operation match retains its parent channel... + expect(channelIds(d)).toEqual(['userChannel']); + // ...but only the matched operation, not its siblings. + expect(operationIds(d)).toEqual(['sendUser']); + }); + + it('exclude removes an operation while the channel and siblings survive', async () => { + const d = await applyFilter(v3Spec, { + include: ['userChannel'], + exclude: ['receiveUser'] + }); + expect(channelIds(d)).toEqual(['userChannel']); + expect(operationIds(d)).toEqual(['sendUser']); + }); + + it('drops a channel with no retained operations and no direct match', async () => { + const d = await applyFilter(v3Spec, {include: ['userChannel']}); + expect(channelIds(d)).not.toContain('orderChannel'); + expect(operationIds(d)).not.toContain('sendOrder'); + }); + + it('prunes component schemas referenced only by a dropped channel', async () => { + const d = await applyFilter(v3Spec, {include: ['userChannel']}); + // Order was referenced only by orderChannel (dropped). + expect(schemaNames(d)).not.toContain('Order'); + // User is kept, and Address is reachable via User (nested). + expect(schemaNames(d)).toEqual(expect.arrayContaining(['User', 'Address'])); + }); + + it('no-filter passthrough keeps every channel and operation', async () => { + const d = await applyFilter(v3Spec, {include: [], exclude: []}); + expect(channelIds(d)).toEqual(['orderChannel', 'userChannel']); + expect(operationIds(d)).toEqual(['receiveUser', 'sendOrder', 'sendUser']); + }); +}); + +describe('filterAsyncapiJson (v2)', () => { + it('includes by channel address', async () => { + const d = await applyFilter(v2Spec, {include: ['user/signedup']}); + expect(channelIds(d)).toEqual(['user/signedup']); + expect(operationIds(d).sort()).toEqual(['receiveUser', 'sendUser']); + }); + + it('excludes a single operation (publish/subscribe) while its channel survives', async () => { + const d = await applyFilter(v2Spec, { + include: ['user/signedup'], + exclude: ['receiveUser'] + }); + expect(channelIds(d)).toEqual(['user/signedup']); + expect(operationIds(d)).toEqual(['sendUser']); + }); + + it('prunes component schemas orphaned by dropped channels/operations', async () => { + const d = await applyFilter(v2Spec, { + include: ['user/signedup'], + exclude: ['receiveUser'] + }); + // Ack was only on the excluded receiveUser; Order only on dropped order channel. + expect(schemaNames(d)).toEqual(['User']); + }); + + it('no-filter passthrough keeps everything', async () => { + const d = await applyFilter(v2Spec, {include: [], exclude: []}); + expect(channelIds(d)).toEqual(['order/created', 'user/signedup']); + expect(operationIds(d).sort()).toEqual([ + 'receiveUser', + 'sendOrder', + 'sendUser' + ]); + }); +}); From 22612a26c77bc0b36efc701c43dce995bde115a3 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 19:59:26 +0200 Subject: [PATCH 06/16] Phase 6: implement AsyncAPI filter helper + wire into all loaders (TDD GREEN) filterAsyncapiJson does model-read keep-set computation, version-aware JSON surgery (v2 publish/subscribe, v3 dual-map + components mirrors), and orphan schema pruning. Wired into Node file/in-memory and browser loaders with early-return on inactive filter. Loader signatures moved to object params; all call sites updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/browser/generate.ts | 8 +- src/browser/parser.ts | 37 ++- src/codegen/configurations.ts | 7 +- src/codegen/inputs/asyncapi/filter.ts | 232 ++++++++++++++++++ src/codegen/inputs/asyncapi/parser.ts | 87 ++++++- .../generators/typescript/channels.spec.ts | 22 +- .../protocols/functionTypeMapping.spec.ts | 8 +- .../generators/typescript/client.spec.ts | 2 +- .../generators/typescript/custom.spec.ts | 6 +- .../generators/typescript/headers.spec.ts | 2 +- .../generators/typescript/parameters.spec.ts | 6 +- .../generators/typescript/payload.spec.ts | 10 +- .../generators/typescript/types.spec.ts | 4 +- 13 files changed, 378 insertions(+), 53 deletions(-) create mode 100644 src/codegen/inputs/asyncapi/filter.ts diff --git a/src/browser/generate.ts b/src/browser/generate.ts index 870b9f61..e8b1f721 100644 --- a/src/browser/generate.ts +++ b/src/browser/generate.ts @@ -19,7 +19,8 @@ import { TheCodegenConfiguration, TheCodegenConfigurationInternal, zodTheCodegenConfiguration, - RunGeneratorContext + RunGeneratorContext, + InputFilter } from '../codegen/types'; import {realizeConfiguration} from '../codegen/configurations'; import {determineRenderGraph, renderGraph} from '../codegen/renderer'; @@ -84,7 +85,10 @@ export async function generate( switch (input.specFormat) { case 'asyncapi': try { - asyncapiDocument = await loadAsyncapiFromMemoryBrowser(input.spec); + asyncapiDocument = await loadAsyncapiFromMemoryBrowser({ + input: input.spec, + filter: (config as {filter?: InputFilter}).filter + }); } catch (error) { // Include details from CodegenError if available let errorMsg = error instanceof Error ? error.message : String(error); diff --git a/src/browser/parser.ts b/src/browser/parser.ts index 982c25de..3d280ecf 100644 --- a/src/browser/parser.ts +++ b/src/browser/parser.ts @@ -13,6 +13,9 @@ * Reference: https://github.com/asyncapi/parser-js#using-in-the-browserspa-applications */ import {createInputDocumentError} from '../codegen/errors'; +import {InputFilter} from '../codegen/types'; +import {isFilterActive} from '../codegen/filter'; +import {filterAsyncapiJson} from '../codegen/inputs/asyncapi/filter'; import {Parser, AsyncAPIDocumentInterface} from './shims/asyncapi-parser'; /** @@ -34,9 +37,13 @@ const parser = new Parser({ * @param input - The AsyncAPI document as a YAML or JSON string * @returns The parsed AsyncAPI document */ -export async function loadAsyncapiFromMemoryBrowser( - input: string -): Promise { +export async function loadAsyncapiFromMemoryBrowser({ + input, + filter +}: { + input: string; + filter?: InputFilter; +}): Promise { const result = await parser.parse(input); // Check for errors (severity 0 = error) @@ -63,5 +70,27 @@ export async function loadAsyncapiFromMemoryBrowser( } // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (result as any).document; + const document = (result as any).document; + + if (!isFilterActive(filter)) { + return document; + } + + const filteredJson = filterAsyncapiJson({document, filter: filter!}); + const filteredResult = await parser.parse(JSON.stringify(filteredJson)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!(filteredResult as any).document) { + throw createInputDocumentError({ + inputPath: 'memory', + inputType: 'asyncapi', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + errorMessage: `Filtering produced an invalid document: ${JSON.stringify( + (filteredResult as any).diagnostics, + null, + 2 + )}` + }); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (filteredResult as any).document; } diff --git a/src/codegen/configurations.ts b/src/codegen/configurations.ts index 4d853b32..69592da2 100644 --- a/src/codegen/configurations.ts +++ b/src/codegen/configurations.ts @@ -341,9 +341,10 @@ export async function realizeInMemoryGeneratorContext({ configFilePath: '/virtual-config.mjs' }; if (config.inputType === 'asyncapi') { - context.asyncapiDocument = await loadAsyncapiFromMemory( - specificationDocument - ); + context.asyncapiDocument = await loadAsyncapiFromMemory({ + input: specificationDocument, + filter: config.filter + }); } else if (config.inputType === 'openapi') { context.openapiDocument = await loadOpenapiFromMemory( specificationDocument diff --git a/src/codegen/inputs/asyncapi/filter.ts b/src/codegen/inputs/asyncapi/filter.ts new file mode 100644 index 00000000..ae795c5e --- /dev/null +++ b/src/codegen/inputs/asyncapi/filter.ts @@ -0,0 +1,232 @@ +/* eslint-disable security/detect-object-injection */ +import {AsyncAPIDocumentInterface, ChannelInterface} from '@asyncapi/parser'; +import {InputFilter, matchesFilter} from '../../filter'; +import {findOperationId} from '../../utils'; +import {Logger} from '../../../LoggingInterface'; + +interface RetentionSets { + channelIds: Set; + /** v3: retained operation ids (keys of the top-level `operations` map). */ + operationIds: Set; + /** v2: retained `${channelId}::${action}` keys (publish/subscribe). */ + v2OperationKeys: Set; +} + +/** + * Build the list of candidate strings a channel is matched against: its id and, + * when present and distinct, its address. + */ +function channelCandidates(channel: ChannelInterface): string[] { + const candidates = [channel.id()]; + const address = channel.address(); + if (address && address !== channel.id()) { + candidates.push(address); + } + return candidates; +} + +/** + * Decide, from the parsed model, which channels and operations to retain. + * A channel is retained when it matches directly or has ≥1 retained operation. + */ +function computeRetentionSets({ + document, + filter +}: { + document: AsyncAPIDocumentInterface; + filter: InputFilter; +}): RetentionSets { + const {include, exclude} = filter; + const sets: RetentionSets = { + channelIds: new Set(), + operationIds: new Set(), + v2OperationKeys: new Set() + }; + for (const channel of document.allChannels().all()) { + const chCandidates = channelCandidates(channel); + const keepChannelDirect = matchesFilter({ + candidates: chCandidates, + include, + exclude + }); + let keepAnyOperation = false; + for (const operation of channel.operations().all()) { + const operationId = findOperationId(operation, channel); + const keepOperation = matchesFilter({ + candidates: [operationId, ...chCandidates], + include, + exclude + }); + if (keepOperation) { + keepAnyOperation = true; + const modelId = operation.id(); + if (modelId) { + sets.operationIds.add(modelId); + } + sets.v2OperationKeys.add(`${channel.id()}::${operation.action()}`); + } + } + if (keepChannelDirect || keepAnyOperation) { + sets.channelIds.add(channel.id()); + } + } + return sets; +} + +/** + * Recursively collect every `x-parser-schema-id` reachable from a node. Used to + * determine which `components.schemas` entries are still referenced after + * channels/operations have been removed, so orphans can be pruned. + */ +function collectSchemaIds(node: unknown, accumulator: Set): void { + if (node === null || typeof node !== 'object') { + return; + } + if (Array.isArray(node)) { + for (const value of node) { + collectSchemaIds(value, accumulator); + } + return; + } + const record = node as Record; + const schemaId = record['x-parser-schema-id']; + if (typeof schemaId === 'string') { + accumulator.add(schemaId); + } + for (const value of Object.values(record)) { + collectSchemaIds(value, accumulator); + } +} + +/** + * Prune `components.schemas` entries that are no longer reachable from the + * retained channels/operations. Mutates the passed JSON in place. + */ +function pruneOrphanSchemas(json: Record): void { + const schemas = json.components?.schemas; + if (!schemas || typeof schemas !== 'object') { + return; + } + const reachable = new Set(); + collectSchemaIds(json.channels, reachable); + collectSchemaIds(json.operations, reachable); + const pruned: string[] = []; + for (const name of Object.keys(schemas)) { + if (!reachable.has(name)) { + delete schemas[name]; + pruned.push(name); + } + } + if (pruned.length > 0) { + Logger.debug( + `Filter pruned orphaned AsyncAPI component schemas: ${pruned.join(', ')}` + ); + } +} + +/** Remove non-retained channels from the top-level `channels` map. */ +function removeChannels({ + json, + retained +}: { + json: Record; + retained: Set; +}): void { + if (!json.channels || typeof json.channels !== 'object') { + return; + } + for (const channelId of Object.keys(json.channels)) { + if (!retained.has(channelId)) { + delete json.channels[channelId]; + } + } +} + +/** + * v3 surgery: drop non-retained top-level operations, then drop the redundant + * `components.channels`/`operations`/`messages` mirrors. In the resolved JSON + * these are inlined copies of the top-level maps; leaving them causes + * `allChannels()`/`allOperations()` to double-count on re-parse. + */ +function applyV3Surgery({ + json, + retained +}: { + json: Record; + retained: RetentionSets; +}): void { + for (const operationId of Object.keys(json.operations)) { + if (!retained.operationIds.has(operationId)) { + delete json.operations[operationId]; + } + } + if (json.components) { + delete json.components.channels; + delete json.components.operations; + delete json.components.messages; + } +} + +/** + * v2 surgery: operations live inside their channel as `publish`/`subscribe`; + * delete the ones not retained. + */ +function applyV2Surgery({ + json, + retained +}: { + json: Record; + retained: RetentionSets; +}): void { + for (const channelId of Object.keys(json.channels ?? {})) { + const channelJson = json.channels[channelId]; + for (const action of ['publish', 'subscribe']) { + if ( + channelJson[action] && + !retained.v2OperationKeys.has(`${channelId}::${action}`) + ) { + delete channelJson[action]; + } + } + } + if (json.components) { + delete json.components.messages; + } +} + +/** + * Filter an AsyncAPI document down to the channels/operations selected by + * `filter`, returning the resulting raw JSON. The caller re-parses the returned + * JSON with its own parser instance. + * + * Matching candidates per surface: + * - channel: channel id + channel address + * - operation: operation id + its channel's id + address + * + * Component schemas/messages left orphaned by the removals are pruned. Works for + * both AsyncAPI v2 (operations nested as `publish`/`subscribe` inside channels) + * and v3 (top-level `operations` map cross-referencing `channels`). + */ +export function filterAsyncapiJson({ + document, + filter +}: { + document: AsyncAPIDocumentInterface; + filter: InputFilter; +}): Record { + // Deep clone the resolved JSON — never mutate the parser's live internal object. + const json: Record = JSON.parse(JSON.stringify(document.json())); + const isV3 = 'operations' in json && typeof json.operations === 'object'; + + const retained = computeRetentionSets({document, filter}); + + removeChannels({json, retained: retained.channelIds}); + if (isV3) { + applyV3Surgery({json, retained}); + } else { + applyV2Surgery({json, retained}); + } + pruneOrphanSchemas(json); + + return json; +} diff --git a/src/codegen/inputs/asyncapi/parser.ts b/src/codegen/inputs/asyncapi/parser.ts index 937c96f1..c29e78fc 100644 --- a/src/codegen/inputs/asyncapi/parser.ts +++ b/src/codegen/inputs/asyncapi/parser.ts @@ -3,13 +3,16 @@ import {AvroSchemaParser} from '@asyncapi/avro-schema-parser'; import {OpenAPISchemaParser} from '@asyncapi/openapi-schema-parser'; import {RamlDTSchemaParser} from '@asyncapi/raml-dt-schema-parser'; import {ProtoBuffSchemaParser} from '@asyncapi/protobuf-schema-parser'; +import {AsyncAPIDocumentInterface} from '@asyncapi/parser'; import {readFileSync} from 'fs'; -import {InputAuthConfig, RunGeneratorContext} from '../../types'; +import {InputAuthConfig, InputFilter, RunGeneratorContext} from '../../types'; import {Logger} from '../../../LoggingInterface'; import {createInputDocumentError} from '../../errors'; import {isRemoteUrl} from '../../../utils/inputSource'; import {fetchRemoteDocument} from '../../../utils/remoteFetch'; import {createAsyncapiResolvers} from '../../../utils/refResolvers'; +import {isFilterActive} from '../../filter'; +import {filterAsyncapiJson} from './filter'; const SHARED_PARSER_OPTIONS = { ruleset: { @@ -41,13 +44,62 @@ function buildParserWithAuth(auth: InputAuthConfig, rootUrl: string): Parser { } export async function loadAsyncapi(context: RunGeneratorContext) { - return loadAsyncapiDocument(context.documentPath, context.inputAuth); + return loadAsyncapiDocument({ + documentPath: context.documentPath, + auth: context.inputAuth, + filter: (context.configuration as {filter?: InputFilter}).filter + }); } -export async function loadAsyncapiDocument( - documentPath: string, - auth?: InputAuthConfig -) { +/** + * Apply the configured filter to a freshly parsed document. When the filter is + * inactive the original document is returned untouched (the no-filter path stays + * byte-identical). Otherwise the document JSON is filtered and re-parsed with the + * same parser instance so downstream generators see the subsetted document. + */ +async function applyAsyncapiFilter({ + document, + parser, + filter, + inputPath, + source +}: { + document: AsyncAPIDocumentInterface; + parser: Parser; + filter?: InputFilter; + inputPath: string; + source?: string; +}): Promise { + if (!isFilterActive(filter)) { + return document; + } + const filteredJson = filterAsyncapiJson({document, filter: filter!}); + const reparsed = await parser.parse(JSON.stringify(filteredJson), { + source + }); + if (!reparsed.document) { + throw createInputDocumentError({ + inputPath, + inputType: 'asyncapi', + errorMessage: `Filtering produced an invalid document: ${JSON.stringify( + reparsed.diagnostics, + null, + 2 + )}` + }); + } + return reparsed.document; +} + +export async function loadAsyncapiDocument({ + documentPath, + auth, + filter +}: { + documentPath: string; + auth?: InputAuthConfig; + filter?: InputFilter; +}): Promise { Logger.verbose(`Loading AsyncAPI document from ${documentPath}`); let content: string; if (isRemoteUrl(documentPath)) { @@ -79,10 +131,22 @@ export async function loadAsyncapiDocument( }); } Logger.debug(`AsyncAPI document loaded successfully`); - return document.document; + return applyAsyncapiFilter({ + document: document.document!, + parser, + filter, + inputPath: documentPath, + source: documentPath + }); } -export async function loadAsyncapiFromMemory(input: string) { +export async function loadAsyncapiFromMemory({ + input, + filter +}: { + input: string; + filter?: InputFilter; +}) { const document = await sharedParser.parse(input); if (document.diagnostics.length > 0) { throw createInputDocumentError({ @@ -92,5 +156,10 @@ export async function loadAsyncapiFromMemory(input: string) { }); } - return document.document; + return applyAsyncapiFilter({ + document: document.document!, + parser: sharedParser, + filter, + inputPath: 'memory' + }); } diff --git a/test/codegen/generators/typescript/channels.spec.ts b/test/codegen/generators/typescript/channels.spec.ts index b690b94d..f3ba9e70 100644 --- a/test/codegen/generators/typescript/channels.spec.ts +++ b/test/codegen/generators/typescript/channels.spec.ts @@ -41,7 +41,7 @@ describe('channels', () => { }; it('should work with basic AsyncAPI inputs', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/asyncapi.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/asyncapi.yaml')}); const parametersDependency: TypeScriptParameterRenderType = { channelModels: { "user/signedup": parameterModel @@ -81,7 +81,7 @@ describe('channels', () => { expect(generatedChannels.result).toMatchSnapshot(); }); it('should work with request and reply AsyncAPI', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/asyncapi-request.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/asyncapi-request.yaml')}); const parametersDependency: TypeScriptParameterRenderType = { channelModels: {}, generator: {outputPath: './test'} as any, @@ -132,7 +132,7 @@ describe('channels', () => { expect(generatedChannels.result).toMatchSnapshot(); }); it('should work with basic AsyncAPI inputs with no parameters', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/asyncapi.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/asyncapi.yaml')}); const parametersDependency: TypeScriptParameterRenderType = { channelModels: { @@ -172,7 +172,7 @@ describe('channels', () => { expect(generatedChannels.result).toMatchSnapshot(); }); it('should work with operation extension', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiFromMemory(JSON.stringify({ + const parsedAsyncAPIDocument = await loadAsyncapiFromMemory({input: JSON.stringify({ asyncapi: "2.6.0", info: { title: "Account Service", @@ -191,7 +191,7 @@ describe('channels', () => { } } } - })); + })}); const parametersDependency: TypeScriptParameterRenderType = { channelModels: { @@ -234,9 +234,7 @@ describe('channels', () => { describe('protocol-specific code generation', () => { // Use asyncapi-channels.yaml which has actual parameters, payloads, and headers const setupWithParametersAndHeaders = async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument( - path.resolve(__dirname, '../../../configs/asyncapi-channels.yaml') - ); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/asyncapi-channels.yaml')}); // Create parameter models that match the fixture's channel parameters const userSignedupParameterModel = createParameterModelWithProperties({ @@ -455,9 +453,7 @@ describe('channels', () => { }); it('should generate HTTP client protocol code for request/reply', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument( - path.resolve(__dirname, '../../../configs/asyncapi-request.yaml') - ); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/asyncapi-request.yaml')}); const parametersDependency: TypeScriptParameterRenderType = { channelModels: {}, @@ -537,9 +533,7 @@ describe('channels', () => { ); const generateBrokerProtocol = async (protocol: string) => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument( - path.resolve(__dirname, '../../../configs/asyncapi-channels.yaml') - ); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/asyncapi-channels.yaml')}); const objectPayload = { messageModel: objectPayloadModel, messageType: 'UserSignedUpPayload' diff --git a/test/codegen/generators/typescript/channels/protocols/functionTypeMapping.spec.ts b/test/codegen/generators/typescript/channels/protocols/functionTypeMapping.spec.ts index febaa114..3c427507 100644 --- a/test/codegen/generators/typescript/channels/protocols/functionTypeMapping.spec.ts +++ b/test/codegen/generators/typescript/channels/protocols/functionTypeMapping.spec.ts @@ -63,9 +63,7 @@ describe('functionTypeMapping undefined bug', () => { let payloadsDependency: TypeScriptPayloadRenderType; beforeAll(async () => { - parsedAsyncAPIDocument = await loadAsyncapiDocument( - path.resolve(__dirname, '../../../../../configs/asyncapi.yaml') - ); + parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../../../configs/asyncapi.yaml')}); const parameterModel = new OutputModel( '', @@ -200,9 +198,7 @@ describe('functionTypeMapping undefined bug', () => { it('HTTP client generator', async () => { // HTTP client needs request/reply pattern, use asyncapi-request.yaml - const requestDoc = await loadAsyncapiDocument( - path.resolve(__dirname, '../../../../../configs/asyncapi-request.yaml') - ); + const requestDoc = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../../../configs/asyncapi-request.yaml')}); const httpPayloadsDependency: TypeScriptPayloadRenderType = { channelModels: { diff --git a/test/codegen/generators/typescript/client.spec.ts b/test/codegen/generators/typescript/client.spec.ts index 63c3b7a1..36d9dd46 100644 --- a/test/codegen/generators/typescript/client.spec.ts +++ b/test/codegen/generators/typescript/client.spec.ts @@ -8,7 +8,7 @@ import { TypeScriptPayloadRenderType } from "../../../../src/codegen/generators/ describe('client', () => { describe('typescript', () => { it('should work with basic AsyncAPI inputs', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/asyncapi.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/asyncapi.yaml')}); const payloadModel = new OutputModel('', new ConstrainedAnyModel('', undefined, {}, 'Payload'), '', {models: {}, originalInput: undefined}, []); const parameterModel = new OutputModel('', new ConstrainedObjectModel('', undefined, {}, 'Parameter', {}), '', {models: {}, originalInput: undefined}, []); diff --git a/test/codegen/generators/typescript/custom.spec.ts b/test/codegen/generators/typescript/custom.spec.ts index bf79bf3c..7bd239d9 100644 --- a/test/codegen/generators/typescript/custom.spec.ts +++ b/test/codegen/generators/typescript/custom.spec.ts @@ -5,7 +5,7 @@ import { loadOpenapiDocument } from "../../../../src/codegen/inputs/openapi"; describe('custom', () => { describe('typescript', () => { it('should work with AsyncAPI input and call renderFunction with correct arguments', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/asyncapi.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/asyncapi.yaml')}); const mockRenderFunction = jest.fn().mockReturnValue('AsyncAPI custom output'); const customGenerator = { @@ -79,7 +79,7 @@ describe('custom', () => { }); it('should handle empty dependency outputs', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/asyncapi.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/asyncapi.yaml')}); const mockRenderFunction = jest.fn().mockReturnValue('No dependencies output'); const customGenerator = { @@ -110,7 +110,7 @@ describe('custom', () => { }); it('should work when renderFunction returns undefined', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/asyncapi.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/asyncapi.yaml')}); const mockRenderFunction = jest.fn().mockReturnValue(undefined); const customGenerator = { diff --git a/test/codegen/generators/typescript/headers.spec.ts b/test/codegen/generators/typescript/headers.spec.ts index cf1f862e..d0727509 100644 --- a/test/codegen/generators/typescript/headers.spec.ts +++ b/test/codegen/generators/typescript/headers.spec.ts @@ -6,7 +6,7 @@ import { loadOpenapiDocument } from "../../../../src/codegen/inputs/openapi"; describe('headers', () => { describe('typescript', () => { it('should work with basic AsyncAPI inputs', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/headers.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/headers.yaml')}); const renderedContent = await generateTypescriptHeaders({ generator: { diff --git a/test/codegen/generators/typescript/parameters.spec.ts b/test/codegen/generators/typescript/parameters.spec.ts index 7f15e871..3afb3cd1 100644 --- a/test/codegen/generators/typescript/parameters.spec.ts +++ b/test/codegen/generators/typescript/parameters.spec.ts @@ -8,7 +8,7 @@ describe('parameters', () => { describe('typescript', () => { describe('asyncapi', () => { it('should work with AsyncAPI that contains parameters', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/parameters.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/parameters.yaml')}); const renderedContent = await generateTypescriptParameters({ generator: { @@ -27,7 +27,7 @@ describe('parameters', () => { expect(renderedContent.channelModels['multiple_parameter']?.result).toMatchSnapshot(); }); it('should work with AsyncAPI v2 that contains parameters and const parameters', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/parameters-v2.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/parameters-v2.yaml')}); const renderedContent = await generateTypescriptParameters({ generator: { @@ -46,7 +46,7 @@ describe('parameters', () => { expect(renderedContent.channelModels['multiple_parameter']?.result).toMatchSnapshot(); }); it('should work with no channels', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/parameters-no-channels.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/parameters-no-channels.yaml')}); const renderedContent = await generateTypescriptParameters({ generator: { diff --git a/test/codegen/generators/typescript/payload.spec.ts b/test/codegen/generators/typescript/payload.spec.ts index 53bdeb01..fcdd8abf 100644 --- a/test/codegen/generators/typescript/payload.spec.ts +++ b/test/codegen/generators/typescript/payload.spec.ts @@ -7,7 +7,7 @@ import { safeStringify } from "../../../../src/codegen/modelina"; describe('payloads', () => { describe('typescript', () => { it('should work with basic AsyncAPI inputs', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/payload.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/payload.yaml')}); const renderedContent = await generateTypescriptPayload({ generator: { @@ -22,7 +22,7 @@ describe('payloads', () => { expect(renderedContent.channelModels['simple'].messageModel.result).toMatchSnapshot(); }); it('should not render validation functions', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/payload.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/payload.yaml')}); const renderedContent = await generateTypescriptPayload({ generator: { @@ -38,7 +38,7 @@ describe('payloads', () => { expect(renderedContent.channelModels['simple'].messageModel.result).toMatchSnapshot(); }); it('should work with no channels', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/payload-no-channels.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/payload-no-channels.yaml')}); const renderedContent = await generateTypescriptPayload({ generator: { @@ -53,7 +53,7 @@ describe('payloads', () => { expect(renderedContent.otherModels[0].messageModel.result).toMatchSnapshot(); }); it('should get correct model names', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/payload-complex.json')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/payload-complex.json')}); const renderedContent = await generateTypescriptPayload({ generator: { @@ -362,7 +362,7 @@ describe('payloads', () => { }); describe('companion interface', () => { const generate = async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/payload.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/payload.yaml')}); return generateTypescriptPayload({ generator: { ...defaultTypeScriptPayloadGenerator, diff --git a/test/codegen/generators/typescript/types.spec.ts b/test/codegen/generators/typescript/types.spec.ts index d22798b0..82b0ad86 100644 --- a/test/codegen/generators/typescript/types.spec.ts +++ b/test/codegen/generators/typescript/types.spec.ts @@ -6,7 +6,7 @@ import { generateTypescriptTypes } from "../../../../src/codegen/generators"; describe('types', () => { describe('typescript', () => { it('should work with basic AsyncAPI 2.x inputs', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/asyncapi.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/asyncapi.yaml')}); const renderedContent = await generateTypescriptTypes({ generator: { @@ -23,7 +23,7 @@ describe('types', () => { expect(renderedContent.result).toMatchSnapshot(); }); it('should work with basic AsyncAPI 3.x inputs', async () => { - const parsedAsyncAPIDocument = await loadAsyncapiDocument(path.resolve(__dirname, '../../../configs/asyncapi-3.yaml')); + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/asyncapi-3.yaml')}); const renderedContent = await generateTypescriptTypes({ generator: { From fbcfd5f8190ff27c4ba120f0bd752a546436be32 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 20:07:18 +0200 Subject: [PATCH 07/16] Phase 7: failing tests for OpenAPI filter + orphan pruning v2+v3 (TDD RED) Co-Authored-By: Claude Opus 4.8 (1M context) --- test/codegen/inputs/openapi/filter.test.ts | 225 +++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 test/codegen/inputs/openapi/filter.test.ts diff --git a/test/codegen/inputs/openapi/filter.test.ts b/test/codegen/inputs/openapi/filter.test.ts new file mode 100644 index 00000000..d9b5c726 --- /dev/null +++ b/test/codegen/inputs/openapi/filter.test.ts @@ -0,0 +1,225 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import {loadOpenapiFromMemory} from '../../../../src/codegen/inputs/openapi'; +import {filterOpenapiDocument} from '../../../../src/codegen/inputs/openapi/filter'; + +async function loadV3(): Promise { + return loadOpenapiFromMemory({ + specString: JSON.stringify(v3Spec) + }); +} + +async function loadV2(): Promise { + return loadOpenapiFromMemory({ + specString: JSON.stringify(v2Spec) + }); +} + +const filtered = async ( + load: () => Promise, + filter: {include?: string[]; exclude?: string[]} +): Promise => { + const document = await load(); + filterOpenapiDocument({ + document, + filter: {include: [], exclude: [], ...filter} + }); + return document; +}; + +const v3Spec = { + openapi: '3.0.0', + info: {title: 'v3', version: '1.0.0'}, + paths: { + '/users': { + get: { + operationId: 'listUsers', + responses: { + '200': { + description: 'ok', + content: { + 'application/json': {schema: {$ref: '#/components/schemas/User'}} + } + } + } + } + }, + '/orders': { + get: { + operationId: 'listOrders', + responses: { + '200': { + description: 'ok', + content: { + 'application/json': {schema: {$ref: '#/components/schemas/Order'}} + } + } + } + } + }, + '/items': { + // No operationId → derived id `getItems`. + get: { + responses: { + '200': { + description: 'ok', + content: { + 'application/json': {schema: {$ref: '#/components/schemas/Item'}} + } + } + } + } + }, + '/pets': { + summary: 'Pets resource', + parameters: [ + {name: 'trace', in: 'query', schema: {type: 'string'}} + ], + get: { + operationId: 'getPets', + responses: { + '200': { + description: 'ok', + content: { + 'application/json': {schema: {$ref: '#/components/schemas/Pet'}} + } + } + } + }, + post: { + operationId: 'addPet', + responses: {'201': {description: 'created'}} + } + } + }, + components: { + schemas: { + User: { + type: 'object', + properties: { + id: {type: 'string'}, + address: {$ref: '#/components/schemas/Address'} + } + }, + Address: {type: 'object', properties: {city: {type: 'string'}}}, + Order: {type: 'object', properties: {total: {type: 'number'}}}, + Item: {type: 'object', properties: {sku: {type: 'string'}}}, + Pet: {type: 'object', properties: {name: {type: 'string'}}}, + Unused: {type: 'object', properties: {x: {type: 'string'}}} + } + } +}; + +const v2Spec = { + swagger: '2.0', + info: {title: 'v2', version: '1.0.0'}, + paths: { + '/users': { + get: { + operationId: 'listUsers', + responses: { + '200': { + description: 'ok', + schema: {$ref: '#/definitions/User'} + } + } + } + }, + '/orders': { + get: { + operationId: 'listOrders', + responses: { + '200': { + description: 'ok', + schema: {$ref: '#/definitions/Order'} + } + } + } + } + }, + definitions: { + User: {type: 'object', properties: {id: {type: 'string'}}}, + Order: {type: 'object', properties: {total: {type: 'number'}}} + } +}; + +const pathKeys = (d: any): string[] => Object.keys(d.paths).sort(); +const schemaKeys = (d: any): string[] => + Object.keys(d.components?.schemas ?? d.definitions ?? {}).sort(); + +describe('filterOpenapiDocument (v3)', () => { + it('includes by path template', async () => { + const d = await filtered(loadV3, {include: ['/users']}); + expect(pathKeys(d)).toEqual(['/users']); + }); + + it('excludes by path template', async () => { + const d = await filtered(loadV3, {exclude: ['/orders']}); + expect(pathKeys(d)).not.toContain('/orders'); + expect(pathKeys(d)).toEqual(expect.arrayContaining(['/users', '/items'])); + }); + + it('includes by operationId', async () => { + const d = await filtered(loadV3, {include: ['listUsers']}); + expect(pathKeys(d)).toEqual(['/users']); + }); + + it('includes by derived operationId (no explicit operationId)', async () => { + const d = await filtered(loadV3, {include: ['getItems']}); + expect(pathKeys(d)).toEqual(['/items']); + }); + + it('filters methods but preserves non-method path keys on retained paths', async () => { + const d = await filtered(loadV3, {include: ['/pets'], exclude: ['addPet']}); + expect(pathKeys(d)).toEqual(['/pets']); + expect(d.paths['/pets'].get).toBeDefined(); + expect(d.paths['/pets'].post).toBeUndefined(); + // Non-method keys survive. + expect(d.paths['/pets'].summary).toBe('Pets resource'); + expect(d.paths['/pets'].parameters).toBeDefined(); + }); + + it('removes a path with no retained methods and no direct path match', async () => { + const d = await filtered(loadV3, {include: ['/users']}); + expect(pathKeys(d)).not.toContain('/pets'); + }); + + it('prunes a component schema referenced only by a removed operation', async () => { + const d = await filtered(loadV3, {include: ['/users']}); + // Order/Item/Pet referenced only by removed ops; Unused never referenced. + expect(schemaKeys(d)).not.toContain('Order'); + expect(schemaKeys(d)).not.toContain('Item'); + expect(schemaKeys(d)).not.toContain('Unused'); + }); + + it('keeps a component schema referenced by a retained operation (and its nested refs)', async () => { + const d = await filtered(loadV3, {include: ['/users']}); + expect(schemaKeys(d)).toEqual(expect.arrayContaining(['User', 'Address'])); + }); + + it('no-filter passthrough leaves paths and components untouched', async () => { + const d = await filtered(loadV3, {include: [], exclude: []}); + expect(pathKeys(d)).toEqual(['/items', '/orders', '/pets', '/users']); + expect(schemaKeys(d)).toEqual([ + 'Address', + 'Item', + 'Order', + 'Pet', + 'Unused', + 'User' + ]); + }); +}); + +describe('filterOpenapiDocument (v2)', () => { + it('filters paths and prunes orphaned definitions', async () => { + const d = await filtered(loadV2, {include: ['/users']}); + expect(pathKeys(d)).toEqual(['/users']); + expect(schemaKeys(d)).toEqual(['User']); + }); + + it('no-filter passthrough leaves definitions untouched', async () => { + const d = await filtered(loadV2, {include: [], exclude: []}); + expect(pathKeys(d)).toEqual(['/orders', '/users']); + expect(schemaKeys(d)).toEqual(['Order', 'User']); + }); +}); From 3c29d1652fe7c837b79797aad1bc2a4bd1e730e7 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 20:14:47 +0200 Subject: [PATCH 08/16] Phase 8: implement OpenAPI filter + orphan-prune walker, wire into loaders (TDD GREEN) filterOpenapiDocument does path/method deletion (explicit HTTP-method whitelist, derived operationId matching) and reachability-based orphan pruning of components.schemas/definitions via x-modelgen-inferred-name. Wired into Node file/in-memory + browser OpenAPI loaders. Added normalizeFilter so partial filters (root Zod defaults not re-materialized by realizeConfiguration) don't crash. Loader signatures moved to object params; all call sites updated. Runtime filter.spec now GREEN. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/browser/generate.ts | 5 +- src/codegen/configurations.ts | 7 +- src/codegen/filter.ts | 22 ++- src/codegen/inputs/asyncapi/filter.ts | 4 +- src/codegen/inputs/openapi/filter.ts | 184 ++++++++++++++++++ src/codegen/inputs/openapi/parser.ts | 40 +++- .../generators/typescript/channels.spec.ts | 16 +- .../generators/typescript/custom.spec.ts | 2 +- .../generators/typescript/headers.spec.ts | 6 +- .../generators/typescript/parameters.spec.ts | 6 +- .../generators/typescript/payload.spec.ts | 8 +- .../generators/typescript/types.spec.ts | 6 +- test/codegen/inputs/openapi/filter.test.ts | 14 +- 13 files changed, 268 insertions(+), 52 deletions(-) create mode 100644 src/codegen/inputs/openapi/filter.ts diff --git a/src/browser/generate.ts b/src/browser/generate.ts index e8b1f721..5e32649d 100644 --- a/src/browser/generate.ts +++ b/src/browser/generate.ts @@ -104,7 +104,10 @@ export async function generate( try { // Shared with the Node in-memory loader so the browser playground // gets the same normalization (incl. reflectComponentSchemaNames). - openapiDocument = await loadOpenapiFromMemory(input.spec); + openapiDocument = await loadOpenapiFromMemory({ + specString: input.spec, + filter: (config as {filter?: InputFilter}).filter + }); } catch (error) { errors.push( `Failed to parse OpenAPI spec: ${error instanceof Error ? error.message : String(error)}` diff --git a/src/codegen/configurations.ts b/src/codegen/configurations.ts index 69592da2..a57beb5b 100644 --- a/src/codegen/configurations.ts +++ b/src/codegen/configurations.ts @@ -346,9 +346,10 @@ export async function realizeInMemoryGeneratorContext({ filter: config.filter }); } else if (config.inputType === 'openapi') { - context.openapiDocument = await loadOpenapiFromMemory( - specificationDocument - ); + context.openapiDocument = await loadOpenapiFromMemory({ + specString: specificationDocument, + filter: config.filter + }); } else if (config.inputType === 'jsonschema') { context.jsonSchemaDocument = loadJsonSchemaFromMemory( specificationDocument diff --git a/src/codegen/filter.ts b/src/codegen/filter.ts index 9daecccf..0e7dfbf5 100644 --- a/src/codegen/filter.ts +++ b/src/codegen/filter.ts @@ -37,14 +37,28 @@ export function matchesFilter({ return included && !excluded; } +/** + * Coerce a possibly-partial filter into `{include, exclude}` with both lists + * present. Root-level config fields are validated but not re-materialized with + * Zod defaults (see `realizeConfiguration`), so `include`/`exclude` can be + * `undefined` at runtime even though the inferred type says otherwise. + */ +export function normalizeFilter(filter?: InputFilter): { + include: string[]; + exclude: string[]; +} { + return { + include: filter?.include ?? [], + exclude: filter?.exclude ?? [] + }; +} + /** * Whether a filter actually restricts anything. When both lists are empty the * loaders short-circuit and leave the document untouched, guaranteeing the * no-filter path is byte-identical to today. */ export function isFilterActive(filter?: InputFilter): boolean { - return ( - filter !== undefined && - (filter.include.length > 0 || filter.exclude.length > 0) - ); + const {include, exclude} = normalizeFilter(filter); + return include.length > 0 || exclude.length > 0; } diff --git a/src/codegen/inputs/asyncapi/filter.ts b/src/codegen/inputs/asyncapi/filter.ts index ae795c5e..6001097e 100644 --- a/src/codegen/inputs/asyncapi/filter.ts +++ b/src/codegen/inputs/asyncapi/filter.ts @@ -1,6 +1,6 @@ /* eslint-disable security/detect-object-injection */ import {AsyncAPIDocumentInterface, ChannelInterface} from '@asyncapi/parser'; -import {InputFilter, matchesFilter} from '../../filter'; +import {InputFilter, matchesFilter, normalizeFilter} from '../../filter'; import {findOperationId} from '../../utils'; import {Logger} from '../../../LoggingInterface'; @@ -36,7 +36,7 @@ function computeRetentionSets({ document: AsyncAPIDocumentInterface; filter: InputFilter; }): RetentionSets { - const {include, exclude} = filter; + const {include, exclude} = normalizeFilter(filter); const sets: RetentionSets = { channelIds: new Set(), operationIds: new Set(), diff --git a/src/codegen/inputs/openapi/filter.ts b/src/codegen/inputs/openapi/filter.ts new file mode 100644 index 00000000..a67698d9 --- /dev/null +++ b/src/codegen/inputs/openapi/filter.ts @@ -0,0 +1,184 @@ +/* eslint-disable security/detect-object-injection */ +import {OpenAPIV2, OpenAPIV3, OpenAPIV3_1} from 'openapi-types'; +import {InputFilter, matchesFilter, normalizeFilter} from '../../filter'; +import {chooseComponentModelNames, deriveOperationId} from './utils'; +import {Logger} from '../../../LoggingInterface'; + +type OpenAPIDocument = + | OpenAPIV3.Document + | OpenAPIV2.Document + | OpenAPIV3_1.Document; + +const MODELINA_INFERRED_NAME = 'x-modelgen-inferred-name'; + +/** + * HTTP methods treated as operations on a path item. Explicit whitelist so + * non-method keys (`parameters`, `servers`, `summary`, `description`) are never + * mistaken for operations. `trace` is included for v3 tolerance; deleting a + * method key that is not present is a no-op. + */ +const HTTP_METHODS = [ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'options', + 'head', + 'trace' +]; + +/** + * Locate the component-schema map for the document version: `components.schemas` + * for OpenAPI 3.x, `definitions` for Swagger/OpenAPI 2.0. + */ +function getSchemaMap( + document: OpenAPIDocument +): Record | undefined { + if ( + 'components' in document && + document.components && + document.components.schemas + ) { + return document.components.schemas as Record; + } + if ('definitions' in document && document.definitions) { + return document.definitions as Record; + } + return undefined; +} + +/** + * Recursively collect every `x-modelgen-inferred-name` value reachable from a + * node. Because dereferencing tags each component schema (and its inlined + * usages) with this name, the set of names found under the retained paths is + * exactly the set of component models still in use. + */ +function collectInferredNames(node: unknown, accumulator: Set): void { + if (node === null || typeof node !== 'object') { + return; + } + if (Array.isArray(node)) { + for (const value of node) { + collectInferredNames(value, accumulator); + } + return; + } + const record = node as Record; + const inferredName = record[MODELINA_INFERRED_NAME]; + if (typeof inferredName === 'string') { + accumulator.add(inferredName); + } + for (const value of Object.values(record)) { + collectInferredNames(value, accumulator); + } +} + +/** + * Delete component schemas/definitions no longer reachable from the retained + * paths. Mutates the document in place. Only ever called when a filter is + * active, so the no-filter path never prunes. + */ +function pruneOrphanSchemas(document: OpenAPIDocument): void { + const schemas = getSchemaMap(document); + if (!schemas) { + return; + } + const reachable = new Set(); + collectInferredNames(document.paths, reachable); + const chosenNames = chooseComponentModelNames(Object.keys(schemas)); + const pruned: string[] = []; + for (const componentName of Object.keys(schemas)) { + const modelName = chosenNames.get(componentName) ?? componentName; + if (!reachable.has(modelName)) { + delete schemas[componentName]; + pruned.push(componentName); + } + } + if (pruned.length > 0) { + Logger.debug( + `Filter pruned orphaned OpenAPI component schemas: ${pruned.join(', ')}` + ); + } +} + +/** + * Filter one path item's methods in place, deleting operations that do not + * match. Returns whether the path should be retained (it matched directly or + * kept at least one method). + */ +function retainPathItem({ + pathKey, + pathItem, + filter +}: { + pathKey: string; + pathItem: Record; + filter: InputFilter; +}): boolean { + const {include, exclude} = filter; + const keepPathDirect = matchesFilter({candidates: [pathKey], include, exclude}); + let keepAnyMethod = false; + for (const method of HTTP_METHODS) { + const operation = pathItem[method]; + if (!operation) { + continue; + } + const operationId = deriveOperationId({ + operationId: operation.operationId, + method, + path: pathKey + }); + const keepMethod = matchesFilter({ + candidates: [pathKey, operationId], + include, + exclude + }); + if (keepMethod) { + keepAnyMethod = true; + } else { + delete pathItem[method]; + } + } + return keepPathDirect || keepAnyMethod; +} + +/** + * Filter a dereferenced OpenAPI document down to the paths/operations selected + * by `filter`, mutating it in place. Matching candidates: + * - path: the path template + * - operation: the path template + its (derived) operationId + * + * A path is retained when it matches directly or has ≥1 retained method. Methods + * that do not match are deleted; non-method path-item keys are preserved. Any + * component schema left orphaned by the removals is pruned. + */ +export function filterOpenapiDocument({ + document, + filter +}: { + document: OpenAPIDocument; + filter: InputFilter; +}): void { + const normalizedFilter = normalizeFilter(filter); + // No-op on the no-filter path — never mutate, never prune. + if ( + normalizedFilter.include.length === 0 && + normalizedFilter.exclude.length === 0 + ) { + return; + } + + const paths = (document.paths ?? {}) as Record; + for (const pathKey of Object.keys(paths)) { + const pathItem = paths[pathKey]; + if (!pathItem || typeof pathItem !== 'object') { + continue; + } + if (!retainPathItem({pathKey, pathItem, filter: normalizedFilter})) { + delete paths[pathKey]; + } + } + + pruneOrphanSchemas(document); +} diff --git a/src/codegen/inputs/openapi/parser.ts b/src/codegen/inputs/openapi/parser.ts index f6ec8860..8513d29f 100644 --- a/src/codegen/inputs/openapi/parser.ts +++ b/src/codegen/inputs/openapi/parser.ts @@ -1,6 +1,6 @@ import {parse} from '@readme/openapi-parser'; import $RefParser from '@apidevtools/json-schema-ref-parser'; -import {InputAuthConfig, RunGeneratorContext} from '../../types'; +import {InputAuthConfig, InputFilter, RunGeneratorContext} from '../../types'; import {readFileSync} from 'fs'; import {parse as parseYaml} from 'yaml'; import {OpenAPIV2, OpenAPIV3, OpenAPIV3_1} from 'openapi-types'; @@ -10,17 +10,28 @@ import {isRemoteUrl} from '../../../utils/inputSource'; import {fetchRemoteDocument} from '../../../utils/remoteFetch'; import {createOpenapiRefParserResolver} from '../../../utils/refResolvers'; import {reflectComponentSchemaNames} from './utils'; +import {isFilterActive} from '../../filter'; +import {filterOpenapiDocument} from './filter'; export async function loadOpenapi( context: RunGeneratorContext ): Promise { - return loadOpenapiDocument(context.documentPath, context.inputAuth); + return loadOpenapiDocument({ + documentPath: context.documentPath, + auth: context.inputAuth, + filter: (context.configuration as {filter?: InputFilter}).filter + }); } -export async function loadOpenapiDocument( - documentPath: string, - auth?: InputAuthConfig -): Promise { +export async function loadOpenapiDocument({ + documentPath, + auth, + filter +}: { + documentPath: string; + auth?: InputAuthConfig; + filter?: InputFilter; +}): Promise { Logger.verbose(`Loading OpenAPI document from ${documentPath}`); try { let documentContent: string; @@ -80,6 +91,10 @@ export async function loadOpenapiDocument( reflectComponentSchemaNames(dereferenced); + if (isFilterActive(filter)) { + filterOpenapiDocument({document: dereferenced, filter: filter!}); + } + Logger.debug(`OpenAPI document loaded and dereferenced`); return dereferenced; } catch (error) { @@ -103,14 +118,21 @@ export async function loadOpenapiDocument( * `generateInMemory`, preview, and the playground) produce byte-identical * output to `codegen generate` on a file. */ -export async function loadOpenapiFromMemory( - specString: string -): Promise { +export async function loadOpenapiFromMemory({ + specString, + filter +}: { + specString: string; + filter?: InputFilter; +}): Promise { const document = parseDocumentContent(specString, 'memory', null); const parsedDocument = await parse(document); const {dereference} = await import('@readme/openapi-parser'); const dereferenced = await dereference(parsedDocument); reflectComponentSchemaNames(dereferenced); + if (isFilterActive(filter)) { + filterOpenapiDocument({document: dereferenced, filter: filter!}); + } Logger.debug(`OpenAPI document loaded and dereferenced from memory`); return dereferenced; } diff --git a/test/codegen/generators/typescript/channels.spec.ts b/test/codegen/generators/typescript/channels.spec.ts index f3ba9e70..34cab984 100644 --- a/test/codegen/generators/typescript/channels.spec.ts +++ b/test/codegen/generators/typescript/channels.spec.ts @@ -596,9 +596,7 @@ describe('channels', () => { it('should widen the HTTP client payload input site (POST body) and import the companion interface', async () => { // openapi-3.json's addPet is a POST with a `Pet` object request body — // the only HTTP shape that carries a payload to widen. - const parsedOpenAPIDocument = await loadOpenapiDocument( - path.resolve(__dirname, '../../../runtime/openapi-3.json') - ); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../runtime/openapi-3.json')}); const petPayloadModel = new OutputModel( '', new ConstrainedObjectModel('Pet', undefined, {}, 'object', {}), @@ -648,9 +646,7 @@ describe('channels', () => { describe('OpenAPI input', () => { it('should generate HTTP client protocol code for OpenAPI spec', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument( - path.resolve(__dirname, '../../../runtime/openapi-3.json') - ); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../runtime/openapi-3.json')}); // Create parameter model for findPetsByStatusAndCategory operation const statusProperty = new ConstrainedObjectPropertyModel( @@ -836,9 +832,7 @@ describe('channels', () => { }); it('should skip generation when http_client is not in protocols', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument( - path.resolve(__dirname, '../../../runtime/openapi-3.json') - ); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../runtime/openapi-3.json')}); const parametersDependency: TypeScriptParameterRenderType = { channelModels: {}, @@ -884,9 +878,7 @@ describe('channels', () => { const generateOpenApiChannels = async ( organization: 'flat' | 'tag' | 'path' ) => { - const parsedOpenAPIDocument = await loadOpenapiDocument( - path.resolve(__dirname, '../../../runtime/openapi-3.json') - ); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../runtime/openapi-3.json')}); const statusProperty = new ConstrainedObjectPropertyModel( 'status', 'status', diff --git a/test/codegen/generators/typescript/custom.spec.ts b/test/codegen/generators/typescript/custom.spec.ts index 7bd239d9..bce32bab 100644 --- a/test/codegen/generators/typescript/custom.spec.ts +++ b/test/codegen/generators/typescript/custom.spec.ts @@ -42,7 +42,7 @@ describe('custom', () => { }); it('should work with OpenAPI input and call renderFunction with correct arguments', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-3.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-3.json')}); const mockRenderFunction = jest.fn().mockReturnValue('OpenAPI custom output'); const customGenerator = { diff --git a/test/codegen/generators/typescript/headers.spec.ts b/test/codegen/generators/typescript/headers.spec.ts index d0727509..5412d398 100644 --- a/test/codegen/generators/typescript/headers.spec.ts +++ b/test/codegen/generators/typescript/headers.spec.ts @@ -25,7 +25,7 @@ describe('headers', () => { expect(renderedContent.channelModels['simple']!.result).toMatchSnapshot(); }); it('should work with OpenAPI 2.0 inputs', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-2.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-2.json')}); const renderedContent = await generateTypescriptHeaders({ generator: { @@ -44,7 +44,7 @@ describe('headers', () => { expect(renderedContent.channelModels['deletePet']!.result).toMatchSnapshot(); }); it('should work with OpenAPI 3.0 inputs', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-3.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-3.json')}); const renderedContent = await generateTypescriptHeaders({ generator: { @@ -63,7 +63,7 @@ describe('headers', () => { expect(renderedContent.channelModels['deletePet']!.result).toMatchSnapshot(); }); it('should work with OpenAPI 3.1 inputs', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-3.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-3.json')}); const renderedContent = await generateTypescriptHeaders({ generator: { diff --git a/test/codegen/generators/typescript/parameters.spec.ts b/test/codegen/generators/typescript/parameters.spec.ts index 3afb3cd1..9510b32e 100644 --- a/test/codegen/generators/typescript/parameters.spec.ts +++ b/test/codegen/generators/typescript/parameters.spec.ts @@ -67,7 +67,7 @@ describe('parameters', () => { describe('openapi', () => { it('should work with OpenAPI 3.0 that contains parameters', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-3.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-3.json')}); const renderedContent = await generateTypescriptParameters({ generator: { @@ -100,7 +100,7 @@ describe('parameters', () => { }); it('should work with OpenAPI 3.1 that contains parameters', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-3_1.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-3_1.json')}); const renderedContent = await generateTypescriptParameters({ generator: { @@ -133,7 +133,7 @@ describe('parameters', () => { }); it('should work with OpenAPI 2.0 that contains parameters', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-2.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-2.json')}); const renderedContent = await generateTypescriptParameters({ generator: { diff --git a/test/codegen/generators/typescript/payload.spec.ts b/test/codegen/generators/typescript/payload.spec.ts index fcdd8abf..5a466df1 100644 --- a/test/codegen/generators/typescript/payload.spec.ts +++ b/test/codegen/generators/typescript/payload.spec.ts @@ -76,7 +76,7 @@ describe('payloads', () => { }); it('should work with basic OpenAPI 2.0 inputs', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-2.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-2.json')}); const renderedContent = await generateTypescriptPayload({ generator: { @@ -159,7 +159,7 @@ describe('payloads', () => { // Regression: dereferencing used to drop component names, so every // `items` array item was named `ItemsItem` and models from different // operations silently overwrote each other's files. - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-shared-items.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-shared-items.json')}); const renderedContent = await generateTypescriptPayload({ generator: { @@ -185,7 +185,7 @@ describe('payloads', () => { expect(usersResponse?.content).toContain('UserModel[]'); }); it('should work with basic OpenAPI 3.0 inputs', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-3.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-3.json')}); const renderedContent = await generateTypescriptPayload({ generator: { @@ -273,7 +273,7 @@ describe('payloads', () => { ]); }); it('should work with basic OpenAPI 3.1 inputs', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-3_1.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-3_1.json')}); const renderedContent = await generateTypescriptPayload({ generator: { diff --git a/test/codegen/generators/typescript/types.spec.ts b/test/codegen/generators/typescript/types.spec.ts index 82b0ad86..52bd8d13 100644 --- a/test/codegen/generators/typescript/types.spec.ts +++ b/test/codegen/generators/typescript/types.spec.ts @@ -40,7 +40,7 @@ describe('types', () => { expect(renderedContent.result).toMatchSnapshot(); }); it('should work with OpenAPI 2.0 inputs', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-2.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-2.json')}); const renderedContent = await generateTypescriptTypes({ generator: { @@ -57,7 +57,7 @@ describe('types', () => { expect(renderedContent.result).toMatchSnapshot(); }); it('should work with OpenAPI 3.0 inputs', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-3.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-3.json')}); const renderedContent = await generateTypescriptTypes({ generator: { @@ -74,7 +74,7 @@ describe('types', () => { expect(renderedContent.result).toMatchSnapshot(); }); it('should work with OpenAPI 3.1 inputs', async () => { - const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-3_1.json')); + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-3_1.json')}); const renderedContent = await generateTypescriptTypes({ generator: { diff --git a/test/codegen/inputs/openapi/filter.test.ts b/test/codegen/inputs/openapi/filter.test.ts index d9b5c726..ed553686 100644 --- a/test/codegen/inputs/openapi/filter.test.ts +++ b/test/codegen/inputs/openapi/filter.test.ts @@ -34,7 +34,7 @@ const v3Spec = { get: { operationId: 'listUsers', responses: { - '200': { + 200: { description: 'ok', content: { 'application/json': {schema: {$ref: '#/components/schemas/User'}} @@ -47,7 +47,7 @@ const v3Spec = { get: { operationId: 'listOrders', responses: { - '200': { + 200: { description: 'ok', content: { 'application/json': {schema: {$ref: '#/components/schemas/Order'}} @@ -60,7 +60,7 @@ const v3Spec = { // No operationId → derived id `getItems`. get: { responses: { - '200': { + 200: { description: 'ok', content: { 'application/json': {schema: {$ref: '#/components/schemas/Item'}} @@ -77,7 +77,7 @@ const v3Spec = { get: { operationId: 'getPets', responses: { - '200': { + 200: { description: 'ok', content: { 'application/json': {schema: {$ref: '#/components/schemas/Pet'}} @@ -87,7 +87,7 @@ const v3Spec = { }, post: { operationId: 'addPet', - responses: {'201': {description: 'created'}} + responses: {201: {description: 'created'}} } } }, @@ -117,7 +117,7 @@ const v2Spec = { get: { operationId: 'listUsers', responses: { - '200': { + 200: { description: 'ok', schema: {$ref: '#/definitions/User'} } @@ -128,7 +128,7 @@ const v2Spec = { get: { operationId: 'listOrders', responses: { - '200': { + 200: { description: 'ok', schema: {$ref: '#/definitions/Order'} } From 545ebb8c40f189baabbcca7b79fcd4de2e804348 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 20:17:09 +0200 Subject: [PATCH 09/16] Phase 9: config-validation tests for filter; verify threading across all flows Co-Authored-By: Claude Opus 4.8 (1M context) --- test/codegen/configurations.spec.ts | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/test/codegen/configurations.spec.ts b/test/codegen/configurations.spec.ts index 5a1a6d6e..cda71974 100644 --- a/test/codegen/configurations.spec.ts +++ b/test/codegen/configurations.spec.ts @@ -418,4 +418,71 @@ describe('configuration manager', () => { ).not.toThrow(); }); }); + + describe('filter shape', () => { + const baseAsyncapi = { + inputType: 'asyncapi' as const, + inputPath: './asyncapi.yaml', + language: 'typescript' as const, + generators: [] + }; + + it('defaults to empty include/exclude when no filter is provided', () => { + const parsed = zodTheCodegenConfiguration.parse({ ...baseAsyncapi }) as { + filter?: { include: string[]; exclude: string[] }; + }; + expect(parsed.filter).toEqual({ include: [], exclude: [] }); + }); + + it('accepts a filter with include and exclude on the asyncapi branch', () => { + const parsed = zodTheCodegenConfiguration.parse({ + ...baseAsyncapi, + filter: { include: ['user/**'], exclude: ['**/internal'] } + }) as { filter?: { include: string[]; exclude: string[] } }; + expect(parsed.filter).toEqual({ + include: ['user/**'], + exclude: ['**/internal'] + }); + }); + + it('defaults exclude to [] when only include is provided', () => { + const parsed = zodTheCodegenConfiguration.parse({ + ...baseAsyncapi, + filter: { include: ['user/**'] } + }) as { filter?: { include: string[]; exclude: string[] } }; + expect(parsed.filter).toEqual({ include: ['user/**'], exclude: [] }); + }); + + it('accepts a filter on the openapi branch', () => { + expect(() => + zodTheCodegenConfiguration.parse({ + inputType: 'openapi', + inputPath: './openapi.yaml', + language: 'typescript', + filter: { include: ['/users/**'] }, + generators: [] + }) + ).not.toThrow(); + }); + + it('strips filter on the jsonschema branch (no channels/paths to filter)', () => { + const parsed = zodTheCodegenConfiguration.parse({ + inputType: 'jsonschema', + inputPath: './schema.json', + language: 'typescript', + filter: { include: ['anything'] }, + generators: [] + } as any) as { filter?: unknown }; + expect(parsed.filter).toBeUndefined(); + }); + + it('rejects a non-array include', () => { + expect(() => + zodTheCodegenConfiguration.parse({ + ...baseAsyncapi, + filter: { include: 'user/**' } + } as any) + ).toThrow(); + }); + }); }); From 60e77ba8566a8fbaea583626418de449becd35d0 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 20:20:18 +0200 Subject: [PATCH 10/16] Phase 10: add openapi-filtering example Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/README.md | 3 + examples/openapi-filtering/README.md | 73 ++++++++++ examples/openapi-filtering/codegen.config.js | 21 +++ examples/openapi-filtering/openapi.json | 125 ++++++++++++++++++ examples/openapi-filtering/package.json | 19 +++ .../src/generated/payloads/Address.ts | 94 +++++++++++++ .../payloads/ListOrdersResponse_200.ts | 45 +++++++ .../payloads/ListUsersResponse_200.ts | 45 +++++++ .../src/generated/payloads/Order.ts | 94 +++++++++++++ .../src/generated/payloads/User.ts | 107 +++++++++++++++ 10 files changed, 626 insertions(+) create mode 100644 examples/openapi-filtering/README.md create mode 100644 examples/openapi-filtering/codegen.config.js create mode 100644 examples/openapi-filtering/openapi.json create mode 100644 examples/openapi-filtering/package.json create mode 100644 examples/openapi-filtering/src/generated/payloads/Address.ts create mode 100644 examples/openapi-filtering/src/generated/payloads/ListOrdersResponse_200.ts create mode 100644 examples/openapi-filtering/src/generated/payloads/ListUsersResponse_200.ts create mode 100644 examples/openapi-filtering/src/generated/payloads/Order.ts create mode 100644 examples/openapi-filtering/src/generated/payloads/User.ts diff --git a/examples/README.md b/examples/README.md index 8fd3d96d..69e1a275 100644 --- a/examples/README.md +++ b/examples/README.md @@ -31,6 +31,9 @@ A comprehensive example showing how to generate TypeScript types from AsyncAPI s ### [OpenAPI HTTP Client](./openapi-http-client/) A minimal, self-contained example of generating a type-safe HTTP client from an OpenAPI document — and how to consume it: building request bodies, supplying path parameters, and reading typed responses. +### [OpenAPI Filtering](./openapi-filtering/) +A minimal example of the root-config `filter` option: generate code for only a subset of an OpenAPI document's paths/operations, with orphaned component schemas pruned automatically. + ## Getting Started 1. Choose an example that matches your use case diff --git a/examples/openapi-filtering/README.md b/examples/openapi-filtering/README.md new file mode 100644 index 00000000..235a106f --- /dev/null +++ b/examples/openapi-filtering/README.md @@ -0,0 +1,73 @@ +# OpenAPI Filtering + +A minimal, self-contained example of the root-config **`filter`** option, which +restricts code generation to a subset of the input document's paths/operations +instead of everything. + +## The API + +[`openapi.json`](./openapi.json) defines four operations: + +| Path | Operation | Response schema | +| ---------------------- | -------------- | --------------- | +| `GET /users` | `listUsers` | `User` (→ `Address`) | +| `GET /users/{id}/audit`| `getUserAudit` | `AuditEntry` | +| `GET /orders` | `listOrders` | `Order` | +| `GET /metrics` | `getMetrics` | `Metrics` | + +## The filter + +[`codegen.config.js`](./codegen.config.js): + +```js +filter: { + include: ['/users', '/users/**', '/orders'], + exclude: ['/users/{id}/audit'] +} +``` + +Patterns are [minimatch](https://github.com/isaacs/minimatch) globs, matched +against the **path template** or the **operationId**. `exclude` is applied after +`include`, so an excluded item is always dropped. An empty/absent `filter` +generates everything, unchanged. + +> Note: `/users/**` matches nested paths like `/users/{id}/audit` but **not** +> `/users` itself — list both when you want the collection and everything under +> it. + +## What gets generated + +Running the generator: + +```bash +npm run generate +``` + +produces payload models for **only the retained operations**: + +``` +src/generated/payloads/ +├── User.ts # kept: /users +├── Address.ts # kept: referenced by User (nested) +├── ListUsersResponse_200.ts # kept: /users response +├── Order.ts # kept: /orders +└── ListOrdersResponse_200.ts # kept: /orders response +``` + +Filtered out: + +- **`/users/{id}/audit`** — matched `include` via `/users/**` but removed by + `exclude`, so no `getUserAudit` models are generated. +- **`/metrics`** — never matched `include`, so it is dropped. +- **`AuditEntry`** and **`Metrics`** — component schemas referenced only by the + dropped operations, so they are **pruned automatically** (orphan pruning). + `Address` survives because it is still referenced by the retained `User`. + +## Notes + +- Filtering happens once, while the document is loaded, so **every** generator + (payloads, parameters, headers, types, channels, client) sees the already + subsetted document — no per-generator configuration needed. +- The same `filter` option works for AsyncAPI input, where patterns match + against channel address, channel id, or operation id. +- JSON Schema input has no `filter` (it has no paths/channels to filter). diff --git a/examples/openapi-filtering/codegen.config.js b/examples/openapi-filtering/codegen.config.js new file mode 100644 index 00000000..8a241c13 --- /dev/null +++ b/examples/openapi-filtering/codegen.config.js @@ -0,0 +1,21 @@ +export default { + inputType: 'openapi', + inputPath: './openapi.json', + // Generate code for only a subset of the API: + // - include: keep everything under /users and the /orders path + // - exclude: drop the internal audit endpoint even though /users/** matched it + // Anything not included (e.g. /metrics) is left out, and component schemas + // that become orphaned (AuditEntry, Metrics) are pruned automatically. + filter: { + include: ['/users', '/users/**', '/orders'], + exclude: ['/users/{id}/audit'] + }, + generators: [ + { + preset: 'payloads', + outputPath: './src/generated/payloads', + language: 'typescript', + serializationType: 'json' + } + ] +}; diff --git a/examples/openapi-filtering/openapi.json b/examples/openapi-filtering/openapi.json new file mode 100644 index 00000000..cef4e241 --- /dev/null +++ b/examples/openapi-filtering/openapi.json @@ -0,0 +1,125 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Store API", + "version": "1.0.0", + "description": "A small API used to demonstrate root-config filtering." + }, + "paths": { + "/users": { + "get": { + "operationId": "listUsers", + "summary": "List users", + "responses": { + "200": { + "description": "A list of users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"$ref": "#/components/schemas/User"} + } + } + } + } + } + } + }, + "/users/{id}/audit": { + "get": { + "operationId": "getUserAudit", + "summary": "Internal audit log for a user", + "parameters": [ + {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "responses": { + "200": { + "description": "Audit entries", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"$ref": "#/components/schemas/AuditEntry"} + } + } + } + } + } + } + }, + "/orders": { + "get": { + "operationId": "listOrders", + "summary": "List orders", + "responses": { + "200": { + "description": "A list of orders", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"$ref": "#/components/schemas/Order"} + } + } + } + } + } + } + }, + "/metrics": { + "get": { + "operationId": "getMetrics", + "summary": "Operational metrics (not part of the public SDK)", + "responses": { + "200": { + "description": "Metrics", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Metrics"} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "displayName": {"type": "string"}, + "address": {"$ref": "#/components/schemas/Address"} + } + }, + "Address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"} + } + }, + "AuditEntry": { + "type": "object", + "properties": { + "action": {"type": "string"}, + "at": {"type": "string", "format": "date-time"} + } + }, + "Order": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "total": {"type": "number"} + } + }, + "Metrics": { + "type": "object", + "properties": { + "uptimeSeconds": {"type": "number"} + } + } + } + } +} diff --git a/examples/openapi-filtering/package.json b/examples/openapi-filtering/package.json new file mode 100644 index 00000000..1b27e54f --- /dev/null +++ b/examples/openapi-filtering/package.json @@ -0,0 +1,19 @@ +{ + "name": "openapi-filtering", + "version": "1.0.0", + "description": "Example showing the root-config `filter` option: generate code for only a subset of an OpenAPI document's paths/operations", + "type": "module", + "scripts": { + "generate": "node ../../bin/run.mjs generate codegen.config.js" + }, + "keywords": [ + "openapi", + "codegen", + "filter", + "filtering", + "subset" + ], + "engines": { + "node": ">=18.0.0" + } +} diff --git a/examples/openapi-filtering/src/generated/payloads/Address.ts b/examples/openapi-filtering/src/generated/payloads/Address.ts new file mode 100644 index 00000000..0e00b5bb --- /dev/null +++ b/examples/openapi-filtering/src/generated/payloads/Address.ts @@ -0,0 +1,94 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +interface AddressInterface { + street?: string + city?: string + additionalProperties?: Record +} +class Address { + private _street?: string; + private _city?: string; + private _additionalProperties?: Record; + + constructor(input: AddressInterface) { + this._street = input.street; + this._city = input.city; + this._additionalProperties = input.additionalProperties; + } + + get street(): string | undefined { return this._street; } + set street(street: string | undefined) { this._street = street; } + + get city(): string | undefined { return this._city; } + set city(city: string | undefined) { this._city = city; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.street !== undefined) { + json["street"] = this.street; + } + if(this.city !== undefined) { + json["city"] = this.city; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["street","city","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): Address { + const instance = new Address({} as any); + + if (obj["street"] !== undefined) { + instance.street = obj["street"] as string; + } + if (obj["city"] !== undefined) { + instance.city = obj["city"] as string; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["street","city","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): Address { + const obj = typeof json === "object" ? json : JSON.parse(json); + return Address.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","properties":{"street":{"type":"string"},"city":{"type":"string"}}}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { Address, AddressInterface }; \ No newline at end of file diff --git a/examples/openapi-filtering/src/generated/payloads/ListOrdersResponse_200.ts b/examples/openapi-filtering/src/generated/payloads/ListOrdersResponse_200.ts new file mode 100644 index 00000000..2a912fd1 --- /dev/null +++ b/examples/openapi-filtering/src/generated/payloads/ListOrdersResponse_200.ts @@ -0,0 +1,45 @@ +import {Order} from './Order'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +type ListOrdersResponse_200 = Order[]; + +export function unmarshal(json: string | any[]): ListOrdersResponse_200 { + const arr = typeof json === 'string' ? JSON.parse(json) : json; + return arr.map((item: any) => { + if (item && typeof item === 'object') { + return Order.unmarshal(item); + } + return item; + }) as ListOrdersResponse_200; +} +export function marshal(payload: ListOrdersResponse_200): string { + return JSON.stringify(payload.map((item) => { + if (item && typeof item === 'object' && 'marshal' in item && typeof item.marshal === 'function') { + return JSON.parse(item.marshal()); + } + return item; + })); +} +export const theCodeGenSchema = {"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"total":{"type":"number"}}},"$id":"listOrders_Response_200","$schema":"http://json-schema.org/draft-07/schema"}; +export function validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; +} +export function createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(theCodeGenSchema); + return validate; +} + + +export { ListOrdersResponse_200 }; \ No newline at end of file diff --git a/examples/openapi-filtering/src/generated/payloads/ListUsersResponse_200.ts b/examples/openapi-filtering/src/generated/payloads/ListUsersResponse_200.ts new file mode 100644 index 00000000..d71ac803 --- /dev/null +++ b/examples/openapi-filtering/src/generated/payloads/ListUsersResponse_200.ts @@ -0,0 +1,45 @@ +import {User} from './User'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +type ListUsersResponse_200 = User[]; + +export function unmarshal(json: string | any[]): ListUsersResponse_200 { + const arr = typeof json === 'string' ? JSON.parse(json) : json; + return arr.map((item: any) => { + if (item && typeof item === 'object') { + return User.unmarshal(item); + } + return item; + }) as ListUsersResponse_200; +} +export function marshal(payload: ListUsersResponse_200): string { + return JSON.stringify(payload.map((item) => { + if (item && typeof item === 'object' && 'marshal' in item && typeof item.marshal === 'function') { + return JSON.parse(item.marshal()); + } + return item; + })); +} +export const theCodeGenSchema = {"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"displayName":{"type":"string"},"address":{"type":"object","properties":{"street":{"type":"string"},"city":{"type":"string"}}}}},"$id":"listUsers_Response_200","$schema":"http://json-schema.org/draft-07/schema"}; +export function validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; +} +export function createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(theCodeGenSchema); + return validate; +} + + +export { ListUsersResponse_200 }; \ No newline at end of file diff --git a/examples/openapi-filtering/src/generated/payloads/Order.ts b/examples/openapi-filtering/src/generated/payloads/Order.ts new file mode 100644 index 00000000..51e0e9ab --- /dev/null +++ b/examples/openapi-filtering/src/generated/payloads/Order.ts @@ -0,0 +1,94 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +interface OrderInterface { + id?: string + total?: number + additionalProperties?: Record +} +class Order { + private _id?: string; + private _total?: number; + private _additionalProperties?: Record; + + constructor(input: OrderInterface) { + this._id = input.id; + this._total = input.total; + this._additionalProperties = input.additionalProperties; + } + + get id(): string | undefined { return this._id; } + set id(id: string | undefined) { this._id = id; } + + get total(): number | undefined { return this._total; } + set total(total: number | undefined) { this._total = total; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.id !== undefined) { + json["id"] = this.id; + } + if(this.total !== undefined) { + json["total"] = this.total; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["id","total","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): Order { + const instance = new Order({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"] as string; + } + if (obj["total"] !== undefined) { + instance.total = obj["total"] as number; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","total","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): Order { + const obj = typeof json === "object" ? json : JSON.parse(json); + return Order.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","properties":{"id":{"type":"string"},"total":{"type":"number"}}}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { Order, OrderInterface }; \ No newline at end of file diff --git a/examples/openapi-filtering/src/generated/payloads/User.ts b/examples/openapi-filtering/src/generated/payloads/User.ts new file mode 100644 index 00000000..b81a34a0 --- /dev/null +++ b/examples/openapi-filtering/src/generated/payloads/User.ts @@ -0,0 +1,107 @@ +import {Address} from './Address'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +interface UserInterface { + id?: string + displayName?: string + address?: Address + additionalProperties?: Record +} +class User { + private _id?: string; + private _displayName?: string; + private _address?: Address; + private _additionalProperties?: Record; + + constructor(input: UserInterface) { + this._id = input.id; + this._displayName = input.displayName; + this._address = input.address; + this._additionalProperties = input.additionalProperties; + } + + get id(): string | undefined { return this._id; } + set id(id: string | undefined) { this._id = id; } + + get displayName(): string | undefined { return this._displayName; } + set displayName(displayName: string | undefined) { this._displayName = displayName; } + + get address(): Address | undefined { return this._address; } + set address(address: Address | undefined) { this._address = address; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.id !== undefined) { + json["id"] = this.id; + } + if(this.displayName !== undefined) { + json["displayName"] = this.displayName; + } + if(this.address !== undefined) { + json["address"] = this.address && typeof this.address === 'object' && 'toJson' in this.address && typeof this.address.toJson === 'function' ? this.address.toJson() : this.address; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["id","displayName","address","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): User { + const instance = new User({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"] as string; + } + if (obj["displayName"] !== undefined) { + instance.displayName = obj["displayName"] as string; + } + if (obj["address"] !== undefined) { + instance.address = Address.fromJson(obj["address"] as Record); + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","displayName","address","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): User { + const obj = typeof json === "object" ? json : JSON.parse(json); + return User.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","properties":{"id":{"type":"string"},"displayName":{"type":"string"},"address":{"type":"object","properties":{"street":{"type":"string"},"city":{"type":"string"}}}}}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { User, UserInterface }; \ No newline at end of file From a9c2887ad9f22ec7f14d49724620e5145749e75d Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 20:23:28 +0200 Subject: [PATCH 11/16] Phase 11: document root-config filter option Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/configurations.md | 60 +++++++++++++++++++++++++++++++++++++++++ docs/inputs/asyncapi.md | 24 +++++++++++++++++ docs/inputs/openapi.md | 28 +++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/docs/configurations.md b/docs/configurations.md index 2e2d4ba1..a2aa9cb8 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -210,3 +210,63 @@ paths: remote URL, the input watcher is skipped and a warning is logged. Use `--watchPath` to watch a local file that triggers regeneration (which will re-fetch the URL) on change. + +## Filtering channels, operations & paths + +The optional root-level `filter` field restricts code generation to a subset of +the input document instead of everything. It is available on the **AsyncAPI** +and **OpenAPI** input branches; JSON Schema input has no `filter` (there are no +channels/paths to filter). + +```javascript +export default { + inputType: 'openapi', + inputPath: './openapi.yaml', + filter: { + include: ['/users', '/users/**', '/orders'], + exclude: ['/users/{id}/audit'] + }, + generators: [ + { preset: 'payloads', outputPath: './src/payloads' } + ] +}; +``` + +### Semantics + +- Patterns are [minimatch](https://github.com/isaacs/minimatch) globs. +- **`include`** — an item is kept when it matches any include pattern. An empty + (or absent) `include` includes everything. +- **`exclude`** — applied *after* `include`; an item matching any exclude + pattern is always dropped. An empty `exclude` excludes nothing. +- With **no** `filter` (or empty `include` + `exclude`) the output is identical + to generating without the field — the feature is opt-in and default-off. + +### What patterns match against + +| Input type | An item matches when a pattern matches any of… | +| ---------- | ---------------------------------------------- | +| AsyncAPI | the channel **address**, the channel **id**, or the **operation id** | +| OpenAPI | the **path template** (e.g. `/users/{id}`) or the **operationId** (the spec's `operationId`, or a derived id when absent) | + +Matching an operation retains its parent channel/path; matching a channel/path +directly retains it even if none of its operations match. + +> Note: `/users/**` matches nested paths like `/users/{id}/audit` but **not** +> `/users` itself — list both when you want the collection *and* everything +> under it. + +### Orphan pruning + +When a filter is active, component schemas (and, for AsyncAPI, messages) left +unreferenced by the retained channels/operations/paths are pruned automatically, +so the generated output contains no models for filtered-out surfaces. A schema +still referenced by a retained surface — including via nested references — is +kept. Pruning only runs when a filter is active; the no-filter path never prunes. + +Because filtering happens once while the document is loaded, **every** generator +(payloads, parameters, headers, types, channels, client, and all protocols) sees +the already-subsetted document — no per-generator configuration is required. + +See the [`openapi-filtering` example](https://github.com/the-codegen-project/cli/tree/main/examples/openapi-filtering) +for a runnable walkthrough. diff --git a/docs/inputs/asyncapi.md b/docs/inputs/asyncapi.md index caf7a720..841161f8 100644 --- a/docs/inputs/asyncapi.md +++ b/docs/inputs/asyncapi.md @@ -32,6 +32,30 @@ There is a lot of overlap with existing tooling, however the idea is to form the via the `auth` field. See the [configurations guide](../configurations.md#remote-url-inputs) for examples and the [auth scope and security considerations](../configurations.md#auth-scope-and-security-considerations) section before using `auth` against a public spec — the configured headers are sent to every `$ref` target as well as the root URL. +## Filtering channels & operations + +Use the root-level `filter` field to generate code for only a subset of the +document's channels/operations. Glob patterns are matched against the channel +**address**, the channel **id**, or the **operation id**: + +```javascript +export default { + inputType: 'asyncapi', + inputPath: './asyncapi.yaml', + filter: { + include: ['user/**', 'orders/created'], + exclude: ['**/internal'] + }, + generators: [ /* ... */ ] +}; +``` + +`exclude` is applied after `include`; component messages/schemas left orphaned by +the filtering are pruned automatically. Works for both AsyncAPI v2 and v3. With +no `filter`, output is unchanged. See the +[filtering section of the configurations guide](../configurations.md#filtering-channels-operations--paths) +for full semantics. + ## Basic AsyncAPI Document Structure Here's a complete basic AsyncAPI document example to get you started: diff --git a/docs/inputs/openapi.md b/docs/inputs/openapi.md index 8d60a5f1..c1ea31d4 100644 --- a/docs/inputs/openapi.md +++ b/docs/inputs/openapi.md @@ -40,6 +40,34 @@ Create a configuration file that specifies OpenAPI as the input type: `inputPath` accepts an `http://` or `https://` URL. Optional authentication (bearer token, API key, or custom headers) is configured via the `auth` field. Cross-spec `$ref` URLs are also resolved through the same auth-aware HTTP client. See the [configurations guide](../configurations.md#remote-url-inputs) for examples and the [auth scope and security considerations](../configurations.md#auth-scope-and-security-considerations) section — the configured headers are sent to every `$ref` target as well as the root URL. +## Filtering paths & operations + +Use the root-level `filter` field to generate code for only a subset of the +document's paths/operations. Glob patterns are matched against the **path +template** or the **operationId** (the spec's `operationId`, or a derived id when +absent): + +```javascript +export default { + inputType: 'openapi', + inputPath: './openapi.yaml', + filter: { + include: ['/users', '/users/**', '/orders'], + exclude: ['/users/{id}/audit'] + }, + generators: [ /* ... */ ] +}; +``` + +`exclude` is applied after `include`; component schemas (`components.schemas` for +3.x, `definitions` for 2.0) left orphaned by the filtering are pruned +automatically. Only real HTTP methods on a path item are treated as operations — +`parameters`, `servers`, `summary`, and `description` are preserved on retained +paths. With no `filter`, output is unchanged. See the +[filtering section of the configurations guide](../configurations.md#filtering-channels-operations--paths) +for full semantics, and the +[`openapi-filtering` example](https://github.com/the-codegen-project/cli/tree/main/examples/openapi-filtering). + ## Troubleshooting ## FAQ From 3fe110a8f99f7ab501c9707a92cadd6b0847e51e Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 20:25:36 +0200 Subject: [PATCH 12/16] Phase 13: regenerate schemas + doc ToCs for filter config Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/README.md | 1 + docs/contributing.md | 1 + docs/migrations/v0.md | 1 + docs/usage.md | 2 +- schemas/configuration-schema-0-with-docs.json | 27 +++++++++++++++++++ schemas/configuration-schema-0.json | 27 +++++++++++++++++++ src/codegen/inputs/openapi/filter.ts | 6 ++++- 7 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index 69f702a1..a997aa6b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -99,5 +99,6 @@ Connect AI assistants like Claude Code, Cursor, and Windsurf to The Codegen Proj + diff --git a/docs/contributing.md b/docs/contributing.md index 05711334..d4075376 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -217,5 +217,6 @@ Prefix that follows specification is not enough though. Remember that the title + diff --git a/docs/migrations/v0.md b/docs/migrations/v0.md index 7567262c..f5e99a29 100644 --- a/docs/migrations/v0.md +++ b/docs/migrations/v0.md @@ -245,5 +245,6 @@ import * as NodeFetch from 'node-fetch'; + diff --git a/docs/usage.md b/docs/usage.md index f516bca5..b9e4749c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -9,7 +9,7 @@ $ npm install -g @the-codegen-project/cli $ codegen COMMAND running command... $ codegen (--version) -@the-codegen-project/cli/0.79.0 linux-x64 node-v22.23.1 +@the-codegen-project/cli/0.79.0 darwin-arm64 node-v24.15.0 $ codegen --help [COMMAND] USAGE $ codegen COMMAND diff --git a/schemas/configuration-schema-0-with-docs.json b/schemas/configuration-schema-0-with-docs.json index b9057439..1fb2b009 100644 --- a/schemas/configuration-schema-0-with-docs.json +++ b/schemas/configuration-schema-0-with-docs.json @@ -22,6 +22,9 @@ "auth": { "$ref": "#/definitions/AsyncAPICodegenConfiguration/properties/auth" }, + "filter": { + "$ref": "#/definitions/AsyncAPICodegenConfiguration/properties/filter" + }, "language": { "$ref": "#/definitions/AsyncAPICodegenConfiguration/properties/language" }, @@ -199,6 +202,30 @@ ], "markdownDescription": "Authentication for fetching remote input specifications via http(s). Ignored for local file paths. WARNING: these credentials are sent to every URL the loader fetches, including external $ref targets on other hosts. See https://the-codegen-project.org/docs/configurations#auth-scope-and-security-considerations for details." }, + "filter": { + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "markdownDescription": "Glob patterns (minimatch) selecting which channels/operations (AsyncAPI) or paths/operations (OpenAPI) to include. An empty list includes everything." + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "markdownDescription": "Glob patterns (minimatch) selecting which channels/operations (AsyncAPI) or paths/operations (OpenAPI) to exclude. Exclude is applied after include, so an excluded item is always dropped. An empty list excludes nothing." + } + }, + "additionalProperties": false, + "default": {}, + "markdownDescription": "Restrict code generation to a subset of the input document using glob patterns. For AsyncAPI, patterns are matched against channel address, channel id, or operation id. For OpenAPI, patterns are matched against the path template or operationId. Component schemas/messages left orphaned by filtering are pruned. Omitting this field (or leaving both lists empty) generates everything, unchanged. [Read more about configurations here](https://the-codegen-project.org/docs/configurations)" + }, "language": { "type": "string", "const": "typescript", diff --git a/schemas/configuration-schema-0.json b/schemas/configuration-schema-0.json index 1b3309f6..fd5ae002 100644 --- a/schemas/configuration-schema-0.json +++ b/schemas/configuration-schema-0.json @@ -22,6 +22,9 @@ "auth": { "$ref": "#/definitions/AsyncAPICodegenConfiguration/properties/auth" }, + "filter": { + "$ref": "#/definitions/AsyncAPICodegenConfiguration/properties/filter" + }, "language": { "$ref": "#/definitions/AsyncAPICodegenConfiguration/properties/language" }, @@ -199,6 +202,30 @@ ], "description": "Authentication for fetching remote input specifications via http(s). Ignored for local file paths. WARNING: these credentials are sent to every URL the loader fetches, including external $ref targets on other hosts. See https://the-codegen-project.org/docs/configurations#auth-scope-and-security-considerations for details." }, + "filter": { + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Glob patterns (minimatch) selecting which channels/operations (AsyncAPI) or paths/operations (OpenAPI) to include. An empty list includes everything." + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Glob patterns (minimatch) selecting which channels/operations (AsyncAPI) or paths/operations (OpenAPI) to exclude. Exclude is applied after include, so an excluded item is always dropped. An empty list excludes nothing." + } + }, + "additionalProperties": false, + "default": {}, + "description": "Restrict code generation to a subset of the input document using glob patterns. For AsyncAPI, patterns are matched against channel address, channel id, or operation id. For OpenAPI, patterns are matched against the path template or operationId. Component schemas/messages left orphaned by filtering are pruned. Omitting this field (or leaving both lists empty) generates everything, unchanged. Read more about configurations here" + }, "language": { "type": "string", "const": "typescript", diff --git a/src/codegen/inputs/openapi/filter.ts b/src/codegen/inputs/openapi/filter.ts index a67698d9..74890059 100644 --- a/src/codegen/inputs/openapi/filter.ts +++ b/src/codegen/inputs/openapi/filter.ts @@ -117,7 +117,11 @@ function retainPathItem({ filter: InputFilter; }): boolean { const {include, exclude} = filter; - const keepPathDirect = matchesFilter({candidates: [pathKey], include, exclude}); + const keepPathDirect = matchesFilter({ + candidates: [pathKey], + include, + exclude + }); let keepAnyMethod = false; for (const method of HTTP_METHODS) { const operation = pathItem[method]; From 9591bc88ef1088adaf6cd92aeea106cdbf760027 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 20:27:54 +0200 Subject: [PATCH 13/16] =?UTF-8?q?Phase=2014:=20runtime=20verification=20?= =?UTF-8?q?=E2=80=94=20commit=20filtered=20runtime=20expected=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerated all runtime configs cleanly (incl. the two filter configs) and committed the filtered payload output. filter.spec.ts passes: kept surfaces generated, dropped surfaces + orphaned models absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../asyncapi-filter/payloads/ArrayMessage.ts | 39 +++++++ .../asyncapi-filter/payloads/StringMessage.ts | 36 ++++++ .../src/openapi-filter/payloads/Address.ts | 94 +++++++++++++++ .../payloads/ListUsersResponse_200.ts | 45 ++++++++ .../src/openapi-filter/payloads/User.ts | 107 ++++++++++++++++++ 5 files changed, 321 insertions(+) create mode 100644 test/runtime/typescript/src/asyncapi-filter/payloads/ArrayMessage.ts create mode 100644 test/runtime/typescript/src/asyncapi-filter/payloads/StringMessage.ts create mode 100644 test/runtime/typescript/src/openapi-filter/payloads/Address.ts create mode 100644 test/runtime/typescript/src/openapi-filter/payloads/ListUsersResponse_200.ts create mode 100644 test/runtime/typescript/src/openapi-filter/payloads/User.ts diff --git a/test/runtime/typescript/src/asyncapi-filter/payloads/ArrayMessage.ts b/test/runtime/typescript/src/asyncapi-filter/payloads/ArrayMessage.ts new file mode 100644 index 00000000..e3d8a41c --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-filter/payloads/ArrayMessage.ts @@ -0,0 +1,39 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * An array of strings payload + */ +type ArrayMessage = string[]; + +export function unmarshal(json: string | any[]): ArrayMessage { + if (typeof json === 'string') { + return JSON.parse(json) as ArrayMessage; + } + return json as ArrayMessage; +} +export function marshal(payload: ArrayMessage): string { + return JSON.stringify(payload); +} +export const theCodeGenSchema = {"type":"array","$schema":"http://json-schema.org/draft-07/schema","items":{"type":"string"},"description":"An array of strings payload","$id":"ArrayMessage"}; +export function validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; +} +export function createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(theCodeGenSchema); + return validate; +} + + +export { ArrayMessage }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-filter/payloads/StringMessage.ts b/test/runtime/typescript/src/asyncapi-filter/payloads/StringMessage.ts new file mode 100644 index 00000000..b4efe734 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-filter/payloads/StringMessage.ts @@ -0,0 +1,36 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * A simple string payload + */ +type StringMessage = string; + +export function unmarshal(json: string): StringMessage { + return JSON.parse(json) as StringMessage; +} +export function marshal(payload: StringMessage): string { + return JSON.stringify(payload); +} +export const theCodeGenSchema = {"type":"string","$schema":"http://json-schema.org/draft-07/schema","description":"A simple string payload","$id":"StringMessage"}; +export function validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; +} +export function createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(theCodeGenSchema); + return validate; +} + + +export { StringMessage }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-filter/payloads/Address.ts b/test/runtime/typescript/src/openapi-filter/payloads/Address.ts new file mode 100644 index 00000000..0e00b5bb --- /dev/null +++ b/test/runtime/typescript/src/openapi-filter/payloads/Address.ts @@ -0,0 +1,94 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +interface AddressInterface { + street?: string + city?: string + additionalProperties?: Record +} +class Address { + private _street?: string; + private _city?: string; + private _additionalProperties?: Record; + + constructor(input: AddressInterface) { + this._street = input.street; + this._city = input.city; + this._additionalProperties = input.additionalProperties; + } + + get street(): string | undefined { return this._street; } + set street(street: string | undefined) { this._street = street; } + + get city(): string | undefined { return this._city; } + set city(city: string | undefined) { this._city = city; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.street !== undefined) { + json["street"] = this.street; + } + if(this.city !== undefined) { + json["city"] = this.city; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["street","city","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): Address { + const instance = new Address({} as any); + + if (obj["street"] !== undefined) { + instance.street = obj["street"] as string; + } + if (obj["city"] !== undefined) { + instance.city = obj["city"] as string; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["street","city","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): Address { + const obj = typeof json === "object" ? json : JSON.parse(json); + return Address.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","properties":{"street":{"type":"string"},"city":{"type":"string"}}}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { Address, AddressInterface }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-filter/payloads/ListUsersResponse_200.ts b/test/runtime/typescript/src/openapi-filter/payloads/ListUsersResponse_200.ts new file mode 100644 index 00000000..d71ac803 --- /dev/null +++ b/test/runtime/typescript/src/openapi-filter/payloads/ListUsersResponse_200.ts @@ -0,0 +1,45 @@ +import {User} from './User'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +type ListUsersResponse_200 = User[]; + +export function unmarshal(json: string | any[]): ListUsersResponse_200 { + const arr = typeof json === 'string' ? JSON.parse(json) : json; + return arr.map((item: any) => { + if (item && typeof item === 'object') { + return User.unmarshal(item); + } + return item; + }) as ListUsersResponse_200; +} +export function marshal(payload: ListUsersResponse_200): string { + return JSON.stringify(payload.map((item) => { + if (item && typeof item === 'object' && 'marshal' in item && typeof item.marshal === 'function') { + return JSON.parse(item.marshal()); + } + return item; + })); +} +export const theCodeGenSchema = {"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"displayName":{"type":"string"},"address":{"type":"object","properties":{"street":{"type":"string"},"city":{"type":"string"}}}}},"$id":"listUsers_Response_200","$schema":"http://json-schema.org/draft-07/schema"}; +export function validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; +} +export function createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(theCodeGenSchema); + return validate; +} + + +export { ListUsersResponse_200 }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-filter/payloads/User.ts b/test/runtime/typescript/src/openapi-filter/payloads/User.ts new file mode 100644 index 00000000..b81a34a0 --- /dev/null +++ b/test/runtime/typescript/src/openapi-filter/payloads/User.ts @@ -0,0 +1,107 @@ +import {Address} from './Address'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +interface UserInterface { + id?: string + displayName?: string + address?: Address + additionalProperties?: Record +} +class User { + private _id?: string; + private _displayName?: string; + private _address?: Address; + private _additionalProperties?: Record; + + constructor(input: UserInterface) { + this._id = input.id; + this._displayName = input.displayName; + this._address = input.address; + this._additionalProperties = input.additionalProperties; + } + + get id(): string | undefined { return this._id; } + set id(id: string | undefined) { this._id = id; } + + get displayName(): string | undefined { return this._displayName; } + set displayName(displayName: string | undefined) { this._displayName = displayName; } + + get address(): Address | undefined { return this._address; } + set address(address: Address | undefined) { this._address = address; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.id !== undefined) { + json["id"] = this.id; + } + if(this.displayName !== undefined) { + json["displayName"] = this.displayName; + } + if(this.address !== undefined) { + json["address"] = this.address && typeof this.address === 'object' && 'toJson' in this.address && typeof this.address.toJson === 'function' ? this.address.toJson() : this.address; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["id","displayName","address","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): User { + const instance = new User({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"] as string; + } + if (obj["displayName"] !== undefined) { + instance.displayName = obj["displayName"] as string; + } + if (obj["address"] !== undefined) { + instance.address = Address.fromJson(obj["address"] as Record); + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","displayName","address","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): User { + const obj = typeof json === "object" ? json : JSON.parse(json); + return User.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","properties":{"id":{"type":"string"},"displayName":{"type":"string"},"address":{"type":"object","properties":{"street":{"type":"string"},"city":{"type":"string"}}}}}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { User, UserInterface }; \ No newline at end of file From d67d3e03677c351a27b60b196fdff374dce9d607 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 20:31:10 +0200 Subject: [PATCH 14/16] Phase 15: revert non-idempotent ToC + platform-string noise from generated docs markdown-toc appends a trailing newline on each run and oclif readme embeds the local platform/node version; neither is a real change. Restored README.md, contributing.md, migrations/v0.md, usage.md to base so the PR carries only the intended filter docs + regenerated schemas. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/README.md | 1 - docs/contributing.md | 1 - docs/migrations/v0.md | 1 - docs/usage.md | 2 +- 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/README.md b/docs/README.md index a997aa6b..69f702a1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -99,6 +99,5 @@ Connect AI assistants like Claude Code, Cursor, and Windsurf to The Codegen Proj - diff --git a/docs/contributing.md b/docs/contributing.md index d4075376..05711334 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -217,6 +217,5 @@ Prefix that follows specification is not enough though. Remember that the title - diff --git a/docs/migrations/v0.md b/docs/migrations/v0.md index f5e99a29..7567262c 100644 --- a/docs/migrations/v0.md +++ b/docs/migrations/v0.md @@ -245,6 +245,5 @@ import * as NodeFetch from 'node-fetch'; - diff --git a/docs/usage.md b/docs/usage.md index b9e4749c..f516bca5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -9,7 +9,7 @@ $ npm install -g @the-codegen-project/cli $ codegen COMMAND running command... $ codegen (--version) -@the-codegen-project/cli/0.79.0 darwin-arm64 node-v24.15.0 +@the-codegen-project/cli/0.79.0 linux-x64 node-v22.23.1 $ codegen --help [COMMAND] USAGE $ codegen COMMAND From 413a5dadd7c877239ed6950631350cff648913d5 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 20:37:25 +0200 Subject: [PATCH 15/16] Phase 16: extract shared collectExtensionValues walker; plan complete De-duplicated the recursive reachability collector shared by the AsyncAPI and OpenAPI orphan-pruning helpers into a single collectExtensionValues primitive in src/codegen/filter.ts, keyed on the extension name each parser uses. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/codegen/filter.ts | 37 +++++++++++++++++++++ src/codegen/inputs/asyncapi/filter.ts | 47 ++++++++++----------------- src/codegen/inputs/openapi/filter.ts | 45 +++++++++---------------- 3 files changed, 70 insertions(+), 59 deletions(-) diff --git a/src/codegen/filter.ts b/src/codegen/filter.ts index 0e7dfbf5..180d8241 100644 --- a/src/codegen/filter.ts +++ b/src/codegen/filter.ts @@ -62,3 +62,40 @@ export function isFilterActive(filter?: InputFilter): boolean { const {include, exclude} = normalizeFilter(filter); return include.length > 0 || exclude.length > 0; } + +/** + * Recursively collect every string value stored under `key` anywhere within + * `node`. Used by both input filters to determine which component + * schemas/messages remain reachable after channels/operations/paths have been + * removed: parsers tag component definitions (and their inlined usages) with a + * name extension (`x-parser-schema-id`, `x-modelgen-inferred-name`), so the set + * of values found under the retained surfaces is exactly what is still in use. + */ +export function collectExtensionValues({ + node, + key, + accumulator +}: { + node: unknown; + key: string; + accumulator: Set; +}): void { + if (node === null || typeof node !== 'object') { + return; + } + if (Array.isArray(node)) { + for (const value of node) { + collectExtensionValues({node: value, key, accumulator}); + } + return; + } + const record = node as Record; + // eslint-disable-next-line security/detect-object-injection + const value = record[key]; + if (typeof value === 'string') { + accumulator.add(value); + } + for (const child of Object.values(record)) { + collectExtensionValues({node: child, key, accumulator}); + } +} diff --git a/src/codegen/inputs/asyncapi/filter.ts b/src/codegen/inputs/asyncapi/filter.ts index 6001097e..747c315a 100644 --- a/src/codegen/inputs/asyncapi/filter.ts +++ b/src/codegen/inputs/asyncapi/filter.ts @@ -1,6 +1,11 @@ /* eslint-disable security/detect-object-injection */ import {AsyncAPIDocumentInterface, ChannelInterface} from '@asyncapi/parser'; -import {InputFilter, matchesFilter, normalizeFilter} from '../../filter'; +import { + InputFilter, + matchesFilter, + normalizeFilter, + collectExtensionValues +} from '../../filter'; import {findOperationId} from '../../utils'; import {Logger} from '../../../LoggingInterface'; @@ -73,34 +78,10 @@ function computeRetentionSets({ return sets; } -/** - * Recursively collect every `x-parser-schema-id` reachable from a node. Used to - * determine which `components.schemas` entries are still referenced after - * channels/operations have been removed, so orphans can be pruned. - */ -function collectSchemaIds(node: unknown, accumulator: Set): void { - if (node === null || typeof node !== 'object') { - return; - } - if (Array.isArray(node)) { - for (const value of node) { - collectSchemaIds(value, accumulator); - } - return; - } - const record = node as Record; - const schemaId = record['x-parser-schema-id']; - if (typeof schemaId === 'string') { - accumulator.add(schemaId); - } - for (const value of Object.values(record)) { - collectSchemaIds(value, accumulator); - } -} - /** * Prune `components.schemas` entries that are no longer reachable from the - * retained channels/operations. Mutates the passed JSON in place. + * retained channels/operations. Reachability is keyed on `x-parser-schema-id`, + * the name the parser stamps on every (inlined) schema. Mutates in place. */ function pruneOrphanSchemas(json: Record): void { const schemas = json.components?.schemas; @@ -108,8 +89,16 @@ function pruneOrphanSchemas(json: Record): void { return; } const reachable = new Set(); - collectSchemaIds(json.channels, reachable); - collectSchemaIds(json.operations, reachable); + collectExtensionValues({ + node: json.channels, + key: 'x-parser-schema-id', + accumulator: reachable + }); + collectExtensionValues({ + node: json.operations, + key: 'x-parser-schema-id', + accumulator: reachable + }); const pruned: string[] = []; for (const name of Object.keys(schemas)) { if (!reachable.has(name)) { diff --git a/src/codegen/inputs/openapi/filter.ts b/src/codegen/inputs/openapi/filter.ts index 74890059..12542845 100644 --- a/src/codegen/inputs/openapi/filter.ts +++ b/src/codegen/inputs/openapi/filter.ts @@ -1,6 +1,11 @@ /* eslint-disable security/detect-object-injection */ import {OpenAPIV2, OpenAPIV3, OpenAPIV3_1} from 'openapi-types'; -import {InputFilter, matchesFilter, normalizeFilter} from '../../filter'; +import { + InputFilter, + matchesFilter, + normalizeFilter, + collectExtensionValues +} from '../../filter'; import {chooseComponentModelNames, deriveOperationId} from './utils'; import {Logger} from '../../../LoggingInterface'; @@ -48,36 +53,12 @@ function getSchemaMap( return undefined; } -/** - * Recursively collect every `x-modelgen-inferred-name` value reachable from a - * node. Because dereferencing tags each component schema (and its inlined - * usages) with this name, the set of names found under the retained paths is - * exactly the set of component models still in use. - */ -function collectInferredNames(node: unknown, accumulator: Set): void { - if (node === null || typeof node !== 'object') { - return; - } - if (Array.isArray(node)) { - for (const value of node) { - collectInferredNames(value, accumulator); - } - return; - } - const record = node as Record; - const inferredName = record[MODELINA_INFERRED_NAME]; - if (typeof inferredName === 'string') { - accumulator.add(inferredName); - } - for (const value of Object.values(record)) { - collectInferredNames(value, accumulator); - } -} - /** * Delete component schemas/definitions no longer reachable from the retained - * paths. Mutates the document in place. Only ever called when a filter is - * active, so the no-filter path never prunes. + * paths. Reachability is keyed on `x-modelgen-inferred-name`, which + * dereferencing stamps on each component schema and its inlined usages. Mutates + * the document in place. Only ever called when a filter is active, so the + * no-filter path never prunes. */ function pruneOrphanSchemas(document: OpenAPIDocument): void { const schemas = getSchemaMap(document); @@ -85,7 +66,11 @@ function pruneOrphanSchemas(document: OpenAPIDocument): void { return; } const reachable = new Set(); - collectInferredNames(document.paths, reachable); + collectExtensionValues({ + node: document.paths, + key: MODELINA_INFERRED_NAME, + accumulator: reachable + }); const chosenNames = chooseComponentModelNames(Object.keys(schemas)); const pruned: string[] = []; for (const componentName of Object.keys(schemas)) { From 5bb276c31ff612920015d0f984c7681b56e35d7a Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 21:43:03 +0200 Subject: [PATCH 16/16] fix --- src/codegen/filter.ts | 16 ++++- test/codegen/inputs/openapi/filter.test.ts | 72 ++++++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/codegen/filter.ts b/src/codegen/filter.ts index 180d8241..0944a8e3 100644 --- a/src/codegen/filter.ts +++ b/src/codegen/filter.ts @@ -70,22 +70,32 @@ export function isFilterActive(filter?: InputFilter): boolean { * removed: parsers tag component definitions (and their inlined usages) with a * name extension (`x-parser-schema-id`, `x-modelgen-inferred-name`), so the set * of values found under the retained surfaces is exactly what is still in use. + * + * Dereferenced OpenAPI documents contain genuine circular object references for + * recursive schemas (a `$ref` cycle is inlined by shared object identity), so + * the walk tracks visited objects in a `WeakSet` to avoid infinite recursion. */ export function collectExtensionValues({ node, key, - accumulator + accumulator, + seen = new WeakSet() }: { node: unknown; key: string; accumulator: Set; + seen?: WeakSet; }): void { if (node === null || typeof node !== 'object') { return; } + if (seen.has(node)) { + return; + } + seen.add(node); if (Array.isArray(node)) { for (const value of node) { - collectExtensionValues({node: value, key, accumulator}); + collectExtensionValues({node: value, key, accumulator, seen}); } return; } @@ -96,6 +106,6 @@ export function collectExtensionValues({ accumulator.add(value); } for (const child of Object.values(record)) { - collectExtensionValues({node: child, key, accumulator}); + collectExtensionValues({node: child, key, accumulator, seen}); } } diff --git a/test/codegen/inputs/openapi/filter.test.ts b/test/codegen/inputs/openapi/filter.test.ts index ed553686..4dd35ecb 100644 --- a/test/codegen/inputs/openapi/filter.test.ts +++ b/test/codegen/inputs/openapi/filter.test.ts @@ -109,6 +109,63 @@ const v3Spec = { } }; +// Recursive schema: dereferencing inlines the self-`$ref` by shared object +// identity, producing a genuine circular object graph. The reachability walk +// must terminate on it rather than recurse forever. +const recursiveSpec = { + openapi: '3.0.0', + info: {title: 'recursive', version: '1.0.0'}, + paths: { + '/tree': { + get: { + operationId: 'getTree', + responses: { + 200: { + description: 'ok', + content: { + 'application/json': {schema: {$ref: '#/components/schemas/Node'}} + } + } + } + } + }, + '/orders': { + get: { + operationId: 'listOrders', + responses: { + 200: { + description: 'ok', + content: { + 'application/json': {schema: {$ref: '#/components/schemas/Order'}} + } + } + } + } + } + }, + components: { + schemas: { + Node: { + type: 'object', + properties: { + name: {type: 'string'}, + children: { + type: 'array', + items: {$ref: '#/components/schemas/Node'} + } + } + }, + Order: {type: 'object', properties: {total: {type: 'number'}}} + } + } +}; + +async function loadRecursive(): Promise { + return loadOpenapiFromMemory({ + specString: JSON.stringify(recursiveSpec) + }); +} + const v2Spec = { swagger: '2.0', info: {title: 'v2', version: '1.0.0'}, @@ -210,6 +267,21 @@ describe('filterOpenapiDocument (v3)', () => { }); }); +describe('filterOpenapiDocument (recursive schemas)', () => { + it('terminates and keeps a recursive schema reachable from a retained path', async () => { + const d = await filtered(loadRecursive, {include: ['/tree']}); + expect(pathKeys(d)).toEqual(['/tree']); + // The self-referential schema is retained without infinite recursion. + expect(schemaKeys(d)).toEqual(['Node']); + }); + + it('prunes a recursive schema reachable only from a removed path', async () => { + const d = await filtered(loadRecursive, {include: ['/orders']}); + expect(pathKeys(d)).toEqual(['/orders']); + expect(schemaKeys(d)).toEqual(['Order']); + }); +}); + describe('filterOpenapiDocument (v2)', () => { it('filters paths and prunes orphaned definitions', async () => { const d = await filtered(loadV2, {include: ['/users']});