Add new "v1" API, rename "v2" and "v3" to legacy

This commit is contained in:
Neshura 2023-12-12 20:39:15 +01:00
parent 134581f8ad
commit b3347a6e53
Signed by: Neshura
GPG key ID: B6983AAA6B9A7A6C
3 changed files with 487 additions and 13 deletions

View file

@ -25,15 +25,21 @@ macro_rules! api_base {
};
}
macro_rules! api_base_1 {
() => {
"/api/v1"
};
}
macro_rules! api_base_2 {
() => {
"/api/v2"
"/api/v2-l"
};
}
macro_rules! api_base_3 {
() => {
"/api/v3"
"/api/v3-l"
};
}
@ -208,9 +214,27 @@ async fn main() -> Result<()> {
)]
struct ApiDocV3;
#[derive(OpenApi)]
#[openapi(
paths(
v1::get_user,
v1::create_user,
v1::update_user,
v1::delete_user
),
components(schemas(
v1::schemas::GetUserParams,
v1::schemas::UpdateUserParams,
v1::schemas::DeleteUserParams,
v1::schemas::User
))
)]
struct ApiDocV1;
let openapi_urls = vec![
Url::new("v2", concat!(api_base_2!(), "/openapi.json")),
Url::new("v3", concat!(api_base_3!(), "/openapi.json")),
Url::new("v1", concat!(api_base_1!(), "/openapi.json")),
Url::new("v2-L", concat!(api_base_2!(), "/openapi.json")),
Url::new("v3-L", concat!(api_base_3!(), "/openapi.json")),
];
let is_alive: Arc<AtomicBool> = Arc::new(AtomicBool::new(true));
@ -218,15 +242,31 @@ async fn main() -> Result<()> {
loop {
let db_auth_tokens = config.auth.clone();
let pool = PgPool::connect(dotenv!("DATABASE_URL")).await.unwrap();
let pool_copy = pool.clone();
let shutdown_clone = shutdown.clone();
let swagger_config = Config::new(openapi_urls.clone());
let mut openapi_v2 = ApiDocV2::openapi();
openapi_v2.info.title = "Chellaris Rust API".to_string();
let mut openapi_v1 = ApiDocV1::openapi();
openapi_v1.info.title = "Chellaris Rust API v1".to_string();
let mut openapi_v3 = ApiDocV3::openapi();
openapi_v3.info.title = "Chellaris Rust API".to_string();
let mut openapi_v2_l = ApiDocV2::openapi();
openapi_v2_l.info.title = "Legacy Chellaris Rust API v2".to_string();
let mut openapi_v3_l = ApiDocV3::openapi();
openapi_v3_l.info.title = "Legacy Chellaris Rust API v3".to_string();
println!("Serving API on: ");
println!(" -> http://[{}]:{}", Ipv6Addr::UNSPECIFIED, 8080);
println!(" -> http://{}:{}", Ipv4Addr::UNSPECIFIED, 8080);
let watchdog_thread = tokio::spawn(async move { postgres_watchdog(pool_copy, shutdown_clone) });
tokio::spawn(async move {
actix_web::rt::signal::ctrl_c().await.unwrap();
println!("Ctrl-C received, killing Server");
abort()
});
let server = HttpServer::new(move || {
App::new()
@ -257,17 +297,26 @@ async fn main() -> Result<()> {
.service(v3::delete_empire)
.service(v3::get_ethics)
.service(v3::get_phenotypes)
// API v1 Endpoints
.service(v1::get_user)
.service(v1::create_user)
.service(v1::update_user)
.service(v1::delete_user)
// Swagger UI
.service(
SwaggerUi::new(concat!(api_base!(), "/swagger/{_:.*}"))
.urls(vec![
(
Url::new("v2", concat!(api_base_2!(), "/openapi.json")),
openapi_v2.clone(),
Url::new("v1", concat!(api_base_1!(), "/openapi.json")),
openapi_v1.clone(),
),
(
Url::new("v3", concat!(api_base_3!(), "/openapi.json")),
openapi_v3.clone(),
Url::new("v2-l", concat!(api_base_2!(), "/openapi.json")),
openapi_v2_l.clone(),
),
(
Url::new("v3-l", concat!(api_base_3!(), "/openapi.json")),
openapi_v3_l.clone(),
),
])
.config(swagger_config.clone()),

360
src/v1/mod.rs Normal file
View file

@ -0,0 +1,360 @@
use crate::{db, AppState};
use actix_web::web::Json;
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse, Responder};
use std::collections::HashMap;
use std::ops::Deref;
use rand::distributions::Alphanumeric;
use rand::{Rng, thread_rng};
use sqlx::QueryBuilder;
pub(crate) mod schemas;
fn get_auth_header<'a>(req: &'a HttpRequest) -> Option<&'a str> {
req.headers().get("x-api-key")?.to_str().ok()
}
async fn verify_user_auth(data: &web::Data<AppState>, auth_token: &str, user_token: &str, table: schemas::TablePermission, permissions_only: bool) -> bool {
let user: db::schemas::User = match sqlx::query_as!(
db::schemas::User,
"SELECT * FROM public.users WHERE token = $1",
auth_token
)
.fetch_one(&data.db)
.await
{
Ok(data) => data,
Err(_) => return false,
};
if user.token == user_token && !permissions_only {
return true;
}
else {
match table {
schemas::TablePermission::Game => {
return user.data_permissions;
},
schemas::TablePermission::Empire => {
return user.empire_permissions;
},
schemas::TablePermission::Data => {
return user.data_permissions;
},
schemas::TablePermission::User => {
return user.user_permissions;
},
}
}
}
// User Endpoints
#[utoipa::path(
request_body = schemas::GetUserParams,
responses(
(status = 200, description = "OK", body = User),
(status = 403, description = "Unauthorized"),
(status = 500, description = "Internal Server Error")
),
security(
("api_key" = [])
),
)]
#[get("/api/v1/user")]
async fn get_user(
data: web::Data<AppState>,
params: web::Json<schemas::GetUserParams>,
req: HttpRequest,
) -> impl Responder {
let auth_header = get_auth_header(&req);
let params = params.into_inner();
let auth_token: String;
match auth_header {
Some(token) => auth_token = token.to_string(),
None => return HttpResponse::Unauthorized().finish(),
};
let auth = verify_user_auth(&data, &auth_token, &params.user_token, schemas::TablePermission::User, false).await;
if auth {
let user: db::schemas::User = match sqlx::query_as!(
db::schemas::User,
"SELECT * FROM public.users WHERE token = $1",
params.user_token
)
.fetch_one(&data.db)
.await
{
Ok(data) => data,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
let mut permissions: HashMap<String, bool> = HashMap::new();
permissions.insert("game_permissions".to_string(), user.game_permissions);
permissions.insert("empire_permissions".to_string(), user.empire_permissions);
permissions.insert("data_permissions".to_string(), user.data_permissions);
permissions.insert("user_permissions".to_string(), user.user_permissions);
let return_data = schemas::User {
user_token: user.token,
discord_handle: user.discord_id,
profile_picture: user.picture_url,
permissions: permissions
};
return HttpResponse::Ok().json(return_data);
}
else {
return HttpResponse::Unauthorized().finish();
}
}
#[utoipa::path(
responses(
(status = 200, description = "OK", body = User),
(status = 500, description = "Internal Server Error")
),
)]
#[post("/api/v1/user")]
pub(crate) async fn create_user(
data: web::Data<AppState>,
) -> impl Responder {
let user: db::schemas::User;
let mut rng = thread_rng();
let user_tokens = match sqlx::query_scalar!(
"SELECT token FROM public.users"
)
.fetch_all(&data.db)
.await
{
Ok(data) => data,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
let new_token: String;
loop {
let mut chars: String = (0..6).map(|_| rng.sample(Alphanumeric) as char).collect();
chars = chars.to_uppercase();
if !user_tokens.contains(&chars) {
new_token = chars;
break;
}
else {
println!("looping");
}
}
user = match sqlx::query_as!(
db::schemas::User,
"INSERT INTO public.users(token, game_permissions, empire_permissions, data_permissions, user_permissions) VALUES ($1, $2, $3, $4, $5) RETURNING * ",
new_token,
false,
false,
false,
false
)
.fetch_one(&data.db)
.await
{
Ok(data) => data,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
return HttpResponse::Ok().json(user)
}
#[utoipa::path(
request_body = UpdateUserParams,
responses(
(status = 200, description = "OK"),
(status = 403, description = "Unauthorized"),
(status = 500, description = "Internal Server Error")
),
security(
("api_key" = [])
),
)]
#[put("/api/v1/user")]
pub(crate) async fn update_user(
data: web::Data<AppState>,
params: web::Json<schemas::UpdateUserParams>,
req: HttpRequest,
) -> impl Responder {
let auth_header = get_auth_header(&req);
let params = params.into_inner();
let auth_token: String;
match auth_header {
Some(token) => auth_token = token.to_string(),
None => return HttpResponse::Unauthorized().finish(),
};
let mut elevated_auth = false;
if params.permissions["game_permissions"] || params.permissions["empire_permissions"] || params.permissions["data_permissions"] || params.permissions["user_permissions"] {
elevated_auth = true;
}
let auth = verify_user_auth(&data, &auth_token, &params.user_token, schemas::TablePermission::User, elevated_auth).await;
// SQL Queries
// TODO: Optimize by utilizing some SQL magic, for now this has to do
if auth {
let user: db::schemas::User;
let mut user_query = QueryBuilder::<sqlx::Postgres>::new("UPDATE public.users SET ");
let mut user_query_separated = user_query.separated(", ");
let mut any_param_present = false;
if let Some(discord_handle) = params.discord_handle {
user_query_separated.push(" discord_id = ").push_bind_unseparated(discord_handle);
any_param_present = true;
}
if let Some(profile_picture) = params.profile_picture {
user_query_separated.push(" picture_url = ");
match any_param_present {
true => user_query_separated.push_bind(profile_picture),
false => user_query_separated.push_bind_unseparated(profile_picture)
};
any_param_present = true;
}
for (entry, value) in params.permissions.iter() {
match entry.deref() {
"game_permissions" => {
user_query_separated.push( " game_permissions = ");
match any_param_present {
true => user_query_separated.push_bind(value),
false => user_query_separated.push_bind_unseparated(value)
};
any_param_present = true;
},
"empire_permissions" => {
user_query_separated.push( " empire_permissions = ");
match any_param_present {
true => user_query_separated.push_bind(value),
false => user_query_separated.push_bind_unseparated(value)
};
any_param_present = true;
},
"data_permissions" => {
user_query_separated.push( " data_permissions = ");
match any_param_present {
true => user_query_separated.push_bind(value),
false => user_query_separated.push_bind_unseparated(value)
};
any_param_present = true;
},
"user_permissions" => {
user_query_separated.push( " user_permissions = ");
match any_param_present {
true => user_query_separated.push_bind(value),
false => user_query_separated.push_bind_unseparated(value)
};
any_param_present = true;
},
_ => {}
}
}
if any_param_present {
user_query_separated.push_unseparated(" WHERE token = ").push_bind_unseparated(params.user_token);
user_query_separated.push_unseparated(" RETURNING *");
user = match user_query
.build_query_as::<db::schemas::User>()
.fetch_one(&data.db)
.await
{
Ok(data) => data,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
} else {
user = match sqlx::query_as!(
db::schemas::User,
"SELECT * FROM public.users WHERE token = $1",
params.user_token
)
.fetch_one(&data.db)
.await
{
Ok(data) => data,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
}
let mut permissions: HashMap<String, bool> = HashMap::new();
permissions.insert("game_permissions".to_string(), user.game_permissions);
permissions.insert("empire_permissions".to_string(), user.empire_permissions);
permissions.insert("data_permissions".to_string(), user.data_permissions);
permissions.insert("user_permissions".to_string(), user.user_permissions);
let return_data = schemas::User {
user_token: user.token,
discord_handle: user.discord_id,
profile_picture: user.picture_url,
permissions: permissions
};
return HttpResponse::Ok().json(return_data);
} else {
return HttpResponse::Unauthorized().finish();
}
}
#[utoipa::path(
request_body = schemas::DeleteUserParams,
responses(
(status = 200, description = "OK"),
(status = 403, description = "Unauthorized"),
(status = 500, description = "Internal Server Error")
),
security(
("api_key" = [])
),
)]
#[delete("/api/v1/user")]
pub(crate) async fn delete_user(
data: web::Data<AppState>,
params: web::Query<schemas::DeleteUserParams>,
req: HttpRequest,
) -> impl Responder {
let auth_header = get_auth_header(&req);
let params = params.into_inner();
let auth_token: String;
match auth_header {
Some(token) => auth_token = token.to_string(),
None => return HttpResponse::Unauthorized().finish(),
};
let auth = verify_user_auth(&data, &auth_token, &params.user_token, schemas::TablePermission::User, false).await;
// SQL Queries
// TODO: Optimize by utilizing some SQL magic, for now this has to do
if auth {
match sqlx::query!(
"DELETE FROM public.users WHERE token = $1",
params.user_token
)
.execute(&data.db)
.await
{
Ok(_) => {}
Err(e) => {
return HttpResponse::InternalServerError().finish();
}
};
return HttpResponse::Ok().into();
} else {
return HttpResponse::Unauthorized().finish();
}
}

65
src/v1/schemas.rs Normal file
View file

@ -0,0 +1,65 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::v3::schemas::ChellarisGameLegacy;
// DB Permission Enums
pub enum TablePermission {
Game,
Empire,
Data,
User
}
// User Structs
#[derive(Serialize, ToSchema, Debug)]
pub struct User {
#[schema(example = "abcdef")]
pub user_token: String,
#[schema(example = "discorduser")]
pub discord_handle: Option<String>,
#[schema(example = "/assets/avatars/124677612.png")]
pub profile_picture: Option<String>,
#[schema(example = "\
{\
[\"game_permissions\"]: true,
[\"empire_permissions\"]: true,
[\"data_permissions\"]: false,
[\"user_permissions\"]: false,
}\
")]
pub permissions: HashMap<String, bool>,
}
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub struct GetUserParams {
#[schema(example = "abcdef")]
pub user_token: String,
}
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub struct UpdateUserParams {
#[schema(example = "abcdef")]
pub user_token: String,
#[schema(example = "discorduser")]
pub discord_handle: Option<String>,
#[schema(example = "/assets/avatars/124677612.png")]
pub profile_picture: Option<String>,
#[schema(example = "\
{\
[\"game_permissions\"]: true,
[\"empire_permissions\"]: true,
[\"data_permissions\"]: false,
[\"user_permissions\"]: false,
}\
")]
pub permissions: HashMap<String, bool>,
}
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub struct DeleteUserParams {
#[schema(example = "abcdef")]
pub user_token: String,
}