diff --git a/Cargo.toml b/Cargo.toml index 909302c..0435691 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,10 @@ rand = "0.8" base64 = "0.22" libm = "0.2" # Math functions for rating calculations +# API Documentation +utoipa = { version = "4", features = ["axum_extras", "chrono"] } +utoipa-scalar = { version = "0.1", features = ["axum"] } + # S3 compatible storage aws-sdk-s3 = "1" aws-config = "1" diff --git a/migrations/20260119000001_initial_schema.sql b/migrations/20260119000001_initial_schema.sql index 5f66196..e67abe7 100644 --- a/migrations/20260119000001_initial_schema.sql +++ b/migrations/20260119000001_initial_schema.sql @@ -41,6 +41,8 @@ CREATE TABLE IF NOT EXISTS team_players ( date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(), team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, player_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + game_name TEXT NOT NULL DEFAULT '', + game_mode TEXT NOT NULL DEFAULT '', position VARCHAR(20) NOT NULL DEFAULT 'member', status INTEGER NOT NULL DEFAULT 0, UNIQUE(team_id, player_id) @@ -70,6 +72,12 @@ CREATE TABLE IF NOT EXISTS ladder_teams ( date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(), ladder_id INTEGER NOT NULL REFERENCES ladders(id) ON DELETE CASCADE, team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status VARCHAR(255) NOT NULL DEFAULT 'active', + seed INTEGER, + result_position INTEGER, + win_count INTEGER NOT NULL DEFAULT 0, + loss_count INTEGER NOT NULL DEFAULT 0, UNIQUE(ladder_id, team_id) ); diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 0dd6e4d..74b84c4 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -1,6 +1,7 @@ use axum::{extract::State, http::StatusCode, Json}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; +use utoipa::ToSchema; use validator::Validate; use crate::{ @@ -11,7 +12,7 @@ use crate::{ }; /// Login request -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct LoginRequest { #[validate(email(message = "Invalid email format"))] pub email: String, @@ -20,14 +21,14 @@ pub struct LoginRequest { } /// Login response -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct AuthResponse { pub token: String, pub user: UserResponse, } /// User data in response -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct UserResponse { pub id: i32, pub email: String, @@ -53,7 +54,7 @@ impl From for UserResponse { } /// Register request -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct RegisterRequest { #[validate(length(min = 1, max = 100, message = "First name is required"))] pub firstname: String, @@ -76,6 +77,16 @@ pub struct AppState { } /// POST /api/auth/login +#[utoipa::path( + post, + path = "/api/auth/login", + tag = "auth", + request_body = LoginRequest, + responses( + (status = 200, description = "Login successful", body = AuthResponse), + (status = 401, description = "Invalid email or password") + ) +)] pub async fn login( State(state): State, Json(payload): Json, @@ -113,6 +124,16 @@ pub async fn login( } /// POST /api/auth/register +#[utoipa::path( + post, + path = "/api/auth/register", + tag = "auth", + request_body = RegisterRequest, + responses( + (status = 201, description = "Registration successful", body = AuthResponse), + (status = 409, description = "Email or username already exists") + ) +)] pub async fn register( State(state): State, Json(payload): Json, diff --git a/src/handlers/health.rs b/src/handlers/health.rs index 8fb4b41..33c4131 100644 --- a/src/handlers/health.rs +++ b/src/handlers/health.rs @@ -1,9 +1,10 @@ use axum::{extract::State, http::StatusCode, Json}; use serde::Serialize; +use utoipa::ToSchema; use super::auth::AppState; -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct HealthResponse { pub status: String, pub version: String, @@ -12,6 +13,15 @@ pub struct HealthResponse { /// Health check endpoint /// GET /health +#[utoipa::path( + get, + path = "/health", + tag = "health", + responses( + (status = 200, description = "API is healthy", body = HealthResponse), + (status = 503, description = "Database connection error") + ) +)] pub async fn health_check(State(state): State) -> (StatusCode, Json) { // Check database connectivity let db_status = match sqlx::query("SELECT 1").fetch_one(&state.pool).await { diff --git a/src/handlers/users.rs b/src/handlers/users.rs index b48c96e..22e806d 100644 --- a/src/handlers/users.rs +++ b/src/handlers/users.rs @@ -1,5 +1,6 @@ use axum::{extract::State, Json}; use serde::Serialize; +use utoipa::ToSchema; use crate::{ auth::AuthUser, @@ -10,7 +11,7 @@ use crate::{ use super::auth::AppState; /// User profile response -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct UserProfileResponse { pub id: i32, pub email: String, @@ -39,6 +40,18 @@ impl From for UserProfileResponse { /// GET /api/users/me /// Get current authenticated user's profile +#[utoipa::path( + get, + path = "/api/users/me", + tag = "users", + responses( + (status = 200, description = "Current user profile", body = UserProfileResponse), + (status = 401, description = "Unauthorized") + ), + security( + ("bearer_auth" = []) + ) +)] pub async fn get_current_user( user: AuthUser, State(state): State, diff --git a/src/lib.rs b/src/lib.rs index 0383529..9d3ab77 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod db; pub mod error; pub mod handlers; pub mod models; +pub mod openapi; pub mod services; // Re-export commonly used types diff --git a/src/main.rs b/src/main.rs index 9a0409c..4f4969a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,8 @@ use tower_http::{ trace::TraceLayer, }; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use utoipa::OpenApi; +use utoipa_scalar::{Scalar, Servable}; use vrbattles_api::{ auth::middleware::JwtSecret, @@ -38,6 +40,7 @@ use vrbattles_api::{ }, users::get_current_user, }, + openapi::ApiDoc, services::storage::StorageService, }; @@ -86,6 +89,8 @@ async fn main() -> Result<(), Box> { // Build router let app = Router::new() + // API Documentation + .merge(Scalar::with_url("/docs", ApiDoc::openapi())) // Health check .route("/health", get(health_check)) // Auth routes diff --git a/src/models/featured_player.rs b/src/models/featured_player.rs index 61e8cd6..8db205b 100644 --- a/src/models/featured_player.rs +++ b/src/models/featured_player.rs @@ -1,9 +1,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; /// Featured player model representing the featured_players table -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow, ToSchema)] pub struct FeaturedPlayer { pub id: i32, pub date_created: DateTime, @@ -12,7 +13,7 @@ pub struct FeaturedPlayer { } /// Featured player with user details (joined query result) -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow, ToSchema)] pub struct FeaturedPlayerWithUser { pub id: i32, pub player_id: i32, diff --git a/src/models/ladder.rs b/src/models/ladder.rs index c464290..c64be05 100644 --- a/src/models/ladder.rs +++ b/src/models/ladder.rs @@ -1,9 +1,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; /// Ladder status -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] pub enum LadderStatus { Open = 0, Closed = 1, @@ -19,7 +20,7 @@ impl From for LadderStatus { } /// Ladder model representing the ladders table -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow, ToSchema)] pub struct Ladder { pub id: i32, pub date_created: DateTime, @@ -32,7 +33,7 @@ pub struct Ladder { } /// Create ladder request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct CreateLadder { pub name: String, pub date_start: String, @@ -40,7 +41,7 @@ pub struct CreateLadder { } /// Update ladder request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct UpdateLadder { pub name: Option, pub date_start: Option, diff --git a/src/models/ladder_team.rs b/src/models/ladder_team.rs index 0c97d20..9c60676 100644 --- a/src/models/ladder_team.rs +++ b/src/models/ladder_team.rs @@ -1,9 +1,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; /// Ladder team model representing the ladder_teams table -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow, ToSchema)] pub struct LadderTeam { pub id: i32, pub date_created: DateTime, @@ -12,7 +13,7 @@ pub struct LadderTeam { } /// Join ladder request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct JoinLadderRequest { pub team_id: i32, } diff --git a/src/models/match_model.rs b/src/models/match_model.rs index 4a3567c..ad852cf 100644 --- a/src/models/match_model.rs +++ b/src/models/match_model.rs @@ -1,9 +1,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; /// Match status values -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] pub enum MatchStatus { Open = 0, // Initial state Scheduled = 1, // Both teams accepted @@ -25,7 +26,7 @@ impl From for MatchStatus { } /// Team challenge status -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] pub enum TeamChallengeStatus { Challenging = 0, // Sent challenge PendingResponse = 1, // Waiting for response @@ -47,7 +48,7 @@ impl From for TeamChallengeStatus { } /// Match model representing the matches table -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow, ToSchema)] pub struct Match { pub id: i32, pub date_created: DateTime, @@ -62,14 +63,14 @@ pub struct Match { } /// Create match/challenge request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct CreateChallengeRequest { pub to_team_id: i32, pub challenge_date: String, } /// Accept/Counter challenge request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct RespondChallengeRequest { pub challenge_date: Option, } diff --git a/src/models/match_round.rs b/src/models/match_round.rs index d44bf7f..480fe12 100644 --- a/src/models/match_round.rs +++ b/src/models/match_round.rs @@ -1,9 +1,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; /// Score acceptance status -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] pub enum ScoreAcceptanceStatus { Pending = 0, Submitted = 1, @@ -23,7 +24,7 @@ impl From for ScoreAcceptanceStatus { } /// Match round model representing the match_rounds table -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow, ToSchema)] pub struct MatchRound { pub id: i32, pub date_created: DateTime, @@ -35,7 +36,7 @@ pub struct MatchRound { } /// Submit score request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct SubmitScoreRequest { pub team_1_score: i32, pub team_2_score: i32, diff --git a/src/models/team.rs b/src/models/team.rs index 5c0fe19..60816db 100644 --- a/src/models/team.rs +++ b/src/models/team.rs @@ -1,9 +1,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; /// Team model representing the teams table -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow, ToSchema)] pub struct Team { pub id: i32, pub date_created: DateTime, @@ -20,7 +21,7 @@ pub struct Team { } /// Team with full stats (for API responses) -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ToSchema)] pub struct TeamWithStats { pub id: i32, pub date_created: DateTime, @@ -52,14 +53,14 @@ impl From for TeamWithStats { } /// Create team request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct CreateTeam { pub name: String, pub bio: Option, } /// Update team request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct UpdateTeam { pub name: Option, pub bio: Option, @@ -67,7 +68,7 @@ pub struct UpdateTeam { } /// Team member info -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ToSchema)] pub struct TeamMember { pub player_id: i32, pub username: String, diff --git a/src/models/team_player.rs b/src/models/team_player.rs index 218f4ac..5c7da79 100644 --- a/src/models/team_player.rs +++ b/src/models/team_player.rs @@ -1,9 +1,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; /// Team player positions -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] #[serde(rename_all = "lowercase")] pub enum PlayerPosition { Captain, @@ -32,7 +33,7 @@ impl PlayerPosition { } /// Team player status -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] pub enum TeamPlayerStatus { Pending = 0, Active = 1, @@ -48,7 +49,7 @@ impl From for TeamPlayerStatus { } /// Team player model representing the team_players table -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow, ToSchema)] pub struct TeamPlayer { pub id: i32, pub date_created: DateTime, @@ -59,13 +60,13 @@ pub struct TeamPlayer { } /// Join team request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct JoinTeamRequest { pub team_id: i32, } /// Change position request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct ChangePositionRequest { pub position: String, } diff --git a/src/models/user.rs b/src/models/user.rs index 356dbd3..61415c3 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,9 +1,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; /// User model representing the users table -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow, ToSchema)] pub struct User { pub id: i32, pub date_registered: DateTime, @@ -25,7 +26,7 @@ pub struct User { } /// User public profile (without sensitive data) -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ToSchema)] pub struct UserProfile { pub id: i32, pub date_registered: DateTime, @@ -57,7 +58,7 @@ impl From for UserProfile { } /// Create user request (internal use) -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct CreateUser { pub firstname: String, pub lastname: String, @@ -67,7 +68,7 @@ pub struct CreateUser { } /// Update user request -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct UpdateUser { pub firstname: Option, pub lastname: Option, @@ -76,7 +77,7 @@ pub struct UpdateUser { } /// Player stats response -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ToSchema)] pub struct PlayerStats { pub id: i32, pub username: String, diff --git a/src/openapi.rs b/src/openapi.rs new file mode 100644 index 0000000..3d944a2 --- /dev/null +++ b/src/openapi.rs @@ -0,0 +1,128 @@ +use utoipa::OpenApi; + +use crate::{ + handlers::{ + auth::{AuthResponse, LoginRequest, RegisterRequest, UserResponse}, + health::HealthResponse, + users::UserProfileResponse, + }, + models::{ + featured_player::{FeaturedPlayer, FeaturedPlayerWithUser}, + ladder::{CreateLadder, Ladder, LadderStatus, UpdateLadder}, + ladder_team::{JoinLadderRequest, LadderTeam}, + match_model::{CreateChallengeRequest, Match, MatchStatus, RespondChallengeRequest, TeamChallengeStatus}, + match_round::{MatchRound, ScoreAcceptanceStatus, SubmitScoreRequest}, + team::{CreateTeam, Team, TeamMember, TeamWithStats, UpdateTeam}, + team_player::{ChangePositionRequest, JoinTeamRequest, PlayerPosition, TeamPlayer, TeamPlayerStatus}, + user::{CreateUser, PlayerStats, UpdateUser, User, UserProfile}, + }, +}; + +#[derive(OpenApi)] +#[openapi( + info( + title = "VRBattles API", + version = "1.0.0", + description = "VRBattles Esports Platform API - Manage teams, matches, tournaments, and rankings for competitive VR gaming.", + contact( + name = "VRBattles Team", + url = "https://vrbattles.gg" + ), + license( + name = "Proprietary" + ) + ), + servers( + (url = "https://api.vrb.gg", description = "Production server"), + (url = "http://localhost:3000", description = "Local development") + ), + tags( + (name = "health", description = "Health check"), + (name = "auth", description = "Authentication endpoints"), + (name = "users", description = "User management"), + (name = "teams", description = "Team management"), + (name = "ladders", description = "Ladder/league management"), + (name = "matches", description = "Match and challenge management"), + (name = "players", description = "Player profiles and rankings"), + (name = "uploads", description = "File uploads (logos, profile pictures)") + ), + paths( + // Health + crate::handlers::health::health_check, + // Auth + crate::handlers::auth::login, + crate::handlers::auth::register, + // Users + crate::handlers::users::get_current_user, + ), + components( + schemas( + // Health + HealthResponse, + // Auth + LoginRequest, + RegisterRequest, + AuthResponse, + UserResponse, + UserProfileResponse, + // User + User, + UserProfile, + CreateUser, + UpdateUser, + PlayerStats, + // Team + Team, + TeamWithStats, + CreateTeam, + UpdateTeam, + TeamMember, + // Team Player + TeamPlayer, + JoinTeamRequest, + ChangePositionRequest, + PlayerPosition, + TeamPlayerStatus, + // Ladder + Ladder, + LadderStatus, + CreateLadder, + UpdateLadder, + LadderTeam, + JoinLadderRequest, + // Match + Match, + MatchStatus, + TeamChallengeStatus, + CreateChallengeRequest, + RespondChallengeRequest, + // Match Round + MatchRound, + ScoreAcceptanceStatus, + SubmitScoreRequest, + // Featured Player + FeaturedPlayer, + FeaturedPlayerWithUser, + ) + ), + modifiers(&SecurityAddon) +)] +pub struct ApiDoc; + +struct SecurityAddon; + +impl utoipa::Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "bearer_auth", + utoipa::openapi::security::SecurityScheme::Http( + utoipa::openapi::security::Http::new( + utoipa::openapi::security::HttpAuthScheme::Bearer, + ) + .bearer_format("JWT"), + ), + ); + } + } +}