Compare commits

...

10 Commits

28 changed files with 7375 additions and 14 deletions

66
Cargo.lock generated
View File

@@ -70,11 +70,15 @@ name = "api"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"axum",
"chrono",
"dotenv",
"hmac",
"jwt",
"serde",
"serde_json",
"sha2",
"sqlx",
"tokio",
"tower",
@@ -93,6 +97,18 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "async-trait"
version = "0.1.83"
@@ -201,6 +217,12 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
version = "0.22.1"
@@ -222,6 +244,15 @@ dependencies = [
"serde",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -274,6 +305,7 @@ dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-targets 0.52.6",
]
@@ -813,6 +845,21 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jwt"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f"
dependencies = [
"base64 0.13.1",
"crypto-common",
"digest",
"hmac",
"serde",
"serde_json",
"sha2",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1039,6 +1086,17 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@@ -1616,7 +1674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64bb4714269afa44aef2755150a0fc19d756fb580a67db8885608cf02f47d06a"
dependencies = [
"atoi",
"base64",
"base64 0.22.1",
"bitflags",
"byteorder",
"bytes",
@@ -1660,7 +1718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fa91a732d854c5d7726349bb4bb879bb9478993ceb764247660aee25f67c2f8"
dependencies = [
"atoi",
"base64",
"base64 0.22.1",
"bitflags",
"byteorder",
"chrono",
@@ -2027,6 +2085,10 @@ name = "uuid"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a"
dependencies = [
"getrandom",
"serde",
]
[[package]]
name = "vcpkg"

View File

@@ -20,6 +20,10 @@ sqlx = { version = "0.8.2", features = [
"macros",
"uuid",
] }
uuid = "1.11.0"
chrono = "0.4.39"
uuid = { version = "1.4.1", features = ["serde", "v4"] }
chrono = { version = "0.4.26", features = ["serde"] }
dotenv = "0.15.0"
argon2 = "0.5.3"
jwt = "0.16.0"
hmac = "0.12.1"
sha2 = "0.10.8"

3180
data/data.txt Normal file

File diff suppressed because it is too large Load Diff

3458
data/muscle-data.json Normal file

File diff suppressed because it is too large Load Diff

73
data/parse.ts Normal file
View File

@@ -0,0 +1,73 @@
import * as fs from 'fs';
interface MuscleEntry {
muscle: string;
location: string;
origin: string;
insertion: string;
artery: string;
nerve: string;
action: string;
antagonist: string;
o: number;
ta: number;
}
function parseMuscleTable(table: string): MuscleEntry[] {
const rows = table.split(/\|- style="vertical-align: top;"/).slice(1); // Split rows and ignore header
return rows.map(row => {
const columns = row.split('\n').map(chopUntilPipe);
columns.shift();
// .replace(/\{\{[^}]+\}\}/g, '') // Remove templates like {{...}}
// .replace(/<[^>]+>/g, '') // Remove HTML tags
// .split('|').slice(1); // Split columns and ignore the first empty element
return {
muscle: extractText(columns[0]),
location: extractText(columns[1]),
origin: extractText(columns[2]),
insertion: extractText(columns[3]),
artery: extractText(columns[4]),
nerve: extractText(columns[5]),
action: extractText(columns[6]),
antagonist: extractText(columns[7] || '?'),
o: parseInt(columns[8] || '0', 10),
ta: parseInt(columns[9] || '0', 10),
};
});
}
function extractText(input: string): string {
return input
.replace(/\[\[(.*?)\|(.*?)\]\]/g, "$2")
.replace(/\[\[(.*?)\]\]/g, "$1")
.replace(/\<ref[^>]*>.*?<\/ref>/g, '') // Remove references like <ref>...</ref>
.trim();
}
function chopUntilPipe(input: string): string {
const pipeIndex = input.indexOf('|');
return pipeIndex >= 0 ? input.slice(pipeIndex + 1) : input;
}
// Read data from file
fs.readFile('data.txt', 'utf8', (err, data) => {
if (err) {
console.error('Error reading file:', err);
return;
}
const parsedData = parseMuscleTable(data);
// console.log(parsedData[0]);
console.log(parsedData);
fs.writeFile('muscle-data.json', JSON.stringify(parsedData, null, 2), (err) => {
if (err) {
console.error('Error writing file:', err);
return;
}
console.log('Data written to muscle-data.json');
});
});

9
data/test.ts Normal file
View File

@@ -0,0 +1,9 @@
const gae = "[[Occipitalis muscle|occipitalis]]";
const gae2 = "occipitalis";
function clean(str: string) {
return str.replace(/\[\[(.*?)\|(.*?)\]\]/g, "$2");
}
console.log(clean(gae));

109
gen.ts Normal file
View File

@@ -0,0 +1,109 @@
// Define the structure of your data
interface Muscle {
colloquial_name: string;
scientific_name: string;
}
interface MuscleGroup {
major_group: string;
group_name: string;
muscles: Muscle[];
}
// Your JSON data
const data: MuscleGroup[] = [
{
major_group: "Upper Body",
group_name: "Chest",
muscles: [
{ colloquial_name: "Upper Pecs", scientific_name: "Clavicular Pectoralis Major" },
{ colloquial_name: "Pecs", scientific_name: "Sternal Pectoralis Major" },
]
},
{
major_group: "Upper Body",
group_name: "Back",
muscles: [
{ colloquial_name: "Lats", scientific_name: "Latissimus dorsi" },
{ colloquial_name: "Traps", scientific_name: "Trapezius" },
{ colloquial_name: "Rhomboids", scientific_name: "Rhomboideus major" },
{ colloquial_name: "Spinal Erectors", scientific_name: "Erector spinae" },
{ colloquial_name: "Teres Major", scientific_name: "Teres major" }
]
},
{
major_group: "Upper Body",
group_name: "Shoulders",
muscles: [
{ colloquial_name: "Front Delts", scientific_name: "Anterior deltoid" },
{ colloquial_name: "Rear Delts", scientific_name: "Posterior deltoid" },
{ colloquial_name: "Side Delts", scientific_name: "Lateral deltoid" },
{ colloquial_name: "Rotator Cuff", scientific_name: "Rotator cuff" }
]
},
{
major_group: "Upper Body",
group_name: "Arms",
muscles: [
{ colloquial_name: "Biceps", scientific_name: "Biceps brachii" },
{ colloquial_name: "Brachialis", scientific_name: "Brachialis" },
{ colloquial_name: "Triceps", scientific_name: "Triceps brachii" },
{ colloquial_name: "Brachioradialis", scientific_name: "Brachioradialis" },
{ colloquial_name: "Forearm Flexors/Extensors", scientific_name: "Flexor and extensor muscles of the forearm" }
]
},
{
major_group: "Core",
group_name: "Abdominals",
muscles: [
{ colloquial_name: "Abs", scientific_name: "Rectus abdominis" },
{ colloquial_name: "Transverse Abs", scientific_name: "Transversus abdominis" },
{ colloquial_name: "External Obliques", scientific_name: "Obliquus externus abdominis" },
{ colloquial_name: "Internal Obliques", scientific_name: "Obliquus internus abdominis" }
]
},
{
major_group: "Core",
group_name: "Lower Back",
muscles: [
{ colloquial_name: "QL", scientific_name: "Quadratus lumborum" },
]
},
{
major_group: "Lower Body",
group_name: "Legs",
muscles: [
{ colloquial_name: "Quads", scientific_name: "Quadriceps femoris" },
{ colloquial_name: "Hamstrings", scientific_name: "Hamstring muscles" },
{ colloquial_name: "Glutes", scientific_name: "Gluteus maximus" },
{ colloquial_name: "Glute Med", scientific_name: "Gluteus medius" },
{ colloquial_name: "Glute Min", scientific_name: "Gluteus minimus" },
{ colloquial_name: "Adductors", scientific_name: "Adductor muscles" },
{ colloquial_name: "Abductors", scientific_name: "Abductor muscles" }
]
},
{
major_group: "Lower Body",
group_name: "Calves",
muscles: [
{ colloquial_name: "Gastrocs", scientific_name: "Gastrocnemius" },
{ colloquial_name: "Soleus", scientific_name: "Soleus" }
]
}
];
// Generate SQL INSERT statements
function generateSQL(data: MuscleGroup[]): string {
let sql = "";
data.forEach((group) => {
group.muscles.forEach((muscle) => {
sql += `INSERT INTO muscles (major_group, minor_group, name, scientific_name) VALUES ('${group.major_group}', '${group.group_name}', '${muscle.colloquial_name}', '${muscle.scientific_name}');\n`;
});
});
return sql;
}
const sqlStatements = generateSQL(data);
console.log(sqlStatements);

View File

@@ -0,0 +1,7 @@
-- Add migration script here
ALTER TABLE exercises
ADD COLUMN official BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN author_id UUID REFERENCES users(id),
ADD COLUMN description TEXT,
ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT NOW();

View File

@@ -0,0 +1,9 @@
-- Add migration script here
ALTER TABLE muscles
ADD COLUMN scientific_name VARCHAR(255),
ADD COLUMN major_group VARCHAR(255),
ADD COLUMN minor_group VARCHAR(255) NOT NULL;
DROP TABLE muscle_bodypart_relations;
DROP TABLE body_parts;

View File

@@ -11,6 +11,7 @@ pub enum Errors {
Ise(anyhow::Error),
Unimplemented,
Unauthorized,
JWTExpired,
}
pub enum AppError {
@@ -73,6 +74,12 @@ impl IntoResponse for AppError {
Errors::Unimplemented => {
(StatusCode::NOT_IMPLEMENTED, "Not implemented").into_response()
}
Errors::JWTExpired => (
StatusCode::UNAUTHORIZED,
"JWT has expired. Please log in again.",
)
.into_response(),
},
}
}

44
src/extractors/jwt.rs Normal file
View File

@@ -0,0 +1,44 @@
use std::sync::Arc;
use crate::*;
use anyhow::bail;
use axum::{
async_trait,
extract::{FromRef, FromRequestParts},
http::{header, request::Parts},
};
use chrono::{DateTime, Utc};
use jwt::VerifyWithKey;
use sqlx::types::Uuid;
use util::auth::JWTClaims;
pub struct JWT(JWTClaims);
#[async_trait]
impl<S> FromRequestParts<S> for JWT
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, s: &S) -> Result<Self, Self::Rejection> {
let state = AppState::from_ref(s);
let jwt_string = parts
.headers
.get(header::AUTHORIZATION)
.ok_or(AppError::Error(Errors::Unauthorized))?
.to_str()
.map_err(|_| AppError::Error(Errors::Unauthorized))?
.strip_prefix("Bearer ")
.ok_or(AppError::Error(Errors::Unauthorized))?;
let claims: JWTClaims = jwt_string
.verify_with_key(&state.jwt_key)
.map_err(|_| AppError::Error(Errors::Unauthorized))?;
Ok(JWT(claims))
}
}

2
src/extractors/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod users;
pub mod jwt;

46
src/extractors/users.rs Normal file
View File

@@ -0,0 +1,46 @@
use crate::*;
use axum::{
async_trait,
extract::{FromRef, FromRequestParts},
http::{header, request::Parts},
};
use jwt::VerifyWithKey;
use sqlx::types::Uuid;
use util::auth::JWTClaims;
/// Remember, this is FromRequestParts, so it has to be ABOVE the extractors
/// that eat the entire request
pub struct UserId(pub Uuid);
#[async_trait]
impl<S> FromRequestParts<S> for UserId
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, s: &S) -> Result<Self, Self::Rejection> {
let state = AppState::from_ref(s);
let jwt_string = parts
.headers
.get(header::AUTHORIZATION)
.ok_or(AppError::Error(Errors::Unauthorized))?
.to_str()
.map_err(|_| AppError::Error(Errors::Unauthorized))?
.strip_prefix("Bearer ")
.ok_or(AppError::Error(Errors::Unauthorized))?;
let claims: JWTClaims = jwt_string
.verify_with_key(&state.jwt_key)
.map_err(|_| AppError::Error(Errors::Unauthorized))?;
if claims.exp < chrono::Utc::now().timestamp() {
return Err(AppError::Error(Errors::JWTExpired));
}
Ok(UserId(claims.sub))
}
}

View File

@@ -1,17 +1,20 @@
use std::{net::Ipv4Addr, sync::Arc};
use hmac::{Hmac, Mac};
use tokio::net::TcpListener;
use utoipa::OpenApi;
use utoipa_axum::{router::OpenApiRouter, routes};
use utoipa_swagger_ui::SwaggerUi;
// tags
use v1::auth::AUTH_TAG;
use v1::{auth::AUTH_TAG, exercises::EXERCISES_TAG, muscles::MUSCLES_TAG};
mod v1;
mod structs;
mod state;
mod db;
mod error;
mod util;
mod extractors;
pub(crate) use anyhow::Context;
pub(crate) use axum::extract::{Json, State};
@@ -24,6 +27,8 @@ pub(crate) use utoipa::ToSchema;
#[openapi(
tags(
(name = AUTH_TAG, description = "Authentication API endpoints"),
(name = EXERCISES_TAG, description = "Exercise API endpoints"),
(name = MUSCLES_TAG, description = "Muscle API endpoints"),
// (name = CUSTOMER_TAG, description = "Customer API endpoints"),
// (name = ORDER_TAG, description = "Order API endpoints")
),
@@ -56,16 +61,19 @@ async fn index() -> &'static str {
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
let db = db::db().await?;
let state = state::AppState { db: Arc::new(db) };
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string()).parse()?;
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
let db = db::db().await?;
let state = state::AppState { db: Arc::new(db),
jwt_key: Hmac::new_from_slice(jwt_secret.as_bytes()).context("Failed to create HMAC")?
};
let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
.routes(routes!(health_check))
.routes(routes!(index))
.with_state(state.clone())
.nest("/api/v1/auth", v1::auth::router(state.clone()))
.nest("/api/v1", v1::router(state.clone()))
.split_for_parts();
let router = router.merge(SwaggerUi::new("/docs").url("/docs/openapi.json", api));

View File

@@ -1,8 +1,13 @@
use std::sync::Arc;
use hmac::Hmac;
use sha2::Sha256;
#[derive(Clone)]
pub struct AppState {
pub db: DB,
// pub jwt_secret: String,
pub jwt_key: Hmac<Sha256>,
}
pub type DB = Arc<sqlx::Pool<sqlx::Postgres>>;

View File

@@ -0,0 +1,55 @@
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, PartialOrd, sqlx::Type, Deserialize, Serialize, ToSchema)]
#[sqlx(type_name = "exercise_type", rename_all = "snake_case")]
pub enum ExerciseType {
Dumbbell,
Barbell,
Bodyweight,
Machine,
Kettlebell,
ResistanceBand,
Cable,
MedicineBall,
Plyometric,
PlateLoadedMachine,
}
impl std::fmt::Display for ExerciseType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExerciseType::Dumbbell => write!(f, "dumbbell"),
ExerciseType::Barbell => write!(f, "barbell"),
ExerciseType::Bodyweight => write!(f, "bodyweight"),
ExerciseType::Machine => write!(f, "machine"),
ExerciseType::Kettlebell => write!(f, "kettlebell"),
ExerciseType::ResistanceBand => write!(f, "resistance_band"),
ExerciseType::Cable => write!(f, "cable"),
ExerciseType::MedicineBall => write!(f, "medicine_ball"),
ExerciseType::Plyometric => write!(f, "plyometric"),
ExerciseType::PlateLoadedMachine => write!(f, "plate_loaded_machine"),
}
}
}
impl std::str::FromStr for ExerciseType {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"dumbbell" => Ok(ExerciseType::Dumbbell),
"barbell" => Ok(ExerciseType::Barbell),
"bodyweight" => Ok(ExerciseType::Bodyweight),
"machine" => Ok(ExerciseType::Machine),
"kettlebell" => Ok(ExerciseType::Kettlebell),
"resistance_band" => Ok(ExerciseType::ResistanceBand),
"cable" => Ok(ExerciseType::Cable),
"medicine_ball" => Ok(ExerciseType::MedicineBall),
"plyometric" => Ok(ExerciseType::Plyometric),
"plate_loaded_machine" => Ok(ExerciseType::PlateLoadedMachine),
_ => Err(anyhow!("Invalid exercise type")),
}
}
}

View File

@@ -1 +1,2 @@
pub mod users;
pub mod exercise_types;

49
src/util/auth.rs Normal file
View File

@@ -0,0 +1,49 @@
use chrono::Utc;
use uuid::Uuid;
use crate::*;
#[derive(Serialize, Deserialize)]
pub struct JWTClaims {
pub sub: Uuid,
pub iat: i64,
pub exp: i64,
pub username: String,
pub real_name: String,
pub email: String,
}
impl JWTClaims {
pub fn new(sub: Uuid, username: String, real_name: String, email: String) -> Self {
let iat = Utc::now().timestamp();
let exp = iat + 60 * 60 * 24 * 7;
Self {
sub,
iat,
exp,
username,
real_name,
email,
}
}
/// Create a new JWT Claims with a custom expiration time. Expiration time is added to current time.
pub fn new_with_exp(
sub: Uuid,
username: String,
real_name: String,
email: String,
exp: i64,
) -> Self {
let iat = Utc::now().timestamp();
Self {
sub,
iat,
exp: iat + exp,
username,
real_name,
email,
}
}
}

1
src/util/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod auth;

48
src/v1/auth/login.rs Normal file
View File

@@ -0,0 +1,48 @@
use crate::*;
use argon2::{
password_hash::{PasswordHash, PasswordVerifier},
Argon2,
};
use jwt::SignWithKey;
use sqlx::query;
use util::auth::JWTClaims;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct LoginBody {
username: String,
password: String,
}
/// Login
#[utoipa::path(post, path = "/login", responses((status = OK, body = String)), tag = super::AUTH_TAG)]
pub async fn login(
State(state): State<AppState>,
Json(body): Json<LoginBody>,
) -> Result<String, AppError> {
let user = query!(
"SELECT id, real_name, username, email, password_hash, is_admin, created_at
FROM users
WHERE username = $1",
body.username,
)
.fetch_one(&*state.db)
.await?;
let argon2 = Argon2::default();
let hash = PasswordHash::new(&body.password).expect("Password hashing failed");
if !argon2
.verify_password(body.password.as_bytes(), &hash)
.is_ok()
{
return Err(AppError::Error(Errors::Unauthorized));
}
let claims = JWTClaims::new(user.id, user.username, user.real_name, user.email);
let token_str = claims
.sign_with_key(&state.jwt_key)
.context("Failed to sign JWT")?;
Ok(token_str)
}

View File

@@ -1,13 +1,15 @@
use crate::state::AppState;
use crate::AppState;
pub(super) use super::*;
pub mod signup;
pub mod login;
pub const AUTH_TAG: &str = "auth";
pub fn router(state: AppState) -> OpenApiRouter {
pub(super) fn router(state: AppState) -> OpenApiRouter {
OpenApiRouter::new()
.routes(routes!(signup::signup))
.routes(routes!(login::login))
.with_state(state)
}

View File

@@ -1,6 +1,14 @@
use crate::*;
use sqlx::query;
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
use jwt::SignWithKey;
use util::auth::JWTClaims;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SignupBody {
real_name: String,
@@ -10,11 +18,19 @@ pub struct SignupBody {
}
/// Sign up
#[utoipa::path(get, path = "/signup", responses((status = OK, body = String)), tag = super::AUTH_TAG)]
#[utoipa::path(post, path = "/signup", responses((status = OK, body = String)), tag = super::AUTH_TAG)]
pub async fn signup(
State(state): State<AppState>,
Json(body): Json<SignupBody>,
) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(body.password.as_bytes(), &salt)
.expect("Password hashing failed")
.to_string();
let user = query!(
"INSERT INTO users (real_name, username, email, password_hash)
VALUES ($1, $2, $3, $4)
@@ -22,10 +38,16 @@ pub async fn signup(
body.real_name,
body.username,
body.email,
body.password,
hash
)
.fetch_one(&*state.db)
.await?;
Ok(user.id.to_string())
let claims = JWTClaims::new(user.id, user.username, user.real_name, user.email);
let token_str = claims
.sign_with_key(&state.jwt_key)
.context("Failed to sign JWT")?;
Ok(token_str)
}

View File

@@ -0,0 +1,57 @@
use chrono::NaiveDateTime;
use extractors::users::UserId;
use sqlx::query;
use structs::exercise_types::ExerciseType;
use uuid::Uuid;
use crate::*;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CreateExerciseBody {
name: String,
// todo: make this an enum
exercise_type: ExerciseType,
description: String,
body_parts: Vec<String>,
primary_muscles: Vec<String>,
secondary_muscles: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, sqlx::Type, PartialEq, sqlx::FromRow)]
pub struct Exercise {
pub id: Uuid,
pub name: String,
pub exercise_type: ExerciseType,
pub description: String,
pub author_id: Uuid,
pub official: bool,
pub created_at: NaiveDateTime,
}
#[utoipa::path(post, path = "/create", responses((status = OK, body = String)), tag = super::EXERCISES_TAG)]
pub async fn create(
State(state): State<AppState>,
UserId(user): UserId,
Json(body): Json<CreateExerciseBody>,
) -> Result<String, AppError> {
let is_admin = query!("SELECT is_admin FROM users WHERE id = $1", user)
.fetch_one(&*state.db)
.await?
.is_admin;
query!(
r#"
INSERT INTO exercises (name, exercise_type, official, author_id, description)
VALUES ($1, $2, $3, $4, $5)
"#,
body.name,
body.exercise_type as ExerciseType,
is_admin,
user,
body.description
)
.fetch_one(&*state.db)
.await?;
todo!()
}

13
src/v1/exercises/mod.rs Normal file
View File

@@ -0,0 +1,13 @@
use crate::AppState;
pub(super) use super::*;
pub mod create;
pub const EXERCISES_TAG: &str = "exercises";
pub(super) fn router(state: AppState) -> OpenApiRouter {
OpenApiRouter::new()
.routes(routes!(create::create))
.with_state(state)
}

View File

@@ -1,4 +1,16 @@
pub(super) use utoipa_axum::router::OpenApiRouter;
pub(super) use utoipa_axum::routes;
use crate::AppState;
pub mod auth;
pub mod exercises;
pub mod muscles;
pub fn router(state: AppState) -> OpenApiRouter {
OpenApiRouter::new()
.with_state(state.clone())
.nest("/auth", auth::router(state.clone()))
.nest("/exercises", exercises::router(state.clone()))
.nest("/muscles", muscles::router(state.clone()))
}

40
src/v1/muscles/create.rs Normal file
View File

@@ -0,0 +1,40 @@
use extractors::users::UserId;
use sqlx::query;
use crate::*;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CreateMuscleBody {
name: String,
scientific_name: String,
major_group: String,
minor_group: String,
}
#[utoipa::path(post, path = "/create", responses((status = OK, body = String)), tag = super::MUSCLES_TAG)]
pub async fn create(
State(state): State<AppState>,
UserId(user_id): UserId,
Json(body): Json<CreateMuscleBody>,
) -> Result<String, AppError> {
let is_admin = query!("SELECT is_admin FROM users WHERE id = $1", user_id)
.fetch_one(&*state.db)
.await?
.is_admin;
if !is_admin {
return Err(AppError::Error(Errors::Unauthorized));
}
let out = query!(
"INSERT INTO muscles (name, scientific_name, major_group, minor_group) VALUES ($1, $2, $3, $4) RETURNING id",
body.name,
body.scientific_name,
body.major_group,
body.minor_group,
)
.fetch_one(&*state.db)
.await?;
Ok(out.id.to_string())
}

23
src/v1/muscles/get_all.rs Normal file
View File

@@ -0,0 +1,23 @@
// use extractors::users::UserId;
use sqlx::query_as;
use crate::*;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Muscle {
id: String,
name: String,
scientific_name: Option<String>,
major_group: Option<String>,
minor_group: String,
}
#[utoipa::path(get, path = "/all", responses((status = OK, body = Muscle)), tag = super::MUSCLES_TAG)]
pub async fn get_all(State(state): State<AppState>) -> Result<Json<Vec<Muscle>>, AppError> {
let muscles = query_as!(Muscle, "SELECT * FROM muscles")
.fetch_all(&*state.db)
.await?;
Ok(Json(muscles))
}

15
src/v1/muscles/mod.rs Normal file
View File

@@ -0,0 +1,15 @@
use crate::AppState;
pub(super) use super::*;
pub mod create;
pub mod get_all;
pub const MUSCLES_TAG: &str = "muscles";
pub(super) fn router(state: AppState) -> OpenApiRouter {
OpenApiRouter::new()
.routes(routes!(create::create))
.routes(routes!(get_all::get_all))
.with_state(state)
}