simple http server

This commit is contained in:
Dmitri 2026-04-16 11:56:27 +02:00
parent 921a22e645
commit 8f0bbe4e0b
Signed by: kanopo
GPG Key ID: 759ADD40E3132AC7
13 changed files with 1961 additions and 19 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
target/ target/
.env

1722
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,8 +5,12 @@ edition = "2024"
[dependencies] [dependencies]
axum = "0.8.9" axum = "0.8.9"
dotenvy = "0.15.7"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] }
thiserror = "2.0.18"
tokio = { version = "1.52.0", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.52.0", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = "0.3.23" tower-http = { version = "0.6.6", features = ["trace"] }
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }

45
Dockerfile Normal file
View File

@ -0,0 +1,45 @@
# ---- STAGE 1: Build ----
# Use an OpenJDK image that matches the version you develop with.
# It contains the JDK, but not Maven.
FROM eclipse-temurin:25-jdk-jammy AS builder
# Set the working directory
WORKDIR /app
# ---- Caching Dependencies ----
# First, copy the files that define the build, including the Maven Wrapper
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
# Make the wrapper executable
RUN chmod +x ./mvnw
# Run a Maven command to download dependencies.
# Since pom.xml and wrapper files rarely change, this layer will be cached by Docker,
# speeding up subsequent builds significantly.
RUN ./mvnw dependency:go-offline
# ---- Building the Application ----
# Now, copy the source code. If only source code changes, the layers above are cached.
COPY src ./src
# Build the application JAR using the Maven Wrapper
RUN ./mvnw clean package -DskipTests
# ---- STAGE 2: Runtime ----
# Use a lean Eclipse Temurin JRE image for a small and secure final container.
FROM eclipse-temurin:25-jre-jammy
# Set the working directory
WORKDIR /app
# Copy only the built JAR file from the 'builder' stage
COPY --from=builder /app/target/*.jar app.jar
# Expose the application port
EXPOSE 8080
# Command to run the application
ENTRYPOINT ["java", "-jar", "app.jar"]

58
compose.yaml Normal file
View File

@ -0,0 +1,58 @@
services:
# api-prod:
# build: .
# restart: unless-stopped
# container_name: qrcode-api
# ports:
# - "8080:8080"
# environment:
# DB_HOST: db-prod
# env_file:
# - ".env"
# depends_on:
# db-prod:
# condition: service_healthy
# profiles:
# - prod
#
# db-prod:
# image: postgres:18.0-alpine
# restart: unless-stopped
# container_name: qrcode-database-prod
# ports:
# - "${DB_PORT:-5432}:5432"
# environment:
# POSTGRES_USER: ${DB_USERNAME}
# POSTGRES_PASSWORD: ${DB_PASSWORD}
# POSTGRES_DB: ${DB_NAME}
# volumes:
# - postgres_data:/var/lib/postgresql/data
# profiles:
# - prod
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME}"]
# interval: 5s
# timeout: 3s
# retries: 10
db-dev:
image: postgres:18.0-alpine
restart: unless-stopped
container_name: rhythm-db-dev
ports:
- "${DB_PORT}:5432"
environment:
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
profiles:
- dev
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME}"]
interval: 5s
timeout: 3s
retries: 10
volumes:
postgres_data:

52
src/config.rs Normal file
View File

@ -0,0 +1,52 @@
use std::env;
use thiserror::Error;
#[derive(Debug, Clone)]
pub struct Config {
pub db_user: String,
pub db_password: String,
pub db_host: String,
pub db_port: u16,
pub db_name: String,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("missing env var: {0}")]
MissingVar(&'static str),
#[error("invalid DB_PORT: {0}")]
InvalidPort(String),
}
impl Config {
pub fn from_env() -> Result<Self, ConfigError> {
dotenvy::dotenv().ok();
let db_user =
env::var("DB_USERNAME").map_err(|_| ConfigError::MissingVar("DB_USERNAME"))?;
let db_password =
env::var("DB_PASSWORD").map_err(|_| ConfigError::MissingVar("DB_PASSWORD"))?;
let db_host = env::var("DB_HOST").map_err(|_| ConfigError::MissingVar("DB_HOST"))?;
let db_name = env::var("DB_NAME").map_err(|_| ConfigError::MissingVar("DB_NAME"))?;
let db_port_raw = env::var("DB_PORT").map_err(|_| ConfigError::MissingVar("DB_PORT"))?;
let db_port = db_port_raw
.parse::<u16>()
.map_err(|_| ConfigError::InvalidPort(db_port_raw))?;
Ok(Self {
db_user,
db_password,
db_host,
db_port,
db_name,
})
}
pub fn database_url(&self) -> String {
format!(
"postgres://{}:{}@{}:{}/{}",
self.db_user, self.db_password, self.db_host, self.db_port, self.db_name
)
}
}

9
src/db/database.rs Normal file
View File

@ -0,0 +1,9 @@
use sqlx::{PgPool, postgres::PgPoolOptions};
pub async fn create_pool(database_url: &str) -> Result<PgPool, crate::error::AppError> {
PgPoolOptions::new()
.max_connections(10)
.connect(database_url)
.await
.map_err(crate::error::AppError::from)
}

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

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

11
src/error.rs Normal file
View File

@ -0,0 +1,11 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error(transparent)]
Config(#[from] crate::config::ConfigError),
#[error(transparent)]
Db(#[from] sqlx::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
}

View File

@ -1 +1 @@
pub mod public; pub mod public_router;

View File

@ -1,9 +0,0 @@
use axum::{Router, routing::get};
pub fn router() -> Router {
Router::new().route("/health", get(get_health()))
}
fn get_health() -> &'static str {
"{'status': 'ok'}"
}

18
src/http/public_router.rs Normal file
View File

@ -0,0 +1,18 @@
use axum::{Json, Router, routing::get};
use serde::Serialize;
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
}
pub fn router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
Router::new().route("/health", get(get_health))
}
async fn get_health() -> Json<HealthResponse> {
Json(HealthResponse { status: "ok" })
}

View File

@ -1,11 +1,49 @@
mod config;
mod db;
mod error;
mod http; mod http;
use crate::db::database;
use crate::error::AppError;
use crate::http::public_router;
use axum::Router; use axum::Router;
use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer};
use tracing::{Level, info};
use tracing_subscriber::{EnvFilter, fmt};
#[derive(Clone)]
struct AppState {
#[allow(dead_code)]
db: sqlx::PgPool,
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,tower_http=info,sqlx=warn"));
fmt().with_env_filter(filter).init();
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() -> Result<(), AppError> {
let app = Router::new().merge(http::public::router()); init_tracing();
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); let cfg = config::Config::from_env()?;
axum::serve(listener, app).await.unwrap(); let pool = database::create_pool(&cfg.database_url()).await?;
info!("database connection established");
let state = AppState { db: pool };
let app = Router::new()
.merge(public_router::router())
.with_state(state)
.layer(
TraceLayer::new_for_http()
.on_request(DefaultOnRequest::new().level(Level::INFO))
.on_response(DefaultOnResponse::new().level(Level::INFO)),
);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
info!("server listening on 0.0.0.0:8080");
axum::serve(listener, app).await?;
Ok(())
} }