sql2api 0.1.0__py3-none-any.whl
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/__init__.py +6 -0
- sql2api/__main__.py +3 -0
- sql2api/app.py +296 -0
- sql2api/cli.py +65 -0
- sql2api/config.py +62 -0
- sql2api/engine.py +37 -0
- sql2api/errors.py +8 -0
- sql2api/formats.py +142 -0
- sql2api/lib/h2-2.2.224.jar +0 -0
- sql2api/openapi.py +127 -0
- sql2api/runners.py +142 -0
- sql2api/sqltools.py +156 -0
- sql2api/store.py +257 -0
- sql2api-0.1.0.dist-info/METADATA +208 -0
- sql2api-0.1.0.dist-info/RECORD +19 -0
- sql2api-0.1.0.dist-info/WHEEL +5 -0
- sql2api-0.1.0.dist-info/entry_points.txt +2 -0
- sql2api-0.1.0.dist-info/licenses/LICENSE +21 -0
- sql2api-0.1.0.dist-info/top_level.txt +1 -0
sql2api/store.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Persistence: the connection registry and the saved-query files (plain JSON on disk).
|
|
2
|
+
|
|
3
|
+
Everything that touches those files goes through here so that access stays confined to the
|
|
4
|
+
configured home folder and read-modify-write cycles are serialised.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import threading
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from . import config
|
|
14
|
+
from .errors import ApiError
|
|
15
|
+
|
|
16
|
+
# Serialises read-modify-write cycles on the JSON files this app manages.
|
|
17
|
+
lock = threading.RLock()
|
|
18
|
+
|
|
19
|
+
_ENV_REF_RE = re.compile(r'\$\{(\w+)\}')
|
|
20
|
+
_FILENAME_RE = re.compile(r'^[\w .\-]{1,100}$')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def write_json_atomic(path, data):
|
|
24
|
+
tmp_path = f'{path}.{uuid.uuid4().hex}.tmp'
|
|
25
|
+
with open(tmp_path, 'w') as f:
|
|
26
|
+
json.dump(data, f, indent=4)
|
|
27
|
+
os.replace(tmp_path, path)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def now():
|
|
31
|
+
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------------------------
|
|
35
|
+
# Connections
|
|
36
|
+
# --------------------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
def read_connections():
|
|
39
|
+
try:
|
|
40
|
+
with open(config.connections_file(), 'r') as f:
|
|
41
|
+
data = json.load(f)
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
return {}
|
|
44
|
+
return data.get('connections', {})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _expand_env(value):
|
|
48
|
+
"""Replace ${VAR} references in a connection value with the environment variable's content."""
|
|
49
|
+
if not isinstance(value, str):
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
def replace(match):
|
|
53
|
+
name = match.group(1)
|
|
54
|
+
if name not in os.environ:
|
|
55
|
+
raise ApiError(f"Environment variable '{name}' referenced by the connection is not set", 500)
|
|
56
|
+
return os.environ[name]
|
|
57
|
+
|
|
58
|
+
return _ENV_REF_RE.sub(replace, value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_connection(connection_name):
|
|
62
|
+
"""Return the usable (active, env-expanded) details of a named connection."""
|
|
63
|
+
if not isinstance(connection_name, str) or not connection_name:
|
|
64
|
+
raise ApiError('Connection name is missing')
|
|
65
|
+
details = read_connections().get(connection_name)
|
|
66
|
+
if not details:
|
|
67
|
+
raise ApiError(f"Connection '{connection_name}' not found", 404)
|
|
68
|
+
if not details.get('active', False):
|
|
69
|
+
raise ApiError(f"The connection '{connection_name}' is currently not active. "
|
|
70
|
+
"To use it, it must be set to active.", 403)
|
|
71
|
+
if details.get('db') not in config.SUPPORTED_DB_TYPES:
|
|
72
|
+
raise ApiError('Unsupported database type')
|
|
73
|
+
return {key: _expand_env(value) for key, value in details.items()}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def mask_passwords(connections):
|
|
77
|
+
"""Hide stored passwords; ${VAR} references are not secrets and stay visible."""
|
|
78
|
+
def mask(conn):
|
|
79
|
+
password = conn.get('password')
|
|
80
|
+
if password and not _ENV_REF_RE.fullmatch(str(password)):
|
|
81
|
+
return {**conn, 'password': config.PASSWORD_MASK}
|
|
82
|
+
return conn
|
|
83
|
+
|
|
84
|
+
return {name: mask(conn) for name, conn in connections.items()}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def update_connections(connections):
|
|
88
|
+
for name, details in connections.items():
|
|
89
|
+
if not isinstance(details, dict) or details.get('db') not in config.SUPPORTED_DB_TYPES:
|
|
90
|
+
raise ApiError(f"Connection '{name}' must be an object whose 'db' is one of: "
|
|
91
|
+
f"{', '.join(config.SUPPORTED_DB_TYPES)}")
|
|
92
|
+
with lock:
|
|
93
|
+
existing = read_connections()
|
|
94
|
+
for name, details in connections.items():
|
|
95
|
+
# GET masks passwords, so a client echoing the mask back means "keep the current one".
|
|
96
|
+
if details.get('password') == config.PASSWORD_MASK and name in existing:
|
|
97
|
+
details = {**details, 'password': existing[name].get('password', '')}
|
|
98
|
+
existing[name] = details
|
|
99
|
+
config.home().mkdir(parents=True, exist_ok=True)
|
|
100
|
+
write_json_atomic(config.connections_file(), {'connections': existing})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def delete_connection(name):
|
|
104
|
+
with lock:
|
|
105
|
+
existing = read_connections()
|
|
106
|
+
if name not in existing:
|
|
107
|
+
raise ApiError(f"Connection '{name}' not found", 404)
|
|
108
|
+
del existing[name]
|
|
109
|
+
write_json_atomic(config.connections_file(), {'connections': existing})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --------------------------------------------------------------------------------------
|
|
113
|
+
# Saved queries (all access is confined to the saved_sql folder)
|
|
114
|
+
# --------------------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def saved_path_for_name(filename):
|
|
117
|
+
"""Path of the saved-query file for a client-supplied name (without extension)."""
|
|
118
|
+
if not isinstance(filename, str) or not _FILENAME_RE.match(filename) or filename.startswith('.'):
|
|
119
|
+
raise ApiError("Filename may only contain letters, digits, spaces, '.', '_' and '-'")
|
|
120
|
+
return os.path.join(config.saved_sql_dir(), f'{filename}.json')
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolve_saved_file(ref):
|
|
124
|
+
"""Resolve a client-supplied file reference to an existing .json file inside the saved_sql folder.
|
|
125
|
+
|
|
126
|
+
Accepts a bare name ("cht" or "cht.json"), a path relative to the home folder
|
|
127
|
+
("saved_sql/cht.json") or an absolute path - as long as it ends up in the saved_sql folder.
|
|
128
|
+
"""
|
|
129
|
+
if not isinstance(ref, str) or not ref.strip():
|
|
130
|
+
raise ApiError('Filename is missing')
|
|
131
|
+
saved_dir = str(config.saved_sql_dir())
|
|
132
|
+
if os.path.isabs(ref):
|
|
133
|
+
candidate = ref
|
|
134
|
+
elif os.sep in ref or '/' in ref:
|
|
135
|
+
candidate = os.path.join(config.home(), ref)
|
|
136
|
+
else:
|
|
137
|
+
candidate = os.path.join(saved_dir, ref if ref.endswith('.json') else f'{ref}.json')
|
|
138
|
+
candidate = os.path.realpath(candidate)
|
|
139
|
+
if os.path.dirname(candidate) != os.path.realpath(saved_dir) or not candidate.endswith('.json'):
|
|
140
|
+
raise ApiError('Only .json files inside the saved_sql folder can be accessed', 403)
|
|
141
|
+
if not os.path.isfile(candidate):
|
|
142
|
+
raise ApiError('File not found', 404)
|
|
143
|
+
return candidate
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_versions(path):
|
|
147
|
+
try:
|
|
148
|
+
with open(path, 'r') as f:
|
|
149
|
+
content = json.load(f)
|
|
150
|
+
except json.JSONDecodeError:
|
|
151
|
+
raise ApiError('Saved query file is not valid JSON', 500) from None
|
|
152
|
+
if not isinstance(content, dict):
|
|
153
|
+
raise ApiError('Saved query file has an unexpected structure', 500)
|
|
154
|
+
return content
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def version_numbers(content):
|
|
158
|
+
return [int(v) for v in content if v.isdigit()]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def select_version(content, version=None):
|
|
162
|
+
"""Return (number, data) for the requested version, or the latest one when none is given."""
|
|
163
|
+
if version is None:
|
|
164
|
+
numbers = version_numbers(content)
|
|
165
|
+
if not numbers:
|
|
166
|
+
raise ApiError('No valid versions found in the file')
|
|
167
|
+
version = max(numbers)
|
|
168
|
+
data = content.get(str(version))
|
|
169
|
+
if not isinstance(data, dict):
|
|
170
|
+
raise ApiError(f'Version {version} not found', 404)
|
|
171
|
+
return int(version), data
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def save_version(filename, fields):
|
|
175
|
+
"""Store ``fields`` as the next version of a saved query; returns (uuid, version number)."""
|
|
176
|
+
path = saved_path_for_name(filename)
|
|
177
|
+
query_uuid = str(uuid.uuid4())
|
|
178
|
+
timestamp = now()
|
|
179
|
+
with lock:
|
|
180
|
+
os.makedirs(config.saved_sql_dir(), exist_ok=True)
|
|
181
|
+
versions = load_versions(path) if os.path.exists(path) else {}
|
|
182
|
+
number = max(version_numbers(versions), default=0) + 1
|
|
183
|
+
versions[str(number)] = {
|
|
184
|
+
'uuid': query_uuid,
|
|
185
|
+
**fields,
|
|
186
|
+
'created_at': timestamp,
|
|
187
|
+
'last_modified_at': timestamp,
|
|
188
|
+
'status': 'active',
|
|
189
|
+
'version': number,
|
|
190
|
+
'execution_history': [],
|
|
191
|
+
}
|
|
192
|
+
write_json_atomic(path, versions)
|
|
193
|
+
return query_uuid, number
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def delete_saved(ref, version=None):
|
|
197
|
+
"""Delete a saved query file, or a single version of it (the file goes with its last version)."""
|
|
198
|
+
path = resolve_saved_file(ref)
|
|
199
|
+
with lock:
|
|
200
|
+
if version is None:
|
|
201
|
+
os.remove(path)
|
|
202
|
+
return
|
|
203
|
+
content = load_versions(path)
|
|
204
|
+
if str(version) not in content:
|
|
205
|
+
raise ApiError(f'Version {version} not found', 404)
|
|
206
|
+
del content[str(version)]
|
|
207
|
+
if version_numbers(content):
|
|
208
|
+
write_json_atomic(path, content)
|
|
209
|
+
else:
|
|
210
|
+
os.remove(path)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def record_execution(path, version, entry):
|
|
214
|
+
"""Append to a saved version's execution_history (newest last, capped). Never raises."""
|
|
215
|
+
try:
|
|
216
|
+
with lock:
|
|
217
|
+
content = load_versions(path)
|
|
218
|
+
data = content.get(str(version))
|
|
219
|
+
if not isinstance(data, dict):
|
|
220
|
+
return
|
|
221
|
+
history = data.setdefault('execution_history', [])
|
|
222
|
+
history.append(entry)
|
|
223
|
+
del history[:-config.HISTORY_LIMIT]
|
|
224
|
+
write_json_atomic(path, content)
|
|
225
|
+
except (OSError, ApiError):
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def list_saved():
|
|
230
|
+
"""Return every saved query with its versions' metadata (SQL text is not included)."""
|
|
231
|
+
saved_dir = str(config.saved_sql_dir())
|
|
232
|
+
if not os.path.isdir(saved_dir):
|
|
233
|
+
raise ApiError('Folder not found', 404)
|
|
234
|
+
files = []
|
|
235
|
+
for filename in os.listdir(saved_dir):
|
|
236
|
+
file_path = os.path.join(saved_dir, filename)
|
|
237
|
+
if not (os.path.isfile(file_path) and filename.endswith('.json')):
|
|
238
|
+
continue
|
|
239
|
+
try:
|
|
240
|
+
content = load_versions(file_path)
|
|
241
|
+
except ApiError:
|
|
242
|
+
continue
|
|
243
|
+
versions = [{
|
|
244
|
+
'version': int(version),
|
|
245
|
+
'author': data.get('author'),
|
|
246
|
+
'description': data.get('description'),
|
|
247
|
+
'tags': data.get('tags', []),
|
|
248
|
+
'query_parameters': data.get('query_parameters', {}),
|
|
249
|
+
'connection_name': data.get('connection_name'),
|
|
250
|
+
'created_at': data.get('created_at'),
|
|
251
|
+
'last_modified_at': data.get('last_modified_at'),
|
|
252
|
+
'status': data.get('status'),
|
|
253
|
+
'execution_history': data.get('execution_history', []),
|
|
254
|
+
} for version, data in content.items() if version.isdigit() and isinstance(data, dict)]
|
|
255
|
+
versions.sort(key=lambda v: v['version'])
|
|
256
|
+
files.append({'filename': filename[:-5], 'versions': versions})
|
|
257
|
+
return files
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sql2api
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
Author-email: Anantha Raju C <arcswdev@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AnanthaRajuC/SQL2API
|
|
8
|
+
Project-URL: Issues, https://github.com/AnanthaRajuC/SQL2API/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/AnanthaRajuC/SQL2API/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: sql,api,rest,flask,database,mysql,postgresql,clickhouse,sqlite,h2
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: Flask
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: Flask>=2.2
|
|
22
|
+
Requires-Dist: PyYAML>=6.0
|
|
23
|
+
Requires-Dist: openpyxl>=3.1
|
|
24
|
+
Provides-Extra: mysql
|
|
25
|
+
Requires-Dist: mysql-connector-python>=8.0; extra == "mysql"
|
|
26
|
+
Provides-Extra: postgres
|
|
27
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == "postgres"
|
|
28
|
+
Provides-Extra: clickhouse
|
|
29
|
+
Requires-Dist: clickhouse-driver>=0.2; extra == "clickhouse"
|
|
30
|
+
Provides-Extra: h2
|
|
31
|
+
Requires-Dist: JayDeBeApi>=1.2; extra == "h2"
|
|
32
|
+
Requires-Dist: JPype1>=1.4; extra == "h2"
|
|
33
|
+
Provides-Extra: all
|
|
34
|
+
Requires-Dist: sql2api[clickhouse,h2,mysql,postgres]; extra == "all"
|
|
35
|
+
Provides-Extra: server
|
|
36
|
+
Requires-Dist: gunicorn>=21; extra == "server"
|
|
37
|
+
Provides-Extra: dev
|
|
38
|
+
Requires-Dist: sql2api[all]; extra == "dev"
|
|
39
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
40
|
+
Dynamic: license-file
|
|
41
|
+
|
|
42
|
+
# SQL2API
|
|
43
|
+
|
|
44
|
+
[](https://github.com/AnanthaRajuC/SQL2API/actions/workflows/ci.yml)
|
|
45
|
+
[](LICENSE)
|
|
46
|
+

|
|
47
|
+
|
|
48
|
+
**Turn SQL into a REST API.** SQL2API is a small Flask service that runs SQL against your databases and returns the
|
|
49
|
+
results as JSON, NDJSON, CSV, TSV, XML, YAML or Excel. Save a query once and it becomes an endpoint with typed,
|
|
50
|
+
injection-safe parameters, versioning and run history.
|
|
51
|
+
|
|
52
|
+
~~~bash
|
|
53
|
+
$ curl 'http://127.0.0.1:5000/q/actor_by_id?id=7'
|
|
54
|
+
[{"actor_id": 7, "first_name": "GRACE", "last_name": "MOSTEL"}]
|
|
55
|
+
|
|
56
|
+
$ curl 'http://127.0.0.1:5000/q/films_by_rating?rating=PG&max_length=60&format=csv&page_size=2'
|
|
57
|
+
film_id,title,rating,length
|
|
58
|
+
410,HEAVEN FREEDOM,PG,48
|
|
59
|
+
443,HURRICANE AFFAIR,PG,49
|
|
60
|
+
~~~
|
|
61
|
+
|
|
62
|
+
| Database | JSON | NDJSON | XML | YAML | CSV | TSV | XLSX |
|
|
63
|
+
|------------|:----:|:------:|:---:|:----:|:---:|:---:|:----:|
|
|
64
|
+
| MySQL | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
65
|
+
| PostgreSQL | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
66
|
+
| ClickHouse | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
67
|
+
| SQLite | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
68
|
+
| H2 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
- **Ad-hoc queries** - `POST /execute_sql` with SQL and a connection name.
|
|
73
|
+
- **Saved, versioned queries** - every save creates a new version; `GET /q/<name>?id=7` runs the latest one
|
|
74
|
+
(or `?version=1`). Each run is recorded in the query's execution history.
|
|
75
|
+
- **Bound parameters** - write `WHERE id = :id` and the value is sent to the database separately from the SQL, so it
|
|
76
|
+
cannot inject anything. Declare types (`{"id": "int"}`) and query-string values are converted for you.
|
|
77
|
+
- **Pagination** - `?page=2&page_size=50`, with `X-Has-More` telling you whether another page exists.
|
|
78
|
+
- **Read-only by default** - only single `SELECT`/`WITH`/`SHOW`/`DESCRIBE`/`EXPLAIN` statements run, and sessions are
|
|
79
|
+
opened read-only where the database supports it.
|
|
80
|
+
- **Secrets stay out of files** - `"password": "${PG_PASSWORD}"` in `db_connections.json` reads the environment.
|
|
81
|
+
- **Self-documenting** - OpenAPI at `/openapi.json`, Swagger UI at `/docs`.
|
|
82
|
+
|
|
83
|
+
## Install
|
|
84
|
+
|
|
85
|
+
~~~bash
|
|
86
|
+
pip install "sql2api[postgres]" # pick the drivers you need: mysql, postgres, clickhouse, h2
|
|
87
|
+
# or everything: pip install "sql2api[all]"
|
|
88
|
+
~~~
|
|
89
|
+
|
|
90
|
+
SQLite needs no extra driver. H2 also needs a Java runtime (the H2 JDBC jar is bundled).
|
|
91
|
+
From a clone: `pip install -e ".[dev]"`. Or use Docker - see [below](#docker).
|
|
92
|
+
|
|
93
|
+
## Quick start
|
|
94
|
+
|
|
95
|
+
The repository ships two sample SQLite databases and a couple of saved queries:
|
|
96
|
+
|
|
97
|
+
~~~bash
|
|
98
|
+
cd examples
|
|
99
|
+
cp db_connections.example.json db_connections.json
|
|
100
|
+
sql2api serve # http://127.0.0.1:5000
|
|
101
|
+
~~~
|
|
102
|
+
|
|
103
|
+
~~~bash
|
|
104
|
+
curl -X POST 'http://127.0.0.1:5000/execute_sql?page_size=3' -H 'Content-Type: application/json' \
|
|
105
|
+
-d '{"sql": "SELECT * FROM actor WHERE actor_id > :min", "params": {"min": 10}, "connection_name": "sakila-sqlite"}'
|
|
106
|
+
~~~
|
|
107
|
+
|
|
108
|
+
Open <http://127.0.0.1:5000/docs> for the interactive API reference.
|
|
109
|
+
|
|
110
|
+
For your own databases, run `sql2api init` in an empty folder: it creates `db_connections.json` (inactive templates for
|
|
111
|
+
every supported database) and `saved_sql/`. Edit the file, set `"active": true`, and start the server there.
|
|
112
|
+
|
|
113
|
+
## Saving a query as an endpoint
|
|
114
|
+
|
|
115
|
+
~~~bash
|
|
116
|
+
curl -X PATCH http://127.0.0.1:5000/save_sql_to_file -H 'Content-Type: application/json' -d '{
|
|
117
|
+
"filename": "actor_by_id",
|
|
118
|
+
"sql_query": "SELECT * FROM actor WHERE actor_id = :id",
|
|
119
|
+
"query_parameters": {"id": "int"},
|
|
120
|
+
"connection_name": "sakila-sqlite",
|
|
121
|
+
"author": "me", "description": "Look up an actor"
|
|
122
|
+
}'
|
|
123
|
+
|
|
124
|
+
curl 'http://127.0.0.1:5000/q/actor_by_id?id=7&format=yaml'
|
|
125
|
+
~~~
|
|
126
|
+
|
|
127
|
+
Saving again under the same name adds version 2; `DELETE /saved_sql/actor_by_id?version=1` removes one version.
|
|
128
|
+
|
|
129
|
+
## Configuration
|
|
130
|
+
|
|
131
|
+
Everything is configured through environment variables (all optional):
|
|
132
|
+
|
|
133
|
+
| Variable | Default | Effect |
|
|
134
|
+
|----------|---------|--------|
|
|
135
|
+
| `SQL2API_HOME` | current directory | Folder holding `db_connections.json` and `saved_sql/`. |
|
|
136
|
+
| `SQL2API_ALLOW_WRITES` | off | Allow `INSERT`/`UPDATE`/DDL. Otherwise only single read-only statements are accepted. |
|
|
137
|
+
| `SQL2API_API_KEY` | unset | When set, every request (except `/health` and `/docs`) needs a matching `X-API-Key` header. |
|
|
138
|
+
| `SQL2API_MAX_PAGE_SIZE` | `1000` | Upper limit for `page_size`. |
|
|
139
|
+
| `SQL2API_HOST` / `SQL2API_PORT` | `127.0.0.1` / `5000` | Bind address for `sql2api serve`. |
|
|
140
|
+
| `SQL2API_DEBUG` | off | Flask debug mode. Never enable on a reachable host. |
|
|
141
|
+
| `SQL2API_H2_JAR` | bundled | Path to a different H2 JDBC jar. |
|
|
142
|
+
|
|
143
|
+
## Security
|
|
144
|
+
|
|
145
|
+
SQL2API runs whatever SQL it is given against your databases, so it ships locked down and expects you to finish the job:
|
|
146
|
+
|
|
147
|
+
- Set `SQL2API_API_KEY` and serve over TLS (put it behind a reverse proxy).
|
|
148
|
+
- Connect with a database account that only has the privileges the API needs - the read-only guard is
|
|
149
|
+
defence in depth, not a replacement for grants. (H2's driver cannot enforce read-only, so H2 relies on the guard.)
|
|
150
|
+
- Use bound `:name` parameters. The older `{name}` placeholders paste text into the SQL and are therefore restricted
|
|
151
|
+
to numbers and plain text.
|
|
152
|
+
- Saved-query files are only read from `saved_sql/`; passwords are never returned by the API.
|
|
153
|
+
|
|
154
|
+
See [SECURITY.md](SECURITY.md) to report a vulnerability.
|
|
155
|
+
|
|
156
|
+
## Docker
|
|
157
|
+
|
|
158
|
+
~~~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
|
|
161
|
+
~~~
|
|
162
|
+
|
|
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).
|
|
165
|
+
|
|
166
|
+
## API overview
|
|
167
|
+
|
|
168
|
+
| Endpoint | Method | Purpose |
|
|
169
|
+
|----------|--------|---------|
|
|
170
|
+
| `/execute_sql` | POST | Run ad-hoc SQL (`sql`, `connection_name`, optional `params`). |
|
|
171
|
+
| `/q/<name>` | GET, POST | Run a saved query; query-string or body values become parameters. |
|
|
172
|
+
| `/save_sql_to_file` | PATCH | Save a query (creates the next version). |
|
|
173
|
+
| `/list_files` | GET | List saved queries and their versions (`sort_by`, `sort_order`). |
|
|
174
|
+
| `/saved_sql/<name>` | DELETE | Delete a saved query or one `?version=`. |
|
|
175
|
+
| `/view_file_content` | GET | Raw content of a saved query file. |
|
|
176
|
+
| `/execute_sql_from_file`, `/execute_sql_with_parameters_from_file` | POST | Run a saved query by `filepath` (same as `/q/<name>`). |
|
|
177
|
+
| `/connections` | GET, PATCH | List (passwords masked) / add / update connections. |
|
|
178
|
+
| `/connections/<name>` | DELETE | Remove a connection. |
|
|
179
|
+
| `/health`, `/docs`, `/openapi.json` | GET | Liveness, Swagger UI, OpenAPI spec. |
|
|
180
|
+
|
|
181
|
+
Full details are in [documentation/API.md](documentation/API.md).
|
|
182
|
+
|
|
183
|
+
## Development
|
|
184
|
+
|
|
185
|
+
~~~bash
|
|
186
|
+
pip install -e ".[dev]"
|
|
187
|
+
ruff check .
|
|
188
|
+
python -m unittest discover -s tests -t .
|
|
189
|
+
~~~
|
|
190
|
+
|
|
191
|
+
The integration tests in `tests/test_integration.py` run against real MySQL, PostgreSQL, ClickHouse and H2 servers when
|
|
192
|
+
the matching `SQL2API_IT_*` variables are set, and are skipped otherwise; CI runs them against service containers.
|
|
193
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the pull request process, and [CHANGELOG.md](CHANGELOG.md) for what changed.
|
|
194
|
+
|
|
195
|
+
## Third-party components
|
|
196
|
+
|
|
197
|
+
The wheel bundles the [H2 Database](https://h2database.com) JDBC driver (MPL 2.0 / EPL 1.0). The sample SQLite
|
|
198
|
+
databases in `examples/` derive from the Sakila and Chinook sample datasets.
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
[MIT](LICENSE) © Anantha Raju C
|
|
203
|
+
|
|
204
|
+
## Contact
|
|
205
|
+
|
|
206
|
+
Anantha Raju C - [@anantharajuc](https://twitter.com/anantharajuc) - arcswdev@gmail.com
|
|
207
|
+
|
|
208
|
+
Project link: <https://github.com/AnanthaRajuC/SQL2API>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
sql2api/__init__.py,sha256=FeKzfwtM7UszmZ7HBQ8AQi_WNHabfDeAIpWARwhqfys,185
|
|
2
|
+
sql2api/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
sql2api/app.py,sha256=VwoiN9TC2a8wPbCyf3IFMuWsnjteH5scrItLKIgbOoI,11447
|
|
4
|
+
sql2api/cli.py,sha256=1L3RYiCgnh1w_speXB4RPqxNWs5kR7kT68X9L-aqDLk,2712
|
|
5
|
+
sql2api/config.py,sha256=goaJF810BKzyeENR-6lc1l6k1OdSaF4-3CJkpMWv4gU,2222
|
|
6
|
+
sql2api/engine.py,sha256=b4uS0FZAWuUipr_CpAWjyVYPyzTtiDD2HDnfvI1PNP0,1551
|
|
7
|
+
sql2api/errors.py,sha256=nJddO1gAjxQZH7-IXf3Bt8ruxv3EhVw6t6viFKfluG4,264
|
|
8
|
+
sql2api/formats.py,sha256=SbLDZ-yQIt7pcWXYi8WNYEmG6JNqoeeGeRwIg5XHmos,4817
|
|
9
|
+
sql2api/openapi.py,sha256=evoO6KsPN0UjvdxYR0dwHTMygAI-7FIPxhURh7i4rXY,8597
|
|
10
|
+
sql2api/runners.py,sha256=tiP4-zNaD-bSdTycr1OBiHx00A-KESpLXXudhAhwBFw,5218
|
|
11
|
+
sql2api/sqltools.py,sha256=HPNC2hCSkBqiJ8O-WYWj7gwILnNOtyVD5A_58SG3lik,6657
|
|
12
|
+
sql2api/store.py,sha256=OHLE1PU7ZrqQcPJec4jJbqNeFBDSt7TOUO8NfH7QLJg,9890
|
|
13
|
+
sql2api/lib/h2-2.2.224.jar,sha256=udjxk1itqCpPbrWxdMbP4yCjdbWpy1pP5FbWI-blVJc,2614933
|
|
14
|
+
sql2api-0.1.0.dist-info/licenses/LICENSE,sha256=ufgSbLEx8zM6J0kFgDoK-6NNb0gqrsFfCb-CnNbZGg0,1071
|
|
15
|
+
sql2api-0.1.0.dist-info/METADATA,sha256=xrC-ppNP7rufNkYTdi_eFDmkieHx_jEjirZOFsf8F38,9461
|
|
16
|
+
sql2api-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
17
|
+
sql2api-0.1.0.dist-info/entry_points.txt,sha256=63gZjCA84qTkErE2iiuTiGS7GtdamvOqi2elEXwy_zE,45
|
|
18
|
+
sql2api-0.1.0.dist-info/top_level.txt,sha256=dicWZ6j0AE1xBDKr0-qh2CkuP7Yc1CWQf_T6r8LS6ms,8
|
|
19
|
+
sql2api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Anantha Raju C
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sql2api
|