first
This commit is contained in:
Generated
+3014
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "hermes-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
axum = { version = "0.8", features = ["json"] }
|
||||
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||
config = "0.14"
|
||||
redis = "0.28"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "uuid", "chrono"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] }
|
||||
tower = { version = "0.5", features = ["make"] }
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
validator = { version = "0.19", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,18 @@
|
||||
DROP TABLE IF EXISTS audit_log_attributes;
|
||||
DROP TABLE IF EXISTS audit_logs;
|
||||
DROP TABLE IF EXISTS analytics_event_attributes;
|
||||
DROP TABLE IF EXISTS analytics_events;
|
||||
DROP TABLE IF EXISTS analytics_event_types;
|
||||
DROP TABLE IF EXISTS localization_values;
|
||||
DROP TABLE IF EXISTS localization_keys;
|
||||
DROP TABLE IF EXISTS experiment_assignments;
|
||||
DROP TABLE IF EXISTS settlements;
|
||||
DROP TABLE IF EXISTS bet_intents;
|
||||
DROP TABLE IF EXISTS outcome_odds;
|
||||
DROP TABLE IF EXISTS odds_versions;
|
||||
DROP TABLE IF EXISTS outcomes;
|
||||
DROP TABLE IF EXISTS markets;
|
||||
DROP TABLE IF EXISTS event_media;
|
||||
DROP TABLE IF EXISTS events;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
DROP TABLE IF EXISTS users;
|
||||
@@ -0,0 +1,190 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
external_ref text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
preferred_language text NOT NULL,
|
||||
device_platform text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
ended_at timestamptz,
|
||||
experiment_variant text NOT NULL,
|
||||
app_version text NOT NULL,
|
||||
device_model text,
|
||||
os_version text,
|
||||
locale_code text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
sport_type text NOT NULL,
|
||||
source_ref text NOT NULL UNIQUE,
|
||||
title_en text NOT NULL,
|
||||
title_sv text NOT NULL,
|
||||
status text NOT NULL,
|
||||
preview_start_ms bigint NOT NULL,
|
||||
preview_end_ms bigint NOT NULL,
|
||||
reveal_start_ms bigint NOT NULL,
|
||||
reveal_end_ms bigint NOT NULL,
|
||||
lock_at timestamptz NOT NULL,
|
||||
settle_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE event_media (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
media_type text NOT NULL,
|
||||
hls_master_url text NOT NULL,
|
||||
poster_url text,
|
||||
duration_ms bigint NOT NULL,
|
||||
preview_start_ms bigint NOT NULL,
|
||||
preview_end_ms bigint NOT NULL,
|
||||
reveal_start_ms bigint NOT NULL,
|
||||
reveal_end_ms bigint NOT NULL,
|
||||
UNIQUE (event_id, media_type)
|
||||
);
|
||||
|
||||
CREATE TABLE markets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
question_key text NOT NULL,
|
||||
market_type text NOT NULL,
|
||||
status text NOT NULL,
|
||||
lock_at timestamptz NOT NULL,
|
||||
settlement_rule_key text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE outcomes (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
market_id uuid NOT NULL REFERENCES markets(id) ON DELETE CASCADE,
|
||||
outcome_code text NOT NULL,
|
||||
label_key text NOT NULL,
|
||||
sort_order integer NOT NULL,
|
||||
UNIQUE (market_id, outcome_code)
|
||||
);
|
||||
|
||||
CREATE TABLE odds_versions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
market_id uuid NOT NULL REFERENCES markets(id) ON DELETE CASCADE,
|
||||
version_no integer NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
is_current boolean NOT NULL DEFAULT false,
|
||||
UNIQUE (market_id, version_no)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX odds_versions_current_idx
|
||||
ON odds_versions (market_id)
|
||||
WHERE is_current;
|
||||
|
||||
CREATE TABLE outcome_odds (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
odds_version_id uuid NOT NULL REFERENCES odds_versions(id) ON DELETE CASCADE,
|
||||
outcome_id uuid NOT NULL REFERENCES outcomes(id) ON DELETE CASCADE,
|
||||
decimal_odds numeric(10,4) NOT NULL,
|
||||
fractional_num integer NOT NULL,
|
||||
fractional_den integer NOT NULL,
|
||||
UNIQUE (odds_version_id, outcome_id)
|
||||
);
|
||||
|
||||
CREATE TABLE bet_intents (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id),
|
||||
session_id uuid NOT NULL REFERENCES sessions(id),
|
||||
event_id uuid NOT NULL REFERENCES events(id),
|
||||
market_id uuid NOT NULL REFERENCES markets(id),
|
||||
outcome_id uuid NOT NULL REFERENCES outcomes(id),
|
||||
idempotency_key text NOT NULL UNIQUE,
|
||||
client_sent_at timestamptz NOT NULL,
|
||||
server_received_at timestamptz NOT NULL DEFAULT now(),
|
||||
accepted boolean NOT NULL,
|
||||
acceptance_code text NOT NULL,
|
||||
accepted_odds_version_id uuid REFERENCES odds_versions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE settlements (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
market_id uuid NOT NULL UNIQUE REFERENCES markets(id) ON DELETE CASCADE,
|
||||
settled_at timestamptz NOT NULL,
|
||||
winning_outcome_id uuid NOT NULL REFERENCES outcomes(id)
|
||||
);
|
||||
|
||||
CREATE TABLE experiment_assignments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
variant text NOT NULL,
|
||||
assigned_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (session_id)
|
||||
);
|
||||
|
||||
CREATE TABLE localization_keys (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key_name text NOT NULL UNIQUE,
|
||||
description text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE localization_values (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
localization_key_id uuid NOT NULL REFERENCES localization_keys(id) ON DELETE CASCADE,
|
||||
locale_code text NOT NULL,
|
||||
text_value text NOT NULL,
|
||||
UNIQUE (localization_key_id, locale_code)
|
||||
);
|
||||
|
||||
CREATE TABLE analytics_event_types (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_name text NOT NULL UNIQUE,
|
||||
description text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE analytics_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
analytics_event_type_id uuid NOT NULL REFERENCES analytics_event_types(id),
|
||||
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE analytics_event_attributes (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
analytics_event_id uuid NOT NULL REFERENCES analytics_events(id) ON DELETE CASCADE,
|
||||
attribute_key text NOT NULL,
|
||||
attribute_value text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE audit_logs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
actor_type text NOT NULL,
|
||||
actor_id uuid,
|
||||
action_name text NOT NULL,
|
||||
target_type text NOT NULL,
|
||||
target_id uuid,
|
||||
trace_id text NOT NULL,
|
||||
note text
|
||||
);
|
||||
|
||||
CREATE TABLE audit_log_attributes (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
audit_log_id uuid NOT NULL REFERENCES audit_logs(id) ON DELETE CASCADE,
|
||||
attribute_key text NOT NULL,
|
||||
attribute_value text NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
|
||||
CREATE INDEX sessions_started_at_idx ON sessions (started_at);
|
||||
CREATE INDEX events_status_idx ON events (status);
|
||||
CREATE INDEX markets_event_id_idx ON markets (event_id);
|
||||
CREATE INDEX markets_lock_at_idx ON markets (lock_at);
|
||||
CREATE INDEX bet_intents_session_id_idx ON bet_intents (session_id);
|
||||
CREATE INDEX bet_intents_market_id_idx ON bet_intents (market_id);
|
||||
CREATE INDEX bet_intents_idempotency_key_idx ON bet_intents (idempotency_key);
|
||||
CREATE INDEX analytics_events_session_id_idx ON analytics_events (session_id);
|
||||
CREATE INDEX analytics_events_user_id_idx ON analytics_events (user_id);
|
||||
CREATE INDEX analytics_events_type_id_idx ON analytics_events (analytics_event_type_id);
|
||||
CREATE INDEX audit_logs_created_at_idx ON audit_logs (created_at);
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use axum::{body::{to_bytes, Body}, http::{Request, StatusCode}};
|
||||
use hermes_backend::{app_state::AppState, build_router, config::AppConfig};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_returns_ok() {
|
||||
let app = build_router(AppState::new(AppConfig::default(), None, None));
|
||||
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_start_and_me_work() {
|
||||
let app = build_router(AppState::new(AppConfig::default(), None, None));
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/session/start")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["locale_code"], "en");
|
||||
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/api/v1/session/me").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
Reference in New Issue
Block a user