Skip to content

Commit

Permalink
add: support leap seconds validation in format time
Browse files Browse the repository at this point in the history
  • Loading branch information
sagold committed Jun 5, 2024
1 parent 07ad747 commit 94cf3ec
Show file tree
Hide file tree
Showing 2 changed files with 74 additions and 49 deletions.
76 changes: 50 additions & 26 deletions lib/validation/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ const isValidIPV6 =
const isValidHostname =
/^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$/;
const matchDate = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;

// const matchTime = /^(\d\d):(\d\d):(\d\d)(\.\d+)?(z|[+-]\d\d(?::?\d\d)?)?$/i;
const matchTime = /^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i;
const matchTime =
/^(?<time>(?:([0-1]\d|2[0-3]):[0-5]\d:(?<second>[0-5]\d|60)))(?:\.\d+)?(?<offset>(?:z|[+-]([0-1]\d|2[0-3])(?::?[0-5]\d)?))$/i;
const DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

const isValidJsonPointer = /^(?:\/(?:[^~/]|~0|~1)*)*$/;
Expand All @@ -31,14 +33,10 @@ const isValidURITemplate =
/^(?:(?:[^\x00-\x20"'<>%\\^`{|}]|%[0-9a-f]{2})|\{[+#./;?&=,!@|]?(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?(?:,(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?)*\})*$/i;
const isValidDurationString = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;


// Default Json-Schema formats: date-time, email, hostname, ipv4, ipv6, uri, uriref
const formatValidators: Record<
string,
(
node: SchemaNode,
value: unknown
) => undefined | JsonError | JsonError[]
(node: SchemaNode, value: unknown) => undefined | JsonError | JsonError[]
> = {
date: (node, value) => {
const { draft, schema, pointer } = node;
Expand Down Expand Up @@ -90,8 +88,15 @@ const formatValidators: Record<
// weeks cannot be combined with other units
const isInvalidDurationString = /(\d+M)(\d+W)|(\d+Y)(\d+W)/;

if (!isValidDurationString.test(value as string) || isInvalidDurationString.test(value as string)) {
return node.draft.errors.formatDurationError({ value, pointer: node.pointer, schema: node.schema });
if (
!isValidDurationString.test(value as string) ||
isInvalidDurationString.test(value as string)
) {
return node.draft.errors.formatDurationError({
value,
pointer: node.pointer,
schema: node.schema
});
}
},
email: (node, value) => {
Expand Down Expand Up @@ -217,31 +222,50 @@ const formatValidators: Record<
},

// hh:mm:ss.sTZD
// https://opis.io/json-schema/2.x/formats.html
// regex https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch04s07.html
// RFC 3339 https://datatracker.ietf.org/doc/html/rfc3339#section-4
time: (node, value) => {
const { draft, schema, pointer } = node;
if (typeof value !== "string" || value === "") {
return undefined;
}

// https://github.com/cfworker/cfworker/blob/main/packages/json-schema/src/format.ts
const matches = value.match(matchTime);
return matches ? undefined : draft.errors.formatDateTimeError({ value, pointer, schema });
// if (!matches) {
// return errors.formatDateTimeError({ value, pointer, schema });
// }
// const hour = +matches[1];
// const minute = +matches[2];
// const second = +matches[3];
// const timeZone = !!matches[5];
// if (
// ((hour <= 23 && minute <= 59 && second <= 59) ||
// (hour == 23 && minute == 59 && second == 60)) &&
// timeZone
// ) {
// return undefined;
// }
// return errors.formatTimeError({ value, pointer, schema });
if (!matches) {
return draft.errors.formatDateTimeError({ value, pointer, schema });
}

// leap second
if (matches.groups.second === "60") {
// bail early
if (/23:59:60(z|\+00:00)/i.test(value)) {
return undefined;
}
// check if sum matches 23:59
const minutes = matches.groups.time.match(/(\d+):(\d+):/);
const offsetMinutes = matches.groups.offset.match(/(\d+):(\d+)/);
if (offsetMinutes) {
const hour = parseInt(minutes[1]);
const offsetHour = parseInt(offsetMinutes[1]);
const min = parseInt(minutes[2]);
const offsetMin = parseInt(offsetMinutes[2]);
let deltaTime;
if (/^-/.test(matches.groups.offset)) {
deltaTime = (hour + offsetHour) * 60 + (min + offsetMin);
} else {
deltaTime = (24 + hour - offsetHour) * 60 + (min - offsetMin);
}
const hours = Math.floor(deltaTime / 60);
const actualHour = hours % 24;
const actualMinutes = deltaTime - hours * 60;
if (actualHour === 23 && actualMinutes === 59) {
return undefined;
}
}
return draft.errors.formatDateTimeError({ value, pointer, schema });
}

return undefined;
},

uri: (node, value) => {
Expand Down
47 changes: 24 additions & 23 deletions test/spec/v2019-09/draft2019-09.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,33 @@ const cache = new Draft2019();
draft2019MetaFormat,
draft2019MetaMetaData,
draft2019MetaValidation
].forEach(schema => { cache.addRemoteSchema(schema.$id, schema); })
].forEach((schema) => {
cache.addRemoteSchema(schema.$id, schema);
});
addRemotes(cache);

const supportedTestCases = (t: FeatureTest) => ![
// TODO CORE FEATURES
// "recursiveRef",
"vocabulary",
// OPTIONAL FEATURES
"cross-draft", // feature
"ecmascript-regex", // should
"float-overflow",
"dependencies-compatibility", // should
"format-date-time", // MUST
"format-time", // MUST
"format-iri",
"format-iri-reference",
"format-idn-hostname",
"format-uuid",
"non-bmp-regex", // should
"patterns always use unicode semantics with patternProperties"
].includes(t.name)
const supportedTestCases = (t: FeatureTest) =>
![
// TODO CORE FEATURES
"vocabulary",
// OPTIONAL FEATURES
"cross-draft", // feature
"ecmascript-regex", // should
"float-overflow",
"dependencies-compatibility", // should
"format-date-time", // MUST
// "format-time", // MUST
"format-iri",
"format-iri-reference",
"format-idn-hostname",
"format-uuid",
"non-bmp-regex", // should
"patterns always use unicode semantics with patternProperties"
].includes(t.name);

const draftFeatureTests = getDraftTests("2019-09")
// .filter(testcase => testcase.name === "recursiveRef")
// .filter(testcase => testcase.name === "defs")
// .filter((testcase) => testcase.name === "dependencies-compatibility")
// .filter((testcase) => testcase.name === "format-time")
.filter(supportedTestCases);

/*
Expand Down Expand Up @@ -96,7 +99,6 @@ const draftFeatureTests = getDraftTests("2019-09")
✖ vocabulary - skipped evaluation of meta-schema
*/


const postponedTestcases = [
// @todo when recursiveRef is implemented
"unevaluatedProperties with $recursiveRef",
Expand All @@ -118,7 +120,6 @@ const postponedTestcases = [
function runTestCase(tc: FeatureTest, skipTest: string[] = []) {
describe(`${tc.name}${tc.optional ? " (optional)" : ""}`, () => {
tc.testCases.forEach((testCase) => {

// if (testCase.description !== "$recursiveRef with nesting") { return; }
// if (testCase.description !== "remote ref, containing refs itself") { return; }

Expand Down

0 comments on commit 94cf3ec

Please sign in to comment.