use std::time::Duration;
use http::{Method, StatusCode};
use soaprs_core::{SoapError, SoapResult};
use crate::{
AuthorizationPolicy, BodyLimitPolicy, CacheVisibility, CorsPolicy, CsrfPolicy,
EndpointContracts, EndpointId, 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 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::NonZeroU64, time::Duration};
use http::{Method, StatusCode};
use crate::{
AuthorizationPolicy, BodyLimitPolicy, EndpointMetadata, 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());
}
}