Skip to main content

apif_execution/
error_handler.rs

1#![allow(clippy::question_mark)]
2use prost::Message;
3use prost_types::Any;
4use serde_json::{Map, Value};
5
6#[derive(Clone, PartialEq, Message)]
7struct GoogleRpcStatus {
8    #[prost(int32, tag = "1")]
9    code: i32,
10    #[prost(string, tag = "2")]
11    message: String,
12    #[prost(message, repeated, tag = "3")]
13    details: Vec<Any>,
14}
15
16pub struct ErrorHandler;
17
18impl ErrorHandler {
19    pub fn status_matches_expected(
20        status: &apif_grpc_transport::GrpcError,
21        expected: &Value,
22    ) -> bool {
23        Self::status_matches_expected_with_options(status, expected, false)
24    }
25    pub fn status_matches_expected_with_options(
26        status: &apif_grpc_transport::GrpcError,
27        expected: &Value,
28        partial: bool,
29    ) -> bool {
30        if !partial && !Self::status_has_no_unexpected_top_level_fields(status, expected) {
31            return false;
32        }
33        if partial {
34            return Self::is_subset_match(&Self::status_to_json(status), expected);
35        }
36        if !Self::message_matches_status(status, expected)
37            || !Self::code_matches_status(status, expected)
38        {
39            return false;
40        }
41        if !expected.get("details").is_some() {
42            return status.details().is_empty();
43        }
44        let Some(actual) = Self::decode_status_details(status.details()) else {
45            return false;
46        };
47        Self::compare_details(expected, actual).is_none()
48    }
49    pub fn status_details_json(status: &apif_grpc_transport::GrpcError) -> Value {
50        match Self::decode_status_details(status.details()) {
51            Some(d) => Value::Array(d),
52            None => Value::Null,
53        }
54    }
55    pub fn status_to_json(status: &apif_grpc_transport::GrpcError) -> Value {
56        let mut obj = Map::with_capacity(3);
57        obj.insert("code".into(), Value::from(status.code() as i64));
58        obj.insert("message".into(), Value::from(status.message()));
59        if let Some(details) = Self::decode_status_details(status.details())
60            && !details.is_empty()
61        {
62            obj.insert("details".into(), Value::Array(details));
63        }
64        Value::Object(obj)
65    }
66    pub fn status_mismatch_reason(
67        status: &apif_grpc_transport::GrpcError,
68        expected: &Value,
69    ) -> Option<String> {
70        Self::status_mismatch_reason_with_options(status, expected, false)
71    }
72    pub fn status_mismatch_reason_with_options(
73        status: &apif_grpc_transport::GrpcError,
74        expected: &Value,
75        partial: bool,
76    ) -> Option<String> {
77        if !partial
78            && let Some(r) = Self::status_unexpected_top_level_field_reason(status, expected)
79        {
80            return Some(r);
81        }
82        if partial {
83            let actual = Self::status_to_json(status);
84            return Self::partial_mismatch_reason(&actual, expected);
85        }
86        let mut reasons = Vec::new();
87        if !Self::code_matches_status(status, expected) {
88            reasons.push("code mismatch".to_string());
89        }
90        if !Self::message_matches_status(status, expected) {
91            reasons.push("message mismatch".to_string());
92        }
93        if reasons.is_empty()
94            && let Some(actual) = Self::decode_status_details(status.details())
95            && let Some(r) = Self::details_mismatch_reason(expected, actual)
96        {
97            reasons.push(r);
98        }
99        if reasons.is_empty() {
100            None
101        } else {
102            Some(reasons.join("; "))
103        }
104    }
105    fn status_has_no_unexpected_top_level_fields(
106        status: &apif_grpc_transport::GrpcError,
107        expected: &Value,
108    ) -> bool {
109        Self::status_unexpected_top_level_field_reason(status, expected).is_none()
110    }
111    fn status_unexpected_top_level_field_reason(
112        status: &apif_grpc_transport::GrpcError,
113        expected: &Value,
114    ) -> Option<String> {
115        let actual = Self::status_to_json(status);
116        let Some(actual_obj) = actual.as_object() else {
117            return None;
118        };
119        let Some(expected_obj) = expected.as_object() else {
120            return None;
121        };
122        for (k, _) in actual_obj {
123            if !expected_obj.contains_key(k) {
124                return Some(format!("Unexpected field '{}' in gRPC status JSON", k));
125            }
126        }
127        None
128    }
129    fn message_matches_status(status: &apif_grpc_transport::GrpcError, expected: &Value) -> bool {
130        expected
131            .get("message")
132            .and_then(|m| m.as_str())
133            .map(|exp_msg| status.message().contains(exp_msg))
134            .unwrap_or(true)
135    }
136    fn code_matches_status(status: &apif_grpc_transport::GrpcError, expected: &Value) -> bool {
137        expected
138            .get("code")
139            .and_then(|c| c.as_i64())
140            .map(|exp_code| status.code() as i64 == exp_code)
141            .unwrap_or(true)
142    }
143    pub fn grpc_code_name_from_numeric(code: i64) -> Option<&'static str> {
144        Some(match code {
145            0 => "OK",
146            1 => "CANCELLED",
147            2 => "UNKNOWN",
148            3 => "INVALID_ARGUMENT",
149            4 => "DEADLINE_EXCEEDED",
150            5 => "NOT_FOUND",
151            6 => "ALREADY_EXISTS",
152            7 => "PERMISSION_DENIED",
153            8 => "RESOURCE_EXHAUSTED",
154            9 => "FAILED_PRECONDITION",
155            10 => "ABORTED",
156            11 => "OUT_OF_RANGE",
157            12 => "UNIMPLEMENTED",
158            13 => "INTERNAL",
159            14 => "UNAVAILABLE",
160            15 => "DATA_LOSS",
161            16 => "UNAUTHENTICATED",
162            _ => return None,
163        })
164    }
165    pub fn error_matches_expected(error_text: &str, expected: &Value) -> bool {
166        if let Value::Object(obj) = expected
167            && let Some(code_val) = obj.get("code").and_then(|c| c.as_i64())
168            && let Some(msg_val) = obj.get("message").and_then(|m| m.as_str())
169        {
170            return error_text.contains(&format!("code={}", code_val))
171                && error_text.contains(msg_val);
172        }
173        false
174    }
175
176    fn decode_status_details(details: &[u8]) -> Option<Vec<Value>> {
177        if details.is_empty() {
178            return None;
179        }
180        let Ok(status) = GoogleRpcStatus::decode(details) else {
181            return None;
182        };
183        let mut items = Vec::new();
184        for any in status.details {
185            if let Ok(msg) = GoogleRpcErrorInfo::decode(any.value.as_slice()) {
186                let mut obj = Map::new();
187                obj.insert("reason".into(), Value::String(msg.reason));
188                obj.insert("domain".into(), Value::String(msg.domain));
189                let meta: Map<String, Value> = msg
190                    .metadata
191                    .into_iter()
192                    .map(|(k, v)| (k, Value::String(v)))
193                    .collect();
194                obj.insert("metadata".into(), Value::Object(meta));
195                items.push(Value::Object(obj));
196            }
197        }
198        if items.is_empty() { None } else { Some(items) }
199    }
200    fn compare_details(expected: &Value, actual: Vec<Value>) -> Option<String> {
201        let expected_details = expected.get("details").and_then(|d| d.as_array())?;
202        if expected_details.len() != actual.len() {
203            return Some(format!(
204                "details count mismatch: expected {} got {}",
205                expected_details.len(),
206                actual.len()
207            ));
208        }
209        None
210    }
211    fn details_mismatch_reason(expected: &Value, actual: Vec<Value>) -> Option<String> {
212        Self::compare_details(expected, actual)
213    }
214    fn is_subset_match(actual: &Value, expected: &Value) -> bool {
215        match (actual, expected) {
216            (Value::Object(a), Value::Object(e)) => e.iter().all(|(k, v)| {
217                a.get(k)
218                    .map(|av| Self::is_subset_match(av, v))
219                    .unwrap_or(false)
220            }),
221            (Value::Array(a), Value::Array(e)) => e
222                .iter()
223                .all(|ev| a.iter().any(|av| Self::is_subset_match(av, ev))),
224            _ => actual == expected,
225        }
226    }
227    fn partial_mismatch_reason(actual: &Value, expected: &Value) -> Option<String> {
228        if Self::is_subset_match(actual, expected) {
229            None
230        } else {
231            Some("partial match failed".to_string())
232        }
233    }
234}
235
236#[derive(Clone, PartialEq, Message)]
237struct GoogleRpcErrorInfo {
238    #[prost(string, tag = "1")]
239    reason: String,
240    #[prost(string, tag = "2")]
241    domain: String,
242    #[prost(map(string, string), tag = "3")]
243    metadata: std::collections::HashMap<String, String>,
244}