diff --git a/src/constants.ts b/src/constants.ts index 4218714ca..e52868f98 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -14,6 +14,7 @@ export const xParserOriginalTraits = 'x-parser-original-traits'; export const xParserCircular = 'x-parser-circular'; export const xParserCircularProps = 'x-parser-circular-props'; +export const xParserObjectUniqueId = 'x-parser-unique-object-id'; export const EXTENSION_REGEX = /^x-[\w\d.\-_]+$/; diff --git a/src/custom-operations/apply-traits.ts b/src/custom-operations/apply-traits.ts index 990aca601..6d99453ff 100644 --- a/src/custom-operations/apply-traits.ts +++ b/src/custom-operations/apply-traits.ts @@ -51,12 +51,17 @@ function applyTraitsToObjectV2(value: Record) { const v3TraitPaths = [ // operations '$.operations.*', + '$.operations.*.channel.*', + '$.operations.*.channel.messages.*', + '$.operations.*.messages.*', '$.components.operations.*', - // messages + '$.components.operations.*.channel.*', + '$.components.operations.*.channel.messages.*', + '$.components.operations.*.messages.*', + // Channels '$.channels.*.messages.*', - '$.operations.*.messages.*', '$.components.channels.*.messages.*', - '$.components.operations.*.messages.*', + // messages '$.components.messages.*', ]; @@ -100,4 +105,4 @@ function applyTraitsToObjectV3(value: Record) { value[String(key)] = mergePatch(value[String(key)], trait[String(key)]); } } -} \ No newline at end of file +} diff --git a/src/custom-operations/index.ts b/src/custom-operations/index.ts index 28754663a..70e923ff9 100644 --- a/src/custom-operations/index.ts +++ b/src/custom-operations/index.ts @@ -2,6 +2,7 @@ import { applyTraitsV2, applyTraitsV3 } from './apply-traits'; import { resolveCircularRefs } from './resolve-circular-refs'; import { parseSchemasV2, parseSchemasV3 } from './parse-schema'; import { anonymousNaming } from './anonymous-naming'; +import { checkCircularRefs } from './check-circular-refs'; import type { RulesetFunctionContext } from '@stoplight/spectral-core'; import type { Parser } from '../parser'; @@ -9,8 +10,8 @@ import type { ParseOptions } from '../parse'; import type { AsyncAPIDocumentInterface } from '../models'; import type { DetailedAsyncAPI } from '../types'; import type { v2, v3 } from '../spec-types'; -import { checkCircularRefs } from './check-circular-refs'; +export {applyUniqueIds} from './apply-unique-ids'; export async function customOperations(parser: Parser, document: AsyncAPIDocumentInterface, detailed: DetailedAsyncAPI, inventory: RulesetFunctionContext['documentInventory'], options: ParseOptions): Promise { switch (detailed.semver.major) { case 2: return operationsV2(parser, document, detailed, inventory, options); diff --git a/src/models/v3/channel.ts b/src/models/v3/channel.ts index 0734d2b35..9c9c14749 100644 --- a/src/models/v3/channel.ts +++ b/src/models/v3/channel.ts @@ -6,9 +6,8 @@ import { Operations } from './operations'; import { Operation } from './operation'; import { Servers } from './servers'; import { Server } from './server'; - +import { xParserObjectUniqueId } from '../../constants'; import { CoreModel } from './mixins'; - import type { ChannelInterface } from '../channel'; import type { ChannelParametersInterface } from '../channel-parameters'; import type { MessagesInterface } from '../messages'; @@ -16,7 +15,6 @@ import type { OperationsInterface } from '../operations'; import type { OperationInterface } from '../operation'; import type { ServersInterface } from '../servers'; import type { ServerInterface } from '../server'; - import type { v3 } from '../../spec-types'; export class Channel extends CoreModel implements ChannelInterface { @@ -30,8 +28,8 @@ export class Channel extends CoreModel impleme servers(): ServersInterface { const servers: ServerInterface[] = []; - const allowedServers = this._json.servers || []; - Object.entries(this._meta.asyncapi?.parsed.servers || {}).forEach(([serverName, server]) => { + const allowedServers = this._json.servers ?? []; + Object.entries(this._meta.asyncapi?.parsed.servers ?? {}).forEach(([serverName, server]) => { if (allowedServers.length === 0 || allowedServers.includes(server)) { servers.push(this.createModel(Server, server, { id: serverName, pointer: `/servers/${serverName}` })); } @@ -41,8 +39,10 @@ export class Channel extends CoreModel impleme operations(): OperationsInterface { const operations: OperationInterface[] = []; - Object.entries(((this._meta.asyncapi?.parsed as v3.AsyncAPIObject)?.operations || {})).forEach(([operationId, operation]) => { - if ((operation as v3.OperationObject).channel === this._json) { + Object.entries(((this._meta.asyncapi?.parsed as v3.AsyncAPIObject)?.operations ?? {} as v3.OperationsObject)).forEach(([operationId, operation]) => { + const operationChannelId = ((operation as v3.OperationObject).channel as any)[xParserObjectUniqueId]; + const channelId = (this._json as any)[xParserObjectUniqueId]; + if (operationChannelId === channelId) { operations.push( this.createModel(Operation, operation as v3.OperationObject, { id: operationId, pointer: `/operations/${operationId}` }), ); @@ -53,7 +53,7 @@ export class Channel extends CoreModel impleme messages(): MessagesInterface { return new Messages( - Object.entries(this._json.messages || {}).map(([messageName, message]) => { + Object.entries(this._json.messages ?? {}).map(([messageName, message]) => { return this.createModel(Message, message as v3.MessageObject, { id: messageName, pointer: this.jsonPath(`messages/${messageName}`) }); }) ); @@ -61,7 +61,7 @@ export class Channel extends CoreModel impleme parameters(): ChannelParametersInterface { return new ChannelParameters( - Object.entries(this._json.parameters || {}).map(([channelParameterName, channelParameter]) => { + Object.entries(this._json.parameters ?? {}).map(([channelParameterName, channelParameter]) => { return this.createModel(ChannelParameter, channelParameter as v3.ParameterObject, { id: channelParameterName, pointer: this.jsonPath(`parameters/${channelParameterName}`), diff --git a/src/models/v3/message.ts b/src/models/v3/message.ts index 123079927..3fe3f05e8 100644 --- a/src/models/v3/message.ts +++ b/src/models/v3/message.ts @@ -6,7 +6,7 @@ import { MessageTraits } from './message-traits'; import { MessageTrait } from './message-trait'; import { Servers } from './servers'; import { Schema } from './schema'; - +import { xParserObjectUniqueId } from '../../constants'; import type { ChannelsInterface } from '../channels'; import type { ChannelInterface } from '../channel'; import type { MessageInterface } from '../message'; @@ -16,7 +16,6 @@ import type { OperationInterface } from '../operation'; import type { ServersInterface } from '../servers'; import type { ServerInterface } from '../server'; import type { SchemaInterface } from '../schema'; - import type { v3 } from '../../spec-types'; export class Message extends MessageTrait implements MessageInterface { @@ -58,6 +57,7 @@ export class Message extends MessageTrait implements MessageIn } channels(): ChannelsInterface { + const thisMessageId = (this._json)[xParserObjectUniqueId]; const channels: ChannelInterface[] = []; const channelsData: any[] = []; this.operations().forEach(operation => { @@ -73,7 +73,10 @@ export class Message extends MessageTrait implements MessageIn Object.entries((this._meta.asyncapi?.parsed as v3.AsyncAPIObject)?.channels || {}).forEach(([channelId, channelData]) => { const channelModel = this.createModel(Channel, channelData as v3.ChannelObject, { id: channelId, pointer: `/channels/${channelId}` }); - if (!channelsData.includes(channelData) && channelModel.messages().some(m => m.json() === this._json)) { + if (!channelsData.includes(channelData) && channelModel.messages().some(m => { + const messageId = (m as any)[xParserObjectUniqueId]; + return messageId === thisMessageId; + })) { channelsData.push(channelData); channels.push(channelModel); } @@ -83,10 +86,15 @@ export class Message extends MessageTrait implements MessageIn } operations(): OperationsInterface { + const thisMessageId = (this._json)[xParserObjectUniqueId]; const operations: OperationInterface[] = []; Object.entries((this._meta.asyncapi?.parsed as v3.AsyncAPIObject)?.operations || {}).forEach(([operationId, operation]) => { const operationModel = this.createModel(Operation, operation as v3.OperationObject, { id: operationId, pointer: `/operations/${operationId}` }); - if (operationModel.messages().some(m => m.json() === this._json)) { + const operationHasMessage = operationModel.messages().some(m => { + const messageId = (m as any)[xParserObjectUniqueId]; + return messageId === thisMessageId; + }); + if (operationHasMessage) { operations.push(operationModel); } }); diff --git a/src/models/v3/operation-reply.ts b/src/models/v3/operation-reply.ts index 93427d388..35e164fed 100644 --- a/src/models/v3/operation-reply.ts +++ b/src/models/v3/operation-reply.ts @@ -4,14 +4,12 @@ import { Message } from './message'; import { Messages } from './messages'; import { MessagesInterface } from '../messages'; import { OperationReplyAddress } from './operation-reply-address'; - import { extensions } from './mixins'; - +import { xParserObjectUniqueId } from '../../constants'; import type { ExtensionsInterface } from '../extensions'; import type { OperationReplyInterface } from '../operation-reply'; import type { OperationReplyAddressInterface } from '../operation-reply-address'; import type { ChannelInterface } from '../channel'; - import type { v3 } from '../../spec-types'; export class OperationReply extends BaseModel implements OperationReplyInterface { @@ -35,14 +33,16 @@ export class OperationReply extends BaseModel { - return this.createModel(Message, message as v3.MessageObject, { id: messageName, pointer: this.jsonPath(`messages/${messageName}`) }); + Object.entries(this._json.messages ?? {}).map(([messageId, message]) => { + return this.createModel(Message, message as v3.MessageObject, { id: messageId, pointer: this.jsonPath(`messages/${messageId}`) }); }) ); } diff --git a/src/models/v3/operation.ts b/src/models/v3/operation.ts index 412fbb4c7..f18753134 100644 --- a/src/models/v3/operation.ts +++ b/src/models/v3/operation.ts @@ -17,6 +17,7 @@ import type { ServersInterface } from '../servers'; import type { ServerInterface } from '../server'; import type { v3 } from '../../spec-types'; +import { xParserObjectUniqueId } from '../../constants'; export class Operation extends OperationTrait implements OperationInterface { action(): OperationAction { @@ -48,23 +49,21 @@ export class Operation extends OperationTrait implements Ope channels(): ChannelsInterface { if (this._json.channel) { - for (const [channelName, channel] of Object.entries(this._meta.asyncapi?.parsed.channels || {})) { - if (channel === this._json.channel) { - return new Channels([ - this.createModel(Channel, this._json.channel as v3.ChannelObject, { id: channelName, pointer: `/channels/${channelName}` }) - ]); - } - } + const operationChannelId = (this._json.channel as any)[xParserObjectUniqueId]; + return new Channels([ + this.createModel(Channel, this._json.channel as v3.ChannelObject, { id: operationChannelId, pointer: `/channels/${operationChannelId}` }) + ]); } return new Channels([]); } - + messages(): MessagesInterface { const messages: MessageInterface[] = []; if (Array.isArray(this._json.messages)) { this._json.messages.forEach((message, index) => { + const messageId = (message as any)[xParserObjectUniqueId]; messages.push( - this.createModel(Message, message as v3.MessageObject, { id: '', pointer: this.jsonPath(`messages/${index}`) }) + this.createModel(Message, message as v3.MessageObject, { id: messageId, pointer: this.jsonPath(`messages/${index}`) }) ); }); return new Messages(messages); diff --git a/src/parse.ts b/src/parse.ts index 0201320ad..479bd9dfb 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,6 +1,6 @@ import { AsyncAPIDocumentInterface, ParserAPIVersion } from './models'; -import { customOperations } from './custom-operations'; +import { applyUniqueIds, customOperations } from './custom-operations'; import { validate } from './validate'; import { copy } from './stringify'; import { createAsyncAPIDocument } from './document'; @@ -38,13 +38,26 @@ const defaultOptions: ParseOptions = { validateOptions: {}, __unstable: {}, }; - +import yaml from 'js-yaml'; export async function parse(parser: Parser, spectral: Spectral, asyncapi: Input, options: ParseOptions = {}): Promise { let spectralDocument: Document | undefined; try { options = mergePatch(defaultOptions, options); - const { validated, diagnostics, extras } = await validate(parser, spectral, asyncapi, { ...options.validateOptions, source: options.source, __unstable: options.__unstable }); + // Normalize input to always be JSON + let loadedObj; + if (typeof asyncapi === 'string') { + try { + loadedObj = yaml.load(asyncapi); + } catch (e) { + loadedObj = JSON.parse(asyncapi); + } + } else { + loadedObj = asyncapi; + } + // Apply unique ids before resolving references + applyUniqueIds(loadedObj); + const { validated, diagnostics, extras } = await validate(parser, spectral, loadedObj, { ...options.validateOptions, source: options.source, __unstable: options.__unstable }); if (validated === undefined) { return { document: undefined, @@ -58,13 +71,12 @@ export async function parse(parser: Parser, spectral: Spectral, asyncapi: Input, // unfreeze the object - Spectral makes resolved document "freezed" const validatedDoc = copy(validated as Record); - const detailed = createDetailedAsyncAPI(validatedDoc, asyncapi as DetailedAsyncAPI['input'], options.source); - detailed.parsed['x-test'] = 'test'; + const detailed = createDetailedAsyncAPI(validatedDoc, loadedObj as DetailedAsyncAPI['input'], options.source); const document = createAsyncAPIDocument(detailed); setExtension(xParserSpecParsed, true, document); setExtension(xParserApiVersion, ParserAPIVersion, document); await customOperations(parser, document, detailed, inventory, options); - + return { document, diagnostics, diff --git a/test/custom-operations/apply-traits-v3.spec.ts b/test/custom-operations/apply-traits-v3.spec.ts index 61ff909ec..1561b783a 100644 --- a/test/custom-operations/apply-traits-v3.spec.ts +++ b/test/custom-operations/apply-traits-v3.spec.ts @@ -1,3 +1,4 @@ +import { xParserObjectUniqueId } from '../../src/constants'; import { AsyncAPIDocumentV3 } from '../../src/models'; import { Parser } from '../../src/parser'; @@ -56,11 +57,11 @@ describe('custom operations - apply traits v3', function() { const someOperation1 = v3Document?.json()?.operations?.someOperation1; delete someOperation1?.traits; - expect(someOperation1).toEqual({ action: 'send', channel: {}, description: 'another description' }); + expect(someOperation1).toEqual({ action: 'send', channel: { 'x-parser-unique-object-id': 'channel1' }, description: 'another description' }); const someOperation2 = v3Document?.json()?.operations?.someOperation2; delete someOperation2?.traits; - expect(someOperation2).toEqual({ action: 'send', channel: {}, description: 'root description' }); + expect(someOperation2).toEqual({ action: 'send', channel: { 'x-parser-unique-object-id': 'channel1' }, description: 'root description' }); }); it('should apply traits to messages (channels)', async function() { @@ -112,11 +113,11 @@ describe('custom operations - apply traits v3', function() { const message1 = v3Document?.json()?.channels?.someChannel1?.messages?.someMessage; delete (message1 as v3.MessageObject)?.traits; - expect(message1).toEqual({ summary: 'some summary', description: 'another description', 'x-parser-message-name': 'someMessage' }); + expect(message1).toEqual({ summary: 'some summary', description: 'another description', 'x-parser-message-name': 'someMessage', 'x-parser-unique-object-id': 'someMessage' }); const message2 = v3Document?.json()?.channels?.someChannel2?.messages?.someMessage; delete (message2 as v3.MessageObject)?.traits; - expect(message2).toEqual({ summary: 'root summary', description: 'root description', 'x-parser-message-name': 'someMessage' }); + expect(message2).toEqual({ summary: 'root summary', description: 'root description', 'x-parser-message-name': 'someMessage', 'x-parser-unique-object-id': 'someMessage' }); }); it('should apply traits to messages (components)', async function() { @@ -168,4 +169,153 @@ describe('custom operations - apply traits v3', function() { delete (message2 as v3.MessageObject)?.traits; expect(message2).toEqual({ summary: 'root summary', description: 'root description', 'x-parser-message-name': 'someMessage2' }); }); + + it('iterative functions should still work after traits have been applied', async function() { + const documentRaw = { + asyncapi: '3.0.0', + info: { + title: 'Streetlights Kafka API', + version: '1.0.0', + description: 'The Smartylighting Streetlights API allows you to remotely manage the city lights.\n\n### Check out its awesome features:\n\n* Turn a specific streetlight on/off 🌃\n* Dim a specific streetlight 😎\n* Receive real-time information about environmental lighting conditions 📈\n', + license: { + name: 'Apache 2.0', + url: 'https://www.apache.org/licenses/LICENSE-2.0' + } + }, + defaultContentType: 'application/json', + channels: { + 'smartylighting.streetlights.1.0.event.{streetlightId}.lighting.measured': { + address: 'smartylighting.streetlights.1.0.event.{streetlightId}.lighting.measured', + messages: { + 'receiveLightMeasurement.message': { + $ref: '#/components/messages/lightMeasured' + } + }, + description: 'The topic on which measured values may be produced and consumed.', + parameters: { + streetlightId: { + $ref: '#/components/parameters/streetlightId' + } + } + } + }, + operations: { + receiveLightMeasurement: { + action: 'receive', + channel: { + $ref: '#/channels/smartylighting.streetlights.1.0.event.{streetlightId}.lighting.measured' + }, + summary: 'Inform about environmental lighting conditions of a particular streetlight.', + traits: [ + { + $ref: '#/components/operationTraits/kafka' + } + ], + messages: [ + { + $ref: '#/components/messages/lightMeasured' + } + ] + } + }, + components: { + messages: { + lightMeasured: { + name: 'lightMeasured', + title: 'Light measured', + summary: 'Inform about environmental lighting conditions of a particular streetlight.', + contentType: 'application/json', + traits: [ + { + $ref: '#/components/messageTraits/commonHeaders' + } + ], + payload: { + $ref: '#/components/schemas/lightMeasuredPayload' + } + } + }, + schemas: { + lightMeasuredPayload: { + type: 'object', + properties: { + lumens: { + type: 'integer', + minimum: 0, + description: 'Light intensity measured in lumens.' + }, + sentAt: { + $ref: '#/components/schemas/sentAt' + } + } + }, + sentAt: { + type: 'string', + format: 'date-time', + description: 'Date and time when the message was sent.' + } + }, + parameters: { + streetlightId: { + description: 'The ID of the streetlight.' + } + }, + messageTraits: { + commonHeaders: { + headers: { + type: 'object', + properties: { + 'my-app-header': { + type: 'integer', + minimum: 0, + maximum: 100 + } + } + } + } + }, + operationTraits: { + kafka: { + bindings: { + kafka: { + clientId: { + type: 'string', + enum: [ + 'my-app-id' + ] + } + } + } + } + } + } + }; + const { document } = await parser.parse(documentRaw); + + const v3Document = document as AsyncAPIDocumentV3; + expect(v3Document).toBeInstanceOf(AsyncAPIDocumentV3); + const expectedOperationId = 'receiveLightMeasurement'; + const expectedChannelId = 'smartylighting.streetlights.1.0.event.{streetlightId}.lighting.measured'; + const operations = v3Document.operations(); + expect(operations.length).toEqual(1); + const operation = operations[0]; + expect(operation.id()).toEqual(expectedOperationId); + const operationChannels = operation.channels().all(); + expect(operationChannels.length).toEqual(1); + const lightMeasuredChannel = operationChannels[0]; + expect(lightMeasuredChannel.json()[xParserObjectUniqueId]).toEqual(expectedChannelId); + const channelOperations = lightMeasuredChannel.operations().all(); + expect(channelOperations.length).toEqual(1); + const circularOperation = channelOperations[0]; + expect(circularOperation.id()).toEqual(expectedOperationId); + + const channels = v3Document.channels(); + expect(channels.length).toEqual(1); + const channel = channels[0]; + expect(channel.json()[xParserObjectUniqueId]).toEqual(expectedChannelId); + const channelOperations2 = channel.operations().all(); + expect(channelOperations2.length).toEqual(1); + const operation2 = channelOperations2[0]; + expect(operation2.id()).toEqual(expectedOperationId); + }); }); diff --git a/test/models/v3/server.spec.ts b/test/models/v3/server.spec.ts index 9059fac8c..2669bc8dd 100644 --- a/test/models/v3/server.spec.ts +++ b/test/models/v3/server.spec.ts @@ -13,6 +13,7 @@ import { SecurityRequirement } from '../../../src/models/v3/security-requirement import { serializeInput, assertCoreModel } from './utils'; import type { v3 } from '../../../src/spec-types'; +import { xParserObjectUniqueId } from '../../../src/constants'; const doc = { production: { @@ -157,6 +158,7 @@ describe('Server Model', function () { it('should return collection of operations - server available only in particular channel', function() { const doc = serializeInput({}); const channel = { servers: [doc] }; + channel[xParserObjectUniqueId] = 'unique-id'; const d = new Server(doc, { asyncapi: { parsed: { channels: { someChannel: channel }, operations: { someOperation1: { channel }, someOperation2: { channel: { servers: [{}] } } } } } as any, pointer: '', id: 'production' }); expect(d.operations()).toBeInstanceOf(Operations); expect(d.operations().all()).toHaveLength(1);