Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spike/new requestbody #3466

Closed
wants to merge 5 commits into from
Closed
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
157 changes: 157 additions & 0 deletions packages/openapi-v3/_spike-request-body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
## 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
- generates the schema inferred from the parameter type

It doesn't give people the convenience to configure the schema spec with `model`
and `schemaOptions` without defining them in a nested object. To simplify the
signature, this spike PR introduces two more parameters `model` and
`schemaOptions` to configure the schema.

Please note the 2nd and 3rd parameters are only used to contribute the spec for
_default schema_(I will explain what default means with an example), any other
request body spec are still defined in the 1st parameter.

See the example from the swagger official website:
https://swagger.io/docs/specification/describing-request-body/

- `application/json` and `application/xml` share the same schema
`#/components/schemas/Pet`
- `application/x-www-form-urlencoded` uses another schema
`#/components/schemas/PetForm'
- `text/plain` receives a string: `type: string`

The new decorator allows

- a default schema (generated from the 2nd and 3rd params, should be `Pet` in
this case) _and_
- custom schemas (provided by the 1st parameter: partial spec, `PetForm` and
string schema)

so that developers don't need to repeat defining the same schema in the 1st
parameter.

The logic of the new decorator is:

- The 1st parameter contains the pure OpenAPI request body spec
- Other parameters contribute the default schema' config, `model`, `options`.
- The decorator:
- first reads schema config
- then generate default schema spec accordingly
- if any content type is missing `schema`, then assign the default schema to
it(Our current implementation is already doing it, see
[code](https://github.com/strongloop/loopback-next/blob/master/packages/openapi-v3/src/decorators/request-body.decorator.ts#L103-L107))

ANY OTHER PARTS of the spec are read from the 1st parameter, the decorator only
generate and merge the default `schema`. We can implement helpers/builders for
developers to build the entire spec, like

```ts
requestBodyBuilder
.addContent('application/x-www-form-urlencoded')
.withSchema(model, options)
.withExample(exampleSpecs);
```

The new decorator `requestBody2()`(let's discuss a better name later, see
section [Naming](#Naming) is written in file 'request-body.spike.decorator.ts'

### Signature

The new decorator's signature is
`@requestBody2(spec, modelCtor, schemaOptions)`.

All the 3 parameters are optional, therefore in the PoC, the real implementation
are in `_requestBody2()`, `requestBody2()` is a wrapper that resolves different
combination of the parameters.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to tighten the API little bit, to make it more difficult for our users to do something wrong or confusing.

(1)
When the spec contains content property, then we should not allow model & schemaOptions.

I'd like to make the following example invalid & rejected by the compiler.

requestBody({
  content: {
    'application/json': {
      schema: getModelSchemaRef(MyModel)
    }
  },
  MyModelData, // notice a different class!
);

Proposed signature (overload):

export function requestBody2<T>(
  spec: Omit<Partial<RequestBodyObject>, 'content'>,
  model: Function,
  schemaOptions?: JsonSchemaOptions<T>,
): (target: object, member: string, index: number) => void;

(2)
As discussed in #3398 (comment), we may want to add a new overload in the future that will allow users to a different ORM framework supply the MediaTypeObject in requestBody parameters, for example:

@requestBody(
  // fields from RequestBodyObject
  {
    description: 'description of the request body parameter',
    required: true,
  },
  // MediaTypeObject to use for populating `content` entries
  {schema: getModelSchemaRef(Product, {exclude: ['id']})}
)

I'd like our current proposal to be forward compatible and allow us to add support for the example above as an incremental & backwards-compatible change in the future.

IIUC the current proposal, it supports the following form:

@requestBody(
  // RequestBodyObject
  {description: 'description of the request body parameter'},
  // JsonSchemaOptions
  {partial: true}
)

I am concerned that if we want to support MediaTypeObject for the second argument too, then it will be difficult to determine what did the user mean - did they provide JsonSchemaOptions or MediaTypeObject?

To avoid that problem, I am proposing to forbid the following form:

@requestBody(
  // RequestBodyObject
  {description: 'description of the request body parameter'},
  // JsonSchemaOptions
  {partial: true}
)

and always require users to provide the model constructor if they want to use JsonSchemaOptions too.

@requestBody(
  // RequestBodyObject
  {description: 'description of the request body parameter'},
  MyModel,
  // JsonSchemaOptions
  {partial: true}
)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(2)
I am proposing to forbid the following form
and always require users to provide the model constructor if they want to use JsonSchemaOptions

My point (2) becomes irrelevant if we decide to nest schemaOptions as a new property of the first argument, see #3466 (comment),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bajtos I don't think excluding the content for the first parameterspec is a good idea, the 2nd and 3rd parameters are there to describe the schema, but content has more stuff than schema, and user should still have their own flexibility to

  • provide an entire content including schema without using the 2nd and 3rd params
  • if the 2nd+3rd params decide the default schema, user can override it for special media types with different schemas, like application/x-www-form-urlencoded in this example

So honestly, IMO the request spec(especially content) is nested in nature, unless we provide a bunch of fluent APIs to help users build it, like requestBodyBuilder.addContent('application/x-www-form-urlencoded').withSchema(model, options).withExample(exampleSpecs), there is not that much to simplify for the UX...and if we implement those builders, the purpose is still generating a complete requestBody spec, which can be provided as the 1st parameter in @requestBody(), and still fits my current proposal.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My point of view:

  • If users want to override special media types, then they provide full content object in the first argument.
  • The 2nd+3rd params are only for the case when they want LB to generate default content object for the given model & schema options.

If you like to go forward with your proposal, then please document the rules we are going to merge the (deep) fields in spec.content with the fields created from model + schemaOptions.

I am also interested how are developers going to learn about those merge rules and more importantly, when they do a mistake, how are they going to figure out why they are getting different schema from what they were expected to see?

Let's start with the example I have shown before:

requestBody({
  content: {
    'application/json': {
      schema: getModelSchemaRef(MyModel)
    }
  },
  MyModelData, // notice a different class!
);

Another case to consider:

requestBody({
  content: {
    'application/json': {
      example: {/* some model data */
    }
  },
  MyModel,
);

Are we going to print debug logs or perhaps regular warnings to notify developers when the framework is overwriting spec in a way that may be unexpected?

Copy link
Contributor Author

@jannyHou jannyHou Jul 30, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bajtos

  1. When the spec contains content property, then we should not allow model & schemaOptions.

Let's still discuss with the example from the swagger official website:
https://swagger.io/docs/specification/describing-request-body/

  • application/json and application/xml share the same schema #/components/schemas/Pet
  • application/x-www-form-urlencoded uses another schema `#/components/schemas/PetForm'
  • text/plain receives a string: type: string

My point is we could allow

  • a default schema (generated from the 2nd and 3rd params, should be Pet in this case)
    and
  • custom schemas (provided by the 1st parameter: partial spec, PetForm and string schema)

so that developers don't need to repeat defining the same schema in the 1st parameter.
And our current implementation is already doing it, see code:

  • we generate a default schema according to the interred type
  • iterate through the contents, if schema is missing, content.schema = defaultSchema.

Back to your use case:

requestBody({
  content: {
    'application/json': {
      schema: getModelSchemaRef(MyModel)
    }
  },
  MyModelData, // notice a different class!
);

This is a VALID use case IMO.
cc @strongloop/sq-lb-apex @raymondfeng more opinions are welcomed here ^^ :)

With your proposal which merges the JsonSchemaOptions into the 1st parameter, everything would be the same...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bajtos hope my comment #3466 (comment) answers your question in #3466 (comment)

Copy link
Contributor Author

@jannyHou jannyHou Jul 30, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. As discussed in [SPIKE] feat: simplify request body decorator #3398 (comment), we may want to add a new overload in the future that will allow users to a different ORM framework supply the MediaTypeObject in requestBody parameters...

@bajtos I would suggest NOT achieve this by adding more signatures by overloading, we can introduce fluent spec builder functions to generate any part of the requestBody spec, they would be much more straightforward to use than providing partial specs in some parameters and the decorator function resolves different parts from different places.

The logic in the current decorator, or my new proposal is pretty straightforward to understand IMO:

  • The 1st parameter contains the pure OpenAPI request body spec
  • Other parameters contribute the default schema' config, model, options...
  • The decorator:
    • reads schema config
    • generate default schema spec accordingly
    • if any content type is missing schema, then assign the default schema to it

ANY OTHER PARTS of the spec are read from the 1st parameter, the decorator only generate and merge the default schema. We can implement helpers/builders for developers to build the entire spec, like

requestBodyBuilder.addContent('application/x-www-form-urlencoded').withSchema(model, options).withExample(exampleSpecs)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After discussing with Janny, I think her proposal makes sense to me high-level(ly). Especially the idea of that we can have some build functions that similar to this that allows users to add/build spec object if they want to pass in more information.

But I am not sure if @jannyHou 's proposal could address the following requirement that @bajtos mentioned above:

(2)
As discussed in #3398 (comment), we may want to add a new overload in the future that will allow users to a different ORM framework supply the MediaTypeObject in requestBody parameters..

If yes, I am fine with the proposal.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @agnes512, but for the first iteration, let's not exclude the content for the first parameter. I am not knowledgable to know if the use-case pointed by @bajtos is either valid or not valid since we have differing opinions, so I think we should explore it further outside the scope of this PR.


My PoC PR only adds 2 unit tests for different signatures, the real
implementation should test all the combinations.

```ts
export function _requestBody2<T>(
specOrModelOrOptions?: Partial<RequestBodyObject>,
modelOrOptions?: Function & {prototype: T},
schemaOptions?: SchemaOptions,
) {
// implementation
}
```

### Create - exclude properties

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

```ts
// The decorator takes in the option without having a nested content object
class MyController1 {
@post('/Product')
create(
@requestBody2(
// Provide the description as before
{
description: 'Create a product',
required: true,
},
// 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.
Product,
// Provide the options that configure your schema
{
// The excluded properties
exclude: ['id'],
},
)
product: Exclude<Product, ['id']>,
) {}
}
```

### Update - partial properties

```ts
class MyController2 {
@put('/Product')
update(
@requestBody2({description: 'Update a product', required: true}, Product, {
partial: true,
})
product: Partial<Product>,
) {}
}
```

## Naming

From @jannyHou: I think we can keep the name `requestBody` unchanged. I enabled
the existing `@requestBody()`'s tests but applied `requestBody2()` decorator,
all tests pass, which means there is no breaking change.

## Follow-up Stories

- [ ] More discussion

- The signature:
- `@requstBody(spec, model, options)`
- or `@requestBody(specWithOptions, model)`
- or `@requestBody(spec, getModelSchemaRef(model, options))`
- or any better signatures
- Which signatures should be disabled

- [ ] Implementation
- Modify the current `@requestBody()` according to the spike code, pass
existing tests across all repos.
- Add more unit tests to verify all the signatures.
- Fix the type conflict detect, see test case 'catches type conflict', and
discussion in
[comment](https://github.com/strongloop/loopback-next/pull/3466/files#r308657211)
- [ ] Upgrade examples to provide the descriptive configs(model, options) using
the new decorator.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {post, requestBody, getControllerSpec} from '../../../..';
import {post, requestBody2, getControllerSpec} from '../../../..';
import {expect} from '@loopback/testlab';
import {model, property} from '@loopback/repository';

Expand All @@ -16,7 +16,7 @@ describe('requestBody decorator', () => {
};
class MyController {
@post('/greeting')
greet(@requestBody(requestSpec) name: string) {}
greet(@requestBody2(requestSpec) name: string) {}
}

const requestBodySpec = getControllerSpec(MyController).paths[
Expand All @@ -35,7 +35,7 @@ describe('requestBody decorator', () => {
};
class MyController {
@post('/greeting')
greet(@requestBody(requestSpec) name: string) {}
greet(@requestBody2(requestSpec) name: string) {}
}

const requestBodySpec = getControllerSpec(MyController).paths[
Expand All @@ -60,7 +60,7 @@ describe('requestBody decorator', () => {
class MyController {
@post('/MyModel')
createMyModel(
@requestBody({content: {'application/text': {}}}) inst: MyModel,
@requestBody2({content: {'application/text': {}}}) inst: MyModel,
) {}
}

Expand All @@ -81,7 +81,9 @@ describe('requestBody decorator', () => {

class MyController {
@post('/MyModel')
createMyModel(@requestBody({content: expectedContent}) inst: MyModel) {}
createMyModel(
@requestBody2({content: expectedContent}) inst: MyModel,
) {}
}

const requestBodySpec = getControllerSpec(MyController).paths['/MyModel'][
Expand All @@ -102,7 +104,7 @@ describe('requestBody decorator', () => {
class MyController {
@post('/MyModel')
createMyModel(
@requestBody({content: expectedContent}) inst: Partial<MyModel>,
@requestBody2({content: expectedContent}) inst: Partial<MyModel>,
) {}
}

Expand All @@ -115,7 +117,7 @@ describe('requestBody decorator', () => {
it('reports error if more than one requestBody are found for the same method', () => {
class MyController {
@post('/greeting')
greet(@requestBody() name: string, @requestBody() foo: number) {}
greet(@requestBody2() name: string, @requestBody2() foo: number) {}
}
expect(() => getControllerSpec(MyController)).to.throwError(
/An operation should only have one parameter decorated by @requestBody/,
Expand Down
Loading