diff --git a/src/main.rs b/src/main.rs index bc60d64..d95e487 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ use chrono::Local; use actix_web::{middleware::Logger, web, App, HttpServer, Result}; use serde::{Deserialize, Serialize}; use sqlx::{PgPool, Pool, Postgres, Connection}; -use utoipa::OpenApi; +use utoipa::{OpenApi, openapi::security::{SecurityScheme, ApiKey, ApiKeyValue}, Modify}; use utoipa_swagger_ui::{Config, SwaggerUi, Url}; mod db; @@ -97,6 +97,18 @@ async fn main() -> Result<()> { println!("DATABASE_URL: {}", env::var("DATABASE_URL").unwrap()); // DBEUG + struct ApiSecurity; + + impl Modify for ApiSecurity { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered. + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("x-api-key"))), + ); + } + } + #[derive(OpenApi)] #[openapi( paths( @@ -128,25 +140,35 @@ async fn main() -> Result<()> { v3::list_games, v3::get_game_data, v3::create_game, - v3::delete_game + v3::edit_game, + v3::delete_game, + v3::create_group, + v3::edit_group, + v3::delete_group ), components(schemas( - v3::schemas::AuthParamsOptional, - v3::schemas::AuthParams, v3::schemas::AuthReturn, v3::schemas::GetGameParam, v3::schemas::PostGameParams, + v3::schemas::UpdateGameParams, v3::schemas::DeleteGameParam, + v3::schemas::PostGroupParams, + v3::schemas::UpdateGroupParams, + v3::schemas::DeleteGroupParams, v3::schemas::FullViewData, v3::schemas::Ethic, v3::schemas::EmpireEthic, + v3::schemas::ChellarisGameLegacy, + v3::schemas::ChellarisGameFlat, v3::schemas::ChellarisGame, - v3::schemas::ChellarisGameLite, v3::schemas::Species, - v3::schemas::ChellarisGameGroup, + v3::schemas::ChellarisGameGroupLegacy, + v3::schemas::ChellarisGroupFlat, v3::schemas::Portrait, - v3::schemas::ChellarisEmpire - )) + v3::schemas::ChellarisEmpire, + v3::schemas::ChellarisEmpireFlat + )), + modifiers(&ApiSecurity) )] struct ApiDocV3; @@ -188,7 +210,11 @@ async fn main() -> Result<()> { .service(v3::list_games) .service(v3::get_game_data) .service(v3::create_game) + .service(v3::edit_game) .service(v3::delete_game) + .service(v3::create_group) + .service(v3::edit_group) + .service(v3::delete_group) // Swagger UI .service( SwaggerUi::new(concat!(api_base!(), "/swagger/{_:.*}")) @@ -226,7 +252,7 @@ async fn main() -> Result<()> { while is_alive.load(Ordering::Relaxed) { let thread = tokio::spawn(async { - thread::sleep(Duration::from_millis(100)); + sleep(Duration::from_millis(100)); }); let _ = thread.await; diff --git a/src/v3/mod.rs b/src/v3/mod.rs index 20b41e5..9656fe1 100644 --- a/src/v3/mod.rs +++ b/src/v3/mod.rs @@ -3,33 +3,36 @@ use std::{collections::HashMap, vec}; use actix_web::{ delete, get, post, put, web::{self, Json}, - HttpResponse, Responder, + HttpResponse, Responder, HttpRequest, }; use crate::{db, AppState}; pub(crate) mod schemas; -fn verify_auth(token: &str, data: &AppState) -> schemas::AuthReturn { +fn verify_auth(token: Option<&str>, data: &AppState) -> schemas::AuthReturn { let mut auth_return = schemas::AuthReturn { moderator: false, admin: false, }; - if token == data.auth_tokens.admin { - auth_return.admin = true; - auth_return.moderator = true; - } else if token == data.auth_tokens.moderator { - auth_return.moderator = true; + if let Some(token) = token { + if token == data.auth_tokens.admin { + auth_return.admin = true; + auth_return.moderator = true; + } else if token == data.auth_tokens.moderator { + auth_return.moderator = true; + } } return auth_return; } +fn get_auth_header<'a>(req: &'a HttpRequest) -> Option<&'a str> { + req.headers().get("x-api-key")?.to_str().ok() +} + #[utoipa::path( - params( - schemas::AuthParams - ), responses( (status = 200, description = "OK", body = AuthReturn), ), @@ -40,31 +43,16 @@ fn verify_auth(token: &str, data: &AppState) -> schemas::AuthReturn { #[get("/api/v3/auth")] pub(crate) async fn auth( data: web::Data, - params: web::Query, + req: HttpRequest, ) -> impl Responder { - let params: schemas::AuthParamsOptional = params.into_inner(); + let auth_token = get_auth_header(&req); - let mut auth_return = schemas::AuthReturn { - moderator: false, - admin: false, - }; - - if let Some(auth_token) = params.token.clone() { - if auth_token == data.auth_tokens.admin { - auth_return.admin = true; - auth_return.moderator = true; - } else if auth_token == data.auth_tokens.moderator { - auth_return.moderator = true; - } - } + let auth_return = verify_auth(auth_token, &data); Json(auth_return) } #[utoipa::path( - params( - - ), responses( (status = 200, description = "OK", body = FullViewData), ), @@ -74,8 +62,6 @@ pub(crate) async fn auth( )] #[get("/api/v3/full_view_data")] pub(crate) async fn full_view_data(data: web::Data) -> impl Responder { - let start = chrono::Local::now(); // DEBUG - // SQL Queries // TODO: Optimize by utilizing some SQL magic, for now this has to do @@ -146,7 +132,7 @@ pub(crate) async fn full_view_data(data: web::Data) -> impl Responder let new_data = schemas::Portrait { id: portrait.id, hires: portrait.hires.clone(), - lores: portrait.lores.clone(), + lores: Some(portrait.lores.clone()), }; parsed_data @@ -161,7 +147,7 @@ pub(crate) async fn full_view_data(data: web::Data) -> impl Responder // Games Vector and Children db_games.iter().for_each(|game| { - let new_data = schemas::ChellarisGame { + let new_data = schemas::ChellarisGameLegacy { id: game.id, name: game.name.clone(), empires: HashMap::new(), @@ -176,7 +162,7 @@ pub(crate) async fn full_view_data(data: web::Data) -> impl Responder }); db_groups.iter().for_each(|group| { - let new_data = schemas::ChellarisGameGroup { + let new_data = schemas::ChellarisGameGroupLegacy { id: group.id, name: group.name.clone(), }; @@ -194,12 +180,12 @@ pub(crate) async fn full_view_data(data: web::Data) -> impl Responder db_empires.iter().for_each(|empire| { let new_data = schemas::ChellarisEmpire { id: empire.id, - gestalt: empire.gestalt.unwrap_or(false), + gestalt: empire.gestalt, machine: false, group: empire.group_id, empire_portrait: empire.empire_portrait_id, empire_portrait_group: empire.empire_portrait_group_id, - discord_user: empire.discord_user.clone(), + discord_user: None, ethics: HashMap::new(), }; @@ -265,13 +251,13 @@ pub(crate) async fn full_view_data(data: web::Data) -> impl Responder .or_insert(new_data); }); - println!("{:?} ms", (chrono::Local::now() - start).to_std().unwrap()); // DEBUG - Json(parsed_data) } // Data Fetching Endpoints for Admin/Moderator Menu +// Game Endpoints + #[utoipa::path( params(), responses( @@ -281,7 +267,7 @@ pub(crate) async fn full_view_data(data: web::Data) -> impl Responder ("api_key" = []) ), )] -#[get("/api/v3/list_games")] +#[get("/api/v3/games")] pub(crate) async fn list_games(data: web::Data) -> impl Responder { // SQL Queries // TODO: Optimize by utilizing some SQL magic, for now this has to do @@ -291,12 +277,12 @@ pub(crate) async fn list_games(data: web::Data) -> impl Responder { .await .expect("Error Fetching Data from DB"); - let mut parsed_data: HashMap = HashMap::new(); + let mut parsed_data: HashMap = HashMap::new(); // Data processing // Games Vector db_games.iter().for_each(|game| { - let new_data = schemas::ChellarisGameLite { + let new_data = schemas::ChellarisGameFlat { id: game.id, name: game.name.clone(), }; @@ -315,7 +301,7 @@ pub(crate) async fn list_games(data: web::Data) -> impl Responder { schemas::GetGameParam ), responses( - (status = 200, description = "OK", body = HashMap), + (status = 200, description = "OK", body = ChellarisGame), ), security( ("api_key" = []) @@ -324,49 +310,86 @@ pub(crate) async fn list_games(data: web::Data) -> impl Responder { #[get("/api/v3/game")] pub(crate) async fn get_game_data( data: web::Data, - params: web::Query, + path_param: web::Query, + req: HttpRequest, ) -> impl Responder { - let params: schemas::GetGameParam = params.into_inner(); + let auth_token = get_auth_header(&req); + let param = path_param.into_inner(); + + let user_auth: schemas::AuthReturn = verify_auth(auth_token.as_deref(), &data); // SQL Queries // TODO: Optimize by utilizing some SQL magic, for now this has to do - let db_games: Vec; - if let Some(game_id) = params.game_id { - db_games = match sqlx::query_as!( - db::schemas::Game, - "SELECT * FROM public.games WHERE id = $1 ORDER BY id", - game_id as i32 - ) - .fetch_one(&data.db) - .await - { - Ok(data) => vec![data], - Err(_) => vec![], - }; - } else { - db_games = sqlx::query_as("SELECT * FROM public.games ORDER BY id") - .fetch_all(&data.db) - .await - .expect("Error Fetching Data from DB"); - } + let db_game: db::schemas::Game = match sqlx::query_as!( + db::schemas::Game, + "SELECT * FROM public.games WHERE id = $1", + param.game_id + ) + .fetch_one(&data.db) + .await + { + Ok(data) => data, + Err(_) => return HttpResponse::UnprocessableEntity().finish(), + }; - let mut parsed_data: HashMap = HashMap::new(); + let db_empires: Vec = sqlx::query_as!( + db::schemas::Empire, + "SELECT * FROM public.empires WHERE group_game_id = $1", + param.game_id + ) + .fetch_all(&data.db) + .await + .unwrap_or(vec![]); - // Data processing - // Games Vector - db_games.iter().for_each(|game| { - let new_data = schemas::ChellarisGameLite { - id: game.id, - name: game.name.clone(), + let db_groups: Vec = sqlx::query_as!( + db::schemas::GameGroup, + "SELECT * FROM public.game_groups WHERE game_id = $1", + param.game_id + ) + .fetch_all(&data.db) + .await + .unwrap_or(vec![]); + + // Data Processing + let mut parsed_data: schemas::ChellarisGame = schemas::ChellarisGame { + id: db_game.id, + name: db_game.name, + empires: HashMap::new(), + groups: HashMap::new(), + }; + + db_empires.iter().for_each(|empire| { + let new_empire = schemas::ChellarisEmpireFlat { + id: empire.id, + group: empire.group_id, + name: if user_auth.moderator || user_auth.admin {empire.name.clone()} else {"[REDACTED]".to_string()}, + discord_user: if user_auth.moderator || user_auth.admin {empire.discord_user.clone()} else {None}, + gestalt: empire.gestalt, + empire_portrait: empire.empire_portrait_id, + empire_portrait_group: empire.empire_portrait_group_id, }; parsed_data - .entry(game.id) - .and_modify(|d| *d = new_data.clone()) - .or_insert(new_data); + .empires + .entry(empire.id) + .and_modify(|d| *d = new_empire.clone()) + .or_insert(new_empire); }); - Json(parsed_data) + db_groups.iter().for_each(|group| { + let new_group = schemas::ChellarisGroupFlat { + id: group.id, + name: group.name.clone(), + }; + + parsed_data + .groups + .entry(group.id) + .and_modify(|d| *d = new_group.clone()) + .or_insert(new_group); + }); + + return HttpResponse::Ok().json(parsed_data); } #[utoipa::path( @@ -384,10 +407,12 @@ pub(crate) async fn get_game_data( pub(crate) async fn create_game( data: web::Data, params: web::Json, + req: HttpRequest, ) -> impl Responder { + let auth_token = get_auth_header(&req); let params = params.into_inner(); - let user_auth: schemas::AuthReturn = verify_auth(¶ms.auth.token, &data); + let user_auth: schemas::AuthReturn = verify_auth(auth_token.as_deref(), &data); // SQL Queries // TODO: Optimize by utilizing some SQL magic, for now this has to do @@ -407,7 +432,18 @@ pub(crate) async fn create_game( Err(_) => return HttpResponse::UnprocessableEntity().finish(), }; - let parsed_game: schemas::ChellarisGameLite = schemas::ChellarisGameLite { + match sqlx::query!( + "INSERT INTO public.game_groups(name, game_id) VALUES ($1, $2)", + "N/A", db_game.id + ) + .execute(&data.db) + .await + { + Ok(_) => {}, + Err(_) => return HttpResponse::UnprocessableEntity().finish(), + }; + + let parsed_game: schemas::ChellarisGameFlat = schemas::ChellarisGameFlat { id: db_game.id, name: db_game.name, }; @@ -421,6 +457,61 @@ pub(crate) async fn create_game( } } +#[utoipa::path( + request_body = UpdateGameParams, + responses( + (status = 200, description = "OK", body = ChellarisGameLite), + (status = 422, description = "Missing Game Name"), + (status = 401, description = "Auth Token Invalid"), + ), + security( + ("api_key" = []) + ), +)] +#[put("/api/v3/game")] +pub(crate) async fn edit_game( + data: web::Data, + params: web::Json, + req: HttpRequest, +) -> impl Responder { + let auth_token = get_auth_header(&req); + let params = params.into_inner(); + + let user_auth: schemas::AuthReturn = verify_auth(auth_token.as_deref(), &data); + + // SQL Queries + // TODO: Optimize by utilizing some SQL magic, for now this has to do + if user_auth.admin || user_auth.moderator { + if params.game_name != "" { + let db_game: db::schemas::Game; + + db_game = match sqlx::query_as!( + db::schemas::Game, + "UPDATE public.games SET name = $1 WHERE id = $2 RETURNING * ;", + params.game_name, params.game_id + ) + .fetch_one(&data.db) + .await + { + Ok(data) => data, + Err(_) => return HttpResponse::UnprocessableEntity().finish(), + }; + + let parsed_game: schemas::ChellarisGameFlat = schemas::ChellarisGameFlat { + id: db_game.id, + name: db_game.name, + }; + + return HttpResponse::Ok().json(parsed_game); + } else { + return HttpResponse::UnprocessableEntity().finish(); + } + } else { + return HttpResponse::Unauthorized().finish(); + } +} + + #[utoipa::path( params( schemas::DeleteGameParam, @@ -438,25 +529,177 @@ pub(crate) async fn create_game( #[delete("/api/v3/game")] pub(crate) async fn delete_game( data: web::Data, - auth_params: web::Json, + req: HttpRequest, param: web::Query, ) -> impl Responder { - let auth_params = auth_params.into_inner(); + let auth_token = get_auth_header(&req); let param = param.into_inner(); - let user_auth: schemas::AuthReturn = verify_auth(&auth_params.token, &data); + let user_auth: schemas::AuthReturn = verify_auth(auth_token, &data); // SQL Queries // TODO: Optimize by utilizing some SQL magic, for now this has to do if user_auth.admin || user_auth.moderator { - match sqlx::query!( - "DELETE FROM public.games WHERE id = $1", - param.game_id as i32 - ) - .execute(&data.db) - .await + match sqlx::query!("DELETE FROM public.games WHERE id = $1", param.game_id) + .execute(&data.db) + .await { - Ok(_) => {}, + Ok(_) => {} + Err(e) => { + println!("{:#?}", e); + return HttpResponse::UnprocessableEntity().finish(); + }, + }; + + return HttpResponse::Ok().into(); + } else { + return HttpResponse::Unauthorized().finish(); + } +} + +// Group Endpoints + +#[utoipa::path( + request_body = PostGroupParams, + responses( + (status = 200, description = "OK", body = ChellarisGroupFlat), + (status = 422, description = "Missing Game Name"), + (status = 401, description = "Auth Token Invalid"), + ), + security( + ("api_key" = []) + ), +)] +#[post("/api/v3/group")] +pub(crate) async fn create_group( + data: web::Data, + params: web::Json, + req: HttpRequest, +) -> impl Responder { + let auth_token = get_auth_header(&req); + let params = params.into_inner(); + + let user_auth: schemas::AuthReturn = verify_auth(auth_token.as_deref(), &data); + + // SQL Queries + // TODO: Optimize by utilizing some SQL magic, for now this has to do + if user_auth.admin || user_auth.moderator { + if params.group_name != "" { + let db_group: db::schemas::GameGroup; + + db_group = match sqlx::query_as!( + db::schemas::GameGroup, + "INSERT INTO public.game_groups(name, game_id) VALUES ($1, $2) RETURNING * ", + params.group_name, params.game_id + ) + .fetch_one(&data.db) + .await + { + Ok(data) => data, + Err(_) => return HttpResponse::UnprocessableEntity().finish(), + }; + + let parsed_group: schemas::ChellarisGroupFlat = schemas::ChellarisGroupFlat { + id: db_group.id, + name: db_group.name, + }; + + return HttpResponse::Ok().json(parsed_group); + } else { + return HttpResponse::UnprocessableEntity().finish(); + } + } else { + return HttpResponse::Unauthorized().finish(); + } +} + +#[utoipa::path( + request_body = UpdateGroupParams, + responses( + (status = 200, description = "OK", body = ChellarisGroupFlat), + (status = 422, description = "Missing Game Name"), + (status = 401, description = "Auth Token Invalid"), + ), + security( + ("api_key" = []) + ), +)] +#[put("/api/v3/group")] +pub(crate) async fn edit_group( + data: web::Data, + params: web::Json, + req: HttpRequest, +) -> impl Responder { + let auth_token = get_auth_header(&req); + let params = params.into_inner(); + + let user_auth: schemas::AuthReturn = verify_auth(auth_token.as_deref(), &data); + + // SQL Queries + // TODO: Optimize by utilizing some SQL magic, for now this has to do + if user_auth.admin || user_auth.moderator { + if params.group_name != "" { + let db_group: db::schemas::GameGroup; + + db_group = match sqlx::query_as!( + db::schemas::GameGroup, + "UPDATE public.game_groups SET name = $1 WHERE id = $2 AND game_id = $3 RETURNING * ;", + params.group_name, params.group_id, params.game_id + ) + .fetch_one(&data.db) + .await + { + Ok(data) => data, + Err(_) => return HttpResponse::UnprocessableEntity().finish(), + }; + + let parsed_group: schemas::ChellarisGroupFlat = schemas::ChellarisGroupFlat { + id: db_group.id, + name: db_group.name, + }; + + return HttpResponse::Ok().json(parsed_group); + } else { + return HttpResponse::UnprocessableEntity().finish(); + } + } else { + return HttpResponse::Unauthorized().finish(); + } +} + +#[utoipa::path( + params( + schemas::DeleteGroupParams, + ), + request_body = AuthParams, + responses( + (status = 200, description = "OK"), + (status = 422, description = "Missing Game ID"), + (status = 401, description = "Auth Token Invalid"), + ), + security( + ("api_key" = []) + ), +)] +#[delete("/api/v3/group")] +pub(crate) async fn delete_group( + data: web::Data, + req: HttpRequest, + param: web::Query, +) -> impl Responder { + let auth_token = get_auth_header(&req); + let param = param.into_inner(); + + let user_auth: schemas::AuthReturn = verify_auth(auth_token, &data); + + // SQL Queries + // TODO: Optimize by utilizing some SQL magic, for now this has to do + if user_auth.admin || user_auth.moderator { + match sqlx::query!("DELETE FROM public.game_groups WHERE id = $1 AND game_id = $2 AND name != 'N/A'", param.group_id, param.game_id) + .execute(&data.db) + .await + { + Ok(_) => {} Err(_) => return HttpResponse::UnprocessableEntity().finish(), }; @@ -466,12 +709,15 @@ pub(crate) async fn delete_game( } } +// Empire Endpoints + + // Data Manipulation Endpoints // Moderator & Admin // Add/Update/Remove Empire -// Add/Update/Remove Group -// Add/Update/Remove Game +// Update Group +// Update Game // Admin // Add/Update/Remove Portrait diff --git a/src/v3/schemas.rs b/src/v3/schemas.rs index 9dcb69b..e5023c6 100644 --- a/src/v3/schemas.rs +++ b/src/v3/schemas.rs @@ -1,20 +1,8 @@ -use std::collections::HashMap; +use std::{collections::HashMap}; use serde::{Serialize, Deserialize}; use utoipa::{ToSchema, IntoParams}; -#[derive(Serialize, Deserialize, ToSchema, Debug, IntoParams)] -pub struct AuthParamsOptional { - #[schema(example = "1357")] - pub token: Option, -} - -#[derive(Serialize, Deserialize, ToSchema, Debug, IntoParams)] -pub struct AuthParams { - #[schema(example = "1357")] - pub token: String, -} - #[derive(Serialize, Deserialize, ToSchema, Debug)] pub struct AuthReturn { #[schema(example = false)] @@ -23,14 +11,24 @@ pub struct AuthReturn { pub admin: bool, } +// Game Structs + #[derive(Serialize, Deserialize, ToSchema, Debug, IntoParams)] pub struct GetGameParam { - pub game_id: Option, + #[schema(example = 0)] + pub game_id: i32, } #[derive(Serialize, Deserialize, ToSchema, Debug)] pub struct PostGameParams { - pub auth: AuthParams, + #[schema(example = "Game XY")] + pub game_name: String, +} + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct UpdateGameParams { + #[schema(example = 0)] + pub game_id: i32, #[schema(example = "Game XY")] pub game_name: String, } @@ -38,44 +36,97 @@ pub struct PostGameParams { #[derive(Serialize, Deserialize, ToSchema, Debug, IntoParams)] pub struct DeleteGameParam { #[schema(example = 0)] - pub game_id: usize, + pub game_id: i32, } #[derive(Serialize, ToSchema, Debug, Clone)] pub struct FullViewData { - pub games: HashMap, + pub games: HashMap, pub ethics: HashMap, pub species: HashMap, } +#[derive(Serialize, ToSchema, Debug, Clone)] +pub struct ChellarisGameLegacy { + pub id: i32, + pub name: String, + pub groups: HashMap, + pub empires: HashMap, +} + +#[derive(Serialize, ToSchema, Debug, Clone)] +pub struct ChellarisGameFlat { + pub id: i32, + pub name: String, +} + #[derive(Serialize, ToSchema, Debug, Clone)] pub struct ChellarisGame { pub id: i32, pub name: String, - pub groups: HashMap, - pub empires: HashMap, + pub empires: HashMap, + pub groups: HashMap +} + +// Group Structs + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct PostGroupParams { + #[schema(example = "Group XY")] + pub group_name: String, + #[schema(example = 0)] + pub game_id: i32 +} + +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct UpdateGroupParams { + #[schema(example = 0)] + pub group_id: i32, + #[schema(example = 0)] + pub game_id: i32, + #[schema(example = "Group XY")] + pub group_name: String +} + +#[derive(Serialize, Deserialize, ToSchema, Debug, IntoParams)] +pub struct DeleteGroupParams { + #[schema(example = 0)] + pub group_id: i32, + #[schema(example = 0)] + pub game_id: i32, } #[derive(Serialize, ToSchema, Debug, Clone)] -pub struct ChellarisGameLite { +pub struct ChellarisGroupFlat { pub id: i32, pub name: String, } #[derive(Serialize, ToSchema, Debug, Clone)] -pub struct ChellarisGameGroup { +pub struct ChellarisGameGroupLegacy { pub id: i32, pub name: String, } +#[derive(Serialize, ToSchema, Debug, Clone)] +pub struct ChellarisEmpireFlat { + pub id: i32, + pub group: i32, + pub name: String, + pub discord_user: Option, + pub gestalt: bool, + pub empire_portrait: Option, + pub empire_portrait_group: Option, +} + #[derive(Serialize, ToSchema, Debug, Clone)] pub struct ChellarisEmpire { pub id: i32, pub gestalt: bool, pub machine: bool, pub group: i32, - pub empire_portrait: i32, - pub empire_portrait_group: i32, + pub empire_portrait: Option, + pub empire_portrait_group: Option, pub discord_user: Option, pub ethics: HashMap, }