Skip to content

Commit

Permalink
feat: BREAKING CHANGE - upgrade to latest typebox
Browse files Browse the repository at this point in the history
  • Loading branch information
smiley-uriux committed Dec 12, 2023
1 parent c658d7b commit 47426f3
Show file tree
Hide file tree
Showing 9 changed files with 1,128 additions and 5,459 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": "2.6.1"
},
"changesets": []
}
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": true
"source.fixAll": "explicit"
}
}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,5 @@ Swagger patch derived from https://github.com/risenforces/nestjs-zod
- Validate observable support
- utility to create typebox schemas with CRUD defaults (i.e. SchemaName['response'], SchemaName['update'])
- include method name in decorator errors
- support validating entire query object? (instead of individual values)
- check controller metadata so resolved path can include params specified at the controller level
6,344 changes: 976 additions & 5,368 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,26 @@
"@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.28.2 || ^0.29.4",
"@sinclair/typebox": "^0.32.0-dev-24",
"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.29.4",
"@sinclair/typebox": "^0.32.0-dev-24",
"@types/node": "^20.4.1",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.44.0",
"eslint-config-prettier": "^8.8.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-require-extensions": "^0.1.3",
"eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3",
"lint-staged": "^13.2.3",
"lint-staged": "^15.2.0",
"prettier": "^3.0.0",
"rxjs": "^7.8.1",
"typescript": "^5.1.6"
Expand Down
109 changes: 26 additions & 83 deletions src/decorators.ts
Original file line number Diff line number Diff line change
@@ -1,92 +1,27 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/ban-types */
import { applyDecorators, assignMetadata, Delete, Get, HttpCode, Patch, PipeTransform, Post, Put, Type as NestType } from '@nestjs/common';
import { applyDecorators, assignMetadata, Delete, Get, HttpCode, Patch, PipeTransform, Post, Put } from '@nestjs/common';
import { INTERCEPTORS_METADATA, ROUTE_ARGS_METADATA } from '@nestjs/common/constants.js';
import { RouteParamtypes } from '@nestjs/common/enums/route-paramtypes.enum.js';
import { extendArrayMetadata } from '@nestjs/common/utils/extend-metadata.util.js';
import { ApiBody, ApiOperation, ApiOperationOptions, ApiParam, ApiQuery } from '@nestjs/swagger';
import { DECORATORS } from '@nestjs/swagger/dist/constants.js';
import { Static, TSchema, Type, TypeGuard } from '@sinclair/typebox';
import { TypeCheck, TypeCompiler } from '@sinclair/typebox/compiler';
import { TypeCompiler } from '@sinclair/typebox/compiler';

import { TypeboxValidationException } from './exceptions.js';
import { TypeboxTransformInterceptor } from './interceptors.js';
import { coerceType, ucFirst } from './util.js';

type Obj<T = unknown> = Record<string, T>;
const isObj = (obj: unknown): obj is Obj => obj !== null && typeof obj === 'object';

export type MethodDecorator<T extends Function = any> = (
target: Object,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>
) => TypedPropertyDescriptor<T> | void;

export interface SchemaValidator<T extends TSchema = TSchema> {
schema: T;
name: string;
check: TypeCheck<T>['Check'];
validate(data: Obj | Obj[]): Static<T>;
}
export interface ValidatorConfigBase {
schema?: TSchema;
coerceTypes?: boolean;
stripUnknownProps?: boolean;
name?: string;
required?: boolean;
pipes?: (PipeTransform | NestType<PipeTransform>)[];
}
export interface ResponseValidatorConfig<T extends TSchema = TSchema> extends ValidatorConfigBase {
schema: T;
type?: 'response';
responseCode?: number;
required?: true;
pipes?: never;
}

export interface ParamValidatorConfig extends ValidatorConfigBase {
schema?: TSchema;
type: 'param';
name: string;
stripUnknownProps?: never;
}

export interface QueryValidatorConfig extends ValidatorConfigBase {
schema?: TSchema;
type: 'query';
name: string;
stripUnknownProps?: never;
}

export interface BodyValidatorConfig extends ValidatorConfigBase {
schema: TSchema;
type: 'body';
}

export type RequestValidatorConfig = ParamValidatorConfig | QueryValidatorConfig | BodyValidatorConfig;
export type SchemaValidatorConfig = RequestValidatorConfig | ResponseValidatorConfig;

export type ValidatorType = NonNullable<SchemaValidatorConfig['type']>;

export interface ValidatorConfig<
S extends TSchema,
ResponseConfig extends ResponseValidatorConfig<S>,
RequestConfigs extends RequestValidatorConfig[],
> {
response?: S | ResponseConfig;
request?: [...RequestConfigs];
}

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
: string | undefined
: RequestConfigs[K]['schema'] extends TSchema
? Static<RequestConfigs[K]['schema']>
: string;
};

import type {
MethodDecorator,
Obj,
RequestConfigsToTypes,
RequestValidatorConfig,
ResponseValidatorConfig,
SchemaValidator,
SchemaValidatorConfig,
ValidatorConfig,
} from './types.js';
import { capitalize, coerceType, isObj } from './util.js';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isSchemaValidator(type: any): type is SchemaValidator {
return type && typeof type === 'object' && typeof type.validate === 'function';
}
Expand All @@ -102,7 +37,7 @@ export function buildSchemaValidator(config: SchemaValidatorConfig): SchemaValid
throw new Error(`Validator of type "${type}" missing name.`);
}

if (!TypeGuard.TSchema(schema)) {
if (!TypeGuard.IsSchema(schema)) {
throw new Error(`Validator "${name}" expects a TypeBox schema.`);
}

Expand Down Expand Up @@ -176,20 +111,22 @@ export function Validate<
ResponseValidator extends ResponseValidatorConfig<T>,
RequestValidators extends RequestValidatorConfig[],
MethodDecoratorType extends (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...args: [...RequestConfigsToTypes<RequestValidators>, ...any[]]
) => Promise<Static<ResponseValidator['schema']>> | Static<ResponseValidator['schema']>,
>(validatorConfig: ValidatorConfig<T, ResponseValidator, RequestValidators>): MethodDecorator<MethodDecoratorType> {
return (target, key, descriptor) => {
let args = Reflect.getMetadata(ROUTE_ARGS_METADATA, target.constructor, key) ?? {};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
extendArrayMetadata(INTERCEPTORS_METADATA, [TypeboxTransformInterceptor], descriptor.value as any);

const { response: responseValidatorConfig, request: requestValidatorConfigs } = validatorConfig;

const methodName = ucFirst(String(key));
const methodName = capitalize(String(key));

if (responseValidatorConfig) {
const validatorConfig: ResponseValidatorConfig = TypeGuard.TSchema(responseValidatorConfig)
const validatorConfig: ResponseValidatorConfig = TypeGuard.IsSchema(responseValidatorConfig)
? { schema: responseValidatorConfig }
: responseValidatorConfig;

Expand All @@ -200,7 +137,10 @@ export function Validate<
name = `${methodName}Response`,
...config
} = validatorConfig;

const validator = buildSchemaValidator({ ...config, required, stripUnknownProps, name, type: 'response' });

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Reflect.defineMetadata(DECORATORS.API_RESPONSE, { [responseCode]: { type: validator } }, (target as any)[key]);
}

Expand All @@ -213,6 +153,8 @@ export function Validate<

args = assignMetadata(args, RouteParamtypes.BODY, index, undefined, ...pipes, validatorPipe);
Reflect.defineMetadata(ROUTE_ARGS_METADATA, args, target.constructor, key);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
ApiBody({ type: validator as any, required })(target, key, descriptor);

break;
Expand Down Expand Up @@ -270,6 +212,7 @@ export const HttpEndpoint = <
ResponseConfig extends Omit<ResponseValidatorConfig<S>, 'responseCode'>,
RequestConfigs extends RequestValidatorConfig[],
MethodDecoratorType extends (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...args: [...RequestConfigsToTypes<RequestConfigs>, ...any[]]
) => Promise<Static<ResponseConfig['schema']>> | Static<ResponseConfig['schema']>,
>(
Expand Down
80 changes: 80 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { PipeTransform, Type } from '@nestjs/common';
import type { TypeCheck } from '@sinclair/typebox/compiler';
import type { Static, TSchema } from '@sinclair/typebox/type';

export type AllKeys<T> = T extends unknown ? Exclude<keyof T, symbol> : never;

export type Obj<T = unknown> = Record<string, T>;

// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any
export type MethodDecorator<T extends Function = any> = (
// eslint-disable-next-line @typescript-eslint/ban-types
target: Object,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>
) => TypedPropertyDescriptor<T> | void;

export interface SchemaValidator<T extends TSchema = TSchema> {
schema: T;
name: string;
check: TypeCheck<T>['Check'];
validate(data: Obj | Obj[]): Static<T>;
}
export interface ValidatorConfigBase {
schema?: TSchema;
coerceTypes?: boolean;
stripUnknownProps?: boolean;
name?: string;
required?: boolean;
pipes?: (PipeTransform | Type<PipeTransform>)[];
}
export interface ResponseValidatorConfig<T extends TSchema = TSchema> extends ValidatorConfigBase {
schema: T;
type?: 'response';
responseCode?: number;
required?: true;
pipes?: never;
}

export interface ParamValidatorConfig extends ValidatorConfigBase {
schema?: TSchema;
type: 'param';
name: string;
stripUnknownProps?: never;
}

export interface QueryValidatorConfig extends ValidatorConfigBase {
schema?: TSchema;
type: 'query';
name: string;
stripUnknownProps?: never;
}

export interface BodyValidatorConfig extends ValidatorConfigBase {
schema: TSchema;
type: 'body';
}

export type RequestValidatorConfig = ParamValidatorConfig | QueryValidatorConfig | BodyValidatorConfig;
export type SchemaValidatorConfig = RequestValidatorConfig | ResponseValidatorConfig;

export type ValidatorType = NonNullable<SchemaValidatorConfig['type']>;

export interface ValidatorConfig<
S extends TSchema,
ResponseConfig extends ResponseValidatorConfig<S>,
RequestConfigs extends RequestValidatorConfig[],
> {
response?: S | ResponseConfig;
request?: [...RequestConfigs];
}

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
: string | undefined
: RequestConfigs[K]['schema'] extends TSchema
? Static<RequestConfigs[K]['schema']>
: string;
};
32 changes: 30 additions & 2 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { Static, TLiteral, TLiteralValue, TSchema, TUnion, Type } from '@sinclair/typebox/type';

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

export const coerceToNumber = (val: unknown, integer?: boolean): unknown => {
switch (typeof val) {
case 'number':
Expand Down Expand Up @@ -29,6 +33,30 @@ export const coerceType = (type: string, val: unknown): unknown => {
}
};

export const ucFirst = (str: string): string => {
return str[0].toUpperCase() + str.slice(1);
export const capitalize = <S extends string>(str: S): Capitalize<S> => {
return (str.charAt(0).toUpperCase() + str.slice(1)) as Capitalize<S>;
};

export const isObj = (obj: unknown): obj is Obj => obj !== null && typeof obj === 'object';

export const LiteralUnion = <V extends TLiteralValue[]>(values: readonly [...V]) => {
return Type.Union(values.map(value => Type.Literal(value))) as TUnion<{ [I in keyof V]: TLiteral<V[I]> }>;
};

export const DistOmit = <T extends TSchema, K extends AllKeys<Static<T>>[]>(schema: T, keys: readonly [...K]) => {
return Type.Extends(
schema,
Type.Unknown(),
Type.Omit(schema, Type.Union(keys.map(key => Type.Literal(key as TLiteralValue))) as TUnion<{ [I in keyof K]: TLiteral<K[I]> }>),
Type.Never()
);
};

export const DistPick = <T extends TSchema, K extends AllKeys<Static<T>>[]>(schema: T, keys: readonly [...K]) => {
return Type.Extends(
schema,
Type.Unknown(),
Type.Pick(schema, Type.Union(keys.map(key => Type.Literal(key as TLiteralValue))) as TUnion<{ [I in keyof K]: TLiteral<K[I]> }>),
Type.Never()
);
};
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"compilerOptions": {
"lib": ["es2022"],
"target": "es2022",
"module": "CommonJS",
"module": "NodeNext",
"moduleResolution": "nodenext",
"declaration": true,
"emitDecoratorMetadata": true,
Expand Down

0 comments on commit 47426f3

Please sign in to comment.