diff --git a/packages/rulesets/package.json b/packages/rulesets/package.json index 27fcc99c3..fd1819890 100644 --- a/packages/rulesets/package.json +++ b/packages/rulesets/package.json @@ -21,11 +21,11 @@ "release": "semantic-release -e semantic-release-monorepo" }, "dependencies": { - "@asyncapi/specs": "^2.14.0", + "@asyncapi/specs": "^3.2.0", "@stoplight/better-ajv-errors": "1.0.3", "@stoplight/json": "^3.17.0", "@stoplight/spectral-core": "^1.8.1", - "@stoplight/spectral-formats": "^1.2.0", + "@stoplight/spectral-formats": "^1.4.0", "@stoplight/spectral-functions": "^1.5.1", "@stoplight/spectral-runtime": "^1.1.1", "@stoplight/types": "^13.6.0", diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-payload.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-payload.test.ts index 534fcb877..d30f9c642 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-payload.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-payload.test.ts @@ -50,6 +50,14 @@ testRule('asyncapi-payload', [ errors: [], }, + { + name: 'valid case (2.5.0 version)', + document: produce(document, (draft: any) => { + draft.asyncapi = '2.5.0'; + }), + errors: [], + }, + { name: 'components.messages.{message}.payload is not valid against the AsyncApi2 schema object', document: produce(document, (draft: any) => { diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-tags-uniqueness.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-tags-uniqueness.test.ts index 2c2f77ea7..2052422c0 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-tags-uniqueness.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-tags-uniqueness.test.ts @@ -26,6 +26,28 @@ testRule('asyncapi-tags-uniqueness', [ ], }, + { + name: 'tags has duplicated names (server)', + document: { + asyncapi: '2.5.0', + servers: { + someServer: { + tags: [{ name: 'one' }, { name: 'one' }], + }, + anotherServer: { + tags: [{ name: 'one' }, { name: 'two' }], + }, + }, + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['servers', 'someServer', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { name: 'tags has duplicated names (operation)', document: { diff --git a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2DocumentSchema.test.ts b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2DocumentSchema.test.ts index 2d8a80c47..2f423a6e8 100644 --- a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2DocumentSchema.test.ts +++ b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2DocumentSchema.test.ts @@ -203,13 +203,6 @@ describe('asyncApi2DocumentSchema', () => { params: { type: 'string' }, message: 'must be string', }, - { - keyword: 'required', - instancePath: '/paths/test/post/parameters/0/schema', - schemaPath: '#/definitions/Reference/required', - params: { missingProperty: '$ref' }, - message: "must have required property '$ref'", - }, { keyword: 'oneOf', instancePath: '/paths/test/post/parameters/0/schema', @@ -325,15 +318,6 @@ describe('asyncApi2DocumentSchema', () => { }, schemaPath: '#/properties/type/type', }, - { - instancePath: '/paths/foo/post/parameters/0/schema', - keyword: 'required', - message: "must have required property '$ref'", - params: { - missingProperty: '$ref', - }, - schemaPath: '#/definitions/Reference/required', - }, { instancePath: '/paths/baz/post/parameters/0/schema', keyword: 'oneOf', diff --git a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2PayloadValidation.test.ts b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2PayloadValidation.test.ts index 9fb7fb059..b38b6b33e 100644 --- a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2PayloadValidation.test.ts +++ b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2PayloadValidation.test.ts @@ -1,7 +1,11 @@ +import { aas2_0 } from '@stoplight/spectral-formats'; import asyncApi2PayloadValidation from '../asyncApi2PayloadValidation'; function runPayloadValidation(targetVal: any) { - return asyncApi2PayloadValidation(targetVal, null, { path: ['components', 'messages', 'aMessage'] } as any); + return asyncApi2PayloadValidation(targetVal, null, { + path: ['components', 'messages', 'aMessage'], + document: { formats: new Set([aas2_0]) }, + } as any); } describe('asyncApi2PayloadValidation', () => { diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts index fe2085612..539527536 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts @@ -1,16 +1,12 @@ 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 } from '@stoplight/spectral-formats'; +import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5 } from '@stoplight/spectral-formats'; + +import { getCopyOfSchema } from './utils/specs'; import type { ErrorObject } from 'ajv'; import type { IFunctionResult, Format } from '@stoplight/spectral-core'; - -// import only 2.X.X AsyncAPI JSON Schemas for better treeshaking -import * as asyncAPI2_0_0Schema from '@asyncapi/specs/schemas/2.0.0.json'; -import * as asyncAPI2_1_0Schema from '@asyncapi/specs/schemas/2.1.0.json'; -import * as asyncAPI2_2_0Schema from '@asyncapi/specs/schemas/2.2.0.json'; -import * as asyncAPI2_3_0Schema from '@asyncapi/specs/schemas/2.3.0.json'; -import * as asyncAPI2_4_0Schema from '@asyncapi/specs/schemas/2.4.0.json'; +import type { AsyncAPISpecVersion } from './utils/specs'; export const asyncApiSpecVersions = ['2.0.0', '2.1.0', '2.2.0', '2.3.0', '2.4.0']; export const latestAsyncApiVersion = asyncApiSpecVersions[asyncApiSpecVersions.length - 1]; @@ -41,9 +37,14 @@ const ERROR_MAP = [ // That being said, we always strip both oneOf and $ref, since we are always interested in the first error. export function prepareResults(errors: ErrorObject[]): void { // Update additionalProperties errors to make them more precise and prevent them from being treated as duplicates - for (const error of errors) { + for (let i = 0; i < errors.length; i++) { + const error = errors[i]; + if (error.keyword === 'additionalProperties') { error.instancePath = `${error.instancePath}/${String(error.params['additionalProperty'])}`; + } else if (error.keyword === 'required' && error.params.missingProperty === '$ref') { + errors.splice(i, 1); + i--; } } @@ -75,18 +76,37 @@ function applyManualReplacements(errors: IFunctionResult[]): void { } } -function getSchema(formats: Set): Record | void { +const serializedSchemas = new Map>(); +function getSerializedSchema(version: AsyncAPISpecVersion): Record { + const schema = serializedSchemas.get(version); + 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 }; + // 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']; + + serializedSchemas.set(version, copied); + return copied; +} + +function getSchema(formats: Set): Record | void { switch (true) { - case formats.has(aas2_0): - return asyncAPI2_0_0Schema; - case formats.has(aas2_1): - return asyncAPI2_1_0Schema; - case formats.has(aas2_2): - return asyncAPI2_2_0Schema; - case formats.has(aas2_3): - return asyncAPI2_3_0Schema; + case formats.has(aas2_5): + return getSerializedSchema('2.5.0'); case formats.has(aas2_4): - return asyncAPI2_4_0Schema; + return getSerializedSchema('2.4.0'); + case formats.has(aas2_3): + return getSerializedSchema('2.3.0'); + case formats.has(aas2_2): + return getSerializedSchema('2.2.0'); + case formats.has(aas2_1): + return getSerializedSchema('2.1.0'); + case formats.has(aas2_0): + return getSerializedSchema('2.0.0'); default: return; } @@ -98,7 +118,7 @@ export default createRulesetFunction( options: null, }, function asyncApi2DocumentSchema(targetVal, _, context) { - const formats = context.document.formats; + const formats = context.document?.formats; if (formats === null || formats === void 0) return; const schema = getSchema(formats); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2PayloadValidation.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2PayloadValidation.ts index 501ccc79f..496b795d8 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApi2PayloadValidation.ts +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2PayloadValidation.ts @@ -1,33 +1,88 @@ import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import { createRulesetFunction } from '@stoplight/spectral-core'; +import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5 } from '@stoplight/spectral-formats'; import betterAjvErrors from '@stoplight/better-ajv-errors'; -// use latest AsyncAPI JSON Schema because there are no differences of Schema Object definitions between the 2.X.X. -import * as asyncApi2Schema from '@asyncapi/specs/schemas/2.3.0.json'; +import { getCopyOfSchema } from './utils/specs'; + +import type { ValidateFunction } from 'ajv'; +import type { Format } from '@stoplight/spectral-core'; +import type { AsyncAPISpecVersion } from './utils/specs'; const asyncApi2SchemaObject = { $ref: 'asyncapi2#/definitions/schema' }; const ajv = new Ajv({ allErrors: true, strict: false, + logger: false, }); - addFormats(ajv); -ajv.addSchema(asyncApi2Schema, 'asyncapi2'); +/** + * To validate the schema of the payload we just need a small portion of official AsyncAPI spec JSON Schema, the Schema Object in particular. The definition of Schema Object must be + * included in the returned JSON Schema. + */ +function preparePayloadSchema(version: AsyncAPISpecVersion): Record { + // 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 }; + // 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']; + + const payloadSchema = `http://asyncapi.com/definitions/${version}/schema.json`; + + return { + $ref: payloadSchema, + definitions: copied.definitions, + }; +} -const ajvValidationFn = ajv.compile(asyncApi2SchemaObject); +function getValidator(version: AsyncAPISpecVersion): ValidateFunction { + let validator = ajv.getSchema(version); + if (!validator) { + const schema = preparePayloadSchema(version); + + ajv.addSchema(schema, version); + validator = ajv.getSchema(version); + } + + return validator as ValidateFunction; +} + +function getSchemaValidator(formats: Set): ValidateFunction | void { + switch (true) { + case formats.has(aas2_5): + return getValidator('2.5.0'); + case formats.has(aas2_4): + return getValidator('2.4.0'); + case formats.has(aas2_3): + return getValidator('2.3.0'); + case formats.has(aas2_2): + return getValidator('2.2.0'); + case formats.has(aas2_1): + return getValidator('2.1.0'); + case formats.has(aas2_0): + return getValidator('2.0.0'); + default: + return; + } +} export default createRulesetFunction( { input: null, options: null, }, - function asyncApi2PayloadValidation(targetVal, _opts, context) { - ajvValidationFn(targetVal); + function asyncApi2PayloadValidation(targetVal, _, context) { + const formats = context.document?.formats; + if (formats === null || formats === void 0) return; + + const validator = getSchemaValidator(formats); + if (validator === void 0) return; - return betterAjvErrors(asyncApi2SchemaObject, ajvValidationFn.errors, { + validator(targetVal); + return betterAjvErrors(asyncApi2SchemaObject, validator.errors, { propertyPath: context.path, targetValue: targetVal, }).map(({ suggestion, error, path: errorPath }) => ({ diff --git a/packages/rulesets/src/asyncapi/functions/utils/specs.ts b/packages/rulesets/src/asyncapi/functions/utils/specs.ts new file mode 100644 index 000000000..7110f682e --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/utils/specs.ts @@ -0,0 +1,22 @@ +// import only 2.X.X AsyncAPI JSON Schemas for better treeshaking +import * as asyncAPI2_0_0Schema from '@asyncapi/specs/schemas/2.0.0.json'; +import * as asyncAPI2_1_0Schema from '@asyncapi/specs/schemas/2.1.0.json'; +import * as asyncAPI2_2_0Schema from '@asyncapi/specs/schemas/2.2.0.json'; +import * as asyncAPI2_3_0Schema from '@asyncapi/specs/schemas/2.3.0.json'; +import * as asyncAPI2_4_0Schema from '@asyncapi/specs/schemas/2.4.0.json'; +import * as asyncAPI2_5_0Schema from '@asyncapi/specs/schemas/2.5.0.json'; + +export type AsyncAPISpecVersion = keyof typeof specs; + +export const specs = { + '2.0.0': asyncAPI2_0_0Schema, + '2.1.0': asyncAPI2_1_0Schema, + '2.2.0': asyncAPI2_2_0Schema, + '2.3.0': asyncAPI2_3_0Schema, + '2.4.0': asyncAPI2_4_0Schema, + '2.5.0': asyncAPI2_5_0Schema, +}; + +export function getCopyOfSchema(version: AsyncAPISpecVersion): Record { + return JSON.parse(JSON.stringify(specs[version])) as Record; +} diff --git a/packages/rulesets/src/asyncapi/index.ts b/packages/rulesets/src/asyncapi/index.ts index d336ca297..bd9410e7d 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -1,4 +1,4 @@ -import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4 } from '@stoplight/spectral-formats'; +import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5 } from '@stoplight/spectral-formats'; import { truthy, pattern, @@ -22,7 +22,7 @@ import asyncApi2Security from './functions/asyncApi2Security'; export default { documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md', - formats: [aas2_0, aas2_1, aas2_2, aas2_3, aas2_4], + formats: [aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5], rules: { 'asyncapi-channel-no-empty-parameter': { description: 'Channel path must not have empty parameter substitution pattern.', @@ -497,6 +497,9 @@ export default { given: [ // root '$.tags', + // servers + '$.servers.*.tags', + '$.components.servers.*.tags', // operations '$.channels.*.[publish,subscribe].tags', '$.components.channels.*.[publish,subscribe].tags', diff --git a/yarn.lock b/yarn.lock index 698bfb9fd..a2e97ff90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27,10 +27,10 @@ __metadata: languageName: node linkType: hard -"@asyncapi/specs@npm:^2.14.0": - version: 2.14.0 - resolution: "@asyncapi/specs@npm:2.14.0" - checksum: 066c23c493df54c44c319433bdcf8482a3acd584e32c0073e6a9f5b167d61bde23a252621be2b28bbaf1466636f6cafaab570795de403f0c671358784d4b12ed +"@asyncapi/specs@npm:^3.2.0": + version: 3.2.0 + resolution: "@asyncapi/specs@npm:3.2.0" + checksum: 09971262aefc8844ab3e7c0c3652711862ac562dd5d614f23b496185690430a81df8e50eddba657f4141e0fd9548ef622fe6c20f4e3dec8054be23f774798335 languageName: node linkType: hard @@ -2592,7 +2592,7 @@ __metadata: languageName: unknown linkType: soft -"@stoplight/spectral-formats@*, @stoplight/spectral-formats@>=1, @stoplight/spectral-formats@^1.0.0, @stoplight/spectral-formats@^1.2.0, @stoplight/spectral-formats@workspace:packages/formats": +"@stoplight/spectral-formats@*, @stoplight/spectral-formats@>=1, @stoplight/spectral-formats@^1.0.0, @stoplight/spectral-formats@^1.4.0, @stoplight/spectral-formats@workspace:packages/formats": version: 0.0.0-use.local resolution: "@stoplight/spectral-formats@workspace:packages/formats" dependencies: @@ -2703,12 +2703,12 @@ __metadata: version: 0.0.0-use.local resolution: "@stoplight/spectral-rulesets@workspace:packages/rulesets" dependencies: - "@asyncapi/specs": ^2.14.0 + "@asyncapi/specs": ^3.2.0 "@stoplight/better-ajv-errors": 1.0.3 "@stoplight/json": ^3.17.0 "@stoplight/path": ^1.3.2 "@stoplight/spectral-core": ^1.8.1 - "@stoplight/spectral-formats": ^1.2.0 + "@stoplight/spectral-formats": ^1.4.0 "@stoplight/spectral-functions": ^1.5.1 "@stoplight/spectral-parsers": "*" "@stoplight/spectral-ref-resolver": "*"