use std::fmt;
use reqwest::header::HeaderMap;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub struct ApiError {
pub status: u16,
pub message: String,
pub code: Option<String>,
pub body: Value,
pub request_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RateLimitError {
pub error: ApiError,
pub retry_after: Option<u64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlanLimitError {
pub error: ApiError,
pub offer: Value,
pub retry_after: Option<u64>,
}
impl PlanLimitError {
fn s(&self, pointer: &str) -> Option<&str> {
self.offer.pointer(pointer).and_then(Value::as_str)
}
pub fn upgrade_url(&self) -> Option<&str> {
self.s("/urls/billing").or_else(|| self.s("/urls/pricing"))
}
pub fn feature(&self) -> Option<&str> {
self.s("/feature")
}
pub fn reason(&self) -> Option<&str> {
self.s("/reason")
}
pub fn headline(&self) -> Option<&str> {
self.s("/headline")
}
pub fn current_plan(&self) -> Option<&str> {
self.s("/current_plan/slug")
}
pub fn recommended_plan(&self) -> Option<&str> {
self.s("/recommended_plan")
}
pub fn plans(&self) -> &[Value] {
self.offer
.get("plans")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[])
}
}
#[derive(Debug)]
pub enum MisarDevError {
Validation(ApiError),
Authentication(ApiError),
PermissionDenied(ApiError),
NotFound(ApiError),
Conflict(ApiError),
RateLimit(Box<RateLimitError>),
Server(ApiError),
PlanLimit(Box<PlanLimitError>),
Api(ApiError),
Network {
message: String,
source: Option<reqwest::Error>,
},
MissingApiKey,
Config(String),
}
impl MisarDevError {
pub fn api_error(&self) -> Option<&ApiError> {
match self {
Self::Validation(e)
| Self::Authentication(e)
| Self::PermissionDenied(e)
| Self::NotFound(e)
| Self::Conflict(e)
| Self::Server(e)
| Self::Api(e) => Some(e),
Self::RateLimit(r) => Some(&r.error),
Self::PlanLimit(p) => Some(&p.error),
_ => None,
}
}
pub fn status(&self) -> u16 {
self.api_error().map(|e| e.status).unwrap_or(0)
}
pub fn message(&self) -> String {
match self {
Self::Network { message, .. } => message.clone(),
Self::MissingApiKey => MISSING_KEY.to_owned(),
Self::Config(m) => m.clone(),
other => other
.api_error()
.map(|e| e.message.clone())
.unwrap_or_default(),
}
}
pub fn code(&self) -> Option<&str> {
match self {
Self::Network { .. } => Some("network_error"),
Self::MissingApiKey => Some("missing_api_key"),
other => other.api_error().and_then(|e| e.code.as_deref()),
}
}
pub fn body(&self) -> Option<&Value> {
self.api_error().map(|e| &e.body)
}
pub fn request_id(&self) -> Option<&str> {
self.api_error().and_then(|e| e.request_id.as_deref())
}
pub fn rate_limit(&self) -> Option<&RateLimitError> {
match self {
Self::RateLimit(r) => Some(r),
_ => None,
}
}
pub fn plan_limit(&self) -> Option<&PlanLimitError> {
match self {
Self::PlanLimit(p) => Some(p),
_ => None,
}
}
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::RateLimit(_) | Self::Server(_) | Self::Network { .. }
)
}
}
pub(crate) const MISSING_KEY: &str =
"a MisarDev API key is required: pass it to the client or set \
MISARDEV_API_KEY. Create one at https://www.misar.dev/settings/api-keys";
impl fmt::Display for MisarDevError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let kind = match self {
Self::Validation(_) => "validation error",
Self::Authentication(_) => "authentication error",
Self::PermissionDenied(_) => "permission denied",
Self::NotFound(_) => "not found",
Self::Conflict(_) => "conflict",
Self::RateLimit(_) => "rate limited",
Self::Server(_) => "server error",
Self::PlanLimit(_) => "plan limit",
Self::Api(_) => "API error",
Self::Network { .. } => "network error",
Self::MissingApiKey => "missing API key",
Self::Config(_) => "configuration error",
};
match self.api_error() {
Some(e) => write!(f, "misardev: {kind} ({}): {}", e.status, e.message),
None => write!(f, "misardev: {kind}: {}", self.message()),
}
}
}
impl std::error::Error for MisarDevError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Network {
source: Some(e), ..
} => Some(e),
_ => None,
}
}
}
impl From<reqwest::Error> for MisarDevError {
fn from(e: reqwest::Error) -> Self {
Self::Network {
message: e.to_string(),
source: Some(e),
}
}
}
pub(crate) fn is_plan_limit_body(body: &Value) -> bool {
body.get("code").and_then(Value::as_str) == Some("plan_limit_exceeded")
}
fn message_of(body: &Value, fallback: &str) -> String {
if let Some(s) = body.get("error").and_then(Value::as_str) {
return s.to_owned();
}
if let Some(s) = body.pointer("/error/message").and_then(Value::as_str) {
return s.to_owned();
}
if let Some(s) = body.get("message").and_then(Value::as_str) {
return s.to_owned();
}
if let Value::String(s) = body {
let t = s.trim();
if !t.is_empty() {
return t.chars().take(500).collect();
}
}
fallback.to_owned()
}
fn code_of(body: &Value) -> Option<String> {
body.get("code")
.and_then(Value::as_str)
.or_else(|| body.pointer("/error/code").and_then(Value::as_str))
.map(str::to_owned)
}
pub(crate) fn retry_after_seconds(headers: &HeaderMap) -> Option<u64> {
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<u64>().ok())
}
pub(crate) fn error_for(
status: u16,
headers: &HeaderMap,
body: Value,
reason: &str,
) -> MisarDevError {
let fallback = if reason.is_empty() {
format!("HTTP {status}")
} else {
reason.to_owned()
};
let error = ApiError {
status,
message: message_of(&body, &fallback),
code: code_of(&body),
request_id: headers
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map(str::to_owned),
body,
};
if is_plan_limit_body(&error.body) {
let offer = error.body.get("upgrade").cloned().unwrap_or(Value::Null);
return MisarDevError::PlanLimit(Box::new(PlanLimitError {
error,
offer,
retry_after: retry_after_seconds(headers),
}));
}
match status {
400 | 422 => MisarDevError::Validation(error),
401 => MisarDevError::Authentication(error),
403 => MisarDevError::PermissionDenied(error),
404 => MisarDevError::NotFound(error),
409 => MisarDevError::Conflict(error),
429 => MisarDevError::RateLimit(Box::new(RateLimitError {
error,
retry_after: retry_after_seconds(headers),
})),
s if s >= 500 => MisarDevError::Server(error),
_ => MisarDevError::Api(error),
}
}