diff --git a/README.md b/README.md index 584af36f0..97cf7a725 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_ZH.md b/README_ZH.md index 1ace66923..ad19b414a 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -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); diff --git a/deno/lib/README.md b/deno/lib/README.md index 584af36f0..97cf7a725 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -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 diff --git a/deno/lib/ZodError.ts b/deno/lib/ZodError.ts index 3863c3e56..e757cd8ba 100644 --- a/deno/lib/ZodError.ts +++ b/deno/lib/ZodError.ts @@ -101,6 +101,7 @@ export type StringValidation = | "datetime" | "date" | "time" + | "duration" | "ip" | "base64" | { includes: string; position?: number } diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index d701be5f0..3f5b95562 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -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); diff --git a/deno/lib/benchmarks/datetime.ts b/deno/lib/benchmarks/datetime.ts index 35dab11c1..8de021a5a 100644 --- a/deno/lib/benchmarks/datetime.ts +++ b/deno/lib/benchmarks/datetime.ts @@ -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))$/; diff --git a/deno/lib/types.ts b/deno/lib/types.ts index 0e574c8fb..1895e435d 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -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 }; @@ -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; @@ -906,6 +910,16 @@ export class ZodString extends ZodType { }); 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); @@ -1046,6 +1060,10 @@ export class ZodString extends ZodType { }); } + duration(message?: errorUtil.ErrMessage) { + return this._addCheck({ kind: "duration", ...errorUtil.errToObj(message) }); + } + regex(regex: RegExp, message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "regex", @@ -1143,6 +1161,9 @@ export class ZodString extends ZodType { 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"); diff --git a/src/ZodError.ts b/src/ZodError.ts index 61e79316d..c1f7aa3ee 100644 --- a/src/ZodError.ts +++ b/src/ZodError.ts @@ -101,6 +101,7 @@ export type StringValidation = | "datetime" | "date" | "time" + | "duration" | "ip" | "base64" | { includes: string; position?: number } diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index 44b734583..db01a64eb 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -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); diff --git a/src/benchmarks/datetime.ts b/src/benchmarks/datetime.ts index 35dab11c1..8de021a5a 100644 --- a/src/benchmarks/datetime.ts +++ b/src/benchmarks/datetime.ts @@ -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))$/; diff --git a/src/types.ts b/src/types.ts index bcbb343c0..1205276b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 }; @@ -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; @@ -906,6 +910,16 @@ export class ZodString extends ZodType { }); 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); @@ -1046,6 +1060,10 @@ export class ZodString extends ZodType { }); } + duration(message?: errorUtil.ErrMessage) { + return this._addCheck({ kind: "duration", ...errorUtil.errToObj(message) }); + } + regex(regex: RegExp, message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "regex", @@ -1143,6 +1161,9 @@ export class ZodString extends ZodType { 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");