alux-http
alux-http lets you declare an HTTP API once, as an ordinary value, and then run that same
declaration on any web framework. The same declaration also reads as an OpenAPI 3.1
document or a typed TypeScript client, so the API is never written a second time.
This crate holds only the declaration: which routes exist, where each handler argument comes from,
and what each endpoint answers with. It contains no web framework and depends only on
alux-ext. A separate crate then interprets the declaration: one turns
it into Poem routes, another into an axum router, another into an OpenAPI document. Adding one
never changes the declaration.
use ;
use ;
use Future;
/// A downstream specification owns its primitive domain meaning.
/// A derived method becomes a value an endpoint can be declared with, argument names included.
/// The route program is declared before any framework is chosen.
;
// The same program is constructible directly, without the convenience macro and without an
// interpreter, because a route program is an ordinary value.
let builder = HttpProgramBuilder;
let program = builder
.routes
// The same endpoint, declared without the convenience macro.
.get
.into_program;
let _nested = builder.routes.nest.into_program;
// Argument names and order survive from the authored method into the program.
assert_eq!;
Methods
Declare the method an endpoint answers on with .get, .post, .put, .patch, .delete,
.head, .options, .trace, or .connect. Use .method to take the method as a type parameter
instead.
Inputs
Every handler argument says where it comes from: .path(), .query(), .body() for JSON,
.form(), .raw_body(), .multipart(), .in_header(), .cookie(), .auth(), and .context() when
you want the framework's own extractor. .with() takes a value the interpreter supplies directly.
Arguments are filled in the order you declare them. The declaration parses nothing itself: each interpreter does that with its framework's extractors.
Query strings, headers and cookies hold names and values, so the type you read one into has to be
a struct rather than a single value. Say so by implementing NamedValuesAlg:
// `?since=…&limit=…`, and `limit` is optional because the field is.
self.routes.get
.query::<String>() does not compile, because a lone String has no name for a caller to send it
under. Header names are converted for you, so a User-Agent header arrives in a user_agent field.
.multipart::<T>() reads a body that arrives as parts. Your type implements
FromPartsAlg, which says how to build it from a reader of parts, and each
interpreter supplies whichever reader its framework has. A reader of parts is a
ChunksAlg, which is a sequence you take one item at a time. Its items are
PartAlg, and a part's own content is another such sequence.
Outputs
Declare what an endpoint answers with: .json(), .text(), .html(), .bytes(), .file(),
.empty(), .redirect(), or .stream(). Your handler's return type is inferred, so you never
repeat it just to pick a format.
Not every kind accepts every result, and that is checked when you compile. .empty() takes a handler
returning () and rejects one returning data, so you cannot quietly throw a value away.
.stream() answers with a body produced over time. Your handler returns a type implementing
ChunksAlg, which says what a chunk is and how to take the next one, so you are not
committed to any particular stream type.
Three kinds wrap the one before them:
.status::<201>()sets the status code. Which code a created resource answers with belongs to the endpoint, not the handler..result()handles a handler returningResult. Success answers with the kind you already chose; a failure answers with the status and message itsHttpErrorAlgimpl gives..out_header::<CacheControl>()adds a response header. The handler returns(value, body), because only the handler knows anETagor a cache lifetime.
A header is just a name, so one this crate does not already ship is three lines of your own and no interpreter changes:
use HeaderNameAlg;
/// States the `x-request-id` header an answer carries.
;
self.routes
// A recording, which creates something and says so.
.post
// One identified reading, or what its failure means.
.get
Paths
Paths are parsed into segments when you declare them, so you do not write them for one particular
router. :id and {id} both mean one segment bound as id; *rest and {*rest} both mean
everything left over. Anything else matches literally.
Each interpreter then renders those segments the way its own router wants: Poem gets :id, axum and
actix-web get {id}, Rocket gets <id>. Every interpreter describes the path the same way though,
so two interpretations of one declaration can be compared.
Composing surfaces
A declaration is a value, so it composes before anything runs it. Two crates that know nothing about each other can each declare part of a service, and a third can declare the whole of it, with no shared route table, no registry, and no framework in the picture yet.
use ext;
use ;
use Future;
/// One surface fragment. Its bounds name only what it uses: status, and JSON output.
/// Another fragment, declared independently, plausibly in another crate.
/// The whole service: both fragments, with one of them under a path prefix.
Write no return type on a declaration: calling it hands back the declared API, and the type is
generated for you. Inside the body, merge puts two declarations beside each other and nest puts
one under a prefix, including a declaration from another crate.
That gives you:
- Fragments that state their own needs.
status_apirequiresJsonOutAlg; a fragment answering with a file requiresFileOutAlginstead. Neither imposes its needs on the other, andservice_apirequires exactly the union. - One surface everywhere.
service_apiis a value, so the served API, theOpenAPIdocument and the generated client are the same merged surface and cannot drift apart. - No special cases. A merged declaration is a declaration, so it can be merged or nested again.
Servers and lifecycle
HttpServerAlg specifies what it means to open, close and end one framework's executable route
surface. Closing releases the address; ending waits for what was already being served as well, up to
the interpreter's own drain. Each crate that serves chooses the surface, open handle and error types it needs. .lifecycle(commands)
turns a sequence of open and close commands into the events they produce, so an application can run one
server or switch between several without the declaration knowing anything about a runtime.
These crates serve the same declaration, without changing it:
alux-http-poemserves it with Poem.alux-http-axumserves it with axum.alux-http-actixserves it with actix-web.alux-http-salvoserves it with Salvo.alux-http-warpserves it with warp.alux-http-rocketserves it with Rocket.alux-http-directserves it with no web framework at all: it matches the request against the routes and calls the handler itself.alux-http-hypercarries the bytes for it over hyper.
These read the same declaration instead of serving it:
alux-http-openapigenerates anOpenAPIdocument for it.alux-http-typescriptgenerates a typed TypeScript client for it.alux-http-textprints its routes and types, which is useful in tests and documentation.
alux-http-conformance is a shared test suite: one declared
API, plus the requests and expected responses every crate above is checked against. It is how the
project knows they all behave the same.