From 5800fe9b07f0aefd915dc35885a918fe29cb320c Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 22:18:00 +0100 Subject: [PATCH 1/7] Support JSON responses via Accept header --- .../sqlpage/migrations/73_accept_json.sql | 130 ++++++++++++++ src/render.rs | 46 +++-- src/webserver/http.rs | 46 +++++ .../data_formats/accept_json_headers_test.sql | 4 + .../accept_json_redirect_test.sql | 2 + tests/data_formats/accept_json_test.sql | 5 + tests/data_formats/mod.rs | 159 +++++++++++++++++- 7 files changed, 379 insertions(+), 13 deletions(-) create mode 100644 examples/official-site/sqlpage/migrations/73_accept_json.sql create mode 100644 tests/data_formats/accept_json_headers_test.sql create mode 100644 tests/data_formats/accept_json_redirect_test.sql create mode 100644 tests/data_formats/accept_json_test.sql diff --git a/examples/official-site/sqlpage/migrations/73_accept_json.sql b/examples/official-site/sqlpage/migrations/73_accept_json.sql new file mode 100644 index 00000000..85ff6441 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/73_accept_json.sql @@ -0,0 +1,130 @@ +-- This migration documents the JSON/JSONL response format feature based on HTTP Accept headers + +-- Update the json component description to include information about the Accept header feature +UPDATE component +SET description = 'Converts SQL query results into the JSON machine-readable data format. Ideal to quickly build APIs for interfacing with external systems. + +**JSON** is a widely used data format for programmatic data exchange. +For example, you can use it to integrate with web services written in different languages, +with mobile or desktop apps, or with [custom client-side components](/custom_components.sql) inside your SQLPage app. + +Use it when your application needs to expose data to external systems. +If you only need to render standard web pages, +and do not need other software to access your data, +you can ignore this component. + +This component **must appear at the top of your SQL file**, before any other data has been sent to the browser. +An HTTP response can have only a single datatype, and it must be declared in the headers. +So if you have already called the `shell` component, or another traditional HTML component, +you cannot use this component in the same file. + +### Alternative: Using HTTP Accept Headers + +SQLPage also supports returning JSON or JSON Lines responses based on the HTTP `Accept` header, +without needing to use this component. This is useful when you want the same SQL file to serve +both HTML pages (for browsers) and JSON data (for API clients). + +See [Automatic JSON output based on Accept headers](#example4) for more details. +' +WHERE name = 'json'; + +-- Add a new example for the Accept header feature +INSERT INTO example (component, description) +VALUES ( + 'json', + ' +## Automatic JSON output based on HTTP Accept headers + +SQLPage can automatically return JSON or JSON Lines responses instead of HTML based on the HTTP `Accept` header sent by the client. +This allows the same SQL file to serve both web browsers and API clients. + +### How it works + +When a client sends a request with an `Accept` header, SQLPage checks if the client prefers JSON: + +- `Accept: application/json` → Returns a JSON array of all component data +- `Accept: application/x-ndjson` → Returns JSON Lines (one JSON object per line) +- `Accept: text/html` or `Accept: */*` → Returns the normal HTML page + +All other SQLPage features work exactly the same: +- Header components (`redirect`, `cookie`, `http_header`, `status_code`, `authentication`) work as expected +- SQLPage functions and variables work normally +- The response just skips HTML template rendering + +### Example: A dual-purpose page + +The following SQL file works as both a normal web page and a JSON API: + +```sql +-- Header components work with both HTML and JSON responses +SELECT ''cookie'' AS component, ''last_visit'' AS name, datetime() AS value; +SELECT ''status_code'' AS component, 200 AS status; + +-- These will be rendered as HTML for browsers, or returned as JSON for API clients +SELECT ''text'' AS component, ''Welcome!'' AS contents; +SELECT ''table'' AS component; +SELECT id, name, email FROM users; +``` + +### HTML Response (default, for browsers) + +```html + + + + +``` + +### JSON Response (when Accept: application/json) + +```json +[ + {"component":"text","contents":"Welcome!"}, + {"component":"table"}, + {"id":1,"name":"Alice","email":"alice@example.com"}, + {"id":2,"name":"Bob","email":"bob@example.com"} +] +``` + +### JSON Lines Response (when Accept: application/x-ndjson) + +``` +{"component":"text","contents":"Welcome!"} +{"component":"table"} +{"id":1,"name":"Alice","email":"alice@example.com"} +{"id":2,"name":"Bob","email":"bob@example.com"} +``` + +### Using from JavaScript + +```javascript +// Fetch JSON from any SQLPage endpoint +const response = await fetch("/users.sql", { + headers: { "Accept": "application/json" } +}); +const data = await response.json(); +console.log(data); +``` + +### Using from curl + +```bash +# Get JSON output +curl -H "Accept: application/json" http://localhost:8080/users.sql + +# Get JSON Lines output +curl -H "Accept: application/x-ndjson" http://localhost:8080/users.sql +``` + +### Comparison with the json component + +| Feature | `json` component | Accept header | +|---------|------------------|---------------| +| Use case | Dedicated API endpoint | Dual-purpose page | +| HTML output | Not possible | Default behavior | +| Custom JSON structure | Yes (via `contents`) | No (component data only) | +| Server-sent events | Yes (`type: sse`) | No | +| Requires code changes | Yes | No | +' +); + diff --git a/src/render.rs b/src/render.rs index 337d08f5..d2b69857 100644 --- a/src/render.rs +++ b/src/render.rs @@ -42,7 +42,7 @@ //! [SQLPage documentation](https://sql-page.com/documentation.sql). use crate::templates::SplitTemplate; -use crate::webserver::http::RequestContext; +use crate::webserver::http::{RequestContext, ResponseFormat}; use crate::webserver::response_writer::{AsyncResponseWriter, ResponseWriter}; use crate::webserver::ErrorWithStatus; use crate::AppState; @@ -96,11 +96,13 @@ impl HeaderContext { writer: ResponseWriter, ) -> Self { let mut response = HttpResponseBuilder::new(StatusCode::OK); - response.content_type("text/html; charset=utf-8"); - let tpl = &app_state.config.content_security_policy; - request_context - .content_security_policy - .apply_to_response(tpl, &mut response); + response.content_type(request_context.response_format.content_type()); + if request_context.response_format == ResponseFormat::Html { + let tpl = &app_state.config.content_security_policy; + request_context + .content_security_policy + .apply_to_response(tpl, &mut response); + } Self { app_state, request_context, @@ -391,11 +393,23 @@ impl HeaderContext { async fn start_body(mut self, data: JsonValue) -> anyhow::Result { self.add_server_timing_header(); - let html_renderer = - HtmlRenderContext::new(self.app_state, self.request_context, self.writer, data) - .await - .with_context(|| "Failed to create a render context from the header context.")?; - let renderer = AnyRenderBodyContext::Html(html_renderer); + let renderer = match self.request_context.response_format { + ResponseFormat::Json => AnyRenderBodyContext::Json( + JsonBodyRenderer::new_array_with_first_row(self.writer, &data), + ), + ResponseFormat::JsonLines => AnyRenderBodyContext::Json( + JsonBodyRenderer::new_jsonlines_with_first_row(self.writer, &data), + ), + ResponseFormat::Html => { + let html_renderer = + HtmlRenderContext::new(self.app_state, self.request_context, self.writer, data) + .await + .with_context(|| { + "Failed to create a render context from the header context." + })?; + AnyRenderBodyContext::Html(html_renderer) + } + }; let http_response = self.response; Ok(PageContext::Body { renderer, @@ -516,6 +530,11 @@ impl JsonBodyRenderer { let _ = renderer.write_prefix(); renderer } + pub fn new_array_with_first_row(writer: W, first_row: &JsonValue) -> JsonBodyRenderer { + let mut renderer = Self::new_array(writer); + let _ = renderer.handle_row(first_row); + renderer + } pub fn new_jsonlines(writer: W) -> JsonBodyRenderer { let mut renderer = Self { writer, @@ -527,6 +546,11 @@ impl JsonBodyRenderer { renderer.write_prefix().unwrap(); renderer } + pub fn new_jsonlines_with_first_row(writer: W, first_row: &JsonValue) -> JsonBodyRenderer { + let mut renderer = Self::new_jsonlines(writer); + let _ = renderer.handle_row(first_row); + renderer + } pub fn new_server_sent_events(writer: W) -> JsonBodyRenderer { let mut renderer = Self { writer, diff --git a/src/webserver/http.rs b/src/webserver/http.rs index 6c27b593..68464e2b 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -12,6 +12,7 @@ use crate::webserver::ErrorWithStatus; use crate::{AppConfig, AppState, ParsedSqlFile, DEFAULT_404_FILE}; use actix_web::dev::{fn_service, ServiceFactory, ServiceRequest}; use actix_web::error::{ErrorBadRequest, ErrorInternalServerError}; +use actix_web::http::header::Accept; use actix_web::http::header::{ContentType, Header, HttpDate, IfModifiedSince, LastModified}; use actix_web::http::{header, StatusCode}; use actix_web::web::PayloadConfig; @@ -40,12 +41,52 @@ use std::sync::Arc; use std::time::SystemTime; use tokio::sync::mpsc; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ResponseFormat { + #[default] + Html, + Json, + JsonLines, +} + #[derive(Clone)] pub struct RequestContext { pub is_embedded: bool, pub source_path: PathBuf, pub content_security_policy: ContentSecurityPolicy, pub server_timing: Arc, + pub response_format: ResponseFormat, +} + +impl ResponseFormat { + #[must_use] + pub fn from_accept_header(accept: &Accept) -> Self { + for quality_item in accept.iter() { + let mime = &quality_item.item; + let type_ = mime.type_().as_str(); + let subtype = mime.subtype().as_str(); + + match (type_, subtype) { + ("application", "json") => return Self::Json, + ("application", "x-ndjson" | "jsonlines" | "x-jsonlines") => { + return Self::JsonLines + } + ("text", "x-ndjson" | "jsonlines" | "x-jsonlines") => return Self::JsonLines, + ("text", "html") | ("*", "*") => return Self::Html, + _ => {} + } + } + Self::Html + } + + #[must_use] + pub fn content_type(self) -> &'static str { + match self { + Self::Html => "text/html; charset=utf-8", + Self::Json => "application/json", + Self::JsonLines => "application/x-ndjson", + } + } } async fn stream_response(stream: impl Stream, mut renderer: AnyRenderBodyContext) { @@ -174,6 +215,10 @@ async fn render_sql( .clone() .into_inner(); + let response_format = Accept::parse(srv_req) + .map(|accept| ResponseFormat::from_accept_header(&accept)) + .unwrap_or_default(); + let exec_ctx = extract_request_info(srv_req, Arc::clone(&app_state), server_timing) .await .map_err(|e| anyhow_err_to_actix(e, &app_state))?; @@ -190,6 +235,7 @@ async fn render_sql( source_path, content_security_policy: ContentSecurityPolicy::with_random_nonce(), server_timing: Arc::clone(&request_info.server_timing), + response_format, }; let mut conn = None; let database_entries_stream = diff --git a/tests/data_formats/accept_json_headers_test.sql b/tests/data_formats/accept_json_headers_test.sql new file mode 100644 index 00000000..5e771880 --- /dev/null +++ b/tests/data_formats/accept_json_headers_test.sql @@ -0,0 +1,4 @@ +SELECT 'cookie' as component, 'test_cookie' as name, 'cookie_value' as value; +SELECT 'status_code' as component, 201 as status; +SELECT 'text' as component, 'Created' as contents; + diff --git a/tests/data_formats/accept_json_redirect_test.sql b/tests/data_formats/accept_json_redirect_test.sql new file mode 100644 index 00000000..e4cf36a9 --- /dev/null +++ b/tests/data_formats/accept_json_redirect_test.sql @@ -0,0 +1,2 @@ +SELECT 'redirect' as component, '/target' as link; + diff --git a/tests/data_formats/accept_json_test.sql b/tests/data_formats/accept_json_test.sql new file mode 100644 index 00000000..07df0cd2 --- /dev/null +++ b/tests/data_formats/accept_json_test.sql @@ -0,0 +1,5 @@ +SELECT 'text' as component, 'Hello World' as contents; +SELECT 'table' as component; +SELECT 1 as id, 'Alice' as name; +SELECT 2 as id, 'Bob' as name; + diff --git a/tests/data_formats/mod.rs b/tests/data_formats/mod.rs index 56ac1de8..ad31a350 100644 --- a/tests/data_formats/mod.rs +++ b/tests/data_formats/mod.rs @@ -1,10 +1,10 @@ use actix_web::{ http::{header, StatusCode}, - test, + test::{self, TestRequest}, }; use sqlpage::webserver::http::main_handler; -use crate::common::get_request_to; +use crate::common::{get_request_to, make_app_data}; #[actix_web::test] async fn test_json_body() -> actix_web::Result<()> { @@ -80,3 +80,158 @@ async fn test_json_columns() { "the json should have been parsed, not returned as a string, in: {body_html_escaped}" ); } + +#[actix_web::test] +async fn test_accept_json_returns_json_array() -> actix_web::Result<()> { + let app_data = make_app_data().await; + let req = TestRequest::get() + .uri("/tests/data_formats/accept_json_test.sql") + .insert_header((header::ACCEPT, "application/json")) + .app_data(app_data) + .to_srv_request(); + let resp = main_handler(req).await?; + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(header::CONTENT_TYPE).unwrap(), + "application/json" + ); + let body = test::read_body(resp).await; + let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(body_json.is_array(), "response should be a JSON array"); + let arr = body_json.as_array().unwrap(); + assert_eq!(arr.len(), 4); + assert_eq!(arr[0]["component"], "text"); + assert_eq!(arr[0]["contents"], "Hello World"); + assert_eq!(arr[1]["component"], "table"); + assert_eq!(arr[2]["id"], 1); + assert_eq!(arr[2]["name"], "Alice"); + assert_eq!(arr[3]["id"], 2); + assert_eq!(arr[3]["name"], "Bob"); + Ok(()) +} + +#[actix_web::test] +async fn test_accept_ndjson_returns_jsonlines() -> actix_web::Result<()> { + let app_data = make_app_data().await; + let req = TestRequest::get() + .uri("/tests/data_formats/accept_json_test.sql") + .insert_header((header::ACCEPT, "application/x-ndjson")) + .app_data(app_data) + .to_srv_request(); + let resp = main_handler(req).await?; + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(header::CONTENT_TYPE).unwrap(), + "application/x-ndjson" + ); + let body = test::read_body(resp).await; + let body_str = String::from_utf8(body.to_vec()).unwrap(); + let lines: Vec<&str> = body_str.trim().lines().collect(); + assert_eq!(lines.len(), 4); + + let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(first["component"], "text"); + assert_eq!(first["contents"], "Hello World"); + + let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap(); + assert_eq!(second["component"], "table"); + + let third: serde_json::Value = serde_json::from_str(lines[2]).unwrap(); + assert_eq!(third["id"], 1); + assert_eq!(third["name"], "Alice"); + + let fourth: serde_json::Value = serde_json::from_str(lines[3]).unwrap(); + assert_eq!(fourth["id"], 2); + assert_eq!(fourth["name"], "Bob"); + Ok(()) +} + +#[actix_web::test] +async fn test_accept_html_returns_html() -> actix_web::Result<()> { + let app_data = make_app_data().await; + let req = TestRequest::get() + .uri("/tests/data_formats/accept_json_test.sql") + .insert_header((header::ACCEPT, "text/html")) + .app_data(app_data) + .to_srv_request(); + let resp = main_handler(req).await?; + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(header::CONTENT_TYPE).unwrap(), + "text/html; charset=utf-8" + ); + let body = test::read_body(resp).await; + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!(body_str.contains(" actix_web::Result<()> { + let app_data = make_app_data().await; + let req = TestRequest::get() + .uri("/tests/data_formats/accept_json_test.sql") + .insert_header((header::ACCEPT, "*/*")) + .app_data(app_data) + .to_srv_request(); + let resp = main_handler(req).await?; + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(header::CONTENT_TYPE).unwrap(), + "text/html; charset=utf-8" + ); + Ok(()) +} + +#[actix_web::test] +async fn test_accept_json_redirect_still_works() -> actix_web::Result<()> { + let app_data = make_app_data().await; + let req = TestRequest::get() + .uri("/tests/data_formats/accept_json_redirect_test.sql") + .insert_header((header::ACCEPT, "application/json")) + .app_data(app_data) + .to_srv_request(); + let resp = main_handler(req).await?; + + assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/target"); + Ok(()) +} + +#[actix_web::test] +async fn test_accept_json_headers_still_work() -> actix_web::Result<()> { + let app_data = make_app_data().await; + let req = TestRequest::get() + .uri("/tests/data_formats/accept_json_headers_test.sql") + .insert_header((header::ACCEPT, "application/json")) + .app_data(app_data) + .to_srv_request(); + let resp = main_handler(req).await?; + + assert_eq!(resp.status(), StatusCode::CREATED); + let set_cookie = resp.headers().get(header::SET_COOKIE).unwrap(); + assert!( + set_cookie + .to_str() + .unwrap() + .contains("test_cookie=cookie_value"), + "Cookie should be set: {:?}", + set_cookie + ); + let body = test::read_body(resp).await; + let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(body_json.is_array()); + let arr = body_json.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["component"], "text"); + assert_eq!(arr[0]["contents"], "Created"); + Ok(()) +} From 609168086948cfafc39898ec38969037e944838b Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 22:27:36 +0100 Subject: [PATCH 2/7] no update in migrations --- AGENTS.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b7c66435..ababedd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,15 +28,19 @@ docker compose up -d mssql # or postgres or mysql DATABASE_URL='mssql://root:Password123!@localhost/sqlpage' cargo test # all dbms use the same user:pass and db name ``` +### Documentation + +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. + +official documentation website sql tables: + - `component(name,description,icon,introduced_in_version)` -- icon name from tabler icon + - `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 + - `example(component REFERENCES component(name), description, properties JSON)` + #### Project Conventions - Components: defined in `./sqlpage/templates/*.handlebars` - Functions: `src/webserver/database/sqlpage_functions/functions.rs` registered with `make_function!`. -- Components and functions are documented in [official website](./examples/official-site/sqlpage/migrations/); one migration per component and per function. - - tables - - `component(name,description,icon,introduced_in_version)` -- icon name from tabler icon - - `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 - - `example(component REFERENCES component(name), description, properties JSON)` - [Configuration](./configuration.md): see [AppConfig](./src/app_config.rs) - Routing: file-based in `src/webserver/routing.rs`; not found handled via `src/default_404.sql`. - Follow patterns from similar modules before introducing new abstractions. From d832027e955794b16f2a30404c3dbac9ad8c4a6f Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 22:43:26 +0100 Subject: [PATCH 3/7] 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. --- .../sqlpage/migrations/01_documentation.sql | 2 +- .../sqlpage/migrations/11_json.sql | 10 ++ .../sqlpage/migrations/73_accept_json.sql | 130 ------------------ 3 files changed, 11 insertions(+), 131 deletions(-) delete mode 100644 examples/official-site/sqlpage/migrations/73_accept_json.sql diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index 17bafdee..96eb6445 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -893,7 +893,7 @@ Numbers can be displayed }, { "feature": "APIs", - "description": "Allows building JSON REST APIs and integrating with external APIs.", + "description": "Allows building JSON REST APIs, integrates with external services, and can serve the same page as HTML in a browser or as JSON to tools like `curl -H \"Accept: application/json\" http://localhost:8080/users.sql`.", "benefits": "Enables automation and integration with other platforms, facilitates data exchange." }, { diff --git a/examples/official-site/sqlpage/migrations/11_json.sql b/examples/official-site/sqlpage/migrations/11_json.sql index 65c3a8a1..8384ab58 100644 --- a/examples/official-site/sqlpage/migrations/11_json.sql +++ b/examples/official-site/sqlpage/migrations/11_json.sql @@ -18,6 +18,10 @@ This component **must appear at the top of your SQL file**, before any other dat An HTTP response can have only a single datatype, and it must be declared in the headers. So if you have already called the `shell` component, or another traditional HTML component, you cannot use this component in the same file. + +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`. + +Use this component when you want to control the payload or force JSON output even for requests that would normally get HTML. ', 'code', '0.9.0' @@ -84,6 +88,12 @@ select * from users; {"username":"James","userid":1}, {"username":"John","userid":2} ] +``` + +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: + +``` +curl -H "Accept: application/json" http://localhost:8080/users.sql ``` ' ), diff --git a/examples/official-site/sqlpage/migrations/73_accept_json.sql b/examples/official-site/sqlpage/migrations/73_accept_json.sql deleted file mode 100644 index 85ff6441..00000000 --- a/examples/official-site/sqlpage/migrations/73_accept_json.sql +++ /dev/null @@ -1,130 +0,0 @@ --- This migration documents the JSON/JSONL response format feature based on HTTP Accept headers - --- Update the json component description to include information about the Accept header feature -UPDATE component -SET description = 'Converts SQL query results into the JSON machine-readable data format. Ideal to quickly build APIs for interfacing with external systems. - -**JSON** is a widely used data format for programmatic data exchange. -For example, you can use it to integrate with web services written in different languages, -with mobile or desktop apps, or with [custom client-side components](/custom_components.sql) inside your SQLPage app. - -Use it when your application needs to expose data to external systems. -If you only need to render standard web pages, -and do not need other software to access your data, -you can ignore this component. - -This component **must appear at the top of your SQL file**, before any other data has been sent to the browser. -An HTTP response can have only a single datatype, and it must be declared in the headers. -So if you have already called the `shell` component, or another traditional HTML component, -you cannot use this component in the same file. - -### Alternative: Using HTTP Accept Headers - -SQLPage also supports returning JSON or JSON Lines responses based on the HTTP `Accept` header, -without needing to use this component. This is useful when you want the same SQL file to serve -both HTML pages (for browsers) and JSON data (for API clients). - -See [Automatic JSON output based on Accept headers](#example4) for more details. -' -WHERE name = 'json'; - --- Add a new example for the Accept header feature -INSERT INTO example (component, description) -VALUES ( - 'json', - ' -## Automatic JSON output based on HTTP Accept headers - -SQLPage can automatically return JSON or JSON Lines responses instead of HTML based on the HTTP `Accept` header sent by the client. -This allows the same SQL file to serve both web browsers and API clients. - -### How it works - -When a client sends a request with an `Accept` header, SQLPage checks if the client prefers JSON: - -- `Accept: application/json` → Returns a JSON array of all component data -- `Accept: application/x-ndjson` → Returns JSON Lines (one JSON object per line) -- `Accept: text/html` or `Accept: */*` → Returns the normal HTML page - -All other SQLPage features work exactly the same: -- Header components (`redirect`, `cookie`, `http_header`, `status_code`, `authentication`) work as expected -- SQLPage functions and variables work normally -- The response just skips HTML template rendering - -### Example: A dual-purpose page - -The following SQL file works as both a normal web page and a JSON API: - -```sql --- Header components work with both HTML and JSON responses -SELECT ''cookie'' AS component, ''last_visit'' AS name, datetime() AS value; -SELECT ''status_code'' AS component, 200 AS status; - --- These will be rendered as HTML for browsers, or returned as JSON for API clients -SELECT ''text'' AS component, ''Welcome!'' AS contents; -SELECT ''table'' AS component; -SELECT id, name, email FROM users; -``` - -### HTML Response (default, for browsers) - -```html - - - - -``` - -### JSON Response (when Accept: application/json) - -```json -[ - {"component":"text","contents":"Welcome!"}, - {"component":"table"}, - {"id":1,"name":"Alice","email":"alice@example.com"}, - {"id":2,"name":"Bob","email":"bob@example.com"} -] -``` - -### JSON Lines Response (when Accept: application/x-ndjson) - -``` -{"component":"text","contents":"Welcome!"} -{"component":"table"} -{"id":1,"name":"Alice","email":"alice@example.com"} -{"id":2,"name":"Bob","email":"bob@example.com"} -``` - -### Using from JavaScript - -```javascript -// Fetch JSON from any SQLPage endpoint -const response = await fetch("/users.sql", { - headers: { "Accept": "application/json" } -}); -const data = await response.json(); -console.log(data); -``` - -### Using from curl - -```bash -# Get JSON output -curl -H "Accept: application/json" http://localhost:8080/users.sql - -# Get JSON Lines output -curl -H "Accept: application/x-ndjson" http://localhost:8080/users.sql -``` - -### Comparison with the json component - -| Feature | `json` component | Accept header | -|---------|------------------|---------------| -| Use case | Dedicated API endpoint | Dual-purpose page | -| HTML output | Not possible | Default behavior | -| Custom JSON structure | Yes (via `contents`) | No (component data only) | -| Server-sent events | Yes (`type: sse`) | No | -| Requires code changes | Yes | No | -' -); - From 630321fd867b63b95352ff409a9164a221b5cf7e Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 22:47:07 +0100 Subject: [PATCH 4/7] revert stupid docs example change stupid bot --- examples/official-site/sqlpage/migrations/01_documentation.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index 96eb6445..17bafdee 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -893,7 +893,7 @@ Numbers can be displayed }, { "feature": "APIs", - "description": "Allows building JSON REST APIs, integrates with external services, and can serve the same page as HTML in a browser or as JSON to tools like `curl -H \"Accept: application/json\" http://localhost:8080/users.sql`.", + "description": "Allows building JSON REST APIs and integrating with external APIs.", "benefits": "Enables automation and integration with other platforms, facilitates data exchange." }, { From f8ec60f35ccf38e813dd235d1e2b5cc1309159a3 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 22:53:35 +0100 Subject: [PATCH 5/7] simplify tests --- .../data_formats/accept_json_headers_test.sql | 4 - .../accept_json_redirect_test.sql | 2 - tests/data_formats/accept_json_test.sql | 5 - tests/data_formats/mod.rs | 145 ++++++------------ 4 files changed, 44 insertions(+), 112 deletions(-) delete mode 100644 tests/data_formats/accept_json_headers_test.sql delete mode 100644 tests/data_formats/accept_json_redirect_test.sql delete mode 100644 tests/data_formats/accept_json_test.sql diff --git a/tests/data_formats/accept_json_headers_test.sql b/tests/data_formats/accept_json_headers_test.sql deleted file mode 100644 index 5e771880..00000000 --- a/tests/data_formats/accept_json_headers_test.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT 'cookie' as component, 'test_cookie' as name, 'cookie_value' as value; -SELECT 'status_code' as component, 201 as status; -SELECT 'text' as component, 'Created' as contents; - diff --git a/tests/data_formats/accept_json_redirect_test.sql b/tests/data_formats/accept_json_redirect_test.sql deleted file mode 100644 index e4cf36a9..00000000 --- a/tests/data_formats/accept_json_redirect_test.sql +++ /dev/null @@ -1,2 +0,0 @@ -SELECT 'redirect' as component, '/target' as link; - diff --git a/tests/data_formats/accept_json_test.sql b/tests/data_formats/accept_json_test.sql deleted file mode 100644 index 07df0cd2..00000000 --- a/tests/data_formats/accept_json_test.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT 'text' as component, 'Hello World' as contents; -SELECT 'table' as component; -SELECT 1 as id, 'Alice' as name; -SELECT 2 as id, 'Bob' as name; - diff --git a/tests/data_formats/mod.rs b/tests/data_formats/mod.rs index ad31a350..9a84cd38 100644 --- a/tests/data_formats/mod.rs +++ b/tests/data_formats/mod.rs @@ -6,6 +6,19 @@ use sqlpage::webserver::http::main_handler; use crate::common::{get_request_to, make_app_data}; +async fn req_with_accept( + path: &str, + accept: &str, +) -> actix_web::Result { + let app_data = make_app_data().await; + let req = TestRequest::get() + .uri(path) + .insert_header((header::ACCEPT, accept)) + .app_data(app_data) + .to_srv_request(); + main_handler(req).await +} + #[actix_web::test] async fn test_json_body() -> actix_web::Result<()> { let req = get_request_to("/tests/data_formats/json_data.sql") @@ -83,14 +96,11 @@ async fn test_json_columns() { #[actix_web::test] async fn test_accept_json_returns_json_array() -> actix_web::Result<()> { - let app_data = make_app_data().await; - let req = TestRequest::get() - .uri("/tests/data_formats/accept_json_test.sql") - .insert_header((header::ACCEPT, "application/json")) - .app_data(app_data) - .to_srv_request(); - let resp = main_handler(req).await?; - + let resp = req_with_accept( + "/tests/sql_test_files/it_works_simple.sql", + "application/json", + ) + .await?; assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get(header::CONTENT_TYPE).unwrap(), @@ -98,29 +108,21 @@ async fn test_accept_json_returns_json_array() -> actix_web::Result<()> { ); let body = test::read_body(resp).await; let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert!(body_json.is_array(), "response should be a JSON array"); + assert!(body_json.is_array()); let arr = body_json.as_array().unwrap(); - assert_eq!(arr.len(), 4); - assert_eq!(arr[0]["component"], "text"); - assert_eq!(arr[0]["contents"], "Hello World"); - assert_eq!(arr[1]["component"], "table"); - assert_eq!(arr[2]["id"], 1); - assert_eq!(arr[2]["name"], "Alice"); - assert_eq!(arr[3]["id"], 2); - assert_eq!(arr[3]["name"], "Bob"); + assert!(arr.len() >= 2); + assert_eq!(arr[0]["component"], "shell"); + assert_eq!(arr[1]["component"], "text"); Ok(()) } #[actix_web::test] async fn test_accept_ndjson_returns_jsonlines() -> actix_web::Result<()> { - let app_data = make_app_data().await; - let req = TestRequest::get() - .uri("/tests/data_formats/accept_json_test.sql") - .insert_header((header::ACCEPT, "application/x-ndjson")) - .app_data(app_data) - .to_srv_request(); - let resp = main_handler(req).await?; - + let resp = req_with_accept( + "/tests/sql_test_files/it_works_simple.sql", + "application/x-ndjson", + ) + .await?; assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get(header::CONTENT_TYPE).unwrap(), @@ -129,60 +131,34 @@ async fn test_accept_ndjson_returns_jsonlines() -> actix_web::Result<()> { let body = test::read_body(resp).await; let body_str = String::from_utf8(body.to_vec()).unwrap(); let lines: Vec<&str> = body_str.trim().lines().collect(); - assert_eq!(lines.len(), 4); - - let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); - assert_eq!(first["component"], "text"); - assert_eq!(first["contents"], "Hello World"); - - let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap(); - assert_eq!(second["component"], "table"); - - let third: serde_json::Value = serde_json::from_str(lines[2]).unwrap(); - assert_eq!(third["id"], 1); - assert_eq!(third["name"], "Alice"); - - let fourth: serde_json::Value = serde_json::from_str(lines[3]).unwrap(); - assert_eq!(fourth["id"], 2); - assert_eq!(fourth["name"], "Bob"); + assert!(lines.len() >= 2); + assert_eq!( + serde_json::from_str::(lines[0]).unwrap()["component"], + "shell" + ); + assert_eq!( + serde_json::from_str::(lines[1]).unwrap()["component"], + "text" + ); Ok(()) } #[actix_web::test] async fn test_accept_html_returns_html() -> actix_web::Result<()> { - let app_data = make_app_data().await; - let req = TestRequest::get() - .uri("/tests/data_formats/accept_json_test.sql") - .insert_header((header::ACCEPT, "text/html")) - .app_data(app_data) - .to_srv_request(); - let resp = main_handler(req).await?; - + let resp = req_with_accept("/tests/sql_test_files/it_works_simple.sql", "text/html").await?; assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get(header::CONTENT_TYPE).unwrap(), "text/html; charset=utf-8" ); let body = test::read_body(resp).await; - let body_str = String::from_utf8(body.to_vec()).unwrap(); - assert!(body_str.contains("")); Ok(()) } #[actix_web::test] async fn test_accept_wildcard_returns_html() -> actix_web::Result<()> { - let app_data = make_app_data().await; - let req = TestRequest::get() - .uri("/tests/data_formats/accept_json_test.sql") - .insert_header((header::ACCEPT, "*/*")) - .app_data(app_data) - .to_srv_request(); - let resp = main_handler(req).await?; - + let resp = req_with_accept("/tests/sql_test_files/it_works_simple.sql", "*/*").await?; assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get(header::CONTENT_TYPE).unwrap(), @@ -193,45 +169,12 @@ async fn test_accept_wildcard_returns_html() -> actix_web::Result<()> { #[actix_web::test] async fn test_accept_json_redirect_still_works() -> actix_web::Result<()> { - let app_data = make_app_data().await; - let req = TestRequest::get() - .uri("/tests/data_formats/accept_json_redirect_test.sql") - .insert_header((header::ACCEPT, "application/json")) - .app_data(app_data) - .to_srv_request(); - let resp = main_handler(req).await?; - + let resp = + req_with_accept("/tests/server_timing/redirect_test.sql", "application/json").await?; assert_eq!(resp.status(), StatusCode::FOUND); - assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/target"); - Ok(()) -} - -#[actix_web::test] -async fn test_accept_json_headers_still_work() -> actix_web::Result<()> { - let app_data = make_app_data().await; - let req = TestRequest::get() - .uri("/tests/data_formats/accept_json_headers_test.sql") - .insert_header((header::ACCEPT, "application/json")) - .app_data(app_data) - .to_srv_request(); - let resp = main_handler(req).await?; - - assert_eq!(resp.status(), StatusCode::CREATED); - let set_cookie = resp.headers().get(header::SET_COOKIE).unwrap(); - assert!( - set_cookie - .to_str() - .unwrap() - .contains("test_cookie=cookie_value"), - "Cookie should be set: {:?}", - set_cookie + assert_eq!( + resp.headers().get(header::LOCATION).unwrap(), + "/destination.sql" ); - let body = test::read_body(resp).await; - let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert!(body_json.is_array()); - let arr = body_json.as_array().unwrap(); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["component"], "text"); - assert_eq!(arr[0]["contents"], "Created"); Ok(()) } From 159387a1daeae654990b1a021a97845b2d80d22c Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 23:00:37 +0100 Subject: [PATCH 6/7] avoid string then json in tests, parse as json directly --- tests/data_formats/mod.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/data_formats/mod.rs b/tests/data_formats/mod.rs index 9a84cd38..1416f0f0 100644 --- a/tests/data_formats/mod.rs +++ b/tests/data_formats/mod.rs @@ -31,8 +31,7 @@ async fn test_json_body() -> actix_web::Result<()> { resp.headers().get(header::CONTENT_TYPE).unwrap(), "application/json" ); - let body = test::read_body(resp).await; - let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let body_json: serde_json::Value = test::read_body_json(resp).await; assert_eq!( body_json, serde_json::json!([{"message": "It works!"}, {"cool": "cool"}]) @@ -106,8 +105,7 @@ async fn test_accept_json_returns_json_array() -> actix_web::Result<()> { resp.headers().get(header::CONTENT_TYPE).unwrap(), "application/json" ); - let body = test::read_body(resp).await; - let body_json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let body_json: serde_json::Value = test::read_body_json(resp).await; assert!(body_json.is_array()); let arr = body_json.as_array().unwrap(); assert!(arr.len() >= 2); From b90c6e668201de0856f16ebaf23edbcb626240d8 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 23:16:37 +0100 Subject: [PATCH 7/7] changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c4353a5..7ea53068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # CHANGELOG.md ## 0.40.0 (unreleased) + - 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: + - `curl -H "Accept: application/json" http://example.com/page.sql`: returns a json array + - `curl -H "Accept: application/x-ndjson" http://example.com/page.sql`: returns one json object per line. - 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. - **New Function**: `sqlpage.set_variable(name, value)` - Returns a URL with the specified variable set to the given value, preserving other existing variables.