From 0c7a6fe9be0376c7134fb4b306722622ff63b7d4 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 12:05:00 +0200 Subject: [PATCH] feat(bootstrap): composition root with Config + factory.rs --- Cargo.toml | 2 + crates/bootstrap/Cargo.toml | 28 ++++++++++ crates/bootstrap/src/config.rs | 36 +++++++++++++ crates/bootstrap/src/factory.rs | 95 +++++++++++++++++++++++++++++++++ crates/bootstrap/src/main.rs | 25 +++++++++ 5 files changed, 186 insertions(+) create mode 100644 crates/bootstrap/Cargo.toml create mode 100644 crates/bootstrap/src/config.rs create mode 100644 crates/bootstrap/src/factory.rs create mode 100644 crates/bootstrap/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 6fd391c..8a7ebbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/application", "crates/api-types", "crates/presentation", + "crates/bootstrap", "crates/worker", "crates/adapters/postgres", "crates/adapters/postgres-search", @@ -38,6 +39,7 @@ async-stream = "0.3" reqwest = { version = "0.13", features = ["json"] } url = { version = "2", features = ["serde"] } +presentation = { path = "crates/presentation" } domain = { path = "crates/domain" } application = { path = "crates/application" } api-types = { path = "crates/api-types" } diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml new file mode 100644 index 0000000..446a33e --- /dev/null +++ b/crates/bootstrap/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "bootstrap" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "thoughts" +path = "src/main.rs" + +[dependencies] +presentation = { workspace = true } +domain = { workspace = true } +postgres = { workspace = true } +postgres-search = { workspace = true } +postgres-federation = { workspace = true } +activitypub = { workspace = true } +activitypub-base = { workspace = true } +nats = { workspace = true } +auth = { workspace = true } +sqlx = { workspace = true } +async-nats = { workspace = true } +async-trait = { workspace = true } +tokio = { workspace = true, features = ["full"] } +axum = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +dotenvy = { workspace = true } diff --git a/crates/bootstrap/src/config.rs b/crates/bootstrap/src/config.rs new file mode 100644 index 0000000..15e700f --- /dev/null +++ b/crates/bootstrap/src/config.rs @@ -0,0 +1,36 @@ +/// All configuration read from environment variables at startup. +pub struct Config { + pub database_url: String, + pub jwt_secret: String, + pub base_url: String, + pub nats_url: Option, + pub port: u16, + pub allow_registration: bool, + /// true when RUST_ENV != "production" — enables AP debug mode + pub debug: bool, +} + +impl Config { + pub fn from_env() -> Self { + dotenvy::dotenv().ok(); + Self { + database_url: std::env::var("DATABASE_URL") + .expect("DATABASE_URL is required"), + jwt_secret: std::env::var("JWT_SECRET") + .expect("JWT_SECRET is required"), + base_url: std::env::var("BASE_URL") + .unwrap_or_else(|_| "http://localhost:3000".into()), + nats_url: std::env::var("NATS_URL").ok(), + port: std::env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(3000), + allow_registration: std::env::var("ALLOW_REGISTRATION") + .map(|v| v == "true") + .unwrap_or(true), + debug: std::env::var("RUST_ENV") + .map(|v| v != "production") + .unwrap_or(true), + } + } +} diff --git a/crates/bootstrap/src/factory.rs b/crates/bootstrap/src/factory.rs new file mode 100644 index 0000000..9426898 --- /dev/null +++ b/crates/bootstrap/src/factory.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; +use async_trait::async_trait; +use sqlx::PgPool; + +use activitypub::ThoughtsObjectHandler; +use activitypub_base::{ApFederationConfig, FederationData}; +use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher}; +use postgres::activitypub::PgActivityPubRepository; +use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository}; +use presentation::state::AppState; + +use crate::config::Config; + +/// Everything the binary needs to start serving. +pub struct Infrastructure { + pub state: AppState, + pub fed_config: ApFederationConfig, +} + +struct NoOpEventPublisher; + +#[async_trait] +impl EventPublisher for NoOpEventPublisher { + async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { Ok(()) } +} + +pub async fn build(cfg: &Config) -> Infrastructure { + // 1. Database connection + migrations + let pool = PgPool::connect(&cfg.database_url) + .await + .expect("Failed to connect to database"); + sqlx::migrate!("../adapters/postgres/migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + tracing::info!("Database connected and migrations applied"); + + // 2. Event publisher — real NATS or no-op fallback + let event_publisher: Arc = match &cfg.nats_url { + Some(url) => match async_nats::connect(url).await { + Ok(client) => { + tracing::info!("Connected to NATS at {url}"); + Arc::new(nats::NatsEventPublisher::new(client)) + } + Err(e) => { + tracing::warn!("NATS connect failed ({e}) — falling back to no-op publisher"); + Arc::new(NoOpEventPublisher) + } + }, + None => { + tracing::info!("NATS_URL not set — using no-op event publisher"); + Arc::new(NoOpEventPublisher) + } + }; + + // 3. ActivityPub federation + let fed_data = FederationData::new( + Arc::new(PostgresFederationRepository::new(pool.clone())), + Arc::new(PostgresApUserRepository::new(pool.clone(), cfg.base_url.clone())), + Arc::new(ThoughtsObjectHandler::new( + Arc::new(PgActivityPubRepository::new(pool.clone())), + &cfg.base_url, + )), + cfg.base_url.clone(), + cfg.allow_registration, + "thoughts".to_string(), + None, + ); + let fed_config = ApFederationConfig::new(fed_data, cfg.debug) + .await + .expect("Failed to build federation config"); + + // 4. Application state + let state = AppState { + users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())), + thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())), + likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())), + boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())), + follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())), + blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())), + tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), + api_keys: Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())), + top_friends: Arc::new(postgres::top_friend::PgTopFriendRepository::new(pool.clone())), + notifications: Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())), + remote_actors: Arc::new(postgres::remote_actor::PgRemoteActorRepository::new(pool.clone())), + feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())), + search: Arc::new(postgres_search::PgSearchRepository::new(pool.clone())), + auth: Arc::new(auth::JwtAuthService::new(cfg.jwt_secret.clone(), 86400 * 30)), + hasher: Arc::new(auth::Argon2PasswordHasher), + events: event_publisher, + fed_config: fed_config.clone(), + }; + + Infrastructure { state, fed_config } +} diff --git a/crates/bootstrap/src/main.rs b/crates/bootstrap/src/main.rs new file mode 100644 index 0000000..121541a --- /dev/null +++ b/crates/bootstrap/src/main.rs @@ -0,0 +1,25 @@ +mod config; +mod factory; + +use tower_http::cors::CorsLayer; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() { + let cfg = config::Config::from_env(); + + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + let infra = factory::build(&cfg).await; + + let app = presentation::routes::router(&infra.fed_config) + .with_state(infra.state) + .layer(CorsLayer::permissive()); + + let addr = format!("0.0.0.0:{}", cfg.port); + tracing::info!("Listening on {addr}"); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +}