use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ProblemErrorEntry {
#[serde(default)]
pub field: String,
#[serde(default)]
pub code: String,
#[serde(default)]
pub detail: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProblemDetails {
pub r#type: String,
pub title: String,
pub status: u16,
pub detail: String,
pub code: String,
pub trace_id: String,
pub errors: Option<Vec<ProblemErrorEntry>>,
pub instance: Option<String>,
pub extensions: serde_json::Map<String, serde_json::Value>,
}
const CANONICAL_PROBLEM_KEYS: [&str; 8] = [
"type", "title", "status", "detail", "code", "trace_id", "errors", "instance",
];
impl ProblemDetails {
#[must_use]
pub fn message(&self) -> String {
if self.detail.is_empty() {
format!("{} (HTTP {})", self.title, self.status)
} else {
self.detail.clone()
}
}
#[must_use]
pub fn extension_str(&self, key: &str) -> Option<String> {
self.extensions.get(key).and_then(|v| match v {
serde_json::Value::String(s) => Some(s.clone()),
_ => None,
})
}
#[must_use]
pub fn extension_u64(&self, key: &str) -> Option<u64> {
self.extensions.get(key).and_then(serde_json::Value::as_u64)
}
#[must_use]
pub fn extension_decimal_u64(&self, key: &str) -> Option<u64> {
self.extension_str(key).and_then(|s| s.parse::<u64>().ok())
}
#[must_use]
pub fn extension_string_array(&self, key: &str) -> Vec<String> {
match self.extensions.get(key) {
Some(serde_json::Value::Array(items)) => items
.iter()
.filter_map(|v| match v {
serde_json::Value::String(s) => Some(s.clone()),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum HttpErrorKind {
Unauthorized,
Forbidden,
InsufficientScope {
required_scopes: Vec<String>,
granted_scopes: Vec<String>,
},
InsufficientFunds {
balance_usd_micros: Option<u64>,
required_usd_micros: Option<u64>,
top_up_url: Option<String>,
},
QuoteExpired {
quote_id: Option<String>,
},
QuoteNotFound {
quote_id: Option<String>,
},
QuoteAlreadyConsumed {
quote_id: Option<String>,
},
NotFound,
RecordNotFound,
IdempotencyConflict,
RateLimited,
ValidationFailed,
InvalidBody,
MalformedCbor,
BatchTooLarge {
max: Option<u64>,
got: Option<u64>,
},
BatchEmpty,
InternalServer,
ServiceUnavailable,
Other,
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[error("{}", .problem.message())]
pub struct Cip309HttpError {
pub problem: ProblemDetails,
pub request_id: String,
pub retry_after_seconds: Option<u64>,
pub kind: HttpErrorKind,
}
impl Cip309HttpError {
#[must_use]
pub fn problem(&self) -> &ProblemDetails {
&self.problem
}
#[must_use]
pub fn code(&self) -> &str {
&self.problem.code
}
#[must_use]
pub fn http_status(&self) -> u16 {
self.problem.status
}
#[must_use]
pub fn request_id(&self) -> &str {
&self.request_id
}
#[must_use]
pub fn retry_after_seconds(&self) -> Option<u64> {
self.retry_after_seconds
}
#[must_use]
pub fn kind(&self) -> &HttpErrorKind {
&self.kind
}
}
#[derive(Debug, Clone)]
pub struct ParseHttpErrorArgs {
pub http_status: u16,
pub body: Option<serde_json::Value>,
pub request_id: Option<String>,
pub retry_after_seconds: Option<u64>,
}
fn synthesise_problem(http_status: u16, request_id: Option<&str>) -> ProblemDetails {
ProblemDetails {
r#type: "about:blank".to_string(),
title: format!("HTTP {http_status}"),
status: http_status,
detail: format!("Server returned HTTP {http_status} without a problem+json body."),
code: format!("http-{http_status}"),
trace_id: request_id.unwrap_or_default().to_string(),
errors: None,
instance: None,
extensions: serde_json::Map::new(),
}
}
fn project_problem_errors(value: &serde_json::Value) -> Option<Vec<ProblemErrorEntry>> {
let arr = value.as_array()?;
let entries = arr
.iter()
.filter_map(serde_json::Value::as_object)
.map(|obj| {
let field = |key: &str| {
obj.get(key)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string()
};
ProblemErrorEntry {
field: field("field"),
code: field("code"),
detail: field("detail"),
}
})
.collect();
Some(entries)
}
fn to_problem_details(
http_status: u16,
body: Option<&serde_json::Value>,
request_id: Option<&str>,
) -> ProblemDetails {
let obj = match body {
Some(serde_json::Value::Object(map)) => map,
_ => return synthesise_problem(http_status, request_id),
};
let code = obj.get("code").and_then(serde_json::Value::as_str);
let title = obj.get("title").and_then(serde_json::Value::as_str);
if code.is_none() && title.is_none() {
return synthesise_problem(http_status, request_id);
}
let status = obj
.get("status")
.and_then(serde_json::Value::as_u64)
.map_or(http_status, |s| u16::try_from(s).unwrap_or(u16::MAX));
let code = code.map_or_else(|| format!("http-{status}"), str::to_string);
let r#type = obj
.get("type")
.and_then(serde_json::Value::as_str)
.map_or_else(|| "about:blank".to_string(), str::to_string);
let title = title.map_or_else(|| format!("HTTP {status}"), str::to_string);
let detail = obj
.get("detail")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let trace_id = obj
.get("trace_id")
.and_then(serde_json::Value::as_str)
.or(request_id)
.unwrap_or_default()
.to_string();
let instance = obj
.get("instance")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let errors = obj.get("errors").and_then(project_problem_errors);
let mut extensions = serde_json::Map::new();
for (key, value) in obj {
if !CANONICAL_PROBLEM_KEYS.contains(&key.as_str()) {
extensions.insert(key.clone(), value.clone());
}
}
ProblemDetails {
r#type,
title,
status,
detail,
code,
trace_id,
errors,
instance,
extensions,
}
}
#[must_use]
pub fn parse_http_error(args: ParseHttpErrorArgs) -> Cip309HttpError {
let problem = to_problem_details(
args.http_status,
args.body.as_ref(),
args.request_id.as_deref(),
);
let request_id = args
.request_id
.clone()
.unwrap_or_else(|| problem.trace_id.clone());
let retry_after_seconds = args.retry_after_seconds;
let kind = match problem.code.as_str() {
"unauthorized" => HttpErrorKind::Unauthorized,
"forbidden" | "csrf-invalid" => HttpErrorKind::Forbidden,
"insufficient-scope" => HttpErrorKind::InsufficientScope {
required_scopes: problem.extension_string_array("required"),
granted_scopes: problem.extension_string_array("granted"),
},
"insufficient-funds" => HttpErrorKind::InsufficientFunds {
balance_usd_micros: problem.extension_decimal_u64("balance_usd_micros"),
required_usd_micros: problem.extension_decimal_u64("required_usd_micros"),
top_up_url: problem.extension_str("top_up_url"),
},
"quote-expired" => HttpErrorKind::QuoteExpired {
quote_id: problem.extension_str("quote_id"),
},
"quote-not-found" => HttpErrorKind::QuoteNotFound {
quote_id: problem.extension_str("quote_id"),
},
"quote-already-consumed" => HttpErrorKind::QuoteAlreadyConsumed {
quote_id: problem.extension_str("quote_id"),
},
"not-found" => HttpErrorKind::NotFound,
"record-not-found" => HttpErrorKind::RecordNotFound,
"idempotency-key-conflict" => HttpErrorKind::IdempotencyConflict,
"rate-limited" => HttpErrorKind::RateLimited,
"validation-failed" => HttpErrorKind::ValidationFailed,
"invalid-body" => HttpErrorKind::InvalidBody,
"malformed-cbor" => HttpErrorKind::MalformedCbor,
"batch-too-large" => HttpErrorKind::BatchTooLarge {
max: problem.extension_u64("max"),
got: problem.extension_u64("got"),
},
"batch-empty" => HttpErrorKind::BatchEmpty,
"internal-error" => HttpErrorKind::InternalServer,
"service-unavailable" | "fx-stale" => HttpErrorKind::ServiceUnavailable,
_ => HttpErrorKind::Other,
};
Cip309HttpError {
problem,
request_id,
retry_after_seconds,
kind,
}
}