Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
JsonNode,
RefObject,
SchemaObject,
isRef,
} from './RefVisitor';

/** Lightweight OAS document top-level fields */
Expand Down Expand Up @@ -144,6 +145,7 @@ export class Converter {
this.convertJsonSchemaContentMediaType();
this.convertConstToEnum();
this.convertNullableTypeArray();
this.convertNullableOneOf();
this.removeWebhooksObject();
this.removeUnsupportedSchemaKeywords();
if (this.convertSchemaComments) {
Expand Down Expand Up @@ -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`);
Expand Down
136 changes: 112 additions & 24 deletions test/converter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}]$": {
Expand Down Expand Up @@ -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'
}
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
});