From 37d7538eec3e4e7e1455e71d5eda07e97f7c489b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 7 Nov 2021 18:02:23 +0000 Subject: [PATCH] add formats iso-time and iso-date-time, make time and date-time strict (#42) --- README.md | 12 ++--- src/formats.ts | 78 +++++++++++++++++---------------- src/index.ts | 6 +-- tests/extras/format.json | 33 ++++++++------ tests/extras/formatMaximum.json | 5 --- tests/extras/formatMinimum.json | 5 --- tests/index.spec.ts | 8 ++-- tests/json-schema.spec.js | 6 +-- tests/strictTime.spec.ts | 43 ------------------ 9 files changed, 75 insertions(+), 121 deletions(-) delete mode 100644 tests/strictTime.spec.ts diff --git a/README.md b/README.md index 4386e39..b4264cd 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,10 @@ addFormats(ajv) The package defines these formats: - _date_: full-date according to [RFC3339](http://tools.ietf.org/html/rfc3339#section-5.6). -- _time_: time with optional time-zone. -- _date-time_: date-time from the same source (time-zone is mandatory). +- _time_: time (time-zone is mandatory). +- _date-time_: date-time (time-zone is mandatory). +- _iso-time_: time with optional time-zone. +- _iso-date-time_: date-time with optional time-zone. - _duration_: duration from [RFC3339](https://tools.ietf.org/html/rfc3339#appendix-A) - _uri_: full URI. - _uri-reference_: URI reference, including full and relative URIs. @@ -105,12 +107,10 @@ addFormats(ajv, {mode: "fast"}) or ```javascript -addFormats(ajv, {mode: "fast", formats: ["date", "time"], keywords: true, strictTime: true}) +addFormats(ajv, {mode: "fast", formats: ["date", "time"], keywords: true}) ``` -In `"fast"` mode the following formats are simplified: `"date"`, `"time"`, `"date-time"`, `"uri"`, `"uri-reference"`, `"email"`. For example `"date"`, `"time"` and `"date-time"` do not validate ranges in `"fast"` mode, only string structure, and other formats have simplified regular expressions. - -With `strictTime: true` option timezone becomes required in `time` and `date-time` formats, and (it also implies `full` mode for these formats). +In `"fast"` mode the following formats are simplified: `"date"`, `"time"`, `"date-time"`, `"iso-time"`, `"iso-date-time"`, `"uri"`, `"uri-reference"`, `"email"`. For example, `"date"`, `"time"` and `"date-time"` do not validate ranges in `"fast"` mode, only string structure, and other formats have simplified regular expressions. ## Tests diff --git a/src/formats.ts b/src/formats.ts index 3ab44f8..b1babd6 100644 --- a/src/formats.ts +++ b/src/formats.ts @@ -7,6 +7,8 @@ export type FormatName = | "date" | "time" | "date-time" + | "iso-time" + | "iso-date-time" | "duration" | "uri" | "uri-reference" @@ -44,8 +46,10 @@ export const fullFormats: DefinedFormats = { // date: http://tools.ietf.org/html/rfc3339#section-5.6 date: fmtDef(date, compareDate), // date-time: http://tools.ietf.org/html/rfc3339#section-5.6 - time: fmtDef(time, compareTime), - "date-time": fmtDef(date_time, compareDateTime), + time: fmtDef(getTime(true), compareTime), + "date-time": fmtDef(getDateTime(true), compareDateTime), + "iso-time": fmtDef(getTime(), compareTime), + "iso-date-time": fmtDef(getDateTime(), compareDateTime), // duration: https://tools.ietf.org/html/rfc3339#appendix-A duration: /^P(?!$)((\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?|(\d+W)?)$/, uri, @@ -94,11 +98,19 @@ export const fastFormats: DefinedFormats = { ...fullFormats, date: fmtDef(/^\d\d\d\d-[0-1]\d-[0-3]\d$/, compareDate), time: fmtDef( - /^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i, + /^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i, compareTime ), "date-time": fmtDef( - /^\d\d\d\d-[0-1]\d-[0-3]\d[t\s](?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i, + /^\d\d\d\d-[0-1]\d-[0-3]\dt(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i, + compareDateTime + ), + "iso-time": fmtDef( + /^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i, + compareTime + ), + "iso-date-time": fmtDef( + /^\d\d\d\d-[0-1]\d-[0-3]\d[t\s](?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i, compareDateTime ), // uri: https://github.com/mafintosh/is-my-json-valid/blob/master/formats.js @@ -111,12 +123,6 @@ export const fastFormats: DefinedFormats = { /^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i, } -export const strictFormats: Partial = { - // date-time: http://tools.ietf.org/html/rfc3339#section-5.6 - time: fmtDef(strict_time, compareTime), - "date-time": fmtDef(strict_date_time, compareDateTime), -} - export const formatNames = Object.keys(fullFormats) as FormatName[] function isLeapYear(year: number): boolean { @@ -151,26 +157,24 @@ function compareDate(d1: string, d2: string): number | undefined { const TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i -function time(str: string, withTimeZone?: boolean, strictTime?: boolean): boolean { - const matches: string[] | null = TIME.exec(str) - if (!matches) return false - const hr: number = +matches[1] - const min: number = +matches[2] - const sec: number = +matches[3] - const tz: string | undefined = matches[4] - const tzSign: number = matches[5] === "-" ? -1 : 1 - const tzH: number = +(matches[6] || 0) - const tzM: number = +(matches[7] || 0) - if (tzH > 23 || tzM > 59 || (withTimeZone && (tz === "" || (strictTime && !tz)))) return false - if (hr <= 23 && min <= 59 && sec < 60) return true - // leap second - const utcMin = min - tzM * tzSign - const utcHr = hr - tzH * tzSign - (utcMin < 0 ? 1 : 0) - return (utcHr === 23 || utcHr === -1) && (utcMin === 59 || utcMin === -1) && sec < 61 -} - -function strict_time(str: string): boolean { - return time(str, true, true) +function getTime(strictTimeZone?: boolean): (str: string) => boolean { + return function time(str: string): boolean { + const matches: string[] | null = TIME.exec(str) + if (!matches) return false + const hr: number = +matches[1] + const min: number = +matches[2] + const sec: number = +matches[3] + const tz: string | undefined = matches[4] + const tzSign: number = matches[5] === "-" ? -1 : 1 + const tzH: number = +(matches[6] || 0) + const tzM: number = +(matches[7] || 0) + if (tzH > 23 || tzM > 59 || (strictTimeZone && !tz)) return false + if (hr <= 23 && min <= 59 && sec < 60) return true + // leap second + const utcMin = min - tzM * tzSign + const utcHr = hr - tzH * tzSign - (utcMin < 0 ? 1 : 0) + return (utcHr === 23 || utcHr === -1) && (utcMin === 59 || utcMin === -1) && sec < 61 + } } function compareTime(t1: string, t2: string): number | undefined { @@ -186,14 +190,14 @@ function compareTime(t1: string, t2: string): number | undefined { } const DATE_TIME_SEPARATOR = /t|\s/i -function date_time(str: string, strictTime?: boolean): boolean { - // http://tools.ietf.org/html/rfc3339#section-5.6 - const dateTime: string[] = str.split(DATE_TIME_SEPARATOR) - return dateTime.length === 2 && date(dateTime[0]) && time(dateTime[1], true, strictTime) -} +function getDateTime(strictTimeZone?: boolean): (str: string) => boolean { + const time = getTime(strictTimeZone) -function strict_date_time(str: string): boolean { - return date_time(str, true) + return function date_time(str: string): boolean { + // http://tools.ietf.org/html/rfc3339#section-5.6 + const dateTime: string[] = str.split(DATE_TIME_SEPARATOR) + return dateTime.length === 2 && date(dateTime[0]) && time(dateTime[1]) + } } function compareDateTime(dt1: string, dt2: string): number | undefined { diff --git a/src/index.ts b/src/index.ts index 9550b15..8fd944a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,6 @@ import { formatNames, fastFormats, fullFormats, - strictFormats, } from "./formats" import formatLimit from "./limit" import type Ajv from "ajv" @@ -18,7 +17,6 @@ export interface FormatOptions { mode?: FormatMode formats?: FormatName[] keywords?: boolean - strictTime?: boolean } export type FormatsPluginOptions = FormatName[] | FormatOptions @@ -32,7 +30,7 @@ const fastName = new Name("fastFormats") const formatsPlugin: FormatsPlugin = ( ajv: Ajv, - opts: FormatsPluginOptions = {keywords: true, strictTime: false} + opts: FormatsPluginOptions = {keywords: true} ): Ajv => { if (Array.isArray(opts)) { addFormats(ajv, opts, fullFormats, fullName) @@ -41,7 +39,7 @@ const formatsPlugin: FormatsPlugin = ( const [formats, exportName] = opts.mode === "fast" ? [fastFormats, fastName] : [fullFormats, fullName] const list = opts.formats || formatNames - addFormats(ajv, list, opts.strictTime ? {...formats, ...strictFormats} : formats, exportName) + addFormats(ajv, list, formats, exportName) if (opts.keywords) formatLimit(ajv) return ajv } diff --git a/tests/extras/format.json b/tests/extras/format.json index 9675730..1a72ec4 100644 --- a/tests/extras/format.json +++ b/tests/extras/format.json @@ -524,36 +524,36 @@ ] }, { - "description": "validation of time strings", - "schema": {"format": "time"}, + "description": "validation of iso-time strings", + "schema": {"format": "iso-time"}, "tests": [ { - "description": "a valid time", + "description": "a valid iso-time", "data": "12:34:56", "valid": true }, { - "description": "a valid time with milliseconds", + "description": "a valid iso-time with milliseconds", "data": "12:34:56.789", "valid": true }, { - "description": "a valid time with timezone", + "description": "a valid iso-time with timezone", "data": "12:34:56+01:00", "valid": true }, { - "description": "an invalid time format", + "description": "an invalid iso-time format", "data": "12.34.56", "valid": false }, { - "description": "an invalid time", + "description": "an invalid iso-time", "data": "12:34:67", "valid": false }, { - "description": "a valid time (leap second)", + "description": "a valid iso-time (leap second)", "data": "23:59:60", "valid": true } @@ -561,15 +561,20 @@ }, { "description": "validation of date-time strings", - "schema": {"format": "date-time"}, + "schema": {"format": "iso-date-time"}, "tests": [ { - "description": "a valid date-time string", + "description": "a valid iso-date-time string", "data": "1963-06-19T12:13:14Z", "valid": true }, { - "description": "an invalid date-time string (no time)", + "description": "a valid iso-date-time string without timezone", + "data": "1963-06-19T12:13:14", + "valid": true + }, + { + "description": "an invalid iso-date-time string (no time)", "data": "1963-06-19", "valid": false }, @@ -579,17 +584,17 @@ "valid": false }, { - "description": "an invalid date-time string (invalid date)", + "description": "an invalid iso-date-time string (invalid date)", "data": "1963-20-19T12:13:14Z", "valid": false }, { - "description": "an invalid date-time string (invalid time)", + "description": "an invalid iso-date-time string (invalid time)", "data": "1963-06-19T12:13:67Z", "valid": false }, { - "description": "a valid date-time string (leap second)", + "description": "a valid iso-date-time string (leap second)", "data": "2016-12-31T23:59:60Z", "valid": true } diff --git a/tests/extras/formatMaximum.json b/tests/extras/formatMaximum.json index 112f8cd..bca7ab2 100644 --- a/tests/extras/formatMaximum.json +++ b/tests/extras/formatMaximum.json @@ -73,11 +73,6 @@ "data": "13:15:17.000+01:00", "valid": true }, - { - "description": "boundary point is valid, no timezone is ok too", - "data": "13:15:17.000", - "valid": true - }, { "description": "time before the maximum time is valid", "data": "10:33:55.000Z", diff --git a/tests/extras/formatMinimum.json b/tests/extras/formatMinimum.json index 26a6236..e6fb7d3 100644 --- a/tests/extras/formatMinimum.json +++ b/tests/extras/formatMinimum.json @@ -73,11 +73,6 @@ "data": "13:15:17.000+01:00", "valid": true }, - { - "description": "boundary point is valid, no timezone is ok too", - "data": "13:15:17.000", - "valid": true - }, { "description": "time before the minimum time is invalid", "data": "10:33:55.000Z", diff --git a/tests/index.spec.ts b/tests/index.spec.ts index 7df3d1d..b434921 100644 --- a/tests/index.spec.ts +++ b/tests/index.spec.ts @@ -16,8 +16,8 @@ describe("addFormats options", () => { expect(validateDate("2020-09-35")).toEqual(false) const validateTime = ajv.compile({format: "time"}) - expect(validateTime("17:27:38")).toEqual(true) - expect(validateDate("25:27:38")).toEqual(false) + expect(validateTime("17:27:38Z")).toEqual(true) + expect(validateDate("25:27:38Z")).toEqual(false) expect(() => ajv.compile({format: "date-time"})).toThrow() addFormats(ajv, ["date-time"]) @@ -32,8 +32,8 @@ describe("addFormats options", () => { expect(validateDate("2020-09")).toEqual(false) const validateTime = ajv.compile({format: "time"}) - expect(validateTime("17:27:38")).toEqual(true) - expect(validateTime("25:27:38")).toEqual(true) + expect(validateTime("17:27:38Z")).toEqual(true) + expect(validateTime("25:27:38Z")).toEqual(true) expect(validateTime("17:27")).toEqual(false) }) }) diff --git a/tests/json-schema.spec.js b/tests/json-schema.spec.js index 1cc4c5c..f4dbc90 100644 --- a/tests/json-schema.spec.js +++ b/tests/json-schema.spec.js @@ -2,7 +2,7 @@ const jsonSchemaTest = require("json-schema-test") const Ajv = require("ajv").default const addFormats = require("../dist") -jsonSchemaTest(getAjv(true), { +jsonSchemaTest(getAjv(), { description: `JSON-Schema Test Suite formats`, suites: { "draft-07 formats": "./JSON-Schema-Test-Suite/tests/draft7/optional/format/*.json", @@ -31,9 +31,9 @@ jsonSchemaTest(getAjv(), { cwd: __dirname, }) -function getAjv(strictTime) { +function getAjv() { const ajv = new Ajv({$data: true, strictTypes: false, formats: {allowedUnknown: true}}) - addFormats(ajv, {mode: "full", keywords: true, strictTime}) + addFormats(ajv, {mode: "full", keywords: true}) return ajv } diff --git a/tests/strictTime.spec.ts b/tests/strictTime.spec.ts deleted file mode 100644 index 4c7034a..0000000 --- a/tests/strictTime.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import Ajv from "ajv" -import addFormats from "../dist" - -const ajv = new Ajv({$data: true, strictTypes: false, formats: {allowedUnknown: true}}) -addFormats(ajv, {mode: "full", strictTime: true}) - -describe("strictTime option", () => { - it("a valid date-time string with time offset", () => { - expect( - ajv.validate( - { - type: "string", - format: "date-time", - }, - "2020-06-19T12:13:14+05:00" - ) - ).toBe(true) - }) - - it("an invalid date-time string (no time offset)", () => { - expect( - ajv.validate( - { - type: "string", - format: "date-time", - }, - "2020-06-19T12:13:14" - ) - ).toBe(false) - }) - - it("an invalid date-time string (invalid time offset)", () => { - expect( - ajv.validate( - { - type: "string", - format: "date-time", - }, - "2020-06-19T12:13:14+26:00" - ) - ).toBe(false) - }) -})