HTTP and SQLite
Accept and validate JSON
Read a JSON request body, validate unknown input with Zod, and return a clear creation or client-error response.
TypeScript types disappear when the program runs. They do not validate data that arrives over HTTP. A client can send any JSON value it likes: a string instead of an object, a missing field, a title 10,000 characters long. Our Note type won’t stop any of it.
So we validate at runtime, and we do it with Zod, the package we added earlier. Define the shape a new note must have:
import { z } from 'zod'
const NoteInput = z.object({
title: z.string().trim().min(1).max(120),
})
Read it as a sentence: an object with a title that is a string, trimmed, at least 1 and at most 120 characters. Everything else is rejected.
Now add a POST handler beside the existing GET handler:
let nextId = 3
const notesRoute = {
GET: () => Response.json(notes),
POST: async (request: Request) => {
const json = await request.json().catch(() => null)
const result = NoteInput.safeParse(json)
if (!result.success) {
return Response.json(
{ error: 'Send a title between 1 and 120 characters' },
{ status: 400 },
)
}
const note = {
id: nextId++,
title: result.data.title,
}
notes.push(note)
return Response.json(note, { status: 201 })
},
}
Two details in there are worth a closer look.
request.json() throws when the body is not valid JSON. The .catch(() => null) turns that into a null, which Zod then rejects like any other bad input. One error path instead of two.
safeParse() never throws. It returns an object with success set to true or false, and the parsed data on success. I prefer it over parse() in request handlers, because a 400 response is a normal outcome, not an exception.
Use the route object in the server:
Bun.serve({
routes: {
'/api/notes': notesRoute,
},
})
Create a note with curl:
curl -X POST http://localhost:3000/api/notes \
-H 'Content-Type: application/json' \
-d '{"title":"Test the API"}'
The response is {"id":3,"title":"Test the API"} with status 201 Created. Run curl http://localhost:3000/api/notes and the new note is in the list.
Now send something wrong:
curl -X POST http://localhost:3000/api/notes \
-H 'Content-Type: application/json' \
-d '{"title":""}'
You get {"error":"Send a title between 1 and 120 characters"} with status 400 Bad Request. Send a body that isn’t JSON at all, like -d 'hello', and you get the same 400.
Validate at the boundary, once. After safeParse() succeeds, result.data has exactly the shape NoteInput describes, and TypeScript knows it. The rest of the handler, and every function it calls, can trust the input without checking it again.
Lesson completed