use axum::{ body, extract::State, middleware::Next, response::{Html, IntoResponse, Response}, routing::get, Router, }; use hyper::{header::CONTENT_TYPE, Request, StatusCode}; use lazy_static::lazy_static; use prometheus::{opts, Encoder, IntCounterVec, TextEncoder}; use std::sync::Arc; use tower_http::services::ServeFile; use tracing::{instrument, log::*}; use crate::{AppState, WebsiteError}; pub mod posts; pub mod tags; lazy_static! { pub static ref HIT_COUNTER: IntCounterVec = prometheus::register_int_counter_vec!( opts!( "http_requests_total", "Total amount of http requests received" ), &["route", "method", "status"] ) .unwrap(); } pub fn routes(state: &Arc) -> Router> { Router::new() .route("/", get(index)) .merge(posts::router()) .merge(tags::router()) .merge(posts::alias_router(state.posts.values())) .layer(axum::middleware::from_fn(metrics_middleware)) .route("/healthcheck", get(healthcheck)) .route("/metrics", get(metrics)) .route_service( "/posts/:slug/*path", tower_http::services::ServeDir::new("./").fallback(ServeFile::new("./404.html")), ) .route_service( "/static/*path", tower_http::services::ServeDir::new("./").fallback(ServeFile::new("./404.html")), ) .fallback_service(ServeFile::new("./404.html")) } #[instrument(skip(state))] pub async fn index( State(state): State>, ) -> std::result::Result, WebsiteError> { let ctx = tera::Context::new(); let res = state.tera.render("index.html", &ctx).map_err(|e| { error!("Failed rendering index: {}", e); WebsiteError::NotFound })?; Ok(Html(res)) } async fn healthcheck() -> &'static str { "OK" } async fn metrics() -> impl IntoResponse { let encoder = TextEncoder::new(); let metric_families = prometheus::gather(); let mut buffer = vec![]; encoder.encode(&metric_families, &mut buffer).unwrap(); Response::builder() .status(200) .header(CONTENT_TYPE, encoder.format_type()) .body(body::boxed(body::Full::from(buffer))) .unwrap() } pub async fn not_found() -> impl IntoResponse { (StatusCode::NOT_FOUND, ()) } pub async fn metrics_middleware(request: Request, next: Next) -> Response { let path = request.uri().path().to_string(); let method = request.method().to_string(); let response = next.run(request).await; if !response.status().is_client_error() { HIT_COUNTER .with_label_values(&[&path, &method, response.status().as_str()]) .inc(); } else if response.status() == StatusCode::NOT_FOUND { HIT_COUNTER .with_label_values(&["not found", &method, response.status().as_str()]) .inc(); } response } impl IntoResponse for WebsiteError { fn into_response(self) -> Response { match self { WebsiteError::NotFound => { info!("not found"); (StatusCode::NOT_FOUND, ()).into_response() } WebsiteError::InternalError(e) => { if let Some(s) = e.source() { error!("internal error: {}: {}", e, s); } else { error!("internal error: {}", e); } (StatusCode::INTERNAL_SERVER_ERROR, ()).into_response() } } } } #[cfg(test)] mod tests { use std::sync::Arc; use crate::AppState; #[test] fn render_index() { let tera = tera::Tera::new("templates/**/*").unwrap(); let ctx = tera::Context::new(); let _res = tera.render("index.html", &ctx).unwrap(); } #[tokio::test] async fn setup_routes() { // Load the actual posts, just to make this test fail if // aliases overlap with themselves or other routes let posts = crate::post::load_all().await.unwrap(); let state = Arc::new(AppState { base_url: "http://localhost:8180".into(), tera: tera::Tera::new("templates/**/*").unwrap(), tags: crate::tag::get_tags(posts.values()), posts, }); super::routes(&state).with_state(state).into_make_service(); } }