From 5b5af77a9fa332e0ab5e6437694ff258aea2ff01 Mon Sep 17 00:00:00 2001 From: shimks Date: Thu, 11 Jan 2018 16:46:34 -0500 Subject: [PATCH 1/6] feat(repository-json-schema): add modules to convert TS models to JSON Schema --- packages/json-schema/.gitignore | 3 + packages/json-schema/.npmrc | 1 + packages/json-schema/LICENSE | 25 ++ packages/json-schema/README.md | 55 +++++ packages/json-schema/docs.json | 8 + packages/json-schema/index.d.ts | 6 + packages/json-schema/index.js | 9 + packages/json-schema/index.ts | 7 + packages/json-schema/package.json | 49 ++++ packages/json-schema/src/build-schema.ts | 74 ++++++ packages/json-schema/src/index.ts | 6 + .../test/integration/build-schema.test.ts | 215 ++++++++++++++++++ packages/json-schema/tsconfig.build.json | 8 + packages/openapi-v2/package.json | 5 +- packages/openapi-v2/src/controller-spec.ts | 38 +++- .../param-decorators/param-body.test.ts | 23 ++ .../param-decorators/param.test.ts | 102 ++++++++- packages/rest/package.json | 1 + packages/rest/src/http-handler.ts | 11 +- packages/rest/src/rest-server.ts | 8 +- .../rest-server.open-api-spec.test.ts | 28 ++- 21 files changed, 673 insertions(+), 9 deletions(-) create mode 100644 packages/json-schema/.gitignore create mode 100644 packages/json-schema/.npmrc create mode 100644 packages/json-schema/LICENSE create mode 100644 packages/json-schema/README.md create mode 100644 packages/json-schema/docs.json create mode 100644 packages/json-schema/index.d.ts create mode 100644 packages/json-schema/index.js create mode 100644 packages/json-schema/index.ts create mode 100644 packages/json-schema/package.json create mode 100644 packages/json-schema/src/build-schema.ts create mode 100644 packages/json-schema/src/index.ts create mode 100644 packages/json-schema/test/integration/build-schema.test.ts create mode 100644 packages/json-schema/tsconfig.build.json diff --git a/packages/json-schema/.gitignore b/packages/json-schema/.gitignore new file mode 100644 index 000000000000..90a8d96cc3ff --- /dev/null +++ b/packages/json-schema/.gitignore @@ -0,0 +1,3 @@ +*.tgz +dist* +package diff --git a/packages/json-schema/.npmrc b/packages/json-schema/.npmrc new file mode 100644 index 000000000000..43c97e719a5a --- /dev/null +++ b/packages/json-schema/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/json-schema/LICENSE b/packages/json-schema/LICENSE new file mode 100644 index 000000000000..fbe7fa628077 --- /dev/null +++ b/packages/json-schema/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2018. All Rights Reserved. +Node module: @loopback/openapi-spec-builder +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/json-schema/README.md b/packages/json-schema/README.md new file mode 100644 index 000000000000..959c29cf3c37 --- /dev/null +++ b/packages/json-schema/README.md @@ -0,0 +1,55 @@ +# @loopback/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/json-schema +``` + +## Basic use + +```ts +import {modelToJsonDef} from '@loopback/json-schema'; +import {model, property} from '@loopback/repository'; + +@model() +MyModel { + @property() name: string; +} + +const jsonDef = modelToJsonDef(MyModel); +``` + +The value of `jsonDef` 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/json-schema/docs.json b/packages/json-schema/docs.json new file mode 100644 index 000000000000..e94e51fe86cf --- /dev/null +++ b/packages/json-schema/docs.json @@ -0,0 +1,8 @@ +{ + "content": ["index.ts", "src/index.ts", "src/build-schema.ts"], + "codeSectionDepth": 4, + "assets": { + "/": "/docs", + "/docs": "/docs" + } +} diff --git a/packages/json-schema/index.d.ts b/packages/json-schema/index.d.ts new file mode 100644 index 000000000000..4f5615634fc1 --- /dev/null +++ b/packages/json-schema/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/openapi-spec-builder +// 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/json-schema/index.js b/packages/json-schema/index.js new file mode 100644 index 000000000000..6498c3381e61 --- /dev/null +++ b/packages/json-schema/index.js @@ -0,0 +1,9 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/testlab +// 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/json-schema/index.ts b/packages/json-schema/index.ts new file mode 100644 index 000000000000..f31e48e330c7 --- /dev/null +++ b/packages/json-schema/index.ts @@ -0,0 +1,7 @@ +// Copyright IBM Corp. 2013,2018. All Rights Reserved. +// Node module: @loopback/openapi-spec-builder +// 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/json-schema/package.json b/packages/json-schema/package.json new file mode 100644 index 000000000000..7c3693a5e35a --- /dev/null +++ b/packages/json-schema/package.json @@ -0,0 +1,49 @@ +{ + "name": "@loopback/json-schema", + "version": "4.0.0-alpha.1", + "description": "Converts TS classes into JSON Schemas using TypeScript's reflection API", + "engines": { + "node": ">=8" + }, + "scripts": { + "build": "npm run build:dist && npm run build:dist6", + "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 ../../test/mocha.opts 'DIST/test/unit/**/*.js' 'DIST/test/integration/**/*.js' 'DIST/test/acceptance/**/*.js'", + "verify": "npm pack && tar xf loopback-jsons-schema*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "license": "MIT", + "keywords": [ + "TypeScript", + "JSON Schema" + ], + "dependencies": { + "@loopback/openapi-spec": "^4.0.0-alpha.18", + "@loopback/repository": "^4.0.0-alpha.22", + "lodash": "^4.17.4" + }, + "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/json-schema/src/build-schema.ts b/packages/json-schema/src/build-schema.ts new file mode 100644 index 000000000000..dccb8d520b3a --- /dev/null +++ b/packages/json-schema/src/build-schema.ts @@ -0,0 +1,74 @@ +// Copyright IBM Corp. 2013,2018. All Rights Reserved. +// Node module: @loopback/openapi-spec-builder +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {SchemaObject, MapObject} from '@loopback/openapi-spec'; +import {ModelMetadataHelper, PropertyDefinition} from '@loopback/repository'; +import {includes} from 'lodash'; + +/** + * Type definition for a JSON Schema Definition + * Currently, objects of type JSONDefinition can also be cast as a SchemaObject, + * a property of OpenAPI-v2's specification + */ +export type JsonDefinition = { + $ref?: string; + required?: Array; + type?: string; + properties?: {[property: string]: JsonDefinition} | MapObject; + items?: JsonDefinition | SchemaObject; +}; + +// 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 modelToJsonDef(ctor: Function): JsonDefinition { + // tslint:disable-next-line:no-any + const meta = ModelMetadataHelper.getModelMetadata(ctor) as any; + const schema: JsonDefinition = {}; + + for (const p in meta.properties) { + const propMeta = meta.properties[p]; + if (propMeta.type) { + if (!schema.properties) { + schema.properties = {}; + } + schema.properties[p] = toJsonProperty(propMeta); + } + } + return schema; +} + +/** + * Converts a property in metadata form to a JSON schema property definition + * @param propMeta Property in metadata to convert from + */ +export function toJsonProperty(propMeta: PropertyDefinition): JsonDefinition { + const ctor = propMeta.type as Function; + + // errors out if @property.array() is not used on a property of array + if (ctor === Array) { + throw new Error('type is defined as an array'); + } + + let prop: JsonDefinition = {}; + + if (propMeta.array === true) { + prop.type = 'array'; + prop.items = toJsonProperty({ + array: ctor === Array ? true : false, + type: ctor, + }); + } else if (includes([String, Number, Boolean, Object], ctor)) { + prop.type = ctor.name.toLowerCase(); + } else { + prop.$ref = `#definitions/${ctor.name}`; + } + return prop; +} diff --git a/packages/json-schema/src/index.ts b/packages/json-schema/src/index.ts new file mode 100644 index 000000000000..7267398a024a --- /dev/null +++ b/packages/json-schema/src/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2013,2018. All Rights Reserved. +// Node module: @loopback/openapi-spec +// 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/json-schema/test/integration/build-schema.test.ts b/packages/json-schema/test/integration/build-schema.test.ts new file mode 100644 index 000000000000..e4c00e012dc5 --- /dev/null +++ b/packages/json-schema/test/integration/build-schema.test.ts @@ -0,0 +1,215 @@ +// Copyright IBM Corp. 2013,2018. All Rights Reserved. +// Node module: @loopback/openapi-spec-builder +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {model, property, ModelMetadataHelper} from '@loopback/repository'; +import {modelToJsonDef, toJsonProperty} from '../../src/build-schema'; +import {expect} from '@loopback/testlab'; + +describe('build-schema', () => { + context('exception', () => { + it('errors out when "@property.array" is not used on an array', () => { + @model() + class BadArray { + @property() badArr: string[]; + } + expect(() => { + modelToJsonDef(BadArray); + }).to.throw(/type is defined as an array/); + }); + }); + describe('modelToJSON', () => { + it('does not convert null or undefined property', () => { + @model() + class TestModel { + @property() nul: null; + @property() undef: undefined; + } + expect(modelToJsonDef(TestModel).properties).to.not.containEql({ + nul: {type: 'null'}, + }); + expect(modelToJsonDef(TestModel).properties).to.not.containEql({ + undef: {type: 'undefined'}, + }); + }); + 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(modelToJsonDef(NoPropertyMeta)).to.eql({}); + expect(modelToJsonDef(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(modelToJsonDef(Empty)).to.eql({}); + expect(modelToJsonDef(NoModelMeta)).to.eql({}); + }); + it('properly converts string, number, and boolean properties', () => { + @model() + class TestModel { + @property() str: string; + @property() num: number; + @property() bool: boolean; + } + + const jsonDef = modelToJsonDef(TestModel); + expect(jsonDef.properties).to.deepEqual({ + str: { + type: 'string', + }, + num: { + type: 'number', + }, + bool: { + type: 'boolean', + }, + }); + }); + it('properly converts object properties', () => { + @model() + class TestModel { + @property() obj: object; + } + + const jsonDef = modelToJsonDef(TestModel); + expect(jsonDef.properties).to.deepEqual({ + obj: { + type: 'object', + }, + }); + }); + it('properly converts custom type properties', () => { + class CustomType { + prop: string; + } + + @model() + class TestModel { + @property() cusType: CustomType; + } + + const jsonDef = modelToJsonDef(TestModel); + expect(jsonDef.properties).to.deepEqual({ + cusType: { + $ref: '#definitions/CustomType', + }, + }); + }); + it('properly converts primitive arrays properties', () => { + @model() + class TestModel { + @property.array(Number) numArr: number[]; + } + + const jsonDef = modelToJsonDef(TestModel); + expect(jsonDef.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 jsonDef = modelToJsonDef(TestModel); + expect(jsonDef.properties).to.deepEqual({ + cusArr: { + type: 'array', + items: { + $ref: '#definitions/CustomType', + }, + }, + }); + }); + describe('toJSONProperty', () => { + class Bar { + barA: number; + } + @model() + class Foo { + @property() str: string; + @property() num: number; + @property() bool: boolean; + @property() obj: object; + @property() nul: null; + @property() undef: undefined; + @property.array(String) arrStr: string[]; + @property() bar: Bar; + @property.array(Bar) arrBar: Bar[]; + } + // tslint:disable-next-line:no-any + let meta: any; + // tslint:disable-next-line:no-any + let propMeta: any; + before(() => { + meta = ModelMetadataHelper.getModelMetadata(Foo); + propMeta = meta.properties; + }); + it('converts primitively typed property correctly', () => { + expect(toJsonProperty(propMeta['str'])).to.deepEqual({ + type: 'string', + }); + expect(toJsonProperty(propMeta['num'])).to.deepEqual({ + type: 'number', + }); + expect(toJsonProperty(propMeta['bool'])).to.deepEqual({ + type: 'boolean', + }); + }); + it('converts object property correctly', () => { + expect(toJsonProperty(propMeta['obj'])).to.deepEqual({ + type: 'object', + }); + }); + it('converts customly typed property correctly', () => { + expect(toJsonProperty(propMeta['bar'])).to.deepEqual({ + $ref: '#definitions/Bar', + }); + }); + it('converts arrays of primitives correctly', () => { + expect(toJsonProperty(propMeta['arrStr'])).to.deepEqual({ + type: 'array', + items: { + type: 'string', + }, + }); + }); + it('converts arrays of custom types correctly', () => { + expect(toJsonProperty(propMeta['arrBar'])).to.deepEqual({ + type: 'array', + items: { + $ref: '#definitions/Bar', + }, + }); + }); + }); + }); +}); diff --git a/packages/json-schema/tsconfig.build.json b/packages/json-schema/tsconfig.build.json new file mode 100644 index 000000000000..855e02848b35 --- /dev/null +++ b/packages/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/openapi-v2/package.json b/packages/openapi-v2/package.json index 2095ac8f89e5..6d5315a5f4ba 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/testlab": "^4.0.0-alpha.19", + "@loopback/repository": "^4.0.0-alpha.22", + "@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/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..fdb41a8a237c 100644 --- a/packages/openapi-v2/src/controller-spec.ts +++ b/packages/openapi-v2/src/controller-spec.ts @@ -21,9 +21,12 @@ import { PathsObject, ItemType, ItemsObject, + DefinitionsObject, } from '@loopback/openapi-spec'; import * as stream from 'stream'; +import {includes} from 'lodash'; +import {modelToJsonDef} from '@loopback/json-schema'; const debug = require('debug')('loopback:rest:router:metadata'); @@ -31,7 +34,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 +50,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 +174,25 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { debug(` adding ${endpointName}`, operationSpec); spec.paths[path][verb] = operationSpec; + + debug(` inferring schema definition for method %s`, op); + const paramTypes = MetadataInspector.getDesignTypeForMethod( + constructor.prototype, + op, + ).parameterTypes; + + for (const p of paramTypes) { + if ( + !includes([String, Number, Boolean, Array, Object], p) && + !isReadableStream(p) + ) { + if (!spec.definitions) { + spec.definitions = {}; + } + spec.definitions[p.name] = modelToJsonDef(p) as SchemaObject; + break; + } + } } return spec; } @@ -177,13 +203,17 @@ 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; } 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..3b4094469da7 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 definition 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..1ad033600bb4 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,97 @@ describe('Routing metadata for parameters', () => { expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); }); + it('infers complex body parameter definition 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(Object.keys(defs)).to.deepEqual(['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('infers empty body parameter definition 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(Object.keys(defs)).to.deepEqual(['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(Object.keys(defs)).to.deepEqual(['MyBody']); + expect(defs.MyBody).to.deepEqual({}); + }); + it('can define multiple parameters in order', () => { const offsetSpec: ParameterObject = { name: 'offset', 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..aa0de3781c66 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) { + 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..a8758090dfe1 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 (Object.keys(defs).length > 0) { + 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..0e8df3b43533 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; @@ -29,6 +30,7 @@ describe('RestServer.getApiSpec()', () => { basePath: '/api', paths: {}, 'x-foo': 'bar', + definitions: {}, }); const spec = server.getApiSpec(); @@ -42,6 +44,7 @@ describe('RestServer.getApiSpec()', () => { basePath: '/api', paths: {}, 'x-foo': 'bar', + definitions: {}, }); }); @@ -119,6 +122,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( From 1787a41a54ab7a0a82dd74fd23fb7a0e3183729e Mon Sep 17 00:00:00 2001 From: shimks Date: Fri, 12 Jan 2018 16:33:21 -0500 Subject: [PATCH 2/6] fix(repository-json-schema): fix license headers --- packages/json-schema/LICENSE | 2 +- packages/json-schema/index.d.ts | 2 +- packages/json-schema/index.js | 2 +- packages/json-schema/index.ts | 2 +- packages/json-schema/src/build-schema.ts | 2 +- packages/json-schema/src/index.ts | 2 +- packages/json-schema/test/integration/build-schema.test.ts | 7 ++++--- 7 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/json-schema/LICENSE b/packages/json-schema/LICENSE index fbe7fa628077..6be7b5055fe2 100644 --- a/packages/json-schema/LICENSE +++ b/packages/json-schema/LICENSE @@ -1,5 +1,5 @@ Copyright (c) IBM Corp. 2018. All Rights Reserved. -Node module: @loopback/openapi-spec-builder +Node module: @loopback/json-schema This project is licensed under the MIT License, full text below. -------- diff --git a/packages/json-schema/index.d.ts b/packages/json-schema/index.d.ts index 4f5615634fc1..3696a1c54b5a 100644 --- a/packages/json-schema/index.d.ts +++ b/packages/json-schema/index.d.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/openapi-spec-builder +// Node module: @loopback/json-schema // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/json-schema/index.js b/packages/json-schema/index.js index 6498c3381e61..22f665c66a94 100644 --- a/packages/json-schema/index.js +++ b/packages/json-schema/index.js @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/testlab +// Node module: @loopback/json-schema // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/json-schema/index.ts b/packages/json-schema/index.ts index f31e48e330c7..9438bdf052d4 100644 --- a/packages/json-schema/index.ts +++ b/packages/json-schema/index.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2013,2018. All Rights Reserved. -// Node module: @loopback/openapi-spec-builder +// Node module: @loopback/json-schema // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/json-schema/src/build-schema.ts b/packages/json-schema/src/build-schema.ts index dccb8d520b3a..7ceea97b3fa4 100644 --- a/packages/json-schema/src/build-schema.ts +++ b/packages/json-schema/src/build-schema.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2013,2018. All Rights Reserved. -// Node module: @loopback/openapi-spec-builder +// Node module: @loopback/json-schema // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/json-schema/src/index.ts b/packages/json-schema/src/index.ts index 7267398a024a..321d2130a240 100644 --- a/packages/json-schema/src/index.ts +++ b/packages/json-schema/src/index.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2013,2018. All Rights Reserved. -// Node module: @loopback/openapi-spec +// Node module: @loopback/json-schema // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/json-schema/test/integration/build-schema.test.ts b/packages/json-schema/test/integration/build-schema.test.ts index e4c00e012dc5..d44e0e0ddd80 100644 --- a/packages/json-schema/test/integration/build-schema.test.ts +++ b/packages/json-schema/test/integration/build-schema.test.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2013,2018. All Rights Reserved. -// Node module: @loopback/openapi-spec-builder +// Node module: @loopback/json-schema // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT @@ -26,10 +26,11 @@ describe('build-schema', () => { @property() nul: null; @property() undef: undefined; } - expect(modelToJsonDef(TestModel).properties).to.not.containEql({ + const jsonDef = modelToJsonDef(TestModel); + expect(jsonDef.properties).to.not.containEql({ nul: {type: 'null'}, }); - expect(modelToJsonDef(TestModel).properties).to.not.containEql({ + expect(jsonDef.properties).to.not.containEql({ undef: {type: 'undefined'}, }); }); From 890344e47927845d7a85a0e0891291578e89e101 Mon Sep 17 00:00:00 2001 From: shimks Date: Mon, 15 Jan 2018 14:23:58 -0500 Subject: [PATCH 3/6] fix(repository-json-schema): apply feedback round 2 --- packages/json-schema/index.js | 9 -- packages/openapi-v2/package.json | 2 +- packages/openapi-v2/src/controller-spec.ts | 78 ++++++++++++++-- .../json-to-schema-object.test.ts | 93 +++++++++++++++++++ .../.gitignore | 0 .../.npmrc | 0 .../LICENSE | 2 +- .../README.md | 6 +- .../docs.json | 0 .../index.d.ts | 2 +- packages/repository-json-schema/index.js | 6 ++ .../index.ts | 2 +- .../package.json | 13 ++- .../src/build-schema.ts | 27 +++--- .../src/index.ts | 2 +- .../test/integration/build-schema.test.ts | 76 +++++++++------ .../tsconfig.build.json | 0 .../repository/src/decorators/metadata.ts | 19 +++- packages/repository/src/decorators/model.ts | 2 +- packages/rest/src/http-handler.ts | 4 +- packages/rest/src/rest-server.ts | 2 +- .../rest-server.open-api-spec.test.ts | 2 - 22 files changed, 261 insertions(+), 86 deletions(-) delete mode 100644 packages/json-schema/index.js create mode 100644 packages/openapi-v2/test/unit/controller-spec/json-to-schema-object.test.ts rename packages/{json-schema => repository-json-schema}/.gitignore (100%) rename packages/{json-schema => repository-json-schema}/.npmrc (100%) rename packages/{json-schema => repository-json-schema}/LICENSE (96%) rename packages/{json-schema => repository-json-schema}/README.md (87%) rename packages/{json-schema => repository-json-schema}/docs.json (100%) rename packages/{json-schema => repository-json-schema}/index.d.ts (79%) create mode 100644 packages/repository-json-schema/index.js rename packages/{json-schema => repository-json-schema}/index.ts (84%) rename packages/{json-schema => repository-json-schema}/package.json (72%) rename packages/{json-schema => repository-json-schema}/src/build-schema.ts (71%) rename packages/{json-schema => repository-json-schema}/src/index.ts (80%) rename packages/{json-schema => repository-json-schema}/test/integration/build-schema.test.ts (84%) rename packages/{json-schema => repository-json-schema}/tsconfig.build.json (100%) diff --git a/packages/json-schema/index.js b/packages/json-schema/index.js deleted file mode 100644 index 22f665c66a94..000000000000 --- a/packages/json-schema/index.js +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/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/openapi-v2/package.json b/packages/openapi-v2/package.json index 6d5315a5f4ba..fa2922aa2336 100644 --- a/packages/openapi-v2/package.json +++ b/packages/openapi-v2/package.json @@ -51,7 +51,7 @@ "dependencies": { "@loopback/context": "^4.0.0-alpha.26", "@loopback/openapi-spec": "^4.0.0-alpha.20", - "@loopback/json-schema": "^4.0.0-alpha.1", + "@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 fdb41a8a237c..2085680c8362 100644 --- a/packages/openapi-v2/src/controller-spec.ts +++ b/packages/openapi-v2/src/controller-spec.ts @@ -22,11 +22,12 @@ import { ItemType, ItemsObject, DefinitionsObject, + MapObject, } from '@loopback/openapi-spec'; import * as stream from 'stream'; import {includes} from 'lodash'; -import {modelToJsonDef} from '@loopback/json-schema'; +import {modelToJsonDef, JsonDefinition} from '@loopback/repository-json-schema'; const debug = require('debug')('loopback:rest:router:metadata'); @@ -181,15 +182,17 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { op, ).parameterTypes; + const isComplexType = (p: Function) => + !includes([String, Number, Boolean, Array, Object], p) && + !isReadableStream(p); + for (const p of paramTypes) { - if ( - !includes([String, Number, Boolean, Array, Object], p) && - !isReadableStream(p) - ) { + if (isComplexType(p)) { if (!spec.definitions) { spec.definitions = {}; } - spec.definitions[p.name] = modelToJsonDef(p) as SchemaObject; + const jsonDef = modelToJsonDef(p); + spec.definitions[p.name] = jsonToSchemaObject(jsonDef); break; } } @@ -218,6 +221,69 @@ export function getControllerSpec(constructor: Function): ControllerSpec { return spec; } +export function jsonToSchemaObject(json: JsonDefinition): SchemaObject { + const emptySchemaObj: SchemaObject = {}; + // tslint:disable-next-line:no-any + const def: {[key: string]: any} = json; + for (const property in def) { + const val = def[property]; + switch (property) { + // converts excepted properties to SchemaObject definitions + case 'type': { + if (def.type === 'array' && !def.items) { + throw new Error( + '"items" property must be present if "type" is an array', + ); + } + emptySchemaObj.type = Array.isArray(json.type) + ? json.type[0] + : json.type; + break; + } + case 'allOf': { + const collector: SchemaObject[] = []; + for (const item of def.allOf) { + collector.push(jsonToSchemaObject(item)); + } + emptySchemaObj.allOf = collector; + break; + } + case 'properties': { + const properties: {[key: string]: JsonDefinition} = def.properties; + const collector: MapObject = {}; + for (const item in properties) { + collector[item] = jsonToSchemaObject(properties[item]); + } + emptySchemaObj.properties = collector; + break; + } + case 'additionalProperties': { + if (json.additionalProperties) { + if (json.additionalProperties === true) { + emptySchemaObj.additionalProperties = {}; + } else { + emptySchemaObj.additionalProperties = jsonToSchemaObject( + json.additionalProperties, + ); + } + } + break; + } + case 'items': { + def.items = Array.isArray(def.items) ? def.items[0] : def.items; + emptySchemaObj.items = jsonToSchemaObject(def.items); + break; + } + default: { + emptySchemaObj[property] = val; + break; + } + } + } + + return emptySchemaObj; +} + /** * Expose a Controller method as a REST API operation * mapped to `GET` request method. 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..182e6a0e0d50 --- /dev/null +++ b/packages/openapi-v2/test/unit/controller-spec/json-to-schema-object.test.ts @@ -0,0 +1,93 @@ +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 = {type: ['string', 'number']}; + const expectedType = {type: 'string'}; + propertyConversionTest('type', typeDef, expectedType); + + const allOfDef: JsonDefinition = { + allOf: [typeDef, typeDef], + }; + const expectedAllOf: SchemaObject = { + allOf: [expectedType, expectedType], + }; + propertyConversionTest('allOf', allOfDef, expectedAllOf); + + const propertyDef: JsonDefinition = { + type: 'object', + properties: { + foo: typeDef, + }, + }; + const expectedProperties: SchemaObject = { + type: 'object', + properties: { + foo: expectedType, + }, + }; + propertyConversionTest('properties', propertyDef, expectedProperties); + + const additionalDef: JsonDefinition = { + type: 'object', + additionalProperties: typeDef, + }; + const expectedAdditional: SchemaObject = { + type: 'object', + additionalProperties: expectedType, + }; + propertyConversionTest( + 'additionalProperties', + additionalDef, + expectedAdditional, + ); + + const itemsDef: JsonDefinition = { + type: 'array', + items: typeDef, + }; + const expectedItems: SchemaObject = { + type: 'array', + items: expectedType, + }; + propertyConversionTest('items', itemsDef, expectedItems); + + it('retains given properties in the conversion', () => { + const inputDef: JsonDefinition = { + title: 'foo', + type: 'object', + }; + const expectedDef: SchemaObject = { + title: 'foo', + type: 'object', + }; + 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( + name: string, + property: Object, + expected: Object, + ) { + it(name, () => { + expect(jsonToSchemaObject(property)).to.eql(expected); + }); + } +}); diff --git a/packages/json-schema/.gitignore b/packages/repository-json-schema/.gitignore similarity index 100% rename from packages/json-schema/.gitignore rename to packages/repository-json-schema/.gitignore diff --git a/packages/json-schema/.npmrc b/packages/repository-json-schema/.npmrc similarity index 100% rename from packages/json-schema/.npmrc rename to packages/repository-json-schema/.npmrc diff --git a/packages/json-schema/LICENSE b/packages/repository-json-schema/LICENSE similarity index 96% rename from packages/json-schema/LICENSE rename to packages/repository-json-schema/LICENSE index 6be7b5055fe2..e03a4f2770bf 100644 --- a/packages/json-schema/LICENSE +++ b/packages/repository-json-schema/LICENSE @@ -1,5 +1,5 @@ Copyright (c) IBM Corp. 2018. All Rights Reserved. -Node module: @loopback/json-schema +Node module: @loopback/repository-json-schema This project is licensed under the MIT License, full text below. -------- diff --git a/packages/json-schema/README.md b/packages/repository-json-schema/README.md similarity index 87% rename from packages/json-schema/README.md rename to packages/repository-json-schema/README.md index 959c29cf3c37..843bb5c4a35f 100644 --- a/packages/json-schema/README.md +++ b/packages/repository-json-schema/README.md @@ -1,4 +1,4 @@ -# @loopback/json-schema +# @loopback/repository-json-schema Convert a TypeScript class/model to a JSON Schema for users, leveraging LoopBack4's decorators, metadata, and reflection system. @@ -9,13 +9,13 @@ This package provides modules to easily convert LoopBack4 models that have been ## Installation ```shell -$ npm install --save @loopback/json-schema +$ npm install --save @loopback/repository-json-schema ``` ## Basic use ```ts -import {modelToJsonDef} from '@loopback/json-schema'; +import {modelToJsonDef} from '@loopback/repository-json-schema'; import {model, property} from '@loopback/repository'; @model() diff --git a/packages/json-schema/docs.json b/packages/repository-json-schema/docs.json similarity index 100% rename from packages/json-schema/docs.json rename to packages/repository-json-schema/docs.json diff --git a/packages/json-schema/index.d.ts b/packages/repository-json-schema/index.d.ts similarity index 79% rename from packages/json-schema/index.d.ts rename to packages/repository-json-schema/index.d.ts index 3696a1c54b5a..d0dd8c84a44b 100644 --- a/packages/json-schema/index.d.ts +++ b/packages/repository-json-schema/index.d.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2018. All Rights Reserved. -// Node module: @loopback/json-schema +// Node module: @loopback/repository-json-schema // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/repository-json-schema/index.js b/packages/repository-json-schema/index.js new file mode 100644 index 000000000000..3e8b76f1dbd0 --- /dev/null +++ b/packages/repository-json-schema/index.js @@ -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 + +module.exports = require('./dist/src'); diff --git a/packages/json-schema/index.ts b/packages/repository-json-schema/index.ts similarity index 84% rename from packages/json-schema/index.ts rename to packages/repository-json-schema/index.ts index 9438bdf052d4..c5f088feb642 100644 --- a/packages/json-schema/index.ts +++ b/packages/repository-json-schema/index.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2013,2018. All Rights Reserved. -// Node module: @loopback/json-schema +// Node module: @loopback/repository-json-schema // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/json-schema/package.json b/packages/repository-json-schema/package.json similarity index 72% rename from packages/json-schema/package.json rename to packages/repository-json-schema/package.json index 7c3693a5e35a..c626dfa6537e 100644 --- a/packages/json-schema/package.json +++ b/packages/repository-json-schema/package.json @@ -1,20 +1,19 @@ { - "name": "@loopback/json-schema", + "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": ">=8" }, "scripts": { - "build": "npm run build:dist && npm run build:dist6", + "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", + "clean": "lb-clean loopback-repository-json-schema*.tgz dist package api-docs", "prepare": "npm run build && npm run build:apidocs", "pretest": "npm run build:current", - "test": "lb-dist mocha --opts ../../test/mocha.opts 'DIST/test/unit/**/*.js' 'DIST/test/integration/**/*.js' 'DIST/test/acceptance/**/*.js'", + "test": "mocha --opts ../../test/mocha.opts 'DIST/test/unit/**/*.js' 'DIST/test/integration/**/*.js' 'DIST/test/acceptance/**/*.js'", "verify": "npm pack && tar xf loopback-jsons-schema*.tgz && tree package && npm run clean" }, "author": "IBM", @@ -26,7 +25,8 @@ "dependencies": { "@loopback/openapi-spec": "^4.0.0-alpha.18", "@loopback/repository": "^4.0.0-alpha.22", - "lodash": "^4.17.4" + "lodash": "^4.17.4", + "typescript-json-schema": "^0.20.0" }, "devDependencies": { "@loopback/build": "^4.0.0-alpha.7", @@ -38,7 +38,6 @@ "index.js", "index.d.ts", "dist/src", - "dist6/src", "api-docs", "src" ], diff --git a/packages/json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts similarity index 71% rename from packages/json-schema/src/build-schema.ts rename to packages/repository-json-schema/src/build-schema.ts index 7ceea97b3fa4..0361e3ccd5f9 100644 --- a/packages/json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -1,24 +1,22 @@ // Copyright IBM Corp. 2013,2018. All Rights Reserved. -// Node module: @loopback/json-schema +// Node module: @loopback/repository-json-schema // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {SchemaObject, MapObject} from '@loopback/openapi-spec'; -import {ModelMetadataHelper, PropertyDefinition} from '@loopback/repository'; +import { + ModelMetadataHelper, + PropertyDefinition, + ModelDefinition, +} from '@loopback/repository'; import {includes} from 'lodash'; +import {Definition} from 'typescript-json-schema'; /** - * Type definition for a JSON Schema Definition - * Currently, objects of type JSONDefinition can also be cast as a SchemaObject, - * a property of OpenAPI-v2's specification + * Type definition for JSON Schema */ -export type JsonDefinition = { - $ref?: string; - required?: Array; - type?: string; - properties?: {[property: string]: JsonDefinition} | MapObject; - items?: JsonDefinition | SchemaObject; -}; +export interface JsonDefinition extends Definition { + properties?: {[property: string]: JsonDefinition}; +} // NOTE(shimks) no metadata for: union, optional, nested array, any, enum, // string literal, anonymous types, and inherited properties @@ -29,8 +27,7 @@ export type JsonDefinition = { * @param ctor Constructor of class to convert from */ export function modelToJsonDef(ctor: Function): JsonDefinition { - // tslint:disable-next-line:no-any - const meta = ModelMetadataHelper.getModelMetadata(ctor) as any; + const meta: ModelDefinition = ModelMetadataHelper.getModelMetadata(ctor); const schema: JsonDefinition = {}; for (const p in meta.properties) { diff --git a/packages/json-schema/src/index.ts b/packages/repository-json-schema/src/index.ts similarity index 80% rename from packages/json-schema/src/index.ts rename to packages/repository-json-schema/src/index.ts index 321d2130a240..7ed8f361d8db 100644 --- a/packages/json-schema/src/index.ts +++ b/packages/repository-json-schema/src/index.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2013,2018. All Rights Reserved. -// Node module: @loopback/json-schema +// Node module: @loopback/repository-json-schema // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT diff --git a/packages/json-schema/test/integration/build-schema.test.ts b/packages/repository-json-schema/test/integration/build-schema.test.ts similarity index 84% rename from packages/json-schema/test/integration/build-schema.test.ts rename to packages/repository-json-schema/test/integration/build-schema.test.ts index d44e0e0ddd80..0b1ca03f5389 100644 --- a/packages/json-schema/test/integration/build-schema.test.ts +++ b/packages/repository-json-schema/test/integration/build-schema.test.ts @@ -1,24 +1,19 @@ // Copyright IBM Corp. 2013,2018. All Rights Reserved. -// Node module: @loopback/json-schema +// 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} from '@loopback/repository'; +import { + model, + property, + ModelMetadataHelper, + ModelDefinition, + PropertyMap, +} from '@loopback/repository'; import {modelToJsonDef, toJsonProperty} from '../../src/build-schema'; import {expect} from '@loopback/testlab'; describe('build-schema', () => { - context('exception', () => { - it('errors out when "@property.array" is not used on an array', () => { - @model() - class BadArray { - @property() badArr: string[]; - } - expect(() => { - modelToJsonDef(BadArray); - }).to.throw(/type is defined as an array/); - }); - }); describe('modelToJSON', () => { it('does not convert null or undefined property', () => { @model() @@ -26,14 +21,11 @@ describe('build-schema', () => { @property() nul: null; @property() undef: undefined; } + const jsonDef = modelToJsonDef(TestModel); - expect(jsonDef.properties).to.not.containEql({ - nul: {type: 'null'}, - }); - expect(jsonDef.properties).to.not.containEql({ - undef: {type: 'undefined'}, - }); + expect(jsonDef.properties).to.not.have.keys(['nul', 'undef']); }); + it('does not convert properties that have not been decorated', () => { @model() class NoPropertyMeta { @@ -45,6 +37,7 @@ describe('build-schema', () => { bar: boolean; baz: number; } + expect(modelToJsonDef(NoPropertyMeta)).to.eql({}); expect(modelToJsonDef(OnePropertyDecorated)).to.deepEqual({ properties: { @@ -54,15 +47,18 @@ describe('build-schema', () => { }, }); }); + it('does not convert models that have not been decorated with @model()', () => { class Empty {} class NoModelMeta { @property() foo: string; bar: number; } + expect(modelToJsonDef(Empty)).to.eql({}); expect(modelToJsonDef(NoModelMeta)).to.eql({}); }); + it('properly converts string, number, and boolean properties', () => { @model() class TestModel { @@ -84,6 +80,7 @@ describe('build-schema', () => { }, }); }); + it('properly converts object properties', () => { @model() class TestModel { @@ -97,6 +94,7 @@ describe('build-schema', () => { }, }); }); + it('properly converts custom type properties', () => { class CustomType { prop: string; @@ -114,6 +112,7 @@ describe('build-schema', () => { }, }); }); + it('properly converts primitive arrays properties', () => { @model() class TestModel { @@ -130,6 +129,7 @@ describe('build-schema', () => { }, }); }); + it('properly converts custom type arrays properties', () => { class CustomType { prop: string; @@ -150,6 +150,18 @@ describe('build-schema', () => { }, }); }); + + it('errors out when "@property.array" is not used on an array', () => { + @model() + class BadArray { + @property() badArr: string[]; + } + + expect(() => { + modelToJsonDef(BadArray); + }).to.throw(/type is defined as an array/); + }); + describe('toJSONProperty', () => { class Bar { barA: number; @@ -166,45 +178,49 @@ describe('build-schema', () => { @property() bar: Bar; @property.array(Bar) arrBar: Bar[]; } - // tslint:disable-next-line:no-any - let meta: any; - // tslint:disable-next-line:no-any - let propMeta: any; + let meta: ModelDefinition; + let propMeta: PropertyMap; + before(() => { meta = ModelMetadataHelper.getModelMetadata(Foo); propMeta = meta.properties; }); + it('converts primitively typed property correctly', () => { - expect(toJsonProperty(propMeta['str'])).to.deepEqual({ + expect(toJsonProperty(propMeta.str)).to.deepEqual({ type: 'string', }); - expect(toJsonProperty(propMeta['num'])).to.deepEqual({ + expect(toJsonProperty(propMeta.num)).to.deepEqual({ type: 'number', }); - expect(toJsonProperty(propMeta['bool'])).to.deepEqual({ + expect(toJsonProperty(propMeta.bool)).to.deepEqual({ type: 'boolean', }); }); + it('converts object property correctly', () => { - expect(toJsonProperty(propMeta['obj'])).to.deepEqual({ + expect(toJsonProperty(propMeta.obj)).to.deepEqual({ type: 'object', }); }); + it('converts customly typed property correctly', () => { - expect(toJsonProperty(propMeta['bar'])).to.deepEqual({ + expect(toJsonProperty(propMeta.bar)).to.deepEqual({ $ref: '#definitions/Bar', }); }); + it('converts arrays of primitives correctly', () => { - expect(toJsonProperty(propMeta['arrStr'])).to.deepEqual({ + expect(toJsonProperty(propMeta.arrStr)).to.deepEqual({ type: 'array', items: { type: 'string', }, }); }); + it('converts arrays of custom types correctly', () => { - expect(toJsonProperty(propMeta['arrBar'])).to.deepEqual({ + expect(toJsonProperty(propMeta.arrBar)).to.deepEqual({ type: 'array', items: { $ref: '#definitions/Bar', diff --git a/packages/json-schema/tsconfig.build.json b/packages/repository-json-schema/tsconfig.build.json similarity index 100% rename from packages/json-schema/tsconfig.build.json rename to packages/repository-json-schema/tsconfig.build.json diff --git a/packages/repository/src/decorators/metadata.ts b/packages/repository/src/decorators/metadata.ts index fed4e59a253b..1914e0f6daab 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, @@ -30,7 +39,7 @@ export class ModelMetadataHelper { 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/src/http-handler.ts b/packages/rest/src/http-handler.ts index aa0de3781c66..700e1ca3f7f7 100644 --- a/packages/rest/src/http-handler.ts +++ b/packages/rest/src/http-handler.ts @@ -22,7 +22,7 @@ import {RestBindings} from './keys'; export class HttpHandler { protected _routes: RoutingTable = new RoutingTable(); - protected _apiDefinitions: DefinitionsObject = {}; + protected _apiDefinitions: DefinitionsObject; public handleRequest: ( request: ServerRequest, @@ -42,7 +42,7 @@ export class HttpHandler { } registerApiDefinitions(defs: DefinitionsObject) { - Object.assign(this._apiDefinitions, defs); + this._apiDefinitions = Object.assign({}, this._apiDefinitions, defs); } getApiDefinitions() { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index a8758090dfe1..3eef1ec85aed 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -453,7 +453,7 @@ export class RestServer extends Context implements Server { // Apply deep clone to prevent getApiSpec() callers from // accidentally modifying our internal routing data spec.paths = cloneDeep(this.httpHandler.describeApiPaths()); - if (Object.keys(defs).length > 0) { + 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 0e8df3b43533..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 @@ -30,7 +30,6 @@ describe('RestServer.getApiSpec()', () => { basePath: '/api', paths: {}, 'x-foo': 'bar', - definitions: {}, }); const spec = server.getApiSpec(); @@ -44,7 +43,6 @@ describe('RestServer.getApiSpec()', () => { basePath: '/api', paths: {}, 'x-foo': 'bar', - definitions: {}, }); }); From a1dec24903ba680038169a49c2160e92115e0b5f Mon Sep 17 00:00:00 2001 From: shimks Date: Tue, 16 Jan 2018 10:23:31 -0500 Subject: [PATCH 4/6] fix(repository-json-schema): add support for node.js 6 --- packages/openapi-v2/package.json | 2 +- .../controller-spec/json-to-schema-object.test.ts | 5 +++++ packages/repository-json-schema/docs.json | 12 ++++++------ packages/repository-json-schema/index.js | 4 +++- packages/repository-json-schema/index.ts | 2 +- packages/repository-json-schema/package.json | 8 +++++--- packages/repository-json-schema/src/build-schema.ts | 2 +- packages/repository-json-schema/src/index.ts | 2 +- .../test/integration/build-schema.test.ts | 2 +- packages/repository/src/decorators/metadata.ts | 1 + 10 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/openapi-v2/package.json b/packages/openapi-v2/package.json index fa2922aa2336..3ab3c32c4f5b 100644 --- a/packages/openapi-v2/package.json +++ b/packages/openapi-v2/package.json @@ -8,8 +8,8 @@ "devDependencies": { "@loopback/build": "^4.0.0-alpha.9", "@loopback/openapi-spec-builder": "^4.0.0-alpha.17", + "@loopback/repository": "^4.0.0-alpha.23", "@loopback/testlab": "^4.0.0-alpha.19", - "@loopback/repository": "^4.0.0-alpha.22", "@types/lodash": "^4.14.92" }, "scripts": { 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 index 182e6a0e0d50..a5039fc67efa 100644 --- 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 @@ -1,3 +1,8 @@ +// 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'; diff --git a/packages/repository-json-schema/docs.json b/packages/repository-json-schema/docs.json index e94e51fe86cf..04013f4d6655 100644 --- a/packages/repository-json-schema/docs.json +++ b/packages/repository-json-schema/docs.json @@ -1,8 +1,8 @@ { - "content": ["index.ts", "src/index.ts", "src/build-schema.ts"], - "codeSectionDepth": 4, - "assets": { - "/": "/docs", - "/docs": "/docs" - } + "content": [ + "index.ts", + "src/index.ts", + "src/build-schema.ts" + ], + "codeSectionDepth": 4 } diff --git a/packages/repository-json-schema/index.js b/packages/repository-json-schema/index.js index 3e8b76f1dbd0..c198f744dfee 100644 --- a/packages/repository-json-schema/index.js +++ b/packages/repository-json-schema/index.js @@ -3,4 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -module.exports = require('./dist/src'); +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 index c5f088feb642..22f271bb3862 100644 --- a/packages/repository-json-schema/index.ts +++ b/packages/repository-json-schema/index.ts @@ -1,4 +1,4 @@ -// Copyright IBM Corp. 2013,2018. All Rights Reserved. +// 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 diff --git a/packages/repository-json-schema/package.json b/packages/repository-json-schema/package.json index c626dfa6537e..ffde4410f965 100644 --- a/packages/repository-json-schema/package.json +++ b/packages/repository-json-schema/package.json @@ -3,17 +3,18 @@ "version": "4.0.0-alpha.1", "description": "Converts TS classes into JSON Schemas using TypeScript's reflection API", "engines": { - "node": ">=8" + "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-repository-json-schema*.tgz dist package api-docs", + "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": "mocha --opts ../../test/mocha.opts 'DIST/test/unit/**/*.js' 'DIST/test/integration/**/*.js' 'DIST/test/acceptance/**/*.js'", + "test": "lb-dist mocha --opts ../../test/mocha.opts 'dist/test/unit/**/*.js' 'dist/test/integration/**/*.js' 'dist/test/acceptance/**/*.js'", "verify": "npm pack && tar xf loopback-jsons-schema*.tgz && tree package && npm run clean" }, "author": "IBM", @@ -38,6 +39,7 @@ "index.js", "index.d.ts", "dist/src", + "dist6/src", "api-docs", "src" ], diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index 0361e3ccd5f9..75c83b729fa4 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -1,4 +1,4 @@ -// Copyright IBM Corp. 2013,2018. All Rights Reserved. +// 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 diff --git a/packages/repository-json-schema/src/index.ts b/packages/repository-json-schema/src/index.ts index 7ed8f361d8db..96e9407c4901 100644 --- a/packages/repository-json-schema/src/index.ts +++ b/packages/repository-json-schema/src/index.ts @@ -1,4 +1,4 @@ -// Copyright IBM Corp. 2013,2018. All Rights Reserved. +// 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 diff --git a/packages/repository-json-schema/test/integration/build-schema.test.ts b/packages/repository-json-schema/test/integration/build-schema.test.ts index 0b1ca03f5389..9e91d5ab6939 100644 --- a/packages/repository-json-schema/test/integration/build-schema.test.ts +++ b/packages/repository-json-schema/test/integration/build-schema.test.ts @@ -1,4 +1,4 @@ -// Copyright IBM Corp. 2013,2018. All Rights Reserved. +// 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 diff --git a/packages/repository/src/decorators/metadata.ts b/packages/repository/src/decorators/metadata.ts index 1914e0f6daab..9b599f162345 100644 --- a/packages/repository/src/decorators/metadata.ts +++ b/packages/repository/src/decorators/metadata.ts @@ -35,6 +35,7 @@ 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), ); From 39aab051649d5a7a506c1386adc1f1e5013886df Mon Sep 17 00:00:00 2001 From: shimks Date: Fri, 19 Jan 2018 10:31:36 -0500 Subject: [PATCH 5/6] feat(repository-json-schema): add json-schema caching --- packages/openapi-v2/src/controller-spec.ts | 4 +-- packages/repository-json-schema/README.md | 4 +-- packages/repository-json-schema/package.json | 2 +- .../src/build-schema.ts | 14 ++++++++ .../test/integration/build-schema.test.ts | 36 +++++++++++++++++++ 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/openapi-v2/src/controller-spec.ts b/packages/openapi-v2/src/controller-spec.ts index 2085680c8362..61b551860b37 100644 --- a/packages/openapi-v2/src/controller-spec.ts +++ b/packages/openapi-v2/src/controller-spec.ts @@ -27,7 +27,7 @@ import { import * as stream from 'stream'; import {includes} from 'lodash'; -import {modelToJsonDef, JsonDefinition} from '@loopback/repository-json-schema'; +import {getJsonDef, JsonDefinition} from '@loopback/repository-json-schema'; const debug = require('debug')('loopback:rest:router:metadata'); @@ -191,7 +191,7 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { if (!spec.definitions) { spec.definitions = {}; } - const jsonDef = modelToJsonDef(p); + const jsonDef = getJsonDef(p); spec.definitions[p.name] = jsonToSchemaObject(jsonDef); break; } diff --git a/packages/repository-json-schema/README.md b/packages/repository-json-schema/README.md index 843bb5c4a35f..0922b09c5401 100644 --- a/packages/repository-json-schema/README.md +++ b/packages/repository-json-schema/README.md @@ -15,7 +15,7 @@ $ npm install --save @loopback/repository-json-schema ## Basic use ```ts -import {modelToJsonDef} from '@loopback/repository-json-schema'; +import {getJsonDef} from '@loopback/repository-json-schema'; import {model, property} from '@loopback/repository'; @model() @@ -23,7 +23,7 @@ MyModel { @property() name: string; } -const jsonDef = modelToJsonDef(MyModel); +const jsonDef = getJsonDef(MyModel); ``` The value of `jsonDef` will be: diff --git a/packages/repository-json-schema/package.json b/packages/repository-json-schema/package.json index ffde4410f965..bbc6eb20ba30 100644 --- a/packages/repository-json-schema/package.json +++ b/packages/repository-json-schema/package.json @@ -24,7 +24,7 @@ "JSON Schema" ], "dependencies": { - "@loopback/openapi-spec": "^4.0.0-alpha.18", + "@loopback/context": "^4.0.0-alpha.25", "@loopback/repository": "^4.0.0-alpha.22", "lodash": "^4.17.4", "typescript-json-schema": "^0.20.0" diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index 75c83b729fa4..c810273c7572 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -10,6 +10,9 @@ import { } from '@loopback/repository'; import {includes} from 'lodash'; import {Definition} from 'typescript-json-schema'; +import {MetadataInspector} from '@loopback/context'; + +export const JSON_SCHEMA_KEY = 'loopback:json-schema'; /** * Type definition for JSON Schema @@ -18,6 +21,17 @@ export interface JsonDefinition extends Definition { properties?: {[property: string]: JsonDefinition}; } +export function getJsonDef(ctor: Function): JsonDefinition { + const jsonDef = MetadataInspector.getClassMetadata(JSON_SCHEMA_KEY, ctor); + if (jsonDef) { + return jsonDef; + } else { + const newDef = modelToJsonDef(ctor); + MetadataInspector.defineMetadata(JSON_SCHEMA_KEY, newDef, ctor); + return newDef; + } +} + // NOTE(shimks) no metadata for: union, optional, nested array, any, enum, // string literal, anonymous types, and inherited properties diff --git a/packages/repository-json-schema/test/integration/build-schema.test.ts b/packages/repository-json-schema/test/integration/build-schema.test.ts index 9e91d5ab6939..df5061ed8bb0 100644 --- a/packages/repository-json-schema/test/integration/build-schema.test.ts +++ b/packages/repository-json-schema/test/integration/build-schema.test.ts @@ -12,6 +12,8 @@ import { } from '@loopback/repository'; import {modelToJsonDef, toJsonProperty} from '../../src/build-schema'; import {expect} from '@loopback/testlab'; +import {MetadataInspector} from '@loopback/context'; +import {JSON_SCHEMA_KEY, getJsonDef} from '../../index'; describe('build-schema', () => { describe('modelToJSON', () => { @@ -229,4 +231,38 @@ describe('build-schema', () => { }); }); }); + + describe('getJsonDef', () => { + it('gets cached JSON definition if one exists', () => { + @model() + class TestModel { + @property() foo: number; + } + const cachedDef = { + properties: { + cachedProperty: { + type: 'string', + }, + }, + }; + MetadataInspector.defineMetadata(JSON_SCHEMA_KEY, cachedDef, TestModel); + + const jsonDef = getJsonDef(TestModel); + expect(jsonDef).to.eql(cachedDef); + }); + + it('creates JSON definition if one does not already exist', () => { + @model() + class NewModel { + @property() newProperty: string; + } + + const jsonDef = getJsonDef(NewModel); + expect(jsonDef.properties).to.containDeep({ + newProperty: { + type: 'string', + }, + }); + }); + }); }); From f630a203518ba58cc76f4d5d97b1e348af1e36b4 Mon Sep 17 00:00:00 2001 From: shimks Date: Mon, 22 Jan 2018 12:46:27 -0500 Subject: [PATCH 6/6] feat(openapi-v2): add full model definition gen --- packages/openapi-v2/src/controller-spec.ts | 108 ++++--- .../param-decorators/param-body.test.ts | 2 +- .../param-decorators/param.test.ts | 35 ++- .../json-to-schema-object.test.ts | 180 ++++++++---- packages/repository-json-schema/.gitignore | 3 - packages/repository-json-schema/README.md | 6 +- packages/repository-json-schema/package.json | 4 +- .../src/build-schema.ts | 125 ++++++--- .../test/integration/build-schema.test.ts | 265 +++++++++++------- 9 files changed, 479 insertions(+), 249 deletions(-) delete mode 100644 packages/repository-json-schema/.gitignore diff --git a/packages/openapi-v2/src/controller-spec.ts b/packages/openapi-v2/src/controller-spec.ts index 61b551860b37..aef85f4c21fc 100644 --- a/packages/openapi-v2/src/controller-spec.ts +++ b/packages/openapi-v2/src/controller-spec.ts @@ -26,8 +26,8 @@ import { } from '@loopback/openapi-spec'; import * as stream from 'stream'; -import {includes} from 'lodash'; -import {getJsonDef, JsonDefinition} from '@loopback/repository-json-schema'; +import {getJsonSchema, JsonDefinition} from '@loopback/repository-json-schema'; +import * as _ from 'lodash'; const debug = require('debug')('loopback:rest:router:metadata'); @@ -176,23 +176,32 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { debug(` adding ${endpointName}`, operationSpec); spec.paths[path][verb] = operationSpec; - debug(` inferring schema definition for method %s`, op); + debug(` inferring schema object for method %s`, op); const paramTypes = MetadataInspector.getDesignTypeForMethod( constructor.prototype, op, ).parameterTypes; - const isComplexType = (p: Function) => - !includes([String, Number, Boolean, Array, Object], p) && - !isReadableStream(p); + 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 jsonDef = getJsonDef(p); - spec.definitions[p.name] = jsonToSchemaObject(jsonDef); + 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; } } @@ -221,67 +230,82 @@ export function getControllerSpec(constructor: Function): ControllerSpec { return spec; } -export function jsonToSchemaObject(json: JsonDefinition): SchemaObject { - const emptySchemaObj: SchemaObject = {}; - // tslint:disable-next-line:no-any - const def: {[key: string]: any} = json; - for (const property in def) { - const val = def[property]; +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) { - // converts excepted properties to SchemaObject definitions case 'type': { - if (def.type === 'array' && !def.items) { + if (json.type === 'array' && !json.items) { throw new Error( '"items" property must be present if "type" is an array', ); } - emptySchemaObj.type = Array.isArray(json.type) - ? json.type[0] - : json.type; + result.type = Array.isArray(json.type) ? json.type[0] : json.type; break; } case 'allOf': { - const collector: SchemaObject[] = []; - for (const item of def.allOf) { - collector.push(jsonToSchemaObject(item)); - } - emptySchemaObj.allOf = collector; + result.allOf = _.map(json.allOf, item => jsonToSchemaObject(item)); + break; + } + case 'definitions': { + result.definitions = _.mapValues(json.definitions, def => + jsonToSchemaObject(def), + ); break; } case 'properties': { - const properties: {[key: string]: JsonDefinition} = def.properties; - const collector: MapObject = {}; - for (const item in properties) { - collector[item] = jsonToSchemaObject(properties[item]); - } - emptySchemaObj.properties = collector; + result.properties = _.mapValues(json.properties, item => + jsonToSchemaObject(item), + ); break; } case 'additionalProperties': { - if (json.additionalProperties) { - if (json.additionalProperties === true) { - emptySchemaObj.additionalProperties = {}; - } else { - emptySchemaObj.additionalProperties = jsonToSchemaObject( - json.additionalProperties, - ); - } + if (typeof json.additionalProperties !== 'boolean') { + result.additionalProperties = jsonToSchemaObject( + json.additionalProperties as JsonDefinition, + ); } break; } case 'items': { - def.items = Array.isArray(def.items) ? def.items[0] : def.items; - emptySchemaObj.items = jsonToSchemaObject(def.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: { - emptySchemaObj[property] = val; + result[property] = json[property]; break; } } } - return emptySchemaObj; + return result; } /** 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 3b4094469da7..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 @@ -48,7 +48,7 @@ describe('Routing metadata for parameters', () => { ]); }); - it('infers a complex parameter definition with in:body', () => { + it('infers a complex parameter schema with in:body', () => { @model() class MyData { @property() name: string; 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 1ad033600bb4..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 @@ -279,7 +279,7 @@ describe('Routing metadata for parameters', () => { expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec); }); - it('infers complex body parameter definition into the controller spec', () => { + it('infers complex body parameter schema into the controller spec', () => { const fooSpec: ParameterObject = { name: 'foo', in: 'body', @@ -309,7 +309,7 @@ describe('Routing metadata for parameters', () => { .definitions as DefinitionsObject; // tslint:disable-next-line:no-any - expect(Object.keys(defs)).to.deepEqual(['Foo', 'Bar']); + expect(defs).to.have.keys('Foo', 'Bar'); expect(defs.Foo).to.deepEqual({ properties: { price: { @@ -329,7 +329,32 @@ describe('Routing metadata for parameters', () => { }); }); - it('infers empty body parameter definition if no property metadata is present', () => { + 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', @@ -346,7 +371,7 @@ describe('Routing metadata for parameters', () => { const defs = getControllerSpec(MyController) .definitions as DefinitionsObject; - expect(Object.keys(defs)).to.deepEqual(['MyBody']); + expect(defs).to.have.key('MyBody'); expect(defs.MyBody).to.deepEqual({}); }); @@ -366,7 +391,7 @@ describe('Routing metadata for parameters', () => { const defs = getControllerSpec(MyController) .definitions as DefinitionsObject; - expect(Object.keys(defs)).to.deepEqual(['MyBody']); + expect(defs).to.have.key('MyBody'); expect(defs.MyBody).to.deepEqual({}); }); 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 index a5039fc67efa..97bf3952bfb6 100644 --- 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 @@ -12,64 +12,144 @@ describe('jsonToSchemaObject', () => { it('does nothing when given an empty object', () => { expect({}).to.eql({}); }); - const typeDef = {type: ['string', 'number']}; - const expectedType = {type: 'string'}; - propertyConversionTest('type', typeDef, expectedType); + const typeDef: JsonDefinition = {type: ['string', 'number']}; + const expectedType: SchemaObject = {type: 'string'}; + it('converts type', () => { + propertyConversionTest(typeDef, expectedType); + }); - const allOfDef: JsonDefinition = { - allOf: [typeDef, typeDef], - }; - const expectedAllOf: SchemaObject = { - allOf: [expectedType, expectedType], - }; - propertyConversionTest('allOf', allOfDef, expectedAllOf); + it('ignores non-compatible JSON schema properties', () => { + const nonCompatibleDef: JsonDefinition = { + anyOf: [], + oneOf: [], + additionalItems: { + anyOf: [], + }, + defaultProperties: [], + typeof: 'function', + }; + const expectedDef: SchemaObject = {}; + propertyConversionTest(nonCompatibleDef, expectedDef); + }); - const propertyDef: JsonDefinition = { - type: 'object', - properties: { - foo: typeDef, - }, - }; - const expectedProperties: SchemaObject = { - type: 'object', - properties: { - foo: expectedType, - }, - }; - propertyConversionTest('properties', propertyDef, expectedProperties); + 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); + }); - const additionalDef: JsonDefinition = { - type: 'object', - additionalProperties: typeDef, - }; - const expectedAdditional: SchemaObject = { - type: 'object', - additionalProperties: expectedType, - }; - propertyConversionTest( - 'additionalProperties', - additionalDef, - expectedAdditional, - ); + it('converts properties', () => { + const propertyDef: JsonDefinition = { + properties: { + foo: typeDef, + }, + }; + const expectedProperties: SchemaObject = { + properties: { + foo: expectedType, + }, + }; + propertyConversionTest(propertyDef, expectedProperties); + }); - const itemsDef: JsonDefinition = { - type: 'array', - items: typeDef, - }; - const expectedItems: SchemaObject = { - type: 'array', - items: expectedType, - }; - propertyConversionTest('items', itemsDef, expectedItems); + 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); }); @@ -86,13 +166,7 @@ describe('jsonToSchemaObject', () => { // Helper function to check conversion of JSON Schema properties // to Swagger versions - function propertyConversionTest( - name: string, - property: Object, - expected: Object, - ) { - it(name, () => { - expect(jsonToSchemaObject(property)).to.eql(expected); - }); + function propertyConversionTest(property: Object, expected: Object) { + expect(jsonToSchemaObject(property)).to.deepEqual(expected); } }); diff --git a/packages/repository-json-schema/.gitignore b/packages/repository-json-schema/.gitignore deleted file mode 100644 index 90a8d96cc3ff..000000000000 --- a/packages/repository-json-schema/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -*.tgz -dist* -package diff --git a/packages/repository-json-schema/README.md b/packages/repository-json-schema/README.md index 0922b09c5401..061768309413 100644 --- a/packages/repository-json-schema/README.md +++ b/packages/repository-json-schema/README.md @@ -15,7 +15,7 @@ $ npm install --save @loopback/repository-json-schema ## Basic use ```ts -import {getJsonDef} from '@loopback/repository-json-schema'; +import {getJsonSchema} from '@loopback/repository-json-schema'; import {model, property} from '@loopback/repository'; @model() @@ -23,10 +23,10 @@ MyModel { @property() name: string; } -const jsonDef = getJsonDef(MyModel); +const jsonSchema = getJsonSchema(MyModel); ``` -The value of `jsonDef` will be: +The value of `jsonSchema` will be: ```json { diff --git a/packages/repository-json-schema/package.json b/packages/repository-json-schema/package.json index bbc6eb20ba30..1b43bc0726b1 100644 --- a/packages/repository-json-schema/package.json +++ b/packages/repository-json-schema/package.json @@ -14,8 +14,8 @@ "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 ../../test/mocha.opts 'dist/test/unit/**/*.js' 'dist/test/integration/**/*.js' 'dist/test/acceptance/**/*.js'", - "verify": "npm pack && tar xf loopback-jsons-schema*.tgz && tree package && npm run clean" + "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", diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index c810273c7572..d9386b972416 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -9,7 +9,7 @@ import { ModelDefinition, } from '@loopback/repository'; import {includes} from 'lodash'; -import {Definition} from 'typescript-json-schema'; +import {Definition, PrimitiveType} from 'typescript-json-schema'; import {MetadataInspector} from '@loopback/context'; export const JSON_SCHEMA_KEY = 'loopback:json-schema'; @@ -18,17 +18,33 @@ 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}; } -export function getJsonDef(ctor: Function): JsonDefinition { - const jsonDef = MetadataInspector.getClassMetadata(JSON_SCHEMA_KEY, ctor); - if (jsonDef) { - return jsonDef; +/** + * 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 newDef = modelToJsonDef(ctor); - MetadataInspector.defineMetadata(JSON_SCHEMA_KEY, newDef, ctor); - return newDef; + const newSchema = modelToJsonSchema(ctor); + MetadataInspector.defineMetadata(JSON_SCHEMA_KEY, newSchema, ctor); + return newSchema; } } @@ -40,46 +56,81 @@ export function getJsonDef(ctor: Function): JsonDefinition { * reflection API * @param ctor Constructor of class to convert from */ -export function modelToJsonDef(ctor: Function): JsonDefinition { +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]; - if (propMeta.type) { + 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 = {}; } - schema.properties[p] = toJsonProperty(propMeta); - } - } - return schema; -} -/** - * Converts a property in metadata form to a JSON schema property definition - * @param propMeta Property in metadata to convert from - */ -export function toJsonProperty(propMeta: PropertyDefinition): JsonDefinition { - const ctor = propMeta.type as Function; + if (propMeta.array === true) { + schema.properties[p] = { + type: 'array', + items: propDef, + }; + } else { + schema.properties[p] = propDef; + } - // errors out if @property.array() is not used on a property of array - if (ctor === Array) { - throw new Error('type is defined as an array'); - } + if (isComplexType(propCtor)) { + const propSchema = getJsonSchema(propCtor); - let prop: JsonDefinition = {}; + if (propSchema && Object.keys(propSchema).length > 0) { + if (!schema.definitions) { + schema.definitions = {}; + } - if (propMeta.array === true) { - prop.type = 'array'; - prop.items = toJsonProperty({ - array: ctor === Array ? true : false, - type: ctor, - }); - } else if (includes([String, Number, Boolean, Object], ctor)) { - prop.type = ctor.name.toLowerCase(); - } else { - prop.$ref = `#definitions/${ctor.name}`; + if (propSchema.definitions) { + for (const key in propSchema.definitions) { + schema.definitions[key] = propSchema.definitions[key]; + } + delete propSchema.definitions; + } + + schema.definitions[propCtor.name] = propSchema; + } + } + } } - return prop; + return 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 index df5061ed8bb0..a616294b2628 100644 --- a/packages/repository-json-schema/test/integration/build-schema.test.ts +++ b/packages/repository-json-schema/test/integration/build-schema.test.ts @@ -10,13 +10,13 @@ import { ModelDefinition, PropertyMap, } from '@loopback/repository'; -import {modelToJsonDef, toJsonProperty} from '../../src/build-schema'; +import {modelToJsonSchema} from '../../src/build-schema'; import {expect} from '@loopback/testlab'; import {MetadataInspector} from '@loopback/context'; -import {JSON_SCHEMA_KEY, getJsonDef} from '../../index'; +import {JSON_SCHEMA_KEY, getJsonSchema} from '../../index'; describe('build-schema', () => { - describe('modelToJSON', () => { + describe('modelToJsonSchema', () => { it('does not convert null or undefined property', () => { @model() class TestModel { @@ -24,8 +24,8 @@ describe('build-schema', () => { @property() undef: undefined; } - const jsonDef = modelToJsonDef(TestModel); - expect(jsonDef.properties).to.not.have.keys(['nul', 'undef']); + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.not.have.keys(['nul', 'undef']); }); it('does not convert properties that have not been decorated', () => { @@ -40,8 +40,8 @@ describe('build-schema', () => { baz: number; } - expect(modelToJsonDef(NoPropertyMeta)).to.eql({}); - expect(modelToJsonDef(OnePropertyDecorated)).to.deepEqual({ + expect(modelToJsonSchema(NoPropertyMeta)).to.eql({}); + expect(modelToJsonSchema(OnePropertyDecorated)).to.deepEqual({ properties: { foo: { type: 'string', @@ -57,8 +57,8 @@ describe('build-schema', () => { bar: number; } - expect(modelToJsonDef(Empty)).to.eql({}); - expect(modelToJsonDef(NoModelMeta)).to.eql({}); + expect(modelToJsonSchema(Empty)).to.eql({}); + expect(modelToJsonSchema(NoModelMeta)).to.eql({}); }); it('properly converts string, number, and boolean properties', () => { @@ -69,8 +69,8 @@ describe('build-schema', () => { @property() bool: boolean; } - const jsonDef = modelToJsonDef(TestModel); - expect(jsonDef.properties).to.deepEqual({ + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.deepEqual({ str: { type: 'string', }, @@ -89,29 +89,105 @@ describe('build-schema', () => { @property() obj: object; } - const jsonDef = modelToJsonDef(TestModel); - expect(jsonDef.properties).to.deepEqual({ + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.deepEqual({ obj: { type: 'object', }, }); }); - it('properly converts custom type properties', () => { - class CustomType { - prop: string; - } + context('with custom type properties', () => { + it('properly converts undecorated custom type properties', () => { + class CustomType { + prop: string; + } - @model() - class TestModel { - @property() cusType: CustomType; - } + @model() + class TestModel { + @property() cusType: CustomType; + } - const jsonDef = modelToJsonDef(TestModel); - expect(jsonDef.properties).to.deepEqual({ - cusType: { - $ref: '#definitions/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', + }, + }, + }, + }, + }); }); }); @@ -121,8 +197,8 @@ describe('build-schema', () => { @property.array(Number) numArr: number[]; } - const jsonDef = modelToJsonDef(TestModel); - expect(jsonDef.properties).to.deepEqual({ + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.deepEqual({ numArr: { type: 'array', items: { @@ -142,8 +218,8 @@ describe('build-schema', () => { @property.array(CustomType) cusArr: CustomType[]; } - const jsonDef = modelToJsonDef(TestModel); - expect(jsonDef.properties).to.deepEqual({ + const jsonSchema = modelToJsonSchema(TestModel); + expect(jsonSchema.properties).to.deepEqual({ cusArr: { type: 'array', items: { @@ -153,6 +229,41 @@ describe('build-schema', () => { }); }); + 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 { @@ -160,105 +271,53 @@ describe('build-schema', () => { } expect(() => { - modelToJsonDef(BadArray); + modelToJsonSchema(BadArray); }).to.throw(/type is defined as an array/); }); - describe('toJSONProperty', () => { - class Bar { - barA: number; - } + it('errors out if "@property.array" is given "Array" as parameter', () => { @model() - class Foo { - @property() str: string; - @property() num: number; - @property() bool: boolean; - @property() obj: object; - @property() nul: null; - @property() undef: undefined; - @property.array(String) arrStr: string[]; - @property() bar: Bar; - @property.array(Bar) arrBar: Bar[]; + class BadArray { + @property.array(Array) badArr: string[][]; } - let meta: ModelDefinition; - let propMeta: PropertyMap; - - before(() => { - meta = ModelMetadataHelper.getModelMetadata(Foo); - propMeta = meta.properties; - }); - - it('converts primitively typed property correctly', () => { - expect(toJsonProperty(propMeta.str)).to.deepEqual({ - type: 'string', - }); - expect(toJsonProperty(propMeta.num)).to.deepEqual({ - type: 'number', - }); - expect(toJsonProperty(propMeta.bool)).to.deepEqual({ - type: 'boolean', - }); - }); - - it('converts object property correctly', () => { - expect(toJsonProperty(propMeta.obj)).to.deepEqual({ - type: 'object', - }); - }); - - it('converts customly typed property correctly', () => { - expect(toJsonProperty(propMeta.bar)).to.deepEqual({ - $ref: '#definitions/Bar', - }); - }); - it('converts arrays of primitives correctly', () => { - expect(toJsonProperty(propMeta.arrStr)).to.deepEqual({ - type: 'array', - items: { - type: 'string', - }, - }); - }); - - it('converts arrays of custom types correctly', () => { - expect(toJsonProperty(propMeta.arrBar)).to.deepEqual({ - type: 'array', - items: { - $ref: '#definitions/Bar', - }, - }); - }); + expect(() => { + modelToJsonSchema(BadArray); + }).to.throw(/type is defined as an array/); }); }); - describe('getJsonDef', () => { - it('gets cached JSON definition if one exists', () => { + describe('getjsonSchema', () => { + it('gets cached JSON schema if one exists', () => { @model() class TestModel { @property() foo: number; } - const cachedDef = { + const cachedSchema = { properties: { cachedProperty: { type: 'string', }, }, }; - MetadataInspector.defineMetadata(JSON_SCHEMA_KEY, cachedDef, TestModel); - - const jsonDef = getJsonDef(TestModel); - expect(jsonDef).to.eql(cachedDef); + MetadataInspector.defineMetadata( + JSON_SCHEMA_KEY, + cachedSchema, + TestModel, + ); + + const jsonSchema = getJsonSchema(TestModel); + expect(jsonSchema).to.eql(cachedSchema); }); - it('creates JSON definition if one does not already exist', () => { + it('creates JSON schema if one does not already exist', () => { @model() class NewModel { @property() newProperty: string; } - const jsonDef = getJsonDef(NewModel); - expect(jsonDef.properties).to.containDeep({ + const jsonSchema = getJsonSchema(NewModel); + expect(jsonSchema.properties).to.deepEqual({ newProperty: { type: 'string', },