Skip to content

Commit

Permalink
make unqualified date string opt-in
Browse files Browse the repository at this point in the history
  • Loading branch information
0xturner committed Oct 28, 2023
1 parent 2486484 commit 7cff53a
Show file tree
Hide file tree
Showing 6 changed files with 288 additions and 42 deletions.
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ z.string().regex(regex);
z.string().includes(string);
z.string().startsWith(string);
z.string().endsWith(string);
z.string().datetime(); // ISO 8601; default is without UTC offset, see below for options
z.string().datetime(); // ISO 8601; default is UTC with zero offset, see below for options
z.string().ip(); // defaults to IPv4 and IPv6, see below for options

// transformations
Expand Down Expand Up @@ -742,22 +742,22 @@ z.string().uuid({ message: "Invalid UUID" });
z.string().includes("tuna", { message: "Must include tuna" });
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!" });
z.string().datetime({ message: "Invalid datetime string! Must be UTC." });
z.string().ip({ message: "Invalid IP address" });
```

### ISO datetimes

The `z.string().datetime()` method enforces ISO 8601; default is no timezone offsets and arbitrary sub-second decimal precision.
The `z.string().datetime()` method enforces ISO 8601; default is UTC with zero timezone offset and arbitrary sub-second decimal precision.

```ts
const datetime = z.string().datetime();

datetime.parse("2020-01-01T00:00:00Z"); // pass
datetime.parse("2020-01-01T00:00:00"); // pass (unqualified)
datetime.parse("2020-01-01T00:00:00.123Z"); // pass
datetime.parse("2020-01-01T00:00:00.123456Z"); // pass (arbitrary precision)
datetime.parse("2020-01-01T00:00:00+02:00"); // fail (no offsets allowed)
datetime.parse("2020-01-01T00:00:00"); // fail (UTC relation required)
```

Timezone offsets can be allowed by setting the `offset` option to `true`.
Expand All @@ -778,11 +778,20 @@ You can additionally constrain the allowable `precision`. By default, arbitrary
const datetime = z.string().datetime({ precision: 3 });

datetime.parse("2020-01-01T00:00:00.123Z"); // pass
datetime.parse("2020-01-01T00:00:00.123"); // pass (unqualified still supported)
datetime.parse("2020-01-01T00:00:00Z"); // fail
datetime.parse("2020-01-01T00:00:00.123456Z"); // fail
```

Unqualified (no UTC relation) datetimes can be allowed by setting the `unqualified` option to `true`.

```ts
const datetime = z.string().datetime({ unqualified: true });

datetime.parse("2020-01-01T00:00:00"); // pass
datetime.parse("2020-01-01T00:00:00.123"); // pass (millis optional)
datetime.parse("2020-01-01T00:00:00Z"); // pass (Z still supported)
```

### IP addresses

The `z.string().ip()` method by default validate IPv4 and IPv6.
Expand Down
19 changes: 14 additions & 5 deletions deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ z.string().regex(regex);
z.string().includes(string);
z.string().startsWith(string);
z.string().endsWith(string);
z.string().datetime(); // ISO 8601; default is without UTC offset, see below for options
z.string().datetime(); // ISO 8601; default is UTC with zero offset, see below for options
z.string().ip(); // defaults to IPv4 and IPv6, see below for options

// transformations
Expand Down Expand Up @@ -742,22 +742,22 @@ z.string().uuid({ message: "Invalid UUID" });
z.string().includes("tuna", { message: "Must include tuna" });
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!" });
z.string().datetime({ message: "Invalid datetime string! Must be UTC." });
z.string().ip({ message: "Invalid IP address" });
```

### ISO datetimes

The `z.string().datetime()` method enforces ISO 8601; default is no timezone offsets and arbitrary sub-second decimal precision.
The `z.string().datetime()` method enforces ISO 8601; default is UTC with zero timezone offset and arbitrary sub-second decimal precision.

```ts
const datetime = z.string().datetime();

datetime.parse("2020-01-01T00:00:00Z"); // pass
datetime.parse("2020-01-01T00:00:00"); // pass (unqualified)
datetime.parse("2020-01-01T00:00:00.123Z"); // pass
datetime.parse("2020-01-01T00:00:00.123456Z"); // pass (arbitrary precision)
datetime.parse("2020-01-01T00:00:00+02:00"); // fail (no offsets allowed)
datetime.parse("2020-01-01T00:00:00"); // fail (UTC relation required)
```

Timezone offsets can be allowed by setting the `offset` option to `true`.
Expand All @@ -778,11 +778,20 @@ You can additionally constrain the allowable `precision`. By default, arbitrary
const datetime = z.string().datetime({ precision: 3 });

datetime.parse("2020-01-01T00:00:00.123Z"); // pass
datetime.parse("2020-01-01T00:00:00.123"); // pass (unqualified still supported)
datetime.parse("2020-01-01T00:00:00Z"); // fail
datetime.parse("2020-01-01T00:00:00.123456Z"); // fail
```

Unqualified (no UTC relation) datetimes can be allowed by setting the `unqualified` option to `true`.

```ts
const datetime = z.string().datetime({ unqualified: true });

datetime.parse("2020-01-01T00:00:00"); // pass
datetime.parse("2020-01-01T00:00:00.123"); // pass (millis optional)
datetime.parse("2020-01-01T00:00:00Z"); // pass (Z still supported)
```

### IP addresses

The `z.string().ip()` method by default validate IPv4 and IPv6.
Expand Down
120 changes: 111 additions & 9 deletions deno/lib/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,15 +404,10 @@ test("datetime", () => {
test("datetime parsing", () => {
const datetime = z.string().datetime();
datetime.parse("1970-01-01T00:00:00.000Z");
datetime.parse("1970-01-01T00:00:00.000");
datetime.parse("2022-10-13T09:52:31.816Z");
datetime.parse("2022-10-13T09:52:31.816");
datetime.parse("2022-10-13T09:52:31.8162314Z");
datetime.parse("2022-10-13T09:52:31.8162314");
datetime.parse("1970-01-01T00:00:00Z");
datetime.parse("1970-01-01T00:00:00");
datetime.parse("2022-10-13T09:52:31Z");
datetime.parse("2022-10-13T09:52:31");
expect(() => datetime.parse("")).toThrow();
expect(() => datetime.parse("foo")).toThrow();
expect(() => datetime.parse("2020-10-14")).toThrow();
Expand All @@ -421,19 +416,15 @@ test("datetime parsing", () => {

const datetimeNoMs = z.string().datetime({ precision: 0 });
datetimeNoMs.parse("1970-01-01T00:00:00Z");
datetimeNoMs.parse("1970-01-01T00:00:00");
datetimeNoMs.parse("2022-10-13T09:52:31Z");
datetimeNoMs.parse("2022-10-13T09:52:31");
expect(() => datetimeNoMs.parse("tuna")).toThrow();
expect(() => datetimeNoMs.parse("1970-01-01T00:00:00.000Z")).toThrow();
expect(() => datetimeNoMs.parse("1970-01-01T00:00:00.Z")).toThrow();
expect(() => datetimeNoMs.parse("2022-10-13T09:52:31.816Z")).toThrow();

const datetime3Ms = z.string().datetime({ precision: 3 });
datetime3Ms.parse("1970-01-01T00:00:00.000Z");
datetime3Ms.parse("1970-01-01T00:00:00.000");
datetime3Ms.parse("2022-10-13T09:52:31.123Z");
datetime3Ms.parse("2022-10-13T09:52:31.123");
expect(() => datetime3Ms.parse("tuna")).toThrow();
expect(() => datetime3Ms.parse("1970-01-01T00:00:00.1Z")).toThrow();
expect(() => datetime3Ms.parse("1970-01-01T00:00:00.12Z")).toThrow();
Expand Down Expand Up @@ -477,6 +468,117 @@ test("datetime parsing", () => {
expect(() =>
datetimeOffset4Ms.parse("2020-10-14T17:42:29.124+00:00")
).toThrow();

const datetimeUnqualified = z.string().datetime({ unqualified: true });
datetimeUnqualified.parse("1970-01-01T00:00:00.000Z");
datetimeUnqualified.parse("1970-01-01T00:00:00.000");
datetimeUnqualified.parse("2022-10-13T09:52:31.816Z");
datetimeUnqualified.parse("2022-10-13T09:52:31.816");
datetimeUnqualified.parse("2022-10-13T09:52:31.8162314Z");
datetimeUnqualified.parse("2022-10-13T09:52:31.8162314");
datetimeUnqualified.parse("1970-01-01T00:00:00Z");
datetimeUnqualified.parse("1970-01-01T00:00:00");
datetimeUnqualified.parse("2022-10-13T09:52:31Z");
datetimeUnqualified.parse("2022-10-13T09:52:31");
expect(() => datetimeOffset.parse("tuna")).toThrow();
expect(() => datetimeOffset.parse("2022-10-13T09:52:31.Z")).toThrow();

const datetimeUnqualifiedNoMs = z
.string()
.datetime({ unqualified: true, precision: 0 });
datetimeUnqualifiedNoMs.parse("1970-01-01T00:00:00Z");
datetimeUnqualifiedNoMs.parse("1970-01-01T00:00:00");
datetimeUnqualifiedNoMs.parse("2022-10-13T09:52:31Z");
datetimeUnqualifiedNoMs.parse("2022-10-13T09:52:31");
expect(() => datetimeUnqualifiedNoMs.parse("tuna")).toThrow();
expect(() =>
datetimeUnqualifiedNoMs.parse("1970-01-01T00:00:00.000Z")
).toThrow();
expect(() =>
datetimeUnqualifiedNoMs.parse("1970-01-01T00:00:00.Z")
).toThrow();
expect(() =>
datetimeUnqualifiedNoMs.parse("2022-10-13T09:52:31.816Z")
).toThrow();

const datetimeUnqualified3Ms = z
.string()
.datetime({ unqualified: true, precision: 3 });
datetimeUnqualified3Ms.parse("1970-01-01T00:00:00.000Z");
datetimeUnqualified3Ms.parse("1970-01-01T00:00:00.000");
datetimeUnqualified3Ms.parse("2022-10-13T09:52:31.123Z");
datetimeUnqualified3Ms.parse("2022-10-13T09:52:31.123");
expect(() => datetimeUnqualified3Ms.parse("tuna")).toThrow();
expect(() =>
datetimeUnqualified3Ms.parse("1970-01-01T00:00:00.1Z")
).toThrow();
expect(() =>
datetimeUnqualified3Ms.parse("1970-01-01T00:00:00.12Z")
).toThrow();
expect(() => datetimeUnqualified3Ms.parse("2022-10-13T09:52:31Z")).toThrow();

const datetimeOffsetUnqualified = z
.string()
.datetime({ offset: true, unqualified: true });
datetimeOffsetUnqualified.parse("1970-01-01T00:00:00.000Z");
datetimeOffsetUnqualified.parse("1970-01-01T00:00:00.000");
datetimeOffsetUnqualified.parse("2022-10-13T09:52:31.816234134Z");
datetimeOffsetUnqualified.parse("2022-10-13T09:52:31.816234134");
datetimeOffsetUnqualified.parse("1970-01-01T00:00:00Z");
datetimeOffsetUnqualified.parse("1970-01-01T00:00:00");
datetimeOffsetUnqualified.parse("2022-10-13T09:52:31.4Z");
datetimeOffsetUnqualified.parse("2022-10-13T09:52:31.4");
datetimeOffsetUnqualified.parse("2020-10-14T17:42:29+00:00");
datetimeOffsetUnqualified.parse("2020-10-14T17:42:29+03:15");
datetimeOffsetUnqualified.parse("2020-10-14T17:42:29+0315");
datetimeOffsetUnqualified.parse("2020-10-14T17:42:29+03");
datetimeOffsetUnqualified.parse("2020-10-14T17:42:29");
expect(() => datetimeOffsetUnqualified.parse("tuna")).toThrow();
expect(() =>
datetimeOffsetUnqualified.parse("2022-10-13T09:52:31.Z")
).toThrow();

const datetimeOffsetUnqualifiedNoMs = z
.string()
.datetime({ offset: true, unqualified: true, precision: 0 });
datetimeOffsetUnqualifiedNoMs.parse("1970-01-01T00:00:00Z");
datetimeOffsetUnqualifiedNoMs.parse("1970-01-01T00:00:00");
datetimeOffsetUnqualifiedNoMs.parse("2022-10-13T09:52:31Z");
datetimeOffsetUnqualifiedNoMs.parse("2022-10-13T09:52:31");
datetimeOffsetUnqualifiedNoMs.parse("2020-10-14T17:42:29+00:00");
datetimeOffsetUnqualifiedNoMs.parse("2020-10-14T17:42:29+0000");
datetimeOffsetUnqualifiedNoMs.parse("2020-10-14T17:42:29+00");
datetimeOffsetUnqualifiedNoMs.parse("2020-10-14T17:42:29");
expect(() => datetimeOffsetUnqualifiedNoMs.parse("tuna")).toThrow();
expect(() =>
datetimeOffsetUnqualifiedNoMs.parse("1970-01-01T00:00:00.000Z")
).toThrow();
expect(() =>
datetimeOffsetUnqualifiedNoMs.parse("1970-01-01T00:00:00.Z")
).toThrow();
expect(() =>
datetimeOffsetUnqualifiedNoMs.parse("2022-10-13T09:52:31.816Z")
).toThrow();
expect(() =>
datetimeOffsetUnqualifiedNoMs.parse("2020-10-14T17:42:29.124+00:00")
).toThrow();

const datetimeOffsetUnqualified4Ms = z
.string()
.datetime({ offset: true, unqualified: true, precision: 4 });
datetimeOffsetUnqualified4Ms.parse("1970-01-01T00:00:00.1234Z");
datetimeOffsetUnqualified4Ms.parse("1970-01-01T00:00:00.1234");
datetimeOffsetUnqualified4Ms.parse("2020-10-14T17:42:29.1234+00:00");
datetimeOffsetUnqualified4Ms.parse("2020-10-14T17:42:29.1234+0000");
datetimeOffsetUnqualified4Ms.parse("2020-10-14T17:42:29.1234+00");
datetimeOffsetUnqualified4Ms.parse("2020-10-14T17:42:29.1234");
expect(() => datetimeOffsetUnqualified4Ms.parse("tuna")).toThrow();
expect(() =>
datetimeOffsetUnqualified4Ms.parse("1970-01-01T00:00:00.123Z")
).toThrow();
expect(() =>
datetimeOffsetUnqualified4Ms.parse("2020-10-14T17:42:29.124+00:00")
).toThrow();
});

test("IP validation", () => {
Expand Down
26 changes: 19 additions & 7 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ export type ZodStringCheck =
kind: "datetime";
offset: boolean;
precision: number | null;
unqualified: boolean;
message?: string;
}
| { kind: "ip"; version?: IpVersion; message?: string };
Expand Down Expand Up @@ -580,33 +581,41 @@ 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 }) => {
const datetimeRegex = (args: {
precision: number | null;
offset: boolean;
unqualified: boolean;
}) => {
const optionalOffsetQuantifier = args.unqualified ? "?" : "";

if (args.precision) {
if (args.offset) {
return new RegExp(
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{${args.precision}}(([+-]\\d{2}(:?\\d{2})?)|Z)$`
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{${args.precision}}(([+-]\\d{2}(:?\\d{2})?)|Z)${optionalOffsetQuantifier}$`
);
} else {
return new RegExp(
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{${args.precision}}Z?$`
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{${args.precision}}Z${optionalOffsetQuantifier}$`
);
}
} else if (args.precision === 0) {
if (args.offset) {
return new RegExp(
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(([+-]\\d{2}(:?\\d{2})?)|Z)$`
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(([+-]\\d{2}(:?\\d{2})?)|Z)${optionalOffsetQuantifier}$`
);
} else {
return new RegExp(`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z?$`);
return new RegExp(
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z${optionalOffsetQuantifier}$`
);
}
} else {
if (args.offset) {
return new RegExp(
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(([+-]\\d{2}(:?\\d{2})?)|Z)$`
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(([+-]\\d{2}(:?\\d{2})?)|Z)${optionalOffsetQuantifier}$`
);
} else {
return new RegExp(
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z?$`
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z${optionalOffsetQuantifier}$`
);
}
}
Expand Down Expand Up @@ -905,13 +914,15 @@ export class ZodString extends ZodType<string, ZodStringDef> {
message?: string | undefined;
precision?: number | null;
offset?: boolean;
unqualified?: boolean;
}
) {
if (typeof options === "string") {
return this._addCheck({
kind: "datetime",
precision: null,
offset: false,
unqualified: false,
message: options,
});
}
Expand All @@ -920,6 +931,7 @@ export class ZodString extends ZodType<string, ZodStringDef> {
precision:
typeof options?.precision === "undefined" ? null : options?.precision,
offset: options?.offset ?? false,
unqualified: options?.unqualified ?? false,
...errorUtil.errToObj(options?.message),
});
}
Expand Down
Loading

0 comments on commit 7cff53a

Please sign in to comment.