Skip to content

Commit 817fd97

Browse files
committed
new sqlpage functions to read HTTP request body
sqlpage.request_body and sqlpage.request_body_base64 closes #316
1 parent 5a7ca66 commit 817fd97

File tree

6 files changed

+256
-7
lines changed

6 files changed

+256
-7
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
INSERT INTO sqlpage_functions (
2+
"name",
3+
"introduced_in_version",
4+
"icon",
5+
"description_md"
6+
)
7+
VALUES (
8+
'request_body',
9+
'0.33.0',
10+
'http-post',
11+
'Returns the raw request body as a string.
12+
13+
A client (like a web browser, mobile app, or another server) can send information to your server in the request body.
14+
This function allows you to read that information in your SQL code,
15+
in order to create or update a resource in your database for instance.
16+
17+
The request body is commonly used when building **REST APIs** (machines-to-machines interfaces)
18+
that receive data from the client.
19+
20+
This is especially useful in:
21+
- `POST` and `PUT` requests for creating or updating resources in your database
22+
- Any API endpoint that needs to receive complex data
23+
24+
### Example: Building a REST API
25+
26+
Here''s an example of building an API endpoint that receives a json object,
27+
and inserts it into a database.
28+
29+
#### `api/create_user.sql`
30+
```sql
31+
-- Get the raw JSON body
32+
set user_data = sqlpage.request_body();
33+
34+
-- Insert the user into database
35+
with parsed_data as (
36+
select
37+
json_extract($user_data, ''$.name'') as name,
38+
json_extract($user_data, ''$.email'') as email
39+
)
40+
insert into users (name, email)
41+
select name, email from parsed_data;
42+
43+
-- Return success response
44+
select ''json'' as component,
45+
json_object(
46+
''status'', ''success'',
47+
''message'', ''User created successfully''
48+
) as contents;
49+
```
50+
51+
### Testing the API
52+
53+
You can test this API using curl:
54+
```bash
55+
curl -X POST http://localhost:8080/api/create_user \
56+
-H "Content-Type: application/json" \
57+
-d ''{"name": "John", "email": "[email protected]"}''
58+
```
59+
60+
## Special cases
61+
62+
### NULL
63+
64+
This function returns NULL if:
65+
- There is no request body
66+
- The request content type is `application/x-www-form-urlencoded` or `multipart/form-data`
67+
(in these cases, use [`sqlpage.variables(''post'')`](?function=variables) instead)
68+
69+
### Binary data
70+
71+
If the request body is not valid text encoded in UTF-8,
72+
invalid characters are replaced with the Unicode replacement character `�` (U+FFFD).
73+
74+
If you need to handle binary data,
75+
use [`sqlpage.request_body_base64()`](?function=request_body_base64) instead.
76+
'
77+
);
78+
79+
INSERT INTO sqlpage_functions (
80+
"name",
81+
"introduced_in_version",
82+
"icon",
83+
"description_md"
84+
)
85+
VALUES (
86+
'request_body_base64',
87+
'0.33.0',
88+
'photo-up',
89+
'Returns the raw request body encoded in base64. This is useful when receiving binary data or when you need to handle non-text content in your API endpoints.
90+
91+
### What is Base64?
92+
93+
Base64 is a way to encode binary data (like images or files) into text that can be safely stored and transmitted. This function automatically converts the incoming request body into this format.
94+
95+
### Example: Handling Binary Data in an API
96+
97+
This example shows how to receive and process an image uploaded directly in the request body:
98+
99+
```sql
100+
-- Assuming this is api/upload_image.sql
101+
-- Client would send a POST request with the raw image data
102+
103+
-- Get the base64-encoded image data
104+
set image_data = sqlpage.request_body_base64();
105+
106+
-- Store the image data in the database
107+
insert into images (data, uploaded_at)
108+
values ($image_data, current_timestamp);
109+
110+
-- Return success response
111+
select ''json'' as component,
112+
json_object(
113+
''status'', ''success'',
114+
''message'', ''Image uploaded successfully''
115+
) as contents;
116+
```
117+
118+
You can test this API using curl:
119+
```bash
120+
curl -X POST http://localhost:8080/api/upload_image.sql \
121+
-H "Content-Type: application/octet-stream" \
122+
--data-binary "@/path/to/image.jpg"
123+
```
124+
125+
This is particularly useful when:
126+
- Working with binary data (images, files, etc.)
127+
- The request body contains non-UTF8 characters
128+
- You need to pass the raw body to another system that expects base64
129+
130+
> Note: Like [`sqlpage.request_body()`](?function=request_body), this function returns NULL if:
131+
> - There is no request body
132+
> - The request content type is `application/x-www-form-urlencoded` or `multipart/form-data`
133+
> (in these cases, use [`sqlpage.variables(''post'')`](?function=variables) instead)
134+
'
135+
);

src/webserver/database/sqlpage_functions/functions.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ super::function_definition_macro::sqlpage_functions! {
4646

4747
variables((&RequestInfo), get_or_post: Option<Cow<str>>);
4848
version();
49+
request_body((&RequestInfo));
50+
request_body_base64((&RequestInfo));
4951
}
5052

5153
/// Returns the password from the HTTP basic auth header, if present.
@@ -604,3 +606,26 @@ async fn variables<'a>(
604606
async fn version() -> &'static str {
605607
env!("CARGO_PKG_VERSION")
606608
}
609+
610+
/// Returns the raw request body as a string.
611+
/// If the request body is not valid UTF-8, invalid characters are replaced with the Unicode replacement character.
612+
/// Returns NULL if there is no request body or if the request content type is
613+
/// application/x-www-form-urlencoded or multipart/form-data (in this case, the body is accessible via the `post_variables` field).
614+
async fn request_body(request: &RequestInfo) -> Option<String> {
615+
let raw_body = request.raw_body.as_ref()?;
616+
Some(String::from_utf8_lossy(raw_body).to_string())
617+
}
618+
619+
/// Returns the raw request body encoded in base64.
620+
/// Returns NULL if there is no request body or if the request content type is
621+
/// application/x-www-form-urlencoded or multipart/form-data (in this case, the body is accessible via the `post_variables` field).
622+
async fn request_body_base64(request: &RequestInfo) -> Option<String> {
623+
let raw_body = request.raw_body.as_ref()?;
624+
let mut base64_string = String::with_capacity((raw_body.len() * 4 + 2) / 3);
625+
base64::Engine::encode_string(
626+
&base64::engine::general_purpose::STANDARD,
627+
raw_body,
628+
&mut base64_string,
629+
);
630+
Some(base64_string)
631+
}

src/webserver/http_request_info.rs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub struct RequestInfo {
3838
pub basic_auth: Option<Basic>,
3939
pub app_state: Arc<AppState>,
4040
pub clone_depth: u8,
41+
pub raw_body: Option<Vec<u8>>,
4142
}
4243

4344
impl RequestInfo {
@@ -56,6 +57,7 @@ impl RequestInfo {
5657
basic_auth: self.basic_auth.clone(),
5758
app_state: self.app_state.clone(),
5859
clone_depth: self.clone_depth + 1,
60+
raw_body: self.raw_body.clone(),
5961
}
6062
}
6163
}
@@ -77,7 +79,8 @@ pub(crate) async fn extract_request_info(
7779
let method = http_req.method().clone();
7880
let protocol = http_req.connection_info().scheme().to_string();
7981
let config = &app_state.config;
80-
let (post_variables, uploaded_files) = extract_post_data(http_req, payload, config).await?;
82+
let (post_variables, uploaded_files, raw_body) =
83+
extract_post_data(http_req, payload, config).await?;
8184
let headers = req.headers().iter().map(|(name, value)| {
8285
(
8386
name.to_string(),
@@ -112,28 +115,36 @@ pub(crate) async fn extract_request_info(
112115
app_state,
113116
protocol,
114117
clone_depth: 0,
118+
raw_body,
115119
})
116120
}
117121

118122
async fn extract_post_data(
119123
http_req: &mut actix_web::HttpRequest,
120124
payload: &mut actix_web::dev::Payload,
121125
config: &crate::app_config::AppConfig,
122-
) -> anyhow::Result<(Vec<(String, String)>, Vec<(String, TempFile)>)> {
126+
) -> anyhow::Result<(
127+
Vec<(String, String)>,
128+
Vec<(String, TempFile)>,
129+
Option<Vec<u8>>,
130+
)> {
123131
let content_type = http_req
124132
.headers()
125133
.get(&CONTENT_TYPE)
126134
.map(AsRef::as_ref)
127135
.unwrap_or_default();
128136
if content_type.starts_with(b"application/x-www-form-urlencoded") {
129137
let vars = extract_urlencoded_post_variables(http_req, payload).await?;
130-
Ok((vars, Vec::new()))
138+
Ok((vars, Vec::new(), None))
131139
} else if content_type.starts_with(b"multipart/form-data") {
132-
extract_multipart_post_data(http_req, payload, config).await
140+
let (vars, files) = extract_multipart_post_data(http_req, payload, config).await?;
141+
Ok((vars, files, None))
133142
} else {
134-
let ct_str = String::from_utf8_lossy(content_type);
135-
log::debug!("Not parsing POST data from request without known content type {ct_str}");
136-
Ok((Vec::new(), Vec::new()))
143+
let body = actix_web::web::Bytes::from_request(http_req, payload)
144+
.await
145+
.map(|bytes| bytes.to_vec())
146+
.unwrap_or_default();
147+
Ok((Vec::new(), Vec::new(), Some(body)))
137148
}
138149
}
139150

tests/index.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,80 @@ async fn test_routing_with_prefix() {
803803
assert_eq!(location.to_str().unwrap(), "/prefix/");
804804
}
805805

806+
#[actix_web::test]
807+
async fn test_request_body() -> actix_web::Result<()> {
808+
let req = get_request_to("/tests/request_body_test.sql")
809+
.await?
810+
.insert_header(("content-type", "text/plain"))
811+
.set_payload("Hello, world!")
812+
.to_srv_request();
813+
let resp = main_handler(req).await?;
814+
815+
assert_eq!(resp.status(), StatusCode::OK);
816+
let body = test::read_body(resp).await;
817+
let body_str = String::from_utf8(body.to_vec()).unwrap();
818+
assert!(
819+
body_str.contains("Hello, world!"),
820+
"{body_str}\nexpected to contain: Hello, world!"
821+
);
822+
823+
// Test with form data - should return NULL
824+
let req = get_request_to("/tests/request_body_test.sql")
825+
.await?
826+
.insert_header(("content-type", "application/x-www-form-urlencoded"))
827+
.set_payload("key=value")
828+
.to_srv_request();
829+
let resp = main_handler(req).await?;
830+
831+
assert_eq!(resp.status(), StatusCode::OK);
832+
let body = test::read_body(resp).await;
833+
let body_str = String::from_utf8(body.to_vec()).unwrap();
834+
assert!(
835+
body_str.contains("NULL"),
836+
"{body_str}\nexpected NULL for form data"
837+
);
838+
Ok(())
839+
}
840+
841+
#[actix_web::test]
842+
async fn test_request_body_base64() -> actix_web::Result<()> {
843+
let binary_data = (0u8..=255u8).collect::<Vec<_>>();
844+
let expected_base64 =
845+
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &binary_data);
846+
847+
let req = get_request_to("/tests/request_body_base64_test.sql")
848+
.await?
849+
.insert_header(("content-type", "application/octet-stream"))
850+
.set_payload(binary_data)
851+
.to_srv_request();
852+
let resp = main_handler(req).await?;
853+
854+
assert_eq!(resp.status(), StatusCode::OK);
855+
let body = test::read_body(resp).await;
856+
let body_str = String::from_utf8(body.to_vec()).unwrap();
857+
assert!(
858+
body_str.contains(&expected_base64),
859+
"{body_str}\nexpected to contain base64: {expected_base64}"
860+
);
861+
862+
// Test with form data - should return NULL
863+
let req = get_request_to("/tests/request_body_base64_test.sql")
864+
.await?
865+
.insert_header(("content-type", "application/x-www-form-urlencoded"))
866+
.set_payload("key=value")
867+
.to_srv_request();
868+
let resp = main_handler(req).await?;
869+
870+
assert_eq!(resp.status(), StatusCode::OK);
871+
let body = test::read_body(resp).await;
872+
let body_str = String::from_utf8(body.to_vec()).unwrap();
873+
assert!(
874+
body_str.contains("NULL"),
875+
"{body_str}\nexpected NULL for form data"
876+
);
877+
Ok(())
878+
}
879+
806880
async fn get_request_to_with_data(
807881
path: &str,
808882
data: actix_web::web::Data<AppState>,

tests/request_body_base64_test.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
select 'shell-empty' as component,
2+
coalesce(sqlpage.request_body_base64(), 'NULL') as html;

tests/request_body_test.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
select 'shell-empty' as component,
2+
coalesce(sqlpage.request_body(), 'NULL') as html;

0 commit comments

Comments
 (0)