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, OperationDocumentation, RateLimitPolicy, RequestContract,
11 RequestContractLocation, ResponseCachePolicy, ResponseContract, RoutePath,
12 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 validate(&self) -> SoapResult<()> {
201 self.authorization.validate()?;
202 if !self.success_status.is_success() {
203 return Err(SoapError::validation(
204 "endpoint success status must be in the 2xx class",
205 ));
206 }
207 if self.timeout.is_some_and(|timeout| timeout.is_zero()) {
208 return Err(SoapError::validation(
209 "endpoint timeout must be greater than zero",
210 ));
211 }
212 if let Some(policy) = &self.rate_limit {
213 policy.validate()?;
214 }
215 if let Some(policy) = &self.cors {
216 policy.validate()?;
217 }
218 if let Some(policy) = &self.security_headers {
219 policy.validate()?;
220 }
221 if let Some(policy) = &self.response_cache {
222 policy.validate()?;
223 }
224 self.documentation.validate()?;
225 self.telemetry.validate()?;
226 if self.tags.iter().any(|tag| tag.trim().is_empty()) {
227 return Err(SoapError::validation("endpoint tag cannot be empty"));
228 }
229 if self.contracts.requests().iter().any(|contract| {
230 contract.location != RequestContractLocation::Body && contract.content_type.is_some()
231 }) {
232 return Err(SoapError::validation(
233 "only body request contracts may declare a content type",
234 ));
235 }
236 if self.response_cache.as_ref().is_some_and(|policy| {
237 policy.visibility == CacheVisibility::Public
238 && !self.authorization.allows_public_response_cache()
239 }) {
240 return Err(SoapError::validation(
241 "authenticated endpoint responses cannot use public caches",
242 ));
243 }
244 Ok(())
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use std::{num::NonZeroU64, time::Duration};
251
252 use http::{Method, StatusCode};
253
254 use crate::{
255 AuthorizationPolicy, BodyLimitPolicy, EndpointMetadata, ResponseCachePolicy, RoutePath,
256 };
257
258 #[test]
259 fn builds_complete_metadata_without_a_framework_handler() {
260 let path = RoutePath::new("/users/{id}");
261 let Some(path) = path.ok() else {
262 panic!("valid route path");
263 };
264 let result = EndpointMetadata::new("users.get", Method::GET, path)
265 .and_then(|metadata| metadata.authorize(AuthorizationPolicy::Authenticated))
266 .and_then(|metadata| metadata.timeout(Duration::from_secs(5)))
267 .and_then(|metadata| metadata.success_status(StatusCode::OK))
268 .map(|metadata| metadata.body_limit(BodyLimitPolicy::new(NonZeroU64::MIN)))
269 .and_then(|metadata| metadata.tag("users"));
270
271 assert!(result.and_then(|metadata| metadata.validate()).is_ok());
272 }
273
274 #[test]
275 fn protected_endpoints_cannot_be_publicly_cached() {
276 let path = RoutePath::new("/me");
277 let cache = ResponseCachePolicy::public(Duration::from_secs(60));
278 let (Some(path), Some(cache)) = (path.ok(), cache.ok()) else {
279 panic!("valid fixtures");
280 };
281 let result = EndpointMetadata::new("users.me", Method::GET, path)
282 .and_then(|metadata| metadata.authorize(AuthorizationPolicy::Authenticated))
283 .and_then(|metadata| metadata.response_cache(cache));
284 assert!(result.is_err());
285 }
286}