From 2af24170f8fd670daf62276833d2bf570b937638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20M=C3=A9ndez?= Date: Tue, 14 Apr 2020 16:50:09 +0200 Subject: [PATCH] feat: improve and add some errors (#61) * feat: improve errors * Unify errors and make errors aware of the original document * Fix typo * Update docs * Change upgrade for update * Add toJS method to the error class * Add docs for toJS method * Add info about all the errors in the README * Change error message and fix tests --- API.md | 149 +++++++++- README.md | 21 +- lib/errors/parser-error-no-js.js | 6 - .../parser-error-unsupported-version.js | 9 - lib/errors/parser-error.js | 76 +++-- lib/json-parse.js | 38 +++ lib/parser.js | 88 ++++-- lib/utils.js | 180 +++++++++++- package-lock.json | 201 +++++++------ package.json | 2 + test/asyncapi.json | 21 ++ test/invalid-asyncapi.json | 43 +++ test/invalid-asyncapi.yaml | 36 +++ test/parse_test.js | 274 ++++++++++++++++-- 14 files changed, 956 insertions(+), 188 deletions(-) delete mode 100644 lib/errors/parser-error-no-js.js delete mode 100644 lib/errors/parser-error-unsupported-version.js create mode 100644 lib/json-parse.js create mode 100644 test/invalid-asyncapi.json create mode 100644 test/invalid-asyncapi.yaml diff --git a/API.md b/API.md index 6fb9898f9..81998989c 100644 --- a/API.md +++ b/API.md @@ -8,6 +8,9 @@ ## Classes
+
ParserError
+

Represents an error while trying to parse an AsyncAPI document.

+
AsyncAPIDocumentBase

Implements functions to deal with the AsyncAPI document.

@@ -84,6 +87,29 @@
+## Functions + +
+
assignUidToParameterSchemas(doc)
+

Assign parameter keys as uid for the parameter schema.

+
+
assignUidToComponentSchemas(doc)
+

Assign uid to component schemas.

+
+
assignNameToAnonymousMessages(doc)
+

Assign anonymous names to nameless messages.

+
+
recursiveSchema(schema, callback(schema))
+

Recursively go through each schema and execute callback.

+
+
schemaDocument(doc, callback(schema))
+

Go through each channel and for each parameter, and message payload and headers recursively call the callback for each schema.

+
+
assignIdToAnonymousSchemas(doc)
+

Gives schemas id to all anonymous schemas.

+
+
+ ## Parser @@ -105,7 +131,7 @@ Parses and validate an AsyncAPI document from YAML or JSON. | --- | --- | --- | --- | | asyncapiYAMLorJSON | String | | An AsyncAPI document in JSON or YAML format. | | [options] | Object | | Configuration options. | -| [options.path] | String | | Path to the AsyncAPI document. It will be used to resolve relative references. | +| [options.path] | String | | Path to the AsyncAPI document. It will be used to resolve relative references. Defaults to current working dir. | | [options.parse] | Object | | Options object to pass to [json-schema-ref-parser](https://apidevtools.org/json-schema-ref-parser/docs/options.html). | | [options.resolve] | Object | | Options object to pass to [json-schema-ref-parser](https://apidevtools.org/json-schema-ref-parser/docs/options.html). | | [options.dereference] | Object | | Options object to pass to [json-schema-ref-parser](https://apidevtools.org/json-schema-ref-parser/docs/options.html). | @@ -137,6 +163,59 @@ Registers a new schema parser. Schema parsers are in charge of parsing and trans | schemaFormats | Array.<string> | An array of schema formats the given schema parser is able to recognize and transform. | | parserFunction | function | The schema parser function. | + + +## ParserError +Represents an error while trying to parse an AsyncAPI document. + +**Kind**: global class + +* [ParserError](#ParserError) + * [new ParserError(definition)](#new_ParserError_new) + * [.toJS()](#ParserError+toJS) + + + +### new ParserError(definition) +Instantiates an error + + +| Param | Type | Description | +| --- | --- | --- | +| definition | Object | | +| definition.type | String | The type of the error. | +| definition.title | String | The message of the error. | +| [definition.detail] | String | A string containing more detailed information about the error. | +| [definition.parsedJSON] | Object | The resulting JSON after YAML transformation. Or the JSON object if the this was the initial format. | +| [definition.validationErrors] | Array.<Object> | The errors resulting from the validation. For more information, see https://www.npmjs.com/package/better-ajv-errors. | +| definition.validationErrors.title | String | A validation error message. | +| definition.validationErrors.jsonPointer | String | The path to the field that contains the error. Uses JSON Pointer format. | +| definition.validationErrors.startLine | Number | The line where the error starts in the AsyncAPI document. | +| definition.validationErrors.startColumn | Number | The column where the error starts in the AsyncAPI document. | +| definition.validationErrors.startOffset | Number | The offset (starting from the beginning of the document) where the error starts in the AsyncAPI document. | +| definition.validationErrors.endLine | Number | The line where the error ends in the AsyncAPI document. | +| definition.validationErrors.endColumn | Number | The column where the error ends in the AsyncAPI document. | +| definition.validationErrors.endOffset | Number | The offset (starting from the beginning of the document) where the error ends in the AsyncAPI document. | +| [definition.location] | Object | Error location details after trying to parse an invalid JSON or YAML document. | +| definition.location.startLine | Number | The line of the YAML/JSON document where the error starts. | +| definition.location.startColumn | Number | The column of the YAML/JSON document where the error starts. | +| definition.location.startOffset | Number | The offset (starting from the beginning of the document) where the error starts in the YAML/JSON AsyncAPI document. | +| [definition.refs] | Array.<Object> | Error details after trying to resolve $ref's. | +| definition.refs.title | String | A validation error message. | +| definition.refs.jsonPointer | String | The path to the field that contains the error. Uses JSON Pointer format. | +| definition.refs.startLine | Number | The line where the error starts in the AsyncAPI document. | +| definition.refs.startColumn | Number | The column where the error starts in the AsyncAPI document. | +| definition.refs.startOffset | Number | The offset (starting from the beginning of the document) where the error starts in the AsyncAPI document. | +| definition.refs.endLine | Number | The line where the error ends in the AsyncAPI document. | +| definition.refs.endColumn | Number | The column where the error ends in the AsyncAPI document. | +| definition.refs.endOffset | Number | The offset (starting from the beginning of the document) where the error ends in the AsyncAPI document. | + + + +### parserError.toJS() +Returns a JS object representation of the error. + +**Kind**: instance method of [ParserError](#ParserError) ## AsyncAPIDocument ⇐ [Base](#Base) @@ -1694,3 +1773,71 @@ Implements functions to deal with a Tag object. ### tag.json() ⇒ Any **Kind**: instance method of [Tag](#Tag) + + +## assignUidToParameterSchemas(doc) +Assign parameter keys as uid for the parameter schema. + +**Kind**: global function + +| Param | Type | +| --- | --- | +| doc | [AsyncAPIDocument](#AsyncAPIDocument) | + + + +## assignUidToComponentSchemas(doc) +Assign uid to component schemas. + +**Kind**: global function + +| Param | Type | +| --- | --- | +| doc | [AsyncAPIDocument](#AsyncAPIDocument) | + + + +## assignNameToAnonymousMessages(doc) +Assign anonymous names to nameless messages. + +**Kind**: global function + +| Param | Type | +| --- | --- | +| doc | [AsyncAPIDocument](#AsyncAPIDocument) | + + + +## recursiveSchema(schema, callback(schema)) +Recursively go through each schema and execute callback. + +**Kind**: global function + +| Param | Type | Description | +| --- | --- | --- | +| schema | [Schema](#Schema) | found. | +| callback(schema) | function | the function that is called foreach schema found. schema {Schema}: the found schema. | + + + +## schemaDocument(doc, callback(schema)) +Go through each channel and for each parameter, and message payload and headers recursively call the callback for each schema. + +**Kind**: global function + +| Param | Type | Description | +| --- | --- | --- | +| doc | [AsyncAPIDocument](#AsyncAPIDocument) | | +| callback(schema) | function | the function that is called foreach schema found. schema {Schema}: the found schema. | + + + +## assignIdToAnonymousSchemas(doc) +Gives schemas id to all anonymous schemas. + +**Kind**: global function + +| Param | Type | +| --- | --- | +| doc | [AsyncAPIDocument](#AsyncAPIDocument) | + diff --git a/README.md b/README.md index 314957133..a7bcc7268 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,25 @@ Head over to [asyncapi/openapi-schema-parser](https://www.github.com/asyncapi/op Head over to [asyncapi/raml-dt-schema-parser](https://www.github.com/asyncapi/raml-dt-schema-parser) for more information. +### Error types + +This package throws a bunch of different error types. All errors contain a `type` (prefixed by this repo URL) and a `title` field. The following table describes all the errors and the extra fields they include: + +|Type|Extra Fields|Description| +|---|---|---| +|`null-or-falsey-document`| None | The AsyncAPI document is null or a JS "falsey" value. +|`invalid-document-type`| None | The AsyncAPI document is not a string nor a JS object. +|`invalid-json`| `detail`, `location` | The AsyncAPI document is not valid JSON. +|`invalid-yaml`| `detail`, `location` | The AsyncAPI document is not valid YAML. +|`impossible-to-convert-to-json`|`detail`|Internally, this parser only handles JSON so it tries to immediately convert the YAML to JSON. This error means this process failed. +|`missing-asyncapi-field`|`parsedJSON`|The AsyncAPI document doesn't have the mandatory `asyncapi` field. +|`unsupported-version`|`detail`, `parsedJSON`, `validationErrors`|The version of the `asyncapi` field is not supported. Typically, this means that you're using a version below 2.0.0. +|`dereference-error`|`parsedJSON`, `refs`|This means the parser tried to resolve and dereference $ref's and the process failed. Typically, this means the $ref it's pointing to doesn't exist. +|`unexpected-error`|`parsedJSON`|We have our code covered with try/catch blocks and you should never see this error. If you see it, please open an issue to let us know. +|`validation-errors`|`parsedJSON`, `validationErrors`|The AsyncAPI document contains errors. See `validationErrors` for more information. + +For more information about the `ParserError` class, [check out the documentation](./API.md#new_ParserError_new). + ### Develop 1. Run tests with `npm test` @@ -89,4 +108,4 @@ Head over to [asyncapi/raml-dt-schema-parser](https://www.github.com/asyncapi/ra ## Contributing -Read [CONTRIBUTING](CONTRIBUTING.md) guide. \ No newline at end of file +Read [CONTRIBUTING](CONTRIBUTING.md) guide. diff --git a/lib/errors/parser-error-no-js.js b/lib/errors/parser-error-no-js.js deleted file mode 100644 index 3650f83b7..000000000 --- a/lib/errors/parser-error-no-js.js +++ /dev/null @@ -1,6 +0,0 @@ -const ParserError = require('./parser-error'); - -class ParserErrorNoJS extends ParserError { -} - -module.exports = ParserErrorNoJS; diff --git a/lib/errors/parser-error-unsupported-version.js b/lib/errors/parser-error-unsupported-version.js deleted file mode 100644 index fdba2dc91..000000000 --- a/lib/errors/parser-error-unsupported-version.js +++ /dev/null @@ -1,9 +0,0 @@ -const ParserError = require('./parser-error'); - -class ParserErrorUnsupportedVersion extends ParserError { - constructor(e, json) { - super(e, json); - } -} - -module.exports = ParserErrorUnsupportedVersion; diff --git a/lib/errors/parser-error.js b/lib/errors/parser-error.js index d08803087..3ccab961e 100644 --- a/lib/errors/parser-error.js +++ b/lib/errors/parser-error.js @@ -1,26 +1,62 @@ -class ParserError extends Error { - constructor(e, json, errors) { - super(e); - - let msg; - - if (typeof e === 'string') { - msg = e; - } - if (typeof e.message === 'string') { - msg = e.message; - } - - if (json) { - this.parsedJSON = json; - } +const ERROR_URL_PREFIX = 'https://github.com/asyncapi/parser-js/'; - if (errors) { - this.errors = errors; - } +/** + * Represents an error while trying to parse an AsyncAPI document. + */ +class ParserError extends Error { + /** + * Instantiates an error + * @param {Object} definition + * @param {String} definition.type The type of the error. + * @param {String} definition.title The message of the error. + * @param {String} [definition.detail] A string containing more detailed information about the error. + * @param {Object} [definition.parsedJSON] The resulting JSON after YAML transformation. Or the JSON object if the this was the initial format. + * @param {Object[]} [definition.validationErrors] The errors resulting from the validation. For more information, see https://www.npmjs.com/package/better-ajv-errors. + * @param {String} definition.validationErrors.title A validation error message. + * @param {String} definition.validationErrors.jsonPointer The path to the field that contains the error. Uses JSON Pointer format. + * @param {Number} definition.validationErrors.startLine The line where the error starts in the AsyncAPI document. + * @param {Number} definition.validationErrors.startColumn The column where the error starts in the AsyncAPI document. + * @param {Number} definition.validationErrors.startOffset The offset (starting from the beginning of the document) where the error starts in the AsyncAPI document. + * @param {Number} definition.validationErrors.endLine The line where the error ends in the AsyncAPI document. + * @param {Number} definition.validationErrors.endColumn The column where the error ends in the AsyncAPI document. + * @param {Number} definition.validationErrors.endOffset The offset (starting from the beginning of the document) where the error ends in the AsyncAPI document. + * @param {Object} [definition.location] Error location details after trying to parse an invalid JSON or YAML document. + * @param {Number} definition.location.startLine The line of the YAML/JSON document where the error starts. + * @param {Number} definition.location.startColumn The column of the YAML/JSON document where the error starts. + * @param {Number} definition.location.startOffset The offset (starting from the beginning of the document) where the error starts in the YAML/JSON AsyncAPI document. + * @param {Object[]} [definition.refs] Error details after trying to resolve $ref's. + * @param {String} definition.refs.title A validation error message. + * @param {String} definition.refs.jsonPointer The path to the field that contains the error. Uses JSON Pointer format. + * @param {Number} definition.refs.startLine The line where the error starts in the AsyncAPI document. + * @param {Number} definition.refs.startColumn The column where the error starts in the AsyncAPI document. + * @param {Number} definition.refs.startOffset The offset (starting from the beginning of the document) where the error starts in the AsyncAPI document. + * @param {Number} definition.refs.endLine The line where the error ends in the AsyncAPI document. + * @param {Number} definition.refs.endColumn The column where the error ends in the AsyncAPI document. + * @param {Number} definition.refs.endOffset The offset (starting from the beginning of the document) where the error ends in the AsyncAPI document. + */ + constructor(def) { + super(); + buildError(def, this); + this.message = def.title; + } - this.message = msg; + /** + * Returns a JS object representation of the error. + */ + toJS() { + return buildError(this, {}); } } +const buildError = (from, to) => { + to.type = from.type.startsWith(ERROR_URL_PREFIX) ? from.type : `${ERROR_URL_PREFIX}${from.type}`; + to.title = from.title; + if (from.detail) to.detail = from.detail; + if (from.validationErrors) to.validationErrors = from.validationErrors; + if (from.parsedJSON) to.parsedJSON = from.parsedJSON; + if (from.location) to.location = from.location; + if (from.refs) to.refs = from.refs; + return to; +}; + module.exports = ParserError; diff --git a/lib/json-parse.js b/lib/json-parse.js new file mode 100644 index 000000000..7baee1aed --- /dev/null +++ b/lib/json-parse.js @@ -0,0 +1,38 @@ +module.exports = (txt, reviver, context = 20) => { + try { + return JSON.parse(txt, reviver) + } catch (e) { + if (typeof txt !== 'string') { + const isEmptyArray = Array.isArray(txt) && txt.length === 0 + const errorMessage = 'Cannot parse ' + + (isEmptyArray ? 'an empty array' : String(txt)) + throw new TypeError(errorMessage) + } + const syntaxErr = e.message.match(/^Unexpected token.*position\s+(\d+)/i) + const errIdx = syntaxErr + ? +syntaxErr[1] + : e.message.match(/^Unexpected end of JSON.*/i) + ? txt.length - 1 + : null + if (errIdx != null) { + const start = errIdx <= context + ? 0 + : errIdx - context + const end = errIdx + context >= txt.length + ? txt.length + : errIdx + context + e.message += ` while parsing near '${ + start === 0 ? '' : '...' + }${txt.slice(start, end)}${ + end === txt.length ? '' : '...' + }'` + } else { + e.message += ` while parsing '${txt.slice(0, context * 2)}'` + } + e.offset = errIdx + const lines = txt.substr(0, errIdx).split('\n'); + e.startLine = lines.length; + e.startColumn = lines[lines.length - 1].length; + throw e + } +} diff --git a/lib/parser.js b/lib/parser.js index 981b23405..9d081b1c9 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -1,12 +1,11 @@ +const path = require('path'); const Ajv = require('ajv'); const fetch = require('node-fetch'); const asyncapi = require('@asyncapi/specs'); const $RefParser = require('json-schema-ref-parser'); const mergePatch = require('tiny-merge-patch').apply; const ParserError = require('./errors/parser-error'); -const ParserErrorNoJS = require('./errors/parser-error-no-js'); -const ParserErrorUnsupportedVersion = require('./errors/parser-error-unsupported-version'); -const { toJS } = require('./utils'); +const { toJS, findRefs, getLocationOf, improveAjvErrors } = require('./utils'); const AsyncAPIDocument = require('./models/asyncapi'); const DEFAULT_SCHEMA_FORMAT = 'application/vnd.aai.asyncapi;version=2.0.0'; @@ -21,8 +20,6 @@ module.exports = { parseFromUrl, registerSchemaParser, ParserError, - ParserErrorNoJS, - ParserErrorUnsupportedVersion, AsyncAPIDocument, }; @@ -32,7 +29,7 @@ module.exports = { * @name module:Parser#parse * @param {String} asyncapiYAMLorJSON An AsyncAPI document in JSON or YAML format. * @param {Object} [options] Configuration options. - * @param {String} [options.path] Path to the AsyncAPI document. It will be used to resolve relative references. + * @param {String} [options.path] Path to the AsyncAPI document. It will be used to resolve relative references. Defaults to current working dir. * @param {Object} [options.parse] Options object to pass to {@link https://apidevtools.org/json-schema-ref-parser/docs/options.html|json-schema-ref-parser}. * @param {Object} [options.resolve] Options object to pass to {@link https://apidevtools.org/json-schema-ref-parser/docs/options.html|json-schema-ref-parser}. * @param {Object} [options.dereference] Options object to pass to {@link https://apidevtools.org/json-schema-ref-parser/docs/options.html|json-schema-ref-parser}. @@ -40,42 +37,67 @@ module.exports = { * @returns {Promise} The parsed AsyncAPI document. */ async function parse(asyncapiYAMLorJSON, options = {}) { - let js; + let parsedJSON; + let initialFormat; + + options.path = options.path || `${process.cwd()}${path.sep}`; try { - js = toJS(asyncapiYAMLorJSON); + ({ initialFormat, parsedJSON } = toJS(asyncapiYAMLorJSON)); - if (typeof js !== 'object') { - throw new ParserErrorNoJS('Could not convert AsyncAPI to JSON.'); + if (typeof parsedJSON !== 'object') { + throw new ParserError({ + type: 'impossible-to-convert-to-json', + title: 'Could not convert AsyncAPI to JSON.', + detail: 'Most probably the AsyncAPI document contains invalid YAML or YAML features not supported in JSON.' + }); } - if (!js.asyncapi || !asyncapi[js.asyncapi]) { - throw new ParserErrorUnsupportedVersion(`AsyncAPI version is missing or unsupported: ${js.asyncapi}.`, js); + if (!parsedJSON.asyncapi) { + throw new ParserError({ + type: 'missing-asyncapi-field', + title: 'The `asyncapi` field is missing.', + parsedJSON, + }); + } + + if (parsedJSON.asyncapi.startsWith('1.') || !asyncapi[parsedJSON.asyncapi]) { + throw new ParserError({ + type: 'unsupported-version', + title: `Version ${parsedJSON.asyncapi} is not supported.`, + detail: 'Please use latest version of the specification.', + parsedJSON, + validationErrors: [getLocationOf('/asyncapi', asyncapiYAMLorJSON, initialFormat)], + }); } if (options.applyTraits === undefined) options.applyTraits = true; - if (options.path) { - js = await $RefParser.dereference(options.path, js, { + try { + parsedJSON = await $RefParser.dereference(options.path, parsedJSON, { parse: options.parse, resolve: options.resolve, dereference: options.dereference, }); - } else { - js = await $RefParser.dereference(js, { - parse: options.parse, - resolve: options.resolve, - dereference: options.dereference, + } catch (err) { + throw new ParserError({ + type: 'dereference-error', + title: err.message, + parsedJSON, + refs: findRefs(parsedJSON, err.path, options.path, initialFormat, asyncapiYAMLorJSON), }); } } catch (e) { if (e instanceof ParserError) throw e; - if (e instanceof ParserErrorNoJS) throw e; - if (e instanceof ParserErrorUnsupportedVersion) throw e; - throw new ParserError(e.message, js); + throw new ParserError({ + type: 'unexpected-error', + title: e.message, + parsedJSON, + }); } const ajv = new Ajv({ + jsonPointers: true, allErrors: true, schemaId: 'id', logger: false, @@ -84,16 +106,26 @@ async function parse(asyncapiYAMLorJSON, options = {}) { ajv.addMetaSchema(require('ajv/lib/refs/json-schema-draft-04.json')); try { - const validate = ajv.compile(asyncapi[js.asyncapi]); - const valid = validate(js); - if (!valid) throw new ParserError('Invalid AsyncAPI document', js, validate.errors); + const validate = ajv.compile(asyncapi[parsedJSON.asyncapi]); + const valid = validate(parsedJSON); + if (!valid) throw new ParserError({ + type: 'validation-errors', + title: 'There were errors validating the AsyncAPI document.', + parsedJSON, + validationErrors: improveAjvErrors(validate.errors, asyncapiYAMLorJSON, initialFormat), + }); - await iterateDocument(js, options); + await iterateDocument(parsedJSON, options); } catch (e) { - throw new ParserError(e, e.parsedJSON, e.errors); + if (e instanceof ParserError) throw e; + throw new ParserError({ + type: 'unexpected-error', + title: e.message, + parsedJSON, + }); } - return new AsyncAPIDocument(js); + return new AsyncAPIDocument(parsedJSON); } /** diff --git a/lib/utils.js b/lib/utils.js index 967115293..36d88c902 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,27 +1,74 @@ +const path = require('path'); const YAML = require('js-yaml'); +const { yamlAST, loc } = require('@fmvilas/pseudo-yaml-ast'); +const jsonAST = require('json-to-ast'); +const jsonParseBetterErrors = require('../lib/json-parse'); +const ParserError = require('./errors/parser-error'); -module.exports.toJS = (asyncapiYAMLorJSON) => { +const utils = module.exports; + +utils.toJS = (asyncapiYAMLorJSON) => { if (!asyncapiYAMLorJSON) { - throw new Error(`Document can't be null, false or empty.`); + throw new ParserError({ + type: 'null-or-falsey-document', + title: `Document can't be null or falsey.`, + }); } - if (typeof asyncapiYAMLorJSON === 'object') { - return asyncapiYAMLorJSON; + if (asyncapiYAMLorJSON.constructor && asyncapiYAMLorJSON.constructor.name === 'Object') { + return { + initialFormat: 'js', + parsedJSON: asyncapiYAMLorJSON, + }; + } + + if (typeof asyncapiYAMLorJSON !== 'string') { + throw new ParserError({ + type: 'invalid-document-type', + title: 'The AsyncAPI document has to be either a string or a JS object.', + }); } - try { - return JSON.parse(asyncapiYAMLorJSON); - } catch (e) { + if (asyncapiYAMLorJSON.trimLeft().startsWith('{')) { try { - return YAML.safeLoad(asyncapiYAMLorJSON); + return { + initialFormat: 'json', + parsedJSON: jsonParseBetterErrors(asyncapiYAMLorJSON), + }; + } catch (e) { + throw new ParserError({ + type: 'invalid-json', + title: 'The provided JSON is not valid.', + detail: e.message, + location: { + startOffset: e.offset, + startLine: e.startLine, + startColumn: e.startColumn, + }, + }); + } + } else { + try { + return { + initialFormat: 'yaml', + parsedJSON: YAML.safeLoad(asyncapiYAMLorJSON), + }; } catch (err) { - err.message = `Document has to be either JSON or YAML: ${err.message}`; - throw err; + throw new ParserError({ + type: 'invalid-yaml', + title: 'The provided YAML is not valid.', + detail: err.message, + location: { + startOffset: err.mark.position, + startLine: err.mark.line + 1, + startColumn: err.mark.column + 1, + }, + }); } } }; -module.exports.createMapOfType = (obj, Type) => { +utils.createMapOfType = (obj, Type) => { const result = {}; if (!obj) return result; @@ -32,13 +79,13 @@ module.exports.createMapOfType = (obj, Type) => { return result; }; -module.exports.getMapKeyOfType = (obj, key, Type) => { +utils.getMapKeyOfType = (obj, key, Type) => { if (!obj) return null; if (!obj[key]) return null; return new Type(obj[key]); }; -module.exports.addExtensions = (obj) => { +utils.addExtensions = (obj) => { obj.prototype.extensions = function () { const result = {}; Object.keys(this._json).forEach(key => { @@ -57,3 +104,110 @@ module.exports.addExtensions = (obj) => { return obj; }; + +utils.findRefs = (json, absolutePath, relativeDir, initialFormat, asyncapiYAMLorJSON) => { + const relativePath = path.relative(relativeDir, absolutePath); + let refs = []; + + traverse(json, (key, value, scope) => { + if (key === '$ref' && value === relativePath) { + refs.push({ location: [...scope, '$ref'] }); + } + }); + + if (!refs.length) return refs; + if (initialFormat === 'js') { + return refs.map(ref => ({ jsonPointer: `/${ref.location.join('/')}` })); + } + + if (initialFormat === 'yaml') { + const pseudoAST = yamlAST(asyncapiYAMLorJSON); + refs = refs.map(ref => findLocationOf(ref.location, pseudoAST, initialFormat)); + } else if (initialFormat === 'json') { + const ast = jsonAST(asyncapiYAMLorJSON); + refs = refs.map(ref => findLocationOf(ref.location, ast, initialFormat)); + } + + return refs; +}; + +utils.getLocationOf = (jsonPointer, asyncapiYAMLorJSON, initialFormat) => { + const ast = getAST(asyncapiYAMLorJSON, initialFormat); + if (!ast) return { jsonPointer }; + return findLocationOf(jsonPointerToArray(jsonPointer), ast, initialFormat); +} + +utils.improveAjvErrors = (errors, asyncapiYAMLorJSON, initialFormat) => { + const ast = getAST(asyncapiYAMLorJSON, initialFormat); + return errors.map(error => { + const defaultLocation = { jsonPointer: error.dataPath || '/' }; + return { + title: `${error.dataPath || '/'} ${error.message}`, + location: ast ? findLocationOf(jsonPointerToArray(error.dataPath), ast, initialFormat) : defaultLocation, + }; + }); +}; + +const jsonPointerToArray = jsonPointer => (jsonPointer || '/').split('/').splice(1); + +const getAST = (asyncapiYAMLorJSON, initialFormat) => { + if (initialFormat === 'yaml') { + return yamlAST(asyncapiYAMLorJSON); + } else if (initialFormat === 'json') { + return jsonAST(asyncapiYAMLorJSON); + } +}; + +const findLocationOf = (keys, ast, initialFormat) => { + let node; + let info; + + if (initialFormat === 'js') return { jsonPointer: `/${keys.join('/')}` }; + + if (initialFormat === 'yaml') { + node = findNode(ast, keys); + if (!node) return { jsonPointer: `/${keys.join('/')}` }; + info = node[loc]; + } else if (initialFormat === 'json') { + node = findNodeInAST(ast, keys); + if (!node) return { jsonPointer: `/${keys.join('/')}` }; + info = node.loc; + } + + return { + jsonPointer: `/${keys.join('/')}`, + startLine: info.start.line, + startColumn: info.start.column + 1, + startOffset: info.start.offset, + endLine: info.end ? info.end.line : undefined, + endColumn: info.end ? info.end.column + 1 : undefined, + endOffset: info.end ? info.end.offset : undefined, + }; +} + +const findNode = (obj, location) => { + for (let key of location) { + obj = obj[key]; + } + return obj; +}; + +const findNodeInAST = (ast, location) => { + let obj = ast; + for (let key of location) { + if (!Array.isArray(obj.children)) return; + const child = obj.children.find(c => c && c.type === 'Property' && c.key && c.key.value === key); + if (!child) return; + obj = child.value; + } + return obj; +}; + +const traverse = function (o, fn, scope = []) { + for (let i in o) { + fn.apply(this, [i, o[i], scope]); + if (o[i] !== null && typeof o[i] === "object") { + traverse(o[i], fn, scope.concat(i)); + } + } +} diff --git a/package-lock.json b/package-lock.json index 924988678..7d0717012 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,9 +38,9 @@ } }, "@babel/parser": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.8.4.tgz", - "integrity": "sha512-0fKu/QqildpXmPVaRBoXOlyBb3MC+J0A66x97qEfLOMkn3u6nfY5esWogQwi/K0BjASYy4DbnsEWnpNL6qT5Mw==", + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.4.tgz", + "integrity": "sha512-bC49otXX6N0/VYhgOMh4gnP26E9xnDZK3TmbNpxYzzz9BQLBosQwfyOe9/cXUU3txYhTzLCbcqd5c8y/OmCjHA==", "dev": true }, "@babel/runtime": { @@ -52,6 +52,14 @@ "regenerator-runtime": "^0.13.4" } }, + "@fmvilas/pseudo-yaml-ast": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@fmvilas/pseudo-yaml-ast/-/pseudo-yaml-ast-0.3.0.tgz", + "integrity": "sha512-4D1auqEVkQ99Pf/9vnYQHqQsjKBZjkFszY5ApqwdjlgFaBlrOktn3rAvzNPEAl6M/tnZszHqYQyj0XHemp9D4g==", + "requires": { + "yaml-ast-parser": "0.0.43" + } + }, "@nodelib/fs.scandir": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz", @@ -1338,6 +1346,11 @@ } } }, + "code-error-fragment": { + "version": "0.0.230", + "resolved": "https://registry.npmjs.org/code-error-fragment/-/code-error-fragment-0.0.230.tgz", + "integrity": "sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw==" + }, "collect-all": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-all/-/collect-all-1.0.3.tgz", @@ -1454,6 +1467,13 @@ } } }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "optional": true + }, "common-sequence": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/common-sequence/-/common-sequence-2.0.0.tgz", @@ -1967,9 +1987,9 @@ } }, "entities": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", - "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz", + "integrity": "sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw==", "dev": true }, "env-ci": { @@ -2523,6 +2543,11 @@ "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", "dev": true }, + "grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==" + }, "growl": { "version": "1.10.5", "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", @@ -2530,24 +2555,18 @@ "dev": true }, "handlebars": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.3.tgz", - "integrity": "sha512-SRGwSYuNfx8DwHD/6InAPzD6RgeruWLT+B8e8a7gGs8FWgHzlExpTFMEq2IA6QpAfOClpKHy6+8IqTjeBCu6Kg==", + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.6.tgz", + "integrity": "sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA==", "dev": true, "requires": { + "minimist": "^1.2.5", "neo-async": "^2.6.0", - "optimist": "^0.6.1", "source-map": "^0.6.1", - "uglify-js": "^3.1.4" + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" }, "dependencies": { - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true - }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -2555,14 +2574,13 @@ "dev": true }, "uglify-js": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.8.0.tgz", - "integrity": "sha512-ugNSTT8ierCsDHso2jkBHXYrU8Y5/fY2ZUprfrJUiD7YpuFvV4jODLFmb3h4btQjqr5Nh4TX4XtgDfCU1WdioQ==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.9.0.tgz", + "integrity": "sha512-j5wNQBWaql8gr06dOUrfaohHlscboQZ9B8sNsoK5o4sBjm7Ht9dxSbrMXyktQpA16Acaij8AcoozteaPYZON0g==", "dev": true, "optional": true, "requires": { - "commander": "~2.20.3", - "source-map": "~0.6.1" + "commander": "~2.20.3" } } } @@ -2984,25 +3002,25 @@ } }, "jsdoc": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-3.6.3.tgz", - "integrity": "sha512-Yf1ZKA3r9nvtMWHO1kEuMZTlHOF8uoQ0vyo5eH7SQy5YeIiHM+B0DgKnn+X6y6KDYZcF7G2SPkKF+JORCXWE/A==", + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-3.6.4.tgz", + "integrity": "sha512-3G9d37VHv7MFdheviDCjUfQoIjdv4TC5zTTf5G9VODLtOnVS6La1eoYBDlbWfsRT3/Xo+j2MIqki2EV12BZfwA==", "dev": true, "requires": { - "@babel/parser": "^7.4.4", - "bluebird": "^3.5.4", + "@babel/parser": "^7.9.4", + "bluebird": "^3.7.2", "catharsis": "^0.8.11", "escape-string-regexp": "^2.0.0", - "js2xmlparser": "^4.0.0", + "js2xmlparser": "^4.0.1", "klaw": "^3.0.0", - "markdown-it": "^8.4.2", - "markdown-it-anchor": "^5.0.2", - "marked": "^0.7.0", - "mkdirp": "^0.5.1", + "markdown-it": "^10.0.0", + "markdown-it-anchor": "^5.2.7", + "marked": "^0.8.2", + "mkdirp": "^1.0.4", "requizzle": "^0.2.3", - "strip-json-comments": "^3.0.1", + "strip-json-comments": "^3.1.0", "taffydb": "2.6.2", - "underscore": "~1.9.1" + "underscore": "~1.10.2" }, "dependencies": { "escape-string-regexp": { @@ -3010,6 +3028,18 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "dev": true + }, + "marked": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-0.8.2.tgz", + "integrity": "sha512-EGwzEeCcLniFX51DhTpmTom+dSA/MG/OBUDjnWtHbEnjAH180VzUeAw+oE4+Zv+CoYBWyRlYOTR0N8SO9R1PVw==", + "dev": true + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true } } }, @@ -3124,6 +3154,15 @@ "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", "dev": true }, + "json-to-ast": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/json-to-ast/-/json-to-ast-2.1.0.tgz", + "integrity": "sha512-W9Lq347r8tA1DfMvAGn9QNcgYm4Wm7Yc+k8e6vezpMnRT+NHbtlxgNBXRVjXe9YM6eTn6+p/MKOlV/aABJcSnQ==", + "requires": { + "code-error-fragment": "0.0.230", + "grapheme-splitter": "^1.0.4" + } + }, "jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -3320,22 +3359,22 @@ "dev": true }, "markdown-it": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-8.4.2.tgz", - "integrity": "sha512-GcRz3AWTqSUphY3vsUqQSFMbgR38a4Lh3GWlHRh/7MRwz8mcu9n2IO7HOh+bXHrR9kOPDl5RNCaEsrneb+xhHQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz", + "integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==", "dev": true, "requires": { "argparse": "^1.0.7", - "entities": "~1.1.1", + "entities": "~2.0.0", "linkify-it": "^2.0.0", "mdurl": "^1.0.1", "uc.micro": "^1.0.5" } }, "markdown-it-anchor": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-5.2.5.tgz", - "integrity": "sha512-xLIjLQmtym3QpoY9llBgApknl7pxAcN3WDRc2d3rwpl+/YvDZHPmKscGs+L6E05xf2KrCXPBvosWt7MZukwSpQ==", + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-5.2.7.tgz", + "integrity": "sha512-REFmIaSS6szaD1bye80DMbp7ePwsPNvLTR5HunsUcZ0SG0rWJQ+Pz24R4UlTKtjKBPhxo0v0tOBDYjZQQknW8Q==", "dev": true }, "marked": { @@ -3527,9 +3566,9 @@ } }, "minimist": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", "dev": true }, "minimist-options": { @@ -3543,20 +3582,12 @@ } }, "mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", "dev": true, "requires": { - "minimist": "0.0.8" - }, - "dependencies": { - "minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true - } + "minimist": "^1.2.5" } }, "mkdirp2": { @@ -3610,6 +3641,21 @@ "path-is-absolute": "^1.0.0" } }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, "strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -7347,24 +7393,6 @@ "resolved": "https://registry.npmjs.org/ono/-/ono-6.0.1.tgz", "integrity": "sha512-5rdYW/106kHqLeG22GE2MHKq+FlsxMERZev9DCzQX1zwkxnFwBivSn5i17a5O/rDmOJOdf4Wyt80UZljzx9+DA==" }, - "optimist": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", - "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", - "dev": true, - "requires": { - "minimist": "~0.0.1", - "wordwrap": "~0.0.2" - }, - "dependencies": { - "minimist": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", - "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", - "dev": true - } - } - }, "os-browserify": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", @@ -8798,9 +8826,9 @@ "dev": true }, "strip-json-comments": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.0.1.tgz", - "integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.0.tgz", + "integrity": "sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w==", "dev": true }, "subarg": { @@ -9130,9 +9158,9 @@ } }, "underscore": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.2.tgz", - "integrity": "sha512-D39qtimx0c1fI3ya1Lnhk3E9nONswSKhnffBI0gME9C99fYOkNi04xs8K6pePLhvl1frbDemkaBQ5ikWllR2HQ==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.10.2.tgz", + "integrity": "sha512-N4P+Q/BuyuEKFJ43B9gYuOj4TQUHXX+j2FqguVOpjkssLUUrnJofCcBccJSCoeturDoZU6GorDTHSvUDlSQbTg==", "dev": true }, "unique-string": { @@ -9270,9 +9298,9 @@ } }, "wordwrap": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", - "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", "dev": true }, "wordwrapjs": { @@ -9363,6 +9391,11 @@ "@babel/runtime": "^7.8.7" } }, + "yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==" + }, "yargs": { "version": "13.3.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.0.tgz", diff --git a/package.json b/package.json index 2e180db58..f130c3c59 100644 --- a/package.json +++ b/package.json @@ -43,10 +43,12 @@ "semantic-release": "^17.0.4" }, "dependencies": { + "@fmvilas/pseudo-yaml-ast": "^0.3.0", "ajv": "^6.10.1", "@asyncapi/specs": "^2.7.1", "js-yaml": "^3.13.1", "json-schema-ref-parser": "^7.1.0", + "json-to-ast": "^2.1.0", "node-fetch": "^2.6.0", "tiny-merge-patch": "^0.1.2" }, diff --git a/test/asyncapi.json b/test/asyncapi.json index 485b04134..e12f4756c 100644 --- a/test/asyncapi.json +++ b/test/asyncapi.json @@ -19,5 +19,26 @@ } } } + }, "components": { + "messages": { + "testMessage": { + "payload": { + "$ref": "#/components/schemas/testSchema" + } + } + }, + "schemas": { + "testSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "test": { + "$ref": "refs/refed.yaml" + } + } + } + } } } diff --git a/test/invalid-asyncapi.json b/test/invalid-asyncapi.json new file mode 100644 index 000000000..207669cfe --- /dev/null +++ b/test/invalid-asyncapi.json @@ -0,0 +1,43 @@ +{ + "asyncapi": "2.0.0", + "info": { + "version": "1.0.0" + }, + "channels": { + "mychannel": { + "publish": { + "message": { + "payload": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + } + }, "components": { + "messages": { + "testMessage": { + "payload": { + "$ref": "#/components/schemas/testSchema" + } + } + }, + "schemas": { + "testSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "test": { + "$ref": "refs/refed.yaml" + } + } + } + } + } +} diff --git a/test/invalid-asyncapi.yaml b/test/invalid-asyncapi.yaml new file mode 100644 index 000000000..3d853145e --- /dev/null +++ b/test/invalid-asyncapi.yaml @@ -0,0 +1,36 @@ +asyncapi: 2.0.0 +info: + version: '1.0.0' +channels: + mychannel: + publish: + traits: + - $ref: '#/components/operationTraits/docs' + externalDocs: + x-extension: true + url: 'https://irrelevant.com' + message: + $ref: '#/components/messages/testMessage' + +components: + messages: + testMessage: + traits: + - $ref: '#/components/messageTraits/extension' + payload: + $ref: '#/components/schemas/testSchema' + schemas: + testSchema: + type: object + properties: + name: + type: string + test: + $ref: 'refs/refed.yaml' + messageTraits: + extension: + x-some-extension: 'some extension' + operationTraits: + docs: + externalDocs: + url: https://company.com/docs diff --git a/test/parse_test.js b/test/parse_test.js index 11889041a..5568d88f2 100644 --- a/test/parse_test.js +++ b/test/parse_test.js @@ -10,64 +10,286 @@ 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 inputJSON = fs.readFileSync(path.resolve(__dirname, "./asyncapi.json"), 'utf8'); +const invalidAsyncapiYAML = fs.readFileSync(path.resolve(__dirname, "./invalid-asyncapi.yaml"), 'utf8'); +const invalidAsyncpiJSON = fs.readFileSync(path.resolve(__dirname, "./invalid-asyncapi.json"), '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","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 invalidYamlOutput = '{"asyncapi":"2.0.0","info":{"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"}}}}}}}}},"components":{"messages":{"testMessage":{"traits":[{"x-some-extension":"some extension"}],"payload":{"type":"object","properties":{"name":{"type":"string"},"test":{"type":"object","properties":{"testing":{"type":"string"}}}}}}},"schemas":{"testSchema":{"type":"object","properties":{"name":{"type":"string"},"test":{"type":"object","properties":{"testing":{"type":"string"}}}}}},"messageTraits":{"extension":{"x-some-extension":"some extension"}},"operationTraits":{"docs":{"externalDocs":{"url":"https://company.com/docs"}}}}}' +const invalidJsonOutput = '{"asyncapi":"2.0.0","info":{"version":"1.0.0"},"channels":{"mychannel":{"publish":{"message":{"payload":{"type":"object","properties":{"name":{"type":"string"}}}}}}},"components":{"messages":{"testMessage":{"payload":{"type":"object","properties":{"name":{"type":"string"},"test":{"type":"object","properties":{"testing":{"type":"string"}}}}}}},"schemas":{"testSchema":{"type":"object","properties":{"name":{"type":"string"},"test":{"type":"object","properties":{"testing":{"type":"string"}}}}}}}}' +const outputJsonWithRefs = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"traits":[{"$ref":"#/components/operationTraits/docs"}],"externalDocs":{"x-extension":true,"url":"https://irrelevant.com"},"message":{"$ref":"#/components/messages/testMessage"}}}},"components":{"messages":{"testMessage":{"traits":[{"$ref":"#/components/messageTraits/extension"}],"payload":{"$ref":"#/components/schemas/testSchema"}}},"schemas":{"testSchema":{"type":"object","properties":{"name":{"type":"string"},"test":{"$ref":"refs/refed.yaml"}}}},"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\'' }]; -describe('parse()', function () { +const checkErrorTypeAndMessage = async (fn, type, message) => { + try { + await fn(); + throw 'should not be reachable'; + } catch (e) { + expect(e instanceof ParserError).to.equal(true); + expect(e).to.have.own.property('type', type); + expect(e).to.have.own.property('message', message); + } +} + +const checkErrorParsedJSON = async (fn, parsedJSON) => { + try { + await fn(); + throw 'should not be reachable'; + } catch (e) { + expect(JSON.stringify(e.parsedJSON)).to.equal(parsedJSON); + } +} + +describe.only('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 () { + 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 when asyncapi is not valid', async function () { try { await parser.parse(invalidAsyncAPI); - } catch (e) { - await expect(e.errors).to.deep.equal(errorsOfInvalidAsyncAPI); + } catch(e) { + await expect(e.type).to.equal('https://github.com/asyncapi/parser-js/validation-errors'); + await expect(e.title).to.equal('There were errors validating the AsyncAPI document.'); + await expect(e.validationErrors).to.deep.equal([{ + title: '/info should have required property \'title\'', + location: { jsonPointer: '/info' } + }, + { + title: '/info should have required property \'version\'', + location: { jsonPointer: '/info' } + }, + { + title: '/ should have required property \'channels\'', + location: { jsonPointer: '/' } + }]); await expect(e.parsedJSON).to.deep.equal(invalidAsyncAPI); } }); - - it('should not forward AsyncAPI json when it is not possible to convert it', async function () { + + it('should fail when asyncapi is not valid (yaml)', async function () { + try { + await parser.parse(invalidAsyncapiYAML, { path: __filename }); + } catch(e) { + await expect(e.type).to.equal('https://github.com/asyncapi/parser-js/validation-errors'); + await expect(e.title).to.equal('There were errors validating the AsyncAPI document.'); + await expect(e.validationErrors).to.deep.equal([{ + title: '/info should have required property \'title\'', + location: { + jsonPointer: '/info', + startLine: 2, + startColumn: 1, + startOffset: 16, + endLine: 3, + endColumn: 19, + endOffset: 40, + } + }]); + await expect(JSON.stringify(e.parsedJSON)).to.equal(invalidYamlOutput); + } + }); + + it('should fail when asyncapi is not valid (json)', async function () { + try { + await parser.parse(invalidAsyncpiJSON, { path: __filename }); + } catch(e) { + await expect(e.type).to.equal('https://github.com/asyncapi/parser-js/validation-errors'); + await expect(e.title).to.equal('There were errors validating the AsyncAPI document.'); + await expect(e.validationErrors).to.deep.equal([{ + title: '/info should have required property \'title\'', + location: { + jsonPointer: '/info', + startLine: 3, + startColumn: 11, + startOffset: 33, + endLine: 5, + endColumn: 4, + endOffset: 58, + } + }]); + await expect(JSON.stringify(e.parsedJSON)).to.equal(invalidJsonOutput); + } + }); + + it('should fail when it is not possible to convert asyncapi to json', async function () { try { await parser.parse('bad'); - } catch (e) { - await expect(e.constructor.name).to.equal('ParserErrorNoJS'); - await expect(e.parsedJSON).to.equal(undefined); + } catch(e) { + await expect(e.type).to.equal('https://github.com/asyncapi/parser-js/impossible-to-convert-to-json'); + await expect(e.title).to.equal('Could not convert AsyncAPI to JSON.'); + await expect(e.detail).to.equal('Most probably the AsyncAPI document contains invalid YAML or YAML features not supported in JSON.'); } }); - it('should forward AsyncAPI json when version is not supported', async function () { + it('should fail when asyncapi is not present', async function () { try { await parser.parse('bad: true'); - } catch (e) { - await expect(e.constructor.name).to.equal('ParserErrorUnsupportedVersion'); + } catch(e) { + await expect(e.type).to.equal('https://github.com/asyncapi/parser-js/missing-asyncapi-field'); + await expect(e.title).to.equal('The `asyncapi` field is missing.'); 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 when asyncapi version is not supported', async function () { + try { + await parser.parse('asyncapi: 1.2.0'); + } catch (e) { + await expect(e.type).to.equal('https://github.com/asyncapi/parser-js/unsupported-version'); + await expect(e.title).to.equal('Version 1.2.0 is not supported.'); + await expect(e.detail).to.equal('Please use latest version of the specification.'); + await expect(e.parsedJSON).to.deep.equal({ asyncapi: '1.2.0' }); + await expect(e.validationErrors).to.deep.equal([{ + jsonPointer: '/asyncapi', + startLine: 1, + startColumn: 1, + startOffset: 0, + endLine: 1, + endColumn: 16, + endOffset: 15, + }]); + } + }); + + it('should fail when asyncapi is not yaml nor json', async function () { + try { + await parser.parse('bad:\nbad:'); + } catch(e) { + await expect(e.type).to.equal('https://github.com/asyncapi/parser-js/invalid-yaml'); + await expect(e.title).to.equal('The provided YAML is not valid.'); + await expect(e.detail).to.equal('duplicated mapping key at line 2, column -4:\n bad:\n ^'); + await expect(e.location).to.deep.equal({ startOffset: 5, startLine: 2, startColumn: -4 }); + } }); 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) + const type = 'https://github.com/asyncapi/parser-js/dereference-error'; + const message = `Error opening file "${path.resolve(process.cwd(), 'refs/refed.yaml')}" \nENOENT: no such file or directory, open '${path.resolve(process.cwd(), 'refs/refed.yaml')}'`; + const testFn = async () => { await parser.parse(inputYAML) }; + await checkErrorTypeAndMessage(testFn, type, message); + await checkErrorParsedJSON(testFn, outputJsonWithRefs); + }); + + it('should offer information about YAML line and column where $ref errors are located', async function () { + try { + await parser.parse(inputYAML) + } catch (e) { + expect(e.refs).to.deep.equal([{ + jsonPointer: '/components/schemas/testSchema/properties/test/$ref', + startLine: 30, + startColumn: 11, + startOffset: 615, + endLine: 30, + endColumn: 34, + endOffset: 638, + }]); + } + }); + + it('should offer information about JSON line and column where $ref errors are located', async function () { + try { + await parser.parse(inputJSON) + } catch (e) { + expect(e.refs).to.deep.equal([{ + jsonPointer: '/components/schemas/testSchema/properties/test/$ref', + startLine: 38, + startColumn: 21, + startOffset: 599, + endLine: 38, + endColumn: 38, + endOffset: 616, + }]); + } + }); + + it('should not offer information about JS line and column where $ref errors are located if format is JS', async function () { + try { + await parser.parse(JSON.parse(inputJSON)) + } catch (e) { + expect(e.refs).to.deep.equal([{ + jsonPointer: '/components/schemas/testSchema/properties/test/$ref', + }]); + } }); it('should throw error if document is invalid YAML', async function () { - const testFn = async () => await parser.parse(invalidYAML, { path: __filename }); - await expect(testFn()) - .to.be.rejectedWith(ParserError) + try { + await parser.parse(invalidYAML, { path: __filename }) + } catch (e) { + expect(e.type).to.equal('https://github.com/asyncapi/parser-js/invalid-yaml'); + expect(e.title).to.equal('The provided YAML is not valid.'); + expect(e.detail).to.equal(`bad indentation of a mapping entry at line 19, column 11:\n $ref: "#/components/schemas/sentAt"\n ^`); + expect(e.location).to.deep.equal({ startOffset: 460, startLine: 19, startColumn: 11 }); + } + }); + + it('should throw error if document is invalid JSON', async function () { + try { + await parser.parse(' {"invalid "json" }'); + } catch (e) { + expect(e.type).to.equal('https://github.com/asyncapi/parser-js/invalid-json'); + expect(e.title).to.equal('The provided JSON is not valid.'); + expect(e.detail).to.equal(`Unexpected token j in JSON at position 12 while parsing near ' {"invalid "json" }'`); + expect(e.location).to.deep.equal({ startOffset: 12, startLine: 1, startColumn: 12 }); + } }); - it('should throw error if document is empty', async function () { - const testFn = async () => await parser.parse(''); - await expect(testFn()) - .to.be.rejectedWith(ParserError) + it('should throw error if document is null or falsey', async function () { + const type = 'https://github.com/asyncapi/parser-js/null-or-falsey-document'; + const message = `Document can't be null or falsey.`; + await checkErrorTypeAndMessage(async () => { + await parser.parse(''); + }, type, message); + await checkErrorTypeAndMessage(async () => { + await parser.parse(false); + }, type, message); + await checkErrorTypeAndMessage(async () => { + await parser.parse(null); + }, type, message); + await checkErrorTypeAndMessage(async () => { + await parser.parse(undefined); + }, type, message); + await checkErrorTypeAndMessage(async () => { + await parser.parse(NaN); + }, type, message); + }); + + it('should throw error if document is not string nor object', async function () { + const type = 'https://github.com/asyncapi/parser-js/invalid-document-type'; + const message = 'The AsyncAPI document has to be either a string or a JS object.'; + + await checkErrorTypeAndMessage(async () => { + await parser.parse(true); + }, type, message); + await checkErrorTypeAndMessage(async () => { + await parser.parse([]); + }, type, message); + await checkErrorTypeAndMessage(async () => { + await parser.parse(new Map()); + }, type, message); + await checkErrorTypeAndMessage(async () => { + await parser.parse(new Set()); + }, type, message); + await checkErrorTypeAndMessage(async () => { + await parser.parse(new WeakMap()); + }, type, message); + await checkErrorTypeAndMessage(async () => { + await parser.parse(new WeakSet()); + }, type, message); + await checkErrorTypeAndMessage(async () => { + await parser.parse(1); + }, type, message); + await checkErrorTypeAndMessage(async () => { + await parser.parse(Symbol('test')); + }, type, message); + await checkErrorTypeAndMessage(async () => { + await parser.parse(() => {}); + }, type, message); }); });