Skip to main content

soaprs_http/
catalog.rs

1//! Deterministic endpoint catalog and route grouping.
2
3use 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/// Ordered endpoint catalog with duplicate identity and route-shape detection.
14#[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    /// Creates an empty endpoint catalog.
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    /// Registers one validated endpoint.
28    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    /// Atomically registers every endpoint or leaves the catalog unchanged.
52    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    /// Returns one endpoint by stable identity.
65    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    /// Returns one endpoint by method and declared portable route shape.
72    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    /// Returns endpoints in deterministic registration order.
79    pub fn endpoints(&self) -> &[EndpointMetadata] {
80        &self.endpoints
81    }
82
83    /// Returns the number of registered endpoints.
84    pub fn len(&self) -> usize {
85        self.endpoints.len()
86    }
87
88    /// Reports whether the catalog is empty.
89    pub fn is_empty(&self) -> bool {
90        self.endpoints.is_empty()
91    }
92
93    /// Consumes the catalog into deterministic endpoint order.
94    pub fn into_endpoints(self) -> Vec<EndpointMetadata> {
95        self.endpoints
96    }
97}
98
99/// Endpoint group that applies a route prefix and shared safe defaults.
100#[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    /// Creates a public endpoint group under one portable prefix.
117    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    /// Sets the authorization inherited by new endpoints.
134    pub fn authorize(mut self, policy: AuthorizationPolicy) -> SoapResult<Self> {
135        policy.validate()?;
136        self.authorization = policy;
137        Ok(self)
138    }
139
140    /// Sets the rate limit inherited by new endpoints.
141    #[must_use]
142    pub fn rate_limit(mut self, policy: RateLimitPolicy) -> Self {
143        self.rate_limit = Some(policy);
144        self
145    }
146
147    /// Sets the timeout inherited by new endpoints.
148    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    /// Sets the encoded body limit inherited by new endpoints.
159    #[must_use]
160    pub fn body_limit(mut self, policy: BodyLimitPolicy) -> Self {
161        self.body_limit = Some(policy);
162        self
163    }
164
165    /// Sets the cross-origin policy inherited by new endpoints.
166    #[must_use]
167    pub fn cors(mut self, policy: CorsPolicy) -> Self {
168        self.cors = Some(policy);
169        self
170    }
171
172    /// Requires CSRF validation for new endpoints.
173    #[must_use]
174    pub const fn require_csrf(mut self) -> Self {
175        self.csrf = CsrfPolicy::Required;
176        self
177    }
178
179    /// Replaces security-header defaults inherited by new endpoints.
180    #[must_use]
181    pub fn security_headers(mut self, policy: SecurityHeadersPolicy) -> Self {
182        self.security_headers = Some(policy);
183        self
184    }
185
186    /// Explicitly delegates security headers for new endpoints to the application.
187    #[must_use]
188    pub fn without_security_headers(mut self) -> Self {
189        self.security_headers = None;
190        self
191    }
192
193    /// Sets the response-cache policy inherited by new endpoints.
194    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    /// Replaces telemetry instructions inherited by new endpoints.
201    pub fn telemetry(mut self, policy: TelemetryPolicy) -> SoapResult<Self> {
202        policy.validate()?;
203        self.telemetry = policy;
204        Ok(self)
205    }
206
207    /// Adds one documentation tag inherited by new endpoints.
208    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    /// Creates a new endpoint with the group prefix and policies applied.
220    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}