use reqwest::StatusCode;
use std::error::Error as StdError;
use thiserror::Error;
use crate::i18n::{self, Language, MessageKey};
pub type BoxError = Box<dyn StdError + Send + Sync>;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error("{message}")]
InvalidProject { message: String },
#[error("{message}")]
InvalidApiKey { message: String },
#[error("{message}")]
InvalidAmount { message: String },
#[error("{message}")]
InvalidOrderId { message: String },
#[error("{message}")]
InvalidPaymentMethod { message: String },
#[error("{message}: {source}")]
EncodeJson {
message: String,
#[source]
source: serde_json::Error,
},
#[error("{message}: {source}")]
DecodeJson {
message: String,
#[source]
source: serde_json::Error,
},
#[error("client: failed to create request: {source}")]
BuildRequest {
#[source]
source: url::ParseError,
},
#[error("pakasir api error: status {status}: {body}")]
Api { status: StatusCode, body: String },
#[error("{message}: {source}")]
RequestFailed {
message: String,
#[source]
source: BoxError,
},
#[error("{message}: {source}")]
RequestFailedAfterRetries {
message: String,
#[source]
source: BoxError,
},
#[error("response body too large: exceeds {limit} bytes")]
ResponseTooLarge { limit: usize },
}
impl Error {
pub(crate) fn invalid_project(lang: Language) -> Self {
Self::InvalidProject {
message: i18n::get(lang, MessageKey::InvalidProject).to_owned(),
}
}
pub(crate) fn invalid_api_key(lang: Language) -> Self {
Self::InvalidApiKey {
message: i18n::get(lang, MessageKey::InvalidApiKey).to_owned(),
}
}
pub(crate) fn invalid_amount(lang: Language) -> Self {
Self::InvalidAmount {
message: i18n::get(lang, MessageKey::InvalidAmount).to_owned(),
}
}
pub(crate) fn invalid_order_id(lang: Language) -> Self {
Self::InvalidOrderId {
message: i18n::get(lang, MessageKey::InvalidOrderId).to_owned(),
}
}
pub(crate) fn encode_json(lang: Language, source: serde_json::Error) -> Self {
Self::EncodeJson {
message: i18n::get(lang, MessageKey::FailedToEncode).to_owned(),
source,
}
}
pub(crate) fn decode_json(lang: Language, source: serde_json::Error) -> Self {
Self::DecodeJson {
message: i18n::get(lang, MessageKey::FailedToDecode).to_owned(),
source,
}
}
pub(crate) fn request_failed(lang: Language, source: BoxError) -> Self {
Self::RequestFailed {
message: i18n::get(lang, MessageKey::RequestFailedPermanent).to_owned(),
source,
}
}
pub(crate) fn request_failed_after_retries(
lang: Language,
retries: usize,
source: BoxError,
) -> Self {
let template = i18n::get(lang, MessageKey::RequestFailedAfterRetries);
let message = template.replacen("%d", &retries.to_string(), 1);
Self::RequestFailedAfterRetries { message, source }
}
pub fn api_status(&self) -> Option<StatusCode> {
match self {
Self::Api { status, .. } => Some(*status),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_serde_error() -> serde_json::Error {
serde_json::from_slice::<serde_json::Value>(b"not json").unwrap_err()
}
#[test]
fn invalid_project_uses_localized_message() {
let err = Error::invalid_project(Language::English);
assert_eq!(err.to_string(), "project slug is required");
let err = Error::invalid_project(Language::Indonesian);
assert_eq!(err.to_string(), "slug proyek wajib diisi");
}
#[test]
fn invalid_api_key_uses_localized_message() {
let err = Error::invalid_api_key(Language::English);
assert_eq!(err.to_string(), "API key is required");
let err = Error::invalid_api_key(Language::Indonesian);
assert_eq!(err.to_string(), "API key wajib diisi");
}
#[test]
fn invalid_amount_uses_localized_message() {
let err = Error::invalid_amount(Language::English);
assert_eq!(err.to_string(), "amount must be greater than 0");
let err = Error::invalid_amount(Language::Indonesian);
assert_eq!(err.to_string(), "jumlah harus lebih dari 0");
}
#[test]
fn invalid_order_id_uses_localized_message() {
let err = Error::invalid_order_id(Language::English);
assert_eq!(err.to_string(), "order ID is required");
let err = Error::invalid_order_id(Language::Indonesian);
assert_eq!(err.to_string(), "ID pesanan wajib diisi");
}
#[test]
fn encode_json_wraps_source_error_with_localized_prefix() {
let err = Error::encode_json(Language::English, make_serde_error());
let text = err.to_string();
assert!(
text.starts_with("failed to encode request as JSON: "),
"unexpected: {text}"
);
assert!(err.source().is_some());
let err = Error::encode_json(Language::Indonesian, make_serde_error());
assert!(
err.to_string()
.starts_with("gagal mengenkode permintaan sebagai JSON: ")
);
}
#[test]
fn decode_json_wraps_source_error_with_localized_prefix() {
let err = Error::decode_json(Language::English, make_serde_error());
let text = err.to_string();
assert!(
text.starts_with("failed to decode response: "),
"unexpected: {text}"
);
assert!(err.source().is_some());
let err = Error::decode_json(Language::Indonesian, make_serde_error());
assert!(err.to_string().starts_with("gagal mendekode respons: "));
}
#[test]
fn request_failed_uses_permanent_template() {
let err = Error::request_failed(Language::English, Box::new(std::io::Error::other("boom")));
assert!(
err.to_string()
.starts_with("request failed due to permanent error: ")
);
assert!(err.source().is_some());
}
#[test]
fn request_failed_after_retries_substitutes_count() {
let err = Error::request_failed_after_retries(
Language::English,
3,
Box::new(std::io::Error::other("flaky")),
);
assert!(err.to_string().contains("after 3 retries"));
let err = Error::request_failed_after_retries(
Language::Indonesian,
5,
Box::new(std::io::Error::other("flaky")),
);
assert!(err.to_string().contains("setelah 5 percobaan ulang"));
}
#[test]
fn api_status_returns_status_for_api_variant() {
let err = Error::Api {
status: StatusCode::BAD_REQUEST,
body: "bad".into(),
};
assert_eq!(err.api_status(), Some(StatusCode::BAD_REQUEST));
assert!(err.to_string().contains("status 400"));
}
#[test]
fn api_status_returns_none_for_other_variants() {
let err = Error::invalid_project(Language::English);
assert_eq!(err.api_status(), None);
let err = Error::ResponseTooLarge { limit: 1024 };
assert_eq!(err.api_status(), None);
assert!(err.to_string().contains("1024"));
}
#[test]
fn build_request_display_includes_source() {
let parse_err = url::Url::parse("not a url").unwrap_err();
let err = Error::BuildRequest { source: parse_err };
assert!(err.to_string().contains("failed to create request"));
assert!(err.source().is_some());
}
#[test]
fn invalid_payment_method_display_is_message() {
let err = Error::InvalidPaymentMethod {
message: "bogus".into(),
};
assert_eq!(err.to_string(), "bogus");
}
}