Skip to content

Commit

Permalink
feat: major typebox upgrade, use TransformDecode for schema inference
Browse files Browse the repository at this point in the history
  • Loading branch information
smiley-uriux committed Nov 18, 2024
1 parent 80420a4 commit 8a7dec8
Show file tree
Hide file tree
Showing 8 changed files with 90 additions and 53 deletions.
8 changes: 8 additions & 0 deletions .changeset/pre.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mode": "pre",
"tag": "next",
"initialVersions": {
"nestjs-typebox": "3.1.0"
},
"changesets": []
}
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@
"@nestjs/common": "^9.0.1 || ^10.0.3",
"@nestjs/core": "^9.0.1 || ^10.0.3",
"@nestjs/swagger": "^6.1.1 || ^7.0.11",
"@sinclair/typebox": "^0.32.4 || ^0.33.12",
"@sinclair/typebox": "^0.34.0",
"rxjs": "^7.5.6"
},
"devDependencies": {
"@changesets/cli": "^2.26.2",
"@nestjs/common": "^10.0.5",
"@nestjs/core": "^10.0.5",
"@nestjs/swagger": "^7.1.1",
"@sinclair/typebox": "^0.33.12",
"@sinclair/typebox": "^0.34.0",
"@types/node": "^20.4.1",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
Expand Down
7 changes: 5 additions & 2 deletions src/analyze-schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TArray, TIntersect, TObject, TRecord, TRef, TSchema, TTuple, TUnion } from '@sinclair/typebox';
import { Deref, Kind, TypeGuard } from '@sinclair/typebox';
import { Kind, TypeGuard } from '@sinclair/typebox';

function FromArray(schema: TArray, analysis: SchemaAnalysis): void {
Visit(schema.items, analysis);
Expand Down Expand Up @@ -31,7 +31,10 @@ function FromRecord(schema: TRecord, analysis: SchemaAnalysis) {
}

function FromRef(schema: TRef, analysis: SchemaAnalysis) {
Visit(Deref(schema, [...analysis.references.values()]), analysis);
const target = analysis.references.get(schema.$ref);
if (target) {
Visit(target, analysis);
}
}

function FromTuple(schema: TTuple, analysis: SchemaAnalysis) {
Expand Down
10 changes: 5 additions & 5 deletions src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { RouteParamtypes } from '@nestjs/common/enums/route-paramtypes.enum.js';
import { extendArrayMetadata } from '@nestjs/common/utils/extend-metadata.util.js';
import { ApiBody, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger';
import { DECORATORS } from '@nestjs/swagger/dist/constants.js';
import { type Static, type TSchema, Type, TypeGuard } from '@sinclair/typebox';
import { StaticDecode, type TSchema, Type, TypeGuard } from '@sinclair/typebox';
import { TypeCompiler } from '@sinclair/typebox/compiler';
import { Clean, Convert, Default, TransformDecode } from '@sinclair/typebox/value';
import { Clean, Convert, Default, TransformDecode, TransformEncode } from '@sinclair/typebox/value';

import { analyzeSchema } from './analyze-schema.js';
import { TypeboxValidationException } from './exceptions.js';
Expand Down Expand Up @@ -75,7 +75,7 @@ export function buildSchemaValidator(config: SchemaValidatorConfig): SchemaValid
}

if (analysis.hasTransform) {
return TransformDecode(schema, references, data);
return type === 'response' ? TransformEncode(schema, references, data) : TransformDecode(schema, references, data);
}

return data;
Expand All @@ -89,7 +89,7 @@ export function Validate<
MethodDecoratorType extends (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...args: [...RequestConfigsToTypes<RequestValidators>, ...any[]]
) => Promise<Static<T>> | Static<T>,
) => Promise<StaticDecode<T>> | StaticDecode<T>,
>(validatorConfig: ValidatorConfig<T, RequestValidators>): MethodDecorator<MethodDecoratorType> {
return (target, key, descriptor) => {
let args = Reflect.getMetadata(ROUTE_ARGS_METADATA, target.constructor, key) ?? {};
Expand Down Expand Up @@ -184,7 +184,7 @@ export const HttpEndpoint = <
MethodDecoratorType extends (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...args: [...RequestConfigsToTypes<RequestConfigs>, ...any[]]
) => Promise<Static<S>> | Static<S>,
) => Promise<StaticDecode<S>> | StaticDecode<S>,
>(
config: HttpEndpointDecoratorConfig<S, RequestConfigs>
): MethodDecorator<MethodDecoratorType> => {
Expand Down
54 changes: 53 additions & 1 deletion src/formats.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,60 @@
import { FormatRegistry } from '@sinclair/typebox';

const emailRegex = /.+\@.+\..+/;
export const emailFormat = (value: string) => value.match(emailRegex) !== null;
export const emailFormat = (value: string) => emailRegex.test(value);

const uuidRegex = /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
export const uuidFormat = (value: string) => uuidRegex.test(value);

const urlRegex =
/^(?:https?|wss?|ftp):\/\/(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u{00a1}-\u{ffff}]+-)*[a-z0-9\u{00a1}-\u{ffff}]+)(?:\.(?:[a-z0-9\u{00a1}-\u{ffff}]+-)*[a-z0-9\u{00a1}-\u{ffff}]+)*(?:\.(?:[a-z\u{00a1}-\u{ffff}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$/iu;
export const urlFormat = (value: string) => urlRegex.test(value);

const DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;

const isLeapYear = (year: number): boolean => {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
};

export const dateFormat = (value: string): boolean => {
const matches: string[] | null = DATE.exec(value);
if (!matches) return false;
const year: number = +matches[1]!;
const month: number = +matches[2]!;
const day: number = +matches[3]!;
return month >= 1 && month <= 12 && day >= 1 && day <= (month === 2 && isLeapYear(year) ? 29 : DAYS[month]!);
};

const timeRegex = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i;
export const timeFormat = (value: string, strictTimeZone?: boolean): boolean => {
const matches: string[] | null = timeRegex.exec(value);
if (!matches) return false;
const hr: number = +matches[1]!;
const min: number = +matches[2]!;
const sec: number = +matches[3]!;
const tz: string | undefined = matches[4];
const tzSign: number = matches[5] === '-' ? -1 : 1;
const tzH: number = +(matches[6] || 0);
const tzM: number = +(matches[7] || 0);
if (tzH > 23 || tzM > 59 || (strictTimeZone && !tz)) return false;
if (hr <= 23 && min <= 59 && sec < 60) return true;
const utcMin = min - tzM * tzSign;
const utcHr = hr - tzH * tzSign - (utcMin < 0 ? 1 : 0);
return (utcHr === 23 || utcHr === -1) && (utcMin === 59 || utcMin === -1) && sec < 61;
};

const dateTimeSplitRegex = /t|\s/i;
export const dateTimeFormat = (value: string, strictTimeZone?: boolean): boolean => {
const dateTime: string[] = value.split(dateTimeSplitRegex);
return dateTime.length === 2 && dateFormat(dateTime[0]!) && timeFormat(dateTime[1]!, strictTimeZone);
};

export const setFormats = () => {
FormatRegistry.Set('email', emailFormat);
FormatRegistry.Set('uuid', uuidFormat);
FormatRegistry.Set('url', urlFormat);
FormatRegistry.Set('date', dateFormat);
FormatRegistry.Set('time', timeFormat);
FormatRegistry.Set('date-time', dateTimeFormat);
};
8 changes: 4 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { PipeTransform, Type } from '@nestjs/common';
import { ApiOperationOptions } from '@nestjs/swagger';
import type { Static, TComposite, TOmit, TPartial, TPick, TSchema } from '@sinclair/typebox';
import type { Static, StaticDecode, TComposite, TOmit, TPartial, TPick, TSchema } from '@sinclair/typebox';
import type { TypeCheck } from '@sinclair/typebox/compiler';

import { SchemaAnalysis } from './analyze-schema.js';
Expand Down Expand Up @@ -37,7 +37,7 @@ export interface SchemaValidator<T extends TSchema = TSchema> {
name: string;
analysis: SchemaAnalysis;
check: TypeCheck<T>['Check'];
validate(data: Obj | Obj[]): Static<T>;
validate(data: Obj | Obj[]): unknown;
}
export interface ValidatorConfigBase {
schema?: TSchema;
Expand Down Expand Up @@ -89,10 +89,10 @@ export interface ValidatorConfig<S extends TSchema, RequestConfigs extends Reque
export type RequestConfigsToTypes<RequestConfigs extends RequestValidatorConfig[]> = {
[K in keyof RequestConfigs]: RequestConfigs[K]['required'] extends false
? RequestConfigs[K]['schema'] extends TSchema
? Static<RequestConfigs[K]['schema']> | undefined
? StaticDecode<RequestConfigs[K]['schema']> | undefined
: string | undefined
: RequestConfigs[K]['schema'] extends TSchema
? Static<RequestConfigs[K]['schema']>
? StaticDecode<RequestConfigs[K]['schema']>
: string;
};

Expand Down
38 changes: 6 additions & 32 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,7 @@
import { SchemaOptions, Static, TLiteral, TObject, TPropertyKey, TSchema, TUnion, Type } from '@sinclair/typebox/type';
import { SchemaOptions, Static, StringOptions, TLiteral, TObject, TPropertyKey, TSchema, TUnion, Type } from '@sinclair/typebox/type';

import { AllKeys, Obj, TPartialSome } from './types.js';

export const coerceToNumber = (val: unknown, integer?: boolean): unknown => {
switch (typeof val) {
case 'number':
return integer ? Math.floor(val) : val;
case 'boolean':
return val === true ? 1 : 0;
case 'string': {
const v = Number(val);
if (Number.isFinite(v)) {
return integer ? Math.floor(v) : v;
}
break;
}
case 'object': {
if (val === null) return 0;
break;
}
}
return val;
};

export const coerceType = (type: string, val: unknown): unknown => {
switch (type) {
case 'number':
case 'integer':
return coerceToNumber(val, type === 'integer');
default:
return val;
}
};

export const capitalize = <S extends string>(str: S): Capitalize<S> => {
return (str.charAt(0).toUpperCase() + str.slice(1)) as Capitalize<S>;
};
Expand Down Expand Up @@ -197,3 +166,8 @@ export function UnionPartialSome<
export function UnionPartialSome(union: TUnion<TObject[]>, keys: readonly [...TPropertyKey[]]): TUnion {
return Type.Union(union.anyOf.map(schema => PartialSome(schema, keys)));
}

export const IsoDate = (options?: StringOptions) =>
Type.Transform(Type.String({ format: 'date-time', ...options }))
.Decode(value => new Date(value))
.Encode(value => value.toISOString());

0 comments on commit 8a7dec8

Please sign in to comment.