Skip to content

Commit

Permalink
Support string formats (#814)
Browse files Browse the repository at this point in the history
  • Loading branch information
ardatan authored Nov 15, 2023
1 parent 039991b commit d1a7484
Show file tree
Hide file tree
Showing 4 changed files with 306 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/cool-lizards-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'fets': patch
---

Add `registerFormats` to support string formats such as `uuid`, `email` or `url`
234 changes: 234 additions & 0 deletions packages/fets/src/plugins/formats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
// based on https://github.com/epoberezkin/ajv/blob/master/lib/compile/formats.js &
// https://github.com/cfworker/cfworker/blob/main/packages/json-schema/src/format.ts

import { FormatRegistry } from '@sinclair/typebox';

const DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
const DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const TIME = /^(\d\d):(\d\d):(\d\d)(\.\d+)?(z|[+-]\d\d(?::?\d\d)?)?$/i;
const HOSTNAME =
/^(?=.{1,253}\.?$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[-0-9a-z]{0,61}[0-9a-z])?)*\.?$/i;
// const URI = /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i;
const URIREF =
/^(?:[a-z][a-z0-9+\-.]*:)?(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'"()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*)?(?:\?(?:[a-z0-9\-._~!$&'"()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'"()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i;
// uri-template: https://tools.ietf.org/html/rfc6570
const URITEMPLATE =
// eslint-disable-next-line no-control-regex
/^(?:(?:[^\x00-\x20"'<>%\\^`{|}]|%[0-9a-f]{2})|\{[+#./;?&=,!@|]?(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?(?:,(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?)*\})*$/i;
const UUID = /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
const JSON_POINTER = /^(?:\/(?:[^~/]|~0|~1)*)*$/;
const JSON_POINTER_URI_FRAGMENT = /^#(?:\/(?:[a-z0-9_\-.!$&'()*+,;:=@]|%[0-9a-f]{2}|~0|~1)*)*$/i;
const RELATIVE_JSON_POINTER = /^(?:0|[1-9][0-9]*)(?:#|(?:\/(?:[^~/]|~0|~1)*)*)$/;

// date: http://tools.ietf.org/html/rfc3339#section-5.6
const FASTDATE = /^\d\d\d\d-[0-1]\d-[0-3]\d$/;
// date-time: http://tools.ietf.org/html/rfc3339#section-5.6
const FASTTIME = /^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i;
const FASTDATETIME =
/^\d\d\d\d-[0-1]\d-[0-3]\d[t\s](?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i;
// uri: https://github.com/mafintosh/is-my-json-valid/blob/master/formats.js
// const FASTURI = /^(?:[a-z][a-z0-9+-.]*:)(?:\/?\/)?[^\s]*$/i;
const FASTURIREFERENCE = /^(?:(?:[a-z][a-z0-9+-.]*:)?\/?\/)?(?:[^\\\s#][^\s#]*)?(?:#[^\\\s]*)?$/i;

// https://github.com/ExodusMovement/schemasafe/blob/master/src/formats.js
const EMAIL = (input: string) => {
if (input[0] === '"') return false;
const [name, host, ...rest] = input.split('@');
if (!name || !host || rest.length !== 0 || name.length > 64 || host.length > 253) return false;
if (name[0] === '.' || name.endsWith('.') || name.includes('..')) return false;
if (!/^[a-z0-9.-]+$/i.test(host) || !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$/i.test(name)) return false;
return host.split('.').every(part => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(part));
};

// optimized https://www.safaribooksonline.com/library/view/regular-expressions-cookbook/9780596802837/ch07s16.html
const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
// optimized http://stackoverflow.com/questions/53497/regular-expression-that-matches-valid-ipv6-addresses
const IPV6 =
/^((([0-9a-f]{1,4}:){7}([0-9a-f]{1,4}|:))|(([0-9a-f]{1,4}:){6}(:[0-9a-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){5}(((:[0-9a-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){4}(((:[0-9a-f]{1,4}){1,3})|((:[0-9a-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){3}(((:[0-9a-f]{1,4}){1,4})|((:[0-9a-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){2}(((:[0-9a-f]{1,4}){1,5})|((:[0-9a-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){1}(((:[0-9a-f]{1,4}){1,6})|((:[0-9a-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-f]{1,4}){1,7})|((:[0-9a-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))$/i;

// https://github.com/ExodusMovement/schemasafe/blob/master/src/formats.js
const DURATION = (input: string) =>
input.length > 1 &&
input.length < 80 &&
(/^P\d+([.,]\d+)?W$/.test(input) ||
(/^P[\dYMDTHS]*(\d[.,]\d+)?[YMDHS]$/.test(input) &&
/^P([.,\d]+Y)?([.,\d]+M)?([.,\d]+D)?(T([.,\d]+H)?([.,\d]+M)?([.,\d]+S)?)?$/.test(input)));

function bind(r: RegExp) {
return r.test.bind(r);
}

const BYTE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/gm;

function byte(str: string): boolean {
BYTE.lastIndex = 0;
return BYTE.test(str);
}

function float(str: string): boolean {
const n = +str;
return !Number.isNaN(n) && Number.isFinite(n);
}

export const fullFormat: Record<FormatName, (s: string) => boolean> = {
date,
time: time.bind(undefined, false),
// eslint-disable-next-line camelcase
'date-time': date_time,
duration: DURATION,
uri,
'uri-reference': bind(URIREF),
'uri-template': bind(URITEMPLATE),
url: v => {
try {
// eslint-disable-next-line no-new
new URL(v);
return true;
} catch (e) {
return false;
}
},
email: EMAIL,
hostname: bind(HOSTNAME),
ipv4: bind(IPV4),
ipv6: bind(IPV6),
regex,
uuid: bind(UUID),
'json-pointer': bind(JSON_POINTER),
'json-pointer-uri-fragment': bind(JSON_POINTER_URI_FRAGMENT),
'relative-json-pointer': bind(RELATIVE_JSON_POINTER),
'iso-date-time': bind(
/^\d\d\d\d-[0-1]\d-[0-3]\dt(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i,
),
'iso-time': bind(/^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i),
byte,
int32: v => Number.isSafeInteger(+v) && +v >= -2147483648 && +v <= 2147483647,
int64: v => {
const vNum = BigInt(v);
return vNum >= -9223372036854775808n && vNum <= 9223372036854775807n;
},
float,
double: float,
password: () => true,
binary: () => true,
};

export const fastFormat: Record<FormatName, (s: string) => boolean> = {
...fullFormat,
date: bind(FASTDATE),
time: bind(FASTTIME),
'date-time': bind(FASTDATETIME),
'uri-reference': bind(FASTURIREFERENCE),
};

function isLeapYear(year: number) {
// https://tools.ietf.org/html/rfc3339#appendix-C
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}

function date(str: string) {
// full-date from http://tools.ietf.org/html/rfc3339#section-5.6
const matches = str.match(DATE);
if (!matches) return false;

const year = +matches[1];
const month = +matches[2];
const day = +matches[3];

return (
month >= 1 &&
month <= 12 &&
day >= 1 &&
// eslint-disable-next-line eqeqeq
day <= (month == 2 && isLeapYear(year) ? 29 : DAYS[month])
);
}

function time(full: boolean, str: string) {
const matches = str.match(TIME);
if (!matches) return false;

const hour = +matches[1];
const minute = +matches[2];
const second = +matches[3];
const timeZone = !!matches[5];
return (
((hour <= 23 && minute <= 59 && second <= 59) ||
// eslint-disable-next-line eqeqeq
(hour == 23 && minute == 59 && second == 60)) &&
(!full || timeZone)
);
}

const DATE_TIME_SEPARATOR = /t|\s/i;
// eslint-disable-next-line camelcase
function date_time(str: string) {
// http://tools.ietf.org/html/rfc3339#section-5.6
const dateTime = str.split(DATE_TIME_SEPARATOR);
// eslint-disable-next-line eqeqeq
return dateTime.length == 2 && date(dateTime[0]) && time(true, dateTime[1]);
}

const NOT_URI_FRAGMENT = /\/|:/;
const URI_PATTERN =
/^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i;

function uri(str: string): boolean {
// http://jmrware.com/articles/2009/uri_regexp/URI_regex.html + optional protocol + required "."
return NOT_URI_FRAGMENT.test(str) && URI_PATTERN.test(str);
}

const Z_ANCHOR = /[^\\]\\Z/;
function regex(str: string) {
if (Z_ANCHOR.test(str)) return false;
try {
// eslint-disable-next-line no-new
new RegExp(str);
return true;
} catch (e) {
return false;
}
}

export type FormatName =
| 'date'
| 'time'
| 'date-time'
| 'iso-time'
| 'iso-date-time'
| 'duration'
| 'uri'
| 'uri-reference'
| 'uri-template'
| 'url'
| 'email'
| 'hostname'
| 'ipv4'
| 'ipv6'
| 'regex'
| 'uuid'
| 'json-pointer'
| 'json-pointer-uri-fragment'
| 'relative-json-pointer'
| 'byte'
| 'int32'
| 'int64'
| 'float'
| 'double'
| 'password'
| 'binary';

interface RegisterFormatOpts {
formats?: FormatName[];
type?: 'fast' | 'full';
}

export function registerFormats({
formats = Object.keys(fastFormat) as FormatName[],
type = 'fast',
}: RegisterFormatOpts = {}) {
const formatMap = type === 'full' ? fullFormat : fastFormat;
for (const format of formats) {
FormatRegistry.Set(format, formatMap[format]);
}
}
56 changes: 56 additions & 0 deletions packages/fets/tests/typebox.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { File, FormData } from '@whatwg-node/fetch';
import { createRouter, Response, Type } from '../src/index.js';
import { registerFormats } from '../src/plugins/formats.js';

describe('TypeBox', () => {
const router = createRouter({}).route({
Expand Down Expand Up @@ -185,4 +186,59 @@ describe('TypeBox', () => {

expect(response.status).toEqual(200);
});
it('validates the string formats', async () => {
registerFormats();
const router = createRouter().route({
path: '/hello',
method: 'POST',
schemas: {
request: {
json: Type.Object({
id: Type.String({
format: 'uuid',
}),
}),
},
} as const,
async handler(request) {
const { id } = await request.json();
return Response.json({
message: `Hello ${id}!`,
});
},
});

const response = await router.fetch('/hello', {
method: 'POST',
body: JSON.stringify({
id: '123',
}),
});

expect(response.status).toEqual(400);

const resultJson = await response.json();
expect(resultJson).toMatchObject({
errors: [
{
message: "Expected string to match 'uuid' format",
name: 'json',
path: '/id',
value: '123',
},
],
});

const validResponse = await router.fetch('/hello', {
method: 'POST',
body: JSON.stringify({
id: '123e4567-e89b-12d3-a456-426614174000',
}),
});

const validResultJson = await validResponse.json();
expect(validResultJson).toMatchObject({
message: 'Hello 123e4567-e89b-12d3-a456-426614174000!',
});
});
});
12 changes: 11 additions & 1 deletion website/src/pages/server/type-safety-and-validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ It allows us to have:
according to the benchmarks found
[here](https://github.com/fastify/fast-json-stringify#how-it-works).

<Callout>
If you want to use [JSON Schema formats](https://json-schema.org/understanding-json-schema/reference/string#format) like `uuid`, you need to register formats using `registerFormats`.
```ts
import { registerFormats } from 'fets'

registerFormats();

````
</Callout>

## Request

You can type individual parts of the `Request` object including JSON body, form data, headers, query
Expand Down Expand Up @@ -59,7 +69,7 @@ const router = createRouter().route({
return Response.json({ message: 'ok' })
}
})
```
````
### Path Parameters
Expand Down

0 comments on commit d1a7484

Please sign in to comment.