| name | sidecar-route |
| description | Use when adding or modifying a Fastify route in `server/src/routes/`. Covers the buildApp DI pattern, request validation with Fastify schemas, error mapping per `09-api-contracts.md`, and the matching test template. |
Skill: Sidecar Route
File layout
- One route per file in
server/src/routes/<name>.ts.
- Each file exports a Fastify plugin function:
import type { FastifyPluginAsync } from "fastify";
import type { Deps } from "../app";
export const fetchRoute: (deps: Deps) => FastifyPluginAsync =
(deps) => async (app) => {
app.post("/fetch", { schema: fetchSchema }, async (req, reply) => {
});
};
Register in app.ts:
app.register(fetchRoute(deps));
Validation
Use Fastify JSON schemas for request bodies. Reject early with 400
matching the contract (e.g., { error: "invalid_url" }).
const fetchSchema = {
body: {
type: "object",
required: ["url"],
properties: { url: { type: "string", format: "uri" } },
additionalProperties: false,
},
} as const;
If schema validation fails, intercept with a setErrorHandler (in
app.ts) that maps validation errors to the documented shape:
app.setErrorHandler((err, req, reply) => {
if (err.validation) {
reply.code(400).send({ error: errorCodeFor(req.routeOptions.url) });
return;
}
req.log.error(err);
reply.code(500).send({ error: "internal" });
});
Dependency injection
- Routes never import the Playwright pool or
undici directly.
- They take
deps from buildApp(deps). This is what makes tests fast
and deterministic.
Error mapping (mandatory)
Match docs/09-api-contracts.md exactly:
| Endpoint | Case | Status | Body |
|---|
| /fetch | invalid url | 400 | { error: "invalid_url" } |
| /fetch | playwright threw | 502 | { error: "fetch_failed", message } |
| /fetch | timeout | 504 | { error: "timeout" } |
| /webhook | invalid url | 400 | { error: "invalid_url" } |
| /webhook | dns/conn fail | 502 | { error: "network", message } |
| /webhook | timeout | 504 | { error: "timeout" } |
| /webhook | upstream 2xx | 200 | { ok: true, status, body } |
| /webhook | upstream non-2xx | 200 | { ok: false, status, body } |
A test must exist for every row in this table per route.
Test template
See .opencode/skill/testing/SKILL.md for the full inject pattern.
Minimum coverage per route:
- Happy path → expected status + body.
- Each documented error branch → status + body.
Anti-patterns
- Top-level
import { chromium } from "playwright" in a route file.
Always go through deps.pool.
- Throwing raw errors. Wrap and map to the contract.
- Logging secrets. There are none in v1, but assume there will be.
- Using
process.env inside a route. Read env at app boot and pass via
deps.
Checklist before marking done