xroute 0.1.0-alpha.2

A heavily opinionated HTTP server wrapper for Rust web applications
Documentation
//! see repo readme

#![forbid(unsafe_code)]
#![cfg_attr(debug_assertions, warn(missing_docs))]
#![cfg_attr(not(debug_assertions), deny(missing_docs))]
#![warn(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::dbg_macro,
    clippy::todo,
    clippy::unimplemented
)]

use serde::Serialize;
pub use xroute_macros::*;

/// Enforce that a Result is Ok, otherwise return the Err as a response
#[macro_export]
macro_rules! enforce {
    ($item:expr) => {{
        match $item {
            Ok(v) => v,
            Err(res) => return res,
        }
    };};
}

/// Generate a redirect response to the given URL
pub fn redirect(url: &str) -> crate::prelude::Res {
    axum::response::Redirect::to(url).into_response()
}

/// Enforce that the request has a user header, otherwise return a 401 response
///
/// This should be used in tandem with `enforce` macro.
#[allow(clippy::result_large_err)]
pub fn enforce_user(req: &prelude::Req) -> Result<String, prelude::Res> {
    let user = req
        .headers()
        .get("Remote-User")
        .or_else(|| req.headers().get("X-Forwarded-User"));
    if user.is_none() {
        // Build the 401 response with Authelia trigger headers
        #[allow(clippy::expect_used)]
        return Err(axum::response::Response::builder()
            .status(prelude::StatusCode::UNAUTHORIZED)
            .header("X-Authelia-Action", "auth")
            .body(axum::body::Body::empty())
            .expect("Failed to build empty body")
            .into_response());
    }
    Ok(user
        .unwrap_or(&axum::http::header::HeaderValue::from_static(""))
        .to_str()
        .unwrap_or("")
        .to_string())
}

#[cfg(feature = "stream")]
/// Create a streamed SSE response from an iterator and a mapping function
pub async fn streamed_response<I, T, E, F, Fut>(
    workers: usize,
    iter: I,
    mapper: F,
) -> crate::prelude::Res
where
    I: IntoIterator + Send + 'static,
    I::IntoIter: Send + 'static,
    I::Item: Send + 'static,
    F: Fn(I::Item) -> Fut + Send + Sync + 'static,
    Fut: futures::Future<Output = Result<T, E>> + Send + 'static,
    T: EasyResponse + Serialize + Send + 'static,
    E: Into<Box<dyn std::error::Error + Send + Sync>> + std::fmt::Display + Send + 'static,
{
    use axum::response::sse::{Event, Sse};
    use futures::{StreamExt, stream};

    let mapper = std::sync::Arc::new(mapper);

    let stream =
        stream::iter(iter.into_iter())
            .map(move |item| {
                let mapper = mapper.clone();
                mapper(item)
            })
            .buffer_unordered(workers)
            .map(|res| match res {
                Ok(item) => {
                    let json = serde_json::to_string(&item).unwrap_or_else(|_| "{}".into());
                    Ok::<Event, E>(Event::default().data(json))
                }
                Err(e) => {
                    log::warn!("Error in streamed response: {}", e);
                    Ok(Event::default()
                        .data(serde_json::json!({ "error": e.to_string() }).to_string()))
                }
            });

    Sse::new(stream).into_response()
}

/// Commonly used items
pub mod prelude {
    /// Generic full request
    pub type Req = axum::extract::Request<axum::body::Body>;
    /// Generic response
    pub type Res = axum::response::Response;
    #[cfg(feature = "stream")]
    pub use crate::streamed_response;
    pub use crate::x::EasyResponse;
    pub use crate::{enforce, redirect, run};
    pub use axum::{
        extract::{Path, Query},
        http::StatusCode,
    };
    pub use log::{debug, error, info, trace, warn};
    pub use xroute_macros::*;
}
/// Macro-used items, not for direct use
pub mod x {
    pub use axum::{
        Json, Router,
        body::Body,
        extract::State,
        extract::{FromRequest, FromRequestParts},
        http::request::Request as HttpRequest,
        response::IntoResponse,
        response::Response,
        routing::{delete, get, patch, post, put},
    };
    pub use serde::{Deserialize, Serialize};
    pub use ts_rs as ts;

    pub use tokio::runtime::Builder as AsyncBuilder;

    /// Trait to easily convert items into JSON responses
    pub trait EasyResponse {
        /// Convert the item into a JSON response with 200 OK status
        fn res(self) -> Response;
        /// Convert the item into a JSON response with the given status
        fn res_with(self, status: crate::prelude::StatusCode) -> Response;
        /// Convert the item into a JSON response with the given status and headers
        fn res_with_headers<const N: usize>(
            self,
            status: crate::prelude::StatusCode,
            headers: [(&str, &str); N],
        ) -> Response;
        /// Convert the item into a JSON string
        ///
        /// This is used for SSE.
        fn json(&self) -> String
        where
            Self: Serialize,
        {
            serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
        }
    }
    impl<T: Serialize> EasyResponse for T {
        fn res(self) -> Response {
            Json(self).into_response()
        }
        fn res_with(self, status: crate::prelude::StatusCode) -> Response {
            (status, Json(self)).into_response()
        }
        fn res_with_headers<const N: usize>(
            self,
            status: crate::prelude::StatusCode,
            headers: [(&str, &str); N],
        ) -> Response {
            (status, headers, Json(self)).into_response()
        }
    }
}
use axum::{extract::Request, http::Uri};
use std::{path::PathBuf, sync::Arc};
use tower::ServiceExt as _;
use tower_http::{compression::CompressionLayer, services::ServeFile};
use x::{Body, IntoResponse, Router};

use crate::x::EasyResponse;

/// Run an Axum server with the given router and static file serving
///
/// # Arguments
/// - `router`: The Axum router to use for handling requests. When using router! macro, use the router() function it generates.
pub async fn run(router: Router) {
    env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));

    let fallback = {
        let static_path = "./web/dist/".to_string();
        move |uri: Uri| {
            let static_path = static_path.clone();
            async move {
                serve_static(uri, static_path.into(), |uri| {
                    format!("404 Not Found: {}", uri)
                })
                .await
            }
        }
    };

    let app = Router::new()
        .merge(router)
        .fallback(fallback)
        .layer(CompressionLayer::new());

    let addr = "0.0.0.0:3000";
    let listener = match tokio::net::TcpListener::bind(addr).await {
        Ok(l) => l,
        Err(e) => {
            log::error!("Failed to listen at `{}`: {}", addr, e);
            return;
        }
    };
    log::info!("Listening on http://0.0.0.0:3000");
    if let Err(e) = axum::serve(listener, app).await {
        log::error!("Server error: {}", e);
    }
}

async fn serve_static<R: IntoResponse>(
    uri: Uri,
    static_path: Arc<String>,
    not_found: fn(uri: Uri) -> R,
) -> impl IntoResponse {
    let path = uri.path().trim_start_matches('/');
    let mut file_path = PathBuf::from(static_path.as_ref());
    file_path.push(path);

    // check if file or dir
    match tokio::fs::metadata(&file_path).await {
        Ok(meta) if meta.is_file() => {}
        _ => {
            let mut index_path = PathBuf::from(static_path.as_str());
            index_path.push("index.html");
            file_path = index_path;
        }
    }

    let file_res = ServeFile::new(file_path)
        .oneshot(Request::new(Body::empty()))
        .await;

    match file_res {
        Ok(res) => res.into_response(),
        Err(_) => not_found(uri).into_response(),
    }
}