alux-http 0.1.1

Composable, interpreter-independent HTTP programs for meaning-first Rust design
Documentation
  • Coverage
  • 100%
    79 out of 79 items documented1 out of 1 items with examples
  • Size
  • Source code size: 34.31 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 1.51 MB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 7s Average build duration of successful builds.
  • all releases: 7s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • Homepage
  • Repository
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • tgrospic

alux-http

alux-http describes typed HTTP programs independently of a web framework.

The crate is a specification. It carries no interpreter and depends only on alux-ext: an HTTP surface is described here as first-order syntax plus the capability algebras an interpreter must witness. Domain specifications provide their own algebras and compose first-order operations into routes.

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;
}

/// Derived operations become first-order values that preserve their argument names.
#[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"]);

Composing surfaces

The reason to keep a surface first-order is that programs compose before anything interprets them. Two crates that know nothing about each other can each declare part of a service, and a third can state the whole of it — no shared route table, no registry, 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: a coproduct of 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()
            // Merge forms the route coproduct.
            .merge(self.status_api::<Alg>())
            // Nesting precomposes a prefix over an entire subtree.
            .nest("/v1", self.items_api::<Alg>())
    }
}

Two things there are worth pausing on, because the authored text is not what runs:

  • The declarations look like they return nothing. They do return something. The macro replaces the written signature with -> ServiceApiProgram<Alg> and the written body with ServiceApiProgram::default(), so calling service_api hands back a zero-sized program value. Writing no return type is the convention: the type is generated, and naming it by hand would only repeat the macro.
  • merge and nest are not receiving routes. The authored body is read as a description rather than executed, and a call to a sibling declaration is lifted into a nested program — the emitted body reads builder.merge(builder.program(builder.status_api::<Alg>())). That is what type-checks, and it is why one fragment composes with another without either knowing how the other was declared.

What that buys, and why it is not just tidiness:

  • Fragments state their own dependencies. status_api requires JsonOutAlg; a fragment returning a file would require FileOutAlg instead. Neither imposes its needs on the other, and service_api inherits exactly the union.
  • Nesting is selector precomposition, not a router feature — so /v1/items arises from composing /v1 with a subtree that never mentions it.
  • Composition happens before interpretation. service_api is a value; every interpreter sees the same merged surface, so documentation and execution cannot drift apart.
  • The surface is closed under composition. A merged program is a program, so it can be merged or nested again without a special case.

A program declared this way compiles through any crate that witnesses its algebras: