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/openapi.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""OpenAPI description of the API, served at /openapi.json and rendered at /docs."""
|
|
2
|
+
import re
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from . import config, schema
|
|
6
|
+
from .params import json_schema
|
|
7
|
+
|
|
8
|
+
_FORMAT_PARAM = {'name': 'format', 'in': 'query', 'schema': {
|
|
9
|
+
'type': 'string', 'enum': ['json', 'ndjson', 'csv', 'tsv', 'xml', 'yaml', 'xlsx'], 'default': 'json'}}
|
|
10
|
+
_PAGE_PARAMS = [
|
|
11
|
+
{'name': 'page', 'in': 'query', 'schema': {'type': 'integer', 'minimum': 1, 'default': 1}},
|
|
12
|
+
{'name': 'page_size', 'in': 'query', 'schema': {'type': 'integer', 'minimum': 1, 'default': 10},
|
|
13
|
+
'description': 'Upper limit is QUERYAPIGATE_MAX_PAGE_SIZE (default 1000).'},
|
|
14
|
+
{'name': 'timeout', 'in': 'query', 'schema': {'type': 'number', 'minimum': 0, 'exclusiveMinimum': True},
|
|
15
|
+
'description': 'Seconds the query may run before it is cancelled (504). Can lower, never raise, the server '
|
|
16
|
+
'limit QUERYAPIGATE_QUERY_TIMEOUT (default 30; 0 disables the server limit).'},
|
|
17
|
+
]
|
|
18
|
+
_ROWS = {'description': 'The requested page. Header X-Has-More says whether another page follows.',
|
|
19
|
+
'headers': {'X-Page': {'schema': {'type': 'integer'}}, 'X-Page-Size': {'schema': {'type': 'integer'}},
|
|
20
|
+
'X-Has-More': {'schema': {'type': 'string', 'enum': ['true', 'false']}}},
|
|
21
|
+
'content': {'application/json': {'schema': {'type': 'array', 'items': {'type': 'object'}}}}}
|
|
22
|
+
_ERRORS = {c: {'$ref': '#/components/responses/Error'} for c in ('400', '401', '403', '404', '429', '500', '504')}
|
|
23
|
+
_QUERY_ENTRY_SCHEMA = {'oneOf': [
|
|
24
|
+
{'type': 'string', 'description': 'A saved-query name - read access only.'},
|
|
25
|
+
{'type': 'object', 'description': 'Read access, plus write access to this one query specifically.',
|
|
26
|
+
'properties': {'name': {'type': 'string'}, 'allow_writes': {'type': 'boolean'}}, 'required': ['name']}]}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _object_schema(properties, required=()):
|
|
30
|
+
"""An object schema; OpenAPI 3.0 forbids an empty ``required`` list, so it is only included when non-empty."""
|
|
31
|
+
schema = {'type': 'object', 'properties': properties}
|
|
32
|
+
if required:
|
|
33
|
+
schema['required'] = list(required)
|
|
34
|
+
return schema
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _body(properties, required):
|
|
38
|
+
return {'required': True, 'content': {'application/json': {'schema': _object_schema(properties, required)}}}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_EXEC_PROPS = {
|
|
42
|
+
'connection_name': {'type': 'string'},
|
|
43
|
+
'format': {'type': 'string'},
|
|
44
|
+
'placeholders': {'type': 'object', 'description': 'Values for :name (bound) and {name} (text) parameters.'},
|
|
45
|
+
'params': {'type': 'object', 'description': 'Alias of placeholders.'},
|
|
46
|
+
'version': {'type': 'integer', 'description': 'Saved version to run (default: latest).'},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _saved_query_paths(queries):
|
|
51
|
+
"""One documented endpoint per saved query, with its declared parameters and rules."""
|
|
52
|
+
paths, seen = {}, set()
|
|
53
|
+
for query in queries:
|
|
54
|
+
name = query['name']
|
|
55
|
+
has_default_connection = bool(query.get('connection_name'))
|
|
56
|
+
properties, required, query_params = {}, [], []
|
|
57
|
+
for param, spec in query['parameters'].items():
|
|
58
|
+
schema = json_schema(spec)
|
|
59
|
+
entry = {'name': param, 'in': 'query', 'required': spec['required'], 'schema': schema}
|
|
60
|
+
if spec['description']:
|
|
61
|
+
entry['description'] = spec['description']
|
|
62
|
+
query_params.append(entry)
|
|
63
|
+
properties[param] = {**schema, **({'description': spec['description']} if spec['description'] else {})}
|
|
64
|
+
if spec['required']:
|
|
65
|
+
required.append(param)
|
|
66
|
+
connection = {'name': 'connection_name', 'in': 'query', 'required': not has_default_connection,
|
|
67
|
+
'schema': {'type': 'string', **({'default': query['connection_name']}
|
|
68
|
+
if has_default_connection else {})}}
|
|
69
|
+
common = [connection, {'name': 'version', 'in': 'query', 'schema': {'type': 'integer'},
|
|
70
|
+
'description': f"Saved version to run (default: latest, currently {query['version']})."},
|
|
71
|
+
_FORMAT_PARAM, *_PAGE_PARAMS]
|
|
72
|
+
summary = query.get('description') or f'Run the saved query {name}'
|
|
73
|
+
tags = query.get('tags')
|
|
74
|
+
detail = f"Runs version {query['version']} of the saved query '{name}'."
|
|
75
|
+
if tags:
|
|
76
|
+
detail += ' Tags: ' + (tags if isinstance(tags, str) else ', '.join(map(str, tags))) + '.'
|
|
77
|
+
operation_id = 'run_' + re.sub(r'\W', '_', name)
|
|
78
|
+
while operation_id in seen:
|
|
79
|
+
operation_id += '_'
|
|
80
|
+
seen.add(operation_id)
|
|
81
|
+
body_properties = {'params': _object_schema(properties, required),
|
|
82
|
+
'connection_name': connection['schema'], 'version': {'type': 'integer'},
|
|
83
|
+
'format': {'type': 'string'}, 'timeout': {'type': 'number'}}
|
|
84
|
+
paths['/q/' + quote(name, safe='')] = {
|
|
85
|
+
'get': {'summary': summary, 'description': detail, 'tags': ['Saved queries (live)'],
|
|
86
|
+
'operationId': operation_id, 'parameters': [*query_params, *common],
|
|
87
|
+
'responses': {'200': _ROWS, **_ERRORS}},
|
|
88
|
+
'post': {'summary': summary + ' (parameters in a JSON body)', 'description': detail,
|
|
89
|
+
'tags': ['Saved queries (live)'], 'operationId': operation_id + '_post',
|
|
90
|
+
'parameters': [_FORMAT_PARAM, *_PAGE_PARAMS],
|
|
91
|
+
'requestBody': {'required': bool(required), 'content': {'application/json': {
|
|
92
|
+
'schema': _object_schema(body_properties)}}},
|
|
93
|
+
'responses': {'200': _ROWS, **_ERRORS}},
|
|
94
|
+
}
|
|
95
|
+
return paths
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_spec(version, saved_queries=None):
|
|
99
|
+
saved = {'filepath': {'type': 'string', 'description': 'Saved query name or path inside saved_sql/.'},
|
|
100
|
+
**_EXEC_PROPS}
|
|
101
|
+
spec = {
|
|
102
|
+
'openapi': '3.0.3',
|
|
103
|
+
'info': {'title': 'QueryAPIGate', 'version': version,
|
|
104
|
+
'description': 'Run SQL against configured databases and get the results back over HTTP.'},
|
|
105
|
+
'components': {
|
|
106
|
+
'securitySchemes': {'ApiKey': {'type': 'apiKey', 'in': 'header', 'name': 'X-API-Key'}},
|
|
107
|
+
'responses': {'Error': {'description': 'Error', 'content': {'application/json': {'schema': {
|
|
108
|
+
'type': 'object', 'properties': {'error': {'type': 'string'}, 'detail': {'type': 'string'}}}}}}},
|
|
109
|
+
},
|
|
110
|
+
'security': [{}, {'ApiKey': []}],
|
|
111
|
+
'paths': {
|
|
112
|
+
'/execute_sql': {'post': {
|
|
113
|
+
'summary': 'Execute SQL', 'tags': ['Query'],
|
|
114
|
+
'parameters': [_FORMAT_PARAM, *_PAGE_PARAMS],
|
|
115
|
+
'requestBody': _body({'sql': {'type': 'string'}, 'connection_name': {'type': 'string'},
|
|
116
|
+
'params': {'type': 'object',
|
|
117
|
+
'description': 'Values for :name bound parameters.'}},
|
|
118
|
+
['sql', 'connection_name']),
|
|
119
|
+
'responses': {'200': _ROWS, **_ERRORS}}},
|
|
120
|
+
'/q/{name}': {
|
|
121
|
+
'parameters': [{'name': 'name', 'in': 'path', 'required': True, 'schema': {'type': 'string'}}],
|
|
122
|
+
'get': {'summary': 'Run a saved query; extra query-string arguments become parameters',
|
|
123
|
+
'tags': ['Saved queries'],
|
|
124
|
+
'parameters': [_FORMAT_PARAM, *_PAGE_PARAMS,
|
|
125
|
+
{'name': 'connection_name', 'in': 'query', 'schema': {'type': 'string'}},
|
|
126
|
+
{'name': 'version', 'in': 'query', 'schema': {'type': 'integer'}}],
|
|
127
|
+
'responses': {'200': _ROWS, **_ERRORS}},
|
|
128
|
+
'post': {'summary': 'Run a saved query with a JSON body of parameters', 'tags': ['Saved queries'],
|
|
129
|
+
'parameters': [_FORMAT_PARAM, *_PAGE_PARAMS],
|
|
130
|
+
'requestBody': _body(_EXEC_PROPS, []),
|
|
131
|
+
'responses': {'200': _ROWS, **_ERRORS}}},
|
|
132
|
+
'/execute_sql_from_file': {'post': {
|
|
133
|
+
'summary': 'Run the latest version of a saved query', 'tags': ['Saved queries'],
|
|
134
|
+
'parameters': [_FORMAT_PARAM, *_PAGE_PARAMS],
|
|
135
|
+
'requestBody': _body(saved, ['filepath']), 'responses': {'200': _ROWS, **_ERRORS}}},
|
|
136
|
+
'/execute_sql_with_parameters_from_file': {'post': {
|
|
137
|
+
'summary': 'Run a saved query with parameter values', 'tags': ['Saved queries'],
|
|
138
|
+
'parameters': [_FORMAT_PARAM, *_PAGE_PARAMS],
|
|
139
|
+
'requestBody': _body(saved, ['filepath']), 'responses': {'200': _ROWS, **_ERRORS}}},
|
|
140
|
+
'/save_sql_to_file': {'patch': {
|
|
141
|
+
'summary': 'Save a query (creates the next version)', 'tags': ['Saved queries'],
|
|
142
|
+
'requestBody': _body({
|
|
143
|
+
'filename': {'type': 'string'}, 'sql_query': {'type': 'string'}, 'author': {'type': 'string'},
|
|
144
|
+
'description': {'type': 'string'}, 'tags': {'type': 'array', 'items': {'type': 'string'}},
|
|
145
|
+
'query_parameters': {'type': 'object', 'example': {'actor_id': 'int'}},
|
|
146
|
+
'connection_name': {'type': 'string', 'description': 'Default connection for /q/{name}.'},
|
|
147
|
+
'cache_ttl': {'type': 'integer', 'minimum': 0, 'description':
|
|
148
|
+
'Seconds to cache a response for; never used for a query that writes.'}},
|
|
149
|
+
['filename', 'sql_query', 'author', 'description']),
|
|
150
|
+
'responses': {'200': {'description': 'Saved'}, **_ERRORS}}},
|
|
151
|
+
'/list_files': {'get': {
|
|
152
|
+
'summary': 'List saved queries', 'tags': ['Saved queries'],
|
|
153
|
+
'parameters': [
|
|
154
|
+
{'name': 'sort_by', 'in': 'query', 'schema': {'type': 'string', 'enum': ['name', 'modified']}},
|
|
155
|
+
{'name': 'sort_order', 'in': 'query', 'schema': {'type': 'string', 'enum': ['asc', 'desc']}}],
|
|
156
|
+
'responses': {'200': {'description': 'Saved queries with version metadata'}, **_ERRORS}}},
|
|
157
|
+
'/view_file_content': {'get': {
|
|
158
|
+
'summary': 'Raw content of a saved query file', 'tags': ['Saved queries'],
|
|
159
|
+
'parameters': [{'name': 'filename', 'in': 'query', 'required': True, 'schema': {'type': 'string'}}],
|
|
160
|
+
'responses': {'200': {'description': 'File content'}, **_ERRORS}}},
|
|
161
|
+
'/saved_sql/{name}': {'delete': {
|
|
162
|
+
'summary': 'Delete a saved query, or one version with ?version=', 'tags': ['Saved queries'],
|
|
163
|
+
'parameters': [{'name': 'name', 'in': 'path', 'required': True, 'schema': {'type': 'string'}},
|
|
164
|
+
{'name': 'version', 'in': 'query', 'schema': {'type': 'integer'}}],
|
|
165
|
+
'responses': {'200': {'description': 'Deleted'}, **_ERRORS}}},
|
|
166
|
+
'/connections': {
|
|
167
|
+
'get': {'summary': 'List connections (passwords masked)', 'tags': ['Connections'],
|
|
168
|
+
'responses': {'200': {'description': 'Connections'}, **_ERRORS}},
|
|
169
|
+
'patch': {'summary': 'Add or update connections', 'tags': ['Connections'],
|
|
170
|
+
'requestBody': _body({'connections': {
|
|
171
|
+
'type': 'object', 'description': 'Map of name to connection details.',
|
|
172
|
+
'additionalProperties': {'type': 'object', 'properties': {
|
|
173
|
+
'db': {'type': 'string', 'enum': list(config.SUPPORTED_DB_TYPES)},
|
|
174
|
+
'host': {'type': 'string'}, 'port': {'type': 'integer'},
|
|
175
|
+
'user': {'type': 'string'}, 'password': {'type': 'string'},
|
|
176
|
+
'database': {'type': 'string'}, 'active': {'type': 'boolean'},
|
|
177
|
+
'jar': {'type': 'string', 'description':
|
|
178
|
+
"db: 'jdbc' only - path to the vendor's JDBC driver .jar."},
|
|
179
|
+
'driver_class': {'type': 'string', 'description':
|
|
180
|
+
"db: 'jdbc' only - the driver's fully-qualified Java class name."},
|
|
181
|
+
'jdbc_url': {'type': 'string', 'description':
|
|
182
|
+
"db: 'jdbc' only - the full JDBC connection URL."}}}}},
|
|
183
|
+
['connections']),
|
|
184
|
+
'responses': {'200': {'description': 'Updated'}, **_ERRORS}}},
|
|
185
|
+
'/connections/{name}': {'delete': {
|
|
186
|
+
'summary': 'Delete a connection', 'tags': ['Connections'],
|
|
187
|
+
'parameters': [{'name': 'name', 'in': 'path', 'required': True, 'schema': {'type': 'string'}}],
|
|
188
|
+
'responses': {'200': {'description': 'Deleted'}, **_ERRORS}}},
|
|
189
|
+
'/connections/{name}/schema': {'get': {
|
|
190
|
+
'summary': "List a connection's tables/views and their columns", 'tags': ['Connections'],
|
|
191
|
+
'parameters': [{'name': 'name', 'in': 'path', 'required': True, 'schema': {'type': 'string'}}],
|
|
192
|
+
'responses': {'200': {'description': 'Tables and columns', 'content': {'application/json': {
|
|
193
|
+
'schema': {'type': 'object', 'properties': {
|
|
194
|
+
'tables': {'type': 'array', 'items': {'type': 'object', 'properties': {
|
|
195
|
+
'name': {'type': 'string'}, 'type': {'type': 'string', 'enum': ['table', 'view']},
|
|
196
|
+
'columns': {'type': 'array', 'items': {'type': 'object', 'properties': {
|
|
197
|
+
'name': {'type': 'string'}, 'type': {'type': 'string'},
|
|
198
|
+
'nullable': {'type': 'boolean'}, 'position': {'type': 'integer'}}}}}}},
|
|
199
|
+
'truncated': {'type': 'boolean', 'description':
|
|
200
|
+
f'True if the schema has more than {schema.ROW_CAP} columns and was cut off.'}}}}}},
|
|
201
|
+
**_ERRORS}}},
|
|
202
|
+
'/api_keys': {
|
|
203
|
+
'get': {'summary': 'List API keys (admin only; never the key itself)', 'tags': ['API keys'],
|
|
204
|
+
'responses': {'200': {'description': 'API keys'}, **_ERRORS}},
|
|
205
|
+
'post': {'summary': 'Create a scoped API key (admin only)', 'tags': ['API keys'],
|
|
206
|
+
'requestBody': _body({
|
|
207
|
+
'name': {'type': 'string'},
|
|
208
|
+
'role': {'type': 'string', 'description':
|
|
209
|
+
'Create this key from a named role (see POST /roles) instead of specifying '
|
|
210
|
+
'grants directly - copies the role\'s connections/allow_writes/queries/'
|
|
211
|
+
'rate_limit/allowed_ips/allowed_write_ops onto this key once, at creation. '
|
|
212
|
+
'Cannot be combined with any of those fields in the same request (400); '
|
|
213
|
+
'create the key from the role, then PATCH it afterward to customize. '
|
|
214
|
+
'expires_at may still be set alongside a role.'},
|
|
215
|
+
'connections': {'description': 'Allowed connection names, or omitted/"*" for all.',
|
|
216
|
+
'oneOf': [{'type': 'array', 'items': {'type': 'string'}},
|
|
217
|
+
{'type': 'string', 'enum': ['*']}]},
|
|
218
|
+
'allow_writes': {'type': 'boolean', 'default': False, 'description':
|
|
219
|
+
'Never wider than the server-wide QUERYAPIGATE_ALLOW_WRITES.'},
|
|
220
|
+
'queries': {'description': 'Saved-query names this key may run regardless of '
|
|
221
|
+
'"connections" - additive, not a narrower version of it; omitted/[] grants '
|
|
222
|
+
'none beyond what "connections" already allows, "*" grants every saved query '
|
|
223
|
+
'by name (but never ad-hoc SQL, and never write access - see below). A list '
|
|
224
|
+
'entry is either a plain name (read access) or {"name": ..., '
|
|
225
|
+
'"allow_writes": true} (write access to that one query specifically, on top '
|
|
226
|
+
'of whatever allow_writes already grants); "*" can never be combined with a '
|
|
227
|
+
'write entry - wanting write access to a specific query means enumerating '
|
|
228
|
+
'the whole list explicitly.',
|
|
229
|
+
'oneOf': [{'type': 'array', 'items': _QUERY_ENTRY_SCHEMA},
|
|
230
|
+
{'type': 'string', 'enum': ['*']}]},
|
|
231
|
+
'expires_at': {'type': 'string', 'format': 'date', 'description':
|
|
232
|
+
'YYYY-MM-DD; the key stops authenticating after the end of this date. '
|
|
233
|
+
'Omitted/null means no expiry.'},
|
|
234
|
+
'rate_limit': {'type': 'string', 'description':
|
|
235
|
+
'This key\'s own limit, e.g. "100/minute" - same grammar as '
|
|
236
|
+
'QUERYAPIGATE_RATE_LIMIT. Checked in addition to the server-wide limit, never '
|
|
237
|
+
'instead of it. Omitted/null means no limit of its own.'},
|
|
238
|
+
'allowed_ips': {'type': 'array', 'items': {'type': 'string'}, 'description':
|
|
239
|
+
'IP addresses or CIDR ranges this key may be used from, e.g. '
|
|
240
|
+
'["203.0.113.5", "10.0.0.0/8"]. Omitted/null means no restriction.'},
|
|
241
|
+
'allowed_write_ops': {'type': 'array', 'items': {'type': 'string'}, 'description':
|
|
242
|
+
'Write statement keywords this key may perform, e.g. ["insert", "update"] '
|
|
243
|
+
'- never widens allow_writes, only narrows which write statements are '
|
|
244
|
+
'permitted once it is on. Omitted/null means every write keyword is '
|
|
245
|
+
'equally permitted (today\'s default behaviour).'}},
|
|
246
|
+
['name']),
|
|
247
|
+
'responses': {'200': {'description':
|
|
248
|
+
'The plaintext key, shown once - only its hash is stored.'}, **_ERRORS}}},
|
|
249
|
+
'/api_keys/{name}': {
|
|
250
|
+
'parameters': [{'name': 'name', 'in': 'path', 'required': True, 'schema': {'type': 'string'}}],
|
|
251
|
+
'patch': {'summary': "Update a key's permissions (admin only; the secret itself never changes)",
|
|
252
|
+
'tags': ['API keys'],
|
|
253
|
+
'requestBody': _body({
|
|
254
|
+
'connections': {'oneOf': [{'type': 'array', 'items': {'type': 'string'}},
|
|
255
|
+
{'type': 'string', 'enum': ['*']}]},
|
|
256
|
+
'allow_writes': {'type': 'boolean'}, 'active': {'type': 'boolean'},
|
|
257
|
+
'queries': {'description': 'Same shape as in POST /api_keys above. Omitted '
|
|
258
|
+
'leaves it unchanged.',
|
|
259
|
+
'oneOf': [{'type': 'array', 'items': _QUERY_ENTRY_SCHEMA},
|
|
260
|
+
{'type': 'string', 'enum': ['*']}]},
|
|
261
|
+
'expires_at': {'type': 'string', 'format': 'date', 'description':
|
|
262
|
+
'YYYY-MM-DD, or explicit null to clear an existing expiry. Omitted leaves '
|
|
263
|
+
'it unchanged.'},
|
|
264
|
+
'rate_limit': {'type': 'string', 'description':
|
|
265
|
+
'e.g. "100/minute", or explicit null to clear it. Omitted leaves it '
|
|
266
|
+
'unchanged.'},
|
|
267
|
+
'allowed_ips': {'type': 'array', 'items': {'type': 'string'}, 'description':
|
|
268
|
+
'IP addresses or CIDR ranges, or explicit null to clear the restriction. '
|
|
269
|
+
'Omitted leaves it unchanged.'},
|
|
270
|
+
'allowed_write_ops': {'type': 'array', 'items': {'type': 'string'}, 'description':
|
|
271
|
+
'Write statement keywords, or explicit null to clear the restriction. '
|
|
272
|
+
'Omitted leaves it unchanged.'}}, []),
|
|
273
|
+
'responses': {'200': {'description': 'Updated'}, **_ERRORS}},
|
|
274
|
+
'delete': {'summary': 'Revoke an API key (admin only)', 'tags': ['API keys'],
|
|
275
|
+
'responses': {'200': {'description': 'Deleted'}, **_ERRORS}}},
|
|
276
|
+
'/roles': {
|
|
277
|
+
'get': {'summary': 'List permission roles (admin only)', 'tags': ['Roles'],
|
|
278
|
+
'responses': {'200': {'description': 'Roles'}, **_ERRORS}},
|
|
279
|
+
'post': {'summary': 'Create a named permission role (admin only)', 'tags': ['Roles'],
|
|
280
|
+
'description': 'A role is a reusable *template*: create_key with a "role" copies '
|
|
281
|
+
'these fields onto the new key once, at creation time. Editing or deleting the '
|
|
282
|
+
'role afterward never changes a key already created from it - the key is the '
|
|
283
|
+
'sole source of truth for every authentication after that.',
|
|
284
|
+
'requestBody': _body({
|
|
285
|
+
'name': {'type': 'string'},
|
|
286
|
+
'connections': {'description': 'Same shape as in POST /api_keys.',
|
|
287
|
+
'oneOf': [{'type': 'array', 'items': {'type': 'string'}},
|
|
288
|
+
{'type': 'string', 'enum': ['*']}]},
|
|
289
|
+
'allow_writes': {'type': 'boolean', 'default': False},
|
|
290
|
+
'queries': {'description': 'Same shape as in POST /api_keys.',
|
|
291
|
+
'oneOf': [{'type': 'array', 'items': _QUERY_ENTRY_SCHEMA},
|
|
292
|
+
{'type': 'string', 'enum': ['*']}]},
|
|
293
|
+
'rate_limit': {'type': 'string', 'description':
|
|
294
|
+
'e.g. "100/minute" - same grammar as QUERYAPIGATE_RATE_LIMIT. Omitted/null means '
|
|
295
|
+
'no limit of its own.'},
|
|
296
|
+
'allowed_ips': {'type': 'array', 'items': {'type': 'string'}, 'description':
|
|
297
|
+
'IP addresses or CIDR ranges. Omitted/null means no restriction.'},
|
|
298
|
+
'allowed_write_ops': {'type': 'array', 'items': {'type': 'string'}, 'description':
|
|
299
|
+
'Write statement keywords, e.g. ["insert", "update"]. Omitted/null means '
|
|
300
|
+
'every write keyword is equally permitted.'}},
|
|
301
|
+
['name']),
|
|
302
|
+
'responses': {'200': {'description': 'Created'}, **_ERRORS}}},
|
|
303
|
+
'/roles/{name}': {
|
|
304
|
+
'parameters': [{'name': 'name', 'in': 'path', 'required': True, 'schema': {'type': 'string'}}],
|
|
305
|
+
'patch': {'summary': 'Update a role (admin only; never affects a key already created from '
|
|
306
|
+
'it)', 'tags': ['Roles'],
|
|
307
|
+
'requestBody': _body({
|
|
308
|
+
'connections': {'oneOf': [{'type': 'array', 'items': {'type': 'string'}},
|
|
309
|
+
{'type': 'string', 'enum': ['*']}]},
|
|
310
|
+
'allow_writes': {'type': 'boolean'},
|
|
311
|
+
'queries': {'oneOf': [{'type': 'array', 'items': _QUERY_ENTRY_SCHEMA},
|
|
312
|
+
{'type': 'string', 'enum': ['*']}]},
|
|
313
|
+
'rate_limit': {'type': 'string', 'description':
|
|
314
|
+
'e.g. "100/minute", or explicit null to clear it. Omitted leaves it '
|
|
315
|
+
'unchanged.'},
|
|
316
|
+
'allowed_ips': {'type': 'array', 'items': {'type': 'string'}, 'description':
|
|
317
|
+
'Or explicit null to clear the restriction. Omitted leaves it unchanged.'},
|
|
318
|
+
'allowed_write_ops': {'type': 'array', 'items': {'type': 'string'}, 'description':
|
|
319
|
+
'Or explicit null to clear the restriction. Omitted leaves it unchanged.'}},
|
|
320
|
+
[]),
|
|
321
|
+
'responses': {'200': {'description': 'Updated'}, **_ERRORS}},
|
|
322
|
+
'delete': {'summary': 'Delete a role (admin only; keys already created from it are '
|
|
323
|
+
'unaffected)', 'tags': ['Roles'],
|
|
324
|
+
'responses': {'200': {'description': 'Deleted'}, **_ERRORS}}},
|
|
325
|
+
'/audit_log': {'get': {
|
|
326
|
+
'summary': 'A durable record of administrative changes - API keys, roles, connections and '
|
|
327
|
+
'saved queries created, changed or removed (admin only)', 'tags': ['API keys'],
|
|
328
|
+
'responses': {'200': {'description': 'Newest entry first, capped at 500 entries'}, **_ERRORS}}},
|
|
329
|
+
'/catalog': {'get': {
|
|
330
|
+
'summary': 'Every saved query this caller can reach, and the terms it is offered under',
|
|
331
|
+
'tags': ['Saved queries'],
|
|
332
|
+
'description': 'Not just what /openapi.json already documents (parameters, description, '
|
|
333
|
+
'connection) but the governance half it has no field for: whether a response can be '
|
|
334
|
+
'cached and for how long, whether this specific caller can write through this query '
|
|
335
|
+
'(may differ per query - see per-query write curation under Authentication and '
|
|
336
|
+
'permissions), and this caller\'s own rate limit alongside the server-wide one. Scoped '
|
|
337
|
+
'the same way /openapi.json already scopes its saved-query list - a query this caller '
|
|
338
|
+
'cannot reach through /q/<name> is never listed here either. Requires authentication '
|
|
339
|
+
'like any other functional endpoint (unlike /openapi.json, this is never public), since '
|
|
340
|
+
'the whole point is answering "what can *I* use."',
|
|
341
|
+
'responses': {'200': {'description': 'Reachable queries and this caller\'s own terms',
|
|
342
|
+
'content': {'application/json': {'schema': {'type': 'object', 'properties': {
|
|
343
|
+
'queries': {'type': 'array', 'items': {'type': 'object', 'properties': {
|
|
344
|
+
'name': {'type': 'string'}, 'version': {'type': 'integer'},
|
|
345
|
+
'description': {'type': 'string'}, 'tags': {'type': 'array', 'items':
|
|
346
|
+
{'type': 'string'}}, 'connection_name': {'type': 'string'},
|
|
347
|
+
'parameters': {'type': 'object'},
|
|
348
|
+
'cache_ttl': {'type': 'integer', 'nullable': True, 'description':
|
|
349
|
+
'Seconds a response may be served from cache, or null when uncached.'},
|
|
350
|
+
'can_write': {'type': 'boolean', 'description':
|
|
351
|
+
'Whether this caller specifically can write through this query.'}}}},
|
|
352
|
+
'caller': {'type': 'object', 'properties': {
|
|
353
|
+
'name': {'type': 'string', 'nullable': True},
|
|
354
|
+
'admin': {'type': 'boolean'}, 'allow_writes': {'type': 'boolean'},
|
|
355
|
+
'allowed_write_ops': {'type': 'array', 'items': {'type': 'string'}, 'nullable': True},
|
|
356
|
+
'rate_limit': {'type': 'string', 'nullable': True, 'description':
|
|
357
|
+
'This caller\'s own additional limit, e.g. "100/minute", or null when it has '
|
|
358
|
+
'none of its own.'},
|
|
359
|
+
'server_rate_limit': {'type': 'string', 'nullable': True, 'description':
|
|
360
|
+
'QUERYAPIGATE_RATE_LIMIT, checked in addition to rate_limit above, or null when '
|
|
361
|
+
'the server has no limit configured.'}}}}}}}}, **_ERRORS}}},
|
|
362
|
+
'/health': {'get': {'summary': 'Liveness check', 'tags': ['Service'],
|
|
363
|
+
'responses': {'200': {'description': 'OK'}}}},
|
|
364
|
+
'/metrics': {'get': {
|
|
365
|
+
'summary': 'Prometheus text-format metrics: request/query counts and latencies, pool occupancy, '
|
|
366
|
+
'rate-limit rejections', 'tags': ['Service'],
|
|
367
|
+
'responses': {'200': {'description': 'Metrics', 'content': {'text/plain': {'schema': {
|
|
368
|
+
'type': 'string'}}}}}}},
|
|
369
|
+
},
|
|
370
|
+
}
|
|
371
|
+
if saved_queries:
|
|
372
|
+
spec['paths'].update(_saved_query_paths(saved_queries))
|
|
373
|
+
spec['tags'] = [{'name': 'Saved queries (live)',
|
|
374
|
+
'description': 'One endpoint per saved query, generated from its latest version: its '
|
|
375
|
+
'declared parameters and their rules, and its default connection.'}]
|
|
376
|
+
return spec
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
DOCS_HTML = """<!doctype html>
|
|
380
|
+
<html><head><meta charset="utf-8"><title>QueryAPIGate docs</title>
|
|
381
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
|
|
382
|
+
<style>
|
|
383
|
+
body { margin: 0; font-family: sans-serif; }
|
|
384
|
+
#key-bar { padding: 8px 16px; background: #f4f4f4; border-bottom: 1px solid #ddd; font-size: 14px; }
|
|
385
|
+
#key-bar input { width: 260px; padding: 4px; }
|
|
386
|
+
</style></head>
|
|
387
|
+
<body>
|
|
388
|
+
<div id="key-bar">
|
|
389
|
+
<a href="ui">Admin UI</a> ·
|
|
390
|
+
API key (only needed if the server sets QUERYAPIGATE_API_KEY; it reveals your saved queries below and is
|
|
391
|
+
sent with "Try it out" requests; kept for this browser tab only):
|
|
392
|
+
<input id="key" type="password" autocomplete="off" placeholder="X-API-Key">
|
|
393
|
+
<button id="save-key">Apply</button>
|
|
394
|
+
</div>
|
|
395
|
+
<div id="ui"></div>
|
|
396
|
+
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
397
|
+
<script>
|
|
398
|
+
var stored = '';
|
|
399
|
+
try { stored = sessionStorage.getItem('queryapigate-key') || ''; } catch (e) {}
|
|
400
|
+
document.getElementById('key').value = stored;
|
|
401
|
+
document.getElementById('save-key').onclick = function () {
|
|
402
|
+
try { sessionStorage.setItem('queryapigate-key', document.getElementById('key').value); } catch (e) {}
|
|
403
|
+
location.reload();
|
|
404
|
+
};
|
|
405
|
+
SwaggerUIBundle({
|
|
406
|
+
url: 'openapi.json', dom_id: '#ui',
|
|
407
|
+
requestInterceptor: function (req) { if (stored) { req.headers['X-API-Key'] = stored; } return req; }
|
|
408
|
+
});
|
|
409
|
+
</script></body></html>
|
|
410
|
+
"""
|