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 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 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/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/browser/generate.ts b/src/browser/generate.ts index 870b9f61..5e32649d 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); @@ -100,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/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..a57beb5b 100644 --- a/src/codegen/configurations.ts +++ b/src/codegen/configurations.ts @@ -341,13 +341,15 @@ 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 - ); + 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 new file mode 100644 index 00000000..0944a8e3 --- /dev/null +++ b/src/codegen/filter.ts @@ -0,0 +1,111 @@ +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; +} + +/** + * 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 { + 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. + * + * 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, + 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, seen}); + } + 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, seen}); + } +} diff --git a/src/codegen/inputs/asyncapi/filter.ts b/src/codegen/inputs/asyncapi/filter.ts new file mode 100644 index 00000000..747c315a --- /dev/null +++ b/src/codegen/inputs/asyncapi/filter.ts @@ -0,0 +1,221 @@ +/* eslint-disable security/detect-object-injection */ +import {AsyncAPIDocumentInterface, ChannelInterface} from '@asyncapi/parser'; +import { + InputFilter, + matchesFilter, + normalizeFilter, + collectExtensionValues +} 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} = normalizeFilter(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; +} + +/** + * Prune `components.schemas` entries that are no longer reachable from the + * 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; + if (!schemas || typeof schemas !== 'object') { + return; + } + const reachable = new Set(); + 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)) { + 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/src/codegen/inputs/openapi/filter.ts b/src/codegen/inputs/openapi/filter.ts new file mode 100644 index 00000000..12542845 --- /dev/null +++ b/src/codegen/inputs/openapi/filter.ts @@ -0,0 +1,173 @@ +/* eslint-disable security/detect-object-injection */ +import {OpenAPIV2, OpenAPIV3, OpenAPIV3_1} from 'openapi-types'; +import { + InputFilter, + matchesFilter, + normalizeFilter, + collectExtensionValues +} 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; +} + +/** + * Delete component schemas/definitions no longer reachable from the retained + * 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); + if (!schemas) { + return; + } + const reachable = new Set(); + 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)) { + 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/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) 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(); + }); + }); }); diff --git a/test/codegen/filter.test.ts b/test/codegen/filter.test.ts new file mode 100644 index 00000000..a99aa2d0 --- /dev/null +++ b/test/codegen/filter.test.ts @@ -0,0 +1,152 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +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); + }); + }); +}); diff --git a/test/codegen/generators/typescript/channels.spec.ts b/test/codegen/generators/typescript/channels.spec.ts index b690b94d..34cab984 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' @@ -602,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', {}), @@ -654,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( @@ -842,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: {}, @@ -890,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/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..bce32bab 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 = { @@ -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 = { @@ -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..5412d398 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: { @@ -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 7f15e871..9510b32e 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: { @@ -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 53bdeb01..5a466df1 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: { @@ -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: { @@ -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..52bd8d13 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: { @@ -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/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' + ]); + }); +}); diff --git a/test/codegen/inputs/openapi/filter.test.ts b/test/codegen/inputs/openapi/filter.test.ts new file mode 100644 index 00000000..4dd35ecb --- /dev/null +++ b/test/codegen/inputs/openapi/filter.test.ts @@ -0,0 +1,297 @@ +/* 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'}}} + } + } +}; + +// 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'}, + 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 (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']}); + 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']); + }); +}); 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/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 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); + }); +});