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, OperationDocumentation, RateLimitPolicy, RequestContract,
11    RequestContractLocation, ResponseCachePolicy, ResponseContract, RoutePath,
12    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    /// Validates invariants after direct public-field construction or mutation.
200    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}