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.
Files changed (64) hide show
  1. {queryapigate-0.8.0/queryapigate.egg-info → queryapigate-0.9.0}/PKG-INFO +4 -2
  2. {queryapigate-0.8.0 → queryapigate-0.9.0}/pyproject.toml +2 -1
  3. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/__init__.py +1 -1
  4. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/app.py +131 -12
  5. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/bundle.py +2 -1
  6. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/config.py +1 -1
  7. queryapigate-0.9.0/queryapigate/definitions.py +98 -0
  8. queryapigate-0.9.0/queryapigate/engine.py +314 -0
  9. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/formats.py +6 -0
  10. queryapigate-0.9.0/queryapigate/mongotools.py +82 -0
  11. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/openapi.py +71 -5
  12. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/runners.py +85 -0
  13. queryapigate-0.9.0/queryapigate/schema.py +134 -0
  14. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/store.py +23 -0
  15. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/ui.py +1477 -159
  16. {queryapigate-0.8.0 → queryapigate-0.9.0/queryapigate.egg-info}/PKG-INFO +4 -2
  17. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate.egg-info/SOURCES.txt +2 -0
  18. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate.egg-info/requires.txt +4 -1
  19. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_audit_log.py +8 -1
  20. queryapigate-0.9.0/tests/test_mongotools.py +81 -0
  21. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_queryapigate.py +324 -3
  22. queryapigate-0.8.0/queryapigate/definitions.py +0 -66
  23. queryapigate-0.8.0/queryapigate/engine.py +0 -166
  24. queryapigate-0.8.0/queryapigate/schema.py +0 -99
  25. {queryapigate-0.8.0 → queryapigate-0.9.0}/LICENSE +0 -0
  26. {queryapigate-0.8.0 → queryapigate-0.9.0}/README.md +0 -0
  27. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/__main__.py +0 -0
  28. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/apikeys.py +0 -0
  29. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/cache.py +0 -0
  30. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/cli.py +0 -0
  31. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/collection_admin.py +0 -0
  32. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/cors.py +0 -0
  33. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/errors.py +0 -0
  34. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/examples.py +0 -0
  35. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/lib/h2-2.2.224.jar +0 -0
  36. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/logging_setup.py +0 -0
  37. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/metrics.py +0 -0
  38. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/params.py +0 -0
  39. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/pool.py +0 -0
  40. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/postman.py +0 -0
  41. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/ratelimit.py +0 -0
  42. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate/sqltools.py +0 -0
  43. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate.egg-info/dependency_links.txt +0 -0
  44. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate.egg-info/entry_points.txt +0 -0
  45. {queryapigate-0.8.0 → queryapigate-0.9.0}/queryapigate.egg-info/top_level.txt +0 -0
  46. {queryapigate-0.8.0 → queryapigate-0.9.0}/setup.cfg +0 -0
  47. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_api_keys.py +0 -0
  48. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_cli.py +0 -0
  49. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_collection_bundle.py +0 -0
  50. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_collection_postman.py +0 -0
  51. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_collections.py +0 -0
  52. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_connection_encryption.py +0 -0
  53. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_cors_and_rate_limit.py +0 -0
  54. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_duckdb.py +0 -0
  55. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_examples.py +0 -0
  56. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_integration.py +0 -0
  57. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_jdbc.py +0 -0
  58. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_observability.py +0 -0
  59. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_params.py +0 -0
  60. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_release_check.py +0 -0
  61. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_response_cache.py +0 -0
  62. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_roles.py +0 -0
  63. {queryapigate-0.8.0 → queryapigate-0.9.0}/tests/test_settings.py +0 -0
  64. {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.8.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",
@@ -1,5 +1,5 @@
1
1
  """QueryAPIGate - expose SQL databases as a REST API."""
2
- __version__ = '0.8.0'
2
+ __version__ = '0.9.0'
3
3
 
4
4
  from .app import create_app # noqa: E402 (app imports __version__)
5
5
 
@@ -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
- if before is not None:
731
- store.record_audit(caller_key_name(), 'delete_connection', name, store.mask_passwords({name: before})[name])
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
- return jsonify(schema.fetch_schema(name)), 200
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
- sql = data.get('sql_query')
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
- sql = data.get('sql_query')
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', 'author', 'description', 'tags', 'query_parameters', 'connection_name',
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}