Skip to content

Commit f8241a9

Browse files
committed
json: support streaming query results as json array, jsonlines, or server-sent events
1 parent 1328f4b commit f8241a9

File tree

5 files changed

+238
-28
lines changed

5 files changed

+238
-28
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
- New `initial_search_value` property in the table component to pre-fill the search bar with a value. This allows to display the table rows that will initially be filtered out by the search bar.
1010
- Fix autoplay of carousels when embedded in a card.
1111
- Allow setting image width and height in carousels, in order to avoid differently sized images to cause layout janking when going through them.
12+
- Many improvements to the [json](https://sql.datapage.app/component.sql?component=json) component, making it easier and faster than ever to build REST APIs entirely in SQL.
13+
- **Ease of use** : the component can now be used to automatically format any query result as a json array, without manually using your database''s json functions.
14+
- **server-sent events** : the component can now be used to stream query results to the client in real-time using server-sent events.
1215

1316
## 0.29.0 (2024-09-25)
1417
- New columns component: `columns`. Useful to display a comparison between items, or large key figures to an user.

examples/official-site/sqlpage/migrations/11_json.sql

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,94 @@
1-
INSERT INTO component (name, description, icon, introduced_in_version)
2-
VALUES (
1+
INSERT INTO
2+
component (name, description, icon, introduced_in_version)
3+
VALUES
4+
(
35
'json',
46
'For advanced users, allows you to easily build an API over your database.
57
The json component responds to the current HTTP request with a JSON object.
68
This component must appear at the top of your SQL file, before any other data has been sent to the browser.',
79
'code',
810
'0.9.0'
911
);
12+
1013
-- Insert the parameters for the http_header component into the parameter table
11-
INSERT INTO parameter (
14+
INSERT INTO
15+
parameter (
1216
component,
1317
name,
1418
description,
1519
type,
1620
top_level,
1721
optional
1822
)
19-
VALUES (
23+
VALUES
24+
(
2025
'json',
2126
'contents',
22-
'The JSON payload to send. You should use your database''s built-in json functions to build the value to enter here.',
27+
'A single JSON payload to send. You can use your database''s built-in json functions to build the value to enter here. If not provided, the contents will be taken from the next SQL statements and rendered as a JSON array.',
2328
'TEXT',
2429
TRUE,
25-
FALSE
30+
TRUE
31+
),
32+
(
33+
'json',
34+
'type',
35+
'The type of the JSON payload to send. Defaults to "array" (each query result is rendered as a JSON object in the array). Other possible values are "jsonlines" (each query result is rendered as a JSON object in a new line, without a top-level array) and "sse" (each query result is rendered as a JSON object in a new line, prefixed by "data: ", which allows you to read the results as server-sent events in real-time from javascript).',
36+
'TEXT',
37+
TRUE,
38+
TRUE
2639
);
40+
2741
-- Insert an example usage of the http_header component into the example table
28-
INSERT INTO example (component, description)
29-
VALUES (
42+
INSERT INTO
43+
example (component, description)
44+
VALUES
45+
(
46+
'json',
47+
'
48+
## Send query results as a JSON array
49+
50+
### SQL
51+
52+
```sql
53+
select ''json'' AS component;
54+
select * from users;
55+
```
56+
57+
### Result
58+
59+
```json
60+
[
61+
{"username":"James","userid":1},
62+
{"username":"John","userid":2}
63+
]
64+
```
65+
'
66+
),
67+
(
68+
'json',
69+
'
70+
## Send a single JSON object
71+
72+
### SQL
73+
74+
```sql
75+
select ''json'' AS component, ''jsonlines'' AS type;
76+
select * from users where id = $user_id LIMIT 1;
77+
```
78+
79+
### Result
80+
81+
```json
82+
{ "username":"James", "userid":1 }
83+
```
84+
'
85+
),
86+
(
3087
'json',
3188
'
32-
Creates an API endpoint that will allow developers to easily query a list of users stored in your database.
89+
## Create a complex API endpoint
90+
91+
This will create an API endpoint that will allow developers to easily query a list of users stored in your database.
3392
3493
You should use [the json functions provided by your database](/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide) to form the value you pass to the `contents` property.
3594
To build a json array out of rows from the database, you can use:
@@ -69,4 +128,30 @@ you can use
69128
- the [`request_method` function](/functions.sql?function=request_method#function) to differentiate between GET and POST requests,
70129
- and the [`path` function](/functions.sql?function=path#function) to extract the `:id` parameter from the URL.
71130
'
72-
);
131+
),
132+
(
133+
'json',
134+
'
135+
## Access query results in real-time with server-sent events
136+
137+
Using server-sent events, you can stream query results to the client in real-time.
138+
This means you can build dynamic applications that will process data as it arrives.
139+
140+
### SQL
141+
142+
```sql
143+
select ''json'' AS component, ''sse'' AS type;
144+
select * from users;
145+
```
146+
147+
### JavaScript
148+
149+
```javascript
150+
const eventSource = new EventSource("users.sql");
151+
eventSource.onmessage = function (event) {
152+
const user = JSON.parse(event.data);
153+
console.log(user.username);
154+
}
155+
```
156+
'
157+
);

src/render.rs

Lines changed: 111 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ impl<'a, W: std::io::Write> HeaderContext<W> {
5959
Some("status_code") => self.status_code(&data).map(PageContext::Header),
6060
Some("http_header") => self.add_http_header(&data).map(PageContext::Header),
6161
Some("redirect") => self.redirect(&data).map(PageContext::Close),
62-
Some("json") => self.json(&data).map(PageContext::Close),
62+
Some("json") => self.json(&data),
6363
Some("cookie") => self.add_cookie(&data).map(PageContext::Header),
6464
Some("authentication") => self.authentication(data).await,
6565
_ => self.start_body(data).await,
@@ -187,18 +187,37 @@ impl<'a, W: std::io::Write> HeaderContext<W> {
187187
}
188188

189189
/// Answers to the HTTP request with a single json object
190-
fn json(mut self, data: &JsonValue) -> anyhow::Result<HttpResponse> {
191-
let contents = data
192-
.get("contents")
193-
.with_context(|| "Missing 'contents' property for the json component")?;
194-
let json_response = if let Some(s) = contents.as_str() {
195-
s.as_bytes().to_owned()
196-
} else {
197-
serde_json::to_vec(contents)?
198-
};
190+
fn json(mut self, data: &JsonValue) -> anyhow::Result<PageContext<W>> {
199191
self.response
200192
.insert_header((header::CONTENT_TYPE, "application/json"));
201-
Ok(self.response.body(json_response))
193+
if let Some(contents) = data.get("contents") {
194+
let json_response = if let Some(s) = contents.as_str() {
195+
s.as_bytes().to_owned()
196+
} else {
197+
serde_json::to_vec(contents)?
198+
};
199+
Ok(PageContext::Close(self.response.body(json_response)))
200+
} else {
201+
let body_type = get_object_str(data, "type");
202+
let json_renderer = match body_type {
203+
None | Some("array") => JsonBodyRenderer::new_array(self.writer),
204+
Some("jsonlines") => JsonBodyRenderer::new_jsonlines(self.writer),
205+
Some("sse") => {
206+
self.response
207+
.insert_header((header::CONTENT_TYPE, "text/event-stream"));
208+
JsonBodyRenderer::new_server_sent_events(self.writer)
209+
}
210+
_ => bail!(
211+
"Invalid value for the 'type' property of the json component: {body_type:?}"
212+
),
213+
};
214+
let renderer = AnyRenderBodyContext::Json(json_renderer);
215+
let http_response = self.response;
216+
Ok(PageContext::Body {
217+
http_response,
218+
renderer,
219+
})
220+
}
202221
}
203222

204223
async fn authentication(mut self, mut data: JsonValue) -> anyhow::Result<PageContext<W>> {
@@ -290,41 +309,121 @@ fn take_object_str(json: &mut JsonValue, key: &str) -> Option<String> {
290309
*/
291310
pub enum AnyRenderBodyContext<W: std::io::Write> {
292311
Html(HtmlRenderContext<W>),
312+
Json(JsonBodyRenderer<W>),
293313
}
294314

295315
/**
296316
* Dummy impl to dispatch method calls to the underlying renderer
297317
*/
298318
impl<W: std::io::Write> AnyRenderBodyContext<W> {
299319
pub async fn handle_row(&mut self, data: &JsonValue) -> anyhow::Result<()> {
320+
log::debug!(
321+
"<- Rendering properties: {}",
322+
serde_json::to_string(&data).unwrap_or_else(|e| e.to_string())
323+
);
300324
match self {
301325
AnyRenderBodyContext::Html(render_context) => render_context.handle_row(data).await,
326+
AnyRenderBodyContext::Json(json_body_renderer) => json_body_renderer.handle_row(data),
302327
}
303328
}
304329
pub async fn handle_error(&mut self, error: &anyhow::Error) -> anyhow::Result<()> {
330+
log::error!("SQL error: {:?}", error);
305331
match self {
306332
AnyRenderBodyContext::Html(render_context) => render_context.handle_error(error).await,
333+
AnyRenderBodyContext::Json(json_body_renderer) => {
334+
json_body_renderer.handle_error(error)
335+
}
307336
}
308337
}
309338
pub async fn finish_query(&mut self) -> anyhow::Result<()> {
310339
match self {
311340
AnyRenderBodyContext::Html(render_context) => render_context.finish_query().await,
341+
AnyRenderBodyContext::Json(_json_body_renderer) => Ok(()),
312342
}
313343
}
314344

315345
pub fn writer_mut(&mut self) -> &mut W {
316346
match self {
317-
AnyRenderBodyContext::Html(HtmlRenderContext { writer, .. }) => writer,
347+
AnyRenderBodyContext::Html(HtmlRenderContext { writer, .. })
348+
| AnyRenderBodyContext::Json(JsonBodyRenderer { writer, .. }) => writer,
318349
}
319350
}
320351

321352
pub async fn close(self) -> W {
322353
match self {
323354
AnyRenderBodyContext::Html(render_context) => render_context.close().await,
355+
AnyRenderBodyContext::Json(json_body_renderer) => json_body_renderer.close(),
324356
}
325357
}
326358
}
327359

360+
pub struct JsonBodyRenderer<W: std::io::Write> {
361+
writer: W,
362+
is_first: bool,
363+
prefix: &'static [u8],
364+
suffix: &'static [u8],
365+
separator: &'static [u8],
366+
}
367+
368+
impl<W: std::io::Write> JsonBodyRenderer<W> {
369+
pub fn new_array(writer: W) -> JsonBodyRenderer<W> {
370+
let mut renderer = Self {
371+
writer,
372+
is_first: true,
373+
prefix: b"[\n",
374+
suffix: b"\n]",
375+
separator: b",\n",
376+
};
377+
let _ = renderer.write_prefix();
378+
renderer
379+
}
380+
pub fn new_jsonlines(writer: W) -> JsonBodyRenderer<W> {
381+
let mut renderer = Self {
382+
writer,
383+
is_first: true,
384+
prefix: b"",
385+
suffix: b"",
386+
separator: b"\n",
387+
};
388+
renderer.write_prefix().unwrap();
389+
renderer
390+
}
391+
pub fn new_server_sent_events(writer: W) -> JsonBodyRenderer<W> {
392+
let mut renderer = Self {
393+
writer,
394+
is_first: true,
395+
prefix: b"data: ",
396+
suffix: b"",
397+
separator: b"\n\ndata: ",
398+
};
399+
renderer.write_prefix().unwrap();
400+
renderer
401+
}
402+
fn write_prefix(&mut self) -> anyhow::Result<()> {
403+
self.writer.write_all(self.prefix)?;
404+
Ok(())
405+
}
406+
pub fn handle_row(&mut self, data: &JsonValue) -> anyhow::Result<()> {
407+
if self.is_first {
408+
self.is_first = false;
409+
} else {
410+
let _ = self.writer.write_all(self.separator);
411+
}
412+
serde_json::to_writer(&mut self.writer, data)?;
413+
Ok(())
414+
}
415+
pub fn handle_error(&mut self, error: &anyhow::Error) -> anyhow::Result<()> {
416+
self.handle_row(&json!({
417+
"error": error.to_string()
418+
}))
419+
}
420+
421+
pub fn close(mut self) -> W {
422+
let _ = self.writer.write_all(self.suffix);
423+
self.writer
424+
}
425+
}
426+
328427
#[allow(clippy::module_name_repetitions)]
329428
pub struct HtmlRenderContext<W: std::io::Write> {
330429
app_state: Arc<AppState>,
@@ -404,10 +503,6 @@ impl<W: std::io::Write> HtmlRenderContext<W> {
404503

405504
#[async_recursion(? Send)]
406505
pub async fn handle_row(&mut self, data: &JsonValue) -> anyhow::Result<()> {
407-
log::debug!(
408-
"<- Rendering properties: {}",
409-
serde_json::to_string(&data).unwrap_or_else(|e| e.to_string())
410-
);
411506
let new_component = get_object_str(data, "component");
412507
let current_component = self
413508
.current_component
@@ -455,7 +550,6 @@ impl<W: std::io::Write> HtmlRenderContext<W> {
455550
/// Handles the rendering of an error.
456551
/// Returns whether the error is irrecoverable and the rendering must stop
457552
pub async fn handle_error(&mut self, error: &anyhow::Error) -> anyhow::Result<()> {
458-
log::error!("SQL error: {:?}", error);
459553
self.close_component()?;
460554
let data = if self.app_state.config.environment.is_prod() {
461555
json!({

tests/index.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ use std::{collections::HashMap, path::PathBuf};
33
use actix_web::{
44
body::MessageBody,
55
dev::{fn_service, ServerHandle, ServiceRequest, ServiceResponse},
6-
http::{self, header::ContentType, StatusCode},
6+
http::{
7+
self,
8+
header::{self, ContentType},
9+
StatusCode,
10+
},
711
test::{self, TestRequest},
812
HttpResponse,
913
};
@@ -231,6 +235,27 @@ async fn test_overwrite_variable() -> actix_web::Result<()> {
231235
Ok(())
232236
}
233237

238+
#[actix_web::test]
239+
async fn test_json_body() -> actix_web::Result<()> {
240+
let req = get_request_to("/tests/json_data.sql")
241+
.await?
242+
.to_srv_request();
243+
let resp = main_handler(req).await?;
244+
245+
assert_eq!(resp.status(), StatusCode::OK);
246+
assert_eq!(
247+
resp.headers().get(header::CONTENT_TYPE).unwrap(),
248+
"application/json"
249+
);
250+
let body = test::read_body(resp).await;
251+
let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
252+
assert_eq!(
253+
body_json,
254+
serde_json::json!([{"message": "It works!"}, {"cool": "cool"}])
255+
);
256+
Ok(())
257+
}
258+
234259
async fn test_file_upload(target: &str) -> actix_web::Result<()> {
235260
let req = get_request_to(target)
236261
.await?

tests/json_data.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
select 'json' as component;
2+
select 'It works!' as message;
3+
select 'cool' as cool;

0 commit comments

Comments
 (0)