Set up the user interface state model and set up the admin user onboarding #283

Merged
savanni merged 12 commits from visions-admin into main 2024-12-18 14:18:16 +00:00
7 changed files with 259 additions and 180 deletions
Showing only changes of commit d7e4293da0 - Show all commits

1
Cargo.lock generated
View File

@ -4294,6 +4294,7 @@ dependencies = [
"lazy_static", "lazy_static",
"mime", "mime",
"mime_guess", "mime_guess",
"result-extended",
"rusqlite", "rusqlite",
"rusqlite_migration", "rusqlite_migration",
"serde 1.0.210", "serde 1.0.210",

View File

@ -33,9 +33,9 @@ use std::{error::Error, fmt};
/// statement. /// statement.
pub trait FatalError: Error {} pub trait FatalError: Error {}
/// Result<A, FE, E> represents a return value that might be a success, might be a fatal error, or /// ResultExt<A, FE, E> represents a return value that might be a success, might be a fatal error, or
/// might be a normal handleable error. /// might be a normal handleable error.
pub enum Result<A, E, FE> { pub enum ResultExt<A, E, FE> {
/// The operation was successful /// The operation was successful
Ok(A), Ok(A),
/// Ordinary errors. These should be handled and the application should recover gracefully. /// Ordinary errors. These should be handled and the application should recover gracefully.
@ -45,72 +45,72 @@ pub enum Result<A, E, FE> {
Fatal(FE), Fatal(FE),
} }
impl<A, E, FE> Result<A, E, FE> { impl<A, E, FE> ResultExt<A, E, FE> {
/// Apply an infallible function to a successful value. /// Apply an infallible function to a successful value.
pub fn map<B, O>(self, mapper: O) -> Result<B, E, FE> pub fn map<B, O>(self, mapper: O) -> ResultExt<B, E, FE>
where where
O: FnOnce(A) -> B, O: FnOnce(A) -> B,
{ {
match self { match self {
Result::Ok(val) => Result::Ok(mapper(val)), ResultExt::Ok(val) => ResultExt::Ok(mapper(val)),
Result::Err(err) => Result::Err(err), ResultExt::Err(err) => ResultExt::Err(err),
Result::Fatal(err) => Result::Fatal(err), ResultExt::Fatal(err) => ResultExt::Fatal(err),
} }
} }
/// Apply a potentially fallible function to a successful value. /// Apply a potentially fallible function to a successful value.
/// ///
/// Like `Result.and_then`, the mapping function can itself fail. /// Like `Result.and_then`, the mapping function can itself fail.
pub fn and_then<B, O>(self, handler: O) -> Result<B, E, FE> pub fn and_then<B, O>(self, handler: O) -> ResultExt<B, E, FE>
where where
O: FnOnce(A) -> Result<B, E, FE>, O: FnOnce(A) -> ResultExt<B, E, FE>,
{ {
match self { match self {
Result::Ok(val) => handler(val), ResultExt::Ok(val) => handler(val),
Result::Err(err) => Result::Err(err), ResultExt::Err(err) => ResultExt::Err(err),
Result::Fatal(err) => Result::Fatal(err), ResultExt::Fatal(err) => ResultExt::Fatal(err),
} }
} }
/// Map a normal error from one type to another. This is useful for converting an error from /// Map a normal error from one type to another. This is useful for converting an error from
/// one type to another, especially in re-throwing an underlying error. `?` syntax does not /// one type to another, especially in re-throwing an underlying error. `?` syntax does not
/// work with `Result`, so you will likely need to use this a lot. /// work with `Result`, so you will likely need to use this a lot.
pub fn map_err<F, O>(self, mapper: O) -> Result<A, F, FE> pub fn map_err<F, O>(self, mapper: O) -> ResultExt<A, F, FE>
where where
O: FnOnce(E) -> F, O: FnOnce(E) -> F,
{ {
match self { match self {
Result::Ok(val) => Result::Ok(val), ResultExt::Ok(val) => ResultExt::Ok(val),
Result::Err(err) => Result::Err(mapper(err)), ResultExt::Err(err) => ResultExt::Err(mapper(err)),
Result::Fatal(err) => Result::Fatal(err), ResultExt::Fatal(err) => ResultExt::Fatal(err),
} }
} }
/// Provide a function to use to recover from (or simply re-throw) an error. /// Provide a function to use to recover from (or simply re-throw) an error.
pub fn or_else<O, F>(self, handler: O) -> Result<A, F, FE> pub fn or_else<O, F>(self, handler: O) -> ResultExt<A, F, FE>
where where
O: FnOnce(E) -> Result<A, F, FE>, O: FnOnce(E) -> ResultExt<A, F, FE>,
{ {
match self { match self {
Result::Ok(val) => Result::Ok(val), ResultExt::Ok(val) => ResultExt::Ok(val),
Result::Err(err) => handler(err), ResultExt::Err(err) => handler(err),
Result::Fatal(err) => Result::Fatal(err), ResultExt::Fatal(err) => ResultExt::Fatal(err),
} }
} }
} }
/// Convert from a normal `Result` type to a `Result` type. The error condition for a `Result` will /// Convert from a normal `Result` type to a `Result` type. The error condition for a `Result` will
/// be treated as `Result::Err`, never `Result::Fatal`. /// be treated as `Result::Err`, never `Result::Fatal`.
impl<A, E, FE> From<std::result::Result<A, E>> for Result<A, E, FE> { impl<A, E, FE> From<std::result::Result<A, E>> for ResultExt<A, E, FE> {
fn from(r: std::result::Result<A, E>) -> Self { fn from(r: std::result::Result<A, E>) -> Self {
match r { match r {
Ok(val) => Result::Ok(val), Ok(val) => ResultExt::Ok(val),
Err(err) => Result::Err(err), Err(err) => ResultExt::Err(err),
} }
} }
} }
impl<A, E, FE> fmt::Debug for Result<A, E, FE> impl<A, E, FE> fmt::Debug for ResultExt<A, E, FE>
where where
A: fmt::Debug, A: fmt::Debug,
FE: fmt::Debug, FE: fmt::Debug,
@ -118,14 +118,14 @@ where
{ {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Result::Ok(val) => f.write_fmt(format_args!("Result::Ok {:?}", val)), ResultExt::Ok(val) => f.write_fmt(format_args!("Result::Ok {:?}", val)),
Result::Err(err) => f.write_fmt(format_args!("Result::Err {:?}", err)), ResultExt::Err(err) => f.write_fmt(format_args!("Result::Err {:?}", err)),
Result::Fatal(err) => f.write_fmt(format_args!("Result::Fatal {:?}", err)), ResultExt::Fatal(err) => f.write_fmt(format_args!("Result::Fatal {:?}", err)),
} }
} }
} }
impl<A, E, FE> PartialEq for Result<A, E, FE> impl<A, E, FE> PartialEq for ResultExt<A, E, FE>
where where
A: PartialEq, A: PartialEq,
FE: PartialEq, FE: PartialEq,
@ -133,27 +133,27 @@ where
{ {
fn eq(&self, rhs: &Self) -> bool { fn eq(&self, rhs: &Self) -> bool {
match (self, rhs) { match (self, rhs) {
(Result::Ok(val), Result::Ok(rhs)) => val == rhs, (ResultExt::Ok(val), ResultExt::Ok(rhs)) => val == rhs,
(Result::Err(_), Result::Err(_)) => true, (ResultExt::Err(_), ResultExt::Err(_)) => true,
(Result::Fatal(_), Result::Fatal(_)) => true, (ResultExt::Fatal(_), ResultExt::Fatal(_)) => true,
_ => false, _ => false,
} }
} }
} }
/// Convenience function to create an ok value. /// Convenience function to create an ok value.
pub fn ok<A, E: Error, FE: FatalError>(val: A) -> Result<A, E, FE> { pub fn ok<A, E: Error, FE: FatalError>(val: A) -> ResultExt<A, E, FE> {
Result::Ok(val) ResultExt::Ok(val)
} }
/// Convenience function to create an error value. /// Convenience function to create an error value.
pub fn error<A, E: Error, FE: FatalError>(err: E) -> Result<A, E, FE> { pub fn error<A, E: Error, FE: FatalError>(err: E) -> ResultExt<A, E, FE> {
Result::Err(err) ResultExt::Err(err)
} }
/// Convenience function to create a fatal value. /// Convenience function to create a fatal value.
pub fn fatal<A, E: Error, FE: FatalError>(err: FE) -> Result<A, E, FE> { pub fn fatal<A, E: Error, FE: FatalError>(err: FE) -> ResultExt<A, E, FE> {
Result::Fatal(err) ResultExt::Fatal(err)
} }
/// Return early from the current function if the value is a fatal error. /// Return early from the current function if the value is a fatal error.
@ -161,9 +161,9 @@ pub fn fatal<A, E: Error, FE: FatalError>(err: FE) -> Result<A, E, FE> {
macro_rules! return_fatal { macro_rules! return_fatal {
($x:expr) => { ($x:expr) => {
match $x { match $x {
Result::Fatal(err) => return Result::Fatal(err), ResultExt::Fatal(err) => return ResultExt::Fatal(err),
Result::Err(err) => Err(err), ResultExt::Err(err) => Err(err),
Result::Ok(val) => Ok(val), ResultExt::Ok(val) => Ok(val),
} }
}; };
} }
@ -173,9 +173,9 @@ macro_rules! return_fatal {
macro_rules! return_error { macro_rules! return_error {
($x:expr) => { ($x:expr) => {
match $x { match $x {
Result::Ok(val) => val, ResultExt::Ok(val) => val,
Result::Err(err) => return Result::Err(err), ResultExt::Err(err) => return ResultExt::Err(err),
Result::Fatal(err) => return Result::Fatal(err), ResultExt::Fatal(err) => return ResultExt::Fatal(err),
} }
}; };
} }
@ -210,19 +210,19 @@ mod test {
#[test] #[test]
fn it_can_map_things() { fn it_can_map_things() {
let success: Result<i32, Error, FatalError> = ok(15); let success: ResultExt<i32, Error, FatalError> = ok(15);
assert_eq!(ok(16), success.map(|v| v + 1)); assert_eq!(ok(16), success.map(|v| v + 1));
} }
#[test] #[test]
fn it_can_chain_success() { fn it_can_chain_success() {
let success: Result<i32, Error, FatalError> = ok(15); let success: ResultExt<i32, Error, FatalError> = ok(15);
assert_eq!(ok(16), success.and_then(|v| ok(v + 1))); assert_eq!(ok(16), success.and_then(|v| ok(v + 1)));
} }
#[test] #[test]
fn it_can_handle_an_error() { fn it_can_handle_an_error() {
let failure: Result<i32, Error, FatalError> = error(Error::Error); let failure: ResultExt<i32, Error, FatalError> = error(Error::Error);
assert_eq!( assert_eq!(
ok::<i32, Error, FatalError>(16), ok::<i32, Error, FatalError>(16),
failure.or_else(|_| ok(16)) failure.or_else(|_| ok(16))
@ -231,7 +231,7 @@ mod test {
#[test] #[test]
fn early_exit_on_fatal() { fn early_exit_on_fatal() {
fn ok_func() -> Result<i32, Error, FatalError> { fn ok_func() -> ResultExt<i32, Error, FatalError> {
let value = return_fatal!(ok::<i32, Error, FatalError>(15)); let value = return_fatal!(ok::<i32, Error, FatalError>(15));
match value { match value {
Ok(_) => ok(14), Ok(_) => ok(14),
@ -239,7 +239,7 @@ mod test {
} }
} }
fn err_func() -> Result<i32, Error, FatalError> { fn err_func() -> ResultExt<i32, Error, FatalError> {
let value = return_fatal!(error::<i32, Error, FatalError>(Error::Error)); let value = return_fatal!(error::<i32, Error, FatalError>(Error::Error));
match value { match value {
Ok(_) => panic!("shouldn't have gotten here"), Ok(_) => panic!("shouldn't have gotten here"),
@ -247,7 +247,7 @@ mod test {
} }
} }
fn fatal_func() -> Result<i32, Error, FatalError> { fn fatal_func() -> ResultExt<i32, Error, FatalError> {
let _ = return_fatal!(fatal::<i32, Error, FatalError>(FatalError::FatalError)); let _ = return_fatal!(fatal::<i32, Error, FatalError>(FatalError::FatalError));
panic!("failed to bail"); panic!("failed to bail");
} }
@ -259,18 +259,18 @@ mod test {
#[test] #[test]
fn it_can_early_exit_on_all_errors() { fn it_can_early_exit_on_all_errors() {
fn ok_func() -> Result<i32, Error, FatalError> { fn ok_func() -> ResultExt<i32, Error, FatalError> {
let value = return_error!(ok::<i32, Error, FatalError>(15)); let value = return_error!(ok::<i32, Error, FatalError>(15));
assert_eq!(value, 15); assert_eq!(value, 15);
ok(14) ok(14)
} }
fn err_func() -> Result<i32, Error, FatalError> { fn err_func() -> ResultExt<i32, Error, FatalError> {
return_error!(error::<i32, Error, FatalError>(Error::Error)); return_error!(error::<i32, Error, FatalError>(Error::Error));
panic!("failed to bail"); panic!("failed to bail");
} }
fn fatal_func() -> Result<i32, Error, FatalError> { fn fatal_func() -> ResultExt<i32, Error, FatalError> {
return_error!(fatal::<i32, Error, FatalError>(FatalError::FatalError)); return_error!(fatal::<i32, Error, FatalError>(FatalError::FatalError));
panic!("failed to bail"); panic!("failed to bail");
} }

View File

@ -6,26 +6,27 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
authdb = { path = "../../authdb/" } async-std = { version = "1.13.0" }
http = { version = "1" } async-trait = { version = "0.1.83" }
serde_json = { version = "*" } authdb = { path = "../../authdb/" }
serde = { version = "1" } futures = { version = "0.3.31" }
tokio = { version = "1", features = [ "full" ] } http = { version = "1" }
warp = { version = "0.3" } include_dir = { version = "0.7.4" }
mime_guess = "2.0.5" lazy_static = { version = "1.5.0" }
mime = "0.3.17" mime = { version = "0.3.17" }
uuid = { version = "1.11.0", features = ["v4"] } mime_guess = { version = "2.0.5" }
tokio-stream = "0.1.16" result-extended = { path = "../../result-extended" }
typeshare = "1.0.4" rusqlite = { version = "0.32.1" }
urlencoding = "2.1.3" rusqlite_migration = { version = "1.3.1", features = ["from-directory"] }
thiserror = "2.0.3" serde = { version = "1" }
rusqlite = "0.32.1" serde_json = { version = "*" }
rusqlite_migration = { version = "1.3.1", features = ["from-directory"] } thiserror = { version = "2.0.3" }
lazy_static = "1.5.0" tokio = { version = "1", features = [ "full" ] }
include_dir = "0.7.4" tokio-stream = { version = "0.1.16" }
async-trait = "0.1.83" typeshare = { version = "1.0.4" }
futures = "0.3.31" urlencoding = { version = "2.1.3" }
async-std = "1.13.0" uuid = { version = "1.11.0", features = ["v4"] }
warp = { version = "0.3" }
[dev-dependencies] [dev-dependencies]
cool_asserts = "2.0.3" cool_asserts = "2.0.3"

View File

@ -2,13 +2,14 @@ use std::{collections::HashMap, sync::Arc};
use async_std::sync::RwLock; use async_std::sync::RwLock;
use mime::Mime; use mime::Mime;
use result_extended::{fatal, ok, ResultExt};
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
asset_db::{self, AssetId, Assets}, asset_db::{self, AssetId, Assets},
database::{CharacterId, Database}, database::{CharacterId, Database, Error},
types::{AppError, Message, Tabletop, RGB}, types::{AppError, FatalError, Message, Tabletop, RGB},
}; };
const DEFAULT_BACKGROUND_COLOR: RGB = RGB { const DEFAULT_BACKGROUND_COLOR: RGB = RGB {
@ -84,17 +85,26 @@ impl Core {
self.0.read().await.tabletop.clone() self.0.read().await.tabletop.clone()
} }
pub async fn get_asset(&self, asset_id: AssetId) -> Result<(Mime, Vec<u8>), AppError> { pub async fn get_asset(
self.0 &self,
.read() asset_id: AssetId,
.await ) -> ResultExt<(Mime, Vec<u8>), AppError, FatalError> {
.asset_store ResultExt::from(
.get(asset_id.clone()) self.0
.map_err(|err| match err { .read()
asset_db::Error::NotFound => AppError::NotFound(format!("{}", asset_id)), .await
asset_db::Error::Inaccessible => AppError::Inaccessible(format!("{}", asset_id)), .asset_store
asset_db::Error::UnexpectedError(err) => AppError::Inaccessible(format!("{}", err)), .get(asset_id.clone())
}) .map_err(|err| match err {
asset_db::Error::NotFound => AppError::NotFound(format!("{}", asset_id)),
asset_db::Error::Inaccessible => {
AppError::Inaccessible(format!("{}", asset_id))
}
asset_db::Error::UnexpectedError(err) => {
AppError::Inaccessible(format!("{}", err))
}
}),
)
} }
pub async fn available_images(&self) -> Vec<AssetId> { pub async fn available_images(&self) -> Vec<AssetId> {
@ -112,29 +122,29 @@ impl Core {
.collect() .collect()
} }
pub async fn set_background_image(&self, asset: AssetId) -> Result<(), AppError> { pub async fn set_background_image(
&self,
asset: AssetId,
) -> ResultExt<(), AppError, FatalError> {
let tabletop = { let tabletop = {
let mut state = self.0.write().await; let mut state = self.0.write().await;
state.tabletop.background_image = Some(asset.clone()); state.tabletop.background_image = Some(asset.clone());
state.tabletop.clone() state.tabletop.clone()
}; };
self.publish(Message::UpdateTabletop(tabletop)).await; self.publish(Message::UpdateTabletop(tabletop)).await;
Ok(()) ok(())
} }
pub async fn get_charsheet( pub async fn get_charsheet(
&self, &self,
id: CharacterId, id: CharacterId,
) -> Result<Option<serde_json::Value>, AppError> { ) -> ResultExt<Option<serde_json::Value>, AppError, FatalError> {
Ok(self let mut state = self.0.write().await;
.0 let cr = state.db.character(id).await;
.write() cr.map(|cr| cr.map(|cr| cr.data)).or_else(|err| {
.await println!("Database error: {:?}", err);
.db ResultExt::Ok(None)
.charsheet(id) })
.await
.unwrap()
.map(|cr| cr.data))
} }
pub async fn publish(&self, message: Message) { pub async fn publish(&self, message: Message) {
@ -197,14 +207,14 @@ mod test {
#[tokio::test] #[tokio::test]
async fn it_lists_available_images() { async fn it_lists_available_images() {
let core = test_core(); let core = test_core();
let image_paths = core.available_images(); let image_paths = core.available_images().await;
assert_eq!(image_paths.len(), 2); assert_eq!(image_paths.len(), 2);
} }
#[tokio::test] #[tokio::test]
async fn it_retrieves_an_asset() { async fn it_retrieves_an_asset() {
let core = test_core(); let core = test_core();
assert_matches!(core.get_asset(AssetId::from("asset_1")).await, Ok((mime, data)) => { assert_matches!(core.get_asset(AssetId::from("asset_1")).await, ResultExt::Ok((mime, data)) => {
assert_eq!(mime.type_(), mime::IMAGE); assert_eq!(mime.type_(), mime::IMAGE);
assert_eq!(data, "abcdefg".as_bytes()); assert_eq!(data, "abcdefg".as_bytes());
}); });
@ -213,7 +223,7 @@ mod test {
#[tokio::test] #[tokio::test]
async fn it_can_retrieve_the_default_tabletop() { async fn it_can_retrieve_the_default_tabletop() {
let core = test_core(); let core = test_core();
assert_matches!(core.tabletop(), Tabletop{ background_color, background_image } => { assert_matches!(core.tabletop().await, Tabletop{ background_color, background_image } => {
assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR); assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR);
assert_eq!(background_image, None); assert_eq!(background_image, None);
}); });
@ -222,8 +232,8 @@ mod test {
#[tokio::test] #[tokio::test]
async fn it_can_change_the_tabletop_background() { async fn it_can_change_the_tabletop_background() {
let core = test_core(); let core = test_core();
assert_matches!(core.set_background_image(AssetId::from("asset_1")), Ok(())); assert_matches!(core.set_background_image(AssetId::from("asset_1")).await, ResultExt::Ok(()));
assert_matches!(core.tabletop(), Tabletop{ background_color, background_image } => { assert_matches!(core.tabletop().await, Tabletop{ background_color, background_image } => {
assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR); assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR);
assert_eq!(background_image, Some(AssetId::from("asset_1"))); assert_eq!(background_image, Some(AssetId::from("asset_1")));
}); });
@ -232,10 +242,13 @@ mod test {
#[tokio::test] #[tokio::test]
async fn it_sends_notices_to_clients_on_tabletop_change() { async fn it_sends_notices_to_clients_on_tabletop_change() {
let core = test_core(); let core = test_core();
let client_id = core.register_client(); let client_id = core.register_client().await;
let mut receiver = core.connect_client(client_id); let mut receiver = core.connect_client(client_id).await;
assert_matches!(core.set_background_image(AssetId::from("asset_1")), Ok(())); assert_matches!(
core.set_background_image(AssetId::from("asset_1")).await,
ResultExt::Ok(())
);
match receiver.recv().await { match receiver.recv().await {
Some(Message::UpdateTabletop(Tabletop { Some(Message::UpdateTabletop(Tabletop {
background_color, background_color,

View File

@ -1,18 +1,18 @@
use std::{ use std::path::Path;
path::{Path, PathBuf},
thread::JoinHandle,
};
use async_std::channel::{bounded, Receiver, Sender}; use async_std::channel::{bounded, Receiver, Sender};
use async_trait::async_trait; use async_trait::async_trait;
use include_dir::{include_dir, Dir}; use include_dir::{include_dir, Dir};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use result_extended::{error, fatal, ok, return_error, ResultExt};
use rusqlite::Connection; use rusqlite::Connection;
use rusqlite_migration::Migrations; use rusqlite_migration::Migrations;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use uuid::Uuid; use uuid::Uuid;
use crate::types::FatalError;
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
lazy_static! { lazy_static! {
@ -22,12 +22,6 @@ lazy_static! {
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
#[error("Duplicate item found for id {0}")]
DuplicateItem(String),
#[error("Unexpected response for message")]
MessageMismatch,
#[error("No response to request")] #[error("No response to request")]
NoResponse, NoResponse,
} }
@ -148,14 +142,17 @@ pub struct CharsheetRow {
#[async_trait] #[async_trait]
pub trait Database: Send + Sync { pub trait Database: Send + Sync {
async fn charsheet(&mut self, id: CharacterId) -> Result<Option<CharsheetRow>, Error>; async fn character(
&mut self,
id: CharacterId,
) -> result_extended::ResultExt<Option<CharsheetRow>, Error, FatalError>;
} }
pub struct DiskDb { pub struct DiskDb {
conn: Connection, conn: Connection,
} }
fn setup_test_database(conn: &Connection) { fn setup_test_database(conn: &Connection) -> Result<(), FatalError> {
let mut gamecount_stmt = conn.prepare("SELECT count(*) FROM games").unwrap(); let mut gamecount_stmt = conn.prepare("SELECT count(*) FROM games").unwrap();
let mut count = gamecount_stmt.query([]).unwrap(); let mut count = gamecount_stmt.query([]).unwrap();
if count.next().unwrap().unwrap().get::<usize, usize>(0) == Ok(0) { if count.next().unwrap().unwrap().get::<usize, usize>(0) == Ok(0) {
@ -164,27 +161,43 @@ fn setup_test_database(conn: &Connection) {
let game_id = format!("{}", Uuid::new_v4()); let game_id = format!("{}", Uuid::new_v4());
let char_id = CharacterId::new(); let char_id = CharacterId::new();
let mut user_stmt = conn.prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)").unwrap(); let mut user_stmt = conn
user_stmt.execute((admin_id.clone(), "admin", "abcdefg", true, true)).unwrap(); .prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)")
user_stmt.execute((user_id.clone(), "savanni", "abcdefg", false, true)).unwrap(); .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
user_stmt
.execute((admin_id.clone(), "admin", "abcdefg", true, true))
.unwrap();
user_stmt
.execute((user_id.clone(), "savanni", "abcdefg", false, true))
.unwrap();
let mut game_stmt = conn.prepare("INSERT INTO games VALUES (?, ?)").unwrap(); let mut game_stmt = conn
game_stmt.execute((game_id.clone(), "Circle of Bluest Sky")).unwrap(); .prepare("INSERT INTO games VALUES (?, ?)")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
game_stmt
.execute((game_id.clone(), "Circle of Bluest Sky"))
.unwrap();
let mut role_stmt = conn.prepare("INSERT INTO roles VALUES (?, ?, ?)").unwrap(); let mut role_stmt = conn
role_stmt.execute((user_id.clone(), game_id.clone(), "gm")).unwrap(); .prepare("INSERT INTO roles VALUES (?, ?, ?)")
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
role_stmt
.execute((user_id.clone(), game_id.clone(), "gm"))
.unwrap();
let mut sheet_stmt = conn let mut sheet_stmt = conn
.prepare("INSERT INTO characters VALUES (?, ?, ?)") .prepare("INSERT INTO characters VALUES (?, ?, ?)")
.unwrap(); .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
sheet_stmt.execute((char_id.as_str(), game_id, r#"{ "type_": "Candela", "name": "Soren Jensen", "pronouns": "he/him", "circle": "Circle of the Bluest Sky", "style": "dapper gentleman", "catalyst": "a cursed book", "question": "What were the contents of that book?", "nerve": { "type_": "nerve", "drives": { "current": 1, "max": 2 }, "resistances": { "current": 0, "max": 3 }, "move": { "gilded": false, "score": 2 }, "strike": { "gilded": false, "score": 1 }, "control": { "gilded": true, "score": 0 } }, "cunning": { "type_": "cunning", "drives": { "current": 1, "max": 1 }, "resistances": { "current": 0, "max": 3 }, "sway": { "gilded": false, "score": 0 }, "read": { "gilded": false, "score": 0 }, "hide": { "gilded": false, "score": 0 } }, "intuition": { "type_": "intuition", "drives": { "current": 0, "max": 0 }, "resistances": { "current": 0, "max": 3 }, "survey": { "gilded": false, "score": 0 }, "focus": { "gilded": false, "score": 0 }, "sense": { "gilded": false, "score": 0 } }, "role": "Slink", "role_abilities": [ "Scout: If you have time to observe a location, you can spend 1 Intuition to ask a question: What do I notice here that others do not see? What in this place might be of use to us? What path should we follow?" ], "specialty": "Detective", "specialty_abilities": [ "Mind Palace: When you want to figure out how two clues might relate or what path they should point you towards, burn 1 Intution resistance. The GM will give you the information you have deduced." ] }"#)) sheet_stmt.execute((char_id.as_str(), game_id, r#"{ "type_": "Candela", "name": "Soren Jensen", "pronouns": "he/him", "circle": "Circle of the Bluest Sky", "style": "dapper gentleman", "catalyst": "a cursed book", "question": "What were the contents of that book?", "nerve": { "type_": "nerve", "drives": { "current": 1, "max": 2 }, "resistances": { "current": 0, "max": 3 }, "move": { "gilded": false, "score": 2 }, "strike": { "gilded": false, "score": 1 }, "control": { "gilded": true, "score": 0 } }, "cunning": { "type_": "cunning", "drives": { "current": 1, "max": 1 }, "resistances": { "current": 0, "max": 3 }, "sway": { "gilded": false, "score": 0 }, "read": { "gilded": false, "score": 0 }, "hide": { "gilded": false, "score": 0 } }, "intuition": { "type_": "intuition", "drives": { "current": 0, "max": 0 }, "resistances": { "current": 0, "max": 3 }, "survey": { "gilded": false, "score": 0 }, "focus": { "gilded": false, "score": 0 }, "sense": { "gilded": false, "score": 0 } }, "role": "Slink", "role_abilities": [ "Scout: If you have time to observe a location, you can spend 1 Intuition to ask a question: What do I notice here that others do not see? What in this place might be of use to us? What path should we follow?" ], "specialty": "Detective", "specialty_abilities": [ "Mind Palace: When you want to figure out how two clues might relate or what path they should point you towards, burn 1 Intution resistance. The GM will give you the information you have deduced." ] }"#))
.unwrap(); .unwrap();
} }
Ok(())
} }
impl DiskDb { impl DiskDb {
pub fn new<P>(path: Option<P>) -> Result<Self, Error> pub fn new<P>(path: Option<P>) -> Result<Self, FatalError>
where where
P: AsRef<Path>, P: AsRef<Path>,
{ {
@ -192,38 +205,46 @@ impl DiskDb {
None => Connection::open(":memory:").expect("to create a memory connection"), None => Connection::open(":memory:").expect("to create a memory connection"),
Some(path) => Connection::open(path).expect("to create connection"), Some(path) => Connection::open(path).expect("to create connection"),
}; };
MIGRATIONS.to_latest(&mut conn).expect("to run migrations");
setup_test_database(&conn); MIGRATIONS
.to_latest(&mut conn)
.map_err(|err| FatalError::DatabaseMigrationFailure(format!("{}", err)))?;
setup_test_database(&conn)?;
Ok(DiskDb { conn }) Ok(DiskDb { conn })
} }
fn user(&self, id: UserId) -> Result<Option<UserRow>, Error> { fn user(&self, id: UserId) -> Result<Option<UserRow>, FatalError> {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT uuid, name, password, admin, enabled WHERE uuid=?") .prepare("SELECT uuid, name, password, admin, enabled WHERE uuid=?")
.unwrap(); .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
let items: Vec<UserRow> = stmt let items: Vec<UserRow> = stmt
.query_map([id.as_str()], |row| Ok(UserRow { .query_map([id.as_str()], |row| {
id: row.get(0).unwrap(), Ok(UserRow {
name: row.get(1).unwrap(), id: row.get(0).unwrap(),
password: row.get(2).unwrap(), name: row.get(1).unwrap(),
admin: row.get(3).unwrap(), password: row.get(2).unwrap(),
enabled: row.get(4).unwrap(), admin: row.get(3).unwrap(),
})).unwrap().collect::<Result<Vec<UserRow>, rusqlite::Error>>().unwrap(); enabled: row.get(4).unwrap(),
})
})
.unwrap()
.collect::<Result<Vec<UserRow>, rusqlite::Error>>()
.unwrap();
match &items[..] { match &items[..] {
[] => Ok(None), [] => Ok(None),
[item] => Ok(Some(item.clone())), [item] => Ok(Some(item.clone())),
_ => unimplemented!(), _ => Err(FatalError::NonUniqueDatabaseKey(id.as_str().to_owned())),
} }
} }
fn charsheet(&self, id: CharacterId) -> Result<Option<CharsheetRow>, Error> { fn character(&self, id: CharacterId) -> Result<Option<CharsheetRow>, FatalError> {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT uuid, game, data FROM charsheet WHERE uuid=?") .prepare("SELECT uuid, game, data FROM characters WHERE uuid=?")
.unwrap(); .map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
let items: Vec<CharsheetRow> = stmt let items: Vec<CharsheetRow> = stmt
.query_map([id.as_str()], |row| { .query_map([id.as_str()], |row| {
let data: String = row.get(2).unwrap(); let data: String = row.get(2).unwrap();
@ -239,24 +260,24 @@ impl DiskDb {
match &items[..] { match &items[..] {
[] => Ok(None), [] => Ok(None),
[item] => Ok(Some(item.clone())), [item] => Ok(Some(item.clone())),
_ => unimplemented!(), _ => Err(FatalError::NonUniqueDatabaseKey(id.as_str().to_owned())),
} }
} }
fn save_charsheet( fn save_character(
&self, &self,
char_id: Option<CharacterId>, char_id: Option<CharacterId>,
game_type: String, game_type: String,
charsheet: serde_json::Value, character: serde_json::Value,
) -> Result<CharacterId, Error> { ) -> std::result::Result<CharacterId, Error> {
match char_id { match char_id {
None => { None => {
let char_id = CharacterId::new(); let char_id = CharacterId::new();
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("INSERT INTO charsheet VALUES (?, ?, ?)") .prepare("INSERT INTO characters VALUES (?, ?, ?)")
.unwrap(); .unwrap();
stmt.execute((char_id.as_str(), game_type, charsheet.to_string())) stmt.execute((char_id.as_str(), game_type, character.to_string()))
.unwrap(); .unwrap();
Ok(char_id) Ok(char_id)
@ -264,9 +285,9 @@ impl DiskDb {
Some(char_id) => { Some(char_id) => {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("UPDATE charsheet SET data=? WHERE uuid=?") .prepare("UPDATE characters SET data=? WHERE uuid=?")
.unwrap(); .unwrap();
stmt.execute((charsheet.to_string(), char_id.as_str())) stmt.execute((character.to_string(), char_id.as_str()))
.unwrap(); .unwrap();
Ok(char_id) Ok(char_id)
@ -281,7 +302,7 @@ async fn db_handler(db: DiskDb, requestor: Receiver<DatabaseRequest>) {
println!("Request received: {:?}", req); println!("Request received: {:?}", req);
match req { match req {
Request::Charsheet(id) => { Request::Charsheet(id) => {
let sheet = db.charsheet(id); let sheet = db.character(id);
println!("sheet retrieved: {:?}", sheet); println!("sheet retrieved: {:?}", sheet);
match sheet { match sheet {
Ok(sheet) => { Ok(sheet) => {
@ -318,27 +339,34 @@ impl DbConn {
#[async_trait] #[async_trait]
impl Database for DbConn { impl Database for DbConn {
async fn charsheet(&mut self, id: CharacterId) -> Result<Option<CharsheetRow>, Error> { async fn character(
&mut self,
id: CharacterId,
) -> ResultExt<Option<CharsheetRow>, Error, FatalError> {
let (tx, rx) = bounded::<DatabaseResponse>(1); let (tx, rx) = bounded::<DatabaseResponse>(1);
let request = DatabaseRequest { let request = DatabaseRequest {
tx, tx,
req: Request::Charsheet(id), req: Request::Charsheet(id),
}; };
self.conn.send(request).await.unwrap();
match self.conn.send(request).await {
Ok(()) => (),
Err(_) => return fatal(FatalError::DatabaseConnectionLost),
};
match rx.recv().await { match rx.recv().await {
Ok(DatabaseResponse::Charsheet(row)) => Ok(row), Ok(DatabaseResponse::Charsheet(row)) => ok(row),
Ok(_) => Err(Error::MessageMismatch), // Ok(_) => fatal(FatalError::MessageMismatch),
Err(err) => { Err(_err) => error(Error::NoResponse),
println!("error: {:?}", err);
Err(Error::NoResponse)
}
} }
} }
} }
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use std::path::PathBuf;
use cool_asserts::assert_matches; use cool_asserts::assert_matches;
use super::*; use super::*;
@ -346,24 +374,24 @@ mod test {
const soren: &'static str = r#"{ "type_": "Candela", "name": "Soren Jensen", "pronouns": "he/him", "circle": "Circle of the Bluest Sky", "style": "dapper gentleman", "catalyst": "a cursed book", "question": "What were the contents of that book?", "nerve": { "type_": "nerve", "drives": { "current": 1, "max": 2 }, "resistances": { "current": 0, "max": 3 }, "move": { "gilded": false, "score": 2 }, "strike": { "gilded": false, "score": 1 }, "control": { "gilded": true, "score": 0 } }, "cunning": { "type_": "cunning", "drives": { "current": 1, "max": 1 }, "resistances": { "current": 0, "max": 3 }, "sway": { "gilded": false, "score": 0 }, "read": { "gilded": false, "score": 0 }, "hide": { "gilded": false, "score": 0 } }, "intuition": { "type_": "intuition", "drives": { "current": 0, "max": 0 }, "resistances": { "current": 0, "max": 3 }, "survey": { "gilded": false, "score": 0 }, "focus": { "gilded": false, "score": 0 }, "sense": { "gilded": false, "score": 0 } }, "role": "Slink", "role_abilities": [ "Scout: If you have time to observe a location, you can spend 1 Intuition to ask a question: What do I notice here that others do not see? What in this place might be of use to us? What path should we follow?" ], "specialty": "Detective", "specialty_abilities": [ "Mind Palace: When you want to figure out how two clues might relate or what path they should point you towards, burn 1 Intution resistance. The GM will give you the information you have deduced." ] }"#; const soren: &'static str = r#"{ "type_": "Candela", "name": "Soren Jensen", "pronouns": "he/him", "circle": "Circle of the Bluest Sky", "style": "dapper gentleman", "catalyst": "a cursed book", "question": "What were the contents of that book?", "nerve": { "type_": "nerve", "drives": { "current": 1, "max": 2 }, "resistances": { "current": 0, "max": 3 }, "move": { "gilded": false, "score": 2 }, "strike": { "gilded": false, "score": 1 }, "control": { "gilded": true, "score": 0 } }, "cunning": { "type_": "cunning", "drives": { "current": 1, "max": 1 }, "resistances": { "current": 0, "max": 3 }, "sway": { "gilded": false, "score": 0 }, "read": { "gilded": false, "score": 0 }, "hide": { "gilded": false, "score": 0 } }, "intuition": { "type_": "intuition", "drives": { "current": 0, "max": 0 }, "resistances": { "current": 0, "max": 3 }, "survey": { "gilded": false, "score": 0 }, "focus": { "gilded": false, "score": 0 }, "sense": { "gilded": false, "score": 0 } }, "role": "Slink", "role_abilities": [ "Scout: If you have time to observe a location, you can spend 1 Intuition to ask a question: What do I notice here that others do not see? What in this place might be of use to us? What path should we follow?" ], "specialty": "Detective", "specialty_abilities": [ "Mind Palace: When you want to figure out how two clues might relate or what path they should point you towards, burn 1 Intution resistance. The GM will give you the information you have deduced." ] }"#;
#[test] #[test]
fn it_can_retrieve_a_charsheet() { fn it_can_retrieve_a_character() {
let no_path: Option<PathBuf> = None; let no_path: Option<PathBuf> = None;
let db = DiskDb::new(no_path).unwrap(); let db = DiskDb::new(no_path).unwrap();
assert_matches!(db.charsheet(CharacterId::from("1")), Ok(None)); assert_matches!(db.character(CharacterId::from("1")), Ok(None));
let js: serde_json::Value = serde_json::from_str(soren).unwrap(); let js: serde_json::Value = serde_json::from_str(soren).unwrap();
let soren_id = db let soren_id = db
.save_charsheet(None, "candela".to_owned(), js.clone()) .save_character(None, "candela".to_owned(), js.clone())
.unwrap(); .unwrap();
assert_matches!(db.charsheet(soren_id).unwrap(), Some(CharsheetRow{ data, .. }) => assert_eq!(js, data)); assert_matches!(db.character(soren_id).unwrap(), Some(CharsheetRow{ data, .. }) => assert_eq!(js, data));
} }
#[tokio::test] #[tokio::test]
async fn it_can_retrieve_a_charsheet_through_conn() { async fn it_can_retrieve_a_character_through_conn() {
let memory_db: Option<PathBuf> = None; let memory_db: Option<PathBuf> = None;
let mut conn = DbConn::new(memory_db); let mut conn = DbConn::new(memory_db);
assert_matches!(conn.charsheet(CharacterId::from("1")).await, Ok(None)); assert_matches!(conn.character(CharacterId::from("1")).await, ResultExt::Ok(None));
} }
} }

View File

@ -1,10 +1,11 @@
use std::future::Future; use std::future::Future;
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use result_extended::{ok, return_error, ResultExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use warp::{http::Response, http::StatusCode, reply::Reply, ws::Message}; use warp::{http::Response, http::StatusCode, reply::Reply, ws::Message};
use crate::{asset_db::AssetId, core::Core, database::CharacterId, types::AppError}; use crate::{asset_db::AssetId, core::Core, database::CharacterId, types::{AppError, FatalError}};
/* /*
pub async fn handle_auth( pub async fn handle_auth(
@ -32,25 +33,28 @@ pub async fn handle_auth(
pub async fn handler<F>(f: F) -> impl Reply pub async fn handler<F>(f: F) -> impl Reply
where where
F: Future<Output = Result<Response<Vec<u8>>, AppError>>, F: Future<Output = ResultExt<Response<Vec<u8>>, AppError, FatalError>>,
{ {
match f.await { match f.await {
Ok(response) => response, ResultExt::Ok(response) => response,
Err(AppError::NotFound(_)) => Response::builder() ResultExt::Err(AppError::NotFound(_)) => Response::builder()
.status(StatusCode::NOT_FOUND) .status(StatusCode::NOT_FOUND)
.body(vec![]) .body(vec![])
.unwrap(), .unwrap(),
Err(_) => Response::builder() ResultExt::Err(_) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR) .status(StatusCode::INTERNAL_SERVER_ERROR)
.body(vec![]) .body(vec![])
.unwrap(), .unwrap(),
ResultExt::Fatal(err) => {
panic!("Shutting down with fatal error: {:?}", err);
}
} }
} }
pub async fn handle_file(core: Core, asset_id: AssetId) -> impl Reply { pub async fn handle_file(core: Core, asset_id: AssetId) -> impl Reply {
handler(async move { handler(async move {
let (mime, bytes) = core.get_asset(asset_id).await?; let (mime, bytes) = return_error!(core.get_asset(asset_id).await);
Ok(Response::builder() ok(Response::builder()
.header("application-type", mime.to_string()) .header("application-type", mime.to_string())
.body(bytes) .body(bytes)
.unwrap()) .unwrap())
@ -67,7 +71,7 @@ pub async fn handle_available_images(core: Core) -> impl Reply {
.map(|path| format!("{}", path.as_str())) .map(|path| format!("{}", path.as_str()))
.collect(); .collect();
Ok(Response::builder() ok(Response::builder()
.header("Access-Control-Allow-Origin", "*") .header("Access-Control-Allow-Origin", "*")
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.body(serde_json::to_vec(&image_paths).unwrap()) .body(serde_json::to_vec(&image_paths).unwrap())
@ -88,7 +92,7 @@ pub async fn handle_register_client(core: Core, _request: RegisterRequest) -> im
handler(async move { handler(async move {
let client_id = core.register_client().await; let client_id = core.register_client().await;
Ok(Response::builder() ok(Response::builder()
.header("Access-Control-Allow-Origin", "*") .header("Access-Control-Allow-Origin", "*")
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.body( .body(
@ -106,7 +110,7 @@ pub async fn handle_unregister_client(core: Core, client_id: String) -> impl Rep
handler(async move { handler(async move {
core.unregister_client(client_id); core.unregister_client(client_id);
Ok(Response::builder() ok(Response::builder()
.status(StatusCode::NO_CONTENT) .status(StatusCode::NO_CONTENT)
.body(vec![]) .body(vec![])
.unwrap()) .unwrap())
@ -150,7 +154,7 @@ pub async fn handle_set_background_image(core: Core, image_name: String) -> impl
handler(async move { handler(async move {
let _ = core.set_background_image(AssetId::from(image_name)).await; let _ = core.set_background_image(AssetId::from(image_name)).await;
Ok(Response::builder() ok(Response::builder()
.header("Access-Control-Allow-Origin", "*") .header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "*") .header("Access-Control-Allow-Methods", "*")
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
@ -162,15 +166,19 @@ pub async fn handle_set_background_image(core: Core, image_name: String) -> impl
pub async fn handle_get_charsheet(core: Core, charid: String) -> impl Reply { pub async fn handle_get_charsheet(core: Core, charid: String) -> impl Reply {
handler(async move { handler(async move {
let sheet = core.get_charsheet(CharacterId::from(charid)).await.unwrap(); let sheet = match core.get_charsheet(CharacterId::from(charid)).await {
ResultExt::Ok(sheet) => sheet,
ResultExt::Err(err) => return ResultExt::Err(err),
ResultExt::Fatal(err) => return ResultExt::Fatal(err),
};
match sheet { match sheet {
Some(sheet) => Ok(Response::builder() Some(sheet) => ok(Response::builder()
.header("Access-Control-Allow-Origin", "*") .header("Access-Control-Allow-Origin", "*")
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.body(serde_json::to_vec(&sheet).unwrap()) .body(serde_json::to_vec(&sheet).unwrap())
.unwrap()), .unwrap()),
None => Ok(Response::builder() None => ok(Response::builder()
.status(StatusCode::NOT_FOUND) .status(StatusCode::NOT_FOUND)
.body(vec![]) .body(vec![])
.unwrap()), .unwrap()),

View File

@ -1,14 +1,42 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error;
use typeshare::typeshare; use typeshare::typeshare;
use crate::asset_db::AssetId; use crate::asset_db::AssetId;
#[derive(Debug)] #[derive(Debug, Error)]
pub enum FatalError {
#[error("Non-unique database key {0}")]
NonUniqueDatabaseKey(String),
#[error("Database migrations failed {0}")]
DatabaseMigrationFailure(String),
#[error("Failed to construct a query")]
ConstructQueryFailure(String),
#[error("Database connection lost")]
DatabaseConnectionLost,
#[error("Unexpected response for message")]
MessageMismatch,
}
impl result_extended::FatalError for FatalError {}
#[derive(Debug, Error)]
pub enum AppError { pub enum AppError {
#[error("something wasn't found {0}")]
NotFound(String), NotFound(String),
#[error("object inaccessible {0}")]
Inaccessible(String), Inaccessible(String),
#[error("invalid json {0}")]
JsonError(serde_json::Error), JsonError(serde_json::Error),
#[error("wat {0}")]
UnexpectedError(String), UnexpectedError(String),
} }