Repository: codekie/openapi-examples-validator
Pull request #110 (2020-11-19 16:59:40)

feat: implement JSON schema draft-04 as default
**Problem**

The example validator will mark this as invalid JSON:

```
{
  "title": "Discount response object (will be provided in case it is applied to a price)",
  "type": "object",
  "properties": {
    "code": {
      "type": "string",
      "example": "CODE_123"
    },
    "type": {
      "type": "string",
      "example": "Standard"
    },
    "percentage": {
      "type": "number",
      "example": 57.5
    },
    "amount": {
      "type": "number",
      "minimum": 0,
      "exclusiveMinimum": true,
      "example": 1
    }
  }
}
```

The `exclusiveMinimum` property indicates that the value of the `minimum` property can not be used, so an example with the value `0` should be marked as invalid.

However, the meta schema validation for this schema reports this error:

`... .items.properties['amount'].exclusiveMinimum should be number,`

After some searching, I found out that this specification has changed from draft-04 to draft-06 in the JSON schema specification, demonstrated by an example here: [avj.js](https://ajv.js.org/keywords.html#maximum--minimum-and-exclusivemaximum--exclusiveminimum)

OAS2 and 3, as supported by this tool use json schema draft-04 and draft-05 respectively. Draft-05 is a slightly polished version of draft-04. See  [draft-05 spec](https://json-schema.org/draft-05/README.html)

See also [OAS3](https://swagger.io/docs/specification/data-models/data-types/) for a reference guide from OAS(3) on how to use the `exlclusiveMinimum` and `exclusiveMaximum` properties.

**Solution**

In order to support the correct draft version of the JSON schema example validation, the default for the draft should be set to draft-04. This ensures both compatibility with OAS2 and OAS3, and will also allow for the (outdated) use of the `exlclusiveMinimum` and `exclusiveMaximum` properties with boolean values.

**Appendix**

I've written test coverage for this specific use-case to ensure compatibility with the draft-04 spec. The other tests remain intact and all succeed. Due to my limited knowledge of the entire project, I might have overlooked something that might break something that has not been covered by tests. Please verify if this is the case or not. :) 

coveralls: 
[![Coverage Status](https://coveralls.io/builds/35100361/badge)](https://coveralls.io/builds/35100361)

Coverage decreased (-0.5%) to 97.384% when pulling **d4213baf0f409b42deb254e5010666761556ec95 on DerkSchooltink:DerkSchooltink/implement-json-schema-draft-04-default** into **991c4c809cc8b182e01332da40fd23904e1a77a6 on codekie:master**.


Patch:
diff --git a/src/index.js b/src/index.js
index db354ea..d7e649c 100644
--- a/src/index.js
+++ b/src/index.js
@@ -179,7 +179,9 @@ async function validateExamplesByMap(filePathSchema, globMapExternalExamples,
     }
     return merge(
         responses.reduce((res, response) => {
-            if (!res) { return response; }
+            if (!res) {
+                return response;
+            }
             return _mergeValidationResponses(res, response);
         }, null),
         { statistics: { matchingFilePathsMapping } }
@@ -235,7 +237,7 @@ async function _parseSpec(filePath) {
             jsonSchema = yaml.parse(fs.readFileSync(filePath, 'utf-8'));
         } catch (e) {
             const { name, message } = e;
-            throw new ApplicationError(ErrorType.parseError, { message: `${ name }: ${ message }` });
+            throw new ApplicationError(ErrorType.parseError, { message: `${name}: ${message}` });
         }
     } else {
         jsonSchema = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
@@ -398,8 +400,10 @@ function _validateExamplesPaths({ impl }, pathsExamples, openapiSpec) {
     // Start validation
     const schemaPaths = Object.keys(validationMap);
     schemaPaths.forEach(pathSchema => {
-        _validateSchema({ openapiSpec, createValidator, pathSchema, validationMap, statistics,
-            validationResult });
+        _validateSchema({
+            openapiSpec, createValidator, pathSchema, validationMap, statistics,
+            validationResult
+        });
     });
     return validationResult;
 }
@@ -414,8 +418,10 @@ function _validateExamplesPaths({ impl }, pathsExamples, openapiSpec) {
  * @param {Object}                  validationResult    Container, for the validation-results
  * @private
  */
-function _validateSchema({ openapiSpec, createValidator, pathSchema, validationMap, statistics,
-    validationResult }) {
+function _validateSchema({
+    openapiSpec, createValidator, pathSchema, validationMap, statistics,
+    validationResult
+}) {
     const errors = validationResult.errors;
     validationMap[pathSchema].forEach(pathExample => {
         const example = _getObjectByPath(pathExample, openapiSpec),
@@ -430,7 +436,9 @@ function _validateSchema({ openapiSpec, createValidator, pathSchema, validationM
                 error.examplePath = jsonPath.toPointer(jsonPath.toPathArray(pathExample));
                 return error;
             });
-        if (!curErrors.length) { return; }
+        if (!curErrors.length) {
+            return;
+        }
         validationResult.valid = false;
         errors.splice(errors.length - 1, 0, ...curErrors);
     });
@@ -497,10 +505,14 @@ function _validateExample({ createValidator, schema, example, statistics, filePa
     }
     statistics[SYM__INTERNAL][PROP__SCHEMAS_WITH_EXAMPLES].add(schema);
     const validate = compileValidate(createValidator(), schema);
-    if (validate(example)) { return errors; }
+    if (validate(example)) {
+        return errors;
+    }
     return errors.concat(...validate.errors.map(ApplicationError.create))
         .map(error => {
-            if (!filePathExample) { return error; }
+            if (!filePathExample) {
+                return error;
+            }
             error.exampleFilePath = filePathExample;
             return error;
         });
@@ -513,6 +525,7 @@ function _validateExample({ createValidator, schema, example, statistics, filePa
  */
 function _initValidatorFactory(specSchema) {
     return getValidatorFactory(specSchema, {
+        schemaId: 'auto',
         allErrors: true,
         nullable: true
     });
@@ -532,7 +545,7 @@ function _initValidatorFactory(specSchema) {
 function _extractSchema(pathSchema, openapiSpec, suppressErrorIfNotFound = false) {
     const schema = _getObjectByPath(pathSchema, openapiSpec);
     if (!suppressErrorIfNotFound && !schema) {
-        throw new ErrorJsonPathNotFound(`Path to schema can't be found: '${ pathSchema }'`, {
+        throw new ErrorJsonPathNotFound(`Path to schema can't be found: '${pathSchema}'`, {
             params: {
                 path: pathSchema
             }
diff --git a/src/validator.js b/src/validator.js
index 13e8504..e099a3f 100644
--- a/src/validator.js
+++ b/src/validator.js
@@ -5,12 +5,13 @@
 const { JSONPath: jsonPath } = require('jsonpath-plus'),
     JsonPointer = require('json-pointer'),
     Ajv = require('ajv'),
-    FormatValidator = require('ajv-oai/lib/format-validator');
+    FormatValidator = require('ajv-oai/lib/format-validator'),
+    draft4MetaSchema = require('ajv/lib/refs/json-schema-draft-04.json');
 
 const PROP__ID = '$id',
     JSON_PATH__REFS = '$..\$ref',
     ID__SPEC_SCHEMA = 'https://www.npmjs.com/package/openapi-examples-validator/defs.json',
-    ID__REPSONSE_SCHEMA = 'https://www.npmjs.com/package/openapi-examples-validator/schema.json';
+    ID__RESPONSE_SCHEMA = 'https://www.npmjs.com/package/openapi-examples-validator/schema.json';
 
 module.exports = {
     getValidatorFactory,
@@ -27,8 +28,11 @@ function getValidatorFactory(specSchema, options) {
     const preparedSpecSchema = _createReferenceSchema(specSchema);
     return () => {
         const validator = new Ajv(options);
-        validator.addSchema(preparedSpecSchema);
+        _applyDraft04Schema(validator);
         _addFormatValidators(validator);
+
+        validator.addSchema(preparedSpecSchema);
+
         return validator;
     };
 }
@@ -40,7 +44,7 @@ function getValidatorFactory(specSchema, options) {
  * @returns {ajv.ValidateFunction}
  */
 function compileValidate(validator, responseSchema) {
-    const preparedResponseSchema = _prepareResponseSchema(responseSchema, ID__REPSONSE_SCHEMA);
+    const preparedResponseSchema = _prepareResponseSchema(responseSchema, ID__RESPONSE_SCHEMA);
     _replaceRefsToPreparedSpecSchema(preparedResponseSchema);
     return validator.compile(preparedResponseSchema);
 }
@@ -109,3 +113,16 @@ function _addFormatValidators(validator) {
     validator.addFormat('double', { type: 'number', validate: FormatValidator.double });
     validator.addFormat('byte', { type: 'string', validate: FormatValidator.byte });
 }
+
+/**
+ * Adds the JSON schema draft-04 schema as default to the validator.
+ * The OpenAPI specifications rely on draft-04 and draft-05.
+ * Draft-04 is used here because of recommendations made here: https://json-schema.org/draft-05/README.html
+ * @param {ajv.Ajv} validator
+ * @private
+ */
+function _applyDraft04Schema(validator) {
+    validator.removeSchema('');
+    validator.addMetaSchema(draft4MetaSchema, draft4MetaSchema.id);
+    validator._opts.defaultMeta = draft4MetaSchema.id;
+}


Test patch:
diff --git a/test/data/v3/draft-04-properties/errors-exclusive-minimum.json b/test/data/v3/draft-04-properties/errors-exclusive-minimum.json
new file mode 100644
index 0000000..da82123
--- /dev/null
+++ b/test/data/v3/draft-04-properties/errors-exclusive-minimum.json
@@ -0,0 +1,15 @@
+[
+  {
+    "dataPath": ".code",
+    "examplePath": "/paths/~1pets/get/responses/default/content/application~1json/example",
+    "keyword": "exclusiveMinimum",
+    "message": "should be > 10",
+    "params": {
+      "comparison": ">",
+      "exclusive": true,
+      "limit": 10
+    },
+    "schemaPath": "#/properties/code/exclusiveMinimum",
+    "type": "Validation"
+  }
+]
diff --git a/test/data/v3/simple-api-with-examples-exclusive-minimum-invalid.json b/test/data/v3/simple-api-with-examples-exclusive-minimum-invalid.json
new file mode 100644
index 0000000..32ef0f9
--- /dev/null
+++ b/test/data/v3/simple-api-with-examples-exclusive-minimum-invalid.json
@@ -0,0 +1,73 @@
+{
+  "openapi": "3.0.0",
+  "info": {
+    "version": "1.0.0",
+    "title": "Swagger Petstore",
+    "license": {
+      "name": "MIT"
+    }
+  },
+  "servers": [
+    {
+      "url": "http://petstore.swagger.io/v1"
+    }
+  ],
+  "paths": {
+    "/pets": {
+      "get": {
+        "summary": "Info for a specific pet",
+        "operationId": "showPetById",
+        "tags": [
+          "pets"
+        ],
+        "parameters": [
+          {
+            "name": "petId",
+            "in": "path",
+            "required": true,
+            "description": "The id of the pet to retrieve",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "default": {
+            "description": "unexpected error",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                },
+                "example": {
+                  "code": 10,
+                  "message": "An unexpected error has occurred."
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "Error": {
+        "required": [
+          "code",
+          "message"
+        ],
+        "properties": {
+          "code": {
+            "type": "number",
+            "minimum": 10,
+            "exclusiveMinimum": true
+          },
+          "message": {
+            "type": "string"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/test/data/v3/simple-api-with-examples-exclusive-minimum.json b/test/data/v3/simple-api-with-examples-exclusive-minimum.json
new file mode 100644
index 0000000..cab76b9
--- /dev/null
+++ b/test/data/v3/simple-api-with-examples-exclusive-minimum.json
@@ -0,0 +1,73 @@
+{
+  "openapi": "3.0.0",
+  "info": {
+    "version": "1.0.0",
+    "title": "Swagger Petstore",
+    "license": {
+      "name": "MIT"
+    }
+  },
+  "servers": [
+    {
+      "url": "http://petstore.swagger.io/v1"
+    }
+  ],
+  "paths": {
+    "/pets": {
+      "get": {
+        "summary": "Info for a specific pet",
+        "operationId": "showPetById",
+        "tags": [
+          "pets"
+        ],
+        "parameters": [
+          {
+            "name": "petId",
+            "in": "path",
+            "required": true,
+            "description": "The id of the pet to retrieve",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "default": {
+            "description": "unexpected error",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                },
+                "example": {
+                  "code": 10,
+                  "message": "An unexpected error has occurred."
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "Error": {
+        "required": [
+          "code",
+          "message"
+        ],
+        "properties": {
+          "code": {
+            "type": "number",
+            "minimum": 10,
+            "exclusiveMinimum": false
+          },
+          "message": {
+            "type": "string"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/test/specs/impl/v3/index.js b/test/specs/impl/v3/index.js
index ad84fd1..b2ad4fa 100644
--- a/test/specs/impl/v3/index.js
+++ b/test/specs/impl/v3/index.js
@@ -12,7 +12,9 @@ const path = require('path'),
     errorsAdditionalPropertiesMap
         = require('../../../data/v3/additional-properties/errors-map-external-examples.json'),
     errorsAdditionalPropertiesSingle
-        = require('../../../data/v3/additional-properties/errors-single.json');
+        = require('../../../data/v3/additional-properties/errors-single.json'),
+    errorsExclusiveMinimum
+        = require('../../../data/v3/draft-04-properties/errors-exclusive-minimum.json');
 
 const JSON_PATH__CONTEXT_MUTUALLY_EXCLUSIVE = '/paths/~1pets/get/responses/200/content/application~1json',
     REL_PATH__EXAMPLE__SIMPLE = 'v3/simple-api-with-example',
@@ -58,7 +60,11 @@ const JSON_PATH__CONTEXT_MUTUALLY_EXCLUSIVE = '/paths/~1pets/get/responses/200/c
         = path.join(__dirname, '../../../data/v3/request-invalid-requestbody-examples.json'),
     FILE_PATH__VALID__VALUE_PROPERTY = path.join(__dirname, '../../../data/v3/valid-single-with-value-property.yaml'),
     FILE_PATH__VALID__YAML = path.join(__dirname, '../../../data/v3/simple-api-with-examples-with-refs.yaml'),
-    FILE_PATH__VALID__YML = path.join(__dirname, '../../../data/v3/simple-api-with-examples-with-refs.yml');
+    FILE_PATH__VALID__YML = path.join(__dirname, '../../../data/v3/simple-api-with-examples-with-refs.yml'),
+    FILE_PATH__VALID_EXAMPLES_EXCLUSIVE_MINIMUM
+        = path.join(__dirname, '../../../data/v3/simple-api-with-examples-exclusive-minimum.json'),
+    FILE_PATH__INVALID_EXAMPLES_EXCLUSIVE_MINIMUM
+        = path.join(__dirname, '../../../data/v3/simple-api-with-examples-exclusive-minimum-invalid.json');
 
 describe('Main-module, for v3 should', function() {
     describe('recognize', function() {
@@ -351,6 +357,15 @@ describe('Main-module, for v3 should', function() {
             });
         });
     });
+    describe('with draft-04 properties', function() {
+        it('`validateFile` should validate successfully', async function() {
+            (await validateFile(FILE_PATH__VALID_EXAMPLES_EXCLUSIVE_MINIMUM)).valid.should.equal(true);
+        });
+        it('`validateFile` should throw an error', async function() {
+            (await validateFile(FILE_PATH__INVALID_EXAMPLES_EXCLUSIVE_MINIMUM))
+                .errors.should.deep.equal(errorsExclusiveMinimum);
+        });
+    });
 });
 
 function _expandFilePathOfErrors(errors, propertyName) {