Skip to content

Commit

Permalink
add formats iso-time and iso-date-time, make time and date-time strict (
Browse files Browse the repository at this point in the history
  • Loading branch information
epoberezkin committed Nov 7, 2021
1 parent 46dbae5 commit 37d7538
Show file tree
Hide file tree
Showing 9 changed files with 75 additions and 121 deletions.
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
78 changes: 41 additions & 37 deletions src/formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export type FormatName =
| "date"
| "time"
| "date-time"
| "iso-time"
| "iso-date-time"
| "duration"
| "uri"
| "uri-reference"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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<DefinedFormats> = {
// 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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
6 changes: 2 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
formatNames,
fastFormats,
fullFormats,
strictFormats,
} from "./formats"
import formatLimit from "./limit"
import type Ajv from "ajv"
Expand All @@ -18,7 +17,6 @@ export interface FormatOptions {
mode?: FormatMode
formats?: FormatName[]
keywords?: boolean
strictTime?: boolean
}

export type FormatsPluginOptions = FormatName[] | FormatOptions
Expand All @@ -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)
Expand All @@ -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
}
Expand Down
33 changes: 19 additions & 14 deletions tests/extras/format.json
Original file line number Diff line number Diff line change
Expand Up @@ -524,52 +524,57 @@
]
},
{
"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
}
]
},
{
"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
},
Expand All @@ -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
}
Expand Down
5 changes: 0 additions & 5 deletions tests/extras/formatMaximum.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 0 additions & 5 deletions tests/extras/formatMinimum.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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)
})
})
Expand Down
6 changes: 3 additions & 3 deletions tests/json-schema.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
}

Expand Down
43 changes: 0 additions & 43 deletions tests/strictTime.spec.ts

This file was deleted.

0 comments on commit 37d7538

Please sign in to comment.