queryapigate 0.5.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.
queryapigate/app.py ADDED
@@ -0,0 +1,844 @@
1
+ """The Flask application: HTTP routes on top of the store, engine and formatters."""
2
+ import logging
3
+ import math
4
+ import time
5
+ import uuid
6
+
7
+ from flask import Blueprint, Flask, Response, current_app, g, jsonify, redirect, request, url_for
8
+ from flask.json.provider import DefaultJSONProvider
9
+ from werkzeug.exceptions import HTTPException
10
+ from werkzeug.middleware.proxy_fix import ProxyFix
11
+
12
+ from . import apikeys, cache, config, cors, engine, logging_setup, metrics, openapi, pool, schema, sqltools, store, ui
13
+ from . import params as param_rules
14
+ from .errors import ApiError
15
+ from .formats import FORMATTERS, STREAM_FORMATTERS, json_default, stream_response
16
+ from .ratelimit import KeyRateLimiters, RateLimiter
17
+
18
+ log = logging.getLogger('queryapigate')
19
+ bp = Blueprint('api', __name__)
20
+
21
+ # Query-string arguments that control a request rather than supplying query parameters.
22
+ RESERVED_ARGS = {'format', 'page', 'page_size', 'connection_name', 'version', 'timeout', 'stream'}
23
+ PUBLIC_ENDPOINTS = {'api.index', 'api.favicon', 'api.health', 'api.docs', 'api.openapi_spec', 'api.admin_ui',
24
+ 'api.metrics_endpoint'}
25
+ RATE_LIMIT_EXEMPT = {'api.health', 'api.metrics_endpoint'} # monitoring keeps working while a client is throttled
26
+ ACCESS_LOG_QUIET = {'api.health', 'api.metrics_endpoint'} # polled too often to log every hit
27
+
28
+
29
+ class JSONProvider(DefaultJSONProvider):
30
+ default = staticmethod(json_default)
31
+ sort_keys = False # keep result columns in the order the query returned them
32
+
33
+
34
+ def create_app():
35
+ from . import __version__
36
+ config.check_settings()
37
+ logging_setup.configure(log)
38
+ app = Flask(__name__)
39
+ hops = config.proxy_hops()
40
+ if hops: # behind reverse proxies: take the client address and scheme from their X-Forwarded-* headers
41
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=hops, x_proto=hops, x_host=hops)
42
+ app.extensions['queryapigate_limiter'] = RateLimiter()
43
+ app.extensions['queryapigate_key_limiter'] = KeyRateLimiters()
44
+ app.extensions['queryapigate_cache'] = cache.ResponseCache()
45
+ if config.cors_origins() == '*' and not config.api_key():
46
+ log.warning('QUERYAPIGATE_CORS_ORIGINS=* without QUERYAPIGATE_API_KEY: any website a user visits can call '
47
+ 'this API from their browser and reach every active connection. Set an API key or list the '
48
+ 'origins.')
49
+ if apikeys.any_configured() and not config.api_key():
50
+ log.warning('Scoped API keys exist but QUERYAPIGATE_API_KEY is not set: no key can manage connections, saved '
51
+ 'queries or other API keys until it is - only a scoped key\'s own allowed connections work.')
52
+ if config.secret_key():
53
+ # Encrypt any literal password already on disk immediately, rather than waiting for its next
54
+ # PATCH /connections - a connection saved before QUERYAPIGATE_SECRET_KEY existed benefits right away.
55
+ store.encrypt_plaintext_passwords_in_place()
56
+ else:
57
+ encrypted = store.encrypted_password_connections()
58
+ if encrypted:
59
+ log.warning('Connection(s) %s have an encrypted password but QUERYAPIGATE_SECRET_KEY is not set - '
60
+ 'they cannot be used until the key that encrypted them is restored.',
61
+ ', '.join(encrypted))
62
+ plaintext = store.plaintext_password_connections()
63
+ if plaintext:
64
+ log.warning("Connection(s) %s store a literal password in %s. Consider a \"${VAR}\" reference to an "
65
+ "environment variable instead - it reads the same way but keeps the secret out of the file, "
66
+ "or set QUERYAPIGATE_SECRET_KEY to encrypt it at rest automatically.",
67
+ ', '.join(plaintext), config.connections_file())
68
+ app.json = JSONProvider(app)
69
+ app.config['QUERYAPIGATE_VERSION'] = __version__
70
+
71
+ @app.errorhandler(ApiError)
72
+ def handle_api_error(error):
73
+ return jsonify({'error': error.message, **error.extra}), error.status
74
+
75
+ @app.errorhandler(HTTPException)
76
+ def handle_http_error(error):
77
+ return jsonify({'error': error.description}), error.code
78
+
79
+ @app.errorhandler(Exception)
80
+ def handle_unexpected_error(error):
81
+ log.exception('Unhandled error')
82
+ return jsonify({'error': 'An error occurred'}), 500
83
+
84
+ @app.before_request
85
+ def gate():
86
+ # Set first so every request - including one rejected below - gets an ID and a measured duration.
87
+ g.request_id = uuid.uuid4().hex[:12]
88
+ g.request_started = time.monotonic()
89
+ # Order matters: a browser's preflight cannot carry the API key, and rate limiting comes before the key
90
+ # check so that guessing keys is throttled too.
91
+ if cors.is_preflight(request):
92
+ return cors.preflight_response(request.headers.get('Origin'))
93
+ limited = check_rate_limit()
94
+ if limited is not None:
95
+ return limited
96
+ g.permission = resolve_permission()
97
+ if request.endpoint not in PUBLIC_ENDPOINTS and g.permission is None:
98
+ return jsonify({'error': 'Unauthorized'}), 401
99
+ limited = check_key_rate_limit()
100
+ if limited is not None:
101
+ return limited
102
+
103
+ @app.after_request
104
+ def decorate(response):
105
+ cors.add_headers(response, request.headers.get('Origin'))
106
+ if g.get('rate_limit'):
107
+ limit, remaining = g.rate_limit
108
+ response.headers['X-RateLimit-Limit'] = str(limit)
109
+ response.headers['X-RateLimit-Remaining'] = str(remaining)
110
+ if g.get('key_rate_limit'):
111
+ limit, remaining = g.key_rate_limit
112
+ response.headers['X-RateLimit-Key-Limit'] = str(limit)
113
+ response.headers['X-RateLimit-Key-Remaining'] = str(remaining)
114
+ response.headers['X-Request-Id'] = g.get('request_id', '-')
115
+ elapsed = time.monotonic() - g.get('request_started', time.monotonic())
116
+ endpoint = request.endpoint or 'unmatched'
117
+ metrics.observe_request(request.method, endpoint, str(response.status_code), elapsed, caller_key_name())
118
+ if endpoint not in ACCESS_LOG_QUIET:
119
+ extra = {'method': request.method, 'path': request.path, 'status': response.status_code,
120
+ 'duration_ms': round(elapsed * 1000, 1)}
121
+ if g.get('serialization_ms') is not None: # only set for a paged response - see render()
122
+ extra['serialization_ms'] = g.serialization_ms
123
+ log.info('%s %s -> %s in %.1fms', request.method, request.path, response.status_code, elapsed * 1000,
124
+ extra=extra)
125
+ return response
126
+
127
+ app.register_blueprint(bp)
128
+ return app
129
+
130
+
131
+ # --------------------------------------------------------------------------------------
132
+ # Request helpers
133
+ # --------------------------------------------------------------------------------------
134
+
135
+ def check_rate_limit():
136
+ """Count this request against its client's quota; returns a 429 response when it is over the limit."""
137
+ limit = config.rate_limit()
138
+ if limit is None or request.method == 'OPTIONS' or request.endpoint in RATE_LIMIT_EXEMPT:
139
+ return None
140
+ count, period = limit
141
+ client = request.remote_addr or 'unknown'
142
+ allowed, remaining, retry_after = current_app.extensions['queryapigate_limiter'].hit(client, count, period)
143
+ g.rate_limit = (count, remaining)
144
+ if allowed:
145
+ return None
146
+ metrics.inc_rate_limit_rejection()
147
+ response = jsonify({'error': 'Rate limit exceeded', 'retry_after': retry_after})
148
+ response.status_code = 429
149
+ response.headers['Retry-After'] = str(retry_after)
150
+ return response
151
+
152
+
153
+ def check_key_rate_limit():
154
+ """Like check_rate_limit() above, but for a key's own optional `rate_limit` grant - checked in
155
+ *addition* to the server-wide, IP-based limit, never instead of it (see apikeys.py's module docstring).
156
+ Runs after g.permission is resolved, unlike the IP-based check, since there is no per-key identity to
157
+ limit by before then. Naturally a no-op for the admin key and the open/no-key case, both of which
158
+ always resolve rate_limit=None - no special-casing needed for either."""
159
+ permission = g.get('permission')
160
+ if permission is None or permission.rate_limit is None or request.method == 'OPTIONS' \
161
+ or request.endpoint in RATE_LIMIT_EXEMPT:
162
+ return None
163
+ count, period = permission.rate_limit
164
+ limiter = current_app.extensions['queryapigate_key_limiter']
165
+ allowed, remaining, retry_after = limiter.hit(permission.name, count, period)
166
+ g.key_rate_limit = (count, remaining)
167
+ if allowed:
168
+ return None
169
+ metrics.inc_rate_limit_rejection()
170
+ response = jsonify({'error': "Rate limit exceeded for this API key", 'retry_after': retry_after})
171
+ response.status_code = 429
172
+ response.headers['Retry-After'] = str(retry_after)
173
+ return response
174
+
175
+
176
+ def resolve_permission():
177
+ """The caller's Permission: a matched key (admin or scoped), the unrestricted OPEN default when no key
178
+ is configured anywhere, or None when a key is required but missing or wrong."""
179
+ permission = apikeys.authenticate(request.headers.get('X-API-Key', ''), client_ip=request.remote_addr)
180
+ if permission is not None:
181
+ return permission
182
+ return None if apikeys.auth_required() else apikeys.OPEN
183
+
184
+
185
+ def require_admin():
186
+ """Only the admin key (QUERYAPIGATE_API_KEY, or no key at all when nothing is configured) manages the
187
+ server's own configuration - connections, saved queries and other API keys."""
188
+ if not g.permission.admin:
189
+ raise ApiError('This API key is not authorized to manage the server configuration', 403)
190
+
191
+
192
+ def require_connection(connection_name):
193
+ if not apikeys.can_use(g.permission, connection_name):
194
+ raise ApiError(f"This API key is not permitted to use the connection '{connection_name}'", 403)
195
+
196
+
197
+ def caller_key_name():
198
+ """The calling API key's name for logs/metrics ('admin', a scoped key's name, or '-'); safe to call even
199
+ before permission is resolved, e.g. while rendering a CORS preflight or a 429 in decorate()."""
200
+ permission = g.get('permission')
201
+ return (permission.name if permission else None) or '-'
202
+
203
+
204
+ def _dict_diff(before, after):
205
+ """{field: {'from':.., 'to':..}} for every field that differs between two dicts (union of both sets of
206
+ keys) - used to build audit_log 'changes' for an update. Only the fields that actually changed, not the
207
+ whole entry, so a reviewer doesn't have to spot the difference themselves."""
208
+ keys = set(before or {}) | set(after or {})
209
+ return {k: {'from': (before or {}).get(k), 'to': (after or {}).get(k)}
210
+ for k in keys if (before or {}).get(k) != (after or {}).get(k)}
211
+
212
+
213
+ def _connection_audit_changes(before, after):
214
+ """Like _dict_diff(), but for a connection's raw (unmasked) stored fields specifically: 'password' is
215
+ reported only as the literal string 'changed' when it differs, in either direction - never the actual
216
+ value, before or after masking, since this is what gets persisted to audit_log.json. Every other field
217
+ (host, port, user, db, database, active, ...) is not a secret and is shown as given. `before=None`
218
+ means the connection didn't exist yet - the caller records that as a 'create_connection' snapshot
219
+ instead of calling this."""
220
+ diff = _dict_diff(before, after)
221
+ if 'password' in diff:
222
+ diff['password'] = 'changed'
223
+ return diff
224
+
225
+
226
+ def get_json_body(required=True):
227
+ data = request.get_json(silent=True)
228
+ if data is None and not required:
229
+ return {}
230
+ if not isinstance(data, dict):
231
+ raise ApiError('Request body must be a JSON object')
232
+ return data
233
+
234
+
235
+ def get_object(value, label):
236
+ if value is None:
237
+ return {}
238
+ if not isinstance(value, dict):
239
+ raise ApiError(f'{label} must be a JSON object')
240
+ return value
241
+
242
+
243
+ def get_int(value, label):
244
+ if value is None or value == '':
245
+ return None
246
+ try:
247
+ return int(value)
248
+ except (TypeError, ValueError):
249
+ raise ApiError(f'{label} must be an integer') from None
250
+
251
+
252
+ def get_pagination():
253
+ """Return (limit, offset, page) from the ?page and ?page_size query parameters."""
254
+ page = get_int(request.args.get('page'), 'page')
255
+ page_size = get_int(request.args.get('page_size'), 'page_size')
256
+ page = 1 if page is None else page
257
+ page_size = 10 if page_size is None else page_size
258
+ if page < 1 or page_size < 1:
259
+ raise ApiError('page and page_size must be positive')
260
+ if page_size > config.max_page_size():
261
+ raise ApiError(f'page_size must not exceed {config.max_page_size()}')
262
+ return page_size, (page - 1) * page_size, page
263
+
264
+
265
+ def get_output_format(body=None):
266
+ output_format = str(request.args.get('format') or (body or {}).get('format') or 'json').lower()
267
+ if output_format not in FORMATTERS:
268
+ raise ApiError(f"Unsupported format '{output_format}'. Supported formats: {', '.join(FORMATTERS)}")
269
+ return output_format
270
+
271
+
272
+ def get_timeout(body=None):
273
+ """Seconds allowed for the query: ?timeout= may lower the server limit but never raise it."""
274
+ raw = request.args.get('timeout', (body or {}).get('timeout'))
275
+ if raw in (None, ''):
276
+ return config.effective_timeout(None)
277
+ try:
278
+ value = float(raw)
279
+ except (TypeError, ValueError):
280
+ raise ApiError('timeout must be a number of seconds') from None
281
+ if not math.isfinite(value) or value <= 0:
282
+ raise ApiError('timeout must be a positive number of seconds')
283
+ return config.effective_timeout(value)
284
+
285
+
286
+ def render(result, output_format, page, page_size):
287
+ """Build the paged response body and record how long that took (JSON/CSV/TSV/XML/YAML/XLSX encoding) as
288
+ its own metric and access-log field, separate from query execution time - see
289
+ metrics.observe_serialization(). Streaming responses never go through here; there's no equivalent
290
+ single serialization span to measure for those (see that function's docstring)."""
291
+ started = time.monotonic()
292
+ response = jsonify({'message': 'No results returned'}) if not result else FORMATTERS[output_format](result)
293
+ elapsed = time.monotonic() - started
294
+ metrics.observe_serialization(output_format, elapsed)
295
+ g.serialization_ms = round(elapsed * 1000, 1)
296
+ response.headers['X-Page'] = str(page)
297
+ response.headers['X-Page-Size'] = str(page_size)
298
+ response.headers['X-Has-More'] = 'true' if result.has_more else 'false'
299
+ return response
300
+
301
+
302
+ def get_stream_flag():
303
+ """Whether ?stream=true was requested: the whole result, streamed as it comes off the cursor instead of
304
+ one page built in memory first. Only csv/tsv/ndjson support it - see formats.STREAM_FORMATTERS."""
305
+ return (request.args.get('stream') or '').strip().lower() in ('1', 'true', 'yes', 'on')
306
+
307
+
308
+ def stream_sql_response(sql, connection_name, params, timeout, output_format, filename, saved=None):
309
+ """Shared by execute_sql_endpoint() and run_saved(): validates the stream=true-specific constraints
310
+ (format, no pagination) and returns the chunked Response. ``saved``, when given, is (path, version) for
311
+ a saved query whose run should still be recorded in its execution_history once the stream finishes."""
312
+ if output_format not in STREAM_FORMATTERS:
313
+ raise ApiError(f"stream=true only supports these formats: {', '.join(sorted(STREAM_FORMATTERS))}")
314
+ if request.args.get('page') or request.args.get('page_size'):
315
+ raise ApiError('stream=true exports the whole result and does not accept page/page_size')
316
+ key_name = caller_key_name()
317
+ columns, rows = engine.stream_sql(sql, connection_name, params, timeout, key_name=key_name)
318
+ if saved is not None:
319
+ path, number = saved
320
+ # Captured here, not inside _record_stream_history(): that generator's body runs lazily, as the
321
+ # response streams out - by then the request/app context this view function runs in is long gone
322
+ # (no flask.stream_with_context() wrapping is used), so g and caller_key_name() are only safe to
323
+ # read up front, while still inside the request that's actually issuing the query.
324
+ rows = _record_stream_history(rows, path, number, connection_name, g.get('request_id'), key_name)
325
+ return stream_response(output_format, columns, rows, filename)
326
+
327
+
328
+ def _record_stream_history(rows, path, number, connection_name, request_id, key_name):
329
+ """Records a saved query's streamed run in its execution_history once fully drained or failed partway
330
+ through (not on a client disconnect, GeneratorExit) - counting rows as they pass through, since the
331
+ total is not known up front, the same trade-off engine._drain() makes for the streaming metric."""
332
+ entry = {'executed_at': store.now(), 'connection_name': connection_name,
333
+ 'request_id': request_id, 'key_name': key_name}
334
+ count = 0
335
+ try:
336
+ for row in rows:
337
+ count += 1
338
+ yield row
339
+ except GeneratorExit:
340
+ raise
341
+ except ApiError as error:
342
+ store.record_execution(path, number, {**entry, 'status': 'error', 'error': error.message, 'rows': count})
343
+ raise
344
+ except Exception as error:
345
+ store.record_execution(path, number, {**entry, 'status': 'error', 'error': str(error), 'rows': count})
346
+ raise
347
+ else:
348
+ store.record_execution(path, number, {**entry, 'status': 'success', 'rows': count})
349
+
350
+
351
+ # --------------------------------------------------------------------------------------
352
+ # Query execution
353
+ # --------------------------------------------------------------------------------------
354
+
355
+ @bp.route('/execute_sql', methods=['POST'])
356
+ def execute_sql_endpoint():
357
+ data = get_json_body()
358
+ if not data.get('sql'):
359
+ raise ApiError('SQL query is missing')
360
+ if not data.get('connection_name'):
361
+ raise ApiError('Connection name is missing')
362
+ require_connection(data['connection_name'])
363
+ params = get_object(data.get('params'), 'params')
364
+ output_format = get_output_format(data)
365
+ timeout = get_timeout(data)
366
+ if get_stream_flag():
367
+ return stream_sql_response(data['sql'], data['connection_name'], params, timeout, output_format,
368
+ filename=data['connection_name'])
369
+ limit, offset, page = get_pagination()
370
+ result = engine.execute_sql(data['sql'], data['connection_name'], limit, offset, params, timeout,
371
+ allow_writes=g.permission.allow_writes, key_name=caller_key_name(),
372
+ allowed_write_ops=g.permission.allowed_write_ops)
373
+ return render(result, output_format, page, limit)
374
+
375
+
376
+ def cache_lookup(cache_key, ttl):
377
+ """A live entry rendered as a response (304 if the client already has it), or None on a cache miss."""
378
+ hit = current_app.extensions['queryapigate_cache'].get(cache_key)
379
+ if hit is None:
380
+ return None
381
+ body, content_type, headers, etag = hit
382
+ quoted = f'"{etag}"'
383
+ if request.headers.get('If-None-Match') == quoted:
384
+ response = Response(status=304)
385
+ else:
386
+ response = Response(body, content_type=content_type)
387
+ for name, value in headers:
388
+ response.headers[name] = value
389
+ response.headers['ETag'] = quoted
390
+ response.headers['Cache-Control'] = f'max-age={ttl}'
391
+ response.headers['X-Cache'] = 'HIT'
392
+ return response
393
+
394
+
395
+ def cache_store(cache_key, response, ttl):
396
+ """Store `response` under `cache_key` for `ttl` seconds and tag it as a fresh cache MISS."""
397
+ replay_headers = [(name, value) for name, value in response.headers.items()
398
+ if name.lower() not in ('content-type', 'content-length')]
399
+ etag = current_app.extensions['queryapigate_cache'].set(cache_key, response.get_data(), response.content_type,
400
+ replay_headers, ttl)
401
+ response.headers['ETag'] = f'"{etag}"'
402
+ response.headers['Cache-Control'] = f'max-age={ttl}'
403
+ response.headers['X-Cache'] = 'MISS'
404
+ return response
405
+
406
+
407
+ def run_saved(ref, body, url_params):
408
+ """Execute a saved query (latest version unless one is requested) and record the run - or, for one with
409
+ a cache_ttl whose SQL is read-only, serve a cached response instead."""
410
+ path = store.resolve_saved_file(ref)
411
+ number, saved = store.select_version(store.load_versions(path),
412
+ get_int(request.args.get('version') or body.get('version'), 'version'))
413
+ connection_name = request.args.get('connection_name') or body.get('connection_name') \
414
+ or saved.get('connection_name')
415
+ if not connection_name:
416
+ raise ApiError('Connection name is missing')
417
+ query_name = store.query_name(path)
418
+ # A key's `queries` grant is additive on top of `connections` (see apikeys.py's module docstring): it
419
+ # can name this exact saved query without needing connection access at all, so only fall back to the
420
+ # ordinary per-connection check when the query itself was not specifically granted.
421
+ if not apikeys.can_use_query(g.permission, query_name):
422
+ require_connection(connection_name)
423
+ if not isinstance(saved.get('sql_query'), str):
424
+ raise ApiError('Saved query has no SQL', 500)
425
+ # Per-query write curation (apikeys.can_write_query()) only ever adds write reach for this one named
426
+ # query on top of whatever the key's blanket allow_writes already grants - never the other way round.
427
+ effective_allow_writes = g.permission.allow_writes or apikeys.can_write_query(g.permission, query_name)
428
+
429
+ raw = {**url_params, **get_object(body.get('params'), 'params'),
430
+ **get_object(body.get('placeholders'), 'placeholders')}
431
+ used = set(sqltools.placeholder_names(saved['sql_query']))
432
+ values = param_rules.resolve(saved.get('query_parameters'), raw, used=used)
433
+ sql = sqltools.fill_placeholders(saved['sql_query'], values)
434
+ output_format = get_output_format(body)
435
+ timeout = get_timeout(body)
436
+
437
+ if get_stream_flag():
438
+ # No caching for a streamed export - caching would require materialising the whole body anyway,
439
+ # defeating the point - but the run is still recorded once the stream finishes, same as any other.
440
+ return stream_sql_response(sql, connection_name, values, timeout, output_format, filename=ref,
441
+ saved=(path, number))
442
+
443
+ limit, offset, page = get_pagination()
444
+
445
+ # A saved query is only ever cached when it declares a cache_ttl *and* its SQL is read-only - never a
446
+ # write, no matter the setting, since serving a cached response would silently skip that write.
447
+ ttl = saved.get('cache_ttl') or 0
448
+ cache_key = None
449
+ if ttl > 0:
450
+ dialect = store.get_connection(connection_name)['db']
451
+ if sqltools.first_keyword(sql, dialect) in sqltools.READ_ONLY_STATEMENTS:
452
+ cache_key = cache.ResponseCache.key(name=ref, version=number, connection=connection_name,
453
+ values=values, format=output_format, page=page, page_size=limit)
454
+ cached = cache_lookup(cache_key, ttl)
455
+ if cached is not None:
456
+ return cached
457
+
458
+ entry = {'executed_at': store.now(), 'connection_name': connection_name,
459
+ 'request_id': g.get('request_id'), 'key_name': caller_key_name()}
460
+ try:
461
+ result, elapsed_ms = engine.timed(engine.execute_sql, sql, connection_name, limit, offset, values, timeout,
462
+ allow_writes=effective_allow_writes, key_name=caller_key_name(),
463
+ allowed_write_ops=g.permission.allowed_write_ops)
464
+ except ApiError as error:
465
+ store.record_execution(path, number, {**entry, 'status': 'error', 'error': error.message})
466
+ raise
467
+ response = render(result, output_format, page, limit) # sets g.serialization_ms - see render()
468
+ store.record_execution(path, number, {**entry, 'status': 'success', 'rows': len(result.rows),
469
+ 'duration_ms': elapsed_ms, 'serialization_ms': g.serialization_ms})
470
+ if cache_key is not None:
471
+ response = cache_store(cache_key, response, ttl)
472
+ return response
473
+
474
+
475
+ @bp.route('/execute_sql_from_file', methods=['POST'])
476
+ @bp.route('/execute_sql_with_parameters_from_file', methods=['POST'])
477
+ def execute_sql_from_file():
478
+ body = get_json_body()
479
+ return run_saved(body.get('filepath'), body, {})
480
+
481
+
482
+ @bp.route('/q/<name>', methods=['GET', 'POST'])
483
+ def run_named_query(name):
484
+ body = get_json_body(required=False) if request.method == 'POST' else {}
485
+ url_params = {k: v for k, v in request.args.items() if k not in RESERVED_ARGS}
486
+ return run_saved(name, body, url_params)
487
+
488
+
489
+ # --------------------------------------------------------------------------------------
490
+ # Saved queries
491
+ # --------------------------------------------------------------------------------------
492
+
493
+ @bp.route('/view_file_content', methods=['GET'])
494
+ def view_file_content():
495
+ require_admin()
496
+ path = store.resolve_saved_file(request.args.get('filename'))
497
+ with open(path, 'r') as f:
498
+ return jsonify({'content': f.read()}), 200
499
+
500
+
501
+ @bp.route('/save_sql_to_file', methods=['PATCH'])
502
+ def save_sql_to_file():
503
+ require_admin()
504
+ data = get_json_body()
505
+ for field, label in (('author', 'Author'), ('description', 'Description'),
506
+ ('sql_query', 'SQL query'), ('filename', 'Filename')):
507
+ if not data.get(field) or not isinstance(data[field], str):
508
+ raise ApiError(f'{label} is missing')
509
+ tags = data.get('tags', [])
510
+ if not isinstance(tags, (list, str)):
511
+ raise ApiError('tags must be a string or a list')
512
+ query_parameters = get_object(data.get('query_parameters'), 'query_parameters')
513
+ param_rules.parse_definitions(query_parameters)
514
+ unused = sorted(set(query_parameters) - set(sqltools.placeholder_names(data['sql_query'])))
515
+ if unused:
516
+ raise ApiError(f"query_parameters declares {', '.join(unused)}, which sql_query does not use "
517
+ '(write :name in the SQL, or remove the declaration)')
518
+ connection_name = data.get('connection_name')
519
+ if connection_name is not None and not isinstance(connection_name, str):
520
+ raise ApiError('connection_name must be a string')
521
+ cache_ttl = data.get('cache_ttl')
522
+ if cache_ttl is not None and (not isinstance(cache_ttl, int) or isinstance(cache_ttl, bool) or cache_ttl < 0):
523
+ raise ApiError('cache_ttl must be a non-negative integer number of seconds')
524
+
525
+ query_uuid, version = store.save_version(data['filename'], {
526
+ 'sql_query': data['sql_query'],
527
+ 'author': data['author'],
528
+ 'description': data['description'],
529
+ 'tags': tags,
530
+ 'query_parameters': query_parameters,
531
+ **({'connection_name': connection_name} if connection_name else {}),
532
+ **({'cache_ttl': cache_ttl} if cache_ttl else {}),
533
+ })
534
+ store.record_audit(caller_key_name(), 'save_query', data['filename'],
535
+ {'version': version, 'connection_name': connection_name, 'author': data['author']})
536
+ return jsonify({'message': 'SQL query saved successfully', 'filename': data['filename'],
537
+ 'uuid': query_uuid, 'version': version}), 200
538
+
539
+
540
+ @bp.route('/saved_sql/<name>', methods=['DELETE'])
541
+ def delete_saved_query(name):
542
+ require_admin()
543
+ version = get_int(request.args.get('version'), 'version')
544
+ store.delete_saved(name, version)
545
+ store.record_audit(caller_key_name(), 'delete_query', name,
546
+ {'version': version} if version is not None else None)
547
+ what = f'Version {version} of {name}' if version is not None else name
548
+ return jsonify({'message': f'{what} deleted'}), 200
549
+
550
+
551
+ @bp.route('/list_files', methods=['GET'])
552
+ def list_files():
553
+ require_admin()
554
+ sort_by = request.args.get('sort_by', 'name')
555
+ if sort_by not in ('name', 'modified'):
556
+ raise ApiError("sort_by must be 'name' or 'modified'")
557
+ sort_order = request.args.get('sort_order', 'asc')
558
+ if sort_order not in ('asc', 'desc'):
559
+ raise ApiError("sort_order must be 'asc' or 'desc'")
560
+
561
+ files = store.list_saved()
562
+ if sort_by == 'name':
563
+ def key(f):
564
+ return f['filename'].lower()
565
+ else:
566
+ def key(f):
567
+ return (f['versions'][-1].get('last_modified_at') or '') if f['versions'] else ''
568
+ files.sort(key=key, reverse=sort_order == 'desc')
569
+ return jsonify({'files': files}), 200
570
+
571
+
572
+ # --------------------------------------------------------------------------------------
573
+ # Connections
574
+ # --------------------------------------------------------------------------------------
575
+
576
+ @bp.route('/connections', methods=['GET'])
577
+ def get_connections():
578
+ require_admin()
579
+ connections = store.mask_passwords(store.read_connections())
580
+ for name, conn in connections.items():
581
+ conn['usage'] = metrics.summary_for_connection(name)
582
+ return jsonify({'connections': connections}), 200
583
+
584
+
585
+ @bp.route('/connections', methods=['PATCH'])
586
+ def update_connections():
587
+ require_admin()
588
+ connections = get_json_body().get('connections')
589
+ if not connections or not isinstance(connections, dict):
590
+ raise ApiError('Connections data is missing')
591
+ before = store.read_connections() # raw, unmasked - in memory only, never itself logged; see below
592
+ store.update_connections(connections)
593
+ after = store.read_connections()
594
+ actor = caller_key_name()
595
+ for name in connections:
596
+ if name not in before:
597
+ store.record_audit(actor, 'create_connection', name, store.mask_passwords({name: after[name]})[name])
598
+ else:
599
+ changes = _connection_audit_changes(before.get(name), after.get(name))
600
+ if changes:
601
+ store.record_audit(actor, 'update_connection', name, changes)
602
+ pool.close_pooled_connections() # new settings or credentials must not be served by old connections
603
+ return jsonify({'message': 'Connections updated successfully'}), 200
604
+
605
+
606
+ @bp.route('/connections/<name>', methods=['DELETE'])
607
+ def delete_connection(name):
608
+ require_admin()
609
+ before = store.read_connections().get(name)
610
+ store.delete_connection(name)
611
+ if before is not None:
612
+ store.record_audit(caller_key_name(), 'delete_connection', name, store.mask_passwords({name: before})[name])
613
+ pool.close_pooled_connections() # a removed connection must not keep serving from idle sockets
614
+ return jsonify({'message': f"Connection '{name}' deleted"}), 200
615
+
616
+
617
+ @bp.route('/connections/<name>/schema', methods=['GET'])
618
+ def connection_schema(name):
619
+ require_connection(name)
620
+ return jsonify(schema.fetch_schema(name)), 200
621
+
622
+
623
+ # --------------------------------------------------------------------------------------
624
+ # API keys
625
+ # --------------------------------------------------------------------------------------
626
+
627
+ @bp.route('/api_keys', methods=['GET'])
628
+ def get_api_keys():
629
+ require_admin()
630
+ keys = apikeys.list_keys()
631
+ for name, key in keys.items():
632
+ key['usage'] = metrics.summary_for_key(name)
633
+ return jsonify({'keys': keys}), 200
634
+
635
+
636
+ @bp.route('/api_keys', methods=['POST'])
637
+ def create_api_key():
638
+ require_admin()
639
+ data = get_json_body()
640
+ secret = apikeys.create_key(data.get('name'), connections=data.get('connections'),
641
+ allow_writes=data.get('allow_writes'), queries=data.get('queries'),
642
+ expires_at=data.get('expires_at'), rate_limit=data.get('rate_limit'),
643
+ allowed_ips=data.get('allowed_ips'),
644
+ allowed_write_ops=data.get('allowed_write_ops'), role=data.get('role'))
645
+ store.record_audit(caller_key_name(), 'create_key', data.get('name'), apikeys.list_keys().get(data.get('name')))
646
+ return jsonify({'name': data.get('name'), 'key': secret,
647
+ 'message': "Store this key now - it can't be shown again."}), 200
648
+
649
+
650
+ @bp.route('/api_keys/<name>', methods=['PATCH'])
651
+ def update_api_key(name):
652
+ require_admin()
653
+ data = get_json_body()
654
+ before = apikeys.list_keys().get(name)
655
+ # expires_at, rate_limit, allowed_ips and allowed_write_ops all need a real presence check, not .get():
656
+ # an explicit null in the request body means "clear it", which must be distinguishable from the field
657
+ # being absent ("leave it alone") - see apikeys.update_key's _UNSET sentinel.
658
+ unset_kwargs = {k: data[k] for k in ('expires_at', 'rate_limit', 'allowed_ips', 'allowed_write_ops')
659
+ if k in data}
660
+ apikeys.update_key(name, connections=data.get('connections'), allow_writes=data.get('allow_writes'),
661
+ active=data.get('active'), queries=data.get('queries'), **unset_kwargs)
662
+ changes = _dict_diff(before, apikeys.list_keys().get(name))
663
+ if changes:
664
+ store.record_audit(caller_key_name(), 'update_key', name, changes)
665
+ return jsonify({'message': f"API key '{name}' updated"}), 200
666
+
667
+
668
+ @bp.route('/api_keys/<name>', methods=['DELETE'])
669
+ def delete_api_key(name):
670
+ require_admin()
671
+ before = apikeys.list_keys().get(name)
672
+ apikeys.delete_key(name)
673
+ store.record_audit(caller_key_name(), 'delete_key', name, before)
674
+ return jsonify({'message': f"API key '{name}' deleted"}), 200
675
+
676
+
677
+ @bp.route('/roles', methods=['GET'])
678
+ def get_roles():
679
+ require_admin()
680
+ return jsonify({'roles': apikeys.list_roles()}), 200
681
+
682
+
683
+ @bp.route('/roles', methods=['POST'])
684
+ def create_role_endpoint():
685
+ require_admin()
686
+ data = get_json_body()
687
+ apikeys.create_role(data.get('name'), connections=data.get('connections'),
688
+ allow_writes=bool(data.get('allow_writes', False)), queries=data.get('queries'),
689
+ rate_limit=data.get('rate_limit'), allowed_ips=data.get('allowed_ips'),
690
+ allowed_write_ops=data.get('allowed_write_ops'))
691
+ store.record_audit(caller_key_name(), 'create_role', data.get('name'), apikeys.list_roles().get(data.get('name')))
692
+ return jsonify({'message': f"Role '{data.get('name')}' created"}), 200
693
+
694
+
695
+ @bp.route('/roles/<name>', methods=['PATCH'])
696
+ def update_role_endpoint(name):
697
+ require_admin()
698
+ data = get_json_body()
699
+ before = apikeys.list_roles().get(name)
700
+ # Same _UNSET-sentinel presence check update_api_key() already uses for these three fields.
701
+ unset_kwargs = {k: data[k] for k in ('rate_limit', 'allowed_ips', 'allowed_write_ops') if k in data}
702
+ apikeys.update_role(name, connections=data.get('connections'), allow_writes=data.get('allow_writes'),
703
+ queries=data.get('queries'), **unset_kwargs)
704
+ changes = _dict_diff(before, apikeys.list_roles().get(name))
705
+ if changes:
706
+ store.record_audit(caller_key_name(), 'update_role', name, changes)
707
+ return jsonify({'message': f"Role '{name}' updated"}), 200
708
+
709
+
710
+ @bp.route('/roles/<name>', methods=['DELETE'])
711
+ def delete_role_endpoint(name):
712
+ require_admin()
713
+ before = apikeys.list_roles().get(name)
714
+ apikeys.delete_role(name)
715
+ store.record_audit(caller_key_name(), 'delete_role', name, before)
716
+ return jsonify({'message': f"Role '{name}' deleted"}), 200
717
+
718
+
719
+ @bp.route('/audit_log', methods=['GET'])
720
+ def audit_log_endpoint():
721
+ """A durable record of administrative changes - who created, changed or removed an API key, connection
722
+ or saved query, and when (see store.record_audit()). Admin only, like everything else that reveals the
723
+ server's own configuration; newest entry first, capped at config.audit_log_limit()."""
724
+ require_admin()
725
+ return jsonify({'entries': list(reversed(store.read_audit_log()))}), 200
726
+
727
+
728
+ # --------------------------------------------------------------------------------------
729
+ # Service endpoints
730
+ # --------------------------------------------------------------------------------------
731
+
732
+ @bp.route('/', methods=['GET'])
733
+ def index():
734
+ return redirect(url_for('api.docs'))
735
+
736
+
737
+ @bp.route('/favicon.ico', methods=['GET'])
738
+ def favicon():
739
+ return '', 204
740
+
741
+
742
+ @bp.route('/health', methods=['GET'])
743
+ def health():
744
+ from flask import current_app
745
+ return jsonify({'status': 'ok', 'version': current_app.config['QUERYAPIGATE_VERSION']})
746
+
747
+
748
+ @bp.route('/metrics', methods=['GET'])
749
+ def metrics_endpoint():
750
+ return Response(metrics.render(), mimetype='text/plain; version=0.0.4; charset=utf-8')
751
+
752
+
753
+ def describe_saved_queries(permission):
754
+ """What the OpenAPI document needs to know about each saved query (never its SQL text) that
755
+ ``permission`` may actually call - the same test run_saved() applies, so a key scoped to specific
756
+ queries (see apikeys.py) sees only its own approved list here, not the whole internal catalogue; an
757
+ unrestricted (admin, or connection-wide) key sees everything, unchanged from before this filter."""
758
+ described = []
759
+ for name, number, data in store.latest_versions():
760
+ sql = data.get('sql_query')
761
+ if not isinstance(sql, str):
762
+ continue
763
+ connection_name = data.get('connection_name')
764
+ if not (apikeys.can_use_query(permission, name)
765
+ or (connection_name and apikeys.can_use(permission, connection_name))):
766
+ continue
767
+ declared = param_rules.read_definitions(data.get('query_parameters'))
768
+ used = sqltools.placeholder_names(sql)
769
+ parameters = {}
770
+ for param in used: # what the SQL needs, in order; undeclared ones are plain required text
771
+ parameters[param] = declared.get(param) or param_rules.read_definition({})
772
+ described.append({'name': name, 'version': number, 'description': data.get('description'),
773
+ 'tags': data.get('tags'), 'connection_name': data.get('connection_name'),
774
+ 'parameters': parameters})
775
+ return described
776
+
777
+
778
+ @bp.route('/openapi.json', methods=['GET'])
779
+ def openapi_spec():
780
+ from flask import current_app
781
+ # The generic API description is public. The list of saved queries (names, descriptions, parameters - never
782
+ # the SQL itself) is shown to any authenticated caller, admin or scoped, same as any other endpoint they
783
+ # could call through /q/<name> - a scoped key still needs to know a query's parameters to use it.
784
+ saved = describe_saved_queries(g.permission) if g.permission is not None else None
785
+ return jsonify(openapi.build_spec(current_app.config['QUERYAPIGATE_VERSION'], saved))
786
+
787
+
788
+ def describe_catalog(permission):
789
+ """Like describe_saved_queries() (same reachability rule, same parameter shape - kept as its own loop
790
+ rather than sharing one, since the two describe different things to different consumers: OpenAPI's
791
+ request/response shape versus this endpoint's governance terms - and OpenAPI's shape is a stable
792
+ contract other tooling parses, not something to risk changing by threading new fields through it), plus
793
+ the governance facts OpenAPI has no field for: whether this query is cached, and whether *this specific
794
+ caller* can write through it (apikeys.can_write_query() - independent of their blanket allow_writes)."""
795
+ catalog = []
796
+ for name, number, data in store.latest_versions():
797
+ sql = data.get('sql_query')
798
+ if not isinstance(sql, str):
799
+ continue
800
+ connection_name = data.get('connection_name')
801
+ if not (apikeys.can_use_query(permission, name)
802
+ or (connection_name and apikeys.can_use(permission, connection_name))):
803
+ continue
804
+ declared = param_rules.read_definitions(data.get('query_parameters'))
805
+ used = sqltools.placeholder_names(sql)
806
+ parameters = {}
807
+ for param in used:
808
+ parameters[param] = declared.get(param) or param_rules.read_definition({})
809
+ catalog.append({'name': name, 'version': number, 'description': data.get('description'),
810
+ 'tags': data.get('tags'), 'connection_name': connection_name,
811
+ 'parameters': parameters, 'cache_ttl': data.get('cache_ttl') or None,
812
+ 'can_write': apikeys.can_write_query(permission, name)})
813
+ return catalog
814
+
815
+
816
+ @bp.route('/catalog', methods=['GET'])
817
+ def catalog():
818
+ """Everything this caller can reach through /q/<name>, and the terms it's offered under, in one place -
819
+ closing the gap where that information exists (cache_ttl on a saved query, a key's own rate_limit and
820
+ write curation) but was scattered across admin-only screens a scoped key can never reach. Requires the
821
+ same authentication any other functional endpoint does (unlike /openapi.json, this is never public) -
822
+ the whole point is answering 'what can *I* use,' which needs a resolved caller to mean anything."""
823
+ permission = g.permission
824
+ return jsonify({
825
+ 'queries': describe_catalog(permission),
826
+ 'caller': {
827
+ 'name': permission.name,
828
+ 'admin': permission.admin,
829
+ 'allow_writes': permission.allow_writes,
830
+ 'allowed_write_ops': permission.allowed_write_ops,
831
+ 'rate_limit': config.format_rate_limit(permission.rate_limit),
832
+ 'server_rate_limit': config.format_rate_limit(config.rate_limit()),
833
+ },
834
+ }), 200
835
+
836
+
837
+ @bp.route('/docs', methods=['GET'])
838
+ def docs():
839
+ return Response(openapi.DOCS_HTML, mimetype='text/html')
840
+
841
+
842
+ @bp.route('/ui', methods=['GET'])
843
+ def admin_ui():
844
+ return Response(ui.UI_HTML, mimetype='text/html')