From 59f0d5637cf7cae067807516f7d63ba592d020b7 Mon Sep 17 00:00:00 2001 From: David Boyne Date: Thu, 19 Mar 2026 15:19:02 +0000 Subject: [PATCH] fix(asyncapi): handle circular references in oneOf/allOf schemas AsyncAPI files using discriminator patterns with oneOf/allOf that reference each other create circular object graphs after parsing. JSON.stringify crashes on these. Replace with a safe serializer that reconstructs valid $ref paths using x-parser-schema-id. Closes #364 Co-Authored-By: Claude Sonnet 4.5 --- .changeset/circular-refs-fix.md | 5 + packages/generator-asyncapi/src/index.ts | 30 +- .../oneOf-allOf-circular-ref.asyncapi.json | 308 ++++++++++++++++++ .../src/test/plugin.test.ts | 97 ++++++ 4 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 .changeset/circular-refs-fix.md create mode 100644 packages/generator-asyncapi/src/test/asyncapi-files/oneOf-allOf-circular-ref.asyncapi.json diff --git a/.changeset/circular-refs-fix.md b/.changeset/circular-refs-fix.md new file mode 100644 index 0000000..0457860 --- /dev/null +++ b/.changeset/circular-refs-fix.md @@ -0,0 +1,5 @@ +--- +'@eventcatalog/generator-asyncapi': patch +--- + +fix(asyncapi): handle circular references in oneOf/allOf schemas with discriminator pattern diff --git a/packages/generator-asyncapi/src/index.ts b/packages/generator-asyncapi/src/index.ts index f922fb8..61318bd 100644 --- a/packages/generator-asyncapi/src/index.ts +++ b/packages/generator-asyncapi/src/index.ts @@ -29,6 +29,32 @@ import { join } from 'node:path'; const parser = new Parser(); +/** + * Safely stringify objects that may contain circular references. + * When a circular ref is detected, it uses the `x-parser-schema-id` property + * to reconstruct a valid JSON Schema `$ref` (e.g. `#/components/schemas/MySchema`). + */ +const safeStringify = (obj: unknown, indent?: number): string => { + const seen = new WeakSet(); + return JSON.stringify( + obj, + (_key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + const schemaId = (value as Record)['x-parser-schema-id']; + if (schemaId && typeof schemaId === 'string') { + return { $ref: `#/components/schemas/${schemaId}` }; + } + return { $ref: '#' }; + } + seen.add(value); + } + return value; + }, + indent + ); +}; + // register avro schema support parser.registerSchemaParser(AvroSchemaParser()); const cliArgs = argv(process.argv.slice(2)); @@ -507,7 +533,7 @@ export default async (config: any, options: Props) => { messageId, { fileName: getSchemaFileName(message), - schema: JSON.stringify(schema, null, 4), + schema: safeStringify(schema, 4), }, messageVersion, { path: cleanedMessagePath } @@ -657,7 +683,7 @@ export default async (config: any, options: Props) => { const getParsedSpecFile = (service: Service, document: AsyncAPIDocumentInterface) => { const isSpecFileJSON = service.path.endsWith('.json'); return isSpecFileJSON - ? JSON.stringify(document.meta().asyncapi.parsed, null, 4) + ? safeStringify(document.meta().asyncapi.parsed, 4) : yaml.dump(document.meta().asyncapi.parsed, { noRefs: true }); }; diff --git a/packages/generator-asyncapi/src/test/asyncapi-files/oneOf-allOf-circular-ref.asyncapi.json b/packages/generator-asyncapi/src/test/asyncapi-files/oneOf-allOf-circular-ref.asyncapi.json new file mode 100644 index 0000000..1d22a6a --- /dev/null +++ b/packages/generator-asyncapi/src/test/asyncapi-files/oneOf-allOf-circular-ref.asyncapi.json @@ -0,0 +1,308 @@ +{ + "asyncapi": "3.1.0", + "info": { + "title": "discriminator-fail-bug-report", + "version": "1.0.0", + "description": "Illustrating a recursive error in event catalog", + "x-generator": "springwolf" + }, + "defaultContentType": "application/json", + "servers": { + "RabbitMQ": { + "host": "localhost:5672", + "protocol": "amqp" + } + }, + "channels": { + "ec-test-dead-letter": { + "address": "ec-test-dead-letter", + "bindings": { + "amqp": { + "is": "queue", + "queue": { + "name": "ec-test-dead-letter", + "durable": true, + "exclusive": false, + "autoDelete": false, + "vhost": "/" + }, + "bindingVersion": "0.3.0" + } + } + }, + "ec-test-dead-letter-exchange_ec-test.dead": { + "address": "ec-test.dead", + "bindings": { + "amqp": { + "is": "routingKey", + "exchange": { + "name": "ec-test-dead-letter-exchange", + "vhost": "/" + }, + "bindingVersion": "0.3.0" + } + } + }, + "ec-test-dead-letter-queue": { + "address": "ec-test-dead-letter-queue", + "bindings": { + "amqp": { + "is": "queue", + "queue": { + "name": "ec-test-dead-letter-queue", + "durable": true, + "exclusive": false, + "autoDelete": false, + "vhost": "/" + }, + "bindingVersion": "0.3.0" + } + } + }, + "ec-test-exchange_ec-test-routingkey": { + "address": "ec-test-exchange_ec-test-routingkey", + "messages": { + "ec.typea-and-typeb-batch": { + "$ref": "#/components/messages/ec.typea-and-typeb-batch" + } + }, + "bindings": { + "amqp": { + "is": "routingKey", + "exchange": { + "name": "ec-test-exchange", + "vhost": "/" + }, + "bindingVersion": "0.3.0" + } + } + }, + "ec-test-queue": { + "address": "ec-test-queue", + "messages": { + "ec.typea-and-typeb-batch": { + "$ref": "#/components/messages/ec.typea-and-typeb-batch" + } + }, + "bindings": { + "amqp": { + "is": "routingKey", + "exchange": { + "name": "ec-test-exchange", + "type": "topic", + "durable": true, + "autoDelete": false, + "vhost": "/" + }, + "bindingVersion": "0.3.0" + } + } + } + }, + "components": { + "schemas": { + "HeadersNotDocumented": { + "title": "HeadersNotDocumented", + "type": "object", + "properties": {}, + "description": "There can be headers, but they are not explicitly documented.", + "examples": [{}] + }, + "SpringRabbitListenerDefaultHeaders": { + "title": "SpringRabbitListenerDefaultHeaders", + "type": "object", + "properties": {}, + "examples": [{}] + }, + "ec-test.batch-element": { + "discriminator": "type", + "title": "ElementDto", + "type": "object", + "properties": { + "type": { + "type": "string" + } + }, + "description": "An event that has 2 possible deserializations.", + "examples": [ + { + "timestamp": "2015-07-20T15:49:04-07:00", + "type": "typeA", + "typeAid": "string" + } + ], + "required": ["type"], + "oneOf": [ + { + "$ref": "#/components/schemas/ec.typea" + }, + { + "$ref": "#/components/schemas/ec.typeb" + } + ] + }, + "ec.typea": { + "type": "object", + "description": "Type A event", + "examples": [ + { + "timestamp": "2015-07-20T15:49:04-07:00", + "type": "typeA", + "typeAid": "string" + } + ], + "required": ["timestamp", "type", "typeAid"], + "allOf": [ + { + "$ref": "#/components/schemas/ec-test.batch-element" + }, + { + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "type": { + "type": "string", + "description": "Type discriminator of the event.", + "minLength": 1, + "examples": ["typeA"] + }, + "typeAid": { + "type": "string", + "minLength": 1 + } + } + } + ] + }, + "ec.typea-and-typeb-batch": { + "title": "TypeAAndTypeBBatchDto", + "type": "object", + "properties": { + "events": { + "type": "array", + "description": "List of events in this batch (mixed types).", + "items": { + "$ref": "#/components/schemas/ec-test.batch-element" + }, + "minItems": 1 + } + }, + "examples": [ + { + "events": [ + { + "timestamp": "2015-07-20T15:49:04-07:00", + "type": "typeA", + "typeAid": "string" + } + ] + } + ], + "required": ["events"] + }, + "ec.typeb": { + "type": "object", + "description": "Type B event", + "examples": [ + { + "displayName": "string", + "type": "typeB", + "typeBid": "string" + } + ], + "required": ["displayName", "type", "typeBid"], + "allOf": [ + { + "$ref": "#/components/schemas/ec-test.batch-element" + }, + { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "type": { + "type": "string", + "description": "Type discriminator of the event.", + "minLength": 1, + "examples": ["typeB"] + }, + "typeBid": { + "type": "string", + "minLength": 1 + } + } + } + ] + } + }, + "messages": { + "ec.typea-and-typeb-batch": { + "headers": { + "$ref": "#/components/schemas/HeadersNotDocumented" + }, + "payload": { + "schemaFormat": "application/vnd.aai.asyncapi+json;version=3.1.0", + "schema": { + "$ref": "#/components/schemas/ec.typea-and-typeb-batch" + } + }, + "name": "ec.typea-and-typeb-batch", + "title": "ec.typea-and-typeb-batch", + "bindings": { + "amqp": { + "bindingVersion": "0.3.0" + } + } + } + } + }, + "operations": { + "ec-test-exchange_ec-test-routingkey_receive_receiveMessage": { + "action": "receive", + "channel": { + "$ref": "#/channels/ec-test-exchange_ec-test-routingkey" + }, + "title": "ec-test-exchange_ec-test-routingkey_receive", + "description": "Listener to polymorphic event with 2 types", + "bindings": { + "amqp": { + "expiration": 0, + "cc": [], + "priority": 0, + "deliveryMode": 1, + "mandatory": false, + "bcc": [], + "timestamp": false, + "ack": false, + "bindingVersion": "0.3.0" + } + }, + "messages": [ + { + "$ref": "#/channels/ec-test-exchange_ec-test-routingkey/messages/ec.typea-and-typeb-batch" + } + ] + }, + "ec-test-queue_receive_receiveMessage": { + "action": "receive", + "channel": { + "$ref": "#/channels/ec-test-queue" + }, + "bindings": { + "amqp": { + "expiration": 0, + "bindingVersion": "0.3.0" + } + }, + "messages": [ + { + "$ref": "#/channels/ec-test-queue/messages/ec.typea-and-typeb-batch" + } + ] + } + } +} diff --git a/packages/generator-asyncapi/src/test/plugin.test.ts b/packages/generator-asyncapi/src/test/plugin.test.ts index 8dde3c7..14be0f9 100644 --- a/packages/generator-asyncapi/src/test/plugin.test.ts +++ b/packages/generator-asyncapi/src/test/plugin.test.ts @@ -1508,6 +1508,103 @@ describe('AsyncAPI EventCatalog Plugin', () => { const schemaParsed = JSON.parse(schema.toString()); expect(schemaParsed).toHaveProperty('type'); }); + it('when a message payload uses oneOf/allOf with circular $ref (discriminator pattern), the schema is documented without circular reference errors', async () => { + await plugin(config, { + services: [ + { path: join(asyncAPIExamplesDir, 'oneOf-allOf-circular-ref.asyncapi.json'), id: 'discriminator-fail-bug-report' }, + ], + }); + + const schema = await fs.readFile( + join(catalogDir, 'services', 'discriminator-fail-bug-report', 'events', 'ec.typea-and-typeb-batch', 'schema.json') + ); + + expect(schema).toBeDefined(); + + const schemaParsed = JSON.parse(schema.toString()); + expect(schemaParsed).toEqual({ + title: 'TypeAAndTypeBBatchDto', + type: 'object', + properties: { + events: { + type: 'array', + description: 'List of events in this batch (mixed types).', + items: { + discriminator: 'type', + title: 'ElementDto', + type: 'object', + properties: { + type: { + type: 'string', + 'x-parser-schema-id': '', + }, + }, + description: 'An event that has 2 possible deserializations.', + examples: [{ timestamp: '2015-07-20T15:49:04-07:00', type: 'typeA', typeAid: 'string' }], + required: ['type'], + oneOf: [ + { + type: 'object', + description: 'Type A event', + examples: [{ timestamp: '2015-07-20T15:49:04-07:00', type: 'typeA', typeAid: 'string' }], + required: ['timestamp', 'type', 'typeAid'], + allOf: [ + { $ref: '#/components/schemas/ec-test.batch-element' }, + { + type: 'object', + properties: { + timestamp: { type: 'string', format: 'date-time', 'x-parser-schema-id': '' }, + type: { + type: 'string', + description: 'Type discriminator of the event.', + minLength: 1, + examples: ['typeA'], + 'x-parser-schema-id': '', + }, + typeAid: { type: 'string', minLength: 1, 'x-parser-schema-id': '' }, + }, + 'x-parser-schema-id': '', + }, + ], + 'x-parser-schema-id': 'ec.typea', + }, + { + type: 'object', + description: 'Type B event', + examples: [{ displayName: 'string', type: 'typeB', typeBid: 'string' }], + required: ['displayName', 'type', 'typeBid'], + allOf: [ + { $ref: '#/components/schemas/ec-test.batch-element' }, + { + type: 'object', + properties: { + displayName: { type: 'string', 'x-parser-schema-id': '' }, + type: { + type: 'string', + description: 'Type discriminator of the event.', + minLength: 1, + examples: ['typeB'], + 'x-parser-schema-id': '', + }, + typeBid: { type: 'string', minLength: 1, 'x-parser-schema-id': '' }, + }, + 'x-parser-schema-id': '', + }, + ], + 'x-parser-schema-id': 'ec.typeb', + }, + ], + 'x-parser-schema-id': 'ec-test.batch-element', + }, + minItems: 1, + 'x-parser-schema-id': '', + }, + }, + examples: [{ events: [{ timestamp: '2015-07-20T15:49:04-07:00', type: 'typeA', typeAid: 'string' }] }], + required: ['events'], + 'x-parser-schema-id': 'ec.typea-and-typeb-batch', + }); + }); }); describe('bindings', () => {