use serde::Deserialize;
#[derive(Debug, Clone, Default)]
pub struct RequestParams(pub Vec<(String, String)>);
impl std::fmt::Display for RequestParams {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.0.is_empty() {
return write!(f, "no params");
}
for (i, (k, v)) in self.0.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{k}={v}")?;
}
Ok(())
}
}
#[allow(clippy::trivially_copy_pass_by_ref, clippy::ref_option)]
fn format_status(code: &Option<u16>) -> String {
match code {
Some(c) => format!(" [HTTP {c}] "),
None => " ".to_string(),
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("not authenticated — call login() or set a token")]
NotAuthenticated,
#[error("authentication failed: {message}")]
Authentication {
message: String,
},
#[error("session token is invalid or expired")]
InvalidToken,
#[error("two-factor authentication required")]
TwoFactorRequired,
#[error(
"server error on {path}{}({params}): {message}",
format_status(status_code)
)]
Server {
message: String,
status_code: Option<u16>,
path: String,
params: RequestParams,
},
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("failed to parse response: {0}")]
Deserialization(#[from] serde_json::Error),
#[error("invalid configuration: {reason}")]
Config {
reason: String,
},
}
impl Error {
#[must_use]
pub fn is_timeout(&self) -> bool {
matches!(self, Self::Http(e) if e.is_timeout())
}
#[must_use]
pub fn is_connection_error(&self) -> bool {
matches!(self, Self::Http(e) if e.is_connect())
}
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Self::Http(e) => {
if e.is_connect() || e.is_timeout() {
return true;
}
if let Some(status) = e.status() {
return matches!(status.as_u16(), 429 | 502 | 503);
}
e.is_request()
}
Self::NotAuthenticated
| Self::Authentication { .. }
| Self::InvalidToken
| Self::TwoFactorRequired
| Self::Server { .. }
| Self::Deserialization(_)
| Self::Config { .. } => false,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ApiResponse<T> {
pub status: String,
#[serde(default)]
pub error_message: Option<String>,
pub response: Option<T>,
#[serde(skip)]
pub http_status: Option<u16>,
#[serde(skip)]
pub path: String,
#[serde(skip)]
pub params: RequestParams,
}
impl<T> ApiResponse<T> {
pub fn check_status(&self) -> Result<(), Error> {
match self.status.as_str() {
"ok" => Ok(()),
"error" => Err(Error::Server {
message: self
.error_message
.clone()
.unwrap_or_else(|| "unknown server error".to_string()),
status_code: self.http_status,
path: self.path.clone(),
params: self.params.clone(),
}),
"invalid-token" => Err(Error::InvalidToken),
"2fa-required" => Err(Error::TwoFactorRequired),
other => Err(Error::Server {
message: format!("unexpected status: {other}"),
status_code: self.http_status,
path: self.path.clone(),
params: self.params.clone(),
}),
}
}
pub fn into_result(self) -> Result<T, Error> {
match self.status.as_str() {
"ok" => self.response.ok_or_else(|| Error::Server {
message: "response body missing expected data".to_string(),
status_code: self.http_status,
path: self.path.clone(),
params: self.params.clone(),
}),
"error" => Err(Error::Server {
message: self
.error_message
.unwrap_or_else(|| "unknown server error".to_string()),
status_code: self.http_status,
path: self.path,
params: self.params,
}),
"invalid-token" => Err(Error::InvalidToken),
"2fa-required" => Err(Error::TwoFactorRequired),
other => Err(Error::Server {
message: format!("unexpected status: {other}"),
status_code: self.http_status,
path: self.path,
params: self.params,
}),
}
}
}