Request bodies, responses and errors¶
This page reads a JSON body, answers with JSON, and says no. Each is a value declared on the route, so the API’s document always matches what the route does.
JSON in, JSON out¶
An order is a record whose quantity has bounds. What was placed is another
record, answered at 201. An item that is sold out is a refusal the
application declares:
(* The shape and its bounds are the description's: a quantity of 0 is a 400
at body.quantity before the handler runs. *)
type order = { item : string; quantity : int [@min 1] [@max 20] }
[@@deriving wiretype]
type placed = { number : int; item : string; quantity : int }
[@@deriving wiretype]
(* A rule the description cannot know is the handler's, refused with a code
declared once, with its status and what it means. *)
let sold_out =
Spindle.Refusal.Code.make "sold_out" ~status:`Conflict
~doc:"The item is sold out."
let menu = [ ("tea", true); ("coffee", true); ("cake", false) ]
let place ({ item; quantity } : order) =
match List.assoc_opt item menu with
| Some true -> Ok { number = 1; item; quantity }
| Some false | None ->
Error
(Spindle.Refusal.make sold_out
(Printf.sprintf "There is no %s left today." item))
let routes =
[
Spindle.post ~refuses:[ sold_out ]
Spindle.Path.(s "orders")
(Spindle.Returns.json ~status:`Created placed_json)
(let+ order = Spindle.json order_json in
place order);
]
curl -i localhost:8080/orders -H 'content-type: application/json' \
-d '{"item": "tea", "quantity": 2}'
A body of the wrong shape is answered before your handler runs, with every problem at once:
{"error":"invalid","message":"Some of that request is not what it should be.","problems":[{"at":"body.quantity","code":"unexpected_type","message":"This must be a whole number, not text."},{"at":"body.item","code":"required","message":"This is required."}]}
curl localhost:8080/orders -H 'content-type: application/json' \
-d '{"item": "tea", "quantity": 0}'
{"error":"invalid","message":"Some of that request is not what it should be.","problems":[{"at":"body.quantity","code":"too_small","message":"This must be at least 1."}]}
A rule only your data can check is the handler’s own refusal:
curl -i localhost:8080/orders -H 'content-type: application/json' \
-d '{"item": "cake", "quantity": 1}'
HTTP/1.1 409 Conflict
content-type: application/json
...
{"error":"sold_out","message":"There is no cake left today."}
What happened:
[@@deriving wiretype]gives each record a description,order_jsonandplaced_json. The same value decodes the body, encodes the answer and describes the shape in the API’s document. A shape the deriver cannot describe is written withwiretype’s combinators.Spindle.json order_jsonreads the body. Types, required fields and bounds like[@min 1]are checked for you; a body that fails is a400.Returns.json ~status:`Created placed_jsondeclares the answer. The handler returns the plain record and the framework encodes it.sold_outis a refusal code, declared once with its status. The route lists it in~refuses.
The whole app
(* A JSON body in, a JSON answer out, and a refusal of the application's
own: POST /orders places an order, 201 with what was placed.
curl -i localhost:8080/orders -H 'content-type: application/json' \
-d '{"item": "tea", "quantity": 2}'
curl -i localhost:8080/orders -H 'content-type: application/json' \
-d '{"item": "tea", "quantity": 0}'
curl -i localhost:8080/orders -H 'content-type: application/json' \
-d '{"item": "cake", "quantity": 1}' *)
open Spindle.Syntax
(* The shape and its bounds are the description's: a quantity of 0 is a 400
at body.quantity before the handler runs. *)
type order = { item : string; quantity : int [@min 1] [@max 20] }
[@@deriving wiretype]
type placed = { number : int; item : string; quantity : int }
[@@deriving wiretype]
(* A rule the description cannot know is the handler's, refused with a code
declared once, with its status and what it means. *)
let sold_out =
Spindle.Refusal.Code.make "sold_out" ~status:`Conflict
~doc:"The item is sold out."
let menu = [ ("tea", true); ("coffee", true); ("cake", false) ]
let place ({ item; quantity } : order) =
match List.assoc_opt item menu with
| Some true -> Ok { number = 1; item; quantity }
| Some false | None ->
Error
(Spindle.Refusal.make sold_out
(Printf.sprintf "There is no %s left today." item))
let routes =
[
Spindle.post ~refuses:[ sold_out ]
Spindle.Path.(s "orders")
(Spindle.Returns.json ~status:`Created placed_json)
(let+ order = Spindle.json order_json in
place order);
]
let () =
Spindle.Log.setup ();
Eio_main.run @@ fun env -> Spindle.serve env routes
Response types¶
A route is Spindle.get, post, put, patch or delete (or
Spindle.route meth), then a path, what it returns, and its inputs. What it
returns decides what the handler must give back:
Spindle.get path Returns.html
(let+ ... in Ok page) (* a page, 200 *)
Spindle.get path Returns.text
(let+ ... in Ok "Hello.") (* text, 200 *)
Spindle.post path (Returns.json ~status:`Accepted verdict_json)
(let+ ... in Ok verdict) (* a value, encoded *)
Spindle.post path (Returns.empty ())
(let+ ... in Ok ()) (* nothing, 204 *)
Spindle.get path Returns.response
(let+ ... in Ok (Response.redirect "/there")) (* a response of its own *)
Spindle.get path (Returns.events Event.[ declare state ])
(let+ ... in Ok (fun send -> ...)) (* declared events *)
Spindle.get path (Returns.websocket chat)
(let+ ... in Ok (fun ws -> ...)) (* a WebSocket *)
Returns.json defaults to 200 and Returns.empty to 204. Event streams
have their own guide, and so do
WebSockets.
When success can be one of several statuses – made or replaced, queued or done – list them, each with a sentence for the document, and return the one you reached beside the value:
Spindle.put Path.(s "users" / user_id)
(Returns.json_response user_json
~statuses:[ (`Created, "The user was made."); (`OK, "The user was replaced.") ])
(let+ id = Spindle.param user_id and+ body = Spindle.json user_json in
match upsert pool id body with
| Ok (`Made u) -> Ok (`Created, u)
| Ok (`Replaced u) -> Ok (`OK, u)
| Error e -> Error (Spindle_postgres.refusal e))
Returns.empty_response ~statuses is the same with no body: the handler
returns Ok `Created or Ok `No_content. Every listed status must
be a 2xx; failing is a refusal. A JSON answer is never 204 or 205,
which HTTP sends without a body: an answer with nothing to say is
Returns.empty’s.
Setting cookies and headers¶
To set a cookie or a header, take Spindle.set_cookie or
Spindle.add_header as an input – each is a function – and call it:
let visits = Spindle.Cookie.named "visits" Spindle.Codec.int
Spindle.get Path.(s "welcome") Returns.text
(let+ set_cookie = Spindle.set_cookie
and+ add_header = Spindle.add_header in
set_cookie (Spindle.Cookie.make visits 3);
add_header ("cache-control", "max-age=60");
Ok "Welcome back.")
- What you set is sent with an
Okanswer only, never with a refusal. - Set cookies with
set_cookie, never withadd_header ("set-cookie", …). - Some fields are the server’s alone to write. A handler that sets one of
them itself, or a header value containing a line break, answers
500:- how the answer is framed:
Content-Length,Transfer-Encoding,Connection,Keep-Alive,Upgrade,TEandTrailer; - what the server derives:
Content-Type(from the route’sReturns),Set-Cookie(fromset_cookie),DateandX-Request-Id.
- how the answer is framed:
- To end the connection after an answer, return
Response.close_connection responsefrom aReturns.responseroute.
Signing in and out with a session is shown in Sessions.
A route that builds its own answer uses Returns.response and Response:
make, html, json, empty, redirect, stream, events, refusal,
and takeover for a protocol spoken after HTTP (see the
Response reference).
Errors¶
An Error is always a Refusal.t, made from a code you declare once
with its status and meaning:
let conflict =
Refusal.Code.make "conflict" ~status:`Conflict ~doc:"Somebody moved first."
Error (Refusal.make ~detail:(Store.error_to_string e) conflict
"Somebody moved first.")
The client gets {"error": "conflict", "message": "Somebody moved first."}.
The ~detail goes to the log only.
- List your codes in the route’s
~refuses. Codes its inputs bring (a JSON body’sunsupported_media_type, say) are added for you, and so are the framework’s own the route’s document lists from what it reads:invalidwhere it reads an input,unreadableandtoo_largewhere it reads a body,cross_originfor a method that changes something,upgrade_requiredfor a WebSocket, andbusyandinternaleverywhere. Any other –Refusal.not_foundfrom a handler, say – goes in~refuseslike your own. A code nobody declared is still answered, but logged as a bug, andSpindle.Test.callraises on it. - A name means one thing. Two codes with the same name but a different status or doc are refused when the app is made.
- A code fails, and an answer succeeds. A code’s status is
4xxor5xx, andReturns.json ~statusorReturns.empty ~statusis2xx; either the other way round is refused when the app is made. - A
401code needs~challenge, theWWW-Authenticatevalue, e.g.~challenge:{|Bearer realm="api"|}. - Your own answer for a bad body:
Spindle.json ~refusal:(bad_body, "That is not an order.") order_json, wherebad_bodyis a code you made withRefusal.Code.make. The dependency declares it for you. The client gets only your code and sentence; the list of problems goes to the log.
Forms and file uploads¶
A form’s fields are typed inputs, read like query parameters. A wrong one is
a problem at form.<name>:
let email = Spindle.Form.required "email" Spindle.Codec.string
let remember = Spindle.Form.checked "remember"
Spindle.post Path.(s "sign-up") (Spindle.Returns.json account_json)
(let+ email = email and+ remember = remember in
sign_up ~email ~remember)
Form.required,optionalandlisttake a codec.checkedistruewhen the field was sent at all, since an unticked box is not sent.- The body is read once, however many fields you read, as
application/x-www-form-urlencodedormultipart/form-data, up to the server’smax_body. Any otherContent-Typeis415. - A route reads one body: a form field beside
Spindle.jsonis refused when the app is made. - Files are
Form.File.required,optionalandlist, each aForm.File.twith afilename(text a person chose, never a path to trust), acontent_typeand thecontent. An upload too large to hold in memory is read withSpindle.multipart(large bodies).
No CSRF token is needed. A cross-site POST is refused with 403
cross_origin before any route sees it. ~check_origin:false on
Spindle.serve turns that check off, and with it your forms’ protection;
to accept another site of your own, list it in ~trusted_origins instead.
Large bodies¶
Spindle.json and Spindle.body hold the whole body, up to the server’s
max_body. For an upload too large to hold, read it as it arrives:
Spindle.body_stream ~max ()hands the handler aSpindle.Body.t, limited to the route’s ownmaxbytes. Read it a part at a time withBody.read. A failure (too large, stopped arriving) is a value you can answer withBody.refusal.~content_types:[ "text/csv" ]accepts only those types, eachtype/subtype,type/*ortype/*+suffix, compared without case or parameters. Any other type is answered415before a byte is read, and the API’s document lists them as the body’s types.Spindle.multipart ~max ()is the same body, split into the parts of amultipart/form-dataupload.Multipart.nextgives a part’s head (itsname,filenameand content type),Multipart.readits bytes. Nothing is written to a temporary file; where a part goes is up to you.
A stream can only be read while the handler runs. A route reads one body,
one way: listing a stream beside Spindle.json is refused when the app is
made.
Next: logging.