diff --git a/packages/openapi-v2/package.json b/packages/openapi-v2/package.json index 2095ac8f89e5..3ab3c32c4f5b 100644 --- a/packages/openapi-v2/package.json +++ b/packages/openapi-v2/package.json @@ -8,7 +8,9 @@ "devDependencies": { "@loopback/build": "^4.0.0-alpha.9", "@loopback/openapi-spec-builder": "^4.0.0-alpha.17", - "@loopback/testlab": "^4.0.0-alpha.19" + "@loopback/repository": "^4.0.0-alpha.23", + "@loopback/testlab": "^4.0.0-alpha.19", + "@types/lodash": "^4.14.92" }, "scripts": { "build": "npm run build:dist && npm run build:dist6", @@ -49,6 +51,7 @@ "dependencies": { "@loopback/context": "^4.0.0-alpha.26", "@loopback/openapi-spec": "^4.0.0-alpha.20", + "@loopback/repository-json-schema": "^4.0.0-alpha.1", "lodash": "^4.17.4" } } diff --git a/packages/openapi-v2/src/controller-spec.ts b/packages/openapi-v2/src/controller-spec.ts index 9f596041428b..aef85f4c21fc 100644 --- a/packages/openapi-v2/src/controller-spec.ts +++ b/packages/openapi-v2/src/controller-spec.ts @@ -21,9 +21,13 @@ import { PathsObject, ItemType, ItemsObject, + DefinitionsObject, + MapObject, } from '@loopback/openapi-spec'; import * as stream from 'stream'; +import {getJsonSchema, JsonDefinition} from '@loopback/repository-json-schema'; +import * as _ from 'lodash'; const debug = require('debug')('loopback:rest:router:metadata'); @@ -31,7 +35,7 @@ const REST_METHODS_KEY = 'rest:methods'; const REST_METHODS_WITH_PARAMETERS_KEY = 'rest:methods:parameters'; const REST_PARAMETERS_KEY = 'rest:parameters'; const REST_CLASS_KEY = 'rest:class'; -const REST_API_SPEC_KEY = 'rest:api-spec'; +const REST_CONTROLLER_SPEC_KEY = 'rest:controller-spec'; // tslint:disable:no-any @@ -47,8 +51,12 @@ export interface ControllerSpec { * The available paths and operations for the API. */ paths: PathsObject; -} + /** + * JSON Schema definitions of models used by the controller + */ + definitions?: DefinitionsObject; +} /** * Decorate the given Controller constructor with metadata describing * the HTTP/REST API the Controller implements/provides. @@ -167,6 +175,36 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { debug(` adding ${endpointName}`, operationSpec); spec.paths[path][verb] = operationSpec; + + debug(` inferring schema object for method %s`, op); + const paramTypes = MetadataInspector.getDesignTypeForMethod( + constructor.prototype, + op, + ).parameterTypes; + + const isComplexType = (ctor: Function) => + !_.includes([String, Number, Boolean, Array, Object], ctor) && + !isReadableStream(ctor); + + for (const p of paramTypes) { + if (isComplexType(p)) { + if (!spec.definitions) { + spec.definitions = {}; + } + const jsonSchema = getJsonSchema(p); + const openapiSchema = jsonToSchemaObject(jsonSchema); + + if (openapiSchema.definitions) { + for (const key in openapiSchema.definitions) { + spec.definitions[key] = openapiSchema.definitions[key]; + } + delete openapiSchema.definitions; + } + + spec.definitions[p.name] = openapiSchema; + break; + } + } } return spec; } @@ -177,17 +215,99 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { */ export function getControllerSpec(constructor: Function): ControllerSpec { let spec = MetadataInspector.getClassMetadata( - REST_API_SPEC_KEY, + REST_CONTROLLER_SPEC_KEY, constructor, {ownMetadataOnly: true}, ); if (!spec) { spec = resolveControllerSpec(constructor); - MetadataInspector.defineMetadata(REST_API_SPEC_KEY, spec, constructor); + MetadataInspector.defineMetadata( + REST_CONTROLLER_SPEC_KEY, + spec, + constructor, + ); } return spec; } +export function jsonToSchemaObject(jsonDef: JsonDefinition): SchemaObject { + const json = jsonDef as {[name: string]: any}; // gets around index signature error + const result: SchemaObject = {}; + const propsToIgnore = [ + 'anyOf', + 'oneOf', + 'additionalItems', + 'defaultProperties', + 'typeof', + ]; + for (const property in json) { + if (propsToIgnore.includes(property)) { + continue; + } + switch (property) { + case 'type': { + if (json.type === 'array' && !json.items) { + throw new Error( + '"items" property must be present if "type" is an array', + ); + } + result.type = Array.isArray(json.type) ? json.type[0] : json.type; + break; + } + case 'allOf': { + result.allOf = _.map(json.allOf, item => jsonToSchemaObject(item)); + break; + } + case 'definitions': { + result.definitions = _.mapValues(json.definitions, def => + jsonToSchemaObject(def), + ); + break; + } + case 'properties': { + result.properties = _.mapValues(json.properties, item => + jsonToSchemaObject(item), + ); + break; + } + case 'additionalProperties': { + if (typeof json.additionalProperties !== 'boolean') { + result.additionalProperties = jsonToSchemaObject( + json.additionalProperties as JsonDefinition, + ); + } + break; + } + case 'items': { + const items = Array.isArray(json.items) ? json.items[0] : json.items; + result.items = jsonToSchemaObject(items as JsonDefinition); + break; + } + case 'enum': { + const newEnum = []; + const primitives = ['string', 'number', 'boolean']; + for (const element of json.enum) { + if (primitives.includes(typeof element) || element === null) { + newEnum.push(element); + } else { + // if element is JsonDefinition, convert to SchemaObject + newEnum.push(jsonToSchemaObject(element as JsonDefinition)); + } + } + result.enum = newEnum; + + break; + } + default: { + result[property] = json[property]; + break; + } + } + } + + return result; +} + /** * Expose a Controller method as a REST API operation * mapped to `GET` request method. diff --git a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-body.test.ts b/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-body.test.ts index 14711c8365dd..08115ea957a8 100644 --- a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-body.test.ts +++ b/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param-body.test.ts @@ -5,6 +5,7 @@ import {post, param, getControllerSpec} from '../../../../..'; import {expect} from '@loopback/testlab'; +import {model, property} from '@loopback/repository'; describe('Routing metadata for parameters', () => { describe('@param.body', () => { @@ -47,6 +48,28 @@ describe('Routing metadata for parameters', () => { ]); }); + it('infers a complex parameter schema with in:body', () => { + @model() + class MyData { + @property() name: string; + } + class MyController { + @post('/greeting') + greet(@param.body('data') data: MyData) {} + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.definitions).to.deepEqual({ + MyData: { + properties: { + name: { + type: 'string', + }, + }, + }, + }); + }); + it('infers a string parameter type with in:body', () => { class MyController { @post('/greeting') diff --git a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts b/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts index 294bc493ff1a..e6a302d441b4 100644 --- a/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts +++ b/packages/openapi-v2/test/unit/controller-spec/controller-decorators/param-decorators/param.test.ts @@ -3,15 +3,24 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {get, param, getControllerSpec, operation, patch} from '../../../../..'; +import { + get, + param, + getControllerSpec, + operation, + patch, + post, +} from '../../../../..'; import { OperationObject, ParameterObject, ResponsesObject, + DefinitionsObject, } from '@loopback/openapi-spec'; import {expect} from '@loopback/testlab'; import {anOperationSpec} from '@loopback/openapi-spec-builder'; import * as stream from 'stream'; +import {model, property} from '@loopback/repository'; describe('Routing metadata for parameters', () => { describe('@param', () => { @@ -270,6 +279,122 @@ describe('Routing metadata for parameters', () => { expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); }); + it('infers complex body parameter schema into the controller spec', () => { + const fooSpec: ParameterObject = { + name: 'foo', + in: 'body', + }; + const barSpec: ParameterObject = { + name: 'bar', + in: 'body', + }; + @model() + class Foo { + @property() price: number; + } + @model() + class Bar { + @property() name: string; + @property() foo: Foo; + } + class MyController { + @post('/foo') + foo(@param(fooSpec) foo: Foo) {} + + @post('/bar') + bar(@param(barSpec) bar: Bar) {} + } + + const defs = getControllerSpec(MyController) + .definitions as DefinitionsObject; + + // tslint:disable-next-line:no-any + expect(defs).to.have.keys('Foo', 'Bar'); + expect(defs.Foo).to.deepEqual({ + properties: { + price: { + type: 'number', + }, + }, + }); + expect(defs.Bar).to.deepEqual({ + properties: { + name: { + type: 'string', + }, + foo: { + $ref: '#definitions/Foo', + }, + }, + }); + }); + + it('does not produce nested definitions', () => { + const paramSpec: ParameterObject = { + name: 'foo', + in: 'body', + }; + @model() + class Foo { + @property() bar: number; + } + @model() + class MyBody { + @property() name: string; + @property() foo: Foo; + } + class MyController { + @post('/foo') + foo(@param(paramSpec) body: MyBody) {} + } + + const defs = getControllerSpec(MyController) + .definitions as DefinitionsObject; + expect(defs).to.have.keys('MyBody', 'Foo'); + expect(defs.MyBody).to.not.have.key('definitions'); + }); + + it('infers empty body parameter schema if no property metadata is present', () => { + const paramSpec: ParameterObject = { + name: 'foo', + in: 'body', + }; + @model() + class MyBody { + name: string; + } + class MyController { + @post('/foo') + foo(@param(paramSpec) foo: MyBody) {} + } + + const defs = getControllerSpec(MyController) + .definitions as DefinitionsObject; + + expect(defs).to.have.key('MyBody'); + expect(defs.MyBody).to.deepEqual({}); + }); + + it('does not infer definition if no class metadata is present', () => { + const paramSpec: ParameterObject = { + name: 'foo', + in: 'body', + }; + class MyBody { + @property() name: string; + } + class MyController { + @post('/foo') + foo(@param(paramSpec) foo: MyBody) {} + } + + const defs = getControllerSpec(MyController) + .definitions as DefinitionsObject; + + expect(defs).to.have.key('MyBody'); + expect(defs.MyBody).to.deepEqual({}); + }); + it('can define multiple parameters in order', () => { const offsetSpec: ParameterObject = { name: 'offset', diff --git a/packages/openapi-v2/test/unit/controller-spec/json-to-schema-object.test.ts b/packages/openapi-v2/test/unit/controller-spec/json-to-schema-object.test.ts new file mode 100644 index 000000000000..97bf3952bfb6 --- /dev/null +++ b/packages/openapi-v2/test/unit/controller-spec/json-to-schema-object.test.ts @@ -0,0 +1,172 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-v2. +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {JsonDefinition} from '@loopback/repository-json-schema'; +import {SchemaObject} from '@loopback/openapi-spec'; +import {jsonToSchemaObject} from '../../../index'; + +describe('jsonToSchemaObject', () => { + it('does nothing when given an empty object', () => { + expect({}).to.eql({}); + }); + const typeDef: JsonDefinition = {type: ['string', 'number']}; + const expectedType: SchemaObject = {type: 'string'}; + it('converts type', () => { + propertyConversionTest(typeDef, expectedType); + }); + + it('ignores non-compatible JSON schema properties', () => { + const nonCompatibleDef: JsonDefinition = { + anyOf: [], + oneOf: [], + additionalItems: { + anyOf: [], + }, + defaultProperties: [], + typeof: 'function', + }; + const expectedDef: SchemaObject = {}; + propertyConversionTest(nonCompatibleDef, expectedDef); + }); + + it('converts allOf', () => { + const allOfDef: JsonDefinition = { + allOf: [typeDef, typeDef], + }; + const expectedAllOf: SchemaObject = { + allOf: [expectedType, expectedType], + }; + propertyConversionTest(allOfDef, expectedAllOf); + }); + + it('converts definitions', () => { + const definitionsDef: JsonDefinition = { + definitions: {foo: typeDef, bar: typeDef}, + }; + const expectedDef: SchemaObject = { + definitions: {foo: expectedType, bar: expectedType}, + }; + propertyConversionTest(definitionsDef, expectedDef); + }); + + it('converts properties', () => { + const propertyDef: JsonDefinition = { + properties: { + foo: typeDef, + }, + }; + const expectedProperties: SchemaObject = { + properties: { + foo: expectedType, + }, + }; + propertyConversionTest(propertyDef, expectedProperties); + }); + + context('additionalProperties', () => { + it('is converted properly when the type is JsonDefinition', () => { + const additionalDef: JsonDefinition = { + additionalProperties: typeDef, + }; + const expectedAdditional: SchemaObject = { + additionalProperties: expectedType, + }; + propertyConversionTest(additionalDef, expectedAdditional); + }); + + it('is converted properly when it is "false"', () => { + const noAdditionalDef: JsonDefinition = { + additionalProperties: false, + }; + const expectedDef: SchemaObject = {}; + propertyConversionTest(noAdditionalDef, expectedDef); + }); + }); + + it('converts items', () => { + const itemsDef: JsonDefinition = { + type: 'array', + items: typeDef, + }; + const expectedItems: SchemaObject = { + type: 'array', + items: expectedType, + }; + propertyConversionTest(itemsDef, expectedItems); + }); + + context('enum', () => { + it('is converted properly when the type is primitive', () => { + const enumStringDef: JsonDefinition = { + enum: ['foo', 'bar'], + }; + const expectedStringDef: SchemaObject = { + enum: ['foo', 'bar'], + }; + propertyConversionTest(enumStringDef, expectedStringDef); + }); + + it('is converted properly when it is null', () => { + const enumNullDef: JsonDefinition = { + enum: [null, null], + }; + const expectedNullDef: JsonDefinition = { + enum: [null, null], + }; + propertyConversionTest(enumNullDef, expectedNullDef); + }); + + it('is converted properly when the type is complex', () => { + const enumCustomDef: JsonDefinition = { + enum: [typeDef, typeDef], + }; + const expectedCustomDef: SchemaObject = { + enum: [expectedType, expectedType], + }; + propertyConversionTest(enumCustomDef, expectedCustomDef); + }); + }); + + it('retains given properties in the conversion', () => { + const inputDef: JsonDefinition = { + title: 'foo', + type: 'object', + properties: { + foo: { + type: 'string', + }, + }, + default: 'Default string', + }; + const expectedDef: SchemaObject = { + title: 'foo', + type: 'object', + properties: { + foo: { + type: 'string', + }, + }, + default: 'Default string', + }; + expect(jsonToSchemaObject(inputDef)).to.eql(expectedDef); + }); + + it('errors if type is an array and items is missing', () => { + expect.throws( + () => { + jsonToSchemaObject({type: 'array'}); + }, + Error, + '"items" property must be present if "type" is an array', + ); + }); + + // Helper function to check conversion of JSON Schema properties + // to Swagger versions + function propertyConversionTest(property: Object, expected: Object) { + expect(jsonToSchemaObject(property)).to.deepEqual(expected); + } +}); diff --git a/packages/repository-json-schema/.npmrc b/packages/repository-json-schema/.npmrc new file mode 100644 index 000000000000..43c97e719a5a --- /dev/null +++ b/packages/repository-json-schema/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/repository-json-schema/LICENSE b/packages/repository-json-schema/LICENSE new file mode 100644 index 000000000000..e03a4f2770bf --- /dev/null +++ b/packages/repository-json-schema/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2018. All Rights Reserved. +Node module: @loopback/repository-json-schema +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/repository-json-schema/README.md b/packages/repository-json-schema/README.md new file mode 100644 index 000000000000..061768309413 --- /dev/null +++ b/packages/repository-json-schema/README.md @@ -0,0 +1,55 @@ +# @loopback/repository-json-schema + +Convert a TypeScript class/model to a JSON Schema for users, leveraging LoopBack4's decorators, metadata, and reflection system. + +## Overview + +This package provides modules to easily convert LoopBack4 models that have been decorated with `@model` and `@property` to a matching JSON Schema Definition. + +## Installation + +```shell +$ npm install --save @loopback/repository-json-schema +``` + +## Basic use + +```ts +import {getJsonSchema} from '@loopback/repository-json-schema'; +import {model, property} from '@loopback/repository'; + +@model() +MyModel { + @property() name: string; +} + +const jsonSchema = getJsonSchema(MyModel); +``` + +The value of `jsonSchema` will be: + +```json +{ + "properties": { + "name": { + "type": "string" + } + } +} +``` + +## Contributions + +IBM/StrongLoop is an active supporter of open source and welcomes contributions to our projects as well as those of the Node.js community in general. For more information on how to contribute please refer to the [Contribution Guide](https://loopback.io/doc/en/contrib/index.html). + +# Tests + +run `npm test` from the root folder. + +# Contributors + +See [all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). + +# License + +MIT diff --git a/packages/repository-json-schema/docs.json b/packages/repository-json-schema/docs.json new file mode 100644 index 000000000000..04013f4d6655 --- /dev/null +++ b/packages/repository-json-schema/docs.json @@ -0,0 +1,8 @@ +{ + "content": [ + "index.ts", + "src/index.ts", + "src/build-schema.ts" + ], + "codeSectionDepth": 4 +} diff --git a/packages/repository-json-schema/index.d.ts b/packages/repository-json-schema/index.d.ts new file mode 100644 index 000000000000..d0dd8c84a44b --- /dev/null +++ b/packages/repository-json-schema/index.d.ts @@ -0,0 +1,6 @@ +// 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 + +export * from './dist/src'; diff --git a/packages/repository-json-schema/index.js b/packages/repository-json-schema/index.js new file mode 100644 index 000000000000..c198f744dfee --- /dev/null +++ b/packages/repository-json-schema/index.js @@ -0,0 +1,8 @@ +// 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 + +const nodeMajorVersion = +process.versions.node.split('.')[0]; +module.exports = + nodeMajorVersion >= 7 ? require('./dist/src') : require('./dist6/src'); diff --git a/packages/repository-json-schema/index.ts b/packages/repository-json-schema/index.ts new file mode 100644 index 000000000000..22f271bb3862 --- /dev/null +++ b/packages/repository-json-schema/index.ts @@ -0,0 +1,7 @@ +// 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 + +// NOTE(bajtos) This file is used by VSCode/TypeScriptServer at dev time only +export * from './src'; diff --git a/packages/repository-json-schema/package.json b/packages/repository-json-schema/package.json new file mode 100644 index 000000000000..1b43bc0726b1 --- /dev/null +++ b/packages/repository-json-schema/package.json @@ -0,0 +1,50 @@ +{ + "name": "@loopback/repository-json-schema", + "version": "4.0.0-alpha.1", + "description": "Converts TS classes into JSON Schemas using TypeScript's reflection API", + "engines": { + "node": ">=6" + }, + "scripts": { + "build": "npm run build:dist", + "build:current": "lb-tsc", + "build:dist": "lb-tsc es2017", + "build:dist6": "lb-tsc es2015", + "build:apidocs": "lb-apidocs", + "clean": "lb-clean loopback-json-schema*.tgz dist dist6 package api-docs", + "prepare": "npm run build && npm run build:apidocs", + "pretest": "npm run build:current", + "test": "lb-dist mocha --opts node_modules/@loopback/build/mocha.opts 'dist/test/unit/**/*.js' 'dist/test/integration/**/*.js' 'dist/test/acceptance/**/*.js'", + "verify": "npm pack && tar xf loopback-json-schema*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "license": "MIT", + "keywords": [ + "TypeScript", + "JSON Schema" + ], + "dependencies": { + "@loopback/context": "^4.0.0-alpha.25", + "@loopback/repository": "^4.0.0-alpha.22", + "lodash": "^4.17.4", + "typescript-json-schema": "^0.20.0" + }, + "devDependencies": { + "@loopback/build": "^4.0.0-alpha.7", + "@loopback/testlab": "^4.0.0-alpha.17", + "@types/lodash": "^4.14.92" + }, + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist/src", + "dist6/src", + "api-docs", + "src" + ], + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + } +} diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts new file mode 100644 index 000000000000..d9386b972416 --- /dev/null +++ b/packages/repository-json-schema/src/build-schema.ts @@ -0,0 +1,136 @@ +// 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 { + ModelMetadataHelper, + PropertyDefinition, + ModelDefinition, +} from '@loopback/repository'; +import {includes} from 'lodash'; +import {Definition, PrimitiveType} from 'typescript-json-schema'; +import {MetadataInspector} from '@loopback/context'; + +export const JSON_SCHEMA_KEY = 'loopback:json-schema'; + +/** + * Type definition for JSON Schema + */ +export interface JsonDefinition extends Definition { + allOf?: JsonDefinition[]; + oneOf?: JsonDefinition[]; + anyOf?: JsonDefinition[]; + items?: JsonDefinition | JsonDefinition[]; + additionalItems?: { + anyOf: JsonDefinition[]; + }; + enum?: PrimitiveType[] | JsonDefinition[]; + additionalProperties?: JsonDefinition | boolean; + definitions?: {[definition: string]: JsonDefinition}; + properties?: {[property: string]: JsonDefinition}; +} + +/** + * 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): JsonDefinition { + // NOTE(shimks) currently impossible to dynamically update + const jsonSchema = MetadataInspector.getClassMetadata(JSON_SCHEMA_KEY, ctor); + if (jsonSchema) { + return jsonSchema; + } else { + const newSchema = modelToJsonSchema(ctor); + MetadataInspector.defineMetadata(JSON_SCHEMA_KEY, newSchema, ctor); + return newSchema; + } +} + +// NOTE(shimks) no metadata for: union, optional, nested array, any, enum, +// string literal, anonymous types, and inherited properties + +/** + * Converts a TypeScript class into a JSON Schema using TypeScript's + * reflection API + * @param ctor Constructor of class to convert from + */ +export function modelToJsonSchema(ctor: Function): JsonDefinition { + const meta: ModelDefinition = ModelMetadataHelper.getModelMetadata(ctor); + const schema: JsonDefinition = {}; + + const isComplexType = (constructor: Function) => + !includes([String, Number, Boolean, Object], constructor); + + const determinePropertyDef = (constructor: Function) => + isComplexType(constructor) + ? {$ref: `#definitions/${constructor.name}`} + : {type: constructor.name.toLowerCase()}; + + for (const p in meta.properties) { + const propMeta = meta.properties[p]; + let propCtor = propMeta.type; + if (typeof propCtor === 'string') { + const type = propCtor.toLowerCase(); + switch (type) { + case 'number': { + propCtor = Number; + break; + } + case 'string': { + propCtor = String; + break; + } + case 'boolean': { + propCtor = Boolean; + break; + } + default: { + throw new Error('Unsupported type'); + } + } + } + if (propCtor && typeof propCtor === 'function') { + // errors out if @property.array() is not used on a property of array + if (propCtor === Array) { + throw new Error('type is defined as an array'); + } + + const propDef: JsonDefinition = determinePropertyDef(propCtor); + + if (!schema.properties) { + schema.properties = {}; + } + + if (propMeta.array === true) { + schema.properties[p] = { + type: 'array', + items: propDef, + }; + } else { + schema.properties[p] = propDef; + } + + if (isComplexType(propCtor)) { + const propSchema = getJsonSchema(propCtor); + + if (propSchema && Object.keys(propSchema).length > 0) { + if (!schema.definitions) { + schema.definitions = {}; + } + + if (propSchema.definitions) { + for (const key in propSchema.definitions) { + schema.definitions[key] = propSchema.definitions[key]; + } + delete propSchema.definitions; + } + + schema.definitions[propCtor.name] = propSchema; + } + } + } + } + return schema; +} diff --git a/packages/repository-json-schema/src/index.ts b/packages/repository-json-schema/src/index.ts new file mode 100644 index 000000000000..96e9407c4901 --- /dev/null +++ b/packages/repository-json-schema/src/index.ts @@ -0,0 +1,6 @@ +// 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 + +export * from './build-schema'; diff --git a/packages/repository-json-schema/test/integration/build-schema.test.ts b/packages/repository-json-schema/test/integration/build-schema.test.ts new file mode 100644 index 000000000000..a616294b2628 --- /dev/null +++ b/packages/repository-json-schema/test/integration/build-schema.test.ts @@ -0,0 +1,327 @@ +// 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 { + model, + property, + ModelMetadataHelper, + ModelDefinition, + PropertyMap, +} from '@loopback/repository'; +import {modelToJsonSchema} from '../../src/build-schema'; +import {expect} from '@loopback/testlab'; +import {MetadataInspector} from '@loopback/context'; +import {JSON_SCHEMA_KEY, getJsonSchema} from '../../index'; + +describe('build-schema', () => { + describe('modelToJsonSchema', () => { + it('does not convert null or undefined property', () => { + @model() + class TestModel { + @property() nul: null; + @property() undef: undefined; + } + + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.not.have.keys(['nul', 'undef']); + }); + + it('does not convert properties that have not been decorated', () => { + @model() + class NoPropertyMeta { + prop: string; + } + @model() + class OnePropertyDecorated { + @property() foo: string; + bar: boolean; + baz: number; + } + + expect(modelToJsonSchema(NoPropertyMeta)).to.eql({}); + expect(modelToJsonSchema(OnePropertyDecorated)).to.deepEqual({ + properties: { + foo: { + type: 'string', + }, + }, + }); + }); + + it('does not convert models that have not been decorated with @model()', () => { + class Empty {} + class NoModelMeta { + @property() foo: string; + bar: number; + } + + expect(modelToJsonSchema(Empty)).to.eql({}); + expect(modelToJsonSchema(NoModelMeta)).to.eql({}); + }); + + it('properly converts string, number, and boolean properties', () => { + @model() + class TestModel { + @property() str: string; + @property() num: number; + @property() bool: boolean; + } + + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.deepEqual({ + str: { + type: 'string', + }, + num: { + type: 'number', + }, + bool: { + type: 'boolean', + }, + }); + }); + + it('properly converts object properties', () => { + @model() + class TestModel { + @property() obj: object; + } + + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.deepEqual({ + obj: { + type: 'object', + }, + }); + }); + + context('with custom type properties', () => { + it('properly converts undecorated custom type properties', () => { + class CustomType { + prop: string; + } + + @model() + class TestModel { + @property() cusType: CustomType; + } + + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.deepEqual({ + cusType: { + $ref: '#definitions/CustomType', + }, + }); + expect(jsonSchema).to.not.have.key('definitions'); + }); + + it('properly converts decorated custom type properties', () => { + @model() + class CustomType { + @property() prop: string; + } + + @model() + class TestModel { + @property() cusType: CustomType; + } + + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.deepEqual({ + cusType: { + $ref: '#definitions/CustomType', + }, + }); + expect(jsonSchema.definitions).to.deepEqual({ + CustomType: { + properties: { + prop: { + type: 'string', + }, + }, + }, + }); + }); + + it('creates definitions only at the root level of the schema', () => { + @model() + class CustomTypeFoo { + @property() prop: string; + } + + @model() + class CustomTypeBar { + @property.array(CustomTypeFoo) prop: CustomTypeFoo[]; + } + + @model() + class TestModel { + @property() cusBar: CustomTypeBar; + } + + const jsonSchema = modelToJsonSchema(TestModel); + const schemaProps = jsonSchema.properties; + const schemaDefs = jsonSchema.definitions; + expect(schemaProps).to.deepEqual({ + cusBar: { + $ref: '#definitions/CustomTypeBar', + }, + }); + expect(schemaDefs).to.deepEqual({ + CustomTypeFoo: { + properties: { + prop: { + type: 'string', + }, + }, + }, + CustomTypeBar: { + properties: { + prop: { + type: 'array', + items: { + $ref: '#definitions/CustomTypeFoo', + }, + }, + }, + }, + }); + }); + }); + + it('properly converts primitive arrays properties', () => { + @model() + class TestModel { + @property.array(Number) numArr: number[]; + } + + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.deepEqual({ + numArr: { + type: 'array', + items: { + type: 'number', + }, + }, + }); + }); + + it('properly converts custom type arrays properties', () => { + class CustomType { + prop: string; + } + + @model() + class TestModel { + @property.array(CustomType) cusArr: CustomType[]; + } + + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.deepEqual({ + cusArr: { + type: 'array', + items: { + $ref: '#definitions/CustomType', + }, + }, + }); + }); + + it('supports explicit primitive type decoration via strings', () => { + @model() + class TestModel { + @property({type: 'string'}) + hardStr: Number; + @property({type: 'boolean'}) + hardBool: String; + @property({type: 'number'}) + hardNum: Boolean; + } + + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.deepEqual({ + hardStr: { + type: 'string', + }, + hardBool: { + type: 'boolean', + }, + hardNum: { + type: 'number', + }, + }); + }); + + it('errors out when explicit type decoration is not primitive', () => { + @model() + class TestModel { + @property({type: 'NotPrimitive'}) + bad: String; + } + + expect(() => modelToJsonSchema(TestModel)).to.throw(/Unsupported type/); + }); + + it('errors out when "@property.array" is not used on an array', () => { + @model() + class BadArray { + @property() badArr: string[]; + } + + expect(() => { + modelToJsonSchema(BadArray); + }).to.throw(/type is defined as an array/); + }); + + it('errors out if "@property.array" is given "Array" as parameter', () => { + @model() + class BadArray { + @property.array(Array) badArr: string[][]; + } + + expect(() => { + modelToJsonSchema(BadArray); + }).to.throw(/type is defined as an array/); + }); + }); + + describe('getjsonSchema', () => { + it('gets cached JSON schema if one exists', () => { + @model() + class TestModel { + @property() foo: number; + } + const cachedSchema = { + properties: { + cachedProperty: { + type: 'string', + }, + }, + }; + MetadataInspector.defineMetadata( + JSON_SCHEMA_KEY, + cachedSchema, + TestModel, + ); + + const jsonSchema = getJsonSchema(TestModel); + expect(jsonSchema).to.eql(cachedSchema); + }); + + it('creates JSON schema if one does not already exist', () => { + @model() + class NewModel { + @property() newProperty: string; + } + + const jsonSchema = getJsonSchema(NewModel); + expect(jsonSchema.properties).to.deepEqual({ + newProperty: { + type: 'string', + }, + }); + }); + }); +}); diff --git a/packages/repository-json-schema/tsconfig.build.json b/packages/repository-json-schema/tsconfig.build.json new file mode 100644 index 000000000000..855e02848b35 --- /dev/null +++ b/packages/repository-json-schema/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../build/config/tsconfig.common.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["src", "test"] +} diff --git a/packages/repository/src/decorators/metadata.ts b/packages/repository/src/decorators/metadata.ts index fed4e59a253b..9b599f162345 100644 --- a/packages/repository/src/decorators/metadata.ts +++ b/packages/repository/src/decorators/metadata.ts @@ -4,8 +4,13 @@ // License text available at https://opensource.org/licenses/MIT import {InspectionOptions, MetadataInspector} from '@loopback/context'; -import {MODEL_PROPERTIES_KEY, MODEL_WITH_PROPERTIES_KEY} from './model'; -import {ModelDefinition, PropertyDefinition} from '../model'; +import { + MODEL_PROPERTIES_KEY, + MODEL_WITH_PROPERTIES_KEY, + PropertyMap, +} from './model'; +import {ModelDefinition} from '../model'; + export class ModelMetadataHelper { /** * A utility function to simplify retrieving metadata from a target model and @@ -14,8 +19,12 @@ export class ModelMetadataHelper { * @param options An options object for the MetadataInspector to customize * the output of the metadata retrieval functions. */ - static getModelMetadata(target: Function, options?: InspectionOptions) { - let classDef = MetadataInspector.getClassMetadata( + static getModelMetadata( + target: Function, + options?: InspectionOptions, + ): ModelDefinition { + let classDef: ModelDefinition | undefined; + classDef = MetadataInspector.getClassMetadata( MODEL_WITH_PROPERTIES_KEY, target, options, @@ -26,11 +35,12 @@ export class ModelMetadataHelper { if (classDef) { return classDef; } else { + // sets the metadata to a dedicated key if cached value does not exist const meta = new ModelDefinition( Object.assign({name: target.name}, classDef), ); meta.properties = Object.assign( - {}, + {}, MetadataInspector.getAllPropertyMetadata( MODEL_PROPERTIES_KEY, target.prototype, diff --git a/packages/repository/src/decorators/model.ts b/packages/repository/src/decorators/model.ts index 8869f481e08c..920bdc455641 100644 --- a/packages/repository/src/decorators/model.ts +++ b/packages/repository/src/decorators/model.ts @@ -19,7 +19,7 @@ export const MODEL_KEY = 'loopback:model'; export const MODEL_PROPERTIES_KEY = 'loopback:model-properties'; export const MODEL_WITH_PROPERTIES_KEY = 'loopback:model-and-properties'; -type PropertyMap = MetadataMap; +export type PropertyMap = MetadataMap; // tslint:disable:no-any diff --git a/packages/rest/package.json b/packages/rest/package.json index bec32af15803..a4bf29fb8e6e 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -41,6 +41,7 @@ "@loopback/build": "^4.0.0-alpha.9", "@loopback/openapi-spec-builder": "^4.0.0-alpha.17", "@loopback/testlab": "^4.0.0-alpha.19", + "@loopback/repository": "^4.0.0-alpha.23", "@types/js-yaml": "^3.9.1", "@types/lodash": "^4.14.85" }, diff --git a/packages/rest/src/http-handler.ts b/packages/rest/src/http-handler.ts index f841126c9345..700e1ca3f7f7 100644 --- a/packages/rest/src/http-handler.ts +++ b/packages/rest/src/http-handler.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {Context} from '@loopback/context'; -import {PathsObject} from '@loopback/openapi-spec'; +import {PathsObject, DefinitionsObject} from '@loopback/openapi-spec'; import {ServerRequest, ServerResponse} from 'http'; import {ControllerSpec} from '@loopback/openapi-v2'; @@ -22,6 +22,7 @@ import {RestBindings} from './keys'; export class HttpHandler { protected _routes: RoutingTable = new RoutingTable(); + protected _apiDefinitions: DefinitionsObject; public handleRequest: ( request: ServerRequest, @@ -40,6 +41,14 @@ export class HttpHandler { this._routes.registerRoute(route); } + registerApiDefinitions(defs: DefinitionsObject) { + this._apiDefinitions = Object.assign({}, this._apiDefinitions, defs); + } + + getApiDefinitions() { + return this._apiDefinitions; + } + describeApiPaths(): PathsObject { return this._routes.describeApiPaths(); } diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 1cc297e21689..3eef1ec85aed 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -218,6 +218,9 @@ export class RestServer extends Context implements Server { // controller methods are specified through app.api() spec continue; } + if (apiSpec.definitions) { + this._httpHandler.registerApiDefinitions(apiSpec.definitions); + } this._httpHandler.registerController(ctor, apiSpec); } @@ -445,11 +448,14 @@ export class RestServer extends Context implements Server { */ getApiSpec(): OpenApiSpec { const spec = this.getSync(RestBindings.API_SPEC); + const defs = this.httpHandler.getApiDefinitions(); // Apply deep clone to prevent getApiSpec() callers from // accidentally modifying our internal routing data spec.paths = cloneDeep(this.httpHandler.describeApiPaths()); - + if (defs) { + spec.definitions = cloneDeep(defs); + } return spec; } diff --git a/packages/rest/test/unit/rest-server/rest-server.open-api-spec.test.ts b/packages/rest/test/unit/rest-server/rest-server.open-api-spec.test.ts index 34a510f00594..8f03a9d15565 100644 --- a/packages/rest/test/unit/rest-server/rest-server.open-api-spec.test.ts +++ b/packages/rest/test/unit/rest-server/rest-server.open-api-spec.test.ts @@ -6,8 +6,9 @@ import {expect, validateApiSpec} from '@loopback/testlab'; import {Application} from '@loopback/core'; import {RestServer, Route, RestComponent} from '../../..'; -import {get} from '@loopback/openapi-v2'; +import {get, post, param} from '@loopback/openapi-v2'; import {anOpenApiSpec} from '@loopback/openapi-spec-builder'; +import {model, property} from '@loopback/repository'; describe('RestServer.getApiSpec()', () => { let app: Application; @@ -119,6 +120,29 @@ describe('RestServer.getApiSpec()', () => { }); }); + it('returns definitions inferred via app.controller()', () => { + @model() + class MyModel { + @property() bar: string; + } + class MyController { + @post('/foo') + createFoo(@param.body('foo') foo: MyModel) {} + } + app.controller(MyController); + + const spec = server.getApiSpec(); + expect(spec.definitions).to.deepEqual({ + MyModel: { + properties: { + bar: { + type: 'string', + }, + }, + }, + }); + }); + it('preserves routes specified in app.api()', () => { function status() {} server.api(