sql2api 0.1.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sql2api-0.1.0/sql2api.egg-info → sql2api-0.3.0}/PKG-INFO +70 -6
- {sql2api-0.1.0 → sql2api-0.3.0}/README.md +68 -5
- {sql2api-0.1.0 → sql2api-0.3.0}/pyproject.toml +1 -1
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/__init__.py +1 -1
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/app.py +112 -15
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/cli.py +6 -1
- sql2api-0.3.0/sql2api/config.py +156 -0
- sql2api-0.3.0/sql2api/cors.py +51 -0
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/engine.py +6 -3
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/openapi.py +100 -8
- sql2api-0.3.0/sql2api/params.py +275 -0
- sql2api-0.3.0/sql2api/pool.py +146 -0
- sql2api-0.3.0/sql2api/ratelimit.py +56 -0
- sql2api-0.3.0/sql2api/runners.py +354 -0
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/sqltools.py +5 -31
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/store.py +18 -0
- {sql2api-0.1.0 → sql2api-0.3.0/sql2api.egg-info}/PKG-INFO +70 -6
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api.egg-info/SOURCES.txt +7 -0
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api.egg-info/requires.txt +1 -0
- sql2api-0.3.0/tests/test_cors_and_rate_limit.py +288 -0
- {sql2api-0.1.0 → sql2api-0.3.0}/tests/test_integration.py +75 -1
- sql2api-0.3.0/tests/test_params.py +134 -0
- sql2api-0.3.0/tests/test_release_check.py +78 -0
- sql2api-0.3.0/tests/test_sql2api.py +1048 -0
- sql2api-0.1.0/sql2api/config.py +0 -62
- sql2api-0.1.0/sql2api/runners.py +0 -142
- sql2api-0.1.0/tests/test_sql2api.py +0 -483
- {sql2api-0.1.0 → sql2api-0.3.0}/LICENSE +0 -0
- {sql2api-0.1.0 → sql2api-0.3.0}/setup.cfg +0 -0
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/__main__.py +0 -0
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/errors.py +0 -0
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/formats.py +0 -0
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/lib/h2-2.2.224.jar +0 -0
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api.egg-info/dependency_links.txt +0 -0
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api.egg-info/entry_points.txt +0 -0
- {sql2api-0.1.0 → sql2api-0.3.0}/sql2api.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql2api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Turn SQL into a REST API: run queries against MySQL, PostgreSQL, ClickHouse, SQLite or H2 over HTTP and get JSON, CSV, XML, YAML or XLSX back.
|
|
5
5
|
Author-email: Anantha Raju C <arcswdev@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -37,6 +37,7 @@ Requires-Dist: gunicorn>=21; extra == "server"
|
|
|
37
37
|
Provides-Extra: dev
|
|
38
38
|
Requires-Dist: sql2api[all]; extra == "dev"
|
|
39
39
|
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
40
|
+
Requires-Dist: openapi-spec-validator>=0.7; extra == "dev"
|
|
40
41
|
Dynamic: license-file
|
|
41
42
|
|
|
42
43
|
# SQL2API
|
|
@@ -74,7 +75,15 @@ film_id,title,rating,length
|
|
|
74
75
|
(or `?version=1`). Each run is recorded in the query's execution history.
|
|
75
76
|
- **Bound parameters** - write `WHERE id = :id` and the value is sent to the database separately from the SQL, so it
|
|
76
77
|
cannot inject anything. Declare types (`{"id": "int"}`) and query-string values are converted for you.
|
|
78
|
+
- **Parameter rules** - saved queries can declare defaults, optional parameters, allowed values, numeric ranges and
|
|
79
|
+
text patterns. Bad input is rejected with a field-by-field `400` before it reaches the database.
|
|
80
|
+
- **A live catalogue of your endpoints** - `/docs` lists every saved query as its own endpoint with its parameters and
|
|
81
|
+
rules. The SQL itself is never shown, and with an API key set the list is hidden from anonymous readers.
|
|
77
82
|
- **Pagination** - `?page=2&page_size=50`, with `X-Has-More` telling you whether another page exists.
|
|
83
|
+
- **Connection pooling** - MySQL, PostgreSQL, ClickHouse and H2 connections are reused between requests instead of
|
|
84
|
+
opened for each one (about 30x lower per-request overhead on MySQL and H2 against a local server; more over a network).
|
|
85
|
+
- **Query time limit** - runaway queries are cancelled on the database (30 s by default, `?timeout=` per request) so
|
|
86
|
+
they cannot tie up the service.
|
|
78
87
|
- **Read-only by default** - only single `SELECT`/`WITH`/`SHOW`/`DESCRIBE`/`EXPLAIN` statements run, and sessions are
|
|
79
88
|
opened read-only where the database supports it.
|
|
80
89
|
- **Secrets stay out of files** - `"password": "${PG_PASSWORD}"` in `db_connections.json` reads the environment.
|
|
@@ -116,14 +125,19 @@ every supported database) and `saved_sql/`. Edit the file, set `"active": true`,
|
|
|
116
125
|
curl -X PATCH http://127.0.0.1:5000/save_sql_to_file -H 'Content-Type: application/json' -d '{
|
|
117
126
|
"filename": "actor_by_id",
|
|
118
127
|
"sql_query": "SELECT * FROM actor WHERE actor_id = :id",
|
|
119
|
-
"query_parameters": {"id": "int"},
|
|
128
|
+
"query_parameters": {"id": {"type": "int", "min": 1, "max": 200, "description": "Actor id"}},
|
|
120
129
|
"connection_name": "sakila-sqlite",
|
|
121
130
|
"author": "me", "description": "Look up an actor"
|
|
122
131
|
}'
|
|
123
132
|
|
|
124
133
|
curl 'http://127.0.0.1:5000/q/actor_by_id?id=7&format=yaml'
|
|
134
|
+
curl 'http://127.0.0.1:5000/q/actor_by_id?id=0'
|
|
135
|
+
# {"error": "Invalid parameters: id must be at least 1", "errors": {"id": "must be at least 1"}}
|
|
125
136
|
~~~
|
|
126
137
|
|
|
138
|
+
Rules: `type` (`int`, `float`, `str`, `bool`), `default`, `required`, `enum`, `min`/`max`, `min_length`/`max_length`,
|
|
139
|
+
`pattern` and `description` - see [the API reference](documentation/API.md#parameter-rules).
|
|
140
|
+
|
|
127
141
|
Saving again under the same name adds version 2; `DELETE /saved_sql/actor_by_id?version=1` removes one version.
|
|
128
142
|
|
|
129
143
|
## Configuration
|
|
@@ -136,6 +150,12 @@ Everything is configured through environment variables (all optional):
|
|
|
136
150
|
| `SQL2API_ALLOW_WRITES` | off | Allow `INSERT`/`UPDATE`/DDL. Otherwise only single read-only statements are accepted. |
|
|
137
151
|
| `SQL2API_API_KEY` | unset | When set, every request (except `/health` and `/docs`) needs a matching `X-API-Key` header. |
|
|
138
152
|
| `SQL2API_MAX_PAGE_SIZE` | `1000` | Upper limit for `page_size`. |
|
|
153
|
+
| `SQL2API_CORS_ORIGINS` | unset | Websites allowed to call the API from a browser: comma-separated origins such as `https://app.example.com`, or `*`. Off by default. |
|
|
154
|
+
| `SQL2API_RATE_LIMIT` | unset | Requests allowed per client address, e.g. `60/minute` (also `second`, `hour`, `day`). Off by default; a malformed value stops startup. |
|
|
155
|
+
| `SQL2API_TRUST_PROXY` | `0` | Number of reverse proxies in front of the app whose `X-Forwarded-*` headers are trusted. Set it (usually `1`) behind nginx, a load balancer or a platform router, or every client looks like the proxy. |
|
|
156
|
+
| `SQL2API_POOL_SIZE` | `5` | Idle connections kept per distinct connection setting. `0` turns pooling off. |
|
|
157
|
+
| `SQL2API_POOL_IDLE_TIMEOUT` | `300` | Seconds an idle pooled connection is kept before it is closed. |
|
|
158
|
+
| `SQL2API_QUERY_TIMEOUT` | `30` | Seconds a query may run before it is cancelled (HTTP 504). `0` disables the limit. A request can lower it with `?timeout=`, never raise it. |
|
|
139
159
|
| `SQL2API_HOST` / `SQL2API_PORT` | `127.0.0.1` / `5000` | Bind address for `sql2api serve`. |
|
|
140
160
|
| `SQL2API_DEBUG` | off | Flask debug mode. Never enable on a reachable host. |
|
|
141
161
|
| `SQL2API_H2_JAR` | bundled | Path to a different H2 JDBC jar. |
|
|
@@ -153,15 +173,59 @@ SQL2API runs whatever SQL it is given against your databases, so it ships locked
|
|
|
153
173
|
|
|
154
174
|
See [SECURITY.md](SECURITY.md) to report a vulnerability.
|
|
155
175
|
|
|
176
|
+
### Calling the API from a browser
|
|
177
|
+
|
|
178
|
+
Browsers refuse cross-origin JSON calls unless the server allows them. List the sites that may call the API:
|
|
179
|
+
|
|
180
|
+
~~~bash
|
|
181
|
+
SQL2API_API_KEY=change-me SQL2API_CORS_ORIGINS=https://app.example.com sql2api serve
|
|
182
|
+
~~~
|
|
183
|
+
|
|
184
|
+
Preflight checks are answered automatically, and the pagination headers (`X-Has-More` etc.) are exposed to the page's
|
|
185
|
+
JavaScript. CORS only tells the *browser* which sites may call; it is not authentication, so keep the API key. Avoid
|
|
186
|
+
`*` without a key: any website a visitor opens could then reach your databases through their browser (the server logs
|
|
187
|
+
a warning if you start that way).
|
|
188
|
+
|
|
189
|
+
### Rate limiting
|
|
190
|
+
|
|
191
|
+
`SQL2API_RATE_LIMIT=60/minute` gives each client address a bucket of 60 requests that refills steadily, so short bursts
|
|
192
|
+
work but the sustained rate is capped. Over the limit, requests get `429` with a `Retry-After` header, and every
|
|
193
|
+
response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`. The limit is applied before the API key check, so
|
|
194
|
+
guessing keys is throttled too; `/health` and CORS preflights are never counted. State is per process: with several
|
|
195
|
+
workers, the effective limit is multiplied by the number of workers.
|
|
196
|
+
|
|
156
197
|
## Docker
|
|
157
198
|
|
|
199
|
+
Every release is published to GitHub Container Registry for `linux/amd64` and `linux/arm64`:
|
|
200
|
+
|
|
201
|
+
~~~bash
|
|
202
|
+
docker run -p 5000:5000 -v "$PWD/data:/data" -e SQL2API_API_KEY=change-me ghcr.io/anantharajuc/sql2api:latest
|
|
203
|
+
~~~
|
|
204
|
+
|
|
205
|
+
| Tag | Contents |
|
|
206
|
+
|-----|----------|
|
|
207
|
+
| `X.Y.Z`, `latest` | SQL2API with the MySQL, PostgreSQL and ClickHouse drivers (SQLite is built in) |
|
|
208
|
+
| `X.Y.Z-h2`, `latest-h2` | The same plus Java and the H2 driver |
|
|
209
|
+
|
|
210
|
+
The container keeps `db_connections.json` and `saved_sql/` in `/data` (create a starter with
|
|
211
|
+
`docker run --rm -v "$PWD/data:/data" ghcr.io/anantharajuc/sql2api sql2api init`). It runs as a non-root user under
|
|
212
|
+
gunicorn with one worker (the files are protected by an in-process lock) and a health check on `/health`. Behind a
|
|
213
|
+
reverse proxy or load balancer, set `SQL2API_TRUST_PROXY=1`. To build it yourself:
|
|
214
|
+
`docker build -t sql2api .` (add `--build-arg WITH_H2=true` for H2).
|
|
215
|
+
|
|
216
|
+
### Try it with one command
|
|
217
|
+
|
|
218
|
+
[`docker-compose.yml`](docker-compose.yml) starts SQL2API in front of a PostgreSQL database seeded with sample films:
|
|
219
|
+
|
|
158
220
|
~~~bash
|
|
159
|
-
docker
|
|
160
|
-
|
|
221
|
+
docker compose up --build
|
|
222
|
+
curl -H 'X-API-Key: demo-key' 'http://127.0.0.1:5000/q/films_by_rating?rating=PG&max_length=90'
|
|
161
223
|
~~~
|
|
162
224
|
|
|
163
|
-
|
|
164
|
-
|
|
225
|
+
Open <http://127.0.0.1:5000/docs>, paste `demo-key` into the box at the top, and both saved queries appear as endpoints.
|
|
226
|
+
The demo listens on localhost only, mounts its configuration read-only, and reads the database password from an
|
|
227
|
+
environment variable (`${DEMO_DB_PASSWORD}` in [`demo/data/db_connections.json`](demo/data/db_connections.json)).
|
|
228
|
+
Clean up with `docker compose down -v`.
|
|
165
229
|
|
|
166
230
|
## API overview
|
|
167
231
|
|
|
@@ -33,7 +33,15 @@ film_id,title,rating,length
|
|
|
33
33
|
(or `?version=1`). Each run is recorded in the query's execution history.
|
|
34
34
|
- **Bound parameters** - write `WHERE id = :id` and the value is sent to the database separately from the SQL, so it
|
|
35
35
|
cannot inject anything. Declare types (`{"id": "int"}`) and query-string values are converted for you.
|
|
36
|
+
- **Parameter rules** - saved queries can declare defaults, optional parameters, allowed values, numeric ranges and
|
|
37
|
+
text patterns. Bad input is rejected with a field-by-field `400` before it reaches the database.
|
|
38
|
+
- **A live catalogue of your endpoints** - `/docs` lists every saved query as its own endpoint with its parameters and
|
|
39
|
+
rules. The SQL itself is never shown, and with an API key set the list is hidden from anonymous readers.
|
|
36
40
|
- **Pagination** - `?page=2&page_size=50`, with `X-Has-More` telling you whether another page exists.
|
|
41
|
+
- **Connection pooling** - MySQL, PostgreSQL, ClickHouse and H2 connections are reused between requests instead of
|
|
42
|
+
opened for each one (about 30x lower per-request overhead on MySQL and H2 against a local server; more over a network).
|
|
43
|
+
- **Query time limit** - runaway queries are cancelled on the database (30 s by default, `?timeout=` per request) so
|
|
44
|
+
they cannot tie up the service.
|
|
37
45
|
- **Read-only by default** - only single `SELECT`/`WITH`/`SHOW`/`DESCRIBE`/`EXPLAIN` statements run, and sessions are
|
|
38
46
|
opened read-only where the database supports it.
|
|
39
47
|
- **Secrets stay out of files** - `"password": "${PG_PASSWORD}"` in `db_connections.json` reads the environment.
|
|
@@ -75,14 +83,19 @@ every supported database) and `saved_sql/`. Edit the file, set `"active": true`,
|
|
|
75
83
|
curl -X PATCH http://127.0.0.1:5000/save_sql_to_file -H 'Content-Type: application/json' -d '{
|
|
76
84
|
"filename": "actor_by_id",
|
|
77
85
|
"sql_query": "SELECT * FROM actor WHERE actor_id = :id",
|
|
78
|
-
"query_parameters": {"id": "int"},
|
|
86
|
+
"query_parameters": {"id": {"type": "int", "min": 1, "max": 200, "description": "Actor id"}},
|
|
79
87
|
"connection_name": "sakila-sqlite",
|
|
80
88
|
"author": "me", "description": "Look up an actor"
|
|
81
89
|
}'
|
|
82
90
|
|
|
83
91
|
curl 'http://127.0.0.1:5000/q/actor_by_id?id=7&format=yaml'
|
|
92
|
+
curl 'http://127.0.0.1:5000/q/actor_by_id?id=0'
|
|
93
|
+
# {"error": "Invalid parameters: id must be at least 1", "errors": {"id": "must be at least 1"}}
|
|
84
94
|
~~~
|
|
85
95
|
|
|
96
|
+
Rules: `type` (`int`, `float`, `str`, `bool`), `default`, `required`, `enum`, `min`/`max`, `min_length`/`max_length`,
|
|
97
|
+
`pattern` and `description` - see [the API reference](documentation/API.md#parameter-rules).
|
|
98
|
+
|
|
86
99
|
Saving again under the same name adds version 2; `DELETE /saved_sql/actor_by_id?version=1` removes one version.
|
|
87
100
|
|
|
88
101
|
## Configuration
|
|
@@ -95,6 +108,12 @@ Everything is configured through environment variables (all optional):
|
|
|
95
108
|
| `SQL2API_ALLOW_WRITES` | off | Allow `INSERT`/`UPDATE`/DDL. Otherwise only single read-only statements are accepted. |
|
|
96
109
|
| `SQL2API_API_KEY` | unset | When set, every request (except `/health` and `/docs`) needs a matching `X-API-Key` header. |
|
|
97
110
|
| `SQL2API_MAX_PAGE_SIZE` | `1000` | Upper limit for `page_size`. |
|
|
111
|
+
| `SQL2API_CORS_ORIGINS` | unset | Websites allowed to call the API from a browser: comma-separated origins such as `https://app.example.com`, or `*`. Off by default. |
|
|
112
|
+
| `SQL2API_RATE_LIMIT` | unset | Requests allowed per client address, e.g. `60/minute` (also `second`, `hour`, `day`). Off by default; a malformed value stops startup. |
|
|
113
|
+
| `SQL2API_TRUST_PROXY` | `0` | Number of reverse proxies in front of the app whose `X-Forwarded-*` headers are trusted. Set it (usually `1`) behind nginx, a load balancer or a platform router, or every client looks like the proxy. |
|
|
114
|
+
| `SQL2API_POOL_SIZE` | `5` | Idle connections kept per distinct connection setting. `0` turns pooling off. |
|
|
115
|
+
| `SQL2API_POOL_IDLE_TIMEOUT` | `300` | Seconds an idle pooled connection is kept before it is closed. |
|
|
116
|
+
| `SQL2API_QUERY_TIMEOUT` | `30` | Seconds a query may run before it is cancelled (HTTP 504). `0` disables the limit. A request can lower it with `?timeout=`, never raise it. |
|
|
98
117
|
| `SQL2API_HOST` / `SQL2API_PORT` | `127.0.0.1` / `5000` | Bind address for `sql2api serve`. |
|
|
99
118
|
| `SQL2API_DEBUG` | off | Flask debug mode. Never enable on a reachable host. |
|
|
100
119
|
| `SQL2API_H2_JAR` | bundled | Path to a different H2 JDBC jar. |
|
|
@@ -112,15 +131,59 @@ SQL2API runs whatever SQL it is given against your databases, so it ships locked
|
|
|
112
131
|
|
|
113
132
|
See [SECURITY.md](SECURITY.md) to report a vulnerability.
|
|
114
133
|
|
|
134
|
+
### Calling the API from a browser
|
|
135
|
+
|
|
136
|
+
Browsers refuse cross-origin JSON calls unless the server allows them. List the sites that may call the API:
|
|
137
|
+
|
|
138
|
+
~~~bash
|
|
139
|
+
SQL2API_API_KEY=change-me SQL2API_CORS_ORIGINS=https://app.example.com sql2api serve
|
|
140
|
+
~~~
|
|
141
|
+
|
|
142
|
+
Preflight checks are answered automatically, and the pagination headers (`X-Has-More` etc.) are exposed to the page's
|
|
143
|
+
JavaScript. CORS only tells the *browser* which sites may call; it is not authentication, so keep the API key. Avoid
|
|
144
|
+
`*` without a key: any website a visitor opens could then reach your databases through their browser (the server logs
|
|
145
|
+
a warning if you start that way).
|
|
146
|
+
|
|
147
|
+
### Rate limiting
|
|
148
|
+
|
|
149
|
+
`SQL2API_RATE_LIMIT=60/minute` gives each client address a bucket of 60 requests that refills steadily, so short bursts
|
|
150
|
+
work but the sustained rate is capped. Over the limit, requests get `429` with a `Retry-After` header, and every
|
|
151
|
+
response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`. The limit is applied before the API key check, so
|
|
152
|
+
guessing keys is throttled too; `/health` and CORS preflights are never counted. State is per process: with several
|
|
153
|
+
workers, the effective limit is multiplied by the number of workers.
|
|
154
|
+
|
|
115
155
|
## Docker
|
|
116
156
|
|
|
157
|
+
Every release is published to GitHub Container Registry for `linux/amd64` and `linux/arm64`:
|
|
158
|
+
|
|
159
|
+
~~~bash
|
|
160
|
+
docker run -p 5000:5000 -v "$PWD/data:/data" -e SQL2API_API_KEY=change-me ghcr.io/anantharajuc/sql2api:latest
|
|
161
|
+
~~~
|
|
162
|
+
|
|
163
|
+
| Tag | Contents |
|
|
164
|
+
|-----|----------|
|
|
165
|
+
| `X.Y.Z`, `latest` | SQL2API with the MySQL, PostgreSQL and ClickHouse drivers (SQLite is built in) |
|
|
166
|
+
| `X.Y.Z-h2`, `latest-h2` | The same plus Java and the H2 driver |
|
|
167
|
+
|
|
168
|
+
The container keeps `db_connections.json` and `saved_sql/` in `/data` (create a starter with
|
|
169
|
+
`docker run --rm -v "$PWD/data:/data" ghcr.io/anantharajuc/sql2api sql2api init`). It runs as a non-root user under
|
|
170
|
+
gunicorn with one worker (the files are protected by an in-process lock) and a health check on `/health`. Behind a
|
|
171
|
+
reverse proxy or load balancer, set `SQL2API_TRUST_PROXY=1`. To build it yourself:
|
|
172
|
+
`docker build -t sql2api .` (add `--build-arg WITH_H2=true` for H2).
|
|
173
|
+
|
|
174
|
+
### Try it with one command
|
|
175
|
+
|
|
176
|
+
[`docker-compose.yml`](docker-compose.yml) starts SQL2API in front of a PostgreSQL database seeded with sample films:
|
|
177
|
+
|
|
117
178
|
~~~bash
|
|
118
|
-
docker
|
|
119
|
-
|
|
179
|
+
docker compose up --build
|
|
180
|
+
curl -H 'X-API-Key: demo-key' 'http://127.0.0.1:5000/q/films_by_rating?rating=PG&max_length=90'
|
|
120
181
|
~~~
|
|
121
182
|
|
|
122
|
-
|
|
123
|
-
|
|
183
|
+
Open <http://127.0.0.1:5000/docs>, paste `demo-key` into the box at the top, and both saved queries appear as endpoints.
|
|
184
|
+
The demo listens on localhost only, mounts its configuration read-only, and reads the database password from an
|
|
185
|
+
environment variable (`${DEMO_DB_PASSWORD}` in [`demo/data/db_connections.json`](demo/data/db_connections.json)).
|
|
186
|
+
Clean up with `docker compose down -v`.
|
|
124
187
|
|
|
125
188
|
## API overview
|
|
126
189
|
|
|
@@ -29,7 +29,7 @@ clickhouse = ["clickhouse-driver>=0.2"]
|
|
|
29
29
|
h2 = ["JayDeBeApi>=1.2", "JPype1>=1.4"] # also needs a Java runtime
|
|
30
30
|
all = ["sql2api[mysql,postgres,clickhouse,h2]"]
|
|
31
31
|
server = ["gunicorn>=21"]
|
|
32
|
-
dev = ["sql2api[all]", "ruff>=0.5"]
|
|
32
|
+
dev = ["sql2api[all]", "ruff>=0.5", "openapi-spec-validator>=0.7"]
|
|
33
33
|
|
|
34
34
|
[project.urls]
|
|
35
35
|
Homepage = "https://github.com/AnanthaRajuC/SQL2API"
|
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
"""The Flask application: HTTP routes on top of the store, engine and formatters."""
|
|
2
2
|
import hmac
|
|
3
3
|
import logging
|
|
4
|
+
import math
|
|
4
5
|
|
|
5
|
-
from flask import Blueprint, Flask, Response, jsonify, redirect, request, url_for
|
|
6
|
+
from flask import Blueprint, Flask, Response, current_app, g, jsonify, redirect, request, url_for
|
|
6
7
|
from flask.json.provider import DefaultJSONProvider
|
|
7
8
|
from werkzeug.exceptions import HTTPException
|
|
9
|
+
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
8
10
|
|
|
9
|
-
from . import config, engine, openapi, sqltools, store
|
|
11
|
+
from . import config, cors, engine, openapi, pool, sqltools, store
|
|
12
|
+
from . import params as param_rules
|
|
10
13
|
from .errors import ApiError
|
|
11
14
|
from .formats import FORMATTERS, json_default
|
|
15
|
+
from .ratelimit import RateLimiter
|
|
12
16
|
|
|
13
17
|
log = logging.getLogger('sql2api')
|
|
14
18
|
bp = Blueprint('api', __name__)
|
|
15
19
|
|
|
16
20
|
# Query-string arguments that control a request rather than supplying query parameters.
|
|
17
|
-
RESERVED_ARGS = {'format', 'page', 'page_size', 'connection_name', 'version'}
|
|
21
|
+
RESERVED_ARGS = {'format', 'page', 'page_size', 'connection_name', 'version', 'timeout'}
|
|
18
22
|
PUBLIC_ENDPOINTS = {'api.index', 'api.favicon', 'api.health', 'api.docs', 'api.openapi_spec'}
|
|
23
|
+
RATE_LIMIT_EXEMPT = {'api.health'} # so monitoring keeps working while a client is being throttled
|
|
19
24
|
|
|
20
25
|
|
|
21
26
|
class JSONProvider(DefaultJSONProvider):
|
|
@@ -25,7 +30,15 @@ class JSONProvider(DefaultJSONProvider):
|
|
|
25
30
|
|
|
26
31
|
def create_app():
|
|
27
32
|
from . import __version__
|
|
33
|
+
config.check_settings()
|
|
28
34
|
app = Flask(__name__)
|
|
35
|
+
hops = config.proxy_hops()
|
|
36
|
+
if hops: # behind reverse proxies: take the client address and scheme from their X-Forwarded-* headers
|
|
37
|
+
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=hops, x_proto=hops, x_host=hops)
|
|
38
|
+
app.extensions['sql2api_limiter'] = RateLimiter()
|
|
39
|
+
if config.cors_origins() == '*' and not config.api_key():
|
|
40
|
+
log.warning('SQL2API_CORS_ORIGINS=* without SQL2API_API_KEY: any website a user visits can call this API '
|
|
41
|
+
'from their browser and reach every active connection. Set an API key or list the origins.')
|
|
29
42
|
app.json = JSONProvider(app)
|
|
30
43
|
app.config['SQL2API_VERSION'] = __version__
|
|
31
44
|
|
|
@@ -43,13 +56,25 @@ def create_app():
|
|
|
43
56
|
return jsonify({'error': 'An error occurred'}), 500
|
|
44
57
|
|
|
45
58
|
@app.before_request
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
def gate():
|
|
60
|
+
# Order matters: a browser's preflight cannot carry the API key, and rate limiting comes before the key
|
|
61
|
+
# check so that guessing keys is throttled too.
|
|
62
|
+
if cors.is_preflight(request):
|
|
63
|
+
return cors.preflight_response(request.headers.get('Origin'))
|
|
64
|
+
limited = check_rate_limit()
|
|
65
|
+
if limited is not None:
|
|
66
|
+
return limited
|
|
67
|
+
if request.endpoint not in PUBLIC_ENDPOINTS and not has_valid_key():
|
|
68
|
+
return jsonify({'error': 'Unauthorized'}), 401
|
|
69
|
+
|
|
70
|
+
@app.after_request
|
|
71
|
+
def decorate(response):
|
|
72
|
+
cors.add_headers(response, request.headers.get('Origin'))
|
|
73
|
+
if g.get('rate_limit'):
|
|
74
|
+
limit, remaining = g.rate_limit
|
|
75
|
+
response.headers['X-RateLimit-Limit'] = str(limit)
|
|
76
|
+
response.headers['X-RateLimit-Remaining'] = str(remaining)
|
|
77
|
+
return response
|
|
53
78
|
|
|
54
79
|
app.register_blueprint(bp)
|
|
55
80
|
return app
|
|
@@ -59,6 +84,33 @@ def create_app():
|
|
|
59
84
|
# Request helpers
|
|
60
85
|
# --------------------------------------------------------------------------------------
|
|
61
86
|
|
|
87
|
+
def check_rate_limit():
|
|
88
|
+
"""Count this request against its client's quota; returns a 429 response when it is over the limit."""
|
|
89
|
+
limit = config.rate_limit()
|
|
90
|
+
if limit is None or request.method == 'OPTIONS' or request.endpoint in RATE_LIMIT_EXEMPT:
|
|
91
|
+
return None
|
|
92
|
+
count, period = limit
|
|
93
|
+
client = request.remote_addr or 'unknown'
|
|
94
|
+
allowed, remaining, retry_after = current_app.extensions['sql2api_limiter'].hit(client, count, period)
|
|
95
|
+
g.rate_limit = (count, remaining)
|
|
96
|
+
if allowed:
|
|
97
|
+
return None
|
|
98
|
+
response = jsonify({'error': 'Rate limit exceeded', 'retry_after': retry_after})
|
|
99
|
+
response.status_code = 429
|
|
100
|
+
response.headers['Retry-After'] = str(retry_after)
|
|
101
|
+
return response
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def has_valid_key():
|
|
105
|
+
"""True when no API key is configured, or the request carries the right X-API-Key header."""
|
|
106
|
+
expected = config.api_key()
|
|
107
|
+
if not expected:
|
|
108
|
+
return True
|
|
109
|
+
# compare_digest rejects non-ASCII str, so compare bytes
|
|
110
|
+
supplied = request.headers.get('X-API-Key', '').encode('utf-8', 'replace')
|
|
111
|
+
return hmac.compare_digest(supplied, expected.encode('utf-8'))
|
|
112
|
+
|
|
113
|
+
|
|
62
114
|
def get_json_body(required=True):
|
|
63
115
|
data = request.get_json(silent=True)
|
|
64
116
|
if data is None and not required:
|
|
@@ -105,6 +157,20 @@ def get_output_format(body=None):
|
|
|
105
157
|
return output_format
|
|
106
158
|
|
|
107
159
|
|
|
160
|
+
def get_timeout(body=None):
|
|
161
|
+
"""Seconds allowed for the query: ?timeout= may lower the server limit but never raise it."""
|
|
162
|
+
raw = request.args.get('timeout', (body or {}).get('timeout'))
|
|
163
|
+
if raw in (None, ''):
|
|
164
|
+
return config.effective_timeout(None)
|
|
165
|
+
try:
|
|
166
|
+
value = float(raw)
|
|
167
|
+
except (TypeError, ValueError):
|
|
168
|
+
raise ApiError('timeout must be a number of seconds') from None
|
|
169
|
+
if not math.isfinite(value) or value <= 0:
|
|
170
|
+
raise ApiError('timeout must be a positive number of seconds')
|
|
171
|
+
return config.effective_timeout(value)
|
|
172
|
+
|
|
173
|
+
|
|
108
174
|
def render(result, output_format, page, page_size):
|
|
109
175
|
response = jsonify({'message': 'No results returned'}) if not result else FORMATTERS[output_format](result)
|
|
110
176
|
response.headers['X-Page'] = str(page)
|
|
@@ -126,8 +192,9 @@ def execute_sql_endpoint():
|
|
|
126
192
|
raise ApiError('Connection name is missing')
|
|
127
193
|
params = get_object(data.get('params'), 'params')
|
|
128
194
|
output_format = get_output_format(data)
|
|
195
|
+
timeout = get_timeout(data)
|
|
129
196
|
limit, offset, page = get_pagination()
|
|
130
|
-
result = engine.execute_sql(data['sql'], data['connection_name'], limit, offset, params)
|
|
197
|
+
result = engine.execute_sql(data['sql'], data['connection_name'], limit, offset, params, timeout)
|
|
131
198
|
return render(result, output_format, page, limit)
|
|
132
199
|
|
|
133
200
|
|
|
@@ -145,14 +212,16 @@ def run_saved(ref, body, url_params):
|
|
|
145
212
|
|
|
146
213
|
raw = {**url_params, **get_object(body.get('params'), 'params'),
|
|
147
214
|
**get_object(body.get('placeholders'), 'placeholders')}
|
|
148
|
-
|
|
149
|
-
|
|
215
|
+
used = set(sqltools.placeholder_names(saved['sql_query']))
|
|
216
|
+
values = param_rules.resolve(saved.get('query_parameters'), raw, used=used)
|
|
217
|
+
sql = sqltools.fill_placeholders(saved['sql_query'], values)
|
|
150
218
|
output_format = get_output_format(body)
|
|
219
|
+
timeout = get_timeout(body)
|
|
151
220
|
limit, offset, page = get_pagination()
|
|
152
221
|
|
|
153
222
|
entry = {'executed_at': store.now(), 'connection_name': connection_name}
|
|
154
223
|
try:
|
|
155
|
-
result, elapsed_ms = engine.timed(engine.execute_sql, sql, connection_name, limit, offset,
|
|
224
|
+
result, elapsed_ms = engine.timed(engine.execute_sql, sql, connection_name, limit, offset, values, timeout)
|
|
156
225
|
except ApiError as error:
|
|
157
226
|
store.record_execution(path, number, {**entry, 'status': 'error', 'error': error.message})
|
|
158
227
|
raise
|
|
@@ -197,6 +266,11 @@ def save_sql_to_file():
|
|
|
197
266
|
if not isinstance(tags, (list, str)):
|
|
198
267
|
raise ApiError('tags must be a string or a list')
|
|
199
268
|
query_parameters = get_object(data.get('query_parameters'), 'query_parameters')
|
|
269
|
+
param_rules.parse_definitions(query_parameters)
|
|
270
|
+
unused = sorted(set(query_parameters) - set(sqltools.placeholder_names(data['sql_query'])))
|
|
271
|
+
if unused:
|
|
272
|
+
raise ApiError(f"query_parameters declares {', '.join(unused)}, which sql_query does not use "
|
|
273
|
+
'(write :name in the SQL, or remove the declaration)')
|
|
200
274
|
connection_name = data.get('connection_name')
|
|
201
275
|
if connection_name is not None and not isinstance(connection_name, str):
|
|
202
276
|
raise ApiError('connection_name must be a string')
|
|
@@ -256,12 +330,14 @@ def update_connections():
|
|
|
256
330
|
if not connections or not isinstance(connections, dict):
|
|
257
331
|
raise ApiError('Connections data is missing')
|
|
258
332
|
store.update_connections(connections)
|
|
333
|
+
pool.close_pooled_connections() # new settings or credentials must not be served by old connections
|
|
259
334
|
return jsonify({'message': 'Connections updated successfully'}), 200
|
|
260
335
|
|
|
261
336
|
|
|
262
337
|
@bp.route('/connections/<name>', methods=['DELETE'])
|
|
263
338
|
def delete_connection(name):
|
|
264
339
|
store.delete_connection(name)
|
|
340
|
+
pool.close_pooled_connections() # a removed connection must not keep serving from idle sockets
|
|
265
341
|
return jsonify({'message': f"Connection '{name}' deleted"}), 200
|
|
266
342
|
|
|
267
343
|
|
|
@@ -285,10 +361,31 @@ def health():
|
|
|
285
361
|
return jsonify({'status': 'ok', 'version': current_app.config['SQL2API_VERSION']})
|
|
286
362
|
|
|
287
363
|
|
|
364
|
+
def describe_saved_queries():
|
|
365
|
+
"""What the OpenAPI document needs to know about each saved query (never its SQL text)."""
|
|
366
|
+
described = []
|
|
367
|
+
for name, number, data in store.latest_versions():
|
|
368
|
+
sql = data.get('sql_query')
|
|
369
|
+
if not isinstance(sql, str):
|
|
370
|
+
continue
|
|
371
|
+
declared = param_rules.read_definitions(data.get('query_parameters'))
|
|
372
|
+
used = sqltools.placeholder_names(sql)
|
|
373
|
+
parameters = {}
|
|
374
|
+
for param in used: # what the SQL needs, in order; undeclared ones are plain required text
|
|
375
|
+
parameters[param] = declared.get(param) or param_rules.read_definition({})
|
|
376
|
+
described.append({'name': name, 'version': number, 'description': data.get('description'),
|
|
377
|
+
'tags': data.get('tags'), 'connection_name': data.get('connection_name'),
|
|
378
|
+
'parameters': parameters})
|
|
379
|
+
return described
|
|
380
|
+
|
|
381
|
+
|
|
288
382
|
@bp.route('/openapi.json', methods=['GET'])
|
|
289
383
|
def openapi_spec():
|
|
290
384
|
from flask import current_app
|
|
291
|
-
|
|
385
|
+
# The generic API description is public. The list of saved queries (names, descriptions, parameters) is only
|
|
386
|
+
# shown to callers who could list them anyway, so an API key protects it too.
|
|
387
|
+
saved = describe_saved_queries() if has_valid_key() else None
|
|
388
|
+
return jsonify(openapi.build_spec(current_app.config['SQL2API_VERSION'], saved))
|
|
292
389
|
|
|
293
390
|
|
|
294
391
|
@bp.route('/docs', methods=['GET'])
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import argparse
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
|
+
import sys
|
|
5
6
|
|
|
6
7
|
from . import __version__, config, store
|
|
7
8
|
from .app import create_app
|
|
@@ -10,7 +11,11 @@ LOOPBACK_HOSTS = ('127.0.0.1', 'localhost', '::1')
|
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def _serve(args):
|
|
13
|
-
|
|
14
|
+
try:
|
|
15
|
+
app = create_app()
|
|
16
|
+
except ValueError as error: # a malformed setting, e.g. SQL2API_RATE_LIMIT
|
|
17
|
+
print(f'sql2api: {error}', file=sys.stderr)
|
|
18
|
+
return 2
|
|
14
19
|
if args.host not in LOOPBACK_HOSTS and not config.api_key():
|
|
15
20
|
logging.getLogger('sql2api').warning(
|
|
16
21
|
'Listening on %s without SQL2API_API_KEY set: anyone who can reach this port can run SQL '
|