Hello! httpkit is not yet more than an experiment in API ergonomics backed by
strong usage of the type-system, that in this case is aimed both at being
delightful to use, impossible to misuse, and blazingly fast.
It was born out of necessity really, while working on the twitchboard cli
tool during one of the ReasonableCoding streams,
because working directly with
http/af is quite low-level.
- Getting Started
- Common Middleware
- Principles: Server-side
- Principles: Client-side
- Benchmarks
- Contributing
httpkit can be used both to build servers and to make requests as a client.
Documentation is still a work-in-progress, but there's examples in the tools
section that can give you a better idea of how to use the libraries. In short:
For making a request:
let req =
Httpkit.Client.Request.create(
~headers=[("User-Agent", "Reason HttpKit")],
`GET,
Uri.of_string("http://api.github.com/repos/ostera/httpkit"),
);
Httpkit_lwt.Client.(
req
|> Http.send
/*|> Https.send(~config=Https.Config.from_pems(~cert, ~priv_key)) */
>>= Response.body
|> Lwt_main.run
);For making a server:
Httpkit.Server.(
make(App.initial_state)
|> use(Common.log)
|> use(App.inc)
|> reply(App.json)
|> Httpkit_lwt.Server.Http.listen(~port=9999, ~on_start)
/*|> Httpkit_lwt.Server.Https.listen(~port=9999, ~on_start, ~key, ~cert) */
|> Lwt_main.run
);I encourage you to read on through the Principles and let me know what you think! 🙌🏼
You can install by pinning with opam:
$ opam pin add httpkit git+https://github.com/ostera/httpkit
$ opam pin add httpkit-lwt git+https://github.com/ostera/httpkitWorth noting that currently a few fixes to it's direct dependencies are needed, all of them by @anmonteiro 🙌 — you can install those fixes by pinning these dependencies:
$ opam pin add httpaf git+https://github.com/anmonteiro/httpaf#cherry-picking
$ opam pin add httpaf-lwt git+https://github.com/anmonteiro/httpaf#anmonteiro/pluggable-read-writeYou can install by dropping the following dependencies in your package.json:
{
"dependencies": {
"@opam/httpkit": "*",
"@opam/httpkit-lwt": "*",
"@opam/logs": "*",
"@opam/fmt": "*",
// ...
},
"resolutions": {
"@opam/httpkit": "ostera/httpkit:httpkit.opam#322ca26",
"@opam/httpkit-lwt": "ostera/httpkit:httpkit-lwt.opam#322ca26",
"@opam/httpaf": "anmonteiro/httpaf:httpaf.opam#57e9dd2",
"@opam/httpaf-lwt": "anmonteiro/httpaf:httpaf-lwt.opam#57e9dd2",
// ...
}
}
NOTE: Make sure you're using the latest commit hash!
As I start using httpkit in other projects, I'm quickly realizing there's a family of very common middleware that should be bundled as part of the library to
support getting up and running quickly.
So far I've identified two: Common.log, and Common.router.
Drop the logger anywhere in your middleware chain to log a request with method, path, and timestamp as an info.
ostera/httpkit λ ./_build/default/tools/HelloWorld.exe
Running on localhost:9999
HelloWorld.exe: [INFO] 15:56:43 — GET /with/code/401
HelloWorld.exe: [INFO] 15:56:44 — GET /with/code/402
HelloWorld.exe: [INFO] 15:56:45 — GET /with/code/403The router can be dropped in as a regular middleware or as a replier, and it's configurable with a routing table (in the form of a function that takes the current application state, and the tokenized path and returns a response).
It's very simple, and it relies on polymorphic variants to ensure that the statuses returned are valid:
let route_handler = (state, path) =>
switch (path) {
| [""] => `OK("hello world #" ++ state.random)
| ["admin"] =>
switch (state.user) {
| `Anonymous => `With_status((`Unauthorized, "Yikes! Login first."))
| `Admin(user) => `OK("welcome back, " ++ user.name ++ "!")
| _ => `Unmatched
}
| _ => `Unmatched
};
/* ... */
server
|> reply(Common.router(App.route_handler))
/* ... */If you were to use With_status with an invalid status (say, Unauthed), you'd
get this nice error:
Error: This expression has type [> `With_status of Httpaf.Status.t * string ]
but an expression was expected of type
[> `OK of string | `With_status of ([> `Unauthed ] as 'a) * string ]
Type
Httpaf.Status.t =
[ `Accepted
| `Bad_gateway
| `Bad_request
| `Code of int
| `Conflict
| `Continue
| `Created
| `Enhance_your_calm
| `Expectation_failed
| `Forbidden
| `Found
| ... # removed a few so it fits here :P
| `Unauthorized
| `Unsupported_media_type
| `Upgrade_required
| `Uri_too_long
| `Use_proxy ]
is not compatible with type [> `Uniauth ] as 'a
The first variant type does not allow tag(s) `Uniauth
Had errors.Note: I'm still investigating how to change the type signature of the server depending on what the middleware is doing with the context. I've got some ideas but it might take some time before they work. If you have some please find me on Discord as @ostera!
The following principles guide the server-side library:
- Servers should be safe to build
- Servers should be buildable using composable parts
- Servers should be responsive to all requests
- Servers should be fast to run
In other words, httpkit should make it impossibly hard to do the wrong thing
(i.e, Pit of
Success),
allow maximum code-reuse, with the guarantee that all requests will be handled,
and should be lightning fast.
Have you ever forgotten to call a function that did some side-effect, a necessary one, before calling the next one? A few come to mind:
- Read a file after you close it
- Write to a read-only buffer before aquiring a lock
- Forget to respond to a request deep down within your API
- ...and many more.
We all do this, we realize the problem fairly quickly, we fix it. But if we have tools that can help us with this, why aren't we using them more extensively?
By making heavy use of type-state and GADTs, httpkit aims to only let you
build servers that do The Right Thing:
- Servers can not be started if they don't always reply to a request
- All middleware must compose type-safely
I believe that these 2 invariants will make building web servers much safer.
If you manage to build a server that is not safe, this is a bug.
The first thing that came to mind when thinking of composable servers was
connect, of Node.js fame. The library didn't invent, but popularized the
concept of middleware in the Web community at large.
middleware is simply a function from a context value to the next state value to be handled by the next piece of middleware. Chaining them means a request is handled across multiple functions until one of them responds and the chain ends.
type middleware('a, 'b) = ctx('a) => 'b;The context itself is less relevant, but the important bit is that it's really just a function. And if you build them carefully, functions can compose very well.
Let's see an example. Here's a logging middleware that by contract promises us not touch the state:
/**
Because the state is a type variable, we can never know what it actually is,
and because of this we can't really change it!
This middleware ensures us that whatever state comes in, will come out.
*/
let log : middleware('a, 'a) = (ctx) => {
/* Put together a timestamp */
let {Unix.tm_hour, tm_min, tm_sec, _} = Unix.time() |> Unix.localtime;
let time = Printf.sprintf("%d:%d:%d", tm_hour, tm_min, tm_sec);
/* Get the string representation of the METHOD */
let meth = ctx.req.meth |> Httpaf.Method.to_string;
/* Get the path of the request, such as /users/1/edit */
let path = ctx.req.target;
/* Log things! */
Logs.info(m => m("%s — %s %s", time, meth, path));
/* Return the next state */
ctx.state;
};It's easy to see that given any ctx value, we can call log on it repeatedly:
ctx |> log |> log |> log |> logAssuming that our composition operator (|>) takes care of putting things back
into a ctx('a). We will go back to this in a second.
Of course this is ludicrous 😅 but if we instead of repeating a log function had
multiple bits and pieces of middleware that performed different things? That
sounds more useful. As long as the output type of the previous middleware
matches the input type of the next, we can compose them in long, type-safe
chains.
ctx /* <-- initial context value! */
|> log /* No change here, we just log stuff */
|> Analytics.trackReq /* No change here, register analytics */
|> Auth.track /* Auth.track returns `Valid(user) | `Auth_error(msg) */
|> Router.handle /* Routerhandle returns `Ok(content) | `Not_found(msg) */
|> Responder.respond; /* Turns `Ok(content) into HTTP 200, `Not_found to 404 */And in this way we can compose request paths with smaller functions, most of which can be built once and reused everywhere, and the rest of which will be safely composed.
The last missing piece here is this composition operator. Clearly |> will not
cut it. For this purpose, httpkit provides a combinator called use.
use takes a middleware, and a server, and returns another server that has that
middleware "stacked". If we change the snippet of code above to use it, it will
look like this:
ctx
|> use(log)
|> use(Analytics.trackReq)
|> use(Auth.track)
|> use(Router.handle)
|> use(Responder.respond);Or if you like operators (like I do) you can use *> and <<:
ctx
*> log
*> Analytics.trackReq
*> Auth.track
*> Router.handle
<< Responder.respond;Note: there's an additional combinator called
reply, which uses the operator<<and signals a response to a client.
If you manage to build middleware that should but does not compose safely, this is a bug.
TBD. I'm working on it 👨🏫
The main idea here is that httpkit should have a negligible impact on the
performance of your service. So if your API is slow, and it's because httpkit
is taking too long, then that's a bug.
Of course it'll take time to define what the maximum overhead should be (and how it should scale given you throw more middleware at it), but as an experiment it's doing quite well.
Let's see an echo server (which you can see in the tools directory of this
repo). First the raw version that does not use anything but some types from
httpkit:
ostera/httpkit λ wrk2 --threads=12 --connections=400 --duration=30s --rate 30K http://localhost:9999/what
Running 30s test @ http://localhost:9999/what
12 threads and 400 connections
Thread calibration: mean lat.: 3103.933ms, rate sampling interval: 11304ms
Thread calibration: mean lat.: 3074.874ms, rate sampling interval: 11141ms
Thread calibration: mean lat.: 3190.094ms, rate sampling interval: 11370ms
Thread calibration: mean lat.: 2974.283ms, rate sampling interval: 10543ms
Thread calibration: mean lat.: 2987.974ms, rate sampling interval: 11091ms
Thread calibration: mean lat.: 2895.381ms, rate sampling interval: 10919ms
Thread calibration: mean lat.: 2908.596ms, rate sampling interval: 10870ms
Thread calibration: mean lat.: 3164.646ms, rate sampling interval: 11526ms
Thread calibration: mean lat.: 3000.368ms, rate sampling interval: 11182ms
Thread calibration: mean lat.: 3051.481ms, rate sampling interval: 11386ms
Thread calibration: mean lat.: 3005.150ms, rate sampling interval: 11165ms
Thread calibration: mean lat.: 2956.432ms, rate sampling interval: 10960ms
Thread Stats Avg Stdev Max +/- Stdev
Latency 12.55s 3.75s 21.20s 59.01%
Req/Sec 0.91k 15.95 0.95k 75.00%
326792 requests in 30.01s, 12.15MB read
Socket errors: connect 0, read 97, write 4, timeout 11
Requests/sec: 10890.16
Transfer/sec: 414.76KBThat is a whopping 10890 Requests per Second, courtesy of http/af and lwt.
Seriously, have a look at those libraries.
The echo server above just bootstraps the minimum infrastructure needed to run a logging middleware, and integer incrementing middleware, and the json replier.
Below is the exact same servers, with the exact same handlers, built with
httpkit:
ostera/httpkit λ wrk2 --threads=12 --connections=400 --duration=30s --rate 30K http://localhost:9999/what
Running 30s test @ http://localhost:9999/what
12 threads and 400 connections
Thread calibration: mean lat.: 3092.528ms, rate sampling interval: 11714ms
Thread calibration: mean lat.: 3241.606ms, rate sampling interval: 12107ms
Thread calibration: mean lat.: 3183.128ms, rate sampling interval: 11763ms
Thread calibration: mean lat.: 3235.211ms, rate sampling interval: 12009ms
Thread calibration: mean lat.: 3109.703ms, rate sampling interval: 11780ms
Thread calibration: mean lat.: 3214.701ms, rate sampling interval: 12017ms
Thread calibration: mean lat.: 3174.243ms, rate sampling interval: 11862ms
Thread calibration: mean lat.: 3067.300ms, rate sampling interval: 11575ms
Thread calibration: mean lat.: 3066.558ms, rate sampling interval: 11755ms
Thread calibration: mean lat.: 3101.464ms, rate sampling interval: 11722ms
Thread calibration: mean lat.: 3084.410ms, rate sampling interval: 11829ms
Thread calibration: mean lat.: 3075.844ms, rate sampling interval: 11444ms
Thread Stats Avg Stdev Max +/- Stdev
Latency 12.87s 3.62s 20.76s 58.95%
Req/Sec 0.92k 18.58 0.95k 58.33%
326230 requests in 30.00s, 12.13MB read
Socket errors: connect 0, read 40, write 3, timeout 4
Requests/sec: 10874.67
Transfer/sec: 414.17KBAnd it clocked 10874 Requests per Second. That is just about 16 RPS less than
barebones http/af.
I'm running this benchmark essentially on every change to make sure that the
abstractions and safety that httpkit give you have close to zero impact on the
performance of your service.
TBD! I'm working on it 👨🏫