mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 12:26:39 -06:00
feat: add PostgreSQL database integration for projects
- Add SQLx with Postgres support and migration system - Create projects table with status enum and auto-updated timestamps - Implement database queries and API response conversion layer - Add Justfile commands for database management and seeding - Integrate health checks for both Bun and database connectivity
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL")?;
|
||||
let pool = PgPool::connect(&database_url).await?;
|
||||
|
||||
println!("🌱 Seeding database...");
|
||||
|
||||
// Clear existing data
|
||||
sqlx::query("DELETE FROM projects").execute(&pool).await?;
|
||||
|
||||
// Seed projects with diverse data
|
||||
let projects = vec![
|
||||
(
|
||||
"xevion-dev",
|
||||
"xevion.dev",
|
||||
"Personal portfolio site with fuzzy tag discovery and ISR caching",
|
||||
"active",
|
||||
Some("Xevion/xevion.dev"),
|
||||
None,
|
||||
10,
|
||||
Some("fa-globe"),
|
||||
),
|
||||
(
|
||||
"contest",
|
||||
"Contest",
|
||||
"Archive and analysis platform for competitive programming problems",
|
||||
"active",
|
||||
Some("Xevion/contest"),
|
||||
Some("https://contest.xevion.dev"),
|
||||
9,
|
||||
Some("fa-trophy"),
|
||||
),
|
||||
(
|
||||
"reforge",
|
||||
"Reforge",
|
||||
"Rust library for parsing and manipulating Replay files from Rocket League",
|
||||
"maintained",
|
||||
Some("Xevion/reforge"),
|
||||
None,
|
||||
8,
|
||||
Some("fa-file-code"),
|
||||
),
|
||||
(
|
||||
"algorithms",
|
||||
"Algorithms",
|
||||
"Collection of algorithm implementations and data structures in Python",
|
||||
"archived",
|
||||
Some("Xevion/algorithms"),
|
||||
None,
|
||||
5,
|
||||
Some("fa-brain"),
|
||||
),
|
||||
(
|
||||
"wordplay",
|
||||
"WordPlay",
|
||||
"Interactive word game with real-time multiplayer using WebSockets",
|
||||
"maintained",
|
||||
Some("Xevion/wordplay"),
|
||||
Some("https://wordplay.example.com"),
|
||||
7,
|
||||
Some("fa-gamepad"),
|
||||
),
|
||||
(
|
||||
"dotfiles",
|
||||
"Dotfiles",
|
||||
"Personal configuration files and development environment setup scripts",
|
||||
"active",
|
||||
Some("Xevion/dotfiles"),
|
||||
None,
|
||||
6,
|
||||
Some("fa-terminal"),
|
||||
),
|
||||
];
|
||||
|
||||
let project_count = projects.len();
|
||||
|
||||
for (slug, title, desc, status, repo, demo, priority, icon) in projects {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO projects (slug, title, description, status, github_repo, demo_url, priority, icon)
|
||||
VALUES ($1, $2, $3, $4::project_status, $5, $6, $7, $8)
|
||||
"#,
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(title)
|
||||
.bind(desc)
|
||||
.bind(status)
|
||||
.bind(repo)
|
||||
.bind(demo)
|
||||
.bind(priority)
|
||||
.bind(icon)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
println!("✅ Seeded {} projects", project_count);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgPool, postgres::PgPoolOptions};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Database types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
|
||||
#[sqlx(type_name = "project_status", rename_all = "lowercase")]
|
||||
pub enum ProjectStatus {
|
||||
Active,
|
||||
Maintained,
|
||||
Archived,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
// Database model
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
#[allow(dead_code)]
|
||||
pub struct DbProject {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub status: ProjectStatus,
|
||||
pub github_repo: Option<String>,
|
||||
pub demo_url: Option<String>,
|
||||
pub priority: i32,
|
||||
pub icon: Option<String>,
|
||||
pub last_github_activity: Option<OffsetDateTime>,
|
||||
pub created_at: OffsetDateTime,
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
// API response types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiProjectLink {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiProject {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "shortDescription")]
|
||||
pub short_description: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
pub links: Vec<ApiProjectLink>,
|
||||
}
|
||||
|
||||
impl DbProject {
|
||||
/// Convert database project to API response format
|
||||
pub fn to_api_project(&self) -> ApiProject {
|
||||
let mut links = Vec::new();
|
||||
|
||||
if let Some(ref repo) = self.github_repo {
|
||||
links.push(ApiProjectLink {
|
||||
url: format!("https://github.com/{}", repo),
|
||||
title: Some("GitHub".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref demo) = self.demo_url {
|
||||
links.push(ApiProjectLink {
|
||||
url: demo.clone(),
|
||||
title: Some("Demo".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
ApiProject {
|
||||
id: self.id.to_string(),
|
||||
name: self.title.clone(),
|
||||
short_description: self.description.clone(),
|
||||
icon: self.icon.clone(),
|
||||
links,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection pool creation
|
||||
pub async fn create_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
|
||||
PgPoolOptions::new()
|
||||
.max_connections(20)
|
||||
.acquire_timeout(std::time::Duration::from_secs(3))
|
||||
.connect(database_url)
|
||||
.await
|
||||
}
|
||||
|
||||
// Queries
|
||||
pub async fn get_public_projects(pool: &PgPool) -> Result<Vec<DbProject>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
DbProject,
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
slug,
|
||||
title,
|
||||
description,
|
||||
status as "status: ProjectStatus",
|
||||
github_repo,
|
||||
demo_url,
|
||||
priority,
|
||||
icon,
|
||||
last_github_activity,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM projects
|
||||
WHERE status != 'hidden'
|
||||
ORDER BY priority DESC, created_at DESC
|
||||
"#
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn health_check(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!("SELECT 1 as check")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
+83
-74
@@ -6,7 +6,6 @@ use axum::{
|
||||
routing::any,
|
||||
};
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -16,6 +15,7 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx
|
||||
|
||||
mod assets;
|
||||
mod config;
|
||||
mod db;
|
||||
mod formatter;
|
||||
mod health;
|
||||
mod middleware;
|
||||
@@ -68,9 +68,30 @@ fn init_tracing() {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Load .env file if present
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
// Parse args early to allow --help to work without database
|
||||
let args = Args::parse();
|
||||
|
||||
init_tracing();
|
||||
|
||||
let args = Args::parse();
|
||||
// Load database URL from environment (fail-fast)
|
||||
let database_url =
|
||||
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set in environment");
|
||||
|
||||
// Create connection pool
|
||||
let pool = db::create_pool(&database_url)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
|
||||
// Run migrations on startup
|
||||
sqlx::migrate!()
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Failed to run database migrations");
|
||||
|
||||
tracing::info!("Database connected and migrations applied");
|
||||
|
||||
if args.listen.is_empty() {
|
||||
eprintln!("Error: At least one --listen address is required");
|
||||
@@ -108,13 +129,15 @@ async fn main() {
|
||||
let downstream_url_for_health = args.downstream.clone();
|
||||
let http_client_for_health = http_client.clone();
|
||||
let unix_client_for_health = unix_client.clone();
|
||||
let pool_for_health = pool.clone();
|
||||
|
||||
let health_checker = Arc::new(HealthChecker::new(move || {
|
||||
let downstream_url = downstream_url_for_health.clone();
|
||||
let http_client = http_client_for_health.clone();
|
||||
let unix_client = unix_client_for_health.clone();
|
||||
let pool = pool_for_health.clone();
|
||||
|
||||
async move { perform_health_check(downstream_url, http_client, unix_client).await }
|
||||
async move { perform_health_check(downstream_url, http_client, unix_client, Some(pool)).await }
|
||||
}));
|
||||
|
||||
let tarpit_config = TarpitConfig::from_env();
|
||||
@@ -137,6 +160,7 @@ async fn main() {
|
||||
unix_client,
|
||||
health_checker,
|
||||
tarpit_state,
|
||||
pool: pool.clone(),
|
||||
});
|
||||
|
||||
// Regenerate common OGP images on startup
|
||||
@@ -238,6 +262,7 @@ pub struct AppState {
|
||||
unix_client: Option<reqwest::Client>,
|
||||
health_checker: Arc<HealthChecker>,
|
||||
tarpit_state: Arc<TarpitState>,
|
||||
pool: sqlx::PgPool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -289,10 +314,7 @@ fn api_routes() -> Router<Arc<AppState>> {
|
||||
"/health",
|
||||
axum::routing::get(health_handler).head(health_handler),
|
||||
)
|
||||
.route(
|
||||
"/projects",
|
||||
axum::routing::get(projects_handler).head(projects_handler),
|
||||
)
|
||||
.route("/projects", axum::routing::get(projects_handler))
|
||||
.fallback(api_404_and_method_handler)
|
||||
}
|
||||
|
||||
@@ -423,55 +445,25 @@ async fn api_404_handler(uri: axum::http::Uri) -> impl IntoResponse {
|
||||
api_404_and_method_handler(req).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ProjectLink {
|
||||
url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Project {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(rename = "shortDescription")]
|
||||
short_description: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
icon: Option<String>,
|
||||
links: Vec<ProjectLink>,
|
||||
}
|
||||
|
||||
async fn projects_handler() -> impl IntoResponse {
|
||||
let projects = vec![
|
||||
Project {
|
||||
id: "1".to_string(),
|
||||
name: "xevion.dev".to_string(),
|
||||
short_description: "Personal portfolio with fuzzy tag discovery".to_string(),
|
||||
icon: None,
|
||||
links: vec![ProjectLink {
|
||||
url: "https://github.com/Xevion/xevion.dev".to_string(),
|
||||
title: Some("GitHub".to_string()),
|
||||
}],
|
||||
},
|
||||
Project {
|
||||
id: "2".to_string(),
|
||||
name: "Contest".to_string(),
|
||||
short_description: "Competitive programming problem archive".to_string(),
|
||||
icon: None,
|
||||
links: vec![
|
||||
ProjectLink {
|
||||
url: "https://github.com/Xevion/contest".to_string(),
|
||||
title: Some("GitHub".to_string()),
|
||||
},
|
||||
ProjectLink {
|
||||
url: "https://contest.xevion.dev".to_string(),
|
||||
title: Some("Demo".to_string()),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Json(projects)
|
||||
async fn projects_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
match db::get_public_projects(&state.pool).await {
|
||||
Ok(projects) => {
|
||||
let api_projects: Vec<db::ApiProject> =
|
||||
projects.into_iter().map(|p| p.to_api_project()).collect();
|
||||
Json(api_projects).into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(error = %err, "Failed to fetch projects from database");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "Internal server error",
|
||||
"message": "Failed to fetch projects"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_tarpit(state: &TarpitState, path: &str) -> bool {
|
||||
@@ -687,6 +679,7 @@ async fn perform_health_check(
|
||||
downstream_url: String,
|
||||
http_client: reqwest::Client,
|
||||
unix_client: Option<reqwest::Client>,
|
||||
pool: Option<sqlx::PgPool>,
|
||||
) -> bool {
|
||||
let url = if downstream_url.starts_with('/') || downstream_url.starts_with("./") {
|
||||
"http://localhost/internal/health".to_string()
|
||||
@@ -700,24 +693,40 @@ async fn perform_health_check(
|
||||
&http_client
|
||||
};
|
||||
|
||||
match tokio::time::timeout(Duration::from_secs(5), client.get(&url).send()).await {
|
||||
Ok(Ok(response)) => {
|
||||
let is_success = response.status().is_success();
|
||||
if !is_success {
|
||||
tracing::warn!(
|
||||
status = response.status().as_u16(),
|
||||
"Health check failed: Bun returned non-success status"
|
||||
);
|
||||
let bun_healthy =
|
||||
match tokio::time::timeout(Duration::from_secs(5), client.get(&url).send()).await {
|
||||
Ok(Ok(response)) => {
|
||||
let is_success = response.status().is_success();
|
||||
if !is_success {
|
||||
tracing::warn!(
|
||||
status = response.status().as_u16(),
|
||||
"Health check failed: Bun returned non-success status"
|
||||
);
|
||||
}
|
||||
is_success
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
tracing::error!(error = %err, "Health check failed: cannot reach Bun");
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::error!("Health check failed: timeout after 5s");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// Check database
|
||||
let db_healthy = if let Some(pool) = pool {
|
||||
match db::health_check(&pool).await {
|
||||
Ok(_) => true,
|
||||
Err(err) => {
|
||||
tracing::error!(error = %err, "Database health check failed");
|
||||
false
|
||||
}
|
||||
is_success
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
tracing::error!(error = %err, "Health check failed: cannot reach Bun");
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::error!("Health check failed: timeout after 5s");
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
bun_healthy && db_healthy
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user