Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add 404.sql handler #544

Merged
merged 3 commits into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions examples/handle-404/api/404.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
SELECT 'debug' AS component,
'api/404.sql' AS serving_file,
sqlpage.path() AS request_path;

SELECT 'button' AS component;
SELECT
'Back home' AS title,
'home' AS icon,
'/' AS link;
9 changes: 9 additions & 0 deletions examples/handle-404/api/index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
SELECT
'title' AS component,
'Welcome to the API' AS contents;

SELECT 'button' AS component;
SELECT
'Back home' AS title,
'home' AS icon,
'/' AS link;
42 changes: 42 additions & 0 deletions examples/handle-404/index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
SELECT 'list' AS component,
'Navigation' AS title;

SELECT
column1 AS title, column2 AS link, column3 AS description_md
FROM (VALUES
('Link to arbitrary path', '/api/does/not/actually/exist', 'Covered by `api/404.sql`'),
('Link to arbitrary file', '/api/nothing.png', 'Covered by `api/404.sql`'),
('Link to non-existing .sql file', '/api/inexistent.sql', 'Covered by `api/404.sql`'),
('Link to 404 handler', '/api/404.sql', 'Actually `api/404.sql`'),
('Link to API landing page', '/api', 'Covered by `api/index.sql`'),
('Link to arbitrary broken path', '/backend/does/not/actually/exist', 'Not covered by anything, will yield a 404 error')
);

SELECT 'text' AS component,
'
# Overview

This demo shows how a `404.sql` file can serve as a fallback error handler. Whenever a `404 Not
Found` error would be emitted, instead a dedicated `404.sql` is called (if it exists) to serve the
request. This is usefull in two scenarios:

1. Providing custom 404 error pages.
2. To provide content under dynamic paths.

The former use-case is primarily of cosmetic nature, it allows for more informative, customized
failure modes, enabling better UX. The latter use-case opens the door especially for REST API
design, where dynamic paths are often used to convey arguments, i.e. `/api/resource/5` where `5` is
the id of a resource.


# Fallback Handler Selection

When a normal request to either a `.sql` or a static file fails with `404`, the `404` error is
intercepted. The reuquest path''s target directory is scanned for a `404.sql`. If it exists, it is
called. Otherwise, the parent directory is scanned for `404.sql`, which will be called if it exists.
This search traverses up until it reaches the `web_root`. If even the webroot does not contain a
`404.sql`, then the original `404` error is served as response to the HTTP client.

The fallback handler is not recursive; i.e. if anything causes another `404` during the call to a
`404.sql`, then the request fails (emitting a `404` response to the HTTP client).
' AS contents_md;
96 changes: 85 additions & 11 deletions src/webserver/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,18 +374,17 @@ fn path_to_sql_file(path: &str) -> Option<PathBuf> {
}

async fn process_sql_request(
mut req: ServiceRequest,
req: &mut ServiceRequest,
sql_path: PathBuf,
) -> actix_web::Result<ServiceResponse> {
) -> actix_web::Result<HttpResponse> {
let app_state: &web::Data<AppState> = req.app_data().expect("app_state");
let sql_file = app_state
.sql_file_cache
.get_with_privilege(app_state, &sql_path, false)
.await
.with_context(|| format!("Unable to get SQL file {sql_path:?}"))
.map_err(anyhow_err_to_actix)?;
let response = render_sql(&mut req, sql_file).await?;
Ok(req.into_response(response))
render_sql(req, sql_file).await
}

fn anyhow_err_to_actix(e: anyhow::Error) -> actix_web::Error {
Expand Down Expand Up @@ -434,25 +433,100 @@ async fn serve_file(
})
}

/// Fallback handler for when a file could not be served
///
/// Recursively traverses upwards in the request's path, looking for a `404.sql` to call as the
/// fallback handler. Fails if none is found, or if there is an issue while running the `404.sql`.
async fn serve_fallback(
service_request: &mut ServiceRequest,
original_err: actix_web::Error,
) -> actix_web::Result<HttpResponse> {
let catch_all = "404.sql";

let req_path = req_path(&service_request);
let mut fallback_path_candidate = req_path.clone().into_owned();
log::debug!("Trying to find a {catch_all:?} handler for {fallback_path_candidate:?}");

let app_state: &web::Data<AppState> = service_request.app_data().expect("app_state");

// Find the indeces of each char which follows a directroy separator (`/`). Also consider the 0
// index, as we also have to try to check the empty path, for a root dir `404.sql`.
for idx in req_path
lovasoa marked this conversation as resolved.
Show resolved Hide resolved
.rmatch_indices('/')
.map(|(idx, _)| idx + 1)
.chain(std::iter::once(0))
{
// Remove the trailing substring behind the current `/`, and append `404.sql`.
fallback_path_candidate.truncate(idx);
fallback_path_candidate.push_str(&catch_all);

// Check if `maybe_fallback_path` actually exists, if not, skip to the next round (which
// will check `maybe_fallback_path`s parent directory for fallback handler).
let mabye_sql_path = PathBuf::from(&fallback_path_candidate);
match app_state
.sql_file_cache
.get_with_privilege(app_state, &mabye_sql_path, false)
.await
{
// `maybe_fallback_path` does seem to exist, lets try to run it!
Ok(sql_file) => {
log::debug!("Processing SQL request via fallback: {:?}", mabye_sql_path);
return render_sql(service_request, sql_file).await;
}
Err(e) => {
let actix_web_err = anyhow_err_to_actix(e);

// `maybe_fallback_path` does not exist, continue search in parent dir.
if actix_web_err.as_response_error().status_code() == StatusCode::NOT_FOUND {
log::trace!("The 404 handler {mabye_sql_path:?} does not exist");
continue;
}

// Another error occured, bubble it up!
return Err(actix_web_err);
}
}
}

log::debug!("There is no {catch_all:?} handler, this response is terminally failed");
Err(original_err)
}

pub async fn main_handler(
mut service_request: ServiceRequest,
) -> actix_web::Result<ServiceResponse> {
let path = req_path(&service_request);
let sql_file_path = path_to_sql_file(&path);
if let Some(sql_path) = sql_file_path {
let maybe_response = if let Some(sql_path) = sql_file_path {
if let Some(redirect) = redirect_missing_trailing_slash(service_request.uri()) {
return Ok(service_request.into_response(redirect));
Ok(redirect)
} else {
log::debug!("Processing SQL request: {:?}", sql_path);
process_sql_request(&mut service_request, sql_path).await
}
log::debug!("Processing SQL request: {:?}", sql_path);
process_sql_request(service_request, sql_path).await
} else {
log::debug!("Serving file: {:?}", path);
let app_state = service_request.extract::<web::Data<AppState>>().await?;
let path = req_path(&service_request);
let if_modified_since = IfModifiedSince::parse(&service_request).ok();
let response = serve_file(&path, &app_state, if_modified_since).await?;
Ok(service_request.into_response(response))
}

serve_file(&path, &app_state, if_modified_since).await
};

// On 404/NOT_FOUND error, fall back to `404.sql` handler if it exists
let response = match maybe_response {
// File could not be served due to a 404 error. Try to find a user provide 404 handler in
// the form of a `404.sql` in the current directory. If there is none, look in the parent
// directeory, and its parent directory, ...
Err(e) if e.as_response_error().status_code() == StatusCode::NOT_FOUND => {
serve_fallback(&mut service_request, e).await?
}

// Either a valid response, or an unrelated error that shall be bubbled up.
e => e?,
};

Ok(service_request.into_response(response))
}

/// Extracts the path from a request and percent-decodes it
Expand Down
3 changes: 3 additions & 0 deletions tests/404.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SELECT 'alert' AS component,
'We almost got an oopsie' AS title,
'But the `404.sql` file saved the day!' AS description_md;
22 changes: 22 additions & 0 deletions tests/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ async fn test_404() {
}
}

#[actix_web::test]
async fn test_404_fallback() {
for f in [
"/tests/does_not_exist.sql",
"/tests/does_not_exist.html",
"/tests/does_not_exist/",
] {
let resp_result = req_path(f).await;
let resp = resp_result.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK, "{f} isnt 200");
wucke13 marked this conversation as resolved.
Show resolved Hide resolved

let body = test::read_body(resp).await;
assert!(body.starts_with(b"<!DOCTYPE html>"));
// the body should contain our happy string, but not the string "error"
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains("But the "));
assert!(body.contains("404.sql"));
assert!(body.contains("file saved the day!"));
assert!(!body.contains("error"));
}
}

#[actix_web::test]
async fn test_concurrent_requests() {
// send 32 requests (less than the default postgres pool size)
Expand Down