Skip to content

Commit

Permalink
feat(#2059): z.string.ip() - add support for IP address (#2066)
Browse files Browse the repository at this point in the history
* Add base implementation of string ip validation

For now check only IPv4

* Add IP version

If the version is not defined, the check goes
for a valid IP whether it is version 4 or 6

* Add IP in docs

* Implement IPv6 validation

* Use errToObj and update readme

---------

Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
  • Loading branch information
fvckDesa and colinhacks committed Feb 26, 2023
1 parent 5ec98e1 commit 7d40ba5
Show file tree
Hide file tree
Showing 8 changed files with 242 additions and 2 deletions.
29 changes: 29 additions & 0 deletions README.md
Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions deno/lib/README.md
Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions deno/lib/ZodError.ts
Expand Up @@ -97,6 +97,7 @@ export type StringValidation =
| "cuid"
| "cuid2"
| "datetime"
| "ip"
| { startsWith: string }
| { endsWith: string };

Expand Down
53 changes: 53 additions & 0 deletions deno/lib/__tests__/string.test.ts
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
39 changes: 38 additions & 1 deletion deno/lib/types.ts
Expand Up @@ -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 }
Expand All @@ -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[];
Expand All @@ -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) {
Expand Down Expand Up @@ -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<string, ZodStringDef> {
_parse(input: ParseInput): ParseReturnType<string> {
if (this._def.coerce) {
Expand Down Expand Up @@ -749,6 +768,16 @@ export class ZodString extends ZodType<string, ZodStringDef> {
});
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);
}
Expand Down Expand Up @@ -793,6 +822,11 @@ export class ZodString extends ZodType<string, ZodStringDef> {
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
Expand Down Expand Up @@ -902,6 +936,9 @@ export class ZodString extends ZodType<string, ZodStringDef> {
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;
Expand Down
1 change: 1 addition & 0 deletions src/ZodError.ts
Expand Up @@ -97,6 +97,7 @@ export type StringValidation =
| "cuid"
| "cuid2"
| "datetime"
| "ip"
| { startsWith: string }
| { endsWith: string };

Expand Down
53 changes: 53 additions & 0 deletions src/__tests__/string.test.ts
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});

0 comments on commit 7d40ba5

Please sign in to comment.