use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::error::ProximaError;
use crate::load::{LoadContext, Spec};
use crate::pipe::PipeHandle;
#[derive(Debug, Clone)]
pub struct Chain {
middlewares: Vec<Value>,
upstream: Value,
}
impl Chain {
#[must_use]
pub fn then<Next: Into<Spec>>(mut self, next: Next) -> Self {
let prev_leaf = std::mem::replace(&mut self.upstream, inline_value(next.into()));
self.middlewares.push(prev_leaf);
self
}
#[must_use]
pub fn labeled(self, _name: impl Into<String>) -> Self {
self
}
}
impl From<Chain> for Spec {
fn from(value: Chain) -> Self {
let mut map = match value.upstream {
Value::Object(m) => m,
other => {
let mut m = Map::new();
m.insert("upstream".into(), other);
m
}
};
if !value.middlewares.is_empty() {
map.insert("middleware".into(), Value::Array(value.middlewares));
}
Spec::Inline(Value::Object(map))
}
}
fn inline_value(spec: Spec) -> Value {
match spec {
Spec::Inline(v) => v,
_ => Value::Null,
}
}
pub trait Composable: Into<Spec> + Sized {
fn then<Next: Into<Spec>>(self, next: Next) -> Chain {
Chain {
middlewares: vec![inline_value(self.into())],
upstream: inline_value(next.into()),
}
}
}
impl<T: Into<Spec>> Composable for T {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChainConfig {
#[serde(default)]
pub middlewares: Vec<Value>,
pub upstream: Value,
}
impl ChainConfig {
#[must_use]
pub fn to_value(&self) -> Value {
let mut map = match &self.upstream {
Value::Object(object) => object.clone(),
other => {
let mut object = Map::new();
object.insert("upstream".into(), other.clone());
object
}
};
if !self.middlewares.is_empty() {
map.insert("middleware".into(), Value::Array(self.middlewares.clone()));
}
Value::Object(map)
}
pub async fn build(&self, context: &LoadContext) -> Result<PipeHandle, ProximaError> {
crate::load::load(Spec::Inline(self.to_value()), context).await
}
#[must_use]
pub fn into_builder(self) -> ChainBuilder {
ChainBuilder { config: self }
}
}
impl From<ChainConfig> for Spec {
fn from(value: ChainConfig) -> Self {
Spec::Inline(value.to_value())
}
}
#[derive(Debug, Clone)]
pub struct ChainBuilder {
config: ChainConfig,
}
impl Chain {
#[must_use]
pub fn builder() -> ChainBuilder {
ChainBuilder {
config: ChainConfig {
middlewares: Vec::new(),
upstream: Value::Null,
},
}
}
}
impl ChainBuilder {
#[must_use]
pub fn middleware<Layer: Into<Spec>>(mut self, layer: Layer) -> Self {
self.config.middlewares.push(inline_value(layer.into()));
self
}
#[must_use]
pub fn rate_limit(self, config: crate::settings::RateLimit) -> Self {
self.middleware(config)
}
#[must_use]
pub fn auth(self, config: crate::settings::BearerAuth) -> Self {
self.middleware(config)
}
#[must_use]
pub fn client_auth(self, config: crate::settings::ClientAuth) -> Self {
self.middleware(config)
}
#[must_use]
pub fn retry(mut self, spec: Value) -> Self {
self.config.middlewares.push(tag_spec("retry", spec));
self
}
#[must_use]
pub fn transform(mut self, spec: Value) -> Self {
self.config.middlewares.push(tag_spec("transform", spec));
self
}
#[must_use]
pub fn validate(mut self, spec: Value) -> Self {
self.config.middlewares.push(tag_spec("validate", spec));
self
}
#[must_use]
pub fn upstream<Leaf: Into<Spec>>(mut self, leaf: Leaf) -> ChainConfig {
self.config.upstream = inline_value(leaf.into());
self.config
}
#[must_use]
pub fn to_config(&self) -> ChainConfig {
self.config.clone()
}
}
fn tag_spec(tag: &str, spec: Value) -> Value {
let mut map = match spec {
Value::Object(object) => object,
other => {
let mut object = Map::new();
object.insert("value".into(), other);
object
}
};
map.insert("type".into(), Value::String(tag.into()));
Value::Object(map)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::settings::{BearerAuth, HttpUpstream, RateLimit};
#[test]
fn upstream_alone_serializes_without_middleware_array() {
let upstream = HttpUpstream::url("https://example.com");
let spec: Spec = upstream.into();
let Spec::Inline(Value::Object(map)) = spec else {
panic!()
};
assert!(map.get("middleware").is_none());
}
#[test]
fn single_middleware_wrap_emits_one_entry_array() {
let composed = BearerAuth::allow_tokens(["t-1"]).then(HttpUpstream::url("https://backend"));
let spec: Spec = composed.into();
let Spec::Inline(Value::Object(map)) = spec else {
panic!()
};
let mw = map.get("middleware").and_then(|v| v.as_array()).unwrap();
assert_eq!(mw.len(), 1);
assert_eq!(mw[0].get("type").and_then(|v| v.as_str()), Some("auth"));
assert_eq!(
map.get("url").and_then(|v| v.as_str()),
Some("https://backend"),
);
}
#[test]
fn three_layer_chain_preserves_outer_first_order() {
let composed = BearerAuth::allow_tokens(["t-1"])
.then(RateLimit::token_bucket(100, 50))
.then(HttpUpstream::url("https://backend"));
let spec: Spec = composed.into();
let Spec::Inline(Value::Object(map)) = spec else {
panic!()
};
let mw = map.get("middleware").and_then(|v| v.as_array()).unwrap();
assert_eq!(mw.len(), 2);
assert_eq!(mw[0].get("type").and_then(|v| v.as_str()), Some("auth"));
assert_eq!(
mw[1].get("type").and_then(|v| v.as_str()),
Some("rate_limit"),
);
assert_eq!(
map.get("url").and_then(|v| v.as_str()),
Some("https://backend"),
);
}
#[test]
fn chain_then_replaces_leaf_and_promotes_previous() {
let composed = BearerAuth::allow_tokens(["t-1"])
.then(RateLimit::token_bucket(100, 50))
.then(HttpUpstream::url("https://backend"));
let spec: Spec = composed.into();
let Spec::Inline(Value::Object(map)) = spec else {
panic!()
};
assert_eq!(
map.get("url").and_then(|v| v.as_str()),
Some("https://backend"),
);
}
fn fluent_chain() -> ChainConfig {
Chain::builder()
.auth(BearerAuth::allow_tokens(["t-1"]))
.retry(serde_json::json!({ "max_attempts": 4, "retry_on_status": [503] }))
.rate_limit(RateLimit::token_bucket(100, 50))
.upstream(HttpUpstream::url("https://backend"))
}
#[test]
fn fluent_builder_carries_the_declarative_config_in_order() {
let config = fluent_chain();
let tags: Vec<&str> = config
.middlewares
.iter()
.filter_map(|mw| mw.get("type").and_then(Value::as_str))
.collect();
assert_eq!(tags, ["auth", "retry", "rate_limit"]);
assert_eq!(
config.upstream.get("url").and_then(Value::as_str),
Some("https://backend"),
);
}
#[test]
fn builder_and_config_lower_to_the_same_value() {
let config = fluent_chain();
let from_config = config.to_value();
let from_spec: Spec = config.clone().into();
let Spec::Inline(from_spec_value) = from_spec else {
panic!("ChainConfig lowers to an inline spec")
};
assert_eq!(from_config, from_spec_value);
assert_eq!(
from_config.get("url").and_then(Value::as_str),
Some("https://backend"),
);
let mw = from_config
.get("middleware")
.and_then(Value::as_array)
.unwrap();
assert_eq!(mw.len(), 3);
}
#[test]
fn into_builder_is_the_inverse_of_to_config() {
let config = fluent_chain();
let round = config.clone().into_builder().to_config();
assert_eq!(round, config, "config → builder → config is identity");
}
#[test]
fn round_trip_through_toml_is_identity() {
let declared = fluent_chain();
let toml_text = toml::to_string(&declared).expect("encode toml");
let reloaded: ChainConfig = toml::from_str(&toml_text).expect("decode toml");
assert_eq!(reloaded, declared, "TOML round-trip diverged");
assert_eq!(reloaded.into_builder().to_config(), declared);
}
#[test]
fn round_trip_through_json_is_identity() {
let declared = fluent_chain();
let json_text = serde_json::to_string(&declared).expect("encode json");
let reloaded: ChainConfig = serde_json::from_str(&json_text).expect("decode json");
assert_eq!(reloaded, declared, "JSON round-trip diverged");
assert_eq!(reloaded.to_value(), declared.to_value());
}
#[test]
fn bare_chain_without_middleware_omits_the_array() {
let config = Chain::builder().upstream(HttpUpstream::url("https://backend"));
assert!(config.middlewares.is_empty());
let value = config.to_value();
assert!(
value.get("middleware").is_none(),
"no middleware → no array"
);
assert_eq!(
value.get("url").and_then(Value::as_str),
Some("https://backend")
);
}
#[proxima::test]
async fn fluent_and_config_build_the_same_runtime_stack() {
use crate::load::LoadContext;
use crate::request::Request;
use proxima_primitives::pipe::SendPipe;
let leaf = serde_json::json!({ "type": "synth", "status": 200, "body": "ok" });
let declared = Chain::builder()
.auth(BearerAuth::allow_tokens(["t-1"]))
.rate_limit(RateLimit::token_bucket(100, 50))
.upstream(leaf);
let toml_text = toml::to_string(&declared).expect("encode");
let reloaded: ChainConfig = toml::from_str(&toml_text).expect("decode");
assert_eq!(reloaded, declared, "config round-trip identity");
let context = LoadContext::with_default_registry().expect("ctx");
let handle = reloaded.build(&context).await.expect("build chain handle");
let request = Request::builder()
.method("GET")
.path("/")
.header("authorization", "Bearer t-1")
.build()
.expect("request");
let response = SendPipe::call(&handle, request).await.expect("call");
assert_eq!(
response.status, 200,
"reached the synth leaf through auth + rate_limit"
);
}
}