queryapigate 0.8.0__tar.gz → 0.9.0__tar.gz
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-0.8.0/queryapigate.egg-info → queryapigate-0.9.0}/PKG-INFO +4 -2
- {queryapigate-0.8.0 → queryapigate-0.9.0}/pyproject.toml +2 -1
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/__init__.py +1 -1
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/app.py +131 -12
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/bundle.py +2 -1
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/config.py +1 -1
- queryapigate-0.9.0/queryapigate/definitions.py +98 -0
- queryapigate-0.9.0/queryapigate/engine.py +314 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/formats.py +6 -0
- queryapigate-0.9.0/queryapigate/mongotools.py +82 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/openapi.py +71 -5
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/runners.py +85 -0
- queryapigate-0.9.0/queryapigate/schema.py +134 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/store.py +23 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/ui.py +1477 -159
- {queryapigate-0.8.0 → queryapigate-0.9.0/queryapigate.egg-info}/PKG-INFO +4 -2
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate.egg-info/SOURCES.txt +2 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate.egg-info/requires.txt +4 -1
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_audit_log.py +8 -1
- queryapigate-0.9.0/tests/test_mongotools.py +81 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_queryapigate.py +324 -3
- queryapigate-0.8.0/queryapigate/definitions.py +0 -66
- queryapigate-0.8.0/queryapigate/engine.py +0 -166
- queryapigate-0.8.0/queryapigate/schema.py +0 -99
- {queryapigate-0.8.0 → queryapigate-0.9.0}/LICENSE +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/README.md +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/__main__.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/apikeys.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/cache.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/cli.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/collection_admin.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/cors.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/errors.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/examples.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/lib/h2-2.2.224.jar +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/logging_setup.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/metrics.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/params.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/pool.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/postman.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/ratelimit.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/sqltools.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate.egg-info/dependency_links.txt +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate.egg-info/entry_points.txt +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate.egg-info/top_level.txt +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/setup.cfg +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_api_keys.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_cli.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_collection_bundle.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_collection_postman.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_collections.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_connection_encryption.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_cors_and_rate_limit.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_duckdb.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_examples.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_integration.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_jdbc.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_observability.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_params.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_release_check.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_response_cache.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_roles.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_settings.py +0 -0
- {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_sql_guard_fuzz.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queryapigate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Turn SQL into a REST API: run queries against MySQL, PostgreSQL, ClickHouse, SQLite or H2 over HTTP and get JSON, CSV, XML, YAML or XLSX back.
|
|
5
5
|
Author-email: Anantha Raju C <arcswdev@gmail.com>
|
|
6
6
|
License-Expression: FSL-1.1-MIT
|
|
@@ -41,10 +41,12 @@ Requires-Dist: JayDeBeApi>=1.2; extra == "h2"
|
|
|
41
41
|
Requires-Dist: JPype1>=1.4; extra == "h2"
|
|
42
42
|
Provides-Extra: duckdb
|
|
43
43
|
Requires-Dist: duckdb>=1.0; extra == "duckdb"
|
|
44
|
+
Provides-Extra: mongo
|
|
45
|
+
Requires-Dist: pymongo>=4.0; extra == "mongo"
|
|
44
46
|
Provides-Extra: encryption
|
|
45
47
|
Requires-Dist: cryptography>=41; extra == "encryption"
|
|
46
48
|
Provides-Extra: all
|
|
47
|
-
Requires-Dist: queryapigate[clickhouse,duckdb,encryption,h2,mysql,postgres]; extra == "all"
|
|
49
|
+
Requires-Dist: queryapigate[clickhouse,duckdb,encryption,h2,mongo,mysql,postgres]; extra == "all"
|
|
48
50
|
Provides-Extra: server
|
|
49
51
|
Requires-Dist: gunicorn>=21; extra == "server"
|
|
50
52
|
Provides-Extra: docs
|
|
@@ -35,8 +35,9 @@ postgres = ["psycopg2-binary>=2.9"]
|
|
|
35
35
|
clickhouse = ["clickhouse-driver>=0.2"]
|
|
36
36
|
h2 = ["JayDeBeApi>=1.2", "JPype1>=1.4"] # also needs a Java runtime
|
|
37
37
|
duckdb = ["duckdb>=1.0"] # Connection.interrupt()/InterruptException, relied on for query timeouts, verified from here
|
|
38
|
+
mongo = ["pymongo>=4.0"]
|
|
38
39
|
encryption = ["cryptography>=41"] # only needed when QUERYAPIGATE_SECRET_KEY is set - see store.py's _get_fernet()
|
|
39
|
-
all = ["queryapigate[mysql,postgres,clickhouse,h2,duckdb,encryption]"]
|
|
40
|
+
all = ["queryapigate[mysql,postgres,clickhouse,h2,duckdb,mongo,encryption]"]
|
|
40
41
|
server = ["gunicorn>=21"]
|
|
41
42
|
docs = ["mkdocs-material>=9,<10"]
|
|
42
43
|
dev = ["queryapigate[all]", "ruff>=0.5", "openapi-spec-validator>=0.7", "hypothesis>=6.100", "mypy>=1.10",
|
|
@@ -21,6 +21,7 @@ from . import (
|
|
|
21
21
|
examples,
|
|
22
22
|
logging_setup,
|
|
23
23
|
metrics,
|
|
24
|
+
mongotools,
|
|
24
25
|
openapi,
|
|
25
26
|
pool,
|
|
26
27
|
postman,
|
|
@@ -411,6 +412,12 @@ def execute_sql_endpoint():
|
|
|
411
412
|
if not data.get('connection_name'):
|
|
412
413
|
raise ApiError('Connection name is missing')
|
|
413
414
|
require_connection(data['connection_name'])
|
|
415
|
+
database = data.get('database')
|
|
416
|
+
if database and not g.permission.admin:
|
|
417
|
+
# Every other scoped-key boundary is per connection, never per database within one - a key granted a
|
|
418
|
+
# connection may run any SQL that connection's own configured database allows, but not redirect that
|
|
419
|
+
# same connection at a sibling database on the same server the admin never listed it for.
|
|
420
|
+
raise ApiError('Only the admin key may run a query against a different database on this connection', 403)
|
|
414
421
|
params = get_object(data.get('params'), 'params')
|
|
415
422
|
output_format = get_output_format(data)
|
|
416
423
|
timeout = get_timeout(data)
|
|
@@ -420,7 +427,29 @@ def execute_sql_endpoint():
|
|
|
420
427
|
limit, offset, page = get_pagination()
|
|
421
428
|
result = engine.execute_sql(data['sql'], data['connection_name'], limit, offset, params, timeout,
|
|
422
429
|
allow_writes=g.permission.allow_writes, key_name=caller_key_name(),
|
|
423
|
-
allowed_write_ops=g.permission.allowed_write_ops)
|
|
430
|
+
allowed_write_ops=g.permission.allowed_write_ops, database=database)
|
|
431
|
+
return render(result, output_format, page, limit)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@bp.route('/execute_mongo', methods=['POST'])
|
|
435
|
+
def execute_mongo_endpoint():
|
|
436
|
+
"""The Mongo sibling of /execute_sql - a find() query, ad-hoc, against a saved connection. Read-only,
|
|
437
|
+
full stop (BACKLOG #36: MongoDB support is find-only in this version) - unlike /execute_sql there is no
|
|
438
|
+
write permission to check."""
|
|
439
|
+
data = get_json_body()
|
|
440
|
+
if not data.get('collection'):
|
|
441
|
+
raise ApiError('Collection is missing')
|
|
442
|
+
if not data.get('connection_name'):
|
|
443
|
+
raise ApiError('Connection name is missing')
|
|
444
|
+
require_connection(data['connection_name'])
|
|
445
|
+
filter_doc = mongotools.validate_filter(get_object(data.get('filter'), 'filter'))
|
|
446
|
+
params = get_object(data.get('params'), 'params')
|
|
447
|
+
output_format = get_output_format(data)
|
|
448
|
+
timeout = get_timeout(data)
|
|
449
|
+
limit, offset, page = get_pagination()
|
|
450
|
+
result = engine.execute_mongo(data['collection'], filter_doc, data['connection_name'], limit, offset,
|
|
451
|
+
params, timeout, projection=data.get('projection'), sort=data.get('sort'),
|
|
452
|
+
key_name=caller_key_name())
|
|
424
453
|
return render(result, output_format, page, limit)
|
|
425
454
|
|
|
426
455
|
|
|
@@ -477,19 +506,24 @@ def run_saved(ref, body, url_params):
|
|
|
477
506
|
or apikeys.can_use_collection(g.permission, store.read_collection(content)))
|
|
478
507
|
if not (granted_by_name and connection_name == saved.get('connection_name')):
|
|
479
508
|
require_connection(connection_name)
|
|
509
|
+
|
|
510
|
+
raw = {**url_params, **get_object(body.get('params'), 'params'),
|
|
511
|
+
**get_object(body.get('placeholders'), 'placeholders')}
|
|
512
|
+
output_format = get_output_format(body)
|
|
513
|
+
timeout = get_timeout(body)
|
|
514
|
+
|
|
515
|
+
if saved.get('query_type') == 'mongo':
|
|
516
|
+
return run_saved_mongo(saved, path, number, ref, connection_name, raw, output_format, timeout)
|
|
517
|
+
|
|
480
518
|
if not isinstance(saved.get('sql_query'), str):
|
|
481
519
|
raise ApiError('Saved query has no SQL', 500)
|
|
482
520
|
# Per-query write curation (apikeys.can_write_query()) only ever adds write reach for this one named
|
|
483
521
|
# query on top of whatever the key's blanket allow_writes already grants - never the other way round.
|
|
484
522
|
effective_allow_writes = g.permission.allow_writes or apikeys.can_write_query(g.permission, query_name)
|
|
485
523
|
|
|
486
|
-
raw = {**url_params, **get_object(body.get('params'), 'params'),
|
|
487
|
-
**get_object(body.get('placeholders'), 'placeholders')}
|
|
488
524
|
used = set(sqltools.placeholder_names(saved['sql_query']))
|
|
489
525
|
values = param_rules.resolve(saved.get('query_parameters'), raw, used=used)
|
|
490
526
|
sql = sqltools.fill_placeholders(saved['sql_query'], values)
|
|
491
|
-
output_format = get_output_format(body)
|
|
492
|
-
timeout = get_timeout(body)
|
|
493
527
|
|
|
494
528
|
if get_stream_flag():
|
|
495
529
|
# No caching for a streamed export - caching would require materialising the whole body anyway,
|
|
@@ -529,6 +563,32 @@ def run_saved(ref, body, url_params):
|
|
|
529
563
|
return response
|
|
530
564
|
|
|
531
565
|
|
|
566
|
+
def run_saved_mongo(saved, path, number, ref, connection_name, raw, output_format, timeout):
|
|
567
|
+
"""The mongo query_type branch of run_saved(): a find() query instead of SQL. No caching (cache_ttl is
|
|
568
|
+
not yet supported for a mongo saved query) and no streaming (?stream=true) in this version - see
|
|
569
|
+
BACKLOG #36. Always read-only, same as the ad-hoc /execute_mongo."""
|
|
570
|
+
if get_stream_flag():
|
|
571
|
+
raise ApiError('Streaming is not supported for Mongo queries yet', 400)
|
|
572
|
+
used = set(mongotools.placeholder_names(saved.get('mongo_filter') or {}))
|
|
573
|
+
values = param_rules.resolve(saved.get('query_parameters'), raw, used=used)
|
|
574
|
+
filter_doc = mongotools.fill_placeholders(saved.get('mongo_filter') or {}, values)
|
|
575
|
+
limit, offset, page = get_pagination()
|
|
576
|
+
entry = {'executed_at': store.now(), 'connection_name': connection_name,
|
|
577
|
+
'request_id': g.get('request_id'), 'key_name': caller_key_name()}
|
|
578
|
+
try:
|
|
579
|
+
result, elapsed_ms = engine.timed(engine.execute_mongo, saved['mongo_collection'], filter_doc,
|
|
580
|
+
connection_name, limit, offset, timeout=timeout,
|
|
581
|
+
projection=saved.get('mongo_projection'), sort=saved.get('mongo_sort'),
|
|
582
|
+
key_name=caller_key_name())
|
|
583
|
+
except ApiError as error:
|
|
584
|
+
store.record_execution(path, number, {**entry, 'status': 'error', 'error': error.message})
|
|
585
|
+
raise
|
|
586
|
+
response = render(result, output_format, page, limit) # sets g.serialization_ms - see render()
|
|
587
|
+
store.record_execution(path, number, {**entry, 'status': 'success', 'rows': len(result.rows),
|
|
588
|
+
'duration_ms': elapsed_ms, 'serialization_ms': g.serialization_ms})
|
|
589
|
+
return response
|
|
590
|
+
|
|
591
|
+
|
|
532
592
|
@bp.route('/execute_sql_from_file', methods=['POST'])
|
|
533
593
|
@bp.route('/execute_sql_with_parameters_from_file', methods=['POST'])
|
|
534
594
|
def execute_sql_from_file():
|
|
@@ -722,13 +782,63 @@ def update_connections():
|
|
|
722
782
|
return jsonify({'message': 'Connections updated successfully'}), 200
|
|
723
783
|
|
|
724
784
|
|
|
785
|
+
@bp.route('/connections/databases', methods=['POST'])
|
|
786
|
+
def list_databases_route():
|
|
787
|
+
"""Every database on the server a connection points at - saved, or not yet saved. Give 'name' alone for
|
|
788
|
+
an already-saved connection (Run SQL's database picker); give the connection's own fields (as
|
|
789
|
+
/connections/test does) to probe one that isn't saved yet - the New/Edit connection form's 'Default
|
|
790
|
+
database' dropdown - or to try changed fields before saving them. Admin only, like every other endpoint
|
|
791
|
+
that reveals the server's own configuration or reaches into a connection's server ahead of a real query."""
|
|
792
|
+
require_admin()
|
|
793
|
+
body = get_json_body()
|
|
794
|
+
if not isinstance(body, dict):
|
|
795
|
+
raise ApiError('Connection fields are missing')
|
|
796
|
+
if body.get('name') and not body.get('db'):
|
|
797
|
+
databases = schema.list_databases(body['name'])
|
|
798
|
+
else:
|
|
799
|
+
if body.get('db') not in config.SUPPORTED_DB_TYPES:
|
|
800
|
+
raise ApiError(f"'db' must be one of: {', '.join(config.SUPPORTED_DB_TYPES)}")
|
|
801
|
+
if body.get('password') == config.PASSWORD_MASK:
|
|
802
|
+
existing = store.read_connections().get(body.get('name') or '', {})
|
|
803
|
+
body = {**body, 'password': existing.get('password', '')}
|
|
804
|
+
databases = engine.list_databases(store.resolve_ad_hoc(body))
|
|
805
|
+
return jsonify({'databases': databases}), 200
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
@bp.route('/connections/test', methods=['POST'])
|
|
809
|
+
def test_connection_route():
|
|
810
|
+
"""Try to connect with the given fields - not a saved connection's name, the fields themselves, exactly
|
|
811
|
+
as the New/Edit connection form has them right now. Nothing is written anywhere; a masked password
|
|
812
|
+
(unchanged from an existing connection's form) is resolved to the real stored value first, the same way
|
|
813
|
+
update_connections() already treats one."""
|
|
814
|
+
require_admin()
|
|
815
|
+
details = get_json_body()
|
|
816
|
+
if not isinstance(details, dict) or details.get('db') not in config.SUPPORTED_DB_TYPES:
|
|
817
|
+
raise ApiError(f"'db' must be one of: {', '.join(config.SUPPORTED_DB_TYPES)}")
|
|
818
|
+
if details.get('password') == config.PASSWORD_MASK:
|
|
819
|
+
existing = store.read_connections().get(details.get('name') or '', {})
|
|
820
|
+
details = {**details, 'password': existing.get('password', '')}
|
|
821
|
+
result = engine.test_connection(store.resolve_ad_hoc(details))
|
|
822
|
+
return jsonify({'message': 'Connected', **result}), 200
|
|
823
|
+
|
|
824
|
+
|
|
725
825
|
@bp.route('/connections/<name>', methods=['DELETE'])
|
|
726
826
|
def delete_connection(name):
|
|
827
|
+
"""Deleting a connection is destructive and hard to reason about after the fact - every saved query that
|
|
828
|
+
used it stops working - so the caller must say why. The reason is stored nowhere but the audit log (there
|
|
829
|
+
is no separate "deleted connections" table); the admin UI's own Deleted tab reads it straight from there,
|
|
830
|
+
the same as everything else on this route already does."""
|
|
727
831
|
require_admin()
|
|
728
832
|
before = store.read_connections().get(name)
|
|
833
|
+
if before is None:
|
|
834
|
+
raise ApiError(f"Connection '{name}' not found", 404)
|
|
835
|
+
data = get_json_body(required=False)
|
|
836
|
+
reason = (data.get('reason') or '').strip()
|
|
837
|
+
if not reason:
|
|
838
|
+
raise ApiError('A reason is required to delete a connection')
|
|
729
839
|
store.delete_connection(name)
|
|
730
|
-
|
|
731
|
-
|
|
840
|
+
changes = {**store.mask_passwords({name: before})[name], 'deleted_reason': reason}
|
|
841
|
+
store.record_audit(caller_key_name(), 'delete_connection', name, changes)
|
|
732
842
|
pool.close_pooled_connections() # a removed connection must not keep serving from idle sockets
|
|
733
843
|
return jsonify({'message': f"Connection '{name}' deleted"}), 200
|
|
734
844
|
|
|
@@ -736,7 +846,10 @@ def delete_connection(name):
|
|
|
736
846
|
@bp.route('/connections/<name>/schema', methods=['GET'])
|
|
737
847
|
def connection_schema(name):
|
|
738
848
|
require_connection(name)
|
|
739
|
-
|
|
849
|
+
database = request.args.get('database') or None
|
|
850
|
+
if database and not g.permission.admin:
|
|
851
|
+
raise ApiError('Only the admin key may browse a different database on this connection', 403)
|
|
852
|
+
return jsonify(schema.fetch_schema(name, database=database)), 200
|
|
740
853
|
|
|
741
854
|
|
|
742
855
|
# --------------------------------------------------------------------------------------
|
|
@@ -881,6 +994,14 @@ def metrics_endpoint():
|
|
|
881
994
|
return Response(metrics.render(), mimetype='text/plain; version=0.0.4; charset=utf-8')
|
|
882
995
|
|
|
883
996
|
|
|
997
|
+
def _is_runnable(data):
|
|
998
|
+
"""Whether a saved-query version has something to actually execute - a SQL string, or (query_type
|
|
999
|
+
'mongo') a Mongo collection - so a definition left mid-edit or otherwise malformed never gets listed
|
|
1000
|
+
somewhere a caller might then try to run it."""
|
|
1001
|
+
return isinstance(data.get('sql_query'), str) or (data.get('query_type') == 'mongo'
|
|
1002
|
+
and isinstance(data.get('mongo_collection'), str))
|
|
1003
|
+
|
|
1004
|
+
|
|
884
1005
|
def describe_saved_queries(permission):
|
|
885
1006
|
"""What the OpenAPI document needs to know about each saved query (never its SQL text) that
|
|
886
1007
|
``permission`` may actually call - the same test run_saved() applies, so a key scoped to specific
|
|
@@ -888,8 +1009,7 @@ def describe_saved_queries(permission):
|
|
|
888
1009
|
unrestricted (admin, or connection-wide) key sees everything, unchanged from before this filter."""
|
|
889
1010
|
described = []
|
|
890
1011
|
for name, number, data, collection in store.latest_versions():
|
|
891
|
-
|
|
892
|
-
if not isinstance(sql, str):
|
|
1012
|
+
if not _is_runnable(data):
|
|
893
1013
|
continue
|
|
894
1014
|
connection_name = data.get('connection_name')
|
|
895
1015
|
if not apikeys.can_run_saved(permission, name, collection, connection_name):
|
|
@@ -920,8 +1040,7 @@ def describe_catalog(permission):
|
|
|
920
1040
|
caller* can write through it (apikeys.can_write_query() - independent of their blanket allow_writes)."""
|
|
921
1041
|
catalog = []
|
|
922
1042
|
for name, number, data, collection in store.latest_versions():
|
|
923
|
-
|
|
924
|
-
if not isinstance(sql, str):
|
|
1043
|
+
if not _is_runnable(data):
|
|
925
1044
|
continue
|
|
926
1045
|
connection_name = data.get('connection_name')
|
|
927
1046
|
if not apikeys.can_run_saved(permission, name, collection, connection_name):
|
|
@@ -18,7 +18,8 @@ FORMAT_VERSION = 1
|
|
|
18
18
|
POLICIES = ('fail', 'skip', 'new-version')
|
|
19
19
|
# Everything an entry may carry. Anything else is an error rather than silently dropped: a field the importer
|
|
20
20
|
# ignores is a field whose loss nobody notices.
|
|
21
|
-
ENTRY_FIELDS = {'name', 'sql_query', '
|
|
21
|
+
ENTRY_FIELDS = {'name', 'sql_query', 'query_type', 'mongo_collection', 'mongo_filter', 'mongo_projection',
|
|
22
|
+
'mongo_sort', 'author', 'description', 'tags', 'query_parameters', 'connection_name',
|
|
22
23
|
'cache_ttl'}
|
|
23
24
|
|
|
24
25
|
|
|
@@ -3,7 +3,7 @@ import os
|
|
|
3
3
|
import re
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
-
SUPPORTED_DB_TYPES = ('mysql', 'postgres', 'clickhouse', 'sqlite', 'h2', 'jdbc', 'duckdb')
|
|
6
|
+
SUPPORTED_DB_TYPES = ('mysql', 'postgres', 'clickhouse', 'sqlite', 'h2', 'jdbc', 'duckdb', 'mongo')
|
|
7
7
|
PASSWORD_MASK = '********'
|
|
8
8
|
CONNECT_TIMEOUT = 10 # seconds
|
|
9
9
|
HISTORY_LIMIT = 50 # executions remembered per saved-query version
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Validation of a saved-query definition - shared by everything that creates one (``PATCH /save_sql_to_file``
|
|
2
|
+
and ``queryapigate collection import``), so a query is held to the same rules however it arrives."""
|
|
3
|
+
from . import mongotools, sqltools, store
|
|
4
|
+
from . import params as param_rules
|
|
5
|
+
from .errors import ApiError
|
|
6
|
+
|
|
7
|
+
NO_COLLECTION_GIVEN = store._UNSET # the request said nothing about a collection: leave the query's as it is
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def as_object(value, label):
|
|
11
|
+
if value is None:
|
|
12
|
+
return {}
|
|
13
|
+
if not isinstance(value, dict):
|
|
14
|
+
raise ApiError(f'{label} must be a JSON object')
|
|
15
|
+
return value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_definition(data):
|
|
19
|
+
"""Check ``data`` (a decoded request body or bundle entry) and return ``(fields, collection)``: the fields
|
|
20
|
+
to store on the new version, and the collection to file the query under - ``NO_COLLECTION_GIVEN`` when
|
|
21
|
+
``data`` has no ``collection`` key, None to remove it from any, or a valid collection name.
|
|
22
|
+
|
|
23
|
+
``query_type`` (default ``'sql'``) picks which shape the query itself takes: ``sql_query`` (a SQL
|
|
24
|
+
string) or, for a Mongo find query, ``mongo_collection`` + ``mongo_filter`` (a JSON filter document,
|
|
25
|
+
optionally ``mongo_projection``/``mongo_sort``) - see mongotools.py and BACKLOG #36. Everything else
|
|
26
|
+
(author/description/tags/query_parameters/connection_name/cache_ttl/collection) is shared between the
|
|
27
|
+
two, unchanged either way.
|
|
28
|
+
"""
|
|
29
|
+
query_type = data.get('query_type', 'sql')
|
|
30
|
+
if query_type not in ('sql', 'mongo'):
|
|
31
|
+
raise ApiError("query_type must be 'sql' or 'mongo'")
|
|
32
|
+
required = [('author', 'Author'), ('description', 'Description'), ('filename', 'Filename')]
|
|
33
|
+
required.append(('sql_query', 'SQL query') if query_type == 'sql' else ('mongo_collection', 'Mongo collection'))
|
|
34
|
+
for field, label in required:
|
|
35
|
+
if not data.get(field) or not isinstance(data[field], str):
|
|
36
|
+
raise ApiError(f'{label} is missing')
|
|
37
|
+
tags = data.get('tags', [])
|
|
38
|
+
if not isinstance(tags, (list, str)):
|
|
39
|
+
raise ApiError('tags must be a string or a list')
|
|
40
|
+
query_parameters = as_object(data.get('query_parameters'), 'query_parameters')
|
|
41
|
+
param_rules.parse_definitions(query_parameters)
|
|
42
|
+
if query_type == 'mongo':
|
|
43
|
+
mongo_filter = mongotools.validate_filter(as_object(data.get('mongo_filter'), 'mongo_filter'))
|
|
44
|
+
used = set(mongotools.placeholder_names(mongo_filter))
|
|
45
|
+
else:
|
|
46
|
+
used = set(sqltools.placeholder_names(data['sql_query']))
|
|
47
|
+
unused = sorted(set(query_parameters) - used)
|
|
48
|
+
if unused:
|
|
49
|
+
where = 'the filter' if query_type == 'mongo' else 'sql_query'
|
|
50
|
+
raise ApiError(f"query_parameters declares {', '.join(unused)}, which {where} does not use "
|
|
51
|
+
'(write :name in it, or remove the declaration)')
|
|
52
|
+
connection_name = data.get('connection_name')
|
|
53
|
+
if connection_name is not None and not isinstance(connection_name, str):
|
|
54
|
+
raise ApiError('connection_name must be a string')
|
|
55
|
+
cache_ttl = data.get('cache_ttl')
|
|
56
|
+
if cache_ttl is not None and (not isinstance(cache_ttl, int) or isinstance(cache_ttl, bool) or cache_ttl < 0):
|
|
57
|
+
raise ApiError('cache_ttl must be a non-negative integer number of seconds')
|
|
58
|
+
collection = NO_COLLECTION_GIVEN
|
|
59
|
+
if 'collection' in data:
|
|
60
|
+
collection = data['collection']
|
|
61
|
+
if collection is not None:
|
|
62
|
+
store.validate_collection_name(collection)
|
|
63
|
+
|
|
64
|
+
fields = {
|
|
65
|
+
'author': data['author'],
|
|
66
|
+
'description': data['description'],
|
|
67
|
+
'tags': tags,
|
|
68
|
+
'query_parameters': query_parameters,
|
|
69
|
+
**({'connection_name': connection_name} if connection_name else {}),
|
|
70
|
+
**({'cache_ttl': cache_ttl} if cache_ttl else {}),
|
|
71
|
+
}
|
|
72
|
+
if query_type == 'mongo':
|
|
73
|
+
# No 'query_type' field at all for a plain SQL query (query_type's own default) - every saved query
|
|
74
|
+
# ever written before this version is missing it too, and every check of it elsewhere
|
|
75
|
+
# (saved.get('query_type') == 'mongo') already treats that absence as "sql", so this keeps a SQL
|
|
76
|
+
# query's stored shape byte-for-byte unchanged, not just behaviourally equivalent.
|
|
77
|
+
fields['query_type'] = 'mongo'
|
|
78
|
+
fields['mongo_collection'] = data['mongo_collection']
|
|
79
|
+
fields['mongo_filter'] = mongo_filter
|
|
80
|
+
for extra in ('mongo_projection', 'mongo_sort'):
|
|
81
|
+
if data.get(extra) is not None:
|
|
82
|
+
fields[extra] = as_object(data.get(extra), extra)
|
|
83
|
+
else:
|
|
84
|
+
fields['sql_query'] = data['sql_query']
|
|
85
|
+
return fields, collection
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def effective_parameters(saved):
|
|
89
|
+
"""A saved version's parameters as callers must supply them: every placeholder its query actually uses, in
|
|
90
|
+
the order it uses them, with the declared rules (an undeclared one is plain required text). The one place
|
|
91
|
+
this is derived - the OpenAPI document, the catalogue and the Postman export all describe a query's
|
|
92
|
+
parameters, and must not each work them out separately."""
|
|
93
|
+
declared = param_rules.read_definitions(saved.get('query_parameters'))
|
|
94
|
+
if saved.get('query_type') == 'mongo':
|
|
95
|
+
names = mongotools.placeholder_names(saved.get('mongo_filter') or {})
|
|
96
|
+
else:
|
|
97
|
+
names = sqltools.placeholder_names(saved['sql_query'])
|
|
98
|
+
return {name: declared.get(name) or param_rules.read_definition({}) for name in names}
|