use alloc::{
borrow::{Cow, ToOwned},
string::String
};
use core::{
error::Error as CoreError,
fmt::{self, Display},
hash::{Hash, Hasher},
str::FromStr
};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[cfg(feature = "openapi")]
use utoipa::{
PartialSchema, ToSchema,
openapi::schema::{ObjectBuilder, Type}
};
use crate::kind::AppErrorKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParseAppCodeError;
impl Display for ParseAppCodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("invalid app code")
}
}
impl CoreError for ParseAppCodeError {}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct AppCode {
repr: Cow<'static, str>
}
#[allow(non_upper_case_globals)]
impl AppCode {
pub const NotFound: Self = Self::from_static("NOT_FOUND");
pub const Validation: Self = Self::from_static("VALIDATION");
pub const Conflict: Self = Self::from_static("CONFLICT");
pub const UserAlreadyExists: Self = Self::from_static("USER_ALREADY_EXISTS");
pub const Unauthorized: Self = Self::from_static("UNAUTHORIZED");
pub const Forbidden: Self = Self::from_static("FORBIDDEN");
pub const NotImplemented: Self = Self::from_static("NOT_IMPLEMENTED");
pub const BadRequest: Self = Self::from_static("BAD_REQUEST");
pub const RateLimited: Self = Self::from_static("RATE_LIMITED");
pub const TelegramAuth: Self = Self::from_static("TELEGRAM_AUTH");
pub const InvalidJwt: Self = Self::from_static("INVALID_JWT");
pub const Internal: Self = Self::from_static("INTERNAL");
pub const Database: Self = Self::from_static("DATABASE");
pub const Service: Self = Self::from_static("SERVICE");
pub const Config: Self = Self::from_static("CONFIG");
pub const Turnkey: Self = Self::from_static("TURNKEY");
pub const Timeout: Self = Self::from_static("TIMEOUT");
pub const Network: Self = Self::from_static("NETWORK");
pub const DependencyUnavailable: Self = Self::from_static("DEPENDENCY_UNAVAILABLE");
pub const Serialization: Self = Self::from_static("SERIALIZATION");
pub const Deserialization: Self = Self::from_static("DESERIALIZATION");
pub const ExternalApi: Self = Self::from_static("EXTERNAL_API");
pub const Queue: Self = Self::from_static("QUEUE");
pub const Cache: Self = Self::from_static("CACHE");
const fn from_static(code: &'static str) -> Self {
Self {
repr: Cow::Borrowed(code)
}
}
fn from_owned(code: String) -> Self {
Self {
repr: Cow::Owned(code)
}
}
#[must_use]
pub const fn new(code: &'static str) -> Self {
if !is_valid_literal(code) {
panic!("AppCode literals must be SCREAMING_SNAKE_CASE");
}
Self::from_static(code)
}
pub fn try_new(code: impl Into<String>) -> Result<Self, ParseAppCodeError> {
let code = code.into();
validate_code(&code)?;
Ok(Self::from_owned(code))
}
#[must_use]
pub fn as_str(&self) -> &str {
self.repr.as_ref()
}
}
impl PartialEq for AppCode {
fn eq(&self, other: &Self) -> bool {
self.as_str() == other.as_str()
}
}
impl Eq for AppCode {}
impl Hash for AppCode {
fn hash<H: Hasher>(&self, state: &mut H) {
self.as_str().hash(state);
}
}
impl Display for AppCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for AppCode {
type Err = ParseAppCodeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some(code) = match_static(s) {
return Ok(code);
}
Self::try_new(s.to_owned())
}
}
impl From<AppErrorKind> for AppCode {
fn from(kind: AppErrorKind) -> Self {
match kind {
AppErrorKind::NotFound => Self::NotFound,
AppErrorKind::Validation => Self::Validation,
AppErrorKind::Conflict => Self::Conflict,
AppErrorKind::Unauthorized => Self::Unauthorized,
AppErrorKind::Forbidden => Self::Forbidden,
AppErrorKind::NotImplemented => Self::NotImplemented,
AppErrorKind::BadRequest => Self::BadRequest,
AppErrorKind::RateLimited => Self::RateLimited,
AppErrorKind::TelegramAuth => Self::TelegramAuth,
AppErrorKind::InvalidJwt => Self::InvalidJwt,
AppErrorKind::Internal => Self::Internal,
AppErrorKind::Database => Self::Database,
AppErrorKind::Service => Self::Service,
AppErrorKind::Config => Self::Config,
AppErrorKind::Turnkey => Self::Turnkey,
AppErrorKind::Timeout => Self::Timeout,
AppErrorKind::Network => Self::Network,
AppErrorKind::DependencyUnavailable => Self::DependencyUnavailable,
AppErrorKind::Serialization => Self::Serialization,
AppErrorKind::Deserialization => Self::Deserialization,
AppErrorKind::ExternalApi => Self::ExternalApi,
AppErrorKind::Queue => Self::Queue,
AppErrorKind::Cache => Self::Cache
}
}
}
impl Serialize for AppCode {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for AppCode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = AppCode;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a SCREAMING_SNAKE_CASE code")
}
fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E>
where
E: serde::de::Error
{
AppCode::from_str(value).map_err(E::custom)
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error
{
AppCode::from_str(value).map_err(E::custom)
}
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
where
E: serde::de::Error
{
AppCode::try_new(value).map_err(E::custom)
}
}
deserializer.deserialize_str(Visitor)
}
}
#[cfg(feature = "openapi")]
impl PartialSchema for AppCode {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
ObjectBuilder::new()
.schema_type(Type::String)
.description(Some(
"Stable machine-readable error code in SCREAMING_SNAKE_CASE.".to_owned()
))
.pattern(Some("^[A-Z0-9_]+$".to_owned()))
.build()
.into()
}
}
#[cfg(feature = "openapi")]
impl ToSchema for AppCode {}
fn validate_code(value: &str) -> Result<(), ParseAppCodeError> {
if !is_valid_literal(value) {
return Err(ParseAppCodeError);
}
Ok(())
}
fn match_static(value: &str) -> Option<AppCode> {
match value {
"NOT_FOUND" => Some(AppCode::NotFound),
"VALIDATION" => Some(AppCode::Validation),
"CONFLICT" => Some(AppCode::Conflict),
"USER_ALREADY_EXISTS" => Some(AppCode::UserAlreadyExists),
"UNAUTHORIZED" => Some(AppCode::Unauthorized),
"FORBIDDEN" => Some(AppCode::Forbidden),
"NOT_IMPLEMENTED" => Some(AppCode::NotImplemented),
"BAD_REQUEST" => Some(AppCode::BadRequest),
"RATE_LIMITED" => Some(AppCode::RateLimited),
"TELEGRAM_AUTH" => Some(AppCode::TelegramAuth),
"INVALID_JWT" => Some(AppCode::InvalidJwt),
"INTERNAL" => Some(AppCode::Internal),
"DATABASE" => Some(AppCode::Database),
"SERVICE" => Some(AppCode::Service),
"CONFIG" => Some(AppCode::Config),
"TURNKEY" => Some(AppCode::Turnkey),
"TIMEOUT" => Some(AppCode::Timeout),
"NETWORK" => Some(AppCode::Network),
"DEPENDENCY_UNAVAILABLE" => Some(AppCode::DependencyUnavailable),
"SERIALIZATION" => Some(AppCode::Serialization),
"DESERIALIZATION" => Some(AppCode::Deserialization),
"EXTERNAL_API" => Some(AppCode::ExternalApi),
"QUEUE" => Some(AppCode::Queue),
"CACHE" => Some(AppCode::Cache),
_ => None
}
}
const fn is_valid_literal(value: &str) -> bool {
let bytes = value.as_bytes();
let len = bytes.len();
if len == 0 {
return false;
}
if bytes[0] == b'_' || bytes[len - 1] == b'_' {
return false;
}
let mut index = 0;
while index < len {
let byte = bytes[index];
if !matches!(byte, b'A'..=b'Z' | b'0'..=b'9' | b'_') {
return false;
}
if byte == b'_' && index + 1 < len && bytes[index + 1] == b'_' {
return false;
}
index += 1;
}
true
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::{AppCode, AppErrorKind, ParseAppCodeError};
#[test]
fn as_str_matches_json_serde_names() {
assert_eq!(AppCode::NotFound.as_str(), "NOT_FOUND");
assert_eq!(AppCode::RateLimited.as_str(), "RATE_LIMITED");
assert_eq!(
AppCode::DependencyUnavailable.as_str(),
"DEPENDENCY_UNAVAILABLE"
);
}
#[test]
fn mapping_from_kind_is_stable() {
assert_eq!(AppCode::from(AppErrorKind::NotFound), AppCode::NotFound);
assert_eq!(AppCode::from(AppErrorKind::Validation), AppCode::Validation);
assert_eq!(AppCode::from(AppErrorKind::Internal), AppCode::Internal);
assert_eq!(AppCode::from(AppErrorKind::Timeout), AppCode::Timeout);
}
#[test]
fn display_uses_screaming_snake_case() {
assert_eq!(AppCode::BadRequest.to_string(), "BAD_REQUEST");
}
#[test]
fn new_and_try_new_validate_input() {
let code = AppCode::new("CUSTOM_CODE");
assert_eq!(code.as_str(), "CUSTOM_CODE");
assert!(AppCode::try_new(String::from("ANOTHER_CODE")).is_ok());
assert!(AppCode::try_new(String::from("lower")).is_err());
}
#[test]
#[should_panic]
fn new_panics_on_invalid_literal() {
let _ = AppCode::new("not_snake");
}
#[test]
fn from_str_parses_known_codes() {
for code in [
AppCode::NotFound,
AppCode::Validation,
AppCode::Unauthorized,
AppCode::Internal,
AppCode::Timeout
] {
let parsed = AppCode::from_str(code.as_str()).expect("parse");
assert_eq!(parsed, code);
}
}
#[test]
fn from_str_allows_dynamic_codes() {
let parsed = AppCode::from_str("THIRD_PARTY_FAILURE").expect("parse");
assert_eq!(parsed.as_str(), "THIRD_PARTY_FAILURE");
}
#[test]
fn from_str_rejects_unknown_code_shape() {
let err = AppCode::from_str("NOT-A-REAL-CODE").unwrap_err();
assert_eq!(err, ParseAppCodeError);
}
}