Repository: asyncapi/modelina (ts)

Issue:
Support additionalItems in CommonModel
#### Reason/Context
The CommonModel currently has no knowledge of `additionalItems` that the array/tuple must also support. This should change in the same way as `additionalProperties` are implemented. 


Pull request:
feat: interpret additionalItems
**Description**
This PR adds the interpretation of `additionalItems` to the interpreter.

**Related issue(s)**
fixes #224 

Patch:
diff --git a/docs/interpretation_of_JSON_Schema_draft_7.md b/docs/interpretation_of_JSON_Schema_draft_7.md
index bc4a2c5b..23bf7111 100644
--- a/docs/interpretation_of_JSON_Schema_draft_7.md
+++ b/docs/interpretation_of_JSON_Schema_draft_7.md
@@ -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)
@@ -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. 
 
diff --git a/src/interpreter/InterpretAdditionalItems.ts b/src/interpreter/InterpretAdditionalItems.ts
new file mode 100644
index 00000000..dfa259d4
--- /dev/null
+++ b/src/interpreter/InterpretAdditionalItems.ts
@@ -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);
+  }
+}
diff --git a/src/interpreter/InterpretAdditionalProperties.ts b/src/interpreter/InterpretAdditionalProperties.ts
index 6eefadc3..120dc274 100644
--- a/src/interpreter/InterpretAdditionalProperties.ts
+++ b/src/interpreter/InterpretAdditionalProperties.ts
@@ -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);
   }
diff --git a/src/interpreter/Interpreter.ts b/src/interpreter/Interpreter.ts
index 47ca24f5..a6990f79 100644
--- a/src/interpreter/Interpreter.ts
+++ b/src/interpreter/Interpreter.ts
@@ -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
@@ -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);
diff --git a/src/models/CommonModel.ts b/src/models/CommonModel.ts
index b6c01c8b..55523b81 100644
--- a/src/models/CommonModel.ts
+++ b/src/models/CommonModel.ts
@@ -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 
@@ -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.
@@ -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) {
@@ -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;
   }
 
@@ -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 
@@ -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 
    * 
@@ -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);
diff --git a/src/models/CommonSchema.ts b/src/models/CommonSchema.ts
index 3e7e356a..71329bc9 100644
--- a/src/models/CommonSchema.ts
+++ b/src/models/CommonSchema.ts
@@ -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
@@ -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} = {};
diff --git a/src/models/Schema.ts b/src/models/Schema.ts
index 2dcfea9a..a5236840 100644
--- a/src/models/Schema.ts
+++ b/src/models/Schema.ts
@@ -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[]; };


Test patch:
diff --git a/test/interpreter/unit/Intepreter.spec.ts b/test/interpreter/unit/Intepreter.spec.ts
index e09bee72..3fe9d6a5 100644
--- a/test/interpreter/unit/Intepreter.spec.ts
+++ b/test/interpreter/unit/Intepreter.spec.ts
@@ -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';
@@ -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 
@@ -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();
diff --git a/test/interpreter/unit/InterpretAdditionalItems.spec.ts b/test/interpreter/unit/InterpretAdditionalItems.spec.ts
new file mode 100644
index 00000000..66ce03ac
--- /dev/null
+++ b/test/interpreter/unit/InterpretAdditionalItems.spec.ts
@@ -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);
+  });
+});
diff --git a/test/interpreter/unit/InterpretAdditionalProperties.spec.ts b/test/interpreter/unit/InterpretAdditionalProperties.spec.ts
index 3abcc03c..5771694e 100644
--- a/test/interpreter/unit/InterpretAdditionalProperties.spec.ts
+++ b/test/interpreter/unit/InterpretAdditionalProperties.spec.ts
@@ -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();
diff --git a/test/models/CommonModel.spec.ts b/test/models/CommonModel.spec.ts
index 7fc2e023..46953b38 100644
--- a/test/models/CommonModel.spec.ts
+++ b/test/models/CommonModel.spec.ts
@@ -98,6 +98,30 @@ describe('CommonModel', () => {
     });
   });
 
+  describe('additionalItems', () => {
+    test('should return a Schema object', () => {
+      const doc: Schema = { additionalItems: { type: 'string' } };
+      const d = Schema.toSchema(doc) as Schema;
+      expect(typeof d).toEqual('object');
+      expect(d.additionalItems).not.toBeUndefined();
+      expect(d.additionalItems!.constructor.name).toEqual('Schema');
+      expect(d.additionalItems).toEqual(doc.additionalItems);
+    });
+    
+    test('should return undefined when not defined', () => {
+      const doc: Schema = {};
+      const d = Schema.toSchema(doc) as Schema;
+      expect(typeof d).toEqual('object');
+      expect(d.additionalItems).toEqual(undefined);
+    });
+    
+    test('should return undefined when undefined', () => {
+      const doc: Schema = { additionalItems: undefined };
+      const d = Schema.toSchema(doc) as Schema;
+      expect(typeof d).toEqual('object');
+      expect(d.additionalItems).toEqual(undefined);
+    });
+  });
   describe('$ref', () => {
     test('should return a string ', () => {
       const doc: any = { $ref: 'some/reference' };
@@ -480,7 +504,33 @@ describe('CommonModel', () => {
         let doc1 = CommonModel.toCommonModel(doc);
         const doc2 = CommonModel.toCommonModel(doc);
         doc1 = CommonModel.mergeCommonModels(doc1, doc2, doc);
-        expect(doc1.patternProperties).toBeUndefined();
+        expect(doc1.additionalProperties).toBeUndefined();
+      });
+    });
+    describe('additionalItems', () => {
+      test('should be merged when only right side is defined', () => {
+        const doc: Schema = { };
+        let doc1 = CommonModel.toCommonModel(doc);
+        const doc2 = CommonModel.toCommonModel(doc);
+        doc2.additionalItems = CommonModel.toCommonModel({type: 'string'});
+        doc1 = CommonModel.mergeCommonModels(doc1, doc2, doc);
+        expect(doc1.additionalItems).toEqual(doc2.additionalItems);
+      });
+      test('should be merged together when both sides are defined', () => {
+        const doc: Schema = { };
+        let doc1 = CommonModel.toCommonModel(doc);
+        const doc2 = CommonModel.toCommonModel(doc);
+        doc2.additionalItems = CommonModel.toCommonModel({type: 'string'});
+        doc1.additionalItems = CommonModel.toCommonModel({type: 'number'});
+        doc1 = CommonModel.mergeCommonModels(doc1, doc2, doc);
+        expect(doc1.additionalItems).toEqual({type: ['number', 'string'], originalSchema: {}});
+      });
+      test('should not change if nothing is defined', () => {
+        const doc: Schema = { };
+        let doc1 = CommonModel.toCommonModel(doc);
+        const doc2 = CommonModel.toCommonModel(doc);
+        doc1 = CommonModel.mergeCommonModels(doc1, doc2, doc);
+        expect(doc1.additionalItems).toBeUndefined();
       });
     });
 
@@ -683,6 +733,32 @@ describe('CommonModel', () => {
       expect(CommonModel.mergeCommonModels).toHaveBeenNthCalledWith(1, additionalPropertiesModel, additionalPropertiesModel, {});
     });
   });
+
+  describe('addAdditionalItems', () => {
+    beforeAll(() => {
+      jest.spyOn(CommonModel, 'mergeCommonModels');
+    });
+    afterEach(() => {
+      jest.clearAllMocks();
+    });
+    test('should add additionalItems to model', () => {
+      const additionalItemsModel = new CommonModel();
+      additionalItemsModel.$id = 'test'; 
+      const model = new CommonModel(); 
+      model.addAdditionalItems(additionalItemsModel, {});
+      expect(model.additionalItems).toEqual(additionalItemsModel);
+      expect(CommonModel.mergeCommonModels).not.toHaveBeenCalled();
+    });
+    test('should merge additionalItems together', () => {
+      const additionalItemsModel = new CommonModel();
+      additionalItemsModel.$id = 'test'; 
+      const model = new CommonModel(); 
+      model.addAdditionalItems(additionalItemsModel, {});
+      model.addAdditionalItems(additionalItemsModel, {});
+      expect(model.additionalItems).toEqual(additionalItemsModel);
+      expect(CommonModel.mergeCommonModels).toHaveBeenNthCalledWith(1, additionalItemsModel, additionalItemsModel, {});
+    });
+  });
   describe('addPatternProperty', () => {
     beforeAll(() => {
       jest.spyOn(CommonModel, 'mergeCommonModels');
@@ -819,9 +895,9 @@ describe('CommonModel', () => {
         expect(d.getNearestDependencies()).toEqual(['1']);
       });
       test('check that all dependencies are returned', () => {
-        const doc = { additionalProperties: { $ref: '1' }, extend: ['2'], items: { $ref: '3' }, properties: { testProp: { $ref: '4' } }, patternProperties: {testPattern: {$ref: '5'}} };
+        const doc = { additionalProperties: { $ref: '1' }, extend: ['2'], items: { $ref: '3' }, properties: { testProp: { $ref: '4' } }, patternProperties: {testPattern: {$ref: '5'}}, additionalItems: { $ref: '6' } };
         const d = CommonModel.toCommonModel(doc);
-        expect(d.getNearestDependencies()).toEqual(['1', '2', '3', '4', '5']);
+        expect(d.getNearestDependencies()).toEqual(['1', '2', '3', '4', '5', '6']);
       });
       test('check that no dependencies is returned if there are none', () => {
         const doc = { };