kegani 0.1.1

A developer-friendly, ergonomic, production-ready Rust web framework
Documentation
//! Application builder for Kegani web framework
//!
//! Provides a fluent API for configuring and running the HTTP server.
//! Fully wired: route registration, state sharing, and graceful shutdown.

use std::sync::Arc;
use std::time::Duration;
use actix_web::{web, App as ActixApp, HttpServer, HttpResponse, middleware::Logger};
use tokio::sync::RwLock;

/// Application state tracked across the server lifetime
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppState {
    Created,
    Starting,
    Running,
    Stopping,
    Stopped,
}

/// Shared server control — stored in Arc so handlers can access it
#[derive(Clone)]
pub struct ServerControl {
    state: Arc<RwLock<AppState>>,
    shutdown_trigger: Arc<RwLock<Option<tokio::sync::oneshot::Sender<()>>>>,
}

impl ServerControl {
    fn new() -> Self {
        Self {
            state: Arc::new(RwLock::new(AppState::Created)),
            shutdown_trigger: Arc::new(RwLock::new(None)),
        }
    }

    async fn set_state(&self, s: AppState) {
        let mut guard = self.state.write().await;
        *guard = s;
    }

    async fn current_state(&self) -> AppState {
        *self.state.read().await
    }

    fn trigger_shutdown(&self) {
        if let Some(tx) = self.shutdown_trigger.try_write().ok().and_then(|mut g| g.take()) {
            let _ = tx.send(());
        }
    }
}

/// Application builder for Kegani web framework
///
/// # Example
///
/// ```rust,no_run
/// use actix_web::web;
/// use kegani::App;
///
/// #[tokio::main]
/// async fn main() -> std::io::Result<()> {
///     App::new()
///         .host("127.0.0.1")
///         .port(8080)
///         .configure(|cfg| {
///             cfg.service(web::scope("/api")
///                 .route("/health", web::get().to(|| async {
///                     HttpResponse::Ok().json(serde_json::json!({ "status": "ok" }))
///                 }))
///             );
///         })
///         .run()
///         .await
/// }
/// ```
pub struct App {
    host: String,
    port: u16,
    shutdown_timeout: Duration,
    configure_fn: Option<Arc<dyn Fn(&mut web::ServiceConfig) + Send + Sync>>,
    control: Arc<RwLock<Option<ServerControl>>>,
}

impl App {
    /// Create a new App instance
    pub fn new() -> Self {
        Self {
            host: "0.0.0.0".to_string(),
            port: 8080,
            shutdown_timeout: Duration::from_secs(30),
            configure_fn: None,
            control: Arc::new(RwLock::new(None)),
        }
    }

    /// Set the host address
    pub fn host(mut self, host: &str) -> Self {
        self.host = host.to_string();
        self
    }

    /// Set the port
    pub fn port(mut self, port: u16) -> Self {
        self.port = port;
        self
    }

    /// Set the shutdown timeout for graceful shutdown
    pub fn shutdown_timeout(mut self, timeout: Duration) -> Self {
        self.shutdown_timeout = timeout;
        self
    }

    /// Configure routes, services, and app data
    ///
    /// This is the primary method for wiring up your application.
    ///
    /// # Example
    /// ```
    /// App::new().configure(|cfg| {
    ///     cfg.service(web::scope("/api")
    ///         .route("/users", web::get().to(list_users))
    ///     );
    /// });
    /// ```
    pub fn configure<F>(mut self, f: F) -> Self
    where
        F: Fn(&mut web::ServiceConfig) + Send + Sync + 'static,
    {
        self.configure_fn = Some(Arc::new(f));
        self
    }

    /// Run the server with graceful Ctrl+C shutdown
    ///
    /// Blocks until Ctrl+C is received, then shuts down gracefully.
    pub async fn run(self) -> std::io::Result<()> {
        let addr = format!("{}:{}", self.host, self.port);

        let control = ServerControl::new();
        control.set_state(AppState::Starting).await;

        {
            let mut c = self.control.write().await;
            *c = Some(control.clone());
        }

        tracing::info!("Starting Kegani server on {}", addr);

        let state = control.state.clone();
        let configure_fn = self.configure_fn.clone();

        // Build and start the server — signals (Ctrl+C) are enabled by default
        let server = HttpServer::new(move || {
            let state = state.clone();

            ActixApp::new()
                .wrap(Logger::default())
                .app_data(web::Data::new(state))
                .configure(|cfg: &mut web::ServiceConfig| {
                    // Default: serve embedded OpenAPI docs
                    cfg.service(web::scope("/api-docs")
                        .route("/openapi.json", web::get().to(openapi_json_handler))
                        .route("/swagger-ui", web::get().to(swagger_ui_handler))
                        .route("/redoc", web::get().to(redoc_handler))
                    );
                    // User's custom routes
                    if let Some(ref f) = configure_fn {
                        f(cfg);
                    }
                })
                .default_service(web::route().to(not_found_handler))
        })
        .bind(&addr)?
        .run();

        control.set_state(AppState::Running).await;

        // Wait for default signal (Ctrl+C or SIGTERM)
        // run() blocks until the server shuts down
        let _ = server.await;

        control.set_state(AppState::Stopped).await;
        tracing::info!("Server stopped");
        Ok(())
    }

    /// Start the server without blocking — returns immediately after bind.
    /// The server runs in the background. Call `.stop()` to shut it down.
    pub async fn serve(self) -> std::io::Result<()> {
        let addr = format!("{}:{}", self.host, self.port);

        let control = ServerControl::new();
        control.set_state(AppState::Starting).await;

        {
            let mut c = self.control.write().await;
            *c = Some(control.clone());
        }

        let state = control.state.clone();
        let configure_fn = self.configure_fn.clone();

        let _server = HttpServer::new(move || {
            let state = state.clone();

            ActixApp::new()
                .wrap(Logger::default())
                .app_data(web::Data::new(state))
                .configure(|cfg: &mut web::ServiceConfig| {
                    cfg.service(web::scope("/api-docs")
                        .route("/openapi.json", web::get().to(openapi_json_handler))
                        .route("/swagger-ui", web::get().to(swagger_ui_handler))
                        .route("/redoc", web::get().to(redoc_handler))
                    );
                    if let Some(ref f) = configure_fn {
                        f(cfg);
                    }
                })
                .default_service(web::route().to(not_found_handler))
        })
        .bind(&addr)?
        .run();

        // server runs in background; we return immediately so caller can manage it
        let _ = _server;

        control.set_state(AppState::Running).await;
        Ok(())
    }

    /// Stop a running server (call after `serve()`)
    pub async fn stop(&self) {
        if let Some(ref ctrl) = *self.control.read().await {
            ctrl.set_state(AppState::Stopping).await;
            ctrl.trigger_shutdown();
        }
    }

    /// Get the current app state
    pub async fn state(&self) -> AppState {
        if let Some(ref ctrl) = *self.control.read().await {
            ctrl.current_state().await
        } else {
            AppState::Created
        }
    }
}

impl Default for App {
    fn default() -> Self {
        Self::new()
    }
}

// ── Default handlers ────────────────────────────────────────────────────────────

async fn not_found_handler() -> HttpResponse {
    HttpResponse::NotFound().json(serde_json::json!({
        "error": "NOT_FOUND",
        "message": "The requested resource was not found"
    }))
}

async fn openapi_json_handler() -> HttpResponse {
    HttpResponse::Ok()
        .content_type("application/json")
        .json(serde_json::json!({
            "openapi": "3.0.3",
            "info": { "title": "Kegani API", "version": "1.0.0" },
            "paths": {}
        }))
}

async fn swagger_ui_handler() -> HttpResponse {
    let html = r#"<!DOCTYPE html>
<html>
<head>
    <title>Kegani API — Swagger UI</title>
    <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
</head>
<body>
    <div id="swagger-ui"></div>
    <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
    <script>
        window.onload = function() {
            SwaggerUIBundle({
                url: "/api-docs/openapi.json",
                dom_id: "swagger-ui",
                presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
                layout: "BaseLayout"
            });
        };
    </script>
</body>
</html>"#;
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(html)
}

async fn redoc_handler() -> HttpResponse {
    let html = r#"<!DOCTYPE html>
<html>
<head>
    <title>Kegani API — ReDoc</title>
    <link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
</head>
<body>
    <redoc spec-url="/api-docs/openapi.json"></redoc>
    <script src="https://unpkg.com/redoc@latest/bundles/redoc.standalone.js"></script>
</body>
</html>"#;
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(html)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_app_creation() {
        let app = App::new();
        assert_eq!(app.host, "0.0.0.0");
        assert_eq!(app.port, 8080);
    }

    #[test]
    fn test_app_builder() {
        let app = App::new().host("127.0.0.1").port(3000);
        assert_eq!(app.host, "127.0.0.1");
        assert_eq!(app.port, 3000);
    }
}