use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyValue {
pub key: String,
pub value: String,
}
impl KeyValue {
pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
}
}
}
impl From<(String, String)> for KeyValue {
fn from((key, value): (String, String)) -> Self {
Self { key, value }
}
}
impl From<(&str, &str)> for KeyValue {
fn from((key, value): (&str, &str)) -> Self {
Self {
key: key.to_string(),
value: value.to_string(),
}
}
}
impl From<KeyValue> for (String, String) {
fn from(kv: KeyValue) -> Self {
(kv.key, kv.value)
}
}
pub type Timestamp = u64;
pub type DurationNs = u64;
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn duration_to_ns(d: Duration) -> DurationNs {
d.as_nanos() as u64
}
#[must_use]
pub fn ns_to_duration(ns: DurationNs) -> Duration {
Duration::from_nanos(ns)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum HttpVersion {
Http10 = 0,
#[default]
Http11 = 1,
Http2 = 2,
Http3 = 3,
}
impl std::fmt::Display for HttpVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpVersion::Http10 => write!(f, "HTTP/1.0"),
HttpVersion::Http11 => write!(f, "HTTP/1.1"),
HttpVersion::Http2 => write!(f, "HTTP/2"),
HttpVersion::Http3 => write!(f, "HTTP/3"),
}
}
}
#[derive(Debug, Clone)]
pub struct RequestMetadata {
pub client_ip: String,
pub client_port: u16,
pub tls_version: Option<String>,
pub tls_cipher: Option<String>,
pub server_name: Option<String>,
pub http_version: HttpVersion,
pub received_at: Timestamp,
}
impl Default for RequestMetadata {
fn default() -> Self {
Self {
client_ip: "127.0.0.1".to_string(),
client_port: 0,
tls_version: None,
tls_cipher: None,
server_name: None,
http_version: HttpVersion::Http11,
received_at: 0,
}
}
}
impl RequestMetadata {
#[must_use]
pub fn local() -> Self {
Self::default()
}
pub fn with_client(ip: impl Into<String>, port: u16) -> Self {
Self {
client_ip: ip.into(),
client_port: port,
..Default::default()
}
}
#[must_use]
pub fn with_tls(mut self, version: impl Into<String>, cipher: impl Into<String>) -> Self {
self.tls_version = Some(version.into());
self.tls_cipher = Some(cipher.into());
self
}
#[must_use]
pub fn with_server_name(mut self, name: impl Into<String>) -> Self {
self.server_name = Some(name.into());
self
}
#[must_use]
pub fn with_http_version(mut self, version: HttpVersion) -> Self {
self.http_version = version;
self
}
#[must_use]
pub fn with_timestamp(mut self, ts: Timestamp) -> Self {
self.received_at = ts;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upstream {
pub host: String,
pub port: u16,
pub tls: bool,
pub connect_timeout_ns: DurationNs,
pub request_timeout_ns: DurationNs,
}
impl Upstream {
pub fn new(host: impl Into<String>, port: u16) -> Self {
Self {
host: host.into(),
port,
tls: false,
connect_timeout_ns: duration_to_ns(Duration::from_secs(5)),
request_timeout_ns: duration_to_ns(Duration::from_secs(30)),
}
}
pub fn https(host: impl Into<String>, port: u16) -> Self {
Self {
tls: true,
..Self::new(host, port)
}
}
#[must_use]
pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout_ns = duration_to_ns(timeout);
self
}
#[must_use]
pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout_ns = duration_to_ns(timeout);
self
}
#[must_use]
pub fn connect_timeout(&self) -> Duration {
ns_to_duration(self.connect_timeout_ns)
}
#[must_use]
pub fn request_timeout(&self) -> Duration {
ns_to_duration(self.request_timeout_ns)
}
#[must_use]
pub fn url(&self) -> String {
let scheme = if self.tls { "https" } else { "http" };
format!("{}://{}:{}", scheme, self.host, self.port)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedirectInfo {
pub location: String,
pub status: u16,
pub preserve_body: bool,
}
impl RedirectInfo {
pub fn permanent(location: impl Into<String>) -> Self {
Self {
location: location.into(),
status: 301,
preserve_body: false,
}
}
pub fn temporary(location: impl Into<String>) -> Self {
Self {
location: location.into(),
status: 302,
preserve_body: false,
}
}
pub fn temporary_with_body(location: impl Into<String>) -> Self {
Self {
location: location.into(),
status: 307,
preserve_body: true,
}
}
pub fn permanent_with_body(location: impl Into<String>) -> Self {
Self {
location: location.into(),
status: 308,
preserve_body: true,
}
}
}
#[derive(Debug, Clone)]
pub struct ImmediateResponse {
pub status: u16,
pub headers: Vec<KeyValue>,
pub body: Vec<u8>,
}
impl ImmediateResponse {
#[must_use]
pub fn new(status: u16) -> Self {
Self {
status,
headers: Vec::new(),
body: Vec::new(),
}
}
#[must_use]
pub fn ok() -> Self {
Self::new(200)
}
#[must_use]
pub fn not_found() -> Self {
Self::new(404)
}
#[must_use]
pub fn forbidden() -> Self {
Self::new(403)
}
#[must_use]
pub fn internal_error() -> Self {
Self::new(500)
}
#[must_use]
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push(KeyValue::new(key, value));
self
}
#[must_use]
pub fn with_body(mut self, body: impl Into<Vec<u8>>) -> Self {
self.body = body.into();
self
}
#[must_use]
pub fn with_json_body(mut self, body: impl AsRef<[u8]>) -> Self {
self.headers
.push(KeyValue::new("Content-Type", "application/json"));
self.body = body.as_ref().to_vec();
self
}
#[must_use]
pub fn with_text_body(mut self, body: impl Into<String>) -> Self {
self.headers
.push(KeyValue::new("Content-Type", "text/plain"));
self.body = body.into().into_bytes();
self
}
}
#[derive(Debug, Clone)]
pub enum RoutingDecision {
Forward(Upstream),
Redirect(RedirectInfo),
RespondImmediate(ImmediateResponse),
ContinueProcessing,
}
impl RoutingDecision {
pub fn forward_http(host: impl Into<String>, port: u16) -> Self {
Self::Forward(Upstream::new(host, port))
}
pub fn forward_https(host: impl Into<String>, port: u16) -> Self {
Self::Forward(Upstream::https(host, port))
}
pub fn redirect_permanent(location: impl Into<String>) -> Self {
Self::Redirect(RedirectInfo::permanent(location))
}
pub fn redirect_temporary(location: impl Into<String>) -> Self {
Self::Redirect(RedirectInfo::temporary(location))
}
#[must_use]
pub fn respond(status: u16) -> Self {
Self::RespondImmediate(ImmediateResponse::new(status))
}
#[must_use]
pub fn continue_processing() -> Self {
Self::ContinueProcessing
}
}
#[derive(Debug, Clone)]
pub enum MiddlewareAction {
ContinueWith(Vec<KeyValue>),
Abort {
status: u16,
reason: String,
},
}
impl MiddlewareAction {
#[must_use]
pub fn continue_unchanged() -> Self {
Self::ContinueWith(Vec::new())
}
#[must_use]
pub fn continue_with_headers(headers: Vec<KeyValue>) -> Self {
Self::ContinueWith(headers)
}
pub fn continue_with_header(key: impl Into<String>, value: impl Into<String>) -> Self {
Self::ContinueWith(vec![KeyValue::new(key, value)])
}
pub fn bad_request(reason: impl Into<String>) -> Self {
Self::Abort {
status: 400,
reason: reason.into(),
}
}
pub fn unauthorized(reason: impl Into<String>) -> Self {
Self::Abort {
status: 401,
reason: reason.into(),
}
}
pub fn forbidden(reason: impl Into<String>) -> Self {
Self::Abort {
status: 403,
reason: reason.into(),
}
}
pub fn rate_limited(reason: impl Into<String>) -> Self {
Self::Abort {
status: 429,
reason: reason.into(),
}
}
pub fn internal_error(reason: impl Into<String>) -> Self {
Self::Abort {
status: 500,
reason: reason.into(),
}
}
pub fn abort(status: u16, reason: impl Into<String>) -> Self {
Self::Abort {
status,
reason: reason.into(),
}
}
#[must_use]
pub fn is_continue(&self) -> bool {
matches!(self, Self::ContinueWith(_))
}
#[must_use]
pub fn is_abort(&self) -> bool {
matches!(self, Self::Abort { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum MessageType {
Text = 0,
Binary = 1,
Ping = 2,
Pong = 3,
Close = 4,
}
impl std::fmt::Display for MessageType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MessageType::Text => write!(f, "text"),
MessageType::Binary => write!(f, "binary"),
MessageType::Ping => write!(f, "ping"),
MessageType::Pong => write!(f, "pong"),
MessageType::Close => write!(f, "close"),
}
}
}
#[derive(Debug, Clone)]
pub struct WebSocketMessage {
pub msg_type: MessageType,
pub data: Vec<u8>,
}
impl WebSocketMessage {
pub fn text(content: impl Into<String>) -> Self {
Self {
msg_type: MessageType::Text,
data: content.into().into_bytes(),
}
}
pub fn binary(data: impl Into<Vec<u8>>) -> Self {
Self {
msg_type: MessageType::Binary,
data: data.into(),
}
}
pub fn ping(data: impl Into<Vec<u8>>) -> Self {
Self {
msg_type: MessageType::Ping,
data: data.into(),
}
}
pub fn pong(data: impl Into<Vec<u8>>) -> Self {
Self {
msg_type: MessageType::Pong,
data: data.into(),
}
}
#[must_use]
pub fn close() -> Self {
Self {
msg_type: MessageType::Close,
data: Vec::new(),
}
}
#[must_use]
pub fn close_with_code(code: u16) -> Self {
Self {
msg_type: MessageType::Close,
data: code.to_be_bytes().to_vec(),
}
}
#[must_use]
pub fn as_text(&self) -> Option<&str> {
if self.msg_type == MessageType::Text {
std::str::from_utf8(&self.data).ok()
} else {
None
}
}
#[must_use]
pub fn is_control(&self) -> bool {
matches!(
self.msg_type,
MessageType::Ping | MessageType::Pong | MessageType::Close
)
}
}
#[derive(Debug, Clone)]
pub enum UpgradeDecision {
Accept,
AcceptWithHeaders(Vec<KeyValue>),
Reject {
status: u16,
reason: String,
},
}
impl UpgradeDecision {
#[must_use]
pub fn accept() -> Self {
Self::Accept
}
#[must_use]
pub fn accept_with_headers(headers: Vec<KeyValue>) -> Self {
Self::AcceptWithHeaders(headers)
}
pub fn accept_subprotocol(protocol: impl Into<String>) -> Self {
Self::AcceptWithHeaders(vec![KeyValue::new("Sec-WebSocket-Protocol", protocol)])
}
pub fn reject_forbidden(reason: impl Into<String>) -> Self {
Self::Reject {
status: 403,
reason: reason.into(),
}
}
pub fn reject_unauthorized(reason: impl Into<String>) -> Self {
Self::Reject {
status: 401,
reason: reason.into(),
}
}
pub fn reject(status: u16, reason: impl Into<String>) -> Self {
Self::Reject {
status,
reason: reason.into(),
}
}
#[must_use]
pub fn is_accepted(&self) -> bool {
matches!(self, Self::Accept | Self::AcceptWithHeaders(_))
}
}
#[derive(Debug, Clone)]
pub struct CacheEntry {
pub ttl_ns: DurationNs,
pub tags: Vec<String>,
pub vary: Vec<String>,
pub stale_while_revalidate_ns: Option<DurationNs>,
}
impl CacheEntry {
#[must_use]
pub fn new(ttl: Duration) -> Self {
Self {
ttl_ns: duration_to_ns(ttl),
tags: Vec::new(),
vary: Vec::new(),
stale_while_revalidate_ns: None,
}
}
#[must_use]
pub fn ttl_secs(seconds: u64) -> Self {
Self::new(Duration::from_secs(seconds))
}
#[must_use]
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
#[must_use]
pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.tags.extend(tags.into_iter().map(Into::into));
self
}
#[must_use]
pub fn vary_on(mut self, header: impl Into<String>) -> Self {
self.vary.push(header.into());
self
}
#[must_use]
pub fn with_stale_while_revalidate(mut self, duration: Duration) -> Self {
self.stale_while_revalidate_ns = Some(duration_to_ns(duration));
self
}
#[must_use]
pub fn ttl(&self) -> Duration {
ns_to_duration(self.ttl_ns)
}
pub fn stale_while_revalidate(&self) -> Option<Duration> {
self.stale_while_revalidate_ns.map(ns_to_duration)
}
}
#[derive(Debug, Clone)]
pub enum CacheDecision {
NoCache,
CacheFor(DurationNs),
CacheWithTags(CacheEntry),
}
impl CacheDecision {
#[must_use]
pub fn no_cache() -> Self {
Self::NoCache
}
#[must_use]
pub fn cache_for(duration: Duration) -> Self {
Self::CacheFor(duration_to_ns(duration))
}
#[must_use]
pub fn cache_for_secs(seconds: u64) -> Self {
Self::cache_for(Duration::from_secs(seconds))
}
#[must_use]
pub fn cache_with_entry(entry: CacheEntry) -> Self {
Self::CacheWithTags(entry)
}
#[must_use]
pub fn is_cacheable(&self) -> bool {
!matches!(self, Self::NoCache)
}
#[must_use]
pub fn ttl(&self) -> Option<Duration> {
match self {
Self::NoCache => None,
Self::CacheFor(ns) => Some(ns_to_duration(*ns)),
Self::CacheWithTags(entry) => Some(entry.ttl()),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum HttpMethod {
#[default]
Get = 0,
Post = 1,
Put = 2,
Delete = 3,
Patch = 4,
Head = 5,
Options = 6,
Connect = 7,
Trace = 8,
}
impl std::fmt::Display for HttpMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpMethod::Get => write!(f, "GET"),
HttpMethod::Post => write!(f, "POST"),
HttpMethod::Put => write!(f, "PUT"),
HttpMethod::Delete => write!(f, "DELETE"),
HttpMethod::Patch => write!(f, "PATCH"),
HttpMethod::Head => write!(f, "HEAD"),
HttpMethod::Options => write!(f, "OPTIONS"),
HttpMethod::Connect => write!(f, "CONNECT"),
HttpMethod::Trace => write!(f, "TRACE"),
}
}
}
impl std::str::FromStr for HttpMethod {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_uppercase().as_str() {
"GET" => Ok(HttpMethod::Get),
"POST" => Ok(HttpMethod::Post),
"PUT" => Ok(HttpMethod::Put),
"DELETE" => Ok(HttpMethod::Delete),
"PATCH" => Ok(HttpMethod::Patch),
"HEAD" => Ok(HttpMethod::Head),
"OPTIONS" => Ok(HttpMethod::Options),
"CONNECT" => Ok(HttpMethod::Connect),
"TRACE" => Ok(HttpMethod::Trace),
_ => Err(format!("Unknown HTTP method: {s}")),
}
}
}
#[derive(Debug, Clone)]
pub struct PluginRequest {
pub request_id: String,
pub path: String,
pub method: HttpMethod,
pub query: Option<String>,
pub headers: Vec<KeyValue>,
pub body: Vec<u8>,
pub timestamp: Timestamp,
pub context: Vec<KeyValue>,
}
impl PluginRequest {
#[allow(clippy::cast_possible_truncation)]
pub fn new(method: HttpMethod, path: impl Into<String>) -> Self {
Self {
request_id: uuid::Uuid::new_v4().to_string(),
path: path.into(),
method,
query: None,
headers: Vec::new(),
body: Vec::new(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64,
context: Vec::new(),
}
}
#[must_use]
pub fn get(path: impl Into<String>) -> Self {
Self::new(HttpMethod::Get, path)
}
#[must_use]
pub fn post(path: impl Into<String>) -> Self {
Self::new(HttpMethod::Post, path)
}
#[must_use]
pub fn with_query(mut self, query: impl Into<String>) -> Self {
self.query = Some(query.into());
self
}
#[must_use]
pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push(KeyValue::new(key, value));
self
}
#[must_use]
pub fn with_body(mut self, body: impl Into<Vec<u8>>) -> Self {
self.body = body.into();
self
}
#[must_use]
pub fn with_context(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.context.push(KeyValue::new(key, value));
self
}
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|kv| kv.key.eq_ignore_ascii_case(name))
.map(|kv| kv.value.as_str())
}
#[must_use]
pub fn context_value(&self, key: &str) -> Option<&str> {
self.context
.iter()
.find(|kv| kv.key == key)
.map(|kv| kv.value.as_str())
}
#[must_use]
pub fn uri(&self) -> String {
match &self.query {
Some(q) if !q.is_empty() => format!("{}?{}", self.path, q),
_ => self.path.clone(),
}
}
}
pub trait RouterPlugin {
fn route(&mut self, request: &PluginRequest, metadata: &RequestMetadata) -> RoutingDecision;
}
pub trait MiddlewarePlugin {
fn on_request(
&mut self,
method: &str,
path: &str,
headers: &[KeyValue],
metadata: &RequestMetadata,
) -> MiddlewareAction;
fn on_response(&mut self, status: u16, headers: &[KeyValue]) -> MiddlewareAction;
}
pub trait WebSocketPlugin {
fn on_upgrade(&mut self, path: &str, headers: &[KeyValue]) -> UpgradeDecision;
fn on_client_message(&mut self, message: &WebSocketMessage) -> Option<WebSocketMessage>;
fn on_upstream_message(&mut self, message: &WebSocketMessage) -> Option<WebSocketMessage>;
}
pub trait CachingPlugin {
fn cache_policy(
&mut self,
method: &str,
path: &str,
status: u16,
headers: &[KeyValue],
) -> CacheDecision;
fn cache_key(&mut self, method: &str, path: &str, headers: &[KeyValue]) -> String;
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum WasmInterfaceError {
#[error("function '{function}' not exported by component")]
FunctionNotFound { function: String },
#[error("failed to call '{function}': {reason}")]
CallFailed { function: String, reason: String },
#[error("failed to convert return value from '{function}': {reason}")]
ConversionFailed { function: String, reason: String },
#[error("component does not implement interface '{interface}'")]
InterfaceNotImplemented { interface: String },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_key_value_new() {
let kv = KeyValue::new("Content-Type", "application/json");
assert_eq!(kv.key, "Content-Type");
assert_eq!(kv.value, "application/json");
}
#[test]
fn test_key_value_from_tuple() {
let kv: KeyValue = ("Host".to_string(), "example.com".to_string()).into();
assert_eq!(kv.key, "Host");
assert_eq!(kv.value, "example.com");
}
#[test]
fn test_key_value_to_tuple() {
let kv = KeyValue::new("X-Custom", "value");
let (k, v): (String, String) = kv.into();
assert_eq!(k, "X-Custom");
assert_eq!(v, "value");
}
#[test]
fn test_duration_conversion_roundtrip() {
let original = Duration::from_secs(30);
let ns = duration_to_ns(original);
let converted = ns_to_duration(ns);
assert_eq!(original, converted);
}
#[test]
fn test_duration_conversion_millis() {
let original = Duration::from_millis(500);
let ns = duration_to_ns(original);
assert_eq!(ns, 500_000_000);
let converted = ns_to_duration(ns);
assert_eq!(original, converted);
}
#[test]
fn test_http_version_display() {
assert_eq!(HttpVersion::Http10.to_string(), "HTTP/1.0");
assert_eq!(HttpVersion::Http11.to_string(), "HTTP/1.1");
assert_eq!(HttpVersion::Http2.to_string(), "HTTP/2");
assert_eq!(HttpVersion::Http3.to_string(), "HTTP/3");
}
#[test]
fn test_http_version_default() {
assert_eq!(HttpVersion::default(), HttpVersion::Http11);
}
#[test]
fn test_request_metadata_local() {
let meta = RequestMetadata::local();
assert_eq!(meta.client_ip, "127.0.0.1");
assert_eq!(meta.client_port, 0);
assert!(meta.tls_version.is_none());
}
#[test]
fn test_request_metadata_builder() {
let meta = RequestMetadata::with_client("192.168.1.100", 54321)
.with_tls("TLSv1.3", "TLS_AES_256_GCM_SHA384")
.with_server_name("example.com")
.with_http_version(HttpVersion::Http2);
assert_eq!(meta.client_ip, "192.168.1.100");
assert_eq!(meta.client_port, 54321);
assert_eq!(meta.tls_version.as_deref(), Some("TLSv1.3"));
assert_eq!(meta.tls_cipher.as_deref(), Some("TLS_AES_256_GCM_SHA384"));
assert_eq!(meta.server_name.as_deref(), Some("example.com"));
assert_eq!(meta.http_version, HttpVersion::Http2);
}
#[test]
fn test_upstream_http() {
let upstream = Upstream::new("backend.local", 8080);
assert_eq!(upstream.host, "backend.local");
assert_eq!(upstream.port, 8080);
assert!(!upstream.tls);
assert_eq!(upstream.url(), "http://backend.local:8080");
}
#[test]
fn test_upstream_https() {
let upstream = Upstream::https("api.example.com", 443);
assert!(upstream.tls);
assert_eq!(upstream.url(), "https://api.example.com:443");
}
#[test]
fn test_upstream_timeouts() {
let upstream = Upstream::new("backend", 80)
.with_connect_timeout(Duration::from_secs(10))
.with_request_timeout(Duration::from_secs(60));
assert_eq!(upstream.connect_timeout(), Duration::from_secs(10));
assert_eq!(upstream.request_timeout(), Duration::from_secs(60));
}
#[test]
fn test_redirect_permanent() {
let redirect = RedirectInfo::permanent("https://example.com");
assert_eq!(redirect.status, 301);
assert!(!redirect.preserve_body);
}
#[test]
fn test_redirect_temporary_with_body() {
let redirect = RedirectInfo::temporary_with_body("https://example.com/new");
assert_eq!(redirect.status, 307);
assert!(redirect.preserve_body);
}
#[test]
fn test_immediate_response_builder() {
let resp = ImmediateResponse::ok()
.with_header("X-Custom", "value")
.with_json_body(r#"{"status":"ok"}"#);
assert_eq!(resp.status, 200);
assert_eq!(resp.headers.len(), 2);
assert!(!resp.body.is_empty());
}
#[test]
fn test_routing_decision_forward() {
let decision = RoutingDecision::forward_https("api.backend.com", 443);
match decision {
RoutingDecision::Forward(upstream) => {
assert!(upstream.tls);
assert_eq!(upstream.host, "api.backend.com");
}
_ => panic!("Expected Forward"),
}
}
#[test]
fn test_routing_decision_continue() {
let decision = RoutingDecision::continue_processing();
assert!(matches!(decision, RoutingDecision::ContinueProcessing));
}
#[test]
fn test_middleware_action_continue() {
let action = MiddlewareAction::continue_unchanged();
assert!(action.is_continue());
assert!(!action.is_abort());
}
#[test]
fn test_middleware_action_abort() {
let action = MiddlewareAction::forbidden("Access denied");
assert!(action.is_abort());
match action {
MiddlewareAction::Abort { status, reason } => {
assert_eq!(status, 403);
assert_eq!(reason, "Access denied");
}
MiddlewareAction::ContinueWith(_) => panic!("Expected Abort"),
}
}
#[test]
fn test_websocket_message_text() {
let msg = WebSocketMessage::text("Hello, World!");
assert_eq!(msg.msg_type, MessageType::Text);
assert_eq!(msg.as_text(), Some("Hello, World!"));
assert!(!msg.is_control());
}
#[test]
fn test_websocket_message_control() {
let ping = WebSocketMessage::ping(vec![1, 2, 3]);
assert!(ping.is_control());
assert_eq!(ping.msg_type, MessageType::Ping);
}
#[test]
fn test_upgrade_decision_accept() {
let decision = UpgradeDecision::accept();
assert!(decision.is_accepted());
}
#[test]
fn test_upgrade_decision_subprotocol() {
let decision = UpgradeDecision::accept_subprotocol("graphql-ws");
match decision {
UpgradeDecision::AcceptWithHeaders(headers) => {
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].key, "Sec-WebSocket-Protocol");
assert_eq!(headers[0].value, "graphql-ws");
}
_ => panic!("Expected AcceptWithHeaders"),
}
}
#[test]
fn test_cache_entry_builder() {
let entry = CacheEntry::ttl_secs(300)
.with_tag("products")
.with_tag("catalog")
.vary_on("Accept-Language")
.with_stale_while_revalidate(Duration::from_secs(60));
assert_eq!(entry.ttl(), Duration::from_secs(300));
assert_eq!(entry.tags, vec!["products", "catalog"]);
assert_eq!(entry.vary, vec!["Accept-Language"]);
assert_eq!(
entry.stale_while_revalidate(),
Some(Duration::from_secs(60))
);
}
#[test]
fn test_cache_decision_no_cache() {
let decision = CacheDecision::no_cache();
assert!(!decision.is_cacheable());
assert!(decision.ttl().is_none());
}
#[test]
fn test_cache_decision_cache_for() {
let decision = CacheDecision::cache_for_secs(3600);
assert!(decision.is_cacheable());
assert_eq!(decision.ttl(), Some(Duration::from_secs(3600)));
}
#[test]
fn test_http_method_display() {
assert_eq!(HttpMethod::Get.to_string(), "GET");
assert_eq!(HttpMethod::Post.to_string(), "POST");
assert_eq!(HttpMethod::Delete.to_string(), "DELETE");
}
#[test]
fn test_http_method_from_str() {
assert_eq!("GET".parse::<HttpMethod>().unwrap(), HttpMethod::Get);
assert_eq!("post".parse::<HttpMethod>().unwrap(), HttpMethod::Post);
assert!("INVALID".parse::<HttpMethod>().is_err());
}
#[test]
fn test_plugin_request_builder() {
let req = PluginRequest::post("/api/users")
.with_query("page=1&limit=10")
.with_header("Content-Type", "application/json")
.with_body(r#"{"name":"test"}"#.as_bytes())
.with_context("user_id", "123");
assert_eq!(req.method, HttpMethod::Post);
assert_eq!(req.path, "/api/users");
assert_eq!(req.query.as_deref(), Some("page=1&limit=10"));
assert_eq!(req.header("content-type"), Some("application/json"));
assert_eq!(req.context_value("user_id"), Some("123"));
assert_eq!(req.uri(), "/api/users?page=1&limit=10");
}
#[test]
fn test_plugin_request_uri_without_query() {
let req = PluginRequest::get("/api/health");
assert_eq!(req.uri(), "/api/health");
}
#[test]
fn test_wasm_interface_error_display() {
let err = WasmInterfaceError::FunctionNotFound {
function: "route".to_string(),
};
assert!(err.to_string().contains("route"));
assert!(err.to_string().contains("not exported"));
}
}