diff --git a/docs/site/Controller-generator.md b/docs/site/Controller-generator.md index 7ab29ddf96cc..5d97d783f189 100644 --- a/docs/site/Controller-generator.md +++ b/docs/site/Controller-generator.md @@ -169,10 +169,17 @@ export class TodoController { }, }) async updateAll( - @requestBody() data: Todo, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Todo, {partial: true}), + }, + }, + }) + todo: Partial @param.query.object('where', getWhereSchemaFor(Todo)) where?: Where, ): Promise { - return await this.todoRepository.updateAll(data, where); + return await this.todoRepository.updateAll(todo, where); } @get('/todos/{id}', { @@ -196,9 +203,16 @@ export class TodoController { }) async updateById( @param.path.number('id') id: number, - @requestBody() data: Todo, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Todo, {partial: true}), + }, + }, + }) + todo: Partial, ): Promise { - await this.todoRepository.updateById(id, data); + await this.todoRepository.updateById(id, todo); } @del('/todos/{id}', { diff --git a/docs/site/tutorials/todo-list/todo-list-tutorial-controller.md b/docs/site/tutorials/todo-list/todo-list-tutorial-controller.md index 367e174982eb..321a69b006f2 100644 --- a/docs/site/tutorials/todo-list/todo-list-tutorial-controller.md +++ b/docs/site/tutorials/todo-list/todo-list-tutorial-controller.md @@ -256,7 +256,14 @@ export class TodoListTodoController { }) async patch( @param.path.number('id') id: number, - @requestBody() todo: Partial, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Todo, {partial: true}), + }, + }, + }) + todo: Partial @param.query.object('where', getWhereSchemaFor(Todo)) where?: Where, ): Promise { return await this.todoListRepo.todos(id).patch(todo, where); diff --git a/examples/express-composition/src/controllers/note.controller.ts b/examples/express-composition/src/controllers/note.controller.ts index 84fc7fa85726..6d9f7a98773c 100644 --- a/examples/express-composition/src/controllers/note.controller.ts +++ b/examples/express-composition/src/controllers/note.controller.ts @@ -14,6 +14,7 @@ import { del, get, getFilterSchemaFor, + getModelSchemaRef, getWhereSchemaFor, param, patch, @@ -84,7 +85,14 @@ export class NoteController { }, }) async updateAll( - @requestBody() note: Note, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Note, {partial: true}), + }, + }, + }) + note: Partial, @param.query.object('where', getWhereSchemaFor(Note)) where?: Where, ): Promise { return await this.noteRepository.updateAll(note, where); @@ -111,7 +119,14 @@ export class NoteController { }) async updateById( @param.path.number('id') id: number, - @requestBody() note: Note, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Note, {partial: true}), + }, + }, + }) + note: Partial, ): Promise { await this.noteRepository.updateById(id, note); } diff --git a/examples/todo-list/src/controllers/todo-list-todo.controller.ts b/examples/todo-list/src/controllers/todo-list-todo.controller.ts index 0cdae05ccae7..b2e25cdb64b8 100644 --- a/examples/todo-list/src/controllers/todo-list-todo.controller.ts +++ b/examples/todo-list/src/controllers/todo-list-todo.controller.ts @@ -13,6 +13,7 @@ import { import { del, get, + getModelSchemaRef, getWhereSchemaFor, param, patch, @@ -71,7 +72,14 @@ export class TodoListTodoController { }) async patch( @param.path.number('id') id: number, - @requestBody() todo: Partial, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Todo, {partial: true}), + }, + }, + }) + todo: Partial, @param.query.object('where', getWhereSchemaFor(Todo)) where?: Where, ): Promise { return await this.todoListRepo.todos(id).patch(todo, where); diff --git a/examples/todo-list/src/controllers/todo-list.controller.ts b/examples/todo-list/src/controllers/todo-list.controller.ts index 443a20ac553c..ebac19f52ce9 100644 --- a/examples/todo-list/src/controllers/todo-list.controller.ts +++ b/examples/todo-list/src/controllers/todo-list.controller.ts @@ -88,11 +88,18 @@ export class TodoListController { }, }) async updateAll( - @requestBody() obj: Partial, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(TodoList, {partial: true}), + }, + }, + }) + todoList: Partial, @param.query.object('where', getWhereSchemaFor(TodoList)) where?: Where, ): Promise { - return await this.todoListRepository.updateAll(obj, where); + return await this.todoListRepository.updateAll(todoList, where); } @get('/todo-lists/{id}', { @@ -124,9 +131,16 @@ export class TodoListController { }) async updateById( @param.path.number('id') id: number, - @requestBody() obj: TodoList, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(TodoList, {partial: true}), + }, + }, + }) + todoList: Partial, ): Promise { - await this.todoListRepository.updateById(id, obj); + await this.todoListRepository.updateById(id, todoList); } @del('/todo-lists/{id}', { diff --git a/examples/todo-list/src/controllers/todo.controller.ts b/examples/todo-list/src/controllers/todo.controller.ts index 1fc5934d2bf2..67d953a64158 100644 --- a/examples/todo-list/src/controllers/todo.controller.ts +++ b/examples/todo-list/src/controllers/todo.controller.ts @@ -98,7 +98,14 @@ export class TodoController { }) async updateTodo( @param.path.number('id') id: number, - @requestBody() todo: Todo, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Todo, {partial: true}), + }, + }, + }) + todo: Partial, ): Promise { await this.todoRepo.updateById(id, todo); } diff --git a/examples/todo/src/__tests__/acceptance/todo.acceptance.ts b/examples/todo/src/__tests__/acceptance/todo.acceptance.ts index e072ce724731..81db0a28c8bb 100644 --- a/examples/todo/src/__tests__/acceptance/todo.acceptance.ts +++ b/examples/todo/src/__tests__/acceptance/todo.acceptance.ts @@ -126,7 +126,6 @@ describe('TodoApplication', () => { it('updates the todo by ID ', async () => { const updatedTodo = givenTodo({ - title: 'DO SOMETHING AWESOME', isComplete: true, }); await client diff --git a/examples/todo/src/controllers/todo.controller.ts b/examples/todo/src/controllers/todo.controller.ts index 2ae2b6ae5a50..c5697e8c7803 100644 --- a/examples/todo/src/controllers/todo.controller.ts +++ b/examples/todo/src/controllers/todo.controller.ts @@ -9,6 +9,7 @@ import { del, get, getFilterSchemaFor, + getModelSchemaRef, param, patch, post, @@ -103,7 +104,14 @@ export class TodoController { }) async updateTodo( @param.path.number('id') id: number, - @requestBody() todo: Todo, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(Todo, {partial: true}), + }, + }, + }) + todo: Partial, ): Promise { await this.todoRepo.updateById(id, todo); } diff --git a/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.ejs b/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.ejs index cf49640903bd..686dd4082ac8 100644 --- a/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.ejs +++ b/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts.ejs @@ -10,6 +10,7 @@ import { param, get, getFilterSchemaFor, + getModelSchemaRef, getWhereSchemaFor, patch, put, @@ -78,7 +79,14 @@ export class <%= className %>Controller { }, }) async updateAll( - @requestBody() <%= modelVariableName %>: <%= modelName %>, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(<%= modelName %>, {partial: true}), + }, + }, + }) + <%= modelVariableName %>: <%= modelName %>, @param.query.object('where', getWhereSchemaFor(<%= modelName %>)) where?: Where<<%= modelName %>>, ): Promise { return await this.<%= repositoryNameCamel %>.updateAll(<%= modelVariableName %>, where); @@ -105,7 +113,14 @@ export class <%= className %>Controller { }) async updateById( @param.path.<%= idType %>('id') id: <%= idType %>, - @requestBody() <%= modelVariableName %>: <%= modelName %>, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(<%= modelName %>, {partial: true}), + }, + }, + }) + <%= modelVariableName %>: <%= modelName %>, ): Promise { await this.<%= repositoryNameCamel %>.updateById(id, <%= modelVariableName %>); } diff --git a/packages/cli/generators/relation/templates/controller-relation-template-has-many.ts.ejs b/packages/cli/generators/relation/templates/controller-relation-template-has-many.ts.ejs index 8aaf5f732b3f..0270d7e8584b 100644 --- a/packages/cli/generators/relation/templates/controller-relation-template-has-many.ts.ejs +++ b/packages/cli/generators/relation/templates/controller-relation-template-has-many.ts.ejs @@ -8,6 +8,7 @@ import { import { del, get, + getModelSchemaRef, getWhereSchemaFor, param, patch, @@ -69,7 +70,14 @@ export class <%= controllerClassName %> { }) async patch( @param.path.<%= sourceModelPrimaryKeyType %>('id') id: <%= sourceModelPrimaryKeyType %>, - @requestBody() <%= targetModelRequestBody %>: Partial<<%= targetModelClassName %>>, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(<%= targetModelClassName %>, {partial: true}), + }, + }, + }) + <%= targetModelRequestBody %>: Partial<<%= targetModelClassName %>>, @param.query.object('where', getWhereSchemaFor(<%= targetModelClassName %>)) where?: Where<<%= targetModelClassName %>>, ): Promise { return await this.<%= paramSourceRepository %>.<%= relationPropertyName %>(id).patch(<%= targetModelRequestBody %>, where); diff --git a/packages/cli/test/integration/generators/controller.integration.js b/packages/cli/test/integration/generators/controller.integration.js index 0803c67d009b..f3160979a182 100644 --- a/packages/cli/test/integration/generators/controller.integration.js +++ b/packages/cli/test/integration/generators/controller.integration.js @@ -287,7 +287,7 @@ function checkRestCrudContents() { /'200': {/, /description: 'ProductReview PATCH success count'/, /content: {'application\/json': {schema: CountSchema}},\s{1,}},\s{1,}},\s{1,}}\)/, - /async updateAll\(\s{1,}\@requestBody\(\) productReview: ProductReview,\s{1,} @param\.query\.object\('where', getWhereSchemaFor\(ProductReview\)\) where\?: Where(|,\s+)\)/, + /async updateAll\(\s+\@requestBody\({\s+content: {\s+'application\/json': {\s+schema: getModelSchemaRef\(ProductReview, {partial: true}\),\s+},\s+},\s+}\)\s+productReview: ProductReview,\s{1,} @param\.query\.object\('where', getWhereSchemaFor\(ProductReview\)\) where\?: Where(|,\s+)\)/, ]; patchUpdateAllRegEx.forEach(regex => { assert.fileContent(expectedFile, regex); @@ -312,7 +312,7 @@ function checkRestCrudContents() { /responses: {/, /'204': {/, /description: 'ProductReview PATCH success'/, - /async updateById\(\s{1,}\@param.path.number\('id'\) id: number,\s{1,}\@requestBody\(\) productReview: ProductReview,\s+\)/, + /async updateById\(\s+\@param.path.number\('id'\) id: number,\s+\@requestBody\({\s+content: {\s+'application\/json': {\s+schema: getModelSchemaRef\(ProductReview, {partial: true}\),\s+},\s+},\s+}\)\s+productReview: ProductReview,\s+\)/, ]; patchUpdateByIdRegEx.forEach(regex => { assert.fileContent(expectedFile, regex); diff --git a/packages/cli/test/integration/generators/hasmany.relation.integration.js b/packages/cli/test/integration/generators/hasmany.relation.integration.js index 2c6e596c9896..59468136845c 100644 --- a/packages/cli/test/integration/generators/hasmany.relation.integration.js +++ b/packages/cli/test/integration/generators/hasmany.relation.integration.js @@ -494,7 +494,8 @@ context('check if the controller file created ', () => { /description: 'Customer.Order PATCH success count',\n/, /content: { 'application\/json': { schema: CountSchema } },\n/, /},\n {4}},\n {2}}\)\n {2}async patch\(\n/, - /\@param\.path\.number\('id'\) id: number,\n {4}\@requestBody\(\) order: Partial,\n/, + /\@param\.path\.number\('id'\) id: number,\n {4}/, + /\@requestBody\({\s+content: {\s+'application\/json': {\s+schema: getModelSchemaRef\(Order, {partial: true}\),\s+},\s+},\s+}\)\s+order: Partial,\n/, /\@param\.query\.object\('where', getWhereSchemaFor\(Order\)\) where\?: Where,\n/, /\): Promise {\n/, /return await this\.customerRepository\.orders\(id\).patch\(order, where\);\n {2}}\n/, @@ -505,7 +506,8 @@ context('check if the controller file created ', () => { /description: 'CustomerClass.OrderClass PATCH success count',\n/, /content: { 'application\/json': { schema: CountSchema } },\n/, /},\n {4}},\n {2}}\)\n {2}async patch\(\n/, - /\@param\.path\.number\('id'\) id: number,\n {4}\@requestBody\(\) orderClass: Partial,\n/, + /\@param\.path\.number\('id'\) id: number,\n {4}/, + /\@requestBody\({\s+content: {\s+'application\/json': {\s+schema: getModelSchemaRef\(OrderClass, {partial: true}\),\s+},\s+},\s+}\)\s+orderClass: Partial,\n/, /\@param\.query\.object\('where', getWhereSchemaFor\(OrderClass\)\) where\?: Where,\n/, /\): Promise {\n/, /return await this\.customerClassRepository\.orderClasses\(id\)\.patch\(orderClass, where\);\n {2}}\n/, @@ -516,7 +518,8 @@ context('check if the controller file created ', () => { /description: 'CustomerClassType.OrderClassType PATCH success count',\n/, /content: { 'application\/json': { schema: CountSchema } },\n/, /},\n {4}},\n {2}}\)\n {2}async patch\(\n/, - /\@param\.path\.number\('id'\) id: number,\n {4}\@requestBody\(\) orderClassType: Partial,\n/, + /\@param\.path\.number\('id'\) id: number,\n {4}/, + /\@requestBody\({\s+content: {\s+'application\/json': {\s+schema: getModelSchemaRef\(OrderClassType, {partial: true}\),\s+},\s+},\s+}\)\s+orderClassType: Partial,\n/, /\@param\.query\.object\('where', getWhereSchemaFor\(OrderClassType\)\) where\?: Where,\n/, /\): Promise {\n/, /return await this\.customerClassTypeRepository\.orderClassTypes\(id\).patch\(orderClassType, where\);\n {2}}\n/, diff --git a/packages/repository-json-schema/src/__tests__/integration/build-schema.integration.ts b/packages/repository-json-schema/src/__tests__/integration/build-schema.integration.ts index bd87242e0620..67ed2a27c95c 100644 --- a/packages/repository-json-schema/src/__tests__/integration/build-schema.integration.ts +++ b/packages/repository-json-schema/src/__tests__/integration/build-schema.integration.ts @@ -910,5 +910,27 @@ describe('build-schema', () => { title: 'Category', }); }); + + it('emits all properties as optional when the option "partial" is set', () => { + @model() + class Product extends Entity { + @property({id: true, required: true}) + id: number; + + @property({required: true}) + name: string; + + @property() + optionalDescription: string; + } + + const originalSchema = getJsonSchema(Product); + expect(originalSchema.required).to.deepEqual(['id', 'name']); + expect(originalSchema.title).to.equal('Product'); + + const partialSchema = getJsonSchema(Product, {partial: true}); + expect(partialSchema.required).to.equal(undefined); + expect(partialSchema.title).to.equal('ProductPartial'); + }); }); }); diff --git a/packages/repository-json-schema/src/__tests__/unit/build-schema.unit.ts b/packages/repository-json-schema/src/__tests__/unit/build-schema.unit.ts index af20e0808acd..aa60cf575452 100644 --- a/packages/repository-json-schema/src/__tests__/unit/build-schema.unit.ts +++ b/packages/repository-json-schema/src/__tests__/unit/build-schema.unit.ts @@ -10,6 +10,7 @@ import { } from '@loopback/repository'; import {expect} from '@loopback/testlab'; import { + buildModelCacheKey, getNavigationalPropertyForRelation, metaToJsonProperty, stringTypeToWrapper, @@ -214,4 +215,30 @@ describe('build-schema', () => { ).to.throw(/targetsMany attribute missing for Test/); }); }); + + describe('buildModelCacheKey', () => { + it('returns "modelOnly" when no options were provided', () => { + const key = buildModelCacheKey(); + expect(key).to.equal('modelOnly'); + }); + + it('returns "modelWithRelations" when a single option "includeRelations" is set', () => { + const key = buildModelCacheKey({includeRelations: true}); + expect(key).to.equal('modelWithRelations'); + }); + + it('returns "partial" when a single option "partial" is set', () => { + const key = buildModelCacheKey({partial: true}); + expect(key).to.equal('partial'); + }); + + it('returns concatenated option names otherwise', () => { + const key = buildModelCacheKey({ + // important: object keys are defined in reverse order + partial: true, + includeRelations: true, + }); + expect(key).to.equal('includeRelations+partial'); + }); + }); }); diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index d72fe9aae3cc..10b08d166a22 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -16,10 +16,44 @@ import {JSONSchema6 as JSONSchema} from 'json-schema'; import {JSON_SCHEMA_KEY, MODEL_TYPE_KEYS} from './keys'; export interface JsonSchemaOptions { + /** + * Set this flag if you want the schema to define navigational properties + * for model relations. + */ includeRelations?: boolean; + + /** + * Set this flag to mark all model properties as optional. This is typically + * used to describe request body of PATCH endpoints. + */ + partial?: boolean; + + /** + * @private + */ visited?: {[key: string]: JSONSchema}; } +/** + * @private + */ +export function buildModelCacheKey(options: JsonSchemaOptions = {}): string { + const flags = Object.keys(options); + + // Backwards compatibility + // Preserve cache keys "modelOnly" and "modelWithRelations" + if (flags.length === 0) { + return MODEL_TYPE_KEYS.ModelOnly; + } else if (flags.length === 1 && options.includeRelations) { + return MODEL_TYPE_KEYS.ModelWithRelations; + } + + // New key schema: concatenate names of options (flags) that are set. + // For example: "includeRelations+partial" + flags.sort(); + return flags.join('+'); +} + /** * 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. @@ -32,10 +66,7 @@ export function getJsonSchema( // In the near future the metadata will be an object with // different titles as keys const cached = MetadataInspector.getClassMetadata(JSON_SCHEMA_KEY, ctor); - const key = - options && options.includeRelations - ? MODEL_TYPE_KEYS.ModelWithRelations - : MODEL_TYPE_KEYS.ModelOnly; + const key = buildModelCacheKey(options); let schema = cached && cached[key]; if (!schema) { @@ -252,6 +283,9 @@ export function modelToJsonSchema( } let title = meta.title || ctor.name; + if (options.partial) { + title += 'Partial'; + } if (options.includeRelations) { title += 'WithRelations'; } @@ -279,7 +313,7 @@ export function modelToJsonSchema( result.properties[p] = metaToJsonProperty(metaProperty); // handling 'required' metadata - if (metaProperty.required) { + if (metaProperty.required && !options.partial) { result.required = result.required || []; result.required.push(p); } diff --git a/packages/repository-json-schema/src/keys.ts b/packages/repository-json-schema/src/keys.ts index dd1df19add29..90ba45fd96e1 100644 --- a/packages/repository-json-schema/src/keys.ts +++ b/packages/repository-json-schema/src/keys.ts @@ -6,6 +6,11 @@ import {MetadataAccessor} from '@loopback/metadata'; import {JSONSchema6 as JSONSchema} from 'json-schema'; +/** + * TODO(semver-major) remove these constants in the next major version + * @deprecated Use the helper `buildModelCacheKey` to obtain the cache key + * for a given set of schema options. + */ export const enum MODEL_TYPE_KEYS { ModelOnly = 'modelOnly', ModelWithRelations = 'modelWithRelations', @@ -15,6 +20,6 @@ export const enum MODEL_TYPE_KEYS { * Metadata key used to set or retrieve repository JSON Schema */ export const JSON_SCHEMA_KEY = MetadataAccessor.create< - {[options in MODEL_TYPE_KEYS]: JSONSchema}, + {[key: string]: JSONSchema}, ClassDecorator >('loopback:json-schema');