Skip to content

Commit 5800fe9

Browse files
committed
Support JSON responses via Accept header
1 parent 24d6057 commit 5800fe9

File tree

7 files changed

+379
-13
lines changed

7 files changed

+379
-13
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
-- This migration documents the JSON/JSONL response format feature based on HTTP Accept headers
2+
3+
-- Update the json component description to include information about the Accept header feature
4+
UPDATE component
5+
SET description = 'Converts SQL query results into the JSON machine-readable data format. Ideal to quickly build APIs for interfacing with external systems.
6+
7+
**JSON** is a widely used data format for programmatic data exchange.
8+
For example, you can use it to integrate with web services written in different languages,
9+
with mobile or desktop apps, or with [custom client-side components](/custom_components.sql) inside your SQLPage app.
10+
11+
Use it when your application needs to expose data to external systems.
12+
If you only need to render standard web pages,
13+
and do not need other software to access your data,
14+
you can ignore this component.
15+
16+
This component **must appear at the top of your SQL file**, before any other data has been sent to the browser.
17+
An HTTP response can have only a single datatype, and it must be declared in the headers.
18+
So if you have already called the `shell` component, or another traditional HTML component,
19+
you cannot use this component in the same file.
20+
21+
### Alternative: Using HTTP Accept Headers
22+
23+
SQLPage also supports returning JSON or JSON Lines responses based on the HTTP `Accept` header,
24+
without needing to use this component. This is useful when you want the same SQL file to serve
25+
both HTML pages (for browsers) and JSON data (for API clients).
26+
27+
See [Automatic JSON output based on Accept headers](#example4) for more details.
28+
'
29+
WHERE name = 'json';
30+
31+
-- Add a new example for the Accept header feature
32+
INSERT INTO example (component, description)
33+
VALUES (
34+
'json',
35+
'
36+
## Automatic JSON output based on HTTP Accept headers
37+
38+
SQLPage can automatically return JSON or JSON Lines responses instead of HTML based on the HTTP `Accept` header sent by the client.
39+
This allows the same SQL file to serve both web browsers and API clients.
40+
41+
### How it works
42+
43+
When a client sends a request with an `Accept` header, SQLPage checks if the client prefers JSON:
44+
45+
- `Accept: application/json` → Returns a JSON array of all component data
46+
- `Accept: application/x-ndjson` → Returns JSON Lines (one JSON object per line)
47+
- `Accept: text/html` or `Accept: */*` → Returns the normal HTML page
48+
49+
All other SQLPage features work exactly the same:
50+
- Header components (`redirect`, `cookie`, `http_header`, `status_code`, `authentication`) work as expected
51+
- SQLPage functions and variables work normally
52+
- The response just skips HTML template rendering
53+
54+
### Example: A dual-purpose page
55+
56+
The following SQL file works as both a normal web page and a JSON API:
57+
58+
```sql
59+
-- Header components work with both HTML and JSON responses
60+
SELECT ''cookie'' AS component, ''last_visit'' AS name, datetime() AS value;
61+
SELECT ''status_code'' AS component, 200 AS status;
62+
63+
-- These will be rendered as HTML for browsers, or returned as JSON for API clients
64+
SELECT ''text'' AS component, ''Welcome!'' AS contents;
65+
SELECT ''table'' AS component;
66+
SELECT id, name, email FROM users;
67+
```
68+
69+
### HTML Response (default, for browsers)
70+
71+
```html
72+
<!DOCTYPE html>
73+
<html>
74+
<!-- Normal SQLPage HTML output -->
75+
</html>
76+
```
77+
78+
### JSON Response (when Accept: application/json)
79+
80+
```json
81+
[
82+
{"component":"text","contents":"Welcome!"},
83+
{"component":"table"},
84+
{"id":1,"name":"Alice","email":"[email protected]"},
85+
{"id":2,"name":"Bob","email":"[email protected]"}
86+
]
87+
```
88+
89+
### JSON Lines Response (when Accept: application/x-ndjson)
90+
91+
```
92+
{"component":"text","contents":"Welcome!"}
93+
{"component":"table"}
94+
{"id":1,"name":"Alice","email":"[email protected]"}
95+
{"id":2,"name":"Bob","email":"[email protected]"}
96+
```
97+
98+
### Using from JavaScript
99+
100+
```javascript
101+
// Fetch JSON from any SQLPage endpoint
102+
const response = await fetch("/users.sql", {
103+
headers: { "Accept": "application/json" }
104+
});
105+
const data = await response.json();
106+
console.log(data);
107+
```
108+
109+
### Using from curl
110+
111+
```bash
112+
# Get JSON output
113+
curl -H "Accept: application/json" http://localhost:8080/users.sql
114+
115+
# Get JSON Lines output
116+
curl -H "Accept: application/x-ndjson" http://localhost:8080/users.sql
117+
```
118+
119+
### Comparison with the json component
120+
121+
| Feature | `json` component | Accept header |
122+
|---------|------------------|---------------|
123+
| Use case | Dedicated API endpoint | Dual-purpose page |
124+
| HTML output | Not possible | Default behavior |
125+
| Custom JSON structure | Yes (via `contents`) | No (component data only) |
126+
| Server-sent events | Yes (`type: sse`) | No |
127+
| Requires code changes | Yes | No |
128+
'
129+
);
130+

src/render.rs

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
//! [SQLPage documentation](https://sql-page.com/documentation.sql).
4343
4444
use crate::templates::SplitTemplate;
45-
use crate::webserver::http::RequestContext;
45+
use crate::webserver::http::{RequestContext, ResponseFormat};
4646
use crate::webserver::response_writer::{AsyncResponseWriter, ResponseWriter};
4747
use crate::webserver::ErrorWithStatus;
4848
use crate::AppState;
@@ -96,11 +96,13 @@ impl HeaderContext {
9696
writer: ResponseWriter,
9797
) -> Self {
9898
let mut response = HttpResponseBuilder::new(StatusCode::OK);
99-
response.content_type("text/html; charset=utf-8");
100-
let tpl = &app_state.config.content_security_policy;
101-
request_context
102-
.content_security_policy
103-
.apply_to_response(tpl, &mut response);
99+
response.content_type(request_context.response_format.content_type());
100+
if request_context.response_format == ResponseFormat::Html {
101+
let tpl = &app_state.config.content_security_policy;
102+
request_context
103+
.content_security_policy
104+
.apply_to_response(tpl, &mut response);
105+
}
104106
Self {
105107
app_state,
106108
request_context,
@@ -391,11 +393,23 @@ impl HeaderContext {
391393

392394
async fn start_body(mut self, data: JsonValue) -> anyhow::Result<PageContext> {
393395
self.add_server_timing_header();
394-
let html_renderer =
395-
HtmlRenderContext::new(self.app_state, self.request_context, self.writer, data)
396-
.await
397-
.with_context(|| "Failed to create a render context from the header context.")?;
398-
let renderer = AnyRenderBodyContext::Html(html_renderer);
396+
let renderer = match self.request_context.response_format {
397+
ResponseFormat::Json => AnyRenderBodyContext::Json(
398+
JsonBodyRenderer::new_array_with_first_row(self.writer, &data),
399+
),
400+
ResponseFormat::JsonLines => AnyRenderBodyContext::Json(
401+
JsonBodyRenderer::new_jsonlines_with_first_row(self.writer, &data),
402+
),
403+
ResponseFormat::Html => {
404+
let html_renderer =
405+
HtmlRenderContext::new(self.app_state, self.request_context, self.writer, data)
406+
.await
407+
.with_context(|| {
408+
"Failed to create a render context from the header context."
409+
})?;
410+
AnyRenderBodyContext::Html(html_renderer)
411+
}
412+
};
399413
let http_response = self.response;
400414
Ok(PageContext::Body {
401415
renderer,
@@ -516,6 +530,11 @@ impl<W: std::io::Write> JsonBodyRenderer<W> {
516530
let _ = renderer.write_prefix();
517531
renderer
518532
}
533+
pub fn new_array_with_first_row(writer: W, first_row: &JsonValue) -> JsonBodyRenderer<W> {
534+
let mut renderer = Self::new_array(writer);
535+
let _ = renderer.handle_row(first_row);
536+
renderer
537+
}
519538
pub fn new_jsonlines(writer: W) -> JsonBodyRenderer<W> {
520539
let mut renderer = Self {
521540
writer,
@@ -527,6 +546,11 @@ impl<W: std::io::Write> JsonBodyRenderer<W> {
527546
renderer.write_prefix().unwrap();
528547
renderer
529548
}
549+
pub fn new_jsonlines_with_first_row(writer: W, first_row: &JsonValue) -> JsonBodyRenderer<W> {
550+
let mut renderer = Self::new_jsonlines(writer);
551+
let _ = renderer.handle_row(first_row);
552+
renderer
553+
}
530554
pub fn new_server_sent_events(writer: W) -> JsonBodyRenderer<W> {
531555
let mut renderer = Self {
532556
writer,

src/webserver/http.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use crate::webserver::ErrorWithStatus;
1212
use crate::{AppConfig, AppState, ParsedSqlFile, DEFAULT_404_FILE};
1313
use actix_web::dev::{fn_service, ServiceFactory, ServiceRequest};
1414
use actix_web::error::{ErrorBadRequest, ErrorInternalServerError};
15+
use actix_web::http::header::Accept;
1516
use actix_web::http::header::{ContentType, Header, HttpDate, IfModifiedSince, LastModified};
1617
use actix_web::http::{header, StatusCode};
1718
use actix_web::web::PayloadConfig;
@@ -40,12 +41,52 @@ use std::sync::Arc;
4041
use std::time::SystemTime;
4142
use tokio::sync::mpsc;
4243

44+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45+
pub enum ResponseFormat {
46+
#[default]
47+
Html,
48+
Json,
49+
JsonLines,
50+
}
51+
4352
#[derive(Clone)]
4453
pub struct RequestContext {
4554
pub is_embedded: bool,
4655
pub source_path: PathBuf,
4756
pub content_security_policy: ContentSecurityPolicy,
4857
pub server_timing: Arc<ServerTiming>,
58+
pub response_format: ResponseFormat,
59+
}
60+
61+
impl ResponseFormat {
62+
#[must_use]
63+
pub fn from_accept_header(accept: &Accept) -> Self {
64+
for quality_item in accept.iter() {
65+
let mime = &quality_item.item;
66+
let type_ = mime.type_().as_str();
67+
let subtype = mime.subtype().as_str();
68+
69+
match (type_, subtype) {
70+
("application", "json") => return Self::Json,
71+
("application", "x-ndjson" | "jsonlines" | "x-jsonlines") => {
72+
return Self::JsonLines
73+
}
74+
("text", "x-ndjson" | "jsonlines" | "x-jsonlines") => return Self::JsonLines,
75+
("text", "html") | ("*", "*") => return Self::Html,
76+
_ => {}
77+
}
78+
}
79+
Self::Html
80+
}
81+
82+
#[must_use]
83+
pub fn content_type(self) -> &'static str {
84+
match self {
85+
Self::Html => "text/html; charset=utf-8",
86+
Self::Json => "application/json",
87+
Self::JsonLines => "application/x-ndjson",
88+
}
89+
}
4990
}
5091

5192
async fn stream_response(stream: impl Stream<Item = DbItem>, mut renderer: AnyRenderBodyContext) {
@@ -174,6 +215,10 @@ async fn render_sql(
174215
.clone()
175216
.into_inner();
176217

218+
let response_format = Accept::parse(srv_req)
219+
.map(|accept| ResponseFormat::from_accept_header(&accept))
220+
.unwrap_or_default();
221+
177222
let exec_ctx = extract_request_info(srv_req, Arc::clone(&app_state), server_timing)
178223
.await
179224
.map_err(|e| anyhow_err_to_actix(e, &app_state))?;
@@ -190,6 +235,7 @@ async fn render_sql(
190235
source_path,
191236
content_security_policy: ContentSecurityPolicy::with_random_nonce(),
192237
server_timing: Arc::clone(&request_info.server_timing),
238+
response_format,
193239
};
194240
let mut conn = None;
195241
let database_entries_stream =
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
SELECT 'cookie' as component, 'test_cookie' as name, 'cookie_value' as value;
2+
SELECT 'status_code' as component, 201 as status;
3+
SELECT 'text' as component, 'Created' as contents;
4+
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
SELECT 'redirect' as component, '/target' as link;
2+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
SELECT 'text' as component, 'Hello World' as contents;
2+
SELECT 'table' as component;
3+
SELECT 1 as id, 'Alice' as name;
4+
SELECT 2 as id, 'Bob' as name;
5+

0 commit comments

Comments
 (0)