This commit is contained in:
2026-09-07 22:47:34 +03:00
commit 3fcee5ea9a
27 changed files with 1849 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
[package]
name = "erplt-api"
version = "0.1.0"
edition = "2024"
[dependencies]
erplt-db.path="../erplt-db"
erplt-config.path = "../erplt-config"
anyhow.workspace = true
axum.workspace = true
tokio.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,5 @@
mod routes;
mod state;
mod server;
pub use server::start;

View File

@@ -0,0 +1,3 @@
pub async fn get() -> &'static str {
"OK"
}

View File

@@ -0,0 +1,8 @@
use axum::{routing::get, Router};
mod health;
pub fn router() -> Router {
Router::new()
.route("/health", get(health::get))
}

View File

@@ -0,0 +1,33 @@
use anyhow::Context;
use axum::http::StatusCode;
use erplt_db::DbPool;
#[axum::debug_handler]
async fn fallback() -> (StatusCode, &'static str) {
(StatusCode::NOT_FOUND, "Not Found")
}
pub async fn start(db: DbPool) -> anyhow::Result<()> {
let config = erplt_config::get();
let app = axum::Router::new()
.merge(crate::routes::router())
.fallback(fallback);
let listener = tokio::net::TcpListener::bind(format!("{}:{}", config.web.host, config.web.port))
.await
.context("Failed to bind HTTP listener")?;
tracing::info!("Web server listening on http://{}", listener.local_addr()?);
axum::serve(listener, app)
.await
.context("Web server stopped unexpectedly")?;
Ok(())
}

View File

View File

@@ -0,0 +1,15 @@
[package]
name = "erplt-config"
version = "0.1.0"
edition = "2024"
[dependencies]
camino.workspace = true
clap.workspace = true
serde.workspace = true
toml.workspace = true
anyhow.workspace = true
tracing-subscriber.workspace = true
tracing.workspace = true
humantime.workspace = true
humantime-serde.workspace = true

View File

@@ -0,0 +1,18 @@
use camino::Utf8PathBuf;
#[derive(Debug, Clone, clap::Parser)]
#[command(
name = "erplt",
about = "ERP system for European businesses",
long_about = "ERPLT is a lightweight ERP system built for the European market.",
)]
pub struct Cli {
/// Config path
#[arg(long, short='c', default_value="config.toml")]
pub config: Utf8PathBuf,
/// Debug output
#[arg(long, short='d', default_value_t=false)]
pub debug: bool,
}

View File

@@ -0,0 +1,107 @@
use std::time::Duration;
use anyhow::Context;
use camino::Utf8PathBuf;
use serde::de;
pub mod cli;
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
pub struct Config {
#[serde(skip)]
pub config_path: Utf8PathBuf,
pub core: ConfigCore,
pub database: ConfigDatabase,
pub web: ConfigWeb,
}
impl Config {
pub(crate) fn parse() -> anyhow::Result<Self> {
if !crate::CLI.config.exists() {
tracing::warn!("Configuration does not exist, creating at {}", crate::CLI.config.canonicalize_utf8()?);
let tml = toml::to_string_pretty(&Config::default()).context("Invalid default config????")?;
std::fs::write(&crate::CLI.config, tml).context("Failed to write default config, check permissions?")?;
}
let text = std::fs::read_to_string(&crate::CLI.config).context("Failed to read config, check permissions?")?;
let mut tml: Self = toml::from_str(&text)?;
tml.config_path = crate::CLI.config.clone();
tml.core.debug = tml.core.debug || crate::CLI.debug;
tracing::info!("Read config at {}", crate::CLI.config.canonicalize_utf8()?);
Ok(tml)
}
pub fn get() -> &'static Self {
crate::CONFIG.get().expect("Config hasnt been read and initialised yet")
}
pub(crate) fn load_to_global(self) {
crate::CONFIG.set(self).expect("Config was already set globaly")
}
}
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
pub struct ConfigCore {
pub debug: bool,
}
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DatabaseType {
#[default]
Postgresql,
}
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
pub struct ConfigDatabase {
pub host: String,
pub port: u16,
pub database: String,
pub username: String,
pub password: String,
#[serde(rename="type")]
pub db_type: DatabaseType,
pub max_pool_size: u32,
pub min_idle: u32,
#[serde(with = "humantime_serde")]
pub connection_timeout: Duration,
#[serde(with = "humantime_serde")]
pub idle_timeout: Duration,
#[serde(with = "humantime_serde")]
pub max_lifetime: Duration,
pub test_on_check_out: bool
}
impl ConfigDatabase {
pub fn connect_url(&self) -> String {
format!(
"postgres://{}:{}@{}:{}/{}",
self.username,
self.password,
self.host,
self.port,
self.database,
)
}
pub fn connect_url_redacted(&self) -> String {
format!(
"postgres://{}:***@{}:{}/{}",
self.username,
self.host,
self.port,
self.database,
)
}
}
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
pub struct ConfigWeb {
pub host: String,
pub port: u16,
}

View File

@@ -0,0 +1,20 @@
use std::sync::{LazyLock, OnceLock};
use clap::Parser;
mod config;
pub use config::Config;
static CLI: LazyLock<config::cli::Cli> = LazyLock::new(config::cli::Cli::parse);
static CONFIG: OnceLock<config::Config> = OnceLock::new();
pub fn init() -> anyhow::Result<()> {
let _ = &*CLI;
config::Config::parse()?.load_to_global();
Ok(())
}
// helper function for faster access
pub fn get() -> &'static Config {
Config::get()
}

View File

@@ -0,0 +1,13 @@
[package]
name = "erplt-db"
version = "0.1.0"
edition = "2024"
[dependencies]
erplt-config.path = "../erplt-config"
diesel.workspace = true
diesel_migrations.workspace = true
serde.workspace = true
toml.workspace = true
anyhow.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,6 @@
mod models;
mod pool;
// pub mod schema;
mod migrations;
pub use pool::{DbPool, connect, DbConnection};

View File

@@ -0,0 +1,17 @@
use anyhow::Context;
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("../../migrations");
pub fn run_migrations(pool: &crate::DbPool) -> anyhow::Result<usize> {
let mut connection = pool
.get()
.context("Failed to get database connection for migrations")?;
connection
.run_pending_migrations(MIGRATIONS)
.map(|migrations| migrations.len())
.map_err(|e| anyhow::anyhow!(e) )
.context("Failed to run database migrations")
}

View File

View File

@@ -0,0 +1,30 @@
use anyhow::Context;
use diesel::r2d2::ConnectionManager;
pub type DbBackend = diesel::pg::Pg;
pub type DbConnection = diesel::PgConnection;
pub type DbPool = diesel::r2d2::Pool<
diesel::r2d2::ConnectionManager<DbConnection>
>;
pub fn connect(url: &str) -> anyhow::Result<DbPool> {
let config= erplt_config::Config::get();
let manager = ConnectionManager::<DbConnection>::new(url);
tracing::info!("Creating database pool at {}", config.database.connect_url_redacted());
let pool = DbPool::builder()
.max_size(config.database.max_pool_size)
.min_idle(Some(config.database.min_idle))
.connection_timeout(config.database.connection_timeout)
.idle_timeout(Some(config.database.idle_timeout))
.max_lifetime(Some(config.database.max_lifetime))
.test_on_check_out(config.database.test_on_check_out)
.build(manager)
.context("Failed to create database connection pool")?;
pool.get()
.context("Failed to connect to PostgreSQL")?;
tracing::info!("Connected to database");
Ok(pool)
}

View File

@@ -0,0 +1,6 @@
[package]
name = "erplt-ledger"
version = "0.1.0"
edition = "2024"
[dependencies]

View File

@@ -0,0 +1,14 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

19
crates/erplt/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "erplt"
version = "0.1.0"
edition = "2024"
[dependencies]
erplt-db.path="../erplt-db"
erplt-api.path="../erplt-api"
erplt-config.path="../erplt-config"
camino.workspace = true
clap.workspace = true
serde.workspace = true
toml.workspace = true
anyhow.workspace = true
tracing-subscriber.workspace = true
tracing.workspace = true
humantime.workspace = true
humantime-serde.workspace = true
tokio.workspace = true

31
crates/erplt/src/main.rs Normal file
View File

@@ -0,0 +1,31 @@
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let exit_code = match real_main().await {
Ok(()) => 0,
Err(err) => {
eprintln!("Exited with error: {err:#}");
eprintln!("Closing!");
1
}
};
std::process::exit(exit_code);
}
pub async fn real_main() -> anyhow::Result<()> {
erplt_config::init()?;
tracing::info!("Starting application");
let db = erplt_db::connect(&erplt_config::Config::get().database.connect_url())?;
erplt_api::start(db).await?;
Ok(())
}