Skip to main content

bitwarden_organization_invite_link/
validation_problem.rs

1//! Parses the server's RFC 7807 validation problem error format.
2//!
3//! Endpoints that expose stable error codes answer a failed request with a validation problem on
4//! `400`:
5//!
6//! ```json
7//! {
8//!   "type": "validation_error",
9//!   "status": 400,
10//!   "errors": { "code": [{ "type": "already_organization_member", "detail": "..." }] }
11//! }
12//! ```
13//!
14//! Older servers (and endpoints not yet migrated) answer with the legacy `ErrorResponseModel`
15//! (`{ "message": "...", ... }`), which carries no code and is not a validation problem.
16
17use std::collections::BTreeMap;
18
19use bitwarden_core::ApiError;
20use http::StatusCode;
21use serde::Deserialize;
22
23/// The subset of the server's RFC 7807 validation problem needed to extract error codes.
24#[derive(Deserialize)]
25pub(crate) struct ValidationProblem {
26    errors: BTreeMap<String, Vec<ValidationError>>,
27}
28
29/// A single error reported in a [`ValidationProblem`].
30#[derive(Debug, Deserialize)]
31pub(crate) struct ValidationError {
32    /// The stable, machine-readable error code.
33    #[serde(rename = "type")]
34    pub(crate) code: String,
35    /// A human-readable English description of the error, suitable for display when the code is
36    /// not recognized.
37    pub(crate) detail: Option<String>,
38}
39
40impl ValidationProblem {
41    /// Parses the validation problem carried by an API error, or returns `None` if the error is not
42    /// a `400` response with a validation problem body (e.g. a transport error, another status, or
43    /// the legacy error format).
44    pub(crate) fn from_api_error(error: &ApiError) -> Option<Self> {
45        let ApiError::Response(content) = error else {
46            return None;
47        };
48        if content.status != StatusCode::BAD_REQUEST {
49            return None;
50        }
51        serde_json::from_str(&content.message).ok()
52    }
53
54    /// Returns the first error in the problem, or `None` if it carries no errors.
55    ///
56    /// Properties are visited in alphabetical order, so the result is deterministic when the
57    /// server reports errors under more than one property.
58    pub(crate) fn into_first_error(self) -> Option<ValidationError> {
59        self.errors.into_values().flatten().next()
60    }
61}
62
63#[cfg(test)]
64pub(crate) mod tests {
65    use bitwarden_api_api::ResponseContent;
66
67    use super::*;
68
69    pub(crate) fn response_error(status: u16, body: &str) -> ApiError {
70        ApiError::Response(ResponseContent {
71            status: status.try_into().expect("valid status code"),
72            message: body.to_string(),
73        })
74    }
75
76    pub(crate) fn validation_problem(property: &str, code: &str) -> String {
77        format!(
78            r#"{{"type":"validation_error","title":"One or more validation errors occurred.","status":400,"errors":{{"{property}":[{{"type":"{code}","detail":"Some detail."}}]}}}}"#
79        )
80    }
81
82    fn first_error(status: u16, body: &str) -> Option<ValidationError> {
83        ValidationProblem::from_api_error(&response_error(status, body))?.into_first_error()
84    }
85
86    fn first_code(status: u16, body: &str) -> Option<String> {
87        first_error(status, body).map(|error| error.code)
88    }
89
90    #[test]
91    fn parses_first_error() {
92        let error = first_error(400, &validation_problem("code", "some_code")).unwrap();
93        assert_eq!(error.code, "some_code");
94        assert_eq!(error.detail.as_deref(), Some("Some detail."));
95    }
96
97    #[test]
98    fn parses_error_without_detail() {
99        let error = first_error(
100            400,
101            r#"{"type":"validation_error","status":400,"errors":{"code":[{"type":"some_code"}]}}"#,
102        )
103        .unwrap();
104        assert_eq!(error.code, "some_code");
105        assert_eq!(error.detail, None);
106    }
107
108    #[test]
109    fn parses_first_code_deterministically_across_properties() {
110        let body = r#"{"type":"validation_error","status":400,"errors":{"resetPasswordKey":[{"type":"second_code"}],"code":[{"type":"first_code"}]}}"#;
111        for _ in 0..16 {
112            assert_eq!(first_code(400, body).as_deref(), Some("first_code"));
113        }
114    }
115
116    #[test]
117    fn parses_problem_without_errors() {
118        let code = first_code(
119            400,
120            r#"{"type":"validation_error","status":400,"errors":{}}"#,
121        );
122        assert_eq!(code, None);
123    }
124
125    #[test]
126    fn rejects_legacy_body() {
127        let error = response_error(
128            400,
129            r#"{"message":"You're already a member of Acme.","validationErrors":null,"object":"error"}"#,
130        );
131        assert!(ValidationProblem::from_api_error(&error).is_none());
132    }
133
134    #[test]
135    fn rejects_model_binding_problem() {
136        // ASP.NET's default validation problem uses plain strings rather than error codes.
137        let error = response_error(
138            400,
139            r#"{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","status":400,"errors":{"Code":["The Code field is required."]}}"#,
140        );
141        assert!(ValidationProblem::from_api_error(&error).is_none());
142    }
143
144    #[test]
145    fn rejects_other_statuses() {
146        let error = response_error(500, &validation_problem("code", "some_code"));
147        assert!(ValidationProblem::from_api_error(&error).is_none());
148    }
149
150    #[test]
151    fn rejects_transport_errors() {
152        let error = ApiError::from(std::io::Error::other("boom"));
153        assert!(ValidationProblem::from_api_error(&error).is_none());
154    }
155}