use std::time::Duration;
use http::{Method, StatusCode};
use soaprs_core::{SoapError, SoapResult};
use crate::{
AuthorizationPolicy, BodyLimitPolicy, CacheVisibility, CorsPolicy, CsrfPolicy,
EndpointContracts, EndpointId, HttpEnforcementCapability, OperationDocumentation,
RateLimitPolicy, RequestContract, RequestContractLocation, ResponseCachePolicy,
ResponseContract, RoutePath, SecurityHeadersPolicy, TelemetryPolicy,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointMetadata {
pub id: EndpointId,
pub method: Method,
pub path: RoutePath,
pub success_status: StatusCode,
pub authorization: AuthorizationPolicy,
pub rate_limit: Option<RateLimitPolicy>,
pub timeout: Option<Duration>,
pub body_limit: Option<BodyLimitPolicy>,
pub cors: Option<CorsPolicy>,
pub csrf: CsrfPolicy,
pub security_headers: Option<SecurityHeadersPolicy>,
pub response_cache: Option<ResponseCachePolicy>,
pub contracts: EndpointContracts,
pub documentation: OperationDocumentation,
pub telemetry: TelemetryPolicy,
pub tags: Vec<String>,
}
impl EndpointMetadata {
pub fn new(id: impl Into<String>, method: Method, path: RoutePath) -> SoapResult<Self> {
Ok(Self {
id: EndpointId::new(id)?,
method,
path,
success_status: StatusCode::OK,
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,
contracts: EndpointContracts::default(),
documentation: OperationDocumentation::default(),
telemetry: TelemetryPolicy::enabled(),
tags: Vec::new(),
})
}
pub fn success_status(mut self, status: StatusCode) -> SoapResult<Self> {
if !status.is_success() {
return Err(SoapError::validation(
"endpoint success status must be in the 2xx class",
));
}
self.success_status = status;
Ok(self)
}
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 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> {
if policy.visibility == CacheVisibility::Public
&& !self.authorization.allows_public_response_cache()
{
return Err(SoapError::validation(
"authenticated endpoint responses cannot use public caches",
));
}
self.response_cache = Some(policy);
Ok(self)
}
#[must_use]
pub fn request_contract(mut self, contract: RequestContract) -> Self {
self.contracts.add_request(contract);
self
}
#[must_use]
pub fn response_contract(mut self, contract: ResponseContract) -> Self {
self.contracts.add_response(contract);
self
}
#[must_use]
pub fn documentation(mut self, documentation: OperationDocumentation) -> Self {
self.documentation = documentation;
self
}
#[must_use]
pub fn telemetry(mut self, telemetry: TelemetryPolicy) -> Self {
self.telemetry = telemetry;
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 tag cannot be empty"));
}
if !self.tags.contains(&tag) {
self.tags.push(tag);
}
Ok(self)
}
pub fn required_enforcement_capabilities(&self) -> Vec<HttpEnforcementCapability> {
let mut capabilities = Vec::new();
if self.authorization.authenticates_when_present() {
capabilities.push(HttpEnforcementCapability::Authentication);
}
if !self.contracts.requests().is_empty() {
capabilities.push(HttpEnforcementCapability::RequestValidation);
}
if self.rate_limit.is_some() {
capabilities.push(HttpEnforcementCapability::RateLimit);
}
if self.cors.is_some() {
capabilities.push(HttpEnforcementCapability::Cors);
}
if self.csrf == CsrfPolicy::Required {
capabilities.push(HttpEnforcementCapability::Csrf);
}
capabilities
}
pub fn validate(&self) -> SoapResult<()> {
self.authorization.validate()?;
if !self.success_status.is_success() {
return Err(SoapError::validation(
"endpoint success status must be in the 2xx class",
));
}
if self.timeout.is_some_and(|timeout| timeout.is_zero()) {
return Err(SoapError::validation(
"endpoint timeout must be greater than zero",
));
}
if let Some(policy) = &self.rate_limit {
policy.validate()?;
}
if let Some(policy) = &self.cors {
policy.validate()?;
}
if let Some(policy) = &self.security_headers {
policy.validate()?;
}
if let Some(policy) = &self.response_cache {
policy.validate()?;
}
self.documentation.validate()?;
self.telemetry.validate()?;
if self.tags.iter().any(|tag| tag.trim().is_empty()) {
return Err(SoapError::validation("endpoint tag cannot be empty"));
}
if self.contracts.requests().iter().any(|contract| {
contract.location != RequestContractLocation::Body && contract.content_type.is_some()
}) {
return Err(SoapError::validation(
"only body request contracts may declare a content type",
));
}
if self.response_cache.as_ref().is_some_and(|policy| {
policy.visibility == CacheVisibility::Public
&& !self.authorization.allows_public_response_cache()
}) {
return Err(SoapError::validation(
"authenticated endpoint responses cannot use public caches",
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::{
num::{NonZeroU32, NonZeroU64},
time::Duration,
};
use http::{Method, StatusCode};
use crate::{
AuthorizationPolicy, BodyLimitPolicy, ContractId, CorsPolicy, EndpointMetadata,
HttpEnforcementCapability, RateLimitPolicy, RequestContract, RequestContractLocation,
ResponseCachePolicy, RoutePath,
};
#[test]
fn builds_complete_metadata_without_a_framework_handler() {
let path = RoutePath::new("/users/{id}");
let Some(path) = path.ok() else {
panic!("valid route path");
};
let result = EndpointMetadata::new("users.get", Method::GET, path)
.and_then(|metadata| metadata.authorize(AuthorizationPolicy::Authenticated))
.and_then(|metadata| metadata.timeout(Duration::from_secs(5)))
.and_then(|metadata| metadata.success_status(StatusCode::OK))
.map(|metadata| metadata.body_limit(BodyLimitPolicy::new(NonZeroU64::MIN)))
.and_then(|metadata| metadata.tag("users"));
assert!(result.and_then(|metadata| metadata.validate()).is_ok());
}
#[test]
fn protected_endpoints_cannot_be_publicly_cached() {
let path = RoutePath::new("/me");
let cache = ResponseCachePolicy::public(Duration::from_secs(60));
let (Some(path), Some(cache)) = (path.ok(), cache.ok()) else {
panic!("valid fixtures");
};
let result = EndpointMetadata::new("users.me", Method::GET, path)
.and_then(|metadata| metadata.authorize(AuthorizationPolicy::Authenticated))
.and_then(|metadata| metadata.response_cache(cache));
assert!(result.is_err());
}
#[test]
fn reports_every_declared_runtime_enforcement_requirement() {
let endpoint = EndpointMetadata::new(
"users.create",
Method::POST,
RoutePath::new("/users").unwrap_or_else(|error| panic!("valid path: {error}")),
)
.and_then(|endpoint| endpoint.authorize(AuthorizationPolicy::Authenticated))
.map(|endpoint| {
endpoint
.request_contract(RequestContract::new(
ContractId::new("users.create.body")
.unwrap_or_else(|error| panic!("valid contract: {error}")),
RequestContractLocation::Body,
))
.rate_limit(
RateLimitPolicy::new(NonZeroU32::MIN, Duration::from_secs(1))
.unwrap_or_else(|error| panic!("valid rate limit: {error}")),
)
.cors(
CorsPolicy::any(vec![Method::POST])
.unwrap_or_else(|error| panic!("valid CORS policy: {error}")),
)
.require_csrf()
})
.unwrap_or_else(|error| panic!("valid endpoint: {error}"));
assert_eq!(
endpoint.required_enforcement_capabilities(),
[
HttpEnforcementCapability::Authentication,
HttpEnforcementCapability::RequestValidation,
HttpEnforcementCapability::RateLimit,
HttpEnforcementCapability::Cors,
HttpEnforcementCapability::Csrf,
]
);
}
}