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