use std::fmt;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::FnError;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Kind {
Call,
Query,
Mutation,
Action,
Job,
Stream,
}
impl Kind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Call => "call",
Self::Query => "query",
Self::Mutation => "mutation",
Self::Action => "action",
Self::Job => "job",
Self::Stream => "stream",
}
}
}
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
pub trait Operation: Serialize + DeserializeOwned + Clone + Send + Sync + 'static {
const NAME: &'static str;
const KIND: Kind;
type Output: Serialize + DeserializeOwned + Send + Sync + 'static;
#[cfg(feature = "server")]
type Context: crate::server::OperationContext;
#[cfg(feature = "server")]
const RETRIES: u32 = 5;
#[cfg(feature = "server")]
fn run(self, cx: &Self::Context) -> impl Future<Output = Result<Self::Output>> + Send;
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
Invalid(String),
Unauthorized(String),
Forbidden(String),
NotFound(String),
Conflict(String),
Contended,
Server(String),
Retry(String),
Permanent(String),
}
impl Error {
pub fn invalid(message: impl Into<String>) -> Self {
Self::Invalid(message.into())
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::Unauthorized(message.into())
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::Forbidden(message.into())
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::NotFound(message.into())
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::Conflict(message.into())
}
pub fn server(message: impl Into<String>) -> Self {
Self::Server(message.into())
}
pub fn retry(message: impl Into<String>) -> Self {
Self::Retry(message.into())
}
pub fn permanent(message: impl Into<String>) -> Self {
Self::Permanent(message.into())
}
#[must_use]
pub fn is_permanent(&self) -> bool {
matches!(self, Self::Permanent(_))
}
#[must_use]
pub fn status(&self) -> u16 {
match self {
Self::Invalid(_) => 400,
Self::Unauthorized(_) => 401,
Self::Forbidden(_) => 403,
Self::NotFound(_) => 404,
Self::Conflict(_) | Self::Contended => 409,
Self::Server(_) | Self::Permanent(_) => 500,
Self::Retry(_) => 503,
}
}
#[must_use]
pub fn message(&self) -> &str {
match self {
Self::Invalid(message)
| Self::Unauthorized(message)
| Self::Forbidden(message)
| Self::NotFound(message)
| Self::Conflict(message)
| Self::Server(message)
| Self::Retry(message)
| Self::Permanent(message) => message,
Self::Contended => "the server was too busy to finish this; try again",
}
}
#[must_use]
pub fn is_contended(&self) -> bool {
matches!(self, Self::Contended)
}
#[must_use]
pub fn to_wire(&self) -> String {
let kind = match self {
Self::Invalid(_) => "Args",
_ => "ServerError",
};
format!("{kind}|{}", self.message())
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.message())
}
}
impl std::error::Error for Error {}
impl From<Error> for FnError {
fn from(error: Error) -> Self {
Self::from_wire(&error.to_wire())
}
}
#[cfg(feature = "db")]
impl From<leptoskit_db::DbError> for Error {
fn from(error: leptoskit_db::DbError) -> Self {
use leptoskit_db::DbError;
if error.is_contended() {
return Self::Contended;
}
match error {
DbError::NotFound { .. } => Self::NotFound(error.to_string()),
DbError::Forbidden { message, .. } => Self::Forbidden(message),
DbError::Invalid(_) | DbError::Check { .. } | DbError::ForeignKey { .. } => {
Self::Invalid(error.to_string())
}
DbError::Conflict { .. } | DbError::Stale { .. } => Self::Conflict(error.to_string()),
_ => Self::Server("something went wrong; try again".to_owned()),
}
}
}
#[diagnostic::on_unimplemented(
message = "`{Self}` can't be an operation's argument",
label = "arguments must implement `Serialize`, `Deserialize`, and `JsonSchema`",
note = "an operation's arguments travel as JSON and are described in the RPC spec; derive \
`serde::Serialize`, `serde::Deserialize`, and `schemars::JsonSchema` for it"
)]
#[doc(hidden)]
pub trait Argument: Serialize + DeserializeOwned + schemars::JsonSchema {}
impl<T: Serialize + DeserializeOwned + schemars::JsonSchema> Argument for T {}
#[diagnostic::on_unimplemented(
message = "`{Self}` can't be an operation's result",
label = "results must implement `Serialize` and `Deserialize`",
note = "an operation's result travels back as JSON; derive `serde::Serialize` and \
`serde::Deserialize` for it"
)]
#[doc(hidden)]
pub trait Output: Serialize + DeserializeOwned + Send + Sync + 'static {}
impl<T: Serialize + DeserializeOwned + Send + Sync + 'static> Output for T {}
#[doc(hidden)]
pub const fn assert_argument<T: Argument>() {}
#[doc(hidden)]
pub const fn assert_output<T: Output>() {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn errors_reach_callers_classified_like_server_functions() {
let cases = [
(Error::invalid("too long"), 400, "Args|too long"),
(Error::unauthorized("sign in"), 401, "ServerError|sign in"),
(Error::forbidden("not yours"), 403, "ServerError|not yours"),
(
Error::not_found("no such post"),
404,
"ServerError|no such post",
),
(Error::conflict("taken"), 409, "ServerError|taken"),
(Error::server("down"), 500, "ServerError|down"),
];
for (error, status, wire) in cases {
assert_eq!(error.status(), status);
assert_eq!(error.to_wire(), wire);
assert_eq!(error.to_string(), error.message());
assert!(!error.is_contended());
}
assert_eq!(Error::Contended.status(), 409);
assert!(Error::Contended.is_contended());
assert!(Error::Contended.to_wire().starts_with("ServerError|"));
assert_eq!(
FnError::from(Error::invalid("bad")),
FnError::Invalid("bad".to_owned())
);
assert_eq!(
FnError::from(Error::not_found("gone")),
FnError::Server("gone".to_owned())
);
}
#[test]
fn kinds_have_their_spec_names() {
assert_eq!(Kind::Call.to_string(), "call");
assert_eq!(Kind::Query.as_str(), "query");
assert_eq!(Kind::Mutation.as_str(), "mutation");
assert_eq!(Kind::Action.as_str(), "action");
assert_eq!(Kind::Job.as_str(), "job");
assert_eq!(Kind::Stream.as_str(), "stream");
}
#[test]
fn jobs_tell_retrying_from_giving_up() {
let retry = Error::retry("the mail server is down");
assert_eq!(retry.status(), 503);
assert_eq!(retry.message(), "the mail server is down");
assert!(!retry.is_permanent());
let permanent = Error::permanent("declined");
assert_eq!(permanent.status(), 500);
assert_eq!(permanent.to_wire(), "ServerError|declined");
assert!(permanent.is_permanent());
assert!(!Error::server("down").is_permanent());
}
#[cfg(feature = "db")]
#[test]
fn database_errors_say_nothing_of_the_database() {
use leptoskit_db::DbError;
assert!(matches!(
Error::from(DbError::NotFound {
table: "posts".to_owned()
}),
Error::NotFound(_)
));
assert_eq!(
Error::from(DbError::Forbidden {
table: "posts".to_owned(),
message: "Only the author can change a post".to_owned(),
}),
Error::forbidden("Only the author can change a post")
);
assert!(matches!(
Error::from(DbError::Stale {
table: "posts".to_owned()
}),
Error::Conflict(_)
));
assert_eq!(
Error::from(DbError::Query("relation \"posts\" has a secret".into())),
Error::server("something went wrong; try again")
);
}
}