1use std::time::Duration;
4
5use http::{Method, StatusCode};
6use soaprs_core::{SoapError, SoapResult};
7
8use crate::{
9 AuthorizationPolicy, BodyLimitPolicy, CacheVisibility, CorsPolicy, CsrfPolicy,
10 EndpointContracts, EndpointId, HttpEnforcementCapability, OperationDocumentation,
11 RateLimitPolicy, RequestContract, RequestContractLocation, ResponseCachePolicy,
12 ResponseContract, RoutePath, SecurityHeadersPolicy, TelemetryPolicy,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct EndpointMetadata {
18 pub id: EndpointId,
20 pub method: Method,
22 pub path: RoutePath,
24 pub success_status: StatusCode,
26 pub authorization: AuthorizationPolicy,
28 pub rate_limit: Option<RateLimitPolicy>,
30 pub timeout: Option<Duration>,
32 pub body_limit: Option<BodyLimitPolicy>,
34 pub cors: Option<CorsPolicy>,
36 pub csrf: CsrfPolicy,
38 pub security_headers: Option<SecurityHeadersPolicy>,
40 pub response_cache: Option<ResponseCachePolicy>,
42 pub contracts: EndpointContracts,
44 pub documentation: OperationDocumentation,
46 pub telemetry: TelemetryPolicy,
48 pub tags: Vec<String>,
50}
51
52impl EndpointMetadata {
53 pub fn new(id: impl Into<String>, method: Method, path: RoutePath) -> SoapResult<Self> {
55 Ok(Self {
56 id: EndpointId::new(id)?,
57 method,
58 path,
59 success_status: StatusCode::OK,
60 authorization: AuthorizationPolicy::Public,
61 rate_limit: None,
62 timeout: None,
63 body_limit: None,
64 cors: None,
65 csrf: CsrfPolicy::Disabled,
66 security_headers: Some(SecurityHeadersPolicy::secure_defaults()),
67 response_cache: None,
68 contracts: EndpointContracts::default(),
69 documentation: OperationDocumentation::default(),
70 telemetry: TelemetryPolicy::enabled(),
71 tags: Vec::new(),
72 })
73 }
74
75 pub fn success_status(mut self, status: StatusCode) -> SoapResult<Self> {
77 if !status.is_success() {
78 return Err(SoapError::validation(
79 "endpoint success status must be in the 2xx class",
80 ));
81 }
82 self.success_status = status;
83 Ok(self)
84 }
85
86 pub fn authorize(mut self, policy: AuthorizationPolicy) -> SoapResult<Self> {
88 policy.validate()?;
89 self.authorization = policy;
90 Ok(self)
91 }
92
93 #[must_use]
95 pub fn rate_limit(mut self, policy: RateLimitPolicy) -> Self {
96 self.rate_limit = Some(policy);
97 self
98 }
99
100 pub fn timeout(mut self, timeout: Duration) -> SoapResult<Self> {
102 if timeout.is_zero() {
103 return Err(SoapError::validation(
104 "endpoint timeout must be greater than zero",
105 ));
106 }
107 self.timeout = Some(timeout);
108 Ok(self)
109 }
110
111 #[must_use]
113 pub fn body_limit(mut self, policy: BodyLimitPolicy) -> Self {
114 self.body_limit = Some(policy);
115 self
116 }
117
118 #[must_use]
120 pub fn cors(mut self, policy: CorsPolicy) -> Self {
121 self.cors = Some(policy);
122 self
123 }
124
125 #[must_use]
127 pub const fn require_csrf(mut self) -> Self {
128 self.csrf = CsrfPolicy::Required;
129 self
130 }
131
132 #[must_use]
134 pub fn security_headers(mut self, policy: SecurityHeadersPolicy) -> Self {
135 self.security_headers = Some(policy);
136 self
137 }
138
139 #[must_use]
141 pub fn without_security_headers(mut self) -> Self {
142 self.security_headers = None;
143 self
144 }
145
146 pub fn response_cache(mut self, policy: ResponseCachePolicy) -> SoapResult<Self> {
148 if policy.visibility == CacheVisibility::Public
149 && !self.authorization.allows_public_response_cache()
150 {
151 return Err(SoapError::validation(
152 "authenticated endpoint responses cannot use public caches",
153 ));
154 }
155 self.response_cache = Some(policy);
156 Ok(self)
157 }
158
159 #[must_use]
161 pub fn request_contract(mut self, contract: RequestContract) -> Self {
162 self.contracts.add_request(contract);
163 self
164 }
165
166 #[must_use]
168 pub fn response_contract(mut self, contract: ResponseContract) -> Self {
169 self.contracts.add_response(contract);
170 self
171 }
172
173 #[must_use]
175 pub fn documentation(mut self, documentation: OperationDocumentation) -> Self {
176 self.documentation = documentation;
177 self
178 }
179
180 #[must_use]
182 pub fn telemetry(mut self, telemetry: TelemetryPolicy) -> Self {
183 self.telemetry = telemetry;
184 self
185 }
186
187 pub fn tag(mut self, tag: impl Into<String>) -> SoapResult<Self> {
189 let tag = tag.into();
190 if tag.trim().is_empty() {
191 return Err(SoapError::validation("endpoint tag cannot be empty"));
192 }
193 if !self.tags.contains(&tag) {
194 self.tags.push(tag);
195 }
196 Ok(self)
197 }
198
199 pub fn required_enforcement_capabilities(&self) -> Vec<HttpEnforcementCapability> {
206 let mut capabilities = Vec::new();
207 if self.authorization.authenticates_when_present() {
208 capabilities.push(HttpEnforcementCapability::Authentication);
209 }
210 if !self.contracts.requests().is_empty() {
211 capabilities.push(HttpEnforcementCapability::RequestValidation);
212 }
213 if self.rate_limit.is_some() {
214 capabilities.push(HttpEnforcementCapability::RateLimit);
215 }
216 if self.cors.is_some() {
217 capabilities.push(HttpEnforcementCapability::Cors);
218 }
219 if self.csrf == CsrfPolicy::Required {
220 capabilities.push(HttpEnforcementCapability::Csrf);
221 }
222 capabilities
223 }
224
225 pub fn validate(&self) -> SoapResult<()> {
227 self.authorization.validate()?;
228 if !self.success_status.is_success() {
229 return Err(SoapError::validation(
230 "endpoint success status must be in the 2xx class",
231 ));
232 }
233 if self.timeout.is_some_and(|timeout| timeout.is_zero()) {
234 return Err(SoapError::validation(
235 "endpoint timeout must be greater than zero",
236 ));
237 }
238 if let Some(policy) = &self.rate_limit {
239 policy.validate()?;
240 }
241 if let Some(policy) = &self.cors {
242 policy.validate()?;
243 }
244 if let Some(policy) = &self.security_headers {
245 policy.validate()?;
246 }
247 if let Some(policy) = &self.response_cache {
248 policy.validate()?;
249 }
250 self.documentation.validate()?;
251 self.telemetry.validate()?;
252 if self.tags.iter().any(|tag| tag.trim().is_empty()) {
253 return Err(SoapError::validation("endpoint tag cannot be empty"));
254 }
255 if self.contracts.requests().iter().any(|contract| {
256 contract.location != RequestContractLocation::Body && contract.content_type.is_some()
257 }) {
258 return Err(SoapError::validation(
259 "only body request contracts may declare a content type",
260 ));
261 }
262 if self.response_cache.as_ref().is_some_and(|policy| {
263 policy.visibility == CacheVisibility::Public
264 && !self.authorization.allows_public_response_cache()
265 }) {
266 return Err(SoapError::validation(
267 "authenticated endpoint responses cannot use public caches",
268 ));
269 }
270 Ok(())
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use std::{
277 num::{NonZeroU32, NonZeroU64},
278 time::Duration,
279 };
280
281 use http::{Method, StatusCode};
282
283 use crate::{
284 AuthorizationPolicy, BodyLimitPolicy, ContractId, CorsPolicy, EndpointMetadata,
285 HttpEnforcementCapability, RateLimitPolicy, RequestContract, RequestContractLocation,
286 ResponseCachePolicy, RoutePath,
287 };
288
289 #[test]
290 fn builds_complete_metadata_without_a_framework_handler() {
291 let path = RoutePath::new("/users/{id}");
292 let Some(path) = path.ok() else {
293 panic!("valid route path");
294 };
295 let result = EndpointMetadata::new("users.get", Method::GET, path)
296 .and_then(|metadata| metadata.authorize(AuthorizationPolicy::Authenticated))
297 .and_then(|metadata| metadata.timeout(Duration::from_secs(5)))
298 .and_then(|metadata| metadata.success_status(StatusCode::OK))
299 .map(|metadata| metadata.body_limit(BodyLimitPolicy::new(NonZeroU64::MIN)))
300 .and_then(|metadata| metadata.tag("users"));
301
302 assert!(result.and_then(|metadata| metadata.validate()).is_ok());
303 }
304
305 #[test]
306 fn protected_endpoints_cannot_be_publicly_cached() {
307 let path = RoutePath::new("/me");
308 let cache = ResponseCachePolicy::public(Duration::from_secs(60));
309 let (Some(path), Some(cache)) = (path.ok(), cache.ok()) else {
310 panic!("valid fixtures");
311 };
312 let result = EndpointMetadata::new("users.me", Method::GET, path)
313 .and_then(|metadata| metadata.authorize(AuthorizationPolicy::Authenticated))
314 .and_then(|metadata| metadata.response_cache(cache));
315 assert!(result.is_err());
316 }
317
318 #[test]
319 fn reports_every_declared_runtime_enforcement_requirement() {
320 let endpoint = EndpointMetadata::new(
321 "users.create",
322 Method::POST,
323 RoutePath::new("/users").unwrap_or_else(|error| panic!("valid path: {error}")),
324 )
325 .and_then(|endpoint| endpoint.authorize(AuthorizationPolicy::Authenticated))
326 .map(|endpoint| {
327 endpoint
328 .request_contract(RequestContract::new(
329 ContractId::new("users.create.body")
330 .unwrap_or_else(|error| panic!("valid contract: {error}")),
331 RequestContractLocation::Body,
332 ))
333 .rate_limit(
334 RateLimitPolicy::new(NonZeroU32::MIN, Duration::from_secs(1))
335 .unwrap_or_else(|error| panic!("valid rate limit: {error}")),
336 )
337 .cors(
338 CorsPolicy::any(vec![Method::POST])
339 .unwrap_or_else(|error| panic!("valid CORS policy: {error}")),
340 )
341 .require_csrf()
342 })
343 .unwrap_or_else(|error| panic!("valid endpoint: {error}"));
344
345 assert_eq!(
346 endpoint.required_enforcement_capabilities(),
347 [
348 HttpEnforcementCapability::Authentication,
349 HttpEnforcementCapability::RequestValidation,
350 HttpEnforcementCapability::RateLimit,
351 HttpEnforcementCapability::Cors,
352 HttpEnforcementCapability::Csrf,
353 ]
354 );
355 }
356}