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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/openapi-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
"devDependencies": {
"@loopback/build": "^4.0.0-alpha.9",
"@loopback/openapi-spec-builder": "^4.0.0-alpha.17",
"@loopback/testlab": "^4.0.0-alpha.19"
"@loopback/repository": "^4.0.0-alpha.23",
"@loopback/testlab": "^4.0.0-alpha.19",
"@types/lodash": "^4.14.92"
},
"scripts": {
"build": "npm run build:dist && npm run build:dist6",
Expand Down Expand Up @@ -49,6 +51,7 @@
"dependencies": {
"@loopback/context": "^4.0.0-alpha.26",
"@loopback/openapi-spec": "^4.0.0-alpha.20",
"@loopback/repository-json-schema": "^4.0.0-alpha.1",
"lodash": "^4.17.4"
}
}
128 changes: 124 additions & 4 deletions packages/openapi-v2/src/controller-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,21 @@ import {
PathsObject,
ItemType,
ItemsObject,
DefinitionsObject,
MapObject,
} from '@loopback/openapi-spec';

import * as stream from 'stream';
import {getJsonSchema, JsonDefinition} from '@loopback/repository-json-schema';
import * as _ from 'lodash';

const debug = require('debug')('loopback:rest:router:metadata');

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

Expand All @@ -47,8 +51,12 @@ export interface ControllerSpec {
* The available paths and operations for the API.
*/
paths: PathsObject;
}

/**
* JSON Schema definitions of models used by the controller
*/
definitions?: DefinitionsObject;
}
/**
* Decorate the given Controller constructor with metadata describing
* the HTTP/REST API the Controller implements/provides.
Expand Down Expand Up @@ -167,6 +175,36 @@ function resolveControllerSpec(constructor: Function): ControllerSpec {

debug(` adding ${endpointName}`, operationSpec);
spec.paths[path][verb] = operationSpec;

debug(` inferring schema object for method %s`, op);
const paramTypes = MetadataInspector.getDesignTypeForMethod(
constructor.prototype,
op,
).parameterTypes;

const isComplexType = (ctor: Function) =>
!_.includes([String, Number, Boolean, Array, Object], ctor) &&
!isReadableStream(ctor);

for (const p of paramTypes) {
if (isComplexType(p)) {
if (!spec.definitions) {
spec.definitions = {};
}
const jsonSchema = getJsonSchema(p);
const openapiSchema = jsonToSchemaObject(jsonSchema);

if (openapiSchema.definitions) {
for (const key in openapiSchema.definitions) {
spec.definitions[key] = openapiSchema.definitions[key];
}
delete openapiSchema.definitions;
}

spec.definitions[p.name] = openapiSchema;
break;
}
}
}
return spec;
}
Expand All @@ -177,17 +215,99 @@ function resolveControllerSpec(constructor: Function): ControllerSpec {
*/
export function getControllerSpec(constructor: Function): ControllerSpec {
let spec = MetadataInspector.getClassMetadata<ControllerSpec>(
REST_API_SPEC_KEY,
REST_CONTROLLER_SPEC_KEY,
constructor,
{ownMetadataOnly: true},
);
if (!spec) {
spec = resolveControllerSpec(constructor);
MetadataInspector.defineMetadata(REST_API_SPEC_KEY, spec, constructor);
MetadataInspector.defineMetadata(
REST_CONTROLLER_SPEC_KEY,
spec,
constructor,
);
}
return spec;
}

export function jsonToSchemaObject(jsonDef: JsonDefinition): SchemaObject {
const json = jsonDef as {[name: string]: any}; // gets around index signature error
const result: SchemaObject = {};
const propsToIgnore = [
'anyOf',
'oneOf',
'additionalItems',
'defaultProperties',
'typeof',
];
for (const property in json) {
if (propsToIgnore.includes(property)) {
continue;
}
switch (property) {
case 'type': {
if (json.type === 'array' && !json.items) {
throw new Error(
'"items" property must be present if "type" is an array',
);
}
result.type = Array.isArray(json.type) ? json.type[0] : json.type;
break;
}
case 'allOf': {
result.allOf = _.map(json.allOf, item => jsonToSchemaObject(item));
break;
}
case 'definitions': {
result.definitions = _.mapValues(json.definitions, def =>
jsonToSchemaObject(def),
);
break;
}
case 'properties': {
result.properties = _.mapValues(json.properties, item =>
jsonToSchemaObject(item),
);
break;
}
case 'additionalProperties': {
if (typeof json.additionalProperties !== 'boolean') {
result.additionalProperties = jsonToSchemaObject(
json.additionalProperties as JsonDefinition,
);
}
break;
}
case 'items': {
const items = Array.isArray(json.items) ? json.items[0] : json.items;
result.items = jsonToSchemaObject(items as JsonDefinition);
break;
}
case 'enum': {
const newEnum = [];
const primitives = ['string', 'number', 'boolean'];
for (const element of json.enum) {
if (primitives.includes(typeof element) || element === null) {
newEnum.push(element);
} else {
// if element is JsonDefinition, convert to SchemaObject
newEnum.push(jsonToSchemaObject(element as JsonDefinition));
}
}
result.enum = newEnum;

break;
}
default: {
result[property] = json[property];
break;
}
}
}

return result;
}

/**
* Expose a Controller method as a REST API operation
* mapped to `GET` request method.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -47,6 +48,28 @@ describe('Routing metadata for parameters', () => {
]);
});

it('infers a complex parameter schema with in:body', () => {
@model()
class MyData {
@property() name: string;
}
class MyController {
@post('/greeting')
greet(@param.body('data') data: MyData) {}
}

const actualSpec = getControllerSpec(MyController);
expect(actualSpec.definitions).to.deepEqual({
MyData: {
properties: {
name: {
type: 'string',
},
},
},
});
});

it('infers a string parameter type with in:body', () => {
class MyController {
@post('/greeting')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -270,6 +279,122 @@ describe('Routing metadata for parameters', () => {
expect(actualSpec.paths['/greet']['get']).to.eql(expectedSpec);
});

it('infers complex body parameter schema into the controller spec', () => {
const fooSpec: ParameterObject = {
name: 'foo',
in: 'body',
};
const barSpec: ParameterObject = {
name: 'bar',
in: 'body',
};
@model()
class Foo {
@property() price: number;
}
@model()
class Bar {
@property() name: string;
@property() foo: Foo;
}
class MyController {
@post('/foo')
foo(@param(fooSpec) foo: Foo) {}

@post('/bar')
bar(@param(barSpec) bar: Bar) {}
}

const defs = getControllerSpec(MyController)
.definitions as DefinitionsObject;

// tslint:disable-next-line:no-any
expect(defs).to.have.keys('Foo', 'Bar');
expect(defs.Foo).to.deepEqual({
properties: {
price: {
type: 'number',
},
},
});
expect(defs.Bar).to.deepEqual({
properties: {
name: {
type: 'string',
},
foo: {
$ref: '#definitions/Foo',
},
},
});
});

it('does not produce nested definitions', () => {
const paramSpec: ParameterObject = {
name: 'foo',
in: 'body',
};
@model()
class Foo {
@property() bar: number;
}
@model()
class MyBody {
@property() name: string;
@property() foo: Foo;
}
class MyController {
@post('/foo')
foo(@param(paramSpec) body: MyBody) {}
}

const defs = getControllerSpec(MyController)
.definitions as DefinitionsObject;
expect(defs).to.have.keys('MyBody', 'Foo');
expect(defs.MyBody).to.not.have.key('definitions');
});

it('infers empty body parameter schema if no property metadata is present', () => {
const paramSpec: ParameterObject = {
name: 'foo',
in: 'body',
};
@model()
class MyBody {
name: string;
}
class MyController {
@post('/foo')
foo(@param(paramSpec) foo: MyBody) {}
}

const defs = getControllerSpec(MyController)
.definitions as DefinitionsObject;

expect(defs).to.have.key('MyBody');
expect(defs.MyBody).to.deepEqual({});
});

it('does not infer definition if no class metadata is present', () => {
const paramSpec: ParameterObject = {
name: 'foo',
in: 'body',
};
class MyBody {
@property() name: string;
}
class MyController {
@post('/foo')
foo(@param(paramSpec) foo: MyBody) {}
}

const defs = getControllerSpec(MyController)
.definitions as DefinitionsObject;

expect(defs).to.have.key('MyBody');
expect(defs.MyBody).to.deepEqual({});
});

it('can define multiple parameters in order', () => {
const offsetSpec: ParameterObject = {
name: 'offset',
Expand Down
Loading