mirror of
https://github.com/deps-rs/deps.rs.git
synced 2024-11-21 18:06:30 +00:00
feat: migrate to axum router
This commit is contained in:
parent
d4d0db2e1e
commit
3dfb480ba4
5 changed files with 165 additions and 201 deletions
25
Cargo.lock
generated
25
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"] }
|
||||
|
||||
|
|
17
src/main.rs
17
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}");
|
||||
|
||||
|
|
|
@ -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"));
|
||||
|
|
|
@ -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();
|
||||
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(
|
||||
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",
|
||||
Route::CrateStatus(StatusFormat::Svg),
|
||||
);
|
||||
|
||||
App {
|
||||
engine,
|
||||
router: Arc::new(router),
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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_html(
|
||||
State(app): State<App>,
|
||||
Path(params): Path<(String, String, String)>,
|
||||
req: Request,
|
||||
) -> Response {
|
||||
Self::repo_status(app, params, req, StatusFormat::Html).await
|
||||
}
|
||||
|
||||
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(
|
||||
&self,
|
||||
req: Request<Body>,
|
||||
params: Params,
|
||||
app: App,
|
||||
(site, qual, name): (String, String, String),
|
||||
req: Request,
|
||||
format: StatusFormat,
|
||||
) -> Result<Response<Body>, axum::Error> {
|
||||
let server = self.clone();
|
||||
|
||||
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");
|
||||
) -> 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(
|
||||
|
||||
Ok(analysis_outcome) => App::status_format_analysis(
|
||||
Some(analysis_outcome),
|
||||
format,
|
||||
SubjectPath::Repo(repo_path),
|
||||
extra_knobs,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
Redirect::temporary(&redirect_url).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
&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(
|
||||
|
||||
Ok(analysis_outcome) => App::status_format_analysis(
|
||||
Some(analysis_outcome),
|
||||
format,
|
||||
SubjectPath::Crate(crate_path),
|
||||
badge_knobs,
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue