diff --git a/src/ruleset/functions/documentStructure.ts b/src/ruleset/functions/documentStructure.ts index a00268d25..2b8db6e96 100644 --- a/src/ruleset/functions/documentStructure.ts +++ b/src/ruleset/functions/documentStructure.ts @@ -1,12 +1,17 @@ +/* eslint-disable sonarjs/no-duplicate-string */ + import specs from '@asyncapi/specs'; import { createRulesetFunction } from '@stoplight/spectral-core'; import { schema as schemaFn } from '@stoplight/spectral-functions'; +import { AsyncAPIFormats } from '../formats'; +import { getSemver } from '../../utils'; + import type { ErrorObject } from 'ajv'; import type { IFunctionResult, Format } from '@stoplight/spectral-core'; -import { AsyncAPIFormats } from '../formats'; type AsyncAPIVersions = keyof typeof specs.schemas; +type RawSchema = Record; function shouldIgnoreError(error: ErrorObject): boolean { return ( @@ -45,24 +50,52 @@ function prepareResults(errors: ErrorObject[]): void { } } -function getCopyOfSchema(version: AsyncAPIVersions): Record { - return JSON.parse(JSON.stringify(specs.schemas[version])) as Record; +// this is needed because some v3 object fields are expected to be only `$ref` to other objects. +// In order to validate resolved references, we modify those schemas and instead allow the definition of the object +function prepareV3ResolvedSchema(copied: any): any { + // channel object + const channelObject = copied.definitions['http://asyncapi.com/definitions/3.0.0/channel.json']; + channelObject.properties.servers.items.$ref = 'http://asyncapi.com/definitions/3.0.0/server.json'; + + // operation object + const operationSchema = copied.definitions['http://asyncapi.com/definitions/3.0.0/operation.json']; + operationSchema.properties.channel.$ref = 'http://asyncapi.com/definitions/3.0.0/channel.json'; + operationSchema.properties.messages.items.$ref = 'http://asyncapi.com/definitions/3.0.0/messageObject.json'; + + // operation reply object + const operationReplySchema = copied.definitions['http://asyncapi.com/definitions/3.0.0/operationReply.json']; + operationReplySchema.properties.channel.$ref = 'http://asyncapi.com/definitions/3.0.0/channel.json'; + operationReplySchema.properties.messages.items.$ref = 'http://asyncapi.com/definitions/3.0.0/messageObject.json'; + + return copied; +} + +function getCopyOfSchema(version: AsyncAPIVersions): RawSchema { + return JSON.parse(JSON.stringify(specs.schemas[version])) as RawSchema; } -const serializedSchemas = new Map>(); -function getSerializedSchema(version: AsyncAPIVersions): Record { - const schema = serializedSchemas.get(version); +const serializedSchemas = new Map(); +function getSerializedSchema(version: AsyncAPIVersions, resolved: boolean): RawSchema { + const serializedSchemaKey = resolved ? `${version}-resolved` : `${version}-unresolved`; + const schema = serializedSchemas.get(serializedSchemaKey as AsyncAPIVersions); if (schema) { return schema; } // Copy to not operate on the original json schema - between imports (in different modules) we operate on this same schema. - const copied = getCopyOfSchema(version) as { definitions: Record }; + let copied = getCopyOfSchema(version) as { '$id': string, definitions: RawSchema }; // Remove the meta schemas because they are already present within Ajv, and it's not possible to add duplicated schemas. delete copied.definitions['http://json-schema.org/draft-07/schema']; delete copied.definitions['http://json-schema.org/draft-04/schema']; + // Spectral caches the schemas using '$id' property + copied['$id'] = copied['$id'].replace('asyncapi.json', `asyncapi-${resolved ? 'resolved' : 'unresolved'}.json`); + + const { major } = getSemver(version); + if (resolved && major === 3) { + copied = prepareV3ResolvedSchema(copied); + } - serializedSchemas.set(version, copied); + serializedSchemas.set(serializedSchemaKey as AsyncAPIVersions, copied); return copied; } @@ -80,10 +113,10 @@ function filterRefErrors(errors: IFunctionResult[], resolved: boolean) { }); } -export function getSchema(docFormats: Set): Record | void { +export function getSchema(docFormats: Set, resolved: boolean): Record | void { for (const [version, format] of AsyncAPIFormats) { if (docFormats.has(format)) { - return getSerializedSchema(version as AsyncAPIVersions); + return getSerializedSchema(version as AsyncAPIVersions, resolved); } } } @@ -107,16 +140,17 @@ export const documentStructure = createRulesetFunction 0).toEqual(true); }); it('should return extras', async function() { diff --git a/test/ruleset/rules/asyncapi-document-resolved.spec.ts b/test/ruleset/rules/asyncapi-document-resolved.spec.ts index 7369c1d0a..b90062123 100644 --- a/test/ruleset/rules/asyncapi-document-resolved.spec.ts +++ b/test/ruleset/rules/asyncapi-document-resolved.spec.ts @@ -15,7 +15,7 @@ testRule('asyncapi-document-resolved', [ }, { - name: 'invalid case (channels property is missing)', + name: 'invalid case for 2.X.X (channels property is missing)', document: { asyncapi: '2.0.0', info: { @@ -32,7 +32,7 @@ testRule('asyncapi-document-resolved', [ }, { - name: 'valid case (case when other errors should also occur but we filter them out)', + name: 'valid case for 2.X.X (case validating $ref resolution works as expected)', document: { asyncapi: '2.0.0', info: { @@ -309,4 +309,89 @@ testRule('asyncapi-document-resolved', [ }, errors: [], }, + + { + name: 'valid case (3.0.0 version)', + document: { + asyncapi: '3.0.0', + info: { + title: 'Signup service example (internal)', + version: '0.1.0', + }, + channels: { + 'user/signedup': { + address: 'user/signedup', + messages: { + 'subscribe.message': { + payload: {} + } + } + } + }, + operations: { + 'user/signedup.subscribe': { + action: 'send', + channel: { + address: 'user/signedup', + messages: { + 'subscribe.message': { + payload: {} + } + } + }, + messages: [ + { + payload: {} + } + ] + } + }, + }, + errors: [], + }, + + { + name: 'invalid case for 3.X.X (info.version property is missing)', + document: { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + }, + }, + errors: [ + { + message: '"info" property must have required property "version"', + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'valid case for 3.X.X (case validating $ref resolution works as expected)', + document: { + asyncapi: '3.0.0', + info: { + title: 'Signup service example (internal)', + version: '0.1.0', + }, + channels: { + userSignedup: { + address: 'user/signedup', + messages: { + 'subscribe.message': { + $ref: '#/components/messages/testMessage' + } + } + } + }, + components: { + messages: { + testMessage: { + payload: {} + } + } + } + }, + errors: [], + }, ]); diff --git a/test/ruleset/rules/asyncapi-document-unresolved.spec.ts b/test/ruleset/rules/asyncapi-document-unresolved.spec.ts index 77876052b..91e1cdd1b 100644 --- a/test/ruleset/rules/asyncapi-document-unresolved.spec.ts +++ b/test/ruleset/rules/asyncapi-document-unresolved.spec.ts @@ -2,7 +2,7 @@ import { testRule, DiagnosticSeverity } from '../tester'; testRule('asyncapi-document-unresolved', [ { - name: 'valid case', + name: 'valid case for 2.X.X', document: { asyncapi: '2.0.0', info: { @@ -28,7 +28,7 @@ testRule('asyncapi-document-unresolved', [ }, { - name: 'invalid case (reference for operation object is not allowed)', + name: 'invalid case for 2.X.X (reference for operation object is not allowed)', document: { asyncapi: '2.0.0', info: { @@ -58,26 +58,49 @@ testRule('asyncapi-document-unresolved', [ }, { - name: 'invalid case (case when other errors should also occur but we filter them out - required info field is omitted)', + name: 'valid case for 3.X.X', document: { - asyncapi: '2.0.0', + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, channels: { - someChannel: { - publish: { - $ref: '#/components/x-operations/someOperation', - }, + userSignedup: { + address: 'user/signedup', + messages: { + 'subscribe.message': { + $ref: '#/components/messages/testMessage' + } + } + } + }, + components: { + messages: { + someMessage: {}, }, }, + }, + errors: [], + }, + + { + name: 'invalid case for 3.X.X (reference for info object is not allowed)', + document: { + asyncapi: '3.0.0', + info: { + $ref: '#/components/x-titles/someTitle' + }, components: { - 'x-operations': { - someOperation: {}, + 'x-titles': { + someTitle: 'some-title', }, }, }, errors: [ { message: 'Referencing in this place is not allowed', - path: ['channels', 'someChannel', 'publish'], + path: ['info'], severity: DiagnosticSeverity.Error, }, ],