1use reqwest::StatusCode;
2use std::fmt;
3
4pub type Result<T> = std::result::Result<T, Error>;
6
7pub trait ResultContext<T> {
8 fn context(self, message: impl Into<String>) -> Result<T>;
9 fn with_context<M>(self, message: impl FnOnce() -> M) -> Result<T>
10 where
11 M: Into<String>;
12}
13
14impl<T, E> ResultContext<T> for std::result::Result<T, E>
15where
16 E: Into<Error>,
17{
18 fn context(self, message: impl Into<String>) -> Result<T> {
19 self.map_err(|err| Error::context(message, err.into()))
20 }
21
22 fn with_context<M>(self, message: impl FnOnce() -> M) -> Result<T>
23 where
24 M: Into<String>,
25 {
26 self.map_err(|err| Error::context(message(), err.into()))
27 }
28}
29
30#[derive(Debug)]
32#[non_exhaustive]
33pub enum Error {
34 InvalidConfiguration(String),
35 InvalidSpace {
36 id: String,
37 available: Vec<String>,
38 },
39 UnsupportedCapability {
40 capability: &'static str,
41 detected: semver::Version,
42 minimum: semver::Version,
43 },
44 MissingResourceId {
45 resource: &'static str,
46 },
47 MissingField {
48 field: &'static str,
49 },
50 RedactedStatusUnauthenticated,
51 ApiResponse {
52 status: StatusCode,
53 body: String,
54 },
55 Transport(reqwest::Error),
56 Url(url::ParseError),
57 HeaderName(reqwest::header::InvalidHeaderName),
58 HeaderValue(reqwest::header::InvalidHeaderValue),
59 HeaderToStr(reqwest::header::ToStrError),
60 Json(serde_json::Error),
61 Yaml(yaml_serde::Error),
62 Version(semver::Error),
63 Io(std::io::Error),
64 SemaphoreClosed,
65 Message(String),
66 Context {
67 message: String,
68 source: Box<Error>,
69 },
70}
71
72impl Error {
73 pub fn api_response(status: StatusCode, body: impl Into<String>) -> Self {
74 Self::ApiResponse {
75 status,
76 body: body.into(),
77 }
78 }
79
80 pub fn message(message: impl Into<String>) -> Self {
81 Self::Message(message.into())
82 }
83
84 pub fn context(message: impl Into<String>, source: Error) -> Self {
85 Self::Context {
86 message: message.into(),
87 source: Box::new(source),
88 }
89 }
90}
91
92impl fmt::Display for Error {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 match self {
95 Self::InvalidConfiguration(message) => write!(f, "invalid configuration: {message}"),
96 Self::InvalidSpace { id, available } => write!(
97 f,
98 "space '{id}' not found in registry. Available spaces: {}",
99 available.join(", ")
100 ),
101 Self::UnsupportedCapability {
102 capability,
103 detected,
104 minimum,
105 } => write!(
106 f,
107 "API '{capability}' requires Kibana {minimum}+ (detected {detected})"
108 ),
109 Self::MissingResourceId { resource } => {
110 write!(f, "{resource} is missing required 'id' field")
111 }
112 Self::MissingField { field } => write!(f, "missing required field: {field}"),
113 Self::RedactedStatusUnauthenticated => write!(
114 f,
115 "Kibana /api/status response did not include version.number; this usually means Kibana returned the unauthenticated redacted status response. Check KIBANA_APIKEY or KIBANA_USERNAME/KIBANA_PASSWORD credentials"
116 ),
117 Self::ApiResponse { status, body } => {
118 write!(f, "Kibana API returned {status}: {body}")
119 }
120 Self::Transport(err) => write!(f, "transport error: {err}"),
121 Self::Url(err) => write!(f, "URL error: {err}"),
122 Self::HeaderName(err) => write!(f, "invalid header name: {err}"),
123 Self::HeaderValue(err) => write!(f, "invalid header value: {err}"),
124 Self::HeaderToStr(err) => write!(f, "invalid header value: {err}"),
125 Self::Json(err) => write!(f, "JSON error: {err}"),
126 Self::Yaml(err) => write!(f, "YAML error: {err}"),
127 Self::Version(err) => write!(f, "version parse error: {err}"),
128 Self::Io(err) => write!(f, "I/O error: {err}"),
129 Self::SemaphoreClosed => write!(f, "request semaphore closed"),
130 Self::Message(message) => f.write_str(message),
131 Self::Context { message, source } => write!(f, "{message}: {source}"),
132 }
133 }
134}
135
136impl std::error::Error for Error {
137 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
138 match self {
139 Self::Transport(err) => Some(err),
140 Self::Url(err) => Some(err),
141 Self::HeaderName(err) => Some(err),
142 Self::HeaderValue(err) => Some(err),
143 Self::HeaderToStr(err) => Some(err),
144 Self::Json(err) => Some(err),
145 Self::Yaml(err) => Some(err),
146 Self::Version(err) => Some(err),
147 Self::Io(err) => Some(err),
148 Self::Context { source, .. } => Some(source.as_ref()),
149 _ => None,
150 }
151 }
152}
153
154impl From<reqwest::Error> for Error {
155 fn from(err: reqwest::Error) -> Self {
156 Self::Transport(err)
157 }
158}
159
160impl From<url::ParseError> for Error {
161 fn from(err: url::ParseError) -> Self {
162 Self::Url(err)
163 }
164}
165
166impl From<reqwest::header::InvalidHeaderName> for Error {
167 fn from(err: reqwest::header::InvalidHeaderName) -> Self {
168 Self::HeaderName(err)
169 }
170}
171
172impl From<reqwest::header::InvalidHeaderValue> for Error {
173 fn from(err: reqwest::header::InvalidHeaderValue) -> Self {
174 Self::HeaderValue(err)
175 }
176}
177
178impl From<reqwest::header::ToStrError> for Error {
179 fn from(err: reqwest::header::ToStrError) -> Self {
180 Self::HeaderToStr(err)
181 }
182}
183
184impl From<serde_json::Error> for Error {
185 fn from(err: serde_json::Error) -> Self {
186 Self::Json(err)
187 }
188}
189
190impl From<yaml_serde::Error> for Error {
191 fn from(err: yaml_serde::Error) -> Self {
192 Self::Yaml(err)
193 }
194}
195
196impl From<semver::Error> for Error {
197 fn from(err: semver::Error) -> Self {
198 Self::Version(err)
199 }
200}
201
202impl From<std::io::Error> for Error {
203 fn from(err: std::io::Error) -> Self {
204 Self::Io(err)
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use std::error::Error as _;
212
213 #[test]
214 fn result_context_preserves_error_source() {
215 let result: std::result::Result<(), std::io::Error> =
216 Err(std::io::Error::other("disk full"));
217
218 let err = result.context("failed to read manifest").unwrap_err();
219
220 assert!(matches!(&err, Error::Context { .. }));
221 assert!(matches!(err.source(), Some(source) if source.is::<Error>()));
222 }
223}