diff --git a/packages/openapi3-parser/CHANGELOG.md b/packages/openapi3-parser/CHANGELOG.md index 30d2c5eb7..3ab230b83 100644 --- a/packages/openapi3-parser/CHANGELOG.md +++ b/packages/openapi3-parser/CHANGELOG.md @@ -9,6 +9,19 @@ or any annotation. It is not supported in the case when one of is used in conjunction with other constraints in the same schema object. +### Bug Fixes + +- Supports using `$ref` in the root of a component, for example: + + ```yaml + components: + schemas: + UserAlias: + $ref: '#/components/schemas/User' + User: + type: object + ``` + ## 0.13.1 (2020-06-22) ### Bug Fixes diff --git a/packages/openapi3-parser/lib/parser/oas/parseComponentsObject.js b/packages/openapi3-parser/lib/parser/oas/parseComponentsObject.js index 10f9971cb..864a55619 100644 --- a/packages/openapi3-parser/lib/parser/oas/parseComponentsObject.js +++ b/packages/openapi3-parser/lib/parser/oas/parseComponentsObject.js @@ -8,6 +8,7 @@ const { createInvalidMemberWarning, } = require('../annotations'); const parseObject = require('../parseObject'); +const parseReference = require('../parseReference'); const pipeParseResult = require('../../pipeParseResult'); const parseSchemaObject = require('./parseSchemaObject'); const parseParameterObject = require('./parseParameterObject'); @@ -71,6 +72,36 @@ const parseComponentMember = R.curry((context, parser, member) => { return parseResult; }); +function registerComponentStateInContext(context, components) { + const { namespace } = context; + + // Component referencing supports recursive (and circular in some cases) + // references and thus we must know about all of the component IDs upfront. + // Below we are putting in the unparsed components so we can keep the + // dereferencing logic simple, these are used during parsing the components + // and later on the components in our context is replaced by the final parsed + // result. + // eslint-disable-next-line no-param-reassign + context.state.components = new namespace.elements.Object(); + + if (isObject(components)) { + components.forEach((value, key) => { + if (isObject(value)) { + // Take each component object (f.e schemas, parameters) and convert to + // object with members for each key (discarding value). We don't want the + // value making it into final parse results under any circumstance, for + // example if the parse errors out and we leave bad state + + const componentObject = new namespace.elements.Object( + value.map((value, key) => new namespace.elements.Member(key)) + ); + + context.state.components.set(key.toValue(), componentObject); + } + }); + } +} + /** * Parse Components Object * @@ -84,24 +115,7 @@ const parseComponentMember = R.curry((context, parser, member) => { function parseComponentsObject(context, element) { const { namespace } = context; - // Schema Object supports recursive (and circular) references and thus we - // must know about all of the schema IDs upfront. Below we are putting - // in the unparsed schemas so we can keep the dereferencing logic simple, - // these are used during parsing the schema components and later on the - // components in our context is replaced by the final parsed result. - // eslint-disable-next-line no-param-reassign - context.state.components = new namespace.elements.Object(); - - if (isObject(element) && element.get('schemas') && isObject(element.get('schemas'))) { - // Take schemas and convert to object with members for each key (discarding value) - // We don't want the value making it into final parse results under any circumstance, - // for example if the parse errors out and we leave bad state - const schemas = new namespace.elements.Object( - element.get('schemas').map((value, key) => new namespace.elements.Member(key)) - ); - - context.state.components.set('schemas', schemas); - } + registerComponentStateInContext(context, element); const createMemberValueNotObjectWarning = member => createWarning(namespace, `'${name}' '${member.key.toValue()}' is not an object`, member.value); @@ -117,24 +131,25 @@ function parseComponentsObject(context, element) { * @returns ParseResult * @private */ - const parseComponentObjectMember = (parser) => { + const parseComponentObjectMember = R.curry((parser, member) => { + const component = member.key.toValue(); + const parseMember = parseComponentMember(context, parser); + const parseMemberOrRef = m => parseReference(component, () => parseMember(m), context, m.value, false, true); - return member => pipeParseResult(context.namespace, + return pipeParseResult(context.namespace, validateIsObject, - R.compose(parseObject(context, name, parseMember), getValue), + R.compose(parseObject(context, name, parseMemberOrRef), getValue), (object) => { - const contextMember = context.state.components.getMember(member.key.toValue()); + const contextMember = context.state.components.getMember(component); if (contextMember) { contextMember.value = object; - } else { - context.state.components.push(new namespace.elements.Member(member.key, object)); } return object; })(member); - }; + }); const setDataStructureId = (dataStructure, key) => { if (dataStructure) { diff --git a/packages/openapi3-parser/lib/parser/oas/parseReferenceObject.js b/packages/openapi3-parser/lib/parser/oas/parseReferenceObject.js index f45d53504..7e48ba64f 100644 --- a/packages/openapi3-parser/lib/parser/oas/parseReferenceObject.js +++ b/packages/openapi3-parser/lib/parser/oas/parseReferenceObject.js @@ -12,6 +12,33 @@ const parseString = require('../parseString'); const name = 'Reference Object'; const requiredKeys = ['$ref']; +/** + * Recursively dereference an element in the given component + * + * @param namespace {Namespace} + * @param component {ObjectElement} + * @param ref {StringElement} + * @param element {Element} + * @param parents {string[]} an optional collections of traversed parents + * + * @returns Element + */ +function dereference(namespace, component, ref, element, parents = []) { + if (parents && parents.includes(element.element)) { + // We've already cycled through this element. We're in a circular loop + parents.shift(); + return createError(namespace, `Reference cannot be circular, '${ref.toValue()}' causes a circular reference via ${parents.join(', ')}`, ref); + } + + const match = component.get(element.element); + if (match) { + parents.push(element.element); + return dereference(namespace, component, ref, match, parents); + } + + return element; +} + /** * Parse Reference Object * @@ -76,7 +103,7 @@ function parseReferenceObject(context, componentName, element, returnReferenceEl return new namespace.elements.ParseResult( component .filter((value, key) => key.toValue() === componentId && value) - .map(value => value) + .map(value => dereference(namespace, component, ref, value)) ); }; diff --git a/packages/openapi3-parser/lib/parser/parseReference.js b/packages/openapi3-parser/lib/parser/parseReference.js index b694c064b..03a07674f 100644 --- a/packages/openapi3-parser/lib/parser/parseReference.js +++ b/packages/openapi3-parser/lib/parser/parseReference.js @@ -6,9 +6,9 @@ function isReferenceObject(element) { return isObject(element) && element.get('$ref') !== undefined; } -function parseReference(component, parser, context, element, isInsideSchema) { +function parseReference(component, parser, context, element, isInsideSchema, returnReferenceElement) { if (isReferenceObject(element)) { - const parseResult = parseReferenceObject(context, component, element, component === 'schemas'); + const parseResult = parseReferenceObject(context, component, element, component === 'schemas' || returnReferenceElement); // If we're referencing a schema object and we're not inside a schema // parser (subschema), then we want to wrap the object in a data structure element diff --git a/packages/openapi3-parser/test/integration/components-test.js b/packages/openapi3-parser/test/integration/components-test.js index 269ca4948..7c3c9f65f 100644 --- a/packages/openapi3-parser/test/integration/components-test.js +++ b/packages/openapi3-parser/test/integration/components-test.js @@ -19,6 +19,11 @@ describe('components', () => { const file = path.join(fixtures, 'path-item-object-parameters-unsupported-parameter'); return testParseFixture(file); }); + + it('handles parameter referencing with reference to alias', () => { + const file = path.join(fixtures, 'path-item-object-parameters-alias'); + return testParseFixture(file); + }); }); describe('Media Type Object', () => { @@ -72,9 +77,16 @@ describe('components', () => { }); }); - it("'Schema Object' circular references", () => { - const file = path.join(fixtures, 'schema-object-circular'); - return testParseFixture(file); + describe('Schema Object', () => { + it('handles circular references', () => { + const file = path.join(fixtures, 'schema-object-circular'); + return testParseFixture(file); + }); + + it('handles schema with reference to alias', () => { + const file = path.join(fixtures, 'schema-alias'); + return testParseFixture(file); + }); }); it("'Operation Object' requestBody references", () => { diff --git a/packages/openapi3-parser/test/integration/fixtures/components/path-item-object-parameters-alias.json b/packages/openapi3-parser/test/integration/fixtures/components/path-item-object-parameters-alias.json new file mode 100644 index 000000000..97906adc8 --- /dev/null +++ b/packages/openapi3-parser/test/integration/fixtures/components/path-item-object-parameters-alias.json @@ -0,0 +1,54 @@ +{ + "element": "parseResult", + "content": [ + { + "element": "category", + "meta": { + "classes": { + "element": "array", + "content": [ + { + "element": "string", + "content": "api" + } + ] + }, + "title": { + "element": "string", + "content": "Parameter Component with alias" + } + }, + "attributes": { + "version": { + "element": "string", + "content": "1.0.0" + } + }, + "content": [ + { + "element": "resource", + "attributes": { + "href": { + "element": "string", + "content": "/{?foo}" + }, + "hrefVariables": { + "element": "hrefVariables", + "content": [ + { + "element": "member", + "content": { + "key": { + "element": "string", + "content": "foo" + } + } + } + ] + } + } + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/openapi3-parser/test/integration/fixtures/components/path-item-object-parameters-alias.yaml b/packages/openapi3-parser/test/integration/fixtures/components/path-item-object-parameters-alias.yaml new file mode 100644 index 000000000..3222be95e --- /dev/null +++ b/packages/openapi3-parser/test/integration/fixtures/components/path-item-object-parameters-alias.yaml @@ -0,0 +1,15 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Parameter Component with alias +paths: + /: + parameters: + - $ref: '#/components/parameters/UserAlias' +components: + parameters: + User: + in: query + name: foo + UserAlias: + $ref: '#/components/parameters/User' diff --git a/packages/openapi3-parser/test/integration/fixtures/components/schema-alias.json b/packages/openapi3-parser/test/integration/fixtures/components/schema-alias.json new file mode 100644 index 000000000..b0f4ec45b --- /dev/null +++ b/packages/openapi3-parser/test/integration/fixtures/components/schema-alias.json @@ -0,0 +1,175 @@ +{ + "element": "parseResult", + "content": [ + { + "element": "category", + "meta": { + "classes": { + "element": "array", + "content": [ + { + "element": "string", + "content": "api" + } + ] + }, + "title": { + "element": "string", + "content": "Schemas Component with alias" + } + }, + "attributes": { + "version": { + "element": "string", + "content": "1.0.0" + } + }, + "content": [ + { + "element": "resource", + "attributes": { + "href": { + "element": "string", + "content": "/" + } + }, + "content": [ + { + "element": "transition", + "content": [ + { + "element": "httpTransaction", + "content": [ + { + "element": "httpRequest", + "attributes": { + "method": { + "element": "string", + "content": "GET" + } + } + }, + { + "element": "httpResponse", + "attributes": { + "headers": { + "element": "httpHeaders", + "content": [ + { + "element": "member", + "content": { + "key": { + "element": "string", + "content": "Content-Type" + }, + "value": { + "element": "string", + "content": "application/json" + } + } + } + ] + }, + "statusCode": { + "element": "string", + "content": "200" + } + }, + "content": [ + { + "element": "asset", + "meta": { + "classes": { + "element": "array", + "content": [ + { + "element": "string", + "content": "messageBody" + } + ] + } + }, + "attributes": { + "contentType": { + "element": "string", + "content": "application/json" + } + }, + "content": "{\"name\":\"\"}" + }, + { + "element": "dataStructure", + "content": { + "element": "UserAlias" + } + }, + { + "element": "copy", + "content": "" + } + ] + } + ] + } + ] + } + ] + }, + { + "element": "category", + "meta": { + "classes": { + "element": "array", + "content": [ + { + "element": "string", + "content": "dataStructures" + } + ] + } + }, + "content": [ + { + "element": "dataStructure", + "content": { + "element": "object", + "meta": { + "id": { + "element": "string", + "content": "User" + } + }, + "content": [ + { + "element": "member", + "content": { + "key": { + "element": "string", + "content": "name" + }, + "value": { + "element": "string" + } + } + } + ] + } + }, + { + "element": "dataStructure", + "content": { + "element": "User", + "meta": { + "id": { + "element": "string", + "content": "UserAlias" + } + } + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/openapi3-parser/test/integration/fixtures/components/schema-alias.yaml b/packages/openapi3-parser/test/integration/fixtures/components/schema-alias.yaml new file mode 100644 index 000000000..31d37c216 --- /dev/null +++ b/packages/openapi3-parser/test/integration/fixtures/components/schema-alias.yaml @@ -0,0 +1,23 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Schemas Component with alias +paths: + /: + get: + responses: + '200': + description: '' + content: + 'application/json': + schema: + $ref: '#/components/schemas/UserAlias' +components: + schemas: + User: + type: object + properties: + name: + type: string + UserAlias: + $ref: '#/components/schemas/User' diff --git a/packages/openapi3-parser/test/unit/parser/oas/parseComponentsObject-test.js b/packages/openapi3-parser/test/unit/parser/oas/parseComponentsObject-test.js index cdcd8b462..9c2fbe611 100644 --- a/packages/openapi3-parser/test/unit/parser/oas/parseComponentsObject-test.js +++ b/packages/openapi3-parser/test/unit/parser/oas/parseComponentsObject-test.js @@ -91,6 +91,34 @@ describe('Components Object', () => { expect(userState).to.be.instanceof(namespace.elements.Member); expect(userState.value).to.be.undefined; }); + + it('parses valid schemas with alias into data structures', () => { + const components = new namespace.elements.Object({ + schemas: { + User: { + type: 'object', + }, + UserAlias: { $ref: '#/components/schemas/User' }, + }, + }); + + const parseResult = parse(context, components); + expect(parseResult.length).to.equal(1); + + const parsedComponents = parseResult.get(0); + expect(parsedComponents).to.be.instanceof(namespace.elements.Object); + + const schemas = parsedComponents.get('schemas'); + expect(schemas).to.be.instanceof(namespace.elements.Object); + + const userSchema = schemas.get('User'); + expect(userSchema).to.be.instanceof(namespace.elements.DataStructure); + expect(userSchema.content).to.be.instanceof(namespace.elements.Object); + + const userSchemaAlias = schemas.get('UserAlias'); + expect(userSchemaAlias).to.be.instanceof(namespace.elements.DataStructure); + expect(userSchemaAlias.content.element).to.equal('User'); + }); }); describe('#parameters', () => { diff --git a/packages/openapi3-parser/test/unit/parser/oas/parseReferenceObject-test.js b/packages/openapi3-parser/test/unit/parser/oas/parseReferenceObject-test.js index d928865f5..02ad9272b 100644 --- a/packages/openapi3-parser/test/unit/parser/oas/parseReferenceObject-test.js +++ b/packages/openapi3-parser/test/unit/parser/oas/parseReferenceObject-test.js @@ -88,6 +88,29 @@ describe('Reference Object', () => { expect(parseResult.length).to.equal(0); }); + it('can parse a reference to a reference', () => { + const nodeAlias = new namespace.elements.Element(); + nodeAlias.element = 'Node'; + + context.state.components = new namespace.elements.Object({ + schemas: { + Node: 'example', + NodeAlias: nodeAlias, + }, + }); + + const reference = new namespace.elements.Object({ + $ref: '#/components/schemas/NodeAlias', + }); + const parseResult = parse(context, 'schemas', reference); + + expect(parseResult.length).to.equal(1); + const structure = parseResult.get(0); + expect(structure).to.be.instanceof(namespace.elements.String); + expect(structure.element).to.equal('string'); + expect(structure.toValue()).to.equal('example'); + }); + describe('invalid references', () => { it('errors when parsing a non-components reference', () => { const reference = new namespace.elements.Object({ @@ -145,6 +168,32 @@ describe('Reference Object', () => { expect(parseResult).to.contain.error("'#/components' is not defined"); }); + + it('errors when parsing reference causing circular reference', () => { + const createElement = (name) => { + const element = new namespace.elements.Element(); + element.element = name; + return element; + }; + + context.state.components = new namespace.elements.Object({ + schemas: { + A: createElement('B'), + B: createElement('C'), + C: createElement('D'), + D: createElement('A'), + }, + }); + + const reference = new namespace.elements.Object({ + $ref: '#/components/schemas/A', + }); + + const parseResult = parse(context, 'schemas', reference); + expect(parseResult).to.contain.error( + "Reference cannot be circular, '#/components/schemas/A' causes a circular reference via C, D, A" + ); + }); }); describe('warnings for invalid properties', () => {