Recipes
Validate a resource
Own one validation model per feature and derive both handler types and route validation from it.
Keep every validator for a feature in one file, and derive the handler types from the same declarations. A field is then declared once.
import { Validation, type InferValidatorOutput } from "@aponiajs/common";
import { z } from "zod";
const createItemSchema = z.object({
name: z.string().min(2),
quantity: z.number().int().positive(),
});
@Validation(createItemSchema)
export class CreateItem {}
export interface CreateItem extends InferValidatorOutput<typeof createItemSchema> {}
const itemParamsSchema = z.object({ id: z.string().min(1) });
@Validation(itemParamsSchema)
export class ItemParams {}
export interface ItemParams extends InferValidatorOutput<typeof itemParamsSchema> {}import { Injectable } from "@aponiajs/common";
import type { CreateItem } from "./item.model.ts";
export interface StoredItem extends CreateItem {
readonly id: string;
}
@Injectable()
export class ItemService {
readonly #items = new Map<string, StoredItem>();
create(item: CreateItem): StoredItem {
const created: StoredItem = { id: crypto.randomUUID(), ...item };
this.#items.set(created.id, created);
return created;
}
findOne(id: string): StoredItem | undefined {
return this.#items.get(id);
}
}import { Body, Controller, Get, Param, Post } from "@aponiajs/common";
import { CreateItem, ItemParams } from "./item.model.ts";
import { ItemService } from "./item.service.ts";
@Controller("items")
export class ItemController {
constructor(private readonly itemService: ItemService) {}
@Post("/", { body: CreateItem })
create(@Body() body: CreateItem) {
return this.itemService.create(body);
}
@Get(":id", { params: ItemParams })
findOne(@Param() params: ItemParams) {
return this.itemService.findOne(params.id);
}
}import { Module } from "@aponiajs/common";
import { ItemController } from "./item.controller.ts";
import { ItemService } from "./item.service.ts";
@Module({
controllers: [ItemController],
providers: [ItemService],
})
export class ItemModule {}Use Elysia's t instead
Elysia's builder ships with the platform peer dependency, so no extra install is
needed. It is also what aponia g resource <name> --type rest generates:
import { Validation, type InferValidatorOutput } from "@aponiajs/common";
import { t } from "elysia";
const createItemSchema = t.Object({
name: t.String({ minLength: 2 }),
quantity: t.Integer({ minimum: 1 }),
});
@Validation(createItemSchema)
export class CreateItem {}
export interface CreateItem extends InferValidatorOutput<typeof createItemSchema> {}t.Numeric() is the right choice for a query or path value that must arrive as
a number, because those parts are strings on the wire.
Test it
import { expect, test } from "bun:test";
import { AponiaFactory } from "@aponiajs/platform-elysia";
import { AppModule } from "../src/app.module.ts";
test("rejects an invalid item", async () => {
const application = await AponiaFactory.create(AppModule, { logger: false });
const rejected = await application.handle(
new Request("http://localhost/items", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "A", quantity: 0 }),
}),
);
expect(rejected.status).toBe(422);
});See route validation for the accepted validators and slots.