use crate::{Error, Result};
use axum::http::{HeaderMap, Method, Uri};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::debug;
#[derive(Debug, Clone)]
struct StateInstance {
resource_id: String,
current_state: String,
resource_type: String,
state_data: HashMap<String, Value>,
}
impl StateInstance {
fn new(resource_id: String, resource_type: String, initial_state: String) -> Self {
Self {
resource_id,
current_state: initial_state,
resource_type,
state_data: HashMap::new(),
}
}
fn transition_to(&mut self, new_state: String) {
self.current_state = new_state;
}
}
struct StateMachineManager {
instances: Arc<RwLock<HashMap<String, StateInstance>>>,
}
impl StateMachineManager {
fn new() -> Self {
Self {
instances: Arc::new(RwLock::new(HashMap::new())),
}
}
async fn get_or_create_instance(
&self,
resource_id: String,
resource_type: String,
initial_state: String,
) -> Result<StateInstance> {
let mut instances = self.instances.write().await;
if let Some(instance) = instances.get(&resource_id) {
Ok(instance.clone())
} else {
let instance = StateInstance::new(resource_id.clone(), resource_type, initial_state);
instances.insert(resource_id, instance.clone());
Ok(instance)
}
}
async fn update_instance(&self, resource_id: String, instance: StateInstance) -> Result<()> {
let mut instances = self.instances.write().await;
instances.insert(resource_id, instance);
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatefulConfig {
pub resource_id_extract: ResourceIdExtract,
pub resource_type: String,
pub state_responses: HashMap<String, StateResponse>,
pub transitions: Vec<TransitionTrigger>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResourceIdExtract {
PathParam {
param: String,
},
JsonPath {
path: String,
},
Header {
name: String,
},
QueryParam {
param: String,
},
Composite {
extractors: Vec<ResourceIdExtract>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateResponse {
pub status_code: u16,
pub headers: HashMap<String, String>,
pub body_template: String,
pub content_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransitionTrigger {
#[serde(with = "method_serde")]
pub method: Method,
pub path_pattern: String,
pub from_state: String,
pub to_state: String,
pub condition: Option<String>,
}
mod method_serde {
use axum::http::Method;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S>(method: &Method, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
method.as_str().serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Method, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Method::from_bytes(s.as_bytes()).map_err(serde::de::Error::custom)
}
}
pub struct StatefulResponseHandler {
state_manager: Arc<StateMachineManager>,
configs: Arc<RwLock<HashMap<String, StatefulConfig>>>,
}
impl StatefulResponseHandler {
pub fn new() -> Result<Self> {
Ok(Self {
state_manager: Arc::new(StateMachineManager::new()),
configs: Arc::new(RwLock::new(HashMap::new())),
})
}
pub async fn add_config(&self, path_pattern: String, config: StatefulConfig) {
let mut configs = self.configs.write().await;
configs.insert(path_pattern, config);
}
pub async fn can_handle(&self, _method: &Method, path: &str) -> bool {
let configs = self.configs.read().await;
for (pattern, _) in configs.iter() {
if self.path_matches(pattern, path) {
return true;
}
}
false
}
pub async fn process_request(
&self,
method: &Method,
uri: &Uri,
headers: &HeaderMap,
body: Option<&[u8]>,
) -> Result<Option<StatefulResponse>> {
let path = uri.path();
let config = {
let configs = self.configs.read().await;
configs
.iter()
.find(|(pattern, _)| self.path_matches(pattern, path))
.map(|(_, config)| config.clone())
};
let config = match config {
Some(c) => c,
None => return Ok(None),
};
let resource_id =
self.extract_resource_id(&config.resource_id_extract, uri, headers, body)?;
let state_instance = self
.state_manager
.get_or_create_instance(
resource_id.clone(),
config.resource_type.clone(),
"initial".to_string(), )
.await?;
let new_state = self
.check_transitions(&config, method, path, &state_instance, headers, body)
.await?;
let current_state = if let Some(ref state) = new_state {
state.clone()
} else {
state_instance.current_state.clone()
};
let state_response = config.state_responses.get(¤t_state).ok_or_else(|| {
Error::invalid_state(format!("No response configuration for state '{}'", current_state))
})?;
if let Some(ref new_state) = new_state {
let mut updated_instance = state_instance.clone();
updated_instance.transition_to(new_state.clone());
self.state_manager
.update_instance(resource_id.clone(), updated_instance)
.await?;
}
Ok(Some(StatefulResponse {
status_code: state_response.status_code,
headers: state_response.headers.clone(),
body: self.render_body_template(&state_response.body_template, &state_instance)?,
content_type: state_response.content_type.clone(),
state: current_state,
resource_id: resource_id.clone(),
}))
}
fn extract_resource_id(
&self,
extract: &ResourceIdExtract,
uri: &Uri,
headers: &HeaderMap,
body: Option<&[u8]>,
) -> Result<String> {
let path = uri.path();
match extract {
ResourceIdExtract::PathParam { param } => {
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if let Some(last) = segments.last() {
Ok(last.to_string())
} else {
Err(Error::validation(format!(
"Could not extract path parameter '{}' from path '{}'",
param, path
)))
}
}
ResourceIdExtract::Header { name } => headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| Error::not_found("header", name)),
ResourceIdExtract::QueryParam { param } => {
uri.query()
.and_then(|q| {
url::form_urlencoded::parse(q.as_bytes())
.find(|(k, _)| k == param)
.map(|(_, v)| v.to_string())
})
.ok_or_else(|| Error::not_found("query parameter", param))
}
ResourceIdExtract::JsonPath { path: json_path } => {
let body_str = body
.and_then(|b| std::str::from_utf8(b).ok())
.ok_or_else(|| Error::validation("Request body is not valid UTF-8"))?;
let json: Value = serde_json::from_str(body_str)
.map_err(|e| Error::validation(format!("Invalid JSON body: {}", e)))?;
self.extract_json_path(&json, json_path)
}
ResourceIdExtract::Composite { extractors } => {
for extract in extractors {
if let Ok(id) = self.extract_resource_id(extract, uri, headers, body) {
return Ok(id);
}
}
Err(Error::validation("Could not extract resource ID from any source"))
}
}
}
fn extract_json_path(&self, json: &Value, path: &str) -> Result<String> {
let path = path.trim_start_matches('$').trim_start_matches('.');
let parts: Vec<&str> = path.split('.').collect();
let mut current = json;
for part in parts {
match current {
Value::Object(map) => {
current = map.get(part).ok_or_else(|| Error::not_found("JSON path", path))?;
}
Value::Array(arr) => {
let idx: usize = part
.parse()
.map_err(|_| Error::validation(format!("Invalid array index: {}", part)))?;
current = arr.get(idx).ok_or_else(|| {
Error::validation(format!("Array index {} out of bounds", idx))
})?;
}
_ => {
return Err(Error::validation(format!(
"Cannot traverse path '{}' at '{}'",
path, part
)));
}
}
}
match current {
Value::String(s) => Ok(s.clone()),
Value::Number(n) => Ok(n.to_string()),
_ => Err(Error::validation(format!(
"Path '{}' does not point to a string or number",
path
))),
}
}
async fn check_transitions(
&self,
config: &StatefulConfig,
method: &Method,
path: &str,
instance: &StateInstance,
headers: &HeaderMap,
body: Option<&[u8]>,
) -> Result<Option<String>> {
for transition in &config.transitions {
if transition.method != *method {
continue;
}
if !self.path_matches(&transition.path_pattern, path) {
continue;
}
if instance.current_state != transition.from_state {
continue;
}
if let Some(ref condition) = transition.condition {
if !self.evaluate_condition(condition, headers, body)? {
continue;
}
}
debug!(
"State transition triggered: {} -> {} for resource {}",
transition.from_state, transition.to_state, instance.resource_id
);
return Ok(Some(transition.to_state.clone()));
}
Ok(None)
}
fn evaluate_condition(
&self,
condition: &str,
_headers: &HeaderMap,
body: Option<&[u8]>,
) -> Result<bool> {
if condition.starts_with("$.") {
let body_str = body
.and_then(|b| std::str::from_utf8(b).ok())
.ok_or_else(|| Error::validation("Request body is not valid UTF-8"))?;
let json: Value = serde_json::from_str(body_str)
.map_err(|e| Error::validation(format!("Invalid JSON body: {}", e)))?;
let value = self.extract_json_path(&json, condition)?;
Ok(!value.is_empty() && value != "false" && value != "0")
} else {
Ok(true)
}
}
fn render_body_template(&self, template: &str, instance: &StateInstance) -> Result<String> {
let mut result = template.to_string();
result = result.replace("{{state}}", &instance.current_state);
result = result.replace("{{resource_id}}", &instance.resource_id);
for (key, value) in &instance.state_data {
let placeholder = format!("{{{{state_data.{}}}}}", key);
let value_str = match value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => serde_json::to_string(value).unwrap_or_default(),
};
result = result.replace(&placeholder, &value_str);
}
Ok(result)
}
#[allow(clippy::too_many_arguments)]
pub async fn process_stub_state(
&self,
method: &Method,
uri: &Uri,
headers: &HeaderMap,
body: Option<&[u8]>,
resource_type: &str,
resource_id_extract: &ResourceIdExtract,
initial_state: &str,
transitions: Option<&[TransitionTrigger]>,
) -> Result<Option<StateInfo>> {
let resource_id = self.extract_resource_id(resource_id_extract, uri, headers, body)?;
let state_instance = self
.state_manager
.get_or_create_instance(
resource_id.clone(),
resource_type.to_string(),
initial_state.to_string(),
)
.await?;
let new_state = if let Some(transition_list) = transitions {
let path = uri.path();
let mut transitioned_state = None;
for transition in transition_list {
if transition.method != *method {
continue;
}
if !self.path_matches(&transition.path_pattern, path) {
continue;
}
if state_instance.current_state != transition.from_state {
continue;
}
if let Some(ref condition) = transition.condition {
if !self.evaluate_condition(condition, headers, body)? {
continue;
}
}
debug!(
"State transition triggered in stub processing: {} -> {} for resource {}",
transition.from_state, transition.to_state, resource_id
);
transitioned_state = Some(transition.to_state.clone());
break; }
transitioned_state
} else {
None
};
let final_state = if let Some(ref new_state) = new_state {
let mut updated_instance = state_instance.clone();
updated_instance.transition_to(new_state.clone());
self.state_manager
.update_instance(resource_id.clone(), updated_instance)
.await?;
new_state.clone()
} else {
state_instance.current_state.clone()
};
Ok(Some(StateInfo {
resource_id: resource_id.clone(),
current_state: final_state,
state_data: state_instance.state_data.clone(),
}))
}
pub async fn update_resource_state(
&self,
resource_id: &str,
resource_type: &str,
new_state: &str,
) -> Result<()> {
let mut instances = self.state_manager.instances.write().await;
if let Some(instance) = instances.get_mut(resource_id) {
if instance.resource_type == resource_type {
instance.transition_to(new_state.to_string());
return Ok(());
}
}
Err(Error::not_found(
format!("resource of type '{}'", resource_type),
resource_id.to_string(),
))
}
pub async fn get_resource_state(
&self,
resource_id: &str,
resource_type: &str,
) -> Result<Option<StateInfo>> {
let instances = self.state_manager.instances.read().await;
if let Some(instance) = instances.get(resource_id) {
if instance.resource_type == resource_type {
return Ok(Some(StateInfo {
resource_id: resource_id.to_string(),
current_state: instance.current_state.clone(),
state_data: instance.state_data.clone(),
}));
}
}
Ok(None)
}
fn path_matches(&self, pattern: &str, path: &str) -> bool {
let pattern_regex = pattern.replace("{", "(?P<").replace("}", ">[^/]+)").replace("*", ".*");
let regex = regex::Regex::new(&format!("^{}$", pattern_regex));
match regex {
Ok(re) => re.is_match(path),
Err(_) => pattern == path, }
}
}
#[derive(Debug, Clone)]
pub struct StateInfo {
pub resource_id: String,
pub current_state: String,
pub state_data: HashMap<String, Value>,
}
#[derive(Debug, Clone)]
pub struct StatefulResponse {
pub status_code: u16,
pub headers: HashMap<String, String>,
pub body: String,
pub content_type: String,
pub state: String,
pub resource_id: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_state_instance_new() {
let instance =
StateInstance::new("order-123".to_string(), "order".to_string(), "pending".to_string());
assert_eq!(instance.resource_id, "order-123");
assert_eq!(instance.resource_type, "order");
assert_eq!(instance.current_state, "pending");
assert!(instance.state_data.is_empty());
}
#[test]
fn test_state_instance_transition_to() {
let mut instance =
StateInstance::new("order-123".to_string(), "order".to_string(), "pending".to_string());
instance.transition_to("confirmed".to_string());
assert_eq!(instance.current_state, "confirmed");
instance.transition_to("shipped".to_string());
assert_eq!(instance.current_state, "shipped");
}
#[test]
fn test_state_instance_clone() {
let instance =
StateInstance::new("order-123".to_string(), "order".to_string(), "pending".to_string());
let cloned = instance.clone();
assert_eq!(cloned.resource_id, instance.resource_id);
assert_eq!(cloned.current_state, instance.current_state);
}
#[test]
fn test_state_instance_debug() {
let instance =
StateInstance::new("order-123".to_string(), "order".to_string(), "pending".to_string());
let debug_str = format!("{:?}", instance);
assert!(debug_str.contains("order-123"));
assert!(debug_str.contains("pending"));
}
#[tokio::test]
async fn test_state_machine_manager_new() {
let manager = StateMachineManager::new();
let instances = manager.instances.read().await;
assert!(instances.is_empty());
}
#[tokio::test]
async fn test_state_machine_manager_get_or_create_new() {
let manager = StateMachineManager::new();
let instance = manager
.get_or_create_instance(
"order-123".to_string(),
"order".to_string(),
"pending".to_string(),
)
.await
.unwrap();
assert_eq!(instance.resource_id, "order-123");
assert_eq!(instance.current_state, "pending");
}
#[tokio::test]
async fn test_state_machine_manager_get_or_create_existing() {
let manager = StateMachineManager::new();
let instance1 = manager
.get_or_create_instance(
"order-123".to_string(),
"order".to_string(),
"pending".to_string(),
)
.await
.unwrap();
assert_eq!(instance1.current_state, "pending");
let instance2 = manager
.get_or_create_instance(
"order-123".to_string(),
"order".to_string(),
"confirmed".to_string(), )
.await
.unwrap();
assert_eq!(instance2.current_state, "pending"); }
#[tokio::test]
async fn test_state_machine_manager_update_instance() {
let manager = StateMachineManager::new();
let mut instance = manager
.get_or_create_instance(
"order-123".to_string(),
"order".to_string(),
"pending".to_string(),
)
.await
.unwrap();
instance.transition_to("confirmed".to_string());
manager.update_instance("order-123".to_string(), instance).await.unwrap();
let updated = manager
.get_or_create_instance(
"order-123".to_string(),
"order".to_string(),
"pending".to_string(),
)
.await
.unwrap();
assert_eq!(updated.current_state, "confirmed");
}
#[test]
fn test_stateful_config_serialize_deserialize() {
let config = StatefulConfig {
resource_id_extract: ResourceIdExtract::PathParam {
param: "order_id".to_string(),
},
resource_type: "order".to_string(),
state_responses: {
let mut map = HashMap::new();
map.insert(
"pending".to_string(),
StateResponse {
status_code: 200,
headers: HashMap::new(),
body_template: "{\"status\": \"pending\"}".to_string(),
content_type: "application/json".to_string(),
},
);
map
},
transitions: vec![],
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: StatefulConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.resource_type, "order");
}
#[test]
fn test_stateful_config_debug() {
let config = StatefulConfig {
resource_id_extract: ResourceIdExtract::PathParam {
param: "order_id".to_string(),
},
resource_type: "order".to_string(),
state_responses: HashMap::new(),
transitions: vec![],
};
let debug_str = format!("{:?}", config);
assert!(debug_str.contains("order"));
}
#[test]
fn test_stateful_config_clone() {
let config = StatefulConfig {
resource_id_extract: ResourceIdExtract::PathParam {
param: "id".to_string(),
},
resource_type: "user".to_string(),
state_responses: HashMap::new(),
transitions: vec![],
};
let cloned = config.clone();
assert_eq!(cloned.resource_type, "user");
}
#[test]
fn test_resource_id_extract_path_param() {
let extract = ResourceIdExtract::PathParam {
param: "order_id".to_string(),
};
let json = serde_json::to_string(&extract).unwrap();
assert!(json.contains("path_param"));
}
#[test]
fn test_resource_id_extract_json_path() {
let extract = ResourceIdExtract::JsonPath {
path: "$.order.id".to_string(),
};
let json = serde_json::to_string(&extract).unwrap();
assert!(json.contains("json_path"));
}
#[test]
fn test_resource_id_extract_header() {
let extract = ResourceIdExtract::Header {
name: "X-Order-ID".to_string(),
};
let json = serde_json::to_string(&extract).unwrap();
assert!(json.contains("header"));
}
#[test]
fn test_resource_id_extract_query_param() {
let extract = ResourceIdExtract::QueryParam {
param: "order_id".to_string(),
};
let json = serde_json::to_string(&extract).unwrap();
assert!(json.contains("query_param"));
}
#[test]
fn test_resource_id_extract_composite() {
let extract = ResourceIdExtract::Composite {
extractors: vec![
ResourceIdExtract::PathParam {
param: "id".to_string(),
},
ResourceIdExtract::Header {
name: "X-ID".to_string(),
},
],
};
let json = serde_json::to_string(&extract).unwrap();
assert!(json.contains("composite"));
}
#[test]
fn test_state_response_serialize_deserialize() {
let response = StateResponse {
status_code: 200,
headers: {
let mut h = HashMap::new();
h.insert("X-State".to_string(), "pending".to_string());
h
},
body_template: "{\"state\": \"{{state}}\"}".to_string(),
content_type: "application/json".to_string(),
};
let json = serde_json::to_string(&response).unwrap();
let deserialized: StateResponse = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.status_code, 200);
assert_eq!(deserialized.content_type, "application/json");
}
#[test]
fn test_state_response_clone() {
let response = StateResponse {
status_code: 201,
headers: HashMap::new(),
body_template: "{}".to_string(),
content_type: "text/plain".to_string(),
};
let cloned = response.clone();
assert_eq!(cloned.status_code, 201);
}
#[test]
fn test_state_response_debug() {
let response = StateResponse {
status_code: 404,
headers: HashMap::new(),
body_template: "Not found".to_string(),
content_type: "text/plain".to_string(),
};
let debug_str = format!("{:?}", response);
assert!(debug_str.contains("404"));
}
#[test]
fn test_transition_trigger_serialize_deserialize() {
let trigger = TransitionTrigger {
method: Method::POST,
path_pattern: "/orders/{id}/confirm".to_string(),
from_state: "pending".to_string(),
to_state: "confirmed".to_string(),
condition: None,
};
let json = serde_json::to_string(&trigger).unwrap();
let deserialized: TransitionTrigger = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.from_state, "pending");
assert_eq!(deserialized.to_state, "confirmed");
}
#[test]
fn test_transition_trigger_with_condition() {
let trigger = TransitionTrigger {
method: Method::POST,
path_pattern: "/orders/{id}/ship".to_string(),
from_state: "confirmed".to_string(),
to_state: "shipped".to_string(),
condition: Some("$.payment.verified".to_string()),
};
let json = serde_json::to_string(&trigger).unwrap();
assert!(json.contains("payment.verified"));
}
#[test]
fn test_transition_trigger_clone() {
let trigger = TransitionTrigger {
method: Method::DELETE,
path_pattern: "/orders/{id}".to_string(),
from_state: "pending".to_string(),
to_state: "cancelled".to_string(),
condition: None,
};
let cloned = trigger.clone();
assert_eq!(cloned.method, Method::DELETE);
}
#[tokio::test]
async fn test_stateful_response_handler_new() {
let handler = StatefulResponseHandler::new().unwrap();
let configs = handler.configs.read().await;
assert!(configs.is_empty());
}
#[tokio::test]
async fn test_stateful_response_handler_add_config() {
let handler = StatefulResponseHandler::new().unwrap();
let config = StatefulConfig {
resource_id_extract: ResourceIdExtract::PathParam {
param: "id".to_string(),
},
resource_type: "order".to_string(),
state_responses: HashMap::new(),
transitions: vec![],
};
handler.add_config("/orders/{id}".to_string(), config).await;
let configs = handler.configs.read().await;
assert!(configs.contains_key("/orders/{id}"));
}
#[tokio::test]
async fn test_stateful_response_handler_can_handle_true() {
let handler = StatefulResponseHandler::new().unwrap();
let config = StatefulConfig {
resource_id_extract: ResourceIdExtract::PathParam {
param: "id".to_string(),
},
resource_type: "order".to_string(),
state_responses: HashMap::new(),
transitions: vec![],
};
handler.add_config("/orders/{id}".to_string(), config).await;
assert!(handler.can_handle(&Method::GET, "/orders/123").await);
}
#[tokio::test]
async fn test_stateful_response_handler_can_handle_false() {
let handler = StatefulResponseHandler::new().unwrap();
assert!(!handler.can_handle(&Method::GET, "/orders/123").await);
}
#[test]
fn test_path_matching() {
let handler = StatefulResponseHandler::new().unwrap();
assert!(handler.path_matches("/orders/{id}", "/orders/123"));
assert!(handler.path_matches("/api/*", "/api/users"));
assert!(!handler.path_matches("/orders/{id}", "/orders/123/items"));
}
#[test]
fn test_path_matching_exact() {
let handler = StatefulResponseHandler::new().unwrap();
assert!(handler.path_matches("/api/health", "/api/health"));
assert!(!handler.path_matches("/api/health", "/api/health/check"));
}
#[test]
fn test_path_matching_multiple_params() {
let handler = StatefulResponseHandler::new().unwrap();
assert!(handler.path_matches("/users/{user_id}/orders/{order_id}", "/users/1/orders/2"));
}
#[test]
fn test_path_matching_wildcard() {
let handler = StatefulResponseHandler::new().unwrap();
assert!(handler.path_matches("/api/*", "/api/anything"));
assert!(handler.path_matches("/api/*", "/api/users/123"));
}
#[tokio::test]
async fn test_extract_resource_id_from_path() {
let handler = StatefulResponseHandler::new().unwrap();
let extract = ResourceIdExtract::PathParam {
param: "order_id".to_string(),
};
let uri: Uri = "/orders/12345".parse().unwrap();
let headers = HeaderMap::new();
let id = handler.extract_resource_id(&extract, &uri, &headers, None).unwrap();
assert_eq!(id, "12345");
}
#[tokio::test]
async fn test_extract_resource_id_from_header() {
let handler = StatefulResponseHandler::new().unwrap();
let extract = ResourceIdExtract::Header {
name: "x-order-id".to_string(),
};
let uri: Uri = "/orders".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert("x-order-id", "order-abc".parse().unwrap());
let id = handler.extract_resource_id(&extract, &uri, &headers, None).unwrap();
assert_eq!(id, "order-abc");
}
#[tokio::test]
async fn test_extract_resource_id_from_query_param() {
let handler = StatefulResponseHandler::new().unwrap();
let extract = ResourceIdExtract::QueryParam {
param: "id".to_string(),
};
let uri: Uri = "/orders?id=query-123".parse().unwrap();
let headers = HeaderMap::new();
let id = handler.extract_resource_id(&extract, &uri, &headers, None).unwrap();
assert_eq!(id, "query-123");
}
#[tokio::test]
async fn test_extract_resource_id_from_json_body() {
let handler = StatefulResponseHandler::new().unwrap();
let extract = ResourceIdExtract::JsonPath {
path: "$.order.id".to_string(),
};
let uri: Uri = "/orders".parse().unwrap();
let headers = HeaderMap::new();
let body = br#"{"order": {"id": "json-456"}}"#;
let id = handler.extract_resource_id(&extract, &uri, &headers, Some(body)).unwrap();
assert_eq!(id, "json-456");
}
#[tokio::test]
async fn test_extract_resource_id_composite() {
let handler = StatefulResponseHandler::new().unwrap();
let extract = ResourceIdExtract::Composite {
extractors: vec![
ResourceIdExtract::Header {
name: "x-id".to_string(),
},
ResourceIdExtract::PathParam {
param: "id".to_string(),
},
],
};
let uri: Uri = "/orders/fallback-123".parse().unwrap();
let headers = HeaderMap::new();
let id = handler.extract_resource_id(&extract, &uri, &headers, None).unwrap();
assert_eq!(id, "fallback-123");
}
#[tokio::test]
async fn test_extract_resource_id_header_not_found() {
let handler = StatefulResponseHandler::new().unwrap();
let extract = ResourceIdExtract::Header {
name: "x-missing".to_string(),
};
let uri: Uri = "/orders".parse().unwrap();
let headers = HeaderMap::new();
let result = handler.extract_resource_id(&extract, &uri, &headers, None);
assert!(result.is_err());
}
#[test]
fn test_extract_json_path_simple() {
let handler = StatefulResponseHandler::new().unwrap();
let json: Value = serde_json::json!({"id": "123"});
let result = handler.extract_json_path(&json, "$.id").unwrap();
assert_eq!(result, "123");
}
#[test]
fn test_extract_json_path_nested() {
let handler = StatefulResponseHandler::new().unwrap();
let json: Value = serde_json::json!({"order": {"details": {"id": "456"}}});
let result = handler.extract_json_path(&json, "$.order.details.id").unwrap();
assert_eq!(result, "456");
}
#[test]
fn test_extract_json_path_number() {
let handler = StatefulResponseHandler::new().unwrap();
let json: Value = serde_json::json!({"count": 42});
let result = handler.extract_json_path(&json, "$.count").unwrap();
assert_eq!(result, "42");
}
#[test]
fn test_extract_json_path_array_index() {
let handler = StatefulResponseHandler::new().unwrap();
let json: Value = serde_json::json!({"items": ["a", "b", "c"]});
let result = handler.extract_json_path(&json, "$.items.1").unwrap();
assert_eq!(result, "b");
}
#[test]
fn test_extract_json_path_not_found() {
let handler = StatefulResponseHandler::new().unwrap();
let json: Value = serde_json::json!({"other": "value"});
let result = handler.extract_json_path(&json, "$.missing");
assert!(result.is_err());
}
#[test]
fn test_render_body_template_state() {
let handler = StatefulResponseHandler::new().unwrap();
let instance =
StateInstance::new("order-123".to_string(), "order".to_string(), "pending".to_string());
let template = r#"{"status": "{{state}}"}"#;
let result = handler.render_body_template(template, &instance).unwrap();
assert_eq!(result, r#"{"status": "pending"}"#);
}
#[test]
fn test_render_body_template_resource_id() {
let handler = StatefulResponseHandler::new().unwrap();
let instance =
StateInstance::new("order-456".to_string(), "order".to_string(), "shipped".to_string());
let template = r#"{"id": "{{resource_id}}"}"#;
let result = handler.render_body_template(template, &instance).unwrap();
assert_eq!(result, r#"{"id": "order-456"}"#);
}
#[test]
fn test_render_body_template_state_data() {
let handler = StatefulResponseHandler::new().unwrap();
let mut instance =
StateInstance::new("order-789".to_string(), "order".to_string(), "shipped".to_string());
instance
.state_data
.insert("carrier".to_string(), Value::String("FedEx".to_string()));
let template = r#"{"carrier": "{{state_data.carrier}}"}"#;
let result = handler.render_body_template(template, &instance).unwrap();
assert_eq!(result, r#"{"carrier": "FedEx"}"#);
}
#[test]
fn test_render_body_template_multiple_placeholders() {
let handler = StatefulResponseHandler::new().unwrap();
let instance = StateInstance::new(
"order-abc".to_string(),
"order".to_string(),
"confirmed".to_string(),
);
let template = r#"{"id": "{{resource_id}}", "status": "{{state}}"}"#;
let result = handler.render_body_template(template, &instance).unwrap();
assert_eq!(result, r#"{"id": "order-abc", "status": "confirmed"}"#);
}
#[tokio::test]
async fn test_process_request_no_config() {
let handler = StatefulResponseHandler::new().unwrap();
let uri: Uri = "/orders/123".parse().unwrap();
let headers = HeaderMap::new();
let result = handler.process_request(&Method::GET, &uri, &headers, None).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_process_request_with_config() {
let handler = StatefulResponseHandler::new().unwrap();
let mut state_responses = HashMap::new();
state_responses.insert(
"initial".to_string(),
StateResponse {
status_code: 200,
headers: HashMap::new(),
body_template: r#"{"state": "{{state}}", "id": "{{resource_id}}"}"#.to_string(),
content_type: "application/json".to_string(),
},
);
let config = StatefulConfig {
resource_id_extract: ResourceIdExtract::PathParam {
param: "id".to_string(),
},
resource_type: "order".to_string(),
state_responses,
transitions: vec![],
};
handler.add_config("/orders/{id}".to_string(), config).await;
let uri: Uri = "/orders/test-123".parse().unwrap();
let headers = HeaderMap::new();
let result = handler.process_request(&Method::GET, &uri, &headers, None).await.unwrap();
assert!(result.is_some());
let response = result.unwrap();
assert_eq!(response.status_code, 200);
assert_eq!(response.state, "initial");
assert_eq!(response.resource_id, "test-123");
assert!(response.body.contains("test-123"));
}
#[tokio::test]
async fn test_process_request_with_transition() {
let handler = StatefulResponseHandler::new().unwrap();
let mut state_responses = HashMap::new();
state_responses.insert(
"initial".to_string(),
StateResponse {
status_code: 200,
headers: HashMap::new(),
body_template: r#"{"state": "{{state}}"}"#.to_string(),
content_type: "application/json".to_string(),
},
);
state_responses.insert(
"confirmed".to_string(),
StateResponse {
status_code: 200,
headers: HashMap::new(),
body_template: r#"{"state": "{{state}}"}"#.to_string(),
content_type: "application/json".to_string(),
},
);
let config = StatefulConfig {
resource_id_extract: ResourceIdExtract::PathParam {
param: "id".to_string(),
},
resource_type: "order".to_string(),
state_responses,
transitions: vec![TransitionTrigger {
method: Method::POST,
path_pattern: "/orders/{id}".to_string(),
from_state: "initial".to_string(),
to_state: "confirmed".to_string(),
condition: None,
}],
};
handler.add_config("/orders/{id}".to_string(), config).await;
let uri: Uri = "/orders/order-1".parse().unwrap();
let headers = HeaderMap::new();
let result = handler.process_request(&Method::GET, &uri, &headers, None).await.unwrap();
assert_eq!(result.unwrap().state, "initial");
let result = handler.process_request(&Method::POST, &uri, &headers, None).await.unwrap();
assert_eq!(result.unwrap().state, "confirmed");
let result = handler.process_request(&Method::GET, &uri, &headers, None).await.unwrap();
assert_eq!(result.unwrap().state, "confirmed");
}
#[tokio::test]
async fn test_process_stub_state() {
let handler = StatefulResponseHandler::new().unwrap();
let extract = ResourceIdExtract::PathParam {
param: "id".to_string(),
};
let uri: Uri = "/users/user-123".parse().unwrap();
let headers = HeaderMap::new();
let result = handler
.process_stub_state(
&Method::GET,
&uri,
&headers,
None,
"user",
&extract,
"active",
None,
)
.await
.unwrap();
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.resource_id, "user-123");
assert_eq!(info.current_state, "active");
}
#[tokio::test]
async fn test_process_stub_state_with_transition() {
let handler = StatefulResponseHandler::new().unwrap();
let extract = ResourceIdExtract::PathParam {
param: "id".to_string(),
};
let uri: Uri = "/users/user-456".parse().unwrap();
let headers = HeaderMap::new();
let _ = handler
.process_stub_state(
&Method::GET,
&uri,
&headers,
None,
"user",
&extract,
"active",
None,
)
.await
.unwrap();
let transitions = vec![TransitionTrigger {
method: Method::DELETE,
path_pattern: "/users/{id}".to_string(),
from_state: "active".to_string(),
to_state: "deleted".to_string(),
condition: None,
}];
let result = handler
.process_stub_state(
&Method::DELETE,
&uri,
&headers,
None,
"user",
&extract,
"active",
Some(&transitions),
)
.await
.unwrap();
let info = result.unwrap();
assert_eq!(info.current_state, "deleted");
}
#[tokio::test]
async fn test_get_resource_state_not_found() {
let handler = StatefulResponseHandler::new().unwrap();
let result = handler.get_resource_state("nonexistent", "order").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_get_resource_state_exists() {
let handler = StatefulResponseHandler::new().unwrap();
let extract = ResourceIdExtract::PathParam {
param: "id".to_string(),
};
let uri: Uri = "/orders/order-999".parse().unwrap();
let headers = HeaderMap::new();
handler
.process_stub_state(
&Method::GET,
&uri,
&headers,
None,
"order",
&extract,
"pending",
None,
)
.await
.unwrap();
let result = handler.get_resource_state("order-999", "order").await.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().current_state, "pending");
}
#[tokio::test]
async fn test_update_resource_state() {
let handler = StatefulResponseHandler::new().unwrap();
let extract = ResourceIdExtract::PathParam {
param: "id".to_string(),
};
let uri: Uri = "/orders/order-update".parse().unwrap();
let headers = HeaderMap::new();
handler
.process_stub_state(
&Method::GET,
&uri,
&headers,
None,
"order",
&extract,
"pending",
None,
)
.await
.unwrap();
handler.update_resource_state("order-update", "order", "shipped").await.unwrap();
let result = handler.get_resource_state("order-update", "order").await.unwrap();
assert_eq!(result.unwrap().current_state, "shipped");
}
#[tokio::test]
async fn test_update_resource_state_not_found() {
let handler = StatefulResponseHandler::new().unwrap();
let result = handler.update_resource_state("nonexistent", "order", "shipped").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_update_resource_state_wrong_type() {
let handler = StatefulResponseHandler::new().unwrap();
let extract = ResourceIdExtract::PathParam {
param: "id".to_string(),
};
let uri: Uri = "/orders/order-type-test".parse().unwrap();
let headers = HeaderMap::new();
handler
.process_stub_state(
&Method::GET,
&uri,
&headers,
None,
"order",
&extract,
"pending",
None,
)
.await
.unwrap();
let result = handler.update_resource_state("order-type-test", "user", "active").await;
assert!(result.is_err());
}
#[test]
fn test_evaluate_condition_json_path_truthy() {
let handler = StatefulResponseHandler::new().unwrap();
let headers = HeaderMap::new();
let body = br#"{"verified": "true"}"#;
let result = handler.evaluate_condition("$.verified", &headers, Some(body)).unwrap();
assert!(result);
}
#[test]
fn test_evaluate_condition_json_path_falsy() {
let handler = StatefulResponseHandler::new().unwrap();
let headers = HeaderMap::new();
let body = br#"{"verified": "false"}"#;
let result = handler.evaluate_condition("$.verified", &headers, Some(body)).unwrap();
assert!(!result);
}
#[test]
fn test_evaluate_condition_non_jsonpath() {
let handler = StatefulResponseHandler::new().unwrap();
let headers = HeaderMap::new();
let result = handler.evaluate_condition("some_condition", &headers, None).unwrap();
assert!(result); }
#[test]
fn test_state_info_clone() {
let info = StateInfo {
resource_id: "res-1".to_string(),
current_state: "active".to_string(),
state_data: HashMap::new(),
};
let cloned = info.clone();
assert_eq!(cloned.resource_id, "res-1");
}
#[test]
fn test_state_info_debug() {
let info = StateInfo {
resource_id: "res-2".to_string(),
current_state: "inactive".to_string(),
state_data: HashMap::new(),
};
let debug_str = format!("{:?}", info);
assert!(debug_str.contains("res-2"));
assert!(debug_str.contains("inactive"));
}
#[test]
fn test_stateful_response_clone() {
let response = StatefulResponse {
status_code: 200,
headers: HashMap::new(),
body: "{}".to_string(),
content_type: "application/json".to_string(),
state: "active".to_string(),
resource_id: "res-3".to_string(),
};
let cloned = response.clone();
assert_eq!(cloned.status_code, 200);
assert_eq!(cloned.state, "active");
}
#[test]
fn test_stateful_response_debug() {
let response = StatefulResponse {
status_code: 404,
headers: HashMap::new(),
body: "Not found".to_string(),
content_type: "text/plain".to_string(),
state: "deleted".to_string(),
resource_id: "res-4".to_string(),
};
let debug_str = format!("{:?}", response);
assert!(debug_str.contains("404"));
assert!(debug_str.contains("deleted"));
}
}