Skip to content

Commit

Permalink
feat: generate schema for relation links
Browse files Browse the repository at this point in the history
  • Loading branch information
bajtos committed Mar 21, 2019
1 parent 43baf2f commit 2256435
Show file tree
Hide file tree
Showing 15 changed files with 232 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,7 @@ describe('build-schema', () => {
};
MetadataInspector.defineMetadata(
JSON_SCHEMA_KEY,
cachedSchema,
{modelOnly: cachedSchema},
TestModel,
);
const jsonSchema = getJsonSchema(TestModel);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright IBM Corp. 2018. All Rights Reserved.
// Node module: @loopback/repository-json-schema
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {
belongsTo,
Entity,
hasMany,
model,
property,
} from '@loopback/repository';
import {expect} from '@loopback/testlab';
import * as Ajv from 'ajv';
import {JsonSchema, modelToJsonSchema} from '../..';

describe('build-schema', () => {
describe('modelToJsonSchema', () => {
it('converts basic model', () => {
@model()
class TestModel {
@property()
foo: string;
}

const jsonSchema = modelToJsonSchema(TestModel);
expectValidJsonSchema(jsonSchema);
expect(jsonSchema.properties).to.containDeep({
foo: <JsonSchema>{
type: 'string',
},
});
});

it('converts HasMany and BelongsTo relation links', () => {
@model()
class Product extends Entity {
@property({id: true})
id: number;

@belongsTo(() => Category)
categoryId: number;
}

@model()
class Category extends Entity {
@property({id: true})
id: number;

@hasMany(() => Product)
products?: Product[];
}

const jsonSchema = modelToJsonSchema(Category, {includeRelations: true});
expectValidJsonSchema(jsonSchema);
expect(jsonSchema).to.deepEqual({
title: 'CategoryWithLinks',
properties: {
// TODO(bajtos): inherit these properties from Category schema
// See https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/
id: {type: 'number'},
products: {
type: 'array',
items: {$ref: '#/definitions/ProductWithLinks'},
},
},
definitions: {
ProductWithLinks: {
title: 'ProductWithLinks',
properties: {
// TODO(bajtos): inherit these properties from Product schema
// See https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/
id: {type: 'number'},
categoryId: {type: 'number'},
category: {$ref: '#/definitions/CategoryWithLinks'},
},
},
},
});
});
});
});

function expectValidJsonSchema(schema: JsonSchema) {
const ajv = new Ajv();
const validate = ajv.compile(
require('ajv/lib/refs/json-schema-draft-06.json'),
);
const isValid = validate(schema);
const result = isValid
? 'JSON Schema is valid'
: ajv.errorsText(validate.errors!);
expect(result).to.equal('JSON Schema is valid');
}
116 changes: 97 additions & 19 deletions packages/repository-json-schema/src/build-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,62 @@ import {
import {JSONSchema6 as JSONSchema} from 'json-schema';
import {JSON_SCHEMA_KEY} from './keys';

export interface JsonSchemaOptions {
includeRelations?: boolean;
visited?: {[key: string]: JSONSchema};
}

/**
* Gets the JSON Schema of a TypeScript model/class by seeing if one exists
* in a cache. If not, one is generated and then cached.
* @param ctor Contructor of class to get JSON Schema from
*/
export function getJsonSchema(ctor: Function): JSONSchema {
export function getJsonSchema(
ctor: Function,
options: JsonSchemaOptions = {},
): JSONSchema {
// NOTE(shimks) currently impossible to dynamically update
const jsonSchema = MetadataInspector.getClassMetadata(JSON_SCHEMA_KEY, ctor);
if (jsonSchema) {
return jsonSchema;
const cached = MetadataInspector.getClassMetadata(JSON_SCHEMA_KEY, ctor);
const key = options.includeRelations ? 'modelWithLinks' : 'modelOnly';

if (cached && cached[key]) {
return cached[key];
} else {
const newSchema = modelToJsonSchema(ctor);
MetadataInspector.defineMetadata(JSON_SCHEMA_KEY.key, newSchema, ctor);
const newSchema = modelToJsonSchema(ctor, options);
if (cached) {
cached[key] = newSchema;
} else {
MetadataInspector.defineMetadata(
JSON_SCHEMA_KEY.key,
{[key]: newSchema},
ctor,
);
}
return newSchema;
}
}

export function getJsonSchemaRef(
ctor: Function,
options: JsonSchemaOptions = {},
): JSONSchema {
const schemaWithDefinitions = getJsonSchema(ctor, options);
const key = schemaWithDefinitions.title;

// ctor is not a model
if (!key) return schemaWithDefinitions;

const definitions = Object.assign({}, schemaWithDefinitions.definitions);
const schema = Object.assign({}, schemaWithDefinitions);
delete schema.definitions;
definitions[key] = schema;

return {
$ref: `#/definitions/${key}`,
definitions,
};
}

/**
* Gets the wrapper function of primitives string, number, and boolean
* @param type Name of type
Expand Down Expand Up @@ -138,16 +177,28 @@ export function metaToJsonProperty(meta: PropertyDefinition): JSONSchema {
* reflection API
* @param ctor Constructor of class to convert from
*/
export function modelToJsonSchema(ctor: Function): JSONSchema {
export function modelToJsonSchema(
ctor: Function,
options: JsonSchemaOptions = {},
): JSONSchema {
options.visited = options.visited || {};

const meta: ModelDefinition | {} = ModelMetadataHelper.getModelMetadata(ctor);
const result: JSONSchema = {};

// returns an empty object if metadata is an empty object
if (!(meta instanceof ModelDefinition)) {
return {};
}

result.title = meta.title || ctor.name;
let title = meta.title || ctor.name;
if (options.includeRelations) {
title += 'WithLinks';
}

if (title in options.visited) return options.visited[title];

const result: JSONSchema = {title};
options.visited[title] = result;

if (meta.description) {
result.description = meta.description;
Expand Down Expand Up @@ -187,20 +238,47 @@ export function modelToJsonSchema(ctor: Function): JSONSchema {
}

const propSchema = getJsonSchema(referenceType);
includeReferencedSchema(referenceType.name, propSchema);
}

if (propSchema && Object.keys(propSchema).length > 0) {
result.definitions = result.definitions || {};
if (options.includeRelations) {
for (const r in meta.relations) {
result.properties = result.properties || {};
const relMeta = meta.relations[r];
const targetType = resolveType(relMeta.target);
const targetSchema = getJsonSchema(targetType, options);
const targetRef = {$ref: `#/definitions/${targetSchema.title}`};

// delete nested definition
if (propSchema.definitions) {
for (const key in propSchema.definitions) {
result.definitions[key] = propSchema.definitions[key];
}
delete propSchema.definitions;
}
const propDef = relMeta.targetsMany
? <JSONSchema>{
type: 'array',
items: targetRef,
}
: targetRef;

result.definitions[referenceType.name] = propSchema;
// IMPORTANT: r !== relMeta.name
// E.g. belongsTo sets r="categoryId" but name="category"
result.properties[relMeta.name] =
result.properties[relMeta.name] || propDef;
includeReferencedSchema(targetSchema.title!, targetSchema);
}
}
return result;

function includeReferencedSchema(name: string, propSchema: JSONSchema) {
if (!propSchema || !Object.keys(propSchema).length) return;

result.definitions = result.definitions || {};

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

result.definitions[name] = propSchema;
}
}
2 changes: 1 addition & 1 deletion packages/repository-json-schema/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ import {JSONSchema6 as JSONSchema} from 'json-schema';
* Metadata key used to set or retrieve repository JSON Schema
*/
export const JSON_SCHEMA_KEY = MetadataAccessor.create<
JSONSchema,
{[options: string]: JSONSchema},
ClassDecorator
>('loopback:json-schema');
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ class Order extends Entity {
.addRelation({
name: 'customer',
type: RelationType.belongsTo,
targetsMany: false,
source: Order,
target: () => Customer,
keyFrom: 'customerId',
Expand Down Expand Up @@ -253,20 +254,23 @@ class Customer extends Entity {
.addRelation({
name: 'orders',
type: RelationType.hasMany,
targetsMany: true,
source: Customer,
target: () => Order,
keyTo: 'customerId',
})
.addRelation({
name: 'reviewsAuthored',
type: RelationType.hasMany,
targetsMany: true,
source: Customer,
target: () => Review,
keyTo: 'authorId',
})
.addRelation({
name: 'reviewsApproved',
type: RelationType.hasMany,
targetsMany: true,
source: Customer,
target: () => Review,
keyTo: 'approvedId',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ describe('relation decorator', () => {
);
expect(meta).to.eql({
type: RelationType.hasMany,
targetsMany: true,
name: 'addresses',
source: AddressBook,
target: () => Address,
Expand All @@ -60,6 +61,7 @@ describe('relation decorator', () => {
expect(AddressBook.definition.relations).to.eql({
addresses: {
type: RelationType.hasMany,
targetsMany: true,
name: 'addresses',
source: AddressBook,
target: () => Address,
Expand Down Expand Up @@ -93,6 +95,7 @@ describe('relation decorator', () => {
);
expect(meta).to.eql({
type: RelationType.hasMany,
targetsMany: true,
name: 'addresses',
source: AddressBook,
target: () => Address,
Expand Down Expand Up @@ -131,6 +134,7 @@ describe('relation decorator', () => {
keyFrom: 'addressBookId',
name: 'addressBook',
type: 'belongsTo',
targetsMany: false,
},
});
});
Expand All @@ -156,6 +160,7 @@ describe('relation decorator', () => {
);
expect(meta).to.eql({
type: RelationType.belongsTo,
targetsMany: false,
name: 'addressBook',
source: Address,
target: () => AddressBook,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function givenAnErrorInstance() {
return new InvalidRelationError('a reason', {
name: 'products',
type: RelationType.hasMany,
targetsMany: true,
source: Category,
target: () => Product,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ describe('createBelongsToAccessor', () => {

const relationMeta: BelongsToDefinition = {
type: RelationType.belongsTo,
targetsMany: false,
name: 'category',
source: Product,
target: () => Category,
Expand Down Expand Up @@ -165,6 +166,7 @@ describe('createBelongsToAccessor', () => {
): BelongsToDefinition {
const defaults: BelongsToDefinition = {
type: RelationType.belongsTo,
targetsMany: false,
name: 'company',
source: Company,
target: () => Customer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ describe('createHasManyRepositoryFactory', () => {

const defaults: HasManyDefinition = {
type: RelationType.hasMany,
targetsMany: true,
name: 'customers',
target: () => Customer,
source: Company,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ describe('createHasOneRepositoryFactory', () => {
): HasOneDefinition {
const defaults: HasOneDefinition = {
type: RelationType.hasOne,
targetsMany: false,
name: 'address',
target: () => Address,
source: Customer,
Expand Down
Loading

0 comments on commit 2256435

Please sign in to comment.