AponiaJSDocs
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.

src/items/item.model.ts
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> {}
src/items/item.service.ts
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);
  }
}
src/items/item.controller.ts
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);
  }
}
src/items/item.module.ts
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.

On this page