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/__init__.py +6 -0
- queryapigate/__main__.py +3 -0
- queryapigate/apikeys.py +535 -0
- queryapigate/app.py +844 -0
- queryapigate/cache.py +61 -0
- queryapigate/cli.py +168 -0
- queryapigate/config.py +268 -0
- queryapigate/cors.py +51 -0
- queryapigate/engine.py +166 -0
- queryapigate/errors.py +8 -0
- queryapigate/formats.py +193 -0
- queryapigate/lib/h2-2.2.224.jar +0 -0
- queryapigate/logging_setup.py +75 -0
- queryapigate/metrics.py +215 -0
- queryapigate/openapi.py +410 -0
- queryapigate/params.py +275 -0
- queryapigate/pool.py +146 -0
- queryapigate/ratelimit.py +88 -0
- queryapigate/runners.py +795 -0
- queryapigate/schema.py +99 -0
- queryapigate/sqltools.py +184 -0
- queryapigate/store.py +433 -0
- queryapigate/ui.py +2371 -0
- queryapigate-0.5.0.dist-info/METADATA +708 -0
- queryapigate-0.5.0.dist-info/RECORD +29 -0
- queryapigate-0.5.0.dist-info/WHEEL +5 -0
- queryapigate-0.5.0.dist-info/entry_points.txt +2 -0
- queryapigate-0.5.0.dist-info/licenses/LICENSE +21 -0
- queryapigate-0.5.0.dist-info/top_level.txt +1 -0
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')
|