Docs / CRUD

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>/:

MethodPathHelperBehavior
GET/api/postscrud.list(post)Paginated list
GET/api/posts/:idcrud.read(post)Single row, 404 if missing
POST/api/postscrud.create(post)Validate body, create, return 201
PUT/api/posts/:idcrud.update(post)Full body validation
PATCH/api/posts/:idcrud.patch(post)Partial body validation
DELETE/api/posts/:idcrud.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.