diff --git a/README.md b/README.md index d7b5f62bb..9cebe0257 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ - [Coercion for primitives](#coercion-for-primitives) - [Literals](#literals) - [Strings](#strings) + - [Datetime](#datetime-validation) + - [IP](#ip-address-validation) - [Numbers](#numbers) - [NaNs](#nans) - [Booleans](#booleans) @@ -594,6 +596,7 @@ z.string().startsWith(string); z.string().endsWith(string); z.string().trim(); // trim whitespace z.string().datetime(); // defaults to UTC, see below for options +z.string().ip(); // defaults to IPv4 and IPv6, see below for options ``` > Check out [validator.js](https://github.com/validatorjs/validator.js) for a bunch of other useful string validation functions that can be used in conjunction with [Refinements](#refine). @@ -619,6 +622,7 @@ z.string().uuid({ message: "Invalid UUID" }); z.string().startsWith("https://", { message: "Must provide secure URL" }); z.string().endsWith(".com", { message: "Only .com domains allowed" }); z.string().datetime({ message: "Invalid datetime string! Must be UTC." }); +z.string().ip({ message: "Invalid IP address" }); ``` ### Datetime validation @@ -656,6 +660,31 @@ datetime.parse("2020-01-01T00:00:00Z"); // fail datetime.parse("2020-01-01T00:00:00.123456Z"); // fail ``` +### IP address validation + +The `z.string().ip()` method by default validate IPv4 and IPv6. + +```ts +const ip = z.string().ip(); + +ip.parse("192.168.1.1"); // pass +ip.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003"); // pass +ip.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:192.168.1.1"); // pass + +ip.parse("256.1.1.1"); // fail +ip.parse("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003"); // fail +``` + +You can additionally set the IP `version`. + +```ts +const ipv4 = z.string().ip({ version: "v4" }); +ipv4.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003"); // fail + +const ipv6 = z.string().ip({ version: "v6" }); +ipv6.parse("192.168.1.1"); // fail +``` + ## Numbers You can customize certain error messages when creating a number schema. diff --git a/deno/lib/README.md b/deno/lib/README.md index d7b5f62bb..9cebe0257 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -55,6 +55,8 @@ - [Coercion for primitives](#coercion-for-primitives) - [Literals](#literals) - [Strings](#strings) + - [Datetime](#datetime-validation) + - [IP](#ip-address-validation) - [Numbers](#numbers) - [NaNs](#nans) - [Booleans](#booleans) @@ -594,6 +596,7 @@ z.string().startsWith(string); z.string().endsWith(string); z.string().trim(); // trim whitespace z.string().datetime(); // defaults to UTC, see below for options +z.string().ip(); // defaults to IPv4 and IPv6, see below for options ``` > Check out [validator.js](https://github.com/validatorjs/validator.js) for a bunch of other useful string validation functions that can be used in conjunction with [Refinements](#refine). @@ -619,6 +622,7 @@ z.string().uuid({ message: "Invalid UUID" }); z.string().startsWith("https://", { message: "Must provide secure URL" }); z.string().endsWith(".com", { message: "Only .com domains allowed" }); z.string().datetime({ message: "Invalid datetime string! Must be UTC." }); +z.string().ip({ message: "Invalid IP address" }); ``` ### Datetime validation @@ -656,6 +660,31 @@ datetime.parse("2020-01-01T00:00:00Z"); // fail datetime.parse("2020-01-01T00:00:00.123456Z"); // fail ``` +### IP address validation + +The `z.string().ip()` method by default validate IPv4 and IPv6. + +```ts +const ip = z.string().ip(); + +ip.parse("192.168.1.1"); // pass +ip.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003"); // pass +ip.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:192.168.1.1"); // pass + +ip.parse("256.1.1.1"); // fail +ip.parse("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003"); // fail +``` + +You can additionally set the IP `version`. + +```ts +const ipv4 = z.string().ip({ version: "v4" }); +ipv4.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003"); // fail + +const ipv6 = z.string().ip({ version: "v6" }); +ipv6.parse("192.168.1.1"); // fail +``` + ## Numbers You can customize certain error messages when creating a number schema. diff --git a/deno/lib/ZodError.ts b/deno/lib/ZodError.ts index 365cb3cae..dcf4cc18a 100644 --- a/deno/lib/ZodError.ts +++ b/deno/lib/ZodError.ts @@ -97,6 +97,7 @@ export type StringValidation = | "cuid" | "cuid2" | "datetime" + | "ip" | { startsWith: string } | { endsWith: string }; diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index b3996bec8..8c46e48ad 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -249,30 +249,42 @@ test("checks getters", () => { expect(z.string().email().isCUID).toEqual(false); expect(z.string().email().isCUID2).toEqual(false); expect(z.string().email().isUUID).toEqual(false); + expect(z.string().email().isIP).toEqual(false); expect(z.string().url().isEmail).toEqual(false); expect(z.string().url().isURL).toEqual(true); expect(z.string().url().isCUID).toEqual(false); expect(z.string().url().isCUID2).toEqual(false); expect(z.string().url().isUUID).toEqual(false); + expect(z.string().url().isIP).toEqual(false); expect(z.string().cuid().isEmail).toEqual(false); expect(z.string().cuid().isURL).toEqual(false); expect(z.string().cuid().isCUID).toEqual(true); expect(z.string().cuid().isCUID2).toEqual(false); expect(z.string().cuid().isUUID).toEqual(false); + expect(z.string().cuid().isIP).toEqual(false); expect(z.string().cuid2().isEmail).toEqual(false); expect(z.string().cuid2().isURL).toEqual(false); expect(z.string().cuid2().isCUID).toEqual(false); expect(z.string().cuid2().isCUID2).toEqual(true); expect(z.string().cuid2().isUUID).toEqual(false); + expect(z.string().cuid2().isIP).toEqual(false); expect(z.string().uuid().isEmail).toEqual(false); expect(z.string().uuid().isURL).toEqual(false); expect(z.string().uuid().isCUID).toEqual(false); expect(z.string().uuid().isCUID2).toEqual(false); expect(z.string().uuid().isUUID).toEqual(true); + expect(z.string().uuid().isIP).toEqual(false); + + expect(z.string().ip().isEmail).toEqual(false); + expect(z.string().ip().isURL).toEqual(false); + expect(z.string().ip().isCUID).toEqual(false); + expect(z.string().ip().isCUID2).toEqual(false); + expect(z.string().ip().isUUID).toEqual(false); + expect(z.string().ip().isIP).toEqual(true); }); test("min max getters", () => { @@ -378,3 +390,44 @@ test("datetime parsing", () => { datetimeOffset4Ms.parse("2020-10-14T17:42:29.124+00:00") ).toThrow(); }); + +test("IP validation", () => { + const ip = z.string().ip(); + expect(ip.safeParse("122.122.122.122").success).toBe(true); + + const ipv4 = z.string().ip({ version: "v4" }); + expect(() => ipv4.parse("6097:adfa:6f0b:220d:db08:5021:6191:7990")).toThrow(); + + const ipv6 = z.string().ip({ version: "v6" }); + expect(() => ipv6.parse("254.164.77.1")).toThrow(); + + const validIPs = [ + "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c", + "9d4:c956:420f:5788:4339:9b3b:2418:75c3", + "a6ea::2454:a5ce:94.105.123.75", + "474f:4c83::4e40:a47:ff95:0cda", + "d329:0:25b4:db47:a9d1:0:4926:0000", + "e48:10fb:1499:3e28:e4b6:dea5:4692:912c", + "114.71.82.94", + "0.0.0.0", + "37.85.236.115", + ]; + + const invalidIPs = [ + "d329:1be4:25b4:db47:a9d1:dc71:4926:992c:14af", + "d5e7:7214:2b78::3906:85e6:53cc:709:32ba", + "8f69::c757:395e:976e::3441", + "54cb::473f:d516:0.255.256.22", + "54cb::473f:d516:192.168.1", + "256.0.4.4", + "-1.0.555.4", + "0.0.0.0.0", + "1.1.1", + ]; + // no parameters check IPv4 or IPv6 + const ipSchema = z.string().ip(); + expect(validIPs.every((ip) => ipSchema.safeParse(ip).success)).toBe(true); + expect( + invalidIPs.every((ip) => ipSchema.safeParse(ip).success === false) + ).toBe(true); +}); diff --git a/deno/lib/types.ts b/deno/lib/types.ts index 15f5b0bb2..2c4c5fd8a 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -486,6 +486,7 @@ export abstract class ZodType< ////////// ////////// ///////////////////////////////////////// ///////////////////////////////////////// +export type IpVersion = "v4" | "v6"; export type ZodStringCheck = | { kind: "min"; value: number; message?: string } | { kind: "max"; value: number; message?: string } @@ -505,7 +506,8 @@ export type ZodStringCheck = offset: boolean; precision: number | null; message?: string; - }; + } + | { kind: "ip"; version?: IpVersion; message?: string }; export interface ZodStringDef extends ZodTypeDef { checks: ZodStringCheck[]; @@ -531,6 +533,12 @@ const emailRegex = const emojiRegex = /(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|\uFE0E|\uFE0F)/; +const ipv4Regex = + /^(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))$/; + +const ipv6Regex = + /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/; + // Adapted from https://stackoverflow.com/a/3143231 const datetimeRegex = (args: { precision: number | null; offset: boolean }) => { if (args.precision) { @@ -564,6 +572,17 @@ const datetimeRegex = (args: { precision: number | null; offset: boolean }) => { } }; +function isValidIP(ip: string, version?: IpVersion) { + if ((version === "v4" || !version) && ipv4Regex.test(ip)) { + return true; + } + if ((version === "v6" || !version) && ipv6Regex.test(ip)) { + return true; + } + + return false; +} + export class ZodString extends ZodType { _parse(input: ParseInput): ParseReturnType { if (this._def.coerce) { @@ -749,6 +768,16 @@ export class ZodString extends ZodType { }); status.dirty(); } + } else if (check.kind === "ip") { + if (!isValidIP(input.data, check.version)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "ip", + code: ZodIssueCode.invalid_string, + message: check.message, + }); + status.dirty(); + } } else { util.assertNever(check); } @@ -793,6 +822,11 @@ export class ZodString extends ZodType { cuid2(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "cuid2", ...errorUtil.errToObj(message) }); } + + ip(options?: string | { version?: "v4" | "v6"; message?: string }) { + return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); + } + datetime( options?: | string @@ -902,6 +936,9 @@ export class ZodString extends ZodType { get isCUID2() { return !!this._def.checks.find((ch) => ch.kind === "cuid2"); } + get isIP() { + return !!this._def.checks.find((ch) => ch.kind === "ip"); + } get minLength() { let min: number | null = null; diff --git a/src/ZodError.ts b/src/ZodError.ts index 03a34432a..0833ae614 100644 --- a/src/ZodError.ts +++ b/src/ZodError.ts @@ -97,6 +97,7 @@ export type StringValidation = | "cuid" | "cuid2" | "datetime" + | "ip" | { startsWith: string } | { endsWith: string }; diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index ac3dcd144..141f4ab22 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -248,30 +248,42 @@ test("checks getters", () => { expect(z.string().email().isCUID).toEqual(false); expect(z.string().email().isCUID2).toEqual(false); expect(z.string().email().isUUID).toEqual(false); + expect(z.string().email().isIP).toEqual(false); expect(z.string().url().isEmail).toEqual(false); expect(z.string().url().isURL).toEqual(true); expect(z.string().url().isCUID).toEqual(false); expect(z.string().url().isCUID2).toEqual(false); expect(z.string().url().isUUID).toEqual(false); + expect(z.string().url().isIP).toEqual(false); expect(z.string().cuid().isEmail).toEqual(false); expect(z.string().cuid().isURL).toEqual(false); expect(z.string().cuid().isCUID).toEqual(true); expect(z.string().cuid().isCUID2).toEqual(false); expect(z.string().cuid().isUUID).toEqual(false); + expect(z.string().cuid().isIP).toEqual(false); expect(z.string().cuid2().isEmail).toEqual(false); expect(z.string().cuid2().isURL).toEqual(false); expect(z.string().cuid2().isCUID).toEqual(false); expect(z.string().cuid2().isCUID2).toEqual(true); expect(z.string().cuid2().isUUID).toEqual(false); + expect(z.string().cuid2().isIP).toEqual(false); expect(z.string().uuid().isEmail).toEqual(false); expect(z.string().uuid().isURL).toEqual(false); expect(z.string().uuid().isCUID).toEqual(false); expect(z.string().uuid().isCUID2).toEqual(false); expect(z.string().uuid().isUUID).toEqual(true); + expect(z.string().uuid().isIP).toEqual(false); + + expect(z.string().ip().isEmail).toEqual(false); + expect(z.string().ip().isURL).toEqual(false); + expect(z.string().ip().isCUID).toEqual(false); + expect(z.string().ip().isCUID2).toEqual(false); + expect(z.string().ip().isUUID).toEqual(false); + expect(z.string().ip().isIP).toEqual(true); }); test("min max getters", () => { @@ -377,3 +389,44 @@ test("datetime parsing", () => { datetimeOffset4Ms.parse("2020-10-14T17:42:29.124+00:00") ).toThrow(); }); + +test("IP validation", () => { + const ip = z.string().ip(); + expect(ip.safeParse("122.122.122.122").success).toBe(true); + + const ipv4 = z.string().ip({ version: "v4" }); + expect(() => ipv4.parse("6097:adfa:6f0b:220d:db08:5021:6191:7990")).toThrow(); + + const ipv6 = z.string().ip({ version: "v6" }); + expect(() => ipv6.parse("254.164.77.1")).toThrow(); + + const validIPs = [ + "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c", + "9d4:c956:420f:5788:4339:9b3b:2418:75c3", + "a6ea::2454:a5ce:94.105.123.75", + "474f:4c83::4e40:a47:ff95:0cda", + "d329:0:25b4:db47:a9d1:0:4926:0000", + "e48:10fb:1499:3e28:e4b6:dea5:4692:912c", + "114.71.82.94", + "0.0.0.0", + "37.85.236.115", + ]; + + const invalidIPs = [ + "d329:1be4:25b4:db47:a9d1:dc71:4926:992c:14af", + "d5e7:7214:2b78::3906:85e6:53cc:709:32ba", + "8f69::c757:395e:976e::3441", + "54cb::473f:d516:0.255.256.22", + "54cb::473f:d516:192.168.1", + "256.0.4.4", + "-1.0.555.4", + "0.0.0.0.0", + "1.1.1", + ]; + // no parameters check IPv4 or IPv6 + const ipSchema = z.string().ip(); + expect(validIPs.every((ip) => ipSchema.safeParse(ip).success)).toBe(true); + expect( + invalidIPs.every((ip) => ipSchema.safeParse(ip).success === false) + ).toBe(true); +}); diff --git a/src/types.ts b/src/types.ts index 82bd23d21..d03b51a2c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -486,6 +486,7 @@ export abstract class ZodType< ////////// ////////// ///////////////////////////////////////// ///////////////////////////////////////// +export type IpVersion = "v4" | "v6"; export type ZodStringCheck = | { kind: "min"; value: number; message?: string } | { kind: "max"; value: number; message?: string } @@ -505,7 +506,8 @@ export type ZodStringCheck = offset: boolean; precision: number | null; message?: string; - }; + } + | { kind: "ip"; version?: IpVersion; message?: string }; export interface ZodStringDef extends ZodTypeDef { checks: ZodStringCheck[]; @@ -531,6 +533,12 @@ const emailRegex = const emojiRegex = /(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|\uFE0E|\uFE0F)/; +const ipv4Regex = + /^(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))$/; + +const ipv6Regex = + /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/; + // Adapted from https://stackoverflow.com/a/3143231 const datetimeRegex = (args: { precision: number | null; offset: boolean }) => { if (args.precision) { @@ -564,6 +572,17 @@ const datetimeRegex = (args: { precision: number | null; offset: boolean }) => { } }; +function isValidIP(ip: string, version?: IpVersion) { + if ((version === "v4" || !version) && ipv4Regex.test(ip)) { + return true; + } + if ((version === "v6" || !version) && ipv6Regex.test(ip)) { + return true; + } + + return false; +} + export class ZodString extends ZodType { _parse(input: ParseInput): ParseReturnType { if (this._def.coerce) { @@ -749,6 +768,16 @@ export class ZodString extends ZodType { }); status.dirty(); } + } else if (check.kind === "ip") { + if (!isValidIP(input.data, check.version)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "ip", + code: ZodIssueCode.invalid_string, + message: check.message, + }); + status.dirty(); + } } else { util.assertNever(check); } @@ -793,6 +822,11 @@ export class ZodString extends ZodType { cuid2(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "cuid2", ...errorUtil.errToObj(message) }); } + + ip(options?: string | { version?: "v4" | "v6"; message?: string }) { + return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); + } + datetime( options?: | string @@ -902,6 +936,9 @@ export class ZodString extends ZodType { get isCUID2() { return !!this._def.checks.find((ch) => ch.kind === "cuid2"); } + get isIP() { + return !!this._def.checks.find((ch) => ch.kind === "ip"); + } get minLength() { let min: number | null = null;