---
name: elysia-api-routes
description: Create and update QuickStack Elysia REST API routes using the project's established /api/v1 route conventions. Use when adding or editing files under src/server/api/v1, defining Elysia query/params/body/response schemas, or handling REST API authorization and errors.
---

# Elysia API Routes

## Quick Start

For QuickStack REST routes under `src/server/api/v1`, follow the current examples in `app/route.ts` and `project/route.ts`:

```ts
export const resourceRoutes = new Elysia()
    .derive(ApiUtils.deriveFunc)
    .get('/resources/:id', async ({ params, identity }) => {
        if (!identity) throw new ApiUnauthorizedException()

        const resource = await resourceService.getByIdOrUndefined(params.id);
        if (!resource) throw new ApiNotFoundException();

        ensureReadResource(identity, resource.id);

        return resource;
    }, {
        params: z.object({
            id: z.string(),
        }),
        response: ApiUtils.mapResponseModel(ResourceModel),
        detail: { summary: 'Get resource by id', security: [{ bearerAuth: [] }] }
    });
```

## Required Route Shape

- Start each route module with `new Elysia().derive(ApiUtils.deriveFunc)` so handlers receive `identity`.
- Import `ApiUtils` from `src/server/utils/api-response.utils`.
- Import `ApiUnauthorizedException`, `ApiNotFoundException`, and `ServiceException` from `src/shared/model/service.exception.model` as needed.
- Declare `query`, `params`, and `body` directly in route options with Zod schemas.
- Declare `response` with `ApiUtils.mapResponseModel(successSchema)`.
- Keep OpenAPI metadata in `detail`, with a short `summary` and `security: [{ bearerAuth: [] }]` for protected routes.

## Handler Rules

- If `identity` is missing, throw `new ApiUnauthorizedException()`.
- If a requested resource does not exist, throw `new ApiNotFoundException()`.
- Use shared authorization helpers such as `ensureReadApp`, `ensureWriteApp`, `ensureCreateAppInProject`, `ensureDeleteAppInProject`, `ensureReadProject`, and `ensureAdmin`.
- Let shared authorization helpers throw; do not duplicate permission checks inline except for simple admin/read filtering already established in list routes.
- Throw `ServiceException` for expected domain validation errors, such as immutable `projectId` violations.
- Return success payloads directly; do not wrap them in `{ data }`, `{ status }`, or error envelopes.
- Do not return `ApiUtils.problem(...)`, raw `Response`, or Elysia `status(...)` for expected route errors.

## Schema Rules

- Use inline Zod objects for simple route params and query inputs.
- Use existing write schemas, such as `AppExtendedWriteZodModel` or a local `projectWriteSchema`, for bodies.
- Do not parse `query`, `params`, or `body` inside the handler if the route option already declares the schema.
- Do not use nested `schema: { query, params, body }` in these route modules.
- For delete routes, return `undefined` and declare `response: ApiUtils.mapResponseModel(z.undefined())`.
- For deployment request routes, return `{ deploymentId }` and declare `response: ApiUtils.mapResponseModel(z.object({ deploymentId: z.string() }))`.

## Write Route Pattern

Use POST upsert semantics:

```ts
.post('/projects', async ({ body, identity }) => {
    if (!identity) throw new ApiUnauthorizedException()

    ensureAdmin(identity);

    let existing: Project | null = null;
    if (body.id) {
        existing = await projectService.getByIdOrUndefined(body.id);
        if (!existing) throw new ApiNotFoundException();
    }

    return projectService.save({ id: existing?.id, name: body.name });
}, {
    body: projectWriteSchema,
    response: ApiUtils.mapResponseModel(ProjectModel),
    detail: { summary: 'Create or update project', security: [{ bearerAuth: [] }] }
})
```

## Validation Checklist

- Run `yarn tsc --noEmit` after route changes.
- Check that every accepted input has a route-level Zod schema.
- Check that every route has `response: ApiUtils.mapResponseModel(...)`.
- Check that expected failures are thrown as exceptions; route mounting maps them centrally with `ApiUtils.mapError(...)`.
- Check `CONTEXT.md` for REST API domain terms and write semantics before changing behavior.
