Skip to main content

cloud_sdk/operation/
policy.rs

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