UNPKG

@adocasts.com/dto

Version:

Easily make and generate DTOs from Lucid Models

592 lines (458 loc) 17.5 kB
# @adocasts.com/dto > Easily make and generate DTOs and validators from Lucid Models [![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] ![][typescript-image] [![license-image]][license-url] Converting Lucid Models to DTO files can be a tedious task. This package aims to make it a little less so by reading your model's property definitions and porting them to a DTO. Will it be perfect? Likely not, but it should help cut back on the repetition needed to complete the task. ## Installation You can easily install and configure via the Ace CLI's `add` command. ```shell node ace add @adocasts.com/dto ``` ##### Manual Install & Configure You can also manually install and configure if you'd prefer ```shell npm install @adocasts.com/dto ``` ```shell node ace configure @adocasts.com/dto ``` ##### Define DTO Import Path The generated DTOs will use `#dtos/*` for relationship imports within the DTOs. As such, we recommend defining this import path within your `package.json` ```json "imports": { "#dtos/*": "./app/dtos/*.js" } ``` ## Generate DTOs Command Want to generate DTOs for all your models in one fell swoop? This is the command for you! ```shell node ace generate:dtos ``` This will read all of your model files, collecting their properties and types. It'll then convert those property's types into serialization-safe types and relationships into their DTO representations. You can also generate validators alongside DTOs by using the `--validator` flag: ```shell node ace generate:dtos --validator ``` ``` File Tree Class ------------------------------------------------ └── app/ ├── dtos/ │ ├── account.ts AccountDto │ ├── account_group.ts AccountGroupDto │ ├── account_type.ts AccountTypeDto │ ├── income.ts IncomeDto │ ├── payee.ts PayeeDto │ └── user.ts UserDto └── models/ ├── account.ts Account ├── account_group.ts AccountGroup ├── account_type.ts AccountType ├── income.ts Income ├── payee.ts Payee └── user.ts User ``` - Gets a list of your model files from the location defined within your `adonisrc.ts` file - Reads those files as plaintext, filering down to just property definitions - Determines the property name, it's types, whether it's a relationship, and if it's optionally modified `?` - Converts those model types into serialized representations (currently a very loose conversion) - Note, at present, this does not account for serialization behaviors defined on the model property (like `serializeAs`) - Creates DTO property definitions from those conversions - Prepares constructor value setters for each property - Collects needed imports for relationships - Generates the DTO file - Note, if a file already exists at the DTOs determined location it will be skipped ## Make DTO Command Want to make a plain DTO file, or a single DTO from a single Model? This is the command for you! To make a DTO named `AccountDto` within a file located at `dto/account.ts`, we can run the following: ```shell node ace make:dto account ``` This will check to see if there is a model named `Account`. If a model is found, it will use that model's property definitions to generate the `AccountDto`. Otherwise, it'll generate just a `AccountDto` file with an empty class inside it. You can also generate a validator alongside the DTO by using the `--validator` flag: ```shell node ace make:dto account --validator ``` This will create both a DTO and a validator for the Account model. ``` File Tree Class ------------------------------------------------ └── app/ ├── dtos/ │ ├── account.ts AccountDto └── models/ ├── account.ts Account ``` ### What If There Isn't An Account Model? As mentioned above, a plain `AccountDto` class will be generated within a new `dto/account.ts` file, which will look like the below. ```ts export default class AccountDto {} ``` #### Specifying A Different Model If the DTO and Model names don't match, you can specify a specific Model to use via the `--model` flag. ```shell node ace make:dto account --model=main_account ``` Now instead of looking for a model named `Account` it'll instead look for `MainAccount` and use it to create a DTO named `AccountDto`. ## Make Validator Command Want to make a validator for a model? This command works similarly to the `make:dto` command: ```shell node ace make:validators account ``` This will check to see if there is a model named `Account`. If a model is found, it will use that model's property definitions to generate the `accountValidator`. Otherwise, it'll generate just a plain validator file with an empty schema. ``` File Tree Variable ------------------------------------------------ └── app/ ├── validators/ │ ├── account.ts accountValidator └── models/ ├── account.ts Account ``` #### Specifying A Different Model Just like with DTOs, you can specify a different model: ```shell node ace make:validators account --model=main_account ``` ## Generate Validators Command Want to generate validators for all your models in one fell swoop? This command works similarly to `generate:dtos`: ```shell node ace generate:validators ``` This will read all of your model files, collecting their properties and types. It'll then convert those property's types into VineJS validator rules. ``` File Tree Variable ------------------------------------------------ └── app/ ├── validators/ │ ├── account.ts accountValidator │ ├── account_group.ts accountGroupValidator │ ├── account_type.ts accountTypeValidator │ ├── income.ts incomeValidator │ ├── payee.ts payeeValidator │ └── user.ts userValidator └── models/ ├── account.ts Account ├── account_group.ts AccountGroup ├── account_type.ts AccountType ├── income.ts Income ├── payee.ts Payee └── user.ts User ``` ## BaseDto Helpers Newly added in v0.0.4, we now include either a `BaseDto` or `BaseModelDto` depeneding on whether we're generating your DTO from a model or not. Both of these bases include a helper called `fromArray`. With this, you can pass in an array of source objects. We'll then loop over them and pass each into a new constructor. This does run with the assumption that you'll populate properties within your DTO constructors. Here's a quick example ```ts class Test extends BaseModel { @column() declare id: number } class TestDto extends BaseModelDto { declare id: number constructor(instance: Test) { super() this.id = instance.id } } const tests = await Test.createMany([{ id: 1 }, { id: 2 }, { id: 3 }]) const dtoArray = TestDto.fromArray(tests) // [TestDto, TestDto, TestDto] ``` Additionally, `BaseModelDto` also includes a `fromPaginator` helper. This allows you to pass in an instance of the `ModelPaginator` to be converted into a `SimplePaginatorDto` we have defined within this package. You can also pass in a URL range start and end and we'll generate those URLs for you during the conversion. Here's a simple example ```ts class Test extends BaseModel { @column() declare id: number } class TestDto extends BaseModelDto { declare id: number constructor(instance: Test) { super() this.id = instance.id } } const tests = await Test.createMany([{ id: 1 }, { id: 2 }, { id: 3 }]) const paginator = await Test.query().paginate(1, 2) const paginatorDto = TestDto.fromPaginator(paginator, { start: 1, end: 2 }) /** * { * data: TestDto[], * meta: SimplePaginatorDtoMetaContract * } */ const paginationUrls = paginatorDto.meta.pagesInRange /** * [ * { * url: '/?page=1', * page: 1, * isActive: true * }, * { * url: '/?page=2', * page: 2, * isActive: false * }, * ] */ ``` If, for example, you're using something like Inertia, you can then type your props accordingly ```ts import { SimplePaginatorDtoContract } from '@adocasts.com/dto/types' import DifficultyDto from '#dtos/difficulty' const props = defineProps<{ paginated: SimplePaginatorDtoContract<DifficultyDto> }>() const rows = props.paginated.data const info = props.paginated.meta const urls = props.paginated.meta.pagesInRange ``` ## Things To Note - At present we assume the Model's name from the file name of the model. - There is NOT currently a setting to change the output directory of the DTOs - Due to reflection limitations, we're reading Models as plaintext. I'm no TypeScript wiz, so if you know of a better approach, I'm all ears! - Since we're reading as plaintext - Currently we're omitting decorators and their options ## Example So, we've use account as our example throughout this guide, so let's end by taking a look at what this Account Model looks like! ##### The Account Model ```ts // app/models/account.ts import { DateTime } from 'luxon' import { BaseModel, belongsTo, column, computed, hasMany, hasOne } from '@adonisjs/lucid/orm' import User from './user.js' import type { BelongsTo, HasMany, HasOne } from '@adonisjs/lucid/types/relations' import AccountType from '#models/account_type' import Payee from '#models/payee' import Stock from '#models/stock' import Transaction from '#models/transaction' import AccountTypeService from '#services/account_type_service' import { columnCurrency } from '#start/orm/column' import type { AccountGroupConfig } from '#config/account' export default class Account extends BaseModel { // region Columns @column({ isPrimary: true }) declare id: number @column() declare userId: number @column() declare accountTypeId: number @column() declare name: string @column() declare note: string @column.date() declare dateOpened: DateTime | null @column.date() declare dateClosed: DateTime | null @columnCurrency() declare balance: number @columnCurrency() declare startingBalance: number @column.dateTime({ autoCreate: true }) declare createdAt: DateTime @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updatedAt: DateTime // endregion // region Unmapped Properties aggregations: Record<string, number> = {} // endregion // region Relationships @belongsTo(() => User) declare user: BelongsTo<typeof User> @belongsTo(() => AccountType) declare accountType: BelongsTo<typeof AccountType> @hasOne(() => Payee) declare payee: HasOne<typeof Payee> @hasMany(() => Stock) declare stocks: HasMany<typeof Stock> @hasMany(() => Transaction) declare transactions: HasMany<typeof Transaction> // endregion // region Computed Properties @computed() get accountGroup(): AccountGroupConfig { return AccountTypeService.getAccountTypeGroup(this.accountTypeId) } @computed() get isCreditIncrease(): boolean { return AccountTypeService.isCreditIncreaseById(this.accountTypeId) } @computed() get isBudgetable() { return AccountTypeService.isBudgetable(this.accountTypeId) } @computed() get balanceDisplay() { return '$' + this.balance.toLocaleString('en-US') } // endregion } ``` It's got - Column properties - Nullable properties - An unmapped property, which also contains a default value - Getters - Relationships Let's see what we get when we generate our DTO! ```shell node ace make:dto account ``` ##### The Account DTO ```ts import { BaseModelDto } from '@adocasts.com/dto/base' import Account from '#models/account' import UserDto from '#dtos/user' import AccountTypeDto from '#dtos/account_type' import PayeeDto from '#dtos/payee' import StockDto from '#dtos/stock' import TransactionDto from '#dtos/transaction' import { AccountGroupConfig } from '#config/account' export default class AccountDto extends BaseModelDto { declare id: number declare userId: number declare accountTypeId: number declare name: string declare note: string declare dateOpened: string | null declare dateClosed: string | null declare balance: number declare startingBalance: number declare createdAt: string declare updatedAt: string aggregations: Record<string, number> = {} declare user: UserDto | null declare accountType: AccountTypeDto | null declare payee: PayeeDto | null declare stocks: StockDto[] declare transactions: TransactionDto[] declare accountGroup: AccountGroupConfig declare isCreditIncrease: boolean declare isBudgetable: boolean declare balanceDisplay: string constructor(account?: Account) { super() if (!account) return this.id = account.id this.userId = account.userId this.accountTypeId = account.accountTypeId this.name = account.name this.note = account.note this.dateOpened = account.dateOpened?.toISO()! this.dateClosed = account.dateClosed?.toISO()! this.balance = account.balance this.startingBalance = account.startingBalance this.createdAt = account.createdAt.toISO()! this.updatedAt = account.updatedAt.toISO()! this.aggregations = account.aggregations this.user = account.user && new UserDto(account.user) this.accountType = account.accountType && new AccountTypeDto(account.accountType) this.payee = account.payee && new PayeeDto(account.payee) this.stocks = StockDto.fromArray(account.stocks) this.transactions = TransactionDto.fromArray(account.transactions) this.accountGroup = account.accountGroup this.isCreditIncrease = account.isCreditIncrease this.isBudgetable = account.isBudgetable this.balanceDisplay = account.balanceDisplay } } ``` It's got the - Needed imports (it'll try to get them all by also referencing the Model's imports) - Column properties from our Model - Nullable property's nullability - Unmapped property from our Model, plus it's default value - Relationships converted into DTO representations - Getters and their types, when specified. If types are inferred, the type will default to string or boolean if variable name starts with `is` - Constructor value setters for all of the above - A helper method `fromArray` that'll normalize to an empty array if need be ## Example Validator Let's see what we get when we generate a validator for our Account model: ```shell node ace make:validator account ``` ##### The Account Validator ```ts import vine from '@vinejs/vine' import Account from '#models/account' import { AccountGroupConfig } from '#config/account' export const accountValidator = vine.compile( vine.object({ id: vine.number(), userId: vine.number(), accountTypeId: vine.number(), name: vine.string().trim(), note: vine.string().trim(), dateOpened: vine.string().datetime().optional(), dateClosed: vine.string().datetime().optional(), balance: vine.number(), startingBalance: vine.number(), createdAt: vine.string().datetime(), updatedAt: vine.string().datetime(), user: vine.object({}), accountType: vine.object({}), payee: vine.object({}), stocks: vine.array(vine.object({})), transactions: vine.array(vine.object({})), accountGroup: vine.object({}), isCreditIncrease: vine.boolean(), isBudgetable: vine.boolean(), balanceDisplay: vine.string().trim() }) ) ``` It's got: - Needed imports from the model - Validation rules for all model properties - Appropriate type conversions (e.g., DateTime to string().datetime()) - Optional modifiers for nullable properties - Object and array validators for relationships ## Using Generated Validators Once you've generated a validator, you can use it in your controllers or routes to validate incoming data: ```typescript import { accountValidator } from '#validators/account' import { HttpContext } from '@adonisjs/core/http' export default class AccountsController { async store({ request, response }: HttpContext) { try { // Validate the request data const data = await accountValidator.validate(request.all()) // Create the account const account = await Account.create(data) // Return the account as a DTO return response.created(new AccountDto(account)) } catch (error) { // Handle validation errors if (error.code === 'E_VALIDATION_ERROR') { return response.unprocessableEntity(error.messages) } throw error } } } ``` [gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/adocasts/package-dto/test.yml?style=for-the-badge [gh-workflow-url]: https://github.com/adocasts/package-dto/actions/workflows/test.yml 'Github action' [npm-image]: https://img.shields.io/npm/v/@adocasts.com/dto/latest.svg?style=for-the-badge&logo=npm [npm-url]: https://www.npmjs.com/package/@adocasts.com/dto/v/latest 'npm' [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript [license-url]: LICENSE.md [license-image]: https://img.shields.io/github/license/adocasts/package-dto?style=for-the-badge