JSON API

Time to connect the database to HTTP. By the end of this page you'll have a working REST API — create, read, update, and delete notes, all testable with curl.

Step 1 — Request types

Handlers need to parse JSON from request bodies. We'll define structs that conform to Deserialize — the inverse of Serialize. Create src/requests/notes.ks:

module notes.requests import quill.value.(Value) import quill.deserialize.(Deserialize, extractString) import quill.error.(DeserializeError) public struct CreateNoteRequest: Deserialize, Cloneable { public var title: String public var body: String public static func fromValue(value: Value) -> Result[CreateNoteRequest, DeserializeError] { let title = try extractString(from: value, "title"); let body = try extractString(from: value, "body"); .Ok(CreateNoteRequest(title: title, body: body)) } public func clone() -> CreateNoteRequest { CreateNoteRequest(title: self.title.clone(), body: self.body.clone()) } } public struct UpdateNoteRequest: Deserialize, Cloneable { public var title: String public var body: String public static func fromValue(value: Value) -> Result[UpdateNoteRequest, DeserializeError] { let title = try extractString(from: value, "title"); let body = try extractString(from: value, "body"); .Ok(UpdateNoteRequest(title: title, body: body)) } public func clone() -> UpdateNoteRequest { UpdateNoteRequest(title: self.title.clone(), body: self.body.clone()) } }

Deserialize requires a static func fromValue that extracts fields from a Value and constructs the struct. extractString is a Quill helper that looks up a key and returns its string value, or an error if the key is missing or the wrong type.

Step 2 — Helpers

Several handlers need the same utilities: parsing a JSON body, building an error response, extracting a route parameter. Create src/helpers.ks:

module notes.helpers import quill.value.(Value) import quill.deserialize.(Deserialize) import quill.json.parser.(parseJson) import perch.json_body.(JsonBody) import perch.response.(Response) public func errorJson(message: String) -> Value { var obj = Dictionary[String, Value](); obj.insert("error", Value.Str(message)); Value.Obj(obj) } public func parseBody[T](body: String) -> Result[T, Response] where T: Deserialize { let value = match parseJson(body) { .Ok(v) => v, .Err(_) => return .Err(Response.badRequest(JsonBody(fromRaw: errorJson("Invalid JSON")))) }; match T.fromValue(value) { .Ok(parsed) => .Ok(parsed), .Err(e) => .Err(Response.badRequest(JsonBody(fromRaw: errorJson(e.description())))) } } public func requireIdParam(value: String?) -> Int64? { guard let .Some(id) = value else { return .None } Int64(parsing: id) }

parseBody[T] is the most interesting piece. It's generic over any T that conforms to Deserialize — the where T: Deserialize clause constrains it. The function first parses raw JSON into a Value, then calls T.fromValue to build the typed struct. If either step fails, it returns a Response directly, so handlers can use match to extract the parsed body or bail early.

guard let is an early-return pattern: if the match fails, the else branch runs and must exit the function.

Step 3 — Note handlers

Create src/handlers/notes.ks:

module notes.handlers import perch.request.(Request) import perch.response.(Response) import perch.json_body.(JsonBody) import quill.value.(Value) import quill.serialize.(Serialize) import notes.context.(AppCtx) import notes.helpers.(errorJson, parseBody, requireIdParam) import notes.requests.(CreateNoteRequest, UpdateNoteRequest) import notes.db.(listNotes, findNoteById, createNote, updateNote, deleteNote) public func handleListNotes(req: Request, ctx: AppCtx) -> Response { let db = ctx.db; guard let .Ok(notes) = listNotes(db) else { return Response.internalServerError() } var arr = Array[Value](); for note in notes { guard let .Ok(v) = note.toValue() else { return Response.internalServerError() } arr.append(v) }; Response.ok(JsonBody(fromRaw: Value.Arr(arr))) } public func handleCreateNote(req: Request, ctx: AppCtx) -> Response { let body = match parseBody[CreateNoteRequest](req.body) { .Ok(b) => b, .Err(resp) => return resp }; let db = ctx.db; guard let .Ok(note) = createNote(db, body.title, body.body) else { return Response.internalServerError() } guard let .Ok(json) = JsonBody(note) else { return Response.internalServerError() } Response.created(json) } public func handleGetNote(req: Request, ctx: AppCtx) -> Response { guard let .Some(noteId) = requireIdParam(req.param("id")) else { return Response.badRequest(JsonBody(fromRaw: errorJson("Invalid note ID"))) } let db = ctx.db; guard let .Ok(maybeNote) = findNoteById(db, id: noteId) else { return Response.internalServerError() } guard let .Some(note) = maybeNote else { return Response.notFound() } guard let .Ok(json) = JsonBody(note) else { return Response.internalServerError() } Response.ok(json) } public func handleUpdateNote(req: Request, ctx: AppCtx) -> Response { guard let .Some(noteId) = requireIdParam(req.param("id")) else { return Response.badRequest(JsonBody(fromRaw: errorJson("Invalid note ID"))) } let body = match parseBody[UpdateNoteRequest](req.body) { .Ok(b) => b, .Err(resp) => return resp }; let db = ctx.db; guard let .Ok(maybeNote) = updateNote(db, id: noteId, body.title, body.body) else { return Response.internalServerError() } guard let .Some(note) = maybeNote else { return Response.notFound() } guard let .Ok(json) = JsonBody(note) else { return Response.internalServerError() } Response.ok(json) } public func handleDeleteNote(req: Request, ctx: AppCtx) -> Response { guard let .Some(noteId) = requireIdParam(req.param("id")) else { return Response.badRequest(JsonBody(fromRaw: errorJson("Invalid note ID"))) } let db = ctx.db; guard let .Ok(_) = deleteNote(db, id: noteId) else { return Response.internalServerError() } Response.noContent() }

Every handler grabs the shared database with let db = ctx.db. Since SharedDatabase is reference-counted, this just bumps the refcount — no new connection is opened. The guard let chains make the happy path read top-to-bottom — each guard bails early if something fails, so the remaining code can assume success.

Step 4 — Wire up routes

Update src/main.ks with all the routes:

module notes.main import perch.app.(App) import perch.request.(Request) import perch.response.(Response) import perch.middleware.(Logger) import talon.sqlite.shared_database.(SharedDatabase) import notes.context.(AppCtx) import notes.db.(initSchema) import notes.handlers.( handleListNotes, handleCreateNote, handleGetNote, handleUpdateNote, handleDeleteNote ) @main func main() { match initSchema("notes.db") { .Ok(_) => {}, .Err(e) => { println("Failed to initialize database: " + e.description()); return } }; let db = match SharedDatabase("notes.db") { .Ok(d) => d, .Err(e) => { println("Failed to open database: " + e.description()); return } }; let ctx = AppCtx(db: db); var app = App[AppCtx](ctx); app.use(Logger[AppCtx]()); app.route(get: "/notes", handleListNotes); app.route(post: "/notes", handleCreateNote); app.route(get: "/notes/:id", handleGetNote); app.route(post: "/notes/:id", handleUpdateNote); app.route(delete: "/notes/:id", handleDeleteNote); let port: UInt16 = 8080; println("Notes app listening on http://localhost:8080"); match app.listen(port) { .Ok(_) => {}, .Err(e) => { println("Error: " + e.description()); } } }

Step 5 — Test it

Run the server and try the full flow:

# Create a note curl -X POST http://localhost:8080/notes \ -H "Content-Type: application/json" \ -d '{"title":"My first note","body":"Hello from Kestrel!"}' # List notes curl http://localhost:8080/notes # Get a single note curl http://localhost:8080/notes/1 # Update it curl -X POST http://localhost:8080/notes/1 \ -H "Content-Type: application/json" \ -d '{"title":"Updated title","body":"New content."}' # Delete it curl -X DELETE http://localhost:8080/notes/1

Each note in the JSON response now includes a created_at field with an RFC 3339 timestamp:

{"id":1,"title":"My first note","body":"Hello from Kestrel!","created_at":"2026-05-27T15:30:05Z"}

What you saw

StepFeature
1Deserialize protocol, extractString helpers
2Generic functions with where clauses (parseBody[T]), guard let
3Handler pattern: parse body, open DB, do work, return JSON
4Perch routing with HTTP method labels
5End-to-end testing with curl, RFC 3339 timestamps in JSON