Skip to content

Commit 9494cb0

Browse files
committed
new function: fetch_with_meta
closes #792
1 parent b11136a commit 9494cb0

File tree

2 files changed

+238
-23
lines changed

2 files changed

+238
-23
lines changed
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
INSERT INTO sqlpage_functions (
2+
"name",
3+
"introduced_in_version",
4+
"icon",
5+
"description_md"
6+
)
7+
VALUES (
8+
'fetch_with_meta',
9+
'0.34.0',
10+
'transfer-vertical',
11+
'Sends an HTTP request and returns detailed metadata about the response, including status code, headers, and body.
12+
13+
This function is similar to `fetch`, but returns a JSON object containing detailed information about the response.
14+
The returned object has the following structure:
15+
```json
16+
{
17+
"status": 200,
18+
"headers": {
19+
"content-type": "application/json",
20+
"content-length": "1234",
21+
...
22+
},
23+
"body": "response body content",
24+
"error": "error message if any"
25+
}
26+
```
27+
28+
If the request fails or encounters an error (e.g., network issues, invalid UTF-8 response), instead of throwing an error,
29+
the function returns a JSON object with an "error" field containing the error message.
30+
31+
### Example: Basic Usage
32+
33+
```sql
34+
-- Make a request and get detailed response information
35+
set response = sqlpage.fetch_with_meta(''https://api.example.com/data'');
36+
37+
-- Check if the request was successful
38+
select case
39+
when json_extract($response, ''$.error'') is not null then
40+
''Request failed: '' || json_extract($response, ''$.error'')
41+
when json_extract($response, ''$.status'') != 200 then
42+
''Request returned status '' || json_extract($response, ''$.status'')
43+
else
44+
''Request successful''
45+
end as message;
46+
47+
-- Display response headers
48+
select ''code'' as component,
49+
''Response Headers'' as title,
50+
''json'' as language,
51+
json_extract($response, ''$.headers'') as contents;
52+
```
53+
54+
### Example: Error Handling with Retries
55+
56+
```sql
57+
-- Function to make a request with retries
58+
create temp table if not exists make_request as
59+
with recursive retry(attempt, response) as (
60+
-- First attempt
61+
select 1 as attempt,
62+
sqlpage.fetch_with_meta(''https://api.example.com/data'') as response
63+
union all
64+
-- Retry up to 3 times if we get a 5xx error
65+
select attempt + 1,
66+
sqlpage.fetch_with_meta(''https://api.example.com/data'')
67+
from retry
68+
where attempt < 3
69+
and (
70+
json_extract(response, ''$.error'') is not null
71+
or cast(json_extract(response, ''$.status'') as integer) >= 500
72+
)
73+
)
74+
select response
75+
from retry
76+
where json_extract(response, ''$.error'') is null
77+
and cast(json_extract(response, ''$.status'') as integer) < 500
78+
limit 1;
79+
80+
-- Use the response
81+
select case
82+
when $response is null then ''All retry attempts failed''
83+
else ''Request succeeded after retries''
84+
end as message;
85+
```
86+
87+
### Example: Advanced Request with Authentication
88+
89+
```sql
90+
set request = json_object(
91+
''method'', ''POST'',
92+
''url'', ''https://api.example.com/data'',
93+
''headers'', json_object(
94+
''Content-Type'', ''application/json'',
95+
''Authorization'', ''Bearer '' || sqlpage.environment_variable(''API_TOKEN'')
96+
),
97+
''body'', json_object(
98+
''key'', ''value''
99+
)
100+
);
101+
set response = sqlpage.fetch_with_meta($request);
102+
103+
-- Check response content type
104+
select case
105+
when json_extract($response, ''$.headers.content-type'') like ''%application/json%''
106+
then json_extract($response, ''$.body'')
107+
else null
108+
end as json_response;
109+
```
110+
111+
The function accepts the same parameters as the `fetch` function. See the documentation of `fetch` for more details about the available parameters.'
112+
);
113+
114+
INSERT INTO sqlpage_function_parameters (
115+
"function",
116+
"index",
117+
"name",
118+
"description_md",
119+
"type"
120+
)
121+
VALUES (
122+
'fetch_with_meta',
123+
1,
124+
'url',
125+
'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.',
126+
'TEXT'
127+
);

src/webserver/database/sqlpage_functions/functions.rs

Lines changed: 111 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ super::function_definition_macro::sqlpage_functions! {
2424
exec((&RequestInfo), program_name: Cow<str>, args: Vec<Cow<str>>);
2525

2626
fetch((&RequestInfo), http_request: SqlPageFunctionParam<super::http_fetch_request::HttpFetchRequest<'_>>);
27+
fetch_with_meta((&RequestInfo), http_request: SqlPageFunctionParam<super::http_fetch_request::HttpFetchRequest<'_>>);
2728

2829
hash_password(password: Option<String>);
2930
header((&RequestInfo), name: Cow<str>);
@@ -135,53 +136,70 @@ async fn exec<'a>(
135136
Ok(String::from_utf8_lossy(&res.stdout).into_owned())
136137
}
137138

138-
async fn fetch(
139-
request: &RequestInfo,
140-
http_request: super::http_fetch_request::HttpFetchRequest<'_>,
141-
) -> anyhow::Result<String> {
139+
fn build_request<'a>(
140+
client: &'a awc::Client,
141+
http_request: &'a super::http_fetch_request::HttpFetchRequest<'_>,
142+
) -> anyhow::Result<awc::ClientRequest> {
142143
use awc::http::Method;
143-
let client = make_http_client(&request.app_state.config)
144-
.with_context(|| "Unable to create an HTTP client")?;
145-
146-
let method = if let Some(method) = http_request.method {
147-
Method::from_str(&method).with_context(|| format!("Invalid HTTP method: {method}"))?
144+
let method = if let Some(method) = &http_request.method {
145+
Method::from_str(method).with_context(|| format!("Invalid HTTP method: {method}"))?
148146
} else {
149147
Method::GET
150148
};
151149
let mut req = client.request(method, http_request.url.as_ref());
152150
if let Some(timeout) = http_request.timeout_ms {
153151
req = req.timeout(core::time::Duration::from_millis(timeout));
154152
}
155-
for (k, v) in http_request.headers {
153+
for (k, v) in &http_request.headers {
156154
req = req.insert_header((k.as_ref(), v.as_ref()));
157155
}
158-
if let Some(username) = http_request.username {
159-
let password = http_request.password.unwrap_or_default();
156+
if let Some(username) = &http_request.username {
157+
let password = http_request.password.as_deref().unwrap_or_default();
160158
req = req.basic_auth(username, password);
161159
}
160+
Ok(req)
161+
}
162+
163+
fn prepare_request_body(
164+
body: &serde_json::value::RawValue,
165+
mut req: awc::ClientRequest,
166+
) -> anyhow::Result<(String, awc::ClientRequest)> {
167+
let val = body.get();
168+
let body_str = if val.starts_with('"') {
169+
serde_json::from_str::<'_, String>(val).with_context(|| {
170+
format!("Invalid JSON string in the body of the HTTP request: {val}")
171+
})?
172+
} else {
173+
req = req.content_type("application/json");
174+
val.to_owned()
175+
};
176+
Ok((body_str, req))
177+
}
178+
179+
async fn fetch(
180+
request: &RequestInfo,
181+
http_request: super::http_fetch_request::HttpFetchRequest<'_>,
182+
) -> anyhow::Result<String> {
183+
let client = make_http_client(&request.app_state.config)
184+
.with_context(|| "Unable to create an HTTP client")?;
185+
let req = build_request(&client, &http_request)?;
186+
162187
log::info!("Fetching {}", http_request.url);
163-
let mut response = if let Some(body) = http_request.body {
164-
let val = body.get();
165-
// The body can be either json, or a string representing a raw body
166-
let body = if val.starts_with('"') {
167-
serde_json::from_str::<'_, String>(val).with_context(|| {
168-
format!("Invalid JSON string in the body of the HTTP request: {val}")
169-
})?
170-
} else {
171-
req = req.content_type("application/json");
172-
val.to_owned()
173-
};
188+
let mut response = if let Some(body) = &http_request.body {
189+
let (body, req) = prepare_request_body(body, req)?;
174190
req.send_body(body)
175191
} else {
176192
req.send()
177193
}
178194
.await
179195
.map_err(|e| anyhow!("Unable to fetch {}: {e}", http_request.url))?;
196+
180197
log::debug!(
181198
"Finished fetching {}. Status: {}",
182199
http_request.url,
183200
response.status()
184201
);
202+
185203
let body = response
186204
.body()
187205
.await
@@ -199,6 +217,76 @@ async fn fetch(
199217
Ok(response_str)
200218
}
201219

220+
async fn fetch_with_meta(
221+
request: &RequestInfo,
222+
http_request: super::http_fetch_request::HttpFetchRequest<'_>,
223+
) -> anyhow::Result<String> {
224+
let client = make_http_client(&request.app_state.config)
225+
.with_context(|| "Unable to create an HTTP client")?;
226+
let req = build_request(&client, &http_request)?;
227+
228+
log::info!("Fetching {} with metadata", http_request.url);
229+
let response_result = if let Some(body) = &http_request.body {
230+
let (body, req) = prepare_request_body(body, req)?;
231+
req.send_body(body).await
232+
} else {
233+
req.send().await
234+
};
235+
236+
let mut response_info = serde_json::Map::new();
237+
match response_result {
238+
Ok(mut response) => {
239+
response_info.insert("status".to_string(), response.status().as_u16().into());
240+
241+
let mut headers = serde_json::Map::new();
242+
for (name, value) in response.headers().iter() {
243+
if let Ok(value_str) = value.to_str() {
244+
headers.insert(name.to_string(), value_str.into());
245+
}
246+
}
247+
response_info.insert("headers".to_string(), headers.clone().into());
248+
249+
match response.body().await {
250+
Ok(body) => {
251+
let body_bytes = body.to_vec();
252+
let content_type = headers.get("content-type")
253+
.and_then(|v| v.as_str())
254+
.unwrap_or_default();
255+
256+
let body_value = if content_type.contains("application/json") {
257+
match serde_json::from_slice(&body_bytes) {
258+
Ok(json_value) => json_value,
259+
Err(_) => serde_json::Value::String(String::from_utf8_lossy(&body_bytes).into_owned()),
260+
}
261+
} else {
262+
match String::from_utf8(body_bytes.clone()) {
263+
Ok(text) => serde_json::Value::String(text),
264+
Err(_) => {
265+
let mut base64_string = String::new();
266+
base64::Engine::encode_string(
267+
&base64::engine::general_purpose::STANDARD,
268+
&body_bytes,
269+
&mut base64_string,
270+
);
271+
serde_json::Value::String(base64_string)
272+
}
273+
}
274+
};
275+
response_info.insert("body".to_string(), body_value);
276+
}
277+
Err(e) => {
278+
response_info.insert("error".to_string(), format!("Failed to read response body: {}", e).into());
279+
}
280+
}
281+
}
282+
Err(e) => {
283+
response_info.insert("error".to_string(), format!("Request failed: {}", e).into());
284+
}
285+
}
286+
287+
Ok(serde_json::to_string(&response_info)?)
288+
}
289+
202290
static NATIVE_CERTS: OnceLock<anyhow::Result<rustls::RootCertStore>> = OnceLock::new();
203291

204292
fn make_http_client(config: &crate::app_config::AppConfig) -> anyhow::Result<awc::Client> {

0 commit comments

Comments
 (0)