Skip to main content

soaprs_http/
endpoint.rs

1//! Complete endpoint declarations without framework handler types.
2
3use 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/// Portable endpoint definition consumed by framework, auth, docs, and telemetry adapters.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct EndpointMetadata {
18    /// Stable endpoint identity used by diagnostics and API documentation.
19    pub id: EndpointId,
20    /// HTTP method.
21    pub method: Method,
22    /// Portable route path.
23    pub path: RoutePath,
24    /// Successful response status used when a handler returns plain output.
25    pub success_status: StatusCode,
26    /// Authentication and authorization requirement.
27    pub authorization: AuthorizationPolicy,
28    /// Optional request rate limit.
29    pub rate_limit: Option<RateLimitPolicy>,
30    /// Optional request timeout.
31    pub timeout: Option<Duration>,
32    /// Optional maximum encoded request body size.
33    pub body_limit: Option<BodyLimitPolicy>,
34    /// Optional cross-origin policy.
35    pub cors: Option<CorsPolicy>,
36    /// Cross-site request-forgery requirement.
37    pub csrf: CsrfPolicy,
38    /// Optional security response headers. Secure defaults are enabled initially.
39    pub security_headers: Option<SecurityHeadersPolicy>,
40    /// Optional HTTP response caching policy.
41    pub response_cache: Option<ResponseCachePolicy>,
42    /// Logical validation and response schema references.
43    pub contracts: EndpointContracts,
44    /// Provider-neutral operation documentation.
45    pub documentation: OperationDocumentation,
46    /// Provider-neutral tracing and metrics instructions.
47    pub telemetry: TelemetryPolicy,
48    /// Documentation and grouping tags.
49    pub tags: Vec<String>,
50}
51
52impl EndpointMetadata {
53    /// Creates a public endpoint with secure headers and telemetry enabled.
54    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    /// Sets a successful 2xx response status.
76    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    /// Sets and validates the authorization policy.
87    pub fn authorize(mut self, policy: AuthorizationPolicy) -> SoapResult<Self> {
88        policy.validate()?;
89        self.authorization = policy;
90        Ok(self)
91    }
92
93    /// Sets the rate-limit policy.
94    #[must_use]
95    pub fn rate_limit(mut self, policy: RateLimitPolicy) -> Self {
96        self.rate_limit = Some(policy);
97        self
98    }
99
100    /// Sets a non-zero request timeout.
101    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    /// Limits the encoded request body before extraction.
112    #[must_use]
113    pub fn body_limit(mut self, policy: BodyLimitPolicy) -> Self {
114        self.body_limit = Some(policy);
115        self
116    }
117
118    /// Sets the cross-origin policy.
119    #[must_use]
120    pub fn cors(mut self, policy: CorsPolicy) -> Self {
121        self.cors = Some(policy);
122        self
123    }
124
125    /// Requires a CSRF adapter to validate the request.
126    #[must_use]
127    pub const fn require_csrf(mut self) -> Self {
128        self.csrf = CsrfPolicy::Required;
129        self
130    }
131
132    /// Replaces the security response-header policy.
133    #[must_use]
134    pub fn security_headers(mut self, policy: SecurityHeadersPolicy) -> Self {
135        self.security_headers = Some(policy);
136        self
137    }
138
139    /// Explicitly delegates every security response header to the application.
140    #[must_use]
141    pub fn without_security_headers(mut self) -> Self {
142        self.security_headers = None;
143        self
144    }
145
146    /// Sets an HTTP response caching policy.
147    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    /// Adds or replaces a request contract for one location.
160    #[must_use]
161    pub fn request_contract(mut self, contract: RequestContract) -> Self {
162        self.contracts.add_request(contract);
163        self
164    }
165
166    /// Adds or replaces a response contract for one status.
167    #[must_use]
168    pub fn response_contract(mut self, contract: ResponseContract) -> Self {
169        self.contracts.add_response(contract);
170        self
171    }
172
173    /// Replaces operation documentation.
174    #[must_use]
175    pub fn documentation(mut self, documentation: OperationDocumentation) -> Self {
176        self.documentation = documentation;
177        self
178    }
179
180    /// Replaces endpoint telemetry instructions.
181    #[must_use]
182    pub fn telemetry(mut self, telemetry: TelemetryPolicy) -> Self {
183        self.telemetry = telemetry;
184        self
185    }
186
187    /// Adds a non-empty documentation tag once.
188    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    /// Returns runtime enforcement that must be supplied by framework
200    /// extensions before this endpoint can be served.
201    ///
202    /// Body limits, deadlines, security response headers, and cache headers are
203    /// translated directly by framework adapters. Telemetry remains
204    /// observational and is therefore not an enforcement capability.
205    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    /// Validates invariants after direct public-field construction or mutation.
226    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}