diff --git a/docs/inputs/asyncapi.md b/docs/inputs/asyncapi.md index b0c66cfe..eb037f60 100644 --- a/docs/inputs/asyncapi.md +++ b/docs/inputs/asyncapi.md @@ -56,6 +56,68 @@ no `filter`, output is unchanged. See the [filtering section of the configurations guide](../configurations.md#filtering-channels-operations--paths) for full semantics. +## Recursive and self-referencing schemas + +A component schema may reference itself, directly or through another component. +Both forms generate models that reference each other the same way the document +does. + +```yaml +components: + schemas: + Node: + type: object + required: [label] + properties: + label: + type: string + children: + type: array + items: + $ref: '#/components/schemas/Node' +``` + +Used as a message payload, `Node` produces one model whose `children` are more +of the same model: + +```typescript +interface NodeMessageInterface { + label: string + children?: NodeMessage[] + additionalProperties?: Record +} +``` + +Mutual recursion works the same way — `GraphNode.edge → GraphEdge` and +`GraphEdge.target → GraphNode` produce two models that reference each other: + +```typescript +interface GraphNodeMessageInterface { + name: string + edge?: GraphEdge + additionalProperties?: Record +} + +interface GraphEdgeInterface { + weight: number + target?: GraphNodeMessage + additionalProperties?: Record +} +``` + +Nested values round-trip to their own type, so a tree survives `marshal()` and +`unmarshal()` with its structure intact: + +```typescript +const tree = new NodeMessage({ + label: 'root', + children: [new NodeMessage({label: 'leaf'})] +}); + +const roundTripped = NodeMessage.unmarshal(tree.marshal()); +roundTripped.children?.[0].label; // 'leaf' +``` + ## Basic AsyncAPI Document Structure Here's a complete basic AsyncAPI document example to get you started: diff --git a/src/codegen/inputs/asyncapi/generators/headers.ts b/src/codegen/inputs/asyncapi/generators/headers.ts index 6b8d345a..669e058c 100644 --- a/src/codegen/inputs/asyncapi/generators/headers.ts +++ b/src/codegen/inputs/asyncapi/generators/headers.ts @@ -8,6 +8,11 @@ import {pascalCase} from '../../../generators/typescript/utils'; import {findNameFromChannel} from '../../../utils'; import {Logger} from '../../../../LoggingInterface'; import {AsyncAPIInputProcessor} from '@asyncapi/modelina'; +import { + inlinedRootTarget, + inlinedUnionTargets, + resolveAsyncapiComponentRefs +} from '../refs'; /** * Convert a single message's headers into an internal JSON Schema. Mirrors the @@ -72,10 +77,15 @@ function collectReplyOnlyMessageIds( * channel-scoped `oneOf` union when two or more do. Header-less messages that * sit alongside header-bearing ones are warned about. */ -function buildChannelHeaderEntry( - channel: ChannelInterface, - replyOnlyMessageIds: Set -): {schema: any; schemaId: string} | undefined { +function buildChannelHeaderEntry({ + channel, + replyOnlyMessageIds, + asyncapiDocument +}: { + channel: ChannelInterface; + replyOnlyMessageIds: Set; + asyncapiDocument: AsyncAPIDocumentInterface; +}): {schema: any; schemaId: string} | undefined { // Exclude reply-only messages — the channel headers model the request side. const messages = channel .messages() @@ -103,8 +113,15 @@ function buildChannelHeaderEntry( if (headerBearingMessages.length === 1) { // Exactly one header-bearing message → unchanged single-message output. const message = headerBearingMessages[0]; + const schema = convertMessageHeaders(message); return { - schema: convertMessageHeaders(message), + // The headers schema itself is the fragment root, so a pointer back to it + // resolves as `#`. + schema: resolveAsyncapiComponentRefs({ + fragment: schema, + asyncapiDocument, + inlinedTargets: inlinedRootTarget(schema) + }), schemaId: pascalCase(`${message.id()}_headers`) }; } @@ -112,15 +129,23 @@ function buildChannelHeaderEntry( // 2+ header-bearing messages → a oneOf union, mirroring the payloads union // builder (channel-scoped union id). const unionId = pascalCase(`${findNameFromChannel(channel)}_Headers`); + const unionSchema = { + type: 'object', + $id: unionId, + $schema: 'http://json-schema.org/draft-07/schema', + oneOf: headerBearingMessages.map((message) => + convertMessageHeaders(message) + ) + }; return { - schema: { - type: 'object', - $id: unionId, - $schema: 'http://json-schema.org/draft-07/schema', - oneOf: headerBearingMessages.map((message) => - convertMessageHeaders(message) - ) - }, + // Applied once to the assembled root, not per member: every member is + // inlined at its own `oneOf` slot, and `#/definitions/...` resolves from + // the fragment root. + schema: resolveAsyncapiComponentRefs({ + fragment: unionSchema, + asyncapiDocument, + inlinedTargets: inlinedUnionTargets(unionSchema.oneOf) + }), schemaId: unionId }; } @@ -141,10 +166,11 @@ export function processAsyncAPIHeaders( const replyOnlyMessageIds = collectReplyOnlyMessageIds(asyncapiDocument); for (const channel of asyncapiDocument.allChannels().all()) { - channelHeaders[channel.id()] = buildChannelHeaderEntry( + channelHeaders[channel.id()] = buildChannelHeaderEntry({ channel, - replyOnlyMessageIds - ); + replyOnlyMessageIds, + asyncapiDocument + }); } return {channelHeaders}; diff --git a/src/codegen/inputs/asyncapi/generators/parameters.ts b/src/codegen/inputs/asyncapi/generators/parameters.ts index 8c432606..b728e013 100644 --- a/src/codegen/inputs/asyncapi/generators/parameters.ts +++ b/src/codegen/inputs/asyncapi/generators/parameters.ts @@ -6,6 +6,7 @@ import { pascalCase } from '../../../generators/typescript/utils'; import {findNameFromChannel} from '../../../utils'; +import {resolveAsyncapiComponentRefs} from '../refs'; import { ConstrainedEnumModel, ConstrainedObjectModel, @@ -41,13 +42,33 @@ export async function processAsyncAPIParameters( 'x-channel-address': channel.address() }; + // Component name -> the parameter property the component is inlined at. + // Only AsyncAPI v2 Parameter Objects carry a `schema`, so this only ever + // has entries for v2 documents. + const inlinedTargets = new Map(); for (const parameter of parameters) { - schemaObj.properties[parameter.id()] = parameter.schema()?.json(); + // `any` because the parser's schema type does not model the + // `x-parser-schema-id` extension the rewrite keys on. + const parameterSchema: any = parameter.schema()?.json(); + schemaObj.properties[parameter.id()] = parameterSchema; schemaObj.required.push(parameter.id()); + const parserSchemaId = parameterSchema?.['x-parser-schema-id']; + if (typeof parserSchemaId === 'string') { + inlinedTargets.set(parserSchemaId, `#/properties/${parameter.id()}`); + } } channelParameters[channel.id()] = { - schema: schemaObj, + // Applied once to the assembled root so `#/definitions/...` resolves. + // Parameter schemas are raw parser JSON rather than + // `convertToInternalSchema` output; a hoisted component is converted + // for consistency with the other extraction sites, which only adds + // Modelina's inferred-name extension on top of the same shape. + schema: resolveAsyncapiComponentRefs({ + fragment: schemaObj, + asyncapiDocument, + inlinedTargets + }), schemaId }; } diff --git a/src/codegen/inputs/asyncapi/generators/payloads.ts b/src/codegen/inputs/asyncapi/generators/payloads.ts index 6305c2be..58baa590 100644 --- a/src/codegen/inputs/asyncapi/generators/payloads.ts +++ b/src/codegen/inputs/asyncapi/generators/payloads.ts @@ -9,6 +9,11 @@ import { onlyUnique } from '../../../utils'; import {Logger} from '../../../../LoggingInterface'; +import { + inlinedRootTarget, + inlinedUnionTargets, + resolveAsyncapiComponentRefs +} from '../refs'; // Interface for processed payload schema data export interface ProcessedPayloadSchemaData { @@ -80,6 +85,15 @@ export async function processAsyncAPIPayloads( }); } } + // Every union member is inlined in this fragment, so a surviving pointer + // to one must aim at its `oneOf` slot rather than at a hoisted copy. + // Applied once to the assembled root because `#/definitions/...` + // resolves from the fragment root. + schemaObj = resolveAsyncapiComponentRefs({ + fragment: schemaObj, + asyncapiDocument, + inlinedTargets: inlinedUnionTargets(schemaObj.oneOf) + }); } else { const message = payloadBearingMessages[0]; const schema = AsyncAPIInputProcessor.convertToInternalSchema( @@ -98,6 +112,13 @@ export async function processAsyncAPIPayloads( ...(schema as any), $id: id }; + // The payload schema itself is the fragment root, so a pointer back to + // it resolves as `#`. + schemaObj = resolveAsyncapiComponentRefs({ + fragment: schemaObj, + asyncapiDocument, + inlinedTargets: inlinedRootTarget(schemaObj) + }); } } diff --git a/src/codegen/inputs/asyncapi/refs.ts b/src/codegen/inputs/asyncapi/refs.ts new file mode 100644 index 00000000..23cb9d89 --- /dev/null +++ b/src/codegen/inputs/asyncapi/refs.ts @@ -0,0 +1,219 @@ +/* eslint-disable security/detect-object-injection */ +import {AsyncAPIInputProcessor} from '@asyncapi/modelina'; +import {AsyncAPIDocumentInterface} from '@asyncapi/parser'; + +const COMPONENT_POINTER_PREFIX = '#/components/schemas/'; + +/** + * Cheap pre-scan: does this fragment contain any pointer the rewrite would act + * on? Fragments without one are returned by identity so documents that never + * hit the circular-reference case take a byte-identical path. + */ +function hasComponentPointer(node: any): boolean { + if (Array.isArray(node)) { + return node.some((entry) => hasComponentPointer(entry)); + } + if (node === null || typeof node !== 'object') { + return false; + } + const ref = node['$ref']; + if (typeof ref === 'string' && ref.startsWith(COMPONENT_POINTER_PREFIX)) { + return true; + } + return Object.values(node).some((value) => hasComponentPointer(value)); +} + +/** + * Inlined-target map for a fragment whose root *is* the extracted schema — a + * pointer back to it resolves as `#`. + */ +export function inlinedRootTarget(fragment: any): Map { + const parserSchemaId = fragment?.['x-parser-schema-id']; + return typeof parserSchemaId === 'string' + ? new Map([[parserSchemaId, '#']]) + : new Map(); +} + +/** + * Inlined-target map for an assembled `oneOf` union — each member is inlined at + * its own slot, so a pointer to one resolves as `#/oneOf/`. Boolean + * members carry no schema id and are skipped. + */ +export function inlinedUnionTargets(members: any[]): Map { + const targets = new Map(); + members.forEach((member, index) => { + const parserSchemaId = member?.['x-parser-schema-id']; + if (typeof parserSchemaId === 'string') { + targets.set(parserSchemaId, `#/oneOf/${index}`); + } + }); + return targets; +} + +interface RewriteContext { + /** Component name → the parsed component schema model. */ + components: Map; + /** Component name → local JSON pointer of the copy inlined in the fragment. */ + inlinedTargets: Map; + /** Accumulated `definitions` block, attached to the fragment root at the end. */ + definitions: Record; + /** Components already hoisted, so a cycle terminates. */ + hoistedComponents: Set; +} + +/** Convert a component and add it to `definitions`, once per component. */ +function hoistComponent({ + componentName, + context +}: { + componentName: string; + context: RewriteContext; +}): void { + if (context.hoistedComponents.has(componentName)) { + return; + } + // Marked before the recursive walk so a cycle back to this component + // terminates instead of hoisting forever. + context.hoistedComponents.add(componentName); + const converted = AsyncAPIInputProcessor.convertToInternalSchema( + context.components.get(componentName) + ); + context.definitions[componentName] = { + ...rewriteNode({node: converted, context}), + $id: componentName + }; +} + +/** Resolve a single surviving `#/components/schemas/X` pointer. */ +function rewriteRef({ + node, + ref, + context +}: { + node: any; + ref: string; + context: RewriteContext; +}): any { + const componentName = ref.slice(COMPONENT_POINTER_PREFIX.length); + const inlinedPointer = context.inlinedTargets.get(componentName); + if (inlinedPointer !== undefined) { + return {...node, $ref: inlinedPointer}; + } + if (context.components.has(componentName)) { + hoistComponent({componentName, context}); + return {...node, $ref: `#/definitions/${componentName}`}; + } + // Unknown component — leave the pointer alone so the existing dereference + // error surfaces rather than a silently wrong model. + return node; +} + +/** + * Rebuild the node when — and only when — something below it changed. Nested + * nodes are shared with the parser's document tree (the extraction sites copy + * only the top level), so rewriting in place would corrupt every other + * extraction site; returning the original reference when nothing changed is + * what keeps untouched fragments identical. + */ +function rewriteNode({ + node, + context +}: { + node: any; + context: RewriteContext; +}): any { + if (Array.isArray(node)) { + let changed = false; + const rewrittenEntries = node.map((entry) => { + const rewrittenEntry = rewriteNode({node: entry, context}); + changed ||= rewrittenEntry !== entry; + return rewrittenEntry; + }); + return changed ? rewrittenEntries : node; + } + if (node === null || typeof node !== 'object') { + return node; + } + + const ref = node['$ref']; + if (typeof ref === 'string' && ref.startsWith(COMPONENT_POINTER_PREFIX)) { + return rewriteRef({node, ref, context}); + } + + let changed = false; + const rewrittenNode: Record = {}; + for (const [key, value] of Object.entries(node)) { + const rewrittenValue = rewriteNode({node: value, context}); + changed ||= rewrittenValue !== value; + rewrittenNode[key] = rewrittenValue; + } + return changed ? rewrittenNode : node; +} + +/** + * Rewrite `#/components/schemas/X` pointers that survived parsing so they + * resolve inside an extracted fragment. + * + * `@asyncapi/parser` inlines every non-circular `$ref`, but leaves a cycle as a + * literal `{"$ref": "#/components/schemas/X"}`. The extraction sites re-root a + * single message payload/headers schema as a standalone draft-07 document, + * which carries no `components` section — so the surviving pointer dangles and + * Modelina's dereference step fails with + * `Could not dereference $ref in input`. + * + * Each surviving pointer is resolved one of two ways, and both halves are + * load-bearing: + * + * - **Already inlined in this fragment** → rewritten to that copy's local JSON + * pointer. Hoisting it into `definitions` instead makes Modelina name the + * dereferenced copy after the *use site*, emitting a second class for the + * same schema (self-recursion yields `Node` *and* `NodeChildrenItem`). + * - **Not inlined anywhere in this fragment** → hoisted into `definitions` with + * `$id` stamped, and recursed into so transitive targets come along. Without + * the `$id`, Modelina again names the model after the referencing property + * (mutual recursion yields `Ab` instead of `B`). + * + * `fragment` and the return value are `any` because the extracted schemas are + * untyped JSON Schema objects throughout the AsyncAPI input path (see + * `ProcessedPayloadSchemaData`). + * + * @param fragment the assembled, re-rooted schema fragment + * @param asyncapiDocument the parsed document the fragment was extracted from + * @param inlinedTargets `x-parser-schema-id` → local JSON pointer of the copy + * already inlined in this fragment (`#` for a single-message root, + * `#/oneOf/` for a union member) + */ +export function resolveAsyncapiComponentRefs({ + fragment, + asyncapiDocument, + inlinedTargets +}: { + fragment: any; + asyncapiDocument: AsyncAPIDocumentInterface; + inlinedTargets: Map; +}): any { + if (!hasComponentPointer(fragment)) { + return fragment; + } + + const components = new Map(); + for (const componentSchema of asyncapiDocument.components().schemas().all()) { + components.set(componentSchema.id(), componentSchema); + } + + const context: RewriteContext = { + components, + inlinedTargets, + definitions: {}, + hoistedComponents: new Set() + }; + + const rewrittenFragment = rewriteNode({node: fragment, context}); + if (Object.keys(context.definitions).length === 0) { + return rewrittenFragment; + } + return { + ...rewrittenFragment, + definitions: {...rewrittenFragment.definitions, ...context.definitions} + }; +} diff --git a/test/blackbox/schemas/asyncapi/recursive-asyncapi.yml b/test/blackbox/schemas/asyncapi/recursive-asyncapi.yml new file mode 100644 index 00000000..c6626eb5 --- /dev/null +++ b/test/blackbox/schemas/asyncapi/recursive-asyncapi.yml @@ -0,0 +1,78 @@ +asyncapi: 3.0.0 +info: + title: Recursive schemas example + version: 1.0.0 +channels: + tree: + address: tree + messages: + nodeMessage: + payload: + $ref: '#/components/schemas/node' + graph: + address: graph + messages: + graphNodeMessage: + payload: + $ref: '#/components/schemas/graphNode' + mixed: + address: mixed + messages: + recursiveMember: + payload: + $ref: '#/components/schemas/node' + flatMember: + payload: + $ref: '#/components/schemas/leaf' +operations: + sendTree: + action: send + channel: + $ref: '#/channels/tree' + messages: + - $ref: '#/channels/tree/messages/nodeMessage' + receiveGraph: + action: receive + channel: + $ref: '#/channels/graph' + messages: + - $ref: '#/channels/graph/messages/graphNodeMessage' + sendMixed: + action: send + channel: + $ref: '#/channels/mixed' + messages: + - $ref: '#/channels/mixed/messages/recursiveMember' + - $ref: '#/channels/mixed/messages/flatMember' +components: + schemas: + node: + type: object + required: + - label + properties: + label: + type: string + children: + type: array + items: + $ref: '#/components/schemas/node' + leaf: + type: object + properties: + value: + type: string + graphNode: + type: object + properties: + name: + type: string + edge: + $ref: '#/components/schemas/graphEdge' + graphEdge: + type: object + properties: + weight: + type: number + target: + $ref: '#/components/schemas/graphNode' diff --git a/test/codegen/inputs/asyncapi/__snapshots__/headers.spec.ts.snap b/test/codegen/inputs/asyncapi/__snapshots__/headers.spec.ts.snap index ad01c11f..931e4911 100644 --- a/test/codegen/inputs/asyncapi/__snapshots__/headers.spec.ts.snap +++ b/test/codegen/inputs/asyncapi/__snapshots__/headers.spec.ts.snap @@ -37,3 +37,68 @@ exports[`processAsyncAPIHeaders multi-message handling builds a oneOf union acro "type": "object", } `; + +exports[`processAsyncAPIHeaders recursive schemas rewrites a recursive union member to its \`oneOf\` pointer 1`] = ` +{ + "$id": "TreeHeaders", + "$schema": "http://json-schema.org/draft-07/schema", + "oneOf": [ + { + "$id": "NodeMessageHeaders", + "$schema": "http://json-schema.org/draft-07/schema", + "properties": { + "parent": { + "$ref": "#/oneOf/0", + "x-modelgen-inferred-name": "NodeHeadersParent", + "x-parser-schema-id": "", + }, + "trace": AsyncapiV2Schema { + "type": "string", + "x-modelgen-inferred-name": "NodeHeadersTrace", + "x-parser-schema-id": "", + }, + }, + "type": "object", + "x-modelgen-inferred-name": "NodeHeaders", + "x-parser-schema-id": "NodeHeaders", + }, + { + "$id": "LeafMessageHeaders", + "$schema": "http://json-schema.org/draft-07/schema", + "properties": { + "leaf": AsyncapiV2Schema { + "type": "string", + "x-modelgen-inferred-name": "LeafHeadersLeaf", + "x-parser-schema-id": "", + }, + }, + "type": "object", + "x-modelgen-inferred-name": "LeafHeaders", + "x-parser-schema-id": "LeafHeaders", + }, + ], + "type": "object", +} +`; + +exports[`processAsyncAPIHeaders recursive schemas rewrites a self-recursive pointer to the fragment root 1`] = ` +{ + "$id": "NodeMessageHeaders", + "$schema": "http://json-schema.org/draft-07/schema", + "properties": { + "parent": { + "$ref": "#", + "x-modelgen-inferred-name": "NodeHeadersParent", + "x-parser-schema-id": "", + }, + "trace": AsyncapiV2Schema { + "type": "string", + "x-modelgen-inferred-name": "NodeHeadersTrace", + "x-parser-schema-id": "", + }, + }, + "type": "object", + "x-modelgen-inferred-name": "NodeHeaders", + "x-parser-schema-id": "NodeHeaders", +} +`; diff --git a/test/codegen/inputs/asyncapi/__snapshots__/parameters.spec.ts.snap b/test/codegen/inputs/asyncapi/__snapshots__/parameters.spec.ts.snap new file mode 100644 index 00000000..a8b8c463 --- /dev/null +++ b/test/codegen/inputs/asyncapi/__snapshots__/parameters.spec.ts.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`processAsyncAPIParameters recursive schemas rewrites a recursive parameter pointer to the inlined parameter property 1`] = ` +{ + "$id": "UserIdParameters", + "$schema": "http://json-schema.org/draft-07/schema", + "additionalProperties": false, + "properties": { + "id": { + "properties": { + "name": { + "type": "string", + "x-parser-schema-id": "", + }, + "parent": { + "$ref": "#/properties/id", + "x-parser-schema-id": "", + }, + }, + "type": "object", + "x-parser-schema-id": "RecursiveId", + }, + }, + "required": [ + "id", + ], + "type": "object", + "x-channel-address": "user/{id}", +} +`; diff --git a/test/codegen/inputs/asyncapi/__snapshots__/payloads.spec.ts.snap b/test/codegen/inputs/asyncapi/__snapshots__/payloads.spec.ts.snap index 7351cdec..9b857322 100644 --- a/test/codegen/inputs/asyncapi/__snapshots__/payloads.spec.ts.snap +++ b/test/codegen/inputs/asyncapi/__snapshots__/payloads.spec.ts.snap @@ -35,3 +35,121 @@ exports[`processAsyncAPIPayloads multi-message handling keeps every payload-bear "type": "object", } `; + +exports[`processAsyncAPIPayloads recursive schemas hoists the non-inlined half of a mutually recursive pair into \`definitions\` 1`] = ` +{ + "$id": "AMessage", + "$schema": "http://json-schema.org/draft-07/schema", + "definitions": { + "B": { + "$id": "B", + "properties": { + "a": { + "$ref": "#", + "x-modelgen-inferred-name": "BA", + "x-parser-schema-id": "", + }, + "size": AsyncapiV2Schema { + "type": "number", + "x-modelgen-inferred-name": "BSize", + "x-parser-schema-id": "", + }, + }, + "type": "object", + "x-modelgen-inferred-name": "B", + "x-parser-schema-id": "B", + }, + }, + "properties": { + "b": { + "$ref": "#/definitions/B", + "x-modelgen-inferred-name": "AB", + "x-parser-schema-id": "", + }, + "name": AsyncapiV2Schema { + "type": "string", + "x-modelgen-inferred-name": "AName", + "x-parser-schema-id": "", + }, + }, + "type": "object", + "x-modelgen-inferred-name": "A", + "x-parser-schema-id": "A", +} +`; + +exports[`processAsyncAPIPayloads recursive schemas rewrites a recursive union member to its \`oneOf\` pointer 1`] = ` +{ + "$id": "TreePayload", + "$schema": "http://json-schema.org/draft-07/schema", + "oneOf": [ + { + "$id": "NodeMessage", + "properties": { + "children": { + "items": { + "$ref": "#/oneOf/0", + "x-modelgen-inferred-name": "NodeChildrenItem", + "x-parser-schema-id": "", + }, + "type": "array", + "x-modelgen-inferred-name": "NodeChildren", + "x-parser-schema-id": "", + }, + "label": AsyncapiV2Schema { + "type": "string", + "x-modelgen-inferred-name": "NodeLabel", + "x-parser-schema-id": "", + }, + }, + "type": "object", + "x-modelgen-inferred-name": "Node", + "x-parser-schema-id": "Node", + }, + { + "$id": "LeafMessage", + "properties": { + "value": AsyncapiV2Schema { + "type": "string", + "x-modelgen-inferred-name": "LeafValue", + "x-parser-schema-id": "", + }, + }, + "type": "object", + "x-modelgen-inferred-name": "Leaf", + "x-parser-schema-id": "Leaf", + }, + ], + "type": "object", +} +`; + +exports[`processAsyncAPIPayloads recursive schemas rewrites a self-recursive pointer to the fragment root 1`] = ` +{ + "$id": "NodeMessage", + "$schema": "http://json-schema.org/draft-07/schema", + "properties": { + "children": { + "items": { + "$ref": "#", + "x-modelgen-inferred-name": "NodeChildrenItem", + "x-parser-schema-id": "", + }, + "type": "array", + "x-modelgen-inferred-name": "NodeChildren", + "x-parser-schema-id": "", + }, + "label": AsyncapiV2Schema { + "type": "string", + "x-modelgen-inferred-name": "NodeLabel", + "x-parser-schema-id": "", + }, + }, + "required": [ + "label", + ], + "type": "object", + "x-modelgen-inferred-name": "Node", + "x-parser-schema-id": "Node", +} +`; diff --git a/test/codegen/inputs/asyncapi/__snapshots__/refs.spec.ts.snap b/test/codegen/inputs/asyncapi/__snapshots__/refs.spec.ts.snap new file mode 100644 index 00000000..49f3832f --- /dev/null +++ b/test/codegen/inputs/asyncapi/__snapshots__/refs.spec.ts.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`resolveAsyncapiComponentRefs terminates on a cycle and never hoists an already-inlined component 1`] = ` +{ + "$id": "AMessage", + "$schema": "http://json-schema.org/draft-07/schema", + "definitions": { + "B": { + "$id": "B", + "properties": { + "c": { + "$ref": "#/definitions/C", + "x-modelgen-inferred-name": "BC", + "x-parser-schema-id": "", + }, + }, + "type": "object", + "x-modelgen-inferred-name": "B", + "x-parser-schema-id": "B", + }, + "C": { + "$id": "C", + "properties": { + "a": { + "$ref": "#", + "x-modelgen-inferred-name": "CA", + "x-parser-schema-id": "", + }, + }, + "type": "object", + "x-modelgen-inferred-name": "C", + "x-parser-schema-id": "C", + }, + }, + "properties": { + "b": { + "$ref": "#/definitions/B", + }, + }, + "type": "object", + "x-parser-schema-id": "A", +} +`; diff --git a/test/codegen/inputs/asyncapi/headers.spec.ts b/test/codegen/inputs/asyncapi/headers.spec.ts index 9843f931..4a73f9c0 100644 --- a/test/codegen/inputs/asyncapi/headers.spec.ts +++ b/test/codegen/inputs/asyncapi/headers.spec.ts @@ -1,6 +1,8 @@ import {processAsyncAPIHeaders} from '../../../../src/codegen/inputs/asyncapi/generators/headers'; import {loadAsyncapiFromMemory} from '../../../../src/codegen/inputs/asyncapi'; import {Logger} from '../../../../src/LoggingInterface'; +import {generateModels} from '../../../../src/codegen/output'; +import {TypeScriptFileGenerator} from '@asyncapi/modelina'; const docWith = (messagesYaml: string): string => `asyncapi: 3.0.0 info: @@ -145,3 +147,193 @@ operations: expect(keys).not.toContain('x-res'); }); }); + +const recursiveHeadersDocument = `asyncapi: 3.0.0 +info: + title: T + version: 1.0.0 +channels: + tree: + address: tree + messages: + NodeMessage: + headers: + $ref: '#/components/schemas/NodeHeaders' + payload: + type: object + properties: + value: + type: string +components: + schemas: + NodeHeaders: + type: object + properties: + trace: + type: string + parent: + $ref: '#/components/schemas/NodeHeaders' +`; + +const recursiveHeaderUnionDocument = `asyncapi: 3.0.0 +info: + title: T + version: 1.0.0 +channels: + tree: + address: tree + messages: + NodeMessage: + headers: + $ref: '#/components/schemas/NodeHeaders' + payload: + type: object + properties: + value: + type: string + LeafMessage: + headers: + $ref: '#/components/schemas/LeafHeaders' + payload: + type: object + properties: + value: + type: string +components: + schemas: + NodeHeaders: + type: object + properties: + trace: + type: string + parent: + $ref: '#/components/schemas/NodeHeaders' + LeafHeaders: + type: object + properties: + leaf: + type: string +`; + +const nonRecursiveHeadersDocument = `asyncapi: 3.0.0 +info: + title: T + version: 1.0.0 +channels: + flat: + address: flat + messages: + FlatMessage: + headers: + $ref: '#/components/schemas/FlatHeaders' + payload: + type: object + properties: + value: + type: string +components: + schemas: + FlatHeaders: + type: object + properties: + trace: + type: string +`; + +/** Every `$ref` string anywhere in a fragment. */ +const collectHeaderRefs = (node: any): string[] => { + if (Array.isArray(node)) { + return node.flatMap((entry) => collectHeaderRefs(entry)); + } + if (node === null || typeof node !== 'object') { + return []; + } + return Object.entries(node).flatMap(([key, value]) => + key === '$ref' && typeof value === 'string' + ? [value] + : collectHeaderRefs(value) + ); +}; + +/** Model names Modelina emits for a fragment, derived from the file names. */ +const generateHeaderModelNames = async (schema: any): Promise => { + const result = await generateModels({ + generator: new TypeScriptFileGenerator(), + input: schema, + outputPath: 'src/headers' + }); + return result.files.map((file) => + file.path.replace('src/headers/', '').replace(/\.ts$/, '') + ); +}; + +describe('processAsyncAPIHeaders recursive schemas', () => { + it('rewrites a self-recursive pointer to the fragment root', async () => { + const document = await loadAsyncapiFromMemory({ + input: recursiveHeadersDocument + }); + const processed = processAsyncAPIHeaders(document as any); + const schema = processed.channelHeaders['tree']!.schema as any; + + expect(collectHeaderRefs(schema)).toEqual(['#']); + expect(schema).not.toHaveProperty('definitions'); + expect(schema).toMatchSnapshot(); + }); + + it('generates a single self-referencing header model', async () => { + const document = await loadAsyncapiFromMemory({ + input: recursiveHeadersDocument + }); + const processed = processAsyncAPIHeaders(document as any); + const modelNames = await generateHeaderModelNames( + processed.channelHeaders['tree']!.schema + ); + + expect(modelNames).toEqual(['NodeMessageHeaders']); + expect(new Set(modelNames).size).toEqual(modelNames.length); + }); + + it('rewrites a recursive union member to its `oneOf` pointer', async () => { + const document = await loadAsyncapiFromMemory({ + input: recursiveHeaderUnionDocument + }); + const processed = processAsyncAPIHeaders(document as any); + const schema = processed.channelHeaders['tree']!.schema as any; + + const nodeIndex = schema.oneOf.findIndex( + (member: any) => member['x-parser-schema-id'] === 'NodeHeaders' + ); + expect(collectHeaderRefs(schema)).toEqual([`#/oneOf/${nodeIndex}`]); + expect(schema).not.toHaveProperty('definitions'); + expect(schema).toMatchSnapshot(); + }); + + it('generates one header model per union member with no duplicates', async () => { + const document = await loadAsyncapiFromMemory({ + input: recursiveHeaderUnionDocument + }); + const processed = processAsyncAPIHeaders(document as any); + const modelNames = await generateHeaderModelNames( + processed.channelHeaders['tree']!.schema + ); + + expect(modelNames).toHaveLength(3); + expect(modelNames.sort()).toEqual([ + 'LeafMessageHeaders', + 'NodeMessageHeaders', + 'TreeHeaders' + ]); + expect(new Set(modelNames).size).toEqual(modelNames.length); + }); + + it('leaves a non-recursive header fragment untouched', async () => { + const document = await loadAsyncapiFromMemory({ + input: nonRecursiveHeadersDocument + }); + const processed = processAsyncAPIHeaders(document as any); + const schema = processed.channelHeaders['flat']!.schema as any; + + expect(collectHeaderRefs(schema)).toEqual([]); + expect(schema).not.toHaveProperty('definitions'); + }); +}); diff --git a/test/codegen/inputs/asyncapi/parameters.spec.ts b/test/codegen/inputs/asyncapi/parameters.spec.ts new file mode 100644 index 00000000..cb69817e --- /dev/null +++ b/test/codegen/inputs/asyncapi/parameters.spec.ts @@ -0,0 +1,110 @@ +import {processAsyncAPIParameters} from '../../../../src/codegen/inputs/asyncapi/generators/parameters'; +import {loadAsyncapiFromMemory} from '../../../../src/codegen/inputs/asyncapi'; +import {generateModels} from '../../../../src/codegen/output'; +import {TypeScriptFileGenerator} from '@asyncapi/modelina'; + +// Only AsyncAPI v2 Parameter Objects carry a `schema`, so only a v2 document can +// reach a component pointer through the parameters path at all. +const recursiveParameterDocument = `asyncapi: 2.6.0 +info: + title: T + version: 1.0.0 +channels: + 'user/{id}': + parameters: + id: + schema: + $ref: '#/components/schemas/RecursiveId' + publish: + message: + payload: + type: object + properties: + value: + type: string +components: + schemas: + RecursiveId: + type: object + properties: + name: + type: string + parent: + $ref: '#/components/schemas/RecursiveId' +`; + +const plainParameterDocument = `asyncapi: 2.6.0 +info: + title: T + version: 1.0.0 +channels: + 'user/{id}': + parameters: + id: + schema: + type: string + publish: + message: + payload: + type: object + properties: + value: + type: string +`; + +/** Every `$ref` string anywhere in a fragment. */ +const collectRefs = (node: any): string[] => { + if (Array.isArray(node)) { + return node.flatMap((entry) => collectRefs(entry)); + } + if (node === null || typeof node !== 'object') { + return []; + } + return Object.entries(node).flatMap(([key, value]) => + key === '$ref' && typeof value === 'string' ? [value] : collectRefs(value) + ); +}; + +describe('processAsyncAPIParameters recursive schemas', () => { + it('rewrites a recursive parameter pointer to the inlined parameter property', async () => { + const document = await loadAsyncapiFromMemory({ + input: recursiveParameterDocument + }); + const processed = await processAsyncAPIParameters(document as any); + const {schema} = processed.channelParameters['user/{id}']; + + expect(collectRefs(schema)).toEqual(['#/properties/id']); + expect(schema).not.toHaveProperty('definitions'); + expect(schema).toMatchSnapshot(); + }); + + it('generates models for a recursive parameter without duplicates', async () => { + const document = await loadAsyncapiFromMemory({ + input: recursiveParameterDocument + }); + const processed = await processAsyncAPIParameters(document as any); + const result = await generateModels({ + generator: new TypeScriptFileGenerator(), + input: processed.channelParameters['user/{id}'].schema, + outputPath: 'src/parameters' + }); + const modelNames = result.files.map((file) => + file.path.replace('src/parameters/', '').replace(/\.ts$/, '') + ); + + expect(modelNames.length).toBeGreaterThan(0); + expect(new Set(modelNames).size).toEqual(modelNames.length); + }); + + it('leaves a non-recursive parameter fragment untouched', async () => { + const document = await loadAsyncapiFromMemory({ + input: plainParameterDocument + }); + const processed = await processAsyncAPIParameters(document as any); + const {schema} = processed.channelParameters['user/{id}']; + + expect(collectRefs(schema)).toEqual([]); + expect(schema).not.toHaveProperty('definitions'); + expect(schema.properties.id.type).toEqual('string'); + }); +}); diff --git a/test/codegen/inputs/asyncapi/payloads.spec.ts b/test/codegen/inputs/asyncapi/payloads.spec.ts index d3892474..d0d83edf 100644 --- a/test/codegen/inputs/asyncapi/payloads.spec.ts +++ b/test/codegen/inputs/asyncapi/payloads.spec.ts @@ -1,6 +1,8 @@ import {processAsyncAPIPayloads} from '../../../../src/codegen/inputs/asyncapi/generators/payloads'; import {loadAsyncapiFromMemory} from '../../../../src/codegen/inputs/asyncapi'; import {Logger} from '../../../../src/LoggingInterface'; +import {generateModels} from '../../../../src/codegen/output'; +import {TypeScriptFileGenerator} from '@asyncapi/modelina'; const docWith = (messagesYaml: string): string => `asyncapi: 3.0.0 info: @@ -78,3 +80,236 @@ describe('processAsyncAPIPayloads multi-message handling', () => { expect(channel.schema.properties?.a).toBeDefined(); }); }); + +const recursiveDocument = `asyncapi: 3.0.0 +info: + title: T + version: 1.0.0 +channels: + tree: + address: tree + messages: + NodeMessage: + payload: + $ref: '#/components/schemas/Node' +components: + schemas: + Node: + type: object + required: [label] + properties: + label: + type: string + children: + type: array + items: + $ref: '#/components/schemas/Node' +`; + +const mutuallyRecursiveDocument = `asyncapi: 3.0.0 +info: + title: T + version: 1.0.0 +channels: + mutual: + address: mutual + messages: + AMessage: + payload: + $ref: '#/components/schemas/A' +components: + schemas: + A: + type: object + properties: + name: + type: string + b: + $ref: '#/components/schemas/B' + B: + type: object + properties: + size: + type: number + a: + $ref: '#/components/schemas/A' +`; + +const recursiveUnionDocument = `asyncapi: 3.0.0 +info: + title: T + version: 1.0.0 +channels: + tree: + address: tree + messages: + NodeMessage: + payload: + $ref: '#/components/schemas/Node' + LeafMessage: + payload: + $ref: '#/components/schemas/Leaf' +components: + schemas: + Node: + type: object + properties: + label: + type: string + children: + type: array + items: + $ref: '#/components/schemas/Node' + Leaf: + type: object + properties: + value: + type: string +`; + +const nonRecursiveDocument = `asyncapi: 3.0.0 +info: + title: T + version: 1.0.0 +channels: + flat: + address: flat + messages: + FlatMessage: + payload: + $ref: '#/components/schemas/Flat' +components: + schemas: + Flat: + type: object + properties: + label: + type: string +`; + +/** Every `$ref` string anywhere in a fragment. */ +const collectRefs = (node: any): string[] => { + if (Array.isArray(node)) { + return node.flatMap((entry) => collectRefs(entry)); + } + if (node === null || typeof node !== 'object') { + return []; + } + return Object.entries(node).flatMap(([key, value]) => + key === '$ref' && typeof value === 'string' ? [value] : collectRefs(value) + ); +}; + +/** Model names Modelina emits for a fragment, derived from the file names. */ +const generateModelNames = async (schema: any): Promise => { + const result = await generateModels({ + generator: new TypeScriptFileGenerator(), + input: schema, + outputPath: 'src/models' + }); + return result.files.map((file) => + file.path.replace('src/models/', '').replace(/\.ts$/, '') + ); +}; + +describe('processAsyncAPIPayloads recursive schemas', () => { + it('rewrites a self-recursive pointer to the fragment root', async () => { + const document = await loadAsyncapiFromMemory({input: recursiveDocument}); + const processed = await processAsyncAPIPayloads(document as any); + const {schema} = processed.channelPayloads['tree']; + + expect(collectRefs(schema)).toEqual(['#']); + expect(schema).not.toHaveProperty('definitions'); + expect(schema).toMatchSnapshot(); + }); + + it('generates a single self-referencing model for a self-recursive payload', async () => { + const document = await loadAsyncapiFromMemory({input: recursiveDocument}); + const processed = await processAsyncAPIPayloads(document as any); + const {schema} = processed.channelPayloads['tree']; + + const result = await generateModels({ + generator: new TypeScriptFileGenerator(), + input: schema, + outputPath: 'src/models' + }); + const modelNames = result.files.map((file) => + file.path.replace('src/models/', '').replace(/\.ts$/, '') + ); + + expect(modelNames).toEqual(['NodeMessage']); + expect(new Set(modelNames).size).toEqual(modelNames.length); + expect(result.files[0].content).toContain('NodeMessage[]'); + }); + + it('hoists the non-inlined half of a mutually recursive pair into `definitions`', async () => { + const document = await loadAsyncapiFromMemory({ + input: mutuallyRecursiveDocument + }); + const processed = await processAsyncAPIPayloads(document as any); + const {schema} = processed.channelPayloads['mutual']; + + expect(collectRefs(schema).sort()).toEqual(['#', '#/definitions/B']); + expect(schema.definitions.B.$id).toEqual('B'); + expect(schema).toMatchSnapshot(); + }); + + it('generates two cross-referencing models for mutual recursion', async () => { + const document = await loadAsyncapiFromMemory({ + input: mutuallyRecursiveDocument + }); + const processed = await processAsyncAPIPayloads(document as any); + const modelNames = await generateModelNames( + processed.channelPayloads['mutual'].schema + ); + + expect(modelNames.sort()).toEqual(['AMessage', 'B']); + expect(new Set(modelNames).size).toEqual(modelNames.length); + }); + + it('rewrites a recursive union member to its `oneOf` pointer', async () => { + const document = await loadAsyncapiFromMemory({ + input: recursiveUnionDocument + }); + const processed = await processAsyncAPIPayloads(document as any); + const {schema} = processed.channelPayloads['tree']; + + const nodeIndex = schema.oneOf.findIndex( + (member: any) => member['x-parser-schema-id'] === 'Node' + ); + expect(collectRefs(schema)).toEqual([`#/oneOf/${nodeIndex}`]); + expect(schema).not.toHaveProperty('definitions'); + expect(schema).toMatchSnapshot(); + }); + + it('generates one model per union member with no duplicates', async () => { + const document = await loadAsyncapiFromMemory({ + input: recursiveUnionDocument + }); + const processed = await processAsyncAPIPayloads(document as any); + const modelNames = await generateModelNames( + processed.channelPayloads['tree'].schema + ); + + // A duplicate-class regression shows up only as an extra model carrying a + // name already in the set, so both the count and the uniqueness matter. + expect(modelNames).toHaveLength(3); + expect(modelNames.sort()).toEqual([ + 'LeafMessage', + 'NodeMessage', + 'TreePayload' + ]); + expect(new Set(modelNames).size).toEqual(modelNames.length); + }); + + it('leaves a non-recursive payload fragment untouched', async () => { + const document = await loadAsyncapiFromMemory({ + input: nonRecursiveDocument + }); + const processed = await processAsyncAPIPayloads(document as any); + const {schema} = processed.channelPayloads['flat']; + + expect(collectRefs(schema)).toEqual([]); + expect(schema).not.toHaveProperty('definitions'); + }); +}); diff --git a/test/codegen/inputs/asyncapi/refs.spec.ts b/test/codegen/inputs/asyncapi/refs.spec.ts new file mode 100644 index 00000000..1870a25d --- /dev/null +++ b/test/codegen/inputs/asyncapi/refs.spec.ts @@ -0,0 +1,285 @@ +import {resolveAsyncapiComponentRefs} from '../../../../src/codegen/inputs/asyncapi/refs'; +import {loadAsyncapiFromMemory} from '../../../../src/codegen/inputs/asyncapi'; + +const selfRecursiveDocument = `asyncapi: 3.0.0 +info: + title: T + version: 1.0.0 +channels: + tree: + address: tree + messages: + NodeMessage: + payload: + $ref: '#/components/schemas/Node' +components: + schemas: + Node: + type: object + required: [label] + properties: + label: + type: string + children: + type: array + items: + $ref: '#/components/schemas/Node' +`; + +// A -> B -> C -> A. Every hop stays a pointer because the whole chain is a +// cycle, which is what makes transitive collection observable. +const chainDocument = `asyncapi: 3.0.0 +info: + title: T + version: 1.0.0 +channels: + chain: + address: chain + messages: + AMessage: + payload: + $ref: '#/components/schemas/A' +components: + schemas: + A: + type: object + properties: + b: + $ref: '#/components/schemas/B' + B: + type: object + properties: + c: + $ref: '#/components/schemas/C' + C: + type: object + properties: + a: + $ref: '#/components/schemas/A' +`; + +const loadDocument = async (input: string): Promise => + loadAsyncapiFromMemory({input}); + +describe('resolveAsyncapiComponentRefs', () => { + it('returns the fragment untouched when no component pointer survived', async () => { + const document = await loadDocument(selfRecursiveDocument); + const fragment = { + type: 'object', + $schema: 'http://json-schema.org/draft-07/schema', + properties: {label: {type: 'string'}}, + $id: 'Plain' + }; + + const resolved = resolveAsyncapiComponentRefs({ + fragment, + asyncapiDocument: document, + inlinedTargets: new Map([['Node', '#']]) + }); + + // Same reference — the gate must not rebuild fragments it has nothing to do + // to, otherwise every existing snapshot churns. + expect(resolved).toBe(fragment); + expect(resolved).not.toHaveProperty('definitions'); + }); + + it('rewrites a pointer to the inlined root as `#`', async () => { + const document = await loadDocument(selfRecursiveDocument); + const fragment = { + type: 'object', + $schema: 'http://json-schema.org/draft-07/schema', + properties: { + children: { + type: 'array', + items: {$ref: '#/components/schemas/Node'} + } + }, + 'x-parser-schema-id': 'Node', + $id: 'NodeMessage' + }; + + const resolved = resolveAsyncapiComponentRefs({ + fragment, + asyncapiDocument: document, + inlinedTargets: new Map([['Node', '#']]) + }); + + expect(resolved.properties.children.items.$ref).toEqual('#'); + expect(resolved).not.toHaveProperty('definitions'); + }); + + it('rewrites a pointer to an inlined union member as `#/oneOf/`', async () => { + const document = await loadDocument(selfRecursiveDocument); + const fragment = { + type: 'object', + $schema: 'http://json-schema.org/draft-07/schema', + oneOf: [ + {type: 'object', 'x-parser-schema-id': 'Leaf', $id: 'LeafMessage'}, + { + type: 'object', + properties: { + children: { + type: 'array', + items: {$ref: '#/components/schemas/Node'} + } + }, + 'x-parser-schema-id': 'Node', + $id: 'NodeMessage' + } + ], + $id: 'TreePayload' + }; + + const resolved = resolveAsyncapiComponentRefs({ + fragment, + asyncapiDocument: document, + inlinedTargets: new Map([ + ['Leaf', '#/oneOf/0'], + ['Node', '#/oneOf/1'] + ]) + }); + + expect(resolved.oneOf[1].properties.children.items.$ref).toEqual( + '#/oneOf/1' + ); + expect(resolved).not.toHaveProperty('definitions'); + }); + + it('hoists a component that is not inlined into `definitions` with `$id` stamped', async () => { + const document = await loadDocument(chainDocument); + const fragment = { + type: 'object', + $schema: 'http://json-schema.org/draft-07/schema', + properties: {b: {$ref: '#/components/schemas/B'}}, + 'x-parser-schema-id': 'A', + $id: 'AMessage' + }; + + const resolved = resolveAsyncapiComponentRefs({ + fragment, + asyncapiDocument: document, + inlinedTargets: new Map([['A', '#']]) + }); + + expect(resolved.properties.b.$ref).toEqual('#/definitions/B'); + // Without `$id` Modelina names the hoisted model after the referencing + // property (`AB`) rather than after the component. + expect(resolved.definitions.B.$id).toEqual('B'); + }); + + it('collects transitively referenced components', async () => { + const document = await loadDocument(chainDocument); + const fragment = { + type: 'object', + $schema: 'http://json-schema.org/draft-07/schema', + properties: {b: {$ref: '#/components/schemas/B'}}, + 'x-parser-schema-id': 'A', + $id: 'AMessage' + }; + + const resolved = resolveAsyncapiComponentRefs({ + fragment, + asyncapiDocument: document, + inlinedTargets: new Map([['A', '#']]) + }); + + expect(Object.keys(resolved.definitions).sort()).toEqual(['B', 'C']); + expect(resolved.definitions.B.properties.c.$ref).toEqual( + '#/definitions/C' + ); + // C points back at A, which is inlined as the fragment root. + expect(resolved.definitions.C.properties.a.$ref).toEqual('#'); + }); + + it('terminates on a cycle and never hoists an already-inlined component', async () => { + const document = await loadDocument(chainDocument); + const fragment = { + type: 'object', + $schema: 'http://json-schema.org/draft-07/schema', + properties: {b: {$ref: '#/components/schemas/B'}}, + 'x-parser-schema-id': 'A', + $id: 'AMessage' + }; + + const resolved = resolveAsyncapiComponentRefs({ + fragment, + asyncapiDocument: document, + inlinedTargets: new Map([['A', '#']]) + }); + + expect(resolved.definitions).not.toHaveProperty('A'); + expect(resolved).toMatchSnapshot(); + }); + + it('leaves a pointer to an unknown component untouched', async () => { + const document = await loadDocument(selfRecursiveDocument); + const fragment = { + type: 'object', + $schema: 'http://json-schema.org/draft-07/schema', + properties: {ghost: {$ref: '#/components/schemas/DoesNotExist'}}, + $id: 'Ghost' + }; + + const resolved = resolveAsyncapiComponentRefs({ + fragment, + asyncapiDocument: document, + inlinedTargets: new Map([['Node', '#']]) + }); + + expect(resolved.properties.ghost.$ref).toEqual( + '#/components/schemas/DoesNotExist' + ); + expect(resolved).not.toHaveProperty('definitions'); + }); + + it('leaves pointers that do not target `components/schemas` untouched', async () => { + const document = await loadDocument(selfRecursiveDocument); + const fragment = { + type: 'object', + $schema: 'http://json-schema.org/draft-07/schema', + properties: { + node: {$ref: '#/components/schemas/Node'}, + other: {$ref: '#/definitions/Existing'} + }, + definitions: {Existing: {type: 'string'}}, + 'x-parser-schema-id': 'Node', + $id: 'NodeMessage' + }; + + const resolved = resolveAsyncapiComponentRefs({ + fragment, + asyncapiDocument: document, + inlinedTargets: new Map([['Node', '#']]) + }); + + expect(resolved.properties.node.$ref).toEqual('#'); + expect(resolved.properties.other.$ref).toEqual('#/definitions/Existing'); + expect(resolved.definitions.Existing).toEqual({type: 'string'}); + }); + + it('copies rather than mutates nodes shared with the parser document tree', async () => { + const document = await loadDocument(selfRecursiveDocument); + // `payloads.ts` rebuilds only the top-level object; nested nodes stay shared + // with the parser's document tree, so an in-place rewrite would corrupt + // every other extraction site. + const sharedNode = {$ref: '#/components/schemas/Node'}; + const sharedParent = {items: sharedNode}; + const fragment = { + type: 'object', + $schema: 'http://json-schema.org/draft-07/schema', + properties: {children: sharedParent}, + 'x-parser-schema-id': 'Node', + $id: 'NodeMessage' + }; + + const resolved = resolveAsyncapiComponentRefs({ + fragment, + asyncapiDocument: document, + inlinedTargets: new Map([['Node', '#']]) + }); + + expect(resolved.properties.children.items.$ref).toEqual('#'); + expect(sharedNode.$ref).toEqual('#/components/schemas/Node'); + expect(sharedParent.items).toBe(sharedNode); + }); +}); diff --git a/test/runtime/asyncapi-recursive.json b/test/runtime/asyncapi-recursive.json new file mode 100644 index 00000000..32062d60 --- /dev/null +++ b/test/runtime/asyncapi-recursive.json @@ -0,0 +1,96 @@ +{ + "asyncapi": "3.0.0", + "info": { + "title": "Recursive schemas", + "version": "1.0.0" + }, + "channels": { + "tree": { + "address": "tree", + "messages": { + "NodeMessage": { + "payload": { + "$ref": "#/components/schemas/Node" + } + } + } + }, + "graph": { + "address": "graph", + "messages": { + "GraphNodeMessage": { + "payload": { + "$ref": "#/components/schemas/GraphNode" + } + } + } + } + }, + "operations": { + "sendTree": { + "action": "send", + "channel": { + "$ref": "#/channels/tree" + }, + "messages": [ + { + "$ref": "#/channels/tree/messages/NodeMessage" + } + ] + }, + "sendGraph": { + "action": "send", + "channel": { + "$ref": "#/channels/graph" + }, + "messages": [ + { + "$ref": "#/channels/graph/messages/GraphNodeMessage" + } + ] + } + }, + "components": { + "schemas": { + "Node": { + "type": "object", + "required": ["label"], + "properties": { + "label": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Node" + } + } + } + }, + "GraphNode": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + }, + "edge": { + "$ref": "#/components/schemas/GraphEdge" + } + } + }, + "GraphEdge": { + "type": "object", + "required": ["weight"], + "properties": { + "weight": { + "type": "number" + }, + "target": { + "$ref": "#/components/schemas/GraphNode" + } + } + } + } + } +} diff --git a/test/runtime/typescript/codegen-recursive.mjs b/test/runtime/typescript/codegen-recursive.mjs new file mode 100644 index 00000000..32dd718f --- /dev/null +++ b/test/runtime/typescript/codegen-recursive.mjs @@ -0,0 +1,13 @@ +/** @type {import("../../../dist").TheCodegenConfiguration} **/ +export default { + inputType: 'asyncapi', + inputPath: '../asyncapi-recursive.json', + language: 'typescript', + generators: [ + { + preset: 'payloads', + outputPath: './src/recursive/payloads', + serializationType: 'json', + } + ] +}; diff --git a/test/runtime/typescript/package.json b/test/runtime/typescript/package.json index 68155342..b0c74a9b 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:http-server && npm run test:organization && npm run test:regular && npm run test:payload-types && npm run test:filter", + "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:http-server && npm run test:organization && npm run test:regular && npm run test:payload-types && npm run test:recursive && 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/jsdoc.spec.ts", "test:kafka": "jest -- ./test/channels/regular/kafka.spec.ts", @@ -15,7 +15,8 @@ "test:http-server": "jest -- ./test/channels/http_server/", "test:payload-types": "jest -- ./test/payload-types/", "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-server && npm run generate:openapi-primitive && npm run generate:payload-types && npm run generate:organization && npm run generate:node16 && npm run generate:filter", + "test:recursive": "jest -- ./test/recursive.spec.ts", + "generate": "npm run generate:regular && npm run generate:request:reply && npm run generate:openapi && npm run generate:openapi-server && npm run generate:openapi-primitive && npm run generate:payload-types && npm run generate:organization && npm run generate:node16 && npm run generate:recursive && 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", @@ -31,6 +32,7 @@ "generate:openapi-primitive": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-openapi-primitive.mjs", "generate:payload-types": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-payload-types.mjs", "generate:node16": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-node16.mjs", + "generate:recursive": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-recursive.mjs", "debug:generate": "node --inspect-brk ../../../bin/run.mjs generate" }, "dependencies": { diff --git a/test/runtime/typescript/src/recursive/payloads/GraphEdge.ts b/test/runtime/typescript/src/recursive/payloads/GraphEdge.ts new file mode 100644 index 00000000..c84734a5 --- /dev/null +++ b/test/runtime/typescript/src/recursive/payloads/GraphEdge.ts @@ -0,0 +1,99 @@ +import {GraphNodeMessage} from './GraphNodeMessage'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface GraphEdgeInterface { + weight: number + target?: GraphNodeMessage + additionalProperties?: Record +} +class GraphEdge { + private _weight: number; + private _target?: GraphNodeMessage; + private _additionalProperties?: Record; + + constructor(input: GraphEdgeInterface) { + this._weight = input.weight; + this._target = input.target; + this._additionalProperties = input.additionalProperties; + } + + get weight(): number { return this._weight; } + set weight(weight: number) { this._weight = weight; } + + get target(): GraphNodeMessage | undefined { return this._target; } + set target(target: GraphNodeMessage | undefined) { this._target = target; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.weight !== undefined) { + json["weight"] = this.weight; + } + if(this.target !== undefined) { + json["target"] = this.target && typeof this.target === 'object' && 'toJson' in this.target && typeof this.target.toJson === 'function' ? this.target.toJson() : this.target; + } + 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(["weight","target","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): GraphEdge { + const instance = new GraphEdge({} as any); + + if (obj["weight"] !== undefined) { + instance.weight = obj["weight"] as number; + } + if (obj["target"] !== undefined) { + instance.target = GraphNodeMessage.fromJson(obj["target"] as Record); + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["weight","target","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): GraphEdge { + const obj = typeof json === "object" ? json : JSON.parse(json); + return GraphEdge.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":true},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}; + 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 ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { GraphEdge }; +export type { GraphEdgeInterface }; \ No newline at end of file diff --git a/test/runtime/typescript/src/recursive/payloads/GraphNodeMessage.ts b/test/runtime/typescript/src/recursive/payloads/GraphNodeMessage.ts new file mode 100644 index 00000000..fe3a0fbb --- /dev/null +++ b/test/runtime/typescript/src/recursive/payloads/GraphNodeMessage.ts @@ -0,0 +1,99 @@ +import {GraphEdge} from './GraphEdge'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface GraphNodeMessageInterface { + name: string + edge?: GraphEdge + additionalProperties?: Record +} +class GraphNodeMessage { + private _name: string; + private _edge?: GraphEdge; + private _additionalProperties?: Record; + + constructor(input: GraphNodeMessageInterface) { + this._name = input.name; + this._edge = input.edge; + this._additionalProperties = input.additionalProperties; + } + + get name(): string { return this._name; } + set name(name: string) { this._name = name; } + + get edge(): GraphEdge | undefined { return this._edge; } + set edge(edge: GraphEdge | undefined) { this._edge = edge; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.name !== undefined) { + json["name"] = this.name; + } + if(this.edge !== undefined) { + json["edge"] = this.edge && typeof this.edge === 'object' && 'toJson' in this.edge && typeof this.edge.toJson === 'function' ? this.edge.toJson() : this.edge; + } + 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(["name","edge","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): GraphNodeMessage { + const instance = new GraphNodeMessage({} as any); + + if (obj["name"] !== undefined) { + instance.name = obj["name"] as string; + } + if (obj["edge"] !== undefined) { + instance.edge = GraphEdge.fromJson(obj["edge"] as Record); + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["name","edge","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): GraphNodeMessage { + const obj = typeof json === "object" ? json : JSON.parse(json); + return GraphNodeMessage.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["name"],"properties":{"name":{"type":"string"},"edge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}},"$id":"GraphNodeMessage","definitions":{"GraphEdge":{"type":"object","required":["weight"],"properties":{"weight":{"type":"number"},"target":true},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}},"$id":"GraphEdge"}}}; + 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 ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { GraphNodeMessage }; +export type { GraphNodeMessageInterface }; \ No newline at end of file diff --git a/test/runtime/typescript/src/recursive/payloads/NodeMessage.ts b/test/runtime/typescript/src/recursive/payloads/NodeMessage.ts new file mode 100644 index 00000000..1d18719f --- /dev/null +++ b/test/runtime/typescript/src/recursive/payloads/NodeMessage.ts @@ -0,0 +1,104 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface NodeMessageInterface { + label: string + children?: NodeMessage[] + additionalProperties?: Record +} +class NodeMessage { + private _label: string; + private _children?: NodeMessage[]; + private _additionalProperties?: Record; + + constructor(input: NodeMessageInterface) { + this._label = input.label; + this._children = input.children; + this._additionalProperties = input.additionalProperties; + } + + get label(): string { return this._label; } + set label(label: string) { this._label = label; } + + get children(): NodeMessage[] | undefined { return this._children; } + set children(children: NodeMessage[] | undefined) { this._children = children; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.label !== undefined) { + json["label"] = this.label; + } + if(this.children !== undefined) { + json["children"] = this.children.map((item: any) => + item && typeof item === 'object' && 'toJson' in item && typeof item.toJson === 'function' + ? item.toJson() + : item + ); + } + 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(["label","children","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): NodeMessage { + const instance = new NodeMessage({} as any); + + if (obj["label"] !== undefined) { + instance.label = obj["label"] as string; + } + if (obj["children"] !== undefined) { + instance.children = obj["children"] == null + ? undefined + : (obj["children"] as Record[]).map((item: Record) => NodeMessage.fromJson(item)); + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["label","children","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): NodeMessage { + const obj = typeof json === "object" ? json : JSON.parse(json); + return NodeMessage.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["label"],"properties":{"label":{"type":"string"},"children":{"type":"array","items":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["label"],"properties":{"label":{"type":"string"},"children":{"type":"array","items":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["label"],"properties":{"label":{"type":"string"},"children":{"type":"array","items":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["label"],"properties":{"label":{"type":"string"},"children":{"type":"array","items":{"type":"object","$schema":"http://json-schema.org/draft-07/schema","required":["label"],"properties":{"label":{"type":"string"},"children":{"type":"array","items":true}},"$id":"NodeMessage"}}},"$id":"NodeMessage"}}},"$id":"NodeMessage"}}},"$id":"NodeMessage"}}},"$id":"NodeMessage"}; + 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 ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { NodeMessage }; +export type { NodeMessageInterface }; \ No newline at end of file diff --git a/test/runtime/typescript/test/recursive.spec.ts b/test/runtime/typescript/test/recursive.spec.ts new file mode 100644 index 00000000..8660a235 --- /dev/null +++ b/test/runtime/typescript/test/recursive.spec.ts @@ -0,0 +1,75 @@ +import {NodeMessage} from '../src/recursive/payloads/NodeMessage'; +import {GraphNodeMessage} from '../src/recursive/payloads/GraphNodeMessage'; +import {GraphEdge} from '../src/recursive/payloads/GraphEdge'; + +describe('recursive payloads', () => { + describe('self-recursive schema', () => { + const tree = new NodeMessage({ + label: 'root', + children: [ + new NodeMessage({ + label: 'branch', + children: [new NodeMessage({label: 'leaf'})] + }), + new NodeMessage({label: 'sibling'}) + ] + }); + + test('a self-recursive schema produces a single self-referencing model', () => { + expect(tree.children?.[0]).toBeInstanceOf(NodeMessage); + expect(tree.children?.[0].children?.[0].label).toEqual('leaf'); + }); + + test('a multi-level tree survives marshal and unmarshal with its structure intact', () => { + const roundTripped = NodeMessage.unmarshal(tree.marshal()); + + expect(roundTripped.label).toEqual('root'); + expect(roundTripped.children).toHaveLength(2); + expect(roundTripped.children?.[0]).toBeInstanceOf(NodeMessage); + expect(roundTripped.children?.[0].label).toEqual('branch'); + expect(roundTripped.children?.[0].children?.[0]).toBeInstanceOf( + NodeMessage + ); + expect(roundTripped.children?.[0].children?.[0].label).toEqual('leaf'); + expect(roundTripped.children?.[1].label).toEqual('sibling'); + expect(roundTripped.marshal()).toEqual(tree.marshal()); + }); + + test('a leaf node marshals without an empty children key', () => { + expect(new NodeMessage({label: 'only'}).marshal()).toEqual( + '{"label":"only"}' + ); + }); + }); + + describe('mutually recursive schemas', () => { + const graph = new GraphNodeMessage({ + name: 'start', + edge: new GraphEdge({ + weight: 1.5, + target: new GraphNodeMessage({ + name: 'end', + edge: new GraphEdge({weight: 2.5}) + }) + }) + }); + + test('each side of the cycle is its own model, cross-referencing the other', () => { + expect(graph.edge).toBeInstanceOf(GraphEdge); + expect(graph.edge?.target).toBeInstanceOf(GraphNodeMessage); + expect(graph.edge?.target?.edge).toBeInstanceOf(GraphEdge); + }); + + test('an alternating cycle survives marshal and unmarshal with its structure intact', () => { + const roundTripped = GraphNodeMessage.unmarshal(graph.marshal()); + + expect(roundTripped.name).toEqual('start'); + expect(roundTripped.edge).toBeInstanceOf(GraphEdge); + expect(roundTripped.edge?.weight).toEqual(1.5); + expect(roundTripped.edge?.target).toBeInstanceOf(GraphNodeMessage); + expect(roundTripped.edge?.target?.name).toEqual('end'); + expect(roundTripped.edge?.target?.edge?.weight).toEqual(2.5); + expect(roundTripped.marshal()).toEqual(graph.marshal()); + }); + }); +});