This commit is contained in:
2026-04-09 14:55:37 +02:00
parent 8be455ba98
commit 4a85efc270
30 changed files with 4895 additions and 1 deletions
+113
View File
@@ -0,0 +1,113 @@
use std::{sync::Arc, time::Instant};
use chrono::{DateTime, Utc};
use redis::Client as RedisClient;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::{config::AppConfig, error::AppError};
#[derive(Clone)]
pub struct AppState {
pub config: AppConfig,
pub started_at: Instant,
pub database_pool: Option<PgPool>,
pub redis_client: Option<RedisClient>,
current_user_id: Uuid,
session: Arc<RwLock<Option<SessionSnapshot>>>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SessionStartRequest {
pub locale_code: Option<String>,
pub device_platform: Option<String>,
pub device_model: Option<String>,
pub os_version: Option<String>,
pub app_version: Option<String>,
pub experiment_variant: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SessionSnapshot {
pub session_id: Uuid,
pub user_id: Uuid,
pub started_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
pub experiment_variant: String,
pub app_version: String,
pub device_model: Option<String>,
pub os_version: Option<String>,
pub locale_code: String,
pub device_platform: String,
}
impl AppState {
pub fn new(
config: AppConfig,
database_pool: Option<PgPool>,
redis_client: Option<RedisClient>,
) -> Self {
Self {
config,
started_at: Instant::now(),
database_pool,
redis_client,
current_user_id: Uuid::new_v4(),
session: Arc::new(RwLock::new(None)),
}
}
pub async fn start_session(&self, request: SessionStartRequest) -> SessionSnapshot {
let session = SessionSnapshot {
session_id: Uuid::new_v4(),
user_id: self.current_user_id,
started_at: Utc::now(),
ended_at: None,
experiment_variant: request
.experiment_variant
.unwrap_or_else(|| self.config.default_experiment_variant.clone()),
app_version: request
.app_version
.unwrap_or_else(|| self.config.app_version.clone()),
device_model: request.device_model,
os_version: request.os_version,
locale_code: request
.locale_code
.unwrap_or_else(|| self.config.default_locale.clone()),
device_platform: request
.device_platform
.unwrap_or_else(|| self.config.default_device_platform.clone()),
};
*self.session.write().await = Some(session.clone());
session
}
pub async fn current_session(&self) -> Option<SessionSnapshot> {
self.session.read().await.clone()
}
pub async fn end_session(&self) -> Result<SessionSnapshot, AppError> {
let mut guard = self.session.write().await;
let mut session = guard
.clone()
.ok_or_else(|| AppError::not_found("No active session"))?;
session.ended_at = Some(Utc::now());
*guard = Some(session.clone());
Ok(session)
}
pub fn database_ready(&self) -> bool {
self.database_pool.is_some()
}
pub fn redis_ready(&self) -> bool {
self.redis_client.is_some()
}
pub fn uptime_ms(&self) -> u128 {
self.started_at.elapsed().as_millis()
}
}
+82
View File
@@ -0,0 +1,82 @@
use std::net::SocketAddr;
use anyhow::Context;
use serde::Deserialize;
use validator::Validate;
#[derive(Clone, Debug, Deserialize, Validate)]
pub struct AppConfig {
#[validate(length(min = 1))]
pub service_name: String,
#[validate(length(min = 1))]
pub environment: String,
pub bind_addr: SocketAddr,
#[validate(length(min = 1))]
pub app_version: String,
#[validate(length(min = 1))]
pub default_locale: String,
#[validate(length(min = 1))]
pub default_device_platform: String,
#[validate(length(min = 1))]
pub default_experiment_variant: String,
#[validate(length(min = 1))]
pub log_level: String,
pub stream_base_url: Option<String>,
pub cdn_base_url: Option<String>,
#[validate(length(min = 1))]
pub analytics_mode: String,
#[validate(length(min = 1))]
pub experiment_config: String,
#[validate(length(min = 1))]
pub localization_source: String,
pub database_url: Option<String>,
pub redis_url: Option<String>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
service_name: "hermes-backend".to_string(),
environment: "local".to_string(),
bind_addr: "127.0.0.1:3000".parse().expect("valid bind address"),
app_version: "0.1.0-dev".to_string(),
default_locale: "en".to_string(),
default_device_platform: "ios".to_string(),
default_experiment_variant: "control".to_string(),
log_level: "info".to_string(),
stream_base_url: None,
cdn_base_url: None,
analytics_mode: "relational".to_string(),
experiment_config: "study-default".to_string(),
localization_source: "contracts/localization".to_string(),
database_url: None,
redis_url: None,
}
}
}
impl AppConfig {
pub fn load() -> anyhow::Result<Self> {
let settings = config::Config::builder()
.set_default("service_name", "hermes-backend")?
.set_default("environment", "local")?
.set_default("bind_addr", "127.0.0.1:3000")?
.set_default("app_version", "0.1.0-dev")?
.set_default("default_locale", "en")?
.set_default("default_device_platform", "ios")?
.set_default("default_experiment_variant", "control")?
.set_default("log_level", "info")?
.set_default("analytics_mode", "relational")?
.set_default("experiment_config", "study-default")?
.set_default("localization_source", "contracts/localization")?
.add_source(config::Environment::with_prefix("HERMES").separator("__"))
.build()
.context("failed to load Hermes configuration")?;
let config: Self = settings
.try_deserialize()
.context("failed to deserialize Hermes configuration")?;
config.validate().context("invalid Hermes configuration")?;
Ok(config)
}
}
+29
View File
@@ -0,0 +1,29 @@
use std::time::Duration;
use anyhow::Context;
use redis::Client as RedisClient;
use sqlx::{postgres::PgPoolOptions, PgPool};
pub async fn connect_postgres(database_url: Option<&str>) -> anyhow::Result<Option<PgPool>> {
match database_url {
Some(url) if !url.trim().is_empty() => {
let pool = PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(5))
.connect(url)
.await
.with_context(|| format!("failed to connect to postgres at {url}"))?;
Ok(Some(pool))
}
_ => Ok(None),
}
}
pub fn connect_redis(redis_url: Option<&str>) -> anyhow::Result<Option<RedisClient>> {
match redis_url {
Some(url) if !url.trim().is_empty() => {
Ok(Some(RedisClient::open(url).context("failed to open redis client")?))
}
_ => Ok(None),
}
}
+69
View File
@@ -0,0 +1,69 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("{0}")]
BadRequest(String),
#[error("{0}")]
NotFound(String),
#[error("{0}")]
Conflict(String),
#[error("{0}")]
ServiceUnavailable(String),
#[error("{0}")]
Internal(String),
}
#[derive(Debug, Serialize)]
struct ErrorResponse {
code: &'static str,
message: String,
}
impl AppError {
pub fn bad_request(message: impl Into<String>) -> Self {
Self::BadRequest(message.into())
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::NotFound(message.into())
}
fn status_code(&self) -> StatusCode {
match self {
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::NotFound(_) => StatusCode::NOT_FOUND,
Self::Conflict(_) => StatusCode::CONFLICT,
Self::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn code(&self) -> &'static str {
match self {
Self::BadRequest(_) => "bad_request",
Self::NotFound(_) => "not_found",
Self::Conflict(_) => "conflict",
Self::ServiceUnavailable(_) => "service_unavailable",
Self::Internal(_) => "internal_error",
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.status_code();
let body = Json(ErrorResponse {
code: self.code(),
message: self.to_string(),
});
(status, body).into_response()
}
}
+43
View File
@@ -0,0 +1,43 @@
#![forbid(unsafe_code)]
pub mod app_state;
pub mod config;
pub mod db;
pub mod error;
pub mod routes;
pub mod telemetry;
use axum::Router;
use axum::Extension;
use tower_http::trace::TraceLayer;
use crate::{app_state::AppState, config::AppConfig};
pub fn build_router(state: AppState) -> Router {
routes::router()
.layer(Extension(state))
.layer(TraceLayer::new_for_http())
}
pub async fn run() -> anyhow::Result<()> {
let config = AppConfig::load()?;
telemetry::init(&config.log_level);
tracing::info!(
service = %config.service_name,
environment = %config.environment,
version = %config.app_version,
"starting Hermes backend"
);
let database_pool = db::connect_postgres(config.database_url.as_deref()).await?;
let redis_client = db::connect_redis(config.redis_url.as_deref())?;
let state = AppState::new(config.clone(), database_pool, redis_client);
let app = build_router(state);
let listener = tokio::net::TcpListener::bind(config.bind_addr).await?;
tracing::info!(addr = %config.bind_addr, "listening");
axum::serve(listener, app).await?;
Ok(())
}
+6
View File
@@ -0,0 +1,6 @@
#![forbid(unsafe_code)]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
hermes_backend::run().await
}
+27
View File
@@ -0,0 +1,27 @@
use axum::{extract::Extension, Json};
use serde::Serialize;
use crate::app_state::AppState;
#[derive(Serialize)]
pub struct HealthResponse {
pub status: &'static str,
pub service_name: String,
pub environment: String,
pub version: String,
pub uptime_ms: u128,
pub database_ready: bool,
pub redis_ready: bool,
}
pub async fn handler(Extension(state): Extension<AppState>) -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok",
service_name: state.config.service_name.clone(),
environment: state.config.environment.clone(),
version: state.config.app_version.clone(),
uptime_ms: state.uptime_ms(),
database_ready: state.database_ready(),
redis_ready: state.redis_ready(),
})
}
+12
View File
@@ -0,0 +1,12 @@
pub mod health;
pub mod session;
use axum::{routing::{get, post}, Router};
pub fn router() -> Router {
Router::new()
.route("/health", get(health::handler))
.route("/api/v1/session/start", post(session::start))
.route("/api/v1/session/end", post(session::end))
.route("/api/v1/session/me", get(session::me))
}
+24
View File
@@ -0,0 +1,24 @@
use axum::{extract::Extension, http::StatusCode, Json};
use crate::{app_state::{AppState, SessionSnapshot, SessionStartRequest}, error::AppError};
pub async fn start(
Extension(state): Extension<AppState>,
Json(payload): Json<SessionStartRequest>,
) -> Result<(StatusCode, Json<SessionSnapshot>), AppError> {
let session = state.start_session(payload).await;
Ok((StatusCode::CREATED, Json(session)))
}
pub async fn end(Extension(state): Extension<AppState>) -> Result<Json<SessionSnapshot>, AppError> {
let session = state.end_session().await?;
Ok(Json(session))
}
pub async fn me(Extension(state): Extension<AppState>) -> Result<Json<SessionSnapshot>, AppError> {
let session = state
.current_session()
.await
.ok_or_else(|| AppError::not_found("No active session"))?;
Ok(Json(session))
}
+15
View File
@@ -0,0 +1,15 @@
use tracing_subscriber::EnvFilter;
pub fn init(filter: &str) {
let filter = EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new("info"));
let subscriber = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(true)
.with_thread_ids(true)
.with_thread_names(true)
.compact()
.finish();
tracing::subscriber::set_global_default(subscriber)
.expect("failed to install tracing subscriber");
}