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/__init__.py
ADDED
sql2api/__main__.py
ADDED
sql2api/app.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""The Flask application: HTTP routes on top of the store, engine and formatters."""
|
|
2
|
+
import hmac
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from flask import Blueprint, Flask, Response, jsonify, redirect, request, url_for
|
|
6
|
+
from flask.json.provider import DefaultJSONProvider
|
|
7
|
+
from werkzeug.exceptions import HTTPException
|
|
8
|
+
|
|
9
|
+
from . import config, engine, openapi, sqltools, store
|
|
10
|
+
from .errors import ApiError
|
|
11
|
+
from .formats import FORMATTERS, json_default
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger('sql2api')
|
|
14
|
+
bp = Blueprint('api', __name__)
|
|
15
|
+
|
|
16
|
+
# Query-string arguments that control a request rather than supplying query parameters.
|
|
17
|
+
RESERVED_ARGS = {'format', 'page', 'page_size', 'connection_name', 'version'}
|
|
18
|
+
PUBLIC_ENDPOINTS = {'api.index', 'api.favicon', 'api.health', 'api.docs', 'api.openapi_spec'}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JSONProvider(DefaultJSONProvider):
|
|
22
|
+
default = staticmethod(json_default)
|
|
23
|
+
sort_keys = False # keep result columns in the order the query returned them
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_app():
|
|
27
|
+
from . import __version__
|
|
28
|
+
app = Flask(__name__)
|
|
29
|
+
app.json = JSONProvider(app)
|
|
30
|
+
app.config['SQL2API_VERSION'] = __version__
|
|
31
|
+
|
|
32
|
+
@app.errorhandler(ApiError)
|
|
33
|
+
def handle_api_error(error):
|
|
34
|
+
return jsonify({'error': error.message, **error.extra}), error.status
|
|
35
|
+
|
|
36
|
+
@app.errorhandler(HTTPException)
|
|
37
|
+
def handle_http_error(error):
|
|
38
|
+
return jsonify({'error': error.description}), error.code
|
|
39
|
+
|
|
40
|
+
@app.errorhandler(Exception)
|
|
41
|
+
def handle_unexpected_error(error):
|
|
42
|
+
log.exception('Unhandled error')
|
|
43
|
+
return jsonify({'error': 'An error occurred'}), 500
|
|
44
|
+
|
|
45
|
+
@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
|
|
53
|
+
|
|
54
|
+
app.register_blueprint(bp)
|
|
55
|
+
return app
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --------------------------------------------------------------------------------------
|
|
59
|
+
# Request helpers
|
|
60
|
+
# --------------------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def get_json_body(required=True):
|
|
63
|
+
data = request.get_json(silent=True)
|
|
64
|
+
if data is None and not required:
|
|
65
|
+
return {}
|
|
66
|
+
if not isinstance(data, dict):
|
|
67
|
+
raise ApiError('Request body must be a JSON object')
|
|
68
|
+
return data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_object(value, label):
|
|
72
|
+
if value is None:
|
|
73
|
+
return {}
|
|
74
|
+
if not isinstance(value, dict):
|
|
75
|
+
raise ApiError(f'{label} must be a JSON object')
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_int(value, label):
|
|
80
|
+
if value is None or value == '':
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
return int(value)
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
raise ApiError(f'{label} must be an integer') from None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_pagination():
|
|
89
|
+
"""Return (limit, offset, page) from the ?page and ?page_size query parameters."""
|
|
90
|
+
page = get_int(request.args.get('page'), 'page')
|
|
91
|
+
page_size = get_int(request.args.get('page_size'), 'page_size')
|
|
92
|
+
page = 1 if page is None else page
|
|
93
|
+
page_size = 10 if page_size is None else page_size
|
|
94
|
+
if page < 1 or page_size < 1:
|
|
95
|
+
raise ApiError('page and page_size must be positive')
|
|
96
|
+
if page_size > config.max_page_size():
|
|
97
|
+
raise ApiError(f'page_size must not exceed {config.max_page_size()}')
|
|
98
|
+
return page_size, (page - 1) * page_size, page
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_output_format(body=None):
|
|
102
|
+
output_format = str(request.args.get('format') or (body or {}).get('format') or 'json').lower()
|
|
103
|
+
if output_format not in FORMATTERS:
|
|
104
|
+
raise ApiError(f"Unsupported format '{output_format}'. Supported formats: {', '.join(FORMATTERS)}")
|
|
105
|
+
return output_format
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render(result, output_format, page, page_size):
|
|
109
|
+
response = jsonify({'message': 'No results returned'}) if not result else FORMATTERS[output_format](result)
|
|
110
|
+
response.headers['X-Page'] = str(page)
|
|
111
|
+
response.headers['X-Page-Size'] = str(page_size)
|
|
112
|
+
response.headers['X-Has-More'] = 'true' if result.has_more else 'false'
|
|
113
|
+
return response
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --------------------------------------------------------------------------------------
|
|
117
|
+
# Query execution
|
|
118
|
+
# --------------------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
@bp.route('/execute_sql', methods=['POST'])
|
|
121
|
+
def execute_sql_endpoint():
|
|
122
|
+
data = get_json_body()
|
|
123
|
+
if not data.get('sql'):
|
|
124
|
+
raise ApiError('SQL query is missing')
|
|
125
|
+
if not data.get('connection_name'):
|
|
126
|
+
raise ApiError('Connection name is missing')
|
|
127
|
+
params = get_object(data.get('params'), 'params')
|
|
128
|
+
output_format = get_output_format(data)
|
|
129
|
+
limit, offset, page = get_pagination()
|
|
130
|
+
result = engine.execute_sql(data['sql'], data['connection_name'], limit, offset, params)
|
|
131
|
+
return render(result, output_format, page, limit)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def run_saved(ref, body, url_params):
|
|
135
|
+
"""Execute a saved query (latest version unless one is requested) and record the run."""
|
|
136
|
+
path = store.resolve_saved_file(ref)
|
|
137
|
+
number, saved = store.select_version(store.load_versions(path),
|
|
138
|
+
get_int(request.args.get('version') or body.get('version'), 'version'))
|
|
139
|
+
connection_name = request.args.get('connection_name') or body.get('connection_name') \
|
|
140
|
+
or saved.get('connection_name')
|
|
141
|
+
if not connection_name:
|
|
142
|
+
raise ApiError('Connection name is missing')
|
|
143
|
+
if not isinstance(saved.get('sql_query'), str):
|
|
144
|
+
raise ApiError('Saved query has no SQL', 500)
|
|
145
|
+
|
|
146
|
+
raw = {**url_params, **get_object(body.get('params'), 'params'),
|
|
147
|
+
**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)
|
|
150
|
+
output_format = get_output_format(body)
|
|
151
|
+
limit, offset, page = get_pagination()
|
|
152
|
+
|
|
153
|
+
entry = {'executed_at': store.now(), 'connection_name': connection_name}
|
|
154
|
+
try:
|
|
155
|
+
result, elapsed_ms = engine.timed(engine.execute_sql, sql, connection_name, limit, offset, params)
|
|
156
|
+
except ApiError as error:
|
|
157
|
+
store.record_execution(path, number, {**entry, 'status': 'error', 'error': error.message})
|
|
158
|
+
raise
|
|
159
|
+
store.record_execution(path, number, {**entry, 'status': 'success', 'rows': len(result.rows),
|
|
160
|
+
'duration_ms': elapsed_ms})
|
|
161
|
+
return render(result, output_format, page, limit)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@bp.route('/execute_sql_from_file', methods=['POST'])
|
|
165
|
+
@bp.route('/execute_sql_with_parameters_from_file', methods=['POST'])
|
|
166
|
+
def execute_sql_from_file():
|
|
167
|
+
body = get_json_body()
|
|
168
|
+
return run_saved(body.get('filepath'), body, {})
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@bp.route('/q/<name>', methods=['GET', 'POST'])
|
|
172
|
+
def run_named_query(name):
|
|
173
|
+
body = get_json_body(required=False) if request.method == 'POST' else {}
|
|
174
|
+
url_params = {k: v for k, v in request.args.items() if k not in RESERVED_ARGS}
|
|
175
|
+
return run_saved(name, body, url_params)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --------------------------------------------------------------------------------------
|
|
179
|
+
# Saved queries
|
|
180
|
+
# --------------------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
@bp.route('/view_file_content', methods=['GET'])
|
|
183
|
+
def view_file_content():
|
|
184
|
+
path = store.resolve_saved_file(request.args.get('filename'))
|
|
185
|
+
with open(path, 'r') as f:
|
|
186
|
+
return jsonify({'content': f.read()}), 200
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@bp.route('/save_sql_to_file', methods=['PATCH'])
|
|
190
|
+
def save_sql_to_file():
|
|
191
|
+
data = get_json_body()
|
|
192
|
+
for field, label in (('author', 'Author'), ('description', 'Description'),
|
|
193
|
+
('sql_query', 'SQL query'), ('filename', 'Filename')):
|
|
194
|
+
if not data.get(field) or not isinstance(data[field], str):
|
|
195
|
+
raise ApiError(f'{label} is missing')
|
|
196
|
+
tags = data.get('tags', [])
|
|
197
|
+
if not isinstance(tags, (list, str)):
|
|
198
|
+
raise ApiError('tags must be a string or a list')
|
|
199
|
+
query_parameters = get_object(data.get('query_parameters'), 'query_parameters')
|
|
200
|
+
connection_name = data.get('connection_name')
|
|
201
|
+
if connection_name is not None and not isinstance(connection_name, str):
|
|
202
|
+
raise ApiError('connection_name must be a string')
|
|
203
|
+
|
|
204
|
+
query_uuid, version = store.save_version(data['filename'], {
|
|
205
|
+
'sql_query': data['sql_query'],
|
|
206
|
+
'author': data['author'],
|
|
207
|
+
'description': data['description'],
|
|
208
|
+
'tags': tags,
|
|
209
|
+
'query_parameters': query_parameters,
|
|
210
|
+
**({'connection_name': connection_name} if connection_name else {}),
|
|
211
|
+
})
|
|
212
|
+
return jsonify({'message': 'SQL query saved successfully', 'filename': data['filename'],
|
|
213
|
+
'uuid': query_uuid, 'version': version}), 200
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@bp.route('/saved_sql/<name>', methods=['DELETE'])
|
|
217
|
+
def delete_saved_query(name):
|
|
218
|
+
version = get_int(request.args.get('version'), 'version')
|
|
219
|
+
store.delete_saved(name, version)
|
|
220
|
+
what = f'Version {version} of {name}' if version is not None else name
|
|
221
|
+
return jsonify({'message': f'{what} deleted'}), 200
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@bp.route('/list_files', methods=['GET'])
|
|
225
|
+
def list_files():
|
|
226
|
+
sort_by = request.args.get('sort_by', 'name')
|
|
227
|
+
if sort_by not in ('name', 'modified'):
|
|
228
|
+
raise ApiError("sort_by must be 'name' or 'modified'")
|
|
229
|
+
sort_order = request.args.get('sort_order', 'asc')
|
|
230
|
+
if sort_order not in ('asc', 'desc'):
|
|
231
|
+
raise ApiError("sort_order must be 'asc' or 'desc'")
|
|
232
|
+
|
|
233
|
+
files = store.list_saved()
|
|
234
|
+
if sort_by == 'name':
|
|
235
|
+
def key(f):
|
|
236
|
+
return f['filename'].lower()
|
|
237
|
+
else:
|
|
238
|
+
def key(f):
|
|
239
|
+
return (f['versions'][-1].get('last_modified_at') or '') if f['versions'] else ''
|
|
240
|
+
files.sort(key=key, reverse=sort_order == 'desc')
|
|
241
|
+
return jsonify({'files': files}), 200
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --------------------------------------------------------------------------------------
|
|
245
|
+
# Connections
|
|
246
|
+
# --------------------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
@bp.route('/connections', methods=['GET'])
|
|
249
|
+
def get_connections():
|
|
250
|
+
return jsonify({'connections': store.mask_passwords(store.read_connections())}), 200
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@bp.route('/connections', methods=['PATCH'])
|
|
254
|
+
def update_connections():
|
|
255
|
+
connections = get_json_body().get('connections')
|
|
256
|
+
if not connections or not isinstance(connections, dict):
|
|
257
|
+
raise ApiError('Connections data is missing')
|
|
258
|
+
store.update_connections(connections)
|
|
259
|
+
return jsonify({'message': 'Connections updated successfully'}), 200
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@bp.route('/connections/<name>', methods=['DELETE'])
|
|
263
|
+
def delete_connection(name):
|
|
264
|
+
store.delete_connection(name)
|
|
265
|
+
return jsonify({'message': f"Connection '{name}' deleted"}), 200
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# --------------------------------------------------------------------------------------
|
|
269
|
+
# Service endpoints
|
|
270
|
+
# --------------------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
@bp.route('/', methods=['GET'])
|
|
273
|
+
def index():
|
|
274
|
+
return redirect(url_for('api.docs'))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@bp.route('/favicon.ico', methods=['GET'])
|
|
278
|
+
def favicon():
|
|
279
|
+
return '', 204
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@bp.route('/health', methods=['GET'])
|
|
283
|
+
def health():
|
|
284
|
+
from flask import current_app
|
|
285
|
+
return jsonify({'status': 'ok', 'version': current_app.config['SQL2API_VERSION']})
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@bp.route('/openapi.json', methods=['GET'])
|
|
289
|
+
def openapi_spec():
|
|
290
|
+
from flask import current_app
|
|
291
|
+
return jsonify(openapi.build_spec(current_app.config['SQL2API_VERSION']))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@bp.route('/docs', methods=['GET'])
|
|
295
|
+
def docs():
|
|
296
|
+
return Response(openapi.DOCS_HTML, mimetype='text/html')
|
sql2api/cli.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Command line entry point: ``sql2api serve`` and ``sql2api init``."""
|
|
2
|
+
import argparse
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from . import __version__, config, store
|
|
7
|
+
from .app import create_app
|
|
8
|
+
|
|
9
|
+
LOOPBACK_HOSTS = ('127.0.0.1', 'localhost', '::1')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _serve(args):
|
|
13
|
+
app = create_app()
|
|
14
|
+
if args.host not in LOOPBACK_HOSTS and not config.api_key():
|
|
15
|
+
logging.getLogger('sql2api').warning(
|
|
16
|
+
'Listening on %s without SQL2API_API_KEY set: anyone who can reach this port can run SQL '
|
|
17
|
+
'on your active connections.', args.host)
|
|
18
|
+
logging.getLogger('sql2api').info('Using %s (connections: %s)', config.home(), config.connections_file().name)
|
|
19
|
+
app.run(host=args.host, port=args.port, debug=args.debug)
|
|
20
|
+
return 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _init(args):
|
|
24
|
+
home = config.home()
|
|
25
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
(home / 'saved_sql').mkdir(exist_ok=True)
|
|
27
|
+
target = config.connections_file()
|
|
28
|
+
if target.exists():
|
|
29
|
+
print(f'{target} already exists - left untouched')
|
|
30
|
+
else:
|
|
31
|
+
store.write_json_atomic(str(target), config.EXAMPLE_CONNECTIONS)
|
|
32
|
+
print(f'Created {target}\nEdit it, set "active": true on the connections you want, then run: sql2api serve')
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_parser():
|
|
37
|
+
parser = argparse.ArgumentParser(prog='sql2api', description='Expose SQL databases as a REST API.')
|
|
38
|
+
parser.add_argument('--version', action='version', version=f'sql2api {__version__}')
|
|
39
|
+
commands = parser.add_subparsers(dest='command')
|
|
40
|
+
|
|
41
|
+
serve = commands.add_parser('serve', help='start the HTTP server (default)')
|
|
42
|
+
serve.add_argument('--host', default=os.environ.get('SQL2API_HOST', '127.0.0.1'))
|
|
43
|
+
serve.add_argument('--port', type=int, default=int(os.environ.get('SQL2API_PORT', 5000)))
|
|
44
|
+
serve.add_argument('--debug', action='store_true', default=config.env_flag('SQL2API_DEBUG'),
|
|
45
|
+
help='Flask debug mode - never use on a reachable host')
|
|
46
|
+
serve.set_defaults(func=_serve)
|
|
47
|
+
|
|
48
|
+
init = commands.add_parser('init', help='create db_connections.json and saved_sql/ in the home folder')
|
|
49
|
+
init.set_defaults(func=_init)
|
|
50
|
+
|
|
51
|
+
for sub in (serve, init):
|
|
52
|
+
sub.add_argument('--home', help='folder holding db_connections.json and saved_sql/ '
|
|
53
|
+
'(default: $SQL2API_HOME or the current directory)')
|
|
54
|
+
return parser
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv=None):
|
|
58
|
+
parser = build_parser()
|
|
59
|
+
args = parser.parse_args(argv)
|
|
60
|
+
if args.command is None:
|
|
61
|
+
args = parser.parse_args(['serve', *(argv or [])])
|
|
62
|
+
if getattr(args, 'home', None):
|
|
63
|
+
os.environ['SQL2API_HOME'] = args.home
|
|
64
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(name)s: %(message)s')
|
|
65
|
+
return args.func(args)
|
sql2api/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Runtime configuration, read from environment variables at call time."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
SUPPORTED_DB_TYPES = ('mysql', 'postgres', 'clickhouse', 'sqlite', 'h2')
|
|
6
|
+
PASSWORD_MASK = '********'
|
|
7
|
+
CONNECT_TIMEOUT = 10 # seconds
|
|
8
|
+
HISTORY_LIMIT = 50 # executions remembered per saved-query version
|
|
9
|
+
|
|
10
|
+
# Ships with the package; override with SQL2API_H2_JAR to use a different H2 version.
|
|
11
|
+
BUNDLED_H2_JAR = Path(__file__).parent / 'lib' / 'h2-2.2.224.jar'
|
|
12
|
+
|
|
13
|
+
# Shown by `sql2api init`. Every entry starts inactive so nothing connects until you opt in.
|
|
14
|
+
EXAMPLE_CONNECTIONS = {
|
|
15
|
+
'connections': {
|
|
16
|
+
'example-sqlite': {'db': 'sqlite', 'database': 'example.db', 'active': False},
|
|
17
|
+
'example-postgres': {'db': 'postgres', 'host': 'localhost', 'port': 5432, 'database': 'postgres',
|
|
18
|
+
'user': 'postgres', 'password': '${POSTGRES_PASSWORD}', 'active': False},
|
|
19
|
+
'example-mysql': {'db': 'mysql', 'host': 'localhost', 'port': 3306, 'database': 'mydb',
|
|
20
|
+
'user': 'root', 'password': '${MYSQL_PASSWORD}', 'active': False},
|
|
21
|
+
'example-clickhouse': {'db': 'clickhouse', 'host': 'localhost', 'port': 9000, 'database': 'default',
|
|
22
|
+
'user': 'default', 'password': '', 'active': False},
|
|
23
|
+
'example-h2': {'db': 'h2', 'host': 'localhost', 'database': 'test', 'user': 'SA', 'password': '',
|
|
24
|
+
'active': False},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def env_flag(name):
|
|
30
|
+
return os.environ.get(name, '').strip().lower() in ('1', 'true', 'yes', 'on')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def home():
|
|
34
|
+
"""Folder holding db_connections.json and saved_sql/ (SQL2API_HOME, default: current directory)."""
|
|
35
|
+
return Path(os.environ.get('SQL2API_HOME') or os.getcwd()).resolve()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def connections_file():
|
|
39
|
+
return home() / 'db_connections.json'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def saved_sql_dir():
|
|
43
|
+
return home() / 'saved_sql'
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def h2_jar():
|
|
47
|
+
return os.environ.get('SQL2API_H2_JAR') or str(BUNDLED_H2_JAR)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def allow_writes():
|
|
51
|
+
return env_flag('SQL2API_ALLOW_WRITES')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def api_key():
|
|
55
|
+
return os.environ.get('SQL2API_API_KEY') or None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def max_page_size():
|
|
59
|
+
try:
|
|
60
|
+
return max(1, int(os.environ.get('SQL2API_MAX_PAGE_SIZE', 1000)))
|
|
61
|
+
except ValueError:
|
|
62
|
+
return 1000
|
sql2api/engine.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Ties the pieces together: look up the connection, validate the SQL, run it, wrap the page."""
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from . import config, store
|
|
6
|
+
from .errors import ApiError
|
|
7
|
+
from .formats import ResultSetDTO
|
|
8
|
+
from .runners import RUNNERS
|
|
9
|
+
from .sqltools import validate_sql
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger('sql2api')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def execute_sql(sql, connection_name, limit, offset, params=None):
|
|
15
|
+
"""Run ``sql`` on a named connection and return the requested page as a ResultSetDTO."""
|
|
16
|
+
details = store.get_connection(connection_name)
|
|
17
|
+
sql = validate_sql(sql)
|
|
18
|
+
log.info('Executing on %s (%s), limit=%s offset=%s: %s', connection_name, details['db'], limit, offset, sql)
|
|
19
|
+
try:
|
|
20
|
+
columns, rows = RUNNERS[details['db']](details, sql, params, limit, offset, not config.allow_writes())
|
|
21
|
+
except ApiError:
|
|
22
|
+
raise
|
|
23
|
+
except ImportError as error:
|
|
24
|
+
log.exception('Missing database driver')
|
|
25
|
+
raise ApiError(f"The driver for '{details['db']}' is not installed", 500, detail=str(error)) from error
|
|
26
|
+
except Exception as error:
|
|
27
|
+
log.exception('Query on %s failed', connection_name)
|
|
28
|
+
raise ApiError('An error occurred while executing the SQL query', 500, detail=str(error)) from error
|
|
29
|
+
has_more = len(rows) > limit
|
|
30
|
+
return ResultSetDTO(rows[:limit], columns, has_more=has_more)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def timed(func, *args, **kwargs):
|
|
34
|
+
"""Call ``func`` and return (result, elapsed milliseconds)."""
|
|
35
|
+
started = time.monotonic()
|
|
36
|
+
result = func(*args, **kwargs)
|
|
37
|
+
return result, round((time.monotonic() - started) * 1000)
|
sql2api/errors.py
ADDED
sql2api/formats.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Result sets and their output formats (JSON, NDJSON, CSV, TSV, XML, YAML, XLSX)."""
|
|
2
|
+
import csv
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from datetime import date, datetime, time
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from io import BytesIO, StringIO
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from flask import Response, jsonify
|
|
12
|
+
from openpyxl import Workbook
|
|
13
|
+
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def json_default(obj):
|
|
17
|
+
"""``default=`` hook for json.dumps covering the types database drivers return."""
|
|
18
|
+
if isinstance(obj, datetime):
|
|
19
|
+
return obj.strftime('%Y-%m-%d %H:%M:%S')
|
|
20
|
+
if isinstance(obj, (date, time)):
|
|
21
|
+
return obj.isoformat()
|
|
22
|
+
if isinstance(obj, Decimal):
|
|
23
|
+
return float(obj)
|
|
24
|
+
if isinstance(obj, (bytes, bytearray, memoryview)):
|
|
25
|
+
return bytes(obj).decode('utf-8', 'replace')
|
|
26
|
+
return str(obj)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _unique(names):
|
|
30
|
+
"""Disambiguate repeated column names (e.g. ``SELECT a.id, b.id``) so no data is lost."""
|
|
31
|
+
seen, result = {}, []
|
|
32
|
+
for name in names:
|
|
33
|
+
name = str(name)
|
|
34
|
+
seen[name] = seen.get(name, 0) + 1
|
|
35
|
+
result.append(name if seen[name] == 1 else f'{name}_{seen[name]}')
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cell(value):
|
|
40
|
+
"""Coerce a value into plain built-in types that CSV, YAML and Excel writers all accept.
|
|
41
|
+
|
|
42
|
+
Some drivers (e.g. H2 through JPype) return subclasses of int/float/str, which PyYAML's safe
|
|
43
|
+
dumper rejects because it only recognises the exact built-in types.
|
|
44
|
+
"""
|
|
45
|
+
if value is None:
|
|
46
|
+
return None
|
|
47
|
+
if isinstance(value, bool):
|
|
48
|
+
return bool(value)
|
|
49
|
+
if isinstance(value, int):
|
|
50
|
+
return int(value)
|
|
51
|
+
if isinstance(value, float):
|
|
52
|
+
return float(value)
|
|
53
|
+
if isinstance(value, str):
|
|
54
|
+
return str(value)
|
|
55
|
+
if isinstance(value, Decimal):
|
|
56
|
+
return float(value)
|
|
57
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
58
|
+
return bytes(value).decode('utf-8', 'replace')
|
|
59
|
+
if isinstance(value, datetime):
|
|
60
|
+
return value.replace(tzinfo=None) if value.tzinfo else value
|
|
61
|
+
if isinstance(value, (date, time)):
|
|
62
|
+
return value
|
|
63
|
+
return str(value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _xml_name(name):
|
|
67
|
+
name = re.sub(r'[^\w.\-]', '_', name)
|
|
68
|
+
return name if re.match(r'[A-Za-z_]', name) else f'_{name}'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ResultSetDTO:
|
|
72
|
+
"""A page of query results (column names + rows) with output-format converters."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, rows, columns, has_more=False):
|
|
75
|
+
self.columns = _unique(columns)
|
|
76
|
+
self.rows = [[_cell(v) for v in row] for row in rows]
|
|
77
|
+
self.has_more = has_more
|
|
78
|
+
|
|
79
|
+
def __bool__(self):
|
|
80
|
+
return bool(self.rows)
|
|
81
|
+
|
|
82
|
+
def as_dicts(self):
|
|
83
|
+
return [dict(zip(self.columns, row)) for row in self.rows]
|
|
84
|
+
|
|
85
|
+
def _to_delimited(self, delimiter, mimetype):
|
|
86
|
+
data_io = StringIO()
|
|
87
|
+
writer = csv.writer(data_io, delimiter=delimiter)
|
|
88
|
+
writer.writerow(self.columns)
|
|
89
|
+
writer.writerows(self.rows)
|
|
90
|
+
return Response(data_io.getvalue(), mimetype=mimetype)
|
|
91
|
+
|
|
92
|
+
def to_csv(self):
|
|
93
|
+
return self._to_delimited(',', 'text/csv')
|
|
94
|
+
|
|
95
|
+
def to_tsv(self):
|
|
96
|
+
return self._to_delimited('\t', 'text/tab-separated-values')
|
|
97
|
+
|
|
98
|
+
def to_json(self):
|
|
99
|
+
return jsonify(self.as_dicts())
|
|
100
|
+
|
|
101
|
+
def to_ndjson(self):
|
|
102
|
+
lines = (json.dumps(row, default=json_default) for row in self.as_dicts())
|
|
103
|
+
return Response('\n'.join(lines) + '\n', mimetype='application/x-ndjson')
|
|
104
|
+
|
|
105
|
+
def to_xml(self):
|
|
106
|
+
root = ET.Element('data')
|
|
107
|
+
tags = [_xml_name(c) for c in self.columns]
|
|
108
|
+
for row in self.rows:
|
|
109
|
+
item = ET.SubElement(root, 'item')
|
|
110
|
+
for tag, value in zip(tags, row):
|
|
111
|
+
ET.SubElement(item, tag).text = '' if value is None else str(value)
|
|
112
|
+
return Response(ET.tostring(root, encoding='unicode', method='xml'), mimetype='application/xml')
|
|
113
|
+
|
|
114
|
+
def to_yaml(self):
|
|
115
|
+
return Response(yaml.safe_dump(self.as_dicts(), default_flow_style=False, sort_keys=False,
|
|
116
|
+
allow_unicode=True),
|
|
117
|
+
mimetype='application/x-yaml')
|
|
118
|
+
|
|
119
|
+
def to_xlsx(self):
|
|
120
|
+
wb = Workbook()
|
|
121
|
+
ws = wb.active
|
|
122
|
+
ws.append(self.columns)
|
|
123
|
+
for row in self.rows:
|
|
124
|
+
ws.append([ILLEGAL_CHARACTERS_RE.sub('', v) if isinstance(v, str) else v for v in row])
|
|
125
|
+
excel_data = BytesIO()
|
|
126
|
+
wb.save(excel_data)
|
|
127
|
+
return Response(
|
|
128
|
+
excel_data.getvalue(),
|
|
129
|
+
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
130
|
+
headers={'Content-Disposition': 'attachment;filename=result.xlsx'},
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
FORMATTERS = {
|
|
135
|
+
'json': ResultSetDTO.to_json,
|
|
136
|
+
'ndjson': ResultSetDTO.to_ndjson,
|
|
137
|
+
'csv': ResultSetDTO.to_csv,
|
|
138
|
+
'tsv': ResultSetDTO.to_tsv,
|
|
139
|
+
'xml': ResultSetDTO.to_xml,
|
|
140
|
+
'yaml': ResultSetDTO.to_yaml,
|
|
141
|
+
'xlsx': ResultSetDTO.to_xlsx,
|
|
142
|
+
}
|
|
Binary file
|