Extract the user management handlers
This commit is contained in:
parent
f6eb942371
commit
5bb9f00a0d
@ -1,138 +0,0 @@
|
|||||||
mod user_management;
|
|
||||||
// pub use user_management::routes_user_management;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
asset_db::AssetId,
|
|
||||||
core::Core,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// Per-endpoint Authentication:
|
|
||||||
//
|
|
||||||
// If an endpoint requires authentication:
|
|
||||||
// - check the Authorization header for a token
|
|
||||||
// - if the token is absent or unknown, return a 403
|
|
||||||
// - if the admin user is absent, return a 403, with a body that indicates the admin user is absent
|
|
||||||
//
|
|
||||||
// The login function does not require authentication, but it should return a session ID
|
|
||||||
|
|
||||||
/*
|
|
||||||
fn cors<H, M>(methods: Vec<M>, headers: Vec<H>) -> Builder
|
|
||||||
where
|
|
||||||
M: Into<Method>,
|
|
||||||
H: Into<HeaderName>,
|
|
||||||
{
|
|
||||||
warp::cors()
|
|
||||||
.allow_credentials(true)
|
|
||||||
.allow_methods(methods)
|
|
||||||
.allow_headers(headers)
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
/*
|
|
||||||
pub fn route_healthcheck() -> impl Filter<Extract = (impl Reply,), Error = warp::Rejection> + Clone
|
|
||||||
{
|
|
||||||
warp::path!("api" / "v1" / "healthcheck")
|
|
||||||
.and(warp::get())
|
|
||||||
.map(|| warp::reply::reply())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn route_server_status(
|
|
||||||
core: Core,
|
|
||||||
) -> impl Filter<Extract = (impl Reply,), Error = warp::Rejection> + Clone {
|
|
||||||
warp::path!("api" / "v1" / "status")
|
|
||||||
.and(warp::get())
|
|
||||||
.then(move || handle_server_status(core.clone()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn route_set_bg_image(
|
|
||||||
core: Core,
|
|
||||||
) -> impl Filter<Extract = (impl Reply,), Error = warp::Rejection> + Clone {
|
|
||||||
warp::path!("api" / "v1" / "tabletop" / "bg_image")
|
|
||||||
.and(warp::put())
|
|
||||||
.and(warp::body::json())
|
|
||||||
.then({
|
|
||||||
let core = core.clone();
|
|
||||||
move |body| handle_set_background_image(core.clone(), body)
|
|
||||||
})
|
|
||||||
.with(cors::<HeaderName, Method>(vec![Method::PUT], vec![]))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn route_image(
|
|
||||||
core: Core,
|
|
||||||
) -> impl Filter<Extract = (impl Reply,), Error = warp::Rejection> + Clone {
|
|
||||||
warp::path!("api" / "v1" / "image" / String)
|
|
||||||
.and(warp::get())
|
|
||||||
.then({
|
|
||||||
let core = core.clone();
|
|
||||||
move |file_name| handle_file(core.clone(), AssetId::from(file_name))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn route_available_images(
|
|
||||||
core: Core,
|
|
||||||
) -> impl Filter<Extract = (impl Reply,), Error = warp::Rejection> + Clone {
|
|
||||||
warp::path!("api" / "v1" / "image").and(warp::get()).then({
|
|
||||||
let core = core.clone();
|
|
||||||
move || handle_available_images(core.clone())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn route_register_client(
|
|
||||||
core: Core,
|
|
||||||
) -> impl Filter<Extract = (impl Reply,), Error = warp::Rejection> + Clone {
|
|
||||||
warp::path!("api" / "v1" / "client")
|
|
||||||
.and(warp::post())
|
|
||||||
.then({
|
|
||||||
let core = core.clone();
|
|
||||||
move || handle_register_client(core.clone(), RegisterRequest {})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn route_unregister_client(
|
|
||||||
core: Core,
|
|
||||||
) -> impl Filter<Extract = (impl Reply,), Error = warp::Rejection> + Clone {
|
|
||||||
warp::path!("api" / "v1" / "client" / String)
|
|
||||||
.and(warp::delete())
|
|
||||||
.then({
|
|
||||||
let core = core.clone();
|
|
||||||
move |client_id| handle_unregister_client(core.clone(), client_id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn route_websocket(
|
|
||||||
core: Core,
|
|
||||||
) -> impl Filter<Extract = (impl Reply,), Error = warp::Rejection> + Clone {
|
|
||||||
warp::path("ws")
|
|
||||||
.and(warp::ws())
|
|
||||||
.and(warp::path::param())
|
|
||||||
.then({
|
|
||||||
let core = core.clone();
|
|
||||||
move |ws, client_id| handle_connect_websocket(core.clone(), ws, client_id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn route_get_charsheet(
|
|
||||||
core: Core,
|
|
||||||
) -> impl Filter<Extract = (impl Reply,), Error = warp::Rejection> + Clone {
|
|
||||||
warp::path!("api" / "v1" / "charsheet" / String)
|
|
||||||
.and(warp::get())
|
|
||||||
.then({
|
|
||||||
let core = core.clone();
|
|
||||||
move |charid| handle_get_charsheet(core.clone(), charid)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn route_authenticate(
|
|
||||||
core: Core,
|
|
||||||
) -> impl Filter<Extract = (impl Reply,), Error = warp::Rejection> + Clone {
|
|
||||||
warp::path!("api" / "v1" / "auth")
|
|
||||||
.and(warp::put())
|
|
||||||
.and(warp::body::json())
|
|
||||||
.then({
|
|
||||||
let core = core.clone();
|
|
||||||
move |body| handle_check_password(core.clone(), body)
|
|
||||||
})
|
|
||||||
.with(cors::<HeaderName, Method>(vec![Method::PUT], vec![]))
|
|
||||||
}
|
|
||||||
*/
|
|
@ -1,182 +0,0 @@
|
|||||||
use std::{convert::Infallible, future::Future};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
core::Core,
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
async fn handle_rejection(err: warp::Rejection) -> Result<impl Reply, Infallible> {
|
|
||||||
println!("handle_rejection: {:?}", err);
|
|
||||||
if let Some(Unauthorized) = err.find() {
|
|
||||||
Ok(warp::reply::with_status(
|
|
||||||
"".to_owned(),
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Ok(warp::reply::with_status(
|
|
||||||
"".to_owned(),
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
/*
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Unauthorized;
|
|
||||||
|
|
||||||
impl reject::Reject for Unauthorized {}
|
|
||||||
|
|
||||||
use super::cors;
|
|
||||||
|
|
||||||
fn route_get_users(
|
|
||||||
core: Core,
|
|
||||||
) -> impl Filter<Extract = (Response<Vec<u8>>,), Error = warp::Rejection> + Clone {
|
|
||||||
warp::path!("api" / "v1" / "users")
|
|
||||||
.and(warp::get())
|
|
||||||
.and(warp::header::optional::<String>("authorization"))
|
|
||||||
.and(warp::any().map(move || core.clone()))
|
|
||||||
.and_then(|auth_token, core: Core| async move {
|
|
||||||
match auth_token {
|
|
||||||
Some(token) => Ok(handle_get_users(core.clone()).await),
|
|
||||||
None => Err(warp::reject::custom(Unauthorized)),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn route_set_admin_password(
|
|
||||||
core: Core,
|
|
||||||
) -> impl Filter<Extract = (impl Reply,), Error = warp::Rejection> + Clone {
|
|
||||||
warp::path!("api" / "v1" / "admin_password")
|
|
||||||
.and(warp::put())
|
|
||||||
.and(warp::body::json())
|
|
||||||
.then({ move |body| handle_set_admin_password(core.clone(), body) })
|
|
||||||
.with(cors(vec![Method::PUT], vec![CONTENT_TYPE]))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn routes_user_management(
|
|
||||||
core: Core,
|
|
||||||
) -> impl Filter<Extract = (impl Reply,), Error = warp::Rejection> + Clone {
|
|
||||||
route_get_users(core.clone()).or(route_set_admin_password(core.clone()))
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
/*
|
|
||||||
pub fn route_check_password(
|
|
||||||
core: Core,
|
|
||||||
) -> impl Filter<Extract = (impl Reply,), Error = warp::Rejection> + Clone {
|
|
||||||
warp::path!("api" / "v1" / "auth")
|
|
||||||
.and(warp::put())
|
|
||||||
.and(warp::body::json())
|
|
||||||
.then({ move |body| handle_check_password(core.clone(), body) })
|
|
||||||
.with(cors::<HeaderName, Method>(vec![Method::PUT], vec![]))
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
/*
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use std::{collections::HashMap, path::PathBuf};
|
|
||||||
|
|
||||||
use result_extended::ResultExt;
|
|
||||||
use warp::http::StatusCode;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
asset_db::mocks::MemoryAssets,
|
|
||||||
database::{Database, DbConn, UserId},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
async fn setup() -> Core {
|
|
||||||
let asset_store = MemoryAssets::new(vec![]);
|
|
||||||
let memory_file: Option<PathBuf> = None;
|
|
||||||
let db = DbConn::new(memory_file);
|
|
||||||
db.save_user(None, "admin", "", true, true).await.unwrap();
|
|
||||||
Core::new(asset_store, db)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn handle_check_password_should_return_a_valid_token() {
|
|
||||||
let core = setup().await;
|
|
||||||
match core.list_users().await {
|
|
||||||
ResultExt::Ok(users) => println!("{:?}", users),
|
|
||||||
ResultExt::Err(err) => panic!("{}", err),
|
|
||||||
ResultExt::Fatal(err) => panic!("{}", err),
|
|
||||||
}
|
|
||||||
match core.user_by_username("admin").await {
|
|
||||||
ResultExt::Ok(Some(user)) => {
|
|
||||||
let _ = core
|
|
||||||
.set_password(UserId::from(user.id), "aoeu".to_owned())
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
ResultExt::Ok(None) => panic!("expected user wasn't found"),
|
|
||||||
ResultExt::Err(err) => panic!("{}", err),
|
|
||||||
ResultExt::Fatal(err) => panic!("{}", err),
|
|
||||||
}
|
|
||||||
let filter = route_check_password(core);
|
|
||||||
let params: HashMap<String, String> = vec![
|
|
||||||
("username".to_owned(), "admin".to_owned()),
|
|
||||||
("password".to_owned(), "aoeu".to_owned()),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.collect();
|
|
||||||
let resp = warp::test::request()
|
|
||||||
.method("PUT")
|
|
||||||
.path("/api/v1/auth")
|
|
||||||
.json(¶ms)
|
|
||||||
.reply(&filter)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
println!("response: {}", resp.status());
|
|
||||||
assert!(resp.status().is_success());
|
|
||||||
println!(
|
|
||||||
"resp.body(): {}",
|
|
||||||
String::from_utf8(resp.body().to_vec()).unwrap()
|
|
||||||
);
|
|
||||||
serde_json::from_slice::<String>(resp.body()).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
#[tokio::test]
|
|
||||||
async fn handle_check_auth_token() {
|
|
||||||
let core = setup().await;
|
|
||||||
let filter = route_get_users(core);
|
|
||||||
let response = warp::test::request()
|
|
||||||
.method("GET")
|
|
||||||
.path("/api/v1/users")
|
|
||||||
.header("Authorization", "abcdefg")
|
|
||||||
.reply(&filter)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
println!("response: {}", response.status());
|
|
||||||
assert!(false);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn it_rejects_unauthorized_requests() {
|
|
||||||
let core = setup().await;
|
|
||||||
let filter = route_get_users(core)
|
|
||||||
.recover(handle_rejection);
|
|
||||||
let response = warp::test::request()
|
|
||||||
.method("GET")
|
|
||||||
.path("/api/v1/users")
|
|
||||||
.reply(&filter)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
println!("response: {:?}", response);
|
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn it_accepts_authorized_requests() {
|
|
||||||
unimplemented!();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn it_returns_special_response_for_no_admin() {
|
|
||||||
unimplemented!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
166
visions/server/src/handlers/user_management.rs
Normal file
166
visions/server/src/handlers/user_management.rs
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::database::UserId;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[typeshare]
|
||||||
|
pub struct AuthRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
#[typeshare]
|
||||||
|
pub struct CreateUserRequest {
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
#[typeshare]
|
||||||
|
pub struct SetPasswordRequest {
|
||||||
|
pub password_1: String,
|
||||||
|
pub password_2: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_session(
|
||||||
|
core: &Core,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> ResultExt<Option<User>, AppError, FatalError> {
|
||||||
|
match headers.get("Authorization") {
|
||||||
|
Some(token) => {
|
||||||
|
match token
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.split(" ")
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.as_slice()
|
||||||
|
{
|
||||||
|
[_schema, token] => core.session(&SessionId::from(token.to_owned())).await,
|
||||||
|
_ => error(AppError::BadRequest),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn auth_required<F, A, Fut>(
|
||||||
|
core: Core,
|
||||||
|
headers: HeaderMap,
|
||||||
|
f: F,
|
||||||
|
) -> (StatusCode, Json<Option<A>>)
|
||||||
|
where
|
||||||
|
F: FnOnce(User) -> Fut,
|
||||||
|
Fut: Future<Output = (StatusCode, Json<Option<A>>)>,
|
||||||
|
{
|
||||||
|
match check_session(&core, headers).await {
|
||||||
|
ResultExt::Ok(Some(user)) => f(user).await,
|
||||||
|
ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)),
|
||||||
|
ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)),
|
||||||
|
ResultExt::Fatal(err) => panic!("{}", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn admin_required<F, A, Fut>(
|
||||||
|
core: Core,
|
||||||
|
headers: HeaderMap,
|
||||||
|
f: F,
|
||||||
|
) -> (StatusCode, Json<Option<A>>)
|
||||||
|
where
|
||||||
|
F: FnOnce(User) -> Fut,
|
||||||
|
Fut: Future<Output = (StatusCode, Json<Option<A>>)>,
|
||||||
|
{
|
||||||
|
match check_session(&core, headers).await {
|
||||||
|
ResultExt::Ok(Some(user)) => {
|
||||||
|
if user.admin {
|
||||||
|
f(user).await
|
||||||
|
} else {
|
||||||
|
(StatusCode::FORBIDDEN, Json(None))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)),
|
||||||
|
ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)),
|
||||||
|
ResultExt::Fatal(err) => panic!("{}", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_password(
|
||||||
|
core: Core,
|
||||||
|
req: Json<AuthRequest>,
|
||||||
|
) -> (StatusCode, Json<Option<SessionId>>) {
|
||||||
|
let Json(AuthRequest { username, password }) = req;
|
||||||
|
match core.auth(&username, &password).await {
|
||||||
|
ResultExt::Ok(session_id) => (StatusCode::OK, Json(Some(session_id))),
|
||||||
|
ResultExt::Err(_err) => (StatusCode::UNAUTHORIZED, Json(None)),
|
||||||
|
ResultExt::Fatal(err) => panic!("Fatal: {}", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user(
|
||||||
|
core: Core,
|
||||||
|
headers: HeaderMap,
|
||||||
|
user_id: Option<UserId>,
|
||||||
|
) -> (StatusCode, Json<Option<UserProfile>>) {
|
||||||
|
auth_required(core.clone(), headers, |user| async move {
|
||||||
|
match user_id {
|
||||||
|
Some(user_id) => match core.user(user_id).await {
|
||||||
|
ResultExt::Ok(Some(user)) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(Some(UserProfile {
|
||||||
|
userid: UserId::from(user.id),
|
||||||
|
username: user.name,
|
||||||
|
is_admin: user.admin,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
ResultExt::Ok(None) => (StatusCode::NOT_FOUND, Json(None)),
|
||||||
|
ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)),
|
||||||
|
ResultExt::Fatal(err) => panic!("{}", err),
|
||||||
|
},
|
||||||
|
None => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(Some(UserProfile {
|
||||||
|
userid: UserId::from(user.id),
|
||||||
|
username: user.name,
|
||||||
|
is_admin: user.admin,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_user(
|
||||||
|
core: Core,
|
||||||
|
headers: HeaderMap,
|
||||||
|
req: CreateUserRequest,
|
||||||
|
) -> (StatusCode, Json<Option<()>>) {
|
||||||
|
admin_required(core.clone(), headers, |_admin| async {
|
||||||
|
match core.create_user(&req.username).await {
|
||||||
|
ResultExt::Ok(_) => (StatusCode::OK, Json(None)),
|
||||||
|
ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)),
|
||||||
|
ResultExt::Fatal(fatal) => panic!("{}", fatal),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_password(
|
||||||
|
core: Core,
|
||||||
|
headers: HeaderMap,
|
||||||
|
req: SetPasswordRequest,
|
||||||
|
) -> (StatusCode, Json<Option<()>>) {
|
||||||
|
auth_required(core.clone(), headers, |user| async {
|
||||||
|
if req.password_1 == req.password_2 {
|
||||||
|
match core.set_password(user.id, req.password_1).await {
|
||||||
|
ResultExt::Ok(_) => (StatusCode::OK, Json(None)),
|
||||||
|
ResultExt::Err(_err) => (StatusCode::BAD_REQUEST, Json(None)),
|
||||||
|
ResultExt::Fatal(fatal) => panic!("{}", fatal),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(StatusCode::BAD_REQUEST, Json(None))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -7,9 +7,8 @@ use database::DbConn;
|
|||||||
mod asset_db;
|
mod asset_db;
|
||||||
mod core;
|
mod core;
|
||||||
mod database;
|
mod database;
|
||||||
mod filters;
|
mod handlers;
|
||||||
pub mod handlers;
|
mod routes;
|
||||||
pub mod routes;
|
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
use crate::{asset_db::AssetId, database::{UserId, UserRow}};
|
use crate::{asset_db::AssetId, database::{GameId, UserId, UserRow}};
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum FatalError {
|
pub enum FatalError {
|
||||||
@ -129,4 +129,21 @@ pub enum Message {
|
|||||||
UpdateTabletop(Tabletop),
|
UpdateTabletop(Tabletop),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
#[typeshare]
|
||||||
|
pub struct UserProfile {
|
||||||
|
pub id: UserId,
|
||||||
|
pub username: String,
|
||||||
|
pub games: Vec<GameOverview>,
|
||||||
|
pub is_admin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
#[typeshare]
|
||||||
|
pub struct GameOverview {
|
||||||
|
pub id: GameId,
|
||||||
|
pub game_type: String,
|
||||||
|
pub game_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user