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