Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add formats iso-time and iso-date-time, make time and date-time strict #42

Merged
merged 1 commit into from
Nov 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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.