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 eeb506f39d91..3244d735f327 100644 --- a/examples/todo-list/src/controllers/todo-list-todo.controller.ts +++ b/examples/todo-list/src/controllers/todo-list-todo.controller.ts @@ -41,7 +41,10 @@ export class TodoListTodoController { @requestBody({ content: { 'application/json': { - schema: getModelSchemaRef(Todo, {exclude: ['id']}), + schema: getModelSchemaRef(Todo, { + exclude: ['id'], + optional: ['todoListId'], + }), }, }, }) diff --git a/packages/repository-json-schema/package-lock.json b/packages/repository-json-schema/package-lock.json index 2042bc217ec0..da789f8a538c 100644 --- a/packages/repository-json-schema/package-lock.json +++ b/packages/repository-json-schema/package-lock.json @@ -4,6 +4,12 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@types/debug": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.4.tgz", + "integrity": "sha512-D9MyoQFI7iP5VdpEyPZyjjqIJ8Y8EDNQFIFVLOmeg1rI1xiHOChyUPMPRUVfqFCerxfE+yS3vMyj37F6IdtOoQ==", + "dev": true + }, "@types/json-schema": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.3.tgz", @@ -27,6 +33,14 @@ "uri-js": "^4.2.2" } }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, "fast-deep-equal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", @@ -45,6 +59,11 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", diff --git a/packages/repository-json-schema/package.json b/packages/repository-json-schema/package.json index 5a3995b2e251..43514b0d773c 100644 --- a/packages/repository-json-schema/package.json +++ b/packages/repository-json-schema/package.json @@ -26,12 +26,14 @@ "@loopback/context": "^1.20.2", "@loopback/metadata": "^1.2.5", "@loopback/repository": "^1.8.2", - "@types/json-schema": "^7.0.3" + "@types/json-schema": "^7.0.3", + "debug": "^4.1.1" }, "devDependencies": { "@loopback/build": "^2.0.3", "@loopback/eslint-config": "^2.0.0", "@loopback/testlab": "^1.6.3", + "@types/debug": "^4.1.4", "@types/node": "^10.14.12", "ajv": "^6.10.2" }, 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 0446ec09f891..9d47afa81167 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 @@ -983,7 +983,7 @@ describe('build-schema', () => { ); }); - it('doesn\'t exclude properties when the option "exclude" is set to exclude no properties', () => { + it(`doesn't exclude properties when the option "exclude" is set to exclude no properties`, () => { const originalSchema = getJsonSchema(Product); expect(originalSchema.properties).to.deepEqual({ id: {type: 'number'}, @@ -1001,5 +1001,79 @@ describe('build-schema', () => { expect(excludeNothingSchema.title).to.equal('Product'); }); }); + + context('optional properties when option "optional" is set', () => { + @model() + class Product extends Entity { + @property({id: true, required: true}) + id: number; + + @property({required: true}) + name: string; + + @property() + description: string; + } + + it('makes one property optional when the option "optional" includes one property', () => { + const originalSchema = getJsonSchema(Product); + expect(originalSchema.required).to.deepEqual(['id', 'name']); + expect(originalSchema.title).to.equal('Product'); + + const optionalIdSchema = getJsonSchema(Product, {optional: ['id']}); + expect(optionalIdSchema.required).to.deepEqual(['name']); + expect(optionalIdSchema.title).to.equal('ProductOptional[id]'); + }); + + it('makes multiple properties optional when the option "optional" includes multiple properties', () => { + const originalSchema = getJsonSchema(Product); + expect(originalSchema.required).to.deepEqual(['id', 'name']); + expect(originalSchema.title).to.equal('Product'); + + const optionalIdAndNameSchema = getJsonSchema(Product, { + optional: ['id', 'name'], + }); + expect(optionalIdAndNameSchema.required).to.equal(undefined); + expect(optionalIdAndNameSchema.title).to.equal( + 'ProductOptional[id,name]', + ); + }); + + it(`doesn't make properties optional when the option "optional" includes no properties`, () => { + const originalSchema = getJsonSchema(Product); + expect(originalSchema.required).to.deepEqual(['id', 'name']); + expect(originalSchema.title).to.equal('Product'); + + const optionalNothingSchema = getJsonSchema(Product, {optional: []}); + expect(optionalNothingSchema.required).to.deepEqual(['id', 'name']); + expect(optionalNothingSchema.title).to.equal('Product'); + }); + + it('overrides "partial" option when "optional" options is set', () => { + const originalSchema = getJsonSchema(Product); + expect(originalSchema.required).to.deepEqual(['id', 'name']); + expect(originalSchema.title).to.equal('Product'); + + const optionalNameSchema = getJsonSchema(Product, { + partial: true, + optional: ['name'], + }); + expect(optionalNameSchema.required).to.deepEqual(['id']); + expect(optionalNameSchema.title).to.equal('ProductOptional[name]'); + }); + + it('uses "partial" option, if provided, when "optional" options is set but empty', () => { + const originalSchema = getJsonSchema(Product); + expect(originalSchema.required).to.deepEqual(['id', 'name']); + expect(originalSchema.title).to.equal('Product'); + + const optionalNameSchema = getJsonSchema(Product, { + partial: true, + optional: [], + }); + expect(optionalNameSchema.required).to.equal(undefined); + expect(optionalNameSchema.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 3dc2e4d91ee5..900d8d9d52b2 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 @@ -246,14 +246,47 @@ describe('build-schema', () => { expect(key).to.equal('modelPartialWithRelations'); }); - it('returns concatenated option names otherwise', () => { + it('returns "optional[id,_rev]" when "optional" is set with two items', () => { + const key = buildModelCacheKey({optional: ['id', '_rev']}); + expect(key).to.equal('modelOptional[id,_rev]'); + }); + + it('does not include "optional" in concatenated option names if it is empty', () => { + const key = buildModelCacheKey({ + partial: true, + optional: [], + includeRelations: true, + }); + expect(key).to.equal('modelPartialWithRelations'); + }); + + it('does not include "partial" in option names if "optional" is not empty', () => { + const key = buildModelCacheKey({ + partial: true, + optional: ['name'], + }); + expect(key).to.equal('modelOptional[name]'); + }); + + it('includes "partial" in option names if "optional" is empty', () => { + const key = buildModelCacheKey({ + partial: true, + optional: [], + }); + expect(key).to.equal('modelPartial'); + }); + + it('returns concatenated option names except "partial" otherwise', () => { const key = buildModelCacheKey({ // important: object keys are defined in reverse order partial: true, exclude: ['id', '_rev'], + optional: ['name'], includeRelations: true, }); - expect(key).to.equal('modelPartialExcluding[id,_rev]WithRelations'); + expect(key).to.equal( + 'modelOptional[name]Excluding[id,_rev]WithRelations', + ); }); }); }); diff --git a/packages/repository-json-schema/src/build-schema.ts b/packages/repository-json-schema/src/build-schema.ts index 04189fbf4d36..423a17d6719a 100644 --- a/packages/repository-json-schema/src/build-schema.ts +++ b/packages/repository-json-schema/src/build-schema.ts @@ -12,8 +12,10 @@ import { RelationMetadata, resolveType, } from '@loopback/repository'; +import * as debugFactory from 'debug'; import {JSONSchema6 as JSONSchema} from 'json-schema'; import {JSON_SCHEMA_KEY, MODEL_TYPE_KEYS} from './keys'; +const debug = debugFactory('loopback:repository-json-schema:build-schema'); export interface JsonSchemaOptions { /** @@ -33,6 +35,11 @@ export interface JsonSchemaOptions { */ exclude?: (keyof T)[]; + /** + * List of model properties to mark as optional. + */ + optional?: (keyof T)[]; + /** * @internal */ @@ -264,7 +271,9 @@ export function getNavigationalPropertyForRelation( function getTitleSuffix(options: JsonSchemaOptions = {}) { let suffix = ''; - if (options.partial) { + if (options.optional && options.optional.length) { + suffix += 'Optional[' + options.optional + ']'; + } else if (options.partial) { suffix += 'Partial'; } if (options.exclude && options.exclude.length) { @@ -291,6 +300,15 @@ export function modelToJsonSchema( ): JSONSchema { const options = {...jsonSchemaOptions}; options.visited = options.visited || {}; + options.optional = options.optional || []; + const partial = options.partial && !options.optional.length; + + if (options.partial && !partial) { + debug('Overriding "partial" option with "optional" option'); + delete options.partial; + } + + debug('JSON schema options: %o', options); const meta: ModelDefinition | {} = ModelMetadataHelper.getModelMetadata(ctor); @@ -328,7 +346,9 @@ export function modelToJsonSchema( result.properties[p] = metaToJsonProperty(metaProperty); // handling 'required' metadata - if (metaProperty.required && !options.partial) { + const optional = options.optional.includes(p as keyof T); + + if (metaProperty.required && !(partial || optional)) { result.required = result.required || []; result.required.push(p); }