Skip to content

Commit 3312556

Browse files
lovasoacursoragent
andauthored
Check for 404 before redirecting on no-extension paths (#972)
* Check for 404 before redirecting on no-extension paths The commit improves routing logic by checking if a path would result in a 404 fixes #971 before adding a trailing slash. This prevents unnecessary redirects when a custom 404 handler exists. * Fix test function signature formatting in routing module (#973) Co-authored-by: Cursor Agent <[email protected]> * Simplify path resolution and redirect logic The shorter code more clearly handles finding files with .sql extensions and decides whether to add trailing slashes based on index file presence. * clippy --------- Co-authored-by: Cursor Agent <[email protected]>
1 parent 1860aa8 commit 3312556

File tree

4 files changed

+260
-18
lines changed

4 files changed

+260
-18
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
```sql
4848
EXECUTE dbo.proc1 DEFAULT
4949
```
50+
- The file-based routing system was improved. Now, requests to `/xxx` redirect to `/xxx/` only if `/xxx/index.sql` exists.
5051

5152
## v0.35.2
5253
- Fix a bug with zero values being displayed with a non-zero height in stacked bar charts.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
2+
INSERT INTO blog_posts (title, description, icon, created_at, content)
3+
VALUES
4+
(
5+
'File-based routing in SQLPage',
6+
'Understanding how SQLPage maps URLs to files and handles errors',
7+
'route',
8+
'2025-07-28',
9+
'
10+
SQLPage uses a simple file-based routing system that maps URLs directly to SQL files in your project directory.
11+
No complex configuration is needed. Just create files and they become accessible endpoints.
12+
13+
This guide explains how SQLPage resolves URLs, handles different file types, and manages 404 errors so you can structure your application effectively.
14+
15+
## How SQLPage Routes Requests
16+
17+
### 1. Site Prefix Handling
18+
19+
If you''ve configured a [`site_prefix`](/your-first-sql-website/nginx) in your settings,
20+
SQLPage will redirect all requests that do not start with the prefix to `/<site_prefix>`.
21+
22+
### 2. Path Resolution Priority
23+
24+
**Directory requests (paths ending with `/`)**: SQLPage looks for an `index.sql` file in that directory and executes it if found.
25+
26+
**Direct SQL file requests (`.sql` extension)**: SQLPage executes the requested SQL file if it exists.
27+
28+
**Static asset requests (other extensions)**: SQLPage serves files like CSS, JavaScript, images, or any other static content directly.
29+
30+
**Clean URL requests (no extension)**: SQLPage first tries to find a matching `.sql` file. If that doesn''t exist but there''s an `index.sql` file in a directory with the same name, it redirects to the directory path with a trailing slash.
31+
32+
### Error Handling
33+
34+
When, after applying each of the rules above in order, SQLPage can''t find a requested file,
35+
it walks up your directory structure looking for [custom `404.sql` files](/your-first-sql-website/custom_urls).
36+
37+
## Dynamic Routing with SQLPage
38+
39+
SQLPage''s file-based routing becomes powerful when combined with strategic use of 404.sql files to handle dynamic URLs. Here''s how to build APIs and pages with dynamic parameters:
40+
41+
### Product Catalog with Dynamic IDs
42+
43+
**Goal**: Handle URLs like `/products/123`, `/products/abc`, `/products/new-laptop`
44+
45+
**Setup**:
46+
```text
47+
products/
48+
├── index.sql # Lists all products (/products/)
49+
├── 404.sql # Handles /products/<product-id>
50+
└── categories.sql # Product categories (/products/categories)
51+
```
52+
53+
**How it works**:
54+
- `/products/` → Executes `products/index.sql` (product listing)
55+
- `/products/123` → No `123.sql` file exists, so executes `products/404.sql`
56+
- `/products/laptop` → No `laptop.sql` file exists, so executes `products/404.sql`
57+
58+
**In `products/404.sql`**:
59+
```sql
60+
set product_id = substr(sqlpage.path(), 1+length(''/products/''));
61+
```
62+
'
63+
);

src/webserver/routing.rs

Lines changed: 194 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,73 @@
1+
//! This module determines how incoming HTTP requests are mapped to
2+
//! SQL files for execution, static assets for serving, or error pages.
3+
//!
4+
//! ## Routing Rules
5+
//!
6+
//! `SQLPage` follows a file-based routing system with the following precedence:
7+
//!
8+
//! ### 1. Site Prefix Handling
9+
//! - If a `site_prefix` is configured and the request path doesn't start with it, redirect to the prefixed path
10+
//! - All subsequent routing operates on the path after stripping the prefix
11+
//!
12+
//! ### 2. Path Resolution (in order of precedence)
13+
//!
14+
//! #### Paths ending with `/` (directories):
15+
//! - Look for `index.sql` in that directory
16+
//! - If found: **Execute** the SQL file
17+
//! - If not found: Look for custom 404 handlers (see Error Handling below)
18+
//!
19+
//! #### Paths with `.sql` extension:
20+
//! - If the file exists: **Execute** the SQL file
21+
//! - If not found: Look for custom 404 handlers (see Error Handling below)
22+
//!
23+
//! #### Paths with other extensions (assets):
24+
//! - If the file exists: **Serve** the static file
25+
//! - If not found: Look for custom 404 handlers (see Error Handling below)
26+
//!
27+
//! #### Paths without extension:
28+
//! - First, try to find `{path}.sql` and **Execute** if found
29+
//! - If no SQL file found but `{path}/index.sql` exists: **Redirect** to `{path}/`
30+
//! - Otherwise: Look for custom 404 handlers (see Error Handling below)
31+
//!
32+
//! ### 3. Error Handling (404 cases)
33+
//!
34+
//! When a requested file is not found, `SQLPage` looks for custom 404 handlers:
35+
//!
36+
//! - Starting from the requested path's directory, walk up the directory tree
37+
//! - Look for `404.sql` in each parent directory
38+
//! - If found: **Execute** the custom 404 SQL file
39+
//! - If no custom 404 found anywhere: Return default **404 Not Found** response
40+
//!
41+
//! ## Examples
42+
//!
43+
//! ```text
44+
//! Request: GET /
45+
//! Result: Execute index.sql
46+
//!
47+
//! Request: GET /users
48+
//! - If users.sql exists: Execute users.sql
49+
//! - Else if users/index.sql exists: Redirect to /users/
50+
//! - Else if 404.sql exists: Execute 404.sql
51+
//! - Else: Default 404
52+
//!
53+
//! Request: GET /users/
54+
//! - If users/index.sql exists: Execute users/index.sql
55+
//! - Else if users/404.sql exists: Execute users/404.sql
56+
//! - Else if 404.sql exists: Execute 404.sql
57+
//! - Else: Default 404
58+
//!
59+
//! Request: GET /api/users.sql
60+
//! - If api/users.sql exists: Execute api/users.sql
61+
//! - Else if api/404.sql exists: Execute api/404.sql
62+
//! - Else if 404.sql exists: Execute 404.sql
63+
//! - Else: Default 404
64+
//!
65+
//! Request: GET /favicon.ico
66+
//! - If favicon.ico exists: Serve favicon.ico
67+
//! - Else if 404.sql exists: Execute 404.sql
68+
//! - Else: Default 404
69+
//! ```
70+
171
use crate::filesystem::FileSystem;
272
use crate::webserver::database::ParsedSqlFile;
373
use crate::{file_cache::FileCache, AppState};
@@ -120,9 +190,15 @@ where
120190
find_file_or_not_found(&path, SQL_EXTENSION, store).await
121191
} else {
122192
let path_with_ext = path.with_extension(SQL_EXTENSION);
123-
match find_file(&path_with_ext, SQL_EXTENSION, store).await? {
124-
Some(action) => Ok(action),
125-
None => Ok(Redirect(append_to_path(path_and_query, FORWARD_SLASH))),
193+
match find_file_or_not_found(&path_with_ext, SQL_EXTENSION, store).await? {
194+
Execute(x) => Ok(Execute(x)),
195+
other_action => {
196+
if store.contains(&path.join(INDEX)).await? {
197+
Ok(Redirect(append_to_path(path_and_query, FORWARD_SLASH)))
198+
} else {
199+
Ok(other_action)
200+
}
201+
}
126202
}
127203
}
128204
}
@@ -190,7 +266,7 @@ mod tests {
190266
use std::default::Default as StdDefault;
191267
use std::path::{Path, PathBuf};
192268
use std::str::FromStr;
193-
use StoreConfig::{Default, Empty, File};
269+
use StoreConfig::{Custom, Default, Empty, File};
194270

195271
mod execute {
196272
use super::StoreConfig::{Default, File};
@@ -332,6 +408,22 @@ mod tests {
332408

333409
assert_eq!(expected, actual);
334410
}
411+
412+
#[tokio::test]
413+
async fn no_extension_path_that_would_result_in_404_does_not_redirect() {
414+
let actual = do_route("/nonexistent", Default, None).await;
415+
let expected = custom_not_found("404.sql");
416+
417+
assert_eq!(expected, actual);
418+
}
419+
420+
#[tokio::test]
421+
async fn no_extension_path_that_would_result_in_404_does_not_redirect_with_site_prefix() {
422+
let actual = do_route("/prefix/nonexistent", Default, Some("/prefix/")).await;
423+
let expected = custom_not_found("404.sql");
424+
425+
assert_eq!(expected, actual);
426+
}
335427
}
336428

337429
mod not_found {
@@ -402,8 +494,8 @@ mod tests {
402494
}
403495

404496
mod redirect {
405-
use super::StoreConfig::Default;
406-
use super::{do_route, redirect};
497+
use super::StoreConfig::{Default, Empty};
498+
use super::{custom_not_found, default_not_found, do_route, redirect};
407499

408500
#[tokio::test]
409501
async fn path_without_site_prefix_redirects_to_site_prefix() {
@@ -414,36 +506,42 @@ mod tests {
414506
}
415507

416508
#[tokio::test]
417-
async fn no_extension_and_no_corresponding_file_redirects_with_trailing_slash() {
509+
async fn no_extension_and_no_corresponding_file_with_custom_404_does_not_redirect() {
418510
let actual = do_route("/folder", Default, None).await;
419-
let expected = redirect("/folder/");
511+
let expected = custom_not_found("404.sql");
420512

421513
assert_eq!(expected, actual);
422514
}
423515

424516
#[tokio::test]
425-
async fn no_extension_no_corresponding_file_redirects_with_trailing_slash_and_query() {
517+
async fn no_extension_no_corresponding_file_with_custom_404_does_not_redirect_with_query() {
426518
let actual = do_route("/folder?misc=1&foo=bar", Default, None).await;
427-
let expected = redirect("/folder/?misc=1&foo=bar");
519+
let expected = custom_not_found("404.sql");
428520

429521
assert_eq!(expected, actual);
430522
}
431523

432524
#[tokio::test]
433-
async fn no_extension_site_prefix_and_no_corresponding_file_redirects_with_trailing_slash()
434-
{
525+
async fn no_extension_site_prefix_and_no_corresponding_file_with_custom_404_does_not_redirect(
526+
) {
435527
let actual = do_route("/prefix/folder", Default, Some("/prefix/")).await;
436-
let expected = redirect("/prefix/folder/");
528+
let expected = custom_not_found("404.sql");
437529

438530
assert_eq!(expected, actual);
439531
}
532+
533+
#[tokio::test]
534+
async fn no_extension_returns_404_when_no_404sql_available() {
535+
assert_eq!(do_route("/folder", Empty, None).await, default_not_found());
536+
}
440537
}
441538

442539
async fn do_route(path: &str, config: StoreConfig, prefix: Option<&str>) -> RoutingAction {
443540
let store = match config {
444541
Default => Store::with_default_contents(),
445542
Empty => Store::empty(),
446543
File(file) => Store::new(file),
544+
Custom(files) => Store::with_files(&files),
447545
};
448546
let config = match prefix {
449547
None => Config::default(),
@@ -478,6 +576,7 @@ mod tests {
478576
Default,
479577
Empty,
480578
File(&'static str),
579+
Custom(Vec<&'static str>),
481580
}
482581

483582
struct Store {
@@ -512,6 +611,12 @@ mod tests {
512611
dbg!(&normalized_path, &self.contents);
513612
self.contents.contains(&normalized_path)
514613
}
614+
615+
fn with_files(files: &[&str]) -> Self {
616+
Self {
617+
contents: files.iter().map(|s| (*s).to_string()).collect(),
618+
}
619+
}
515620
}
516621

517622
impl FileStore for Store {
@@ -542,4 +647,80 @@ mod tests {
542647
Self::new("/")
543648
}
544649
}
650+
651+
mod specific_configuration {
652+
use crate::webserver::routing::tests::default_not_found;
653+
654+
use super::StoreConfig::Custom;
655+
use super::{custom_not_found, do_route, execute, redirect, RoutingAction};
656+
657+
async fn route_with_index_and_folder_404(path: &str) -> RoutingAction {
658+
do_route(
659+
path,
660+
Custom(vec![
661+
"index.sql",
662+
"folder/404.sql",
663+
"folder_with_index/index.sql",
664+
]),
665+
None,
666+
)
667+
.await
668+
}
669+
670+
#[tokio::test]
671+
async fn root_path_executes_index() {
672+
let actual = route_with_index_and_folder_404("/").await;
673+
let expected = execute("index.sql");
674+
assert_eq!(expected, actual);
675+
}
676+
677+
#[tokio::test]
678+
async fn index_sql_path_executes_index() {
679+
let actual = route_with_index_and_folder_404("/index.sql").await;
680+
let expected = execute("index.sql");
681+
assert_eq!(expected, actual);
682+
}
683+
684+
#[tokio::test]
685+
async fn folder_without_trailing_slash_redirects() {
686+
let actual = route_with_index_and_folder_404("/folder_with_index").await;
687+
let expected = redirect("/folder_with_index/");
688+
assert_eq!(expected, actual);
689+
}
690+
691+
#[tokio::test]
692+
async fn folder_without_trailing_slash_without_index_does_not_redirect() {
693+
let actual = route_with_index_and_folder_404("/folder").await;
694+
let expected = default_not_found();
695+
assert_eq!(expected, actual);
696+
}
697+
698+
#[tokio::test]
699+
async fn folder_with_trailing_slash_executes_custom_404() {
700+
let actual = route_with_index_and_folder_404("/folder/").await;
701+
let expected = custom_not_found("folder/404.sql");
702+
assert_eq!(expected, actual);
703+
}
704+
705+
#[tokio::test]
706+
async fn folder_xxx_executes_custom_404() {
707+
let actual = route_with_index_and_folder_404("/folder/xxx").await;
708+
let expected = custom_not_found("folder/404.sql");
709+
assert_eq!(expected, actual);
710+
}
711+
712+
#[tokio::test]
713+
async fn folder_xxx_with_query_executes_custom_404() {
714+
let actual = route_with_index_and_folder_404("/folder/xxx?x=1").await;
715+
let expected = custom_not_found("folder/404.sql");
716+
assert_eq!(expected, actual);
717+
}
718+
719+
#[tokio::test]
720+
async fn folder_nested_path_executes_custom_404() {
721+
let actual = route_with_index_and_folder_404("/folder/xxx/yyy").await;
722+
let expected = custom_not_found("folder/404.sql");
723+
assert_eq!(expected, actual);
724+
}
725+
}
545726
}

tests/errors/mod.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,10 @@ async fn test_default_404_with_redirect() {
7575
let resp = resp_result.unwrap();
7676
assert_eq!(
7777
resp.status(),
78-
http::StatusCode::MOVED_PERMANENTLY,
79-
"/i-do-not-exist should return 301"
78+
http::StatusCode::NOT_FOUND,
79+
"/i-do-not-exist should return 404"
8080
);
8181

82-
let location = resp.headers().get(http::header::LOCATION).unwrap();
83-
assert_eq!(location, "/i-do-not-exist/");
84-
8582
let resp_result = req_path("/i-do-not-exist/").await;
8683
let resp = resp_result.unwrap();
8784
assert_eq!(

0 commit comments

Comments
 (0)