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.
Files changed (36) hide show
  1. {sql2api-0.1.0/sql2api.egg-info → sql2api-0.3.0}/PKG-INFO +70 -6
  2. {sql2api-0.1.0 → sql2api-0.3.0}/README.md +68 -5
  3. {sql2api-0.1.0 → sql2api-0.3.0}/pyproject.toml +1 -1
  4. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/__init__.py +1 -1
  5. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/app.py +112 -15
  6. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/cli.py +6 -1
  7. sql2api-0.3.0/sql2api/config.py +156 -0
  8. sql2api-0.3.0/sql2api/cors.py +51 -0
  9. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/engine.py +6 -3
  10. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/openapi.py +100 -8
  11. sql2api-0.3.0/sql2api/params.py +275 -0
  12. sql2api-0.3.0/sql2api/pool.py +146 -0
  13. sql2api-0.3.0/sql2api/ratelimit.py +56 -0
  14. sql2api-0.3.0/sql2api/runners.py +354 -0
  15. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/sqltools.py +5 -31
  16. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/store.py +18 -0
  17. {sql2api-0.1.0 → sql2api-0.3.0/sql2api.egg-info}/PKG-INFO +70 -6
  18. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api.egg-info/SOURCES.txt +7 -0
  19. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api.egg-info/requires.txt +1 -0
  20. sql2api-0.3.0/tests/test_cors_and_rate_limit.py +288 -0
  21. {sql2api-0.1.0 → sql2api-0.3.0}/tests/test_integration.py +75 -1
  22. sql2api-0.3.0/tests/test_params.py +134 -0
  23. sql2api-0.3.0/tests/test_release_check.py +78 -0
  24. sql2api-0.3.0/tests/test_sql2api.py +1048 -0
  25. sql2api-0.1.0/sql2api/config.py +0 -62
  26. sql2api-0.1.0/sql2api/runners.py +0 -142
  27. sql2api-0.1.0/tests/test_sql2api.py +0 -483
  28. {sql2api-0.1.0 → sql2api-0.3.0}/LICENSE +0 -0
  29. {sql2api-0.1.0 → sql2api-0.3.0}/setup.cfg +0 -0
  30. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/__main__.py +0 -0
  31. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/errors.py +0 -0
  32. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/formats.py +0 -0
  33. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api/lib/h2-2.2.224.jar +0 -0
  34. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api.egg-info/dependency_links.txt +0 -0
  35. {sql2api-0.1.0 → sql2api-0.3.0}/sql2api.egg-info/entry_points.txt +0 -0
  36. {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.1.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 build -t sql2api . # add --build-arg WITH_H2=true for H2 support
160
- docker run -p 5000:5000 -v "$PWD/data:/data" -e SQL2API_API_KEY=change-me sql2api
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
- The container keeps `db_connections.json` and `saved_sql/` in `/data`. It runs gunicorn with a single worker
164
- (the files are protected by an in-process lock).
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 build -t sql2api . # add --build-arg WITH_H2=true for H2 support
119
- docker run -p 5000:5000 -v "$PWD/data:/data" -e SQL2API_API_KEY=change-me sql2api
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
- The container keeps `db_connections.json` and `saved_sql/` in `/data`. It runs gunicorn with a single worker
123
- (the files are protected by an in-process lock).
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,5 +1,5 @@
1
1
  """SQL2API - expose SQL databases as a REST API."""
2
- __version__ = '0.1.0'
2
+ __version__ = '0.3.0'
3
3
 
4
4
  from .app import create_app # noqa: E402 (app imports __version__)
5
5
 
@@ -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 require_api_key():
47
- expected = config.api_key()
48
- if expected and request.endpoint not in PUBLIC_ENDPOINTS:
49
- # compare_digest rejects non-ASCII str, so compare bytes
50
- supplied = request.headers.get('X-API-Key', '').encode('utf-8', 'replace')
51
- if not hmac.compare_digest(supplied, expected.encode('utf-8')):
52
- return jsonify({'error': 'Unauthorized'}), 401
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
- params = sqltools.coerce_params(saved.get('query_parameters'), raw)
149
- sql = sqltools.fill_placeholders(saved['sql_query'], params)
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, params)
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
- return jsonify(openapi.build_spec(current_app.config['SQL2API_VERSION']))
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
- app = create_app()
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 '