Lakutata DTO and Validation
A DTO (data transfer object) is a class whose properties are declared with validation schemas. The framework uses DTOs to validate the options of objects (@Configurable), the arguments and results of methods (@Accept, @Return) and the input of controller actions. DTO is exported by lakutata and the decorators by lakutata/decorator/dto; the validation engine is the framework's own, with the API of joi (extended with bigints and the numeric types below).
1. Defining a DTO
import {DTO} from 'lakutata'
import {Expect} from 'lakutata/decorator/dto'
class AddressDTO extends DTO {
@Expect(DTO.String().required())
public city: string
@Expect(DTO.String().pattern(/^[0-9]{5}$/).optional())
public zip?: string
}
class CustomerDTO extends DTO {
@Expect(DTO.String().min(2).max(50).required())
public name: string
@Expect(DTO.String().email().required())
public email: string
@Expect(DTO.Number().integer().min(0).default(0))
public credit: number
//A nested DTO
@Expect(AddressDTO.required())
public address: AddressDTO
//An array of nested DTOs
@Expect(DTO.Array(AddressDTO.Schema()).optional())
public otherAddresses?: AddressDTO[]
}The schemas
The schemas are built by the static methods of DTO: DTO.String(), DTO.Number(), DTO.BigInt(), DTO.Boolean(), DTO.Date(), DTO.Binary(), DTO.Symbol(), DTO.Object(), DTO.Array(), DTO.Alternatives(), DTO.Any(), DTO.Function(), DTO.AsyncFunction(), DTO.Class(), DTO.InstanceOf(), DTO.Cron(), DTO.Glob(), DTO.Ref(), DTO.Link()… Their rules are those of joi (.required(), .optional(), .default(), .valid(), .min(), .max(), .pattern(), .email(), .uri()…).
- Strict by default: a value of the wrong type is rejected, not converted (the string
'5'is not the number 5). Call.strict(false)to convert, for example for HTTP query strings and route parameters, which are always strings. - Numeric types:
DTO.Number()provides the integer typesint8,uint8,int16,uint16,int32,uint32,int64anduint64(the 64-bit types limited to the safe integers of JavaScript), and the floating point typesfloat(32-bit range) anddouble. - Validation options: by default, validation stops at the first error (
abortEarly) and unknown keys are removed (stripUnknown).MyDTO.options({...})returns the schema of a DTO with other options. - A DTO class as a schema:
MyDTO.Schema()is its object schema,MyDTO.required()andMyDTO.optional()the same schema marked required or optional. A DTO class is accepted wherever a schema is expected (@Accept,@Return, the action decorators).
2. Validating
import {DTO} from 'lakutata'
import {Expect} from 'lakutata/decorator/dto'
class CustomerDTO extends DTO {
@Expect(DTO.String().min(2).required())
public name: string
@Expect(DTO.Number().integer().min(0).default(0))
public credit: number
}
//Validated when constructed: defaults are applied and the unknown key "note" is removed;
//invalid data throws an InvalidValueException
const customer: CustomerDTO = new CustomerDTO({name: 'Ada', note: 'vip'})
//A DTO instance validates each change of its properties: an invalid change throws and is undone
customer.credit = 10
//Validated without creating an instance: returns the validated data (a plain object) or throws an InvalidValueException
const received: any = JSON.parse('{"name": "Ada"}')
const data: CustomerDTO = CustomerDTO.validate(received)
const later: CustomerDTO = await CustomerDTO.validateAsync(received)
//Checked without throwing
const valid: boolean = CustomerDTO.isValid({name: ''})
//Any value against any schema
const port: number = DTO.validate(8080, DTO.Number().port().required())validate(), validateAsync(), isValid() and isValidAsync() take the value, then optionally a schema (the DTO's own by default) and the validation options.
3. The decorators (lakutata/decorator/dto)
| Decorator | Applies to | Effect |
|---|---|---|
@Expect(schema) | A property of a DTO | Declares the schema of the property |
@IndexSignature(schema) | A DTO class | Keeps the keys that have no @Expect schema, validating them against this schema, instead of removing them |
@Accept(...schemas) | A method | Validates the arguments of each call, by position; throws InvalidMethodAcceptException |
@Return(schema) | A method | Validates the result of each call (awaited for an async method); throws InvalidMethodReturnException |
@Accept and @Return pass the validated values on to the method (or to its caller), with defaults applied and unknown keys removed. When a DTO class validates an argument or a result, the value passed on is a plain object, not an instance of the class. A missing argument is accepted unless its schema is required (MyDTO.required()).
import {Component, DTO} from 'lakutata'
import {Accept, Expect, IndexSignature, Return} from 'lakutata/decorator/dto'
class TransferDTO extends DTO {
@Expect(DTO.String().required())
public to: string
@Expect(DTO.Number().positive().required())
public amount: number
}
class UserDTO extends DTO {
@Expect(DTO.Number().integer().required())
public id: number
@Expect(DTO.String().required())
public name: string
}
//A device and its numeric readings, named freely
@IndexSignature(DTO.Number())
class ReadingsDTO extends DTO {
[sensor: string]: any
@Expect(DTO.String().required())
public device: string
}
class Bank extends Component {
//The transfer is required, the note optional
@Accept(TransferDTO.required(), DTO.String().max(140).optional())
public async transfer(transfer: TransferDTO, note?: string): Promise<void> {
console.log(`${transfer.amount} to ${transfer.to}`, note)
}
//The password hash is removed from the result: UserDTO has no schema for it
@Return(UserDTO)
public async find(id: number): Promise<UserDTO> {
return {id: id, name: 'Ada', passwordHash: '…'} as UserDTO
}
}4. DTOs in the framework
- Object options:
@Configurable(schema)(fromlakutata/decorator/di) validates an option of a component or a provider; see Injection. - Controller actions: the DTO given to
@GET,@POST,@CLIAction,@ServiceAction… validates the input of the action; invalid input gets a 400 response. See Controllers. - Pagination (
lakutata/dtos):PaginationSearchDTOandPaginationResultDTOare the input and the result of paginated actions, andpaginatorcomputes the pages;FlexibleDTOis a DTO that accepts any key. - Validation messages: error messages can be translated;
registerValidationMessages,runInValidationLanguageandcurrentValidationLanguage(fromlakutata) manage their languages, and the language middleware of the I18n component sets the language of each request (see I18n).
5. JSON Schema and OpenAPI
DTOs and schemas convert to and from JSON Schema (drafts 04, 06, 07, 2019-09 and 2020-12) and OpenAPI 3.0/3.1 schema objects, to share validation rules with front ends, publish API documents, store schemas, or generate DTOs from OpenAPI documents.
5.1 Export
import {DTO} from 'lakutata'
import {Expect} from 'lakutata/decorator/dto'
import {toJsonSchema} from 'lakutata/dtos'
class UserDTO extends DTO {
@Expect(DTO.Number().integer().required())
public id: number
@Expect(DTO.String().required())
public name: string
}
UserDTO.toJsonSchema() //JSON Schema 2020-12 (default)
UserDTO.toJsonSchema('draft-07') //'draft-2020-12' | 'draft-2019-09' | 'draft-07' | 'draft-04'
UserDTO.toOpenAPIJsonSchema() //OpenAPI 3.1 (default), or '3.0'
toJsonSchema(DTO.String().email(), {target: 'openapi-3.0'}) //any schema, or a DTO class- The JSON Schema describes the values the schema accepts without conversion, and ajv (with ajv-formats) and other validators accept those values. What JSON Schema cannot express (custom rules, references to other values, conversions) is left out, so the JSON Schema may also accept values the schema rejects, never the reverse.
- Unknown keys follow the validation options: with the defaults of lakutata (
allowUnknown,stripUnknown), objects accept them; passpreferencesto convert a schema for other options. - The numeric types become integer bounds (
int8touint64) and float ranges; in OpenAPI they also get the formatsint32,int64,floatanddouble.
5.2 Import
import {readFileSync} from 'node:fs'
import {DTO, type JSONSchema} from 'lakutata'
import {fromJsonSchema} from 'lakutata/dtos'
const userJsonSchema: JSONSchema = {
type: 'object',
properties: {id: {type: 'integer'}, name: {type: 'string'}},
required: ['id']
}
//A schema
const schema = fromJsonSchema(userJsonSchema)
DTO.validate({id: 1}, schema)
//A DTO class, a subclass of the class on which fromJsonSchema() is called
const UserDTO = DTO.fromJsonSchema(userJsonSchema)
const user = new UserDTO({id: 1, name: 'Alice'})
//The DTO classes of the object schemas of an OpenAPI document (components.schemas), by name
const openApiDocument: Record<string, unknown> = JSON.parse(readFileSync('openapi.json', 'utf8'))
const {Pet, Person} = DTO.fromOpenAPI(openApiDocument)- Imported schemas validate as JSON Schema does: values are not converted (unless
convert: trueis given), unknown keys followadditionalProperties, formats are checked as ajv-formats checks them, and defaults are applied. - References are resolved by JSON pointer,
$anchorand$id(pass other documents inschemas, and an OpenAPI document indocument); recursive references are supported. - The version is read from
$schema, or given withdialect(default: 2020-12). - Converting a lakutata schema to JSON Schema loses information (custom rules, conversions): to store a schema and restore it exactly, keep its
describe()output (a JSON value unless it contains functions) and rebuild it withDTO.Build(description).