Skip to content

Commit

Permalink
feat: interpret additionalItems (#257)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonaslagoni authored Jun 18, 2021
1 parent f98e456 commit 21c8ca4
Show file tree
Hide file tree
Showing 11 changed files with 246 additions and 11 deletions.
2 changes: 1 addition & 1 deletion docs/interpretation_of_JSON_Schema_draft_7.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The order of interpretation:
- `required` are interpreted as is.
- `patternProperties` are interpreted as is, where duplicate patterns for the model are [merged](#Merging-models).
- `additionalProperties` are interpreted as is, where duplicate additionalProperties for the model are [merged](#Merging-models). If the schema does not define `additionalProperties` it defaults to `true` schema.
- `additionalItems` are interpreted as is, where duplicate additionalItems for the model are [merged](#Merging-models). If the schema does not define `additionalItems` it defaults to `true` schema.
- `items` are interpreted as ether tuples or simple array, where more than 1 item are [merged](#Merging-models). Usage of `items` infers `array` model type.
- `properties` are interpreted as is, where duplicate `properties` for the model are [merged](#Merging-models). Usage of `properties` infers `object` model type.
- [allOf](#allOf-sub-schemas)
Expand Down Expand Up @@ -47,7 +48,6 @@ The following JSON Schema keywords are [merged](#Merging-models) with the alread
- `then`
- `else`


## Merging models
Because of the recursive nature of the interpreter (and the nested nature of JSON Schema) it happens that two models needs to be merged together.

Expand Down
19 changes: 19 additions & 0 deletions src/interpreter/InterpretAdditionalItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { CommonModel } from 'models';
import { Schema } from 'models/Schema';
import { Interpreter, InterpreterOptions } from './Interpreter';

/**
* Interpreter function for JSON Schema draft 7 additionalProperties keyword.
*
* @param schema
* @param model
* @param interpreter
* @param interpreterOptions to control the interpret process
*/
export default function interpretAdditionalItems(schema: Schema, model: CommonModel, interpreter : Interpreter, interpreterOptions: InterpreterOptions = Interpreter.defaultInterpreterOptions): void {
if (model.type?.includes('array') === false) {return;}
const additionalItemsModel = interpreter.interpret(schema.additionalItems === undefined ? true : schema.additionalItems, interpreterOptions);
if (additionalItemsModel !== undefined) {
model.addAdditionalItems(additionalItemsModel, schema);
}
}
2 changes: 1 addition & 1 deletion src/interpreter/InterpretAdditionalProperties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { isModelObject } from './Utils';
*/
export default function interpretAdditionalProperties(schema: Schema, model: CommonModel, interpreter : Interpreter, interpreterOptions: InterpreterOptions = Interpreter.defaultInterpreterOptions): void {
if (isModelObject(model) === false) {return;}
const additionalPropertiesModel = interpreter.interpret(schema.additionalProperties || true, interpreterOptions);
const additionalPropertiesModel = interpreter.interpret(schema.additionalProperties === undefined ? true : schema.additionalProperties, interpreterOptions);
if (additionalPropertiesModel !== undefined) {
model.addAdditionalProperty(additionalPropertiesModel, schema);
}
Expand Down
2 changes: 2 additions & 0 deletions src/interpreter/Interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import interpretItems from './InterpretItems';
import interpretPatternProperties from './InterpretPatternProperties';
import interpretNot from './InterpretNot';
import interpretDependencies from './InterpretDependencies';
import interpretAdditionalItems from './InterpretAdditionalItems';

export type InterpreterOptions = {
allowInheritance?: boolean
Expand Down Expand Up @@ -63,6 +64,7 @@ export class Interpreter {

interpretPatternProperties(schema, model, this, interpreterOptions);
interpretAdditionalProperties(schema, model, this, interpreterOptions);
interpretAdditionalItems(schema, model, this, interpreterOptions);
interpretItems(schema, model, this, interpreterOptions);
interpretProperties(schema, model, this, interpreterOptions);
interpretAllOf(schema, model, this, interpreterOptions);
Expand Down
48 changes: 43 additions & 5 deletions src/models/CommonModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export class CommonModel extends CommonSchema<CommonModel> {

/**
* Adds additionalProperty to the model.
* If another model already are added the two are merged.
* If another model already exist the two are merged.
*
* @param additionalPropertiesModel
* @param schema
Expand All @@ -219,6 +219,22 @@ export class CommonModel extends CommonSchema<CommonModel> {
this.additionalProperties = additionalPropertiesModel;
}
}

/**
* Adds additionalItems to the model.
* If another model already exist the two are merged.
*
* @param additionalItemsModel
* @param schema
*/
addAdditionalItems(additionalItemsModel: CommonModel, schema: Schema): void {
if (this.additionalItems !== undefined) {
Logger.warn('While trying to add additionalItems to model, but it is already present, merging models together', additionalItemsModel, schema, this);
this.additionalItems = CommonModel.mergeCommonModels(this.additionalItems, additionalItemsModel, schema);
} else {
this.additionalItems = additionalItemsModel;
}
}

/**
* Adds a patternProperty to the model.
Expand Down Expand Up @@ -264,9 +280,7 @@ export class CommonModel extends CommonSchema<CommonModel> {
// eslint-disable-next-line sonarjs/cognitive-complexity
getNearestDependencies(): string[] {
const dependsOn = [];
if (this.additionalProperties !== undefined &&
this.additionalProperties instanceof CommonModel &&
this.additionalProperties.$ref !== undefined) {
if (this.additionalProperties?.$ref !== undefined) {
dependsOn.push(this.additionalProperties.$ref);
}
if (this.extend !== undefined) {
Expand All @@ -293,6 +307,9 @@ export class CommonModel extends CommonSchema<CommonModel> {
.map((patternPropertyModel: CommonModel) => String(patternPropertyModel.$ref));
dependsOn.push(...referencedPatternProperties);
}
if (this.additionalItems?.$ref !== undefined) {
dependsOn.push(this.additionalItems.$ref);
}
return dependsOn;
}

Expand Down Expand Up @@ -338,7 +355,7 @@ export class CommonModel extends CommonSchema<CommonModel> {
}
}
/**
* Merge two common model additional properties together
* Merge two common model additionalProperties together
*
* @param mergeTo
* @param mergeFrom
Expand All @@ -357,6 +374,26 @@ export class CommonModel extends CommonSchema<CommonModel> {
}
}
}
/**
* Merge two common model additionalItems together
*
* @param mergeTo
* @param mergeFrom
* @param originalSchema
* @param alreadyIteratedModels
*/
private static mergeAdditionalItems(mergeTo: CommonModel, mergeFrom: CommonModel, originalSchema: Schema, alreadyIteratedModels: Map<CommonModel, CommonModel> = new Map()) {
const mergeToAdditionalItems = mergeTo.additionalItems;
const mergeFromAdditionalItems= mergeFrom.additionalItems;
if (mergeFromAdditionalItems !== undefined) {
if (mergeToAdditionalItems === undefined) {
mergeTo.additionalItems = mergeFromAdditionalItems;
} else {
Logger.warn(`Found duplicate additionalItems for model. additionalItems from ${mergeFrom.$id || 'unknown'} merged into ${mergeTo.$id || 'unknown'}`, mergeTo, mergeFrom, originalSchema);
mergeTo.additionalItems = CommonModel.mergeCommonModels(mergeToAdditionalItems, mergeFromAdditionalItems, originalSchema, alreadyIteratedModels);
}
}
}
/**
* Merge two common model pattern properties together
*
Expand Down Expand Up @@ -466,6 +503,7 @@ export class CommonModel extends CommonSchema<CommonModel> {
alreadyIteratedModels.set(mergeFrom, mergeTo);

CommonModel.mergeAdditionalProperties(mergeTo, mergeFrom, originalSchema, alreadyIteratedModels);
CommonModel.mergeAdditionalItems(mergeTo, mergeFrom, originalSchema, alreadyIteratedModels);
CommonModel.mergePatternProperties(mergeTo, mergeFrom, originalSchema, alreadyIteratedModels);
CommonModel.mergeProperties(mergeTo, mergeFrom, originalSchema, alreadyIteratedModels);
CommonModel.mergeItems(mergeTo, mergeFrom, originalSchema, alreadyIteratedModels);
Expand Down
5 changes: 5 additions & 0 deletions src/models/CommonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export class CommonSchema<T> {
patternProperties?: { [key: string]: T; };
$ref?: string;
required?: string[];
additionalItems?: T;

/**
* Function to transform nested schemas into type of generic extended class
Expand Down Expand Up @@ -40,6 +41,10 @@ export class CommonSchema<T> {
schema.additionalProperties !== undefined) {
schema.additionalProperties = transformationSchemaCallback(schema.additionalProperties, seenSchemas);
}
if (typeof schema.additionalItems === 'object' &&
schema.additionalItems !== undefined) {
schema.additionalItems = transformationSchemaCallback(schema.additionalItems, seenSchemas);
}
if (typeof schema.patternProperties === 'object' &&
schema.patternProperties !== undefined) {
const patternProperties : {[key: string]: T | boolean} = {};
Expand Down
1 change: 0 additions & 1 deletion src/models/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export class Schema extends CommonSchema<Schema | boolean> {
oneOf?: (Schema | boolean)[];
anyOf?: (Schema | boolean)[];
not?: (Schema | boolean);
additionalItems?: boolean | Schema;
contains?: (Schema | boolean);
const?: any;
dependencies?: { [key: string]: Schema | boolean | string[]; };
Expand Down
8 changes: 8 additions & 0 deletions test/interpreter/unit/Intepreter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import interpretEnum from '../../../src/interpreter/InterpretEnum';
import interpretAllOf from '../../../src/interpreter/InterpretAllOf';
import interpretItems from '../../../src/interpreter/InterpretItems';
import interpretAdditionalProperties from '../../../src/interpreter/InterpretAdditionalProperties';
import interpretAdditionalItems from '../../../src/interpreter/InterpretAdditionalItems';
import interpretNot from '../../../src/interpreter/InterpretNot';
import interpretDependencies from '../../../src/interpreter/InterpretDependencies';
import { CommonModel, Schema } from '../../../src/models';
Expand All @@ -19,6 +20,7 @@ jest.mock('../../../src/interpreter/InterpretItems');
jest.mock('../../../src/interpreter/InterpretAdditionalProperties');
jest.mock('../../../src/interpreter/InterpretNot');
jest.mock('../../../src/interpreter/InterpretDependencies');
jest.mock('../../../src/interpreter/InterpretAdditionalItems');
CommonModel.mergeCommonModels = jest.fn();
/**
* Some of these test are purely theoretical and have little if any merit
Expand Down Expand Up @@ -175,6 +177,12 @@ describe('Interpreter', () => {
interpreter.interpret(schema);
expect(interpretDependencies).toHaveBeenNthCalledWith(1, schema, expect.anything(), expect.anything(), Interpreter.defaultInterpreterOptions);
});
test('should always try to interpret additionalItems', () => {
const schema = {};
const interpreter = new Interpreter();
interpreter.interpret(schema);
expect(interpretAdditionalItems).toHaveBeenNthCalledWith(1, schema, expect.anything(), expect.anything(), Interpreter.defaultInterpreterOptions);
});
test('should support primitive roots', () => {
const schema = { type: 'string' };
const interpreter = new Interpreter();
Expand Down
76 changes: 76 additions & 0 deletions test/interpreter/unit/InterpretAdditionalItems.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/* eslint-disable no-undef */
import { CommonModel } from '../../../src/models/CommonModel';
import { Interpreter } from '../../../src/interpreter/Interpreter';
import interpretAdditionalItems from '../../../src/interpreter/InterpretAdditionalItems';
jest.mock('../../../src/interpreter/Interpreter');
jest.mock('../../../src/models/CommonModel');
describe('Interpretation of additionalItems', () => {
beforeEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
jest.restoreAllMocks();
});
test('should try and interpret additionalItems schema', () => {
const schema: any = { additionalItems: { type: 'string' } };
const model = new CommonModel();
model.type = 'array';
const interpreter = new Interpreter();
const mockedReturnModel = new CommonModel();
(interpreter.interpret as jest.Mock).mockReturnValue(mockedReturnModel);

interpretAdditionalItems(schema, model, interpreter);

expect(interpreter.interpret).toHaveBeenNthCalledWith(1, { type: 'string' }, Interpreter.defaultInterpreterOptions);
expect(model.addAdditionalItems).toHaveBeenNthCalledWith(1, mockedReturnModel, schema);
});
test('should ignore model if interpreter cannot interpret additionalItems schema', () => {
const schema: any = { };
const model = new CommonModel();
model.type = 'array';
const interpreter = new Interpreter();
(interpreter.interpret as jest.Mock).mockReturnValue(undefined);

interpretAdditionalItems(schema, model, interpreter);

expect(model.addAdditionalItems).not.toHaveBeenCalled();
});
test('should be able to define additionalItems as false', () => {
const schema: any = { additionalItems: false };
const model = new CommonModel();
model.type = 'array';
const interpreter = new Interpreter();
(interpreter.interpret as jest.Mock).mockReturnValue(undefined);

interpretAdditionalItems(schema, model, interpreter);

expect(interpreter.interpret).toHaveBeenNthCalledWith(1, false, Interpreter.defaultInterpreterOptions);
expect(model.addAdditionalItems).not.toHaveBeenCalled();
});
test('should only work if model is array type', () => {
const schema: any = { };
const model = new CommonModel();
model.type = 'string';
const interpreter = new Interpreter();
const mockedReturnModel = new CommonModel();
(interpreter.interpret as jest.Mock).mockReturnValue(mockedReturnModel);

interpretAdditionalItems(schema, model, interpreter);

expect(interpreter.interpret).not.toHaveBeenCalled();
expect(model.addAdditionalItems).not.toHaveBeenCalled();
});
test('should default to true', () => {
const schema: any = { };
const model = new CommonModel();
model.type = 'array';
const interpreter = new Interpreter();
const mockedReturnModel = new CommonModel();
(interpreter.interpret as jest.Mock).mockReturnValue(mockedReturnModel);

interpretAdditionalItems(schema, model, interpreter);

expect(interpreter.interpret).toHaveBeenNthCalledWith(1, true, Interpreter.defaultInterpreterOptions);
expect(model.addAdditionalItems).toHaveBeenNthCalledWith(1, mockedReturnModel, schema);
});
});
12 changes: 12 additions & 0 deletions test/interpreter/unit/InterpretAdditionalProperties.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ describe('Interpretation of additionalProperties', () => {

expect(model.addAdditionalProperty).not.toHaveBeenCalled();
});
test('should be able to define additionalProperties as false', () => {
const schema: any = { additionalProperties: false };
const model = new CommonModel();
model.type = 'object';
const interpreter = new Interpreter();
(interpreter.interpret as jest.Mock).mockReturnValue(undefined);

interpretAdditionalProperties(schema, model, interpreter);

expect(interpreter.interpret).toHaveBeenNthCalledWith(1, false, Interpreter.defaultInterpreterOptions);
expect(model.addAdditionalProperty).not.toHaveBeenCalled();
});
test('should only work if model is object type', () => {
const schema: any = { };
const model = new CommonModel();
Expand Down
Loading

0 comments on commit 21c8ca4

Please sign in to comment.