first
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
hermes_backend::run().await
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user