1use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
23#[serde(rename_all = "snake_case")]
24pub enum ConfigApiCode {
25 PathNotFound,
27 ValidationFailed,
29 ConfigChangedExternally,
32 ReloadFailed,
35 OpNotSupported,
37 SecretTestForbidden,
40 ValueTypeMismatch,
44 RequiredFieldEmpty,
48 InvalidNumericRange,
51 InvalidFormat,
54 InvalidEnumVariant,
57 DanglingReference,
60 InternalError,
63}
64
65impl ConfigApiCode {
66 pub fn as_str(self) -> &'static str {
67 match self {
68 Self::PathNotFound => "path_not_found",
69 Self::ValidationFailed => "validation_failed",
70 Self::ConfigChangedExternally => "config_changed_externally",
71 Self::ReloadFailed => "reload_failed",
72 Self::OpNotSupported => "op_not_supported",
73 Self::SecretTestForbidden => "secret_test_forbidden",
74 Self::ValueTypeMismatch => "value_type_mismatch",
75 Self::RequiredFieldEmpty => "required_field_empty",
76 Self::InvalidNumericRange => "invalid_numeric_range",
77 Self::InvalidFormat => "invalid_format",
78 Self::InvalidEnumVariant => "invalid_enum_variant",
79 Self::DanglingReference => "dangling_reference",
80 Self::InternalError => "internal_error",
81 }
82 }
83
84 pub fn http_status(self) -> u16 {
86 match self {
87 Self::PathNotFound => 404,
88 Self::ValidationFailed
89 | Self::OpNotSupported
90 | Self::SecretTestForbidden
91 | Self::ValueTypeMismatch
92 | Self::RequiredFieldEmpty
93 | Self::InvalidNumericRange
94 | Self::InvalidFormat
95 | Self::InvalidEnumVariant
96 | Self::DanglingReference => 400,
97 Self::ConfigChangedExternally => 409,
98 Self::ReloadFailed | Self::InternalError => 500,
99 }
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
106#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
107pub struct ConfigApiError {
108 pub code: ConfigApiCode,
110 pub message: String,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub path: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub op_index: Option<usize>,
120}
121
122impl ConfigApiError {
123 pub fn new(code: ConfigApiCode, message: impl Into<String>) -> Self {
124 Self {
125 code,
126 message: message.into(),
127 path: None,
128 op_index: None,
129 }
130 }
131
132 pub fn with_path(mut self, path: impl Into<String>) -> Self {
133 self.path = Some(path.into());
134 self
135 }
136
137 pub fn with_op_index(mut self, index: usize) -> Self {
138 self.op_index = Some(index);
139 self
140 }
141
142 pub fn from_validation(err: anyhow::Error) -> Self {
154 if let Some(structured) = err.downcast_ref::<ConfigApiError>() {
155 return structured.clone();
156 }
157 let msg = err.to_string();
158 let code = classify_validation_message(&msg);
159 Self::new(code, msg)
160 }
161}
162
163pub fn classify_validation_message(msg: &str) -> ConfigApiCode {
169 let lower = msg.to_lowercase();
170 if lower.contains("type mismatch") || lower.contains("invalid value") {
171 return ConfigApiCode::ValueTypeMismatch;
172 }
173 if lower.starts_with("unknown property") {
174 return ConfigApiCode::PathNotFound;
175 }
176 ConfigApiCode::ValidationFailed
177}
178
179impl ConfigApiError {
180 pub fn path_not_found(path: impl Into<String>) -> Self {
182 let path = path.into();
183 Self::new(
184 ConfigApiCode::PathNotFound,
185 format!("property path not found in schema: {path}"),
186 )
187 .with_path(path)
188 }
189
190 pub fn secret_test_forbidden(path: impl Into<String>) -> Self {
192 let path = path.into();
193 Self::new(
194 ConfigApiCode::SecretTestForbidden,
195 format!(
196 "JSON Patch `test` operations against secret or derived-from-secret paths \
197 are forbidden: {path}"
198 ),
199 )
200 .with_path(path)
201 }
202
203 pub fn op_not_supported(op: impl Into<String>) -> Self {
205 let op = op.into();
206 Self::new(
207 ConfigApiCode::OpNotSupported,
208 format!("JSON Patch operation `{op}` is not supported in this version"),
209 )
210 }
211}
212
213impl std::fmt::Display for ConfigApiError {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 match &self.path {
216 Some(path) => write!(f, "[{}] {} ({})", self.code.as_str(), self.message, path),
217 None => write!(f, "[{}] {}", self.code.as_str(), self.message),
218 }
219 }
220}
221
222impl std::error::Error for ConfigApiError {}
223
224#[macro_export]
243macro_rules! validation_bail {
244 ($code:ident, $path:expr, $($msg:tt)*) => {{
245 let err = $crate::api_error::ConfigApiError::new(
246 $crate::api_error::ConfigApiCode::$code,
247 format!($($msg)*),
248 )
249 .with_path($path);
250 return Err(::anyhow::Error::from(err));
251 }};
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn code_str_round_trip() {
260 for code in [
261 ConfigApiCode::PathNotFound,
262 ConfigApiCode::ValidationFailed,
263 ConfigApiCode::ConfigChangedExternally,
264 ConfigApiCode::ReloadFailed,
265 ConfigApiCode::OpNotSupported,
266 ConfigApiCode::SecretTestForbidden,
267 ConfigApiCode::ValueTypeMismatch,
268 ConfigApiCode::InternalError,
269 ] {
270 let serialized = serde_json::to_value(code).unwrap();
271 let s = serialized.as_str().unwrap();
272 assert_eq!(s, code.as_str());
273 }
274 }
275
276 #[test]
277 fn http_status_matches_intent() {
278 assert_eq!(ConfigApiCode::PathNotFound.http_status(), 404);
279 assert_eq!(ConfigApiCode::ValidationFailed.http_status(), 400);
280 assert_eq!(ConfigApiCode::ConfigChangedExternally.http_status(), 409);
281 assert_eq!(ConfigApiCode::ReloadFailed.http_status(), 500);
282 }
283
284 #[test]
285 fn classify_unknown_property() {
286 assert_eq!(
287 classify_validation_message("Unknown property 'foo.bar'"),
288 ConfigApiCode::PathNotFound
289 );
290 }
291
292 #[test]
293 fn classify_falls_back_to_validation_failed() {
294 assert_eq!(
295 classify_validation_message("some unrelated random validator output"),
296 ConfigApiCode::ValidationFailed
297 );
298 }
299
300 #[test]
301 fn path_not_found_carries_path() {
302 let err = ConfigApiError::path_not_found("providers.models");
303 assert_eq!(err.code, ConfigApiCode::PathNotFound);
304 assert_eq!(err.path.as_deref(), Some("providers.models"));
305 assert!(err.message.contains("providers.models"));
306 }
307
308 #[test]
309 fn secret_test_forbidden_carries_path() {
310 let err = ConfigApiError::secret_test_forbidden("providers.models.openrouter.api-key");
311 assert_eq!(err.code, ConfigApiCode::SecretTestForbidden);
312 assert!(err.message.contains("providers.models.openrouter.api-key"));
313 }
314
315 #[test]
316 fn from_validation_uses_message() {
317 let anyhow_err = anyhow::Error::msg("gateway.host must not be empty");
318 let api_err = ConfigApiError::from_validation(anyhow_err);
319 assert_eq!(api_err.code, ConfigApiCode::ValidationFailed);
320 assert!(api_err.message.contains("gateway.host"));
321 }
322
323 #[test]
324 fn display_includes_code_and_path() {
325 let err = ConfigApiError::path_not_found("foo.bar");
326 let s = format!("{err}");
327 assert!(s.contains("path_not_found"));
328 assert!(s.contains("foo.bar"));
329 }
330}