use std::{collections::HashMap, time::Duration};
use http::Method;
use soaprs_core::{SoapError, SoapResult};
use crate::{
AuthorizationPolicy, BodyLimitPolicy, CorsPolicy, CsrfPolicy, EndpointId, EndpointMetadata,
RateLimitPolicy, ResponseCachePolicy, RoutePath, SecurityHeadersPolicy, TelemetryPolicy,
};
#[derive(Debug, Clone, Default)]
pub struct EndpointCatalog {
endpoints: Vec<EndpointMetadata>,
identities: HashMap<EndpointId, usize>,
routes: HashMap<(Method, String), usize>,
}
impl EndpointCatalog {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, endpoint: EndpointMetadata) -> SoapResult<()> {
endpoint.validate()?;
if self.identities.contains_key(&endpoint.id) {
return Err(SoapError::conflict(format!(
"endpoint `{}` is already registered",
endpoint.id
)));
}
let route_key = (endpoint.method.clone(), endpoint.path.shape());
if let Some(existing) = self.routes.get(&route_key) {
return Err(SoapError::conflict(format!(
"route {} {} conflicts with endpoint `{}`",
endpoint.method, endpoint.path, self.endpoints[*existing].id
)));
}
let index = self.endpoints.len();
self.identities.insert(endpoint.id.clone(), index);
self.routes.insert(route_key, index);
self.endpoints.push(endpoint);
Ok(())
}
pub fn register_all<I>(&mut self, endpoints: I) -> SoapResult<()>
where
I: IntoIterator<Item = EndpointMetadata>,
{
let mut candidate = self.clone();
for endpoint in endpoints {
candidate.register(endpoint)?;
}
*self = candidate;
Ok(())
}
pub fn endpoint(&self, id: &EndpointId) -> Option<&EndpointMetadata> {
self.identities
.get(id)
.and_then(|index| self.endpoints.get(*index))
}
pub fn route(&self, method: &Method, path: &RoutePath) -> Option<&EndpointMetadata> {
self.routes
.get(&(method.clone(), path.shape()))
.and_then(|index| self.endpoints.get(*index))
}
pub fn endpoints(&self) -> &[EndpointMetadata] {
&self.endpoints
}
pub fn len(&self) -> usize {
self.endpoints.len()
}
pub fn is_empty(&self) -> bool {
self.endpoints.is_empty()
}
pub fn into_endpoints(self) -> Vec<EndpointMetadata> {
self.endpoints
}
}
#[derive(Debug, Clone)]
pub struct EndpointGroup {
prefix: RoutePath,
authorization: AuthorizationPolicy,
rate_limit: Option<RateLimitPolicy>,
timeout: Option<Duration>,
body_limit: Option<BodyLimitPolicy>,
cors: Option<CorsPolicy>,
csrf: CsrfPolicy,
security_headers: Option<SecurityHeadersPolicy>,
response_cache: Option<ResponseCachePolicy>,
telemetry: TelemetryPolicy,
tags: Vec<String>,
}
impl EndpointGroup {
pub fn new(prefix: RoutePath) -> Self {
Self {
prefix,
authorization: AuthorizationPolicy::Public,
rate_limit: None,
timeout: None,
body_limit: None,
cors: None,
csrf: CsrfPolicy::Disabled,
security_headers: Some(SecurityHeadersPolicy::secure_defaults()),
response_cache: None,
telemetry: TelemetryPolicy::enabled(),
tags: Vec::new(),
}
}
pub fn authorize(mut self, policy: AuthorizationPolicy) -> SoapResult<Self> {
policy.validate()?;
self.authorization = policy;
Ok(self)
}
#[must_use]
pub fn rate_limit(mut self, policy: RateLimitPolicy) -> Self {
self.rate_limit = Some(policy);
self
}
pub fn timeout(mut self, timeout: Duration) -> SoapResult<Self> {
if timeout.is_zero() {
return Err(SoapError::validation(
"endpoint group timeout must be greater than zero",
));
}
self.timeout = Some(timeout);
Ok(self)
}
#[must_use]
pub fn body_limit(mut self, policy: BodyLimitPolicy) -> Self {
self.body_limit = Some(policy);
self
}
#[must_use]
pub fn cors(mut self, policy: CorsPolicy) -> Self {
self.cors = Some(policy);
self
}
#[must_use]
pub const fn require_csrf(mut self) -> Self {
self.csrf = CsrfPolicy::Required;
self
}
#[must_use]
pub fn security_headers(mut self, policy: SecurityHeadersPolicy) -> Self {
self.security_headers = Some(policy);
self
}
#[must_use]
pub fn without_security_headers(mut self) -> Self {
self.security_headers = None;
self
}
pub fn response_cache(mut self, policy: ResponseCachePolicy) -> SoapResult<Self> {
policy.validate()?;
self.response_cache = Some(policy);
Ok(self)
}
pub fn telemetry(mut self, policy: TelemetryPolicy) -> SoapResult<Self> {
policy.validate()?;
self.telemetry = policy;
Ok(self)
}
pub fn tag(mut self, tag: impl Into<String>) -> SoapResult<Self> {
let tag = tag.into();
if tag.trim().is_empty() {
return Err(SoapError::validation("endpoint group tag cannot be empty"));
}
if !self.tags.contains(&tag) {
self.tags.push(tag);
}
Ok(self)
}
pub fn endpoint(
&self,
id: impl Into<String>,
method: Method,
path: RoutePath,
) -> SoapResult<EndpointMetadata> {
let mut endpoint = EndpointMetadata::new(id, method, self.prefix.join(&path)?)?
.authorize(self.authorization.clone())?;
if let Some(policy) = &self.rate_limit {
endpoint = endpoint.rate_limit(policy.clone());
}
if let Some(timeout) = self.timeout {
endpoint = endpoint.timeout(timeout)?;
}
if let Some(policy) = self.body_limit {
endpoint = endpoint.body_limit(policy);
}
if let Some(policy) = &self.cors {
endpoint = endpoint.cors(policy.clone());
}
if self.csrf == CsrfPolicy::Required {
endpoint = endpoint.require_csrf();
}
endpoint = match &self.security_headers {
Some(policy) => endpoint.security_headers(policy.clone()),
None => endpoint.without_security_headers(),
};
if let Some(policy) = &self.response_cache {
endpoint = endpoint.response_cache(policy.clone())?;
}
endpoint = endpoint.telemetry(self.telemetry.clone());
for tag in &self.tags {
endpoint = endpoint.tag(tag.clone())?;
}
Ok(endpoint)
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use http::Method;
use soaprs_core::SoapError;
use super::{EndpointCatalog, EndpointGroup};
use crate::{
AuthorizationPolicy, CsrfPolicy, EndpointId, EndpointMetadata, ResponseCachePolicy,
RoutePath, TelemetryPolicy,
};
#[test]
fn catalog_rejects_duplicate_ids_and_equivalent_parameter_shapes() {
let Some(first_path) = RoutePath::new("/users/{id}").ok() else {
panic!("valid route");
};
let Some(second_path) = RoutePath::new("/users/{user_id}").ok() else {
panic!("valid route");
};
let Some(first) = EndpointMetadata::new("users.get", Method::GET, first_path).ok() else {
panic!("valid endpoint");
};
let Some(second) = EndpointMetadata::new("users.find", Method::GET, second_path).ok()
else {
panic!("valid endpoint");
};
let mut catalog = EndpointCatalog::new();
assert!(catalog.register(first).is_ok());
let conflict = catalog.register(second);
assert_eq!(
conflict.as_ref().map_err(SoapError::kind),
Err(soaprs_core::SoapErrorKind::Conflict)
);
}
#[test]
fn bulk_registration_is_atomic_and_groups_apply_defaults() {
let Some(prefix) = RoutePath::new("/api/v1").ok() else {
panic!("valid prefix");
};
let group = EndpointGroup::new(prefix)
.authorize(AuthorizationPolicy::Authenticated)
.and_then(|group| group.tag("users"));
let Some(group) = group.ok() else {
panic!("valid group");
};
let Some(list_path) = RoutePath::new("/users").ok() else {
panic!("valid path");
};
let Some(duplicate_path) = RoutePath::new("/users").ok() else {
panic!("valid path");
};
let first = group.endpoint("users.list", Method::GET, list_path);
let second = group.endpoint("users.other", Method::GET, duplicate_path);
let (Some(first), Some(second)) = (first.ok(), second.ok()) else {
panic!("valid endpoints");
};
let mut catalog = EndpointCatalog::new();
assert!(catalog.register_all([first, second]).is_err());
assert!(catalog.is_empty());
let Some(id) = EndpointId::new("users.list").ok() else {
panic!("valid endpoint id");
};
assert!(catalog.endpoint(&id).is_none());
}
#[test]
fn groups_apply_security_cache_and_telemetry_defaults_consistently() {
let Some(prefix) = RoutePath::new("/api").ok() else {
panic!("valid prefix");
};
let Some(cache) = ResponseCachePolicy::private(Duration::from_secs(30)).ok() else {
panic!("valid cache policy");
};
let group = EndpointGroup::new(prefix)
.require_csrf()
.without_security_headers()
.response_cache(cache)
.and_then(|group| group.telemetry(TelemetryPolicy::disabled()));
let Some(group) = group.ok() else {
panic!("valid endpoint group");
};
let Some(path) = RoutePath::new("/sessions").ok() else {
panic!("valid endpoint path");
};
let Some(endpoint) = group.endpoint("sessions.create", Method::POST, path).ok() else {
panic!("valid grouped endpoint");
};
assert_eq!(endpoint.csrf, CsrfPolicy::Required);
assert!(endpoint.security_headers.is_none());
assert!(endpoint.response_cache.is_some());
assert!(!endpoint.telemetry.enabled);
assert!(endpoint.validate().is_ok());
}
}