From d9d500b4c5b1bf858cb4469378fe86963ebdc472 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Mon, 25 May 2026 18:36:58 +0000 Subject: [PATCH] feat(net-core): composition primitives (#12) - Add workspace deps: bytes, http, http-body, futures-core, futures-sink - Wire crates/net/core/Cargo.toml with those deps (zero runtime deps) - Implement service.rs: Service, Layer, ServiceBuilder, Identity, Stack - Implement error_kind.rs: ErrorKind, HasErrorKind - Update lib.rs: module declarations and pub re-exports - ServiceBuilder derives Clone; layer() is #[must_use] --- Cargo.lock | 33 +++++- Cargo.toml | 13 ++- crates/net/core/Cargo.toml | 7 +- crates/net/core/src/error_kind.rs | 45 ++++++++ crates/net/core/src/lib.rs | 17 ++- crates/net/core/src/service.rs | 169 ++++++++++++++++++++++++++++++ 6 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 crates/net/core/src/error_kind.rs create mode 100644 crates/net/core/src/service.rs diff --git a/Cargo.lock b/Cargo.lock index da9a796..e5271f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -235,6 +241,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -385,8 +411,11 @@ dependencies = [ name = "oath-net-core" version = "0.1.0" dependencies = [ - "oath-model", - "thiserror", + "bytes", + "futures-core", + "futures-sink", + "http", + "http-body", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d394aa3..b361340 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,10 +40,15 @@ oath-risk-core = { path = "crates/risk/core", version = "0.1.0" } oath-strategy-core = { path = "crates/strategy/core", version = "0.1.0" } # External shared — backend-specific deps belong in individual crate Cargo.toml files -serde = { version = "1", features = ["derive"] } -thiserror = "2" -tokio = { version = "1", features = ["full"] } -tracing = "0.1" +bytes = "1" +http = "1" +http-body = "1" +futures-core = { version = "0.3", default-features = false } +futures-sink = { version = "0.3", default-features = false } +serde = { version = "1", features = ["derive"] } +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" # --- Lints --- # Centralized lint configuration inherited by all workspace crates via `[lints] workspace = true`. diff --git a/crates/net/core/Cargo.toml b/crates/net/core/Cargo.toml index 64fd989..0519734 100644 --- a/crates/net/core/Cargo.toml +++ b/crates/net/core/Cargo.toml @@ -9,5 +9,8 @@ license.workspace = true workspace = true [dependencies] -oath-model = { workspace = true } -thiserror = { workspace = true } +bytes = { workspace = true } +http = { workspace = true } +http-body = { workspace = true } +futures-core = { workspace = true } +futures-sink = { workspace = true } diff --git a/crates/net/core/src/error_kind.rs b/crates/net/core/src/error_kind.rs new file mode 100644 index 0000000..32c5b81 --- /dev/null +++ b/crates/net/core/src/error_kind.rs @@ -0,0 +1,45 @@ +//! Error classification shared across the entire network stack. +//! +//! Backends map their concrete error types to [`ErrorKind`] via +//! [`HasErrorKind`]. Adapters branch only on [`ErrorKind`] — they never +//! pattern-match backend-specific error variants. + +/// Coarse-grained classification of a network or protocol error. +/// +/// Every backend maps its concrete error type to one of these variants via +/// [`HasErrorKind`]. Layers such as `RetryLayer` and `CircuitBreakerLayer` +/// branch on this value; they never inspect the underlying error type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ErrorKind { + /// The operation did not complete within the allowed time. + Timeout, + + /// A network-level failure: TCP reset, DNS failure, TLS handshake error, etc. + Connection, + + /// The server signalled that the client is sending too many requests. + Throttled, + + /// Credentials were missing, invalid, or expired. + Auth, + + /// The request was well-formed but the server rejected it (4xx other than + /// auth/throttle). + Client, + + /// The server encountered an internal error (5xx). + Server, + + /// The error does not fit any other category. + Unknown, +} + +/// Implemented by error types that can be classified as an [`ErrorKind`]. +/// +/// Backends implement this on their concrete error types. Middleware layers +/// call [`HasErrorKind::kind`] to decide whether to retry, open a circuit, etc. +pub trait HasErrorKind { + /// Return the coarse classification of this error. + fn kind(&self) -> ErrorKind; +} diff --git a/crates/net/core/src/lib.rs b/crates/net/core/src/lib.rs index 40a0bca..3885bc8 100644 --- a/crates/net/core/src/lib.rs +++ b/crates/net/core/src/lib.rs @@ -1,2 +1,17 @@ -//! HTTP and WebSocket client traits. +//! `oath-net-core` — composition primitives and capability trait contracts. +//! +//! This crate is **zero I/O, zero runtime**. It defines the shared +//! abstractions that every layer in the network stack depends on: +//! +//! - [`service`] — `Service`, `Layer`, `ServiceBuilder`, `Identity`, `Stack` +//! - [`error_kind`] — `ErrorKind`, `HasErrorKind` +//! +//! No `tokio`, `hyper`, `reqwest`, `serde`, or `thiserror` may appear in this +//! crate's dependency graph. #![forbid(unsafe_code)] + +pub mod error_kind; +pub mod service; + +pub use error_kind::{ErrorKind, HasErrorKind}; +pub use service::{Identity, Layer, Service, ServiceBuilder, Stack}; diff --git a/crates/net/core/src/service.rs b/crates/net/core/src/service.rs new file mode 100644 index 0000000..94a0456 --- /dev/null +++ b/crates/net/core/src/service.rs @@ -0,0 +1,169 @@ +//! Core composition primitives: `Service`, `Layer`, `ServiceBuilder`, `Identity`, `Stack`. +//! +//! These are the building blocks for the entire network stack. Every middleware +//! concern is expressed as a [`Layer`] that wraps any [`Service`], and +//! [`ServiceBuilder`] composes them at compile time with no virtual dispatch. +//! +//! # Ordering invariant +//! +//! The **first** `.layer()` call is permanently the outermost wrapper and +//! therefore the first to handle each request. Subsequent calls are nested +//! progressively further inward. +//! +//! ```no_run +//! # use oath_net_core::service::{Layer, Service, ServiceBuilder}; +//! # use std::future::Future; +//! # struct TracingLayer; +//! # struct MetricsLayer; +//! # struct Transport; +//! # impl Layer for TracingLayer { type Service = S; fn layer(&self, s: S) -> S { s } } +//! # impl Layer for MetricsLayer { type Service = S; fn layer(&self, s: S) -> S { s } } +//! # impl Service<()> for Transport { +//! # type Response = (); +//! # type Error = (); +//! # fn call(&self, _: ()) -> impl Future> + Send { +//! # async { Ok(()) } +//! # } +//! # } +//! // TracingLayer is added first → it is outermost → handles every request first. +//! let svc = ServiceBuilder::new() +//! .layer(TracingLayer) // outermost: first to see each request +//! .layer(MetricsLayer) // innermost of the two wrappers +//! .service(Transport); // leaf: performs actual I/O +//! ``` + +use std::future::Future; + +/// A single async call: request in, `Result` out. +/// +/// Implementations must not require `&mut self` — services are shared across +/// tasks and must therefore be `Send + Sync`. Backpressure is handled inside +/// `call` (e.g. by awaiting a semaphore permit) rather than through a separate +/// `poll_ready`. +/// +/// Use RPITIT for the return type — no `async-trait`, no `dyn`, no per-call +/// allocation. +pub trait Service { + /// The value produced on success. + type Response; + + /// The error produced on failure. + type Error; + + /// Drive the request to completion. + fn call(&self, req: Req) -> impl Future> + Send; +} + +/// Transform one [`Service`] into another [`Service`]. +/// +/// Typically a struct that holds configuration and owns an inner service. The +/// outer layer's [`Layer::layer`] method wraps the inner service, producing a +/// new [`Service`] that adds the layer's behaviour. +pub trait Layer { + /// The wrapped service type produced by this layer. + type Service; + + /// Wrap `inner` with this layer's behaviour. + fn layer(&self, inner: S) -> Self::Service; +} + +/// Type-safe layer compositor. +/// +/// Layers are applied in **declaration order**: the first `.layer()` call is +/// the outermost wrapper and therefore the first to execute on each request. +/// +/// ``` +/// # use oath_net_core::service::{Identity, ServiceBuilder}; +/// let _builder = ServiceBuilder::new(); // starts with Identity (no-op) +/// ``` +#[derive(Debug, Clone)] +pub struct ServiceBuilder { + layer: L, +} + +impl Default for ServiceBuilder { + fn default() -> Self { + Self::new() + } +} + +impl ServiceBuilder { + /// Create a new builder with no layers applied. + #[must_use] + pub const fn new() -> Self { + Self { layer: Identity } + } +} + +impl ServiceBuilder { + /// Add a new layer `New` into this `ServiceBuilder`. + /// + /// `New` becomes the new `Inner`; the accumulated `L` remains `Outer` and + /// therefore executes first on every request. This preserves the invariant + /// that the **first** `.layer()` call stays permanently outermost. + #[must_use] + pub fn layer(self, layer: New) -> ServiceBuilder> { + ServiceBuilder { + layer: Stack { + inner: layer, + outer: self.layer, + }, + } + } + + /// Finalize the stack by wrapping a concrete service. + /// + /// Consumes the builder and returns the fully composed `Service` value. + /// The concrete type is fully resolved at compile time — no boxing, no + /// `dyn`. + pub fn service(self, service: S) -> L::Service + where + L: Layer, + { + self.layer.layer(service) + } +} + +/// The no-op layer — passes the inner service through unchanged. +/// +/// `Identity` is the initial state of a fresh [`ServiceBuilder`]. +#[derive(Debug, Clone, Copy)] +pub struct Identity; + +impl Layer for Identity { + type Service = S; + + fn layer(&self, inner: S) -> S { + inner + } +} + +/// Compose two [`Layer`] impls into one. +/// +/// When assembling the stack, `Inner.layer(leaf)` is applied first, then +/// `Outer.layer(result)`. `Outer` is therefore the outermost service and the +/// first to handle each request. +/// +/// Because [`ServiceBuilder::layer`] produces `Stack` with `New` in +/// the `Inner` slot and the accumulated `L` in the `Outer` slot, each new +/// layer is nested *inside* the existing stack — leaving the first `.layer()` +/// call's layer permanently outermost. +#[derive(Debug, Clone, Copy)] +pub struct Stack { + inner: Inner, + outer: Outer, +} + +impl Layer for Stack +where + Inner: Layer, + Outer: Layer, +{ + type Service = Outer::Service; + + fn layer(&self, service: S) -> Outer::Service { + // Apply Inner first (closer to the leaf), then wrap with Outer. + let inner_svc = self.inner.layer(service); + self.outer.layer(inner_svc) + } +}