From 6f55c0b4fdd49ebafc189d0e668226ec09d4f517 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Tue, 24 Sep 2024 11:38:52 +0200 Subject: [PATCH] fix(rulesets): add validation ruleset for v3 --- docs/reference/asyncapi-rules.md | 24 ++- .../asyncapi-3-document-resolved.test.ts | 157 ++++++++++++++++++ .../asyncapi-3-document-unresolved.test.ts | 89 ++++++++++ .../__tests__/asyncapi-schema.test.ts | 18 -- .../functions/asyncApiDocumentSchema.ts | 147 +++++++++------- packages/rulesets/src/asyncapi/index.ts | 36 +++- 6 files changed, 388 insertions(+), 83 deletions(-) create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-resolved.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-unresolved.test.ts diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md index 88f6bfbce..d32be7b36 100644 --- a/docs/reference/asyncapi-rules.md +++ b/docs/reference/asyncapi-rules.md @@ -124,12 +124,6 @@ Parameter objects should have a `description`. **Recommended:** No -### asyncapi-schema - -Validate structure of AsyncAPI specification. - -**Recommended:** Yes - ### asyncapi-servers A non-empty `servers` object is expected to be located at the root of the document. @@ -164,6 +158,12 @@ specifications that reference those objects). The following rules ONLY apply to AsyncAPI v2 documents. +### asyncapi-schema + +Validate structure of AsyncAPI specification. + +**Recommended:** Yes + ### asyncapi-server-security Server `security` values must match a scheme defined in the `components.securitySchemes` object. It also checks if there are `oauth2` scopes that have been defined for the given security. @@ -862,3 +862,15 @@ invoicedItems: Defining tags allows you to add more information like a `description`. For more information see [asyncapi-3-tag-description](#asyncapi-3-tag-description). **Recommended:** Yes + +### asyncapi-3-document-resolved + +Validate structure of AsyncAPI specification when references have been resolved. + +**Recommended:** Yes + +### asyncapi-3-document-unresolved + +Validate structure of AsyncAPI specification before references have been resolved. + +**Recommended:** Yes diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-resolved.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-resolved.test.ts new file mode 100644 index 000000000..5d5a73734 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-resolved.test.ts @@ -0,0 +1,157 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-document-resolved', [ + { + name: 'valid case AsyncAPI 3', + document: { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + }, + errors: [], + }, + { + name: 'valid case resolved case message', + document: { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + SomeChannel: { + address: 'users/{userId}/signedUp', + messages: { + SomeMessage: { $ref: '#/components/messages/SomeMessage' }, + }, + }, + }, + components: { + messages: { + SomeMessage: { payload: { type: 'string' } }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid AsyncAPI 3 info property is missing', + document: { + asyncapi: '3.0.0', + }, + errors: [{ message: 'Object must have required property "info"', severity: DiagnosticSeverity.Error }], + }, + { + name: 'invalid AsyncAPI 3 resolved case message', + document: { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + SomeChannel: { + address: 'users/{userId}/signedUp', + messages: { + SomeMessage: { $ref: '#/x-SomeMessage' }, + }, + }, + }, + 'x-SomeMessage': { test: 'test' }, + }, + errors: [ + { + message: 'Property "test" is not expected to be here', + severity: DiagnosticSeverity.Error, + path: ['channels', 'SomeChannel', 'messages', 'SomeMessage', 'test'], + }, + ], + }, + { + 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.0.0 (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/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-unresolved.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-unresolved.test.ts new file mode 100644 index 000000000..293f282d6 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-unresolved.test.ts @@ -0,0 +1,89 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-document-unresolved', [ + { + name: 'valid case AsyncAPI 3', + document: { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + }, + errors: [], + }, + { + name: 'valid case unresolved case message', + document: { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + SomeChannel: { + address: 'users/{userId}/signedUp', + messages: { + SomeMessage: { $ref: '#/components/messages/SomeMessage' }, + }, + }, + }, + components: { + messages: { + SomeMessage: { payload: { type: 'string' } }, + }, + }, + }, + errors: [], + }, + { + name: 'valid AsyncAPI 3 unresolved case operations', + document: { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + SomeChannel: { + address: 'users/{userId}/signedUp', + messages: { + SomeMessage: { $ref: '#/x-SomeMessage' }, + }, + }, + }, + operations: { + SomeOperation: { + action: 'send', + channel: { + $ref: '#/channels/SomeChannel', + }, + messages: [{ $ref: '#/channels/SomeChannel' }], + }, + }, + }, + errors: [], + }, + { + name: 'invalid case for 3.0.0 (reference for info object is not allowed)', + document: { + asyncapi: '3.0.0', + info: { + $ref: '#/components/x-titles/someTitle', + }, + components: { + 'x-titles': { + someTitle: 'some-title', + }, + }, + }, + errors: [ + { + message: 'Referencing in this place is not allowed', + path: ['info'], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-schema.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-schema.test.ts index 0c7ee8f8d..35f04b96a 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-schema.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-schema.test.ts @@ -14,17 +14,6 @@ testRule('asyncapi-schema', [ }, errors: [], }, - { - name: 'valid case AsyncAPI 3', - document: { - asyncapi: '3.0.0', - info: { - title: 'Valid AsyncApi document', - version: '1.0', - }, - }, - errors: [], - }, { name: 'invalid AsyncAPI 2 channels property is missing', document: { @@ -36,11 +25,4 @@ testRule('asyncapi-schema', [ }, errors: [{ message: 'Object must have required property "channels"', severity: DiagnosticSeverity.Error }], }, - { - name: 'invalid AsyncAPI 3 info property is missing', - document: { - asyncapi: '3.0.0', - }, - errors: [{ message: 'Object must have required property "info"', severity: DiagnosticSeverity.Error }], - }, ]); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApiDocumentSchema.ts b/packages/rulesets/src/asyncapi/functions/asyncApiDocumentSchema.ts index e32f9b96a..2f64cbc6a 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApiDocumentSchema.ts +++ b/packages/rulesets/src/asyncapi/functions/asyncApiDocumentSchema.ts @@ -1,12 +1,16 @@ -import { createRulesetFunction } from '@stoplight/spectral-core'; -import { schema as schemaFn } from '@stoplight/spectral-functions'; -import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6, aas3_0 } from '@stoplight/spectral-formats'; +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import specs from '@asyncapi/specs'; +import { createRulesetFunction, IFunctionResult, Format } from '@stoplight/spectral-core'; +import { schema as schemaFn } from '@stoplight/spectral-functions'; +import type { ErrorObject } from 'ajv'; import { getCopyOfSchema } from './utils/specs'; +import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6, aas3_0 } from '@stoplight/spectral-formats'; -import type { ErrorObject } from 'ajv'; -import type { IFunctionResult, Format } from '@stoplight/spectral-core'; -import type { AsyncAPISpecVersion } from './utils/specs'; +type AsyncAPIVersions = keyof typeof specs.schemas; +type RawSchema = Record; function shouldIgnoreError(error: ErrorObject): boolean { return ( @@ -17,21 +21,8 @@ function shouldIgnoreError(error: ErrorObject): boolean { ); } -// this is supposed to cover edge cases we need to cover manually, when it's impossible to detect the most appropriate error, i.e. oneOf consisting of more than 3 members, etc. -// note, more errors can be included if certain messages reported by AJV are not quite meaningful -const ERROR_MAP = [ - { - path: /^components\/securitySchemes\/[^/]+$/, - message: 'Invalid security scheme', - }, -]; - -// The function removes irrelevant (aka misleading, confusing, useless, whatever you call it) errors. -// There are a few exceptions, i.e. security components I covered manually, -// yet apart from them we usually deal with a relatively simple scenario that can be literally expressed as: "either proper value of $ref property". -// The $ref part is never going to be interesting for us, because both aas-schema rules operate on resolved content, so we won't have any $refs left. -// As you can see, what we deal here wit is actually not really oneOf anymore - it's always the first member of oneOf we match against. -// That being said, we always strip both oneOf and $ref, since we are always interested in the first error. +// ajv throws a lot of errors that have no understandable context, e.g. errors related to the fact that a value doesn't meet the conditions of some sub-schema in `oneOf`, `anyOf` etc. +// for this reason, we filter these unnecessary errors and leave only the most important ones (usually the first occurring in the list of errors). export function prepareResults(errors: ErrorObject[]): void { // Update additionalProperties errors to make them more precise and prevent them from being treated as duplicates for (let i = 0; i < errors.length; i++) { @@ -58,79 +49,121 @@ export function prepareResults(errors: ErrorObject[]): void { } } -function applyManualReplacements(errors: IFunctionResult[]): void { - for (const error of errors) { - if (error.path === void 0) continue; +// 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'; - const joinedPath = error.path.join('/'); + // 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'; - for (const mappedError of ERROR_MAP) { - if (mappedError.path.test(joinedPath)) { - error.message = mappedError.message; - break; - } - } - } + // 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; } -const serializedSchemas = new Map>(); -function getSerializedSchema(version: AsyncAPISpecVersion): 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`); - serializedSchemas.set(version, copied); + if (resolved && version === '3.0.0') { + copied = prepareV3ResolvedSchema(copied); + } + + serializedSchemas.set(serializedSchemaKey as AsyncAPIVersions, copied); return copied; } -function getSchema(formats: Set): Record | void { +const refErrorMessage = 'Property "$ref" is not expected to be here'; +function filterRefErrors(errors: IFunctionResult[], resolved: boolean) { + if (resolved) { + return errors.filter(err => err.message !== refErrorMessage); + } + + return errors + .filter(err => err.message === refErrorMessage) + .map(err => { + err.message = 'Referencing in this place is not allowed'; + return err; + }); +} + +export function getSchema(formats: Set, resolved: boolean): Record | void { switch (true) { case formats.has(aas3_0): - return getSerializedSchema('3.0.0'); + return getSerializedSchema('3.0.0', resolved); case formats.has(aas2_6): - return getSerializedSchema('2.6.0'); + return getSerializedSchema('2.6.0', resolved); case formats.has(aas2_5): - return getSerializedSchema('2.5.0'); + return getSerializedSchema('2.5.0', resolved); case formats.has(aas2_4): - return getSerializedSchema('2.4.0'); + return getSerializedSchema('2.4.0', resolved); case formats.has(aas2_3): - return getSerializedSchema('2.3.0'); + return getSerializedSchema('2.3.0', resolved); case formats.has(aas2_2): - return getSerializedSchema('2.2.0'); + return getSerializedSchema('2.2.0', resolved); case formats.has(aas2_1): - return getSerializedSchema('2.1.0'); + return getSerializedSchema('2.1.0', resolved); case formats.has(aas2_0): - return getSerializedSchema('2.0.0'); + return getSerializedSchema('2.0.0', resolved); default: return; } } -export default createRulesetFunction( +export const asyncApiDocumentSchema = createRulesetFunction( { input: null, - options: null, + options: { + type: 'object', + properties: { + resolved: { + type: 'boolean', + }, + }, + required: ['resolved'], + }, }, - function asyncApiDocumentSchema(targetVal, _, context) { + (targetVal, options, context) => { const formats = context.document?.formats; - if (formats === null || formats === void 0) return; - - const schema = getSchema(formats); - if (schema === void 0) return; + if (!formats) { + return; + } - const errors = schemaFn(targetVal, { allErrors: true, schema, prepareResults }, context); + const resolved = options.resolved; + const schema = getSchema(formats, resolved); + if (!schema) { + return; + } - if (Array.isArray(errors)) { - applyManualReplacements(errors); + const errors = schemaFn( + targetVal, + { allErrors: true, schema, prepareResults: resolved ? prepareResults : undefined }, + context, + ); + if (!Array.isArray(errors)) { + return; } - return errors; + return filterRefErrors(errors, resolved); }, ); diff --git a/packages/rulesets/src/asyncapi/index.ts b/packages/rulesets/src/asyncapi/index.ts index 192a61773..da9abe6cd 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -21,7 +21,7 @@ import { import asyncApiChannelParameters from './functions/asyncApiChannelParameters'; import asyncApi2ChannelServers from './functions/asyncApi2ChannelServers'; -import asyncApiDocumentSchema from './functions/asyncApiDocumentSchema'; +import { asyncApiDocumentSchema } from './functions/asyncApiDocumentSchema'; import asyncApi2MessageExamplesValidation from './functions/asyncApi2MessageExamplesValidation'; import asyncApi2MessageIdUniqueness from './functions/asyncApi2MessageIdUniqueness'; import asyncApi2OperationIdUniqueness from './functions/asyncApi2OperationIdUniqueness'; @@ -511,10 +511,13 @@ export default { message: '{{error}}', severity: 'error', recommended: true, - formats: [aas2, aas3], + formats: [aas2], given: '$', then: { function: asyncApiDocumentSchema, + functionOptions: { + resolved: true, + }, }, }, 'asyncapi-server-variables': { @@ -793,5 +796,34 @@ export default { }, }, }, + 'asyncapi-3-document-resolved': { + description: 'Checking if the AsyncAPI v3 document has valid structure after resolving references.', + message: '{{error}}', + severity: 'error', + recommended: true, + given: '$', + formats: [aas3], + then: { + function: asyncApiDocumentSchema, + functionOptions: { + resolved: true, + }, + }, + }, + 'asyncapi-3-document-unresolved': { + description: 'Checking if the AsyncAPI v3 document has valid structure before resolving references.', + message: '{{error}}', + severity: 'error', + recommended: true, + resolved: false, + given: '$', + formats: [aas3], + then: { + function: asyncApiDocumentSchema, + functionOptions: { + resolved: false, + }, + }, + }, }, };