From 3dfb480ba40c966beab6e6b825b66f1aecc86b14 Mon Sep 17 00:00:00 2001 From: Rob Ede Date: Mon, 27 May 2024 03:48:30 +0100 Subject: [PATCH] feat: migrate to axum router --- Cargo.lock | 25 +++- Cargo.toml | 2 +- src/main.rs | 17 +-- src/server/assets.rs | 2 + src/server/mod.rs | 320 +++++++++++++++++++------------------------ 5 files changed, 165 insertions(+), 201 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4811221..4b98ecc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2336,12 +2336,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "route-recognizer" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" - [[package]] name = "rustc-demangle" version = "0.1.24" @@ -2647,7 +2641,6 @@ dependencies = [ "pulldown-cmark", "relative-path", "reqwest", - "route-recognizer", "rustsec", "semver", "serde", @@ -2656,6 +2649,7 @@ dependencies = [ "tokio", "toml 0.8.13", "tower", + "tower-http", "tracing", "tracing-subscriber", ] @@ -3034,6 +3028,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.5.0", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-layer" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index 9eb97ba..b87e9c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,6 @@ parking_lot = "0.12" pulldown-cmark = "0.11" relative-path = { version = "1", features = ["serde"] } reqwest = { version = "0.12", features = ["json"] } -route-recognizer = "0.3" rustsec = "0.29" semver = { version = "1.0", features = ["serde"] } serde = { version = "1", features = ["derive"] } @@ -39,6 +38,7 @@ serde_urlencoded = "0.7" tokio = { version = "1.24.2", features = ["rt-multi-thread", "macros", "sync", "time"] } toml = "0.8" tower = "0.4" +tower-http = { version = "0.5", features = ["normalize-path", "trace"] } tracing = "0.1.30" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/main.rs b/src/main.rs index ebf76f6..4cf939a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,10 +9,9 @@ use std::{ time::Duration, }; -use axum::{extract::Request, Router}; use cadence::{QueuingMetricSink, UdpMetricSink}; use reqwest::redirect::Policy as RedirectPolicy; -use tracing::Instrument as _; +use tokio::net::TcpListener; mod engine; mod interactors; @@ -90,18 +89,8 @@ async fn main() { let app = App::new(engine.clone()); - let lst = tokio::net::TcpListener::bind(addr).await.unwrap(); - - let router = Router::new().fallback(|req: Request| async move { - let path = req.uri().path().to_owned(); - - app.handle(req) - .instrument(tracing::info_span!("@", %path)) - .await - .unwrap() - }); - - let server = axum::serve(lst, router); + let lst = TcpListener::bind(addr).await.unwrap(); + let server = axum::serve(lst, App::router().with_state(app)); tracing::info!("Server running on port {port}"); diff --git a/src/server/assets.rs b/src/server/assets.rs index 7172d7f..c0d79ee 100644 --- a/src/server/assets.rs +++ b/src/server/assets.rs @@ -9,6 +9,8 @@ pub const STATIC_STYLE_CSS_ETAG: &str = concat!( include_str!(concat!(env!("OUT_DIR"), "/style.css.sha1")), "\"" ); + +pub const STATIC_FAVICON_PATH: &str = "/static/logo.svg"; pub static STATIC_FAVICON: &[u8] = include_bytes!("../../assets/logo.svg"); pub static STATIC_LINKS_JS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/links.js")); diff --git a/src/server/mod.rs b/src/server/mod.rs index 2bd902c..6791c4d 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,26 +1,29 @@ -use std::{env, sync::Arc, time::Instant}; +use std::env; use axum::{ body::Body, - extract::Request, + extract::{Path, Request, State}, http::{ - header::{CACHE_CONTROL, CONTENT_TYPE, ETAG, LOCATION}, - Method, StatusCode, + header::{CACHE_CONTROL, CONTENT_TYPE, ETAG}, + StatusCode, }, - response::Response, + response::{IntoResponse as _, Redirect, Response}, + routing::get, + Router, }; use badge::BadgeStyle; use futures_util::future; use once_cell::sync::Lazy; -use route_recognizer::{Params, Router}; use semver::VersionReq; use serde::Deserialize; +use tower_http::{normalize_path::NormalizePathLayer, trace::TraceLayer}; mod assets; mod views; use self::assets::{ - STATIC_LINKS_JS_ETAG, STATIC_LINKS_JS_PATH, STATIC_STYLE_CSS_ETAG, STATIC_STYLE_CSS_PATH, + STATIC_FAVICON_PATH, STATIC_LINKS_JS_ETAG, STATIC_LINKS_JS_PATH, STATIC_STYLE_CSS_ETAG, + STATIC_STYLE_CSS_PATH, }; use crate::{ engine::{AnalyzeDependenciesOutcome, Engine}, @@ -44,116 +47,54 @@ enum StaticFile { LinksJs, } -enum Route { - Index, - Static(StaticFile), - RepoStatus(StatusFormat), - CrateRedirect, - CrateStatus(StatusFormat), - LatestCrateBadge, -} - #[derive(Clone)] pub struct App { engine: Engine, - router: Arc>, } impl App { pub fn new(engine: Engine) -> App { - let mut router = Router::new(); - - router.add("/", Route::Index); - - router.add(STATIC_STYLE_CSS_PATH, Route::Static(StaticFile::StyleCss)); - router.add("/static/logo.svg", Route::Static(StaticFile::FaviconPng)); - router.add(STATIC_LINKS_JS_PATH, Route::Static(StaticFile::LinksJs)); - - router.add( - "/repo/*site/:qual/:name", - Route::RepoStatus(StatusFormat::Html), - ); - router.add( - "/repo/*site/:qual/:name/status.svg", - Route::RepoStatus(StatusFormat::Svg), - ); - - router.add("/crate/:name", Route::CrateRedirect); - router.add( - "/crate/:name/:version", - Route::CrateStatus(StatusFormat::Html), - ); - router.add("/crate/:name/latest/status.svg", Route::LatestCrateBadge); - router.add( - "/crate/:name/:version/status.svg", - Route::CrateStatus(StatusFormat::Svg), - ); - - App { - engine, - router: Arc::new(router), - } + App { engine } } - pub async fn handle(&self, req: Request) -> Result, axum::Error> { - let start = Instant::now(); - - // allows `/path/` to also match `/path` - let normalized_path = req.uri().path().trim_end_matches('/'); - - let res = if let Ok(route_match) = self.router.recognize(normalized_path) { - match (req.method(), route_match.handler()) { - (&Method::GET, Route::Index) => self.index(req, route_match.params().clone()).await, - - (&Method::GET, Route::RepoStatus(format)) => { - self.repo_status(req, route_match.params().clone(), *format) - .await - } - - (&Method::GET, Route::CrateStatus(format)) => { - self.crate_status(req, route_match.params().clone(), *format) - .await - } - - (&Method::GET, Route::LatestCrateBadge) => { - self.crate_status(req, route_match.params().clone(), StatusFormat::Svg) - .await - } - - (&Method::GET, Route::CrateRedirect) => { - self.crate_redirect(req, route_match.params().clone()).await - } - - (&Method::GET, Route::Static(file)) => Ok(App::static_file(*file)), - - _ => Ok(not_found()), - } - } else { - Ok(not_found()) - }; - - let end = Instant::now(); - let diff = end - start; - - match &res { - Ok(res) => tracing::info!( - status = %res.status(), - time = %format_args!("{}ms", diff.as_millis()), - ), - Err(err) => tracing::error!(%err), - }; - - res + pub(crate) fn router() -> Router { + Router::new() + .route("/", get(App::index)) + .route("/crate/:name", get(App::crate_redirect)) + .route("/crate/:name/:version", get(App::crate_status_html)) + .route( + "/crate/:name/latest/status.svg", + get(App::crate_latest_status_svg), + ) + .route( + "/crate/:name/:version/status.svg", + get(App::crate_status_svg), + ) + // TODO: `:site` isn't quite right, original was `*site` + .route("/repo/:site/:qual/:name", get(App::repo_status_html)) + .route( + "/repo/:site/:qual/:name/status.svg", + get(App::repo_status_svg), + ) + .route( + STATIC_STYLE_CSS_PATH, + get(|| App::static_file(StaticFile::StyleCss)), + ) + .route( + STATIC_FAVICON_PATH, + get(|| App::static_file(StaticFile::FaviconPng)), + ) + .route( + STATIC_LINKS_JS_PATH, + get(|| App::static_file(StaticFile::LinksJs)), + ) + .fallback(|| async { not_found() }) + .layer(NormalizePathLayer::trim_trailing_slash()) + .layer(TraceLayer::new_for_http()) } -} -impl App { - async fn index( - &self, - _req: Request, - _params: Params, - ) -> Result, axum::Error> { - let engine = self.engine.clone(); + async fn index(State(app): State) -> Response { + let engine = app.engine.clone(); let popular = future::try_join(engine.get_popular_repos(), engine.get_popular_crates()).await; @@ -164,29 +105,42 @@ impl App { let mut response = views::html::error::render("Could not retrieve popular items", ""); *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; - Ok(response) + response } + Ok((popular_repos, popular_crates)) => { - Ok(views::html::index::render(popular_repos, popular_crates)) + views::html::index::render(popular_repos, popular_crates) } } } - async fn repo_status( - &self, - req: Request, - params: Params, - format: StatusFormat, - ) -> Result, axum::Error> { - let server = self.clone(); + async fn repo_status_html( + State(app): State, + Path(params): Path<(String, String, String)>, + req: Request, + ) -> Response { + Self::repo_status(app, params, req, StatusFormat::Html).await + } - let site = params.find("site").expect("route param 'site' not found"); - let qual = params.find("qual").expect("route param 'qual' not found"); - let name = params.find("name").expect("route param 'name' not found"); + async fn repo_status_svg( + State(app): State, + Path(params): Path<(String, String, String)>, + req: Request, + ) -> Response { + Self::repo_status(app, params, req, StatusFormat::Svg).await + } + + async fn repo_status( + app: App, + (site, qual, name): (String, String, String), + req: Request, + format: StatusFormat, + ) -> Response { + let engine = app.engine.clone(); let extra_knobs = ExtraConfig::from_query_string(req.uri().query()); - let repo_path_result = RepoPath::from_parts(site, qual, name); + let repo_path_result = RepoPath::from_parts(&site, &qual, &name); match repo_path_result { Err(err) => { @@ -196,48 +150,40 @@ impl App { "Please make sure to provide a valid repository path.", ); *response.status_mut() = StatusCode::BAD_REQUEST; - Ok(response) + response } Ok(repo_path) => { - let analyze_result = server - .engine + let analyze_result = engine .analyze_repo_dependencies(repo_path.clone(), &extra_knobs.path) .await; match analyze_result { Err(err) => { tracing::error!(%err); - let response = App::status_format_analysis( + + App::status_format_analysis( None, format, SubjectPath::Repo(repo_path), extra_knobs, - ); - Ok(response) - } - Ok(analysis_outcome) => { - let response = App::status_format_analysis( - Some(analysis_outcome), - format, - SubjectPath::Repo(repo_path), - extra_knobs, - ); - Ok(response) + ) } + + Ok(analysis_outcome) => App::status_format_analysis( + Some(analysis_outcome), + format, + SubjectPath::Repo(repo_path), + extra_knobs, + ), } } } } - async fn crate_redirect( - &self, - _req: Request, - params: Params, - ) -> Result, axum::Error> { - let engine = self.engine.clone(); + async fn crate_redirect(State(app): State, Path((name,)): Path<(String,)>) -> Response { + let engine = app.engine.clone(); - let name = params.find("name").expect("route param 'name' not found"); let crate_name_result = name.parse::(); match crate_name_result { @@ -248,7 +194,7 @@ impl App { "Please make sure to provide a valid crate name.", ); *response.status_mut() = StatusCode::BAD_REQUEST; - Ok(response) + response } Ok(crate_name) => { @@ -264,7 +210,7 @@ impl App { "Please make sure to provide a valid crate name.", ); *response.status_mut() = StatusCode::NOT_FOUND; - Ok(response) + response } Ok(None) => { let mut response = views::html::error::render( @@ -272,7 +218,7 @@ impl App { "Please make sure to provide a valid crate name.", ); *response.status_mut() = StatusCode::NOT_FOUND; - Ok(response) + response } Ok(Some(release)) => { let redirect_url = format!( @@ -282,31 +228,48 @@ impl App { release.version ); - let res = Response::builder() - .status(StatusCode::TEMPORARY_REDIRECT) - .header(LOCATION, redirect_url) - .body(Body::empty()) - .unwrap(); - - Ok(res) + Redirect::temporary(&redirect_url).into_response() } } } } } + async fn crate_status_html( + State(app): State, + Path((name, version)): Path<(String, String)>, + req: Request, + ) -> Response { + Self::crate_status(app, (name, Some(version)), req, StatusFormat::Html).await + } + + async fn crate_status_svg( + State(app): State, + Path((name, version)): Path<(String, String)>, + req: Request, + ) -> Response { + Self::crate_status(app, (name, Some(version)), req, StatusFormat::Svg).await + } + + async fn crate_latest_status_svg( + State(app): State, + Path((name,)): Path<(String,)>, + req: Request, + ) -> Response { + Self::crate_status(app, (name, None), req, StatusFormat::Svg).await + } + async fn crate_status( - &self, - req: Request, - params: Params, + app: App, + (name, version): (String, Option), + req: Request, format: StatusFormat, - ) -> Result, axum::Error> { - let server = self.clone(); + ) -> Response { + let server = app.clone(); - let name = params.find("name").expect("route param 'name' not found"); - - let version = match params.find("version") { + let version = match version { Some(ver) => ver.to_owned(), + None => { let crate_name = match name.parse() { Ok(name) => name, @@ -316,7 +279,7 @@ impl App { "Please make sure to provide a valid crate name and version.", ); *response.status_mut() = StatusCode::BAD_REQUEST; - return Ok(response); + return response; } }; @@ -326,7 +289,7 @@ impl App { .await { Ok(Some(latest_rel)) => latest_rel.version.to_string(), - Ok(None) => return Ok(not_found()), + Ok(None) => return not_found(), Err(err) => { tracing::error!(%err); let mut response = views::html::error::render( @@ -334,13 +297,13 @@ impl App { "Please make sure to provide a valid crate name.", ); *response.status_mut() = StatusCode::NOT_FOUND; - return Ok(response); + return response; } } } }; - let crate_path_result = CratePath::from_parts(name, &version); + let crate_path_result = CratePath::from_parts(&name, &version); let badge_knobs = ExtraConfig::from_query_string(req.uri().query()); match crate_path_result { @@ -351,8 +314,9 @@ impl App { "Please make sure to provide a valid crate name and version.", ); *response.status_mut() = StatusCode::BAD_REQUEST; - Ok(response) + response } + Ok(crate_path) => { let analyze_result = server .engine @@ -362,24 +326,20 @@ impl App { match analyze_result { Err(err) => { tracing::error!(%err); - let response = App::status_format_analysis( + App::status_format_analysis( None, format, SubjectPath::Crate(crate_path), badge_knobs, - ); - Ok(response) + ) } - Ok(analysis_outcome) => { - let response = App::status_format_analysis( - Some(analysis_outcome), - format, - SubjectPath::Crate(crate_path), - badge_knobs, - ); - Ok(response) - } + Ok(analysis_outcome) => App::status_format_analysis( + Some(analysis_outcome), + format, + SubjectPath::Crate(crate_path), + badge_knobs, + ), } } } @@ -390,7 +350,7 @@ impl App { format: StatusFormat, subject_path: SubjectPath, badge_knobs: ExtraConfig, - ) -> Response { + ) -> Response { match format { StatusFormat::Svg => views::badge::response(analysis_outcome.as_ref(), badge_knobs), StatusFormat::Html => { @@ -399,7 +359,7 @@ impl App { } } - fn static_file(file: StaticFile) -> Response { + async fn static_file(file: StaticFile) -> Response { match file { StaticFile::StyleCss => Response::builder() .header(CONTENT_TYPE, "text/css; charset=utf-8") @@ -407,10 +367,12 @@ impl App { .header(CACHE_CONTROL, "public, max-age=365000000, immutable") .body(Body::from(assets::STATIC_STYLE_CSS)) .unwrap(), + StaticFile::FaviconPng => Response::builder() .header(CONTENT_TYPE, "image/svg+xml") .body(Body::from(assets::STATIC_FAVICON)) .unwrap(), + StaticFile::LinksJs => Response::builder() .header(CONTENT_TYPE, "text/javascript; charset=utf-8") .header(ETAG, STATIC_LINKS_JS_ETAG) @@ -421,7 +383,7 @@ impl App { } } -fn not_found() -> Response { +fn not_found() -> Response { views::html::error::render_404() }