feat: migrate to axum router

This commit is contained in:
Rob Ede 2024-05-27 03:48:30 +01:00
parent d4d0db2e1e
commit 3dfb480ba4
No known key found for this signature in database
GPG key ID: 97C636207D3EF933
5 changed files with 165 additions and 201 deletions

25
Cargo.lock generated
View file

@ -2336,12 +2336,6 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.24" version = "0.1.24"
@ -2647,7 +2641,6 @@ dependencies = [
"pulldown-cmark", "pulldown-cmark",
"relative-path", "relative-path",
"reqwest", "reqwest",
"route-recognizer",
"rustsec", "rustsec",
"semver", "semver",
"serde", "serde",
@ -2656,6 +2649,7 @@ dependencies = [
"tokio", "tokio",
"toml 0.8.13", "toml 0.8.13",
"tower", "tower",
"tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@ -3034,6 +3028,23 @@ dependencies = [
"tracing", "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]] [[package]]
name = "tower-layer" name = "tower-layer"
version = "0.3.2" version = "0.3.2"

View file

@ -31,7 +31,6 @@ parking_lot = "0.12"
pulldown-cmark = "0.11" pulldown-cmark = "0.11"
relative-path = { version = "1", features = ["serde"] } relative-path = { version = "1", features = ["serde"] }
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
route-recognizer = "0.3"
rustsec = "0.29" rustsec = "0.29"
semver = { version = "1.0", features = ["serde"] } semver = { version = "1.0", features = ["serde"] }
serde = { version = "1", features = ["derive"] } 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"] } tokio = { version = "1.24.2", features = ["rt-multi-thread", "macros", "sync", "time"] }
toml = "0.8" toml = "0.8"
tower = "0.4" tower = "0.4"
tower-http = { version = "0.5", features = ["normalize-path", "trace"] }
tracing = "0.1.30" tracing = "0.1.30"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -9,10 +9,9 @@ use std::{
time::Duration, time::Duration,
}; };
use axum::{extract::Request, Router};
use cadence::{QueuingMetricSink, UdpMetricSink}; use cadence::{QueuingMetricSink, UdpMetricSink};
use reqwest::redirect::Policy as RedirectPolicy; use reqwest::redirect::Policy as RedirectPolicy;
use tracing::Instrument as _; use tokio::net::TcpListener;
mod engine; mod engine;
mod interactors; mod interactors;
@ -90,18 +89,8 @@ async fn main() {
let app = App::new(engine.clone()); let app = App::new(engine.clone());
let lst = tokio::net::TcpListener::bind(addr).await.unwrap(); let lst = TcpListener::bind(addr).await.unwrap();
let server = axum::serve(lst, App::router().with_state(app));
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);
tracing::info!("Server running on port {port}"); tracing::info!("Server running on port {port}");

View file

@ -9,6 +9,8 @@ pub const STATIC_STYLE_CSS_ETAG: &str = concat!(
include_str!(concat!(env!("OUT_DIR"), "/style.css.sha1")), 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_FAVICON: &[u8] = include_bytes!("../../assets/logo.svg");
pub static STATIC_LINKS_JS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/links.js")); pub static STATIC_LINKS_JS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/links.js"));

View file

@ -1,26 +1,29 @@
use std::{env, sync::Arc, time::Instant}; use std::env;
use axum::{ use axum::{
body::Body, body::Body,
extract::Request, extract::{Path, Request, State},
http::{ http::{
header::{CACHE_CONTROL, CONTENT_TYPE, ETAG, LOCATION}, header::{CACHE_CONTROL, CONTENT_TYPE, ETAG},
Method, StatusCode, StatusCode,
}, },
response::Response, response::{IntoResponse as _, Redirect, Response},
routing::get,
Router,
}; };
use badge::BadgeStyle; use badge::BadgeStyle;
use futures_util::future; use futures_util::future;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use route_recognizer::{Params, Router};
use semver::VersionReq; use semver::VersionReq;
use serde::Deserialize; use serde::Deserialize;
use tower_http::{normalize_path::NormalizePathLayer, trace::TraceLayer};
mod assets; mod assets;
mod views; mod views;
use self::assets::{ 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::{ use crate::{
engine::{AnalyzeDependenciesOutcome, Engine}, engine::{AnalyzeDependenciesOutcome, Engine},
@ -44,116 +47,54 @@ enum StaticFile {
LinksJs, LinksJs,
} }
enum Route {
Index,
Static(StaticFile),
RepoStatus(StatusFormat),
CrateRedirect,
CrateStatus(StatusFormat),
LatestCrateBadge,
}
#[derive(Clone)] #[derive(Clone)]
pub struct App { pub struct App {
engine: Engine, engine: Engine,
router: Arc<Router<Route>>,
} }
impl App { impl App {
pub fn new(engine: Engine) -> App { pub fn new(engine: Engine) -> App {
let mut router = Router::new(); App { engine }
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),
}
} }
pub async fn handle(&self, req: Request<Body>) -> Result<Response<Body>, axum::Error> { pub(crate) fn router() -> Router<App> {
let start = Instant::now(); Router::new()
.route("/", get(App::index))
// allows `/path/` to also match `/path` .route("/crate/:name", get(App::crate_redirect))
let normalized_path = req.uri().path().trim_end_matches('/'); .route("/crate/:name/:version", get(App::crate_status_html))
.route(
let res = if let Ok(route_match) = self.router.recognize(normalized_path) { "/crate/:name/latest/status.svg",
match (req.method(), route_match.handler()) { get(App::crate_latest_status_svg),
(&Method::GET, Route::Index) => self.index(req, route_match.params().clone()).await, )
.route(
(&Method::GET, Route::RepoStatus(format)) => { "/crate/:name/:version/status.svg",
self.repo_status(req, route_match.params().clone(), *format) get(App::crate_status_svg),
.await )
} // TODO: `:site` isn't quite right, original was `*site`
.route("/repo/:site/:qual/:name", get(App::repo_status_html))
(&Method::GET, Route::CrateStatus(format)) => { .route(
self.crate_status(req, route_match.params().clone(), *format) "/repo/:site/:qual/:name/status.svg",
.await get(App::repo_status_svg),
} )
.route(
(&Method::GET, Route::LatestCrateBadge) => { STATIC_STYLE_CSS_PATH,
self.crate_status(req, route_match.params().clone(), StatusFormat::Svg) get(|| App::static_file(StaticFile::StyleCss)),
.await )
} .route(
STATIC_FAVICON_PATH,
(&Method::GET, Route::CrateRedirect) => { get(|| App::static_file(StaticFile::FaviconPng)),
self.crate_redirect(req, route_match.params().clone()).await )
} .route(
STATIC_LINKS_JS_PATH,
(&Method::GET, Route::Static(file)) => Ok(App::static_file(*file)), get(|| App::static_file(StaticFile::LinksJs)),
)
_ => Ok(not_found()), .fallback(|| async { not_found() })
} .layer(NormalizePathLayer::trim_trailing_slash())
} else { .layer(TraceLayer::new_for_http())
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
} }
}
impl App { async fn index(State(app): State<App>) -> Response {
async fn index( let engine = app.engine.clone();
&self,
_req: Request<Body>,
_params: Params,
) -> Result<Response<Body>, axum::Error> {
let engine = self.engine.clone();
let popular = let popular =
future::try_join(engine.get_popular_repos(), engine.get_popular_crates()).await; future::try_join(engine.get_popular_repos(), engine.get_popular_crates()).await;
@ -164,29 +105,42 @@ impl App {
let mut response = let mut response =
views::html::error::render("Could not retrieve popular items", ""); views::html::error::render("Could not retrieve popular items", "");
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
Ok(response) response
} }
Ok((popular_repos, popular_crates)) => { 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( async fn repo_status_html(
&self, State(app): State<App>,
req: Request<Body>, Path(params): Path<(String, String, String)>,
params: Params, req: Request,
format: StatusFormat, ) -> Response {
) -> Result<Response<Body>, axum::Error> { Self::repo_status(app, params, req, StatusFormat::Html).await
let server = self.clone(); }
let site = params.find("site").expect("route param 'site' not found"); async fn repo_status_svg(
let qual = params.find("qual").expect("route param 'qual' not found"); State(app): State<App>,
let name = params.find("name").expect("route param 'name' not found"); 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 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 { match repo_path_result {
Err(err) => { Err(err) => {
@ -196,48 +150,40 @@ impl App {
"Please make sure to provide a valid repository path.", "Please make sure to provide a valid repository path.",
); );
*response.status_mut() = StatusCode::BAD_REQUEST; *response.status_mut() = StatusCode::BAD_REQUEST;
Ok(response) response
} }
Ok(repo_path) => { Ok(repo_path) => {
let analyze_result = server let analyze_result = engine
.engine
.analyze_repo_dependencies(repo_path.clone(), &extra_knobs.path) .analyze_repo_dependencies(repo_path.clone(), &extra_knobs.path)
.await; .await;
match analyze_result { match analyze_result {
Err(err) => { Err(err) => {
tracing::error!(%err); tracing::error!(%err);
let response = App::status_format_analysis(
App::status_format_analysis(
None, None,
format, format,
SubjectPath::Repo(repo_path), SubjectPath::Repo(repo_path),
extra_knobs, 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( async fn crate_redirect(State(app): State<App>, Path((name,)): Path<(String,)>) -> Response {
&self, let engine = app.engine.clone();
_req: Request<Body>,
params: Params,
) -> Result<Response<Body>, axum::Error> {
let engine = self.engine.clone();
let name = params.find("name").expect("route param 'name' not found");
let crate_name_result = name.parse::<CrateName>(); let crate_name_result = name.parse::<CrateName>();
match crate_name_result { match crate_name_result {
@ -248,7 +194,7 @@ impl App {
"Please make sure to provide a valid crate name.", "Please make sure to provide a valid crate name.",
); );
*response.status_mut() = StatusCode::BAD_REQUEST; *response.status_mut() = StatusCode::BAD_REQUEST;
Ok(response) response
} }
Ok(crate_name) => { Ok(crate_name) => {
@ -264,7 +210,7 @@ impl App {
"Please make sure to provide a valid crate name.", "Please make sure to provide a valid crate name.",
); );
*response.status_mut() = StatusCode::NOT_FOUND; *response.status_mut() = StatusCode::NOT_FOUND;
Ok(response) response
} }
Ok(None) => { Ok(None) => {
let mut response = views::html::error::render( let mut response = views::html::error::render(
@ -272,7 +218,7 @@ impl App {
"Please make sure to provide a valid crate name.", "Please make sure to provide a valid crate name.",
); );
*response.status_mut() = StatusCode::NOT_FOUND; *response.status_mut() = StatusCode::NOT_FOUND;
Ok(response) response
} }
Ok(Some(release)) => { Ok(Some(release)) => {
let redirect_url = format!( let redirect_url = format!(
@ -282,31 +228,48 @@ impl App {
release.version release.version
); );
let res = Response::builder() Redirect::temporary(&redirect_url).into_response()
.status(StatusCode::TEMPORARY_REDIRECT)
.header(LOCATION, redirect_url)
.body(Body::empty())
.unwrap();
Ok(res)
} }
} }
} }
} }
} }
async fn crate_status_html(
State(app): State<App>,
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<App>,
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<App>,
Path((name,)): Path<(String,)>,
req: Request,
) -> Response {
Self::crate_status(app, (name, None), req, StatusFormat::Svg).await
}
async fn crate_status( async fn crate_status(
&self, app: App,
req: Request<Body>, (name, version): (String, Option<String>),
params: Params, req: Request,
format: StatusFormat, format: StatusFormat,
) -> Result<Response<Body>, axum::Error> { ) -> Response {
let server = self.clone(); let server = app.clone();
let name = params.find("name").expect("route param 'name' not found"); let version = match version {
let version = match params.find("version") {
Some(ver) => ver.to_owned(), Some(ver) => ver.to_owned(),
None => { None => {
let crate_name = match name.parse() { let crate_name = match name.parse() {
Ok(name) => name, Ok(name) => name,
@ -316,7 +279,7 @@ impl App {
"Please make sure to provide a valid crate name and version.", "Please make sure to provide a valid crate name and version.",
); );
*response.status_mut() = StatusCode::BAD_REQUEST; *response.status_mut() = StatusCode::BAD_REQUEST;
return Ok(response); return response;
} }
}; };
@ -326,7 +289,7 @@ impl App {
.await .await
{ {
Ok(Some(latest_rel)) => latest_rel.version.to_string(), Ok(Some(latest_rel)) => latest_rel.version.to_string(),
Ok(None) => return Ok(not_found()), Ok(None) => return not_found(),
Err(err) => { Err(err) => {
tracing::error!(%err); tracing::error!(%err);
let mut response = views::html::error::render( let mut response = views::html::error::render(
@ -334,13 +297,13 @@ impl App {
"Please make sure to provide a valid crate name.", "Please make sure to provide a valid crate name.",
); );
*response.status_mut() = StatusCode::NOT_FOUND; *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()); let badge_knobs = ExtraConfig::from_query_string(req.uri().query());
match crate_path_result { match crate_path_result {
@ -351,8 +314,9 @@ impl App {
"Please make sure to provide a valid crate name and version.", "Please make sure to provide a valid crate name and version.",
); );
*response.status_mut() = StatusCode::BAD_REQUEST; *response.status_mut() = StatusCode::BAD_REQUEST;
Ok(response) response
} }
Ok(crate_path) => { Ok(crate_path) => {
let analyze_result = server let analyze_result = server
.engine .engine
@ -362,24 +326,20 @@ impl App {
match analyze_result { match analyze_result {
Err(err) => { Err(err) => {
tracing::error!(%err); tracing::error!(%err);
let response = App::status_format_analysis( App::status_format_analysis(
None, None,
format, format,
SubjectPath::Crate(crate_path), SubjectPath::Crate(crate_path),
badge_knobs, 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, format: StatusFormat,
subject_path: SubjectPath, subject_path: SubjectPath,
badge_knobs: ExtraConfig, badge_knobs: ExtraConfig,
) -> Response<Body> { ) -> Response {
match format { match format {
StatusFormat::Svg => views::badge::response(analysis_outcome.as_ref(), badge_knobs), StatusFormat::Svg => views::badge::response(analysis_outcome.as_ref(), badge_knobs),
StatusFormat::Html => { StatusFormat::Html => {
@ -399,7 +359,7 @@ impl App {
} }
} }
fn static_file(file: StaticFile) -> Response<Body> { async fn static_file(file: StaticFile) -> Response {
match file { match file {
StaticFile::StyleCss => Response::builder() StaticFile::StyleCss => Response::builder()
.header(CONTENT_TYPE, "text/css; charset=utf-8") .header(CONTENT_TYPE, "text/css; charset=utf-8")
@ -407,10 +367,12 @@ impl App {
.header(CACHE_CONTROL, "public, max-age=365000000, immutable") .header(CACHE_CONTROL, "public, max-age=365000000, immutable")
.body(Body::from(assets::STATIC_STYLE_CSS)) .body(Body::from(assets::STATIC_STYLE_CSS))
.unwrap(), .unwrap(),
StaticFile::FaviconPng => Response::builder() StaticFile::FaviconPng => Response::builder()
.header(CONTENT_TYPE, "image/svg+xml") .header(CONTENT_TYPE, "image/svg+xml")
.body(Body::from(assets::STATIC_FAVICON)) .body(Body::from(assets::STATIC_FAVICON))
.unwrap(), .unwrap(),
StaticFile::LinksJs => Response::builder() StaticFile::LinksJs => Response::builder()
.header(CONTENT_TYPE, "text/javascript; charset=utf-8") .header(CONTENT_TYPE, "text/javascript; charset=utf-8")
.header(ETAG, STATIC_LINKS_JS_ETAG) .header(ETAG, STATIC_LINKS_JS_ETAG)
@ -421,7 +383,7 @@ impl App {
} }
} }
fn not_found() -> Response<Body> { fn not_found() -> Response {
views::html::error::render_404() views::html::error::render_404()
} }