Skip to content

Commit

Permalink
fix: allow json schema with circular refs to be converted to OpenAPI …
Browse files Browse the repository at this point in the history
…schema

Fixes #3706
  • Loading branch information
raymondfeng committed Oct 24, 2019
1 parent bb84e6f commit cd5ca92
Show file tree
Hide file tree
Showing 4 changed files with 166 additions and 9 deletions.
129 changes: 129 additions & 0 deletions packages/openapi-v3/src/__tests__/unit/json-to-schema.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,135 @@ describe('jsonToSchemaObject', () => {
expect(jsonToSchemaObject(inputDef)).to.eql(expectedDef);
});

it('handles circular references with $ref', () => {
const schemaJson: JsonSchema = {
title: 'ReportState',
properties: {
// ReportState[]
states: {type: 'array', items: {$ref: '#/definitions/ReportState'}},
benchmarkId: {type: 'string'},
color: {type: 'string'},
},
};
const schema = jsonToSchemaObject(schemaJson);
expect(schema).to.eql({
title: 'ReportState',
properties: {
states: {
type: 'array',
items: {$ref: '#/components/schemas/ReportState'},
},
benchmarkId: {type: 'string'},
color: {type: 'string'},
},
});
});

it('handles circular references with object', () => {
const schemaJson: JsonSchema = {
title: 'ReportState',
properties: {
benchmarkId: {type: 'string'},
color: {type: 'string'},
},
};
// Add states: ReportState[]
schemaJson.properties!.states = {type: 'array', items: schemaJson};
const schema = jsonToSchemaObject(schemaJson);
expect(schema).to.eql({
title: 'ReportState',
properties: {
states: {
type: 'array',
items: {$ref: '#/components/schemas/ReportState'},
},
benchmarkId: {type: 'string'},
color: {type: 'string'},
},
});
});

it('handles indirect circular references with $ref', () => {
const schemaJson: JsonSchema = {
title: 'ReportState',
properties: {
parentState: {$ref: '#/definitions/ParentState'},
benchmarkId: {type: 'string'},
color: {type: 'string'},
},
definitions: {
ParentState: {
title: 'ParentState',
properties: {
timestamp: {type: 'string'},
state: {$ref: '#/definitions/ReportState'},
},
},
},
};
const schema = jsonToSchemaObject(schemaJson);
expect(schema).to.eql({
title: 'ReportState',
properties: {
parentState: {$ref: '#/components/schemas/ParentState'},
benchmarkId: {type: 'string'},
color: {type: 'string'},
},
definitions: {
ParentState: {
title: 'ParentState',
properties: {
timestamp: {type: 'string'},
state: {$ref: '#/components/schemas/ReportState'},
},
},
},
});
});

it('handles indirect circular references with object', () => {
const parentStateSchema: JsonSchema = {
title: 'ParentState',
properties: {
timestamp: {type: 'string'},
// state: {$ref: '#/definitions/ReportState'},
},
};

const schemaJson: JsonSchema = {
title: 'ReportState',
properties: {
parentState: {$ref: '#/definitions/ParentState'},
benchmarkId: {type: 'string'},
color: {type: 'string'},
},
definitions: {
ParentState: parentStateSchema,
},
};

parentStateSchema.properties!.state = schemaJson;

const schema = jsonToSchemaObject(schemaJson);
expect(schema).to.eql({
title: 'ReportState',
properties: {
parentState: {$ref: '#/components/schemas/ParentState'},
benchmarkId: {type: 'string'},
color: {type: 'string'},
},
definitions: {
ParentState: {
title: 'ParentState',
properties: {
timestamp: {type: 'string'},
state: {$ref: '#/components/schemas/ReportState'},
},
},
},
});
});

it('errors if type is an array and items is missing', () => {
expect.throws(
() => {
Expand Down
37 changes: 30 additions & 7 deletions packages/openapi-v3/src/json-to-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@

import {JsonSchema} from '@loopback/repository-json-schema';
import * as _ from 'lodash';
import {ReferenceObject, SchemaObject, SchemasObject} from './types';
import {
isSchemaObject,
ReferenceObject,
SchemaObject,
SchemasObject,
} from './types';

/**
* Custom LoopBack extension: a reference to Schema object that's bundled
Expand All @@ -32,9 +37,25 @@ export type SchemaRef = ReferenceObject & {definitions: SchemasObject};
/**
* Converts JSON Schemas into a SchemaObject
* @param json - JSON Schema to convert from
* @param visited - A map to keep track of mapped json schemas to handle
* circular references
*/
export function jsonToSchemaObject(json: JsonSchema): SchemaObject | SchemaRef {
const result: SchemaObject = {};
export function jsonToSchemaObject(
json: JsonSchema,
visited: Map<JsonSchema, SchemaObject | SchemaRef> = new Map(),
): SchemaObject | SchemaRef {
// A flag to check if a schema object is fully converted
const converted = 'x-loopback-converted';
const schema = visited.get(json);
if (schema != null && isSchemaObject(schema) && schema[converted] === false) {
return {$ref: `#/components/schemas/${json.title}`};
}
if (schema != null) return schema;

const result: SchemaObject = {
[converted]: false,
};
visited.set(json, result);
const propsToIgnore = [
'anyOf',
'oneOf',
Expand All @@ -58,19 +79,19 @@ export function jsonToSchemaObject(json: JsonSchema): SchemaObject | SchemaRef {
}
case 'allOf': {
result.allOf = _.map(json.allOf, item =>
jsonToSchemaObject(item as JsonSchema),
jsonToSchemaObject(item as JsonSchema, visited),
);
break;
}
case 'definitions': {
result.definitions = _.mapValues(json.definitions, def =>
jsonToSchemaObject(jsonOrBooleanToJSON(def)),
jsonToSchemaObject(jsonOrBooleanToJSON(def), visited),
);
break;
}
case 'properties': {
result.properties = _.mapValues(json.properties, item =>
jsonToSchemaObject(jsonOrBooleanToJSON(item)),
jsonToSchemaObject(jsonOrBooleanToJSON(item), visited),
);
break;
}
Expand All @@ -80,13 +101,14 @@ export function jsonToSchemaObject(json: JsonSchema): SchemaObject | SchemaRef {
} else {
result.additionalProperties = jsonToSchemaObject(
json.additionalProperties!,
visited,
);
}
break;
}
case 'items': {
const items = Array.isArray(json.items) ? json.items[0] : json.items;
result.items = jsonToSchemaObject(jsonOrBooleanToJSON(items!));
result.items = jsonToSchemaObject(jsonOrBooleanToJSON(items!), visited);
break;
}
case '$ref': {
Expand All @@ -110,6 +132,7 @@ export function jsonToSchemaObject(json: JsonSchema): SchemaObject | SchemaRef {
}
}

delete result[converted];
return result;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ describe('build-schema', () => {
benchmarkId: {type: 'string'},
color: {type: 'string'},
});
// No circular references in definitions
expect(schema.definitions).to.be.undefined();
});
});

Expand Down
7 changes: 5 additions & 2 deletions packages/repository-json-schema/src/build-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,18 +459,21 @@ export function modelToJsonSchema<T extends object>(

function includeReferencedSchema(name: string, schema: JSONSchema) {
if (!schema || !Object.keys(schema).length) return;
result.definitions = result.definitions || {};

// promote nested definition to the top level
if (result !== schema && schema.definitions) {
for (const key in schema.definitions) {
if (key === title) continue;
result.definitions = result.definitions || {};
result.definitions[key] = schema.definitions[key];
}
delete schema.definitions;
}

result.definitions[name] = schema;
if (result !== schema) {
result.definitions = result.definitions || {};
result.definitions[name] = schema;
}
}
return result;
}

0 comments on commit cd5ca92

Please sign in to comment.