Skip to content

Commit

Permalink
feat(repository-json-schema): add an option to make properties optional
Browse files Browse the repository at this point in the history
Add a new option `optional: []` so that `getJsonSchema` and related helpers can request a model schema that marks specified properties as optional
  • Loading branch information
nabdelgadir committed Jul 15, 2019
1 parent 7f7feaa commit 946de02
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 6 deletions.
19 changes: 19 additions & 0 deletions packages/repository-json-schema/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/repository-json-schema/package.json
Expand Up @@ -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"
},
Expand Down
Expand Up @@ -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'},
Expand All @@ -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');
});
});
});
});
Expand Up @@ -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',
);
});
});
});
24 changes: 22 additions & 2 deletions packages/repository-json-schema/src/build-schema.ts
Expand Up @@ -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<T extends object> {
/**
Expand All @@ -33,6 +35,11 @@ export interface JsonSchemaOptions<T extends object> {
*/
exclude?: (keyof T)[];

/**
* List of model properties to mark as optional.
*/
optional?: (keyof T)[];

/**
* @internal
*/
Expand Down Expand Up @@ -264,7 +271,9 @@ export function getNavigationalPropertyForRelation(

function getTitleSuffix<T extends object>(options: JsonSchemaOptions<T> = {}) {
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) {
Expand All @@ -291,6 +300,15 @@ export function modelToJsonSchema<T extends object>(
): 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);

Expand Down Expand Up @@ -328,7 +346,9 @@ export function modelToJsonSchema<T extends object>(
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);
}
Expand Down

0 comments on commit 946de02

Please sign in to comment.