Skip to main content

cloud_sdk/operation/
policy.rs

1//! Checked provider-neutral response policy.
2
3use core::fmt;
4
5use crate::rate_limit::RateLimit;
6use crate::transport::{
7    MediaType, ResponseBuffer, ResponseContentType, ResponseWriterError, StatusCode,
8    TransportResponse,
9};
10
11/// Expected response-body shape.
12#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
13pub enum ResponseBodyPolicy {
14    /// A non-empty response body is required.
15    Required,
16    /// An empty or non-empty response body is accepted.
17    Optional,
18    /// Any response body is rejected.
19    Forbidden,
20}
21
22/// Response content-type requirement and accepted media types.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum ContentTypePolicy {
25    /// A content type is required and must match one accepted media type.
26    Required(&'static [MediaType<'static>]),
27    /// A content type may be absent, but when present it must match.
28    Optional(&'static [MediaType<'static>]),
29    /// Any response content type is rejected.
30    Forbidden,
31}
32
33/// Invalid response-policy construction.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum ResponsePolicyValidationError {
36    /// At least one success status is required.
37    MissingSuccessStatus,
38    /// Expected success statuses must be in the HTTP `2xx` range.
39    NonSuccessStatus,
40    /// Expected success statuses must not contain duplicates.
41    DuplicateSuccessStatus,
42    /// Required or optional content-type policy needs accepted media types.
43    MissingAcceptedMediaType,
44    /// Accepted media types must not contain duplicates.
45    DuplicateAcceptedMediaType,
46    /// Required response bodies need a nonzero maximum length.
47    RequiredBodyHasZeroLimit,
48    /// Forbidden response bodies require a zero maximum length.
49    ForbiddenBodyHasNonzeroLimit,
50    /// A forbidden body cannot require or optionally accept a content type.
51    ForbiddenBodyAllowsContentType,
52    /// A required body cannot forbid its content type.
53    RequiredBodyForbidsContentType,
54}
55
56impl_static_error!(ResponsePolicyValidationError,
57    Self::MissingSuccessStatus => "response policy has no success status",
58    Self::NonSuccessStatus => "response policy contains a non-success status",
59    Self::DuplicateSuccessStatus => "response policy contains duplicate statuses",
60    Self::MissingAcceptedMediaType => "response policy has no accepted media type",
61    Self::DuplicateAcceptedMediaType => "response policy contains duplicate media types",
62    Self::RequiredBodyHasZeroLimit => "required response body has a zero limit",
63    Self::ForbiddenBodyHasNonzeroLimit => "forbidden response body has a nonzero limit",
64    Self::ForbiddenBodyAllowsContentType => "forbidden response body allows a content type",
65    Self::RequiredBodyForbidsContentType => "required response body forbids its content type",
66);
67
68/// Response rejected before provider decoding.
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub enum ResponsePolicyError {
71    /// The response status is not an expected success status.
72    UnexpectedStatus,
73    /// The initialized body exceeds the operation's admitted limit.
74    BodyTooLarge,
75    /// A required response body is empty.
76    MissingBody,
77    /// A response body was supplied when forbidden.
78    ForbiddenBody,
79    /// A required response content type is absent.
80    MissingContentType,
81    /// The supplied content type is not accepted.
82    UnexpectedContentType,
83    /// A content type was supplied when forbidden.
84    ForbiddenContentType,
85    /// The response writer was not successfully committed.
86    UncommittedResponse,
87}
88
89impl_static_error!(ResponsePolicyError,
90    Self::UnexpectedStatus => "response status is not expected",
91    Self::BodyTooLarge => "response body exceeds the operation limit",
92    Self::MissingBody => "required response body is missing",
93    Self::ForbiddenBody => "response body is forbidden",
94    Self::MissingContentType => "required response content type is missing",
95    Self::UnexpectedContentType => "response content type is not accepted",
96    Self::ForbiddenContentType => "response content type is forbidden",
97    Self::UncommittedResponse => "response writer is not committed",
98);
99
100/// Complete checked-response policy.
101#[derive(Clone, Copy, Debug, Eq, PartialEq)]
102pub struct ResponsePolicy {
103    success_statuses: &'static [StatusCode],
104    content_type: ContentTypePolicy,
105    body: ResponseBodyPolicy,
106    max_body_bytes: usize,
107}
108
109impl ResponsePolicy {
110    /// Creates a complete policy without implicit status, media, or body defaults.
111    pub fn new(
112        success_statuses: &'static [StatusCode],
113        content_type: ContentTypePolicy,
114        body: ResponseBodyPolicy,
115        max_body_bytes: usize,
116    ) -> Result<Self, ResponsePolicyValidationError> {
117        validate_statuses(success_statuses)?;
118        validate_media_types(content_type)?;
119        match (body, content_type, max_body_bytes) {
120            (ResponseBodyPolicy::Required, _, 0) => {
121                return Err(ResponsePolicyValidationError::RequiredBodyHasZeroLimit);
122            }
123            (ResponseBodyPolicy::Forbidden, _, limit) if limit != 0 => {
124                return Err(ResponsePolicyValidationError::ForbiddenBodyHasNonzeroLimit);
125            }
126            (
127                ResponseBodyPolicy::Forbidden,
128                ContentTypePolicy::Required(_) | ContentTypePolicy::Optional(_),
129                _,
130            ) => {
131                return Err(ResponsePolicyValidationError::ForbiddenBodyAllowsContentType);
132            }
133            (ResponseBodyPolicy::Required, ContentTypePolicy::Forbidden, _) => {
134                return Err(ResponsePolicyValidationError::RequiredBodyForbidsContentType);
135            }
136            _ => {}
137        }
138        Ok(Self {
139            success_statuses,
140            content_type,
141            body,
142            max_body_bytes,
143        })
144    }
145
146    /// Returns expected success statuses.
147    #[must_use]
148    pub const fn success_statuses(self) -> &'static [StatusCode] {
149        self.success_statuses
150    }
151
152    /// Returns response content-type policy.
153    #[must_use]
154    pub const fn content_type_policy(self) -> ContentTypePolicy {
155        self.content_type
156    }
157
158    /// Returns response-body policy.
159    #[must_use]
160    pub const fn body_policy(self) -> ResponseBodyPolicy {
161        self.body
162    }
163
164    /// Returns maximum admitted initialized response bytes.
165    #[must_use]
166    pub const fn max_body_bytes(self) -> usize {
167        self.max_body_bytes
168    }
169
170    /// Checks status, initialized length, body shape, and content type.
171    pub fn validate<'buffer, 'sanitizer, S>(
172        self,
173        writer: ResponseBuffer<'buffer, 'sanitizer, S>,
174    ) -> Result<CheckedResponseGuard<'buffer, 'sanitizer, S>, ResponsePolicyError>
175    where
176        S: crate::transport::ResponseStorageSanitizer + ?Sized,
177    {
178        let snapshot = {
179            let response = writer.response().map_err(map_writer_error)?;
180            self.validate_view(response)?
181        };
182        Ok(CheckedResponseGuard { writer, snapshot })
183    }
184
185    fn validate_view(
186        self,
187        response: TransportResponse<'_>,
188    ) -> Result<CheckedResponseSnapshot, ResponsePolicyError> {
189        if !self.success_statuses.contains(&response.status()) {
190            return Err(ResponsePolicyError::UnexpectedStatus);
191        }
192        match self.body {
193            ResponseBodyPolicy::Forbidden if !response.body().is_empty() => {
194                return Err(ResponsePolicyError::ForbiddenBody);
195            }
196            _ => {}
197        }
198        if response.body().len() > self.max_body_bytes {
199            return Err(ResponsePolicyError::BodyTooLarge);
200        }
201        if matches!(self.body, ResponseBodyPolicy::Required) && response.body().is_empty() {
202            return Err(ResponsePolicyError::MissingBody);
203        }
204        validate_content_type(self.content_type, response.content_type())?;
205        Ok(CheckedResponseSnapshot {
206            status: response.status(),
207            body_len: response.body().len(),
208            content_type: response.content_type(),
209            rate_limit: response.rate_limit(),
210        })
211    }
212}
213
214/// Response that passed one operation's complete provider-neutral policy.
215#[derive(Clone, Copy)]
216pub struct CheckedResponse<'body> {
217    status: StatusCode,
218    body: &'body [u8],
219    content_type: Option<ResponseContentType>,
220    rate_limit: Option<RateLimit>,
221}
222
223impl CheckedResponse<'_> {
224    /// Returns the checked status code.
225    #[must_use]
226    pub const fn status(&self) -> StatusCode {
227        self.status
228    }
229
230    /// Returns the checked initialized response body.
231    #[must_use]
232    pub const fn body(&self) -> &[u8] {
233        self.body
234    }
235
236    /// Returns the checked response content type when supplied.
237    #[must_use]
238    pub const fn content_type(&self) -> Option<ResponseContentType> {
239        self.content_type
240    }
241
242    /// Returns validated rate-limit metadata when supplied.
243    #[must_use]
244    pub const fn rate_limit(&self) -> Option<RateLimit> {
245        self.rate_limit
246    }
247}
248
249#[derive(Clone, Copy)]
250struct CheckedResponseSnapshot {
251    status: StatusCode,
252    body_len: usize,
253    content_type: Option<ResponseContentType>,
254    rate_limit: Option<RateLimit>,
255}
256
257/// Policy-checked response that owns cleanup of its caller storage.
258///
259/// Borrowed access is closure-scoped. [`Self::decode_owned`] clears the
260/// complete response storage before returning the owned decoded result.
261pub struct CheckedResponseGuard<
262    'buffer,
263    'sanitizer,
264    S: crate::transport::ResponseStorageSanitizer + ?Sized,
265> {
266    writer: ResponseBuffer<'buffer, 'sanitizer, S>,
267    snapshot: CheckedResponseSnapshot,
268}
269
270impl<S> CheckedResponseGuard<'_, '_, S>
271where
272    S: crate::transport::ResponseStorageSanitizer + ?Sized,
273{
274    /// Returns the checked status code.
275    #[must_use]
276    pub const fn status(&self) -> StatusCode {
277        self.snapshot.status
278    }
279
280    /// Returns the checked response content type when supplied.
281    #[must_use]
282    pub const fn content_type(&self) -> Option<ResponseContentType> {
283        self.snapshot.content_type
284    }
285
286    /// Returns validated rate-limit metadata when supplied.
287    #[must_use]
288    pub const fn rate_limit(&self) -> Option<RateLimit> {
289        self.snapshot.rate_limit
290    }
291
292    /// Runs a closure with a checked response borrow that cannot escape.
293    ///
294    /// ```compile_fail
295    /// use cloud_sdk::operation::CheckedResponseGuard;
296    /// use cloud_sdk::transport::ResponseStorageSanitizer;
297    ///
298    /// fn escape<'guard, S>(
299    ///     guard: &'guard CheckedResponseGuard<'_, '_, S>,
300    /// ) -> &'guard [u8]
301    /// where
302    ///     S: ResponseStorageSanitizer + ?Sized,
303    /// {
304    ///     guard.with_borrowed(|response| response.body())
305    /// }
306    /// ```
307    pub fn with_borrowed<R>(
308        &self,
309        inspect: impl for<'response> FnOnce(CheckedResponse<'response>) -> R,
310    ) -> R {
311        inspect(self.checked_response())
312    }
313
314    /// Decodes an owned value, clears all response storage, and then returns.
315    pub fn decode_owned<R, E>(
316        self,
317        decode: impl for<'response> FnOnce(CheckedResponse<'response>) -> Result<R, E>,
318    ) -> Result<R, E> {
319        let result = decode(self.checked_response());
320        drop(self);
321        result
322    }
323
324    fn checked_response(&self) -> CheckedResponse<'_> {
325        CheckedResponse {
326            status: self.snapshot.status,
327            body: self.writer.initialized_body(self.snapshot.body_len),
328            content_type: self.snapshot.content_type,
329            rate_limit: self.snapshot.rate_limit,
330        }
331    }
332}
333
334impl<S> fmt::Debug for CheckedResponseGuard<'_, '_, S>
335where
336    S: crate::transport::ResponseStorageSanitizer + ?Sized,
337{
338    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
339        formatter
340            .debug_struct("CheckedResponseGuard")
341            .field("status", &self.status())
342            .field("body_len", &self.snapshot.body_len)
343            .field("body", &"[redacted]")
344            .field("content_type", &self.content_type())
345            .field("rate_limit", &self.rate_limit())
346            .finish()
347    }
348}
349
350impl fmt::Debug for CheckedResponse<'_> {
351    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
352        formatter
353            .debug_struct("CheckedResponse")
354            .field("status", &self.status())
355            .field("body_len", &self.body().len())
356            .field("body", &"[redacted]")
357            .field("content_type", &self.content_type())
358            .field("rate_limit", &self.rate_limit())
359            .finish()
360    }
361}
362
363fn validate_statuses(statuses: &[StatusCode]) -> Result<(), ResponsePolicyValidationError> {
364    if statuses.is_empty() {
365        return Err(ResponsePolicyValidationError::MissingSuccessStatus);
366    }
367    for (index, status) in statuses.iter().enumerate() {
368        if !status.is_success() {
369            return Err(ResponsePolicyValidationError::NonSuccessStatus);
370        }
371        if statuses
372            .get(..index)
373            .is_some_and(|seen| seen.contains(status))
374        {
375            return Err(ResponsePolicyValidationError::DuplicateSuccessStatus);
376        }
377    }
378    Ok(())
379}
380
381fn validate_media_types(policy: ContentTypePolicy) -> Result<(), ResponsePolicyValidationError> {
382    let media_types = match policy {
383        ContentTypePolicy::Required(values) | ContentTypePolicy::Optional(values) => values,
384        ContentTypePolicy::Forbidden => return Ok(()),
385    };
386    if media_types.is_empty() {
387        return Err(ResponsePolicyValidationError::MissingAcceptedMediaType);
388    }
389    for (index, media_type) in media_types.iter().enumerate() {
390        if media_types.get(..index).is_some_and(|seen| {
391            seen.iter()
392                .any(|candidate| candidate.as_str().eq_ignore_ascii_case(media_type.as_str()))
393        }) {
394            return Err(ResponsePolicyValidationError::DuplicateAcceptedMediaType);
395        }
396    }
397    Ok(())
398}
399
400fn validate_content_type(
401    policy: ContentTypePolicy,
402    actual: Option<ResponseContentType>,
403) -> Result<(), ResponsePolicyError> {
404    match (policy, actual) {
405        (ContentTypePolicy::Required(_), None) => Err(ResponsePolicyError::MissingContentType),
406        (ContentTypePolicy::Forbidden, Some(_)) => Err(ResponsePolicyError::ForbiddenContentType),
407        (ContentTypePolicy::Forbidden | ContentTypePolicy::Optional(_), None) => Ok(()),
408        (
409            ContentTypePolicy::Required(accepted) | ContentTypePolicy::Optional(accepted),
410            Some(actual),
411        ) => {
412            if accepted
413                .iter()
414                .any(|media_type| actual.matches(*media_type))
415            {
416                Ok(())
417            } else {
418                Err(ResponsePolicyError::UnexpectedContentType)
419            }
420        }
421    }
422}
423
424const fn map_writer_error(error: ResponseWriterError) -> ResponsePolicyError {
425    match error {
426        ResponseWriterError::NotCommitted
427        | ResponseWriterError::AlreadyCommitted
428        | ResponseWriterError::InitializedLengthTooLarge => {
429            ResponsePolicyError::UncommittedResponse
430        }
431    }
432}