bitwarden_organization_invite_link/
validation_problem.rs1use std::collections::BTreeMap;
18
19use bitwarden_core::ApiError;
20use http::StatusCode;
21use serde::Deserialize;
22
23#[derive(Deserialize)]
25pub(crate) struct ValidationProblem {
26 errors: BTreeMap<String, Vec<ValidationError>>,
27}
28
29#[derive(Debug, Deserialize)]
31pub(crate) struct ValidationError {
32 #[serde(rename = "type")]
34 pub(crate) code: String,
35 pub(crate) detail: Option<String>,
38}
39
40impl ValidationProblem {
41 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 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 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}