Skip to content

Commit

Permalink
feat: simplify request body decorator
Browse files Browse the repository at this point in the history
  • Loading branch information
jannyHou committed Jul 19, 2019
1 parent f6ecf29 commit 57dc19e
Show file tree
Hide file tree
Showing 8 changed files with 420 additions and 78 deletions.
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"args": [
"--config",
"${workspaceRoot}/packages/build/config/.mocharc.json",
"packages/*/dist/__tests__/**/*.js",
"packages/openapi-v3/dist/__tests__/**/*.js",
"-t",
"0"
]
Expand Down
71 changes: 71 additions & 0 deletions packages/openapi-v3/spike.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
## Improve the UX of @requestBody()

The original discussion is tracked in issue [Spike: simplify requestBody annotation with schema options](https://github.com/strongloop/loopback-next/issues/2654).

The current @requestBody() can only

- takes in an entire request body specification with very nested media type objects
or
- generate the schema inferred from the parameter type

To simplify the signature, this spike PR introduces a 2nd parameter `schemaOptions` to configure the schema. The new decorator `newRequestBody1` is written in file 'request-body.options1.decorator.ts'

### Create - exclude properties

Take "Create a new product with excluded properties" as an example:

```ts
// Provide the description as before
const requestBodySpec = {
description: 'Create a product',
required: true,
};

// Provide the options that configure your schema
const excludeOptions = {
// Using advanced ts types like `Exclude<>`, `Partial<>` results in
// `MetadataInspector.getDesignTypeForMethod(target, member)` only
// returns `Object` as the param type, which loses the model type's info.
// Therefore user must provide the model type in options.
[TS_TYPE_KEY]: Product,
// Make sure you give the custom schema a unique schema name,
// this name will be used as the reference name
// like `$ref: '#components/schemas/ProductWithoutID'`
schemaName: 'ProductWithoutID',
// The excluded properties
exclude: ['id']
}

// The decorator takes in the option without having a nested content object
class MyController1 {
@post('/Product')
create(@newRequestBody1(
requestBodySpec,
excludeOptions
) product: Exclude<Product, ['id']>) { }
}
```

### Update - partial properties

```ts
const requestSpecForUpdate = {
description: 'Update a product',
required: true,
};

const partialOptions = {
[TS_TYPE_KEY]: Product,
schemaName: 'PartialProduct',
partial: true
}

class MyController2 {
@put('/Product')
update(@newRequestBody1(
requestSpecForUpdate,
partialOptions
) product: Partial<Product>) { }
}
```

Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Copyright IBM Corp. 2019. All Rights Reserved.
// Node module: @loopback/openapi-v3
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import { belongsTo, Entity, hasMany, model, property } from '@loopback/repository';
import { expect } from '@loopback/testlab';
import { getControllerSpec, post, put } from '../../../..';
import { TS_TYPE_KEY } from '../../../../controller-spec';
import { newRequestBody1 } from '../../../../decorators/request-body.option1.decorator';

describe.only('spike - requestBody decorator', () => {
context('proposal 1', () => {
@model()
class Product extends Entity {
@property({
type: 'string',
})
name: string;
@belongsTo(() => Category)
categoryId: number;

constructor(data?: Partial<Product>) {
super(data);
}
}

/**
* Navigation properties of the Product model.
*/
interface ProductRelations {
category?: CategoryWithRelations;
}
/**
* Product's own properties and navigation properties.
*/
type ProductWithRelations = Product & ProductRelations;

@model()
class Category extends Entity {
@hasMany(() => Product)
products?: Product[];
}
/**
* Navigation properties of the Category model.
*/
interface CategoryRelations {
products?: ProductWithRelations[];
}
/**
* Category's own properties and navigation properties.
*/
type CategoryWithRelations = Category & CategoryRelations;

it('create - generates schema with excluded properties', () => {
const requestBodySpec = {
description: 'Create a product',
required: true,
};

const excludeOptions = {
[TS_TYPE_KEY]: Product,
schemaName: 'ProductWithoutID',
exclude: ['id']
}

class MyController1 {
@post('/Product')
create(@newRequestBody1(
requestBodySpec,
excludeOptions
) product: Exclude<Product, ['id']>) { }
}

const spec1 = getControllerSpec(MyController1)

const requestBodySpecForCreate = spec1.paths[
'/Product'
]['post'].requestBody;

const referenceSchema = spec1.components!.schemas!.ProductWithoutID;

expect(requestBodySpecForCreate).to.have.properties({
description: 'Create a product',
required: true,
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/ProductWithoutID'
}
}
}
});

// The feature that generates schemas according to
// different options is TO BE DONE and out of the
// scope of this spike, so that the schema `PartialProduct`
// here is still the same as `Product`
expect(referenceSchema).to.have.properties({
title: 'ProductWithoutID',
properties: {
categoryId: { type: 'number' },
name: { type: 'string' }
}
});
})

it('update - generates schema with partial properties', () => {
const requestSpecForUpdate = {
description: 'Update a product',
required: true,
};

const partialOptions = {
[TS_TYPE_KEY]: Product,
schemaName: 'PartialProduct',
partial: true
}

class MyController2 {
@put('/Product')
update(@newRequestBody1(
requestSpecForUpdate,
partialOptions
) product: Partial<Product>) { }
}

const spec2 = getControllerSpec(MyController2)

const requestBodySpecForCreate = spec2.paths[
'/Product'
]['put'].requestBody;

const referenceSchema = spec2.components!.schemas!.PartialProduct;

expect(requestBodySpecForCreate).to.have.properties({
description: 'Update a product',
required: true,
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/PartialProduct'
}
}
}
});

// The feature that generates schemas according to
// different options is TO BE DONE and out of the
// scope of this spike, so that the schema `PartialProduct`
// here is still the same as `Product`
expect(referenceSchema).to.have.properties({
title: 'PartialProduct',
properties: {
categoryId: { type: 'number' },
name: { type: 'string' }
}
});
});
});
});
83 changes: 52 additions & 31 deletions packages/openapi-v3/src/controller-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,14 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {DecoratorFactory, MetadataInspector} from '@loopback/context';
import {
getJsonSchema,
getJsonSchemaRef,
JsonSchemaOptions,
} from '@loopback/repository-json-schema';
import { DecoratorFactory, MetadataInspector } from '@loopback/context';
import { getJsonSchemaRef, JsonSchemaOptions } from '@loopback/repository-json-schema';
import * as _ from 'lodash';
import {resolveSchema} from './generate-schema';
import {jsonToSchemaObject, SchemaRef} from './json-to-schema';
import {OAI3Keys} from './keys';
import {
ComponentsObject,
ISpecificationExtension,
isReferenceObject,
OperationObject,
ParameterObject,
PathObject,
ReferenceObject,
RequestBodyObject,
ResponseObject,
SchemaObject,
SchemasObject,
} from './types';
import { SchemaOptions } from './decorators/request-body.option1.decorator';
import { resolveSchema } from './generate-schema';
import { jsonToSchemaObject, SchemaRef } from './json-to-schema';
import { OAI3Keys } from './keys';
import { ComponentsObject, ISpecificationExtension, isReferenceObject, OperationObject, ParameterObject, PathObject, ReferenceObject, RequestBodyObject, ResponseObject, SchemaObject, SchemasObject } from './types';

const debug = require('debug')('loopback:openapi3:metadata:controller-spec');

Expand Down Expand Up @@ -74,7 +59,7 @@ function resolveControllerSpec(constructor: Function): ControllerSpec {
debug(' using class-level spec defined via @api()', spec);
spec = DecoratorFactory.cloneDeep(spec);
} else {
spec = {paths: {}};
spec = { paths: {} };
}

let endpoints =
Expand Down Expand Up @@ -183,7 +168,7 @@ function resolveControllerSpec(constructor: Function): ControllerSpec {

const content = requestBody.content || {};
for (const mediaType in content) {
processSchemaExtensions(spec, content[mediaType].schema);
processSchemaExtensionsForRequestBody(spec, content[mediaType].schema);
}
}
}
Expand Down Expand Up @@ -271,31 +256,67 @@ function processSchemaExtensions(
}
}

function processSchemaExtensionsForRequestBody(
spec: ControllerSpec,
schema?: SchemaObject | (ReferenceObject & ISpecificationExtension),
) {
debug(' processing extensions in schema: %j', schema);
if (!schema) return;

assignRelatedSchemas(spec, schema.definitions);
delete schema.definitions;

const tsType = schema.options && schema.options[TS_TYPE_KEY];
debug(' %s => %o', TS_TYPE_KEY, tsType);
if (tsType) {

if (!schema.options.isVisited) schema = resolveSchema(tsType, schema);
if (schema.$ref) generateOpenAPISchema(spec, tsType, schema.options);

// We don't want a Function type in the final spec.
delete schema.options;
return;
}
if (schema.type === 'array') {
processSchemaExtensionsForRequestBody(spec, schema.items);
} else if (schema.type === 'object') {
if (schema.properties) {
for (const p in schema.properties) {
processSchemaExtensionsForRequestBody(spec, schema.properties[p]);
}
}
}
}

/**
* Generate json schema for a given x-ts-type
* @param spec - Controller spec
* @param tsType - TS Type
*/
function generateOpenAPISchema(spec: ControllerSpec, tsType: Function) {
function generateOpenAPISchema(spec: ControllerSpec, tsType: Function, options?: SchemaOptions) {
if (!spec.components) {
spec.components = {};
}
if (!spec.components.schemas) {
spec.components.schemas = {};
}
if (tsType.name in spec.components.schemas) {
const schemaName = options && options.schemaName || tsType.name
if (schemaName in spec.components.schemas) {
// Preserve user-provided definitions
debug(' skipping type %j as already defined', tsType.name || tsType);
return;
}
const jsonSchema = getJsonSchema(tsType);
const openapiSchema = jsonToSchemaObject(jsonSchema);

const openapiSchema = getModelSchemaRef(tsType, options);
// const jsonSchema = getJsonSchema(tsType);
// const openapiSchema = jsonToSchemaObject(jsonSchema);
delete openapiSchema.definitions.options;

assignRelatedSchemas(spec, openapiSchema.definitions);
delete openapiSchema.definitions;

debug(' defining schema for %j: %j', tsType.name, openapiSchema);
spec.components.schemas[tsType.name] = openapiSchema;
// debug(' defining schema for %j: %j', tsType.name, openapiSchema);
// spec.components.schemas[tsType.name] = openapiSchema;
}

/**
Expand Down Expand Up @@ -337,7 +358,7 @@ export function getControllerSpec(constructor: Function): ControllerSpec {
let spec = MetadataInspector.getClassMetadata<ControllerSpec>(
OAI3Keys.CONTROLLER_SPEC_KEY,
constructor,
{ownMetadataOnly: true},
{ ownMetadataOnly: true },
);
if (!spec) {
spec = resolveControllerSpec(constructor);
Expand Down
Loading

0 comments on commit 57dc19e

Please sign in to comment.