1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4#[derive(Error, Debug)]
5pub enum Error {
6 #[error("I/O error: {0}")]
7 Io(#[from] std::io::Error),
8 #[error("Network error: {0}")]
9 Network(#[from] reqwest::Error),
10 #[error("YAML parsing error: {0}")]
11 Yaml(#[from] serde_yaml::Error),
12 #[error("JSON parsing error: {0}")]
13 Json(#[from] serde_json::Error),
14 #[error("TOML parsing error: {0}")]
15 Toml(#[from] toml::de::Error),
16 #[error("Configuration error: {0}")]
17 Config(String),
18 #[error("Validation error: {0}")]
19 Validation(String),
20
21 #[error("API specification '{name}' not found")]
23 SpecNotFound { name: String },
24 #[error("API specification '{name}' already exists. Use --force to overwrite")]
25 SpecAlreadyExists { name: String },
26 #[error("No cached spec found for '{name}'. Run 'aperture config add {name}' first")]
27 CachedSpecNotFound { name: String },
28 #[error("Failed to deserialize cached spec '{name}': {reason}. The cache may be corrupted")]
29 CachedSpecCorrupted { name: String, reason: String },
30 #[error("Cache format version mismatch for '{name}': found v{found}, expected v{expected}")]
31 CacheVersionMismatch {
32 name: String,
33 found: u32,
34 expected: u32,
35 },
36 #[error(
37 "Environment variable '{env_var}' required for authentication '{scheme_name}' is not set"
38 )]
39 SecretNotSet {
40 scheme_name: String,
41 env_var: String,
42 },
43 #[error("Invalid header format '{header}'. Expected 'Name: Value'")]
44 InvalidHeaderFormat { header: String },
45 #[error("Invalid header name '{name}': {reason}")]
46 InvalidHeaderName { name: String, reason: String },
47 #[error("Invalid header value for '{name}': {reason}")]
48 InvalidHeaderValue { name: String, reason: String },
49 #[error("EDITOR environment variable not set")]
50 EditorNotSet,
51 #[error("Editor command failed for spec '{name}'")]
52 EditorFailed { name: String },
53 #[error("Invalid HTTP method: {method}")]
54 InvalidHttpMethod { method: String },
55 #[error("Missing path parameter: {name}")]
56 MissingPathParameter { name: String },
57 #[error("Unsupported HTTP authentication scheme: {scheme}")]
58 UnsupportedAuthScheme { scheme: String },
59 #[error("Unsupported security scheme type: {scheme_type}")]
60 UnsupportedSecurityScheme { scheme_type: String },
61 #[error("Failed to serialize cached spec: {reason}")]
62 SerializationError { reason: String },
63 #[error("Invalid config.toml: {reason}")]
64 InvalidConfig { reason: String },
65 #[error("Could not determine home directory")]
66 HomeDirectoryNotFound,
67 #[error("Invalid JSON body: {reason}")]
68 InvalidJsonBody { reason: String },
69 #[error("Request failed: {reason}")]
70 RequestFailed { reason: String },
71 #[error("Failed to read response: {reason}")]
72 ResponseReadError { reason: String },
73 #[error("Request failed with status {status}: {body}")]
74 HttpErrorWithContext {
75 status: u16,
76 body: String,
77 api_name: String,
78 operation_id: Option<String>,
79 security_schemes: Vec<String>,
80 },
81 #[error("Invalid command for API '{context}': {reason}")]
82 InvalidCommand { context: String, reason: String },
83 #[error("Could not find operation from command path")]
84 OperationNotFound,
85 #[error("Invalid idempotency key")]
86 InvalidIdempotencyKey,
87 #[error("Header name cannot be empty")]
88 EmptyHeaderName,
89 #[error("JQ filter error: {reason}")]
90 JqFilterError { reason: String },
91 #[error("Invalid path '{path}': {reason}")]
92 InvalidPath { path: String, reason: String },
93
94 #[error(transparent)]
95 Anyhow(#[from] anyhow::Error),
96}
97
98#[derive(Debug, Serialize, Deserialize)]
100pub struct JsonError {
101 pub error_type: String,
102 pub message: String,
103 pub context: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub details: Option<serde_json::Value>,
106}
107
108impl Error {
109 #[must_use]
111 pub fn with_context(self, context: &str) -> Self {
112 match self {
113 Self::Network(e) => Self::Config(format!("{context}: {e}")),
114 Self::Io(e) => Self::Config(format!("{context}: {e}")),
115 _ => self,
116 }
117 }
118
119 #[must_use]
121 #[allow(clippy::too_many_lines)]
122 pub fn to_json(&self) -> JsonError {
123 use serde_json::json;
124
125 let (error_type, message, context, details) = match self {
126 Self::Config(msg) => ("Configuration", msg.clone(), None, None),
127 Self::Io(io_err) => {
128 let context = match io_err.kind() {
129 std::io::ErrorKind::NotFound => {
130 Some("Check that the file path is correct and the file exists.")
131 }
132 std::io::ErrorKind::PermissionDenied => {
133 Some("Check file permissions or run with appropriate privileges.")
134 }
135 _ => None,
136 };
137 (
138 "FileSystem",
139 io_err.to_string(),
140 context.map(str::to_string),
141 None,
142 )
143 }
144 Self::Network(req_err) => {
145 let context = if req_err.is_connect() {
146 Some("Check that the API server is running and accessible.")
147 } else if req_err.is_timeout() {
148 Some("The API server may be slow or unresponsive. Try again later.")
149 } else if req_err.is_status() {
150 req_err.status().and_then(|status| match status.as_u16() {
151 401 => Some("Check your API credentials and authentication configuration."),
152 403 => Some(
153 "Your credentials may be valid but lack permission for this operation.",
154 ),
155 404 => Some("Check that the API endpoint and parameters are correct."),
156 429 => {
157 Some("You're making requests too quickly. Wait before trying again.")
158 }
159 500..=599 => {
160 Some("The API server is experiencing issues. Try again later.")
161 }
162 _ => None,
163 })
164 } else {
165 None
166 };
167 ("Network", req_err.to_string(), context.map(str::to_string), None)
168 }
169 Self::Yaml(yaml_err) => (
170 "YAMLParsing",
171 yaml_err.to_string(),
172 Some("Check that your OpenAPI specification is valid YAML syntax.".to_string()),
173 None,
174 ),
175 Self::Json(json_err) => (
176 "JSONParsing",
177 json_err.to_string(),
178 Some("Check that your request body or response contains valid JSON.".to_string()),
179 None,
180 ),
181 Self::Validation(msg) => (
182 "Validation",
183 msg.clone(),
184 Some(
185 "Check that your OpenAPI specification follows the required format."
186 .to_string(),
187 ),
188 None,
189 ),
190 Self::Toml(toml_err) => (
191 "TOMLParsing",
192 toml_err.to_string(),
193 Some("Check that your configuration file is valid TOML syntax.".to_string()),
194 None,
195 ),
196 Self::SpecNotFound { name } => (
197 "SpecNotFound",
198 format!("API specification '{name}' not found"),
199 Some("Use 'aperture config list' to see available specifications.".to_string()),
200 Some(json!({ "spec_name": name })),
201 ),
202 Self::SpecAlreadyExists { name } => (
203 "SpecAlreadyExists",
204 format!("API specification '{name}' already exists. Use --force to overwrite"),
205 None,
206 Some(json!({ "spec_name": name })),
207 ),
208 Self::CachedSpecNotFound { name } => (
209 "CachedSpecNotFound",
210 format!("No cached spec found for '{name}'. Run 'aperture config add {name}' first"),
211 None,
212 Some(json!({ "spec_name": name })),
213 ),
214 Self::CachedSpecCorrupted { name, reason } => (
215 "CachedSpecCorrupted",
216 format!("Failed to deserialize cached spec '{name}': {reason}. The cache may be corrupted"),
217 Some("Try removing and re-adding the specification.".to_string()),
218 Some(json!({ "spec_name": name, "corruption_reason": reason })),
219 ),
220 Self::CacheVersionMismatch { name, found, expected } => (
221 "CacheVersionMismatch",
222 format!("Cache format version mismatch for '{name}': found v{found}, expected v{expected}"),
223 Some("Run 'aperture config reinit' to regenerate the cache.".to_string()),
224 Some(json!({ "spec_name": name, "found_version": found, "expected_version": expected })),
225 ),
226 Self::SecretNotSet { scheme_name, env_var } => (
227 "SecretNotSet",
228 format!("Environment variable '{env_var}' required for authentication '{scheme_name}' is not set"),
229 Some(format!("Set the environment variable: export {env_var}=<your-secret>")),
230 Some(json!({ "scheme_name": scheme_name, "env_var": env_var })),
231 ),
232 Self::InvalidHeaderFormat { header } => (
233 "InvalidHeaderFormat",
234 format!("Invalid header format '{header}'. Expected 'Name: Value'"),
235 None,
236 Some(json!({ "header": header })),
237 ),
238 Self::InvalidHeaderName { name, reason } => (
239 "InvalidHeaderName",
240 format!("Invalid header name '{name}': {reason}"),
241 None,
242 Some(json!({ "name": name, "reason": reason })),
243 ),
244 Self::InvalidHeaderValue { name, reason } => (
245 "InvalidHeaderValue",
246 format!("Invalid header value for '{name}': {reason}"),
247 None,
248 Some(json!({ "name": name, "reason": reason })),
249 ),
250 Self::EditorNotSet => (
251 "EditorNotSet",
252 "EDITOR environment variable not set".to_string(),
253 Some("Set your preferred editor: export EDITOR=vim".to_string()),
254 None,
255 ),
256 Self::EditorFailed { name } => (
257 "EditorFailed",
258 format!("Editor command failed for spec '{name}'"),
259 None,
260 Some(json!({ "spec_name": name })),
261 ),
262 Self::InvalidHttpMethod { method } => (
263 "InvalidHttpMethod",
264 format!("Invalid HTTP method: {method}"),
265 None,
266 Some(json!({ "method": method })),
267 ),
268 Self::MissingPathParameter { name } => (
269 "MissingPathParameter",
270 format!("Missing path parameter: {name}"),
271 None,
272 Some(json!({ "parameter_name": name })),
273 ),
274 Self::UnsupportedAuthScheme { scheme } => (
275 "UnsupportedAuthScheme",
276 format!("Unsupported HTTP authentication scheme: {scheme}"),
277 Some("Only 'bearer' and 'basic' schemes are supported.".to_string()),
278 Some(json!({ "scheme": scheme })),
279 ),
280 Self::UnsupportedSecurityScheme { scheme_type } => (
281 "UnsupportedSecurityScheme",
282 format!("Unsupported security scheme type: {scheme_type}"),
283 Some("Only 'apiKey' and 'http' security schemes are supported.".to_string()),
284 Some(json!({ "scheme_type": scheme_type })),
285 ),
286 Self::SerializationError { reason } => (
287 "SerializationError",
288 format!("Failed to serialize cached spec: {reason}"),
289 None,
290 Some(json!({ "reason": reason })),
291 ),
292 Self::InvalidConfig { reason } => (
293 "InvalidConfig",
294 format!("Invalid config.toml: {reason}"),
295 Some("Check the TOML syntax in your configuration file.".to_string()),
296 Some(json!({ "reason": reason })),
297 ),
298 Self::HomeDirectoryNotFound => (
299 "HomeDirectoryNotFound",
300 "Could not determine home directory".to_string(),
301 Some("Ensure HOME environment variable is set.".to_string()),
302 None,
303 ),
304 Self::InvalidJsonBody { reason } => (
305 "InvalidJsonBody",
306 format!("Invalid JSON body: {reason}"),
307 Some("Check your JSON syntax and ensure all quotes are properly escaped.".to_string()),
308 Some(json!({ "reason": reason })),
309 ),
310 Self::RequestFailed { reason } => (
311 "RequestFailed",
312 format!("Request failed: {reason}"),
313 None,
314 Some(json!({ "reason": reason })),
315 ),
316 Self::ResponseReadError { reason } => (
317 "ResponseReadError",
318 format!("Failed to read response: {reason}"),
319 None,
320 Some(json!({ "reason": reason })),
321 ),
322 Self::HttpErrorWithContext { status, body, api_name, operation_id, security_schemes } => {
323 let context_hint = match status {
324 401 => {
325 if security_schemes.is_empty() {
326 Some("Check your API credentials and authentication configuration.".to_string())
327 } else {
328 let env_vars: Vec<String> = security_schemes.iter()
329 .map(|scheme| format!("Check environment variable for '{scheme}' authentication"))
330 .collect();
331 Some(env_vars.join("; "))
332 }
333 },
334 403 => Some("Your credentials may be valid but lack permission for this operation.".to_string()),
335 404 => Some("Check that the API endpoint and parameters are correct.".to_string()),
336 429 => Some("You're making requests too quickly. Wait before trying again.".to_string()),
337 500..=599 => Some("The API server is experiencing issues. Try again later.".to_string()),
338 _ => None,
339 };
340 (
341 "HttpError",
342 format!("Request failed with status {status}: {body}"),
343 context_hint,
344 Some(json!({
345 "status": status,
346 "body": body,
347 "api_name": api_name,
348 "operation_id": operation_id,
349 "security_schemes": security_schemes
350 })),
351 )
352 },
353 Self::InvalidCommand { context, reason } => (
354 "InvalidCommand",
355 format!("Invalid command for API '{context}': {reason}"),
356 Some("Use --help to see available commands.".to_string()),
357 Some(json!({ "context": context, "reason": reason })),
358 ),
359 Self::OperationNotFound => (
360 "OperationNotFound",
361 "Could not find operation from command path".to_string(),
362 Some("Check that the command matches an available operation.".to_string()),
363 None,
364 ),
365 Self::InvalidIdempotencyKey => (
366 "InvalidIdempotencyKey",
367 "Invalid idempotency key".to_string(),
368 Some("Idempotency key must be a valid header value.".to_string()),
369 None,
370 ),
371 Self::EmptyHeaderName => (
372 "EmptyHeaderName",
373 "Header name cannot be empty".to_string(),
374 None,
375 None,
376 ),
377 Self::JqFilterError { reason } => (
378 "JqFilterError",
379 format!("JQ filter error: {reason}"),
380 Some("Check your JQ filter syntax. Common examples: '.name', '.[] | select(.active)'".to_string()),
381 Some(json!({ "reason": reason })),
382 ),
383 Self::InvalidPath { path, reason } => (
384 "InvalidPath",
385 format!("Invalid path '{path}': {reason}"),
386 Some("Check that the path is valid and properly formatted.".to_string()),
387 Some(json!({ "path": path, "reason": reason })),
388 ),
389 Self::Anyhow(err) => (
390 "Unexpected",
391 err.to_string(),
392 Some(
393 "This may be a bug. Please report it with the command you were running."
394 .to_string(),
395 ),
396 None,
397 ),
398 };
399
400 JsonError {
401 error_type: error_type.to_string(),
402 message,
403 context,
404 details,
405 }
406 }
407}