Skip to main content

cloud_sdk/operation/
policy.rs

1//! Checked provider-neutral response policy.
2mod cursor;
3use core::fmt;
4
5use super::RequestIdPolicy;
6use crate::rate_limit::RateLimit;
7use crate::transport::{
8    MediaType, ResponseBuffer, ResponseContentType, ResponseDecodeWorkspace, ResponseWriterError,
9    RetainedMetadataError, RetainedResponseMetadata, StatusCode, TransportResponse,
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    /// A present response content type is malformed.
82    InvalidContentType,
83    /// The supplied content type is not accepted.
84    UnexpectedContentType,
85    /// A content type was supplied when forbidden.
86    ForbiddenContentType,
87    /// The response writer was not successfully committed.
88    UncommittedResponse,
89    /// A present provider request identifier violated its bounded policy.
90    InvalidRequestId,
91}
92
93impl_static_error!(ResponsePolicyError,
94    Self::UnexpectedStatus => "response status is not expected",
95    Self::BodyTooLarge => "response body exceeds the operation limit",
96    Self::MissingBody => "required response body is missing",
97    Self::ForbiddenBody => "response body is forbidden",
98    Self::MissingContentType => "required response content type is missing",
99    Self::InvalidContentType => "response content type is invalid",
100    Self::UnexpectedContentType => "response content type is not accepted",
101    Self::ForbiddenContentType => "response content type is forbidden",
102    Self::UncommittedResponse => "response writer is not committed",
103    Self::InvalidRequestId => "response request identifier is invalid",
104);
105
106/// Complete checked-response policy.
107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
108pub struct ResponsePolicy {
109    success_statuses: &'static [StatusCode],
110    content_type: ContentTypePolicy,
111    body: ResponseBodyPolicy,
112    max_body_bytes: usize,
113}
114
115impl ResponsePolicy {
116    /// Creates a complete policy without implicit status, media, or body defaults.
117    pub fn new(
118        success_statuses: &'static [StatusCode],
119        content_type: ContentTypePolicy,
120        body: ResponseBodyPolicy,
121        max_body_bytes: usize,
122    ) -> Result<Self, ResponsePolicyValidationError> {
123        validate_statuses(success_statuses)?;
124        validate_media_types(content_type)?;
125        match (body, content_type, max_body_bytes) {
126            (ResponseBodyPolicy::Required, _, 0) => {
127                return Err(ResponsePolicyValidationError::RequiredBodyHasZeroLimit);
128            }
129            (ResponseBodyPolicy::Forbidden, _, limit) if limit != 0 => {
130                return Err(ResponsePolicyValidationError::ForbiddenBodyHasNonzeroLimit);
131            }
132            (
133                ResponseBodyPolicy::Forbidden,
134                ContentTypePolicy::Required(_) | ContentTypePolicy::Optional(_),
135                _,
136            ) => {
137                return Err(ResponsePolicyValidationError::ForbiddenBodyAllowsContentType);
138            }
139            (ResponseBodyPolicy::Required, ContentTypePolicy::Forbidden, _) => {
140                return Err(ResponsePolicyValidationError::RequiredBodyForbidsContentType);
141            }
142            _ => {}
143        }
144        Ok(Self {
145            success_statuses,
146            content_type,
147            body,
148            max_body_bytes,
149        })
150    }
151
152    /// Returns expected success statuses.
153    #[must_use]
154    pub const fn success_statuses(self) -> &'static [StatusCode] {
155        self.success_statuses
156    }
157    /// Returns response content-type policy.
158    #[must_use]
159    pub const fn content_type_policy(self) -> ContentTypePolicy {
160        self.content_type
161    }
162    /// Returns response-body policy.
163    #[must_use]
164    pub const fn body_policy(self) -> ResponseBodyPolicy {
165        self.body
166    }
167
168    /// Returns maximum admitted initialized response bytes.
169    #[must_use]
170    pub const fn max_body_bytes(self) -> usize {
171        self.max_body_bytes
172    }
173
174    /// Checks status, initialized length, body shape, and content type.
175    pub fn validate<'buffer>(
176        self,
177        mut writer: ResponseBuffer<'buffer>,
178        request_id_policy: RequestIdPolicy,
179    ) -> Result<CheckedResponseGuard<'buffer>, ResponsePolicyError> {
180        apply_request_id_policy(&mut writer, request_id_policy)?;
181        let snapshot = {
182            let response = writer.response().map_err(map_writer_error)?;
183            self.validate_view(response, request_id_policy)?
184        };
185        Ok(CheckedResponseGuard {
186            writer,
187            snapshot,
188            workspace: ResponseDecodeWorkspace::new(),
189        })
190    }
191
192    fn validate_view(
193        self,
194        response: TransportResponse<'_, '_>,
195        request_id_policy: RequestIdPolicy,
196    ) -> Result<CheckedResponseSnapshot, ResponsePolicyError> {
197        if !self.success_statuses.contains(&response.status()) {
198            return Err(ResponsePolicyError::UnexpectedStatus);
199        }
200        match self.body {
201            ResponseBodyPolicy::Forbidden if !response.body().is_empty() => {
202                return Err(ResponsePolicyError::ForbiddenBody);
203            }
204            _ => {}
205        }
206        if response.body().len() > self.max_body_bytes {
207            return Err(ResponsePolicyError::BodyTooLarge);
208        }
209        if matches!(self.body, ResponseBodyPolicy::Required) && response.body().is_empty() {
210            return Err(ResponsePolicyError::MissingBody);
211        }
212        let content_type = response
213            .content_type()
214            .map_err(|_| ResponsePolicyError::InvalidContentType)?;
215        validate_content_type(self.content_type, content_type)?;
216        Ok(CheckedResponseSnapshot {
217            status: response.status(),
218            body_len: response.body().len(),
219            rate_limit: response.rate_limit(),
220            request_id_policy,
221        })
222    }
223}
224
225pub(crate) fn apply_request_id_policy(
226    writer: &mut ResponseBuffer<'_>,
227    request_id_policy: RequestIdPolicy,
228) -> Result<(), ResponsePolicyError> {
229    writer.response().map_err(map_writer_error)?;
230    writer
231        .apply_request_id_policy(request_id_policy)
232        .map_err(|_| ResponsePolicyError::InvalidRequestId)
233}
234
235/// Response that passed one operation's complete provider-neutral policy.
236#[derive(Clone, Copy)]
237pub struct CheckedResponse<'body> {
238    status: StatusCode,
239    body: &'body [u8],
240    content_type: Option<ResponseContentType<'body>>,
241    rate_limit: Option<RateLimit>,
242    request_id: Option<&'body [u8]>,
243    request_id_policy: RequestIdPolicy,
244}
245
246impl<'body> CheckedResponse<'body> {
247    /// Returns the checked status code.
248    #[must_use]
249    pub const fn status(&self) -> StatusCode {
250        self.status
251    }
252
253    /// Returns the checked initialized response body.
254    #[must_use]
255    pub const fn body(&self) -> &[u8] {
256        self.body
257    }
258
259    /// Returns the checked response content type when supplied.
260    #[must_use]
261    pub const fn content_type(&self) -> Option<ResponseContentType<'body>> {
262        self.content_type
263    }
264
265    /// Returns validated rate-limit metadata when supplied.
266    #[must_use]
267    pub const fn rate_limit(&self) -> Option<RateLimit> {
268        self.rate_limit
269    }
270
271    /// Returns the request-identifier lifecycle policy.
272    #[must_use]
273    pub const fn request_id_policy(&self) -> RequestIdPolicy {
274        self.request_id_policy
275    }
276
277    /// Runs a closure with the protected request identifier when retained.
278    pub fn with_request_id<R>(&self, inspect: impl FnOnce(Option<&[u8]>) -> R) -> R {
279        inspect(self.request_id)
280    }
281}
282
283#[derive(Clone, Copy)]
284struct CheckedResponseSnapshot {
285    status: StatusCode,
286    body_len: usize,
287    rate_limit: Option<RateLimit>,
288    request_id_policy: RequestIdPolicy,
289}
290
291/// Policy-checked response that owns cleanup of its caller storage.
292///
293/// Borrowed access is closure-scoped. [`Self::decode_owned`] clears the
294/// complete response storage before returning the owned decoded result.
295pub struct CheckedResponseGuard<'buffer> {
296    writer: ResponseBuffer<'buffer>,
297    snapshot: CheckedResponseSnapshot,
298    workspace: ResponseDecodeWorkspace,
299}
300
301impl CheckedResponseGuard<'_> {
302    /// Returns the checked status code.
303    #[must_use]
304    pub const fn status(&self) -> StatusCode {
305        self.snapshot.status
306    }
307
308    /// Returns the checked response content type when supplied.
309    #[must_use]
310    pub fn content_type(&self) -> Option<ResponseContentType<'_>> {
311        self.writer
312            .response()
313            .ok()
314            .and_then(|response| response.content_type().ok().flatten())
315    }
316
317    /// Returns validated rate-limit metadata when supplied.
318    #[must_use]
319    pub const fn rate_limit(&self) -> Option<RateLimit> {
320        self.snapshot.rate_limit
321    }
322
323    /// Runs a closure with a checked response borrow that cannot escape.
324    ///
325    /// ```compile_fail
326    /// use cloud_sdk::operation::CheckedResponseGuard;
327    /// fn escape<'guard>(
328    ///     guard: &'guard CheckedResponseGuard<'_>,
329    /// ) -> &'guard [u8] {
330    ///     guard.with_borrowed(|response| response.body())
331    /// }
332    /// ```
333    pub fn with_borrowed<R>(
334        &self,
335        inspect: impl for<'response> FnOnce(CheckedResponse<'response>) -> R,
336    ) -> R {
337        inspect(self.checked_response())
338    }
339
340    /// Decodes an owned value, clears all response storage, and then returns.
341    pub fn decode_owned<R, E>(
342        self,
343        decode: impl for<'response> FnOnce(CheckedResponse<'response>) -> Result<R, E>,
344    ) -> Result<R, E> {
345        self.decode_owned_with_workspace(|response, _workspace| decode(response))
346    }
347
348    /// Decodes with guard-owned scratch, then clears the complete workspace.
349    pub fn decode_owned_with_workspace<R, E>(
350        mut self,
351        decode: impl for<'response> FnOnce(
352            CheckedResponse<'response>,
353            &mut ResponseDecodeWorkspace,
354        ) -> Result<R, E>,
355    ) -> Result<R, E> {
356        let result = {
357            let Self {
358                writer,
359                snapshot,
360                workspace,
361            } = &mut self;
362            let response = checked_response(writer, *snapshot);
363            decode(response, workspace)
364        };
365        drop(self);
366        result
367    }
368
369    /// Atomically moves a retainable request ID into another cleanup owner.
370    pub fn retain_metadata_into<'destination>(
371        &mut self,
372        destination: &'destination mut [u8],
373        request_id_limit: usize,
374    ) -> Result<RetainedResponseMetadata<'destination>, RetainedMetadataError> {
375        if self.snapshot.request_id_policy != RequestIdPolicy::Retain {
376            return Err(RetainedMetadataError::RetentionForbidden);
377        }
378        self.writer.retain_request_id(destination, request_id_limit)
379    }
380
381    fn checked_response(&self) -> CheckedResponse<'_> {
382        checked_response(&self.writer, self.snapshot)
383    }
384}
385
386impl fmt::Debug for CheckedResponseGuard<'_> {
387    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
388        formatter
389            .debug_struct("CheckedResponseGuard")
390            .field("status", &self.status())
391            .field("body_len", &self.snapshot.body_len)
392            .field("body", &"[redacted]")
393            .field("content_type", &self.content_type())
394            .field("rate_limit", &self.rate_limit())
395            .field("request_id", &"[redacted]")
396            .finish()
397    }
398}
399
400impl fmt::Debug for CheckedResponse<'_> {
401    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
402        formatter
403            .debug_struct("CheckedResponse")
404            .field("status", &self.status())
405            .field("body_len", &self.body().len())
406            .field("body", &"[redacted]")
407            .field("content_type", &self.content_type())
408            .field("rate_limit", &self.rate_limit())
409            .field("request_id", &"[redacted]")
410            .finish()
411    }
412}
413
414fn checked_response<'response>(
415    writer: &'response ResponseBuffer<'_>,
416    snapshot: CheckedResponseSnapshot,
417) -> CheckedResponse<'response> {
418    CheckedResponse {
419        status: snapshot.status,
420        body: writer.initialized_body(snapshot.body_len),
421        content_type: writer
422            .response()
423            .ok()
424            .and_then(|response| response.content_type().ok().flatten()),
425        rate_limit: snapshot.rate_limit,
426        request_id: writer.request_id(),
427        request_id_policy: snapshot.request_id_policy,
428    }
429}
430
431fn validate_statuses(statuses: &[StatusCode]) -> Result<(), ResponsePolicyValidationError> {
432    if statuses.is_empty() {
433        return Err(ResponsePolicyValidationError::MissingSuccessStatus);
434    }
435    for (index, status) in statuses.iter().enumerate() {
436        if !status.is_success() {
437            return Err(ResponsePolicyValidationError::NonSuccessStatus);
438        }
439        if statuses
440            .get(..index)
441            .is_some_and(|seen| seen.contains(status))
442        {
443            return Err(ResponsePolicyValidationError::DuplicateSuccessStatus);
444        }
445    }
446    Ok(())
447}
448
449fn validate_media_types(policy: ContentTypePolicy) -> Result<(), ResponsePolicyValidationError> {
450    let media_types = match policy {
451        ContentTypePolicy::Required(values) | ContentTypePolicy::Optional(values) => values,
452        ContentTypePolicy::Forbidden => return Ok(()),
453    };
454    if media_types.is_empty() {
455        return Err(ResponsePolicyValidationError::MissingAcceptedMediaType);
456    }
457    for (index, media_type) in media_types.iter().enumerate() {
458        if media_types.get(..index).is_some_and(|seen| {
459            seen.iter()
460                .any(|candidate| candidate.as_str().eq_ignore_ascii_case(media_type.as_str()))
461        }) {
462            return Err(ResponsePolicyValidationError::DuplicateAcceptedMediaType);
463        }
464    }
465    Ok(())
466}
467
468fn validate_content_type(
469    policy: ContentTypePolicy,
470    actual: Option<ResponseContentType<'_>>,
471) -> Result<(), ResponsePolicyError> {
472    match (policy, actual) {
473        (ContentTypePolicy::Required(_), None) => Err(ResponsePolicyError::MissingContentType),
474        (ContentTypePolicy::Forbidden, Some(_)) => Err(ResponsePolicyError::ForbiddenContentType),
475        (ContentTypePolicy::Forbidden | ContentTypePolicy::Optional(_), None) => Ok(()),
476        (
477            ContentTypePolicy::Required(accepted) | ContentTypePolicy::Optional(accepted),
478            Some(actual),
479        ) => {
480            if accepted
481                .iter()
482                .any(|media_type| actual.matches(*media_type))
483            {
484                Ok(())
485            } else {
486                Err(ResponsePolicyError::UnexpectedContentType)
487            }
488        }
489    }
490}
491const fn map_writer_error(error: ResponseWriterError) -> ResponsePolicyError {
492    match error {
493        ResponseWriterError::NotCommitted
494        | ResponseWriterError::AlreadyCommitted
495        | ResponseWriterError::InitializedLengthTooLarge => {
496            ResponsePolicyError::UncommittedResponse
497        }
498    }
499}