Move the assertNever utility to a utilities file

This commit is contained in:
Savanni D'Gerinel 2025-01-03 11:59:33 -05:00
parent dc8cb834e0
commit 08462388ea
16 changed files with 208 additions and 117 deletions

15
Cargo.lock generated
View File

@ -4394,6 +4394,20 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697"
dependencies = [
"bitflags 2.6.0",
"bytes",
"http 1.2.0",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@ -4688,6 +4702,7 @@ dependencies = [
"thiserror 2.0.3",
"tokio",
"tokio-stream",
"tower-http",
"typeshare",
"urlencoding",
"uuid 1.11.0",

View File

@ -24,6 +24,7 @@ serde_json = { version = "*" }
thiserror = { version = "2.0.3" }
tokio = { version = "1", features = [ "full" ] }
tokio-stream = { version = "0.1.16" }
tower-http = { version = "0.6.2", features = ["cors"] }
typeshare = { version = "1.0.4" }
urlencoding = { version = "2.1.3" }
uuid = { version = "1.11.0", features = ["v4"] }

View File

@ -2,9 +2,11 @@ use std::fmt;
use rusqlite::types::{FromSql, FromSqlResult, ValueRef};
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use uuid::Uuid;
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[typeshare]
pub struct UserId(String);
impl UserId {
@ -47,6 +49,7 @@ impl fmt::Display for UserId {
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[typeshare]
pub struct SessionId(String);
impl SessionId {
@ -87,6 +90,7 @@ impl fmt::Display for SessionId {
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[typeshare]
pub struct GameId(String);
impl GameId {
@ -121,6 +125,7 @@ impl FromSql for GameId {
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[typeshare]
pub struct CharacterId(String);
impl CharacterId {

View File

@ -16,7 +16,7 @@ use crate::{
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct HealthCheck {
pub ok: bool,
pub admin_enabled: bool,
}
pub async fn wrap_handler<F, A, Fut>(f: F) -> (StatusCode, Json<Option<A>>)
@ -46,10 +46,10 @@ where
pub async fn healthcheck(core: Core) -> Vec<u8> {
match core.status().await {
ResultExt::Ok(s) => serde_json::to_vec(&HealthCheck {
ok: s.admin_enabled,
admin_enabled: s.admin_enabled,
})
.unwrap(),
ResultExt::Err(_) => serde_json::to_vec(&HealthCheck { ok: false }).unwrap(),
ResultExt::Err(_) => serde_json::to_vec(&HealthCheck { admin_enabled: false }).unwrap(),
ResultExt::Fatal(err) => panic!("{}", err),
}
}

View File

@ -1,9 +1,10 @@
use axum::{
extract::Path,
http::{HeaderMap, StatusCode},
http::{header::CONTENT_TYPE, HeaderMap, Method, StatusCode},
routing::{get, post, put},
Json, Router,
};
use tower_http::cors::{Any, CorsLayer};
use crate::{
core::Core,
@ -21,60 +22,67 @@ pub fn routes(core: Core) -> Router {
get({
let core = core.clone();
move || healthcheck(core)
}),
})
.layer(
CorsLayer::new()
.allow_methods([Method::GET])
.allow_origin(Any),
),
)
.route(
"/api/v1/auth",
post({
let core = core.clone();
move |req: Json<AuthRequest>| wrap_handler(|| check_password(core, req))
}).layer(
CorsLayer::new().allow_methods([Method::POST]).allow_headers([CONTENT_TYPE]).allow_origin(Any),
),
)
.route(
// By default, just get the self user.
"/api/v1/user",
get({
let core = core.clone();
move |headers: HeaderMap| wrap_handler(|| get_user(core, headers, None))
})
.put({
let core = core.clone();
move |headers: HeaderMap, req: Json<CreateUserRequest>| {
let Json(req) = req;
wrap_handler(|| create_user(core, headers, req))
}
}),
)
.route(
"/api/v1/user/password",
put({
let core = core.clone();
move |headers: HeaderMap, req: Json<SetPasswordRequest>| {
let Json(req) = req;
wrap_handler(|| set_password(core, headers, req))
}
}),
)
.route(
"/api/v1/user/:user_id",
get({
let core = core.clone();
move |user_id: Path<UserId>, headers: HeaderMap| {
let Path(user_id) = user_id;
wrap_handler(|| get_user(core, headers, Some(user_id)))
}
}),
)
.route(
"/api/v1/games",
put({
let core = core.clone();
move |headers: HeaderMap, req: Json<CreateGameRequest>| {
let Json(req) = req;
wrap_handler(|| create_game(core, headers, req))
}
}),
)
.route(
// By default, just get the self user.
"/api/v1/user",
get({
let core = core.clone();
move |headers: HeaderMap| wrap_handler(|| get_user(core, headers, None))
})
.put({
let core = core.clone();
move |headers: HeaderMap, req: Json<CreateUserRequest>| {
let Json(req) = req;
wrap_handler(|| create_user(core, headers, req))
}
}),
)
.route(
"/api/v1/user/password",
put({
let core = core.clone();
move |headers: HeaderMap, req: Json<SetPasswordRequest>| {
let Json(req) = req;
wrap_handler(|| set_password(core, headers, req))
}
}),
)
.route(
"/api/v1/user/:user_id",
get({
let core = core.clone();
move |user_id: Path<UserId>, headers: HeaderMap| {
let Path(user_id) = user_id;
wrap_handler(|| get_user(core, headers, Some(user_id)))
}
}),
)
.route(
"/api/v1/games",
put({
let core = core.clone();
move |headers: HeaderMap, req: Json<CreateGameRequest>| {
let Json(req) = req;
wrap_handler(|| create_game(core, headers, req))
}
}),
)
}
#[cfg(test)]

View File

@ -46,9 +46,9 @@ const App = ({ client }: AppProps) => {
console.log("rendering app");
const [websocketUrl, setWebsocketUrl] = useState<string | undefined>(undefined);
useEffect(() => {
client.registerWebsocket().then((url) => setWebsocketUrl(url))
}, [client]);
// useEffect(() => {
// client.registerWebsocket().then((url) => setWebsocketUrl(url))
// }, [client]);
let router =
createBrowserRouter([

View File

@ -1,3 +1,5 @@
import { SessionId } from "visions-types";
export type PlayingField = {
backgroundImage: string;
}
@ -64,15 +66,15 @@ export class Client {
return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(password) });
}
async auth(username: string, password: string) {
async auth(username: string, password: string): Promise<SessionId | undefined> {
const url = new URL(this.base);
url.pathname = `api/v1/auth`
return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify({ 'username': username, 'password': password }) });
url.pathname = `/api/v1/auth`
return fetch(url, { method: 'POST', headers: [['Content-Type', 'application/json']], body: JSON.stringify({ 'username': username, 'password': password }) });
}
async status() {
async health() {
const url = new URL(this.base);
url.pathname = `/api/v1/status`;
url.pathname = `/api/v1/health`;
return fetch(url).then((response) => response.json());
}

View File

@ -1,9 +1,9 @@
import React, { useContext } from 'react';
import './Tabletop.css';
import { RGB } from 'visions-types';
import { Rgb } from 'visions-types';
interface TabletopElementProps {
backgroundColor: RGB;
backgroundColor: Rgb;
backgroundUrl: URL | undefined;
}

View File

@ -1,5 +1,5 @@
import React from 'react';
import { assertNever } from '.';
import { assertNever } from '../../utils';
import './Charsheet.css';
import { DriveGuage } from './DriveGuage/DriveGuage';
import { Charsheet, Nerve, Cunning, Intuition } from './types';

View File

@ -1,9 +1,9 @@
import React from 'react';
import { assertNever } from '.';
import { SimpleGuage } from '../../components/Guages/SimpleGuage';
import { Charsheet, Nerve, Cunning, Intuition } from './types';
import './CharsheetPanel.css';
import classNames from 'classnames';
import { assertNever } from '../../utils';
interface CharsheetPanelProps {
sheet: Charsheet;

View File

@ -1,9 +1,5 @@
import { CharsheetElement } from './Charsheet';
import { CharsheetPanelElement } from './CharsheetPanel';
export function assertNever(value: never) {
throw new Error("Unexpected value: " + value);
}
export default { CharsheetElement, CharsheetPanelElement };

View File

@ -1,26 +1,53 @@
import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer, useRef } from "react";
import { Status, Tabletop } from "visions-types";
import { Client } from "../../client";
import { assertNever } from "../../plugins/Candela";
import { assertNever } from "../../utils";
type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", userid: string };
type AppState = {
auth: AuthState;
tabletop: Tabletop;
type LoadingState = { type: "Loading" }
type ReadyState = {
type: "Ready",
auth: AuthState,
tabletop: Tabletop,
}
type AppState = LoadingState | ReadyState
type Action = { type: "SetAuthState", content: AuthState };
/*
const initialState = (): AppState => (
{
auth: { type: "NoAdmin" },
tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined }
}
);
*/
const initialState = (): AppState => ({ type: "Loading" })
const loadingReducer = (state: LoadingState, action: Action): AppState => {
return { type: "Ready", auth: action.content, tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined } }
}
const readyReducer = (state: ReadyState, action: Action): AppState => {
return { ...state, auth: action.content }
}
const stateReducer = (state: AppState, action: Action): AppState => {
return { ...state, auth: action.content }
switch (state.type) {
case "Loading": {
return loadingReducer(state, action);
}
case "Ready": {
return readyReducer(state, action);
}
default: {
assertNever(state);
return { type: "Loading" };
}
}
}
class StateManager {
@ -35,7 +62,7 @@ class StateManager {
async status() {
if (!this.client || !this.dispatch) return;
const { admin_enabled } = await this.client.status();
const { admin_enabled } = await this.client.health();
if (!admin_enabled) {
this.dispatch({ type: "SetAuthState", content: { type: "NoAdmin" } });
} else {
@ -54,9 +81,9 @@ class StateManager {
if (!this.client || !this.dispatch) return;
let resp = await this.client.auth(username, password);
let userid = await resp.json();
console.log("userid retrieved", userid);
this.dispatch({ type: "SetAuthState", content: { type: "Authed", userid } });
let sessionid = await resp.json();
console.log("sessionid retrieved", sessionid);
this.dispatch({ type: "SetAuthState", content: { type: "Authed", sessionid } });
}
}

4
visions/ui/src/utils.ts Normal file
View File

@ -0,0 +1,4 @@
export function assertNever(value: never) {
throw new Error("Unexpected value: " + value);
}

View File

@ -1,6 +1,6 @@
import { PropsWithChildren, useContext, useState } from 'react';
import { StateContext } from '../../providers/StateProvider/StateProvider';
import { assertNever } from '../../plugins/Candela';
import { assertNever } from '../../utils';
import './Authentication.css';
interface AuthenticationProps {
@ -17,35 +17,48 @@ export const Authentication = ({ onAdminPassword, onAuth, children }: PropsWithC
let [pwField, setPwField] = useState<string>("");
let [state, _] = useContext(StateContext);
switch (state.auth.type) {
case "NoAdmin": {
return <div className="auth">
<div className="card">
<h1> Welcome to your new Visions VTT Instance </h1>
<p> Set your admin password: </p>
<input type="password" placeholder="Password" onChange={(evt) => setPwField(evt.target.value)} />
<input type="submit" value="Submit" onClick={() => onAdminPassword(pwField)} />
</div>
</div>;
switch (state.type) {
case "Loading": {
return <div>Loading</div>
}
case "Unauthed": {
return <div className="auth card">
<div className="card">
<h1> Welcome to Visions VTT </h1>
<div className="auth__input-line">
<input type="text" placeholder="Username" onChange={(evt) => setUserField(evt.target.value)} />
<input type="password" placeholder="Password" onChange={(evt) => setPwField(evt.target.value)} />
<input type="submit" value="Sign in" onClick={() => onAuth(userField, pwField)} />
</div>
</div>
</div>;
}
case "Authed": {
return <div> {children} </div>;
case "Ready": {
switch (state.auth.type) {
case "NoAdmin": {
return <div className="auth">
<div className="card">
<h1> Welcome to your new Visions VTT Instance </h1>
<p> Set your admin password: </p>
<input type="password" placeholder="Password" onChange={(evt) => setPwField(evt.target.value)} />
<input type="submit" value="Submit" onClick={() => onAdminPassword(pwField)} />
</div>
</div>;
}
case "Unauthed": {
return <div className="auth card">
<div className="card">
<h1> Welcome to Visions VTT </h1>
<div className="auth__input-line">
<input type="text" placeholder="Username" onChange={(evt) => setUserField(evt.target.value)} />
<input type="password" placeholder="Password" onChange={(evt) => setPwField(evt.target.value)} />
<input type="submit" value="Sign in" onClick={() => onAuth(userField, pwField)} />
</div>
</div>
</div>;
}
case "Authed": {
return <div> {children} </div>;
}
default: {
assertNever(state.auth);
return <div></div>;
}
}
}
default: {
assertNever(state.auth);
return <div></div>;
assertNever(state);
return <div></div>
}
}
}

View File

@ -5,6 +5,7 @@ import { TabletopElement } from '../../components/Tabletop/Tabletop';
import { ThumbnailElement } from '../../components/Thumbnail/Thumbnail';
import { WebsocketContext } from '../../components/WebsocketProvider';
import './GmView.css';
import { assertNever } from '../../utils';
interface GmViewProps {
client: Client
@ -18,12 +19,21 @@ export const GmView = ({ client }: GmViewProps) => {
client.availableImages().then((images) => setImages(images));
}, [client]);
const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined;
return (<div className="gm-view">
<div>
{images.map((imageName) => <ThumbnailElement id={imageName} url={client.imageUrl(imageName)} onclick={() => { client.setBackgroundImage(imageName); }} />)}
</div>
<TabletopElement backgroundColor={state.tabletop.backgroundColor} backgroundUrl={backgroundUrl} />
</div>)
switch (state.type) {
case "Loading": return <div></div>;
case "Ready": {
const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined;
return (<div className="gm-view">
<div>
{images.map((imageName) => <ThumbnailElement id={imageName} url={client.imageUrl(imageName)} onclick={() => { client.setBackgroundImage(imageName); }} />)}
</div>
<TabletopElement backgroundColor={state.tabletop.backgroundColor} backgroundUrl={backgroundUrl} />
</div>)
}
default: {
assertNever(state);
return <div></div>;
}
}
}

View File

@ -5,6 +5,7 @@ import { Client } from '../../client';
import { TabletopElement } from '../../components/Tabletop/Tabletop';
import Candela from '../../plugins/Candela';
import { StateContext } from '../../providers/StateProvider/StateProvider';
import { assertNever } from '../../utils';
const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803";
@ -26,14 +27,23 @@ export const PlayerView = ({ client }: PlayerViewProps) => {
[client, setCharsheet]
);
const backgroundColor = state.tabletop.backgroundColor;
const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`;
const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined;
switch (state.type) {
case "Loading": return <div></div>;
case "Ready": {
const backgroundColor = state.tabletop.backgroundColor;
const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`;
const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined;
return (<div className="player-view" style={{ backgroundColor: tabletopColorStyle }}>
<div className="player-view__middle-panel"> <TabletopElement backgroundColor={backgroundColor} backgroundUrl={backgroundUrl} /> </div>
<div className="player-view__right-panel">
{charsheet ? <Candela.CharsheetPanelElement sheet={charsheet} /> : <div> </div>}</div>
</div>)
return (<div className="player-view" style={{ backgroundColor: tabletopColorStyle }}>
<div className="player-view__middle-panel"> <TabletopElement backgroundColor={backgroundColor} backgroundUrl={backgroundUrl} /> </div>
<div className="player-view__right-panel">
{charsheet ? <Candela.CharsheetPanelElement sheet={charsheet} /> : <div> </div>}</div>
</div>)
}
default: {
assertNever(state);
return <div></div>;
}
}
}