sql2api 0.2.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 (33) hide show
  1. {sql2api-0.2.0/sql2api.egg-info → sql2api-0.3.0}/PKG-INFO +63 -6
  2. {sql2api-0.2.0 → sql2api-0.3.0}/README.md +61 -5
  3. {sql2api-0.2.0 → sql2api-0.3.0}/pyproject.toml +1 -1
  4. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/__init__.py +1 -1
  5. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/app.py +91 -13
  6. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/cli.py +6 -1
  7. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/config.py +55 -0
  8. sql2api-0.3.0/sql2api/cors.py +51 -0
  9. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/openapi.py +98 -9
  10. sql2api-0.3.0/sql2api/params.py +275 -0
  11. sql2api-0.3.0/sql2api/ratelimit.py +56 -0
  12. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/sqltools.py +5 -31
  13. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/store.py +18 -0
  14. {sql2api-0.2.0 → sql2api-0.3.0/sql2api.egg-info}/PKG-INFO +63 -6
  15. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api.egg-info/SOURCES.txt +6 -0
  16. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api.egg-info/requires.txt +1 -0
  17. sql2api-0.3.0/tests/test_cors_and_rate_limit.py +288 -0
  18. sql2api-0.3.0/tests/test_params.py +134 -0
  19. sql2api-0.3.0/tests/test_release_check.py +78 -0
  20. {sql2api-0.2.0 → sql2api-0.3.0}/tests/test_sql2api.py +170 -8
  21. {sql2api-0.2.0 → sql2api-0.3.0}/LICENSE +0 -0
  22. {sql2api-0.2.0 → sql2api-0.3.0}/setup.cfg +0 -0
  23. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/__main__.py +0 -0
  24. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/engine.py +0 -0
  25. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/errors.py +0 -0
  26. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/formats.py +0 -0
  27. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/lib/h2-2.2.224.jar +0 -0
  28. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/pool.py +0 -0
  29. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api/runners.py +0 -0
  30. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api.egg-info/dependency_links.txt +0 -0
  31. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api.egg-info/entry_points.txt +0 -0
  32. {sql2api-0.2.0 → sql2api-0.3.0}/sql2api.egg-info/top_level.txt +0 -0
  33. {sql2api-0.2.0 → sql2api-0.3.0}/tests/test_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql2api
3
- Version: 0.2.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,6 +75,10 @@ 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.
78
83
  - **Connection pooling** - MySQL, PostgreSQL, ClickHouse and H2 connections are reused between requests instead of
79
84
  opened for each one (about 30x lower per-request overhead on MySQL and H2 against a local server; more over a network).
@@ -120,14 +125,19 @@ every supported database) and `saved_sql/`. Edit the file, set `"active": true`,
120
125
  curl -X PATCH http://127.0.0.1:5000/save_sql_to_file -H 'Content-Type: application/json' -d '{
121
126
  "filename": "actor_by_id",
122
127
  "sql_query": "SELECT * FROM actor WHERE actor_id = :id",
123
- "query_parameters": {"id": "int"},
128
+ "query_parameters": {"id": {"type": "int", "min": 1, "max": 200, "description": "Actor id"}},
124
129
  "connection_name": "sakila-sqlite",
125
130
  "author": "me", "description": "Look up an actor"
126
131
  }'
127
132
 
128
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"}}
129
136
  ~~~
130
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
+
131
141
  Saving again under the same name adds version 2; `DELETE /saved_sql/actor_by_id?version=1` removes one version.
132
142
 
133
143
  ## Configuration
@@ -140,6 +150,9 @@ Everything is configured through environment variables (all optional):
140
150
  | `SQL2API_ALLOW_WRITES` | off | Allow `INSERT`/`UPDATE`/DDL. Otherwise only single read-only statements are accepted. |
141
151
  | `SQL2API_API_KEY` | unset | When set, every request (except `/health` and `/docs`) needs a matching `X-API-Key` header. |
142
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. |
143
156
  | `SQL2API_POOL_SIZE` | `5` | Idle connections kept per distinct connection setting. `0` turns pooling off. |
144
157
  | `SQL2API_POOL_IDLE_TIMEOUT` | `300` | Seconds an idle pooled connection is kept before it is closed. |
145
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. |
@@ -160,15 +173,59 @@ SQL2API runs whatever SQL it is given against your databases, so it ships locked
160
173
 
161
174
  See [SECURITY.md](SECURITY.md) to report a vulnerability.
162
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
+
163
197
  ## Docker
164
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
+
165
220
  ~~~bash
166
- docker build -t sql2api . # add --build-arg WITH_H2=true for H2 support
167
- 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'
168
223
  ~~~
169
224
 
170
- The container keeps `db_connections.json` and `saved_sql/` in `/data`. It runs gunicorn with a single worker
171
- (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`.
172
229
 
173
230
  ## API overview
174
231
 
@@ -33,6 +33,10 @@ 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.
37
41
  - **Connection pooling** - MySQL, PostgreSQL, ClickHouse and H2 connections are reused between requests instead of
38
42
  opened for each one (about 30x lower per-request overhead on MySQL and H2 against a local server; more over a network).
@@ -79,14 +83,19 @@ every supported database) and `saved_sql/`. Edit the file, set `"active": true`,
79
83
  curl -X PATCH http://127.0.0.1:5000/save_sql_to_file -H 'Content-Type: application/json' -d '{
80
84
  "filename": "actor_by_id",
81
85
  "sql_query": "SELECT * FROM actor WHERE actor_id = :id",
82
- "query_parameters": {"id": "int"},
86
+ "query_parameters": {"id": {"type": "int", "min": 1, "max": 200, "description": "Actor id"}},
83
87
  "connection_name": "sakila-sqlite",
84
88
  "author": "me", "description": "Look up an actor"
85
89
  }'
86
90
 
87
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"}}
88
94
  ~~~
89
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
+
90
99
  Saving again under the same name adds version 2; `DELETE /saved_sql/actor_by_id?version=1` removes one version.
91
100
 
92
101
  ## Configuration
@@ -99,6 +108,9 @@ Everything is configured through environment variables (all optional):
99
108
  | `SQL2API_ALLOW_WRITES` | off | Allow `INSERT`/`UPDATE`/DDL. Otherwise only single read-only statements are accepted. |
100
109
  | `SQL2API_API_KEY` | unset | When set, every request (except `/health` and `/docs`) needs a matching `X-API-Key` header. |
101
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. |
102
114
  | `SQL2API_POOL_SIZE` | `5` | Idle connections kept per distinct connection setting. `0` turns pooling off. |
103
115
  | `SQL2API_POOL_IDLE_TIMEOUT` | `300` | Seconds an idle pooled connection is kept before it is closed. |
104
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. |
@@ -119,15 +131,59 @@ SQL2API runs whatever SQL it is given against your databases, so it ships locked
119
131
 
120
132
  See [SECURITY.md](SECURITY.md) to report a vulnerability.
121
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
+
122
155
  ## Docker
123
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
+
124
178
  ~~~bash
125
- docker build -t sql2api . # add --build-arg WITH_H2=true for H2 support
126
- 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'
127
181
  ~~~
128
182
 
129
- The container keeps `db_connections.json` and `saved_sql/` in `/data`. It runs gunicorn with a single worker
130
- (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`.
131
187
 
132
188
  ## API overview
133
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.2.0'
2
+ __version__ = '0.3.0'
3
3
 
4
4
  from .app import create_app # noqa: E402 (app imports __version__)
5
5
 
@@ -3,13 +3,16 @@ import hmac
3
3
  import logging
4
4
  import math
5
5
 
6
- 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
7
7
  from flask.json.provider import DefaultJSONProvider
8
8
  from werkzeug.exceptions import HTTPException
9
+ from werkzeug.middleware.proxy_fix import ProxyFix
9
10
 
10
- from . import config, engine, openapi, pool, sqltools, store
11
+ from . import config, cors, engine, openapi, pool, sqltools, store
12
+ from . import params as param_rules
11
13
  from .errors import ApiError
12
14
  from .formats import FORMATTERS, json_default
15
+ from .ratelimit import RateLimiter
13
16
 
14
17
  log = logging.getLogger('sql2api')
15
18
  bp = Blueprint('api', __name__)
@@ -17,6 +20,7 @@ bp = Blueprint('api', __name__)
17
20
  # Query-string arguments that control a request rather than supplying query parameters.
18
21
  RESERVED_ARGS = {'format', 'page', 'page_size', 'connection_name', 'version', 'timeout'}
19
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
20
24
 
21
25
 
22
26
  class JSONProvider(DefaultJSONProvider):
@@ -26,7 +30,15 @@ class JSONProvider(DefaultJSONProvider):
26
30
 
27
31
  def create_app():
28
32
  from . import __version__
33
+ config.check_settings()
29
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.')
30
42
  app.json = JSONProvider(app)
31
43
  app.config['SQL2API_VERSION'] = __version__
32
44
 
@@ -44,13 +56,25 @@ def create_app():
44
56
  return jsonify({'error': 'An error occurred'}), 500
45
57
 
46
58
  @app.before_request
47
- def require_api_key():
48
- expected = config.api_key()
49
- if expected and request.endpoint not in PUBLIC_ENDPOINTS:
50
- # compare_digest rejects non-ASCII str, so compare bytes
51
- supplied = request.headers.get('X-API-Key', '').encode('utf-8', 'replace')
52
- if not hmac.compare_digest(supplied, expected.encode('utf-8')):
53
- 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
54
78
 
55
79
  app.register_blueprint(bp)
56
80
  return app
@@ -60,6 +84,33 @@ def create_app():
60
84
  # Request helpers
61
85
  # --------------------------------------------------------------------------------------
62
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
+
63
114
  def get_json_body(required=True):
64
115
  data = request.get_json(silent=True)
65
116
  if data is None and not required:
@@ -161,15 +212,16 @@ def run_saved(ref, body, url_params):
161
212
 
162
213
  raw = {**url_params, **get_object(body.get('params'), 'params'),
163
214
  **get_object(body.get('placeholders'), 'placeholders')}
164
- params = sqltools.coerce_params(saved.get('query_parameters'), raw)
165
- 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)
166
218
  output_format = get_output_format(body)
167
219
  timeout = get_timeout(body)
168
220
  limit, offset, page = get_pagination()
169
221
 
170
222
  entry = {'executed_at': store.now(), 'connection_name': connection_name}
171
223
  try:
172
- result, elapsed_ms = engine.timed(engine.execute_sql, sql, connection_name, limit, offset, params, timeout)
224
+ result, elapsed_ms = engine.timed(engine.execute_sql, sql, connection_name, limit, offset, values, timeout)
173
225
  except ApiError as error:
174
226
  store.record_execution(path, number, {**entry, 'status': 'error', 'error': error.message})
175
227
  raise
@@ -214,6 +266,11 @@ def save_sql_to_file():
214
266
  if not isinstance(tags, (list, str)):
215
267
  raise ApiError('tags must be a string or a list')
216
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)')
217
274
  connection_name = data.get('connection_name')
218
275
  if connection_name is not None and not isinstance(connection_name, str):
219
276
  raise ApiError('connection_name must be a string')
@@ -304,10 +361,31 @@ def health():
304
361
  return jsonify({'status': 'ok', 'version': current_app.config['SQL2API_VERSION']})
305
362
 
306
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
+
307
382
  @bp.route('/openapi.json', methods=['GET'])
308
383
  def openapi_spec():
309
384
  from flask import current_app
310
- 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))
311
389
 
312
390
 
313
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 '
@@ -1,5 +1,6 @@
1
1
  """Runtime configuration, read from environment variables at call time."""
2
2
  import os
3
+ import re
3
4
  from pathlib import Path
4
5
 
5
6
  SUPPORTED_DB_TYPES = ('mysql', 'postgres', 'clickhouse', 'sqlite', 'h2')
@@ -94,6 +95,60 @@ def pool_idle_timeout():
94
95
  return value if value > 0 else DEFAULT_POOL_IDLE_TIMEOUT
95
96
 
96
97
 
98
+ def cors_origins():
99
+ """Origins allowed to call the API from a browser (SQL2API_CORS_ORIGINS): None (off), '*' or a frozenset.
100
+
101
+ Entries are compared case-insensitively and without a trailing slash, e.g. https://app.example.com.
102
+ """
103
+ raw = os.environ.get('SQL2API_CORS_ORIGINS', '').strip()
104
+ if not raw:
105
+ return None
106
+ origins = [item.strip().rstrip('/').lower() for item in raw.split(',') if item.strip()]
107
+ if '*' in origins:
108
+ return '*'
109
+ return frozenset(origins) or None
110
+
111
+
112
+ _RATE_PERIODS = {'second': 1, 'minute': 60, 'hour': 3600, 'day': 86400}
113
+ _RATE_RE = re.compile(r'^\s*(\d+)\s*/\s*(second|minute|hour|day)s?\s*$', re.I)
114
+
115
+
116
+ def parse_rate_limit(text):
117
+ """Parse '60/minute' (also second, hour, day) into (requests, seconds); raises ValueError when malformed."""
118
+ match = _RATE_RE.match(text or '')
119
+ if not match or int(match.group(1)) < 1:
120
+ raise ValueError(f"SQL2API_RATE_LIMIT must look like '60/minute' (a positive count, then second, minute, "
121
+ f"hour or day), not {text!r}")
122
+ return int(match.group(1)), _RATE_PERIODS[match.group(2).lower()]
123
+
124
+
125
+ def rate_limit():
126
+ """(requests, seconds) allowed per client (SQL2API_RATE_LIMIT), or None when limiting is off."""
127
+ raw = os.environ.get('SQL2API_RATE_LIMIT', '').strip()
128
+ if not raw:
129
+ return None
130
+ try:
131
+ return parse_rate_limit(raw)
132
+ except ValueError:
133
+ return None # create_app() rejects a malformed value at startup; never limit by accident afterwards
134
+
135
+
136
+ def proxy_hops():
137
+ """Reverse proxies in front of the app whose X-Forwarded-* headers can be trusted (SQL2API_TRUST_PROXY)."""
138
+ try:
139
+ return max(0, int(os.environ.get('SQL2API_TRUST_PROXY', 0)))
140
+ except ValueError:
141
+ return 0
142
+
143
+
144
+ def check_settings():
145
+ """Raise ValueError for a malformed setting, so a typo fails at startup instead of silently switching off a
146
+ protection."""
147
+ raw = os.environ.get('SQL2API_RATE_LIMIT', '').strip()
148
+ if raw:
149
+ parse_rate_limit(raw)
150
+
151
+
97
152
  def max_page_size():
98
153
  try:
99
154
  return max(1, int(os.environ.get('SQL2API_MAX_PAGE_SIZE', 1000)))
@@ -0,0 +1,51 @@
1
+ """Cross-origin (CORS) support for browser clients, off unless SQL2API_CORS_ORIGINS is set.
2
+
3
+ Without it a web page on another origin cannot call the API: browsers refuse to send the JSON requests and to read
4
+ the answers. CORS only tells the *browser* which sites may call; it is not authentication (use SQL2API_API_KEY).
5
+ """
6
+ from flask import Response
7
+
8
+ from . import config
9
+
10
+ ALLOWED_METHODS = 'GET, POST, PATCH, DELETE, OPTIONS'
11
+ ALLOWED_HEADERS = 'Content-Type, X-API-Key'
12
+ # Browsers hide response headers a page has not been told about, and pagination depends on these.
13
+ EXPOSED_HEADERS = 'X-Page, X-Page-Size, X-Has-More, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After'
14
+ PREFLIGHT_MAX_AGE = '600'
15
+
16
+
17
+ def allow_origin_value(origin):
18
+ """The Access-Control-Allow-Origin value for a request from ``origin``, or None when it is not allowed."""
19
+ allowed = config.cors_origins()
20
+ if allowed is None or not origin:
21
+ return None
22
+ if allowed == '*':
23
+ return '*'
24
+ return origin if origin.rstrip('/').lower() in allowed else None
25
+
26
+
27
+ def is_preflight(request):
28
+ return (request.method == 'OPTIONS' and 'Access-Control-Request-Method' in request.headers
29
+ and config.cors_origins() is not None)
30
+
31
+
32
+ def preflight_response(origin):
33
+ """Answer a browser's permission check before it sends the real request (no auth: it carries no key)."""
34
+ response = Response(status=204)
35
+ value = allow_origin_value(origin)
36
+ if value:
37
+ response.headers['Access-Control-Allow-Methods'] = ALLOWED_METHODS
38
+ response.headers['Access-Control-Allow-Headers'] = ALLOWED_HEADERS
39
+ response.headers['Access-Control-Max-Age'] = PREFLIGHT_MAX_AGE
40
+ add_headers(response, origin)
41
+ return response
42
+
43
+
44
+ def add_headers(response, origin):
45
+ value = allow_origin_value(origin)
46
+ if value:
47
+ response.headers['Access-Control-Allow-Origin'] = value
48
+ response.headers['Access-Control-Expose-Headers'] = EXPOSED_HEADERS
49
+ if value != '*':
50
+ response.headers.add('Vary', 'Origin') # the answer depends on who asked
51
+ return response