use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub mod codes {
pub const BAD_REQUEST: &str = "bad_request";
pub const VALIDATION_FAILED: &str = "validation_failed";
pub const UNAUTHORIZED: &str = "unauthorized";
pub const TOKEN_EXPIRED: &str = "token_expired";
pub const FORBIDDEN: &str = "forbidden";
pub const NOT_FOUND: &str = "not_found";
pub const METHOD_NOT_ALLOWED: &str = "method_not_allowed";
pub const UNSUPPORTED_MEDIA_TYPE: &str = "unsupported_media_type";
pub const CONFLICT: &str = "conflict";
pub const VERSION_CONFLICT: &str = "version_conflict";
pub const PAYLOAD_TOO_LARGE: &str = "payload_too_large";
pub const RATE_LIMITED: &str = "rate_limited";
pub const QUOTA_EXCEEDED: &str = "quota_exceeded";
pub const UNKNOWN_TYPE: &str = "unknown_type";
pub const UNSUPPORTED_PROTOCOL: &str = "unsupported_protocol";
pub const INVALID_CREDENTIALS: &str = "invalid_credentials";
pub const REFRESH_TOKEN_REUSED: &str = "refresh_token_reused";
pub const EMAIL_TAKEN: &str = "email_taken";
pub const EMAIL_NOT_VERIFIED: &str = "email_not_verified";
pub const INVALID_TOKEN: &str = "invalid_token";
pub const REAUTHENTICATION_REQUIRED: &str = "reauthentication_required";
pub const BANNED: &str = "banned";
pub const STEAM_AUTH_FAILED: &str = "steam_auth_failed";
pub const ROOM_FULL: &str = "room_full";
pub const NOT_A_MEMBER: &str = "not_a_member";
pub const HOOK_TIMEOUT: &str = "hook_timeout";
pub const UNAVAILABLE: &str = "unavailable";
pub const INTERNAL: &str = "internal";
}
pub fn http_status_for(code: &str) -> Option<u16> {
Some(match code {
codes::BAD_REQUEST | codes::UNSUPPORTED_PROTOCOL | codes::INVALID_TOKEN => 400,
codes::UNAUTHORIZED | codes::TOKEN_EXPIRED | codes::INVALID_CREDENTIALS | codes::REFRESH_TOKEN_REUSED | codes::STEAM_AUTH_FAILED => 401,
codes::FORBIDDEN | codes::QUOTA_EXCEEDED | codes::EMAIL_NOT_VERIFIED | codes::BANNED | codes::NOT_A_MEMBER | codes::REAUTHENTICATION_REQUIRED => 403,
codes::NOT_FOUND => 404,
codes::METHOD_NOT_ALLOWED => 405,
codes::UNSUPPORTED_MEDIA_TYPE => 415,
codes::CONFLICT | codes::VERSION_CONFLICT | codes::EMAIL_TAKEN | codes::ROOM_FULL => 409,
codes::PAYLOAD_TOO_LARGE => 413,
codes::VALIDATION_FAILED => 422,
codes::RATE_LIMITED => 429,
codes::INTERNAL => 500,
codes::HOOK_TIMEOUT | codes::UNAVAILABLE => 503,
_ => return None,
})
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ApiError {
pub code: String,
#[serde(default)]
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
impl ApiError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self { code: code.into(), message: message.into(), details: None }
}
pub fn with_details(mut self, details: Value) -> Self {
self.details = Some(details);
self
}
pub fn validation(details: ValidationDetails) -> Self {
let details = serde_json::to_value(&details).unwrap_or(Value::Null);
Self::new(codes::VALIDATION_FAILED, "the request is invalid").with_details(details)
}
pub fn is(&self, code: &str) -> bool {
self.code == code
}
pub fn http_status(&self) -> u16 {
http_status_for(&self.code).unwrap_or(400)
}
pub fn details_as<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
self.details.as_ref().and_then(|details| T::deserialize(details).ok())
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.message.is_empty() {
f.write_str(&self.code)
} else {
write!(f, "{}: {}", self.code, self.message)
}
}
}
impl std::error::Error for ApiError {}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ErrorBody {
pub error: ApiError,
}
impl ErrorBody {
pub fn new(error: ApiError) -> Self {
Self { error }
}
}
impl From<ApiError> for ErrorBody {
fn from(error: ApiError) -> Self {
Self { error }
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ValidationDetails {
#[serde(default)]
pub fields: BTreeMap<String, Vec<String>>,
}
impl ValidationDetails {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
self.fields.entry(field.into()).or_default().push(message.into());
}
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
pub fn into_result(self) -> Result<(), ApiError> {
if self.is_empty() {
Ok(())
} else {
Err(ApiError::validation(self))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_code_has_a_status() {
let all = [
codes::BAD_REQUEST,
codes::VALIDATION_FAILED,
codes::UNAUTHORIZED,
codes::TOKEN_EXPIRED,
codes::FORBIDDEN,
codes::NOT_FOUND,
codes::METHOD_NOT_ALLOWED,
codes::UNSUPPORTED_MEDIA_TYPE,
codes::CONFLICT,
codes::VERSION_CONFLICT,
codes::PAYLOAD_TOO_LARGE,
codes::RATE_LIMITED,
codes::QUOTA_EXCEEDED,
codes::UNSUPPORTED_PROTOCOL,
codes::INVALID_CREDENTIALS,
codes::REFRESH_TOKEN_REUSED,
codes::EMAIL_TAKEN,
codes::EMAIL_NOT_VERIFIED,
codes::INVALID_TOKEN,
codes::BANNED,
codes::REAUTHENTICATION_REQUIRED,
codes::STEAM_AUTH_FAILED,
codes::ROOM_FULL,
codes::NOT_A_MEMBER,
codes::HOOK_TIMEOUT,
codes::UNAVAILABLE,
codes::INTERNAL,
];
for code in all {
assert!(http_status_for(code).is_some(), "{code}");
assert!(code.bytes().all(|b| b.is_ascii_lowercase() || b == b'_'), "{code}");
}
assert_eq!(http_status_for(codes::UNKNOWN_TYPE), None);
assert_eq!(ApiError::new("game_specific", "x").http_status(), 400);
}
#[test]
fn validation_details() {
let mut details = ValidationDetails::new();
assert!(details.clone().into_result().is_ok());
details.add("password", "is too short");
let error = details.clone().into_result().err().unwrap_or_else(|| ApiError::new("x", "x"));
assert!(error.is(codes::VALIDATION_FAILED));
assert_eq!(error.details_as::<ValidationDetails>(), Some(details));
assert_eq!(error.to_string(), "validation_failed: the request is invalid");
assert_eq!(ApiError::new("x", "").to_string(), "x");
}
}