Skip to content

Commit

Permalink
feat: added support for hostname method in z.string() #3589
Browse files Browse the repository at this point in the history
  • Loading branch information
Kumar06Lav committed Aug 8, 2024
1 parent 821d45b commit 342fc32
Show file tree
Hide file tree
Showing 11 changed files with 105 additions and 2 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,7 @@ z.string().cuid2();
z.string().ulid();
z.string().regex(regex);
z.string().includes(string);
z.string().hostname();
z.string().startsWith(string);
z.string().endsWith(string);
z.string().datetime(); // ISO 8601; by default only `Z` timezone allowed
Expand Down Expand Up @@ -774,6 +775,7 @@ z.string().url({ message: "Invalid url" });
z.string().emoji({ message: "Contains non-emoji characters" });
z.string().uuid({ message: "Invalid UUID" });
z.string().includes("tuna", { message: "Must include tuna" });
z.string().hostname({ message: "Must provide a valid hostname" });
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." });
Expand Down
2 changes: 2 additions & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@ z.string().ulid();
z.string().duration();
z.string().regex(regex);
z.string().includes(string);
z.string().hostname();
z.string().startsWith(string);
z.string().endsWith(string);
z.string().datetime(); // ISO 8601;默认值为无 UTC 偏移,选项见下文
Expand Down Expand Up @@ -516,6 +517,7 @@ z.string().url({ message: "Invalid url" });
z.string().emoji({ message: "Contains non-emoji characters" });
z.string().uuid({ message: "Invalid UUID" });
z.string().includes("tuna", { message: "Must include tuna" });
z.string().hostname({ message: "Must provide a valid hostname" });
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." });
Expand Down
7 changes: 5 additions & 2 deletions deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ Sponsorship at any level is appreciated and encouraged. If you built a paid prod
<td align="center">
<p></p>
<p>
<a href="https://speakeasyapi.dev/?utm_source=zod+docs">
<a href="https://speakeasy.com/?utm_source=zod+docs">
<picture height="40px">
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/colinhacks/zod/assets/3084745/b1d86601-c7fb-483c-9927-5dc24ce8b737">
<img alt="speakeasy" height="40px" src="https://github.com/colinhacks/zod/assets/3084745/647524a4-22bb-4199-be70-404207a5a2b5">
Expand All @@ -261,7 +261,7 @@ Sponsorship at any level is appreciated and encouraged. If you built a paid prod
<br />
SDKs & Terraform providers for your API
<br/>
<a href="https://speakeasyapi.dev/?utm_source=zod+docs" style="text-decoration:none;">speakeasyapi.dev</a>
<a href="https://speakeasy.com/?utm_source=zod+docs" style="text-decoration:none;">speakeasy.com</a>
</p>
<p></p>
</td>
Expand Down Expand Up @@ -490,6 +490,7 @@ There are a growing number of tools that are built atop or support Zod natively!
- [`zod-prisma`](https://github.com/CarterGrimmeisen/zod-prisma): Generate Zod schemas from your Prisma schema.
- [`Supervillain`](https://github.com/Southclaws/supervillain): Generate Zod schemas from your Go structs.
- [`prisma-zod-generator`](https://github.com/omar-dulaimi/prisma-zod-generator): Emit Zod schemas from your Prisma schema.
- [`drizzle-zod`](https://orm.drizzle.team/docs/zod): Emit Zod schemas from your Drizzle schema.
- [`prisma-trpc-generator`](https://github.com/omar-dulaimi/prisma-trpc-generator): Emit fully implemented tRPC routers and their validation schemas using Zod.
- [`zod-prisma-types`](https://github.com/chrishoermann/zod-prisma-types) Create Zod types from your Prisma models.
- [`quicktype`](https://app.quicktype.io/): Convert JSON objects and JSON schemas into Zod schemas.
Expand Down Expand Up @@ -734,6 +735,7 @@ z.string().cuid2();
z.string().ulid();
z.string().regex(regex);
z.string().includes(string);
z.string().hostname();
z.string().startsWith(string);
z.string().endsWith(string);
z.string().datetime(); // ISO 8601; by default only `Z` timezone allowed
Expand Down Expand Up @@ -773,6 +775,7 @@ z.string().url({ message: "Invalid url" });
z.string().emoji({ message: "Contains non-emoji characters" });
z.string().uuid({ message: "Invalid UUID" });
z.string().includes("tuna", { message: "Must include tuna" });
z.string().hostname({ message: "Must provide a valid hostname" });
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." });
Expand Down
1 change: 1 addition & 0 deletions deno/lib/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export type StringValidation =
| "base64"
| { includes: string; position?: number }
| { startsWith: string }
| { hostname: string }
| { endsWith: string };

export interface ZodInvalidStringIssue extends ZodIssueBase {
Expand Down
14 changes: 14 additions & 0 deletions deno/lib/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const includes = z.string().includes("includes");
const includesFromIndex2 = z.string().includes("includes", { position: 2 });
const startsWith = z.string().startsWith("startsWith");
const endsWith = z.string().endsWith("endsWith");
const hostname = z.string().hostname();

test("passing validations", () => {
minFive.parse("12345");
Expand All @@ -24,6 +25,12 @@ test("passing validations", () => {
includesFromIndex2.parse("XXXincludesXX");
startsWith.parse("startsWithX");
endsWith.parse("XendsWith");
hostname.parse("developer.mozilla.org");
hostname.parse("hello.world.example.com");
hostname.parse("www.google.com");
hostname.parse("[2001:db8::ff00:42:8329]");
hostname.parse("192.168.1.1");
hostname.parse("xn--d1acj3b");
});

test("failing validations", () => {
Expand All @@ -36,6 +43,13 @@ test("failing validations", () => {
expect(() => includesFromIndex2.parse("XincludesXX")).toThrow();
expect(() => startsWith.parse("x")).toThrow();
expect(() => endsWith.parse("x")).toThrow();
expect(() => hostname.parse("ht!tp://invalid.com")).toThrow();
expect(() => hostname.parse("xn--d1acj3b..com")).toThrow();
expect(() => hostname.parse("[email protected]")).toThrow();
expect(() => hostname.parse("[2001:db8::zzzz]")).toThrow();
expect(() => hostname.parse("exa mple.com")).toThrow();
expect(() => hostname.parse("-example.com")).toThrow();
expect(() => hostname.parse("example..com")).toThrow();
});

test("email validations", () => {
Expand Down
2 changes: 2 additions & 0 deletions deno/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const errorMap: ZodErrorMap = (issue, _ctx) => {
}
} else if ("startsWith" in issue.validation) {
message = `Invalid input: must start with "${issue.validation.startsWith}"`;
} else if ("hostname" in issue.validation) {
message = `Invalid input: must be a valid hostname`;
} else if ("endsWith" in issue.validation) {
message = `Invalid input: must end with "${issue.validation.endsWith}"`;
} else {
Expand Down
31 changes: 31 additions & 0 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@ export type ZodStringCheck =
| { kind: "includes"; value: string; position?: number; message?: string }
| { kind: "cuid2"; message?: string }
| { kind: "ulid"; message?: string }
| { kind: "hostname"; message?: string }
| { kind: "startsWith"; value: string; message?: string }
| { kind: "endsWith"; value: string; message?: string }
| { kind: "regex"; regex: RegExp; message?: string }
Expand Down Expand Up @@ -855,6 +856,29 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
input.data = input.data.toLowerCase();
} else if (check.kind === "toUpperCase") {
input.data = input.data.toUpperCase();
} else if (check.kind === "hostname") {
const domainNameRegex =
/^(?!-)(?!.*--)(?!.*\.\.)(?!.*\.$)[a-zA-Z0-9-]{1,63}(?<!-)(\.[a-zA-Z0-9-]{1,63})*$/;
const punycodeRegex = /^xn--[a-zA-Z0-9-]{1,63}$/;
const ipv4Regex =
/^(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)$/;
const ipv6Regex = /^\[[0-9a-fA-F:]+\]$/;

const isValid =
domainNameRegex.test(input.data) ||
ipv4Regex.test(input.data) ||
ipv6Regex.test(input.data) ||
punycodeRegex.test(input.data);

if (!isValid) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
code: ZodIssueCode.invalid_string,
validation: { hostname: input.data },
message: check.message,
});
status.dirty();
}
} else if (check.kind === "startsWith") {
if (!(input.data as string).startsWith(check.value)) {
ctx = this._getOrReturnCtx(input, ctx);
Expand Down Expand Up @@ -1082,6 +1106,13 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
});
}

hostname(options?: { message?: string }) {
return this._addCheck({
kind: "hostname",
...errorUtil.errToObj(options?.message),
});
}

startsWith(value: string, message?: errorUtil.ErrMessage) {
return this._addCheck({
kind: "startsWith",
Expand Down
1 change: 1 addition & 0 deletions src/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export type StringValidation =
| "base64"
| { includes: string; position?: number }
| { startsWith: string }
| { hostname: string }
| { endsWith: string };

export interface ZodInvalidStringIssue extends ZodIssueBase {
Expand Down
14 changes: 14 additions & 0 deletions src/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const includes = z.string().includes("includes");
const includesFromIndex2 = z.string().includes("includes", { position: 2 });
const startsWith = z.string().startsWith("startsWith");
const endsWith = z.string().endsWith("endsWith");
const hostname = z.string().hostname();

test("passing validations", () => {
minFive.parse("12345");
Expand All @@ -23,6 +24,12 @@ test("passing validations", () => {
includesFromIndex2.parse("XXXincludesXX");
startsWith.parse("startsWithX");
endsWith.parse("XendsWith");
hostname.parse("developer.mozilla.org");
hostname.parse("hello.world.example.com");
hostname.parse("www.google.com");
hostname.parse("[2001:db8::ff00:42:8329]");
hostname.parse("192.168.1.1");
hostname.parse("xn--d1acj3b");
});

test("failing validations", () => {
Expand All @@ -35,6 +42,13 @@ test("failing validations", () => {
expect(() => includesFromIndex2.parse("XincludesXX")).toThrow();
expect(() => startsWith.parse("x")).toThrow();
expect(() => endsWith.parse("x")).toThrow();
expect(() => hostname.parse("ht!tp://invalid.com")).toThrow();
expect(() => hostname.parse("xn--d1acj3b..com")).toThrow();
expect(() => hostname.parse("[email protected]")).toThrow();
expect(() => hostname.parse("[2001:db8::zzzz]")).toThrow();
expect(() => hostname.parse("exa mple.com")).toThrow();
expect(() => hostname.parse("-example.com")).toThrow();
expect(() => hostname.parse("example..com")).toThrow();
});

test("email validations", () => {
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const errorMap: ZodErrorMap = (issue, _ctx) => {
}
} else if ("startsWith" in issue.validation) {
message = `Invalid input: must start with "${issue.validation.startsWith}"`;
} else if ("hostname" in issue.validation) {
message = `Invalid input: must be a valid hostname`;
} else if ("endsWith" in issue.validation) {
message = `Invalid input: must end with "${issue.validation.endsWith}"`;
} else {
Expand Down
31 changes: 31 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@ export type ZodStringCheck =
| { kind: "includes"; value: string; position?: number; message?: string }
| { kind: "cuid2"; message?: string }
| { kind: "ulid"; message?: string }
| { kind: "hostname"; message?: string }
| { kind: "startsWith"; value: string; message?: string }
| { kind: "endsWith"; value: string; message?: string }
| { kind: "regex"; regex: RegExp; message?: string }
Expand Down Expand Up @@ -855,6 +856,29 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
input.data = input.data.toLowerCase();
} else if (check.kind === "toUpperCase") {
input.data = input.data.toUpperCase();
} else if (check.kind === "hostname") {
const domainNameRegex =
/^(?!-)(?!.*--)(?!.*\.\.)(?!.*\.$)[a-zA-Z0-9-]{1,63}(?<!-)(\.[a-zA-Z0-9-]{1,63})*$/;
const punycodeRegex = /^xn--[a-zA-Z0-9-]{1,63}$/;
const ipv4Regex =
/^(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)$/;
const ipv6Regex = /^\[[0-9a-fA-F:]+\]$/;

const isValid =
domainNameRegex.test(input.data) ||
ipv4Regex.test(input.data) ||
ipv6Regex.test(input.data) ||
punycodeRegex.test(input.data);

if (!isValid) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
code: ZodIssueCode.invalid_string,
validation: { hostname: input.data },
message: check.message,
});
status.dirty();
}
} else if (check.kind === "startsWith") {
if (!(input.data as string).startsWith(check.value)) {
ctx = this._getOrReturnCtx(input, ctx);
Expand Down Expand Up @@ -1082,6 +1106,13 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
});
}

hostname(options?: { message?: string }) {
return this._addCheck({
kind: "hostname",
...errorUtil.errToObj(options?.message),
});
}

startsWith(value: string, message?: errorUtil.ErrMessage) {
return this._addCheck({
kind: "startsWith",
Expand Down

0 comments on commit 342fc32

Please sign in to comment.