Skip to main content

wip_http/
response.rs

1use std::error::Error;
2use std::fmt::{self, Display, Formatter};
3
4use http::StatusCode;
5use serde_json::{Map, Value as Json};
6use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
7
8use crate::{BodyKind, CodecError, Limits, json};
9
10/// A successfully classified client response.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum DecodedResponse<T> {
13    /// A successful direct response body.
14    Success(T),
15    /// A canonical Host protocol failure.
16    ProtocolFailure {
17        /// The authoritative protocol error decoded from the envelope.
18        error: ProtocolError,
19        /// A diagnostic when the HTTP status differs from the canonical mapping.
20        status_mismatch: Option<StatusMismatch>,
21    },
22}
23
24/// Diagnostic evidence that HTTP metadata disagrees with a valid error envelope.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct StatusMismatch {
27    /// Status received from the peer.
28    pub actual: StatusCode,
29    /// Canonical status for the envelope's protocol code.
30    pub expected: StatusCode,
31}
32
33/// Class of non-protocol HTTP failure returned without a valid WIP envelope.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum TransportFailureKind {
36    /// HTTP 401, commonly emitted by authentication infrastructure.
37    AuthenticationRequired,
38    /// HTTP 403, commonly emitted by endpoint policy or infrastructure.
39    EndpointForbidden,
40    /// The response uses a content coding the client does not support.
41    UnsupportedContentCoding,
42    /// Another non-success HTTP response without a WIP error envelope.
43    HttpStatus,
44}
45
46/// A non-success HTTP response that is not a valid WIP protocol failure.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct TransportBindingFailure {
49    /// HTTP status returned by the peer or intermediary.
50    pub status: StatusCode,
51    /// Coarse classification which does not invent a protocol error code.
52    pub kind: TransportFailureKind,
53}
54
55/// Why a response violates the WIP v1 response contract.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum InvalidResponseKind {
58    /// A 2xx response contained an error envelope.
59    ErrorEnvelopeOnSuccessStatus,
60    /// A non-2xx response contained a valid success body.
61    SuccessBodyOnErrorStatus,
62    /// A valid error envelope used a code forbidden for the interaction.
63    DisallowedProtocolCode,
64    /// A valid success body used a 2xx status other than canonical HTTP 200.
65    NonCanonicalSuccessStatus,
66    /// A 2xx body was malformed or semantically invalid.
67    InvalidSuccessBody,
68}
69
70/// A local failure while classifying or validating a peer response.
71#[derive(Debug)]
72pub enum ClientResponseError {
73    /// The HTTP exchange failed outside the protocol envelope.
74    TransportBinding(TransportBindingFailure),
75    /// The peer supplied a contradictory or invalid WIP response.
76    InvalidResponse {
77        /// Stable response-failure category.
78        kind: InvalidResponseKind,
79        /// Bounded diagnostic from local decoding.
80        detail: String,
81    },
82    /// The descriptor is structurally valid but uses an unsupported format.
83    UnsupportedDescriptorFormat {
84        /// Format identifier received from the Host.
85        format: String,
86    },
87}
88
89impl ClientResponseError {
90    pub(crate) fn invalid(kind: InvalidResponseKind, detail: impl Into<String>) -> Self {
91        Self::InvalidResponse {
92            kind,
93            detail: detail.into(),
94        }
95    }
96}
97
98impl Display for ClientResponseError {
99    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
100        match self {
101            Self::TransportBinding(failure) => write!(
102                formatter,
103                "transport binding failure ({:?}, HTTP {})",
104                failure.kind, failure.status
105            ),
106            Self::InvalidResponse { kind, detail } => {
107                write!(formatter, "invalid WIP response ({kind:?}): {detail}")
108            }
109            Self::UnsupportedDescriptorFormat { format } => {
110                write!(
111                    formatter,
112                    "unsupported interface descriptor format `{format}`"
113                )
114            }
115        }
116    }
117}
118
119impl Error for ClientResponseError {}
120
121/// Returns the canonical HTTP status for a wire-level protocol error.
122#[must_use]
123pub const fn status_for_error(code: ProtocolErrorCode) -> StatusCode {
124    match code {
125        ProtocolErrorCode::InvalidRequest => StatusCode::BAD_REQUEST,
126        ProtocolErrorCode::NotFound | ProtocolErrorCode::InterfaceNotFound => StatusCode::NOT_FOUND,
127        ProtocolErrorCode::InterfaceMismatch => StatusCode::CONFLICT,
128        ProtocolErrorCode::OperationNotFound => StatusCode::NOT_FOUND,
129        ProtocolErrorCode::InvalidArguments => StatusCode::UNPROCESSABLE_ENTITY,
130        ProtocolErrorCode::ValidatorRequired | ProtocolErrorCode::InterfaceValidatorRequired => {
131            StatusCode::PRECONDITION_REQUIRED
132        }
133        ProtocolErrorCode::ValidatorMismatch | ProtocolErrorCode::InterfaceValidatorMismatch => {
134            StatusCode::PRECONDITION_FAILED
135        }
136        ProtocolErrorCode::PermissionDenied => StatusCode::FORBIDDEN,
137        ProtocolErrorCode::ResourceLimitExceeded => StatusCode::UNPROCESSABLE_ENTITY,
138        ProtocolErrorCode::Internal | ProtocolErrorCode::OperationOutcomeUnknown => {
139            StatusCode::INTERNAL_SERVER_ERROR
140        }
141    }
142}
143
144fn status_matches_error(status: StatusCode, code: ProtocolErrorCode) -> bool {
145    status == status_for_error(code)
146}
147
148/// Returns the stable snake-case wire spelling of a protocol code.
149#[must_use]
150pub const fn error_code_name(code: ProtocolErrorCode) -> &'static str {
151    match code {
152        ProtocolErrorCode::InvalidRequest => "invalid_request",
153        ProtocolErrorCode::NotFound => "not_found",
154        ProtocolErrorCode::InterfaceNotFound => "interface_not_found",
155        ProtocolErrorCode::InterfaceMismatch => "interface_mismatch",
156        ProtocolErrorCode::OperationNotFound => "operation_not_found",
157        ProtocolErrorCode::InvalidArguments => "invalid_arguments",
158        ProtocolErrorCode::ValidatorRequired => "validator_required",
159        ProtocolErrorCode::ValidatorMismatch => "validator_mismatch",
160        ProtocolErrorCode::InterfaceValidatorRequired => "interface_validator_required",
161        ProtocolErrorCode::InterfaceValidatorMismatch => "interface_validator_mismatch",
162        ProtocolErrorCode::PermissionDenied => "permission_denied",
163        ProtocolErrorCode::ResourceLimitExceeded => "resource_limit_exceeded",
164        ProtocolErrorCode::Internal => "internal",
165        ProtocolErrorCode::OperationOutcomeUnknown => "operation_outcome_unknown",
166    }
167}
168
169fn error_code_from_name(name: &str) -> Option<ProtocolErrorCode> {
170    Some(match name {
171        "invalid_request" => ProtocolErrorCode::InvalidRequest,
172        "not_found" => ProtocolErrorCode::NotFound,
173        "interface_not_found" => ProtocolErrorCode::InterfaceNotFound,
174        "interface_mismatch" => ProtocolErrorCode::InterfaceMismatch,
175        "operation_not_found" => ProtocolErrorCode::OperationNotFound,
176        "invalid_arguments" => ProtocolErrorCode::InvalidArguments,
177        "validator_required" => ProtocolErrorCode::ValidatorRequired,
178        "validator_mismatch" => ProtocolErrorCode::ValidatorMismatch,
179        "interface_validator_required" => ProtocolErrorCode::InterfaceValidatorRequired,
180        "interface_validator_mismatch" => ProtocolErrorCode::InterfaceValidatorMismatch,
181        "permission_denied" => ProtocolErrorCode::PermissionDenied,
182        "resource_limit_exceeded" => ProtocolErrorCode::ResourceLimitExceeded,
183        "internal" => ProtocolErrorCode::Internal,
184        "operation_outcome_unknown" => ProtocolErrorCode::OperationOutcomeUnknown,
185        _ => return None,
186    })
187}
188
189pub(crate) fn error_to_json(error: &ProtocolError) -> Json {
190    serde_json::json!({
191        "error": {
192            "code": error_code_name(error.code),
193            "message": error.message,
194        }
195    })
196}
197
198fn protocol_error_from_json(value: &Json) -> Result<ProtocolError, CodecError> {
199    let fields = value
200        .as_object()
201        .ok_or_else(|| invalid_field("error", "expected object"))?;
202    exact(fields, &["code", "message"], "error")?;
203    let code_name = required_string(fields, "code")?;
204    let code = error_code_from_name(code_name)
205        .ok_or_else(|| invalid_field("error.code", "unknown protocol error code"))?;
206    let message = required_string(fields, "message")?.to_owned();
207    Ok(ProtocolError { code, message })
208}
209
210fn envelope_error_from_json(value: &Json) -> Result<Option<ProtocolError>, CodecError> {
211    let Some(fields) = value.as_object() else {
212        return Ok(None);
213    };
214    if fields.len() != 1 || !fields.contains_key("error") {
215        return Ok(None);
216    }
217    protocol_error_from_json(&fields["error"]).map(Some)
218}
219
220fn exact(fields: &Map<String, Json>, allowed: &[&str], parent: &str) -> Result<(), CodecError> {
221    for name in fields.keys() {
222        if !allowed.contains(&name.as_str()) {
223            return Err(CodecError::UnknownField {
224                field: format!("{parent}.{name}"),
225            });
226        }
227    }
228    for name in allowed {
229        if !fields.contains_key(*name) {
230            return Err(CodecError::MissingField {
231                field: format!("{parent}.{name}"),
232            });
233        }
234    }
235    Ok(())
236}
237
238fn required_string<'a>(fields: &'a Map<String, Json>, name: &str) -> Result<&'a str, CodecError> {
239    fields
240        .get(name)
241        .and_then(Json::as_str)
242        .ok_or_else(|| invalid_field(&format!("error.{name}"), "expected string"))
243}
244
245fn invalid_field(field: &str, reason: &str) -> CodecError {
246    CodecError::InvalidField {
247        field: field.into(),
248        reason: reason.into(),
249    }
250}
251
252pub(crate) fn decode<T>(
253    status: StatusCode,
254    body: &[u8],
255    interaction: ProtocolInteraction,
256    limits: Limits,
257    success: impl FnOnce(&Json) -> Result<T, CodecError>,
258) -> Result<DecodedResponse<T>, ClientResponseError> {
259    let value = match json::parse(body, BodyKind::Response, limits) {
260        Ok(value) => value,
261        Err(_) if !status.is_success() => {
262            return Err(transport_failure(status));
263        }
264        Err(error) => {
265            return Err(ClientResponseError::invalid(
266                InvalidResponseKind::InvalidSuccessBody,
267                error.to_string(),
268            ));
269        }
270    };
271
272    match envelope_error_from_json(&value) {
273        Ok(Some(error)) => {
274            if status.is_success() {
275                return Err(ClientResponseError::invalid(
276                    InvalidResponseKind::ErrorEnvelopeOnSuccessStatus,
277                    "protocol error envelope carried by a success status",
278                ));
279            }
280            if !error.code.is_allowed_for(interaction) {
281                return Err(ClientResponseError::invalid(
282                    InvalidResponseKind::DisallowedProtocolCode,
283                    format!(
284                        "code `{}` is not allowed for {interaction:?}",
285                        error_code_name(error.code)
286                    ),
287                ));
288            }
289            let expected = status_for_error(error.code);
290            let status_mismatch =
291                (!status_matches_error(status, error.code)).then_some(StatusMismatch {
292                    actual: status,
293                    expected,
294                });
295            Ok(DecodedResponse::ProtocolFailure {
296                error,
297                status_mismatch,
298            })
299        }
300        Err(_) if !status.is_success() => Err(transport_failure(status)),
301        Err(error) => Err(ClientResponseError::invalid(
302            InvalidResponseKind::InvalidSuccessBody,
303            error.to_string(),
304        )),
305        Ok(None) if status == StatusCode::OK => success(&value)
306            .map(DecodedResponse::Success)
307            .map_err(|error| {
308                ClientResponseError::invalid(
309                    InvalidResponseKind::InvalidSuccessBody,
310                    error.to_string(),
311                )
312            }),
313        Ok(None) if status.is_success() => match success(&value) {
314            Ok(_) => Err(ClientResponseError::invalid(
315                InvalidResponseKind::NonCanonicalSuccessStatus,
316                format!("success body carried by non-canonical HTTP {status}"),
317            )),
318            Err(error) => Err(ClientResponseError::invalid(
319                InvalidResponseKind::InvalidSuccessBody,
320                error.to_string(),
321            )),
322        },
323        Ok(None) => match success(&value) {
324            Ok(_) => Err(ClientResponseError::invalid(
325                InvalidResponseKind::SuccessBodyOnErrorStatus,
326                "valid success body carried by a non-success status",
327            )),
328            Err(_) => Err(transport_failure(status)),
329        },
330    }
331}
332
333pub(crate) fn unsupported_content_coding(status: StatusCode) -> ClientResponseError {
334    ClientResponseError::TransportBinding(TransportBindingFailure {
335        status,
336        kind: TransportFailureKind::UnsupportedContentCoding,
337    })
338}
339
340pub(crate) fn transport_failure(status: StatusCode) -> ClientResponseError {
341    let kind = match status {
342        StatusCode::UNAUTHORIZED => TransportFailureKind::AuthenticationRequired,
343        StatusCode::FORBIDDEN => TransportFailureKind::EndpointForbidden,
344        _ => TransportFailureKind::HttpStatus,
345    };
346    ClientResponseError::TransportBinding(TransportBindingFailure { status, kind })
347}