Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 9 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
7 changes: 5 additions & 2 deletions crates/net/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
45 changes: 45 additions & 0 deletions crates/net/core/src/error_kind.rs
Original file line number Diff line number Diff line change
@@ -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;
}
17 changes: 16 additions & 1 deletion crates/net/core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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};
169 changes: 169 additions & 0 deletions crates/net/core/src/service.rs
Original file line number Diff line number Diff line change
@@ -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<S> Layer<S> for TracingLayer { type Service = S; fn layer(&self, s: S) -> S { s } }
//! # impl<S> Layer<S> 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<Output = Result<(), ()>> + 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<Req> {
/// 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<Output = Result<Self::Response, Self::Error>> + 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<S> {
/// 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<L> {
layer: L,
}

impl Default for ServiceBuilder<Identity> {
fn default() -> Self {
Self::new()
}
}

impl ServiceBuilder<Identity> {
/// Create a new builder with no layers applied.
#[must_use]
pub const fn new() -> Self {
Self { layer: Identity }
}
}

impl<L> ServiceBuilder<L> {
/// 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<New>(self, layer: New) -> ServiceBuilder<Stack<New, L>> {
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<S>(self, service: S) -> L::Service
where
L: Layer<S>,
{
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<S> Layer<S> 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<New, L>` 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, Outer> {
inner: Inner,
outer: Outer,
}

impl<S, Inner, Outer> Layer<S> for Stack<Inner, Outer>
where
Inner: Layer<S>,
Outer: Layer<Inner::Service>,
{
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)
}
}