Skip to content

Dependencies

A handler needs things from the request: a path parameter, a query string, a header, a cookie, the body – and things built from them, like who is signed in or which page of a list was asked for. Each of these is a dependency, and a handler lists the ones it reads.

How it works

Each input is a value of type 'a Spindle.Dep.t. A handler lists them with let+ … and+ … – read it as a parameter list:

open Spindle.Syntax

(let+ id = Spindle.param user_id
 and+ page = Spindle.Query.optional "page" Spindle.Codec.int
 and+ now = Spindle.now in
 answer id ~page ~now)

open Spindle.Syntax brings in let+ and and+ and nothing else. Every input is worked out before the code after in runs, and that code sees plain values: an int64 (if user_id is a Path.int64), an int option, a Ptime.t.

  • A bad request is answered all at once. Every wrong path parameter, query parameter, header and cookie is reported in one 400, each problem at its place (path.user_id, query.page). Only when those are all fine is the body read, and its problems (body.items[2].count) are then reported together the same way. The handler never runs.
  • A route’s inputs are known without running it. That is what the API’s document is made from.
  • A dependency can be made of other dependencies, and a route lists it just like a built-in.

A query parameter

A query parameter is read with the codec of its type, so the handler is handed a float and is arithmetic and nothing else:

open Spindle.Syntax

type reading = { fahrenheit : float } [@@deriving wiretype]

let to_fahrenheit celsius = Ok { fahrenheit = (celsius *. 9. /. 5.) +. 32. }
let celsius = Spindle.Query.required "celsius" Spindle.Codec.float

let routes =
  [
    Spindle.get
      Spindle.Path.(s "fahrenheit")
      (Spindle.Returns.json reading_json)
      (let+ celsius = celsius in
       to_fahrenheit celsius);
  ]

let () =
  Spindle.Log.setup ();
  Eio_main.run @@ fun env ->
  Spindle.serve env (routes @ Spindle.Openapi.docs routes)
curl 'localhost:8080/fahrenheit?celsius=21'
{"fahrenheit":69.8}
curl 'localhost:8080/fahrenheit?celsius=warm'
{"error":"invalid","message":"Some of that request is not what it should be.","problems":[{"at":"query.celsius","code":"malformed","message":"This is not a number."}]}
curl localhost:8080/fahrenheit
{"error":"invalid","message":"Some of that request is not what it should be.","problems":[{"at":"query.celsius","code":"required","message":"This is required."}]}
  • Query.required refuses a missing parameter; Query.optional hands over an option, and Query.list every value given.
  • Openapi.docs routes serves the API’s own document beside it: the OpenAPI page.

Custom dependencies

A page of a list is two query parameters, read as one value and checked:

let too_small at =
  Spindle.Dep.problem ~at ~code:"too_small" "This is 1 or more."

(* Two query parameters read as one value, and checked: a number that is no
   page is a problem at its place, as a number that does not parse is. *)
let paging =
  Spindle.Dep.join
    (let+ page = Spindle.Query.optional "page" Spindle.Codec.int
     and+ per_page = Spindle.Query.optional "per_page" Spindle.Codec.int in
     let page = Option.value page ~default:1
     and per_page = Option.value per_page ~default:10 in
     if page < 1 then Error (too_small "query.page")
     else if per_page < 1 then Error (too_small "query.per_page")
     else Ok { page; per_page })

A route lists it like any built-in:

let routes =
  [
    Spindle.get
      Spindle.Path.(s "books")
      (Spindle.Returns.json (Wiretype.list Wiretype.string))
      (let+ paging = paging in
       books_on paging);
  ]
curl 'localhost:8080/books?page=2&per_page=2'
["Persuasion","Ulysses"]
curl 'localhost:8080/books?page=0'
{"error":"invalid","message":"Some of that request is not what it should be.","problems":[{"at":"query.page","code":"too_small","message":"This is 1 or more."}]}
curl 'localhost:8080/books?page=two&per_page=x'
{"error":"invalid","message":"Some of that request is not what it should be.","problems":[{"at":"query.page","code":"malformed","message":"This is not a whole number."},{"at":"query.per_page","code":"malformed","message":"This is not a whole number."}]}
  • Dep.join makes a dependency that may refuse, as paging above does: write a let+ block that answers Ok or Error, and wrap it. Ok v gives v; Error refuses the request. ~refuses names the codes it may refuse with, so the document lists them; a 400 about an input, like paging’s, needs none.

let bearer =
  Spindle.Dep.join ~refuses:[ signed_out ]
    (let+ token = Spindle.Header.optional "authorization" Spindle.Codec.string in
     Option.to_result ~none:(Spindle.Refusal.make signed_out "Sign in.") token)
- Dep.problem ~at ~code message reports a wrong value at its place, alongside every other problem in the request. - Dep.map f d derives a value that cannot fail: Spindle.Dep.map String.lowercase_ascii email. - Dep.refuse refusal always refuses, for a route that is switched off. - Dep.of_body ~need:(Raw { content_types = [ "text/csv" ] }) parse reads the whole body as a string and parses it into a type of your own. A request of any other Content-Type is answered 415 before its body is read.

The whole app
(* Inputs, and a dependency of the application's own made of them: a page
   of a list, read from two query parameters, and refused at the one that
   is not a page's.

     curl 'localhost:8080/books?page=2&per_page=2'
     curl -i 'localhost:8080/books?page=0'
     curl -i 'localhost:8080/books?page=two' *)

open Spindle.Syntax

type page = { page : int; per_page : int }

let too_small at =
  Spindle.Dep.problem ~at ~code:"too_small" "This is 1 or more."

(* Two query parameters read as one value, and checked: a number that is no
   page is a problem at its place, as a number that does not parse is. *)
let paging =
  Spindle.Dep.join
    (let+ page = Spindle.Query.optional "page" Spindle.Codec.int
     and+ per_page = Spindle.Query.optional "per_page" Spindle.Codec.int in
     let page = Option.value page ~default:1
     and per_page = Option.value per_page ~default:10 in
     if page < 1 then Error (too_small "query.page")
     else if per_page < 1 then Error (too_small "query.per_page")
     else Ok { page; per_page })

let books = [ "Emma"; "Middlemarch"; "Persuasion"; "Ulysses"; "Walden" ]

let books_on { page; per_page } =
  Ok (List.filteri (fun i _ -> i / per_page = page - 1) books)

let routes =
  [
    Spindle.get
      Spindle.Path.(s "books")
      (Spindle.Returns.json (Wiretype.list Wiretype.string))
      (let+ paging = paging in
       books_on paging);
  ]

let () =
  Spindle.Log.setup ();
  Eio_main.run @@ fun env -> Spindle.serve env routes

Custom error codes

For a refusal that isn’t “this input is wrong”, declare a code and list it with ~refuses, so the route’s document names it. A bearer token read from the Authorization header:

(* A 401 names how to authenticate, in WWW-Authenticate, so the code carries
   the challenge. *)
let signed_out =
  Spindle.Refusal.Code.make "signed_out" ~status:`Unauthorized
    ~challenge:{|Bearer realm="notes"|} ~doc:"No valid bearer token was sent."

let please_sign_in = Spindle.Refusal.make signed_out "Please sign in."

(* Made of the framework's own header input, read as credentials -- a
   scheme compared without case, so "bearer abc" is a bearer token as
   "Bearer abc" is -- and [join] turns a check that fails into a refusal of
   the request. The header is optional rather than required because a
   missing token is a 401, not a malformed request. [credential] names the
   scheme for the document, which reads the rest: Authorization is HTTP
   authentication, and a 401 means the token is needed. *)
let bearer =
  Spindle.Dep.credential ~scheme:"bearer"
    (Spindle.Dep.join ~refuses:[ signed_out ]
       (let+ credentials =
          Spindle.Header.optional "authorization" Spindle.Codec.credentials
        in
        match credentials with
        | Some { scheme = "bearer"; value = Token68 token } -> Ok token
        | Some _ | None -> Error please_sign_in))
  • The header is optional: a missing token should be a 401, not a 400.
  • A 401 code needs a ~challenge (the WWW-Authenticate value); the app refuses to start without one.
  • Unlike an input problem, a refusal like this ends the request at once.
  • Dep.credential ~scheme:"bearer" tells the document that this input proves who is asking, so it lists the scheme and which routes need it.
  • The dependency hands over the token, not the account. Looking the account up is a read, and belongs in the handler, next to the work it authorises (why).
The whole app: a bearer token, and two routes that need it
(* A dependency of the application's own: a bearer token, read from the
   Authorization header and refused with a 401 when it is missing or is not
   one. Two routes list it beside the framework's inputs, and /docs says
   both need a bearer token.

     curl -H 'Authorization: Bearer ada-token' localhost:8080/me
     curl -H 'Authorization: Bearer ada-token' localhost:8080/notes/1
     curl -i localhost:8080/notes/1 *)

open Spindle.Syntax

let ( let* ) = Result.bind

(* A 401 names how to authenticate, in WWW-Authenticate, so the code carries
   the challenge. *)
let signed_out =
  Spindle.Refusal.Code.make "signed_out" ~status:`Unauthorized
    ~challenge:{|Bearer realm="notes"|} ~doc:"No valid bearer token was sent."

let please_sign_in = Spindle.Refusal.make signed_out "Please sign in."

(* Made of the framework's own header input, read as credentials -- a
   scheme compared without case, so "bearer abc" is a bearer token as
   "Bearer abc" is -- and [join] turns a check that fails into a refusal of
   the request. The header is optional rather than required because a
   missing token is a 401, not a malformed request. [credential] names the
   scheme for the document, which reads the rest: Authorization is HTTP
   authentication, and a 401 means the token is needed. *)
let bearer =
  Spindle.Dep.credential ~scheme:"bearer"
    (Spindle.Dep.join ~refuses:[ signed_out ]
       (let+ credentials =
          Spindle.Header.optional "authorization" Spindle.Codec.credentials
        in
        match credentials with
        | Some { scheme = "bearer"; value = Token68 token } -> Ok token
        | Some _ | None -> Error please_sign_in))

let no_such_note =
  Spindle.Refusal.Code.make "no_such_note" ~status:`Not_found
    ~doc:"There is no note by that id that is yours."

(* What the application holds is closed over, not a dependency. The
   dependency hands over the token, and the handler decides whose it is. *)
let people = [ ("ada-token", "Ada"); ("alan-token", "Alan") ]
let notes = [ (1, ("Ada", "Write to Charles")); (2, ("Alan", "Fix the bombe")) ]

let whose token =
  Option.to_result ~none:please_sign_in (List.assoc_opt token people)

(* Somebody else's note is answered as a missing one, so an id says nothing
   about whether a note exists. *)
let note name id =
  match List.assoc_opt id notes with
  | Some (owner, text) when String.equal owner name -> Ok text
  | Some _ | None ->
      Error (Spindle.Refusal.make no_such_note "There is no such note.")

let note_id = Spindle.Path.param "note_id" Spindle.Codec.int

let routes =
  [
    Spindle.get ~summary:"Who the token belongs to"
      Spindle.Path.(s "me")
      (Spindle.Returns.json Wiretype.string)
      (let+ token = bearer in
       whose token);
    Spindle.get ~summary:"One of your notes" ~refuses:[ no_such_note ]
      Spindle.Path.(s "notes" / note_id)
      (Spindle.Returns.json Wiretype.string)
      (let+ token = bearer and+ id = Spindle.param note_id in
       let* name = whose token in
       note name id);
  ]

let () =
  Spindle.Log.setup ();
  Eio_main.run @@ fun env ->
  Spindle.serve env (routes @ Spindle.Openapi.docs ~title:"Notes" routes)

Query parameters, headers and cookies are read by a codec from Spindle.Codec – the same codecs a path parameter uses:

module Codec = Spindle.Codec

Spindle.Query.optional "page" Codec.int          (* int option *)
Spindle.Query.default "limit" Codec.int 20       (* int: 20 when absent *)
Spindle.Query.required "q" Codec.string          (* string, or a 400 *)
Spindle.Query.list "tag" Codec.string            (* ?tag=a&tag=b *)
Spindle.Header.optional "x-count" Codec.int      (* name matched without case *)
  • Codecs: string, int, int64, float, bool, uuid (a Uuidm.t, as a JSON body’s and a row’s), date, instant (a Ptime.t), enum ~kind to_string values, and custom for a type of your own.
  • Structured headers have codecs that parse them: media_type, accept, weighted, credentials, cache_control, forwarded, and structured_item, structured_list, structured_dictionary.
  • A header sent on several lines is read whole by a codec of a list – accept, weighted, cache_control, forwarded, structured_list, structured_dictionary, and a custom ~list:true of your own – its lines joined by ,. To any other codec it is malformed, a 400: a value sent twice is no message HTTP allows.

A cookie is declared once, with its name and codec. That one value reads it and sets it:

let visits = Spindle.Cookie.named "visits" Codec.int

Spindle.Cookie.optional visits                   (* an input: int option *)
Spindle.Cookie.make visits 3                     (* a cookie to set *)
Spindle.Cookie.clear visits                      (* removes it *)

A handler sets a cookie by listing Spindle.set_cookie among its inputs and calling it (how). For a cookie holding arbitrary text, use the Spindle.Cookie.encoded codec. Signed and encrypted cookies and sessions are on their own page.

Other built-ins:

  • Spindle.param: a path parameter.
  • Spindle.now: the instant the request is answered at, a Ptime.t.
  • Spindle.request: the whole Spindle.Request.t.
  • Spindle.request_id: the id on every log line of this request.
  • Spindle.peer: the address that opened the connection, which is the proxy when there is one.
  • Spindle.client: who is asking. It is the peer, or, when the peer is a proxy you told the server to trust, the address that proxy forwarded for (the client’s IP address).
  • Spindle.set_cookie and Spindle.add_header: functions that add a cookie or a header to the answer.
  • Spindle.client_gone: a promise resolved once the client has gone, for a long-running event stream.
  • Spindle.body, Spindle.json, Spindle.body_stream and Spindle.multipart: the body, read whole or as it arrives.

How dependencies run

  • The body is read last. Everything else runs first, so a request refused for a missing token never has its body read.
  • A dependency runs once per request, however many others use it: two dependencies that both use bearer read the header once.
  • Sharing works by identity. Define a dependency once, let current = …, and use that value everywhere. A function let current () = … called twice makes two dependencies, which run twice. Dep.uncached d runs d at every use on purpose.
  • What the app holds is a resource: read like any other dependency, but supplied once, where the server starts (below).
Reading what no built-in reads

Dep.of_request f runs f on the Spindle.Request.t. Say what it reads with ~needs (a Custom need names something the framework has no word for) and how it may refuse with ~refuses, so the document can list it; without ~needs it is opaque. Dep.bind is for a dependency that needs another’s value to decide what to read next; it is opaque too.

Resources

Every dependency above is request-wide: worked out from the request, once per request. A resource is app-wide: a database pool, a client, a broadcast hub, made once where the program starts. Name it with Dep.of_resource, read it like any other dependency, and supply it when the server starts:

let db : Spindle_postgres.Pool.t Spindle.Dep.t = Spindle.Dep.of_resource "database"

(let+ pool = db and+ id = Spindle.param order_id in ...)

Spindle.serve env ~resources:[ Spindle.Dep.resource db pool ] routes
  • The routes stay a value. They read the pool without holding it, so the command line describes them with no database, and a test supplies its own.
  • A missing resource stops the server. A route that reads one ~resources does not hold is refused before the server listens, naming the route and the resource; so is one supplied twice. One read inside Dep.bind is found only as it runs: unsupplied, it is a 500, logged by name.
  • It is no input. A request does not carry it, so the API’s document and the client schemas never list it.
  • Make it once, at the top level. It has an identity, as every dependency has: the value supplied is the value read.

Database shows the whole shape, and why a transaction is not a dependency.

Next: request bodies, responses and errors.