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: added support for hostname method in z.string() #3589 #3692

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
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
16 changes: 16 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,13 @@ 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.com");
hostname.parse("xn--d1acj3b.org");
});

test("failing validations", () => {
Expand All @@ -36,6 +44,14 @@ 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")).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}\.[a-zA-Z]{2,}$/; // Ensure TLD after punycode
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); // Apply punycodeRegex here

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
16 changes: 16 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,13 @@ 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.com");
hostname.parse("xn--d1acj3b.org");
});

test("failing validations", () => {
Expand All @@ -35,6 +43,14 @@ 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")).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}\.[a-zA-Z]{2,}$/; // Ensure TLD after punycode
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); // Apply punycodeRegex here

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