feat(bootstrap): composition root with Config + factory.rs
This commit is contained in:
@@ -4,6 +4,7 @@ members = [
|
|||||||
"crates/application",
|
"crates/application",
|
||||||
"crates/api-types",
|
"crates/api-types",
|
||||||
"crates/presentation",
|
"crates/presentation",
|
||||||
|
"crates/bootstrap",
|
||||||
"crates/worker",
|
"crates/worker",
|
||||||
"crates/adapters/postgres",
|
"crates/adapters/postgres",
|
||||||
"crates/adapters/postgres-search",
|
"crates/adapters/postgres-search",
|
||||||
@@ -38,6 +39,7 @@ async-stream = "0.3"
|
|||||||
reqwest = { version = "0.13", features = ["json"] }
|
reqwest = { version = "0.13", features = ["json"] }
|
||||||
url = { version = "2", features = ["serde"] }
|
url = { version = "2", features = ["serde"] }
|
||||||
|
|
||||||
|
presentation = { path = "crates/presentation" }
|
||||||
domain = { path = "crates/domain" }
|
domain = { path = "crates/domain" }
|
||||||
application = { path = "crates/application" }
|
application = { path = "crates/application" }
|
||||||
api-types = { path = "crates/api-types" }
|
api-types = { path = "crates/api-types" }
|
||||||
|
|||||||
28
crates/bootstrap/Cargo.toml
Normal file
28
crates/bootstrap/Cargo.toml
Normal file
@@ -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 }
|
||||||
36
crates/bootstrap/src/config.rs
Normal file
36
crates/bootstrap/src/config.rs
Normal file
@@ -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<String>,
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
crates/bootstrap/src/factory.rs
Normal file
95
crates/bootstrap/src/factory.rs
Normal file
@@ -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<dyn EventPublisher> = 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 }
|
||||||
|
}
|
||||||
25
crates/bootstrap/src/main.rs
Normal file
25
crates/bootstrap/src/main.rs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user