Adding API Features
Adding API Features
Step-by-step guide for adding new endpoints to the Toast API.
Quick Start
The code generator scaffolds the full structure:
pnpm td generate feature <name>For manual setup, follow the checklist below.
Checklist
When adding a new resource/endpoint:
- Database schema (if needed)
- Repository factory
- Service factory
- Schemas (Zod + OpenAPI)
- Controller factory
- Route factory
- Barrel exports and mount
- Tests (100% coverage required)
1. Database Schema
Edit packages/db/src/schema.ts:
export const myTable = pgTable(
'my_table',
{
id: uuid('id').defaultRandom().primaryKey(),
siteId: uuid('site_id')
.notNull()
.references(() => sites.id),
// ... fields
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index('idx_my_table_site_id').on(table.siteId)]
);Then generate and apply migration:
pnpm db:generate
pnpm db:migrateSee database.md for schema conventions and migration details.
2. Repository Layer
Create apps/api/src/repositories/{resource}.repository.ts:
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import { and, eq } from 'drizzle-orm';
import { myTable } from '@toast/db';
export type MyTableRow = typeof myTable.$inferSelect;
export type NewMyTableRow = typeof myTable.$inferInsert;
export function createMyResourceRepository(db: PostgresJsDatabase) {
return {
async findBySiteId(siteId: string): Promise<MyTableRow[]> {
return db.select().from(myTable).where(eq(myTable.siteId, siteId));
},
async findById(id: string, siteId: string): Promise<MyTableRow | null> {
const results = await db
.select()
.from(myTable)
.where(and(eq(myTable.id, id), eq(myTable.siteId, siteId)));
return results[0] ?? null;
},
async create(data: NewMyTableRow): Promise<MyTableRow> {
const results = await db.insert(myTable).values(data).returning();
return results[0]!;
},
};
}
export type MyResourceRepository = ReturnType<typeof createMyResourceRepository>;Key rules:
- Every query must filter by
siteIdfor multi-tenancy - Factory accepts a
PostgresJsDatabaseinstance (no globalgetDb()calls) - Export the
ReturnTypefor use in service dep interfaces
Add to apps/api/src/repositories/index.ts:
export { type MyResourceRepository, createMyResourceRepository } from './my-resource.repository.js';3. Service Layer
Create apps/api/src/services/{resource}.service.ts:
import type { MyResourceRepository } from '../repositories/my-resource.repository.js';
export interface MyResourceResponse {
id: string;
name: string;
createdAt: string; // ISO string
}
export interface MyResourceServiceDeps {
myResourceRepository: MyResourceRepository;
}
export function createMyResourceService(deps: MyResourceServiceDeps) {
const { myResourceRepository: repo } = deps;
function toResponse(row: MyTableRow): MyResourceResponse {
return {
id: row.id,
name: row.name,
createdAt: row.createdAt.toISOString(),
};
}
return {
async list(siteId: string): Promise<MyResourceResponse[]> {
const items = await repo.findBySiteId(siteId);
return items.map(toResponse);
},
async create(siteId: string, input: CreateInput): Promise<MyResourceResponse> {
const row = await repo.create({ ...input, siteId });
return toResponse(row);
},
};
}
export type MyResourceService = ReturnType<typeof createMyResourceService>;Key rules:
- Deps interface declares repository dependencies
toResponsetransforms database rows to API types (Date → ISO string, etc.)- All methods receive
siteIdfor tenant scoping - Export
ReturnTypefor use in controller dep interfaces
Add to apps/api/src/services/index.ts:
export { type MyResourceService, createMyResourceService } from './my-resource.service.js';4. Schemas
Create apps/api/src/routes/{resource}/schemas.ts:
import { z } from '@hono/zod-openapi';
export const CreateMyResourceSchema = z
.object({
name: z.string().min(1),
})
.openapi('CreateMyResource');
export const MyResourceSchema = z
.object({
id: z.string().uuid(),
name: z.string(),
createdAt: z.string().datetime(),
})
.openapi('MyResource');
// Shared error schemas
export const NotFoundErrorSchema = z.object({ error: z.string() }).openapi('NotFoundError');
// Inferred types for use in controllers
export type CreateMyResource = z.infer<typeof CreateMyResourceSchema>;
export type MyResource = z.infer<typeof MyResourceSchema>;5. Controller Factory
Create apps/api/src/routes/{resource}/{resource}.controller.ts:
import type { Context } from 'hono';
import { getAuthContext } from '../../middleware/index.js';
import type { MyResourceService } from '../../services/index.js';
import type { CreateMyResource } from './schemas.js';
/** Dependencies for the controller factory — use Pick for narrow interfaces */
export interface MyResourceControllerDeps {
myResourceService: Pick<MyResourceService, 'list' | 'create'>;
}
/**
* Create a controller with injected dependencies.
*/
export function createMyResourceController(deps: MyResourceControllerDeps) {
const { myResourceService: service } = deps;
return {
async list(c: Context) {
const { siteId } = getAuthContext(c);
const items = await service.list(siteId);
return c.json(items, 200);
},
async create(c: Context, body: CreateMyResource) {
const { user, siteId } = getAuthContext(c);
const created = await service.create(siteId, body);
return c.json(created, 201);
},
};
}
/** Inferred type for dependency injection */
export type MyResourceController = ReturnType<typeof createMyResourceController>;Key rules:
- Use
Pick<ServiceType, 'method1' | 'method2'>for narrow dep interfaces — only declare what you use - Extract
siteIdanduserfromgetAuthContext(c) - Controllers are thin: extract input, call service, format response
- Export
ReturnTypefor type inference
6. Route Factory
Create apps/api/src/routes/{resource}/routes.ts:
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import { requirePermission } from '../../middleware/require-permission.js';
import type { ApiEnv } from '../../types/hono.js';
import {
createMyResourceController,
type MyResourceControllerDeps,
} from './my-resource.controller.js';
import { CreateMyResourceSchema, MyResourceSchema, NotFoundErrorSchema } from './schemas.js';
/** Dependencies for the route factory */
export type MyResourceRoutesDeps = MyResourceControllerDeps;
const listRoute = createRoute({
method: 'get',
path: '/',
tags: ['MyResource'],
responses: {
200: {
description: 'List of resources',
content: { 'application/json': { schema: z.array(MyResourceSchema) } },
},
},
});
const createResourceRoute = createRoute({
method: 'post',
path: '/',
tags: ['MyResource'],
request: {
body: { content: { 'application/json': { schema: CreateMyResourceSchema } } },
},
responses: {
201: {
description: 'Created resource',
content: { 'application/json': { schema: MyResourceSchema } },
},
},
});
/**
* Create routes with injected dependencies.
*/
export function createMyResourceRoutes(deps: MyResourceRoutesDeps) {
// eslint-disable-next-line toast/require-route-permission -- applied below per-route
const routes = new OpenAPIHono<ApiEnv>();
const controller = createMyResourceController(deps);
routes.use('*', requirePermission({ myResource: ['read'] }));
routes.openapi(listRoute, async (c) => {
return await controller.list(c);
});
routes.openapi(createResourceRoute, async (c) => {
const body = c.req.valid('json');
return await controller.create(c, body);
});
return routes;
}Key rules:
- Route deps type aliases the controller deps (or extends it if routes need extra deps)
requirePermissionmiddleware applied to all routes (or per-route for mixed permissions)- Route handlers extract validated input via
c.req.valid('json')/c.req.valid('param')and pass to controller - ESLint guard
toast/require-route-permissionfires onnew OpenAPIHono()— place disable comment on that line
7. Barrel Exports and Mount
Barrel file
Create apps/api/src/routes/{resource}/index.ts:
export { createMyResourceRoutes, type MyResourceRoutesDeps, myResourceRoutes } from './routes.js';
export {
type MyResource,
MyResourceSchema,
type CreateMyResource,
CreateMyResourceSchema,
} from './schemas.js';Route aggregation
Add to apps/api/src/routes/index.ts:
export {
createMyResourceRoutes,
type MyResource,
type MyResourceRoutesDeps,
MyResourceSchema,
myResourceRoutes,
} from './my-resource/index.js';Mount in app
In apps/api/src/app.ts:
import { myResourceRoutes } from './routes/my-resource/index.js';
app.route('/api/my-resource', myResourceRoutes);8. Tests
New code must achieve 100% test coverage. Run pnpm test:coverage locally before pushing.
Factory tests (controller + routes)
Create apps/api/src/routes/{resource}/{resource}-factory.test.ts:
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock middleware to bypass auth in tests
const mockGetAuthContext = vi.hoisted(() =>
vi.fn().mockReturnValue({
user: { id: 'user-1', name: 'Test User' },
siteId: 'site-1',
})
);
const passthrough = vi.hoisted(() => async (_c: unknown, next: () => Promise<void>) => {
await next();
});
vi.mock('../../middleware/index.js', () => ({
getAuthContext: mockGetAuthContext,
requireAuth: () => passthrough,
requirePermission: () => passthrough,
}));
vi.mock('../../middleware/require-permission.js', () => ({
requirePermission: () => passthrough,
}));
import { createMyResourceController } from './my-resource.controller.js';
import { createMyResourceRoutes } from './routes.js';
beforeEach(() => {
vi.clearAllMocks();
});
function createMockDeps() {
return {
myResourceService: {
list: vi.fn().mockResolvedValue([{ id: 'r-1', name: 'Test' }]),
create: vi.fn().mockResolvedValue({ id: 'r-new', name: 'New' }),
},
};
}
describe('createMyResourceRoutes', () => {
it('wires GET / to list', async () => {
const deps = createMockDeps();
const routes = createMyResourceRoutes(deps);
const res = await routes.request('/');
expect(res.status).toBe(200);
expect(deps.myResourceService.list).toHaveBeenCalledWith('site-1');
});
it('wires POST / to create', async () => {
const deps = createMockDeps();
const routes = createMyResourceRoutes(deps);
const res = await routes.request(
new Request('http://localhost/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New' }),
})
);
expect(res.status).toBe(201);
expect(deps.myResourceService.create).toHaveBeenCalled();
});
});
describe('createMyResourceController', () => {
function createMockContext() {
return {
json: vi.fn((data: unknown, status: number) => {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
});
}),
header: vi.fn(),
};
}
it('list returns items from the injected service', async () => {
const deps = createMockDeps();
const controller = createMyResourceController(deps);
const c = createMockContext();
// biome-ignore lint/suspicious/noExplicitAny: test mock
const res = await controller.list(c as any);
expect(res.status).toBe(200);
expect(deps.myResourceService.list).toHaveBeenCalledWith('site-1');
});
});Test pattern summary:
- Mock middleware with
vi.hoisted()+vi.mock()for auth bypass - Create mock deps with
vi.fn()— only the methods declared inPick<> - Test route wiring: call
routes.request()and verify status + service calls - Test controller logic: call controller methods directly with mock context
- Cover error paths (404, 409, etc.), not just happy paths
Service factory tests
Create apps/api/src/services/{resource}.service.factory.test.ts:
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createMyResourceService } from './my-resource.service.js';
function createMockDeps() {
return {
myResourceRepository: {
findBySiteId: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({ id: 'r-1', name: 'Test', createdAt: new Date() }),
},
};
}
describe('createMyResourceService', () => {
beforeEach(() => vi.clearAllMocks());
it('list passes siteId to repository', async () => {
const deps = createMockDeps();
const service = createMyResourceService(deps);
await service.list('site-1');
expect(deps.myResourceRepository.findBySiteId).toHaveBeenCalledWith('site-1');
});
});See testing.md for more testing patterns.
Layer Responsibilities
| Layer | Location | Responsibility |
|---|---|---|
| Controller | routes/*/*.controller.ts | HTTP concerns: extract input, call service, format response |
| Service | services/*.service.ts | Business logic, domain rules, orchestration |
| Repository | repositories/*.repository.ts | Data access, query construction, siteId scoping |
| @toast/db | packages/db/ | Schema, migrations, connection management |
See ADR-004 for the full rationale.
Dependency Flow
Route factory
→ creates Controller factory (with service deps via Pick<>)
→ Controller calls Service methods (with siteId from auth context)
→ Service calls Repository methods (with siteId for tenant isolation)
→ Repository queries database via Drizzle ORMEach layer depends only on the interface of the layer below, never on concrete implementations. This enables isolated unit testing at every level.