diff --git a/Cargo.lock b/Cargo.lock index 8dd4d25..4b83574 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -538,14 +538,14 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5916d2ae698f6de9bfb891ad7a8d65c09d232dc58cc4ac433c7da3b2fd84bc2b" +checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" [[package]] name = "libshire" version = "0.1.0" -source = "git+https://github.com/pantonshire/libshire#70fa5daf6c11229d88687324475c016cb49789f1" +source = "git+https://github.com/pantonshire/libshire#62dae931409c14531cf338d73269e415c638dede" [[package]] name = "line-wrap" diff --git a/blog_server/src/main.rs b/blog_server/src/main.rs index 8b13258..4d12e67 100644 --- a/blog_server/src/main.rs +++ b/blog_server/src/main.rs @@ -1,32 +1,17 @@ mod codeblock; mod fs_watcher; -mod handlers; -mod html_response; mod post; mod posts_store; mod render; +mod services; -use std::{env, fs, io, path::PathBuf, thread}; - -use axum::{ - {routing::{get, get_service}, Router}, - extract::{Extension, Path}, - response::{IntoResponse, Response}, - handler::Handler, - http::StatusCode -}; -use libshire::convert::infallible_elim; -use maud::html; +use std::{env, fs, path::PathBuf, thread}; + +use axum::Server; use miette::{IntoDiagnostic, Context}; -use tower::{ - limit::ConcurrencyLimitLayer, - ServiceExt, -}; -use tower_http::{services::ServeDir, trace::TraceLayer}; use tracing::info; use codeblock::CodeBlockRenderer; -use html_response::HtmlResponse; use posts_store::ConcurrentPostsStore; use render::Renderer; @@ -94,20 +79,11 @@ fn main() -> miette::Result<()> { } async fn run(config: Config, posts_store: ConcurrentPostsStore) -> miette::Result<()> { - let static_service = get_service(ServeDir::new(&config.static_dir) - .fallback(handle_fallback - .into_service() - .map_err(infallible_elim::))) - .handle_error(handle_static_io_error); - - let router = Router::new() - .route("/", get(handle_index)) - .route("/posts/:post_id", get(handle_post_page)) - .nest("/static", static_service) - .fallback(handle_fallback.into_service()) - .layer(ConcurrencyLimitLayer::new(config.concurrency_limit)) - .layer(TraceLayer::new_for_http()) - .layer(Extension(posts_store)); + let service = services::site_service( + posts_store, + &config.static_dir, + config.concurrency_limit + ); let bind_address = &config.bind .parse() @@ -116,106 +92,11 @@ async fn run(config: Config, posts_store: ConcurrentPostsStore) -> miette::Resul info!(address = %bind_address, "Starting server"); - axum::Server::try_bind(bind_address) + Server::try_bind(bind_address) .into_diagnostic() .wrap_err_with(|| format!("Failed to bind {}", bind_address))? - .serve(router.into_make_service()) + .serve(service.into_make_service()) .await .into_diagnostic() .wrap_err("Fatal error while running the server") } - -async fn handle_fallback() -> Error { - Error::NotFound -} - -async fn handle_static_io_error(_err: io::Error) -> Error { - Error::Internal -} - -async fn handle_index(Extension(posts): Extension) -> HtmlResponse { - HtmlResponse::new() - .with_title_static("Placeholder title") - .with_crawler_permissive() - .with_body(html! { - h1 { "Here is my great heading" } - p { "Hello world" } - ul { - @for post in posts.read().await.iter_by_created().rev() { - li { - a href={ "/posts/" (post.id_str()) } { - (post.title()) - }; - } - } - } - }) -} - -async fn handle_post_page( - Path(post_id): Path, - Extension(posts): Extension -) -> Result -{ - let post = posts.get(&post_id) - .await - .ok_or(Error::NotFound)?; - - Ok(HtmlResponse::new() - .with_crawler_permissive() - .with_title_owned(post.title().to_owned()) - .with_head(html! { - link href="/static/style/code.css" rel="stylesheet"; - }) - .with_body(html! { - h1 { (post.title()) } - p { "by " (post.author()) } - article { - (post.html()) - } - })) -} - -// TODO: store diagnostic information in Error struct which is output to trace -#[derive(Debug)] -enum Error { - Internal, - NotFound, -} - -impl Error { - fn status_code(&self) -> StatusCode { - match self { - Error::Internal => StatusCode::INTERNAL_SERVER_ERROR, - Error::NotFound => StatusCode::NOT_FOUND, - } - } -} - -impl IntoResponse for Error { - fn into_response(self) -> Response { - let status_code = self.status_code(); - - // Create a string buffer containing the full error text, e.g. "404 Not Found". - let status_text = { - let status_code_str = status_code.as_str(); - let reason = status_code.canonical_reason(); - let mut buf = String::with_capacity( - status_code_str.len() + reason.map(|reason| reason.len() + 1).unwrap_or(0)); - buf.push_str(status_code_str); - if let Some(reason) = reason { - buf.push(' '); - buf.push_str(reason); - } - buf - }; - - HtmlResponse::new() - .with_status(status_code) - .with_body(html! { - p { (status_text) } - }) - .with_title_owned(status_text) - .into_response() - } -} diff --git a/blog_server/src/services/index.rs b/blog_server/src/services/index.rs new file mode 100644 index 0000000..293a739 --- /dev/null +++ b/blog_server/src/services/index.rs @@ -0,0 +1,24 @@ +use axum::extract::Extension; +use maud::html; + +use crate::posts_store::ConcurrentPostsStore; +use super::response::HtmlResponse; + +pub async fn handle(Extension(posts): Extension) -> HtmlResponse { + HtmlResponse::new() + .with_title_static("Placeholder title") + .with_crawler_permissive() + .with_body(html! { + h1 { "Here is my great heading" } + p { "Hello world" } + ul { + @for post in posts.read().await.iter_by_created().rev() { + li { + a href={ "/posts/" (post.id_str()) } { + (post.title()) + }; + } + } + } + }) +} diff --git a/blog_server/src/services/mod.rs b/blog_server/src/services/mod.rs new file mode 100644 index 0000000..db13380 --- /dev/null +++ b/blog_server/src/services/mod.rs @@ -0,0 +1,7 @@ +mod index; +mod posts; +mod response; +mod site; +mod static_content; + +pub use site::service as site_service; diff --git a/blog_server/src/services/posts.rs b/blog_server/src/services/posts.rs new file mode 100644 index 0000000..d7bef58 --- /dev/null +++ b/blog_server/src/services/posts.rs @@ -0,0 +1,29 @@ +use axum::extract::{Extension, Path}; +use maud::html; + +use crate::posts_store::ConcurrentPostsStore; +use super::response::{ErrorResponse, HtmlResponse}; + +pub async fn handle( + Path(post_id): Path, + Extension(posts): Extension +) -> Result +{ + let post = posts.get(&post_id) + .await + .ok_or(ErrorResponse::PostNotFound)?; + + Ok(HtmlResponse::new() + .with_crawler_permissive() + .with_title_owned(post.title().to_owned()) + .with_head(html! { + link href="/static/style/code.css" rel="stylesheet"; + }) + .with_body(html! { + h1 { (post.title()) } + p { "by " (post.author()) } + article { + (post.html()) + } + })) +} diff --git a/blog_server/src/html_response.rs b/blog_server/src/services/response.rs similarity index 76% rename from blog_server/src/html_response.rs rename to blog_server/src/services/response.rs index 07c095b..af70e08 100644 --- a/blog_server/src/html_response.rs +++ b/blog_server/src/services/response.rs @@ -5,6 +5,58 @@ use axum::response::{IntoResponse, Response}; use axum::http::{self, StatusCode}; use maud::{html, Markup, Render, Escaper, DOCTYPE}; +#[derive(Debug)] +pub enum ErrorResponse { + Internal, + PostNotFound, + StaticResourceNotFound, + RouteNotFound, +} + +impl ErrorResponse { + fn status_code(&self) -> StatusCode { + match self { + ErrorResponse::Internal => StatusCode::INTERNAL_SERVER_ERROR, + ErrorResponse::PostNotFound => StatusCode::NOT_FOUND, + ErrorResponse::StaticResourceNotFound => StatusCode::NOT_FOUND, + ErrorResponse::RouteNotFound => StatusCode::NOT_FOUND, + } + } +} + +impl IntoResponse for ErrorResponse { + fn into_response(self) -> Response { + let status_code = self.status_code(); + + // Create a string buffer containing the full error text, e.g. "404 Not Found". + let status_text = { + let status_code_str = status_code.as_str(); + let reason = status_code.canonical_reason(); + + // Allocate a buffer with enough capacity to store the full error text. + let mut buf = String::with_capacity( + status_code_str.len() + reason.map(|reason| reason.len() + 1).unwrap_or(0)); + + // Push the numerical code string first, then a space, then the error reason string. + buf.push_str(status_code_str); + if let Some(reason) = reason { + buf.push(' '); + buf.push_str(reason); + } + + buf + }; + + HtmlResponse::new() + .with_status(status_code) + .with_body(html! { + p { (status_text) } + }) + .with_title_owned(status_text) + .into_response() + } +} + pub struct HtmlResponse { status: StatusCode, title: Cow<'static, str>, diff --git a/blog_server/src/services/site.rs b/blog_server/src/services/site.rs new file mode 100644 index 0000000..3d29933 --- /dev/null +++ b/blog_server/src/services/site.rs @@ -0,0 +1,41 @@ +use std::path::Path; + +use axum::{ + handler::Handler, + http::Uri, + extract::Extension, + Router, + routing::get, +}; +use tower::limit::ConcurrencyLimitLayer; +use tower_http::trace::TraceLayer; +use tracing::info; + +use crate::posts_store::ConcurrentPostsStore; +use super::{ + index, + posts, + response::ErrorResponse, + static_content, +}; + +pub fn service( + posts_store: ConcurrentPostsStore, + static_dir: &Path, + concurrency_limit: usize +) -> Router +{ + Router::new() + .route("/", get(index::handle)) + .route("/posts/:post_id", get(posts::handle)) + .nest("/static", static_content::service(static_dir)) + .fallback(handle_fallback.into_service()) + .layer(ConcurrencyLimitLayer::new(concurrency_limit)) + .layer(TraceLayer::new_for_http()) + .layer(Extension(posts_store)) +} + +pub async fn handle_fallback(uri: Uri) -> ErrorResponse { + info!(path = %uri.path(), "Requested resource not found"); + ErrorResponse::RouteNotFound +} diff --git a/blog_server/src/services/static_content.rs b/blog_server/src/services/static_content.rs new file mode 100644 index 0000000..ce1cf29 --- /dev/null +++ b/blog_server/src/services/static_content.rs @@ -0,0 +1,40 @@ +use std::{ + convert::Infallible, + io, + path::Path, +}; + +use axum::{ + body::Body, + handler::Handler, + http::Uri, + routing::{get_service, MethodRouter}, +}; +use libshire::convert::Empty; +use tower::ServiceExt; +use tower_http::services::ServeDir; +use tracing::{info, error}; + +use super::response::ErrorResponse; + +pub fn service(static_dir: &Path) -> MethodRouter { + let fallback_service = handle_fallback + .into_service() + .map_err(Empty::elim::); + + let serve_dir = ServeDir::new(static_dir) + .fallback(fallback_service); + + get_service(serve_dir) + .handle_error(handle_error) +} + +pub async fn handle_fallback(uri: Uri) -> ErrorResponse { + info!(path = %uri.path(), "Requested static file not found"); + ErrorResponse::StaticResourceNotFound +} + +pub async fn handle_error(uri: Uri, err: io::Error) -> ErrorResponse { + error!(path = %uri.path(), err = %err, "IO error"); + ErrorResponse::Internal +} diff --git a/blog_server/src/handlers.rs b/blog_server/src/static_content.rs similarity index 100% rename from blog_server/src/handlers.rs rename to blog_server/src/static_content.rs