1use std::{collections::HashMap, time::Duration};
4
5use http::Method;
6use soaprs_core::{SoapError, SoapResult};
7
8use crate::{
9 AuthorizationPolicy, BodyLimitPolicy, CorsPolicy, CsrfPolicy, EndpointId, EndpointMetadata,
10 RateLimitPolicy, ResponseCachePolicy, RoutePath, SecurityHeadersPolicy, TelemetryPolicy,
11};
12
13#[derive(Debug, Clone, Default)]
15pub struct EndpointCatalog {
16 endpoints: Vec<EndpointMetadata>,
17 identities: HashMap<EndpointId, usize>,
18 routes: HashMap<(Method, String), usize>,
19}
20
21impl EndpointCatalog {
22 pub fn new() -> Self {
24 Self::default()
25 }
26
27 pub fn register(&mut self, endpoint: EndpointMetadata) -> SoapResult<()> {
29 endpoint.validate()?;
30 if self.identities.contains_key(&endpoint.id) {
31 return Err(SoapError::conflict(format!(
32 "endpoint `{}` is already registered",
33 endpoint.id
34 )));
35 }
36 let route_key = (endpoint.method.clone(), endpoint.path.shape());
37 if let Some(existing) = self.routes.get(&route_key) {
38 return Err(SoapError::conflict(format!(
39 "route {} {} conflicts with endpoint `{}`",
40 endpoint.method, endpoint.path, self.endpoints[*existing].id
41 )));
42 }
43
44 let index = self.endpoints.len();
45 self.identities.insert(endpoint.id.clone(), index);
46 self.routes.insert(route_key, index);
47 self.endpoints.push(endpoint);
48 Ok(())
49 }
50
51 pub fn register_all<I>(&mut self, endpoints: I) -> SoapResult<()>
53 where
54 I: IntoIterator<Item = EndpointMetadata>,
55 {
56 let mut candidate = self.clone();
57 for endpoint in endpoints {
58 candidate.register(endpoint)?;
59 }
60 *self = candidate;
61 Ok(())
62 }
63
64 pub fn endpoint(&self, id: &EndpointId) -> Option<&EndpointMetadata> {
66 self.identities
67 .get(id)
68 .and_then(|index| self.endpoints.get(*index))
69 }
70
71 pub fn route(&self, method: &Method, path: &RoutePath) -> Option<&EndpointMetadata> {
73 self.routes
74 .get(&(method.clone(), path.shape()))
75 .and_then(|index| self.endpoints.get(*index))
76 }
77
78 pub fn endpoints(&self) -> &[EndpointMetadata] {
80 &self.endpoints
81 }
82
83 pub fn len(&self) -> usize {
85 self.endpoints.len()
86 }
87
88 pub fn is_empty(&self) -> bool {
90 self.endpoints.is_empty()
91 }
92
93 pub fn into_endpoints(self) -> Vec<EndpointMetadata> {
95 self.endpoints
96 }
97}
98
99#[derive(Debug, Clone)]
101pub struct EndpointGroup {
102 prefix: RoutePath,
103 authorization: AuthorizationPolicy,
104 rate_limit: Option<RateLimitPolicy>,
105 timeout: Option<Duration>,
106 body_limit: Option<BodyLimitPolicy>,
107 cors: Option<CorsPolicy>,
108 csrf: CsrfPolicy,
109 security_headers: Option<SecurityHeadersPolicy>,
110 response_cache: Option<ResponseCachePolicy>,
111 telemetry: TelemetryPolicy,
112 tags: Vec<String>,
113}
114
115impl EndpointGroup {
116 pub fn new(prefix: RoutePath) -> Self {
118 Self {
119 prefix,
120 authorization: AuthorizationPolicy::Public,
121 rate_limit: None,
122 timeout: None,
123 body_limit: None,
124 cors: None,
125 csrf: CsrfPolicy::Disabled,
126 security_headers: Some(SecurityHeadersPolicy::secure_defaults()),
127 response_cache: None,
128 telemetry: TelemetryPolicy::enabled(),
129 tags: Vec::new(),
130 }
131 }
132
133 pub fn authorize(mut self, policy: AuthorizationPolicy) -> SoapResult<Self> {
135 policy.validate()?;
136 self.authorization = policy;
137 Ok(self)
138 }
139
140 #[must_use]
142 pub fn rate_limit(mut self, policy: RateLimitPolicy) -> Self {
143 self.rate_limit = Some(policy);
144 self
145 }
146
147 pub fn timeout(mut self, timeout: Duration) -> SoapResult<Self> {
149 if timeout.is_zero() {
150 return Err(SoapError::validation(
151 "endpoint group timeout must be greater than zero",
152 ));
153 }
154 self.timeout = Some(timeout);
155 Ok(self)
156 }
157
158 #[must_use]
160 pub fn body_limit(mut self, policy: BodyLimitPolicy) -> Self {
161 self.body_limit = Some(policy);
162 self
163 }
164
165 #[must_use]
167 pub fn cors(mut self, policy: CorsPolicy) -> Self {
168 self.cors = Some(policy);
169 self
170 }
171
172 #[must_use]
174 pub const fn require_csrf(mut self) -> Self {
175 self.csrf = CsrfPolicy::Required;
176 self
177 }
178
179 #[must_use]
181 pub fn security_headers(mut self, policy: SecurityHeadersPolicy) -> Self {
182 self.security_headers = Some(policy);
183 self
184 }
185
186 #[must_use]
188 pub fn without_security_headers(mut self) -> Self {
189 self.security_headers = None;
190 self
191 }
192
193 pub fn response_cache(mut self, policy: ResponseCachePolicy) -> SoapResult<Self> {
195 policy.validate()?;
196 self.response_cache = Some(policy);
197 Ok(self)
198 }
199
200 pub fn telemetry(mut self, policy: TelemetryPolicy) -> SoapResult<Self> {
202 policy.validate()?;
203 self.telemetry = policy;
204 Ok(self)
205 }
206
207 pub fn tag(mut self, tag: impl Into<String>) -> SoapResult<Self> {
209 let tag = tag.into();
210 if tag.trim().is_empty() {
211 return Err(SoapError::validation("endpoint group tag cannot be empty"));
212 }
213 if !self.tags.contains(&tag) {
214 self.tags.push(tag);
215 }
216 Ok(self)
217 }
218
219 pub fn endpoint(
221 &self,
222 id: impl Into<String>,
223 method: Method,
224 path: RoutePath,
225 ) -> SoapResult<EndpointMetadata> {
226 let mut endpoint = EndpointMetadata::new(id, method, self.prefix.join(&path)?)?
227 .authorize(self.authorization.clone())?;
228 if let Some(policy) = &self.rate_limit {
229 endpoint = endpoint.rate_limit(policy.clone());
230 }
231 if let Some(timeout) = self.timeout {
232 endpoint = endpoint.timeout(timeout)?;
233 }
234 if let Some(policy) = self.body_limit {
235 endpoint = endpoint.body_limit(policy);
236 }
237 if let Some(policy) = &self.cors {
238 endpoint = endpoint.cors(policy.clone());
239 }
240 if self.csrf == CsrfPolicy::Required {
241 endpoint = endpoint.require_csrf();
242 }
243 endpoint = match &self.security_headers {
244 Some(policy) => endpoint.security_headers(policy.clone()),
245 None => endpoint.without_security_headers(),
246 };
247 if let Some(policy) = &self.response_cache {
248 endpoint = endpoint.response_cache(policy.clone())?;
249 }
250 endpoint = endpoint.telemetry(self.telemetry.clone());
251 for tag in &self.tags {
252 endpoint = endpoint.tag(tag.clone())?;
253 }
254 Ok(endpoint)
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use std::time::Duration;
261
262 use http::Method;
263 use soaprs_core::SoapError;
264
265 use super::{EndpointCatalog, EndpointGroup};
266 use crate::{
267 AuthorizationPolicy, CsrfPolicy, EndpointId, EndpointMetadata, ResponseCachePolicy,
268 RoutePath, TelemetryPolicy,
269 };
270
271 #[test]
272 fn catalog_rejects_duplicate_ids_and_equivalent_parameter_shapes() {
273 let Some(first_path) = RoutePath::new("/users/{id}").ok() else {
274 panic!("valid route");
275 };
276 let Some(second_path) = RoutePath::new("/users/{user_id}").ok() else {
277 panic!("valid route");
278 };
279 let Some(first) = EndpointMetadata::new("users.get", Method::GET, first_path).ok() else {
280 panic!("valid endpoint");
281 };
282 let Some(second) = EndpointMetadata::new("users.find", Method::GET, second_path).ok()
283 else {
284 panic!("valid endpoint");
285 };
286 let mut catalog = EndpointCatalog::new();
287 assert!(catalog.register(first).is_ok());
288 let conflict = catalog.register(second);
289 assert_eq!(
290 conflict.as_ref().map_err(SoapError::kind),
291 Err(soaprs_core::SoapErrorKind::Conflict)
292 );
293 }
294
295 #[test]
296 fn bulk_registration_is_atomic_and_groups_apply_defaults() {
297 let Some(prefix) = RoutePath::new("/api/v1").ok() else {
298 panic!("valid prefix");
299 };
300 let group = EndpointGroup::new(prefix)
301 .authorize(AuthorizationPolicy::Authenticated)
302 .and_then(|group| group.tag("users"));
303 let Some(group) = group.ok() else {
304 panic!("valid group");
305 };
306 let Some(list_path) = RoutePath::new("/users").ok() else {
307 panic!("valid path");
308 };
309 let Some(duplicate_path) = RoutePath::new("/users").ok() else {
310 panic!("valid path");
311 };
312 let first = group.endpoint("users.list", Method::GET, list_path);
313 let second = group.endpoint("users.other", Method::GET, duplicate_path);
314 let (Some(first), Some(second)) = (first.ok(), second.ok()) else {
315 panic!("valid endpoints");
316 };
317 let mut catalog = EndpointCatalog::new();
318 assert!(catalog.register_all([first, second]).is_err());
319 assert!(catalog.is_empty());
320
321 let Some(id) = EndpointId::new("users.list").ok() else {
322 panic!("valid endpoint id");
323 };
324 assert!(catalog.endpoint(&id).is_none());
325 }
326
327 #[test]
328 fn groups_apply_security_cache_and_telemetry_defaults_consistently() {
329 let Some(prefix) = RoutePath::new("/api").ok() else {
330 panic!("valid prefix");
331 };
332 let Some(cache) = ResponseCachePolicy::private(Duration::from_secs(30)).ok() else {
333 panic!("valid cache policy");
334 };
335 let group = EndpointGroup::new(prefix)
336 .require_csrf()
337 .without_security_headers()
338 .response_cache(cache)
339 .and_then(|group| group.telemetry(TelemetryPolicy::disabled()));
340 let Some(group) = group.ok() else {
341 panic!("valid endpoint group");
342 };
343 let Some(path) = RoutePath::new("/sessions").ok() else {
344 panic!("valid endpoint path");
345 };
346 let Some(endpoint) = group.endpoint("sessions.create", Method::POST, path).ok() else {
347 panic!("valid grouped endpoint");
348 };
349
350 assert_eq!(endpoint.csrf, CsrfPolicy::Required);
351 assert!(endpoint.security_headers.is_none());
352 assert!(endpoint.response_cache.is_some());
353 assert!(!endpoint.telemetry.enabled);
354 assert!(endpoint.validate().is_ok());
355 }
356}