Skip to main content

cloud_sdk/operation/
policy.rs

1//! Checked provider-neutral response policy.
2mod cursor;
3use super::RequestIdPolicy;
4use crate::rate_limit::RateLimit;
5use crate::transport::{
6    MediaType, ResponseBuffer, ResponseContentType, ResponseDecodeWorkspace, ResponseWriterError,
7    RetainedMetadataError, RetainedResponseMetadata, StatusCode, TransportResponse,
8};
9use core::fmt;
10/// Expected response-body shape.
11#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub enum ResponseBodyPolicy {
13    /// A non-empty response body is required.
14    Required,
15    /// An empty or non-empty response body is accepted.
16    Optional,
17    /// Any response body is rejected.
18    Forbidden,
19}
20
21/// Response content-type requirement and accepted media types.
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum ContentTypePolicy {
24    /// A content type is required and must match one accepted media type.
25    Required(&'static [MediaType<'static>]),
26    /// A content type may be absent, but when present it must match.
27    Optional(&'static [MediaType<'static>]),
28    /// Any response content type is rejected.
29    Forbidden,
30}
31
32/// Invalid response-policy construction.
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum ResponsePolicyValidationError {
35    /// At least one success status is required.
36    MissingSuccessStatus,
37    /// Expected success statuses must be in the HTTP `2xx` range.
38    NonSuccessStatus,
39    /// Expected success statuses must not contain duplicates.
40    DuplicateSuccessStatus,
41    /// Required or optional content-type policy needs accepted media types.
42    MissingAcceptedMediaType,
43    /// Accepted media types must not contain duplicates.
44    DuplicateAcceptedMediaType,
45    /// Required response bodies need a nonzero maximum length.
46    RequiredBodyHasZeroLimit,
47    /// Forbidden response bodies require a zero maximum length.
48    ForbiddenBodyHasNonzeroLimit,
49    /// A forbidden body cannot require or optionally accept a content type.
50    ForbiddenBodyAllowsContentType,
51    /// A required body cannot forbid its content type.
52    RequiredBodyForbidsContentType,
53}
54
55impl_static_error!(ResponsePolicyValidationError,
56    Self::MissingSuccessStatus => "response policy has no success status",
57    Self::NonSuccessStatus => "response policy contains a non-success status",
58    Self::DuplicateSuccessStatus => "response policy contains duplicate statuses",
59    Self::MissingAcceptedMediaType => "response policy has no accepted media type",
60    Self::DuplicateAcceptedMediaType => "response policy contains duplicate media types",
61    Self::RequiredBodyHasZeroLimit => "required response body has a zero limit",
62    Self::ForbiddenBodyHasNonzeroLimit => "forbidden response body has a nonzero limit",
63    Self::ForbiddenBodyAllowsContentType => "forbidden response body allows a content type",
64    Self::RequiredBodyForbidsContentType => "required response body forbids its content type",
65);
66
67/// Response rejected before provider decoding.
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub enum ResponsePolicyError {
70    /// The response status is not an expected success status.
71    UnexpectedStatus,
72    /// The initialized body exceeds the operation's admitted limit.
73    BodyTooLarge,
74    /// A required response body is empty.
75    MissingBody,
76    /// A response body was supplied when forbidden.
77    ForbiddenBody,
78    /// A required response content type is absent.
79    MissingContentType,
80    /// A present response content type is malformed.
81    InvalidContentType,
82    /// The supplied content type is not accepted.
83    UnexpectedContentType,
84    /// A content type was supplied when forbidden.
85    ForbiddenContentType,
86    /// The response writer was not successfully committed.
87    UncommittedResponse,
88    /// A present provider request identifier violated its bounded policy.
89    InvalidRequestId,
90}
91
92impl_static_error!(ResponsePolicyError,
93    Self::UnexpectedStatus => "response status is not expected",
94    Self::BodyTooLarge => "response body exceeds the operation limit",
95    Self::MissingBody => "required response body is missing",
96    Self::ForbiddenBody => "response body is forbidden",
97    Self::MissingContentType => "required response content type is missing",
98    Self::InvalidContentType => "response content type is invalid",
99    Self::UnexpectedContentType => "response content type is not accepted",
100    Self::ForbiddenContentType => "response content type is forbidden",
101    Self::UncommittedResponse => "response writer is not committed",
102    Self::InvalidRequestId => "response request identifier is invalid",
103);
104
105/// Complete checked-response policy.
106#[derive(Clone, Copy, Debug, Eq, PartialEq)]
107pub struct ResponsePolicy {
108    success_statuses: &'static [StatusCode],
109    content_type: ContentTypePolicy,
110    body: ResponseBodyPolicy,
111    max_body_bytes: usize,
112}
113
114impl ResponsePolicy {
115    /// Creates a complete policy without implicit status, media, or body defaults.
116    pub fn new(
117        success_statuses: &'static [StatusCode],
118        content_type: ContentTypePolicy,
119        body: ResponseBodyPolicy,
120        max_body_bytes: usize,
121    ) -> Result<Self, ResponsePolicyValidationError> {
122        validate_statuses(success_statuses)?;
123        validate_media_types(content_type)?;
124        match (body, content_type, max_body_bytes) {
125            (ResponseBodyPolicy::Required, _, 0) => {
126                return Err(ResponsePolicyValidationError::RequiredBodyHasZeroLimit);
127            }
128            (ResponseBodyPolicy::Forbidden, _, limit) if limit != 0 => {
129                return Err(ResponsePolicyValidationError::ForbiddenBodyHasNonzeroLimit);
130            }
131            (
132                ResponseBodyPolicy::Forbidden,
133                ContentTypePolicy::Required(_) | ContentTypePolicy::Optional(_),
134                _,
135            ) => {
136                return Err(ResponsePolicyValidationError::ForbiddenBodyAllowsContentType);
137            }
138            (ResponseBodyPolicy::Required, ContentTypePolicy::Forbidden, _) => {
139                return Err(ResponsePolicyValidationError::RequiredBodyForbidsContentType);
140            }
141            _ => {}
142        }
143        Ok(Self {
144            success_statuses,
145            content_type,
146            body,
147            max_body_bytes,
148        })
149    }
150
151    /// Returns expected success statuses.
152    #[must_use]
153    pub const fn success_statuses(self) -> &'static [StatusCode] {
154        self.success_statuses
155    }
156    /// Returns response content-type policy.
157    #[must_use]
158    pub const fn content_type_policy(self) -> ContentTypePolicy {
159        self.content_type
160    }
161    /// Returns response-body policy.
162    #[must_use]
163    pub const fn body_policy(self) -> ResponseBodyPolicy {
164        self.body
165    }
166
167    /// Returns maximum admitted initialized response bytes.
168    #[must_use]
169    pub const fn max_body_bytes(self) -> usize {
170        self.max_body_bytes
171    }
172
173    /// Checks status, initialized length, body shape, and content type.
174    pub fn validate<'buffer>(
175        self,
176        mut writer: ResponseBuffer<'buffer>,
177        request_id_policy: RequestIdPolicy,
178    ) -> Result<CheckedResponseGuard<'buffer>, ResponsePolicyError> {
179        apply_request_id_policy(&mut writer, request_id_policy)?;
180        let snapshot = {
181            let response = writer.response().map_err(map_writer_error)?;
182            self.validate_view(response, request_id_policy)?
183        };
184        Ok(CheckedResponseGuard {
185            writer,
186            snapshot,
187            workspace: ResponseDecodeWorkspace::new(),
188        })
189    }
190
191    fn validate_view(
192        self,
193        response: TransportResponse<'_, '_>,
194        request_id_policy: RequestIdPolicy,
195    ) -> Result<CheckedResponseSnapshot, ResponsePolicyError> {
196        if !self.success_statuses.contains(&response.status()) {
197            return Err(ResponsePolicyError::UnexpectedStatus);
198        }
199        match self.body {
200            ResponseBodyPolicy::Forbidden if !response.body().is_empty() => {
201                return Err(ResponsePolicyError::ForbiddenBody);
202            }
203            _ => {}
204        }
205        if response.body().len() > self.max_body_bytes {
206            return Err(ResponsePolicyError::BodyTooLarge);
207        }
208        if matches!(self.body, ResponseBodyPolicy::Required) && response.body().is_empty() {
209            return Err(ResponsePolicyError::MissingBody);
210        }
211        let content_type = response
212            .content_type()
213            .map_err(|_| ResponsePolicyError::InvalidContentType)?;
214        validate_content_type(self.content_type, content_type)?;
215        Ok(CheckedResponseSnapshot {
216            status: response.status(),
217            body_len: response.body().len(),
218            rate_limit: response.rate_limit(),
219            request_id_policy,
220        })
221    }
222}
223
224pub(crate) fn apply_request_id_policy(
225    writer: &mut ResponseBuffer<'_>,
226    request_id_policy: RequestIdPolicy,
227) -> Result<(), ResponsePolicyError> {
228    writer.response().map_err(map_writer_error)?;
229    writer
230        .apply_request_id_policy(request_id_policy)
231        .map_err(|_| ResponsePolicyError::InvalidRequestId)
232}
233
234/// Response that passed one operation's complete provider-neutral policy.
235#[derive(Clone, Copy)]
236pub struct CheckedResponse<'body> {
237    status: StatusCode,
238    body: &'body [u8],
239    headers: &'body crate::transport::ResponseHeaders<'body>,
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
400fn checked_response<'response>(
401    writer: &'response ResponseBuffer<'_>,
402    snapshot: CheckedResponseSnapshot,
403) -> CheckedResponse<'response> {
404    CheckedResponse {
405        status: snapshot.status,
406        body: writer.initialized_body(snapshot.body_len),
407        headers: writer.headers(),
408        content_type: writer
409            .response()
410            .ok()
411            .and_then(|response| response.content_type().ok().flatten()),
412        rate_limit: snapshot.rate_limit,
413        request_id: writer.request_id(),
414        request_id_policy: snapshot.request_id_policy,
415    }
416}
417
418fn validate_statuses(statuses: &[StatusCode]) -> Result<(), ResponsePolicyValidationError> {
419    if statuses.is_empty() {
420        return Err(ResponsePolicyValidationError::MissingSuccessStatus);
421    }
422    for (index, status) in statuses.iter().enumerate() {
423        if !status.is_success() {
424            return Err(ResponsePolicyValidationError::NonSuccessStatus);
425        }
426        if statuses
427            .get(..index)
428            .is_some_and(|seen| seen.contains(status))
429        {
430            return Err(ResponsePolicyValidationError::DuplicateSuccessStatus);
431        }
432    }
433    Ok(())
434}
435
436fn validate_media_types(policy: ContentTypePolicy) -> Result<(), ResponsePolicyValidationError> {
437    let media_types = match policy {
438        ContentTypePolicy::Required(values) | ContentTypePolicy::Optional(values) => values,
439        ContentTypePolicy::Forbidden => return Ok(()),
440    };
441    if media_types.is_empty() {
442        return Err(ResponsePolicyValidationError::MissingAcceptedMediaType);
443    }
444    for (index, media_type) in media_types.iter().enumerate() {
445        if media_types.get(..index).is_some_and(|seen| {
446            seen.iter()
447                .any(|candidate| candidate.as_str().eq_ignore_ascii_case(media_type.as_str()))
448        }) {
449            return Err(ResponsePolicyValidationError::DuplicateAcceptedMediaType);
450        }
451    }
452    Ok(())
453}
454
455fn validate_content_type(
456    policy: ContentTypePolicy,
457    actual: Option<ResponseContentType<'_>>,
458) -> Result<(), ResponsePolicyError> {
459    match (policy, actual) {
460        (ContentTypePolicy::Required(_), None) => Err(ResponsePolicyError::MissingContentType),
461        (ContentTypePolicy::Forbidden, Some(_)) => Err(ResponsePolicyError::ForbiddenContentType),
462        (ContentTypePolicy::Forbidden | ContentTypePolicy::Optional(_), None) => Ok(()),
463        (
464            ContentTypePolicy::Required(accepted) | ContentTypePolicy::Optional(accepted),
465            Some(actual),
466        ) => {
467            if accepted
468                .iter()
469                .any(|media_type| actual.matches(*media_type))
470            {
471                Ok(())
472            } else {
473                Err(ResponsePolicyError::UnexpectedContentType)
474            }
475        }
476    }
477}
478const fn map_writer_error(error: ResponseWriterError) -> ResponsePolicyError {
479    match error {
480        ResponseWriterError::NotCommitted
481        | ResponseWriterError::AlreadyCommitted
482        | ResponseWriterError::InitializedLengthTooLarge => {
483            ResponsePolicyError::UncommittedResponse
484        }
485    }
486}