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

feat: Add support for ISO-8601 Durations #3265

Merged
merged 3 commits into from
Apr 17, 2024
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -858,10 +858,11 @@ z.string().endsWith(string);
z.string().datetime(); // ISO 8601; by default only `Z` timezone allowed
z.string().date(); // ISO date format (YYYY-MM-DD)
z.string().time(); // ISO time format (HH:mm:ss[.SSSSSS])
z.string().duration(); // ISO 8601 duration
z.string().ip(); // defaults to allow both IPv4 and IPv6
z.string().base64();

// transformations
// transforms
z.string().trim(); // trim whitespace
z.string().toLowerCase(); // toLowerCase
z.string().toUpperCase(); // toUpperCase
Expand Down
1 change: 1 addition & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,7 @@ z.string().uuid();
z.string().cuid();
z.string().cuid2();
z.string().ulid();
z.string().duration();
z.string().regex(regex);
z.string().includes(string);
z.string().startsWith(string);
Expand Down
3 changes: 2 additions & 1 deletion deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -858,10 +858,11 @@ z.string().endsWith(string);
z.string().datetime(); // ISO 8601; by default only `Z` timezone allowed
z.string().date(); // ISO date format (YYYY-MM-DD)
z.string().time(); // ISO time format (HH:mm:ss[.SSSSSS])
z.string().duration(); // ISO 8601 duration
z.string().ip(); // defaults to allow both IPv4 and IPv6
z.string().base64();

// transformations
// transforms
z.string().trim(); // trim whitespace
z.string().toLowerCase(); // toLowerCase
z.string().toUpperCase(); // toUpperCase
Expand Down
1 change: 1 addition & 0 deletions deno/lib/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export type StringValidation =
| "datetime"
| "date"
| "time"
| "duration"
| "ip"
| "base64"
| { includes: string; position?: number }
Expand Down
54 changes: 54 additions & 0 deletions deno/lib/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,60 @@ test("time parsing", () => {
// expect(() => time4.parse("00:00:00.000+00:00")).toThrow();
});

test("duration", () => {
const duration = z.string().duration();
expect(duration.isDuration).toEqual(true);

const validDurations = [
"P3Y6M4DT12H30M5S",
"P2Y9M3DT12H31M8.001S",
"+P3Y6M4DT12H30M5S",
"-PT0.001S",
"+PT0.001S",
"PT0,001S",
"PT12H30M5S",
"-P2M1D",
"P-2M-1D",
"-P5DT10H",
"P-5DT-10H",
"P1Y",
"P2MT30M",
"PT6H",
"P5W",
"P0.5Y",
"P0,5Y",
"P42YT7.004M",
];

const invalidDurations = [
"foo bar",
"",
" ",
"P",
"T1H",
"P0.5Y1D",
"P0,5Y6M",
"P1YT",
];

for (const val of validDurations) {
const result = duration.safeParse(val);
if (!result.success) {
throw Error(`Valid duration could not be parsed: ${val}`);
}
}

for (const val of invalidDurations) {
const result = duration.safeParse(val);

if (result.success) {
throw Error(`Invalid duration was successful parsed: ${val}`);
}

expect(result.error.issues[0].message).toEqual("Invalid duration");
}
});

test("IP validation", () => {
const ip = z.string().ip();
expect(ip.safeParse("122.122.122.122").success).toBe(true);
Expand Down
9 changes: 0 additions & 9 deletions deno/lib/benchmarks/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,6 @@ const DATA = "2021-01-01";
const MONTHS_31 = new Set([1, 3, 5, 7, 8, 10, 12]);
const MONTHS_30 = new Set([4, 6, 9, 11]);

function generateRandomDatetime(): string {
const year = Math.floor(Math.random() * 3000);
const month = Math.floor(Math.random() * 12) + 1;
const day = Math.floor(Math.random() * 31) + 1;
return `${year}-${month.toString().padStart(2, "0")}-${day
.toString()
.padStart(2, "0")}`;
}

const simpleDatetimeRegex = /^(\d{4})-(\d{2})-(\d{2})$/;
const datetimeRegexNoLeapYearValidation =
/^\d{4}-((0[13578]|10|12)-31|(0[13-9]|1[0-2])-30|(0[1-9]|1[0-2])-(0[1-9]|1\d|2\d))$/;
Expand Down
21 changes: 21 additions & 0 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ export type ZodStringCheck =
precision: number | null;
message?: string;
}
| { kind: "duration"; message?: string }
| { kind: "ip"; version?: IpVersion; message?: string }
| { kind: "base64"; message?: string };

Expand All @@ -580,6 +581,9 @@ const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const uuidRegex =
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i;
const nanoidRegex = /^[a-z0-9_-]{21}$/i;
const durationRegex =
/^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/;

// from https://stackoverflow.com/a/46181/1550155
// old version: too slow, didn't support unicode
// const emailRegex = /^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$/i;
Expand Down Expand Up @@ -906,6 +910,16 @@ export class ZodString extends ZodType<string, ZodStringDef> {
});
status.dirty();
}
} else if (check.kind === "duration") {
if (!durationRegex.test(input.data)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "duration",
code: ZodIssueCode.invalid_string,
message: check.message,
});
status.dirty();
}
} else if (check.kind === "ip") {
if (!isValidIP(input.data, check.version)) {
ctx = this._getOrReturnCtx(input, ctx);
Expand Down Expand Up @@ -1046,6 +1060,10 @@ export class ZodString extends ZodType<string, ZodStringDef> {
});
}

duration(message?: errorUtil.ErrMessage) {
return this._addCheck({ kind: "duration", ...errorUtil.errToObj(message) });
}

regex(regex: RegExp, message?: errorUtil.ErrMessage) {
return this._addCheck({
kind: "regex",
Expand Down Expand Up @@ -1143,6 +1161,9 @@ export class ZodString extends ZodType<string, ZodStringDef> {
get isTime() {
return !!this._def.checks.find((ch) => ch.kind === "time");
}
get isDuration() {
return !!this._def.checks.find((ch) => ch.kind === "duration");
}

get isEmail() {
return !!this._def.checks.find((ch) => ch.kind === "email");
Expand Down
1 change: 1 addition & 0 deletions src/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export type StringValidation =
| "datetime"
| "date"
| "time"
| "duration"
| "ip"
| "base64"
| { includes: string; position?: number }
Expand Down
54 changes: 54 additions & 0 deletions src/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,60 @@ test("time parsing", () => {
// expect(() => time4.parse("00:00:00.000+00:00")).toThrow();
});

test("duration", () => {
const duration = z.string().duration();
expect(duration.isDuration).toEqual(true);

const validDurations = [
"P3Y6M4DT12H30M5S",
"P2Y9M3DT12H31M8.001S",
"+P3Y6M4DT12H30M5S",
"-PT0.001S",
"+PT0.001S",
"PT0,001S",
"PT12H30M5S",
"-P2M1D",
"P-2M-1D",
"-P5DT10H",
"P-5DT-10H",
"P1Y",
"P2MT30M",
"PT6H",
"P5W",
"P0.5Y",
"P0,5Y",
"P42YT7.004M",
];

const invalidDurations = [
"foo bar",
"",
" ",
"P",
"T1H",
"P0.5Y1D",
"P0,5Y6M",
"P1YT",
];

for (const val of validDurations) {
const result = duration.safeParse(val);
if (!result.success) {
throw Error(`Valid duration could not be parsed: ${val}`);
}
}

for (const val of invalidDurations) {
const result = duration.safeParse(val);

if (result.success) {
throw Error(`Invalid duration was successful parsed: ${val}`);
}

expect(result.error.issues[0].message).toEqual("Invalid duration");
}
});

test("IP validation", () => {
const ip = z.string().ip();
expect(ip.safeParse("122.122.122.122").success).toBe(true);
Expand Down
9 changes: 0 additions & 9 deletions src/benchmarks/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,6 @@ const DATA = "2021-01-01";
const MONTHS_31 = new Set([1, 3, 5, 7, 8, 10, 12]);
const MONTHS_30 = new Set([4, 6, 9, 11]);

function generateRandomDatetime(): string {
const year = Math.floor(Math.random() * 3000);
const month = Math.floor(Math.random() * 12) + 1;
const day = Math.floor(Math.random() * 31) + 1;
return `${year}-${month.toString().padStart(2, "0")}-${day
.toString()
.padStart(2, "0")}`;
}

const simpleDatetimeRegex = /^(\d{4})-(\d{2})-(\d{2})$/;
const datetimeRegexNoLeapYearValidation =
/^\d{4}-((0[13578]|10|12)-31|(0[13-9]|1[0-2])-30|(0[1-9]|1[0-2])-(0[1-9]|1\d|2\d))$/;
Expand Down
21 changes: 21 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ export type ZodStringCheck =
precision: number | null;
message?: string;
}
| { kind: "duration"; message?: string }
| { kind: "ip"; version?: IpVersion; message?: string }
| { kind: "base64"; message?: string };

Expand All @@ -580,6 +581,9 @@ const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const uuidRegex =
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i;
const nanoidRegex = /^[a-z0-9_-]{21}$/i;
const durationRegex =
/^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/;

// from https://stackoverflow.com/a/46181/1550155
// old version: too slow, didn't support unicode
// const emailRegex = /^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$/i;
Expand Down Expand Up @@ -906,6 +910,16 @@ export class ZodString extends ZodType<string, ZodStringDef> {
});
status.dirty();
}
} else if (check.kind === "duration") {
if (!durationRegex.test(input.data)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "duration",
code: ZodIssueCode.invalid_string,
message: check.message,
});
status.dirty();
}
} else if (check.kind === "ip") {
if (!isValidIP(input.data, check.version)) {
ctx = this._getOrReturnCtx(input, ctx);
Expand Down Expand Up @@ -1046,6 +1060,10 @@ export class ZodString extends ZodType<string, ZodStringDef> {
});
}

duration(message?: errorUtil.ErrMessage) {
return this._addCheck({ kind: "duration", ...errorUtil.errToObj(message) });
}

regex(regex: RegExp, message?: errorUtil.ErrMessage) {
return this._addCheck({
kind: "regex",
Expand Down Expand Up @@ -1143,6 +1161,9 @@ export class ZodString extends ZodType<string, ZodStringDef> {
get isTime() {
return !!this._def.checks.find((ch) => ch.kind === "time");
}
get isDuration() {
return !!this._def.checks.find((ch) => ch.kind === "duration");
}

get isEmail() {
return !!this._def.checks.find((ch) => ch.kind === "email");
Expand Down
Loading