diff --git a/package-lock.json b/package-lock.json index c0854cec1..191bf1b26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.0.0", "license": "Apache-2.0", "dependencies": { + "@asyncapi/specs": "^3.1.0", "@stoplight/spectral-core": "^1.10.1", "@stoplight/spectral-functions": "^1.5.1", "@stoplight/spectral-parsers": "^1.0.1", @@ -54,9 +55,9 @@ } }, "node_modules/@asyncapi/specs": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-2.13.1.tgz", - "integrity": "sha512-Hl44ml5/yDtBnOlk0A7RWl+Xy8JcWRni/2QVT1tkmQmwg6ylW+nfIXn2Zzi9Hww+oCkgyrqMXe45rlcJVhKlDQ==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-3.1.0.tgz", + "integrity": "sha512-6xFvzDd54+M9g6EM259Y4a4iiFb2VzPr6eoxA/ttwTu7NRxaGScocXskXtuz53ZWx9BWZWuzwDYKfM3KBkDfiQ==" }, "node_modules/@babel/code-frame": { "version": "7.16.7", @@ -2222,6 +2223,11 @@ "node": ">=12" } }, + "node_modules/@stoplight/spectral-rulesets/node_modules/@asyncapi/specs": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-2.14.0.tgz", + "integrity": "sha512-hHsYF6XsYNIKb1P2rXaooF4H+uKKQ4b/Ljxrk3rZ3riEDiSxMshMEfb1fUlw9Yj4V4OmJhjXwkNvw8W59AXv1A==" + }, "node_modules/@stoplight/spectral-rulesets/node_modules/@stoplight/better-ajv-errors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stoplight/better-ajv-errors/-/better-ajv-errors-1.0.1.tgz", @@ -15126,9 +15132,9 @@ } }, "@asyncapi/specs": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-2.13.1.tgz", - "integrity": "sha512-Hl44ml5/yDtBnOlk0A7RWl+Xy8JcWRni/2QVT1tkmQmwg6ylW+nfIXn2Zzi9Hww+oCkgyrqMXe45rlcJVhKlDQ==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-3.1.0.tgz", + "integrity": "sha512-6xFvzDd54+M9g6EM259Y4a4iiFb2VzPr6eoxA/ttwTu7NRxaGScocXskXtuz53ZWx9BWZWuzwDYKfM3KBkDfiQ==" }, "@babel/code-frame": { "version": "7.16.7", @@ -16799,6 +16805,11 @@ "tslib": "^2.3.0" }, "dependencies": { + "@asyncapi/specs": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-2.14.0.tgz", + "integrity": "sha512-hHsYF6XsYNIKb1P2rXaooF4H+uKKQ4b/Ljxrk3rZ3riEDiSxMshMEfb1fUlw9Yj4V4OmJhjXwkNvw8W59AXv1A==" + }, "@stoplight/better-ajv-errors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stoplight/better-ajv-errors/-/better-ajv-errors-1.0.1.tgz", diff --git a/package.json b/package.json index b017fb76c..797baf8f1 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "typescript": "^4.6.2" }, "dependencies": { + "@asyncapi/specs": "^3.1.0", "@stoplight/spectral-core": "^1.10.1", "@stoplight/spectral-functions": "^1.5.1", "@stoplight/spectral-parsers": "^1.0.1", diff --git a/src/schema-parser/asyncapi-schema-parser.ts b/src/schema-parser/asyncapi-schema-parser.ts index 2d38f32b8..17b6c75f9 100644 --- a/src/schema-parser/asyncapi-schema-parser.ts +++ b/src/schema-parser/asyncapi-schema-parser.ts @@ -1,11 +1,15 @@ import { SchemaParser, ParseSchemaInput, ValidateSchemaInput } from "../schema-parser"; -import Ajv from "ajv"; -import { JSONSchema7 } from "json-schema" +import Ajv, { ErrorObject, ValidateFunction } from "ajv"; import type { AsyncAPISchema, SchemaValidateResult } from '../types'; +// @ts-ignore +import specs from '@asyncapi/specs'; const ajv = new Ajv({ allErrors: true, -}) + strict: false, +}); + +const specVersions = Object.keys(specs).filter((version: string) => !['1.0.0', '1.1.0', '1.2.0', '2.0.0-rc1', '2.0.0-rc2'].includes(version)); export function AsyncAPISchemaParser(): SchemaParser { return { @@ -16,62 +20,72 @@ export function AsyncAPISchemaParser(): SchemaParser { } async function validate(input: ValidateSchemaInput): Promise { - const schema = input.data as JSONSchema7; - let errors: SchemaValidateResult[] = []; - - try { - ajv.compile(schema); - } catch (error: any) { - if (error! instanceof Error) { - errors = ajvToSpectralErrors(error); - } else { - // Unknown and unexpected error - throw error; - } + const version = input.asyncapi.semver.version + const validator = findSchemaValidator(version); + + let result: SchemaValidateResult[] = [] + const valid = validator(input.data); + if (!valid && validator.errors) { + result = ajvToSpectralResult(validator.errors, input.path); } - return errors; + return result; } -function ajvToSpectralErrors(error: Error): SchemaValidateResult[] { - let errors: SchemaValidateResult[] = []; - let errorMessage = error.message; - - // Validation errors. - // See related AJV function where the error message is generated: - // https://github.com/ajv-validator/ajv/blob/99e884dc4bbb828cf47771b7bbdb14f23193b0b1/lib/core.ts#L501-L522 - const validationErrorPrefix = "schema is invalid: "; - if (error.message.startsWith(validationErrorPrefix)) { - // remove prefix - errorMessage = errorMessage.substring(validationErrorPrefix.length); - - // message can contain multiple validation errors separated by ',' (comma) - errorMessage.split(", ").forEach((message: string) => { - const splitIndex = message.indexOf(" "); - const path = message.slice(0, splitIndex); - const error = message.slice(splitIndex + 1); - - const resultErr: SchemaValidateResult = { - message: error, - path: path.split("/") - }; - - errors.push(resultErr); - }); - } else { - // Not a validation error - const resultErr: SchemaValidateResult = { +function ajvToSpectralResult(errors: ErrorObject[], parentPath: Array): SchemaValidateResult[] { + if (parentPath === undefined) { + parentPath = []; + } + + return errors.map(error => { + const errorPath = error.instancePath.replace(/^\//, '').split('/'); // TODO: Instance Path or Schema Path? + + return { message: error.message, - }; + path: parentPath.concat(errorPath), + } as SchemaValidateResult; + }); +} - errors.push(resultErr); +function findSchemaValidator(version: string): ValidateFunction { + let validator = ajv.getSchema(version); + if (!validator) { + const schema = preparePayloadSchema2(specs[version], version); + + ajv.addSchema(schema, version); + validator = ajv.getSchema(version); } - return errors; + return validator as ValidateFunction; } async function parse(input: ParseSchemaInput): Promise { - return input.data as JSONSchema7; + return input.data as AsyncAPISchema; +} + +/** + * To validate schema of the payload we just need a small portion of official AsyncAPI spec JSON Schema, the definition of the schema must be + * a main part of the JSON Schema + * + * @private + * @param {Object} asyncapiSchema AsyncAPI specification JSON Schema + * @param {Object} version AsyncAPI version. + * @returns {Object} valid JSON Schema document describing format of AsyncAPI-valid schema for message payload + */ +function preparePayloadSchema2(asyncapiSchema: AsyncAPISchema, version: string) { + const payloadSchema = `http://asyncapi.com/definitions/${version}/schema.json`; + const definitions = asyncapiSchema.definitions; + if (definitions === undefined) { + throw new Error("AsyncAPI schema must contain definitions"); + } + + // Remove the meta schemas because it is already present within Ajv, and it's not possible to add duplicate schemas. + delete definitions['http://json-schema.org/draft-07/schema']; + delete definitions['http://json-schema.org/draft-04/schema']; + return { + $ref: payloadSchema, + definitions + }; } function getMimeTypes() { @@ -80,7 +94,8 @@ function getMimeTypes() { 'application/schema+json;version=draft-07', 'application/schema+yaml;version=draft-07', ]; - ['2.0.0', '2.1.0', '2.2.0', '2.3.0'].forEach(version => { + + specVersions.forEach((version: string) => { mimeTypes.push( `application/vnd.aai.asyncapi;version=${version}`, `application/vnd.aai.asyncapi+json;version=${version}`, diff --git a/test/schema-parser/asyncapi-schema-parser.spec.ts b/test/schema-parser/asyncapi-schema-parser.spec.ts index 65746f048..e4ad16dcd 100644 --- a/test/schema-parser/asyncapi-schema-parser.spec.ts +++ b/test/schema-parser/asyncapi-schema-parser.spec.ts @@ -7,7 +7,7 @@ describe('AsyncAPISchemaParser', function () { const validSchema = { asyncapi: { semver: { - major: 2 + version: "2.4.0", } }, data: { @@ -28,6 +28,10 @@ describe('AsyncAPISchemaParser', function () { const parser = AsyncAPISchemaParser(); + it('should return Mime Types', async function () { + expect(parser.getMimeTypes()).not.toEqual([]); + }); + it('should parse valid AsyncAPI Schema', async function () { const schema = >validSchema; const parsed = await parser.parse(schema); @@ -45,9 +49,10 @@ describe('AsyncAPISchemaParser', function () { const schema = >{ asyncapi: { semver: { - major: 2 + version: "2.4.0", } }, + path: ["components", "schemas", "schema1", "payload"], data: { oneOf: "this should be an array", properties: { @@ -60,28 +65,8 @@ describe('AsyncAPISchemaParser', function () { const result = await parser.validate(schema); const expectedResult: SchemaValidateResult[] = [ - { "message": "must be object,boolean", "path": ["data", "properties", "name", "if"] }, - { "message": "must be array", "path": ["data", "oneOf"] } - ]; - - expect(result).toEqual(expectedResult); - }); - - it('should validate invalid AsyncAPI Schema with invalid meta schema', async function () { - const schema = >{ - asyncapi: { - semver: { - major: 2 - } - }, - data: { - $schema: "non-existent-meta-schema", - } - }; - - const result = await parser.validate(schema); - const expectedResult: SchemaValidateResult[] = [ - { "message": "no schema with key or ref \"non-existent-meta-schema\"" }, + { "message": "must be object,boolean", "path": ["components", "schemas", "schema1", "payload", "properties", "name", "if"] }, + { "message": "must be array", "path": ["components", "schemas", "schema1", "payload", "oneOf"] } ]; expect(result).toEqual(expectedResult);