feat: add configuration

This commit is contained in:
Kristofers Solo 2025-06-21 13:42:32 +03:00
parent c4d25790a4
commit 3e190e3cca
Signed by: kristoferssolo
GPG Key ID: 8687F2D3EEE6F0ED
11 changed files with 1114 additions and 8 deletions

847
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -20,8 +20,9 @@ console_error_panic_hook = "0.1.7"
console_log = "1.0.0"
http = "1.3.1"
log = "0.4.27"
serde = { version = "1", features = ["derive"] }
simple_logger = "5.0.0"
thiserror = "2.0.12"
thiserror = "2"
tokio = { version = "1.45", features = ["rt", "macros", "rt-multi-thread"] }
tower = { version = "0.5.2", features = ["full"] }
tower-http = { version = "0.6.4", features = ["full"] }

View File

@ -11,12 +11,26 @@ leptos = { workspace = true, features = ["ssr"] }
leptos_axum.workspace = true
axum.workspace = true
simple_logger.workspace = true
tokio = { workspace = true, features = ["tracing"] }
tower.workspace = true
tower-http.workspace = true
config = { version = "0.15", features = ["toml"], default-features = false }
log.workspace = true
tracing-bunyan-formatter = { version = "0.3", default-features = false }
secrecy = { version = "0.10", features = ["serde"] }
serde-aux = "4.7"
serde.workspace = true
simple_logger.workspace = true
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio",
"tls-rustls",
"macros",
"postgres",
"uuid",
"chrono",
"migrate",
] }
thiserror.workspace = true
tokio = { workspace = true, features = ["tracing"] }
tower-http.workspace = true
tower.workspace = true
tracing = { version = "0.1", features = ["log"] }
tracing-bunyan-formatter = { version = "0.3", default-features = false }
tracing-log = "0.2.0"
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }

View File

@ -0,0 +1,9 @@
[application]
port = 8000
[database]
host = "127.0.0.1"
port = 5432
username = "postgres"
password = "password"
database_name = "kristofersxyz"

View File

@ -0,0 +1,5 @@
[application]
host = "127.0.0.1"
[database]
require_ssl = false

View File

@ -0,0 +1,5 @@
[application]
host = "0.0.0.0"
[database]
require_ssl = true

140
server/src/configuration.rs Normal file
View File

@ -0,0 +1,140 @@
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use serde_aux::field_attributes::deserialize_number_from_string;
use sqlx::{
ConnectOptions,
postgres::{PgConnectOptions, PgSslMode},
};
use std::{fmt::Display, str::FromStr};
use thiserror::Error;
/// Top-level application settings.
#[derive(Debug, Deserialize, Clone)]
pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
}
/// Database-related settings.
#[derive(Debug, Deserialize, Clone)]
pub struct DatabaseSettings {
pub username: String,
pub password: SecretString,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
pub database_name: String,
pub require_ssl: bool,
}
/// Application-related settings.
#[derive(Debug, Deserialize, Clone)]
pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
}
/// Supported application environments.
#[derive(Debug, Clone)]
pub enum Environment {
Local,
Production,
}
/// Errors that can occur while loading configuration.
#[derive(Debug, Error)]
pub enum ConfigurationError {
#[error("Failed to load configuration file: {0}")]
ConfigFileError(#[from] config::ConfigError),
#[error("Invalid environment: {0}")]
InvalidEnvironment(String),
}
/// Load the application configuration.
///
/// This function reads configuration files and environment variables to build
/// the `Settings` struct.
///
/// - `base.toml` is always loaded.
/// - `{environment}.toml` is loaded based on the `APP_ENVIRONMENT` variable.
/// - Environment variables prefixed with `APP_` override file-based settings.
///
/// # Errors
/// Returns a `ConfigurationError` if the configuration cannot be loaded.
pub fn get_config() -> Result<Settings, ConfigurationError> {
let base_path = std::env::current_dir().expect("Failed to determine current directory");
let config_directory = base_path.join("config");
let env = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.parse::<Environment>()?;
let env_filename = format!("{}.toml", &env);
let settings = config::Config::builder()
.add_source(config::File::from(config_directory.join("base.toml")))
.add_source(config::File::from(config_directory.join(env_filename)))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?;
settings
.try_deserialize::<Settings>()
.map_err(ConfigurationError::from)
}
impl DatabaseSettings {
/// Build connection options without specifying the database name.
pub fn without_db(&self) -> PgConnectOptions {
let ssl_mode = if self.require_ssl {
PgSslMode::Require
} else {
PgSslMode::Prefer
};
PgConnectOptions::new()
.host(&self.host)
.username(&self.username)
.password(self.password.expose_secret())
.port(self.port)
.ssl_mode(ssl_mode)
}
/// Build connection options with the database name.
pub fn with_db(&self) -> PgConnectOptions {
self.without_db()
.database(&self.database_name)
.log_statements(tracing_log::log::LevelFilter::Trace)
}
}
impl Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Environment::Local => "local",
Environment::Production => "production",
};
write!(f, "{s}")
}
}
impl FromStr for Environment {
type Err = ConfigurationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"local" => Ok(Self::Local),
"production" => Ok(Self::Production),
other => Err(ConfigurationError::InvalidEnvironment(other.to_string())),
}
}
}
impl TryFrom<String> for Environment {
type Error = ConfigurationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}

4
server/src/lib.rs Normal file
View File

@ -0,0 +1,4 @@
mod configuration;
mod routes;
mod startup;
mod telemetry;

View File

@ -1,5 +1,3 @@
mod telemetry;
use app::*;
use axum::Router;
use leptos::logging::log;

6
server/src/routes/mod.rs Normal file
View File

@ -0,0 +1,6 @@
use crate::startup::AppState;
use axum::Router;
pub fn route(state: AppState) -> Router {
todo!()
}

77
server/src/startup.rs Normal file
View File

@ -0,0 +1,77 @@
use crate::{
configuration::{DatabaseSettings, Settings},
routes::route,
};
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::{
io::{Error, ErrorKind},
sync::Arc,
};
use tokio::{net::TcpListener, task::JoinHandle};
use tracing::{error, info};
/// Shared application state.
pub struct App {
pub pool: PgPool,
}
/// Type alias for the shared application state wrapped in `Arc`.
pub type AppState = Arc<App>;
/// Represents the application, including its server and configuration.
pub struct Application {
port: u16,
server: Option<JoinHandle<Result<(), Error>>>,
}
impl Application {
/// Build a new `Application` instance from the provided configuration.
///
/// This method initializes the database connection pool, binds the server
/// to the specified address, and prepares the application for startup.
pub async fn build(config: &Settings) -> Result<Self, Error> {
// Initialize the database connection pool
let pool = get_connection_pool(&config.database);
info!("Database connection pool initialized.");
// Bind the server to the specified address
let addr = format!("{}:{}", config.application.host, config.application.port);
let listener = TcpListener::bind(addr).await?;
let port = listener.local_addr().unwrap().port();
info!("Server listening on port {}", port);
// Create the shared application state
let app_state = App { pool }.into();
// Spawn the server
let server = tokio::spawn(async move {
axum::serve(listener, route(app_state)).await.map_err(|e| {
error!("Server error: {}", e);
e
})
});
Ok(Self {
port,
server: Some(server),
})
}
/// Get the port the server is listening on.
pub fn port(&self) -> u16 {
self.port
}
pub async fn start(self) -> Result<(), Error> {
self.server
.ok_or_else(|| Error::new(ErrorKind::Other, "Server was not initialized."))?
.await?
}
}
/// Initialize the database connection pool.
///
/// This method creates a lazy connection pool using the provided database settings.
fn get_connection_pool(config: &DatabaseSettings) -> PgPool {
PgPoolOptions::new().connect_lazy_with(config.with_db())
}