use serde::Deserialize;
use thiserror::Error;
pub use yldfi_common::api::{sanitize_error_body, ApiError};
#[derive(Debug, Clone, Deserialize)]
pub struct ApiErrorResponse {
pub message: Option<String>,
#[serde(alias = "name")]
pub code: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum PlanTier {
Free,
Starter,
Pro,
Business,
}
impl std::fmt::Display for PlanTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PlanTier::Free => write!(f, "Free"),
PlanTier::Starter => write!(f, "Starter"),
PlanTier::Pro => write!(f, "Pro"),
PlanTier::Business => write!(f, "Business"),
}
}
}
#[derive(Error, Debug)]
pub enum DomainError {
#[error("Unauthorized: Invalid or missing API key")]
Unauthorized,
#[error("Plan upgrade required: This endpoint requires {required_plan} plan or higher")]
PlanRequired {
required_plan: PlanTier,
message: String,
},
#[error("Not found: {0}")]
NotFound(String),
#[error("Configuration error: {0}")]
Config(String),
#[error("Missing API key")]
MissingApiKey,
#[error("Insecure URL scheme: use HTTPS to protect API keys (got: {0})")]
InsecureScheme(String),
}
pub type Error = ApiError<DomainError>;
pub type Result<T> = std::result::Result<T, Error>;
#[must_use]
pub fn unauthorized() -> Error {
ApiError::domain(DomainError::Unauthorized)
}
pub fn plan_required(required_plan: PlanTier, message: impl Into<String>) -> Error {
ApiError::domain(DomainError::PlanRequired {
required_plan,
message: message.into(),
})
}
pub fn not_found(resource: impl Into<String>) -> Error {
ApiError::domain(DomainError::NotFound(resource.into()))
}
pub fn config(message: impl Into<String>) -> Error {
ApiError::domain(DomainError::Config(message.into()))
}
#[must_use]
pub fn missing_api_key() -> Error {
ApiError::domain(DomainError::MissingApiKey)
}
pub fn insecure_scheme(scheme: impl Into<String>) -> Error {
ApiError::domain(DomainError::InsecureScheme(scheme.into()))
}
#[must_use]
pub fn from_response(status: u16, body: &str, retry_after: Option<u64>) -> Error {
let sanitized_body = sanitize_error_body(body);
let parsed: Option<ApiErrorResponse> = serde_json::from_str(body).ok();
let message = parsed
.as_ref()
.and_then(|r| r.message.as_ref().map(|m| sanitize_error_body(m)))
.unwrap_or_else(|| sanitized_body.clone());
match status {
401 => unauthorized(),
402 | 403 => {
let required_plan = if message.to_lowercase().contains("business") {
PlanTier::Business
} else if message.to_lowercase().contains("pro") {
PlanTier::Pro
} else if message.to_lowercase().contains("starter") {
PlanTier::Starter
} else {
PlanTier::Pro };
plan_required(required_plan, message)
}
404 => not_found(message),
_ => ApiError::from_response(status, &sanitized_body, retry_after),
}
}
#[must_use]
pub fn is_plan_required(error: &Error) -> bool {
matches!(error, ApiError::Domain(DomainError::PlanRequired { .. }))
}