diff --git a/src/converter.ts b/src/converter.ts index 6d22bff..0d8bc6b 100644 --- a/src/converter.ts +++ b/src/converter.ts @@ -12,6 +12,7 @@ import { JsonNode, RefObject, SchemaObject, + isRef, } from './RefVisitor'; /** Lightweight OAS document top-level fields */ @@ -144,6 +145,7 @@ export class Converter { this.convertJsonSchemaContentMediaType(); this.convertConstToEnum(); this.convertNullableTypeArray(); + this.convertNullableOneOf(); this.removeWebhooksObject(); this.removeUnsupportedSchemaKeywords(); if (this.convertSchemaComments) { @@ -244,6 +246,93 @@ export class Converter { visitSchemaObjects(this.openapi30, schemaVisitor); } + /** + * Finds the schema object from the components schemas. + * + * @param ref The $ref string value. + * @returns The schema object from the document. + */ + findSchema(ref: string): SchemaObject { + const prefix = "#/components/schemas/"; + const schemaName = ref.startsWith(prefix) && ref.slice(prefix.length); + + if (schemaName) { + const components = this.openapi30?.components; + const schemas = components && components['schemas']; + if (schemas) { + return schemas[schemaName]; + } + } + } + + /** + * Finds the type of an SchemaObject, walking trough the references. + * + * @param node The node that we want to find the type of. + * @returns The deduced type for this node. + */ + findSchemaObjectType(node: SchemaObject): string { + if (node.hasOwnProperty('type')) { + return node['type']; + } else if (node.hasOwnProperty('allOf') || node.hasOwnProperty('oneOf') || node.hasOwnProperty('anyOf')) { + const variants = node['allOf'] || node['anyOf'] || node['oneOf']; + const types: [string] = variants.map((variant: SchemaObject) => this.findSchemaObjectType(variant)); + const uniqueTypes = [...new Set(types.filter((type) => type !== undefined))]; + if (uniqueTypes.length === 1) { + return uniqueTypes[0]; + } + } else if (isRef(node)) { + const ref = node['$ref']; + const resolvedSchema = this.findSchema(ref); + if (resolvedSchema) { + const type = this.findSchemaObjectType(resolvedSchema); + return type; + } + } + } + + /** + * OpenAPI 3.1 has a common pattern where an `{ oneOf: [{ type: null }, { .. }]}` + * Is used to represent a nullable type. + * + * Up to this point the conversion would result in a `{ oneOf: [{ nullable: true }, { .. }]}` node. + * Since `nullable: true` must have a sibling `type` property, + * this function adds the type to the `nullable: true` field. + */ + convertNullableOneOf() { + const schemaVisitor: SchemaVisitor = (schema: SchemaObject): SchemaObject => { + if (schema.hasOwnProperty('oneOf')) { + const oneOf = schema['oneOf']; + const nonTypeNull = oneOf.filter((variant: object) => { + const keys = Object.keys(variant); + return !(keys.length === 1 && keys.includes('type') && variant['type'] === 'null'); + }); + + if (oneOf.length > nonTypeNull.length) { + const type = this.findSchemaObjectType({ oneOf: nonTypeNull }); + // Nodes with type 'array' must have a sibling 'items' property. + // Thus, we'll inline the array type, if possible. + if (type === 'array' && nonTypeNull.length === 1) { + delete schema['oneOf']; + const arraySchema = isRef(nonTypeNull[0]) ? this.findSchema(nonTypeNull[0]['$ref']) : nonTypeNull[0]; + for (const key of Object.keys(arraySchema)) { + schema[key] = arraySchema[key]; + } + schema['nullable'] = true; + } + // Other node types work well with this approach. + else if (type) { + delete schema['oneOf']; + const allOf = [{ nullable: true, type }, { oneOf: nonTypeNull }]; + schema['allOf'] = allOf; + } + } + } + return this.walkNestedSchemaObjects(schema, schemaVisitor); + }; + visitSchemaObjects(this.openapi30, schemaVisitor); + } + removeWebhooksObject() { if (Object.hasOwnProperty.call(this.openapi30, 'webhooks')) { this.log(`Deleted webhooks object`); diff --git a/test/converter.spec.ts b/test/converter.spec.ts index 38dbd10..14e0e4a 100644 --- a/test/converter.spec.ts +++ b/test/converter.spec.ts @@ -364,9 +364,9 @@ describe('resolver test suite', () => { a: { type: 'object', properties: { - s: { - type: 'string', - }, + s: { + type: 'string', + }, }, patternProperties: { "^[a-z{2}-[A-Z]{2,3}]$": { @@ -482,24 +482,24 @@ describe('resolver test suite', () => { }); - test('Remove webhooks object', (done) => { + test('Remove webhooks object', (done) => { const input = { openapi: '3.1.0', - webhooks: { - newThing: { - post: { - requestBody: { - description: 'Information about a new thing in the system', - content: { - 'application/json': { - schema: { + webhooks: { + newThing: { + post: { + requestBody: { + description: 'Information about a new thing in the system', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/newThing' } } } - }, - responses: { - 200: { + }, + responses: { + 200: { description: 'Return a 200 status to indicate that the data was received successfully' } } @@ -831,7 +831,7 @@ test('binary encoded data with existing binary format', (done) => { const converter = new Converter(input); let caught = false; try { - converter.convert(); + converter.convert(); } catch (e) { caught = true; } @@ -977,14 +977,102 @@ test('contentMediaType with existing unexpected format', (done) => { }, }; - const converter = new Converter(input); - let caught = false; - try { - converter.convert(); - } catch (e) { - caught = true; - } - expect(caught).toBeTruthy(); + const converter = new Converter(input); + let caught = false; + try { + converter.convert(); + } catch (e) { + caught = true; + } + expect(caught).toBeTruthy(); // TODO how to check that Converter logged to console.warn ? done(); }); + +test('converts nullable oneOf with an array', (done) => { + const input = { + openapi: '3.1.0', + components: { + schemas: { + ArrayItem: { + type: 'object', + properties: { text: { type: 'string' } }, + }, + ArrayType: { + type: 'array', + items: { $ref: '#/components/schemas/ArrayItem' }, + }, + NullableOneOfArray: { + oneOf: [{ type: 'null' }, { $ref: '#/components/schemas/ArrayType' }], + }, + }, + }, + }; + + const expected = { + openapi: '3.0.3', + components: { + schemas: { + ArrayItem: { + type: 'object', + properties: { text: { type: 'string' } }, + }, + ArrayType: { + type: 'array', + items: { $ref: '#/components/schemas/ArrayItem' }, + }, + NullableOneOfArray: { + nullable: true, + type: 'array', + items: { $ref: '#/components/schemas/ArrayItem' }, + }, + }, + }, + }; + + const converter = new Converter(input); + const converted: any = converter.convert(); + + console.log(converted); + expect(converted).toEqual(expected); + done(); +}); + +test('converts nullable oneOf with an object type', (done) => { + const input = { + openapi: '3.1.0', + components: { + schemas: { + Object: { + type: 'object', + properties: { text: { type: 'string' } }, + }, + NullableOneOfArray: { + oneOf: [{ type: 'null' }, { $ref: '#/components/schemas/Object' }], + }, + }, + }, + }; + + const expected = { + openapi: '3.0.3', + components: { + schemas: { + Object: { + type: 'object', + properties: { text: { type: 'string' } }, + }, + NullableOneOfArray: { + allOf: [{ nullable: true, type: 'object' }, { oneOf: [{ $ref: '#/components/schemas/Object' }] }], + }, + }, + }, + }; + + const converter = new Converter(input); + const converted: any = converter.convert(); + + console.log(converted); + expect(converted).toEqual(expected); + done(); +});