diff --git a/lib/models/asyncapi.js b/lib/models/asyncapi.js index 38b1802df..0a1aa161e 100644 --- a/lib/models/asyncapi.js +++ b/lib/models/asyncapi.js @@ -18,10 +18,11 @@ class AsyncAPIDocument extends Base { super(...args); assignNameToAnonymousMessages(this); - assignIdToAnonymousSchemas(this); - assignNameToComponentMessages(this); + assignUidToComponentSchemas(this); + assignUidToParameterSchemas(this); + assignIdToAnonymousSchemas(this); } /** @@ -37,7 +38,7 @@ class AsyncAPIDocument extends Base { info() { return new Info(this._json.info); } - + /** * @returns {string} */ @@ -51,7 +52,7 @@ class AsyncAPIDocument extends Base { hasServers() { return !!this._json.servers; } - + /** * @returns {Object} */ @@ -73,14 +74,14 @@ class AsyncAPIDocument extends Base { hasChannels() { return !!this._json.channels; } - + /** * @returns {Object} */ channels() { return createMapOfType(this._json.channels, Channel, this); } - + /** * @returns {string[]} */ @@ -139,7 +140,6 @@ class AsyncAPIDocument extends Base { */ allMessages() { const messages = new Map(); - if (this.hasChannels()) { this.channelNames().forEach(channelName => { const channel = this.channel(channelName); @@ -155,13 +155,11 @@ class AsyncAPIDocument extends Base { } }); } - if (this.hasComponents()) { Object.values(this.components().messages()).forEach(m => { - messages.set(m.uid(), m); + messages.set(m.uid(), m); }); } - return messages; } @@ -170,7 +168,6 @@ class AsyncAPIDocument extends Base { */ allSchemas() { const schemas = new Map(); - if (this.hasChannels()) { this.channelNames().forEach(channelName => { const channel = this.channel(channelName); @@ -205,29 +202,48 @@ class AsyncAPIDocument extends Base { } }); } - if (this.hasComponents()) { Object.values(this.components().schemas()).forEach(s => { - schemas.set(s.uid(), s); + schemas.set(s.uid(), s); }); } - return schemas; } } -function assignNameToComponentMessages(doc){ + +function assignNameToComponentMessages(doc) { if (doc.hasComponents()) { - for(const [key, m] of Object.entries(doc.components().messages())){ + for (const [key, m] of Object.entries(doc.components().messages())) { if (m.name() === undefined) { m.json()['x-parser-message-name'] = key; } } } } -function assignUidToComponentSchemas(doc){ + +/** + * Assign parameter keys as uid for the parameter schema. + * + * @param {AsyncAPIDocument} doc + */ +function assignUidToParameterSchemas(doc) { + doc.channelNames().forEach(channelName => { + const channel = doc.channel(channelName); + for (const [parameterKey, parameterSchema] of Object.entries(channel.parameters())) { + parameterSchema.json()['x-parser-schema-id'] = parameterKey; + } + }); +} + +/** + * Assign uid to component schemas. + * + * @param {AsyncAPIDocument} doc + */ +function assignUidToComponentSchemas(doc) { if (doc.hasComponents()) { - for(const [key, s] of Object.entries(doc.components().schemas())){ + for (const [key, s] of Object.entries(doc.components().schemas())) { s.json()['x-parser-schema-id'] = key; } } @@ -256,43 +272,96 @@ function assignNameToAnonymousMessages(doc) { } } -function assignIdToAnonymousSchemas(doc) { - let anonymousSchemaCounter = 0; +/** + * Recursively go through each schema and execute callback. + * + * @param {Schema} schema found. + * @param {Function} callback(schema) + * the function that is called foreach schema found. + * schema {Schema}: the found schema. + */ +function recursiveSchema(schema, callback) { + if (schema === null) return; + callback(schema); + if (schema.type() !== null) { + switch (schema.type()) { + case 'object': + const props = schema.properties(); + for (const [, propertySchema] of Object.entries(props)) { + recursiveSchema(propertySchema, callback); + } + break; + case 'array': + if (Array.isArray(schema.items())) { + schema.items().forEach(arraySchema => { + recursiveSchema(arraySchema, callback); + }); + } else { + recursiveSchema(schema.items(), callback); + } + break; + } + } else { + //check for allOf, oneOf, anyOf + const checkCombiningSchemas = (combineArray) => { + if (combineArray !== null && combineArray.length > 0) { + combineArray.forEach(combineSchema => { + recursiveSchema(combineSchema, callback);; + }); + } + } + checkCombiningSchemas(schema.allOf()); + checkCombiningSchemas(schema.anyOf()); + checkCombiningSchemas(schema.oneOf()); + } +} +/** + * Go through each channel and for each parameter, and message payload and headers recursively call the callback for each schema. + * + * @param {AsyncAPIDocument} doc + * @param {Function} callback(schema) + * the function that is called foreach schema found. + * schema {Schema}: the found schema. + */ +function schemaDocument(doc, callback) { if (doc.hasChannels()) { doc.channelNames().forEach(channelName => { const channel = doc.channel(channelName); Object.values(channel.parameters()).forEach(p => { - if (p.schema() && !p.schema().$id()) { - p.schema().json()['x-parser-schema-id'] = ``; - } + recursiveSchema(p.schema(), callback); }); if (channel.hasPublish()) { channel.publish().messages().forEach(m => { - if (m.headers() && !m.headers().$id()) { - m.headers().json()['x-parser-schema-id'] = ``; - } - - if (m.payload() && !m.payload().$id()) { - m.payload().json()['x-parser-schema-id'] = ``; - } + recursiveSchema(m.headers(), callback); + recursiveSchema(m.payload(), callback); }); } if (channel.hasSubscribe()) { channel.subscribe().messages().forEach(m => { - if (m.headers() && !m.headers().$id()) { - m.headers().json()['x-parser-schema-id'] = ``; - } - - if (m.payload() && !m.payload().$id()) { - m.payload().json()['x-parser-schema-id'] = ``; - } + recursiveSchema(m.headers(), callback); + recursiveSchema(m.payload(), callback); }); } }); } } +/** + * Gives schemas id to all anonymous schemas. + * + * @param {AsyncAPIDocument} doc + */ +function assignIdToAnonymousSchemas(doc) { + let anonymousSchemaCounter = 0; + const callback = (schema) => { + if (!schema.uid()) { + schema.json()['x-parser-schema-id'] = ``; + } + }; + schemaDocument(doc, callback); +} + module.exports = addExtensions(AsyncAPIDocument); diff --git a/test/models/asyncapi_test.js b/test/models/asyncapi_test.js index ff3485615..ddcd553ce 100644 --- a/test/models/asyncapi_test.js +++ b/test/models/asyncapi_test.js @@ -2,6 +2,14 @@ const { expect } = require('chai'); const AsyncAPIDocument = require('../../lib/models/asyncapi'); describe('AsyncAPIDocument', () => { + describe('assignUidToParameterSchemas()', () => { + it('should assign uids to parameters', () => { + const inputDoc = { "channels": { "smartylighting/{streetlightId}": { "parameters": { "streetlightId": { "schema": { "type": "string" } } } } } }; + const expectedDoc = { "channels": { "smartylighting/{streetlightId}": { "parameters": { "streetlightId": { "schema": { "type": "string", "x-parser-schema-id": "" }, "x-parser-schema-id": "streetlightId" } } } } } + const d = new AsyncAPIDocument(inputDoc); + expect(d.json()).to.be.deep.equal(expectedDoc); + }); + }); describe('#ext()', () => { it('should support extensions', () => { const doc = { 'x-test': 'testing' }; diff --git a/test/parse_test.js b/test/parse_test.js index 344bd3bac..11889041a 100644 --- a/test/parse_test.js +++ b/test/parse_test.js @@ -10,30 +10,30 @@ const expect = chai.expect; const invalidYAML = fs.readFileSync(path.resolve(__dirname, "./malformed-asyncapi.yaml"), 'utf8'); const inputYAML = fs.readFileSync(path.resolve(__dirname, "./asyncapi.yaml"), 'utf8'); -const outputJSON = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"externalDocs":{"x-extension":true,"url":"https://company.com/docs"},"message":{"payload":{"type":"object","properties":{"name":{"type":"string"},"test":{"type":"object","properties":{"testing":{"type":"string"}}}},"x-parser-schema-id":"testSchema"},"x-some-extension":"some extension","x-parser-original-traits":[{"x-some-extension":"some extension"}],"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-name":"testMessage"},"x-parser-original-traits":[{"externalDocs":{"url":"https://company.com/docs"}}]}}},"components":{"messages":{"testMessage":{"payload":{"type":"object","properties":{"name":{"type":"string"},"test":{"type":"object","properties":{"testing":{"type":"string"}}}},"x-parser-schema-id":"testSchema"},"x-some-extension":"some extension","x-parser-original-traits":[{"x-some-extension":"some extension"}],"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-name":"testMessage"}},"schemas":{"testSchema":{"type":"object","properties":{"name":{"type":"string"},"test":{"type":"object","properties":{"testing":{"type":"string"}}}},"x-parser-schema-id":"testSchema"}},"messageTraits":{"extension":{"x-some-extension":"some extension"}},"operationTraits":{"docs":{"externalDocs":{"url":"https://company.com/docs"}}}}}'; -const outputJsonNoApplyTraits = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"traits":[{"externalDocs":{"url":"https://company.com/docs"}}],"externalDocs":{"x-extension":true,"url":"https://irrelevant.com"},"message":{"traits":[{"x-some-extension":"some extension"}],"payload":{"type":"object","properties":{"name":{"type":"string"},"test":{"type":"object","properties":{"testing":{"type":"string"}}}},"x-parser-schema-id":"testSchema"},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-name":"testMessage"}}}},"components":{"messages":{"testMessage":{"traits":[{"x-some-extension":"some extension"}],"payload":{"type":"object","properties":{"name":{"type":"string"},"test":{"type":"object","properties":{"testing":{"type":"string"}}}},"x-parser-schema-id":"testSchema"},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-name":"testMessage"}},"schemas":{"testSchema":{"type":"object","properties":{"name":{"type":"string"},"test":{"type":"object","properties":{"testing":{"type":"string"}}}},"x-parser-schema-id":"testSchema"}},"messageTraits":{"extension":{"x-some-extension":"some extension"}},"operationTraits":{"docs":{"externalDocs":{"url":"https://company.com/docs"}}}}}'; +const outputJSON = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"externalDocs":{"x-extension":true,"url":"https://company.com/docs"},"message":{"payload":{"type":"object","properties":{"name":{"type":"string","x-parser-schema-id":""},"test":{"type":"object","properties":{"testing":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""}},"x-parser-schema-id":"testSchema"},"x-some-extension":"some extension","x-parser-original-traits":[{"x-some-extension":"some extension"}],"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-name":"testMessage"},"x-parser-original-traits":[{"externalDocs":{"url":"https://company.com/docs"}}]}}},"components":{"messages":{"testMessage":{"payload":{"type":"object","properties":{"name":{"type":"string","x-parser-schema-id":""},"test":{"type":"object","properties":{"testing":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""}},"x-parser-schema-id":"testSchema"},"x-some-extension":"some extension","x-parser-original-traits":[{"x-some-extension":"some extension"}],"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-name":"testMessage"}},"schemas":{"testSchema":{"type":"object","properties":{"name":{"type":"string","x-parser-schema-id":""},"test":{"type":"object","properties":{"testing":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""}},"x-parser-schema-id":"testSchema"}},"messageTraits":{"extension":{"x-some-extension":"some extension"}},"operationTraits":{"docs":{"externalDocs":{"url":"https://company.com/docs"}}}}}'; +const outputJsonNoApplyTraits = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"traits":[{"externalDocs":{"url":"https://company.com/docs"}}],"externalDocs":{"x-extension":true,"url":"https://irrelevant.com"},"message":{"traits":[{"x-some-extension":"some extension"}],"payload":{"type":"object","properties":{"name":{"type":"string","x-parser-schema-id":""},"test":{"type":"object","properties":{"testing":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""}},"x-parser-schema-id":"testSchema"},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-name":"testMessage"}}}},"components":{"messages":{"testMessage":{"traits":[{"x-some-extension":"some extension"}],"payload":{"type":"object","properties":{"name":{"type":"string","x-parser-schema-id":""},"test":{"type":"object","properties":{"testing":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""}},"x-parser-schema-id":"testSchema"},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-name":"testMessage"}},"schemas":{"testSchema":{"type":"object","properties":{"name":{"type":"string","x-parser-schema-id":""},"test":{"type":"object","properties":{"testing":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""}},"x-parser-schema-id":"testSchema"}},"messageTraits":{"extension":{"x-some-extension":"some extension"}},"operationTraits":{"docs":{"externalDocs":{"url":"https://company.com/docs"}}}}}'; const invalidAsyncAPI = { "asyncapi": "2.0.0", "info": {} }; -const errorsOfInvalidAsyncAPI = [{keyword: 'required',dataPath: '.info',schemaPath: '#/required',params: { missingProperty: 'title' },message: 'should have required property \'title\''},{keyword: 'required',dataPath: '.info',schemaPath: '#/required',params: { missingProperty: 'version' },message: 'should have required property \'version\''},{keyword: 'required',dataPath: '',schemaPath: '#/required',params: { missingProperty: 'channels' },message: 'should have required property \'channels\''}]; +const errorsOfInvalidAsyncAPI = [{ keyword: 'required', dataPath: '.info', schemaPath: '#/required', params: { missingProperty: 'title' }, message: 'should have required property \'title\'' }, { keyword: 'required', dataPath: '.info', schemaPath: '#/required', params: { missingProperty: 'version' }, message: 'should have required property \'version\'' }, { keyword: 'required', dataPath: '', schemaPath: '#/required', params: { missingProperty: 'channels' }, message: 'should have required property \'channels\'' }]; describe('parse()', function () { it('should parse YAML', async function () { const result = await parser.parse(inputYAML, { path: __filename }); await expect(JSON.stringify(result.json())).to.equal(outputJSON); }); - + it('should forward ajv errors and AsyncAPI json', async function () { try { await parser.parse(invalidAsyncAPI); - } catch(e) { + } catch (e) { await expect(e.errors).to.deep.equal(errorsOfInvalidAsyncAPI); await expect(e.parsedJSON).to.deep.equal(invalidAsyncAPI); } }); - + it('should not forward AsyncAPI json when it is not possible to convert it', async function () { try { await parser.parse('bad'); - } catch(e) { + } catch (e) { await expect(e.constructor.name).to.equal('ParserErrorNoJS'); await expect(e.parsedJSON).to.equal(undefined); } @@ -42,23 +42,23 @@ describe('parse()', function () { it('should forward AsyncAPI json when version is not supported', async function () { try { await parser.parse('bad: true'); - } catch(e) { + } catch (e) { await expect(e.constructor.name).to.equal('ParserErrorUnsupportedVersion'); await expect(e.parsedJSON).to.deep.equal({ bad: true }); } }); - + it('should not apply traits', async function () { const result = await parser.parse(inputYAML, { path: __filename, applyTraits: false }); await expect(JSON.stringify(result.json())).to.equal(outputJsonNoApplyTraits); }); - + it('should fail to resolve relative files when options.path is not provided', async function () { const testFn = async () => await parser.parse(inputYAML); await expect(testFn()) .to.be.rejectedWith(ParserError) }); - + it('should throw error if document is invalid YAML', async function () { const testFn = async () => await parser.parse(invalidYAML, { path: __filename }); await expect(testFn())