esylla-core 0.1.0

Core esylla contract: the Module trait, app builder, and migration runner.
//! The module contract, the app builder, and the migration runner.

use std::collections::HashSet;
use std::future::Future;
use std::pin::Pin;

use axum::Router;
use axum::response::Response;
use sea_orm::sea_query::{Alias, ColumnDef, Query, Table};
use sea_orm::{ConnectionTrait, DatabaseConnection, DbErr};
use sea_orm_migration::{MigrationTrait, SchemaManager};
use utoipa::openapi::OpenApi;
use utoipa_axum::router::OpenApiRouter;

use esylla_error::EsyllaError;

/// A pluggable unit: routes (with their OpenAPI paths), schema migrations, and an
/// async startup hook.
///
/// A module is generic over the host state `S` and constrains it in its own impl
/// (e.g. `impl<S> Module<S> for Auth where Arc<dyn SessionStore>: FromRef<S>`), so
/// `.module(m)` only type-checks when the host state provides what the module needs.
pub trait Module<S: Clone + Send + Sync + 'static>: Send + Sync {
    fn name(&self) -> &'static str;

    /// HTTP routes plus their OpenAPI paths. Default: none — handy for modules that
    /// only contribute a schema/migration (e.g. a tree-traversal helper).
    fn routes(&self) -> OpenApiRouter<S> {
        OpenApiRouter::new()
    }

    fn migrations(&self) -> Vec<Box<dyn MigrationTrait>> {
        Vec::new()
    }

    /// Run once at startup by [`Esylla::init`], with the host state. Seed data, warm
    /// caches, register jobs, validate config. Default: no-op.
    fn on_init(&self, _state: &S) -> impl Future<Output = anyhow::Result<()>> + Send {
        async { Ok(()) }
    }
}

/// Table tracking which migrations have been applied.
const MIGRATION_TABLE: &str = "esylla_migrations";

/// Apply the not-yet-applied `migrations`, in order.
///
/// Forward-only and idempotent: applied names are recorded in `esylla_migrations`
/// (created on demand). Portable across any sea-orm backend. Rollback and
/// per-migration transactions are out of scope.
#[tracing::instrument(skip_all, fields(pending = migrations.len()))]
pub async fn run_migrations(
    db: &DatabaseConnection,
    migrations: &[Box<dyn MigrationTrait>],
) -> Result<(), DbErr> {
    let create_tracking = Table::create()
        .table(Alias::new(MIGRATION_TABLE))
        .if_not_exists()
        .col(
            ColumnDef::new(Alias::new("name"))
                .string()
                .not_null()
                .primary_key(),
        )
        .to_owned();
    db.execute(&create_tracking).await?;

    let select_applied = Query::select()
        .column(Alias::new("name"))
        .from(Alias::new(MIGRATION_TABLE))
        .to_owned();
    let applied: HashSet<String> = db
        .query_all(&select_applied)
        .await?
        .iter()
        .filter_map(|row| row.try_get::<String>("", "name").ok())
        .collect();

    let manager = SchemaManager::new(db);
    let mut applied_now = 0u32;
    for migration in migrations {
        let name = migration.name().to_string();
        if applied.contains(&name) {
            continue;
        }
        tracing::info!(migration = %name, "applying migration");
        migration.up(&manager).await?;
        let record = Query::insert()
            .into_table(Alias::new(MIGRATION_TABLE))
            .columns([Alias::new("name")])
            .values_panic([name.into()])
            .to_owned();
        db.execute(&record).await?;
        applied_now += 1;
    }
    tracing::info!(applied = applied_now, "migrations up to date");
    Ok(())
}

/// Deferred module init: given a clone of the host state, returns its `on_init`
/// future. Boxed so heterogeneous modules can be collected and run together.
type InitFn<S> =
    Box<dyn FnOnce(S) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> + Send>;

/// Where to mount the API docs.
struct DocsPaths {
    ui: String,
    spec: String,
}

impl Default for DocsPaths {
    fn default() -> Self {
        DocsPaths {
            ui: "/docs".to_string(),
            spec: "/openapi.json".to_string(),
        }
    }
}

/// The app builder.
///
/// Each `.module(m)` imposes that module's own bounds on `S` at the call site, so
/// one builder can compose modules with different capability requirements: the
/// host's concrete `S` just has to satisfy all of them, checked at compile time.
pub struct Esylla<S> {
    state: S,
    router: OpenApiRouter<S>,
    migrations: Vec<Box<dyn MigrationTrait>>,
    inits: Vec<(&'static str, InitFn<S>)>,
    docs: bool,
    docs_paths: DocsPaths,
}

impl<S> Esylla<S>
where
    S: Clone + Send + Sync + 'static,
{
    pub fn new(state: S) -> Self {
        Esylla {
            state,
            router: OpenApiRouter::new(),
            migrations: Vec::new(),
            inits: Vec::new(),
            docs: true,
            docs_paths: DocsPaths::default(),
        }
    }

    pub fn module<M: Module<S> + 'static>(mut self, module: M) -> Self {
        self.router = self.router.merge(module.routes());
        self.migrations.extend(module.migrations());
        let name = module.name();
        // Capture the module now; run its `on_init` later with a fresh state clone
        // (keeps the future `'static`, no self-borrow).
        self.inits.push((
            name,
            Box::new(move |state: S| Box::pin(async move { module.on_init(&state).await })),
        ));
        self
    }

    /// Whether to mount the API docs in [`Self::into_router`] (default `true`).
    ///
    /// A runtime switch — cargo features can't vary by build profile — so e.g.
    /// `.docs(cfg!(debug_assertions))` serves docs in dev but not release. (The
    /// `swagger-ui` feature is the separate compile-time switch for the UI assets.)
    pub fn docs(mut self, enabled: bool) -> Self {
        self.docs = enabled;
        self
    }

    /// Override the docs paths (defaults: `/docs` for the UI, `/openapi.json` for
    /// the spec).
    pub fn docs_paths(mut self, ui: impl Into<String>, spec: impl Into<String>) -> Self {
        self.docs_paths = DocsPaths {
            ui: ui.into(),
            spec: spec.into(),
        };
        self
    }

    /// Add a per-request tracing span (tower-http `TraceLayer`). For full control,
    /// skip this and add your own layer after [`Self::into_router`].
    pub fn trace_requests(mut self) -> Self {
        self.router = self
            .router
            .layer(tower_http::trace::TraceLayer::new_for_http());
        self
    }

    /// Install the host-owned error renderer. See [`esylla_error::set_error_renderer`].
    pub fn on_error<F>(self, f: F) -> Self
    where
        F: Fn(&dyn EsyllaError) -> Response + Send + Sync + 'static,
    {
        esylla_error::set_error_renderer(f);
        self
    }

    /// Migrations collected from all registered modules, in registration order.
    pub fn collected_migrations(&self) -> &[Box<dyn MigrationTrait>] {
        &self.migrations
    }

    /// The merged OpenAPI document. Useful for host tooling (e.g. dumping the spec
    /// to a file for frontend codegen).
    pub fn openapi(&self) -> OpenApi {
        self.router.get_openapi().clone()
    }

    /// Run all collected migrations against `db`.
    pub async fn migrate(&self, db: &DatabaseConnection) -> Result<(), DbErr> {
        run_migrations(db, &self.migrations).await
    }

    /// Run each module's `on_init` hook, in registration order, with a state clone.
    /// Drains the init queue, so a second call is a no-op.
    pub async fn init(&mut self) -> anyhow::Result<()> {
        for (name, init) in std::mem::take(&mut self.inits) {
            tracing::debug!(module = name, "module init");
            init(self.state.clone()).await?;
        }
        Ok(())
    }

    /// Finalize into an axum [`Router`] with the host state attached. Mounts the
    /// docs unless disabled (see [`Self::docs`]).
    pub fn into_router(self) -> Router {
        let (router, api) = self.router.split_for_parts();
        let router = if self.docs {
            mount_docs(router, api, &self.docs_paths)
        } else {
            router
        };
        router.with_state(self.state)
    }
}

#[cfg(feature = "swagger-ui")]
fn mount_docs<S>(router: Router<S>, api: OpenApi, paths: &DocsPaths) -> Router<S>
where
    S: Clone + Send + Sync + 'static,
{
    router.merge(utoipa_swagger_ui::SwaggerUi::new(paths.ui.clone()).url(paths.spec.clone(), api))
}

#[cfg(not(feature = "swagger-ui"))]
fn mount_docs<S>(router: Router<S>, api: OpenApi, paths: &DocsPaths) -> Router<S>
where
    S: Clone + Send + Sync + 'static,
{
    // No UI: still serve the raw spec. Clone per call so the handler stays `Fn`.
    router.route(
        &paths.spec,
        axum::routing::get(move || {
            let api = api.clone();
            async move { axum::Json(api) }
        }),
    )
}