From 9d9ee03e899ce47e8646114de378a134a22e24d0 Mon Sep 17 00:00:00 2001 From: Vitaly Budovski Date: Wed, 1 Jan 2025 20:32:09 +1100 Subject: [PATCH 1/8] feature: String includes --- .../reference/Schema/Primitives/string.mdx | 8 ++++ paseri-lib/bench/string/includes.bench.ts | 36 ++++++++++++++++ paseri-lib/src/issue.ts | 1 + paseri-lib/src/locales/en-GB.ts | 1 + paseri-lib/src/schemas/string.test.ts | 41 +++++++++++++++++++ paseri-lib/src/schemas/string.ts | 12 ++++++ 6 files changed, 99 insertions(+) create mode 100644 paseri-lib/bench/string/includes.bench.ts diff --git a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx index 1309bc5..c6ca28c 100644 --- a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx +++ b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx @@ -85,3 +85,11 @@ A valid [Nano ID](https://github.com/ai/nanoid). ```typescript p.string().nanoid(); ``` + +### `includes` + +Contains the `searchString`. + +```typescript +p.string().includes('foo'); +``` diff --git a/paseri-lib/bench/string/includes.bench.ts b/paseri-lib/bench/string/includes.bench.ts new file mode 100644 index 0000000..98fdeb5 --- /dev/null +++ b/paseri-lib/bench/string/includes.bench.ts @@ -0,0 +1,36 @@ +import * as v from '@badrap/valita'; +import { z } from 'zod'; +import * as p from '../../src/index.ts'; + +const { bench } = Deno; + +const paseriSchema = p.string().includes('foo'); +const zodSchema = z.string().includes('foo'); +const valitaSchema = v.string().assert((value) => value.includes('foo')); + +const dataValid = 'Hello,fooworld!'; +const dataInvalid = 'Hello, world!'; + +bench('Paseri', { group: 'Includes valid' }, () => { + paseriSchema.safeParse(dataValid); +}); + +bench('Zod', { group: 'Includes valid' }, () => { + zodSchema.safeParse(dataValid); +}); + +bench('Valita', { group: 'Includes valid' }, () => { + valitaSchema.try(dataValid); +}); + +bench('Paseri', { group: 'Includes invalid' }, () => { + paseriSchema.safeParse(dataInvalid); +}); + +bench('Zod', { group: 'Includes invalid' }, () => { + zodSchema.safeParse(dataInvalid); +}); + +bench('Valita', { group: 'Includes invalid' }, () => { + valitaSchema.try(dataInvalid); +}); diff --git a/paseri-lib/src/issue.ts b/paseri-lib/src/issue.ts index 821bc32..ae950d7 100644 --- a/paseri-lib/src/issue.ts +++ b/paseri-lib/src/issue.ts @@ -11,6 +11,7 @@ const issueCodes = { INVALID_EMOJI: 'invalid_emoji' as Tagged<'invalid_emoji', 'IssueCode'>, INVALID_UUID: 'invalid_uuid' as Tagged<'invalid_uuid', 'IssueCode'>, INVALID_NANOID: 'invalid_nanoid' as Tagged<'invalid_nanoid', 'IssueCode'>, + DOES_NOT_INCLUDE: 'does_not_include' as Tagged<'does_not_include', 'IssueCode'>, // BigInt/Number. TOO_SMALL: 'too_small' as Tagged<'too_small', 'IssueCode'>, TOO_LARGE: 'too_large' as Tagged<'too_large', 'IssueCode'>, diff --git a/paseri-lib/src/locales/en-GB.ts b/paseri-lib/src/locales/en-GB.ts index ded0c80..7cd237a 100644 --- a/paseri-lib/src/locales/en-GB.ts +++ b/paseri-lib/src/locales/en-GB.ts @@ -11,6 +11,7 @@ const en_GB = { invalid_emoji: 'Invalid emoji.', invalid_uuid: 'Invalid UUID.', invalid_nanoid: 'Invalid Nano ID.', + does_not_include: 'Does not include search string.', too_small: 'Too small.', too_large: 'Too large.', invalid_integer: 'Number must be an integer.', diff --git a/paseri-lib/src/schemas/string.test.ts b/paseri-lib/src/schemas/string.test.ts index 16b0586..66fd8fc 100644 --- a/paseri-lib/src/schemas/string.test.ts +++ b/paseri-lib/src/schemas/string.test.ts @@ -326,6 +326,41 @@ test('Nano ID ReDoS', () => { expect(diagnostics.status).toBe('safe'); }); +test('Valid includes', () => { + const schema = p.string().includes('foo'); + + fc.assert( + fc.property(fc.string(), fc.string(), (prefix, suffix) => { + const data = `${prefix}foo${suffix}`; + const result = schema.safeParse(data); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf; + expect(result.value).toBe(data); + } else { + expect(result.ok).toBeTruthy(); + } + }), + ); +}); + +test('Invalid includes', () => { + const schema = p.string().includes('foo'); + + fc.assert( + fc.property( + fc.string().filter((value) => !value.includes('foo')), + (data) => { + const result = schema.safeParse(data); + if (!result.ok) { + expect(result.messages()).toEqual([{ path: [], message: 'Does not include search string.' }]); + } else { + expect(result.ok).toBeFalsy(); + } + }, + ), + ); +}); + test('Optional', () => { const schema = p.string().optional(); @@ -400,4 +435,10 @@ test('Immutable', async (t) => { const modified = original.nanoid(); expect(modified).not.toEqual(original); }); + + await t.step('includes', () => { + const original = p.string(); + const modified = original.includes('foo'); + expect(modified).not.toEqual(original); + }); }); diff --git a/paseri-lib/src/schemas/string.ts b/paseri-lib/src/schemas/string.ts index 01cdecd..a6be19e 100644 --- a/paseri-lib/src/schemas/string.ts +++ b/paseri-lib/src/schemas/string.ts @@ -20,6 +20,7 @@ class StringSchema extends Schema { INVALID_EMOJI: { type: 'leaf', code: issueCodes.INVALID_EMOJI }, INVALID_UUID: { type: 'leaf', code: issueCodes.INVALID_UUID }, INVALID_NANOID: { type: 'leaf', code: issueCodes.INVALID_NANOID }, + DOES_NOT_INCLUDE: { type: 'leaf', code: issueCodes.DOES_NOT_INCLUDE }, } as const satisfies Record; protected _clone(): StringSchema { @@ -123,6 +124,17 @@ class StringSchema extends Schema { } }); + return cloned; + } + includes(searchString: string): StringSchema { + const cloned = this._clone(); + cloned._checks = this._checks || []; + cloned._checks.push((_value) => { + if (!_value.includes(searchString)) { + return this.issues.DOES_NOT_INCLUDE; + } + }); + return cloned; } } From 39051b13a039429a332c4d9037d75bde4e047fbb Mon Sep 17 00:00:00 2001 From: Vitaly Budovski Date: Wed, 1 Jan 2025 20:41:04 +1100 Subject: [PATCH 2/8] feature: String startsWith --- .../reference/Schema/Primitives/string.mdx | 8 ++++ paseri-lib/bench/string/startsWith.bench.ts | 36 ++++++++++++++++ paseri-lib/src/issue.ts | 1 + paseri-lib/src/locales/en-GB.ts | 1 + paseri-lib/src/schemas/string.test.ts | 41 +++++++++++++++++++ paseri-lib/src/schemas/string.ts | 12 ++++++ 6 files changed, 99 insertions(+) create mode 100644 paseri-lib/bench/string/startsWith.bench.ts diff --git a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx index c6ca28c..085f96a 100644 --- a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx +++ b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx @@ -93,3 +93,11 @@ Contains the `searchString`. ```typescript p.string().includes('foo'); ``` + +### `startsWith` + +Starts with the `searchString`. + +```typescript +p.string().startsWith('foo'); +``` diff --git a/paseri-lib/bench/string/startsWith.bench.ts b/paseri-lib/bench/string/startsWith.bench.ts new file mode 100644 index 0000000..57109e9 --- /dev/null +++ b/paseri-lib/bench/string/startsWith.bench.ts @@ -0,0 +1,36 @@ +import * as v from '@badrap/valita'; +import { z } from 'zod'; +import * as p from '../../src/index.ts'; + +const { bench } = Deno; + +const paseriSchema = p.string().startsWith('foo'); +const zodSchema = z.string().startsWith('foo'); +const valitaSchema = v.string().assert((value) => value.startsWith('foo')); + +const dataValid = 'fooHello, world!'; +const dataInvalid = 'Hello, world!'; + +bench('Paseri', { group: 'Starts with valid' }, () => { + paseriSchema.safeParse(dataValid); +}); + +bench('Zod', { group: 'Starts with valid' }, () => { + zodSchema.safeParse(dataValid); +}); + +bench('Valita', { group: 'Starts with valid' }, () => { + valitaSchema.try(dataValid); +}); + +bench('Paseri', { group: 'Starts with invalid' }, () => { + paseriSchema.safeParse(dataInvalid); +}); + +bench('Zod', { group: 'Starts with invalid' }, () => { + zodSchema.safeParse(dataInvalid); +}); + +bench('Valita', { group: 'Starts with invalid' }, () => { + valitaSchema.try(dataInvalid); +}); diff --git a/paseri-lib/src/issue.ts b/paseri-lib/src/issue.ts index ae950d7..392f36b 100644 --- a/paseri-lib/src/issue.ts +++ b/paseri-lib/src/issue.ts @@ -12,6 +12,7 @@ const issueCodes = { INVALID_UUID: 'invalid_uuid' as Tagged<'invalid_uuid', 'IssueCode'>, INVALID_NANOID: 'invalid_nanoid' as Tagged<'invalid_nanoid', 'IssueCode'>, DOES_NOT_INCLUDE: 'does_not_include' as Tagged<'does_not_include', 'IssueCode'>, + DOES_NOT_START_WITH: 'does_not_start_with' as Tagged<'does_not_start_with', 'IssueCode'>, // BigInt/Number. TOO_SMALL: 'too_small' as Tagged<'too_small', 'IssueCode'>, TOO_LARGE: 'too_large' as Tagged<'too_large', 'IssueCode'>, diff --git a/paseri-lib/src/locales/en-GB.ts b/paseri-lib/src/locales/en-GB.ts index 7cd237a..031cba1 100644 --- a/paseri-lib/src/locales/en-GB.ts +++ b/paseri-lib/src/locales/en-GB.ts @@ -12,6 +12,7 @@ const en_GB = { invalid_uuid: 'Invalid UUID.', invalid_nanoid: 'Invalid Nano ID.', does_not_include: 'Does not include search string.', + does_not_start_with: 'Does not start with search string.', too_small: 'Too small.', too_large: 'Too large.', invalid_integer: 'Number must be an integer.', diff --git a/paseri-lib/src/schemas/string.test.ts b/paseri-lib/src/schemas/string.test.ts index 66fd8fc..ad8f57d 100644 --- a/paseri-lib/src/schemas/string.test.ts +++ b/paseri-lib/src/schemas/string.test.ts @@ -361,6 +361,41 @@ test('Invalid includes', () => { ); }); +test('Valid startsWith', () => { + const schema = p.string().startsWith('foo'); + + fc.assert( + fc.property(fc.string(), (suffix) => { + const data = `foo${suffix}`; + const result = schema.safeParse(data); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf; + expect(result.value).toBe(data); + } else { + expect(result.ok).toBeTruthy(); + } + }), + ); +}); + +test('Invalid startsWith', () => { + const schema = p.string().startsWith('foo'); + + fc.assert( + fc.property( + fc.string().filter((value) => !value.startsWith('foo')), + (data) => { + const result = schema.safeParse(data); + if (!result.ok) { + expect(result.messages()).toEqual([{ path: [], message: 'Does not start with search string.' }]); + } else { + expect(result.ok).toBeFalsy(); + } + }, + ), + ); +}); + test('Optional', () => { const schema = p.string().optional(); @@ -441,4 +476,10 @@ test('Immutable', async (t) => { const modified = original.includes('foo'); expect(modified).not.toEqual(original); }); + + await t.step('startsWith', () => { + const original = p.string(); + const modified = original.startsWith('foo'); + expect(modified).not.toEqual(original); + }); }); diff --git a/paseri-lib/src/schemas/string.ts b/paseri-lib/src/schemas/string.ts index a6be19e..ea5cb96 100644 --- a/paseri-lib/src/schemas/string.ts +++ b/paseri-lib/src/schemas/string.ts @@ -21,6 +21,7 @@ class StringSchema extends Schema { INVALID_UUID: { type: 'leaf', code: issueCodes.INVALID_UUID }, INVALID_NANOID: { type: 'leaf', code: issueCodes.INVALID_NANOID }, DOES_NOT_INCLUDE: { type: 'leaf', code: issueCodes.DOES_NOT_INCLUDE }, + DOES_NOT_START_WITH: { type: 'leaf', code: issueCodes.DOES_NOT_START_WITH }, } as const satisfies Record; protected _clone(): StringSchema { @@ -135,6 +136,17 @@ class StringSchema extends Schema { } }); + return cloned; + } + startsWith(searchString: string): StringSchema { + const cloned = this._clone(); + cloned._checks = this._checks || []; + cloned._checks.push((_value) => { + if (!_value.startsWith(searchString)) { + return this.issues.DOES_NOT_START_WITH; + } + }); + return cloned; } } From b271e31f1fc4fa03cffe16b075d56962402b006d Mon Sep 17 00:00:00 2001 From: Vitaly Budovski Date: Wed, 1 Jan 2025 20:50:55 +1100 Subject: [PATCH 3/8] feature: String endsWith --- .../reference/Schema/Primitives/string.mdx | 8 ++++ paseri-lib/bench/string/endsWith.bench.ts | 36 ++++++++++++++++ paseri-lib/src/issue.ts | 1 + paseri-lib/src/locales/en-GB.ts | 1 + paseri-lib/src/schemas/string.test.ts | 41 +++++++++++++++++++ paseri-lib/src/schemas/string.ts | 12 ++++++ 6 files changed, 99 insertions(+) create mode 100644 paseri-lib/bench/string/endsWith.bench.ts diff --git a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx index 085f96a..7039244 100644 --- a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx +++ b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx @@ -101,3 +101,11 @@ Starts with the `searchString`. ```typescript p.string().startsWith('foo'); ``` + +### `endsWith` + +Ends with the `searchString`. + +```typescript +p.string().endsWith('foo'); +``` diff --git a/paseri-lib/bench/string/endsWith.bench.ts b/paseri-lib/bench/string/endsWith.bench.ts new file mode 100644 index 0000000..1fd658e --- /dev/null +++ b/paseri-lib/bench/string/endsWith.bench.ts @@ -0,0 +1,36 @@ +import * as v from '@badrap/valita'; +import { z } from 'zod'; +import * as p from '../../src/index.ts'; + +const { bench } = Deno; + +const paseriSchema = p.string().endsWith('foo'); +const zodSchema = z.string().endsWith('foo'); +const valitaSchema = v.string().assert((value) => value.endsWith('foo')); + +const dataValid = 'Hello, world!foo'; +const dataInvalid = 'Hello, world!'; + +bench('Paseri', { group: 'Ends with valid' }, () => { + paseriSchema.safeParse(dataValid); +}); + +bench('Zod', { group: 'Ends with valid' }, () => { + zodSchema.safeParse(dataValid); +}); + +bench('Valita', { group: 'Ends with valid' }, () => { + valitaSchema.try(dataValid); +}); + +bench('Paseri', { group: 'Ends with invalid' }, () => { + paseriSchema.safeParse(dataInvalid); +}); + +bench('Zod', { group: 'Ends with invalid' }, () => { + zodSchema.safeParse(dataInvalid); +}); + +bench('Valita', { group: 'Ends with invalid' }, () => { + valitaSchema.try(dataInvalid); +}); diff --git a/paseri-lib/src/issue.ts b/paseri-lib/src/issue.ts index 392f36b..ee7a2d4 100644 --- a/paseri-lib/src/issue.ts +++ b/paseri-lib/src/issue.ts @@ -13,6 +13,7 @@ const issueCodes = { INVALID_NANOID: 'invalid_nanoid' as Tagged<'invalid_nanoid', 'IssueCode'>, DOES_NOT_INCLUDE: 'does_not_include' as Tagged<'does_not_include', 'IssueCode'>, DOES_NOT_START_WITH: 'does_not_start_with' as Tagged<'does_not_start_with', 'IssueCode'>, + DOES_NOT_END_WITH: 'does_not_end_with' as Tagged<'does_not_end_with', 'IssueCode'>, // BigInt/Number. TOO_SMALL: 'too_small' as Tagged<'too_small', 'IssueCode'>, TOO_LARGE: 'too_large' as Tagged<'too_large', 'IssueCode'>, diff --git a/paseri-lib/src/locales/en-GB.ts b/paseri-lib/src/locales/en-GB.ts index 031cba1..7bbdc95 100644 --- a/paseri-lib/src/locales/en-GB.ts +++ b/paseri-lib/src/locales/en-GB.ts @@ -13,6 +13,7 @@ const en_GB = { invalid_nanoid: 'Invalid Nano ID.', does_not_include: 'Does not include search string.', does_not_start_with: 'Does not start with search string.', + does_not_end_with: 'Does not end with search string.', too_small: 'Too small.', too_large: 'Too large.', invalid_integer: 'Number must be an integer.', diff --git a/paseri-lib/src/schemas/string.test.ts b/paseri-lib/src/schemas/string.test.ts index ad8f57d..79b7157 100644 --- a/paseri-lib/src/schemas/string.test.ts +++ b/paseri-lib/src/schemas/string.test.ts @@ -396,6 +396,41 @@ test('Invalid startsWith', () => { ); }); +test('Valid endsWith', () => { + const schema = p.string().endsWith('foo'); + + fc.assert( + fc.property(fc.string(), (prefix) => { + const data = `${prefix}foo`; + const result = schema.safeParse(data); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf; + expect(result.value).toBe(data); + } else { + expect(result.ok).toBeTruthy(); + } + }), + ); +}); + +test('Invalid endsWith', () => { + const schema = p.string().endsWith('foo'); + + fc.assert( + fc.property( + fc.string().filter((value) => !value.endsWith('foo')), + (data) => { + const result = schema.safeParse(data); + if (!result.ok) { + expect(result.messages()).toEqual([{ path: [], message: 'Does not end with search string.' }]); + } else { + expect(result.ok).toBeFalsy(); + } + }, + ), + ); +}); + test('Optional', () => { const schema = p.string().optional(); @@ -482,4 +517,10 @@ test('Immutable', async (t) => { const modified = original.startsWith('foo'); expect(modified).not.toEqual(original); }); + + await t.step('endsWith', () => { + const original = p.string(); + const modified = original.endsWith('foo'); + expect(modified).not.toEqual(original); + }); }); diff --git a/paseri-lib/src/schemas/string.ts b/paseri-lib/src/schemas/string.ts index ea5cb96..56f4306 100644 --- a/paseri-lib/src/schemas/string.ts +++ b/paseri-lib/src/schemas/string.ts @@ -22,6 +22,7 @@ class StringSchema extends Schema { INVALID_NANOID: { type: 'leaf', code: issueCodes.INVALID_NANOID }, DOES_NOT_INCLUDE: { type: 'leaf', code: issueCodes.DOES_NOT_INCLUDE }, DOES_NOT_START_WITH: { type: 'leaf', code: issueCodes.DOES_NOT_START_WITH }, + DOES_NOT_END_WITH: { type: 'leaf', code: issueCodes.DOES_NOT_END_WITH }, } as const satisfies Record; protected _clone(): StringSchema { @@ -147,6 +148,17 @@ class StringSchema extends Schema { } }); + return cloned; + } + endsWith(searchString: string): StringSchema { + const cloned = this._clone(); + cloned._checks = this._checks || []; + cloned._checks.push((_value) => { + if (!_value.endsWith(searchString)) { + return this.issues.DOES_NOT_END_WITH; + } + }); + return cloned; } } From d3579ec882ecdd5a15d7f8b0e72e3fef8a0bd522 Mon Sep 17 00:00:00 2001 From: Vitaly Budovski Date: Wed, 1 Jan 2025 22:32:17 +1100 Subject: [PATCH 4/8] feature: String date --- .../reference/Schema/Primitives/string.mdx | 8 +++ paseri-lib/bench/string/date.bench.ts | 26 +++++++ paseri-lib/src/issue.ts | 1 + paseri-lib/src/locales/en-GB.ts | 1 + paseri-lib/src/schemas/string.test.ts | 68 ++++++++++++++++++- paseri-lib/src/schemas/string.ts | 19 +++++- 6 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 paseri-lib/bench/string/date.bench.ts diff --git a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx index 7039244..91e7eb9 100644 --- a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx +++ b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx @@ -109,3 +109,11 @@ Ends with the `searchString`. ```typescript p.string().endsWith('foo'); ``` + +### `date` + +A valid [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) date string `YYYY-MM-DD`. + +```typescript +p.string().date(); +``` diff --git a/paseri-lib/bench/string/date.bench.ts b/paseri-lib/bench/string/date.bench.ts new file mode 100644 index 0000000..01f3182 --- /dev/null +++ b/paseri-lib/bench/string/date.bench.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import * as p from '../../src/index.ts'; + +const { bench } = Deno; + +const paseriSchema = p.string().date(); +const zodSchema = z.string().date(); + +const dataValid = '2020-01-01'; +const dataInvalid = '2024-01-32'; + +bench('Paseri', { group: 'Date valid' }, () => { + paseriSchema.safeParse(dataValid); +}); + +bench('Zod', { group: 'Date valid' }, () => { + zodSchema.safeParse(dataValid); +}); + +bench('Paseri', { group: 'Date invalid' }, () => { + paseriSchema.safeParse(dataInvalid); +}); + +bench('Zod', { group: 'Date invalid' }, () => { + zodSchema.safeParse(dataInvalid); +}); diff --git a/paseri-lib/src/issue.ts b/paseri-lib/src/issue.ts index ee7a2d4..ccdd497 100644 --- a/paseri-lib/src/issue.ts +++ b/paseri-lib/src/issue.ts @@ -14,6 +14,7 @@ const issueCodes = { DOES_NOT_INCLUDE: 'does_not_include' as Tagged<'does_not_include', 'IssueCode'>, DOES_NOT_START_WITH: 'does_not_start_with' as Tagged<'does_not_start_with', 'IssueCode'>, DOES_NOT_END_WITH: 'does_not_end_with' as Tagged<'does_not_end_with', 'IssueCode'>, + INVALID_DATE_STRING: 'invalid_date_string' as Tagged<'invalid_date_string', 'IssueCode'>, // BigInt/Number. TOO_SMALL: 'too_small' as Tagged<'too_small', 'IssueCode'>, TOO_LARGE: 'too_large' as Tagged<'too_large', 'IssueCode'>, diff --git a/paseri-lib/src/locales/en-GB.ts b/paseri-lib/src/locales/en-GB.ts index 7bbdc95..06ab2ff 100644 --- a/paseri-lib/src/locales/en-GB.ts +++ b/paseri-lib/src/locales/en-GB.ts @@ -14,6 +14,7 @@ const en_GB = { does_not_include: 'Does not include search string.', does_not_start_with: 'Does not start with search string.', does_not_end_with: 'Does not end with search string.', + invalid_date_string: 'Invalid date string.', too_small: 'Too small.', too_large: 'Too large.', invalid_integer: 'Number must be an integer.', diff --git a/paseri-lib/src/schemas/string.test.ts b/paseri-lib/src/schemas/string.test.ts index 79b7157..440a9ce 100644 --- a/paseri-lib/src/schemas/string.test.ts +++ b/paseri-lib/src/schemas/string.test.ts @@ -4,10 +4,21 @@ import fc from 'fast-check'; import { checkSync } from 'recheck'; import emoji from '../emoji.json' with { type: 'json' }; import * as p from '../index.ts'; -import { emailRegex, emojiRegex, nanoidRegex, uuidRegex } from './string.ts'; +import { dateRegex, emailRegex, emojiRegex, nanoidRegex, uuidRegex } from './string.ts'; const { test } = Deno; +function formatDate(value: Date): string { + const year = + value.getFullYear() >= 0 + ? String(value.getFullYear()).padStart(4, '0') + : `-${String(Math.abs(value.getFullYear())).padStart(4, '0')}`; + const month = String(value.getMonth() + 1).padStart(2, '0'); + const date = String(value.getDate()).padStart(2, '0'); + + return `${year}-${month}-${date}`; +} + test('Valid type', () => { const schema = p.string(); @@ -431,6 +442,55 @@ test('Invalid endsWith', () => { ); }); +test('Valid date', () => { + const schema = p.string().date(); + + fc.assert( + fc.property( + fc.date({ min: new Date(0, 0, 1), max: new Date(9999, 11, 31) }).map((value) => { + return formatDate(value); + }), + (data) => { + const result = schema.safeParse(data); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf; + expect(result.value).toBe(data); + } else { + expect(result.ok).toBeTruthy(); + } + }, + ), + ); +}); + +test('Invalid date', () => { + const schema = p.string().date(); + + fc.assert( + fc.property( + fc.string().filter((value) => !dateRegex.test(value)), + (data) => { + const result = schema.safeParse(data); + if (!result.ok) { + expect(result.messages()).toEqual([{ path: [], message: 'Invalid date string.' }]); + } else { + expect(result.ok).toBeFalsy(); + } + }, + ), + ); +}); + +test('Date ReDoS', () => { + const diagnostics = checkSync(dateRegex.source, dateRegex.flags); + if (diagnostics.status === 'vulnerable') { + console.log(`Vulnerable pattern: ${diagnostics.attack.pattern}`); + } else if (diagnostics.status === 'unknown') { + console.log(`Error: ${diagnostics.error.kind}.`); + } + expect(diagnostics.status).toBe('safe'); +}); + test('Optional', () => { const schema = p.string().optional(); @@ -523,4 +583,10 @@ test('Immutable', async (t) => { const modified = original.endsWith('foo'); expect(modified).not.toEqual(original); }); + + await t.step('date', () => { + const original = p.string(); + const modified = original.date(); + expect(modified).not.toEqual(original); + }); }); diff --git a/paseri-lib/src/schemas/string.ts b/paseri-lib/src/schemas/string.ts index 56f4306..aa7ba59 100644 --- a/paseri-lib/src/schemas/string.ts +++ b/paseri-lib/src/schemas/string.ts @@ -2,10 +2,15 @@ import { type LeafNode, type TreeNode, issueCodes } from '../issue.ts'; import type { InternalParseResult } from '../result.ts'; import { Schema } from './schema.ts'; +// These regular expressions should match Zod, wherever possible. const emailRegex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; const emojiRegex = /^(?:(?=(\p{Extended_Pictographic}|\p{Emoji_Component}))\1)+$/u; const uuidRegex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i; const nanoidRegex = /^[a-z0-9_-]{21}$/i; +// Does not support negative years, or years above 9999. +const dateRegexString = + '((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))'; +const dateRegex = new RegExp(`^${dateRegexString}$`); type CheckFunction = (value: string) => TreeNode | undefined; @@ -23,6 +28,7 @@ class StringSchema extends Schema { DOES_NOT_INCLUDE: { type: 'leaf', code: issueCodes.DOES_NOT_INCLUDE }, DOES_NOT_START_WITH: { type: 'leaf', code: issueCodes.DOES_NOT_START_WITH }, DOES_NOT_END_WITH: { type: 'leaf', code: issueCodes.DOES_NOT_END_WITH }, + INVALID_DATE_STRING: { type: 'leaf', code: issueCodes.INVALID_DATE_STRING }, } as const satisfies Record; protected _clone(): StringSchema { @@ -159,6 +165,17 @@ class StringSchema extends Schema { } }); + return cloned; + } + date(): StringSchema { + const cloned = this._clone(); + cloned._checks = this._checks || []; + cloned._checks.push((_value) => { + if (!dateRegex.test(_value)) { + return this.issues.INVALID_DATE_STRING; + } + }); + return cloned; } } @@ -170,4 +187,4 @@ const singleton = /* @__PURE__ */ new StringSchema(); */ const string = /* @__PURE__ */ (): StringSchema => singleton; -export { string, emailRegex, emojiRegex, uuidRegex, nanoidRegex }; +export { string, emailRegex, emojiRegex, uuidRegex, nanoidRegex, dateRegex }; From 80c15343f3dc59f7de1969b0046c0ebed403d847 Mon Sep 17 00:00:00 2001 From: Vitaly Budovski Date: Wed, 1 Jan 2025 23:26:13 +1100 Subject: [PATCH 5/8] feature: String time --- .../reference/Schema/Primitives/string.mdx | 16 ++++ paseri-lib/bench/string/time.bench.ts | 26 +++++++ paseri-lib/src/issue.ts | 1 + paseri-lib/src/locales/en-GB.ts | 1 + paseri-lib/src/schemas/string.test.ts | 76 ++++++++++++++++++- paseri-lib/src/schemas/string.ts | 17 ++++- 6 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 paseri-lib/bench/string/time.bench.ts diff --git a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx index 91e7eb9..af61a99 100644 --- a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx +++ b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx @@ -117,3 +117,19 @@ A valid [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) date string `YYYY-MM- ```typescript p.string().date(); ``` + +### `time` + +A valid [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) time string `hh:mm:ss[.s+]`. + +```typescript +p.string().time(); +``` + +You can require a fixed precision by setting the `precision` option. + +```typescript +p.string().time({ precision: 3 }); +// 01:02:03.123 ✅ +// 01:02:03.1234 ❌ +``` diff --git a/paseri-lib/bench/string/time.bench.ts b/paseri-lib/bench/string/time.bench.ts new file mode 100644 index 0000000..da20c74 --- /dev/null +++ b/paseri-lib/bench/string/time.bench.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import * as p from '../../src/index.ts'; + +const { bench } = Deno; + +const paseriSchema = p.string().time(); +const zodSchema = z.string().time(); + +const dataValid = '00:00:00'; +const dataInvalid = '99:99:99'; + +bench('Paseri', { group: 'Time valid' }, () => { + paseriSchema.safeParse(dataValid); +}); + +bench('Zod', { group: 'Time valid' }, () => { + zodSchema.safeParse(dataValid); +}); + +bench('Paseri', { group: 'Time invalid' }, () => { + paseriSchema.safeParse(dataInvalid); +}); + +bench('Zod', { group: 'Time invalid' }, () => { + zodSchema.safeParse(dataInvalid); +}); diff --git a/paseri-lib/src/issue.ts b/paseri-lib/src/issue.ts index ccdd497..dbd69c7 100644 --- a/paseri-lib/src/issue.ts +++ b/paseri-lib/src/issue.ts @@ -15,6 +15,7 @@ const issueCodes = { DOES_NOT_START_WITH: 'does_not_start_with' as Tagged<'does_not_start_with', 'IssueCode'>, DOES_NOT_END_WITH: 'does_not_end_with' as Tagged<'does_not_end_with', 'IssueCode'>, INVALID_DATE_STRING: 'invalid_date_string' as Tagged<'invalid_date_string', 'IssueCode'>, + INVALID_TIME_STRING: 'invalid_time_string' as Tagged<'invalid_time_string', 'IssueCode'>, // BigInt/Number. TOO_SMALL: 'too_small' as Tagged<'too_small', 'IssueCode'>, TOO_LARGE: 'too_large' as Tagged<'too_large', 'IssueCode'>, diff --git a/paseri-lib/src/locales/en-GB.ts b/paseri-lib/src/locales/en-GB.ts index 06ab2ff..c3b9e7f 100644 --- a/paseri-lib/src/locales/en-GB.ts +++ b/paseri-lib/src/locales/en-GB.ts @@ -15,6 +15,7 @@ const en_GB = { does_not_start_with: 'Does not start with search string.', does_not_end_with: 'Does not end with search string.', invalid_date_string: 'Invalid date string.', + invalid_time_string: 'Invalid time string.', too_small: 'Too small.', too_large: 'Too large.', invalid_integer: 'Number must be an integer.', diff --git a/paseri-lib/src/schemas/string.test.ts b/paseri-lib/src/schemas/string.test.ts index 440a9ce..67bba2d 100644 --- a/paseri-lib/src/schemas/string.test.ts +++ b/paseri-lib/src/schemas/string.test.ts @@ -4,7 +4,7 @@ import fc from 'fast-check'; import { checkSync } from 'recheck'; import emoji from '../emoji.json' with { type: 'json' }; import * as p from '../index.ts'; -import { dateRegex, emailRegex, emojiRegex, nanoidRegex, uuidRegex } from './string.ts'; +import { dateRegex, emailRegex, emojiRegex, nanoidRegex, timeRegex, uuidRegex } from './string.ts'; const { test } = Deno; @@ -19,6 +19,19 @@ function formatDate(value: Date): string { return `${year}-${month}-${date}`; } +function formatTime(value: Date, precision?: number): string { + const hour = String(value.getHours()).padStart(2, '0'); + const minute = String(value.getMinutes()).padStart(2, '0'); + const second = String(value.getSeconds()).padStart(2, '0'); + const fraction = value.getMilliseconds() / 1000; + const fractionString = + precision === undefined + ? String(fraction).slice(1) + : `.${fraction.toFixed(precision).slice(2).padEnd(precision, '0')}`; + + return `${hour}:${minute}:${second}${fractionString}`; +} + test('Valid type', () => { const schema = p.string(); @@ -491,6 +504,61 @@ test('Date ReDoS', () => { expect(diagnostics.status).toBe('safe'); }); +test('Valid time', () => { + fc.assert( + fc.property( + fc.date({ min: new Date(0, 0, 1), max: new Date(9999, 11, 31) }), + fc.option(fc.integer({ min: 0, max: 8 }), { nil: undefined }), + (date, precision) => { + const data = formatTime(date, precision); + + const schema = p.string().time({ precision }); + const result = schema.safeParse(data); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf; + expect(result.value).toBe(data); + } else { + expect(result.ok).toBeTruthy(); + } + }, + ), + ); +}); + +test('Invalid time', () => { + const schema = p.string().time(); + + fc.assert( + fc.property( + fc.string().filter((value) => !timeRegex().test(value)), + (data) => { + const result = schema.safeParse(data); + if (!result.ok) { + expect(result.messages()).toEqual([{ path: [], message: 'Invalid time string.' }]); + } else { + expect(result.ok).toBeFalsy(); + } + }, + ), + ); +}); + +test('Time ReDoS', () => { + fc.assert( + fc.property(fc.option(fc.integer({ min: 0, max: 8 }), { nil: undefined }), (precision) => { + const regex = timeRegex(precision); + const diagnostics = checkSync(regex.source, regex.flags); + if (diagnostics.status === 'vulnerable') { + console.log(`Vulnerable pattern: ${diagnostics.attack.pattern}`); + } else if (diagnostics.status === 'unknown') { + console.log(`Error: ${diagnostics.error.kind}.`); + } + expect(diagnostics.status).toBe('safe'); + }), + { ignoreEqualValues: true }, + ); +}); + test('Optional', () => { const schema = p.string().optional(); @@ -589,4 +657,10 @@ test('Immutable', async (t) => { const modified = original.date(); expect(modified).not.toEqual(original); }); + + await t.step('time', () => { + const original = p.string(); + const modified = original.time(); + expect(modified).not.toEqual(original); + }); }); diff --git a/paseri-lib/src/schemas/string.ts b/paseri-lib/src/schemas/string.ts index aa7ba59..b6defe3 100644 --- a/paseri-lib/src/schemas/string.ts +++ b/paseri-lib/src/schemas/string.ts @@ -11,6 +11,9 @@ const nanoidRegex = /^[a-z0-9_-]{21}$/i; const dateRegexString = '((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))'; const dateRegex = new RegExp(`^${dateRegexString}$`); +const timeRegexString = (precision?: number) => + `([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d${precision === undefined ? '(\\.\\d+)?' : `\\.\\d{${precision}}`}`; +const timeRegex = (precision?: number) => new RegExp(`^${timeRegexString(precision)}$`); type CheckFunction = (value: string) => TreeNode | undefined; @@ -29,6 +32,7 @@ class StringSchema extends Schema { DOES_NOT_START_WITH: { type: 'leaf', code: issueCodes.DOES_NOT_START_WITH }, DOES_NOT_END_WITH: { type: 'leaf', code: issueCodes.DOES_NOT_END_WITH }, INVALID_DATE_STRING: { type: 'leaf', code: issueCodes.INVALID_DATE_STRING }, + INVALID_TIME_STRING: { type: 'leaf', code: issueCodes.INVALID_TIME_STRING }, } as const satisfies Record; protected _clone(): StringSchema { @@ -176,6 +180,17 @@ class StringSchema extends Schema { } }); + return cloned; + } + time(options: { precision?: number } = {}): StringSchema { + const cloned = this._clone(); + cloned._checks = this._checks || []; + cloned._checks.push((_value) => { + if (!timeRegex(options.precision).test(_value)) { + return this.issues.INVALID_TIME_STRING; + } + }); + return cloned; } } @@ -187,4 +202,4 @@ const singleton = /* @__PURE__ */ new StringSchema(); */ const string = /* @__PURE__ */ (): StringSchema => singleton; -export { string, emailRegex, emojiRegex, uuidRegex, nanoidRegex, dateRegex }; +export { string, emailRegex, emojiRegex, uuidRegex, nanoidRegex, dateRegex, timeRegex }; From 662c4711ff783af50f04aa1967bd717d2cb1f2f5 Mon Sep 17 00:00:00 2001 From: Vitaly Budovski Date: Fri, 3 Jan 2025 15:23:32 +1100 Subject: [PATCH 6/8] feature: String datetime --- .../reference/Schema/Primitives/string.mdx | 34 ++++++++ paseri-lib/bench/string/datetime.bench.ts | 26 ++++++ paseri-lib/src/issue.ts | 1 + paseri-lib/src/locales/en-GB.ts | 1 + paseri-lib/src/schemas/string.test.ts | 80 ++++++++++++++++++- paseri-lib/src/schemas/string.ts | 23 +++++- 6 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 paseri-lib/bench/string/datetime.bench.ts diff --git a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx index af61a99..67db519 100644 --- a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx +++ b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx @@ -133,3 +133,37 @@ p.string().time({ precision: 3 }); // 01:02:03.123 ✅ // 01:02:03.1234 ❌ ``` + +### `datetime` + +A valid [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) UTC datetime string `YYYY-MM-DDThh:mm:ss[.s+]Z`. + +```typescript +p.string().datetime(); +``` + +You can require a fixed precision by setting the `precision` option. + +```typescript +p.string().datetime({ precision: 3 }); +// 2020-01-02T01:02:03.123Z ✅ +// 2020-01-02T01:02:03.1234Z ❌ +``` + +Non-UTC offsets are accepted by setting the `offset` option. + +```typescript +p.string().datetime({ offset: true }); +// 2020-01-02T01:02:03.123Z ✅ +// 2020-01-02T01:02:03.123+02:30 ✅ +// 2020-01-02T01:02:03.123-0430 ✅ +// 2020-01-02T01:02:03.123 ❌ +``` + +Offset-less (naïve) values are accepted by setting the `local` option. + +```typescript +p.string().datetime({ local: true }); +// 2020-01-02T01:02:03.123Z ✅ +// 2020-01-02T01:02:03.123 ✅ +``` diff --git a/paseri-lib/bench/string/datetime.bench.ts b/paseri-lib/bench/string/datetime.bench.ts new file mode 100644 index 0000000..70acab0 --- /dev/null +++ b/paseri-lib/bench/string/datetime.bench.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import * as p from '../../src/index.ts'; + +const { bench } = Deno; + +const paseriSchema = p.string().datetime(); +const zodSchema = z.string().datetime(); + +const dataValid = '2020-01-01T01:02:03.45678Z'; +const dataInvalid = '2024-01-32T00:00:00'; + +bench('Paseri', { group: 'Datetime valid' }, () => { + paseriSchema.safeParse(dataValid); +}); + +bench('Zod', { group: 'Datetime valid' }, () => { + zodSchema.safeParse(dataValid); +}); + +bench('Paseri', { group: 'Datetime invalid' }, () => { + paseriSchema.safeParse(dataInvalid); +}); + +bench('Zod', { group: 'Datetime invalid' }, () => { + zodSchema.safeParse(dataInvalid); +}); diff --git a/paseri-lib/src/issue.ts b/paseri-lib/src/issue.ts index dbd69c7..3a4ab2e 100644 --- a/paseri-lib/src/issue.ts +++ b/paseri-lib/src/issue.ts @@ -16,6 +16,7 @@ const issueCodes = { DOES_NOT_END_WITH: 'does_not_end_with' as Tagged<'does_not_end_with', 'IssueCode'>, INVALID_DATE_STRING: 'invalid_date_string' as Tagged<'invalid_date_string', 'IssueCode'>, INVALID_TIME_STRING: 'invalid_time_string' as Tagged<'invalid_time_string', 'IssueCode'>, + INVALID_DATE_TIME_STRING: 'invalid_date_time_string' as Tagged<'invalid_date_time_string', 'IssueCode'>, // BigInt/Number. TOO_SMALL: 'too_small' as Tagged<'too_small', 'IssueCode'>, TOO_LARGE: 'too_large' as Tagged<'too_large', 'IssueCode'>, diff --git a/paseri-lib/src/locales/en-GB.ts b/paseri-lib/src/locales/en-GB.ts index c3b9e7f..ccf68af 100644 --- a/paseri-lib/src/locales/en-GB.ts +++ b/paseri-lib/src/locales/en-GB.ts @@ -16,6 +16,7 @@ const en_GB = { does_not_end_with: 'Does not end with search string.', invalid_date_string: 'Invalid date string.', invalid_time_string: 'Invalid time string.', + invalid_date_time_string: 'Invalid datetime string.', too_small: 'Too small.', too_large: 'Too large.', invalid_integer: 'Number must be an integer.', diff --git a/paseri-lib/src/schemas/string.test.ts b/paseri-lib/src/schemas/string.test.ts index 67bba2d..e96acab 100644 --- a/paseri-lib/src/schemas/string.test.ts +++ b/paseri-lib/src/schemas/string.test.ts @@ -4,7 +4,7 @@ import fc from 'fast-check'; import { checkSync } from 'recheck'; import emoji from '../emoji.json' with { type: 'json' }; import * as p from '../index.ts'; -import { dateRegex, emailRegex, emojiRegex, nanoidRegex, timeRegex, uuidRegex } from './string.ts'; +import { dateRegex, datetimeRegex, emailRegex, emojiRegex, nanoidRegex, timeRegex, uuidRegex } from './string.ts'; const { test } = Deno; @@ -32,6 +32,15 @@ function formatTime(value: Date, precision?: number): string { return `${hour}:${minute}:${second}${fractionString}`; } +function formatDatetime(value: Date, timezone: number, precision?: number, offset?: boolean, local?: boolean): string { + const timezoneString = + timezone === 0 + ? 'Z' + : `${Math.sign(timezone) >= 0 ? '+' : '-'}${String(Math.floor(Math.abs(timezone) / 60)).padStart(2, '0')}:${String(Math.abs(timezone) % 60).padStart(2, '0')}`; + + return `${formatDate(value)}T${formatTime(value, precision)}${local ? '' : offset ? timezoneString : 'Z'}`; +} + test('Valid type', () => { const schema = p.string(); @@ -559,6 +568,69 @@ test('Time ReDoS', () => { ); }); +test('Valid datetime', () => { + fc.assert( + fc.property( + fc.date({ min: new Date(0, 0, 1), max: new Date(9999, 11, 31) }), + fc.integer({ min: -1000, max: 1000 }), + fc.option(fc.integer({ min: 0, max: 8 }), { nil: undefined }), + fc.boolean(), + fc.boolean(), + (date, timezone, precision, offset, local) => { + const data = formatDatetime(date, timezone, precision, offset, local); + + const schema = p.string().datetime({ precision, offset, local }); + const result = schema.safeParse(data); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf; + expect(result.value).toBe(data); + } else { + expect(result.ok).toBeTruthy(); + } + }, + ), + ); +}); + +test('Invalid datetime', () => { + const schema = p.string().datetime(); + + fc.assert( + fc.property( + fc.string().filter((value) => !datetimeRegex().test(value)), + (data) => { + const result = schema.safeParse(data); + if (!result.ok) { + expect(result.messages()).toEqual([{ path: [], message: 'Invalid datetime string.' }]); + } else { + expect(result.ok).toBeFalsy(); + } + }, + ), + ); +}); + +test('Datetime ReDoS', () => { + fc.assert( + fc.property( + fc.option(fc.integer({ min: 0, max: 8 }), { nil: undefined }), + fc.boolean(), + fc.boolean(), + (precision, offset, local) => { + const regex = datetimeRegex(precision, offset, local); + const diagnostics = checkSync(regex.source, regex.flags); + if (diagnostics.status === 'vulnerable') { + console.log(`Vulnerable pattern: ${diagnostics.attack.pattern}`); + } else if (diagnostics.status === 'unknown') { + console.log(`Error: ${diagnostics.error.kind}.`); + } + expect(diagnostics.status).toBe('safe'); + }, + ), + { ignoreEqualValues: true }, + ); +}); + test('Optional', () => { const schema = p.string().optional(); @@ -663,4 +735,10 @@ test('Immutable', async (t) => { const modified = original.time(); expect(modified).not.toEqual(original); }); + + await t.step('datetime', () => { + const original = p.string(); + const modified = original.datetime(); + expect(modified).not.toEqual(original); + }); }); diff --git a/paseri-lib/src/schemas/string.ts b/paseri-lib/src/schemas/string.ts index b6defe3..1cd2788 100644 --- a/paseri-lib/src/schemas/string.ts +++ b/paseri-lib/src/schemas/string.ts @@ -14,6 +14,15 @@ const dateRegex = new RegExp(`^${dateRegexString}$`); const timeRegexString = (precision?: number) => `([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d${precision === undefined ? '(\\.\\d+)?' : `\\.\\d{${precision}}`}`; const timeRegex = (precision?: number) => new RegExp(`^${timeRegexString(precision)}$`); +const datetimeRegex = (precision?: number, offset?: boolean, local?: boolean) => { + const timezone: string[] = []; + timezone.push(local ? 'Z?' : 'Z'); + if (offset) { + timezone.push('([+-][0-5]\\d:[0-5]\\d)'); + } + + return new RegExp(`^${dateRegexString}T${timeRegexString(precision)}${timezone.join('|')}$`); +}; type CheckFunction = (value: string) => TreeNode | undefined; @@ -33,6 +42,7 @@ class StringSchema extends Schema { DOES_NOT_END_WITH: { type: 'leaf', code: issueCodes.DOES_NOT_END_WITH }, INVALID_DATE_STRING: { type: 'leaf', code: issueCodes.INVALID_DATE_STRING }, INVALID_TIME_STRING: { type: 'leaf', code: issueCodes.INVALID_TIME_STRING }, + INVALID_DATE_TIME_STRING: { type: 'leaf', code: issueCodes.INVALID_DATE_TIME_STRING }, } as const satisfies Record; protected _clone(): StringSchema { @@ -191,6 +201,17 @@ class StringSchema extends Schema { } }); + return cloned; + } + datetime(options: { precision?: number; offset?: boolean; local?: boolean } = {}): StringSchema { + const cloned = this._clone(); + cloned._checks = this._checks || []; + cloned._checks.push((_value) => { + if (!datetimeRegex(options.precision, options.offset, options.local).test(_value)) { + return this.issues.INVALID_DATE_TIME_STRING; + } + }); + return cloned; } } @@ -202,4 +223,4 @@ const singleton = /* @__PURE__ */ new StringSchema(); */ const string = /* @__PURE__ */ (): StringSchema => singleton; -export { string, emailRegex, emojiRegex, uuidRegex, nanoidRegex, dateRegex, timeRegex }; +export { string, emailRegex, emojiRegex, uuidRegex, nanoidRegex, dateRegex, timeRegex, datetimeRegex }; From 2e50f39bb9e553613559baefef2f1f8f294cb9d6 Mon Sep 17 00:00:00 2001 From: Vitaly Budovski Date: Fri, 3 Jan 2025 16:52:20 +1100 Subject: [PATCH 7/8] feature: String ip --- .../reference/Schema/Primitives/string.mdx | 18 ++++ paseri-lib/bench/string/ip.bench.ts | 26 ++++++ paseri-lib/src/issue.ts | 1 + paseri-lib/src/locales/en-GB.ts | 1 + paseri-lib/src/schemas/string.test.ts | 83 ++++++++++++++++++- paseri-lib/src/schemas/string.ts | 40 ++++++++- 6 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 paseri-lib/bench/string/ip.bench.ts diff --git a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx index 67db519..3bc66a4 100644 --- a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx +++ b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx @@ -167,3 +167,21 @@ p.string().datetime({ local: true }); // 2020-01-02T01:02:03.123Z ✅ // 2020-01-02T01:02:03.123 ✅ ``` + +### `ip` + +A valid [IPv4](https://en.wikipedia.org/wiki/IPv4) or [IPv6](https://en.wikipedia.org/wiki/IPv6) address. + +```typescript +p.string().ip(); +// 127.0.0.1 ✅ +// ::1 ✅ +``` + +The protocol version can be restricted. + +```typescript +p.string().ip({ version: 4 }); +// 127.0.0.1 ✅ +// ::1 ❌ +``` diff --git a/paseri-lib/bench/string/ip.bench.ts b/paseri-lib/bench/string/ip.bench.ts new file mode 100644 index 0000000..9fd7968 --- /dev/null +++ b/paseri-lib/bench/string/ip.bench.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import * as p from '../../src/index.ts'; + +const { bench } = Deno; + +const paseriSchema = p.string().ip(); +const zodSchema = z.string().ip(); + +const dataValid = '192.168.1.254'; +const dataInvalid = '999'; + +bench('Paseri', { group: 'IP valid' }, () => { + paseriSchema.safeParse(dataValid); +}); + +bench('Zod', { group: 'IP valid' }, () => { + zodSchema.safeParse(dataValid); +}); + +bench('Paseri', { group: 'IP invalid' }, () => { + paseriSchema.safeParse(dataInvalid); +}); + +bench('Zod', { group: 'IP invalid' }, () => { + zodSchema.safeParse(dataInvalid); +}); diff --git a/paseri-lib/src/issue.ts b/paseri-lib/src/issue.ts index 3a4ab2e..8d94a89 100644 --- a/paseri-lib/src/issue.ts +++ b/paseri-lib/src/issue.ts @@ -17,6 +17,7 @@ const issueCodes = { INVALID_DATE_STRING: 'invalid_date_string' as Tagged<'invalid_date_string', 'IssueCode'>, INVALID_TIME_STRING: 'invalid_time_string' as Tagged<'invalid_time_string', 'IssueCode'>, INVALID_DATE_TIME_STRING: 'invalid_date_time_string' as Tagged<'invalid_date_time_string', 'IssueCode'>, + INVALID_IP_ADDRESS: 'invalid_ip_address' as Tagged<'invalid_ip_address', 'IssueCode'>, // BigInt/Number. TOO_SMALL: 'too_small' as Tagged<'too_small', 'IssueCode'>, TOO_LARGE: 'too_large' as Tagged<'too_large', 'IssueCode'>, diff --git a/paseri-lib/src/locales/en-GB.ts b/paseri-lib/src/locales/en-GB.ts index ccf68af..4b2aeb9 100644 --- a/paseri-lib/src/locales/en-GB.ts +++ b/paseri-lib/src/locales/en-GB.ts @@ -17,6 +17,7 @@ const en_GB = { invalid_date_string: 'Invalid date string.', invalid_time_string: 'Invalid time string.', invalid_date_time_string: 'Invalid datetime string.', + invalid_ip_address: 'Invalid IP address.', too_small: 'Too small.', too_large: 'Too large.', invalid_integer: 'Number must be an integer.', diff --git a/paseri-lib/src/schemas/string.test.ts b/paseri-lib/src/schemas/string.test.ts index e96acab..4f3f4b2 100644 --- a/paseri-lib/src/schemas/string.test.ts +++ b/paseri-lib/src/schemas/string.test.ts @@ -4,7 +4,17 @@ import fc from 'fast-check'; import { checkSync } from 'recheck'; import emoji from '../emoji.json' with { type: 'json' }; import * as p from '../index.ts'; -import { dateRegex, datetimeRegex, emailRegex, emojiRegex, nanoidRegex, timeRegex, uuidRegex } from './string.ts'; +import { + dateRegex, + datetimeRegex, + emailRegex, + emojiRegex, + ipv4Regex, + ipv6Regex, + nanoidRegex, + timeRegex, + uuidRegex, +} from './string.ts'; const { test } = Deno; @@ -631,6 +641,71 @@ test('Datetime ReDoS', () => { ); }); +test('Valid ip', () => { + const schema = p.string().ip(); + + fc.assert( + fc.property( + fc.oneof( + fc.ipV4(), + // Exclude dual format addresses. + fc + .ipV6() + .filter((value) => !value.includes('.')), + ), + (data) => { + const result = schema.safeParse(data); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf; + expect(result.value).toBe(data); + } else { + expect(result.ok).toBeTruthy(); + } + }, + ), + ); +}); + +test('Invalid ip', () => { + const schema = p.string().ip(); + + fc.assert( + fc.property( + fc.string().filter((value) => !ipv4Regex.test(value) && !ipv6Regex.test(value)), + (data) => { + const result = schema.safeParse(data); + if (!result.ok) { + expect(result.messages()).toEqual([{ path: [], message: 'Invalid IP address.' }]); + } else { + expect(result.ok).toBeFalsy(); + } + }, + ), + ); +}); + +test('ip ReDoS', async (t) => { + await t.step('IPv4', () => { + const diagnostics = checkSync(ipv4Regex.source, ipv4Regex.flags); + if (diagnostics.status === 'vulnerable') { + console.log(`Vulnerable pattern: ${diagnostics.attack.pattern}`); + } else if (diagnostics.status === 'unknown') { + console.log(`Error: ${diagnostics.error.kind}.`); + } + expect(diagnostics.status).toBe('safe'); + }); + + await t.step('IPv6', () => { + const diagnostics = checkSync(ipv6Regex.source, ipv6Regex.flags); + if (diagnostics.status === 'vulnerable') { + console.log(`Vulnerable pattern: ${diagnostics.attack.pattern}`); + } else if (diagnostics.status === 'unknown') { + console.log(`Error: ${diagnostics.error.kind}.`); + } + expect(diagnostics.status).toBe('safe'); + }); +}); + test('Optional', () => { const schema = p.string().optional(); @@ -741,4 +816,10 @@ test('Immutable', async (t) => { const modified = original.datetime(); expect(modified).not.toEqual(original); }); + + await t.step('ip', () => { + const original = p.string(); + const modified = original.ip(); + expect(modified).not.toEqual(original); + }); }); diff --git a/paseri-lib/src/schemas/string.ts b/paseri-lib/src/schemas/string.ts index 1cd2788..4b1ff96 100644 --- a/paseri-lib/src/schemas/string.ts +++ b/paseri-lib/src/schemas/string.ts @@ -23,6 +23,11 @@ const datetimeRegex = (precision?: number, offset?: boolean, local?: boolean) => return new RegExp(`^${dateRegexString}T${timeRegexString(precision)}${timezone.join('|')}$`); }; +const ipv4Regex = + /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; +// Does not support dual format IPv4/IPv6 addresses "y:y:y:y:y:y:x.x.x.x". +const ipv6Regex = + /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))$/; type CheckFunction = (value: string) => TreeNode | undefined; @@ -43,6 +48,7 @@ class StringSchema extends Schema { INVALID_DATE_STRING: { type: 'leaf', code: issueCodes.INVALID_DATE_STRING }, INVALID_TIME_STRING: { type: 'leaf', code: issueCodes.INVALID_TIME_STRING }, INVALID_DATE_TIME_STRING: { type: 'leaf', code: issueCodes.INVALID_DATE_TIME_STRING }, + INVALID_IP_ADDRESS: { type: 'leaf', code: issueCodes.INVALID_IP_ADDRESS }, } as const satisfies Record; protected _clone(): StringSchema { @@ -212,6 +218,27 @@ class StringSchema extends Schema { } }); + return cloned; + } + ip(options: { version?: 4 | 6 } = {}): StringSchema { + const cloned = this._clone(); + cloned._checks = this._checks || []; + cloned._checks.push((_value) => { + if (!options.version) { + if (!ipv4Regex.test(_value) && !ipv6Regex.test(_value)) { + return this.issues.INVALID_IP_ADDRESS; + } + } else if (options.version === 4) { + if (!ipv4Regex.test(_value)) { + return this.issues.INVALID_IP_ADDRESS; + } + } else { + if (!ipv6Regex.test(_value)) { + return this.issues.INVALID_IP_ADDRESS; + } + } + }); + return cloned; } } @@ -223,4 +250,15 @@ const singleton = /* @__PURE__ */ new StringSchema(); */ const string = /* @__PURE__ */ (): StringSchema => singleton; -export { string, emailRegex, emojiRegex, uuidRegex, nanoidRegex, dateRegex, timeRegex, datetimeRegex }; +export { + string, + emailRegex, + emojiRegex, + uuidRegex, + nanoidRegex, + dateRegex, + timeRegex, + datetimeRegex, + ipv4Regex, + ipv6Regex, +}; From ad9799a67e37af68bd2cb421d651c9c3b6bdae5c Mon Sep 17 00:00:00 2001 From: Vitaly Budovski Date: Fri, 3 Jan 2025 17:24:32 +1100 Subject: [PATCH 8/8] feature: String cidr --- .../reference/Schema/Primitives/string.mdx | 18 +++++ paseri-lib/bench/string/cidr.bench.ts | 26 +++++++ paseri-lib/src/issue.ts | 1 + paseri-lib/src/locales/en-GB.ts | 1 + paseri-lib/src/schemas/string.test.ts | 78 +++++++++++++++++++ paseri-lib/src/schemas/string.ts | 28 +++++++ 6 files changed, 152 insertions(+) create mode 100644 paseri-lib/bench/string/cidr.bench.ts diff --git a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx index 3bc66a4..3c93f30 100644 --- a/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx +++ b/paseri-docs/src/content/docs/reference/Schema/Primitives/string.mdx @@ -185,3 +185,21 @@ p.string().ip({ version: 4 }); // 127.0.0.1 ✅ // ::1 ❌ ``` + +### `cidr` + +A valid IP address range in [CIDR](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing) notation. + +```typescript +p.string().cidr(); +// 127.0.0.0/8 ✅ +// ::1/128 ✅ +``` + +The protocol version can be restricted. + +```typescript +p.string().cidr({ version: 4 }); +// 127.0.0.0/8 ✅ +// ::1/128 ❌ +``` diff --git a/paseri-lib/bench/string/cidr.bench.ts b/paseri-lib/bench/string/cidr.bench.ts new file mode 100644 index 0000000..4f9f961 --- /dev/null +++ b/paseri-lib/bench/string/cidr.bench.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import * as p from '../../src/index.ts'; + +const { bench } = Deno; + +const paseriSchema = p.string().cidr(); +const zodSchema = z.string().cidr(); + +const dataValid = '10.0.0.0/22'; +const dataInvalid = '127.0.0.1'; + +bench('Paseri', { group: 'CIDR valid' }, () => { + paseriSchema.safeParse(dataValid); +}); + +bench('Zod', { group: 'CIDR valid' }, () => { + zodSchema.safeParse(dataValid); +}); + +bench('Paseri', { group: 'CIDR invalid' }, () => { + paseriSchema.safeParse(dataInvalid); +}); + +bench('Zod', { group: 'CIDR invalid' }, () => { + zodSchema.safeParse(dataInvalid); +}); diff --git a/paseri-lib/src/issue.ts b/paseri-lib/src/issue.ts index 8d94a89..7d45710 100644 --- a/paseri-lib/src/issue.ts +++ b/paseri-lib/src/issue.ts @@ -18,6 +18,7 @@ const issueCodes = { INVALID_TIME_STRING: 'invalid_time_string' as Tagged<'invalid_time_string', 'IssueCode'>, INVALID_DATE_TIME_STRING: 'invalid_date_time_string' as Tagged<'invalid_date_time_string', 'IssueCode'>, INVALID_IP_ADDRESS: 'invalid_ip_address' as Tagged<'invalid_ip_address', 'IssueCode'>, + INVALID_IP_ADDRESS_RANGE: 'invalid_ip_address_range' as Tagged<'invalid_ip_address_range', 'IssueCode'>, // BigInt/Number. TOO_SMALL: 'too_small' as Tagged<'too_small', 'IssueCode'>, TOO_LARGE: 'too_large' as Tagged<'too_large', 'IssueCode'>, diff --git a/paseri-lib/src/locales/en-GB.ts b/paseri-lib/src/locales/en-GB.ts index 4b2aeb9..c51033c 100644 --- a/paseri-lib/src/locales/en-GB.ts +++ b/paseri-lib/src/locales/en-GB.ts @@ -18,6 +18,7 @@ const en_GB = { invalid_time_string: 'Invalid time string.', invalid_date_time_string: 'Invalid datetime string.', invalid_ip_address: 'Invalid IP address.', + invalid_ip_address_range: 'Invalid IP address range.', too_small: 'Too small.', too_large: 'Too large.', invalid_integer: 'Number must be an integer.', diff --git a/paseri-lib/src/schemas/string.test.ts b/paseri-lib/src/schemas/string.test.ts index 4f3f4b2..3fbeb1d 100644 --- a/paseri-lib/src/schemas/string.test.ts +++ b/paseri-lib/src/schemas/string.test.ts @@ -9,7 +9,9 @@ import { datetimeRegex, emailRegex, emojiRegex, + ipv4CidrRegex, ipv4Regex, + ipv6CidrRegex, ipv6Regex, nanoidRegex, timeRegex, @@ -706,6 +708,76 @@ test('ip ReDoS', async (t) => { }); }); +test('Valid cidr', () => { + const schema = p.string().cidr(); + + fc.assert( + fc.property( + fc.oneof( + fc.tuple(fc.ipV4(), fc.integer({ min: 1, max: 32 })).map(([ip, bits]) => `${ip}/${bits}`), + fc + .tuple( + // Exclude dual format addresses. + fc + .ipV6() + .filter((value) => !value.includes('.')), + fc.integer({ min: 1, max: 128 }), + ) + .map(([ip, bits]) => `${ip}/${bits}`), + ), + (data) => { + const result = schema.safeParse(data); + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf; + expect(result.value).toBe(data); + } else { + expect(result.ok).toBeTruthy(); + } + }, + ), + ); +}); + +test('Invalid cidr', () => { + const schema = p.string().cidr(); + + fc.assert( + fc.property( + fc.string().filter((value) => !ipv4CidrRegex.test(value) && !ipv6CidrRegex.test(value)), + (data) => { + const result = schema.safeParse(data); + if (!result.ok) { + expect(result.messages()).toEqual([{ path: [], message: 'Invalid IP address range.' }]); + } else { + expect(result.ok).toBeFalsy(); + } + }, + ), + ); +}); + +test('cidr ReDoS', async (t) => { + await t.step('IPv4', () => { + const diagnostics = checkSync(ipv4CidrRegex.source, ipv4CidrRegex.flags); + if (diagnostics.status === 'vulnerable') { + console.log(`Vulnerable pattern: ${diagnostics.attack.pattern}`); + } else if (diagnostics.status === 'unknown') { + console.log(`Error: ${diagnostics.error.kind}.`); + } + expect(diagnostics.status).toBe('safe'); + }); + + await t.step('IPv6', () => { + const diagnostics = checkSync(ipv6CidrRegex.source, ipv6CidrRegex.flags); + if (diagnostics.status === 'vulnerable') { + console.log(`Vulnerable pattern: ${diagnostics.attack.pattern}`); + } else if (diagnostics.status === 'unknown') { + console.log(`Error: ${diagnostics.error.kind}.`); + } + expect(diagnostics.status).toBe('safe'); + }); +}); + test('Optional', () => { const schema = p.string().optional(); @@ -822,4 +894,10 @@ test('Immutable', async (t) => { const modified = original.ip(); expect(modified).not.toEqual(original); }); + + await t.step('cidr', () => { + const original = p.string(); + const modified = original.cidr(); + expect(modified).not.toEqual(original); + }); }); diff --git a/paseri-lib/src/schemas/string.ts b/paseri-lib/src/schemas/string.ts index 4b1ff96..21e4c39 100644 --- a/paseri-lib/src/schemas/string.ts +++ b/paseri-lib/src/schemas/string.ts @@ -28,6 +28,10 @@ const ipv4Regex = // Does not support dual format IPv4/IPv6 addresses "y:y:y:y:y:y:x.x.x.x". const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))$/; +const ipv4CidrRegex = + /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/; +const ipv6CidrRegex = + /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; type CheckFunction = (value: string) => TreeNode | undefined; @@ -49,6 +53,7 @@ class StringSchema extends Schema { INVALID_TIME_STRING: { type: 'leaf', code: issueCodes.INVALID_TIME_STRING }, INVALID_DATE_TIME_STRING: { type: 'leaf', code: issueCodes.INVALID_DATE_TIME_STRING }, INVALID_IP_ADDRESS: { type: 'leaf', code: issueCodes.INVALID_IP_ADDRESS }, + INVALID_IP_ADDRESS_RANGE: { type: 'leaf', code: issueCodes.INVALID_IP_ADDRESS_RANGE }, } as const satisfies Record; protected _clone(): StringSchema { @@ -239,6 +244,27 @@ class StringSchema extends Schema { } }); + return cloned; + } + cidr(options: { version?: 4 | 6 } = {}): StringSchema { + const cloned = this._clone(); + cloned._checks = this._checks || []; + cloned._checks.push((_value) => { + if (!options.version) { + if (!ipv4CidrRegex.test(_value) && !ipv6CidrRegex.test(_value)) { + return this.issues.INVALID_IP_ADDRESS_RANGE; + } + } else if (options.version === 4) { + if (!ipv4CidrRegex.test(_value)) { + return this.issues.INVALID_IP_ADDRESS_RANGE; + } + } else { + if (!ipv6CidrRegex.test(_value)) { + return this.issues.INVALID_IP_ADDRESS_RANGE; + } + } + }); + return cloned; } } @@ -261,4 +287,6 @@ export { datetimeRegex, ipv4Regex, ipv6Regex, + ipv4CidrRegex, + ipv6CidrRegex, };