Skip to content

Commit 2af95a0

Browse files
authored
Accept header JSON responses (#1127)
* Support JSON responses via Accept header * no update in migrations * No UPDATE in official site migrations - Updated the JSON component description to clarify its integration with external services and the ability to serve both HTML and JSON based on the HTTP Accept header. - Added examples demonstrating how to request JSON responses using `curl`. - Removed the obsolete migration file that documented the JSON response format feature, consolidating information into the main documentation. * revert stupid docs example change stupid bot * simplify tests * avoid string then json in tests, parse as json directly * changelog
1 parent 24d6057 commit 2af95a0

File tree

6 files changed

+203
-20
lines changed

6 files changed

+203
-20
lines changed

AGENTS.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,19 @@ docker compose up -d mssql # or postgres or mysql
2828
DATABASE_URL='mssql://root:Password123!@localhost/sqlpage' cargo test # all dbms use the same user:pass and db name
2929
```
3030

31+
### Documentation
32+
33+
Components and functions are documented in [official website](./examples/official-site/sqlpage/migrations/); one migration per component and per function. You CAN update existing migrations, the official site database is recreated from scratch on each deployment.
34+
35+
official documentation website sql tables:
36+
- `component(name,description,icon,introduced_in_version)` -- icon name from tabler icon
37+
- `parameter(top_level BOOLEAN, name, component REFERENCES component(name), description, description_md, type, optional BOOLEAN)` parameter types: BOOLEAN, COLOR, HTML, ICON, INTEGER, JSON, REAL, TEXT, TIMESTAMP, URL
38+
- `example(component REFERENCES component(name), description, properties JSON)`
39+
3140
#### Project Conventions
3241

3342
- Components: defined in `./sqlpage/templates/*.handlebars`
3443
- Functions: `src/webserver/database/sqlpage_functions/functions.rs` registered with `make_function!`.
35-
- Components and functions are documented in [official website](./examples/official-site/sqlpage/migrations/); one migration per component and per function.
36-
- tables
37-
- `component(name,description,icon,introduced_in_version)` -- icon name from tabler icon
38-
- `parameter(top_level BOOLEAN, name, component REFERENCES component(name), description, description_md, type, optional BOOLEAN)` parameter types: BOOLEAN, COLOR, HTML, ICON, INTEGER, JSON, REAL, TEXT, TIMESTAMP, URL
39-
- `example(component REFERENCES component(name), description, properties JSON)`
4044
- [Configuration](./configuration.md): see [AppConfig](./src/app_config.rs)
4145
- Routing: file-based in `src/webserver/routing.rs`; not found handled via `src/default_404.sql`.
4246
- Follow patterns from similar modules before introducing new abstractions.

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# CHANGELOG.md
22

33
## 0.40.0 (unreleased)
4+
- SQLPage now respects [HTTP accept headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept) for JSON. You can now easily process the contents of any existing sql page programmatically with:
5+
- `curl -H "Accept: application/json" http://example.com/page.sql`: returns a json array
6+
- `curl -H "Accept: application/x-ndjson" http://example.com/page.sql`: returns one json object per line.
47
- Fixed a bug in `sqlpage.link`: a link with no path (link to the current page) and no url parameter now works as expected. It used to keep the existing url parameters instead of removing them. `sqlpage.link('', '{}')` now returns `'?'` instead of the empty string.
58
- **New Function**: `sqlpage.set_variable(name, value)`
69
- Returns a URL with the specified variable set to the given value, preserving other existing variables.

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ This component **must appear at the top of your SQL file**, before any other dat
1818
An HTTP response can have only a single datatype, and it must be declared in the headers.
1919
So if you have already called the `shell` component, or another traditional HTML component,
2020
you cannot use this component in the same file.
21+
22+
SQLPage can also return JSON or JSON Lines when the incoming request says it prefers them with an HTTP `Accept` header, so the same `/users.sql` page can show a table in a browser but return raw data to `curl -H "Accept: application/json" http://localhost:8080/users.sql`.
23+
24+
Use this component when you want to control the payload or force JSON output even for requests that would normally get HTML.
2125
',
2226
'code',
2327
'0.9.0'
@@ -84,6 +88,12 @@ select * from users;
8488
{"username":"James","userid":1},
8589
{"username":"John","userid":2}
8690
]
91+
```
92+
93+
Clients can also receive JSON or JSON Lines automatically by requesting the same SQL file with an HTTP `Accept` header such as `application/json` or `application/x-ndjson` when the component is omitted, for example:
94+
95+
```
96+
curl -H "Accept: application/json" http://localhost:8080/users.sql
8797
```
8898
'
8999
),

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 =

tests/data_formats/mod.rs

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
use actix_web::{
22
http::{header, StatusCode},
3-
test,
3+
test::{self, TestRequest},
44
};
55
use sqlpage::webserver::http::main_handler;
66

7-
use crate::common::get_request_to;
7+
use crate::common::{get_request_to, make_app_data};
8+
9+
async fn req_with_accept(
10+
path: &str,
11+
accept: &str,
12+
) -> actix_web::Result<actix_web::dev::ServiceResponse> {
13+
let app_data = make_app_data().await;
14+
let req = TestRequest::get()
15+
.uri(path)
16+
.insert_header((header::ACCEPT, accept))
17+
.app_data(app_data)
18+
.to_srv_request();
19+
main_handler(req).await
20+
}
821

922
#[actix_web::test]
1023
async fn test_json_body() -> actix_web::Result<()> {
@@ -18,8 +31,7 @@ async fn test_json_body() -> actix_web::Result<()> {
1831
resp.headers().get(header::CONTENT_TYPE).unwrap(),
1932
"application/json"
2033
);
21-
let body = test::read_body(resp).await;
22-
let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
34+
let body_json: serde_json::Value = test::read_body_json(resp).await;
2335
assert_eq!(
2436
body_json,
2537
serde_json::json!([{"message": "It works!"}, {"cool": "cool"}])
@@ -80,3 +92,87 @@ async fn test_json_columns() {
8092
"the json should have been parsed, not returned as a string, in: {body_html_escaped}"
8193
);
8294
}
95+
96+
#[actix_web::test]
97+
async fn test_accept_json_returns_json_array() -> actix_web::Result<()> {
98+
let resp = req_with_accept(
99+
"/tests/sql_test_files/it_works_simple.sql",
100+
"application/json",
101+
)
102+
.await?;
103+
assert_eq!(resp.status(), StatusCode::OK);
104+
assert_eq!(
105+
resp.headers().get(header::CONTENT_TYPE).unwrap(),
106+
"application/json"
107+
);
108+
let body_json: serde_json::Value = test::read_body_json(resp).await;
109+
assert!(body_json.is_array());
110+
let arr = body_json.as_array().unwrap();
111+
assert!(arr.len() >= 2);
112+
assert_eq!(arr[0]["component"], "shell");
113+
assert_eq!(arr[1]["component"], "text");
114+
Ok(())
115+
}
116+
117+
#[actix_web::test]
118+
async fn test_accept_ndjson_returns_jsonlines() -> actix_web::Result<()> {
119+
let resp = req_with_accept(
120+
"/tests/sql_test_files/it_works_simple.sql",
121+
"application/x-ndjson",
122+
)
123+
.await?;
124+
assert_eq!(resp.status(), StatusCode::OK);
125+
assert_eq!(
126+
resp.headers().get(header::CONTENT_TYPE).unwrap(),
127+
"application/x-ndjson"
128+
);
129+
let body = test::read_body(resp).await;
130+
let body_str = String::from_utf8(body.to_vec()).unwrap();
131+
let lines: Vec<&str> = body_str.trim().lines().collect();
132+
assert!(lines.len() >= 2);
133+
assert_eq!(
134+
serde_json::from_str::<serde_json::Value>(lines[0]).unwrap()["component"],
135+
"shell"
136+
);
137+
assert_eq!(
138+
serde_json::from_str::<serde_json::Value>(lines[1]).unwrap()["component"],
139+
"text"
140+
);
141+
Ok(())
142+
}
143+
144+
#[actix_web::test]
145+
async fn test_accept_html_returns_html() -> actix_web::Result<()> {
146+
let resp = req_with_accept("/tests/sql_test_files/it_works_simple.sql", "text/html").await?;
147+
assert_eq!(resp.status(), StatusCode::OK);
148+
assert_eq!(
149+
resp.headers().get(header::CONTENT_TYPE).unwrap(),
150+
"text/html; charset=utf-8"
151+
);
152+
let body = test::read_body(resp).await;
153+
assert!(body.starts_with(b"<!DOCTYPE html>"));
154+
Ok(())
155+
}
156+
157+
#[actix_web::test]
158+
async fn test_accept_wildcard_returns_html() -> actix_web::Result<()> {
159+
let resp = req_with_accept("/tests/sql_test_files/it_works_simple.sql", "*/*").await?;
160+
assert_eq!(resp.status(), StatusCode::OK);
161+
assert_eq!(
162+
resp.headers().get(header::CONTENT_TYPE).unwrap(),
163+
"text/html; charset=utf-8"
164+
);
165+
Ok(())
166+
}
167+
168+
#[actix_web::test]
169+
async fn test_accept_json_redirect_still_works() -> actix_web::Result<()> {
170+
let resp =
171+
req_with_accept("/tests/server_timing/redirect_test.sql", "application/json").await?;
172+
assert_eq!(resp.status(), StatusCode::FOUND);
173+
assert_eq!(
174+
resp.headers().get(header::LOCATION).unwrap(),
175+
"/destination.sql"
176+
);
177+
Ok(())
178+
}

0 commit comments

Comments
 (0)