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::{MediaType, ResponseContentType, StatusCode, TransportResponse};
7
8/// Expected response-body shape.
9#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub enum ResponseBodyPolicy {
11    /// A non-empty response body is required.
12    Required,
13    /// An empty or non-empty response body is accepted.
14    Optional,
15    /// Any response body is rejected.
16    Forbidden,
17}
18
19/// Response content-type requirement and accepted media types.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum ContentTypePolicy {
22    /// A content type is required and must match one accepted media type.
23    Required(&'static [MediaType<'static>]),
24    /// A content type may be absent, but when present it must match.
25    Optional(&'static [MediaType<'static>]),
26    /// Any response content type is rejected.
27    Forbidden,
28}
29
30/// Invalid response-policy construction.
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum ResponsePolicyValidationError {
33    /// At least one success status is required.
34    MissingSuccessStatus,
35    /// Expected success statuses must be in the HTTP `2xx` range.
36    NonSuccessStatus,
37    /// Expected success statuses must not contain duplicates.
38    DuplicateSuccessStatus,
39    /// Required or optional content-type policy needs accepted media types.
40    MissingAcceptedMediaType,
41    /// Accepted media types must not contain duplicates.
42    DuplicateAcceptedMediaType,
43    /// Required response bodies need a nonzero maximum length.
44    RequiredBodyHasZeroLimit,
45    /// Forbidden response bodies require a zero maximum length.
46    ForbiddenBodyHasNonzeroLimit,
47    /// A forbidden body cannot require or optionally accept a content type.
48    ForbiddenBodyAllowsContentType,
49    /// A required body cannot forbid its content type.
50    RequiredBodyForbidsContentType,
51}
52
53impl_static_error!(ResponsePolicyValidationError,
54    Self::MissingSuccessStatus => "response policy has no success status",
55    Self::NonSuccessStatus => "response policy contains a non-success status",
56    Self::DuplicateSuccessStatus => "response policy contains duplicate statuses",
57    Self::MissingAcceptedMediaType => "response policy has no accepted media type",
58    Self::DuplicateAcceptedMediaType => "response policy contains duplicate media types",
59    Self::RequiredBodyHasZeroLimit => "required response body has a zero limit",
60    Self::ForbiddenBodyHasNonzeroLimit => "forbidden response body has a nonzero limit",
61    Self::ForbiddenBodyAllowsContentType => "forbidden response body allows a content type",
62    Self::RequiredBodyForbidsContentType => "required response body forbids its content type",
63);
64
65/// Response rejected before provider decoding.
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum ResponsePolicyError {
68    /// The response status is not an expected success status.
69    UnexpectedStatus,
70    /// The initialized body exceeds the operation's admitted limit.
71    BodyTooLarge,
72    /// A required response body is empty.
73    MissingBody,
74    /// A response body was supplied when forbidden.
75    ForbiddenBody,
76    /// A required response content type is absent.
77    MissingContentType,
78    /// The supplied content type is not accepted.
79    UnexpectedContentType,
80    /// A content type was supplied when forbidden.
81    ForbiddenContentType,
82}
83
84impl_static_error!(ResponsePolicyError,
85    Self::UnexpectedStatus => "response status is not expected",
86    Self::BodyTooLarge => "response body exceeds the operation limit",
87    Self::MissingBody => "required response body is missing",
88    Self::ForbiddenBody => "response body is forbidden",
89    Self::MissingContentType => "required response content type is missing",
90    Self::UnexpectedContentType => "response content type is not accepted",
91    Self::ForbiddenContentType => "response content type is forbidden",
92);
93
94/// Complete checked-response policy.
95#[derive(Clone, Copy, Debug, Eq, PartialEq)]
96pub struct ResponsePolicy {
97    success_statuses: &'static [StatusCode],
98    content_type: ContentTypePolicy,
99    body: ResponseBodyPolicy,
100    max_body_bytes: usize,
101}
102
103impl ResponsePolicy {
104    /// Creates a complete policy without implicit status, media, or body defaults.
105    pub fn new(
106        success_statuses: &'static [StatusCode],
107        content_type: ContentTypePolicy,
108        body: ResponseBodyPolicy,
109        max_body_bytes: usize,
110    ) -> Result<Self, ResponsePolicyValidationError> {
111        validate_statuses(success_statuses)?;
112        validate_media_types(content_type)?;
113        match (body, content_type, max_body_bytes) {
114            (ResponseBodyPolicy::Required, _, 0) => {
115                return Err(ResponsePolicyValidationError::RequiredBodyHasZeroLimit);
116            }
117            (ResponseBodyPolicy::Forbidden, _, limit) if limit != 0 => {
118                return Err(ResponsePolicyValidationError::ForbiddenBodyHasNonzeroLimit);
119            }
120            (
121                ResponseBodyPolicy::Forbidden,
122                ContentTypePolicy::Required(_) | ContentTypePolicy::Optional(_),
123                _,
124            ) => {
125                return Err(ResponsePolicyValidationError::ForbiddenBodyAllowsContentType);
126            }
127            (ResponseBodyPolicy::Required, ContentTypePolicy::Forbidden, _) => {
128                return Err(ResponsePolicyValidationError::RequiredBodyForbidsContentType);
129            }
130            _ => {}
131        }
132        Ok(Self {
133            success_statuses,
134            content_type,
135            body,
136            max_body_bytes,
137        })
138    }
139
140    /// Returns expected success statuses.
141    #[must_use]
142    pub const fn success_statuses(self) -> &'static [StatusCode] {
143        self.success_statuses
144    }
145
146    /// Returns response content-type policy.
147    #[must_use]
148    pub const fn content_type_policy(self) -> ContentTypePolicy {
149        self.content_type
150    }
151
152    /// Returns response-body policy.
153    #[must_use]
154    pub const fn body_policy(self) -> ResponseBodyPolicy {
155        self.body
156    }
157
158    /// Returns maximum admitted initialized response bytes.
159    #[must_use]
160    pub const fn max_body_bytes(self) -> usize {
161        self.max_body_bytes
162    }
163
164    /// Checks status, initialized length, body shape, and content type.
165    pub fn validate<'body>(
166        self,
167        response: TransportResponse<'body>,
168    ) -> Result<CheckedResponse<'body>, ResponsePolicyError> {
169        if !self.success_statuses.contains(&response.status()) {
170            return Err(ResponsePolicyError::UnexpectedStatus);
171        }
172        match self.body {
173            ResponseBodyPolicy::Forbidden if !response.body().is_empty() => {
174                return Err(ResponsePolicyError::ForbiddenBody);
175            }
176            _ => {}
177        }
178        if response.body().len() > self.max_body_bytes {
179            return Err(ResponsePolicyError::BodyTooLarge);
180        }
181        if matches!(self.body, ResponseBodyPolicy::Required) && response.body().is_empty() {
182            return Err(ResponsePolicyError::MissingBody);
183        }
184        validate_content_type(self.content_type, response.content_type())?;
185        Ok(CheckedResponse { response })
186    }
187}
188
189/// Response that passed one operation's complete provider-neutral policy.
190#[derive(Clone, Copy, Eq, PartialEq)]
191pub struct CheckedResponse<'body> {
192    response: TransportResponse<'body>,
193}
194
195impl CheckedResponse<'_> {
196    /// Returns the checked status code.
197    #[must_use]
198    pub const fn status(&self) -> StatusCode {
199        self.response.status()
200    }
201
202    /// Returns the checked initialized response body.
203    #[must_use]
204    pub const fn body(&self) -> &[u8] {
205        self.response.body()
206    }
207
208    /// Returns the checked response content type when supplied.
209    #[must_use]
210    pub const fn content_type(&self) -> Option<ResponseContentType> {
211        self.response.content_type()
212    }
213
214    /// Returns validated rate-limit metadata when supplied.
215    #[must_use]
216    pub const fn rate_limit(&self) -> Option<RateLimit> {
217        self.response.rate_limit()
218    }
219}
220
221impl fmt::Debug for CheckedResponse<'_> {
222    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
223        formatter
224            .debug_struct("CheckedResponse")
225            .field("status", &self.status())
226            .field("body_len", &self.body().len())
227            .field("body", &"[redacted]")
228            .field("content_type", &self.content_type())
229            .field("rate_limit", &self.rate_limit())
230            .finish()
231    }
232}
233
234fn validate_statuses(statuses: &[StatusCode]) -> Result<(), ResponsePolicyValidationError> {
235    if statuses.is_empty() {
236        return Err(ResponsePolicyValidationError::MissingSuccessStatus);
237    }
238    for (index, status) in statuses.iter().enumerate() {
239        if !status.is_success() {
240            return Err(ResponsePolicyValidationError::NonSuccessStatus);
241        }
242        if statuses
243            .get(..index)
244            .is_some_and(|seen| seen.contains(status))
245        {
246            return Err(ResponsePolicyValidationError::DuplicateSuccessStatus);
247        }
248    }
249    Ok(())
250}
251
252fn validate_media_types(policy: ContentTypePolicy) -> Result<(), ResponsePolicyValidationError> {
253    let media_types = match policy {
254        ContentTypePolicy::Required(values) | ContentTypePolicy::Optional(values) => values,
255        ContentTypePolicy::Forbidden => return Ok(()),
256    };
257    if media_types.is_empty() {
258        return Err(ResponsePolicyValidationError::MissingAcceptedMediaType);
259    }
260    for (index, media_type) in media_types.iter().enumerate() {
261        if media_types.get(..index).is_some_and(|seen| {
262            seen.iter()
263                .any(|candidate| candidate.as_str().eq_ignore_ascii_case(media_type.as_str()))
264        }) {
265            return Err(ResponsePolicyValidationError::DuplicateAcceptedMediaType);
266        }
267    }
268    Ok(())
269}
270
271fn validate_content_type(
272    policy: ContentTypePolicy,
273    actual: Option<ResponseContentType>,
274) -> Result<(), ResponsePolicyError> {
275    match (policy, actual) {
276        (ContentTypePolicy::Required(_), None) => Err(ResponsePolicyError::MissingContentType),
277        (ContentTypePolicy::Forbidden, Some(_)) => Err(ResponsePolicyError::ForbiddenContentType),
278        (ContentTypePolicy::Forbidden | ContentTypePolicy::Optional(_), None) => Ok(()),
279        (
280            ContentTypePolicy::Required(accepted) | ContentTypePolicy::Optional(accepted),
281            Some(actual),
282        ) => {
283            if accepted
284                .iter()
285                .any(|media_type| actual.matches(*media_type))
286            {
287                Ok(())
288            } else {
289                Err(ResponsePolicyError::UnexpectedContentType)
290            }
291        }
292    }
293}