alux-http 0.3.0

Composable, interpreter-independent HTTP programs for meaning-first Rust design
Documentation

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 alux_ext::{OperationAlg, ext};
use alux_http::{HttpApiAlg, HttpProgramBuilder, JsonOutAlg, http};
use core::future::Future;

/// A downstream specification owns its primitive domain meaning.
trait StatusAlg {
    type Status;

    fn status(&self) -> impl Future<Output = Self::Status> + Send;
    fn status_at(&self, id: u32) -> impl Future<Output = Self::Status> + Send;
}

/// A derived method becomes a value an endpoint can be declared with, argument names included.
#[ext(name = StatusOperationExt, defunc)]
impl<This> This
where
    This: StatusAlg,
{
    async fn status_current(&self) -> This::Status {
        self.status().await
    }

    async fn status_for_id(&self, id: u32) -> This::Status {
        self.status_at(id).await
    }
}

/// The route program is declared before any framework is chosen.
#[ext(name = StatusApiExt, defunc(via = http))]
impl<This> This
where
    This: HttpApiAlg + JsonOutAlg,
{
    /// Declares the status surface: the current reading and one identified reading.
    fn status_api<Alg>(&self)
    where
        Alg: StatusAlg,
    {
        self.routes()
            // The reading as it stands.
            .get("/status", self.op(Alg::status_current).json())
            // One identified reading, its id taken from the path.
            .get("/status/:id", self.op(Alg::status_for_id).path::<u32>().json())
    }
}

struct App;

impl StatusAlg for App {
    type Status = u32;

    async fn status(&self) -> u32 {
        1
    }

    async fn status_at(&self, id: u32) -> u32 {
        id
    }
}

// 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("/status", builder.op(StatusCurrentOperation::<App>::default()).json())
    .into_program();
let _nested = builder.routes().nest("/api", builder.program(program)).into_program();

// Argument names and order survive from the authored method into the program.
assert_eq!(<StatusForIdOperation<App> as OperationAlg>::ARG_NAMES, ["id"]);

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:

#[derive(Deserialize)]
struct Filters {
    since: u64,
    limit: Option<u32>,
}

impl NamedValuesAlg for Filters {}

// `?since=…&limit=…`, and `limit` is optional because the field is.
self.routes().get("/readings", self.op(Alg::search).query::<Filters>().json())

.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 returning Result. Success answers with the kind you already chose; a failure answers with the status and message its HttpErrorAlg impl gives.
  • .out_header::<CacheControl>() adds a response header. The handler returns (value, body), because only the handler knows an ETag or 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 alux_http::HeaderNameAlg;

/// States the `x-request-id` header an answer carries.
struct RequestId;

impl HeaderNameAlg for RequestId {
    const HEADER_NAME: &'static str = "x-request-id";
}
self.routes()
    // A recording, which creates something and says so.
    .post("/record", self.op(Alg::record).body::<u32>().json().status::<201>())
    // One identified reading, or what its failure means.
    .get("/find/{id}", self.op(Alg::find).path::<u32>().json().result())

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 alux_ext::ext;
use alux_http::{HttpApiAlg, JsonOutAlg, http};
use core::future::Future;

trait StatusAlg {
    type Status;

    fn status(&self) -> impl Future<Output = Self::Status> + Send;
}

trait ItemsAlg {
    type Items;

    fn items(&self) -> impl Future<Output = Self::Items> + Send;
}

#[ext(name = StatusOperationExt, defunc)]
impl<This> This
where
    This: StatusAlg,
{
    /// Returns the status as it stands.
    async fn status_current(&self) -> This::Status {
        self.status().await
    }
}

#[ext(name = ItemsOperationExt, defunc)]
impl<This> This
where
    This: ItemsAlg,
{
    /// Returns every item the domain holds.
    async fn items_current(&self) -> This::Items {
        self.items().await
    }
}

/// One surface fragment. Its bounds name only what it uses: status, and JSON output.
#[ext(name = StatusApiExt, defunc(via = http))]
impl<This> This
where
    This: HttpApiAlg + JsonOutAlg,
{
    /// Declares the status route.
    fn status_api<Alg>(&self)
    where
        Alg: StatusAlg,
    {
        // The reading as it stands.
        self.routes().get("/status", self.op(Alg::status_current).json())
    }
}

/// Another fragment, declared independently, plausibly in another crate.
#[ext(name = ItemsApiExt, defunc(via = http))]
impl<This> This
where
    This: HttpApiAlg + JsonOutAlg,
{
    /// Declares the item route.
    fn items_api<Alg>(&self)
    where
        Alg: ItemsAlg,
    {
        // Every item the domain holds.
        self.routes().get("/items", self.op(Alg::items_current).json())
    }
}

/// The whole service: both fragments, with one of them under a path prefix.
#[ext(name = ServiceApiExt, defunc(via = http))]
impl<This> This
where
    This: HttpApiAlg,
{
    /// Declares `/status` beside `/v1/items`.
    fn service_api<Alg>(&self)
    where
        Alg: StatusAlg + ItemsAlg,
    {
        self.routes()
            // Both fragments, side by side.
            .merge(self.status_api::<Alg>())
            // The whole items fragment, under one prefix.
            .nest("/v1", self.items_api::<Alg>())
    }
}

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_api requires JsonOutAlg; a fragment answering with a file requires FileOutAlg instead. Neither imposes its needs on the other, and service_api requires exactly the union.
  • One surface everywhere. service_api is a value, so the served API, the OpenAPI document 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:

These read the same declaration instead of serving it:

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.