├── .gitignore ├── README.md ├── dist ├── index.d.ts ├── index.js └── index.js.map ├── jest.config.js ├── nestjs-examples ├── dto-context-validation-pipe.ts └── dto-validation-pipe.ts ├── nodemon.json ├── package-lock.json ├── package.json ├── readme-types-example.ts ├── src ├── decorators.ts ├── dt-object.spec.ts ├── dt-object.ts ├── exceptions │ ├── parse-error.ts │ └── parse-issue.ts ├── fields │ ├── array-field.spec.ts │ ├── array-field.ts │ ├── base-field.ts │ ├── boolean-field.spec.ts │ ├── boolean-field.ts │ ├── combine-field.spec.ts │ ├── combine-field.ts │ ├── date-time-field.spec.ts │ ├── date-time-field.ts │ ├── number-field.spec.ts │ ├── number-field.ts │ ├── string-field.spec.ts │ └── string-field.ts ├── index.spec.ts ├── index.ts ├── recursive.ts ├── regex.ts ├── types.ts └── utils.ts ├── tsconfig.build.json ├── tsconfig.json └── vs-code-demo-1.gif /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | tsconfig.tsbuildinfo 3 | dist/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | DTO Classes is a TypeScript library for modelling data transfer objects in HTTP JSON APIs. 4 | 5 | It gives you the following, a bundle I've found missing for TypeScript/Node: 6 | - Class-based schemas that serialize *and* deserialize: 7 | - Parse/validate JSON to internal objects 8 | - Format internal objects to JSON 9 | - Static types by default without an additional `infer` call 10 | - Custom validation by adding methods to a schema class 11 | - Simple way to access additional context (eg the request object) when parsing 12 | - Async parsing & formatting to play nice with ORMs 13 | - An API broadly similar to OpenAPI and JSON Schema 14 | 15 | Example: 16 | 17 | ```typescript 18 | import { DTObject, ArrayField, BooleanField, StringField, DateTimeField } from "dto-classes"; 19 | 20 | class UserDto extends DTObject { 21 | name = StringField.bind() 22 | nickName = StringField.bind({ required: false }) 23 | birthday = DateTimeField.bind() 24 | active = BooleanField.bind({ default: true }) 25 | hobbies = ArrayField.bind({ items: StringField.bind() }) 26 | favoriteColor = StringField.bind({ allowNull: true }) 27 | } 28 | 29 | const userDto = await UserDto.parse({ 30 | name: "Michael Scott", 31 | birthday: '1962-08-16', 32 | hobbies: ["Comedy", "Paper"], 33 | favoriteColor: "Red" 34 | }); 35 | ``` 36 | 37 | VS Code: 38 | 39 | ![Alt Text](vs-code-demo-1.gif) 40 | 41 | # Table of Contents 42 | 43 | 44 | 45 | - [Introduction](#introduction) 46 | - [Table of Contents](#table-of-contents) 47 | - [Installation](#installation) 48 | - [From npm](#from-npm) 49 | - [Config](#config) 50 | - [Basic Usage](#basic-usage) 51 | - [Parsing & Validating Objects](#parsing--validating-objects) 52 | - [Formatting Objects](#formatting-objects) 53 | - [Fields](#fields) 54 | - [StringField: string](#stringfield-string) 55 | - [BooleanField: boolean](#booleanfield-boolean) 56 | - [NumberField: number](#numberfield-number) 57 | - [DateTimeField: Date](#datetimefield-date) 58 | - [ArrayField: Array](#arrayfield-arrayt) 59 | - [CombineField: oneOf, anyOf](#combinefield-oneof-anyof) 60 | - [Nested Objects: DTObject](#nested-objects-dtobject) 61 | - [Error Handling](#error-handling) 62 | - [Custom Parsing/Validation](#custom-parsingvalidation) 63 | - [Custom Formatting](#custom-formatting) 64 | - [Recursive Objects](#recursive-objects) 65 | - [Standalone Fields](#standalone-fields) 66 | - [NestJS](#nestjs) 67 | - [Simple Validation Pipe](#simple-validation-pipe) 68 | - [Validation Pipe with Request Context](#validation-pipe-with-request-context) 69 | 70 | 71 | 72 | # Installation 73 | 74 | TypeScript 4.5+ is required. 75 | 76 | ## From npm 77 | ```npm install dto-classes``` 78 | 79 | ## Config 80 | You'll get more accurate type hints with `strict` set to `true` in your `tsconfig.json`: 81 | ```jsonc 82 | { 83 | "compilerOptions": { 84 | // ... 85 | "strict": true 86 | // ... 87 | } 88 | ``` 89 | 90 | If that's not practical, you'll still get useful type hints by setting `strictNullChecks` to `true`: 91 | ```jsonc 92 | { 93 | "compilerOptions": { 94 | // ... 95 | "strictNullChecks": true 96 | // ... 97 | } 98 | ``` 99 | 100 | # Basic Usage 101 | 102 | The library handles both _parsing_, the process of transforming inputs to the most relevant types, and _validating_, the process of ensuring values meet the correct criteria. 103 | 104 | This aligns with the [robustness principle](https://en.wikipedia.org/wiki/Robustness_principle). When consuming an input for an age field, most applications will want the string `"25"` auto-converted to the number `25`. However, you can override this default behavior with your own custom `NumberField`. 105 | 106 | ## Parsing & Validating Objects 107 | 108 | Let's start by defining some schema classes. Extend the `DTObject` class and define its fields: 109 | 110 | ```typescript 111 | import { DTObject, StringField, DateTimeField } from 'dto-classes'; 112 | 113 | class DirectorDto extends DTObject { 114 | name = StringField.bind() 115 | } 116 | 117 | class MovieDto extends DTObject { 118 | title = StringField.bind() 119 | releaseDate = DateTimeField.bind() 120 | director = DirectorDto.bind(), 121 | genre = StringField.bind({required: false}) 122 | } 123 | ``` 124 | 125 | There's some incoming data: 126 | ```typescript 127 | const data = { 128 | "title": "The Departed", 129 | "releaseDate": '2006-10-06', 130 | "director": {"name": "Martin Scorsese"} 131 | } 132 | ``` 133 | 134 | We can coerce and validate the data by calling the static method `parse(data)` which will return a newly created DTO instance: 135 | 136 | ```typescript 137 | const movieDto = await MovieDto.parse(data); 138 | ``` 139 | 140 | If it succeeds, it will return a strongly typed instance of the class. If it fails, it will raise a validation error: 141 | 142 | ```typescript 143 | import { ParseError } from "dto-classes"; 144 | 145 | try { 146 | const movieDto = await MovieDto.parse(data); 147 | } catch (error) { 148 | if (error instanceof ParseError) { 149 | // 150 | } 151 | } 152 | ``` 153 | 154 | For incoming `PATCH` requests, the convention is to make all fields optional, even if they'd otherwise be required. 155 | 156 | You can pass `partial: true` for validation to succeed in these scenarios: 157 | 158 | ```typescript 159 | const data = { 160 | "releaseDate": '2006-10-06' 161 | } 162 | 163 | const movieDto = await MovieDto.parse(data, {partial: true}); 164 | ``` 165 | 166 | ## Formatting Objects 167 | 168 | You can also format internal data types to JSON data that can be returned in an HTTP response. 169 | 170 | A common example is model instances originating from an ORM: 171 | 172 | ```typescript 173 | const movie = await repo.fetchMovie(45).join('director') 174 | const jsonData = await MovieDto.format(movie); 175 | return HttpResponse(jsonData); 176 | ``` 177 | 178 | Special types, like JavaScript's Date object, will be converted to JSON compatible output: 179 | ```json 180 | { 181 | "title": "The Departed", 182 | "releaseDate": "2006-10-06", 183 | "director": {"name": "Martin Scorsese"} 184 | } 185 | ``` 186 | 187 | Any internal properties not specified will be ommitted from the formatted output. 188 | 189 | # Fields 190 | 191 | Fields handle converting between primitive values and internal datatypes. They also deal with validating input values. They are attached to a `DTObject` using the `bind(options)` static method. 192 | 193 | All field types accept some core options: 194 | 195 | ```typescript 196 | interface BaseFieldOptions { 197 | required?: boolean; 198 | allowNull?: boolean; 199 | readOnly?: boolean; 200 | writeOnly?: boolean; 201 | default?: any; 202 | partial?: boolean; 203 | formatSource?: string; 204 | ignoreInput?: boolean; 205 | context?: {[key: string]: any}; 206 | } 207 | ``` 208 | 209 | | Option | Description | Default | 210 | | ----------- | ---------------------------------- | ------- | 211 | | required | Whether an input value is required | true | 212 | | allowNull | Whether null input values are allowed | false | 213 | | readOnly | If true, any input value is ignored during parsing, but is included in the output format | false | 214 | | writeOnly | If true, the field's value is excluded from the formatted output, but is included in parsing | false | 215 | | default | The default value to use during parsing if none is present in the input | n/a | 216 | | formatSource | The name of the attribute that will be used to populate the field, if different from the formatted field name name | n/a | 217 | | ignoreInput | Whether to always return the provided `default` when parsing and ignore the user-provider input. | false | 218 | | context | A container for additional data that'd be useful during parsing or formatting. A common scenario is to pass in an HTTP request object. | n/a | 219 | 220 | ## StringField: `string` 221 | 222 | - Parses input to _strings_. Coerces numbers, other types invalid. 223 | - Formats all value types to _strings_. 224 | 225 | ```typescript 226 | interface StringOptions extends BaseFieldOptions { 227 | allowBlank?: boolean; 228 | trimWhitespace?: boolean; 229 | maxLength?: number; 230 | minLength?: number; 231 | pattern?: RegExp, 232 | format?: 'email' | 'url' 233 | } 234 | ``` 235 | | Option | Description | Default | 236 | | ----------- | ---------------------------------- | ------- | 237 | | allowBlank | If set to true then the empty string should be considered a valid value. If set to false then the empty string is considered invalid and will raise a validation error. | false | 238 | | trimWhitespace | If set to true then leading and trailing whitespace is trimmed. | true | 239 | | maxLength | Validates that the input contains no more than this number of characters. | n/a | 240 | | minLength | Validates that the input contains no fewer than this number of characters. | n/a | 241 | | pattern | A `Regex` that the input must match or a ParseError will be thrown. | n/a | 242 | | format | A predefined format that the input must conform to or a ParseError will be thrown. Supported values: `email`, `url`. | n/a | 243 | 244 | ## BooleanField: `boolean` 245 | 246 | - Parses input to _booleans_. Coerces certain bool-y strings. Other types invalid. 247 | - Formats values to _booleans_. 248 | 249 | Truthy inputs: 250 | ```typescript 251 | ['t', 'T', 'y', 'Y', 'yes', 'Yes', 'YES', 'true', 'True', 'TRUE', 'on', 'On', 'ON', '1', 1, true] 252 | ``` 253 | 254 | Falsey inputs: 255 | ```typescript 256 | ['f', 'F', 'n', 'N', 'no', 'No', 'NO', 'false', 'False', 'FALSE', 'off', 'Off', 'OFF', '0', 0, 0.0, false] 257 | ``` 258 | 259 | ## NumberField: `number` 260 | 261 | - Parses input to _numbers_. Coerces numeric strings. Other types invalid. 262 | - Formats values to _numbers_. 263 | 264 | ```typescript 265 | interface NumberOptions extends BaseFieldOptions { 266 | maxValue?: number; 267 | minValue?: number; 268 | } 269 | ``` 270 | 271 | | Option | Description | Default | 272 | | ----------- | ---------------------------------- | ------- | 273 | | maxValue | Validate that the number provided is no greater than this value. | n/a | 274 | | minValue | Validate that the number provided is no less than this value. | n/a | 275 | 276 | 277 | 278 | ## DateTimeField: `Date` 279 | 280 | - Parses input to _`Date` instances_. Coercing date-ish strings using `Date.parse()`. 281 | - Formats values to _strings_ with `Date.toISOString()`. 282 | 283 | ```typescript 284 | interface DateTimeFieldOptions extends BaseFieldOptions { 285 | maxDate?: Date; 286 | minDate?: Date; 287 | } 288 | ``` 289 | 290 | | Option | Description | Default | 291 | | ----------- | ---------------------------------------------------------------- | --- | 292 | | maxDate | Validate that the date provided is no later than this date. | n/a | 293 | | minDate | Validate that the date provided is no earlier than this date. | n/a | 294 | 295 | 296 | ## ArrayField: `Array` 297 | 298 | - Parses and formats a list of fields or nested objects. 299 | 300 | ```typescript 301 | interface ArrayOptions extends BaseFieldOptions { 302 | items: BaseField | DTObject; 303 | maxLength?: number; 304 | minLength?: number; 305 | } 306 | ``` 307 | 308 | | Option | Description | Default | 309 | | ----------- | ---------------------------------------------------------------- | --- | 310 | | items | A bound field or object | n/a | 311 | | maxLength | Validates that the array contains no fewer than this number of elements. | n/a | 312 | | minLength | Validates that the list contains no more than this number of elements. | n/a | 313 | 314 | 315 | Examples: 316 | 317 | ```typescript 318 | class ActionDto extends DTObject { 319 | name = String.bind() 320 | timestamp = DateTimeField.bind() 321 | } 322 | 323 | class UserDto extends DTObject { 324 | actions = ArrayField.bind({ items: ActionDto.bind() }) 325 | emailAddresses = ArrayField.bind({ items: StringField.bind() }) 326 | } 327 | ``` 328 | 329 | ## CombineField: `oneOf`, `anyOf` 330 | 331 | Fields or objects can be composed together, using JSON Schema's `oneOf` or `anyOf` to parse & validate with `OR` or `XOR` logic. That is, the input must match *at least one* (`anyOf`) or *exactly* one (`oneOf`) of the specified sub-fields. 332 | 333 | ```typescript 334 | interface CombineField extends BaseFieldOptions { 335 | anyOf?: Array; 336 | oneOf?: Array; 337 | } 338 | ``` 339 | 340 | | Option | Description | Default | 341 | | ----------- | ---------------------------------------------------------------- | --- | 342 | | anyOf | The supplied data must be valid against any (one or more) of the given subschemas. | n/a | 343 | | oneOf | The supplied data must be valid against exactly one of the given subschemas. | n/a | 344 | 345 | **Example: `oneOf`** 346 | 347 | ```typescript 348 | import { CombineField } from "dto-classes"; 349 | 350 | // friendSchema must match a person or dog object, but not both 351 | 352 | class Person extends DTObject { 353 | name = StringField.bind() 354 | hasTwoLegs = BooleanField.bind() 355 | } 356 | 357 | class Dog extends DTObject { 358 | name = StringField.bind() 359 | hasFourLegs = BooleanField.bind() 360 | } 361 | 362 | const friendSchema = new CombineField({ 363 | oneOf: [ 364 | Person.bind(), 365 | Dog.bind() 366 | ] 367 | }); 368 | ``` 369 | 370 | **Example: `anyOf`** 371 | 372 | ```typescript 373 | // Quantity must be between 1-5 or 50-100 374 | 375 | class InventoryItem extends DTObject { 376 | quantity = CombineField.bind({ 377 | anyOf: [ 378 | NumberField.bind({ minValue: 1, maxValue: 5 }), 379 | NumberField.bind({ minValue: 50, maxValue: 100 }) 380 | ] 381 | }) 382 | } 383 | ``` 384 | 385 | ## Nested Objects: `DTObject` 386 | 387 | `DTObject` classes can be nested under parent `DTObject` classes and configured with the same core `BaseFieldOptions`: 388 | 389 | ```typescript 390 | import { DTObject, StringField, DateTimeField } from 'dto-classes'; 391 | 392 | 393 | class Plot extends DTObject { 394 | content: StringField.bind() 395 | } 396 | 397 | class MovieDto extends DTObject { 398 | title = StringField.bind() 399 | plot = Plot.bind({required: false, allowNull: true}) 400 | } 401 | ``` 402 | 403 | # Error Handling 404 | 405 | If parsing fails for any reason -- the input data could not be parsed or a validation constraint failed -- a `ParseError` is thrown. 406 | 407 | The error can be inspected: 408 | 409 | ```typescript 410 | class ParseError extends Error { 411 | issues: ValidationIssue[]; 412 | } 413 | 414 | interface ValidationIssue { 415 | path: string[]; // path to the field that raised the error 416 | message: string; // English description of the problem 417 | } 418 | ``` 419 | 420 | Example: 421 | ```typescript 422 | import { ParseError } from "dto-classes"; 423 | 424 | 425 | class DirectorDto extends DTObject { 426 | name = StringField.bind() 427 | } 428 | 429 | class MovieDto extends DTObject { 430 | title = StringField.bind() 431 | director = DirectorDto.bind(), 432 | } 433 | 434 | try { 435 | const movieDto = await MovieDto.parse({ 436 | title: 'Clifford', 437 | director: {} 438 | }); 439 | } catch (error) { 440 | if (error instanceof ParseError) { 441 | console.log(error.issues); 442 | /* [ 443 | { 444 | "path": ["director", "name"], 445 | "message": "This field is required" 446 | } 447 | ] */ 448 | } 449 | } 450 | 451 | ``` 452 | 453 | # Custom Parsing/Validation 454 | 455 | For custom validation and rules that must examine the whole object, methods can be added to the `DTObject` class. 456 | 457 | To run the logic after coercion, use the `@AfterParse` decorator. 458 | 459 | ```typescript 460 | import { AfterParse, BeforeParse, ParseError } from "dto-classes"; 461 | 462 | class MovieDto extends DTObject { 463 | title = StringField.bind() 464 | director = DirectorDto.bind() 465 | 466 | @AfterParse() 467 | rejectBadTitles() { 468 | if (this.title == 'Yet Another Superhero Movie') { 469 | throw new ParseError('No thanks'); 470 | } 471 | } 472 | } 473 | ``` 474 | 475 | The method can modify the object as well: 476 | 477 | ```typescript 478 | import { AfterParse, BeforeParse, ParseError } from "dto-classes"; 479 | 480 | class MovieDto extends DTObject { 481 | title = StringField.bind() 482 | director = DirectorDto.bind() 483 | 484 | @AfterParse() 485 | makeTitleExciting() { 486 | this.title = this.title + '!!'; 487 | } 488 | } 489 | ``` 490 | 491 | # Custom Formatting 492 | 493 | Override the static `format` method to apply custom formatting. 494 | 495 | ```typescript 496 | import { AfterParse, BeforeParse, ParseError } from "dto-classes"; 497 | 498 | class MovieDto extends DTObject { 499 | title = StringField.bind() 500 | director = DirectorDto.bind() 501 | 502 | static format(value: any) { 503 | const formatted = super.format(value); 504 | formatted['genre'] = formatted['director']['name'].includes("Mike Judge") ? 'comedy' : 'drama'; 505 | return formatted; 506 | } 507 | } 508 | ``` 509 | 510 | A nicer way to expose computed values is to use the `@Format` decorator. The single argument (e.g. "obj") will always be 511 | the full object initially passed to the static `format()` method: 512 | 513 | ```typescript 514 | class Person extends DTObject { 515 | firstName = StringField.bind() 516 | lastName = StringField.bind() 517 | 518 | @Format() 519 | fullName(obj: any) { 520 | return `${obj.firstName} ${obj.lastName}`; 521 | } 522 | } 523 | 524 | const formattedData = await Person.format({ 525 | firstName: 'George', 526 | lastName: 'Washington', 527 | }); 528 | 529 | expect(formattedData).toEqual({ 530 | firstName: 'George', 531 | lastName: 'Washington', 532 | fullName: 'George Washington' 533 | }); 534 | ``` 535 | 536 | You can customize the formatted field name by passing `{fieldName: string}` to the decorator: 537 | 538 | ```typescript 539 | class Person extends DTObject { 540 | firstName = StringField.bind() 541 | lastName = StringField.bind() 542 | 543 | @Format({fieldName: 'fullName'}) 544 | makeFullName(obj: any) { 545 | return `${obj.firstName} ${obj.lastName}`; 546 | } 547 | } 548 | 549 | /* 550 | { 551 | firstName: 'George', 552 | lastName: 'Washington', 553 | fullName: 'George Washington' 554 | } 555 | */ 556 | ``` 557 | 558 | # Recursive Objects 559 | 560 | To prevent recursion errors (eg "Maximum call stack size exceeded"), wrap nested self-refrencing objects in a `Recursive` call: 561 | 562 | ```typescript 563 | import { ArrayField, Rescursive } from "dto-classes"; 564 | 565 | class MovieDto extends DTObject { 566 | title = StringField.bind() 567 | director = DirectorDto.bind() 568 | sequels: ArrayField({items: Recursive(MovieDto)}) 569 | } 570 | ``` 571 | 572 | # Standalone Fields 573 | 574 | It's possible to use fields outside of `DTObject` schemas: 575 | 576 | ```typescript 577 | import { DateTimeField } from "dto-classes"; 578 | 579 | const pastOrPresentday = DateTimeField.parse('2022-12-25', {maxDate: new Date()}); 580 | ``` 581 | 582 | You can also create re-usable field schemas by calling the instance method `parseValue()`: 583 | 584 | ```typescript 585 | const pastOrPresentSchema = new DateTimeField({maxDate: new Date()}); 586 | 587 | pastOrPresentSchema.parseValue('2021-04-16'); 588 | pastOrPresentSchema.parseValue('2015-10-23'); 589 | ``` 590 | 591 | # NestJS 592 | 593 | `DTObject` classes can integrate easily with NestJS global pipes. 594 | 595 | Two ready-to-use examples are included. 596 | 597 | ## Simple Validation Pipe 598 | 599 | Copy the pipe in [nestjs-examples/dto-validation-pipe.ts](nestjs-examples/dto-validation-pipe.ts) to your project. 600 | 601 | ```typescript 602 | async function bootstrap() { 603 | const app = await NestFactory.create(AppModule); 604 | app.useGlobalPipes(new DTOValidationPipe()); 605 | await app.listen(3000); 606 | } 607 | bootstrap(); 608 | ``` 609 | 610 | ## Validation Pipe with Request Context 611 | 612 | To implement more complex validation it's often useful to be able to access the current HTTP request object. For example, knowing the current user could affect whether validation succeeds. Or maybe you want to implement a hidden field that always returns the current user. 613 | 614 | Copy the pipe in [nestjs-examples/dto-context-validation-pipe.ts](nestjs-examples/dto-context-validation-pipe.ts) to your project. 615 | 616 | Each request will construct its own pipe with the current request object so the pipe must be configured as a provider: 617 | 618 | ```typescript 619 | @Module({ 620 | imports: [ 621 | UsersModule 622 | ], 623 | controllers: [], 624 | providers: [ 625 | { 626 | provide: APP_PIPE, 627 | useClass: DTOContextValidationPipe, 628 | } 629 | ], 630 | }) 631 | export class AppModule { } 632 | ``` 633 | 634 | You can now easily set up a hidden input field that always returns the authenticated user of the request: 635 | 636 | ```typescript 637 | import { BaseField, BaseFieldOptions, ParseReturnType } from 'dto-classes'; 638 | import { User } from 'path-to-entities/user.entity'; 639 | 640 | 641 | export class CurrentUserField extends BaseField { 642 | _options: T; 643 | 644 | constructor(options?: T) { 645 | super(options); 646 | this._options.ignoreInput = true; 647 | } 648 | 649 | public async parseValue(value: any): ParseReturnType { 650 | return this.getDefaultParseValue(); 651 | } 652 | 653 | async getDefaultParseValue(): Promise { 654 | const user = await this._context.request.fetchUser(); 655 | return user; 656 | } 657 | } 658 | 659 | ``` 660 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | export { Format, BeforeParse, AfterParse } from "./decorators"; 2 | export { DTObject } from "./dt-object"; 3 | export { BaseField, BaseFieldOptions } from "./fields/base-field"; 4 | export { ArrayField, ArrayOptions } from "./fields/array-field"; 5 | export { BooleanField } from "./fields/boolean-field"; 6 | export { DateTimeField, DateTimeFieldOptions } from "./fields/date-time-field"; 7 | export { StringField, StringOptions } from "./fields/string-field"; 8 | export { NumberField, NumberOptions } from "./fields/number-field"; 9 | export { Recursive } from "./recursive"; 10 | export { ParseError as ParseError } from "./exceptions/parse-error"; 11 | export { ParseIssue as ValidationIssue } from "./exceptions/parse-issue"; 12 | export { ParseArrayReturnType, ParseReturnType } from "./types"; 13 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.ValidationIssue = exports.ParseError = exports.Recursive = exports.NumberField = exports.StringField = exports.DateTimeField = exports.BooleanField = exports.ArrayField = exports.BaseField = exports.DTObject = exports.AfterParse = exports.BeforeParse = exports.Format = void 0; 4 | var decorators_1 = require("./decorators"); 5 | Object.defineProperty(exports, "Format", { enumerable: true, get: function () { return decorators_1.Format; } }); 6 | Object.defineProperty(exports, "BeforeParse", { enumerable: true, get: function () { return decorators_1.BeforeParse; } }); 7 | Object.defineProperty(exports, "AfterParse", { enumerable: true, get: function () { return decorators_1.AfterParse; } }); 8 | var dt_object_1 = require("./dt-object"); 9 | Object.defineProperty(exports, "DTObject", { enumerable: true, get: function () { return dt_object_1.DTObject; } }); 10 | var base_field_1 = require("./fields/base-field"); 11 | Object.defineProperty(exports, "BaseField", { enumerable: true, get: function () { return base_field_1.BaseField; } }); 12 | var array_field_1 = require("./fields/array-field"); 13 | Object.defineProperty(exports, "ArrayField", { enumerable: true, get: function () { return array_field_1.ArrayField; } }); 14 | var boolean_field_1 = require("./fields/boolean-field"); 15 | Object.defineProperty(exports, "BooleanField", { enumerable: true, get: function () { return boolean_field_1.BooleanField; } }); 16 | var date_time_field_1 = require("./fields/date-time-field"); 17 | Object.defineProperty(exports, "DateTimeField", { enumerable: true, get: function () { return date_time_field_1.DateTimeField; } }); 18 | var string_field_1 = require("./fields/string-field"); 19 | Object.defineProperty(exports, "StringField", { enumerable: true, get: function () { return string_field_1.StringField; } }); 20 | var number_field_1 = require("./fields/number-field"); 21 | Object.defineProperty(exports, "NumberField", { enumerable: true, get: function () { return number_field_1.NumberField; } }); 22 | var recursive_1 = require("./recursive"); 23 | Object.defineProperty(exports, "Recursive", { enumerable: true, get: function () { return recursive_1.Recursive; } }); 24 | var parse_error_1 = require("./exceptions/parse-error"); 25 | Object.defineProperty(exports, "ParseError", { enumerable: true, get: function () { return parse_error_1.ParseError; } }); 26 | var parse_issue_1 = require("./exceptions/parse-issue"); 27 | Object.defineProperty(exports, "ValidationIssue", { enumerable: true, get: function () { return parse_issue_1.ParseIssue; } }); 28 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /dist/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,2CAA+D;AAAtD,oGAAA,MAAM,OAAA;AAAE,yGAAA,WAAW,OAAA;AAAE,wGAAA,UAAU,OAAA;AACxC,yCAAuC;AAA9B,qGAAA,QAAQ,OAAA;AACjB,kDAAkE;AAAzD,uGAAA,SAAS,OAAA;AAClB,oDAAgE;AAAvD,yGAAA,UAAU,OAAA;AACnB,wDAAsD;AAA7C,6GAAA,YAAY,OAAA;AACrB,4DAA+E;AAAtE,gHAAA,aAAa,OAAA;AACtB,sDAAmE;AAA1D,2GAAA,WAAW,OAAA;AACpB,sDAAmE;AAA1D,2GAAA,WAAW,OAAA;AACpB,yCAAwC;AAA/B,sGAAA,SAAS,OAAA;AAClB,wDAAoE;AAA3D,yGAAA,UAAU,OAAc;AACjC,wDAAyE;AAAhE,8GAAA,UAAU,OAAmB"} -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.ts?$': 'ts-jest', 4 | }, 5 | testEnvironment: 'node', 6 | testRegex: './src/.*\\.(test|spec)?\\.(ts|ts)$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | roots: ['/src'], 9 | modulePaths: [""], 10 | }; 11 | -------------------------------------------------------------------------------- /nestjs-examples/dto-context-validation-pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentMetadata, 3 | BadRequestException, 4 | Inject, 5 | Injectable, 6 | PipeTransform, 7 | Scope, 8 | } from '@nestjs/common'; 9 | import { REQUEST } from '@nestjs/core'; 10 | import { DTObject, ParseError } from 'dto-classes'; 11 | import { Request } from 'express' 12 | 13 | 14 | @Injectable({ scope: Scope.REQUEST }) 15 | export class DTOContextValidationPipe implements PipeTransform { 16 | 17 | constructor(@Inject(REQUEST) protected readonly request: Request) { } 18 | 19 | async transform(value: any, metadata: ArgumentMetadata) { 20 | let DTOClass: typeof DTObject = null; 21 | 22 | if (metadata.metatype.prototype instanceof DTObject) { 23 | DTOClass = metadata.metatype as any; 24 | } 25 | 26 | if (!DTOClass) { 27 | return value; 28 | } 29 | 30 | try { 31 | value = await DTOClass.parse(value, { context: { request: this.request } }); 32 | } catch (e) { 33 | if (e instanceof ParseError) { 34 | throw new BadRequestException({ 35 | message: 'Validation failed.', 36 | error: e.issues.map(x => { 37 | return { message: x.message, path: x.path } 38 | }) 39 | }); 40 | } 41 | } 42 | 43 | return value; 44 | } 45 | } -------------------------------------------------------------------------------- /nestjs-examples/dto-validation-pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentMetadata, 3 | BadRequestException, 4 | Injectable, 5 | PipeTransform, 6 | } from '@nestjs/common'; 7 | import { DTObject, ParseError } from 'dto-classes'; 8 | 9 | 10 | @Injectable() 11 | export class DTOValidationPipe implements PipeTransform { 12 | 13 | async transform(value: any, metadata: ArgumentMetadata) { 14 | let DTOClass: typeof DTObject = null; 15 | 16 | if (metadata.metatype.prototype instanceof DTObject) { 17 | DTOClass = metadata.metatype as any; 18 | } 19 | 20 | if (!DTOClass) { 21 | return value; 22 | } 23 | 24 | try { 25 | value = await DTOClass.parse(value); 26 | } catch (e) { 27 | if (e instanceof ParseError) { 28 | throw new BadRequestException({ 29 | message: 'Validation failed.', 30 | error: e.issues.map(x => { 31 | return { message: x.message, path: x.path } 32 | }) 33 | }); 34 | } 35 | } 36 | 37 | return value; 38 | } 39 | } -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src" 4 | ], 5 | "ext": ".ts,.js", 6 | "ignore": [], 7 | "exec": "npx ts-node ./src/index.ts" 8 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dto-classes", 3 | "version": "0.0.13", 4 | "description": "Classes for data transfer objects.", 5 | "types": "dist/index.d.ts", 6 | "scripts": { 7 | "build": "tsc", 8 | "start:dev": "npx nodemon", 9 | "test": "jest", 10 | "test:dev": "jest --watchAll", 11 | "prepare": "npm run build", 12 | "prepublishOnly": "npm test", 13 | "version": "git add -A src", 14 | "postversion": "git push && git push --tags" 15 | }, 16 | "homepage": "https://github.com/rsinger86/dto-classes", 17 | "author": "Robert Singer", 18 | "license": "ISC", 19 | "devDependencies": { 20 | "@types/jest": "^29.5.1", 21 | "@types/node": "^17.0.29", 22 | "jest": "^29.5.0", 23 | "nodemon": "^2.0.22", 24 | "ts-jest": "^29.1.0", 25 | "ts-node": "^10.9.1", 26 | "typescript": "^5.0.4" 27 | }, 28 | "files": [ 29 | "dist/**/*" 30 | ], 31 | "exports": { 32 | ".": "./dist/index.js" 33 | } 34 | } -------------------------------------------------------------------------------- /readme-types-example.ts: -------------------------------------------------------------------------------- 1 | import { DTObject } from "./src/dt-object"; 2 | import { ArrayField } from "./src/fields/array-field"; 3 | import { BooleanField } from "./src/fields/boolean-field"; 4 | import { DateTimeField } from "./src/fields/date-time-field"; 5 | import { StringField } from "./src/fields/string-field"; 6 | import { CombineField } from "./src/fields/combine-field"; 7 | import { NumberField } from "./src/fields/number-field"; 8 | 9 | 10 | class UserDto extends DTObject { 11 | name = StringField.bind() 12 | nickName = StringField.bind({ required: false }) 13 | birthday = DateTimeField.bind() 14 | active = BooleanField.bind({ default: true }) 15 | hobbies = ArrayField.bind({ items: StringField.bind() }) 16 | favoriteColor = StringField.bind({ allowNull: true }) 17 | } 18 | 19 | async function t() { 20 | const userDto = await UserDto.parse({ 21 | name: "Michael Scott", 22 | birthday: '1962-08-16', 23 | hobbies: ["Comedy", "Paper"], 24 | favoriteColor: "Red" 25 | }); 26 | 27 | userDto.name 28 | userDto.nickName 29 | userDto.birthday 30 | userDto.active 31 | userDto.hobbies 32 | userDto.favoriteColor 33 | 34 | class InventoryItem extends DTObject { 35 | quantity = CombineField.bind({ 36 | anyOf: [ 37 | NumberField.bind({ minValue: 1, maxValue: 10 }), 38 | NumberField.bind({ minValue: 50, maxValue: 100 }) 39 | ] 40 | }) 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/decorators.ts: -------------------------------------------------------------------------------- 1 | export const IS_POST_PARSER_KEY = '__isPostParser'; 2 | export const POST_PARSER_OPTIONS_KEY = '__PostParserOptions'; 3 | export const IS_PREPARSER_KEY = '__isPreparser'; 4 | export const IS_FORMATTER_KEY = '__isFormatter'; 5 | export const FORMATTER_OPTIONS_KEY = '__FormatterOptions'; 6 | 7 | 8 | export interface ValidateMethodOptions { 9 | receieveNull?: boolean; 10 | } 11 | 12 | export const AfterParse = (options: ValidateMethodOptions = {}) => { 13 | return (target: any, memberName: string, propertyDescriptor: PropertyDescriptor) => { 14 | target[memberName][IS_POST_PARSER_KEY] = true; 15 | target[memberName][POST_PARSER_OPTIONS_KEY] = options ?? {}; 16 | return target; 17 | } 18 | } 19 | 20 | export const BeforeParse = (options: {} = {}) => { 21 | return (target: any, memberName: string, propertyDescriptor: PropertyDescriptor) => { 22 | target[memberName][IS_PREPARSER_KEY] = true; 23 | return target; 24 | } 25 | } 26 | 27 | 28 | export const Format = (options: { fieldName?: string } = {}) => { 29 | return (target: any, memberName: string, propertyDescriptor: PropertyDescriptor) => { 30 | if (!options.fieldName) { 31 | options.fieldName = target[memberName].name 32 | } 33 | 34 | target[memberName][IS_FORMATTER_KEY] = true; 35 | target[memberName][FORMATTER_OPTIONS_KEY] = options; 36 | return target; 37 | } 38 | } -------------------------------------------------------------------------------- /src/dt-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { AfterParse, Format } from "./decorators"; 2 | import { DTObject } from "./dt-object"; 3 | import { ParseError } from "./exceptions/parse-error"; 4 | import { ArrayField } from "./fields/array-field"; 5 | import { BooleanField } from "./fields/boolean-field"; 6 | import { DateTimeField } from "./fields/date-time-field"; 7 | import { StringField } from "./fields/string-field"; 8 | import { Recursive } from "./recursive"; 9 | 10 | 11 | 12 | describe('test parse', () => { 13 | test('simple fields should succeed', async () => { 14 | class Person extends DTObject { 15 | firstName = StringField.bind() 16 | lastName = StringField.bind() 17 | } 18 | 19 | const person = await Person.parse({ firstName: 'Robert', lastName: 'Singer' }); 20 | expect(person.firstName).toEqual('Robert'); 21 | expect(person.lastName).toEqual('Singer'); 22 | }); 23 | 24 | test('should fail if required field missing', async () => { 25 | class Person extends DTObject { 26 | firstName = StringField.bind() 27 | lastName = StringField.bind() 28 | } 29 | 30 | await expect( 31 | async () => await Person.parse({ firstName: 'Robert' }) 32 | ).rejects.toThrowError('This field is required.') 33 | }); 34 | 35 | describe('test parse', () => { 36 | test('should not fail if required field missing but partial is ture', async () => { 37 | class Person extends DTObject { 38 | firstName = StringField.bind() 39 | lastName = StringField.bind() 40 | } 41 | 42 | const personDto = await Person.parse({ firstName: 'Robert' }, { partial: true }); 43 | personDto.firstName 44 | //expect(personDto.firstName).toEqual('Robert'); 45 | //expect(personDto.lastName).toEqual(undefined); 46 | }); 47 | }); 48 | 49 | test('should use default for missing field', async () => { 50 | class Person extends DTObject { 51 | firstName = StringField.bind() 52 | lastName = StringField.bind({ "default": "James" }) 53 | } 54 | 55 | const person = await Person.parse({ firstName: 'Robert' }); 56 | expect(person.firstName).toEqual('Robert'); 57 | expect(person.lastName).toEqual('James'); 58 | }); 59 | 60 | test('should parse nested object', async () => { 61 | class Job extends DTObject { 62 | title = StringField.bind() 63 | isSatisfying = BooleanField.bind() 64 | } 65 | 66 | class Person extends DTObject { 67 | firstName = StringField.bind() 68 | lastName = StringField.bind({ "default": "James" }) 69 | job = Job.bind() 70 | } 71 | 72 | const person = await Person.parse({ firstName: 'Robert', job: { title: 'Programmer', isSatisfying: true } }); 73 | expect(person.firstName).toEqual('Robert'); 74 | expect(person.lastName).toEqual('James'); 75 | expect(person.job.title).toEqual('Programmer'); 76 | expect(person.job.isSatisfying).toEqual(true); 77 | }); 78 | 79 | test('should parse triple nested object', async () => { 80 | class EmployerDto extends DTObject { 81 | name = StringField.bind() 82 | public = BooleanField.bind() 83 | dateStarted = DateTimeField.bind() 84 | } 85 | 86 | class JobDto extends DTObject { 87 | title = StringField.bind() 88 | isSatisfying = BooleanField.bind() 89 | company = EmployerDto.bind() 90 | } 91 | 92 | class PersonDto extends DTObject { 93 | firstName = StringField.bind() 94 | lastName = StringField.bind({ "default": "James" }) 95 | job = JobDto.bind() 96 | } 97 | 98 | const person = await new PersonDto().parseValue({ 99 | firstName: 'Robert', 100 | job: { 101 | title: 'Programmer', isSatisfying: true, 102 | company: { 103 | name: 'Dunder Mifflin', 104 | public: false, 105 | dateStarted: '1999-06-05' 106 | } 107 | } 108 | }); 109 | expect(person.firstName).toEqual('Robert'); 110 | expect(person.lastName).toEqual('James'); 111 | expect(person.job.title).toEqual('Programmer'); 112 | expect(person.job.isSatisfying).toEqual(true); 113 | expect(person.job.company.name).toEqual('Dunder Mifflin'); 114 | expect(person.job.company.public).toEqual(false); 115 | expect(person.job.company.dateStarted).toEqual(new Date('1999-06-05')); 116 | }); 117 | 118 | }); 119 | 120 | 121 | describe('test getValues', () => { 122 | test('should getValues of nested object', async () => { 123 | class Job extends DTObject { 124 | title = StringField.bind() 125 | isSatisfying = BooleanField.bind() 126 | } 127 | 128 | 129 | class Person extends DTObject { 130 | firstName = StringField.bind() 131 | lastName = StringField.bind({ "default": "James" }) 132 | job = Job.bind() 133 | } 134 | 135 | const person = await new Person().parseValue({ firstName: 'Robert', job: { title: 'Programmer', isSatisfying: true } }); 136 | const plainData = person.getValues() 137 | 138 | expect(plainData).toEqual({ 139 | firstName: 'Robert', 140 | lastName: 'James', 141 | job: { title: 'Programmer', isSatisfying: true } 142 | }); 143 | }); 144 | }); 145 | 146 | describe('test decorated method calls', () => { 147 | test('should raise error from custom validate method', async () => { 148 | class Job extends DTObject { 149 | title = StringField.bind() 150 | isSatisfying = BooleanField.bind() 151 | } 152 | 153 | class Person extends DTObject { 154 | firstName = StringField.bind() 155 | lastName = StringField.bind({ "default": "James" }) 156 | job = Job.bind() 157 | 158 | @AfterParse() 159 | customValidate(value: this) { 160 | if (value.firstName === 'Robert') { 161 | throw new ParseError("Can't use that name") 162 | } 163 | } 164 | } 165 | 166 | const f = async () => { 167 | await new Person().parseValue({ firstName: 'Robert', job: { title: 'Programmer', isSatisfying: true } }); 168 | }; 169 | 170 | await expect(f).rejects.toThrow("Can't use that name") 171 | }); 172 | 173 | test('should raise error from nested custom validate method', async () => { 174 | class Job extends DTObject { 175 | title = StringField.bind() 176 | isSatisfying = BooleanField.bind() 177 | 178 | @AfterParse() 179 | customValidate(value: this) { 180 | if (value.isSatisfying) { 181 | throw new ParseError("Can't have a satisfying job") 182 | } 183 | } 184 | } 185 | 186 | class Person extends DTObject { 187 | firstName = StringField.bind() 188 | lastName = StringField.bind({ "default": "James" }) 189 | job = Job.bind() 190 | 191 | 192 | } 193 | 194 | let correctExceptionFound = false; 195 | 196 | try { 197 | await new Person().parseValue({ firstName: 'Robert', job: { title: 'Programmer', isSatisfying: true } }); 198 | } catch (e: any) { 199 | if (e instanceof ParseError) { 200 | expect(e.issues[0].path).toEqual(['job']); 201 | expect(e.issues[0].message).toEqual("Can't have a satisfying job") 202 | correctExceptionFound = true; 203 | } 204 | } 205 | 206 | expect(correctExceptionFound).toBeTruthy() 207 | }); 208 | }); 209 | 210 | describe('test custom validators', () => { 211 | test('should getValues of nested object', async () => { 212 | class Job extends DTObject { 213 | title = StringField.bind() 214 | isSatisfying = BooleanField.bind() 215 | } 216 | 217 | class Person extends DTObject { 218 | firstName = StringField.bind() 219 | lastName = StringField.bind({ "default": "James" }) 220 | job = Job.bind() 221 | 222 | @AfterParse() 223 | validateNamesDifferent() { 224 | 225 | } 226 | } 227 | 228 | const person = await new Person().parseValue({ firstName: 'Robert', job: { title: 'Programmer', isSatisfying: true } }); 229 | 230 | }); 231 | }); 232 | 233 | 234 | describe('test format', () => { 235 | test('format should exclude write only fields', async () => { 236 | class Person extends DTObject { 237 | firstName = StringField.bind() 238 | lastName = StringField.bind() 239 | password = StringField.bind({ writeOnly: true }) 240 | } 241 | 242 | const data = await new Person({}).formatValue({ firstName: 'Robert', lastName: 'Singer', password: '123' }); 243 | expect(data).toEqual({ firstName: 'Robert', lastName: 'Singer' }); 244 | }); 245 | 246 | test('format should obey formatSource', async () => { 247 | class Person extends DTObject { 248 | firstName = StringField.bind() 249 | lastName = StringField.bind() 250 | birthday = DateTimeField.bind({ formatSource: 'dateOfBirth' }) 251 | } 252 | 253 | const data = await Person.format({ 254 | firstName: 'Robert', 255 | lastName: 'Singer', 256 | dateOfBirth: new Date('1987-11-11') 257 | }); 258 | 259 | expect(data).toEqual({ 260 | firstName: 'Robert', 261 | lastName: 'Singer', 262 | birthday: "1987-11-11T00:00:00.000Z" 263 | }); 264 | }); 265 | 266 | test('format recursive array', async () => { 267 | class Person extends DTObject { 268 | firstName = StringField.bind() 269 | lastName = StringField.bind() 270 | dateOfBirth = DateTimeField.bind() 271 | family = ArrayField.bind({ items: Recursive(Person), required: false }) 272 | } 273 | 274 | const data = await Person.format({ 275 | firstName: 'Steve', 276 | lastName: 'Coolman', 277 | dateOfBirth: new Date('1960-01-01'), 278 | family: [ 279 | { 280 | firstName: "Jake", 281 | lastName: "Coolman", 282 | family: [ 283 | { 284 | firstName: "Child A", 285 | lastName: "Coolman" 286 | } 287 | ] 288 | }, 289 | { 290 | firstName: "Tim", 291 | lastName: "Coolman", 292 | family: [ 293 | { 294 | firstName: "Child B", 295 | lastName: "Coolman" 296 | } 297 | ] 298 | } 299 | ] 300 | }); 301 | 302 | const expected = { 303 | "firstName": "Steve", 304 | "lastName": "Coolman", 305 | "dateOfBirth": "1960-01-01T00:00:00.000Z", 306 | "family": [ 307 | { 308 | "firstName": "Jake", 309 | "lastName": "Coolman", 310 | "dateOfBirth": null, 311 | "family": [ 312 | { 313 | "firstName": "Child A", 314 | "lastName": "Coolman", 315 | "dateOfBirth": null, 316 | "family": null 317 | } 318 | ] 319 | }, 320 | { 321 | "firstName": "Tim", 322 | "lastName": "Coolman", 323 | "dateOfBirth": null, 324 | "family": [ 325 | { 326 | "firstName": "Child B", 327 | "lastName": "Coolman", 328 | "dateOfBirth": null, 329 | "family": null 330 | } 331 | ] 332 | } 333 | ] 334 | }; 335 | 336 | expect(data).toEqual(expected); 337 | 338 | }); 339 | 340 | 341 | test('format recursive object', async () => { 342 | class StaffMember extends DTObject { 343 | firstName = StringField.bind() 344 | lastName = StringField.bind() 345 | supervisor = Recursive(StaffMember, { required: false }) 346 | } 347 | 348 | const data = await new StaffMember({}).formatValue({ 349 | firstName: 'Robert', 350 | lastName: 'S', 351 | supervisor: { 352 | firstName: 'Matt', 353 | lastName: 'X', 354 | supervisor: { 355 | firstName: 'Pat', 356 | lastName: 'X' 357 | } 358 | } 359 | }); 360 | 361 | const expected = { 362 | "firstName": "Robert", 363 | "lastName": "S", 364 | "supervisor": { 365 | "firstName": "Matt", 366 | "lastName": "X", 367 | "supervisor": { 368 | "firstName": "Pat", 369 | "lastName": "X", 370 | "supervisor": null 371 | } 372 | } 373 | }; 374 | 375 | expect(data).toEqual(expected); 376 | }); 377 | 378 | test('format should use decorated function', async () => { 379 | class Person extends DTObject { 380 | firstName = StringField.bind() 381 | lastName = StringField.bind() 382 | 383 | @Format() 384 | fullName(obj) { 385 | return `${obj.firstName} ${obj.lastName}`; 386 | } 387 | } 388 | 389 | const data = await new Person({}).formatValue({ 390 | firstName: 'Steve', 391 | lastName: 'Coolman', 392 | }); 393 | 394 | expect(data).toEqual({ firstName: 'Steve', lastName: 'Coolman', fullName: 'Steve Coolman' }); 395 | }); 396 | 397 | test('format should use decorated function w/ custom field name', async () => { 398 | class Person extends DTObject { 399 | firstName = StringField.bind() 400 | lastName = StringField.bind() 401 | 402 | @Format({ fieldName: 'completeName' }) 403 | fullName(obj) { 404 | return `${obj.firstName} ${obj.lastName}`; 405 | } 406 | } 407 | 408 | const data = await Person.format({ 409 | firstName: 'Steve', 410 | lastName: 'Coolman', 411 | }); 412 | 413 | expect(data).toEqual({ firstName: 'Steve', lastName: 'Coolman', completeName: 'Steve Coolman' }); 414 | }); 415 | }); 416 | 417 | 418 | describe('test access context', () => { 419 | test('should access context in deeply nested fields', async () => { 420 | class Person extends DTObject { 421 | name = StringField.bind() 422 | manager = Recursive(Person, { required: false }) 423 | } 424 | 425 | const person = await new Person({ context: { request: 'request123' } }).parseValue({ 426 | name: 'Robert', 427 | manager: { 428 | name: 'Fred', 429 | manager: { 430 | name: 'Sam' 431 | } 432 | } 433 | }); 434 | 435 | expect(person._context).toEqual({ request: 'request123' }); 436 | expect(person.manager._context).toEqual({ request: 'request123' }); 437 | expect(person.manager.manager._context).toEqual({ request: 'request123' }); 438 | }); 439 | }); 440 | 441 | 442 | describe('test ignore field input', () => { 443 | test('should use default value when ignoreInput = true', async () => { 444 | class Person extends DTObject { 445 | name = StringField.bind() 446 | timestamp = DateTimeField.bind({ ignoreInput: true, default: () => new Date() }) 447 | } 448 | 449 | const person = await new Person().parseValue({ 450 | name: 'Robert', 451 | timestamp: '1965-01-01' 452 | }); 453 | 454 | expect(person.timestamp).toBeInstanceOf(Date) 455 | expect(person.timestamp.getFullYear()).toEqual(new Date().getFullYear()) 456 | }); 457 | }); 458 | -------------------------------------------------------------------------------- /src/dt-object.ts: -------------------------------------------------------------------------------- 1 | import { FORMATTER_OPTIONS_KEY, IS_FORMATTER_KEY } from "./decorators"; 2 | import { ParseError } from "./exceptions/parse-error"; 3 | import { BaseField, BaseFieldOptions } from "./fields/base-field"; 4 | import { DeferredField } from "./recursive"; 5 | import { ParseDtoReturnType } from "./types"; 6 | import { getAllPropertyNames, isKeyableObject } from "./utils"; 7 | 8 | 9 | 10 | export class DTObject extends BaseField { 11 | public _options: any; 12 | private _parsedValues: { [key: string]: any } | null = null; 13 | private _fields: Array = []; 14 | 15 | constructor(options?: BaseFieldOptions) { 16 | super(options ?? {}); 17 | } 18 | 19 | private get allFields(): Array { 20 | if (this._fields.length > 0) { 21 | return this._fields; 22 | } 23 | 24 | const fields: Array = [] 25 | 26 | for (const attrName of Object.keys(this)) { 27 | if (attrName === '_parent') { 28 | continue; 29 | } 30 | 31 | let attribute = this[attrName]; 32 | 33 | if (attribute instanceof BaseField || attribute instanceof DeferredField) { 34 | if (attribute instanceof DeferredField) { 35 | attribute = (attribute as any).construct() 36 | } 37 | 38 | const clonedField = attribute._clone(); 39 | clonedField._asChild(this, attrName, { 40 | partial: this._options.partial ?? false, 41 | context: this._options.context ?? null 42 | }); 43 | fields.push(clonedField) 44 | } 45 | } 46 | 47 | this._fields = fields; 48 | return this._fields; 49 | } 50 | 51 | private getFormatterMethods(): Function[] { 52 | const methods: Function[] = []; 53 | 54 | for (const propName of getAllPropertyNames(this)) { 55 | const property = this[propName] as any; 56 | 57 | if (property && property[IS_FORMATTER_KEY]) { 58 | methods.push(property); 59 | } 60 | } 61 | 62 | return methods; 63 | } 64 | 65 | private getFieldsToParse(): Array { 66 | return this.allFields.filter(x => { 67 | return x._options.readOnly !== true; 68 | }); 69 | } 70 | 71 | private getFieldsToFormat(): Array { 72 | return this.allFields.filter(x => { 73 | return x._options.writeOnly !== true; 74 | }); 75 | } 76 | 77 | public getValues(): { [key: string]: any } { 78 | if (this._parsedValues === null) { 79 | throw new Error("Must call parse() before getValues()") 80 | } 81 | 82 | const plain = {}; 83 | 84 | for (const key in this._parsedValues) { 85 | let value = this._parsedValues[key]; 86 | 87 | if (value instanceof DTObject) { 88 | value = value.getValues(); 89 | } 90 | 91 | plain[key] = value; 92 | } 93 | return plain 94 | } 95 | 96 | 97 | public async parseValue(rawObject: object): ParseDtoReturnType { 98 | const errors: ParseError[] = []; 99 | this._parsedValues = {}; 100 | 101 | for (const field of this.getFieldsToParse()) { 102 | const fieldName = field._getFieldName(); 103 | const rawValue = rawObject ? rawObject[fieldName] : undefined; 104 | const ignoreInput = field._options.ignoreInput ?? false; 105 | 106 | if (ignoreInput) { 107 | this[fieldName] = await field.getDefaultParseValue(); 108 | continue; 109 | } 110 | 111 | try { 112 | this[fieldName] = await field.parseValue(rawValue); 113 | this._parsedValues[fieldName] = this[fieldName]; 114 | } catch (e) { 115 | this[fieldName] = undefined; 116 | if (e instanceof ParseError) { 117 | e.addParentPath(fieldName); 118 | errors.push(e) 119 | } else { 120 | throw e; 121 | } 122 | } 123 | } 124 | 125 | if (errors.length > 0) { 126 | throw ParseError.combine(errors); 127 | } 128 | 129 | return this; 130 | } 131 | 132 | public async formatValue(internalObj: any): Promise<{ [key: string]: any }> { 133 | const formatted = {}; 134 | 135 | for (const field of this.getFieldsToFormat()) { 136 | const fieldName = field._getFieldName(); 137 | 138 | if (isKeyableObject(internalObj)) { 139 | const internalValue = await field._getValueToFormat(internalObj); 140 | 141 | if (internalValue === undefined || internalValue === null) { 142 | formatted[fieldName] = null; 143 | } else { 144 | formatted[fieldName] = await field.formatValue(internalValue); 145 | } 146 | } else { 147 | formatted[fieldName] = null; 148 | } 149 | } 150 | 151 | for (const formatter of this.getFormatterMethods()) { 152 | const fieldName = formatter[FORMATTER_OPTIONS_KEY]['fieldName'] 153 | formatted[fieldName] = await formatter.apply(this, [internalObj]); 154 | } 155 | 156 | return formatted; 157 | } 158 | 159 | } 160 | -------------------------------------------------------------------------------- /src/exceptions/parse-error.ts: -------------------------------------------------------------------------------- 1 | import { ParseIssue } from "./parse-issue"; 2 | 3 | export class ParseError extends Error { 4 | issues: ParseIssue[] = []; 5 | 6 | constructor(issues: ParseIssue[] | string) { 7 | if (typeof issues === 'string') { 8 | issues = [new ParseIssue(issues)] 9 | } 10 | super(issues.map(x => x.message).join(' ')); 11 | this.name = "ParseError"; 12 | this.issues = issues; 13 | } 14 | 15 | addParentPath(path: string) { 16 | for (const issue of this.issues) { 17 | issue.addParentPath(path); 18 | } 19 | } 20 | 21 | public static combine(errors: ParseError[]) { 22 | let allIssues: ParseIssue[] = []; 23 | 24 | for (const error of errors) { 25 | for (const issue of error.issues) { 26 | allIssues.push(issue); 27 | } 28 | } 29 | 30 | return new ParseError(allIssues); 31 | } 32 | } 33 | 34 | // https://github.com/colinhacks/zod/blob/master/src/ZodError.ts 35 | -------------------------------------------------------------------------------- /src/exceptions/parse-issue.ts: -------------------------------------------------------------------------------- 1 | export class ParseIssue { 2 | public readonly message: string; 3 | public path: string[] = []; 4 | 5 | constructor(message: string) { 6 | this.message = message 7 | } 8 | 9 | public addParentPath(path: string) { 10 | this.path.unshift(path) 11 | } 12 | } 13 | 14 | // https://github.com/colinhacks/zod/blob/master/src/ZodError.ts -------------------------------------------------------------------------------- /src/fields/array-field.spec.ts: -------------------------------------------------------------------------------- 1 | import { ArrayField } from "./array-field"; 2 | import { StringField } from "./string-field"; 3 | 4 | 5 | describe('test', () => { 6 | test('should parse array of strings', async () => { 7 | const schema = new ArrayField({ items: StringField.bind() }); 8 | var value = await schema.parseValue(['A', 'B', 'C ']); 9 | expect(value).toEqual(['A', 'B', 'C']); 10 | }); 11 | 12 | test('should parse array of emails', async () => { 13 | const schema = new ArrayField({ items: StringField.bind({ format: 'email' }) }); 14 | var value = await schema.parseValue(['joe@hotmail.com', 'bill@gmail.com', 'louis@goldens.com']); 15 | expect(value).toEqual(['joe@hotmail.com', 'bill@gmail.com', 'louis@goldens.com']); 16 | }); 17 | 18 | test('should fail if exceed max items', async () => { 19 | const schema = new ArrayField({ items: StringField.bind({ format: 'email' }), maxLength: 2 }); 20 | 21 | await expect(async () => { 22 | await schema.parseValue(['joe@hotmail.com', 'bill@gmail.com', 'louis@goldens.com']) 23 | }).rejects.toThrow('nsure this field has no more than 2 items'); 24 | }); 25 | 26 | 27 | test('should fail if doesnt not meet min items', async () => { 28 | const schema = new ArrayField({ items: StringField.bind({ format: 'email' }), minLength: 4 }); 29 | 30 | await expect(async () => { 31 | await schema.parseValue(['joe@hotmail.com', 'bill@gmail.com', 'louis@goldens.com']) 32 | }).rejects.toThrow('Ensure this field has at least 4 items'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/fields/array-field.ts: -------------------------------------------------------------------------------- 1 | import { ParseIssue } from "../exceptions/parse-issue"; 2 | import { BaseFieldOptions, BaseField } from "./base-field"; 3 | import { ParseArrayReturnType } from "../types"; 4 | import { ParseError } from "../exceptions/parse-error"; 5 | import { AfterParse } from "../decorators"; 6 | import { DeferredField } from "../recursive"; 7 | import { DTObject } from "src/dt-object"; 8 | 9 | 10 | export interface ArrayOptions extends BaseFieldOptions { 11 | items: any; // BaseField | DTObject; 12 | maxLength?: number | null; 13 | minLength?: number | null; 14 | } 15 | 16 | export class ArrayField extends BaseField { 17 | // @ts-ignore 18 | _options: T; 19 | 20 | constructor(options?: T) { 21 | super(options); 22 | } 23 | 24 | public async parseValue(value: any): ParseArrayReturnType { 25 | const parsedItems: any[] = []; 26 | let itemField = this._options.items as BaseField; 27 | 28 | if (!Array.isArray(value)) { 29 | throw new ParseIssue(`Ensure value is an array.`) 30 | } 31 | 32 | if (itemField instanceof DeferredField) { 33 | itemField = itemField.construct() 34 | } 35 | 36 | for (const v of value) { 37 | parsedItems.push(await itemField.parseValue(v)) 38 | } 39 | 40 | return parsedItems as any; 41 | } 42 | 43 | public async formatValue(value: any): Promise { 44 | const formattedItems: any[] = []; 45 | let itemField = this._options.items as BaseField; 46 | 47 | if (!Array.isArray(value)) { 48 | return []; 49 | } 50 | 51 | if (itemField instanceof DeferredField) { 52 | itemField = itemField.construct() 53 | } 54 | 55 | for (const v of value) { 56 | const formattedValue = await itemField.formatValue(v); 57 | formattedItems.push(formattedValue) 58 | } 59 | 60 | return formattedItems; 61 | } 62 | 63 | @AfterParse({ receieveNull: false }) 64 | public validateMinLength(value: any[]) { 65 | const minLen = this._options.minLength ?? -Infinity; 66 | 67 | if (minLen !== null && value.length < minLen) { 68 | throw new ParseError(`Ensure this field has at least ${minLen} items.`) 69 | } 70 | 71 | return value; 72 | } 73 | 74 | @AfterParse({ receieveNull: false }) 75 | public validateMaxLength(value: any[]) { 76 | const maxLength = this._options.maxLength ?? Infinity; 77 | 78 | if (maxLength !== null && value.length > maxLength) { 79 | throw new ParseError(`Ensure this field has no more than ${maxLength} items.`) 80 | } 81 | 82 | return value; 83 | } 84 | 85 | } -------------------------------------------------------------------------------- /src/fields/base-field.ts: -------------------------------------------------------------------------------- 1 | import { getAllPropertyNames } from "../utils"; 2 | import { ParseError } from "../exceptions/parse-error"; 3 | import { ParseIssue } from "../exceptions/parse-issue"; 4 | import { BeforeParse, IS_POST_PARSER_KEY, IS_PREPARSER_KEY, POST_PARSER_OPTIONS_KEY, ValidateMethodOptions } from "../decorators"; 5 | import { StaticBindReturnType } from "../types"; 6 | 7 | 8 | export interface BaseFieldOptions { 9 | items?: any; 10 | required?: boolean; 11 | allowNull?: boolean; 12 | readOnly?: boolean; 13 | writeOnly?: boolean; 14 | default?: any; 15 | partial?: boolean; 16 | formatSource?: string | null; 17 | context?: { [key: string]: any }; 18 | ignoreInput?: boolean; 19 | } 20 | 21 | 22 | export class BaseField { 23 | public readonly _options: BaseFieldOptions; 24 | 25 | private _parent: BaseField | null = null; 26 | private _fieldName: string = ''; 27 | 28 | constructor(options: BaseFieldOptions = {}) { 29 | this._options = options ?? {}; 30 | const originalParse = this.parseValue; 31 | 32 | this.parseValue = async (value) => { 33 | value = await this._beforeParse(value); 34 | 35 | if (value === undefined) { 36 | if (this._options.partial) { 37 | return undefined; 38 | } else { 39 | return await this.getDefaultParseValue(); 40 | } 41 | } else if (value !== null) { 42 | value = await originalParse.apply(this, [value]); 43 | } 44 | 45 | value = await this._afterParse(value); 46 | return value; 47 | }; 48 | } 49 | 50 | protected _clone() { 51 | const ThisClass = this.constructor as any; 52 | return new ThisClass(this._options); 53 | } 54 | 55 | public get _context(): { [key: string]: any } { 56 | return this._options.context ?? {}; 57 | } 58 | 59 | public _asChild(parent: BaseField, fieldName: string, options: BaseFieldOptions = {}) { 60 | this._parent = parent; 61 | this._fieldName = fieldName; 62 | // @ts-ignore 63 | this._options = { ...this._options, ...options }; 64 | } 65 | 66 | public _getFieldName(): string { 67 | return this._fieldName; 68 | } 69 | 70 | public _getParent(): BaseField | null { 71 | return this._parent; 72 | } 73 | 74 | public async getDefaultParseValue(): Promise { 75 | const value = this._options.default; 76 | 77 | if (this._options.partial) { 78 | throw new Error("Cannot access default value when applying partial parsing and validation."); 79 | } 80 | 81 | if (typeof value === 'function') { 82 | return value(); 83 | } else { 84 | return value; 85 | } 86 | } 87 | 88 | protected async _beforeParse(value: any) { 89 | for (const propName of getAllPropertyNames(this)) { 90 | const property = this[propName] 91 | 92 | if (!property || !property[IS_PREPARSER_KEY]) { 93 | continue; 94 | } 95 | 96 | const validateMethod = property; 97 | const validatedValue = await validateMethod.apply(this, [value]); 98 | 99 | // Only assign the new value if the method returns 100 | if (validatedValue !== undefined) { 101 | value = validatedValue; 102 | } 103 | } 104 | 105 | return value; 106 | 107 | } 108 | 109 | protected async _afterParse(value: string) { 110 | for (const propName of getAllPropertyNames(this)) { 111 | const property = this[propName] 112 | 113 | if (!property || !property[IS_POST_PARSER_KEY]) { 114 | continue; 115 | } 116 | 117 | const options: ValidateMethodOptions = property[POST_PARSER_OPTIONS_KEY]; 118 | const validateMethod = property; 119 | const isNull = value === null; 120 | const isEmpty = (value === '' || value === null || value === undefined); 121 | const receiveNull = options.receieveNull ?? false; 122 | 123 | if (isNull && !receiveNull) { 124 | continue; 125 | } 126 | 127 | const validatedValue = await validateMethod.apply(this, [value]); 128 | 129 | // Only assign the new value if the method returns 130 | if (validatedValue !== undefined) { 131 | value = validatedValue; 132 | } 133 | } 134 | 135 | return value; 136 | } 137 | 138 | public _getValueToFormat(internalObject: any): any { 139 | const source = this._options.formatSource ?? this._fieldName; 140 | return internalObject[source] ?? null; 141 | } 142 | 143 | @BeforeParse() 144 | protected validateNull(value: any) { 145 | if (value === null && !this._options.allowNull) { 146 | throw new ParseError([new ParseIssue('This field may not be null.')]); 147 | } 148 | 149 | return value; 150 | } 151 | 152 | @BeforeParse() 153 | protected validateUndefined(value: any) { 154 | const isRequired = this._options.required ?? true; 155 | const hasDefault = this._options.default !== undefined 156 | const isPartialValidation = this._options.partial === true 157 | 158 | if (isPartialValidation) { 159 | return value; 160 | } 161 | 162 | if (value === undefined && isRequired && !hasDefault) { 163 | throw new ParseError([new ParseIssue('This field is required.')]); 164 | } 165 | 166 | return value; 167 | } 168 | 169 | public async parseValue(value: NonNullable): Promise { 170 | return value; 171 | } 172 | 173 | public async formatValue(value: any): Promise { 174 | return String(value); 175 | } 176 | 177 | static bind< 178 | T extends typeof BaseField, 179 | C extends ConstructorParameters[0] & {} 180 | >( 181 | this: T, 182 | args?: C 183 | ): 184 | Awaited> { 185 | return new this(args ?? {} as any) as any; 186 | } 187 | 188 | static async parse< 189 | T extends typeof BaseField, 190 | C extends ConstructorParameters[0] & {} 191 | >( 192 | this: T, 193 | data: any, 194 | args?: C 195 | ): Promise> { 196 | const instance = new this(args ?? {} as any) as any; 197 | return await instance.parseValue(data); 198 | } 199 | 200 | static async format< 201 | T extends typeof BaseField, 202 | C extends ConstructorParameters[0] & {} 203 | >( 204 | this: T, 205 | internalObj: any, 206 | args?: C 207 | ): Promise<{ [key: string]: any }> { 208 | const instance = new this(args ?? {} as any) as any; 209 | return await instance.formatValue(internalObj); 210 | } 211 | 212 | } -------------------------------------------------------------------------------- /src/fields/boolean-field.spec.ts: -------------------------------------------------------------------------------- 1 | import { ParseError } from "../exceptions/parse-error"; 2 | import { BooleanField } from "./boolean-field"; 3 | 4 | describe('parse tests', () => { 5 | test('should allow null value', async () => { 6 | const boolSchema = new BooleanField({ allowNull: true }); 7 | 8 | for (const v of ['null', 'Null', 'NULL', '', null]) { 9 | const value = await boolSchema.parseValue(v); 10 | expect(value).toEqual(null); 11 | } 12 | }); 13 | 14 | test('should not allow null value', async () => { 15 | const boolSchema = new BooleanField({ allowNull: false }); 16 | 17 | for (const v of ['null', 'Null', 'NULL', '', null]) { 18 | const t = async () => { 19 | await boolSchema.parseValue(v); 20 | 21 | } 22 | await expect(t).rejects.toThrow(ParseError); 23 | } 24 | 25 | for (const v of ['null', 'Null', 'NULL', '', null]) { 26 | const t = async () => { 27 | await BooleanField.parse(v, { allowNull: false }); 28 | 29 | } 30 | await expect(t).rejects.toThrow(ParseError); 31 | } 32 | }); 33 | 34 | test('should parse to false', async () => { 35 | const boolSchema = new BooleanField({ allowNull: false }); 36 | 37 | for (const v of [ 38 | 'f', 'F', 39 | 'n', 'N', 'no', 'No', 'NO', 40 | 'false', 'False', 'FALSE', 41 | 'off', 'Off', 'OFF', 42 | '0', 0, 0.0, 43 | false 44 | ]) { 45 | const value = await boolSchema.parseValue(v); 46 | expect(value).toEqual(false); 47 | } 48 | }); 49 | 50 | test('should parse to true', async () => { 51 | const boolSchema = new BooleanField({ allowNull: false }); 52 | 53 | for (const v of [ 54 | 't', 'T', 55 | 'y', 'Y', 'yes', 'Yes', 'YES', 56 | 'true', 'True', 'TRUE', 57 | 'on', 'On', 'ON', 58 | '1', 1, 59 | true 60 | ]) { 61 | const value = await boolSchema.parseValue(v); 62 | expect(value).toEqual(true); 63 | } 64 | }); 65 | }); 66 | 67 | 68 | describe('format tests', () => { 69 | test('should format as null', async () => { 70 | const boolSchema = new BooleanField({ allowNull: true }); 71 | 72 | for (const v of ['null', 'Null', 'NULL', '', null]) { 73 | const value = await boolSchema.formatValue(v); 74 | expect(value).toEqual(null); 75 | } 76 | }); 77 | 78 | test('should format to false', async () => { 79 | const boolSchema = new BooleanField({ allowNull: false }); 80 | 81 | for (const v of [ 82 | 'f', 'F', 83 | 'n', 'N', 'no', 'No', 'NO', 84 | 'false', 'False', 'FALSE', 85 | 'off', 'Off', 'OFF', 86 | '0', 0, 0.0, 87 | false 88 | ]) { 89 | const value = await boolSchema.formatValue(v); 90 | expect(value).toEqual(false); 91 | } 92 | }); 93 | 94 | test('should format to true', async () => { 95 | const boolSchema = new BooleanField({ allowNull: false }); 96 | 97 | for (const v of [ 98 | 't', 'T', 99 | 'y', 'Y', 'yes', 'Yes', 'YES', 100 | 'true', 'True', 'TRUE', 101 | 'on', 'On', 'ON', 102 | '1', 1, 103 | true 104 | ]) { 105 | const value = await boolSchema.parseValue(v); 106 | expect(value).toEqual(true); 107 | } 108 | }); 109 | }); -------------------------------------------------------------------------------- /src/fields/boolean-field.ts: -------------------------------------------------------------------------------- 1 | import { ParseError } from "../exceptions/parse-error"; 2 | import { BaseField, BaseFieldOptions } from "./base-field"; 3 | import { ParseIssue } from "../exceptions/parse-issue"; 4 | import { ParseReturnType } from "../types"; 5 | 6 | 7 | export class BooleanField extends BaseField { 8 | _options: T; 9 | 10 | readonly TRUE_VALUES = ['t', 'T', 'y', 'Y', 'yes', 'Yes', 'YES', 'true', 'True', 'TRUE', 'on', 'On', 'ON', '1', 1, true]; 11 | 12 | readonly FALSE_VALUES = ['f', 'F', 'n', 'N', 'no', 'No', 'NO', 'false', 'False', 'FALSE', 'off', 'Off', 'OFF', '0', 0, 0.0, false]; 13 | 14 | readonly NULL_VALUES = ['null', 'Null', 'NULL', '', null]; 15 | 16 | constructor(options?: T) { 17 | super(options); 18 | } 19 | 20 | public async parseValue(value: any): ParseReturnType { 21 | if (this.TRUE_VALUES.includes(value)) { 22 | return true as any; 23 | } else if (this.FALSE_VALUES.includes(value)) { 24 | return false as any; 25 | } else if (this._options.allowNull && this.NULL_VALUES.includes(value)) { 26 | return null as any; 27 | } 28 | 29 | throw new ParseError([new ParseIssue('Must be a valid boolean.')]) 30 | } 31 | 32 | public async formatValue(value: any): Promise { 33 | if (this.TRUE_VALUES.includes(value)) { 34 | return true; 35 | } else if (this.FALSE_VALUES.includes(value)) { 36 | return false; 37 | } else if (this._options.allowNull && this.NULL_VALUES.includes(value)) { 38 | return null as any; 39 | } 40 | 41 | return Boolean(value); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/fields/combine-field.spec.ts: -------------------------------------------------------------------------------- 1 | import { BooleanField } from "./boolean-field"; 2 | import { CombineField } from "./combine-field"; 3 | import { NumberField } from "./number-field"; 4 | import { StringField } from "./string-field"; 5 | 6 | 7 | describe('anyOf parse tests', () => { 8 | test('should use number schema', async () => { 9 | const field = new CombineField({ 10 | anyOf: [ 11 | StringField.bind({ maxLength: 2 }), 12 | NumberField.bind({ maxValue: 1000 }) 13 | ] 14 | }); 15 | 16 | const value = await field.parseValue('999'); 17 | expect(value).toEqual(999); 18 | }); 19 | 20 | test('should use string schema', async () => { 21 | const field = new CombineField({ 22 | anyOf: [ 23 | StringField.bind({ maxLength: 2 }), 24 | NumberField.bind({ maxValue: 1000 }) 25 | ] 26 | }); 27 | 28 | const value = await field.parseValue('99'); 29 | expect(value).toEqual('99'); 30 | }); 31 | 32 | test('should use boolean schema', async () => { 33 | const field = new CombineField({ 34 | anyOf: [ 35 | BooleanField.bind(), 36 | StringField.bind({ maxLength: 2 }), 37 | NumberField.bind({ maxValue: 1000 }) 38 | ] 39 | }); 40 | 41 | const value = await field.parseValue('1'); 42 | expect(value).toEqual(true); 43 | }); 44 | 45 | test('should fail if no matches', async () => { 46 | const t = async () => { 47 | await CombineField.parse('9999999999999', { 48 | anyOf: [ 49 | BooleanField.bind(), 50 | StringField.bind({ maxLength: 2 }), 51 | NumberField.bind({ maxValue: 1000 }) 52 | ] 53 | },) 54 | } 55 | await expect(t).rejects.toThrow('None of the subschemas matched'); 56 | }); 57 | 58 | test('null should match first schema allowing null', async () => { 59 | const value = await CombineField.parse(null, { 60 | anyOf: [ 61 | BooleanField.bind(), 62 | StringField.bind({ maxLength: 2 }), 63 | NumberField.bind({ allowNull: true }) 64 | ] 65 | }); 66 | 67 | expect(value).toEqual(null); 68 | }); 69 | }); 70 | 71 | 72 | 73 | describe('oneOf parse tests', () => { 74 | test('should use number schema', async () => { 75 | const field = new CombineField({ 76 | oneOf: [ 77 | StringField.bind({ maxLength: 2 }), 78 | NumberField.bind({ maxValue: 1000 }) 79 | ] 80 | }); 81 | 82 | const value = await field.parseValue('999'); 83 | expect(value).toEqual(999); 84 | }); 85 | 86 | test('should use string schema', async () => { 87 | const field = new CombineField({ 88 | oneOf: [ 89 | StringField.bind({ maxLength: 2 }), 90 | NumberField.bind({ maxValue: 80 }) 91 | ] 92 | }); 93 | 94 | const value = await field.parseValue('99'); 95 | expect(value).toEqual('99'); 96 | }); 97 | 98 | test('should fail if no matches', async () => { 99 | const t = async () => { 100 | await CombineField.parse('9999999999999', { 101 | oneOf: [ 102 | BooleanField.bind(), 103 | StringField.bind({ maxLength: 2 }), 104 | NumberField.bind({ maxValue: 1000 }) 105 | ] 106 | },) 107 | } 108 | await expect(t).rejects.toThrow('None of the subschemas matched'); 109 | }); 110 | 111 | test('null should match first schema allowing null', async () => { 112 | const value = await CombineField.parse(null, { 113 | oneOf: [ 114 | BooleanField.bind(), 115 | StringField.bind({ maxLength: 2 }), 116 | NumberField.bind({ allowNull: true }) 117 | ] 118 | }); 119 | 120 | expect(value).toEqual(null); 121 | }); 122 | 123 | test('should use string schema', async () => { 124 | const field = new CombineField({ 125 | oneOf: [ 126 | StringField.bind({ maxLength: 2 }), 127 | NumberField.bind({ maxValue: 80 }) 128 | ] 129 | }); 130 | 131 | const value = await field.parseValue('99'); 132 | expect(value).toEqual('99'); 133 | }); 134 | 135 | test('should fail if more than one matches', async () => { 136 | const t = async () => { 137 | await CombineField.parse('1', { 138 | oneOf: [ 139 | BooleanField.bind(), 140 | StringField.bind(), 141 | NumberField.bind() 142 | ] 143 | },) 144 | } 145 | await expect(t).rejects.toThrow('Only one subschema is allowed to match, but 3 did'); 146 | }); 147 | }); 148 | 149 | 150 | 151 | describe('constructor init tests', () => { 152 | test('should throw error if neither arg set', async () => { 153 | const t = async () => { 154 | await CombineField.parse('1', {},) 155 | } 156 | await expect(t).rejects.toThrow('When using CombineField, must set `anyOf` or `oneOf`, but not both'); 157 | }); 158 | 159 | test('should throw error if both args set', async () => { 160 | const t = async () => { 161 | await CombineField.parse('1', { 162 | oneOf: [ 163 | BooleanField.bind(), 164 | StringField.bind() 165 | ], 166 | anyOf: [ 167 | BooleanField.bind(), 168 | StringField.bind() 169 | ] 170 | },) 171 | } 172 | await expect(t).rejects.toThrow('When using CombineField, must set `anyOf` or `oneOf`, but not both'); 173 | }); 174 | 175 | }); 176 | -------------------------------------------------------------------------------- /src/fields/combine-field.ts: -------------------------------------------------------------------------------- 1 | import { BaseField, BaseFieldOptions } from "./base-field"; 2 | import { DeferredField } from "../recursive"; 3 | import { ParseCombineReturnType } from "../types"; 4 | import { ParseError } from "../exceptions/parse-error"; 5 | import { ParseIssue } from "../exceptions/parse-issue"; 6 | 7 | 8 | export interface CombineOptions extends BaseFieldOptions { 9 | readonly oneOf?: unknown[]; 10 | readonly anyOf?: unknown[]; 11 | } 12 | 13 | export class CombineField extends BaseField { 14 | // @ts-ignore 15 | _options: T; 16 | 17 | constructor(options?: T) { 18 | super(options); 19 | 20 | const errorMsg = 'When using CombineField, must set `anyOf` or `oneOf`, but not both.'; 21 | 22 | if (!this._options.anyOf && !this._options.oneOf) { 23 | throw Error(errorMsg) 24 | } else if (this._options.anyOf && this._options.oneOf) { 25 | throw Error(errorMsg) 26 | } 27 | } 28 | 29 | protected validateNull(value: any) { 30 | return value; 31 | } 32 | 33 | protected validateUndefined(value: any) { 34 | return value; 35 | } 36 | 37 | public async parseValue(value: any): ParseCombineReturnType { 38 | const parsedItems: any[] = []; 39 | 40 | if (this._options.oneOf) { 41 | const fields = this.getSubSchemaFields(this._options.oneOf); 42 | return this.parseOneOf(fields, value) as any; 43 | } else if (this._options.anyOf) { 44 | const fields = this.getSubSchemaFields(this._options.anyOf); 45 | return this.parseAnyOf(fields, value) as any; 46 | } 47 | 48 | return parsedItems as any; 49 | } 50 | 51 | private getSubSchemaFields(fields: any[]): Array { 52 | return fields.map(x => { 53 | if (x instanceof DeferredField) { 54 | x = x.construct() 55 | } 56 | return x; 57 | }) 58 | } 59 | 60 | private async parseAnyOf(fields: Array, value: any) { 61 | for (const field of fields) { 62 | try { 63 | value = await field.parseValue(value); 64 | return value; 65 | } catch (e) { 66 | if (e instanceof ParseError) { 67 | // 68 | } else { 69 | throw e; 70 | } 71 | } 72 | } 73 | 74 | throw new ParseError([new ParseIssue('None of the subschemas matched.')]) 75 | } 76 | 77 | private async parseOneOf(fields: Array, value: any) { 78 | let matchedCount = 0; 79 | let parsedValue = null; 80 | 81 | for (const field of fields) { 82 | try { 83 | parsedValue = await field.parseValue(value); 84 | matchedCount += 1; 85 | } catch (e) { 86 | if (e instanceof ParseError) { 87 | // 88 | } else { 89 | throw e; 90 | } 91 | } 92 | } 93 | 94 | if (matchedCount === 1) { 95 | return parsedValue; 96 | } else if (matchedCount === 0) { 97 | throw new ParseError([new ParseIssue('None of the subschemas matched.')]) 98 | } else if (matchedCount > 1) { 99 | throw new ParseError([new ParseIssue(`Only one subschema is allowed to match, but ${matchedCount} did.`)]) 100 | } 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /src/fields/date-time-field.spec.ts: -------------------------------------------------------------------------------- 1 | import { DateTimeField } from "./date-time-field"; 2 | 3 | 4 | describe('test parse', () => { 5 | test('should parse string', async () => { 6 | const dtSchema = new DateTimeField(); 7 | const v = await dtSchema.parseValue('2000-01-01'); 8 | expect(v).toEqual(new Date('2000-01-01')) 9 | }); 10 | 11 | test('should pass through date', async () => { 12 | const dtSchema = new DateTimeField(); 13 | const dateInstance = new Date('2023-01-12T23:57:19Z'); 14 | const v = await dtSchema.parseValue(dateInstance); 15 | expect(v).toEqual(dateInstance) 16 | }); 17 | 18 | test('should raise for invalid string', async () => { 19 | const dtSchema = new DateTimeField(); 20 | await expect( 21 | async () => await dtSchema.parseValue('asdasdfasdfa') 22 | ).rejects.toThrow('Invalid date-time passed.'); 23 | }); 24 | 25 | test('should raise for date that is too old', async () => { 26 | const dtSchema = new DateTimeField({ minDate: new Date('2022-02-02') }); 27 | await expect( 28 | async () => await dtSchema.parseValue('2022-01-02') 29 | ).rejects.toThrow('Ensure the value is at least Tue Feb 01 2022'); 30 | }); 31 | 32 | test('should raise for date that is too new', async () => { 33 | const dtSchema = new DateTimeField({ maxDate: new Date('2022-03-02') }); 34 | await expect( 35 | async () => await dtSchema.parseValue('2022-03-03') 36 | ).rejects.toThrow('Ensure the value is no more than Tue Mar 01 2022'); 37 | }); 38 | }); 39 | 40 | describe('test format', () => { 41 | test('format string', async () => { 42 | const schema = new DateTimeField({}); 43 | const value = await schema.formatValue('2022-02-01'); 44 | expect(value).toEqual('2022-02-01'); 45 | }); 46 | 47 | test('format date', async () => { 48 | const schema = new DateTimeField({}); 49 | const value = await schema.formatValue(new Date('2021-03-03T04:45')); 50 | expect(value).toEqual('2021-03-03T10:45:00.000Z'); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/fields/date-time-field.ts: -------------------------------------------------------------------------------- 1 | import { BaseField, BaseFieldOptions } from "./base-field"; 2 | import { ParseReturnType } from "../types"; 3 | import { AfterParse } from "../decorators"; 4 | import { ParseError } from "../exceptions/parse-error"; 5 | 6 | export interface DateTimeFieldOptions extends BaseFieldOptions { 7 | maxDate?: Date | null; 8 | minDate?: Date | null; 9 | format?: '*'; 10 | } 11 | 12 | export class DateTimeField extends BaseField { 13 | // @ts-ignore 14 | _options: T; 15 | 16 | constructor(options?: T) { 17 | super(options); 18 | } 19 | 20 | public async parseValue(value: any): ParseReturnType { 21 | if (value instanceof Date) { 22 | return value as any; 23 | } 24 | 25 | 26 | if (typeof value !== 'string') { 27 | throw new ParseError('Invalid date-time passed.'); 28 | } 29 | 30 | const parsed = Date.parse(value); 31 | 32 | if (Number.isNaN(parsed)) { 33 | throw new ParseError('Invalid date-time passed.'); 34 | } 35 | 36 | return new Date(parsed) as any; 37 | } 38 | 39 | @AfterParse({ receieveNull: false }) 40 | public validateMaxDate(value: Date) { 41 | const maxDate = this._options.maxDate ?? null; 42 | 43 | if (maxDate !== null && value > maxDate) { 44 | throw new ParseError(`Ensure the value is no more than ${maxDate}.`) 45 | } 46 | 47 | return value; 48 | } 49 | 50 | @AfterParse({ receieveNull: false }) 51 | public validateMinDate(value: Date) { 52 | const minDate = this._options.minDate ?? null; 53 | 54 | if (minDate !== null && value < minDate) { 55 | throw new ParseError(`Ensure the value is at least ${minDate}.`) 56 | } 57 | 58 | return value; 59 | } 60 | 61 | public async formatValue(value: any): Promise { 62 | if (typeof value === 'string') { 63 | return value; 64 | } else if (value instanceof Date) { 65 | return value.toISOString(); 66 | } else { 67 | return null; 68 | } 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/fields/number-field.spec.ts: -------------------------------------------------------------------------------- 1 | import { ParseReturnType } from "src/types"; 2 | import { NumberField } from "./number-field"; 3 | import { BaseField, BaseFieldOptions } from "./base-field"; 4 | 5 | describe('test parse', () => { 6 | test('parse float string', async () => { 7 | const schema = new NumberField({}); 8 | const value = await schema.parseValue('3.4444'); 9 | expect(value).toEqual(3.4444); 10 | }); 11 | 12 | test('parse float', async () => { 13 | const schema = new NumberField({}); 14 | const value = await schema.parseValue(10.4444); 15 | expect(value).toEqual(10.4444); 16 | }); 17 | 18 | 19 | test('parse fail on uncastable value', async () => { 20 | const schema = new NumberField({}); 21 | await expect( 22 | async () => await schema.parseValue('lalala') 23 | ).rejects.toThrowError('Invalid number passed.') 24 | }); 25 | 26 | test('fail when exceed max value', async () => { 27 | const schema = new NumberField({ maxValue: 10 }); 28 | await expect( 29 | async () => await schema.parseValue('11') 30 | ).rejects.toThrowError('Ensure the value is no more than 10') 31 | }); 32 | 33 | test('fail when under min value', async () => { 34 | const schema = new NumberField({ minValue: 10 }); 35 | await expect( 36 | async () => await schema.parseValue('5') 37 | ).rejects.toThrowError('Ensure the value is at least 10.') 38 | }); 39 | 40 | }); 41 | 42 | 43 | describe('test format', () => { 44 | test('format string', async () => { 45 | const schema = new NumberField({}); 46 | const value = await schema.formatValue('3.4444'); 47 | expect(value).toEqual(3.4444); 48 | }); 49 | 50 | test('format NaN', async () => { 51 | const schema = new NumberField({}); 52 | const value = await schema.formatValue('adfadf3.4444'); 53 | expect(value).toEqual(null); 54 | }); 55 | }); 56 | 57 | 58 | describe('test custom field', () => { 59 | test('test request user field', async () => { 60 | class User { 61 | id = 3; 62 | } 63 | const request = { user: new User() }; 64 | 65 | class RequestUserField extends BaseField { 66 | _options: T; 67 | 68 | constructor(options?: T) { 69 | super(options); 70 | } 71 | 72 | public async parseValue(value: any): ParseReturnType { 73 | return this._context.request.user; 74 | } 75 | 76 | public async getDefaultParseValue() { 77 | return this._context.request.user; 78 | } 79 | } 80 | 81 | const value = await RequestUserField.parse(3, { context: { request: request } }); 82 | expect(value).toEqual(request.user) 83 | }); 84 | 85 | 86 | }); 87 | -------------------------------------------------------------------------------- /src/fields/number-field.ts: -------------------------------------------------------------------------------- 1 | import { BaseField, BaseFieldOptions } from "./base-field"; 2 | import { ParseReturnType } from "../types"; 3 | import { AfterParse } from "../decorators"; 4 | import { ParseError } from "../exceptions/parse-error"; 5 | 6 | export interface NumberOptions extends BaseFieldOptions { 7 | maxValue?: number; 8 | minValue?: number; 9 | } 10 | 11 | export class NumberField extends BaseField { 12 | // @ts-ignore 13 | _options: T; 14 | 15 | constructor(options?: T) { 16 | super(options); 17 | } 18 | 19 | public async parseValue(value: any): ParseReturnType { 20 | if (typeof value === 'string' && /^\d+$/.test(value)) { 21 | value = parseInt(value); 22 | } else if (typeof value === 'string' && /^[+-]?\d+(\.\d+)?$/.test(value)) { 23 | value = parseFloat(value); 24 | } else if (typeof value === 'number') { 25 | value = value; 26 | } else { 27 | throw new ParseError('Invalid number passed.'); 28 | } 29 | 30 | return value; 31 | } 32 | 33 | @AfterParse({ receieveNull: false }) 34 | public validateMaxValue(value: number) { 35 | const maxValue = this._options.maxValue ?? null; 36 | 37 | if (maxValue !== null && value > maxValue) { 38 | throw new ParseError(`Ensure the value is no more than ${maxValue}.`) 39 | } 40 | 41 | return value; 42 | } 43 | 44 | @AfterParse({ receieveNull: false }) 45 | public validateMinValue(value: any) { 46 | const minValue = this._options.minValue ?? null; 47 | 48 | if (minValue !== null && value < minValue) { 49 | throw new ParseError(`Ensure the value is at least ${minValue}.`) 50 | } 51 | 52 | return value; 53 | } 54 | 55 | public async formatValue(value: any) { 56 | const v = Number(value); 57 | 58 | if (Number.isNaN(v)) { 59 | return null as any; 60 | } 61 | 62 | return v; 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/fields/string-field.spec.ts: -------------------------------------------------------------------------------- 1 | import { StringField } from "./string-field"; 2 | 3 | describe('parse tests', () => { 4 | test('should allow blank', async () => { 5 | const schema = new StringField({ allowBlank: true }); 6 | const value = await schema.parseValue(''); 7 | expect(value).toEqual(''); 8 | }); 9 | 10 | test('should not allow blank', async () => { 11 | const schema = new StringField({ allowBlank: false }); 12 | await expect( 13 | async () => await schema.parseValue('') 14 | ).rejects.toThrowError('This field may not be blank') 15 | }); 16 | 17 | test('should trim', async () => { 18 | const schema = new StringField({ trimWhitespace: true }); 19 | const value = await schema.parseValue(' The dog went for a walk. '); 20 | expect(value).toEqual('The dog went for a walk.'); 21 | }); 22 | 23 | test('should not trim', async () => { 24 | const schema = new StringField({ trimWhitespace: false, allowNull: true }); 25 | const value = await schema.parseValue(' The dog went for a walk. '); 26 | expect(value).toEqual(' The dog went for a walk. '); 27 | }); 28 | 29 | test('should not allow null', async () => { 30 | const schema = new StringField({ allowNull: false }); 31 | await expect( 32 | async () => await schema.parseValue(null) 33 | ).rejects.toThrowError('This field may not be null') 34 | }); 35 | 36 | 37 | test('should allow null', async () => { 38 | const schema = new StringField({ allowNull: true }); 39 | const value = await schema.parseValue(null); 40 | expect(value).toEqual(null); 41 | }); 42 | 43 | test('should not allow more than X chars', async () => { 44 | const schema = new StringField({ maxLength: 10 }); 45 | await expect( 46 | async () => await schema.parseValue('AAAAAAAAAAAAAAAAAAAAAAAAA') 47 | ).rejects.toThrowError('Ensure this field has no more than 10 characters') 48 | }); 49 | 50 | test('should require at least X chars', async () => { 51 | const schema = new StringField({ minLength: 3 }); 52 | await expect( 53 | async () => await schema.parseValue('A') 54 | ).rejects.toThrowError('Ensure this field has at least 3 characters') 55 | }); 56 | }); 57 | 58 | describe('validate pattern tests', () => { 59 | test('should match', async () => { 60 | const schema = new StringField({ pattern: /^Hi/ }); 61 | const value = await schema.parseValue('Hi'); 62 | expect(value).toEqual('Hi'); 63 | }); 64 | 65 | test('should not match', async () => { 66 | const schema = new StringField({ pattern: /^Hi/ }); 67 | 68 | await expect( 69 | async () => await schema.parseValue('hello') 70 | ).rejects.toThrow('This value does not match the required pattern.'); 71 | }); 72 | }); 73 | 74 | 75 | describe('validate email format tests', () => { 76 | test('should fail for invalid email', async () => { 77 | const schema = new StringField({ format: 'email' }); 78 | await expect( 79 | async () => await schema.parseValue('tester.at.hotmail') 80 | ).rejects.toThrowError('Not a valid email address') 81 | }); 82 | 83 | test('should pass for valid email', async () => { 84 | const schema = new StringField({ format: 'email' }); 85 | const value = await schema.parseValue('tester@hotmail.com'); 86 | expect(value).toEqual('tester@hotmail.com'); 87 | }); 88 | }); 89 | 90 | 91 | describe('validate url format tests', () => { 92 | test('should match', async () => { 93 | let testValue = 'https://github.com/encode/django-rest-framework/blob/master/rest_framework/fields.py' 94 | const schema = new StringField({ format: 'url' }); 95 | const value = await schema.parseValue(testValue); 96 | expect(value).toEqual(testValue); 97 | }); 98 | 99 | test('should not match', async () => { 100 | const schema = new StringField({ format: 'url' }); 101 | await expect( 102 | async () => await schema.parseValue('hello') 103 | ).rejects.toThrow('This value is not a valid URL.'); 104 | }); 105 | }); 106 | 107 | 108 | describe('format tests', () => { 109 | test('should format as string', async () => { 110 | const schema = new StringField({ allowBlank: true }); 111 | const value = await schema.formatValue(1) 112 | expect(value).toEqual('1'); 113 | }); 114 | 115 | test('should format as string', async () => { 116 | const schema = new StringField({ allowBlank: true }); 117 | const value = await schema.formatValue({ 1: 2 }) 118 | expect(value).toEqual('[object Object]'); 119 | }); 120 | 121 | test('should format as string', async () => { 122 | const schema = new StringField({ allowBlank: true }); 123 | const value = await schema.formatValue(false) 124 | expect(value).toEqual('false'); 125 | }); 126 | }); 127 | 128 | 129 | -------------------------------------------------------------------------------- /src/fields/string-field.ts: -------------------------------------------------------------------------------- 1 | import { ParseError } from "../exceptions/parse-error"; 2 | import { BaseField, BaseFieldOptions } from "./base-field"; 3 | import { ParseReturnType } from "../types"; 4 | import { AfterParse } from "../decorators"; 5 | import { REGEX_PATTERNS } from "../regex"; 6 | 7 | 8 | export interface StringOptions extends BaseFieldOptions { 9 | allowBlank?: boolean; 10 | trimWhitespace?: boolean; 11 | maxLength?: number | null; 12 | minLength?: number | null; 13 | pattern?: RegExp, 14 | format?: 'email' | 'url' 15 | } 16 | 17 | export class StringField extends BaseField { 18 | // @ts-ignore 19 | public _options: StringOptions; 20 | 21 | constructor(options?: T) { 22 | super(options); 23 | } 24 | 25 | public async parseValue(value: any): ParseReturnType { 26 | const validTypes = ['number', 'string']; 27 | 28 | if (!validTypes.includes(typeof value)) { 29 | throw new ParseError('Not a valid string.'); 30 | } 31 | 32 | value = String(value) as string; 33 | const trimWhitespace = this._options.trimWhitespace ?? true; 34 | 35 | if (trimWhitespace) { 36 | value = value.trim(); 37 | } 38 | 39 | return value; 40 | } 41 | 42 | @AfterParse() 43 | public validateBlankness(value: string) { 44 | if (!this._options.allowBlank && value.length === 0) { 45 | throw new ParseError('This field may not be blank.'); 46 | } 47 | 48 | return value; 49 | } 50 | 51 | @AfterParse({ receieveNull: false }) 52 | public validateMinLength(value: string) { 53 | const minLen = this._options.minLength ?? null; 54 | 55 | if (minLen !== null && value.length < minLen) { 56 | throw new ParseError(`Ensure this field has at least ${minLen} characters.`) 57 | } 58 | 59 | return value; 60 | } 61 | 62 | @AfterParse({ receieveNull: false }) 63 | public validateMaxLength(value: string) { 64 | const maxLength = this._options.maxLength ?? null; 65 | 66 | if (maxLength !== null && value.length > maxLength) { 67 | throw new ParseError(`Ensure this field has no more than ${maxLength} characters.`) 68 | } 69 | 70 | return value; 71 | } 72 | 73 | @AfterParse({ receieveNull: false }) 74 | public validatePattern(value: string) { 75 | const pattern = this._options.pattern ?? null; 76 | 77 | if (pattern && !pattern.test(value)) { 78 | throw new ParseError('This value does not match the required pattern.'); 79 | } 80 | 81 | return value; 82 | } 83 | 84 | @AfterParse() 85 | public validateEmailFormat(value: string) { 86 | if (this._options.format === 'email' && !REGEX_PATTERNS.EMAIL.test(value)) { 87 | throw new ParseError('Not a valid email address.') 88 | } 89 | 90 | return value; 91 | } 92 | 93 | @AfterParse() 94 | public validateUrlFormat(value: string) { 95 | if (this._options.format === 'url' && !REGEX_PATTERNS.HTTP_URL.test(value)) { 96 | throw new ParseError('This value is not a valid URL.') 97 | } 98 | 99 | return value; 100 | } 101 | 102 | public async formatValue(value: any): Promise { 103 | return String(value); 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | describe('test', () => { 2 | test('add', async () => { 3 | expect(1 + 1).toEqual(2); 4 | }); 5 | }); -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Format, BeforeParse, AfterParse } from "./decorators"; 2 | export { DTObject } from "./dt-object"; 3 | export { BaseField, BaseFieldOptions } from "./fields/base-field"; 4 | export { ArrayField, ArrayOptions } from "./fields/array-field"; 5 | export { BooleanField } from "./fields/boolean-field"; 6 | export { DateTimeField, DateTimeFieldOptions } from "./fields/date-time-field"; 7 | export { StringField, StringOptions } from "./fields/string-field"; 8 | export { NumberField, NumberOptions } from "./fields/number-field"; 9 | export { CombineField, CombineOptions } from "./fields/combine-field"; 10 | export { Recursive } from "./recursive"; 11 | export { ParseError as ParseError } from "./exceptions/parse-error"; 12 | export { ParseIssue as ValidationIssue } from "./exceptions/parse-issue"; 13 | export { ParseArrayReturnType, ParseReturnType } from "./types"; 14 | -------------------------------------------------------------------------------- /src/recursive.ts: -------------------------------------------------------------------------------- 1 | import { BaseField, BaseFieldOptions } from "./fields/base-field"; 2 | 3 | 4 | export class DeferredField { 5 | private fieldClass: typeof BaseField 6 | private options: BaseFieldOptions; 7 | 8 | constructor(fieldClass: typeof BaseField, options: BaseFieldOptions) { 9 | this.fieldClass = fieldClass, 10 | this.options = options; 11 | } 12 | 13 | public construct(): InstanceType { 14 | return new this.fieldClass(this.options) as any; 15 | } 16 | 17 | } 18 | 19 | export function Recursive( 20 | field: T, 21 | options?: ConstructorParameters[0] 22 | ): InstanceType { 23 | return new DeferredField(field, options ?? {}) as any; 24 | } 25 | -------------------------------------------------------------------------------- /src/regex.ts: -------------------------------------------------------------------------------- 1 | export const REGEX_PATTERNS = { 2 | EMAIL: /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, 3 | HTTP_URL: /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/ 4 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ArrayOptions } from "./fields/array-field"; 2 | import { BaseField, BaseFieldOptions } from "./fields/base-field"; 3 | 4 | type InternalMethods = '_getParent' | 5 | '_asChild' | 6 | 'getDefaultParseValue' | 7 | '_getFieldName' | 8 | '_options' | 9 | '_getValueToFormat' | 10 | 'format' | 11 | 'parse'; 12 | 13 | type DeepPartial = T extends object ? { 14 | [P in keyof T]?: DeepPartial; 15 | } : T; 16 | 17 | type PartialPromise = Partial; 18 | 19 | 20 | export type ArrayElement = 21 | ArrayType extends readonly (infer ElementType)[] ? ElementType : never; 22 | 23 | 24 | export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never 25 | 26 | export type StaticBindReturnType< 27 | T extends typeof BaseField, O extends BaseFieldOptions 28 | > = O extends { items: any } ? ParseArrayReturnType : 29 | O extends { oneOf: any } ? ParseOneOfReturnType : 30 | O extends { anyOf: any } ? ParseAnyOfReturnType : 31 | ParseReturnType['parseValue']>, O>; 32 | 33 | 34 | export type ParseReturnType = 35 | Promise< 36 | ( 37 | O extends { allowNull: true } ? T | null : 38 | O extends { required: false, allowNull: true } ? T | null | undefined : 39 | O extends { required: false } ? T | undefined : T 40 | ) 41 | >; 42 | 43 | 44 | export type ParseArrayReturnType = ParseReturnType, T>; 45 | 46 | export type ParseOneOfReturnType< 47 | T extends BaseFieldOptions & { oneOf: unknown[] } 48 | > = ParseReturnType, T>; 49 | 50 | export type ParseAnyOfReturnType< 51 | T extends BaseFieldOptions & { anyOf: unknown[] } 52 | > = ParseReturnType, T>; 53 | 54 | 55 | export type ParseCombineReturnType = 56 | Promise : 57 | O extends { anyOf: any } ? ParseAnyOfReturnType : never>; 58 | 59 | export type ParseDtoReturnType = Promise>; 60 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | export function isNumeric(n): boolean { 3 | return !isNaN(parseFloat(n)) && isFinite(n); 4 | } 5 | 6 | export function getAllPropertyNames(obj): string[] { 7 | var allProps: any[] = []; 8 | var curr = obj; 9 | 10 | do { 11 | var props = Object.getOwnPropertyNames(curr); 12 | 13 | props.forEach(function (prop: any) { 14 | if (allProps.indexOf(prop) === -1) 15 | allProps.push(prop) 16 | }) 17 | } while (curr = Object.getPrototypeOf(curr)) 18 | 19 | return allProps 20 | } 21 | 22 | 23 | export function isKeyableObject(candidate: any): boolean { 24 | return ( 25 | typeof candidate === 'object' && 26 | !Array.isArray(candidate) && 27 | candidate !== null 28 | ) 29 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "declaration": true, 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "es2017", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "rootDir": "./src", 15 | "incremental": true, 16 | "skipLibCheck": true, 17 | "strictNullChecks": true, 18 | "strict": true, 19 | "strictPropertyInitialization": false, 20 | "noImplicitAny": false, 21 | "strictBindCallApply": false, 22 | "forceConsistentCasingInFileNames": false, 23 | "noFallthroughCasesInSwitch": false, 24 | "paths": { 25 | "*": [ 26 | "./*" 27 | ] 28 | } 29 | }, 30 | "include": [ 31 | "src/**/*.ts" 32 | ], 33 | } -------------------------------------------------------------------------------- /vs-code-demo-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsinger86/dto-classes/539001bacf907fb32956879adf681ef992b0dc02/vs-code-demo-1.gif --------------------------------------------------------------------------------