From c804fd5ad8d6c353a8f8df2dd7ff457ec43fd7a5 Mon Sep 17 00:00:00 2001 From: ue85540 Date: Wed, 8 Jun 2022 08:52:35 +0200 Subject: [PATCH] feat: new options to generate nice $id (asyncapi#516) --- .gitignore | 4 +- API.md | 20 + README.md | 1 + lib/anonymousNaming.js | 166 +++- lib/constants.js | 4 +- lib/iterators.js | 39 +- lib/models/asyncapi.js | 28 +- lib/parser.js | 17 +- lib/utils.js | 11 + .../asyncapi-very-complex-schema-output.json | 876 ++++++++++++++++++ test/good/asyncapi-very-complex-schema.yml | 107 +++ test/parse_test.js | 9 +- types.d.ts | 11 +- 13 files changed, 1236 insertions(+), 57 deletions(-) create mode 100644 test/good/asyncapi-very-complex-schema-output.json create mode 100644 test/good/asyncapi-very-complex-schema.yml diff --git a/.gitignore b/.gitignore index 23aab80c8..06292263d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ node_modules coverage .DS_Store test/sample_browser/bundle.js -dist/bundle.js \ No newline at end of file +dist/bundle.js +.idea +*.iml diff --git a/API.md b/API.md index 0c47b9437..16a20f7ed 100644 --- a/API.md +++ b/API.md @@ -25,6 +25,14 @@ +## Functions + +
+
upperFirst(word)
+

returns a string with the first character of string capitalized, if that character is alphabetic.

+
+
+ ## Typedefs
@@ -3783,6 +3791,7 @@ The complete list of parse configuration options used to parse the given data. | [parse] | Object | Options object to pass to [json-schema-ref-parser](https://apitools.dev/json-schema-ref-parser/docs/options.html). | | [resolve] | Object | Options object to pass to [json-schema-ref-parser](https://apitools.dev/json-schema-ref-parser/docs/options.html). | | [applyTraits] | Boolean | Whether to resolve and apply traits or not. Defaults to true. | +| [genererateIdInSchema] | Boolean | Genereate speaking $id where everver not given by schema. | @@ -3979,6 +3988,17 @@ Implements functions to deal with the Tags object. | --- | --- | --- | | name | string | Name of the tag. | + + +## upperFirst(word) +returns a string with the first character of string capitalized, if that character is alphabetic. + +**Kind**: global function + +| Param | Type | +| --- | --- | +| word | String | + ## SchemaIteratorCallbackType diff --git a/README.md b/README.md index bee6272dc..476365e1f 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ The parser uses custom extensions to define additional information about the spe - `x-parser-original-schema-format` holds information about the original schema format of the payload. You can use different schema formats with the AsyncAPI documents and the parser converts them to AsyncAPI schema. This is why different schema format is set, and the original one is preserved in the extension. - `x-parser-original-payload` holds the original payload of the message. You can use different formats for payloads with the AsyncAPI documents and the parser converts them to. For example, it converts payload described with Avro schema to AsyncAPI schema. The original payload is preserved in the extension. - [`x-parser-circular`](#circular-references) +- `x-parser-schema-id-level` is internally used to generate use friendly `x-parser-schema-id` if `genererateIdInSchema` option is passed to the parser. It indicates the traversal level of a schema. This is used to prepare the name of well-defined schemas. > **NOTE**: All extensions added by the parser (including all properties) should be retrieved using special functions. Names of extensions and their location may change, and their eventual changes will not be announced. diff --git a/lib/anonymousNaming.js b/lib/anonymousNaming.js index 656d23043..f6d2d00d7 100644 --- a/lib/anonymousNaming.js +++ b/lib/anonymousNaming.js @@ -1,17 +1,23 @@ -const {xParserMessageName, xParserSchemaId} = require('./constants'); -const {traverseAsyncApiDocument} = require('./iterators'); +const { xParserMessageName, xParserSchemaId, xParserSchemaIdLevel } = require('./constants'); +const { traverseAsyncApiDocument, traverseSchema, SchemaIteratorCallbackType } = require('./iterators'); +const { upperFirst } = require('./utils'); /** * Assign message keys as message name to all the component messages. * * @private - * @param {AsyncAPIDocument} doc + * @param {AsyncAPIDocument} doc + * @param {boolean} traversIds Walk recursive through schema and try to find reasonable uid where no uids was given. */ -function assignNameToComponentMessages(doc) { +function assignNameToComponentMessages(doc, traversIds = false) { if (doc.hasComponents()) { for (const [key, m] of Object.entries(doc.components().messages())) { if (m.name() === undefined) { m.json()[String(xParserMessageName)] = key; + + if (traversIds) { + assignUidToComponentSchemasRecursive(m.payload(), key); + } } } } @@ -43,42 +49,170 @@ function assignUidToParameterSchemas(doc) { assignIdToParameters(channel.parameters()); }); } - + /** * Assign uid to component schemas. * * @private * @param {AsyncAPIDocument} doc + * @param {boolean} traversIds Walk recursive through schema and try to find reasonamble uid where no uids was given. */ -function assignUidToComponentSchemas(doc) { +function assignUidToComponentSchemas(doc, traversIds = false) { if (doc.hasComponents()) { - for (const [key, s] of Object.entries(doc.components().schemas())) { - s.json()[String(xParserSchemaId)] = key; + for (const [key, schema] of Object.entries(doc.components().schemas())) { + if (traversIds) { + assignUidToComponentSchemasRecursive(schema, key); + } else { + schema.json()[String(xParserSchemaId)] = key; + } } } } /** - * Assign uid to component parameters schemas + * Assign uid to component schemas. By walking recursive through schema. * - * @private - * @param {AsyncAPIDocument} doc + * @param {Schema} schema which is going to be processed. + * @param {String} key The name of the schema to be processed. */ +function assignUidToComponentSchemasRecursive(schema, key) { + traverseSchema( + schema, + key, + { + parentId: '', + level: 0, + propOrIndexModifier: (propertyName, subSchema, options) => { + if (typeof propertyName === 'number') { + // It is not save to generate ids with arrays containing multiple types + // This is the case when be called from iterators.js:recursiveSchemaArray + + // This example is working because `Car` and `Airplane` are root level schemas and gots its own ids: + // properties: + // vehicle: + // type: array + // x-parser-schema-id: mySchemaVehicleArray + // items: + // - $ref: "#/components/schemas/Car" + // - $ref: "#/components/schemas/Airplane" + + // but if the items are anonymous + // properties: + // vehicle: + // type: array + // x-parser-schema-id: mySchemaVehicleArray <-- collision + // items: + // - type: object + // x-parser-schema-id: mySchemaVehicle + // properties: + // maxSpeed: + // type: string + // - type: object + // x-parser-schema-id: mySchemaVehicle <-- collision + // properties: + // maxAltitude: + // type: string + // + // would not work so we can not give code generator firendly id when `items`is an array. + return null; + } + + return options.parentId + upperFirst(propertyName); + }, + callback: (subSchema, propOrIndex, callbackType, options) => { + if (callbackType === SchemaIteratorCallbackType.END_SCHEMA) { + return; + } + + if (propOrIndex === null && + options.parentType === 'array' // Special edge case, here we need to inheritance the id to the childs. + ) { + propOrIndex = options.parentId; + } + + if (propOrIndex === null) { + return false; + } + + if (!(subSchema.type() === 'object' || + subSchema.type() === 'array' || + (subSchema.type() === 'string' && subSchema.enum()) || + !subSchema.type() // oneOf, anyOf, allOf dont have a type. + )) { + // Simple types dont need generated ids. + return false; + } + + options.parentId = propOrIndex; + options.parentType = subSchema.type(); + options.level++; + + if (!subSchema.$id()) { + if (subSchema.json()[String(xParserSchemaIdLevel)] && subSchema.json()[String(xParserSchemaIdLevel)] < options.level) { + // After deref we get a tree, always prefer names of parent root schema definitions. + + // The usage of $ref will lead to having the same schema multiple times in the tree caused by deref. + // By walking through the tree, each level deper in the tree gets a higher xParserSchemaIdLevel + // So if we find a genereated id with a higher xParserSchemaIdLevel the the current, this id was generated because the current schema was used via $ref as subelement in another schema. + // and we prefer the generated id that is clother to a rood node in tree. + return false; + } + + if (subSchema.type() === 'array') { + // You want the shorter on the child, this is used by the code generator to define the class name. + // Example: + // properties: + // pet: + // x-parser-schema-id: mySchemaPetArray <-- this is just a field of class "MySchema", so no need to get a great name + // x-parser-schema-id-level: 1 + // type: array + // items: + // x-parser-schema-id: mySchemaPet <-- This will be generate as a class "MySchemaPet" + // x-parser-schema-id-level: 2 + // type: object + // properties: + // name: + // type: string + // kind: + // type: string + subSchema.json()[String(xParserSchemaId)] = propOrIndex + 'Array'; + } else { + subSchema.json()[String(xParserSchemaId)] = propOrIndex; + } + subSchema.json()[String(xParserSchemaIdLevel)] = options.level; + } + }, + schemaTypesToIterate: [ + 'objects', + 'arrays', + 'allOf' // Will inheritate the parent id, because it is a joined object. + ], + seenSchemas: new Set() + } + ); +} + +/** +* Assign uid to component parameters schemas +* +* @private +* @param {AsyncAPIDocument} doc +*/ function assignUidToComponentParameterSchemas(doc) { if (doc.hasComponents()) { assignIdToParameters(doc.components().parameters()); } } - + /** * Assign anonymous names to nameless messages. * * @private - * @param {AsyncAPIDocument} doc + * @param {AsyncAPIDocument} doc */ function assignNameToAnonymousMessages(doc) { let anonymousMessageCounter = 0; - + if (doc.hasChannels()) { doc.channelNames().forEach(channelName => { const channel = doc.channel(channelName); @@ -87,7 +221,7 @@ function assignNameToAnonymousMessages(doc) { }); } } - + /** * Add anonymous name to key if no name provided. * @@ -101,7 +235,7 @@ function addNameToKey(messages, number) { } }); } - + /** * Gives schemas id to all anonymous schemas. * diff --git a/lib/constants.js b/lib/constants.js index de084e0c7..63e8c2d7d 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -2,6 +2,7 @@ const xParserSpecParsed = 'x-parser-spec-parsed'; const xParserSpecStringified = 'x-parser-spec-stringified'; const xParserMessageName = 'x-parser-message-name'; const xParserSchemaId = 'x-parser-schema-id'; +const xParserSchemaIdLevel = 'x-parser-schema-id-level'; const xParserCircle = 'x-parser-circular'; const xParserCircleProps = 'x-parser-circular-props'; @@ -10,6 +11,7 @@ module.exports = { xParserSpecStringified, xParserMessageName, xParserSchemaId, + xParserSchemaIdLevel, xParserCircle, xParserCircleProps -}; \ No newline at end of file +}; diff --git a/lib/iterators.js b/lib/iterators.js index f4c095067..b5eae549d 100644 --- a/lib/iterators.js +++ b/lib/iterators.js @@ -96,7 +96,7 @@ function traverseSchema(schema, propOrIndex, options) { // NOSONAR if (!schemaTypesToIterate.includes(SchemaTypesToIterate.arrays) && types.includes('array')) return; // check callback `NEW_SCHEMA` case - if (callback(schema, propOrIndex, SchemaIteratorCallbackType.NEW_SCHEMA) === false) return; + if (callback(schema, propOrIndex, SchemaIteratorCallbackType.NEW_SCHEMA, options) === false) return; if (schemaTypesToIterate.includes(SchemaTypesToIterate.objects) && types.includes('object')) { recursiveSchemaObject(schema, options); @@ -106,46 +106,46 @@ function traverseSchema(schema, propOrIndex, options) { // NOSONAR } if (schemaTypesToIterate.includes(SchemaTypesToIterate.oneOfs)) { (schema.oneOf() || []).forEach((combineSchema, idx) => { - traverseSchema(combineSchema, idx, options); + traverseSchema(combineSchema, idx, {...options}); }); } if (schemaTypesToIterate.includes(SchemaTypesToIterate.anyOfs)) { (schema.anyOf() || []).forEach((combineSchema, idx) => { - traverseSchema(combineSchema, idx, options); + traverseSchema(combineSchema, idx, {...options}); }); } if (schemaTypesToIterate.includes(SchemaTypesToIterate.allOfs)) { (schema.allOf() || []).forEach((combineSchema, idx) => { - traverseSchema(combineSchema, idx, options); + traverseSchema(combineSchema, idx, {...options}); }); } if (schemaTypesToIterate.includes(SchemaTypesToIterate.nots) && schema.not()) { - traverseSchema(schema.not(), null, options); + traverseSchema(schema.not(), null, {...options}); } if (schemaTypesToIterate.includes(SchemaTypesToIterate.ifs) && schema.if()) { - traverseSchema(schema.if(), null, options); + traverseSchema(schema.if(), null, {...options}); } if (schemaTypesToIterate.includes(SchemaTypesToIterate.thenes) && schema.then()) { - traverseSchema(schema.then(), null, options); + traverseSchema(schema.then(), null, {...options}); } if (schemaTypesToIterate.includes(SchemaTypesToIterate.elses) && schema.else()) { - traverseSchema(schema.else(), null, options); + traverseSchema(schema.else(), null, {...options}); } if (schemaTypesToIterate.includes(SchemaTypesToIterate.dependencies)) { Object.entries(schema.dependencies() || {}).forEach(([depName, dep]) => { // do not iterate dependent required - if (dep && !Array.isArray(dep)) { - traverseSchema(dep, depName, options); + if (dep && !Array.isArray(dep)) { + traverseSchema(dep, depName, {...options}); } }); } if (schemaTypesToIterate.includes(SchemaTypesToIterate.definitions)) { Object.entries(schema.definitions() || {}).forEach(([defName, def]) => { - traverseSchema(def, defName, options); + traverseSchema(def, defName, {...options}); }); } - callback(schema, propOrIndex, SchemaIteratorCallbackType.END_SCHEMA); + callback(schema, propOrIndex, SchemaIteratorCallbackType.END_SCHEMA, options); seenSchemas.delete(jsonSchema); } /* eslint-enable sonarjs/cognitive-complexity */ @@ -156,12 +156,16 @@ function traverseSchema(schema, propOrIndex, options) { // NOSONAR * @private * @param {Object} options * @param {SchemaIteratorCallbackType} [options.callback] callback used when crawling a schema. + * @param {SchemaIteratorCallbackType} [options.propOrIndexModifier] callback used modify, propertyName. * @param {SchemaTypesToIterate[]} [options.schemaTypesToIterate] list of schema types to crawl. * @param {Set} [options.seenSchemas] Set which holds all defined schemas in the tree - it is mainly used to check circular references */ function recursiveSchemaObject(schema, options) { Object.entries(schema.properties() || {}).forEach(([propertyName, property]) => { - traverseSchema(property, propertyName, options); + if (options.propOrIndexModifier) { + propertyName = options.propOrIndexModifier(propertyName, schema, options); + } + traverseSchema(property, propertyName, {...options}); }); const additionalProperties = schema.additionalProperties(); @@ -186,6 +190,7 @@ function recursiveSchemaObject(schema, options) { * @private * @param {Object} options * @param {SchemaIteratorCallbackType} [options.callback] callback used when crawling a schema. + * @param {SchemaIteratorCallbackType} [options.propOrIndexModifier] callback used modify, propertyName. * @param {SchemaTypesToIterate[]} [options.schemaTypesToIterate] list of schema types to crawl. * @param {Set} [options.seenSchemas] Set which holds all defined schemas in the tree - it is mainly used to check circular references */ @@ -194,7 +199,10 @@ function recursiveSchemaArray(schema, options) { if (items) { if (Array.isArray(items)) { items.forEach((item, idx) => { - traverseSchema(item, idx, options); + if (options.propOrIndexModifier) { + idx = options.propOrIndexModifier(idx, schema, options); + } + traverseSchema(item, idx, {...options}); }); } else { traverseSchema(items, null, options); @@ -322,4 +330,5 @@ module.exports = { SchemaIteratorCallbackType, SchemaTypesToIterate, traverseAsyncApiDocument, -}; \ No newline at end of file + traverseSchema +}; diff --git a/lib/models/asyncapi.js b/lib/models/asyncapi.js index c4b0f6b05..afac06f56 100644 --- a/lib/models/asyncapi.js +++ b/lib/models/asyncapi.js @@ -8,9 +8,9 @@ const Components = require('./components'); const MixinExternalDocs = require('../mixins/external-docs'); const MixinTags = require('../mixins/tags'); const MixinSpecificationExtensions = require('../mixins/specification-extensions'); -const {xParserSpecParsed, xParserSpecStringified, xParserCircle} = require('../constants'); -const {assignNameToAnonymousMessages, assignNameToComponentMessages, assignUidToComponentSchemas, assignUidToParameterSchemas, assignIdToAnonymousSchemas, assignUidToComponentParameterSchemas} = require('../anonymousNaming'); -const {traverseAsyncApiDocument} = require('../iterators'); +const { xParserSpecParsed, xParserSpecStringified, xParserCircle } = require('../constants'); +const { assignNameToAnonymousMessages, assignNameToComponentMessages, assignUidToComponentSchemas, assignUidToParameterSchemas, assignIdToAnonymousSchemas, assignUidToComponentParameterSchemas } = require('../anonymousNaming'); +const { traverseAsyncApiDocument } = require('../iterators'); /** * Implements functions to deal with the AsyncAPI document. @@ -26,17 +26,19 @@ class AsyncAPIDocument extends Base { /** * @constructor */ - constructor(...args) { - super(...args); + constructor(json, options = {}) { + super(json); if (this.ext(xParserSpecParsed) === true) { return; } - assignNameToComponentMessages(this); + if (options.genererateIdInSchema === undefined) options.genererateIdInSchema = false; + + assignNameToComponentMessages(this, options.genererateIdInSchema); assignNameToAnonymousMessages(this); - assignUidToComponentSchemas(this); + assignUidToComponentSchemas(this, options.genererateIdInSchema); assignUidToComponentParameterSchemas(this); assignUidToParameterSchemas(this); assignIdToAnonymousSchemas(this); @@ -275,7 +277,7 @@ class AsyncAPIDocument extends Base { } // remove `x-parser-spec-stringified` extension delete parsedJSON[String(xParserSpecStringified)]; - + const objToPath = new Map(); const pathToObj = new Map(); traverseStringifiedDoc(parsedJSON, undefined, parsedJSON, objToPath, pathToObj); @@ -295,19 +297,19 @@ function refReplacer() { const paths = new Map(); let init = null; - return function(field, value) { + return function (field, value) { // `this` points to parent object of given value - some object or array - const pathPart = modelPaths.get(this) + (Array.isArray(this) ? `[${field}]` : `.${ field}`); + const pathPart = modelPaths.get(this) + (Array.isArray(this) ? `[${field}]` : `.${field}`); // check if `objOrPath` has "reference" const isComplex = value === Object(value); if (isComplex) { modelPaths.set(value, pathPart); } - + const savedPath = paths.get(value) || ''; if (!savedPath && isComplex) { - const valuePath = pathPart.replace(/undefined\.\.?/,''); + const valuePath = pathPart.replace(/undefined\.\.?/, ''); paths.set(value, valuePath); } @@ -345,7 +347,7 @@ function traverseStringifiedDoc(parent, field, root, objToPath, pathToObj) { objToPath.set(objOrPath, path); pathToObj.set(path, objOrPath); - + const ref = pathToObj.get(objOrPath); if (ref) { parent[String(field)] = ref; diff --git a/lib/parser.js b/lib/parser.js index f756fcae9..92da4249a 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -42,6 +42,7 @@ module.exports = { * @property {Object=} parse - Options object to pass to {@link https://apitools.dev/json-schema-ref-parser/docs/options.html|json-schema-ref-parser}. * @property {Object=} resolve - Options object to pass to {@link https://apitools.dev/json-schema-ref-parser/docs/options.html|json-schema-ref-parser}. * @property {Boolean=} applyTraits - Whether to resolve and apply traits or not. Defaults to true. + * @property {Boolean=} genererateIdInSchema - Genereate speaking $id where everver not given by schema. */ /** @@ -67,7 +68,7 @@ async function parse(asyncapiYAMLorJSON, options = {}) { detail: 'Most probably the AsyncAPI document contains invalid YAML or YAML features not supported in JSON.' }); } - + if (!parsedJSON.asyncapi) { throw new ParserError({ type: 'missing-asyncapi-field', @@ -75,7 +76,7 @@ async function parse(asyncapiYAMLorJSON, options = {}) { parsedJSON, }); } - + if (parsedJSON.asyncapi.startsWith('1.') || !asyncapi[parsedJSON.asyncapi]) { throw new ParserError({ type: 'unsupported-version', @@ -116,7 +117,7 @@ async function parse(asyncapiYAMLorJSON, options = {}) { }); } - return new AsyncAPIDocument(parsedJSON); + return new AsyncAPIDocument(parsedJSON, { ...options }); } /** @@ -219,7 +220,7 @@ async function validateAndConvertMessage(msg, originalAsyncAPIDocument, fileForm defaultSchemaFormat, originalAsyncAPIDocument, parsedAsyncAPIDocument, - fileFormat, + fileFormat, pathToPayload }); @@ -233,9 +234,9 @@ async function validateAndConvertMessage(msg, originalAsyncAPIDocument, fileForm * @param {Object} parserModule The schema parser module containing parse() and getMimeTypes() functions. */ function registerSchemaParser(parserModule) { - if (typeof parserModule !== 'object' - || typeof parserModule.parse !== 'function' - || typeof parserModule.getMimeTypes !== 'function') + if (typeof parserModule !== 'object' + || typeof parserModule.parse !== 'function' + || typeof parserModule.getMimeTypes !== 'function') throw new ParserError({ type: 'impossible-to-register-parser', title: 'parserModule must have parse() and getMimeTypes() functions.' @@ -277,7 +278,7 @@ async function customChannelsOperations(parsedJSON, asyncapiYAMLorJSON, initialF if (!op) return; const messages = op.message ? (op.message.oneOf || [op.message]) : []; - if (options.applyTraits) { + if (options.applyTraits) { applyTraits(op); messages.forEach(m => applyTraits(m)); } diff --git a/lib/utils.js b/lib/utils.js index 0faf2e0c0..9d7932df6 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -320,3 +320,14 @@ utils.getUnknownServers = (parsedJSON, channel) => { utils.getDefaultSchemaFormat = (asyncapiVersion) => { return `application/vnd.aai.asyncapi;version=${asyncapiVersion}`; }; + +/** + * returns a string with the first character of string capitalized, if that character is alphabetic. + * + * @function upperFirst + * @private + * @param {String} word + */ +utils.upperFirst = (word) => { + return word.charAt(0).toUpperCase() + word.slice(1); +}; diff --git a/test/good/asyncapi-very-complex-schema-output.json b/test/good/asyncapi-very-complex-schema-output.json new file mode 100644 index 000000000..3e01e5509 --- /dev/null +++ b/test/good/asyncapi-very-complex-schema-output.json @@ -0,0 +1,876 @@ +{ + "asyncapi": "2.0.0", + "info": { + "title": "Streetlights API", + "version": "1.0.0", + "description": "The Smartylighting Streetlights API allows you\nto remotely manage the city lights.\n", + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0" + } + }, + "servers": { + "mosquitto": { + "url": "mqtt://test.mosquitto.org", + "protocol": "mqtt" + } + }, + "channels": { + "street/lights": { + "publish": { + "operationId": "getStreetLights", + "message": { + "name": "streetLights", + "title": "Street light information", + "contentType": "application/json", + "payload": { + "type": "object", + "properties": { + "lumens": { + "type": "integer", + "minimum": 0, + "description": "Light intensity measured in lumens.", + "x-parser-schema-id": "" + }, + "maintenance": { + "type": "array", + "items": { + "type": "object", + "properties": { + "year": { + "type": "number", + "x-parser-schema-id": "" + }, + "cost": { + "type": "number", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "streetLightMaintenance", + "x-parser-schema-id-level": 3 + }, + "x-parser-schema-id": "streetLightMaintenanceArray", + "x-parser-schema-id-level": 2 + }, + "holder": { + "type": "array", + "items": { + "type": "object", + "properties": { + "country": { + "type": "string", + "x-parser-schema-id": "" + }, + "name": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "manufactor", + "x-parser-schema-id-level": 1 + }, + "x-parser-schema-id": "streetLightHolderArray", + "x-parser-schema-id-level": 2 + }, + "lightbulb": { + "allOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string", + "x-parser-schema-id": "" + }, + "name": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "manufactor", + "x-parser-schema-id-level": 1 + }, + { + "type": "object", + "properties": { + "ecologically": { + "type": "string", + "enum": [ + "A+++", + "A++", + "A+", + "A", + "B", + "C", + "D", + "E", + "F" + ], + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + } + ], + "x-parser-schema-id": "streetLightLightbulb", + "x-parser-schema-id-level": 2 + }, + "socket": { + "anyOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string", + "x-parser-schema-id": "" + }, + "name": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "manufactor", + "x-parser-schema-id-level": 1 + }, + { + "type": "object", + "properties": { + "demo": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + } + ], + "x-parser-schema-id": "streetLightSocket", + "x-parser-schema-id-level": 2 + }, + "vendor": { + "oneOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string", + "x-parser-schema-id": "" + }, + "name": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "manufactor", + "x-parser-schema-id-level": 1 + }, + { + "type": "object", + "properties": { + "demo": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + } + ], + "x-parser-schema-id": "streetLightVendor", + "x-parser-schema-id-level": 2 + }, + "sentAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when the message was sent.", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "streetLight", + "x-parser-schema-id-level": 1 + }, + "headers": { + "type": "object", + "properties": { + "my-app-header": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + }, + "x-parser-original-traits": [ + { + "headers": { + "type": "object", + "properties": { + "my-app-header": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + } + } + ], + "x-parser-original-schema-format": "application/vnd.aai.asyncapi;version=2.0.0", + "x-parser-original-payload": { + "type": "object", + "properties": { + "lumens": { + "type": "integer", + "minimum": 0, + "description": "Light intensity measured in lumens." + }, + "maintenance": { + "type": "array", + "items": { + "type": "object", + "properties": { + "year": { + "type": "number" + }, + "cost": { + "type": "number" + } + } + } + }, + "holder": { + "type": "array", + "items": { + "type": "object", + "properties": { + "country": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + }, + "lightbulb": { + "allOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "ecologically": { + "type": "string", + "enum": [ + "A+++", + "A++", + "A+", + "A", + "B", + "C", + "D", + "E", + "F" + ] + } + } + } + ] + }, + "socket": { + "anyOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "demo": { + "type": "string" + } + } + } + ] + }, + "vendor": { + "oneOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "demo": { + "type": "string" + } + } + } + ] + }, + "sentAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when the message was sent." + } + } + }, + "schemaFormat": "application/vnd.aai.asyncapi;version=2.0.0", + "x-parser-message-parsed": true + } + } + } + }, + "components": { + "messages": { + "streetLights": { + "name": "streetLights", + "title": "Street light information", + "contentType": "application/json", + "payload": { + "type": "object", + "properties": { + "lumens": { + "type": "integer", + "minimum": 0, + "description": "Light intensity measured in lumens.", + "x-parser-schema-id": "" + }, + "maintenance": { + "type": "array", + "items": { + "type": "object", + "properties": { + "year": { + "type": "number", + "x-parser-schema-id": "" + }, + "cost": { + "type": "number", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "streetLightMaintenance", + "x-parser-schema-id-level": 3 + }, + "x-parser-schema-id": "streetLightMaintenanceArray", + "x-parser-schema-id-level": 2 + }, + "holder": { + "type": "array", + "items": { + "type": "object", + "properties": { + "country": { + "type": "string", + "x-parser-schema-id": "" + }, + "name": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "manufactor", + "x-parser-schema-id-level": 1 + }, + "x-parser-schema-id": "streetLightHolderArray", + "x-parser-schema-id-level": 2 + }, + "lightbulb": { + "allOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string", + "x-parser-schema-id": "" + }, + "name": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "manufactor", + "x-parser-schema-id-level": 1 + }, + { + "type": "object", + "properties": { + "ecologically": { + "type": "string", + "enum": [ + "A+++", + "A++", + "A+", + "A", + "B", + "C", + "D", + "E", + "F" + ], + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + } + ], + "x-parser-schema-id": "streetLightLightbulb", + "x-parser-schema-id-level": 2 + }, + "socket": { + "anyOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string", + "x-parser-schema-id": "" + }, + "name": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "manufactor", + "x-parser-schema-id-level": 1 + }, + { + "type": "object", + "properties": { + "demo": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + } + ], + "x-parser-schema-id": "streetLightSocket", + "x-parser-schema-id-level": 2 + }, + "vendor": { + "oneOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string", + "x-parser-schema-id": "" + }, + "name": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "manufactor", + "x-parser-schema-id-level": 1 + }, + { + "type": "object", + "properties": { + "demo": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + } + ], + "x-parser-schema-id": "streetLightVendor", + "x-parser-schema-id-level": 2 + }, + "sentAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when the message was sent.", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "streetLight", + "x-parser-schema-id-level": 1 + }, + "headers": { + "type": "object", + "properties": { + "my-app-header": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + }, + "x-parser-original-traits": [ + { + "headers": { + "type": "object", + "properties": { + "my-app-header": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + } + } + ], + "x-parser-original-schema-format": "application/vnd.aai.asyncapi;version=2.0.0", + "x-parser-original-payload": { + "type": "object", + "properties": { + "lumens": { + "type": "integer", + "minimum": 0, + "description": "Light intensity measured in lumens." + }, + "maintenance": { + "type": "array", + "items": { + "type": "object", + "properties": { + "year": { + "type": "number" + }, + "cost": { + "type": "number" + } + } + } + }, + "holder": { + "type": "array", + "items": { + "type": "object", + "properties": { + "country": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + }, + "lightbulb": { + "allOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "ecologically": { + "type": "string", + "enum": [ + "A+++", + "A++", + "A+", + "A", + "B", + "C", + "D", + "E", + "F" + ] + } + } + } + ] + }, + "socket": { + "anyOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "demo": { + "type": "string" + } + } + } + ] + }, + "vendor": { + "oneOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "demo": { + "type": "string" + } + } + } + ] + }, + "sentAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when the message was sent." + } + } + }, + "schemaFormat": "application/vnd.aai.asyncapi;version=2.0.0", + "x-parser-message-parsed": true + } + }, + "schemas": { + "streetLight": { + "type": "object", + "properties": { + "lumens": { + "type": "integer", + "minimum": 0, + "description": "Light intensity measured in lumens.", + "x-parser-schema-id": "" + }, + "maintenance": { + "type": "array", + "items": { + "type": "object", + "properties": { + "year": { + "type": "number", + "x-parser-schema-id": "" + }, + "cost": { + "type": "number", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "streetLightMaintenance", + "x-parser-schema-id-level": 3 + }, + "x-parser-schema-id": "streetLightMaintenanceArray", + "x-parser-schema-id-level": 2 + }, + "holder": { + "type": "array", + "items": { + "type": "object", + "properties": { + "country": { + "type": "string", + "x-parser-schema-id": "" + }, + "name": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "manufactor", + "x-parser-schema-id-level": 1 + }, + "x-parser-schema-id": "streetLightHolderArray", + "x-parser-schema-id-level": 2 + }, + "lightbulb": { + "allOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string", + "x-parser-schema-id": "" + }, + "name": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "manufactor", + "x-parser-schema-id-level": 1 + }, + { + "type": "object", + "properties": { + "ecologically": { + "type": "string", + "enum": [ + "A+++", + "A++", + "A+", + "A", + "B", + "C", + "D", + "E", + "F" + ], + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + } + ], + "x-parser-schema-id": "streetLightLightbulb", + "x-parser-schema-id-level": 2 + }, + "socket": { + "anyOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string", + "x-parser-schema-id": "" + }, + "name": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "manufactor", + "x-parser-schema-id-level": 1 + }, + { + "type": "object", + "properties": { + "demo": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + } + ], + "x-parser-schema-id": "streetLightSocket", + "x-parser-schema-id-level": 2 + }, + "vendor": { + "oneOf": [ + { + "type": "object", + "properties": { + "country": { + "type": "string", + "x-parser-schema-id": "" + }, + "name": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "manufactor", + "x-parser-schema-id-level": 1 + }, + { + "type": "object", + "properties": { + "demo": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + } + ], + "x-parser-schema-id": "streetLightVendor", + "x-parser-schema-id-level": 2 + }, + "sentAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when the message was sent.", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "streetLight", + "x-parser-schema-id-level": 1 + }, + "sentAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when the message was sent.", + "x-parser-schema-id": "" + }, + "manufactor": { + "type": "object", + "properties": { + "country": { + "type": "string", + "x-parser-schema-id": "" + }, + "name": { + "type": "string", + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "manufactor", + "x-parser-schema-id-level": 1 + } + }, + "messageTraits": { + "commonHeaders": { + "headers": { + "type": "object", + "properties": { + "my-app-header": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "x-parser-schema-id": "" + } + }, + "x-parser-schema-id": "" + } + } + } + }, + "x-parser-spec-parsed": true +} diff --git a/test/good/asyncapi-very-complex-schema.yml b/test/good/asyncapi-very-complex-schema.yml new file mode 100644 index 000000000..ca6719d3a --- /dev/null +++ b/test/good/asyncapi-very-complex-schema.yml @@ -0,0 +1,107 @@ +asyncapi: "2.0.0" +info: + title: Streetlights API + version: "1.0.0" + description: | + The Smartylighting Streetlights API allows you + to remotely manage the city lights. + license: + name: Apache 2.0 + url: "https://www.apache.org/licenses/LICENSE-2.0" +servers: + mosquitto: + url: mqtt://test.mosquitto.org + protocol: mqtt +channels: + street/lights: + publish: + operationId: getStreetLights + message: + $ref: "#/components/messages/streetLights" + +components: + messages: + streetLights: + name: streetLights + title: Street light information + contentType: application/json + traits: + - $ref: "#/components/messageTraits/commonHeaders" + payload: + $ref: "#/components/schemas/streetLight" + + schemas: + streetLight: + type: object + properties: + lumens: + type: integer + minimum: 0 + description: Light intensity measured in lumens. + maintenance: + type: array + items: + type: object + properties: + year: + type: number + cost: + type: number + holder: + type: array + items: + $ref: "#/components/schemas/manufactor" + lightbulb: + allOf: + - $ref: "#/components/schemas/manufactor" + - type: object + properties: + ecologically: + type: string + enum: + - A+++ + - A++ + - A+ + - A + - B + - C + - D + - E + - F + socket: + anyOf: + - $ref: "#/components/schemas/manufactor" + - type: object + properties: + demo: + type: string + vendor: + oneOf: + - $ref: "#/components/schemas/manufactor" + - type: object + properties: + demo: + type: string + sentAt: + $ref: "#/components/schemas/sentAt" + sentAt: + type: string + format: date-time + description: Date and time when the message was sent. + manufactor: + type: object + properties: + country: + type: string + name: + type: string + + messageTraits: + commonHeaders: + headers: + type: object + properties: + my-app-header: + type: integer + minimum: 0 + maximum: 100 diff --git a/test/parse_test.js b/test/parse_test.js index d2635ca88..4d7dd0b7b 100644 --- a/test/parse_test.js +++ b/test/parse_test.js @@ -27,6 +27,8 @@ const outputJsonWithRefs = '{"asyncapi":"2.0.0","info":{"title":"My API","versio const invalidAsyncAPI = '{"asyncapi":"2.0.0","info":{}}'; const outputJSONNoChannels = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{},"components":{"messages":{"testMessage":{"payload":{"type":"object","properties":{"name":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":"testSchema"},"x-some-extension":"some extension","headers":{"type":"object","properties":{"some-common-header":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""},"x-parser-original-traits":[{"x-some-extension":"some extension","headers":{"type":"object","properties":{"some-common-header":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""}}],"x-parser-original-schema-format":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-original-payload":{"type":"object","properties":{"name":{"type":"string"}}},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-parsed":true,"x-parser-message-name":"testMessage"}},"schemas":{"testSchema":{"type":"object","properties":{"name":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":"testSchema"}},"messageTraits":{"extension":{"x-some-extension":"some extension","headers":{"type":"object","properties":{"some-common-header":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""}}}},"x-parser-spec-parsed":true}'; const outputJSONMessagesChannels = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"message":{"x-some-extension":"some extension","headers":{"type":"object","properties":{"some-common-header":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""},"x-parser-original-traits":[{"x-some-extension":"some extension","headers":{"type":"object","properties":{"some-common-header":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""}}],"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-parsed":true,"x-parser-message-name":"channelMessage"}}}},"components":{"messages":{"channelMessage":{"x-some-extension":"some extension","headers":{"type":"object","properties":{"some-common-header":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""},"x-parser-original-traits":[{"x-some-extension":"some extension","headers":{"type":"object","properties":{"some-common-header":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""}}],"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-parsed":true,"x-parser-message-name":"channelMessage"},"testMessage":{"payload":{"type":"object","properties":{"name":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":"testSchema"},"x-some-extension":"some extension","headers":{"type":"object","properties":{"some-common-header":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""},"x-parser-original-traits":[{"x-some-extension":"some extension","headers":{"type":"object","properties":{"some-common-header":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""}}],"x-parser-original-schema-format":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-original-payload":{"type":"object","properties":{"name":{"type":"string"}}},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-parsed":true,"x-parser-message-name":"testMessage"}},"schemas":{"testSchema":{"type":"object","properties":{"name":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":"testSchema"}},"messageTraits":{"extension":{"x-some-extension":"some extension","headers":{"type":"object","properties":{"some-common-header":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":""}}}},"x-parser-spec-parsed":true}'; +const inputYAMLVeryComplexSchema = fs.readFileSync(path.resolve(__dirname, './good/asyncapi-very-complex-schema.yml'), 'utf8'); +const outputJSONVeryComplexSchema = fs.readFileSync(path.resolve(__dirname, './good/asyncapi-very-complex-schema-output.json'), 'utf8'); // Source: https://github.com/asyncapi/tck/blob/master/tests/asyncapi-2.0/AsyncAPI%20Object/invalid-duplicate-tags.yaml const invalidRootWithDuplicateTags = fs.readFileSync(path.resolve(__dirname, './wrong/invalid-asyncapi-root-with-duplicate-tags.yaml'), 'utf8'); @@ -87,7 +89,12 @@ describe('parse()', function() { const result = await parser.parse(inputYAMLMessagesChannels, { path: __filename }); expect(JSON.stringify(result.json())).to.equal(outputJSONMessagesChannels); }); - + + it('should generate ids', async function () { + const result = await parser.parse(inputYAMLVeryComplexSchema, { path: __filename, genererateIdInSchema: true }); + expect(JSON.stringify(result.json(), null, 2).trim()).to.equal(outputJSONVeryComplexSchema.trim()); + }); + it('should fail when asyncapi is not valid', async function() { const expectedErrorObject = { type: 'https://github.com/asyncapi/parser-js/validation-errors', diff --git a/types.d.ts b/types.d.ts index 293efe4e8..093220989 100644 --- a/types.d.ts +++ b/types.d.ts @@ -194,12 +194,12 @@ declare module "@asyncapi/parser" { hasCircular(): boolean; /** * Traverse schemas in the document and select which types of schemas to include. - * By default all schemas are iterated + By default all schemas are iterated */ traverseSchemas(callback: TraverseSchemas, schemaTypesToIterate: SchemaTypesToIterate[]): void; /** * Converts a valid AsyncAPI document to a JavaScript Object Notation (JSON) string. - * A stringified AsyncAPI document using this function should be parsed via the AsyncAPIDocument.parse() function - the JSON.parse() function is not compatible. + A stringified AsyncAPI document using this function should be parsed via the AsyncAPIDocument.parse() function - the JSON.parse() function is not compatible. * @param doc - A valid AsyncAPIDocument instance. * @param [space] - Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read. */ @@ -1176,12 +1176,14 @@ declare module "@asyncapi/parser" { * @property [parse] - Options object to pass to {@link https://apitools.dev/json-schema-ref-parser/docs/options.html|json-schema-ref-parser}. * @property [resolve] - Options object to pass to {@link https://apitools.dev/json-schema-ref-parser/docs/options.html|json-schema-ref-parser}. * @property [applyTraits] - Whether to resolve and apply traits or not. Defaults to true. + * @property [genererateIdInSchema] - Genereate speaking $id where everver not given by schema. */ type ParserOptions = { path?: string; parse?: any; resolve?: any; applyTraits?: boolean; + genererateIdInSchema?: boolean; }; /** * Parses and validate an AsyncAPI document from YAML or JSON. @@ -1205,3 +1207,8 @@ declare module "@asyncapi/parser" { function registerSchemaParser(parserModule: any): void; } +/** + * returns a string with the first character of string capitalized, if that character is alphabetic. + */ +declare function upperFirst(word: string): void; +