use crate::config::{
ApiDeprecationConfig, DeprecatedEndpoint, DeprecationAction, DeprecationStatus,
PastSunsetAction,
};
use crate::headers::{gone_response_body, DeprecationHeaders};
use crate::metrics::DeprecationMetrics;
use async_trait::async_trait;
use chrono::Utc;
use sentinel_agent_sdk::{Agent, Decision, Request, Response};
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{debug, info, warn};
pub struct ApiDeprecationAgent {
config: ApiDeprecationConfig,
metrics: Arc<DeprecationMetrics>,
}
impl ApiDeprecationAgent {
pub fn new(config: ApiDeprecationConfig) -> Self {
let metrics = Arc::new(DeprecationMetrics::new(&config.metrics.prefix));
for endpoint in &config.endpoints {
if let Some(sunset) = &endpoint.sunset_at {
let days = (*sunset - Utc::now()).num_days();
metrics.set_days_until_sunset(&endpoint.id, &endpoint.path, days);
}
}
info!(
endpoints = config.endpoints.len(),
"API deprecation agent initialized"
);
Self {
config,
metrics,
}
}
pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
let config: ApiDeprecationConfig = serde_yaml::from_str(yaml)?;
Ok(Self::new(config))
}
pub fn metrics(&self) -> &DeprecationMetrics {
&self.metrics
}
fn process_request(
&self,
path: &str,
method: &str,
query_string: Option<&str>,
) -> Option<DeprecationDecision> {
let endpoint = self.config.find_endpoint(path, method)?;
debug!(
endpoint_id = %endpoint.id,
path = %path,
method = %method,
"Request matches deprecated endpoint"
);
if endpoint.track_usage {
let status = match endpoint.status {
DeprecationStatus::Deprecated => "deprecated",
DeprecationStatus::Removed => "removed",
DeprecationStatus::Scheduled => "scheduled",
};
self.metrics
.record_request(&endpoint.id, path, method, status);
}
let past_sunset = endpoint.is_past_sunset();
if past_sunset {
warn!(
endpoint_id = %endpoint.id,
sunset = ?endpoint.sunset_at,
"Request to endpoint past sunset date"
);
}
let action = self.determine_action(endpoint, past_sunset);
let headers = DeprecationHeaders::for_endpoint(endpoint, &self.config.settings).build();
let redirect_url = if matches!(action, DeprecationActionResult::Redirect { .. }) {
endpoint.replacement.as_ref().map(|r| {
let mut url = r.path.clone();
if r.preserve_query {
if let Some(qs) = query_string {
if !qs.is_empty() {
url.push('?');
url.push_str(qs);
}
}
}
url
})
} else {
None
};
Some(DeprecationDecision {
endpoint_id: endpoint.id.clone(),
action,
headers,
redirect_url,
message: endpoint.deprecation_message(),
documentation_url: endpoint.documentation_url.clone(),
})
}
fn determine_action(
&self,
endpoint: &DeprecatedEndpoint,
past_sunset: bool,
) -> DeprecationActionResult {
if matches!(endpoint.status, DeprecationStatus::Removed) {
return DeprecationActionResult::Block { status_code: 410 };
}
if past_sunset {
return match self.config.settings.past_sunset_action {
PastSunsetAction::Warn => DeprecationActionResult::Warn,
PastSunsetAction::Block => DeprecationActionResult::Block { status_code: 410 },
PastSunsetAction::Redirect => {
if endpoint.replacement.is_some() {
DeprecationActionResult::Redirect { status_code: 301 }
} else {
DeprecationActionResult::Block { status_code: 410 }
}
}
};
}
match &endpoint.action {
DeprecationAction::Warn => DeprecationActionResult::Warn,
DeprecationAction::Redirect { status_code } => DeprecationActionResult::Redirect {
status_code: *status_code,
},
DeprecationAction::Block { status_code } => DeprecationActionResult::Block {
status_code: *status_code,
},
DeprecationAction::Custom {
status_code,
body,
content_type,
} => DeprecationActionResult::Custom {
status_code: *status_code,
body: body.clone(),
content_type: content_type.clone(),
},
}
}
fn apply_headers(&self, decision: Decision, headers: HashMap<String, String>) -> Decision {
let mut d = decision;
for (name, value) in headers {
d = d.add_response_header(name, value);
}
d
}
}
struct DeprecationDecision {
endpoint_id: String,
action: DeprecationActionResult,
headers: HashMap<String, String>,
redirect_url: Option<String>,
message: String,
documentation_url: Option<String>,
}
#[derive(Debug, Clone)]
enum DeprecationActionResult {
Warn,
Redirect { status_code: u16 },
Block { status_code: u16 },
Custom {
status_code: u16,
body: String,
content_type: String,
},
}
unsafe impl Send for ApiDeprecationAgent {}
unsafe impl Sync for ApiDeprecationAgent {}
#[async_trait]
impl Agent for ApiDeprecationAgent {
async fn on_request(&self, request: &Request) -> Decision {
let method = request.method();
let path = request.path();
let query_string = request.query_string();
let decision = match self.process_request(path, method, query_string) {
Some(d) => d,
None => {
return Decision::allow();
}
};
if self.config.settings.log_access {
info!(
endpoint_id = %decision.endpoint_id,
path = %path,
method = %method,
action = ?decision.action,
"Deprecated endpoint accessed"
);
}
match decision.action {
DeprecationActionResult::Warn => {
let mut d = Decision::allow()
.with_tag("deprecated")
.with_metadata("deprecated_endpoint", serde_json::json!(decision.endpoint_id));
d = self.apply_headers(d, decision.headers);
d
}
DeprecationActionResult::Redirect { status_code } => {
if let Some(redirect_url) = decision.redirect_url {
self.metrics.record_redirect(
&decision.endpoint_id,
path,
&redirect_url,
);
let mut d = if status_code == 301 {
Decision::redirect_permanent(&redirect_url)
} else if status_code == 302 {
Decision::redirect(&redirect_url)
} else {
Decision::block(status_code)
.with_block_header("Location", &redirect_url)
.with_body("")
};
d = d
.with_tag("deprecated")
.with_tag("redirected")
.with_metadata("deprecated_endpoint", serde_json::json!(decision.endpoint_id))
.with_metadata("redirect_target", serde_json::json!(redirect_url));
for (name, value) in decision.headers {
d = d.with_block_header(name, value);
}
d
} else {
self.metrics
.record_blocked(&decision.endpoint_id, path, "no_replacement");
Decision::block(410)
.with_body(gone_response_body(&DeprecatedEndpoint {
id: decision.endpoint_id.clone(),
path: path.to_string(),
methods: vec![],
status: DeprecationStatus::Removed,
deprecated_at: None,
sunset_at: None,
replacement: None,
documentation_url: decision.documentation_url,
message: Some(decision.message),
action: DeprecationAction::Block { status_code: 410 },
headers: HashMap::new(),
track_usage: false,
path_matcher: None,
}))
.with_block_header("Content-Type", "application/json")
.with_tag("deprecated")
.with_tag("blocked")
}
}
DeprecationActionResult::Block { status_code } => {
self.metrics
.record_blocked(&decision.endpoint_id, path, "removed");
let body = gone_response_body(&DeprecatedEndpoint {
id: decision.endpoint_id.clone(),
path: path.to_string(),
methods: vec![],
status: DeprecationStatus::Removed,
deprecated_at: None,
sunset_at: None,
replacement: None,
documentation_url: decision.documentation_url,
message: Some(decision.message),
action: DeprecationAction::Block { status_code },
headers: HashMap::new(),
track_usage: false,
path_matcher: None,
});
let mut d = Decision::block(status_code)
.with_body(body)
.with_block_header("Content-Type", "application/json")
.with_tag("deprecated")
.with_tag("blocked")
.with_metadata("deprecated_endpoint", serde_json::json!(decision.endpoint_id));
for (name, value) in decision.headers {
d = d.with_block_header(name, value);
}
d
}
DeprecationActionResult::Custom {
status_code,
body,
content_type,
} => {
Decision::block(status_code)
.with_body(body)
.with_block_header("Content-Type", content_type)
.with_tag("deprecated")
.with_tag("custom_response")
.with_metadata("deprecated_endpoint", serde_json::json!(decision.endpoint_id))
}
}
}
async fn on_response(&self, _request: &Request, _response: &Response) -> Decision {
Decision::allow()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DeprecationStatus;
fn test_config() -> ApiDeprecationConfig {
let yaml = r#"
endpoints:
- id: legacy-users
path: /api/v1/users
methods: [GET, POST]
status: deprecated
sunset_at: "2030-06-01T00:00:00Z"
replacement:
path: /api/v2/users
documentation_url: https://docs.example.com/migration
action:
type: warn
- id: removed-posts
path: /api/v1/posts
status: removed
action:
type: block
status_code: 410
- id: redirect-orders
path: /api/v1/orders
status: deprecated
replacement:
path: /api/v2/orders
action:
type: redirect
status_code: 308
"#;
serde_yaml::from_str(yaml).unwrap()
}
#[test]
fn test_agent_creation() {
let config = test_config();
let agent = ApiDeprecationAgent::new(config);
assert_eq!(agent.config.endpoints.len(), 3);
}
#[test]
fn test_process_deprecated_endpoint() {
let config = test_config();
let agent = ApiDeprecationAgent::new(config);
let decision = agent.process_request("/api/v1/users", "GET", None);
assert!(decision.is_some());
let d = decision.unwrap();
assert_eq!(d.endpoint_id, "legacy-users");
assert!(matches!(d.action, DeprecationActionResult::Warn));
}
#[test]
fn test_process_removed_endpoint() {
let config = test_config();
let agent = ApiDeprecationAgent::new(config);
let decision = agent.process_request("/api/v1/posts", "GET", None);
assert!(decision.is_some());
let d = decision.unwrap();
assert_eq!(d.endpoint_id, "removed-posts");
assert!(matches!(d.action, DeprecationActionResult::Block { status_code: 410 }));
}
#[test]
fn test_process_redirect_endpoint() {
let config = test_config();
let agent = ApiDeprecationAgent::new(config);
let decision = agent.process_request("/api/v1/orders", "GET", Some("page=1"));
assert!(decision.is_some());
let d = decision.unwrap();
assert_eq!(d.endpoint_id, "redirect-orders");
assert!(matches!(d.action, DeprecationActionResult::Redirect { status_code: 308 }));
assert_eq!(d.redirect_url, Some("/api/v2/orders?page=1".to_string()));
}
#[test]
fn test_non_deprecated_endpoint() {
let config = test_config();
let agent = ApiDeprecationAgent::new(config);
let decision = agent.process_request("/api/v2/users", "GET", None);
assert!(decision.is_none());
}
#[test]
fn test_method_filtering() {
let config = test_config();
let agent = ApiDeprecationAgent::new(config);
let decision = agent.process_request("/api/v1/users", "GET", None);
assert!(decision.is_some());
let decision = agent.process_request("/api/v1/users", "DELETE", None);
assert!(decision.is_none());
}
#[test]
fn test_deprecation_headers() {
let config = test_config();
let agent = ApiDeprecationAgent::new(config);
let decision = agent.process_request("/api/v1/users", "GET", None).unwrap();
assert!(decision.headers.contains_key("Deprecation"));
assert!(decision.headers.contains_key("Sunset"));
assert!(decision.headers.contains_key("Link"));
assert!(decision.headers.contains_key("X-Deprecation-Notice"));
}
#[test]
fn test_metrics_tracking() {
let config = test_config();
let agent = ApiDeprecationAgent::new(config);
let _ = agent.process_request("/api/v1/users", "GET", None);
let output = agent.metrics().encode();
assert!(output.contains("requests_total"));
assert!(output.contains("legacy-users"));
}
}