CRUD
Six endpoints scaffolded as real files, not runtime-injected.
CRUD is not runtime magic — it’s scaffolded files you can read and edit. The CLI writes two tiny route files per model; the framework then serves them like any other file route. Nothing is synthesized from a flag.
hopak generate crud post
# → Created file app/routes/api/posts.ts
# → Created file app/routes/api/posts/[id].ts
The generated files use the crud helpers from @hopak/core:
// app/routes/api/posts.ts
import { crud } from '@hopak/core';
import post from '../../models/post';
export const GET = crud.list(post);
export const POST = crud.create(post);
// app/routes/api/posts/[id].ts
import { crud } from '@hopak/core';
import post from '../../../models/post';
export const GET = crud.read(post);
export const PUT = crud.update(post);
export const PATCH = crud.patch(post);
export const DELETE = crud.remove(post);
That’s the whole REST surface. Six endpoints under /api/<plural>/:
| Method | Path | Helper | Behavior |
|---|---|---|---|
GET | /api/posts | crud.list(post) | Paginated list |
GET | /api/posts/:id | crud.read(post) | Single row, 404 if missing |
POST | /api/posts | crud.create(post) | Validate body, create, return 201 |
PUT | /api/posts/:id | crud.update(post) | Full body validation |
PATCH | /api/posts/:id | crud.patch(post) | Partial body validation |
DELETE | /api/posts/:id | crud.remove(post) | 204 on success, 404 if missing |
curl -X POST http://localhost:3000/api/posts \
-H 'content-type: application/json' \
-d '{"title":"Hello Hopak","content":"It works!"}'
# → 201 {"id":1,"title":"Hello Hopak","content":"It works!", ...}
curl http://localhost:3000/api/posts?limit=10&offset=20
# → {"items":[...],"total":42,"limit":10,"offset":20}
limit defaults to 20, max 100. Validation errors return 400 with field-level details; UNIQUE violations return 409; password / secret / token fields are stripped from responses (including those loaded through include).
Customize an endpoint
Just edit the generated file. To replace POST /api/posts with your own logic, delete the POST export from app/routes/api/posts.ts and write a custom handler:
// app/routes/api/posts.ts
import { crud, defineRoute } from '@hopak/core';
import post from '../../models/post';
export const GET = crud.list(post);
export const POST = defineRoute({
handler: async (ctx) => {
// your custom create logic — e.g. force-prefix the title,
// enforce auth, etc. — then call ctx.db!.model('post').create(...)
},
});
The other five verbs stay as they are. Because everything is in source files, there’s no “override” magic to learn — you just change what the file exports.
Gate a CRUD verb with middleware
Each crud.* helper takes an optional second argument with before, after, wrap:
import { crud } from '@hopak/core';
import { requireRole } from '@hopak/auth';
import { requireAuth } from '../../middleware/auth';
import post from '../../models/post';
export const GET = crud.list(post);
export const POST = crud.create(post, { before: [requireAuth()] });
export const DELETE = crud.remove(post, {
before: [requireAuth(), requireRole('admin')],
});
Options apply only to that verb. See Middleware for the full Before / After / Wrap contract.
Skip CRUD for a model
Don’t run hopak generate crud for it. The model still becomes a table, you just don’t expose HTTP routes. To add them later, run the command — or write the file by hand if you want non-/api/<plural>/ URLs.