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)
{"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."}]}
{"error":"invalid","message":"Some of that request is not what it should be.","problems":[{"at":"query.celsius","code":"required","message":"This is required."}]}
Query.requiredrefuses a missing parameter;Query.optionalhands over anoption, andQuery.listevery value given.Openapi.docs routesserves 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);
]
{"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."}]}
{"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.joinmakes a dependency that may refuse, aspagingabove does: write alet+block that answersOkorError, and wrap it.Ok vgivesv;Errorrefuses the request.~refusesnames the codes it may refuse with, so the document lists them; a400about an input, likepaging’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 a401, not a400. - A
401code needs a~challenge(theWWW-Authenticatevalue); 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, header and cookie inputs¶
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(aUuidm.t, as a JSON body’s and a row’s),date,instant(aPtime.t),enum ~kind to_string values, andcustomfor a type of your own. - Structured headers have codecs that parse them:
media_type,accept,weighted,credentials,cache_control,forwarded, andstructured_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 acustom ~list:trueof your own – its lines joined by,. To any other codec it is malformed, a400: 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, aPtime.t.Spindle.request: the wholeSpindle.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_cookieandSpindle.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_streamandSpindle.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
bearerread the header once. - Sharing works by identity. Define a dependency once,
let current = …, and use that value everywhere. A functionlet current () = …called twice makes two dependencies, which run twice.Dep.uncached drunsdat 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
~resourcesdoes not hold is refused before the server listens, naming the route and the resource; so is one supplied twice. One read insideDep.bindis found only as it runs: unsupplied, it is a500, 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.