├── .vscode ├── extensions.json └── settings.json ├── .prettierrc ├── .editorconfig ├── .changeset ├── config.json └── README.md ├── src ├── index.ts ├── configure.ts ├── exceptions.ts ├── interceptors.ts ├── swagger-patch.ts ├── formats.ts ├── analyze-schema.ts ├── types.ts ├── util.ts └── decorators.ts ├── .gitignore ├── tsconfig.json ├── .eslintrc.js ├── LICENSE ├── .github └── workflows │ └── prepare-release.yml ├── package.json ├── test.ts ├── README.md └── CHANGELOG.md /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "editorconfig.editorconfig", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "es5", 8 | "arrowParens": "avoid" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll": "explicit" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | max_line_length = 160 12 | quote_type = single 13 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './analyze-schema.js'; 2 | export * from './configure.js'; 3 | export * from './decorators.js'; 4 | export * from './exceptions.js'; 5 | export * from './formats.js'; 6 | export * from './interceptors.js'; 7 | export * from './swagger-patch.js'; 8 | export * from './types.js'; 9 | export * from './util.js'; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | .env 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | coverage 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /src/configure.ts: -------------------------------------------------------------------------------- 1 | import { DefaultErrorFunction, SetErrorFunction } from '@sinclair/typebox/errors'; 2 | 3 | import { setFormats } from './formats.js'; 4 | import { patchNestJsSwagger } from './swagger-patch.js'; 5 | import { Configure } from './types.js'; 6 | 7 | export const configureNestJsTypebox = (options?: Configure) => { 8 | SetErrorFunction(params => params.schema.errorMessage ?? DefaultErrorFunction(params)); 9 | 10 | if (options?.patchSwagger) { 11 | patchNestJsSwagger(); 12 | } 13 | 14 | if (options?.setFormats) { 15 | setFormats(); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "es2022" 5 | ], 6 | "target": "es2022", 7 | "module": "NodeNext", 8 | "moduleResolution": "nodenext", 9 | "declaration": true, 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "outDir": "./dist", 15 | "incremental": true, 16 | "strict": true, 17 | "skipLibCheck": true, 18 | "noUncheckedIndexedAccess": true 19 | }, 20 | "include": [ 21 | "src/**/*.ts" 22 | ] 23 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | 'plugin:@typescript-eslint/recommended', 5 | 'plugin:prettier/recommended', 6 | 'plugin:require-extensions/recommended', 7 | 'plugin:import/recommended', 8 | ], 9 | plugins: ['simple-import-sort'], 10 | rules: { 11 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], 12 | 'simple-import-sort/imports': 'error', 13 | 'simple-import-sort/exports': 'error', 14 | 'import/no-unresolved': 'off', 15 | 'import/first': 'error', 16 | 'import/newline-after-import': 'error', 17 | }, 18 | ignorePatterns: ['dist/*'], 19 | }; 20 | -------------------------------------------------------------------------------- /src/exceptions.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, HttpStatus } from '@nestjs/common'; 2 | import { ValueError, ValueErrorIterator, ValueErrorType } from '@sinclair/typebox/errors'; 3 | 4 | import type { ValidatorType } from './types.js'; 5 | 6 | export class TypeboxValidationException extends BadRequestException { 7 | constructor(type: ValidatorType, errors: ValueErrorIterator) { 8 | const topLevelErrors: ValueError[] = []; 9 | const unionPaths: string[] = []; 10 | for (const error of errors) { 11 | // don't deeply traverse union errors to reduce error noise 12 | if (unionPaths.some(path => error.path.includes(path))) continue; 13 | if (error.type === ValueErrorType.Union) { 14 | unionPaths.push(error.path); 15 | } 16 | topLevelErrors.push(error); 17 | } 18 | 19 | super({ 20 | statusCode: HttpStatus.BAD_REQUEST, 21 | message: `Validation failed (${type})`, 22 | errors: topLevelErrors, 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/interceptors.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { DECORATORS } from '@nestjs/swagger/dist/constants.js'; 4 | import { Observable } from 'rxjs'; 5 | import { map } from 'rxjs/operators'; 6 | 7 | import { isSchemaValidator } from './decorators.js'; 8 | 9 | @Injectable() 10 | export class TypeboxTransformInterceptor implements NestInterceptor { 11 | constructor(private reflector: Reflector) {} 12 | 13 | intercept(context: ExecutionContext, next: CallHandler): Observable { 14 | return next.handle().pipe( 15 | map(data => { 16 | const responseMeta = this.reflector.get(DECORATORS.API_RESPONSE, context.getHandler()) ?? {}; 17 | const validator = (responseMeta['200'] || responseMeta['201'] || {})['type']; 18 | 19 | if (!isSchemaValidator(validator)) { 20 | return data; 21 | } 22 | 23 | return validator.validate(data); 24 | }) 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Andrew Smiley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/prepare-release.yml: -------------------------------------------------------------------------------- 1 | name: Prepare Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | concurrency: ${{ github.workflow }}-${{ github.ref }} 8 | 9 | jobs: 10 | release: 11 | name: Prepare Release 12 | runs-on: ubuntu-latest 13 | permissions: 14 | id-token: write 15 | contents: write 16 | pull-requests: write 17 | 18 | steps: 19 | - name: Checkout Repo 20 | uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Setup Node.js 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: 18.x 28 | cache: 'npm' 29 | 30 | - name: Install Dependencies 31 | run: npm i 32 | 33 | - name: Build 34 | run: npm run build 35 | 36 | - name: Create Release Pull Request or Publish 37 | uses: changesets/action@v1 38 | with: 39 | title: New Release 40 | publish: npx changeset publish 41 | env: 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | -------------------------------------------------------------------------------- /src/swagger-patch.ts: -------------------------------------------------------------------------------- 1 | import { Type as NestType } from '@nestjs/common'; 2 | import { SchemaObjectFactory } from '@nestjs/swagger/dist/services/schema-object-factory.js'; 3 | 4 | import { isSchemaValidator } from './decorators.js'; 5 | 6 | export function patchNestJsSwagger() { 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | if ((SchemaObjectFactory.prototype as any).__primatePatched) return; 9 | const defaultExplore = SchemaObjectFactory.prototype.exploreModelSchema; 10 | 11 | const extendedExplore: SchemaObjectFactory['exploreModelSchema'] = function exploreModelSchema( 12 | this: SchemaObjectFactory, 13 | type, 14 | schemas, 15 | schemaRefsStack 16 | ) { 17 | if (this['isLazyTypeFunc'](type)) { 18 | const factory = type as () => NestType; 19 | type = factory(); 20 | } 21 | 22 | if (!isSchemaValidator(type)) { 23 | return defaultExplore.apply(this, [type, schemas, schemaRefsStack]); 24 | } 25 | 26 | schemas[type.name] = type.schema; 27 | 28 | return type.name; 29 | }; 30 | 31 | SchemaObjectFactory.prototype.exploreModelSchema = extendedExplore; 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 33 | (SchemaObjectFactory.prototype as any).__primatePatched = true; 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-typebox", 3 | "version": "4.0.0", 4 | "description": "", 5 | "author": "Andrew Smiley ", 6 | "license": "MIT", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "files": [ 10 | "src", 11 | "dist" 12 | ], 13 | "scripts": { 14 | "start": "tsc --watch", 15 | "build": "tsc", 16 | "lint": "eslint \"src/**/*.ts\" --fix", 17 | "prepare": "husky install", 18 | "test": "npx tsx test.ts" 19 | }, 20 | "lint-staged": { 21 | "./{src,test}/**/*.ts": [ 22 | "eslint --fix" 23 | ] 24 | }, 25 | "peerDependencies": { 26 | "@nestjs/common": "^9.0.1 || ^10.0.3 || ^11.0.6", 27 | "@nestjs/core": "^9.0.1 || ^10.0.3 || ^11.0.6", 28 | "@nestjs/swagger": "^6.1.1 || ^7.0.11 || ^11.0.3", 29 | "@sinclair/typebox": "^0.34.0", 30 | "rxjs": "^7.5.6" 31 | }, 32 | "devDependencies": { 33 | "@changesets/cli": "^2.26.2", 34 | "@nestjs/common": "^11.0.6", 35 | "@nestjs/core": "^11.0.6", 36 | "@nestjs/swagger": "^11.0.3", 37 | "@sinclair/typebox": "^0.34.0", 38 | "@types/node": "^22.12.0", 39 | "@typescript-eslint/eslint-plugin": "^6.0.0", 40 | "@typescript-eslint/parser": "^6.0.0", 41 | "eslint": "^8.44.0", 42 | "eslint-config-prettier": "^9.1.0", 43 | "eslint-plugin-import": "^2.27.5", 44 | "eslint-plugin-prettier": "^5.0.0", 45 | "eslint-plugin-require-extensions": "^0.1.3", 46 | "eslint-plugin-simple-import-sort": "^10.0.0", 47 | "husky": "^8.0.3", 48 | "lint-staged": "^15.2.0", 49 | "prettier": "^3.0.0", 50 | "rxjs": "^7.8.1", 51 | "typescript": "^5.1.6" 52 | }, 53 | "publishConfig": { 54 | "access": "public" 55 | }, 56 | "keywords": [ 57 | "typebox", 58 | "nestjs", 59 | "json-schema", 60 | "nestjs-typebox", 61 | "class-validator" 62 | ], 63 | "repository": { 64 | "type": "git", 65 | "url": "https://github.com/jayalfredprufrock/nestjs-typebox" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/formats.ts: -------------------------------------------------------------------------------- 1 | import { FormatRegistry } from '@sinclair/typebox'; 2 | 3 | const emailRegex = /.+\@.+\..+/; 4 | export const emailFormat = (value: string) => emailRegex.test(value); 5 | 6 | const uuidRegex = /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i; 7 | export const uuidFormat = (value: string) => uuidRegex.test(value); 8 | 9 | const urlRegex = 10 | /^(?: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; 11 | export const urlFormat = (value: string) => urlRegex.test(value); 12 | 13 | const DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; 14 | const DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/; 15 | 16 | const isLeapYear = (year: number): boolean => { 17 | return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); 18 | }; 19 | 20 | export const dateFormat = (value: string): boolean => { 21 | const matches: string[] | null = DATE.exec(value); 22 | if (!matches) return false; 23 | const year: number = +matches[1]!; 24 | const month: number = +matches[2]!; 25 | const day: number = +matches[3]!; 26 | return month >= 1 && month <= 12 && day >= 1 && day <= (month === 2 && isLeapYear(year) ? 29 : DAYS[month]!); 27 | }; 28 | 29 | const timeRegex = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i; 30 | export const timeFormat = (value: string, strictTimeZone?: boolean): boolean => { 31 | const matches: string[] | null = timeRegex.exec(value); 32 | if (!matches) return false; 33 | const hr: number = +matches[1]!; 34 | const min: number = +matches[2]!; 35 | const sec: number = +matches[3]!; 36 | const tz: string | undefined = matches[4]; 37 | const tzSign: number = matches[5] === '-' ? -1 : 1; 38 | const tzH: number = +(matches[6] || 0); 39 | const tzM: number = +(matches[7] || 0); 40 | if (tzH > 23 || tzM > 59 || (strictTimeZone && !tz)) return false; 41 | if (hr <= 23 && min <= 59 && sec < 60) return true; 42 | const utcMin = min - tzM * tzSign; 43 | const utcHr = hr - tzH * tzSign - (utcMin < 0 ? 1 : 0); 44 | return (utcHr === 23 || utcHr === -1) && (utcMin === 59 || utcMin === -1) && sec < 61; 45 | }; 46 | 47 | const dateTimeSplitRegex = /t|\s/i; 48 | export const dateTimeFormat = (value: string, strictTimeZone?: boolean): boolean => { 49 | const dateTime: string[] = value.split(dateTimeSplitRegex); 50 | return dateTime.length === 2 && dateFormat(dateTime[0]!) && timeFormat(dateTime[1]!, strictTimeZone); 51 | }; 52 | 53 | export const setFormats = () => { 54 | FormatRegistry.Set('email', emailFormat); 55 | FormatRegistry.Set('uuid', uuidFormat); 56 | FormatRegistry.Set('url', urlFormat); 57 | FormatRegistry.Set('date', dateFormat); 58 | FormatRegistry.Set('time', timeFormat); 59 | FormatRegistry.Set('date-time', dateTimeFormat); 60 | }; 61 | -------------------------------------------------------------------------------- /test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { Static, TObject, TSchema, TUnion, Type, TypeGuard } from '@sinclair/typebox'; 3 | import { Value } from '@sinclair/typebox/value'; 4 | 5 | export type AllKeys = T extends any ? keyof T : never; 6 | export type Obj = Record; 7 | 8 | type Minus1 = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...0[]]; 9 | 10 | export type IsTuple = T extends [any, ...any] ? true : false; 11 | export type IsNonTupleObjArray = T extends object[] ? (IsTuple extends true ? false : true) : false; 12 | 13 | // P = current dot path 14 | // D = current depth 15 | export type DotPathJoin

= D extends string ? `${P}${'' extends D ? '' : '.'}${D}` : never; 16 | 17 | // T = object type 18 | // D = maximum depth to recurse 19 | export type DotPath = [D] extends [never] 20 | ? never 21 | : IsNonTupleObjArray extends true 22 | ? T extends object[] 23 | ? DotPathJoin<`${number}`, DotPath> 24 | : never 25 | : T extends object 26 | ? { 27 | [Key in keyof T]-?: Key extends string ? DotPathJoin> : ''; 28 | }[keyof T] 29 | : ''; 30 | 31 | export const stripExcessProps = (schema: T, obj: Static): Static => { 32 | return Value.Clean(schema, obj) as Static; 33 | }; 34 | 35 | export const schemaAtPath = (schema: T, path: (DotPath> & string) | string[]): TSchema => { 36 | const [segment, ...remainingPath] = typeof path === 'string' ? path.split('.') : path; 37 | 38 | let schemaAtSegment: TSchema | undefined; 39 | 40 | if (TypeGuard.IsObject(schema)) { 41 | schemaAtSegment = schema.properties[segment]; 42 | } else if (TypeGuard.IsUnion(schema)) { 43 | const unionSchema = schema.anyOf.flatMap(s => { 44 | try { 45 | return schemaAtPath(s, segment as any); 46 | } catch { 47 | return []; 48 | } 49 | }); 50 | 51 | if (unionSchema.length) { 52 | schemaAtSegment = unionSchema.length === 1 ? unionSchema[0] : Type.Union(unionSchema); 53 | } 54 | } 55 | 56 | if (!schemaAtSegment) { 57 | throw new Error('Invalid schema path.'); 58 | } 59 | 60 | return remainingPath.length ? schemaAtPath(schemaAtSegment, remainingPath) : schemaAtSegment; 61 | }; 62 | 63 | const Person = Type.Object({ 64 | name: Type.String(), 65 | age: Type.Number(), 66 | type: Type.Literal('Person'), 67 | address: Type.Object({ 68 | street: Type.String(), 69 | city: Type.String(), 70 | zip: Type.Number(), 71 | }), 72 | }); 73 | 74 | const Animal = Type.Object({ 75 | name: Type.String(), 76 | breed: Type.String(), 77 | type: Type.Literal('Animal'), 78 | }); 79 | 80 | const Thing = Type.Union([Person, Animal]); 81 | type Thing = Static; 82 | 83 | type Test = DotPath; 84 | 85 | console.log(schemaAtPath(Thing, 'address.zip')); 86 | 87 | console.log( 88 | stripExcessProps(Thing, { 89 | name: 'Andrew', 90 | type: 'Person', 91 | age: 36, 92 | gender: 'Male', 93 | address: { street: 'riverside ave', city: 'Jacksonville', zip: 32205, state: 'Florida' }, 94 | } as any) 95 | ); 96 | -------------------------------------------------------------------------------- /src/analyze-schema.ts: -------------------------------------------------------------------------------- 1 | import type { TArray, TIntersect, TObject, TRecord, TRef, TSchema, TTuple, TUnion } from '@sinclair/typebox'; 2 | import { Kind, TypeGuard } from '@sinclair/typebox'; 3 | 4 | function FromArray(schema: TArray, analysis: SchemaAnalysis): void { 5 | Visit(schema.items, analysis); 6 | } 7 | 8 | function FromIntersect(schema: TIntersect, analysis: SchemaAnalysis) { 9 | analysis.hasTransform = analysis.hasTransform || TypeGuard.IsTransform(schema.unevaluatedProperties); 10 | schema.allOf.forEach(schema => Visit(schema, analysis)); 11 | } 12 | 13 | function FromObject(schema: TObject, analysis: SchemaAnalysis) { 14 | Object.values(schema.properties).forEach(schema => Visit(schema, analysis)); 15 | if (TypeGuard.IsSchema(schema.additionalProperties)) { 16 | Visit(schema.additionalProperties, analysis); 17 | } 18 | } 19 | 20 | function FromRecord(schema: TRecord, analysis: SchemaAnalysis) { 21 | if (!analysis.hasTransform && TypeGuard.IsSchema(schema.additionalProperties)) { 22 | analysis.hasTransform = TypeGuard.IsTransform(schema.additionalProperties); 23 | } 24 | 25 | const pattern = Object.getOwnPropertyNames(schema.patternProperties)[0]; 26 | const property = schema.patternProperties[pattern ?? '']; 27 | 28 | if (TypeGuard.IsSchema(property)) { 29 | Visit(property, analysis); 30 | } 31 | } 32 | 33 | function FromRef(schema: TRef, analysis: SchemaAnalysis) { 34 | const target = analysis.references.get(schema.$ref); 35 | if (target) { 36 | Visit(target, analysis); 37 | } 38 | } 39 | 40 | function FromTuple(schema: TTuple, analysis: SchemaAnalysis) { 41 | if (schema.items) { 42 | schema.items.forEach(schema => Visit(schema, analysis)); 43 | } 44 | } 45 | 46 | function FromUnion(schema: TUnion, analysis: SchemaAnalysis) { 47 | schema.anyOf.forEach(schema => Visit(schema, analysis)); 48 | } 49 | 50 | function Visit(schema: TSchema, analysis: SchemaAnalysis): void { 51 | analysis.hasTransform = analysis.hasTransform || TypeGuard.IsTransform(schema); 52 | analysis.hasDefault = analysis.hasDefault || 'default' in schema; 53 | 54 | if (schema.$id) { 55 | if (analysis.references.has(schema.$id)) return; 56 | analysis.references.set(schema.$id, schema); 57 | } 58 | 59 | switch (schema[Kind]) { 60 | case 'Array': 61 | return FromArray(schema as TSchema as TArray, analysis); 62 | case 'Intersect': 63 | return FromIntersect(schema as TSchema as TIntersect, analysis); 64 | case 'Object': 65 | return FromObject(schema as TSchema as TObject, analysis); 66 | case 'Record': 67 | return FromRecord(schema as TSchema as TRecord, analysis); 68 | case 'Ref': 69 | return FromRef(schema as TSchema as TRef, analysis); 70 | case 'Tuple': 71 | return FromTuple(schema as TSchema as TTuple, analysis); 72 | case 'Union': 73 | return FromUnion(schema as TSchema as TUnion, analysis); 74 | } 75 | } 76 | 77 | export interface SchemaAnalysis { 78 | hasTransform: boolean; 79 | hasDefault: boolean; 80 | references: Map; 81 | } 82 | 83 | export const analyzeSchema = (schema: TSchema): SchemaAnalysis => { 84 | const analysis = { hasTransform: false, hasDefault: false, references: new Map() }; 85 | Visit(schema, analysis); 86 | return analysis; 87 | }; 88 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { PipeTransform, Type } from '@nestjs/common'; 2 | import { ApiOperationOptions } from '@nestjs/swagger'; 3 | import type { Static, StaticDecode, TComposite, TOmit, TPartial, TPick, TSchema } from '@sinclair/typebox'; 4 | import type { TypeCheck } from '@sinclair/typebox/compiler'; 5 | 6 | import { SchemaAnalysis } from './analyze-schema.js'; 7 | 8 | export type AllKeys = T extends unknown ? Exclude : never; 9 | 10 | export type Obj = Record; 11 | 12 | export interface Configure { 13 | patchSwagger?: boolean; 14 | setFormats?: boolean; 15 | } 16 | 17 | // eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any 18 | export type MethodDecorator = ( 19 | // eslint-disable-next-line @typescript-eslint/ban-types 20 | target: Object, 21 | propertyKey: string | symbol, 22 | descriptor: TypedPropertyDescriptor 23 | ) => TypedPropertyDescriptor | void; 24 | 25 | export interface HttpEndpointDecoratorConfig< 26 | S extends TSchema = TSchema, 27 | RequestConfigs extends RequestValidatorConfig[] = RequestValidatorConfig[], 28 | > extends Omit { 29 | method: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'; 30 | responseCode?: number; 31 | path?: string; 32 | validate?: ValidatorConfig; 33 | } 34 | 35 | export interface SchemaValidator { 36 | schema: T; 37 | name: string; 38 | analysis: SchemaAnalysis; 39 | check: TypeCheck['Check']; 40 | validate(data: Obj | Obj[]): unknown; 41 | } 42 | export interface ValidatorConfigBase { 43 | schema?: TSchema; 44 | coerceTypes?: boolean; 45 | stripUnknownProps?: boolean; 46 | name?: string; 47 | required?: boolean; 48 | pipes?: (PipeTransform | Type)[]; 49 | } 50 | export interface ResponseValidatorConfig extends ValidatorConfigBase { 51 | schema: T; 52 | type?: 'response'; 53 | responseCode?: number; 54 | description?: string; 55 | example?: Static; 56 | required?: true; 57 | pipes?: never; 58 | } 59 | 60 | export interface ParamValidatorConfig extends ValidatorConfigBase { 61 | schema?: TSchema; 62 | type: 'param'; 63 | name: string; 64 | stripUnknownProps?: never; 65 | } 66 | 67 | export interface QueryValidatorConfig extends ValidatorConfigBase { 68 | schema?: TSchema; 69 | type: 'query'; 70 | name: string; 71 | stripUnknownProps?: never; 72 | } 73 | 74 | export interface BodyValidatorConfig extends ValidatorConfigBase { 75 | schema: TSchema; 76 | type: 'body'; 77 | } 78 | 79 | export type RequestValidatorConfig = ParamValidatorConfig | QueryValidatorConfig | BodyValidatorConfig; 80 | export type SchemaValidatorConfig = RequestValidatorConfig | ResponseValidatorConfig; 81 | 82 | export type ValidatorType = NonNullable; 83 | 84 | export interface ValidatorConfig { 85 | response?: ResponseValidatorConfig | S; 86 | request?: [...RequestConfigs]; 87 | } 88 | 89 | export type RequestConfigsToTypes = { 90 | [K in keyof RequestConfigs]: RequestConfigs[K]['required'] extends false 91 | ? RequestConfigs[K]['schema'] extends TSchema 92 | ? StaticDecode | undefined 93 | : string | undefined 94 | : RequestConfigs[K]['schema'] extends TSchema 95 | ? StaticDecode 96 | : string; 97 | }; 98 | 99 | export type TPartialSome = TComposite<[TOmit, TPartial>]>; 100 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { SchemaOptions, Static, StringOptions, TLiteral, TObject, TPropertyKey, TSchema, TUnion, Type } from '@sinclair/typebox/type'; 2 | 3 | import { AllKeys, Obj, TPartialSome } from './types.js'; 4 | 5 | export const capitalize = (str: S): Capitalize => { 6 | return (str.charAt(0).toUpperCase() + str.slice(1)) as Capitalize; 7 | }; 8 | 9 | export const isObj = (obj: unknown): obj is Obj => obj !== null && typeof obj === 'object'; 10 | 11 | export type TUnionOfString = T extends [infer L extends string, ...infer R extends string[]] 12 | ? TUnionOfString]> 13 | : Acc; 14 | 15 | export const LiteralUnion = (values: [...T], options?: SchemaOptions): TUnion> => { 16 | return Type.Union( 17 | values.map(value => Type.Literal(value)), 18 | options 19 | ) as never; 20 | }; 21 | 22 | export const PartialSome = >[]>( 23 | schema: T, 24 | keys: readonly [...K], 25 | options?: SchemaOptions 26 | ): TPartialSome => { 27 | return Type.Composite([Type.Omit(schema, keys), Type.Partial(Type.Pick(schema, keys))], options); 28 | }; 29 | 30 | // NOTE: Latest version of typebox makes Omit/Pick distributive by default, but loses strongly typed keys 31 | export const DistOmit = >[]>(schema: T, keys: readonly [...K], options?: SchemaOptions) => { 32 | return Type.Omit(schema, keys, options); 33 | }; 34 | 35 | export const DistPick = >[]>(schema: T, keys: readonly [...K], options?: SchemaOptions) => { 36 | return Type.Pick(schema, keys, options); 37 | }; 38 | 39 | export const MaybeArray = (schema: T, options?: SchemaOptions) => Type.Union([schema, Type.Array(schema)], options); 40 | 41 | export const Nullable = (schema: T, options?: SchemaOptions) => 42 | Type.Optional(Type.Union([schema, Type.Null()], options)); 43 | 44 | /* 45 | Current issue with Type.KeyOf() + Generics 46 | export const SchemaOverride = (schema: S, overrides: T, options?: ObjectOptions) => { 47 | return Type.Composite([Type.Omit(schema, Type.KeyOf(overrides)), overrides], options); 48 | }; 49 | */ 50 | 51 | // TODO: figure out a way of building UnionPartialSome without having to explicitly overload 52 | // for every tuple length. 53 | 54 | export function UnionPartialSome>[]>( 55 | union: TUnion<[U1]>, 56 | keys: readonly [...K] 57 | ): TUnion<[TPartialSome]>; 58 | 59 | export function UnionPartialSome>[]>( 60 | union: TUnion<[U1, U2]>, 61 | keys: readonly [...K] 62 | ): TUnion<[TPartialSome, TPartialSome]>; 63 | 64 | export function UnionPartialSome>[]>( 65 | union: TUnion<[U1, U2, U3]>, 66 | keys: readonly [...K] 67 | ): TUnion<[TPartialSome, TPartialSome, TPartialSome]>; 68 | 69 | export function UnionPartialSome< 70 | U1 extends TObject, 71 | U2 extends TObject, 72 | U3 extends TObject, 73 | U4 extends TObject, 74 | K extends AllKeys>[], 75 | >( 76 | union: TUnion<[U1, U2, U3, U4]>, 77 | keys: readonly [...K] 78 | ): TUnion<[TPartialSome, TPartialSome, TPartialSome, TPartialSome]>; 79 | 80 | export function UnionPartialSome< 81 | U1 extends TObject, 82 | U2 extends TObject, 83 | U3 extends TObject, 84 | U4 extends TObject, 85 | K extends AllKeys>[], 86 | >( 87 | union: TUnion<[U1, U2, U3, U4]>, 88 | keys: readonly [...K] 89 | ): TUnion<[TPartialSome, TPartialSome, TPartialSome, TPartialSome]>; 90 | 91 | export function UnionPartialSome< 92 | U1 extends TObject, 93 | U2 extends TObject, 94 | U3 extends TObject, 95 | U4 extends TObject, 96 | U5 extends TObject, 97 | K extends AllKeys>[], 98 | >( 99 | union: TUnion<[U1, U2, U3, U4, U5]>, 100 | keys: readonly [...K] 101 | ): TUnion<[TPartialSome, TPartialSome, TPartialSome, TPartialSome, TPartialSome]>; 102 | 103 | export function UnionPartialSome< 104 | U1 extends TObject, 105 | U2 extends TObject, 106 | U3 extends TObject, 107 | U4 extends TObject, 108 | U5 extends TObject, 109 | U6 extends TObject, 110 | K extends AllKeys>[], 111 | >( 112 | union: TUnion<[U1, U2, U3, U4, U5, U6]>, 113 | keys: readonly [...K] 114 | ): TUnion<[TPartialSome, TPartialSome, TPartialSome, TPartialSome, TPartialSome, TPartialSome]>; 115 | 116 | export function UnionPartialSome< 117 | U1 extends TObject, 118 | U2 extends TObject, 119 | U3 extends TObject, 120 | U4 extends TObject, 121 | U5 extends TObject, 122 | U6 extends TObject, 123 | U7 extends TObject, 124 | K extends AllKeys>[], 125 | >( 126 | union: TUnion<[U1, U2, U3, U4, U5, U6, U7]>, 127 | keys: readonly [...K] 128 | ): TUnion< 129 | [ 130 | TPartialSome, 131 | TPartialSome, 132 | TPartialSome, 133 | TPartialSome, 134 | TPartialSome, 135 | TPartialSome, 136 | TPartialSome, 137 | ] 138 | >; 139 | 140 | export function UnionPartialSome< 141 | U1 extends TObject, 142 | U2 extends TObject, 143 | U3 extends TObject, 144 | U4 extends TObject, 145 | U5 extends TObject, 146 | U6 extends TObject, 147 | U7 extends TObject, 148 | U8 extends TObject, 149 | K extends AllKeys>[], 150 | >( 151 | union: TUnion<[U1, U2, U3, U4, U5, U6, U7, U8]>, 152 | keys: readonly [...K] 153 | ): TUnion< 154 | [ 155 | TPartialSome, 156 | TPartialSome, 157 | TPartialSome, 158 | TPartialSome, 159 | TPartialSome, 160 | TPartialSome, 161 | TPartialSome, 162 | TPartialSome, 163 | ] 164 | >; 165 | 166 | export function UnionPartialSome(union: TUnion, keys: readonly [...TPropertyKey[]]): TUnion { 167 | return Type.Union(union.anyOf.map(schema => PartialSome(schema, keys))); 168 | } 169 | 170 | export const IsoDate = (options?: StringOptions) => 171 | Type.Transform(Type.String({ format: 'date-time', ...options })) 172 | .Decode(value => new Date(value)) 173 | .Encode(value => value.toISOString()); 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nestjs-typebox 2 | 3 | This library provides helper utilities for writing and validating NestJS APIs using [TypeBox](https://github.com/sinclairzx81/typebox) as 4 | an alternative to class-validator/class-transformer. Can be configured to patch @nestjs/swagger allowing OpenAPI generation to continue working. 5 | Supports property defaults, basic type coercion, transforms, stripping unknown properties, and custom error messages. See typebox docs for more info. 6 | 7 | ## Installation 8 | 9 | ```sh 10 | npm i nestjs-typebox @sinclair/typebox 11 | ``` 12 | 13 | > **Important:** Note that `nestjs-typebox` is an alternative to the class-validator DTO approach detailed in the NestJS docs, and is 14 | > meant to fully replace it and all of the built-in validation/parsing pipes. Make sure you remove any global validation/parsing pipes 15 | > before installing this library and avoid using any local validation/parsing pipe decorators in combination with this library's decorators. 16 | 17 | ## Usage 18 | 19 | ### 1. Create TypeBox schema 20 | 21 | > The example below demonstrates a discriminated union type, 22 | > which cannot be achieved using class-based introspection approaches like that of class-validator. 23 | 24 | ```ts 25 | import { Type } from '@sinclair/typebox'; 26 | 27 | export const PetSchemaBase = Type.Object({ 28 | id: Type.Number(), 29 | name: Type.String({ 30 | description: "The pet's name", 31 | examples: ['Figaro'], 32 | }), 33 | microchip: Type.String(){ 34 | minLength: 10, 35 | description: 'Secret microchip number. Not sent to client', 36 | errorMessage: '"microchip" is required and must be at least 10 characters.' 37 | }, 38 | }); 39 | 40 | export const CatSchema = Type.Composite([ 41 | PetSchemaBase, 42 | Type.Object({ 43 | type: Type.Literal('cat'), 44 | breed: Type.Union([Type.Literal('shorthair'), Type.Literal('persian'), Type.Literal('siamese')]), 45 | }), 46 | ]); 47 | 48 | export const DogSchema = Type.Composite([ 49 | PetSchemaBase, 50 | Type.Object({ 51 | type: Type.Literal('dog'), 52 | breed: Type.Union([Type.Literal('shiba-inu'), Type.Literal('poodle'), Type.Literal('dachshund')]), 53 | }), 54 | ]); 55 | 56 | export const PetSchema = Type.Union([CatSchema, DogSchema]); 57 | export type Pet = Static; 58 | ``` 59 | 60 | ### 2. Decorate controller methods 61 | 62 | > The example below shows two different decorators and their usage, calling out default configuration. 63 | > Schemas have all been defined inline for brevity, but could just as easily be defined elsewhere 64 | > and reused. The primary benefit of using @HttpEndpoint over @Validator is the additional validation 65 | > enforcing path parameters to be properly defined as request "param" validators. Otherwise, it simply 66 | > passes through options specified in `validate` to the underlying @Validator decorator. 67 | 68 | ```ts 69 | import { Type } from '@sinclair/typebox'; 70 | import { Validate, HttpEndpoint } from 'nestjs-typebox'; 71 | 72 | @Controller('pets') 73 | export class PetController { 74 | constructor(private readonly petService: PetService) {} 75 | 76 | @Get() 77 | @Validate({ 78 | response: { schema: Type.Array(Type.Omit(PetSchema, ['microchip'])), stripUnknownProps: true }, 79 | }) 80 | async getPets() { 81 | return this.petService.getPets(); 82 | } 83 | 84 | @Get(':id') 85 | @Validate({ 86 | // stripUnknownProps is true by default for response validators 87 | // so this shorthand is equivalent 88 | response: Type.Omit(PetSchema, ['microchip']), 89 | request: [ 90 | // coerceTypes is true by default for "param" and "query" request validators 91 | { name: 'id', type: 'param', schema: Type.Number(), coerceTypes: true }, 92 | ], 93 | }) 94 | // no need to use @Param() decorator here since the @Validate() decorator will 95 | // automatically attach a pipe to populate and convert the paramater value 96 | async getPet(id: number) { 97 | return this.petService.getPet(id); 98 | } 99 | 100 | @Post() 101 | @Validate({ 102 | response: Type.Omit(PetSchema, ['microchip']), 103 | request: [ 104 | // if "name" not provided, method name will be used 105 | { type: 'body', schema: Type.Omit(PetSchema, 'id') }, 106 | ], 107 | }) 108 | async createPet(data: Omit) { 109 | return this.petService.createPet(data); 110 | } 111 | 112 | @HttpEndpoint({ 113 | method: 'PATCH', 114 | path: ':id', 115 | validate: { 116 | response: Type.Omit(PetSchema, ['microchip']), 117 | request: [ 118 | { name: 'id', type: 'param', schema: Type.Number() }, 119 | { type: 'body', schema: Type.Partial(Type.Omit(PetSchema, ['id'])) }, 120 | ], 121 | }, 122 | }) 123 | // the order of the controller method parameters must correspond to the order/types of 124 | // "request" validators, including "required" configuration. Additionally nestjs-typebox will 125 | // throw at bootup if parameters defined in the "request" validator config don't correspond 126 | // with the parameters defined in the "path" configuration 127 | async updatePet(id: number, data: Partial>) { 128 | return this.petService.updatePet(id, data); 129 | } 130 | 131 | @HttpEndpoint({ 132 | method: 'DELETE', 133 | path: ':id', 134 | validate: { 135 | response: Type.Omit(PetSchema, ['microchip']), 136 | request: [{ name: 'id', type: 'param', schema: Type.Number() }], 137 | }, 138 | }) 139 | async deletePet(id: number) { 140 | return this.petService.deletePet(id); 141 | } 142 | } 143 | ``` 144 | 145 | ### 3. Optionally configure 146 | 147 | Calling configure allows for the patching of the swagger plugin, custom 148 | string formats (email, url, date, time, date-time, uuid), and support for `errorMessage` overrides 149 | within schema options. 150 | 151 | ```ts 152 | // main.ts 153 | 154 | import { Reflector } from '@nestjs/core'; 155 | import { configureNestJsTypebox } from 'nestjs-typebox'; 156 | 157 | configureNestJsTypebox({ 158 | patchSwagger: true, 159 | setFormats: true, 160 | }); 161 | 162 | async function bootstrap() { 163 | const app = await NestFactory.create(AppModule); 164 | 165 | await app.listen(3000); 166 | console.log(`Application is running on: ${await app.getUrl()}`); 167 | } 168 | 169 | bootstrap(); 170 | ``` 171 | 172 | ### Credits 173 | 174 | Swagger patch derived from https://github.com/risenforces/nestjs-zod 175 | 176 | ### Todo 177 | 178 | - Validate observable support 179 | - utility to create typebox schemas with CRUD defaults (i.e. SchemaName['response'], SchemaName['update']) 180 | - include method name in decorator errors 181 | - support validating entire query object? (instead of individual values) 182 | - check controller metadata so resolved path can include params specified at the controller level 183 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # nestjs-typebox 2 | 3 | ## 4.0.0 4 | 5 | ### Major Changes 6 | 7 | - 579b899: Requires latest typebox version, 0.34.0. Decorators use TransformDecode for schema type inference. 8 | 9 | ### Patch Changes 10 | 11 | - 5369669: fix: apply response transformation before validation 12 | 13 | ## 4.0.0-next.1 14 | 15 | ### Patch Changes 16 | 17 | - 5369669: fix: apply response transformation before validation 18 | 19 | ## 4.0.0-next.0 20 | 21 | ### Major Changes 22 | 23 | - 579b899: Requires latest typebox version, 0.34.0. Decorators use TransformDecode for schema type inference. 24 | 25 | ## 3.1.1 26 | 27 | ### Patch Changes 28 | 29 | - 659837c: fix: stricter response validation typings 30 | 31 | ## 3.1.0 32 | 33 | ### Minor Changes 34 | 35 | - 0e46d5c: feat: support for request description and example 36 | 37 | ## 3.0.2 38 | 39 | ### Patch Changes 40 | 41 | - 23d5c89: fix: cooercion and defaults of param and query request types 42 | 43 | ## 3.0.1 44 | 45 | ### Patch Changes 46 | 47 | - 4f2fcc8: fix: detect schemas with defaults properly 48 | 49 | ## 3.0.0 50 | 51 | ### Major Changes 52 | 53 | - 2f56ec8: feat: upgraded to typebox 0.32.0 54 | 55 | ### Minor Changes 56 | 57 | - ffa676f: feat: upgrading typebox in preparation for v3 releast 58 | 59 | ### Patch Changes 60 | 61 | - 95c0123: chore: upgrade typebox to latest stable 62 | - 163be99: fix: export types 63 | - ff46411: feat: add some more typebox utilities 64 | - 87181c4: fix: revert prev commit for now 65 | - 09f6e3e: fix: explcitly type DistPick and DistOmit return types 66 | - 56c4916: fix: simplify DistOmit and DistPick typings for better inference 67 | - 03645a5: fix: handle empty responses 68 | - 7a0a776: Add UnionPartialSome typebox helper 69 | 70 | ## 3.0.0-next.9 71 | 72 | ### Minor Changes 73 | 74 | - ffa676f: feat: upgrading typebox in preparation for v3 releast 75 | 76 | ## 3.0.0-next.8 77 | 78 | ### Patch Changes 79 | 80 | - 03645a5: fix: handle empty responses 81 | 82 | ## 3.0.0-next.7 83 | 84 | ### Patch Changes 85 | 86 | - 87181c4: fix: revert prev commit for now 87 | 88 | ## 3.0.0-next.6 89 | 90 | ### Patch Changes 91 | 92 | - 09f6e3e: fix: explcitly type DistPick and DistOmit return types 93 | 94 | ## 3.0.0-next.5 95 | 96 | ### Patch Changes 97 | 98 | - 95c0123: chore: upgrade typebox to latest stable 99 | 100 | ## 3.0.0-next.4 101 | 102 | ### Patch Changes 103 | 104 | - 7a0a776: Add UnionPartialSome typebox helper 105 | 106 | ## 3.0.0-next.3 107 | 108 | ### Patch Changes 109 | 110 | - 163be99: fix: export types 111 | 112 | ## 3.0.0-next.2 113 | 114 | ### Patch Changes 115 | 116 | - ff46411: feat: add some more typebox utilities 117 | 118 | ## 3.0.0-next.1 119 | 120 | ### Patch Changes 121 | 122 | - 56c4916: fix: simplify DistOmit and DistPick typings for better inference 123 | 124 | ## 3.0.0-next.0 125 | 126 | ### Major Changes 127 | 128 | - feat: upgraded to typebox 0.32.0 129 | 130 | ## 2.6.1 131 | 132 | ### Patch Changes 133 | 134 | - fc130b1: fix: support for passing pipe classes without instantiation 135 | 136 | ## 2.6.0 137 | 138 | ### Minor Changes 139 | 140 | - 97e4c28: feat: transform pipe support for request validators 141 | 142 | ## 2.5.5 143 | 144 | ### Patch Changes 145 | 146 | - ba0c38b: update deps 147 | 148 | ## 2.5.4 149 | 150 | ### Patch Changes 151 | 152 | - 50afcd1: updated peer dep range to support nest 10 153 | 154 | ## 2.5.3 155 | 156 | ### Patch Changes 157 | 158 | - 74357c3: chore: updated readme with decorator usage 159 | 160 | ## 2.5.2 161 | 162 | ### Patch Changes 163 | 164 | - 5afcaba: remove useless generic 165 | 166 | ## 2.5.1 167 | 168 | ### Patch Changes 169 | 170 | - 6f85747: Make schema optional for param and query validators 171 | 172 | ## 2.5.0 173 | 174 | ### Minor Changes 175 | 176 | - 4ec4c58: Automatically apply interceptor. New HttpEndpoint decorator 177 | 178 | ## 2.4.1 179 | 180 | ### Patch Changes 181 | 182 | - 87f3ccc: provide a default name for responses and body 183 | 184 | ## 2.4.0 185 | 186 | ### Minor Changes 187 | 188 | - f922327: separate response and request validator configuration 189 | 190 | ## 2.3.0 191 | 192 | ### Minor Changes 193 | 194 | - c128d38: Upgrade typebox 195 | 196 | ## 2.2.0 197 | 198 | ### Minor Changes 199 | 200 | - 4d38b6e: feat: new ValidateResp decorator experiment 201 | 202 | ## 2.1.0 203 | 204 | ### Minor Changes 205 | 206 | - b9c33c2: vite lib mode and esm just arent there yet 207 | 208 | ## 2.0.4 209 | 210 | ### Patch Changes 211 | 212 | - 1af7324: Fix package exports again 213 | 214 | ## 2.0.3 215 | 216 | ### Patch Changes 217 | 218 | - 7e14c0e: include typings 219 | 220 | ## 2.0.2 221 | 222 | ### Patch Changes 223 | 224 | - 6bd610a: use cjs and mjs file extensions on build output 225 | 226 | ## 2.0.1 227 | 228 | ### Patch Changes 229 | 230 | - fe17f29: fix: use file extension when importing absolute nestjs files 231 | 232 | ## 2.0.0 233 | 234 | ### Major Changes 235 | 236 | - 646a659: Migrate to ESM. Major bump just to be safe 237 | 238 | ## 1.0.7 239 | 240 | ### Patch Changes 241 | 242 | - d6d8b0c: update deps 243 | 244 | ## 1.0.6 245 | 246 | ### Patch Changes 247 | 248 | - 41e2c08: fix: restore typebox dto typecheck in interceptor 249 | 250 | ## 1.0.5 251 | 252 | ### Patch Changes 253 | 254 | - 05b5975: force dto type as any when returning union 255 | 256 | ## 1.0.4 257 | 258 | ### Patch Changes 259 | 260 | - 82ab159: fix: merge union schemas so dto classes can construct them 261 | 262 | ## 1.0.3 263 | 264 | ### Patch Changes 265 | 266 | - 05eed8f: Loosen type restrictions on dtos 267 | - 2f5606f: update deps 268 | 269 | ## 1.0.2 270 | 271 | ### Patch Changes 272 | 273 | - 7f5e932: handle undefined parameter decorator keys 274 | 275 | ## 1.0.1 276 | 277 | ### Patch Changes 278 | 279 | - ce13144: Upgrade vite-dts lib to get proper types emitted 280 | 281 | ## 1.0.0 282 | 283 | ### Major Changes 284 | 285 | - ac3e770: Support for latest typebox and vitejs versions 286 | 287 | ## 0.4.1 288 | 289 | ### Patch Changes 290 | 291 | - cc7bc57: Ugprade typebox dep 292 | 293 | ## 0.4.0 294 | 295 | ### Minor Changes 296 | 297 | - c2e150f: feat: basic support for object unions 298 | 299 | ## 0.3.0 300 | 301 | ### Minor Changes 302 | 303 | - 5b86b6d: support for integer data type 304 | 305 | ## 0.2.2 306 | 307 | ### Patch Changes 308 | 309 | - 6941dcb: preserve this context when calling dto validate 310 | 311 | ## 0.2.1 312 | 313 | ### Patch Changes 314 | 315 | - f233aa4: fix: response transformer should leave non-arrays as is 316 | 317 | ## 0.2.0 318 | 319 | ### Minor Changes 320 | 321 | - 20dcd69: Remove concept of transform for now and allow beforeValidate to return new data 322 | 323 | ## 0.1.6 324 | 325 | ### Patch Changes 326 | 327 | - 820ac92: fix: swap esbuild for swc to make sure \_\_metadata is emitted 328 | - 6cd9762: Add usage instructions to readme 329 | 330 | ## 0.1.5 331 | 332 | ### Patch Changes 333 | 334 | - 083d296: use apply to call original swagger method to avoid clobbering this 335 | 336 | ## 0.1.4 337 | 338 | ### Patch Changes 339 | 340 | - 65de093: simplify swagger patch now that bug is fixed 341 | 342 | ## 0.1.3 343 | 344 | ### Patch Changes 345 | 346 | - bae856d: Copy zod patch approach to see if that fixes things 347 | 348 | ## 0.1.2 349 | 350 | ### Patch Changes 351 | 352 | - b9c40c4: Stop minifying dist files 353 | 354 | ## 0.1.1 355 | 356 | ### Patch Changes 357 | 358 | - 14f01df: fix publish 359 | 360 | ## 0.1.0 361 | 362 | ### Minor Changes 363 | 364 | - 3008e6f: First release 365 | -------------------------------------------------------------------------------- /src/decorators.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, assignMetadata, Delete, Get, HttpCode, Patch, PipeTransform, Post, Put } from '@nestjs/common'; 2 | import { INTERCEPTORS_METADATA, ROUTE_ARGS_METADATA } from '@nestjs/common/constants.js'; 3 | import { RouteParamtypes } from '@nestjs/common/enums/route-paramtypes.enum.js'; 4 | import { extendArrayMetadata } from '@nestjs/common/utils/extend-metadata.util.js'; 5 | import { ApiBody, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger'; 6 | import { DECORATORS } from '@nestjs/swagger/dist/constants.js'; 7 | import { StaticDecode, type TSchema, Type, TypeGuard } from '@sinclair/typebox'; 8 | import { TypeCompiler } from '@sinclair/typebox/compiler'; 9 | import { Clean, Convert, Default, TransformDecode, TransformEncode } from '@sinclair/typebox/value'; 10 | 11 | import { analyzeSchema } from './analyze-schema.js'; 12 | import { TypeboxValidationException } from './exceptions.js'; 13 | import { TypeboxTransformInterceptor } from './interceptors.js'; 14 | import type { 15 | HttpEndpointDecoratorConfig, 16 | MethodDecorator, 17 | RequestConfigsToTypes, 18 | RequestValidatorConfig, 19 | ResponseValidatorConfig, 20 | SchemaValidator, 21 | SchemaValidatorConfig, 22 | ValidatorConfig, 23 | } from './types.js'; 24 | import { capitalize } from './util.js'; 25 | 26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | export function isSchemaValidator(type: any): type is SchemaValidator { 28 | return type && typeof type === 'object' && typeof type.validate === 'function'; 29 | } 30 | 31 | export function buildSchemaValidator(config: SchemaValidatorConfig): SchemaValidator { 32 | const { type, schema, coerceTypes, stripUnknownProps, name, required } = config; 33 | 34 | if (!type) { 35 | throw new Error('Validator missing "type".'); 36 | } 37 | 38 | if (!name) { 39 | throw new Error(`Validator of type "${type}" missing name.`); 40 | } 41 | 42 | if (!TypeGuard.IsSchema(schema)) { 43 | throw new Error(`Validator "${name}" expects a TypeBox schema.`); 44 | } 45 | 46 | const analysis = analyzeSchema(schema); 47 | const references = [...analysis.references.values()]; 48 | 49 | const checker = TypeCompiler.Compile(schema, references); 50 | 51 | return { 52 | schema, 53 | name, 54 | check: checker.Check, 55 | analysis, 56 | validate(data: unknown) { 57 | if (analysis.hasDefault) { 58 | data = Default(schema, references, data); 59 | } 60 | 61 | if (data === undefined && !required) { 62 | return; 63 | } 64 | 65 | if (stripUnknownProps) { 66 | data = Clean(schema, references, data); 67 | } 68 | 69 | if (coerceTypes) { 70 | data = Convert(schema, references, data); 71 | } 72 | 73 | if (analysis.hasTransform && type === 'response') { 74 | data = TransformEncode(schema, references, data); 75 | } 76 | 77 | if (!checker.Check(data)) { 78 | throw new TypeboxValidationException(type, checker.Errors(data)); 79 | } 80 | 81 | if (analysis.hasTransform && type !== 'response') { 82 | data = TransformDecode(schema, references, data); 83 | } 84 | 85 | return data; 86 | }, 87 | }; 88 | } 89 | 90 | export function Validate< 91 | T extends TSchema, 92 | RequestValidators extends RequestValidatorConfig[], 93 | MethodDecoratorType extends ( 94 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 95 | ...args: [...RequestConfigsToTypes, ...any[]] 96 | ) => Promise> | StaticDecode, 97 | >(validatorConfig: ValidatorConfig): MethodDecorator { 98 | return (target, key, descriptor) => { 99 | let args = Reflect.getMetadata(ROUTE_ARGS_METADATA, target.constructor, key) ?? {}; 100 | 101 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 102 | extendArrayMetadata(INTERCEPTORS_METADATA, [TypeboxTransformInterceptor], descriptor.value as any); 103 | 104 | const { response: responseValidatorConfig, request: requestValidatorConfigs } = validatorConfig; 105 | 106 | const methodName = capitalize(String(key)); 107 | 108 | if (responseValidatorConfig) { 109 | const validatorConfig: ResponseValidatorConfig = TypeGuard.IsSchema(responseValidatorConfig) 110 | ? { schema: responseValidatorConfig } 111 | : responseValidatorConfig; 112 | 113 | const { 114 | responseCode = 200, 115 | description, 116 | example, 117 | required = true, 118 | stripUnknownProps = true, 119 | name = `${methodName}Response`, 120 | ...config 121 | } = validatorConfig; 122 | 123 | const validator = buildSchemaValidator({ ...config, required, stripUnknownProps, name, type: 'response' }); 124 | 125 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 126 | Reflect.defineMetadata( 127 | DECORATORS.API_RESPONSE, 128 | { [responseCode]: { type: validator, description, example } }, 129 | (target as any)[key] 130 | ); 131 | } 132 | 133 | requestValidatorConfigs?.forEach((validatorConfig, index) => { 134 | switch (validatorConfig.type) { 135 | case 'body': { 136 | const { required = true, name = `${methodName}Body`, pipes = [], ...config } = validatorConfig; 137 | const validator = buildSchemaValidator({ ...config, name, required } as SchemaValidatorConfig); 138 | const validatorPipe: PipeTransform = { transform: value => validator.validate(value) }; 139 | 140 | args = assignMetadata(args, RouteParamtypes.BODY, index, undefined, ...pipes, validatorPipe); 141 | Reflect.defineMetadata(ROUTE_ARGS_METADATA, args, target.constructor, key); 142 | 143 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 144 | ApiBody({ type: validator as any, required })(target, key, descriptor); 145 | 146 | break; 147 | } 148 | 149 | case 'param': { 150 | const { required = true, coerceTypes = true, schema = Type.String(), pipes = [], ...config } = validatorConfig; 151 | const validator = buildSchemaValidator({ ...config, coerceTypes, required, schema } as SchemaValidatorConfig); 152 | const validatorPipe: PipeTransform = { transform: value => validator.validate(value) }; 153 | 154 | args = assignMetadata(args, RouteParamtypes.PARAM, index, validatorConfig.name, ...pipes, validatorPipe); 155 | Reflect.defineMetadata(ROUTE_ARGS_METADATA, args, target.constructor, key); 156 | ApiParam({ name: validatorConfig.name, schema: validatorConfig.schema, required })(target, key, descriptor); 157 | 158 | break; 159 | } 160 | 161 | case 'query': { 162 | const { required = false, coerceTypes = true, schema = Type.String(), pipes = [], ...config } = validatorConfig; 163 | const validator = buildSchemaValidator({ ...config, coerceTypes, required, schema } as SchemaValidatorConfig); 164 | const validatorPipe: PipeTransform = { transform: value => validator.validate(value) }; 165 | 166 | args = assignMetadata(args, RouteParamtypes.QUERY, index, validatorConfig.name, ...pipes, validatorPipe); 167 | Reflect.defineMetadata(ROUTE_ARGS_METADATA, args, target.constructor, key); 168 | ApiQuery({ name: validatorConfig.name, schema: validatorConfig.schema, required })(target, key, descriptor); 169 | } 170 | } 171 | }); 172 | 173 | return descriptor; 174 | }; 175 | } 176 | 177 | const nestHttpDecoratorMap = { 178 | GET: Get, 179 | POST: Post, 180 | PATCH: Patch, 181 | DELETE: Delete, 182 | PUT: Put, 183 | }; 184 | 185 | export const HttpEndpoint = < 186 | S extends TSchema, 187 | RequestConfigs extends RequestValidatorConfig[], 188 | MethodDecoratorType extends ( 189 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 190 | ...args: [...RequestConfigsToTypes, ...any[]] 191 | ) => Promise> | StaticDecode, 192 | >( 193 | config: HttpEndpointDecoratorConfig 194 | ): MethodDecorator => { 195 | const { method, responseCode = 200, path, validate, ...apiOperationOptions } = config; 196 | 197 | const decorators: MethodDecorator[] = [nestHttpDecoratorMap[method](path), HttpCode(responseCode), ApiOperation(apiOperationOptions)]; 198 | 199 | if (path) { 200 | const pathParams = path 201 | .split('/') 202 | .filter(seg => seg.startsWith(':')) 203 | .map(seg => ({ name: seg.replace(/^:([^\?]+)\??$/, '$1'), required: !seg.endsWith('?') })); 204 | 205 | // TODO: handle optional path parameters 206 | 207 | for (const pathParam of pathParams) { 208 | const paramValidator = validate?.request?.find(v => v.name === pathParam.name); 209 | if (!paramValidator) { 210 | throw new Error(`Path param "${pathParam.name}" is missing a request validator.`); 211 | } 212 | if (paramValidator.required === false && pathParam.required === true) { 213 | throw new Error(`Optional path param "${pathParam.name}" is required in validator.`); 214 | } 215 | } 216 | 217 | const missingPathParam = validate?.request?.find(v => v.type === 'param' && !pathParams.some(p => p.name == v.name)); 218 | if (missingPathParam) { 219 | throw new Error(`Request validator references non-existent path parameter "${missingPathParam.name}".`); 220 | } 221 | } 222 | 223 | if (validate) { 224 | decorators.push(Validate(validate)); 225 | } 226 | 227 | return applyDecorators(...decorators); 228 | }; 229 | --------------------------------------------------------------------------------