Add utoipa and Scalar for API documentation

- Add utoipa and utoipa-scalar dependencies
- Add ToSchema derives to all models
- Add OpenAPI path annotations to auth, users, and health handlers
- Create openapi.rs module with API documentation
- Serve Scalar UI at /docs endpoint
- Include JWT bearer auth security scheme

Co-Authored-By: Warp <agent@warp.dev>
This commit is contained in:
VinceC
2026-01-20 01:28:17 -06:00
parent c26a1820d5
commit cac4b83140
16 changed files with 235 additions and 37 deletions

View File

@@ -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"

View File

@@ -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)
);

View File

@@ -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<User> 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<AppState>,
Json(payload): Json<LoginRequest>,
@@ -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<AppState>,
Json(payload): Json<RegisterRequest>,

View File

@@ -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<AppState>) -> (StatusCode, Json<HealthResponse>) {
// Check database connectivity
let db_status = match sqlx::query("SELECT 1").fetch_one(&state.pool).await {

View File

@@ -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<User> 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<AppState>,

View File

@@ -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

View File

@@ -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<dyn std::error::Error>> {
// Build router
let app = Router::new()
// API Documentation
.merge(Scalar::with_url("/docs", ApiDoc::openapi()))
// Health check
.route("/health", get(health_check))
// Auth routes

View File

@@ -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<Utc>,
@@ -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,

View File

@@ -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<i32> 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<Utc>,
@@ -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<String>,
pub date_start: Option<String>,

View File

@@ -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<Utc>,
@@ -12,7 +13,7 @@ pub struct LadderTeam {
}
/// Join ladder request
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct JoinLadderRequest {
pub team_id: i32,
}

View File

@@ -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<i32> 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<i32> 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<Utc>,
@@ -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<String>,
}

View File

@@ -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<i32> 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<Utc>,
@@ -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,

View File

@@ -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<Utc>,
@@ -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<Utc>,
@@ -52,14 +53,14 @@ impl From<Team> for TeamWithStats {
}
/// Create team request
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateTeam {
pub name: String,
pub bio: Option<String>,
}
/// Update team request
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateTeam {
pub name: Option<String>,
pub bio: Option<String>,
@@ -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,

View File

@@ -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<i32> 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<Utc>,
@@ -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,
}

View File

@@ -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<Utc>,
@@ -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<Utc>,
@@ -57,7 +58,7 @@ impl From<User> 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<String>,
pub lastname: Option<String>,
@@ -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,

128
src/openapi.rs Normal file
View File

@@ -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"),
),
);
}
}
}