Skip to content

Commit ba3e36f

Browse files
committed
Add implementations for all spec formats
1 parent 3790aee commit ba3e36f

20 files changed

+737
-0
lines changed

.npmignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
eslint.config.js
33
**/*.test.js
44
tsconfig.json
5+
documents/
56
scratch/
67
TODO*

documents/idna2008-limitations.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
title: IDNA2008 Limitations
3+
---
4+
5+
TODO

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,16 @@
2727
"devDependencies": {
2828
"@stylistic/eslint-plugin": "*",
2929
"@types/node": "*",
30+
"@types/tr46": "*",
3031
"eslint-import-resolver-typescript": "*",
3132
"eslint-plugin-import": "*",
33+
"json-schema-test-suite": "github:json-schema-org/json-schema-test-suite",
3234
"typedoc": "*",
3335
"typescript-eslint": "*",
3436
"vitest": "*"
37+
},
38+
"dependencies": {
39+
"@hyperjump/uri": "^1.3.2",
40+
"tr46": "^6.0.0"
3541
}
3642
}

src/date-math.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/** @type (month: string, year: number) => number */
2+
export const daysInMonth = (month, year) => {
3+
switch (month) {
4+
case "01":
5+
case "Jan":
6+
case "03":
7+
case "Mar":
8+
case "05":
9+
case "May":
10+
case "07":
11+
case "Jul":
12+
case "08":
13+
case "Aug":
14+
case "10":
15+
case "Oct":
16+
case "12":
17+
case "Dec":
18+
return 31;
19+
case "04":
20+
case "Apr":
21+
case "06":
22+
case "Jun":
23+
case "09":
24+
case "Sep":
25+
case "11":
26+
case "Nov":
27+
return 30;
28+
case "02":
29+
case "Feb":
30+
return isLeapYear(year) ? 29 : 28;
31+
default:
32+
return 0;
33+
}
34+
};
35+
36+
/** @type (year: number) => boolean */
37+
export const isLeapYear = (year) => {
38+
return (year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0));
39+
};
40+
41+
/** @type (date: Date) => boolean */
42+
export const hasLeapSecond = (date) => {
43+
const utcDate = `${date.getUTCFullYear()}-${date.getUTCMonth() + 1}-${date.getUTCDate()}`;
44+
return leapSecondDates.has(utcDate)
45+
&& date.getUTCHours() === 23
46+
&& date.getUTCMinutes() === 59;
47+
};
48+
49+
const leapSecondDates = new Set([
50+
"1960-12-31",
51+
"1961-07-31",
52+
"1961-12-31",
53+
"1963-10-31",
54+
"1963-12-31",
55+
"1964-03-31",
56+
"1964-08-31",
57+
"1964-12-31",
58+
"1965-02-28",
59+
"1965-06-30",
60+
"1965-08-31",
61+
"1965-12-31",
62+
"1968-01-31",
63+
"1971-12-31",
64+
"1972-06-30",
65+
"1972-12-31",
66+
"1973-12-31",
67+
"1974-12-31",
68+
"1975-12-31",
69+
"1976-12-31",
70+
"1977-12-31",
71+
"1978-12-31",
72+
"1979-12-31",
73+
"1981-06-30",
74+
"1982-06-30",
75+
"1983-06-30",
76+
"1985-06-30",
77+
"1987-12-31",
78+
"1989-12-31",
79+
"1990-12-31",
80+
"1992-06-30",
81+
"1993-06-30",
82+
"1994-06-30",
83+
"1995-12-31",
84+
"1997-06-30",
85+
"1998-12-31",
86+
"2005-12-31",
87+
"2008-12-31",
88+
"2012-06-30",
89+
"2015-06-30",
90+
"2016-12-31"
91+
]);
92+
93+
/** @type (dayName: string) => number */
94+
export const dayOfWeekId = (dayName) => {
95+
switch (dayName) {
96+
case "Sun":
97+
case "Sunday":
98+
return 0;
99+
case "Mon":
100+
case "Monday":
101+
return 1;
102+
case "Tue":
103+
case "Tuesday":
104+
return 2;
105+
case "Wed":
106+
case "Wednesday":
107+
return 3;
108+
case "Thu":
109+
case "Thursday":
110+
return 4;
111+
case "Fri":
112+
case "Friday":
113+
return 5;
114+
case "Sat":
115+
case "Saturday":
116+
return 6;
117+
default:
118+
return -1;
119+
}
120+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const unescaped = `[\\u{00}-\\u{2E}\\u{30}-\\u{7D}\\u{7F}-\\u{10FFFF}]`; // %x2F ('/') and %x7E ('~') are excluded from 'unescaped'
2+
const escaped = `~[01]`; // representing '~' and '/', respectively
3+
const referenceToken = `(?:${unescaped}|${escaped})*`;
4+
const jsonPointer = `(?:/${referenceToken})*`;
5+
6+
const nonNegativeInteger = `(?:0|[1-9][0-9]*)`;
7+
const indexManipulation = `(?:[+-]${nonNegativeInteger})`;
8+
const relativeJsonPointer = `${nonNegativeInteger}(?:${indexManipulation}?${jsonPointer}|#)`;
9+
10+
/**
11+
* The 'relative-json-pointer' format. Validates that a string represents an IRI
12+
* Reference as defined by [draft-bhutton-relative-json-pointer-00](https://datatracker.ietf.org/doc/html/draft-bhutton-relative-json-pointer-00).
13+
*
14+
* @see [JSON Schema Core, section 7.3.5](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-7.3.5)
15+
*
16+
* @function
17+
*/
18+
export const isRelativeJsonPointer = RegExp.prototype.test.bind(new RegExp(`^${relativeJsonPointer}$`, "u"));

src/ecma262.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* The 'regex' format. Validates that a string represents a regular expression
3+
* as defined by [ECMA-262](https://262.ecma-international.org/5.1/).
4+
*
5+
* @see [JSON Schema Core, section 7.3.8](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-7.3.8)
6+
*
7+
* @param {string} regex
8+
* @returns {boolean}
9+
*/
10+
export const isRegex = (regex) => {
11+
try {
12+
new RegExp(regex, "u");
13+
return true;
14+
} catch (_error) {
15+
return false;
16+
}
17+
};

src/idna2008.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import tr46 from "tr46";
2+
3+
const label = `[A-Za-z0-9-]{1,63}`;
4+
const domain = `${label}(?:\\.${label})*`;
5+
6+
const domainPattern = new RegExp(`^${domain}$`);
7+
8+
const parserOptions = {
9+
checkBidi: true,
10+
checkJoiners: true,
11+
checkHyphens: true
12+
};
13+
14+
/**
15+
* The 'hostname' format since draft-07. Validates that a string represents an
16+
* IDNA2008 internationalized domain name consiting of only A-labels and NR-LDH
17+
* labels as defined by [RFC 5890, section 2.3.2.1](https://www.rfc-editor.org/rfc/rfc5890.html#section-2.3.2.3).
18+
*
19+
* **NOTE**: The 'hostname' format changed in draft-07. Use {@link isHostname}
20+
* for draft-06 and earlier.
21+
*
22+
* **WARNING**: This function can't completely validate IDNA2008 hostnames. See
23+
* {@link !"IDNA2008 Limitations" | IDNA2008 Limitations} for details.
24+
*
25+
* @see [JSON Schema Core, section 7.3.3](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-7.3.3)
26+
*
27+
* @param {string} hostname
28+
* @returns {boolean}
29+
*/
30+
export const isAsciiIdn = (hostname) => {
31+
return domainPattern.test(hostname)
32+
&& hostname.length < 256
33+
&& !tr46.toUnicode(hostname, parserOptions).error;
34+
};
35+
36+
/**
37+
* The 'idn-hostname' format. Validates that a string represents an IDNA2008
38+
* internationalized domain name as defined by [RFC 5890, section 2.3.2.1](https://www.rfc-editor.org/rfc/rfc5890.html#section-2.3.2.1).
39+
*
40+
* **WARNING**: This function can't completely validate IDNA2008 hostnames. See
41+
* {@link !"IDNA2008 Limitations" | IDNA2008 Limitations} for details.
42+
*
43+
* @see [JSON Schema Core, section 7.3.3](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-7.3.3)
44+
*
45+
* @param {string} hostname
46+
* @returns {boolean}
47+
*/
48+
export const isIdn = (hostname) => {
49+
const asciiHostname = tr46.toASCII(hostname);
50+
51+
if (!asciiHostname) {
52+
return false;
53+
}
54+
55+
return isAsciiIdn(asciiHostname);
56+
};

src/index.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @module
3+
*
4+
* @document ../documents/idna2008-limitations.md
5+
*/
6+
7+
// JSON Schema Validation - Dates, Times, and Duration
8+
export { isDate, isTime, isDateTime, isDuration } from "./rfc3339.js";
9+
10+
// JSON Schema Validation - Email Addresses
11+
export { isEmail } from "./rfc5321.js";
12+
export { isIdnEmail } from "./rfc6531.js";
13+
14+
// JSON Schema Validation - Hostnames
15+
export { isHostname } from "./rfc1123.js";
16+
export { isAsciiIdn, isIdn } from "./idna2008.js";
17+
18+
// JSON Schema Validation - IP Addresses
19+
export { isIPv4 } from "./rfc2673.js";
20+
export { isIPv6 } from "./rfc4291.js";
21+
22+
// JSON Schema Validation - Resource Identifiers
23+
export { isUri, isUriReference } from "./rfc3986.js";
24+
export { isIri, isIriReference } from "./rfc3987.js";
25+
export { isUuid } from "./rfc4122.js";
26+
27+
// JSON Schema Validation - URI Template
28+
export { isUriTemplate } from "./rfc6570.js";
29+
30+
// JSON Schema Validation - JSON Pointers
31+
export { isJsonPointer } from "./rfc6901.js";
32+
export { isRelativeJsonPointer } from "./draft-bhutton-relative-json-pointer-00.js";
33+
34+
// JSON Schema Validation - Regular Expressions
35+
export { isRegex } from "./ecma262.js";

src/json-schema-test-suite.test.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { readFile } from "node:fs/promises";
2+
import { describe, test, expect } from "vitest";
3+
import { isDate, isDateTime, isDuration, isTime } from "./rfc3339.js";
4+
import { isRegex } from "./ecma262.js";
5+
import { isEmail } from "./rfc5321.js";
6+
import { isHostname } from "./rfc1123.js";
7+
import { isAsciiIdn, isIdn } from "./idna2008.js";
8+
import { isIdnEmail } from "./rfc6531.js";
9+
import { isIPv4 } from "./rfc2673.js";
10+
import { isIPv6 } from "./rfc4291.js";
11+
import { isIriReference } from "./rfc3987.js";
12+
import { isJsonPointer } from "./rfc6901.js";
13+
import { isRelativeJsonPointer } from "./draft-bhutton-relative-json-pointer-00.js";
14+
import { isUri, isUriReference } from "./rfc3986.js";
15+
import { isUriTemplate } from "./rfc6570.js";
16+
import { isUuid } from "./rfc4122.js";
17+
18+
/**
19+
* @typedef {{
20+
* description: string;
21+
* data: unknown;
22+
* valid: boolean;
23+
* }} Test
24+
*/
25+
26+
/**
27+
* @typedef {{
28+
* description: string;
29+
* tests: Test[];
30+
* }} TestCase
31+
*/
32+
33+
/**
34+
* @typedef {TestCase[]} TestSuite
35+
*/
36+
37+
/** @type (format: string, fn: (value: any) => boolean, skip?: Record<string, Set<string>>) => Promise<void> */
38+
export const testSuite = async (format, fn, skip = {}) => {
39+
const testSuiteJson = await readFile(`./node_modules/json-schema-test-suite/tests/${format}.json`, "utf8");
40+
/** @type TestSuite */
41+
const testSuite = JSON.parse(testSuiteJson); // eslint-disable-line @typescript-eslint/no-unsafe-assignment
42+
43+
for (const testCase of testSuite) {
44+
describe(testCase.description, () => {
45+
for (const formatTest of testCase.tests) {
46+
if (skip[testCase.description]?.has(formatTest.description)) {
47+
continue;
48+
}
49+
50+
test(formatTest.description, () => {
51+
expect(fn(formatTest.data)).to.equal(formatTest.valid);
52+
});
53+
}
54+
});
55+
}
56+
};
57+
58+
await testSuite("draft2020-12/optional/format/date-time", (dateTime) => typeof dateTime !== "string" || isDateTime(dateTime));
59+
await testSuite("draft2020-12/optional/format/date", (date) => typeof date !== "string" || isDate(date));
60+
await testSuite("draft2020-12/optional/format/duration", (duration) => typeof duration !== "string" || isDuration(duration));
61+
await testSuite("draft2020-12/optional/format/ecmascript-regex", (pattern) => typeof pattern !== "string" || isRegex(pattern));
62+
await testSuite("draft2020-12/optional/format/email", (email) => typeof email !== "string" || isEmail(email));
63+
await testSuite("draft6/optional/format/hostname", (hostname) => typeof hostname !== "string" || isHostname(hostname));
64+
await testSuite("draft2020-12/optional/format/hostname", (hostname) => typeof hostname !== "string" || isAsciiIdn(hostname));
65+
await testSuite("draft2020-12/optional/format/idn-email", (email) => typeof email !== "string" || isIdnEmail(email));
66+
await testSuite("draft2020-12/optional/format/idn-hostname", (hostname) => typeof hostname !== "string" || isIdn(hostname), {
67+
"validation of internationalized host names": new Set([
68+
"contains illegal char U+302E Hangul single dot tone mark",
69+
"Exceptions that are DISALLOWED, right-to-left chars",
70+
"Exceptions that are DISALLOWED, left-to-right chars",
71+
"MIDDLE DOT with no preceding 'l'",
72+
"MIDDLE DOT with nothing preceding",
73+
"MIDDLE DOT with no following 'l'",
74+
"MIDDLE DOT with nothing following",
75+
"Greek KERAIA not followed by Greek",
76+
"Greek KERAIA not followed by anything",
77+
"Hebrew GERESH not preceded by anything",
78+
"Hebrew GERSHAYIM not preceded by anything",
79+
"KATAKANA MIDDLE DOT with no Hiragana, Katakana, or Han",
80+
"KATAKANA MIDDLE DOT with no other characters"
81+
])
82+
});
83+
await testSuite("draft2020-12/optional/format/ipv4", (ip) => typeof ip !== "string" || isIPv4(ip));
84+
await testSuite("draft2020-12/optional/format/ipv6", (ip) => typeof ip !== "string" || isIPv6(ip));
85+
await testSuite("draft2020-12/optional/format/iri-reference", (iri) => typeof iri !== "string" || isIriReference(iri));
86+
await testSuite("draft2020-12/optional/format/json-pointer", (pointer) => typeof pointer !== "string" || isJsonPointer(pointer));
87+
await testSuite("draft2020-12/optional/format/regex", (pattern) => typeof pattern !== "string" || isRegex(pattern));
88+
await testSuite("draft2020-12/optional/format/relative-json-pointer", (pointer) => typeof pointer !== "string" || isRelativeJsonPointer(pointer));
89+
await testSuite("draft2020-12/optional/format/time", (time) => typeof time !== "string" || isTime(time), {
90+
"validation of time strings": new Set([
91+
"a valid time string with leap second, Zulu",
92+
"valid leap second, zero time-offset",
93+
"valid leap second, positive time-offset",
94+
"valid leap second, large positive time-offset",
95+
"valid leap second, negative time-offset",
96+
"valid leap second, large negative time-offset"
97+
])
98+
});
99+
await testSuite("draft2020-12/optional/format/uri-reference", (uri) => typeof uri !== "string" || isUriReference(uri));
100+
await testSuite("draft2020-12/optional/format/uri-template", (uri) => typeof uri !== "string" || isUriTemplate(uri));
101+
await testSuite("draft2020-12/optional/format/uri", (uri) => typeof uri !== "string" || isUri(uri));
102+
await testSuite("draft2020-12/optional/format/uuid", (uuid) => typeof uuid !== "string" || isUuid(uuid));

0 commit comments

Comments
 (0)