DevPluginsREST API
Return only allowed fields
Send only the fields a VitNode route's response schema declares with respondJson, so extra database columns never reach the client.
c.json(row, 200) sends every property of row, including columns the response schema never mentions. respondJson from @vitnode/core/api/lib/respond-json copies only the fields the schema declares, at every depth, and then validates the result. In this guide the site-notes detail route selects whole rows and still answers with only id, title and body.
Before you begin
- The
GET /notes/{id}route reading thesite_notestable, from Database.
Answer with respondJson
In src/api/modules/notes/routes/show.route.ts, select the whole row and pass the response schema the route already declares for 200:
import { respondJson } from "@vitnode/core/api/lib/respond-json";
handler: async c => {
const { id } = c.req.valid("param");
const [note] = await c
.get("db")
.select({ id: site_notes.id, title: site_notes.title, body: site_notes.body })
.select()
.from(site_notes)
.where(eq(site_notes.id, id));
if (!note) return c.body(null, 404);
return c.json(note, 200);
return respondJson(c, 200, zodNote, note);
},The row now carries createdAt, which zodNote does not declare, so respondJson leaves it out. Pass the same schema as the route's responses[200]. The handler's return type then stays exactly what c.json(note, 200) gave, and pages that call this route with fetcher keep the same inferred body. Headers and cookies set on c are sent as usual.
Check the result
Open http://localhost:3000/api/site-notes/notes/2 in the browser. The body is {"id":2,"title":"Office closed on Friday","body":"..."}, without createdAt. A column you add to site_notes later also stays out of this response until you add it to zodNote.
How fields are chosen
- Objects keep only the keys in their shape, even with
z.looseObject()or.catchall(). Nested objects, arrays, records and tuples are projected the same way. An object schema accepts only plain objects andObject.create(null)objects. A class instance is refused rather than copied, so map it to a plain object first. - Optional and nullable fields: a missing or
undefinedvalue stays missing andnullstaysnull. Nothing is filled in. - Unions use the first option that accepts the projected value, so a discriminated union keeps only the fields of the matching branch. When no option accepts its projected value, the response is refused. The original value is never sent.
- Plain JSON schemas only. A schema with
.transform(),.pipe(),.default(),.catch(),z.lazy(), an intersection, or abigint,maporsetis refused, because its output is computed rather than copied.z.date()is accepted and sent as an ISO string, as withc.json.
When the value does not match
After projecting, respondJson validates the result. A required field that is missing or has the wrong type throws ResponseProjectionError, and VitNode answers 500. The server broke its own contract, so the client is not told its request was wrong. The logged message names the path and issue code, such as response.title: invalid_type (expected string), never the value.
respondJson removes fields the schema does not declare. It does not decide
who may see the fields it does declare. Check access first, and give viewers
who may see less their own response schema.
Reference
All exports of @vitnode/core/api/lib/respond-json:
| Export | Signature | Behavior |
|---|---|---|
respondJson | (c, status, schema, value) => TypedResponse | c.json(projectResponse(schema, value), status), typed like c.json for that status. |
projectResponse | (schema, value) => z.output<typeof schema> | Projects and validates without sending, for example inside a service. Universal. |
ResponseProjectionError | Error with reason | Thrown for a mismatch or an unsupported schema. |
Related
- API routes: declaring a route's responses.
- Fetcher: calling the route from a page.