Skip to content

Commit 066c80a

Browse files
committed
new function: sqlpage.fetch
fixes #7 see - #197 - #215 - #254
1 parent d303f3b commit 066c80a

File tree

7 files changed

+232
-5
lines changed

7 files changed

+232
-5
lines changed

CHANGELOG.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## 0.20.3 (unreleased)
44

5+
- New `dropdown` row-level property in the [`form` component](https://sql.ophir.dev/documentation.sql?component=form#component)
6+
- ![select dropdown in form](https://github.com/lovasoa/SQLpage/assets/552629/5a2268d3-4996-49c9-9fb5-d310e753f844)
7+
- ![multiselect input](https://github.com/lovasoa/SQLpage/assets/552629/e8d62d1a-c851-4fef-8c5c-a22991ffadcf)
8+
- Adds a new [`sqlpage.fetch`](https://sql.ophir.dev/functions.sql?function=fetch#function) function that allows sending http requests from SQLPage. This is useful to query external APIs. This avoids having to resort to `sqlpage.exec`.
59
- Fixed a bug that occured when using both HTTP and HTTPS in the same SQLPage instance. SQLPage tried to bind to the same (HTTP)
610
port twice instead of binding to the HTTPS port. This is now fixed, and SQLPage can now be used with both a non-443 `port` and
711
an `https_domain` set in the configuration file.
@@ -11,9 +15,6 @@
1115
- Optimize queries like `select 'xxx' as component, sqlpage.some_function(...) as parameter`
1216
to avoid making an unneeded database query.
1317
This is especially important for the performance of `sqlpage.run_sql` and the `dynamic` component.
14-
- New `dropdown` row-level property in the [`form` component](https://sql.ophir.dev/documentation.sql?component=form#component)
15-
- ![select dropdown in form](https://github.com/lovasoa/SQLpage/assets/552629/5a2268d3-4996-49c9-9fb5-d310e753f844)
16-
- ![multiselect input](https://github.com/lovasoa/SQLpage/assets/552629/e8d62d1a-c851-4fef-8c5c-a22991ffadcf)
1718

1819
## 0.20.2 (2024-04-01)
1920

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ tokio = { version = "1.24.1", features = ["macros", "rt", "process", "sync"] }
3131
tokio-stream = "0.1.9"
3232
anyhow = "1"
3333
serde = "1"
34-
serde_json = { version = "1.0.82", features = ["preserve_order"] }
34+
serde_json = { version = "1.0.82", features = ["preserve_order", "raw_value"] }
3535
lambda-web = { version = "0.2.1", features = ["actix4"], optional = true }
3636
sqlparser = { version = "0.45.0", features = ["visitor"] }
3737
async-stream = "0.3"
@@ -49,6 +49,7 @@ base64 = "0.22"
4949
rustls-acme = "0.7.7"
5050
dotenvy = "0.15.7"
5151
csv-async = { version = "1.2.6", features = ["tokio"] }
52+
awc = { version = "3", features = ["rustls"] }
5253

5354
[build-dependencies]
5455
awc = { version = "3", features = ["rustls"] }
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
INSERT INTO sqlpage_functions (
2+
"name",
3+
"introduced_in_version",
4+
"icon",
5+
"description_md"
6+
)
7+
VALUES (
8+
'fetch',
9+
'0.20.3',
10+
'transfer-vertical',
11+
'Sends an HTTP request and returns the results as a string.
12+
13+
### Example
14+
15+
#### Simple GET query
16+
17+
In this example, we use an API call to find the latitude and longitude of a place
18+
the user searched for, and we display it on a map.
19+
20+
We use the simplest form of the fetch function, that takes the URL to fetch as a string.
21+
22+
23+
```sql
24+
set url = ''https://nominatim.openstreetmap.org/search?format=json&q='' || sqlpage.url_encode($user_search)
25+
set api_results = sqlpage.fetch($url);
26+
27+
select ''map'' as component;
28+
select $user_search as title,
29+
CAST($api_results->>0->>''lat'' AS FLOAT) as latitude,
30+
CAST($api_results->>0->>''lon'' AS FLOAT) as longitude;
31+
```
32+
#### POST query with a body
33+
34+
In this example, we use the complex form of the function to make an
35+
authenticated POST request, with custom request headers and a custom request body.
36+
37+
We use SQLite''s json functions to build the request body.
38+
39+
```sql
40+
set request = json_object(
41+
''method'', ''POST''
42+
''url'', ''https://postman-echo.com/post'',
43+
''headers'', json_object(
44+
''Content-Type'', ''application/json'',
45+
''Authorization'', ''Bearer '' || sqlpage.environment_variable(''MY_API_TOKEN'')
46+
),
47+
''body'', json_object(
48+
''Hello'', ''world'',
49+
),
50+
);
51+
set api_results = sqlpage.fetch($request);
52+
53+
select ''code'' as component;
54+
select
55+
''API call results'' as title,
56+
''json'' as language,
57+
$api_results as contents;
58+
```
59+
'
60+
);
61+
INSERT INTO sqlpage_function_parameters (
62+
"function",
63+
"index",
64+
"name",
65+
"description_md",
66+
"type"
67+
)
68+
VALUES (
69+
'fetch',
70+
1,
71+
'url',
72+
'Either a string containing an URL to request, or a json object in the standard format of the request interface of the web fetch API.',
73+
'TEXT'
74+
);

src/webserver/database/sql_pseudofunctions.rs

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
use std::{borrow::Cow, collections::HashMap};
1+
use std::{borrow::Cow, collections::HashMap, str::FromStr};
22

33
use actix_web::http::StatusCode;
44
use actix_web_httpauth::headers::authorization::Basic;
5+
use awc::http::Method;
56
use base64::Engine;
67
use mime_guess::{mime::APPLICATION_OCTET_STREAM, Mime};
78
use sqlparser::ast::FunctionArg;
@@ -51,6 +52,7 @@ pub(super) enum StmtParam {
5152
ReadFileAsText(Box<StmtParam>),
5253
ReadFileAsDataUrl(Box<StmtParam>),
5354
RunSql(Box<StmtParam>),
55+
Fetch(Box<StmtParam>),
5456
Path,
5557
Protocol,
5658
}
@@ -140,6 +142,7 @@ pub(super) fn func_call_to_param(func_name: &str, arguments: &mut [FunctionArg])
140142
extract_variable_argument("read_file_as_data_url", arguments),
141143
)),
142144
"run_sql" => StmtParam::RunSql(Box::new(extract_variable_argument("run_sql", arguments))),
145+
"fetch" => StmtParam::Fetch(Box::new(extract_variable_argument("fetch", arguments))),
143146
unknown_name => StmtParam::Error(format!(
144147
"Unknown function {unknown_name}({})",
145148
FormatArguments(arguments)
@@ -389,6 +392,90 @@ async fn run_sql<'a>(
389392
Ok(Some(Cow::Owned(String::from_utf8(json_results_bytes)?)))
390393
}
391394

395+
type HeaderVec<'a> = Vec<(Cow<'a, str>, Cow<'a, str>)>;
396+
#[derive(serde::Deserialize)]
397+
struct Req<'b> {
398+
#[serde(borrow)]
399+
url: Cow<'b, str>,
400+
#[serde(borrow)]
401+
method: Option<Cow<'b, str>>,
402+
#[serde(borrow, deserialize_with = "deserialize_map_to_vec_pairs")]
403+
headers: HeaderVec<'b>,
404+
#[serde(borrow)]
405+
body: Option<&'b serde_json::value::RawValue>,
406+
}
407+
408+
fn deserialize_map_to_vec_pairs<'de, D: serde::Deserializer<'de>>(
409+
deserializer: D,
410+
) -> Result<HeaderVec<'de>, D::Error> {
411+
struct Visitor;
412+
413+
impl<'de> serde::de::Visitor<'de> for Visitor {
414+
type Value = Vec<(Cow<'de, str>, Cow<'de, str>)>;
415+
416+
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
417+
formatter.write_str("a map")
418+
}
419+
420+
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
421+
where
422+
A: serde::de::MapAccess<'de>,
423+
{
424+
let mut vec = Vec::new();
425+
while let Some((key, value)) = map.next_entry()? {
426+
vec.push((key, value));
427+
}
428+
Ok(vec)
429+
}
430+
}
431+
432+
deserializer.deserialize_map(Visitor)
433+
}
434+
435+
async fn fetch<'a>(
436+
param0: &StmtParam,
437+
request: &'a RequestInfo,
438+
) -> Result<Option<Cow<'a, str>>, anyhow::Error> {
439+
let Some(fetch_target) = Box::pin(extract_req_param(param0, request)).await? else {
440+
log::debug!("fetch: first argument is NULL, returning NULL");
441+
return Ok(None);
442+
};
443+
let client = awc::Client::default();
444+
let res = if fetch_target.starts_with("http") {
445+
client.get(fetch_target.as_ref()).send()
446+
} else {
447+
let r = serde_json::from_str::<'_, Req<'_>>(&fetch_target)
448+
.with_context(|| format!("Invalid request: {fetch_target}"))?;
449+
let method = if let Some(method) = r.method {
450+
Method::from_str(&method)?
451+
} else {
452+
Method::GET
453+
};
454+
let mut req = client.request(method, r.url.as_ref());
455+
for (k, v) in r.headers {
456+
req = req.insert_header((k.as_ref(), v.as_ref()));
457+
}
458+
if let Some(body) = r.body {
459+
let val = body.get();
460+
// The body can be either json, or a string representing a raw body
461+
let body = if val.starts_with('"') {
462+
serde_json::from_str::<'_, String>(val)?
463+
} else {
464+
req = req.content_type("application/json");
465+
val.to_owned()
466+
};
467+
req.send_body(body)
468+
} else {
469+
req.send()
470+
}
471+
};
472+
let mut res = res
473+
.await
474+
.map_err(|e| anyhow!("Unable to fetch {fetch_target}: {e}"))?;
475+
let body = res.body().await?.to_vec();
476+
Ok(Some(String::from_utf8(body)?.into()))
477+
}
478+
392479
fn mime_from_upload<'a>(param0: &StmtParam, request: &'a RequestInfo) -> Option<&'a Mime> {
393480
if let StmtParam::UploadedFilePath(name) | StmtParam::UploadedFileMimeType(name) = param0 {
394481
request.uploaded_files.get(name)?.content_type.as_ref()
@@ -429,6 +516,7 @@ pub(super) async fn extract_req_param<'a>(
429516
StmtParam::ReadFileAsText(inner) => read_file_as_text(inner, request).await?,
430517
StmtParam::ReadFileAsDataUrl(inner) => read_file_as_data_url(inner, request).await?,
431518
StmtParam::RunSql(inner) => run_sql(inner, request).await?,
519+
StmtParam::Fetch(inner) => fetch(inner, request).await?,
432520
StmtParam::PersistUploadedFile {
433521
field_name,
434522
folder,

tests/index.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use actix_web::{
22
body::MessageBody,
3+
dev::{fn_service, ServerHandle, ServiceRequest, ServiceResponse},
34
http::{self, header::ContentType, StatusCode},
45
test::{self, TestRequest},
6+
HttpResponse,
57
};
68
use sqlpage::{app_config::AppConfig, webserver::http::main_handler, AppState};
79

@@ -81,8 +83,40 @@ async fn test_concurrent_requests() {
8183
}
8284
}
8385

86+
fn start_echo_server() -> ServerHandle {
87+
async fn echo_server(mut r: ServiceRequest) -> actix_web::Result<ServiceResponse> {
88+
let method = r.method();
89+
let path = r.uri();
90+
let mut headers_vec = r
91+
.headers()
92+
.into_iter()
93+
.map(|(k, v)| format!("{k}: {}", String::from_utf8_lossy(v.as_bytes())))
94+
.collect::<Vec<_>>();
95+
headers_vec.sort();
96+
let headers = headers_vec.join("\n");
97+
let mut resp_bytes = format!("{method} {path}\n{headers}\n\n").into_bytes();
98+
resp_bytes.extend(r.extract::<actix_web::web::Bytes>().await?);
99+
let resp = HttpResponse::Ok().body(resp_bytes);
100+
Ok(r.into_response(resp))
101+
}
102+
let server = actix_web::HttpServer::new(move || {
103+
actix_web::App::new().default_service(fn_service(echo_server))
104+
})
105+
.bind("localhost:62802")
106+
.unwrap()
107+
.shutdown_timeout(5) // shutdown timeout
108+
.run();
109+
110+
let handle = server.handle();
111+
tokio::spawn(server);
112+
113+
handle
114+
}
115+
84116
#[actix_web::test]
85117
async fn test_files() {
118+
// start a dummy server that test files can query
119+
let echo_server = start_echo_server();
86120
// Iterate over all the sql test files in the tests/ directory
87121
let path = std::path::Path::new("tests/sql_test_files");
88122
let app_data = make_app_data().await;
@@ -128,6 +162,7 @@ async fn test_files() {
128162
);
129163
}
130164
}
165+
echo_server.stop(true).await
131166
}
132167

133168
#[actix_web::test]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
set res = sqlpage.fetch('{
2+
"method": "POST",
3+
"url": "http://localhost:62802/post",
4+
"headers": {"x-custom": "1"},
5+
"body": {"hello": "world"}
6+
}');
7+
set expected_like = 'POST /post
8+
accept-encoding: br, gzip, deflate, zstd
9+
content-length: 18
10+
content-type: application/json
11+
date: %
12+
host: localhost:62802
13+
x-custom: 1
14+
15+
{"hello": "world"}';
16+
select 'text' as component,
17+
case
18+
when $res LIKE $expected_like then 'It works !'
19+
else 'It failed ! Expected:
20+
' || $expected_like || 'Got:
21+
' || $res
22+
end as contents;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
set res = sqlpage.fetch('http://localhost:62802/hello_world')
2+
select 'text' as component,
3+
case
4+
when $res LIKE 'GET /hello_world%' then 'It works !'
5+
else 'It failed ! Got: ' || $res
6+
end as contents;

0 commit comments

Comments
 (0)