Routes¶
A route says when somebody sends this method to this path, read these inputs from the request and pass them to this function. An app is a list of routes.
Anatomy of a route¶
Every route has four parts:
Spindle.get (* 1. the method *)
Spindle.Path.(s "todos" / id) (* 2. the path: /todos/{id} *)
(Spindle.Returns.json todo_json) (* 3. what it answers *)
(let+ id = Spindle.param id in (* 4. what it reads, *)
get_todo id) (* and what it does with it *)
let+ and and+ come from open Spindle.Syntax at the top of the file.
- The method:
Spindle.get,post,put,patchordelete. Any other method isSpindle.route, as inSpindle.route `OPTIONS. - The path:
s "todos"is a fixed segment;idis a typed parameter, so/todos/7hands the handler the number7. - What it answers: text, HTML, JSON of a type, nothing, or a response you build yourself, such as a redirect. Request bodies, responses and errors lists them all.
- What it reads, and what it does with it: a value built with
let+. It reads inputs from the request and passes them to your function, the handler, hereget_todo. A request that doesn’t fit –/todos/seven– gets a400saying what is wrong, and the handler never runs.
One route per method¶
A small API over a list of todos:
open Spindle.Syntax
type todo = { id : int; title : string } [@@deriving wiretype]
type draft = { title : string } [@@deriving wiretype]
(* The handlers know nothing of HTTP, so a test calls them as they are. *)
let list_todos () =
Ok [ { id = 1; title = "Learn OCaml" }; { id = 2; title = "Try Spindle" } ]
let get_todo id = Ok { id; title = "Learn OCaml" }
let add_todo (draft : draft) = Ok { id = 3; title = draft.title }
let replace_todo id (draft : draft) = Ok { id; title = draft.title }
let delete_todo _id = Ok ()
(* A parameter is made once, so every route whose path has it reads it the
same way. *)
let id = Spindle.Path.int "id"
let routes =
[
Spindle.get
Spindle.Path.(s "todos")
(Spindle.Returns.json (Wiretype.list todo_json))
(let+ () = Spindle.Dep.return () in
list_todos ());
Spindle.get
Spindle.Path.(s "todos" / id)
(Spindle.Returns.json todo_json)
(let+ id = Spindle.param id in
get_todo id);
Spindle.post
Spindle.Path.(s "todos")
(Spindle.Returns.json ~status:`Created todo_json)
(let+ draft = Spindle.json draft_json in
add_todo draft);
Spindle.put
Spindle.Path.(s "todos" / id)
(Spindle.Returns.json todo_json)
(let+ id = Spindle.param id and+ draft = Spindle.json draft_json in
replace_todo id draft);
Spindle.delete
Spindle.Path.(s "todos" / id)
(Spindle.Returns.empty ())
(let+ id = Spindle.param id in
delete_todo id);
]
let () = Eio_main.run @@ fun env -> Spindle.serve env routes
curl -X POST localhost:8080/todos -H 'content-type: application/json' \
-d '{"title": "Write a server"}'
curl -X PUT localhost:8080/todos/1 -H 'content-type: application/json' \
-d '{"title": "Learn OCaml properly"}'
{"error":"invalid","message":"Some of that request is not what it should be.","problems":[{"at":"path.id","code":"malformed","message":"This is not a whole number."}]}
GET /todosreads nothing, so its input isSpindle.Dep.return (). Writtenlet+ () = Spindle.Dep.return () in list_todos (), it callslist_todoson every request.Spindle.Dep.return (list_todos ())would call it once, when the routes are built, and answer that same list for ever.GET /todos/{id}reads theidwithSpindle.param.POST /todosreads a JSON body withSpindle.json draft_json, and answers201 Createdthrough~status.PUT /todos/{id}reads two things, joined withand+.DELETE /todos/{id}answersSpindle.Returns.empty ():204 No Content.
[@@deriving wiretype] writes todo_json and draft_json from the types.
Request bodies covers that; keeping the todos for real is
the database page.
Path parameters¶
Below, Path is Spindle.Path and Codec is Spindle.Codec.
let order_id = Path.str "order_id" (* any segment *)
let page = Path.int "page" (* an int *)
let user_id = Path.int64 "user_id" (* an int64 *)
let file = Path.rest "file" (* every segment left *)
let invoice = Path.param "invoice" Codec.uuid (* a UUID, as a Uuidm.t *)
let colour =
Path.param "colour"
(Codec.enum ~kind:"colour" colour_to_string
[ Red; Green; Blue ]) (* one of a set of words *)
let sku =
Path.param "sku"
(Codec.custom ~kind:"sku" ~parse:Sku.of_string
~print:Sku.to_string ()) (* a type of your own *)
Path.(s "orders" / order_id / s "items") (* /orders/{order_id}/items *)
Path.(s "static" / file) (* /static/{file*} *)
Path.root (* / *)
- Make each parameter once and use it in every path that has it. Read it in
a route with
Spindle.param order_id: you pass the parameter, never the path built from it. Codecalso hasfloat,boolanddate(2026-09-30, read as(year, month, day)).- A segment arrives percent-decoded:
/orders/Kim%20Liis"Kim Li". - A
customcodec’s~parseanswersOkthe value, orErrorthe sentence a client is told:Error "This is not a SKU.". Path.resttakes every segment left, as a list:/static/css/site.cssis["css"; "site.css"], and/staticis[]. It must come last. It is left out of the OpenAPI document.
One parameter, read by two routes, one answering text and one JSON:
let user_id = Spindle.Path.int64 "user_id"
let name = Spindle.Path.str "name"
(* The answer's description is derived from its record, so a name holding a
quote is still one JSON string. *)
type user = { user_id : string; name : string } [@@deriving wiretype]
(* The handlers take values, not a request: the route below says where each
value comes from. *)
let user_text id = Ok (Printf.sprintf "User %Ld" id)
let user_answer id name = Ok { user_id = Int64.to_string id; name }
let routes =
[
Spindle.get
Spindle.Path.(s "users" / user_id)
Spindle.Returns.text
(let+ id = Spindle.param user_id in
user_text id);
Spindle.get
Spindle.Path.(s "api" / s "users" / user_id / name)
(Spindle.Returns.json user_json)
(let+ id = Spindle.param user_id and+ name = Spindle.param name in
user_answer id name);
]
{"error":"invalid","message":"Some of that request is not what it should be.","problems":[{"at":"path.user_id","code":"malformed","message":"This is not a whole number."}]}
The parameter’s name, user_id, is where a segment that does not parse is
reported.
How routes are matched¶
- A literal beats a parameter, whatever the order of the list:
/users/meanswers before/users/{id}. - A parameter that doesn’t parse is a
400atpath.<name>. Give it~unparsed:Next_route(Path.(s "posts" / int ~unparsed:Next_route "key")) to make it “not this route” instead, so the next route whose path matches answers –/posts/42by its number,/posts/blue-lampby its name. Routes like these, that differ only by the parameter’s type, must give it the same name. The OpenAPI document describes them as one operation, so they must also answer the same way and read the same body; otherwiseSpindle.Openapi.docsrefuses them. - A path no route names is
404, or your own answer withSpindle.serve env ~not_found:page routes. A path another method’s route names is405, withAllowlisting the methods it has. - A method no route declares and Spindle does not know –
PROPFIND, say – is501 Not Implemented. OPTIONSof a path a route names is answered for you,204with thatAllow, unless you declare anOPTIONSroute there;OPTIONS *, which asks of the server, is204as well.- A trailing slash is a different path (
404), unless you pass~trailing_slash:Spindle.App.Redirect, which answers a308to the path without it. HEADis answered by theGETroute; you don’t declare it.
A route table that can’t be right is refused before the server listens:
Spindle.serve raises Invalid_argument naming the route. That covers two
routes of one method that could answer the same URL, a parameter named twice
in a path, a Path.rest that isn’t last, a Spindle.param its route’s path
doesn’t have, and a HEAD route. The one Spindle.param this cannot check
is one inside Spindle.Dep.bind, whose inputs are only known when it runs:
if its route’s path lacks the parameter, the request answers 500.
Building URLs¶
Build a link or a redirect from the same path the route matches, so the two can’t drift apart:
let order tail = Path.(s "orders" / order_id / tail)
Path.url (order (Path.s "items")) [ Path.arg order_id "Kim Li" ]
(* Ok "/orders/Kim%20Li/items" *)
Each segment is percent-encoded, a segment of . or .. too, which a
browser would otherwise resolve away. A parameter left out, given twice or
not in the path is an Error naming it.
Next: dependencies, everything else a handler can read from a request.