From b45d7bc52514aaede51178f8742e96138a1175ff Mon Sep 17 00:00:00 2001 From: lovasoa Date: Sat, 22 Jul 2023 15:08:08 +0200 Subject: [PATCH] add support for building JSON APIs in SQLPage --- CHANGELOG.md | 3 + Cargo.lock | 6 +- Cargo.toml | 2 +- README.md | 1 + .../sqlpage/migrations/11_json.sql | 66 +++++++++++++++++++ .../api.sql | 7 ++ .../my_react_component.js | 13 +++- .../sqlpage/migrations/0001_clicks.sql | 3 + src/render.rs | 29 ++++++-- src/webserver/http.rs | 5 +- 10 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 examples/official-site/sqlpage/migrations/11_json.sql create mode 100644 examples/using react and other custom scripts and styles/api.sql create mode 100644 examples/using react and other custom scripts and styles/sqlpage/migrations/0001_clicks.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 81c81d5b..66f73faa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## unreleased + - Added a new `json` component, which allows building a JSON API entirely in SQL with SQLPage ! + Now creating an api over your database is as simple as `SELECT 'json' AS component, JSON_OBJECT('hello', 'world') AS contents`. + ## 0.8.0 (2023-07-17) - Added a new [`sqlite_extensions` configuration parameter](./configuration.md) to load SQLite extensions. This allows many interesting use cases, such as diff --git a/Cargo.lock b/Cargo.lock index b7f310ce..b195473b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -721,9 +721,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "either" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" [[package]] name = "encoding_rs" @@ -2153,7 +2153,7 @@ dependencies = [ [[package]] name = "sqlpage" -version = "0.8.0" +version = "0.9.0" dependencies = [ "actix-web", "actix-web-httpauth", diff --git a/Cargo.toml b/Cargo.toml index c9c5d74f..353c0f7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlpage" -version = "0.8.0" +version = "0.9.0" edition = "2021" description = "A SQL-only web application framework. Takes .sql files and formats the query result using pre-made configurable professional-looking components." keywords = ["web", "sql", "framework"] diff --git a/README.md b/README.md index a1ffa1f6..8b74f6f9 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ An alternative for Mac OS users is to use [SQLPage's homebrew package](https://f - [Master-Detail Form](./examples/master-detail-form/): shows how to implement a simple set of forms to insert data into database tables that have a one-to-many relationship. - [SQLPage's own official website and documentation](./examples/official-site/): The SQL source code for the project's official site, https://sql.ophir.dev - [User Management](./examples/user-authentication/): An authentication demo with user registration, log in, log out, and confidential pages. Uses PostgreSQL. +- [Making a JSON API and integrating React components in the frontend](./examples/using%20react%20and%20other%20custom%20scripts%20and%20styles/): Shows how to integrate a react component in a SQLPage website, and how to easily build a REST API with SQLPage. ## Configuration diff --git a/examples/official-site/sqlpage/migrations/11_json.sql b/examples/official-site/sqlpage/migrations/11_json.sql new file mode 100644 index 00000000..587d71df --- /dev/null +++ b/examples/official-site/sqlpage/migrations/11_json.sql @@ -0,0 +1,66 @@ +INSERT INTO component (name, description, icon, introduced_in_version) +VALUES ( + 'json', + 'For advanced users, allows you to easily build an API over your database. + The json component responds to the current HTTP request with a JSON object. + This component must appear at the top of your SQL file, before any other data has been sent to the browser.', + 'code', + '0.9.0' + ); +-- Insert the parameters for the http_header component into the parameter table +INSERT INTO parameter ( + component, + name, + description, + type, + top_level, + optional + ) +VALUES ( + 'json', + 'contents', + 'The JSON payload to send. You should use your database''s built-in json functions to build the value to enter here.', + 'TEXT', + TRUE, + FALSE + ); +-- Insert an example usage of the http_header component into the example table +INSERT INTO example (component, description) +VALUES ( + 'json', + ' +Creates an API endpoint that will allow developers to easily query a list of users stored in your database. + +You should use the json functions provided by your database to form the value you pass to the `contents` property. +To build a json array out of rows from the database, you can use: + - `json_group_array()` in SQLite, + - `json_agg()` in Postgres, or + - `JSON_ARRAYAGG()` in MySQL. + + +```sql +SELECT ''json'' AS component, + JSON_OBJECT( + ''users'', ( + SELECT JSON_GROUP_ARRAY( + JSON_OBJECT( + ''username'', username, + ''userid'', id + ) + ) FROM users + ) + ) AS contents; +``` + +This will return a JSON response that looks like this: + +```json +{ + "users" : [ + { "username":"James", "userid":1 } + ] +} +``` + +' + ); \ No newline at end of file diff --git a/examples/using react and other custom scripts and styles/api.sql b/examples/using react and other custom scripts and styles/api.sql new file mode 100644 index 00000000..2fa81330 --- /dev/null +++ b/examples/using react and other custom scripts and styles/api.sql @@ -0,0 +1,7 @@ +-- Adds a new entry in the clicks table +INSERT INTO clicks(click_time) VALUES (datetime('now')); + +SELECT 'json' AS component, + JSON_OBJECT( + 'total_clicks', (SELECT count(*) FROM clicks) + ) AS contents; \ No newline at end of file diff --git a/examples/using react and other custom scripts and styles/my_react_component.js b/examples/using react and other custom scripts and styles/my_react_component.js index 115603f3..b65e4988 100644 --- a/examples/using react and other custom scripts and styles/my_react_component.js +++ b/examples/using react and other custom scripts and styles/my_react_component.js @@ -5,8 +5,17 @@ function MyComponent({ greeting_name }) { const [count, setCount] = React.useState(0); return React.createElement( 'button', - { onClick: () => setCount(count + 1), className: 'btn btn-primary' }, - `Hello ${greeting_name}, you clicked me ${count} times!` + { + onClick: async () => { + const r = await fetch('/api.sql'); + const { total_clicks } = await r.json(); + setCount(total_clicks) + }, + className: 'btn btn-primary' + }, + count == 0 + ? `Hello, ${greeting_name}. Click me !` + : `You clicked me ${count} times!` ); } diff --git a/examples/using react and other custom scripts and styles/sqlpage/migrations/0001_clicks.sql b/examples/using react and other custom scripts and styles/sqlpage/migrations/0001_clicks.sql new file mode 100644 index 00000000..72ef2817 --- /dev/null +++ b/examples/using react and other custom scripts and styles/sqlpage/migrations/0001_clicks.sql @@ -0,0 +1,3 @@ +CREATE TABLE clicks( + click_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/src/render.rs b/src/render.rs index 7f445846..0f6a36e0 100644 --- a/src/render.rs +++ b/src/render.rs @@ -20,8 +20,8 @@ pub enum PageContext { renderer: RenderContext, }, - /// The headers have been set, but no response body should be sent - Close(HeaderContext), + /// The response is ready, and should be sent as is. No further statements should be executed + Close(HttpResponse), } /// Handles the first SQL statements, before the headers have been sent to @@ -49,6 +49,7 @@ impl HeaderContext { Some("status_code") => self.status_code(&data).map(PageContext::Header), Some("http_header") => self.add_http_header(&data).map(PageContext::Header), Some("redirect") => self.redirect(&data).map(PageContext::Close), + Some("json") => self.json(&data).map(PageContext::Close), Some("cookie") => self.add_cookie(&data).map(PageContext::Header), Some("authentication") => self.authentication(&data), _ => self.start_body(data).await, @@ -131,13 +132,29 @@ impl HeaderContext { Ok(self) } - fn redirect(mut self, data: &JsonValue) -> anyhow::Result { + fn redirect(mut self, data: &JsonValue) -> anyhow::Result { self.response.status(StatusCode::FOUND); self.has_status = true; let link = get_object_str(data, "link") .with_context(|| "The redirect component requires a 'link' property")?; self.response.insert_header((header::LOCATION, link)); - Ok(self) + let response = self.response.body(()); + Ok(response) + } + + /// Answers to the HTTP request with a single json object + fn json(mut self, data: &JsonValue) -> anyhow::Result { + let contents = data + .get("contents") + .with_context(|| "Missing 'contents' property for the json component")?; + let json_response = if let Some(s) = contents.as_str() { + s.as_bytes().to_owned() + } else { + serde_json::to_vec(contents)? + }; + self.response + .insert_header((header::CONTENT_TYPE, "application/json")); + Ok(self.response.body(json_response)) } fn authentication(mut self, data: &JsonValue) -> anyhow::Result> { @@ -171,7 +188,9 @@ impl HeaderContext { .insert_header((header::WWW_AUTHENTICATE, "Basic realm=\"Auth required\"")); self.has_status = true; } - Ok(PageContext::Close(self)) + // Set an empty response body + let http_response = self.response.body(()); + Ok(PageContext::Close(http_response)) } async fn start_body(self, data: JsonValue) -> anyhow::Result> { diff --git a/src/webserver/http.rs b/src/webserver/http.rs index ece4922d..271739bc 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -170,9 +170,8 @@ async fn build_response_header_and_stream>( database_entries_stream: stream, }); } - PageContext::Close(h) => { - head_context = h; - break; + PageContext::Close(http_response) => { + return Ok(ResponseWithWriter::FinishedResponse { http_response }) } }, DbItem::FinishedQuery => log::debug!("finished query"),