DevPluginsREST API
Generate an API resource
Scaffold a VitNode API resource with vitnode generate resource, including a module, Zod schemas, service stubs and list, show, create, update and delete routes.
vitnode g resource <name> --kind api writes a wired set of CRUD endpoints: a module, shared Zod schemas, a service with one typed function per operation, and five routes that call it. Nothing is stored yet. Every route answers 501 Not Implemented until you implement the service, so the scaffold never returns made-up data.
Before you begin
- A plugin made with Create plugin, with
pnpm devrunning at the workspace root and a terminal inplugins/site-notes. - No
notesmodule with routes. A resource needs a new or empty module, so if you followed the earlier generator guides, start from a fresh plugin or pick another name.
Generate the resource
Describe the fields with --fields name:type, using string, number, int or boolean. A ? after the name makes a field optional. --id-type number makes the id a positive integer instead of a string:
pnpm vitnode g resource notes --kind api --fields "title:string,body:string,pinned?:boolean" --id-type number ✓ created src/api/modules/notes/notes.module.ts module "notes"; register the notes resource routes
✓ created src/api/modules/notes/notes.schema.ts schemas for the notes resource
✓ created src/api/modules/notes/notes.service.ts service stubs for the notes resource
✓ created src/api/modules/notes/routes/list.route.ts GET /notes
✓ created src/api/modules/notes/routes/show.route.ts GET /notes/{id}
✓ created src/api/modules/notes/routes/create.route.ts POST /notes
✓ created src/api/modules/notes/routes/update.route.ts PATCH /notes/{id}
✓ created src/api/modules/notes/routes/delete.route.ts DELETE /notes/{id}
✓ updated src/config.api.ts register notesModule in buildApiPluginThe module, schemas and service sit together in src/api/modules/notes/. Each route has its own file in routes/ and imports ../notes.schema and ../notes.service. Without --fields, an interactive terminal asks for them. In CI the schemas model only the id, with a comment in notes.schema.ts that says to add the rest. The generator never guesses fields such as title from a name.
Read the contract
notes.schema.ts is the single source of the resource's shape. The routes validate with these schemas, and the service's types are inferred from them, so there are no parallel interfaces to keep in sync:
import { z } from "@hono/zod-openapi";
/** One notes entry, as the API returns it. */
export const zodNotesItem = z.object({
id: z.number().int().positive(),
title: z.string(),
body: z.string(),
pinned: z.boolean().optional(),
});
/** What creating a notes entry accepts. */
export const zodNotesCreateBody = z.object({
title: z.string(),
body: z.string(),
pinned: z.boolean().optional(),
});
/** What updating a notes entry accepts: any subset of the create fields. */
export const zodNotesUpdateBody = zodNotesCreateBody.partial();
export type NotesItem = z.infer<typeof zodNotesItem>;
export type NotesCreateInput = z.infer<typeof zodNotesCreateBody>;
export type NotesUpdateInput = z.infer<typeof zodNotesUpdateBody>;| Route | Method and path | Success | Also answers |
|---|---|---|---|
list | GET /notes | 200 | 501 |
show | GET /notes/{id} | 200 | 400 invalid id, 404, 501 |
create | POST /notes | 201 | 400 invalid body, 501 |
update | PATCH /notes/{id} | 200 | 400, 404, 501 |
delete | DELETE /notes/{id} | 204 | 400, 404, 501 |
Implement the service
Each operation in notes.service.ts is a typed stub that rejects with NotesNotImplementedError. The service knows nothing about HTTP:
/** Rejected by an operation that is not implemented yet. The routes answer it with 501. */
export class NotesNotImplementedError extends Error {
constructor(operation: string) {
super(
`notes: ${operation} is not implemented yet. Implement it in notes.service.ts.`,
);
this.name = "NotesNotImplementedError";
}
}
const notImplemented = async (operation: string): Promise<never> =>
Promise.reject(new NotesNotImplementedError(operation));
export const createNotesService = (_deps: NotesServiceDeps) => ({
list: async (): Promise<NotesItem[]> => notImplemented("list"),
/** Resolves `null` when there is no entry with this id. */
get: async (_id: number): Promise<NotesItem | null> => notImplemented("get"),
// create, update, remove...
});Each route catches that error and maps it to a typed 501 response, which it declares in responses:
handler: async c => {
try {
return c.json(await createNotesService({ db: c.get("db") }).list(), 200);
} catch (error) {
if (error instanceof NotesNotImplementedError) {
return c.json(
{ error: "not_implemented" as const, message: error.message },
501,
);
}
throw error;
}
},Keep the signatures and replace the bodies, one operation at a time. Rename _deps to { db } once an operation queries. The routes already answer 404 when get or update resolves null, or remove resolves false. With the site_notes table from the Database lesson, list becomes:
export const createNotesService = ({ db }: NotesServiceDeps) => ({
list: async (): Promise<NotesItem[]> =>
db
.select({ id: site_notes.id, title: site_notes.title, body: site_notes.body })
.from(site_notes),
// the other operations
});When every operation is implemented, drop the catch blocks, the 501 responses and NotesNotImplementedError, as each route's comment says.
Import site_notes from @/database/notes. The generator does not create the table or a migration. Add a service explains where the service's dependencies come from.
Check the result
Call GET /notes with the temporary fetcher button from Generate an endpoint, using module: "notes" and path: "/" with no args. Before you implement list, it answers 501 with {"error":"not_implemented","message":"notes: list is not implemented yet. Implement it in notes.service.ts."}. Afterwards it returns the rows. Invalid input answers 400 either way, because validation runs before the service. Through fetcher, TypeScript already rejects a body such as { "title": 1 } at compile time.
Admin resources
--admin generates the resource under admin/ and guards each operation with its own staff permission, so can_create never guards a delete:
pnpm vitnode g resource notes --admin --kind api --permission-module notes --declare-permission --parents| Operations | Permission |
|---|---|
list, show | notes.can_view |
create | notes.can_create |
update | notes.can_edit |
delete | notes.can_delete |
Without --declare-permission, the command lists the permissions missing from the plugin's defineStaffPermissions(manifest, {...}) and writes nothing:
✖ notes.can_view, notes.can_create, notes.can_edit, notes.can_delete are not declared.
Nobody can be granted a permission the plugin does not declare in admin["notes"] of its defineStaffPermissions(manifest, {...}).
Pass --declare-permission to add them (and their labels).With it, they are added to src/staff-permissions.ts, which the generator creates and passes to buildApiPlugin({ permissionStaff }) when the plugin has none, with their AdminCP labels. Each route guards with its reference, such as staffPermissions.admin.notes.canView. A staff permission decides who may call an operation, not which entries they may change. Check ownership in the service or handler when it matters.