Add Router Skill
Create a new tRPC router with CRUD procedures, OpenAPI meta, and test file for x4-mono.
Arguments
The user describes the resource. If unclear, ask for:
- Resource name (singular, camelCase for router, e.g.,
comment) - Which procedures: list, get, create, update, delete (default: all)
- Auth level: public, protected, or admin
- Whether it needs ownership checks
File Locations
- Router:
apps/api/src/routers/{name}.ts - Registration:
apps/api/src/routers/index.ts - Tests:
apps/api/src/__tests__/{name}.test.ts - Schemas:
packages/shared/utils/validators.ts(if not already there)
Router Template
Reference: apps/api/src/routers/projects.ts
import { eq, sql } from 'drizzle-orm';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { Errors } from '../lib/errors';
import { tableName } from '@x4/database';
import {
CreateEntitySchema,
UpdateEntitySchema,
EntityResponseSchema,
EntityListResponseSchema,
IdParamSchema,
PaginationSchema,
} from '@x4/shared/utils';
export const entityRouter = router({
list: publicProcedure
.meta({ openapi: { method: 'GET', path: '/entities', tags: ['Entity'] } })
.input(PaginationSchema)
.output(EntityListResponseSchema)
.query(async ({ ctx, input }) => {
const items = await ctx.db.select().from(tableName).limit(input.limit).offset(input.offset);
return { items, total: items.length, limit: input.limit, offset: input.offset };
}),
get: publicProcedure
.meta({ openapi: { method: 'GET', path: '/entities/{id}', tags: ['Entity'] } })
.input(IdParamSchema)
.output(EntityResponseSchema)
.query(async ({ ctx, input }) => {
const [record] = await ctx.db.select().from(tableName).where(eq(tableName.id, input.id));
if (!record) throw Errors.notFound('Entity').toTRPCError();
return record;
}),
create: protectedProcedure
.meta({ openapi: { method: 'POST', path: '/entities', tags: ['Entity'], protect: true } })
.input(CreateEntitySchema)
.output(EntityResponseSchema)
.mutation(async ({ ctx, input }) => {
const [record] = await ctx.db
.insert(tableName)
.values({ ...input, ownerId: ctx.user.userId })
.returning();
return record;
}),
update: protectedProcedure
.meta({ openapi: { method: 'PATCH', path: '/entities/{id}', tags: ['Entity'], protect: true } })
.input(UpdateEntitySchema)
.output(EntityResponseSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const [existing] = await ctx.db.select().from(tableName).where(eq(tableName.id, id));
if (!existing) throw Errors.notFound('Entity').toTRPCError();
if (existing.ownerId !== ctx.user.userId && ctx.user.role !== 'admin') {
throw Errors.forbidden('Not the owner').toTRPCError();
}
const [updated] = await ctx.db
.update(tableName)
.set(data)
.where(eq(tableName.id, id))
.returning();
return updated;
}),
delete: protectedProcedure
.meta({
openapi: { method: 'DELETE', path: '/entities/{id}', tags: ['Entity'], protect: true },
})
.input(IdParamSchema)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const [existing] = await ctx.db.select().from(tableName).where(eq(tableName.id, input.id));
if (!existing) throw Errors.notFound('Entity').toTRPCError();
if (existing.ownerId !== ctx.user.userId && ctx.user.role !== 'admin') {
throw Errors.forbidden('Not the owner').toTRPCError();
}
await ctx.db.delete(tableName).where(eq(tableName.id, input.id));
return { success: true };
}),
});
Registration
Add to apps/api/src/routers/index.ts:
import { entityRouter } from './entity';
export const appRouter = router({
// ... existing routers
entity: entityRouter,
});
Workflow
- Ensure Zod schemas exist (use
/add-schemafirst if needed) - Ensure database table exists (use
/add-tablefirst if needed) - Create router file at
apps/api/src/routers/{name}.ts - Register in
apps/api/src/routers/index.ts - Run
bun turbo type-checkto verify AppRouter updates - Create test file at
apps/api/src/__tests__/{name}.test.ts(use/add-testor bun-test-gen) - Run
bun test --cwd apps/apito verify