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",
]
[[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"

View file

@ -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"] }

View file

@ -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}");

View file

@ -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"));

View file

@ -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<Router<Route>>,
}
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<Body>) -> Result<Response<Body>, 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<App> {
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<Body>,
_params: Params,
) -> Result<Response<Body>, axum::Error> {
let engine = self.engine.clone();
async fn index(State(app): State<App>) -> 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<Body>,
params: Params,
format: StatusFormat,
) -> Result<Response<Body>, axum::Error> {
let server = self.clone();
async fn repo_status_html(
State(app): State<App>,
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<App>,
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<Body>,
params: Params,
) -> Result<Response<Body>, axum::Error> {
let engine = self.engine.clone();
async fn crate_redirect(State(app): State<App>, 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::<CrateName>();
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<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(
&self,
req: Request<Body>,
params: Params,
app: App,
(name, version): (String, Option<String>),
req: Request,
format: StatusFormat,
) -> Result<Response<Body>, 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<Body> {
) -> 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<Body> {
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<Body> {
fn not_found() -> Response {
views::html::error::render_404()
}