mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
The formatter had never been run over these files, so `oxfmt` on a couple of touched files rewrote two dozen others and buried the real change. Running it everywhere once makes the next diff mean something. No behaviour changes: import ordering, line joining, and reindented JSON in the generated migration snapshots. Both test suites and both typechecks give the same answers as before, including the two type errors this does not fix.
284 lines
9.9 KiB
Markdown
284 lines
9.9 KiB
Markdown
# `apps/api` — routes, errors and the entry point
|
|
|
|
## API Route Pattern (`apps/api/app/routes/`)
|
|
|
|
Every API domain is a TypeScript `namespace` with a `.route` property — a plain `new Hono()` instance with chained route definitions.
|
|
|
|
### Route → Domain function flow
|
|
|
|
```
|
|
HTTP request ──► Route handler (thin) ──► Core domain fn() ──► DB
|
|
│ │
|
|
│ validates input │ pulls Actor.userID
|
|
│ calls domain fn │ handles business logic
|
|
│ returns c.json({data}) │ inside Database.transaction()
|
|
```
|
|
|
|
Route handlers are **thin wrappers** — they validate input, call a core function, and return the result. All business logic lives in `packages/core/src/<module>/`.
|
|
|
|
### Creating a route module
|
|
|
|
```ts
|
|
// app/routes/<thing>.ts
|
|
import { z } from 'zod';
|
|
import { Hono } from 'hono';
|
|
import { describeRoute } from 'hono-openapi';
|
|
import { Thing } from '@nestri/core/thing/index';
|
|
import { Examples } from '@nestri/core/examples';
|
|
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
|
import { ErrorResponses, notPublic, Result, validator } from '../utils';
|
|
|
|
export namespace ThingApi {
|
|
export const route = new Hono()
|
|
.use(notPublic)
|
|
.get(
|
|
'/',
|
|
describeRoute({
|
|
tags: ['Thing'],
|
|
summary: 'List things',
|
|
description: 'List all things',
|
|
responses: {
|
|
200: {
|
|
content: {
|
|
'application/json': {
|
|
schema: Result(
|
|
Thing.Info.array().meta({
|
|
description: 'All things',
|
|
example: [Examples.Thing]
|
|
})
|
|
)
|
|
}
|
|
},
|
|
description: 'All things'
|
|
},
|
|
400: ErrorResponses[400],
|
|
404: ErrorResponses[404],
|
|
429: ErrorResponses[429]
|
|
}
|
|
}),
|
|
async (c) => c.json({ data: await Thing.list() })
|
|
)
|
|
.get(
|
|
'/:id',
|
|
describeRoute({/* … */}),
|
|
validator(
|
|
'param',
|
|
z.object({
|
|
id: z.string().meta({
|
|
description: 'ID of the thing',
|
|
example: Examples.Thing.id
|
|
})
|
|
})
|
|
),
|
|
async (c) => {
|
|
const thing = await Thing.fromID(c.req.valid('param').id);
|
|
if (!thing) {
|
|
throw new VisibleError(
|
|
'not_found',
|
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
|
`Thing ${id} not found`
|
|
);
|
|
}
|
|
return c.json({ data: thing });
|
|
}
|
|
);
|
|
}
|
|
```
|
|
|
|
### Grouped routes: `/(group)/(sub-route)`
|
|
|
|
For domains with multiple sub-routes (e.g. Steam with `/link`, `/sync`, `/unlink`), group them under one route file. The namespace name is `XxxApi` (e.g. `SteamApi`), the route path is `/(group)`:
|
|
|
|
```ts
|
|
// app/routes/steam.ts
|
|
import { z } from "zod";
|
|
import { Hono } from "hono";
|
|
import { describeRoute } from "hono-openapi";
|
|
import { Steam } from "@nestri/core/steam/index";
|
|
import { ErrorResponses, notPublic, Result, validator } from "../utils";
|
|
|
|
export namespace SteamApi {
|
|
export const route = new Hono()
|
|
.use(notPublic)
|
|
.post("/link", // → POST /steam/link
|
|
describeRoute({ tags: ["Steam"], summary: "Link a Steam account", ... }),
|
|
validator("json", z.object({ steamId: z.string() })),
|
|
async (c) => {
|
|
const { steamId } = c.req.valid("json");
|
|
const result = await Steam.link({ steamId }); // ← calls core fn
|
|
return c.json({ data: { linkedAccountId: result, steamId } });
|
|
},
|
|
)
|
|
.post("/sync", // → POST /steam/sync
|
|
// ...
|
|
);
|
|
}
|
|
```
|
|
|
|
Registered in the app entry as `/steam`:
|
|
|
|
```ts
|
|
// app/index.ts
|
|
import { SteamApi } from "./routes/steam.js";
|
|
|
|
const routes = app
|
|
.route("/", IndexApi.route)
|
|
.route("/users", UserApi.route)
|
|
.route("/steam", SteamApi.route) // mounts all /steam/* routes
|
|
.onError(…);
|
|
```
|
|
|
|
This keeps the route path and the namespace name aligned — the Hono instance at `SteamApi.route` is mounted at `/steam`.
|
|
|
|
### Key conventions
|
|
|
|
| Element | Pattern |
|
|
| ---------------- | --------------------------------------------------------------------------------- |
|
|
| Structure | `export namespace XxxApi { export const route = new Hono() … }` |
|
|
| Group route | `POST "/link"` at `XxxApi` → mounted at `/xxx` → `POST /xxx/link` |
|
|
| Auth guard | `.use(notPublic)` at the namespace level (or per-route) |
|
|
| Route is thin | validates input → calls core fn → returns `c.json({ data: … })` |
|
|
| Core fn | reusable `fn()` in `packages/core/src/<module>/` owns all logic |
|
|
| OpenAPI | `describeRoute({ tags, summary, description, responses })` wraps each handler |
|
|
| Response schema | `Result(Schema)` → `resolver(z.object({ data: schema }))` |
|
|
| Error responses | `ErrorResponses[statusCode]` for 400, 401, 403, 404, 409, 429, 500 |
|
|
| Param validation | `validator("param", z.object({…}))` — uses custom wrapper that formats Zod errors |
|
|
| Body validation | `validator("json", z.object({…}))` — same wrapper for request body |
|
|
| Not found | `throw new VisibleError("not_found", ErrorCodes.NotFound.RESOURCE_NOT_FOUND, …)` |
|
|
| Metadata | Use `.meta()` (NOT `.openapi()`) — Zod v4 native + `zod-openapi` v6 |
|
|
|
|
### Registering a route in the app
|
|
|
|
```ts
|
|
// app/index.ts
|
|
import { SteamApi } from "./routes/steam.js";
|
|
import { ThingApi } from "./routes/thing.js";
|
|
|
|
const routes = app
|
|
.route("/", IndexApi.route)
|
|
.route("/users", UserApi.route)
|
|
.route("/steam", SteamApi.route) // mount group at /steam
|
|
.route("/things", ThingApi.route) // mount group at /things
|
|
.onError(…);
|
|
```
|
|
|
|
The first argument to `.route()` is the URL prefix. All sub-routes defined on that Hono instance are relative to this prefix.
|
|
|
|
---
|
|
|
|
## API Utils (`apps/api/app/utils/`)
|
|
|
|
| File | Export | Purpose |
|
|
| -------------- | -------------------- | ---------------------------------------------------------------------------------------------- |
|
|
| `index.ts` | — | Barrel re-export of all utils |
|
|
| `auth.ts` | `auth`, `notPublic` | Re-exports from `middleware/auth` |
|
|
| `error.ts` | `ErrorResponses` | `{ 400, 401, 403, 404, 409, 429, 500 }` → OpenAPI response objects |
|
|
| `result.ts` | `Result<T>` | `resolver(z.object({ data: T }))` — standard `{ data: … }` response shape |
|
|
| `validator.ts` | `validator` | Wraps `hono-openapi/zod`'s validator with standardized Zod error formatting (400 + error code) |
|
|
| `hook.ts` | `Hook`, `zValidator` | Type declarations (re-exported from `@hono/zod-validator`) |
|
|
|
|
---
|
|
|
|
## Main API Entry (`apps/api/app/index.ts`)
|
|
|
|
The entry point wires everything together. Key structure:
|
|
|
|
```ts
|
|
import 'zod-openapi'; // augment Zod v4 with OpenAPI metadata types
|
|
import { Hono } from 'hono';
|
|
import { logger } from 'hono/logger';
|
|
import { cors } from 'hono/cors';
|
|
import { showRoutes } from 'hono/dev';
|
|
import { openAPISpecs } from 'hono-openapi';
|
|
import { HTTPException } from 'hono/http-exception';
|
|
|
|
export const app = new Hono();
|
|
|
|
// Global middleware (order matters)
|
|
app
|
|
.use(logger())
|
|
.use(async (c, next) => {
|
|
c.header('Cache-Control', 'no-store');
|
|
return next();
|
|
})
|
|
.use(cors({ origin: Env.env.FRONTEND_URL || 'http://localhost:5173', credentials: true }))
|
|
.use(auth);
|
|
|
|
// Routes + error handler
|
|
const routes = app
|
|
.route('/', IndexApi.route)
|
|
.route('/things', ThingApi.route)
|
|
.onError((error, c) => {
|
|
if (error instanceof VisibleError) {
|
|
return c.json(error.toResponse(), error.statusCode());
|
|
}
|
|
if (error instanceof HTTPException) {
|
|
return c.json(
|
|
{
|
|
type: 'validation',
|
|
code: ErrorCodes.Validation.INVALID_PARAMETER,
|
|
message: 'Invalid request'
|
|
},
|
|
error.status
|
|
);
|
|
}
|
|
return c.json(
|
|
{
|
|
type: 'internal',
|
|
code: ErrorCodes.Server.INTERNAL_ERROR,
|
|
message: 'Internal server error'
|
|
},
|
|
500
|
|
);
|
|
});
|
|
|
|
// OpenAPI spec at /doc
|
|
app.get(
|
|
'/doc',
|
|
openAPISpecs(routes, { documentation: { info: { title: 'API', version: '0.0.1' } } })
|
|
);
|
|
|
|
showRoutes(app);
|
|
|
|
export default { port: process.env.PORT ?? 3000, fetch: app.fetch };
|
|
```
|
|
|
|
### Dev / production
|
|
|
|
```
|
|
bun --watch app/index.ts # dev with hot reload
|
|
bun app/index.ts # production
|
|
```
|
|
|
|
No Vite needed — Bun runs TypeScript natively.
|
|
|
|
---
|
|
|
|
## Important: `.meta()` vs `.openapi()`
|
|
|
|
| Library | Method | Notes |
|
|
| -------------------------- | ------------ | ------------------------------------------------------------------------------------ |
|
|
| `zod-openapi` v4 (old) | `.openapi()` | Required `import "zod-openapi/extend"` |
|
|
| `zod-openapi` v6 (current) | `.meta()` | Native Zod v4 method; no import needed — auto-augments via `declare module 'zod/v4'` |
|
|
|
|
- **Domain schemas** (`@nestri/core/*/index.ts`) use `.meta()` for descriptions/examples.
|
|
- **API route schemas** (`apps/api/app/routes/*.ts`) use `.meta()` for OpenAPI response/docs metadata.
|
|
- **Never** use `.openapi()` — it doesn't exist in `zod-openapi` v6.
|
|
|
|
---
|
|
|
|
## Error flow summary
|
|
|
|
```
|
|
Route handler
|
|
│
|
|
├─ throws VisibleError ──► onError → c.json(error.toResponse(), error.statusCode())
|
|
│
|
|
├─ throws HTTPException ─► onError → c.json({ type: "validation", … }, error.status)
|
|
│
|
|
├─ throws raw Error ─────► onError → c.json({ type: "internal", … }, 500)
|
|
│ (includes VisibleError for Actor model / context issues)
|
|
│
|
|
└─ returns normally ─────► c.json({ data: … })
|
|
```
|