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!;
Interpretation capabilities
The extension above is tagless-final: This chooses the route and endpoint representations.
defunc(via = http) reads each endpoint's input roles and output kind and states one
HttpOperationAlg<Operation, Inputs, Kind> bound per endpoint, so the declaration names only
HttpRouteAlg and its domain. The operation already carries its domain context, argument signature,
argument names, and result; these are not repeated in the HTTP capability.
Operation::out::<Kind>() selects output meaning. .json(), .text(), and the other built-in
spellings are ordinary neutral methods on the operation declaration. A downstream
.out::<LoginOut>() follows the same path; the interpreter proves its converter when the endpoint
is folded.
The interpreter implements OutputKindAlg<Interpreter, From> for a custom kind to select its
conversion. Execution and text interpretations require the selected converter's OutputAlg<From>;
OpenAPI requires OpenApiOutputAlg<From> and can enumerate several response alternatives. Built-in
families such as TextOutAlg remain interpreter implementation capabilities, not additional bounds
that every API declaration must repeat.
The direct syntax builder remains available for constructing first-order trees explicitly. Both
forms use the same HttpOperationAlg interpretation.
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.
Four wrappers come before the kind. A declaration reads from the outside in and the kind closes it, so .out_header::<ETag>().result().json() answers with an ETag header around a result around a JSON body, and its handler returns (etag, Result<body, E>): the header is sent whether the handler succeeded or not. Written the other way round, .result().out_header::<ETag>().json(), the handler returns Result<(etag, body), E> and the header is sent only on success. A wrapper after the kind does not compile.
.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 what follows; a failure answers with the status and message itsHttpErrorAlgimpl gives..out_header::<CacheControl>()adds a response header. The handler returns(value, rest), because only the handler knows anETagor a cache lifetime..out_headers::<Signed>()adds every header a named product states, the output twin of.in_header::<Agent>(). The handler returns(headers, rest). Each member is one header named by its member name, socache_controliscache-control; anOptionthat isNonewrites nothing, and aVecwrites one header per value, which is how severalset-cookieheaders are stated. Prefer it over stacking.out_header, which adds one pair per header.
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
// The readings, with every header one product states: the handler returns `(Signed, body)`.
.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 the route algebra; the macro states its endpoint.
/// 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_apirequires a JSON endpoint for its operation; a fragment answering with a file requires a file endpoint instead. 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.