Repository: codekie/openapi-examples-validator
Pull request #159 (2021-12-03 14:41:47)

feat: add date-time validator, according to `RFC 3339`
See also #157 


Patch:
diff --git a/src/validator.js b/src/validator.js
index e668d75..ebdb636 100644
--- a/src/validator.js
+++ b/src/validator.js
@@ -7,6 +7,7 @@ const { JSONPath: jsonPath } = require('jsonpath-plus'),
     Ajv = require('ajv'),
     FormatValidator = require('ajv-oai/lib/format-validator'),
     draft4MetaSchema = require('ajv/lib/refs/json-schema-draft-04.json');
+const { validateDateTime } = require('./validators/date-time');
 
 const PROP__ID = '$id',
     JSON_PATH__REFS = '$..\$ref',
@@ -119,6 +120,7 @@ function _addFormatValidators(validator) {
     validator.addFormat('float', { type: 'number', validate: FormatValidator.float });
     validator.addFormat('double', { type: 'number', validate: FormatValidator.double });
     validator.addFormat('byte', { type: 'string', validate: FormatValidator.byte });
+    validator.addFormat('date-time', { type: 'string', validate: validateDateTime });
 }
 
 /**
diff --git a/src/validators/date-time.js b/src/validators/date-time.js
new file mode 100644
index 0000000..5ec6573
--- /dev/null
+++ b/src/validators/date-time.js
@@ -0,0 +1,37 @@
+/**
+ * Validates a date-time string, according to RFC3339
+ *
+ * The origins of the supplied RegEx are:
+ *
+ * - https://stackoverflow.com/questions/28020805/regex-validate-correct-iso8601-date-string-with-time
+ * - https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch04s07.html
+ *
+ * The foundation is the Stackoverflow-version, with following modifications:
+ *
+ * - Added milliseconds and replaced timezones
+ * - Supporting lower case t and z, as stated in https://datatracker.ietf.org/doc/html/rfc3339#section-5.6
+ * - Supports negative years (for BC)
+ * - Supports leap seconds
+ * - Added named groups for date, time and timezone
+ * - Made [Tt] and [Zz] non-capturing groups
+ *
+ * @type {string}
+ */
+
+const REGEX_DATE_TIME = new RegExp(
+    '^(?<date>-?(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])'
+    + '|(?:0[13-9]|1[0-2])-(?:29|30)'
+    + '|(?:0[13578]|1[02])-31)'
+    + '|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])'
+    + '|(?:[2468][048]|[13579][26])00)-02-29))(?:[Tt])'
+    + '(?<time>(?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.[0-9]+)?)'
+    + '(?<timezone>[Zz]|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])$'
+);
+
+module.exports = {
+    validateDateTime
+};
+
+function validateDateTime(dateTimeString) {
+    return REGEX_DATE_TIME.test(dateTimeString);
+}


Test patch:
diff --git a/test/data/v3/response-invalid-date-time-format.json b/test/data/v3/response-invalid-date-time-format.json
new file mode 100644
index 0000000..1dbdafe
--- /dev/null
+++ b/test/data/v3/response-invalid-date-time-format.json
@@ -0,0 +1,44 @@
+{
+  "openapi": "3.0.0",
+  "info": {
+    "version": "1.0.0",
+    "title": "Sample number formats",
+    "license": {
+      "name": "MIT"
+    }
+  },
+  "servers": [
+    {
+      "url": "http://example.com/v1"
+    }
+  ],
+  "paths": {
+    "/get-numbers": {
+      "get": {
+        "summary": "Gets some big numbers",
+        "operationId": "getNumbers",
+        "responses": {
+          "200": {
+            "description": "Some big numbers",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "date": {
+                      "type": "string",
+                      "format": "date-time"
+                    }
+                  }
+                },
+                "example": {
+                  "date": "42"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/test/data/v3/response-valid-date-time-format.json b/test/data/v3/response-valid-date-time-format.json
new file mode 100644
index 0000000..5cbc55c
--- /dev/null
+++ b/test/data/v3/response-valid-date-time-format.json
@@ -0,0 +1,44 @@
+{
+  "openapi": "3.0.0",
+  "info": {
+    "version": "1.0.0",
+    "title": "Sample number formats",
+    "license": {
+      "name": "MIT"
+    }
+  },
+  "servers": [
+    {
+      "url": "http://example.com/v1"
+    }
+  ],
+  "paths": {
+    "/get-numbers": {
+      "get": {
+        "summary": "Gets some big numbers",
+        "operationId": "getNumbers",
+        "responses": {
+          "200": {
+            "description": "Some big numbers",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "date": {
+                      "type": "string",
+                      "format": "date-time"
+                    }
+                  }
+                },
+                "example": {
+                  "date": "2017-07-21T17:32:28Z"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/test/specs/impl/v3/index.js b/test/specs/impl/v3/index.js
index 27f59fd..92ea9ef 100644
--- a/test/specs/impl/v3/index.js
+++ b/test/specs/impl/v3/index.js
@@ -53,10 +53,12 @@ const JSON_PATH__CONTEXT_MUTUALLY_EXCLUSIVE = '/paths/~1pets/get/responses/200/c
         = path.join(__dirname, '../../../data/v3/request-invalid-parameter-examples.json'),
     FILE_PATH__VALID__NULLABLE = path.join(__dirname, '../../../data/v3/response-valid-nullable.json'),
     FILE_PATH__VALID__NUMBER_FORMATS = path.join(__dirname, '../../../data/v3/response-valid-number-formats.json'),
+    FILE_PATH__VALID__DATE_TIME_FORMATS = path.join(__dirname, '../../../data/v3/response-valid-date-time-format.json'),
     FILE_PATH__VALID__REQUEST_BODY = path.join(__dirname, '../../../data/v3/request-valid-requestbody.json'),
     FILE_PATH__VALID__REQUEST_BODY__EXAMPLES
         = path.join(__dirname, '../../../data/v3/request-valid-requestbody-examples.json'),
     FILE_PATH__INVALID__NUMBER_FORMATS = path.join(__dirname, '../../../data/v3/response-invalid-number-formats.json'),
+    FILE_PATH__INVALID__DATE_TIME_FORMAT = path.join(__dirname, '../../../data/v3/response-invalid-date-time-format.json'),
     FILE_PATH__INVALID__REQUEST_BODY = path.join(__dirname, '../../../data/v3/request-invalid-requestbody.json'),
     FILE_PATH__INVALID__REQUEST_BODY__EXAMPLES
         = path.join(__dirname, '../../../data/v3/request-invalid-requestbody-examples.json'),
@@ -273,6 +275,24 @@ describe('Main-module, for v3 should', function() {
             });
         });
     });
+    describe('be able to handle date-time formats', function() {
+        it('with valid examples', async function() {
+            (await validateFile(FILE_PATH__VALID__NUMBER_FORMATS)).valid.should.equal(true);
+        });
+        describe('invalid examples', function() {
+            before(async function() {
+                this.validationResults = await validateFile(FILE_PATH__INVALID__DATE_TIME_FORMAT);
+            });
+
+            it('invalid date-time', function() {
+                const error = this.validationResults.errors[0];
+                error.message.should.equal('should match format "date-time"');
+                error.keyword.should.equal('format');
+                error.params.format.should.equal('date-time');
+            });
+        });
+    });
+
     describe('example with `value`s as properties', function() {
         it('should not be recognized as separate example', async function() {
             const { valid, statistics } = (await validateFile(FILE_PATH__VALID__VALUE_PROPERTY));
diff --git a/test/specs/validators/date-time.js b/test/specs/validators/date-time.js
new file mode 100644
index 0000000..6eeca2c
--- /dev/null
+++ b/test/specs/validators/date-time.js
@@ -0,0 +1,68 @@
+const { validateDateTime } = require('../../../src/validators/date-time');
+
+const VAL__VALID = [
+    '-1001-08-30T01:45:36.123Z',
+    '2008-08-30T01:45:36.123Z',
+    '1985-04-12T23:20:50.52Z',
+    '1996-12-19T16:39:57-08:00',
+    '1937-01-01T12:00:27.87+00:20',
+    '2017-07-21T17:32:28Z',
+    '2007-08-31T16:47:00+00:00',
+    '2007-12-24T18:21:00Z',
+    '2009-01-01T12:00:00+01:00',
+    '2009-06-30T18:30:00+02:00',
+    '2021-12-01T13:54:15+00:00',
+    '2021-12-01T13:54:15Z',
+    '2021-12-01t13:54:15z',
+    '2024-03-30T12:34:56Z'
+];
+
+const VAL__LEAP_SECONDS = [
+    '1990-12-31T23:59:60Z',
+    '1990-12-31T15:59:60-08:00'
+];
+
+const VAL__LEAP_YEARS = [
+    '1996-02-29T08:47:23Z',
+    '2024-02-29T12:34:56Z'
+];
+
+const VAL__INVALID = [
+    '2008-08-30T01:45:36',
+    '108-08-30T01:45:36',
+    '2008-08-32T01:45:36',
+    '2008-08-02T27:45:36',
+    '2009-01-01T12:00:00+01:00:15',
+    '2024-02-30T12:34:56Z',
+    '2021-13-02T08:27:49Z',
+    '2022-07-32T09:36:24Z',
+    '2023-02-29T12:34:56Z',
+    '2021-12-01TZ',
+    '2021-12-01Z',
+    '2009-01-01T+01:00',
+    '1996-12-19-08:00',
+    '2009-06-30+02:00'
+];
+
+describe('date-time validator', function() {
+    it('should detect valid inputs', function() {
+        VAL__VALID.forEach(value => {
+            validateDateTime(value).should.equal(true, `expected ${ value } to be valid`);
+        });
+    });
+    it('should detect leap seconds', function() {
+        VAL__LEAP_SECONDS.forEach(value => {
+            validateDateTime(value).should.equal(true, `expected ${ value } to be valid`);
+        });
+    });
+    it('should detect leap years', function() {
+        VAL__LEAP_YEARS.forEach(value => {
+            validateDateTime(value).should.equal(true, `expected ${ value } to be valid`);
+        });
+    });
+    it('should detect invalid input', function() {
+        VAL__INVALID.forEach(value => {
+            validateDateTime(value).should.equal(false, `expected ${ value } to be valid`);
+        });
+    });
+});