api-dock 0.7.0__tar.gz → 0.7.1__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.
- {api_dock-0.7.0 → api_dock-0.7.1}/PKG-INFO +120 -1
- {api_dock-0.7.0 → api_dock-0.7.1}/README.md +119 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/database_config.py +4 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/databases/example_db.yaml +14 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/route_mapper.py +8 -1
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/sql_builder.py +390 -3
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock.egg-info/PKG-INFO +120 -1
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock.egg-info/SOURCES.txt +1 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/pyproject.toml +1 -1
- api_dock-0.7.1/tests/test_sql_selector.py +277 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/LICENSE.md +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/__init__.py +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/auth.py +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/cli.py +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/config.py +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/config_discovery.py +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/encryption.py +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/config.yaml +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/remotes/example_remote.yaml +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/fast_api.py +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/flask_api.py +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/storage_auth.py +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/types.py +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock.egg-info/dependency_links.txt +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock.egg-info/entry_points.txt +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock.egg-info/requires.txt +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/api_dock.egg-info/top_level.txt +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/setup.cfg +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/tests/test_inject_cookies.py +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/tests/test_proxy_pipeline.py +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/tests/test_sql_builder.py +0 -0
- {api_dock-0.7.0 → api_dock-0.7.1}/tests/test_types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: api_dock
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: A flexible API gateway that allows you to proxy requests to multiple remote APIs and Databases
|
|
5
5
|
Author-email: Brookie Guzder-Williams <bguzder-williams@berkeley.edu>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -672,6 +672,125 @@ Parameters are processed in this order (first match wins for early returns):
|
|
|
672
672
|
|
|
673
673
|
---
|
|
674
674
|
|
|
675
|
+
## Conditional SQL Selection
|
|
676
|
+
|
|
677
|
+
Some routes need a *different* base query depending on the request — for example, `?count=true` should return a species histogram (`SELECT … COUNT(*) … GROUP BY …`) rather than rows. `sql_append` can't help (it only adds trailing clauses), and a second `route:` can't either (the path is identical). For this, a route's `sql` may be a **rule list** instead of a string: a first-match-wins decision tree that picks the base query from the presence and value of path, query, and cookie params.
|
|
678
|
+
|
|
679
|
+
Everything downstream is unchanged: the selected base composes with `query_params` WHERE-fragments and `sql_append` exactly as a plain `sql:` string does.
|
|
680
|
+
|
|
681
|
+
### The histogram example
|
|
682
|
+
|
|
683
|
+
```yaml
|
|
684
|
+
routes:
|
|
685
|
+
- route: detections
|
|
686
|
+
sql:
|
|
687
|
+
# ?count=<truthy> → species histogram
|
|
688
|
+
- when: count
|
|
689
|
+
then:
|
|
690
|
+
sql: >
|
|
691
|
+
SELECT [[detections]].common_name, [[detections]].scientific_name,
|
|
692
|
+
COUNT(*) AS count
|
|
693
|
+
FROM [[detections]]
|
|
694
|
+
sql_append: GROUP BY [[detections]].common_name, [[detections]].scientific_name
|
|
695
|
+
# otherwise → detection rows
|
|
696
|
+
- else: SELECT [[detections]].* FROM [[detections]]
|
|
697
|
+
query_params:
|
|
698
|
+
- recording:
|
|
699
|
+
sql: "[[detections]].recording_id = {{recording}}"
|
|
700
|
+
multivalue_sql: "[[detections]].recording_id IN {{recording}}"
|
|
701
|
+
- limit:
|
|
702
|
+
sql_append: LIMIT {{limit}} # applies in BOTH modes
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
```bash
|
|
706
|
+
GET /db/detections?recording=1&count=true
|
|
707
|
+
# SELECT detections.common_name, detections.scientific_name, COUNT(*) AS count
|
|
708
|
+
# FROM detections WHERE detections.recording_id = '1'
|
|
709
|
+
# GROUP BY detections.common_name, detections.scientific_name
|
|
710
|
+
|
|
711
|
+
GET /db/detections?recording=1
|
|
712
|
+
# SELECT detections.* FROM detections WHERE detections.recording_id = '1'
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
Note the pipeline order: the selected branch's `sql_append` (the `GROUP BY`) is applied **before** route-level `sql_append` (the shared `LIMIT`), so SQL clause order stays valid.
|
|
716
|
+
|
|
717
|
+
### Rule forms
|
|
718
|
+
|
|
719
|
+
A `sql` list contains rules evaluated top to bottom; the **first match wins**. Each rule's payload (an *sql node*) is a SQL string, a leaf object `{sql, sql_append}`, or a nested rule list.
|
|
720
|
+
|
|
721
|
+
```yaml
|
|
722
|
+
sql:
|
|
723
|
+
- when: count # fires when `count` is truthy (shorthand for equals: _truthy)
|
|
724
|
+
then: <sql node>
|
|
725
|
+
|
|
726
|
+
- when: mode
|
|
727
|
+
equals: 'true' # fires only when mode == "true" (case-insensitive)
|
|
728
|
+
then: <sql node>
|
|
729
|
+
|
|
730
|
+
- when: format # value map: different SQL per value
|
|
731
|
+
match:
|
|
732
|
+
species: <sql node> # ?format=species
|
|
733
|
+
recording: <sql node> # ?format=recording
|
|
734
|
+
_truthy: <sql node> # any other truthy value
|
|
735
|
+
_default: <sql node> # any present value not matched above
|
|
736
|
+
|
|
737
|
+
- when: [count, recording] # list: fires when ALL are truthy (AND)
|
|
738
|
+
then: <sql node>
|
|
739
|
+
|
|
740
|
+
- when: [count, something_else] # list + positional case list
|
|
741
|
+
match:
|
|
742
|
+
- values: [_any, x] # something_else == x, count anything
|
|
743
|
+
then: <sql node>
|
|
744
|
+
- values: [_truthy, _absent] # count truthy AND something_else not passed
|
|
745
|
+
then: <sql node>
|
|
746
|
+
- default: <sql node>
|
|
747
|
+
|
|
748
|
+
- else: <sql node> # default (a trailing bare string works too)
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
Nesting works because a payload can itself be a rule list:
|
|
752
|
+
|
|
753
|
+
```yaml
|
|
754
|
+
sql:
|
|
755
|
+
- when: some_value
|
|
756
|
+
match:
|
|
757
|
+
'4':
|
|
758
|
+
- when: count
|
|
759
|
+
then: <sql for value 4 with count>
|
|
760
|
+
- else: <sql for value 4>
|
|
761
|
+
_default: <sql for other values>
|
|
762
|
+
- else: <base sql>
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
### Value specs
|
|
766
|
+
|
|
767
|
+
| spec | matches when the param… |
|
|
768
|
+
|---|---|
|
|
769
|
+
| `'literal'` (`'4'`, `'true'`) | is present and equals it (case-insensitive) |
|
|
770
|
+
| `_truthy` / `_falsy` | present, and value is / isn't in `{"", "0", "false", "no", "off", "null", "none"}` |
|
|
771
|
+
| `_present` / `_absent` | exists / does not exist |
|
|
772
|
+
| `_any` | wildcard — present or absent (used for a position in a case list) |
|
|
773
|
+
| `_default` | catch-all for any *present* value (value maps only) |
|
|
774
|
+
|
|
775
|
+
In a single-param `match:` map, precedence is order-independent: exact literal > `_falsy`/`_truthy` > `_present`/`_absent` > `_default`. In a positional case list, cases match strictly top-to-bottom.
|
|
776
|
+
|
|
777
|
+
### No match → URL error
|
|
778
|
+
|
|
779
|
+
If no rule matches and there is no default (`else`, a trailing bare string, or a `_default`/`default` catch-all), the request returns a **400** with `{"error": "No matching query configuration for the given parameters", "http_status": 400}`. Customize it with a terminal `no_match` rule:
|
|
780
|
+
|
|
781
|
+
```yaml
|
|
782
|
+
sql:
|
|
783
|
+
- when: recording
|
|
784
|
+
then: SELECT [[detections]].* FROM [[detections]] WHERE recording_id = {{recording}}
|
|
785
|
+
- no_match:
|
|
786
|
+
error: "recording is required, or pass count=true for a histogram"
|
|
787
|
+
http_status: 400
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
Cookies participate via the `cookies.<name>` key (e.g. `when: cookies.role`, `equals: admin`).
|
|
791
|
+
|
|
792
|
+
---
|
|
793
|
+
|
|
675
794
|
# CLI
|
|
676
795
|
|
|
677
796
|
## Commands
|
|
@@ -633,6 +633,125 @@ Parameters are processed in this order (first match wins for early returns):
|
|
|
633
633
|
|
|
634
634
|
---
|
|
635
635
|
|
|
636
|
+
## Conditional SQL Selection
|
|
637
|
+
|
|
638
|
+
Some routes need a *different* base query depending on the request — for example, `?count=true` should return a species histogram (`SELECT … COUNT(*) … GROUP BY …`) rather than rows. `sql_append` can't help (it only adds trailing clauses), and a second `route:` can't either (the path is identical). For this, a route's `sql` may be a **rule list** instead of a string: a first-match-wins decision tree that picks the base query from the presence and value of path, query, and cookie params.
|
|
639
|
+
|
|
640
|
+
Everything downstream is unchanged: the selected base composes with `query_params` WHERE-fragments and `sql_append` exactly as a plain `sql:` string does.
|
|
641
|
+
|
|
642
|
+
### The histogram example
|
|
643
|
+
|
|
644
|
+
```yaml
|
|
645
|
+
routes:
|
|
646
|
+
- route: detections
|
|
647
|
+
sql:
|
|
648
|
+
# ?count=<truthy> → species histogram
|
|
649
|
+
- when: count
|
|
650
|
+
then:
|
|
651
|
+
sql: >
|
|
652
|
+
SELECT [[detections]].common_name, [[detections]].scientific_name,
|
|
653
|
+
COUNT(*) AS count
|
|
654
|
+
FROM [[detections]]
|
|
655
|
+
sql_append: GROUP BY [[detections]].common_name, [[detections]].scientific_name
|
|
656
|
+
# otherwise → detection rows
|
|
657
|
+
- else: SELECT [[detections]].* FROM [[detections]]
|
|
658
|
+
query_params:
|
|
659
|
+
- recording:
|
|
660
|
+
sql: "[[detections]].recording_id = {{recording}}"
|
|
661
|
+
multivalue_sql: "[[detections]].recording_id IN {{recording}}"
|
|
662
|
+
- limit:
|
|
663
|
+
sql_append: LIMIT {{limit}} # applies in BOTH modes
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
```bash
|
|
667
|
+
GET /db/detections?recording=1&count=true
|
|
668
|
+
# SELECT detections.common_name, detections.scientific_name, COUNT(*) AS count
|
|
669
|
+
# FROM detections WHERE detections.recording_id = '1'
|
|
670
|
+
# GROUP BY detections.common_name, detections.scientific_name
|
|
671
|
+
|
|
672
|
+
GET /db/detections?recording=1
|
|
673
|
+
# SELECT detections.* FROM detections WHERE detections.recording_id = '1'
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
Note the pipeline order: the selected branch's `sql_append` (the `GROUP BY`) is applied **before** route-level `sql_append` (the shared `LIMIT`), so SQL clause order stays valid.
|
|
677
|
+
|
|
678
|
+
### Rule forms
|
|
679
|
+
|
|
680
|
+
A `sql` list contains rules evaluated top to bottom; the **first match wins**. Each rule's payload (an *sql node*) is a SQL string, a leaf object `{sql, sql_append}`, or a nested rule list.
|
|
681
|
+
|
|
682
|
+
```yaml
|
|
683
|
+
sql:
|
|
684
|
+
- when: count # fires when `count` is truthy (shorthand for equals: _truthy)
|
|
685
|
+
then: <sql node>
|
|
686
|
+
|
|
687
|
+
- when: mode
|
|
688
|
+
equals: 'true' # fires only when mode == "true" (case-insensitive)
|
|
689
|
+
then: <sql node>
|
|
690
|
+
|
|
691
|
+
- when: format # value map: different SQL per value
|
|
692
|
+
match:
|
|
693
|
+
species: <sql node> # ?format=species
|
|
694
|
+
recording: <sql node> # ?format=recording
|
|
695
|
+
_truthy: <sql node> # any other truthy value
|
|
696
|
+
_default: <sql node> # any present value not matched above
|
|
697
|
+
|
|
698
|
+
- when: [count, recording] # list: fires when ALL are truthy (AND)
|
|
699
|
+
then: <sql node>
|
|
700
|
+
|
|
701
|
+
- when: [count, something_else] # list + positional case list
|
|
702
|
+
match:
|
|
703
|
+
- values: [_any, x] # something_else == x, count anything
|
|
704
|
+
then: <sql node>
|
|
705
|
+
- values: [_truthy, _absent] # count truthy AND something_else not passed
|
|
706
|
+
then: <sql node>
|
|
707
|
+
- default: <sql node>
|
|
708
|
+
|
|
709
|
+
- else: <sql node> # default (a trailing bare string works too)
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
Nesting works because a payload can itself be a rule list:
|
|
713
|
+
|
|
714
|
+
```yaml
|
|
715
|
+
sql:
|
|
716
|
+
- when: some_value
|
|
717
|
+
match:
|
|
718
|
+
'4':
|
|
719
|
+
- when: count
|
|
720
|
+
then: <sql for value 4 with count>
|
|
721
|
+
- else: <sql for value 4>
|
|
722
|
+
_default: <sql for other values>
|
|
723
|
+
- else: <base sql>
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
### Value specs
|
|
727
|
+
|
|
728
|
+
| spec | matches when the param… |
|
|
729
|
+
|---|---|
|
|
730
|
+
| `'literal'` (`'4'`, `'true'`) | is present and equals it (case-insensitive) |
|
|
731
|
+
| `_truthy` / `_falsy` | present, and value is / isn't in `{"", "0", "false", "no", "off", "null", "none"}` |
|
|
732
|
+
| `_present` / `_absent` | exists / does not exist |
|
|
733
|
+
| `_any` | wildcard — present or absent (used for a position in a case list) |
|
|
734
|
+
| `_default` | catch-all for any *present* value (value maps only) |
|
|
735
|
+
|
|
736
|
+
In a single-param `match:` map, precedence is order-independent: exact literal > `_falsy`/`_truthy` > `_present`/`_absent` > `_default`. In a positional case list, cases match strictly top-to-bottom.
|
|
737
|
+
|
|
738
|
+
### No match → URL error
|
|
739
|
+
|
|
740
|
+
If no rule matches and there is no default (`else`, a trailing bare string, or a `_default`/`default` catch-all), the request returns a **400** with `{"error": "No matching query configuration for the given parameters", "http_status": 400}`. Customize it with a terminal `no_match` rule:
|
|
741
|
+
|
|
742
|
+
```yaml
|
|
743
|
+
sql:
|
|
744
|
+
- when: recording
|
|
745
|
+
then: SELECT [[detections]].* FROM [[detections]] WHERE recording_id = {{recording}}
|
|
746
|
+
- no_match:
|
|
747
|
+
error: "recording is required, or pass count=true for a histogram"
|
|
748
|
+
http_status: 400
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
Cookies participate via the `cookies.<name>` key (e.g. `when: cookies.role`, `equals: admin`).
|
|
752
|
+
|
|
753
|
+
---
|
|
754
|
+
|
|
636
755
|
# CLI
|
|
637
756
|
|
|
638
757
|
## Commands
|
|
@@ -295,6 +295,10 @@ def validate_route_config(route_config: Dict[str, Any]) -> bool:
|
|
|
295
295
|
if 'route' not in route_config:
|
|
296
296
|
return False
|
|
297
297
|
|
|
298
|
+
# The sql selector may be a plain string or a list of selector rules.
|
|
299
|
+
if 'sql' in route_config and not isinstance(route_config['sql'], (str, list)):
|
|
300
|
+
return False
|
|
301
|
+
|
|
298
302
|
# Validate query_params structure if present
|
|
299
303
|
if 'query_params' in route_config:
|
|
300
304
|
query_params = route_config['query_params']
|
{api_dock-0.7.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/databases/example_db.yaml
RENAMED
|
@@ -80,3 +80,17 @@ routes:
|
|
|
80
80
|
# - debug:
|
|
81
81
|
# response:
|
|
82
82
|
# message: "Debug mode — no query executed"
|
|
83
|
+
|
|
84
|
+
# Conditional SQL selection — pick a different base query from a param's
|
|
85
|
+
# presence/value. Here ?count=<truthy> returns a histogram; otherwise rows.
|
|
86
|
+
# The selected base still composes with query_params (WHERE + sql_append).
|
|
87
|
+
# - route: detections
|
|
88
|
+
# sql:
|
|
89
|
+
# - when: count
|
|
90
|
+
# then:
|
|
91
|
+
# sql: SELECT [[items]].category, COUNT(*) AS count FROM [[items]]
|
|
92
|
+
# sql_append: GROUP BY [[items]].category
|
|
93
|
+
# - else: SELECT [[items]].* FROM [[items]]
|
|
94
|
+
# query_params:
|
|
95
|
+
# - category:
|
|
96
|
+
# sql: "[[items]].category = '{{category}}'"
|
|
@@ -18,7 +18,7 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
|
|
|
18
18
|
from api_dock.auth import validate_authentication
|
|
19
19
|
from api_dock.config import filter_cookies_by_config, filter_remote_query_params, find_remote_config, find_route_mapping, get_authentication_config, get_database_names, get_remote_names, get_remote_versions, get_settings, is_route_allowed, is_versioned_remote, load_main_config, merge_inherited_config, resolve_latest_version
|
|
20
20
|
from api_dock.database_config import find_database_route, get_database_versions, is_versioned_database, load_database_config, merge_query_params, resolve_latest_database_version
|
|
21
|
-
from api_dock.sql_builder import build_sql_query, extract_path_parameters, process_query_parameters
|
|
21
|
+
from api_dock.sql_builder import build_sql_query, extract_path_parameters, process_query_parameters, SqlSelectionError
|
|
22
22
|
from api_dock.storage_auth import detect_required_backends, extract_table_metadata_by_backend, extract_table_uris, setup_storage_authentication
|
|
23
23
|
from api_dock.types import PreparedRequest, ProxyResponse
|
|
24
24
|
|
|
@@ -435,6 +435,13 @@ class RouteMapper:
|
|
|
435
435
|
route_config, database_config, path_params, query_params,
|
|
436
436
|
filtered_cookies, multi_query_params
|
|
437
437
|
)
|
|
438
|
+
except SqlSelectionError as e:
|
|
439
|
+
return ProxyResponse(
|
|
440
|
+
status_code=e.status_code,
|
|
441
|
+
content=json.dumps(e.response).encode(),
|
|
442
|
+
content_type="application/json",
|
|
443
|
+
error_message=str(e.response.get("error")) if e.response.get("error") else None,
|
|
444
|
+
)
|
|
438
445
|
except ValueError:
|
|
439
446
|
return _error_response(500, "SQL query error")
|
|
440
447
|
|
|
@@ -17,9 +17,52 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
17
17
|
from api_dock.database_config import get_named_query, get_table_definition
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
#
|
|
21
|
+
# CONSTANTS
|
|
22
|
+
#
|
|
23
|
+
# Query-param values treated as "falsy" for _truthy/_falsy matching in the sql
|
|
24
|
+
# selector. Comparison is case-insensitive after stripping surrounding space.
|
|
25
|
+
FALSY_VALUES: frozenset = frozenset({"", "0", "false", "no", "off", "null", "none"})
|
|
26
|
+
|
|
27
|
+
# Response returned when an sql selector matches no branch and defines no
|
|
28
|
+
# default (else / trailing string / _default) or no_match override.
|
|
29
|
+
DEFAULT_NO_MATCH_RESPONSE: Dict[str, Any] = {
|
|
30
|
+
"error": "No matching query configuration for the given parameters",
|
|
31
|
+
"http_status": 400,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Sentinel signalling that a selector rule did not fire (distinct from a rule
|
|
35
|
+
# that fires but resolves to an empty base SQL).
|
|
36
|
+
_NO_MATCH: Any = object()
|
|
37
|
+
|
|
38
|
+
|
|
20
39
|
#
|
|
21
40
|
# PUBLIC
|
|
22
41
|
#
|
|
42
|
+
class SqlSelectionError(Exception):
|
|
43
|
+
"""Raised when an sql selector matches no branch and defines no default.
|
|
44
|
+
|
|
45
|
+
Carries the JSON response body and HTTP status to return to the client, so
|
|
46
|
+
the caller can surface a proper URL error (default 400) rather than a
|
|
47
|
+
generic 500.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
response: JSON-serializable response body to return to the client.
|
|
51
|
+
status_code: HTTP status code to return.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, response: Dict[str, Any]) -> None:
|
|
55
|
+
"""Initialize the error from a response spec.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
response: Response body dict; its ``http_status`` (default 400)
|
|
59
|
+
becomes the HTTP status code.
|
|
60
|
+
"""
|
|
61
|
+
self.response = response
|
|
62
|
+
self.status_code = int(response.get("http_status", 400))
|
|
63
|
+
super().__init__(str(response.get("error", "No matching query configuration")))
|
|
64
|
+
|
|
65
|
+
|
|
23
66
|
def build_sql_query(
|
|
24
67
|
route_config: Dict[str, Any],
|
|
25
68
|
database_config: Dict[str, Any],
|
|
@@ -55,8 +98,13 @@ def build_sql_query(
|
|
|
55
98
|
if multi_query_params is None:
|
|
56
99
|
multi_query_params = {}
|
|
57
100
|
|
|
58
|
-
#
|
|
59
|
-
|
|
101
|
+
# Resolve the base SQL via the selector. ``sql`` may be a plain string or a
|
|
102
|
+
# rule-list decision tree; selection is driven by path, query, and cookie
|
|
103
|
+
# params. branch_appends are post-WHERE clauses (e.g. GROUP BY) contributed
|
|
104
|
+
# by the selected branch, applied before route-level sql_append fragments.
|
|
105
|
+
selection_params = {**path_params, **query_params}
|
|
106
|
+
selection_params.update({f"cookies.{key}": value for key, value in cookies.items()})
|
|
107
|
+
sql_template, branch_appends = resolve_route_sql(route_config, selection_params)
|
|
60
108
|
|
|
61
109
|
# Check if sql_template is a reference to a named query
|
|
62
110
|
if sql_template.startswith("[[") and sql_template.endswith("]]"):
|
|
@@ -98,6 +146,16 @@ def build_sql_query(
|
|
|
98
146
|
where_clause = ' WHERE ' + ' AND '.join(where_fragments)
|
|
99
147
|
sql_with_tables += where_clause
|
|
100
148
|
|
|
149
|
+
# Apply post-WHERE clauses from the selected sql branch (e.g. GROUP BY for a
|
|
150
|
+
# histogram mode). Branch appends come before route-level sql_append so a
|
|
151
|
+
# branch GROUP BY precedes a shared ORDER BY / LIMIT.
|
|
152
|
+
if branch_appends:
|
|
153
|
+
expanded_branch = [
|
|
154
|
+
_substitute_table_references(fragment, database_config)
|
|
155
|
+
for fragment in branch_appends
|
|
156
|
+
]
|
|
157
|
+
sql_with_tables += ' ' + ' '.join(expanded_branch)
|
|
158
|
+
|
|
101
159
|
# Build post-WHERE append fragments (ORDER BY, LIMIT, etc.)
|
|
102
160
|
append_fragments = build_append_clause_from_params(route_config, query_params, path_params)
|
|
103
161
|
# Expand [[table_name]] references in APPEND fragments (same syntax as main sql)
|
|
@@ -112,6 +170,46 @@ def build_sql_query(
|
|
|
112
170
|
return sql_with_params
|
|
113
171
|
|
|
114
172
|
|
|
173
|
+
def resolve_route_sql(
|
|
174
|
+
route_config: Dict[str, Any],
|
|
175
|
+
selection_params: Dict[str, str]) -> Tuple[str, List[str]]:
|
|
176
|
+
"""Resolve a route's ``sql`` selector to a base SQL string and append clauses.
|
|
177
|
+
|
|
178
|
+
The ``sql`` value may be a plain string (used directly) or a list of rules
|
|
179
|
+
forming a first-match-wins decision tree. Each rule selects an sql node based
|
|
180
|
+
on the presence and value of params. An sql node is itself a string, a leaf
|
|
181
|
+
object (``{sql, sql_append}``), or a nested rule list.
|
|
182
|
+
|
|
183
|
+
Rule forms (evaluated top-to-bottom, first match wins):
|
|
184
|
+
- ``{when: <param>, then: <node>}`` — fires when the param is truthy.
|
|
185
|
+
- ``{when: <param>, equals: <valspec>, then: <node>}`` — fires on a value.
|
|
186
|
+
- ``{when: <param>, match: {<valspec>: <node>, ...}}`` — value map.
|
|
187
|
+
- ``{when: [<params>], then: <node>}`` — fires when all params are truthy.
|
|
188
|
+
- ``{when: [<params>], match: [{values: [...], then: <node>}, ...]}``
|
|
189
|
+
- ``{else: <node>}`` or a trailing bare string — the default.
|
|
190
|
+
- ``{no_match: <response>}`` — raise a URL error instead of a default.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
route_config: Route configuration dictionary. ``sql`` is a string or a
|
|
194
|
+
list of selector rules.
|
|
195
|
+
selection_params: Merged params available for matching, keyed by name
|
|
196
|
+
(path and query params, plus cookies as ``cookies.<name>``). A key
|
|
197
|
+
is "present" iff it appears here.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Tuple of ``(base_sql, branch_appends)`` where ``branch_appends`` are
|
|
201
|
+
post-WHERE SQL fragments contributed by the selected branch.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
SqlSelectionError: If no branch matches and no default/no_match is given.
|
|
205
|
+
"""
|
|
206
|
+
sql_spec = route_config.get('sql', '')
|
|
207
|
+
resolved = _resolve_sql_node(sql_spec, selection_params)
|
|
208
|
+
if resolved is None:
|
|
209
|
+
raise SqlSelectionError(dict(DEFAULT_NO_MATCH_RESPONSE))
|
|
210
|
+
return resolved
|
|
211
|
+
|
|
212
|
+
|
|
115
213
|
# Keep backward compatibility with old signature
|
|
116
214
|
def build_sql_query_legacy(
|
|
117
215
|
sql_template: str,
|
|
@@ -705,4 +803,293 @@ def _substitute_variables_in_dict(template_dict: Dict[str, Any], params: Dict[st
|
|
|
705
803
|
result[key] = [_substitute_variables_in_string(str(item), params) if isinstance(item, str) else item for item in value]
|
|
706
804
|
else:
|
|
707
805
|
result[key] = value
|
|
708
|
-
return result
|
|
806
|
+
return result
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _resolve_sql_node(node: Any, params: Dict[str, str]) -> Optional[Tuple[str, List[str]]]:
|
|
810
|
+
"""Resolve an sql node to a base SQL string and post-WHERE append fragments.
|
|
811
|
+
|
|
812
|
+
An sql node is a string (base SQL, no appends), a leaf object
|
|
813
|
+
(``{sql, sql_append}``), or a rule list (nested selection).
|
|
814
|
+
|
|
815
|
+
Args:
|
|
816
|
+
node: The sql node to resolve.
|
|
817
|
+
params: Merged selection params (see resolve_route_sql).
|
|
818
|
+
|
|
819
|
+
Returns:
|
|
820
|
+
Tuple of (base_sql, branch_appends), or None if a rule-list node matched
|
|
821
|
+
nothing and defined no default.
|
|
822
|
+
|
|
823
|
+
Raises:
|
|
824
|
+
SqlSelectionError: If a nested no_match rule is reached.
|
|
825
|
+
"""
|
|
826
|
+
if isinstance(node, str):
|
|
827
|
+
return (node, [])
|
|
828
|
+
if isinstance(node, dict):
|
|
829
|
+
if 'sql' in node:
|
|
830
|
+
return (str(node.get('sql', '')), _as_append_list(node.get('sql_append')))
|
|
831
|
+
return None
|
|
832
|
+
if isinstance(node, list):
|
|
833
|
+
return _resolve_rule_list(node, params)
|
|
834
|
+
return None
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _resolve_rule_list(rules: List[Any], params: Dict[str, str]) -> Optional[Tuple[str, List[str]]]:
|
|
838
|
+
"""Resolve a rule list, returning the first matching branch's node.
|
|
839
|
+
|
|
840
|
+
Args:
|
|
841
|
+
rules: List of selector rules (see resolve_route_sql).
|
|
842
|
+
params: Merged selection params.
|
|
843
|
+
|
|
844
|
+
Returns:
|
|
845
|
+
Tuple of (base_sql, branch_appends), or None if nothing matched.
|
|
846
|
+
|
|
847
|
+
Raises:
|
|
848
|
+
SqlSelectionError: If a no_match rule is reached before any match.
|
|
849
|
+
"""
|
|
850
|
+
for item in rules:
|
|
851
|
+
# Terminal bare-string default.
|
|
852
|
+
if isinstance(item, str):
|
|
853
|
+
return (item, [])
|
|
854
|
+
if not isinstance(item, dict):
|
|
855
|
+
continue
|
|
856
|
+
if 'else' in item:
|
|
857
|
+
return _require_node(item['else'], params)
|
|
858
|
+
if 'no_match' in item:
|
|
859
|
+
raise SqlSelectionError(_no_match_response(item['no_match']))
|
|
860
|
+
if 'when' in item:
|
|
861
|
+
matched = _match_rule(item, params)
|
|
862
|
+
if matched is _NO_MATCH:
|
|
863
|
+
continue
|
|
864
|
+
return _require_node(matched, params)
|
|
865
|
+
return None
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _require_node(node: Any, params: Dict[str, str]) -> Tuple[str, List[str]]:
|
|
869
|
+
"""Resolve an sql node that must yield a result (committed branch).
|
|
870
|
+
|
|
871
|
+
Args:
|
|
872
|
+
node: The sql node to resolve.
|
|
873
|
+
params: Merged selection params.
|
|
874
|
+
|
|
875
|
+
Returns:
|
|
876
|
+
Tuple of (base_sql, branch_appends).
|
|
877
|
+
|
|
878
|
+
Raises:
|
|
879
|
+
SqlSelectionError: If the node resolves to no match (e.g. a nested rule
|
|
880
|
+
list with no default).
|
|
881
|
+
"""
|
|
882
|
+
resolved = _resolve_sql_node(node, params)
|
|
883
|
+
if resolved is None:
|
|
884
|
+
raise SqlSelectionError(dict(DEFAULT_NO_MATCH_RESPONSE))
|
|
885
|
+
return resolved
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _match_rule(rule: Dict[str, Any], params: Dict[str, str]) -> Any:
|
|
889
|
+
"""Evaluate a ``when`` rule, returning its payload node or _NO_MATCH.
|
|
890
|
+
|
|
891
|
+
Args:
|
|
892
|
+
rule: A rule dict containing ``when`` plus optional equals/match/then.
|
|
893
|
+
params: Merged selection params.
|
|
894
|
+
|
|
895
|
+
Returns:
|
|
896
|
+
The selected sql node to resolve, or _NO_MATCH if the rule did not fire.
|
|
897
|
+
"""
|
|
898
|
+
when = rule.get('when')
|
|
899
|
+
if isinstance(when, list):
|
|
900
|
+
return _match_multi(rule, [str(w) for w in when], params)
|
|
901
|
+
return _match_single(rule, str(when), params)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _match_single(rule: Dict[str, Any], param: str, params: Dict[str, str]) -> Any:
|
|
905
|
+
"""Evaluate a single-param ``when`` rule.
|
|
906
|
+
|
|
907
|
+
Args:
|
|
908
|
+
rule: The rule dict.
|
|
909
|
+
param: The single param name from ``when``.
|
|
910
|
+
params: Merged selection params.
|
|
911
|
+
|
|
912
|
+
Returns:
|
|
913
|
+
The selected sql node, or _NO_MATCH.
|
|
914
|
+
"""
|
|
915
|
+
present = param in params
|
|
916
|
+
value = params.get(param)
|
|
917
|
+
if 'match' in rule:
|
|
918
|
+
node = _resolve_value_map(rule['match'], value, present)
|
|
919
|
+
return node if node is not None else _NO_MATCH
|
|
920
|
+
spec = rule.get('equals', '_truthy')
|
|
921
|
+
if _value_matches(spec, value, present):
|
|
922
|
+
return rule.get('then', '')
|
|
923
|
+
return _NO_MATCH
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _match_multi(rule: Dict[str, Any], param_names: List[str], params: Dict[str, str]) -> Any:
|
|
927
|
+
"""Evaluate a multi-param (list ``when``) rule.
|
|
928
|
+
|
|
929
|
+
``match`` is a positional case list; otherwise the rule fires when every
|
|
930
|
+
named param satisfies ``equals`` (positional) or, by default, is truthy.
|
|
931
|
+
|
|
932
|
+
Args:
|
|
933
|
+
rule: The rule dict.
|
|
934
|
+
param_names: The param names from ``when``.
|
|
935
|
+
params: Merged selection params.
|
|
936
|
+
|
|
937
|
+
Returns:
|
|
938
|
+
The selected sql node, or _NO_MATCH.
|
|
939
|
+
"""
|
|
940
|
+
if 'match' in rule:
|
|
941
|
+
cases = rule['match']
|
|
942
|
+
if not isinstance(cases, list):
|
|
943
|
+
return _NO_MATCH
|
|
944
|
+
for case in cases:
|
|
945
|
+
if not isinstance(case, dict):
|
|
946
|
+
continue
|
|
947
|
+
if 'default' in case:
|
|
948
|
+
return case['default']
|
|
949
|
+
values = case.get('values')
|
|
950
|
+
if not isinstance(values, list) or len(values) != len(param_names):
|
|
951
|
+
continue
|
|
952
|
+
if _all_positions_match(param_names, values, params):
|
|
953
|
+
return case.get('then', '')
|
|
954
|
+
return _NO_MATCH
|
|
955
|
+
|
|
956
|
+
specs = rule.get('equals')
|
|
957
|
+
if isinstance(specs, list) and len(specs) == len(param_names):
|
|
958
|
+
if _all_positions_match(param_names, specs, params):
|
|
959
|
+
return rule.get('then', '')
|
|
960
|
+
return _NO_MATCH
|
|
961
|
+
|
|
962
|
+
# Default: fire only when every named param is truthy (AND).
|
|
963
|
+
for name in param_names:
|
|
964
|
+
if not _value_matches('_truthy', params.get(name), name in params):
|
|
965
|
+
return _NO_MATCH
|
|
966
|
+
return rule.get('then', '')
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _all_positions_match(param_names: List[str], specs: List[Any], params: Dict[str, str]) -> bool:
|
|
970
|
+
"""Check that each param satisfies its positional value spec.
|
|
971
|
+
|
|
972
|
+
Args:
|
|
973
|
+
param_names: Param names, aligned with ``specs``.
|
|
974
|
+
specs: Value specs, one per param.
|
|
975
|
+
params: Merged selection params.
|
|
976
|
+
|
|
977
|
+
Returns:
|
|
978
|
+
True if every position matches, False otherwise.
|
|
979
|
+
"""
|
|
980
|
+
for name, spec in zip(param_names, specs):
|
|
981
|
+
if not _value_matches(spec, params.get(name), name in params):
|
|
982
|
+
return False
|
|
983
|
+
return True
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _resolve_value_map(value_map: Any, value: Optional[str], present: bool) -> Any:
|
|
987
|
+
"""Select an sql node from a single-param value map by precedence.
|
|
988
|
+
|
|
989
|
+
Precedence (order-independent): exact literal > _falsy/_truthy >
|
|
990
|
+
_present/_absent > _default. An absent param matches only _absent.
|
|
991
|
+
|
|
992
|
+
Args:
|
|
993
|
+
value_map: Mapping of value spec -> sql node.
|
|
994
|
+
value: The param's value (or None if absent).
|
|
995
|
+
present: Whether the param is present.
|
|
996
|
+
|
|
997
|
+
Returns:
|
|
998
|
+
The matching sql node, or None if nothing matched.
|
|
999
|
+
"""
|
|
1000
|
+
if not isinstance(value_map, dict):
|
|
1001
|
+
return None
|
|
1002
|
+
|
|
1003
|
+
if not present:
|
|
1004
|
+
return value_map.get('_absent')
|
|
1005
|
+
|
|
1006
|
+
value_norm = str(value).strip().lower()
|
|
1007
|
+
# 1. Exact literal (case-insensitive) among non-special keys.
|
|
1008
|
+
for key, node in value_map.items():
|
|
1009
|
+
if str(key).startswith('_'):
|
|
1010
|
+
continue
|
|
1011
|
+
if str(key).strip().lower() == value_norm:
|
|
1012
|
+
return node
|
|
1013
|
+
# 2. Truthiness.
|
|
1014
|
+
if _is_truthy(value):
|
|
1015
|
+
if '_truthy' in value_map:
|
|
1016
|
+
return value_map['_truthy']
|
|
1017
|
+
elif '_falsy' in value_map:
|
|
1018
|
+
return value_map['_falsy']
|
|
1019
|
+
# 3. Presence.
|
|
1020
|
+
if '_present' in value_map:
|
|
1021
|
+
return value_map['_present']
|
|
1022
|
+
# 4. Catch-all.
|
|
1023
|
+
return value_map.get('_default')
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _value_matches(spec: Any, value: Optional[str], present: bool) -> bool:
|
|
1027
|
+
"""Check whether a param value satisfies a single value spec.
|
|
1028
|
+
|
|
1029
|
+
Args:
|
|
1030
|
+
spec: A literal string or special token (_any, _present, _absent,
|
|
1031
|
+
_truthy, _falsy, _default).
|
|
1032
|
+
value: The param's value (or None if absent).
|
|
1033
|
+
present: Whether the param is present.
|
|
1034
|
+
|
|
1035
|
+
Returns:
|
|
1036
|
+
True if the value satisfies the spec, False otherwise.
|
|
1037
|
+
"""
|
|
1038
|
+
spec_str = str(spec)
|
|
1039
|
+
if spec_str in ('_any', '_default'):
|
|
1040
|
+
return True
|
|
1041
|
+
if spec_str == '_present':
|
|
1042
|
+
return present
|
|
1043
|
+
if spec_str == '_absent':
|
|
1044
|
+
return not present
|
|
1045
|
+
if spec_str == '_truthy':
|
|
1046
|
+
return present and _is_truthy(value)
|
|
1047
|
+
if spec_str == '_falsy':
|
|
1048
|
+
return present and not _is_truthy(value)
|
|
1049
|
+
return present and str(value).strip().lower() == spec_str.strip().lower()
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _is_truthy(value: Optional[str]) -> bool:
|
|
1053
|
+
"""Return whether a param value is "truthy" per FALSY_VALUES.
|
|
1054
|
+
|
|
1055
|
+
Args:
|
|
1056
|
+
value: The value to test.
|
|
1057
|
+
|
|
1058
|
+
Returns:
|
|
1059
|
+
True unless the normalized value is in FALSY_VALUES.
|
|
1060
|
+
"""
|
|
1061
|
+
return str(value).strip().lower() not in FALSY_VALUES
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _as_append_list(value: Any) -> List[str]:
|
|
1065
|
+
"""Normalize a leaf node's sql_append into a list of clause strings.
|
|
1066
|
+
|
|
1067
|
+
Args:
|
|
1068
|
+
value: A string, list of strings, or None.
|
|
1069
|
+
|
|
1070
|
+
Returns:
|
|
1071
|
+
List of append clause strings (empty if value is None/unsupported).
|
|
1072
|
+
"""
|
|
1073
|
+
if value is None:
|
|
1074
|
+
return []
|
|
1075
|
+
if isinstance(value, str):
|
|
1076
|
+
return [value]
|
|
1077
|
+
if isinstance(value, list):
|
|
1078
|
+
return [str(item) for item in value]
|
|
1079
|
+
return []
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _no_match_response(spec: Any) -> Dict[str, Any]:
|
|
1083
|
+
"""Build a no_match response body, defaulting http_status to 400.
|
|
1084
|
+
|
|
1085
|
+
Args:
|
|
1086
|
+
spec: A response dict or a plain error string/value.
|
|
1087
|
+
|
|
1088
|
+
Returns:
|
|
1089
|
+
Response body dict with an http_status key.
|
|
1090
|
+
"""
|
|
1091
|
+
if isinstance(spec, dict):
|
|
1092
|
+
response = dict(spec)
|
|
1093
|
+
response.setdefault('http_status', 400)
|
|
1094
|
+
return response
|
|
1095
|
+
return {'error': str(spec), 'http_status': 400}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: api_dock
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: A flexible API gateway that allows you to proxy requests to multiple remote APIs and Databases
|
|
5
5
|
Author-email: Brookie Guzder-Williams <bguzder-williams@berkeley.edu>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -672,6 +672,125 @@ Parameters are processed in this order (first match wins for early returns):
|
|
|
672
672
|
|
|
673
673
|
---
|
|
674
674
|
|
|
675
|
+
## Conditional SQL Selection
|
|
676
|
+
|
|
677
|
+
Some routes need a *different* base query depending on the request — for example, `?count=true` should return a species histogram (`SELECT … COUNT(*) … GROUP BY …`) rather than rows. `sql_append` can't help (it only adds trailing clauses), and a second `route:` can't either (the path is identical). For this, a route's `sql` may be a **rule list** instead of a string: a first-match-wins decision tree that picks the base query from the presence and value of path, query, and cookie params.
|
|
678
|
+
|
|
679
|
+
Everything downstream is unchanged: the selected base composes with `query_params` WHERE-fragments and `sql_append` exactly as a plain `sql:` string does.
|
|
680
|
+
|
|
681
|
+
### The histogram example
|
|
682
|
+
|
|
683
|
+
```yaml
|
|
684
|
+
routes:
|
|
685
|
+
- route: detections
|
|
686
|
+
sql:
|
|
687
|
+
# ?count=<truthy> → species histogram
|
|
688
|
+
- when: count
|
|
689
|
+
then:
|
|
690
|
+
sql: >
|
|
691
|
+
SELECT [[detections]].common_name, [[detections]].scientific_name,
|
|
692
|
+
COUNT(*) AS count
|
|
693
|
+
FROM [[detections]]
|
|
694
|
+
sql_append: GROUP BY [[detections]].common_name, [[detections]].scientific_name
|
|
695
|
+
# otherwise → detection rows
|
|
696
|
+
- else: SELECT [[detections]].* FROM [[detections]]
|
|
697
|
+
query_params:
|
|
698
|
+
- recording:
|
|
699
|
+
sql: "[[detections]].recording_id = {{recording}}"
|
|
700
|
+
multivalue_sql: "[[detections]].recording_id IN {{recording}}"
|
|
701
|
+
- limit:
|
|
702
|
+
sql_append: LIMIT {{limit}} # applies in BOTH modes
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
```bash
|
|
706
|
+
GET /db/detections?recording=1&count=true
|
|
707
|
+
# SELECT detections.common_name, detections.scientific_name, COUNT(*) AS count
|
|
708
|
+
# FROM detections WHERE detections.recording_id = '1'
|
|
709
|
+
# GROUP BY detections.common_name, detections.scientific_name
|
|
710
|
+
|
|
711
|
+
GET /db/detections?recording=1
|
|
712
|
+
# SELECT detections.* FROM detections WHERE detections.recording_id = '1'
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
Note the pipeline order: the selected branch's `sql_append` (the `GROUP BY`) is applied **before** route-level `sql_append` (the shared `LIMIT`), so SQL clause order stays valid.
|
|
716
|
+
|
|
717
|
+
### Rule forms
|
|
718
|
+
|
|
719
|
+
A `sql` list contains rules evaluated top to bottom; the **first match wins**. Each rule's payload (an *sql node*) is a SQL string, a leaf object `{sql, sql_append}`, or a nested rule list.
|
|
720
|
+
|
|
721
|
+
```yaml
|
|
722
|
+
sql:
|
|
723
|
+
- when: count # fires when `count` is truthy (shorthand for equals: _truthy)
|
|
724
|
+
then: <sql node>
|
|
725
|
+
|
|
726
|
+
- when: mode
|
|
727
|
+
equals: 'true' # fires only when mode == "true" (case-insensitive)
|
|
728
|
+
then: <sql node>
|
|
729
|
+
|
|
730
|
+
- when: format # value map: different SQL per value
|
|
731
|
+
match:
|
|
732
|
+
species: <sql node> # ?format=species
|
|
733
|
+
recording: <sql node> # ?format=recording
|
|
734
|
+
_truthy: <sql node> # any other truthy value
|
|
735
|
+
_default: <sql node> # any present value not matched above
|
|
736
|
+
|
|
737
|
+
- when: [count, recording] # list: fires when ALL are truthy (AND)
|
|
738
|
+
then: <sql node>
|
|
739
|
+
|
|
740
|
+
- when: [count, something_else] # list + positional case list
|
|
741
|
+
match:
|
|
742
|
+
- values: [_any, x] # something_else == x, count anything
|
|
743
|
+
then: <sql node>
|
|
744
|
+
- values: [_truthy, _absent] # count truthy AND something_else not passed
|
|
745
|
+
then: <sql node>
|
|
746
|
+
- default: <sql node>
|
|
747
|
+
|
|
748
|
+
- else: <sql node> # default (a trailing bare string works too)
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
Nesting works because a payload can itself be a rule list:
|
|
752
|
+
|
|
753
|
+
```yaml
|
|
754
|
+
sql:
|
|
755
|
+
- when: some_value
|
|
756
|
+
match:
|
|
757
|
+
'4':
|
|
758
|
+
- when: count
|
|
759
|
+
then: <sql for value 4 with count>
|
|
760
|
+
- else: <sql for value 4>
|
|
761
|
+
_default: <sql for other values>
|
|
762
|
+
- else: <base sql>
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
### Value specs
|
|
766
|
+
|
|
767
|
+
| spec | matches when the param… |
|
|
768
|
+
|---|---|
|
|
769
|
+
| `'literal'` (`'4'`, `'true'`) | is present and equals it (case-insensitive) |
|
|
770
|
+
| `_truthy` / `_falsy` | present, and value is / isn't in `{"", "0", "false", "no", "off", "null", "none"}` |
|
|
771
|
+
| `_present` / `_absent` | exists / does not exist |
|
|
772
|
+
| `_any` | wildcard — present or absent (used for a position in a case list) |
|
|
773
|
+
| `_default` | catch-all for any *present* value (value maps only) |
|
|
774
|
+
|
|
775
|
+
In a single-param `match:` map, precedence is order-independent: exact literal > `_falsy`/`_truthy` > `_present`/`_absent` > `_default`. In a positional case list, cases match strictly top-to-bottom.
|
|
776
|
+
|
|
777
|
+
### No match → URL error
|
|
778
|
+
|
|
779
|
+
If no rule matches and there is no default (`else`, a trailing bare string, or a `_default`/`default` catch-all), the request returns a **400** with `{"error": "No matching query configuration for the given parameters", "http_status": 400}`. Customize it with a terminal `no_match` rule:
|
|
780
|
+
|
|
781
|
+
```yaml
|
|
782
|
+
sql:
|
|
783
|
+
- when: recording
|
|
784
|
+
then: SELECT [[detections]].* FROM [[detections]] WHERE recording_id = {{recording}}
|
|
785
|
+
- no_match:
|
|
786
|
+
error: "recording is required, or pass count=true for a histogram"
|
|
787
|
+
http_status: 400
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
Cookies participate via the `cookies.<name>` key (e.g. `when: cookies.role`, `equals: admin`).
|
|
791
|
+
|
|
792
|
+
---
|
|
793
|
+
|
|
675
794
|
# CLI
|
|
676
795
|
|
|
677
796
|
## Commands
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "api_dock"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.1"
|
|
8
8
|
description = "A flexible API gateway that allows you to proxy requests to multiple remote APIs and Databases"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "BSD-3-Clause"
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""
|
|
2
|
+
|
|
3
|
+
Tests for the conditional SQL selector in the SQL builder.
|
|
4
|
+
|
|
5
|
+
Covers ``resolve_route_sql`` (the decision-tree resolver) and its end-to-end
|
|
6
|
+
composition with the existing query_params WHERE/append pipeline in
|
|
7
|
+
``build_sql_query``, plus the SqlSelectionError path through
|
|
8
|
+
``RouteMapper.map_database_route``.
|
|
9
|
+
|
|
10
|
+
The selector lets a route's ``sql`` be a plain string (today's behavior) or a
|
|
11
|
+
first-match-wins list of rules that pick a base SQL node from the presence and
|
|
12
|
+
value of path/query/cookie params.
|
|
13
|
+
|
|
14
|
+
License: BSD 3-Clause
|
|
15
|
+
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
#
|
|
19
|
+
# IMPORTS
|
|
20
|
+
#
|
|
21
|
+
import json
|
|
22
|
+
from unittest.mock import patch
|
|
23
|
+
|
|
24
|
+
import pytest
|
|
25
|
+
|
|
26
|
+
from api_dock.route_mapper import RouteMapper
|
|
27
|
+
from api_dock.sql_builder import build_sql_query, resolve_route_sql, SqlSelectionError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
#
|
|
31
|
+
# CONSTANTS
|
|
32
|
+
#
|
|
33
|
+
DATABASE_CONFIG = {"tables": {"detections": "data/detections.parquet"}}
|
|
34
|
+
|
|
35
|
+
COUNT_ROUTE = {
|
|
36
|
+
"route": "detections",
|
|
37
|
+
"sql": [
|
|
38
|
+
{
|
|
39
|
+
"when": "count",
|
|
40
|
+
"then": {
|
|
41
|
+
"sql": (
|
|
42
|
+
"SELECT [[detections]].common_name, [[detections]].scientific_name, "
|
|
43
|
+
"COUNT(*) AS count FROM [[detections]]"
|
|
44
|
+
),
|
|
45
|
+
"sql_append": (
|
|
46
|
+
"GROUP BY [[detections]].common_name, [[detections]].scientific_name"
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{"else": "SELECT [[detections]].* FROM [[detections]]"},
|
|
51
|
+
],
|
|
52
|
+
"query_params": [
|
|
53
|
+
{
|
|
54
|
+
"recording": {
|
|
55
|
+
"sql": "[[detections]].recording_id = {{recording}}",
|
|
56
|
+
"multivalue_sql": "[[detections]].recording_id IN {{recording}}",
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{"limit": {"sql_append": "LIMIT {{limit}}"}},
|
|
60
|
+
],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
#
|
|
65
|
+
# PUBLIC
|
|
66
|
+
#
|
|
67
|
+
class TestResolveRouteSql:
|
|
68
|
+
"""Unit tests for the decision-tree resolver."""
|
|
69
|
+
|
|
70
|
+
def test_plain_string_passthrough(self) -> None:
|
|
71
|
+
"""A plain string sql resolves to itself with no branch appends."""
|
|
72
|
+
assert resolve_route_sql({"sql": "SELECT 1"}, {}) == ("SELECT 1", [])
|
|
73
|
+
|
|
74
|
+
def test_missing_sql_returns_empty(self) -> None:
|
|
75
|
+
"""A route with no sql resolves to an empty base (legacy behavior)."""
|
|
76
|
+
assert resolve_route_sql({}, {}) == ("", [])
|
|
77
|
+
|
|
78
|
+
def test_when_truthy_fires(self) -> None:
|
|
79
|
+
"""A bare when fires on a truthy value."""
|
|
80
|
+
rc = {"sql": [{"when": "count", "then": "HIST"}, {"else": "BASE"}]}
|
|
81
|
+
assert resolve_route_sql(rc, {"count": "True"}) == ("HIST", [])
|
|
82
|
+
|
|
83
|
+
def test_when_absent_falls_to_else(self) -> None:
|
|
84
|
+
"""An absent query param skips the rule and falls to else."""
|
|
85
|
+
rc = {"sql": [{"when": "count", "then": "HIST"}, {"else": "BASE"}]}
|
|
86
|
+
assert resolve_route_sql(rc, {}) == ("BASE", [])
|
|
87
|
+
|
|
88
|
+
def test_when_falsy_falls_to_else(self) -> None:
|
|
89
|
+
"""A falsy value does not fire a bare when."""
|
|
90
|
+
rc = {"sql": [{"when": "count", "then": "HIST"}, {"else": "BASE"}]}
|
|
91
|
+
assert resolve_route_sql(rc, {"count": "false"}) == ("BASE", [])
|
|
92
|
+
|
|
93
|
+
def test_leaf_object_with_append(self) -> None:
|
|
94
|
+
"""A leaf object contributes base sql plus post-WHERE append clauses."""
|
|
95
|
+
rc = {
|
|
96
|
+
"sql": [
|
|
97
|
+
{"when": "count", "then": {"sql": "HIST", "sql_append": "GROUP BY x"}},
|
|
98
|
+
"BASE",
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
assert resolve_route_sql(rc, {"count": "1"}) == ("HIST", ["GROUP BY x"])
|
|
102
|
+
|
|
103
|
+
def test_trailing_string_default(self) -> None:
|
|
104
|
+
"""A trailing bare string acts as the default (implicit else)."""
|
|
105
|
+
rc = {"sql": [{"when": "count", "then": "HIST"}, "BASE"]}
|
|
106
|
+
assert resolve_route_sql(rc, {}) == ("BASE", [])
|
|
107
|
+
|
|
108
|
+
def test_equals_literal_case_insensitive(self) -> None:
|
|
109
|
+
"""equals matches a specific value, case-insensitively, and only that value."""
|
|
110
|
+
rc = {"sql": [{"when": "mode", "equals": "true", "then": "T"}, {"else": "E"}]}
|
|
111
|
+
assert resolve_route_sql(rc, {"mode": "True"}) == ("T", [])
|
|
112
|
+
assert resolve_route_sql(rc, {"mode": "1"}) == ("E", [])
|
|
113
|
+
|
|
114
|
+
def test_value_map_precedence_literal_over_truthy(self) -> None:
|
|
115
|
+
"""A value map prefers an exact literal over _truthy regardless of order."""
|
|
116
|
+
rc = {"sql": [{"when": "count", "match": {"_truthy": "TRU", "true": "LIT"}}, {"else": "E"}]}
|
|
117
|
+
assert resolve_route_sql(rc, {"count": "true"}) == ("LIT", [])
|
|
118
|
+
assert resolve_route_sql(rc, {"count": "1"}) == ("TRU", [])
|
|
119
|
+
|
|
120
|
+
def test_value_map_default_requires_presence(self) -> None:
|
|
121
|
+
"""_default fires for any present value but not when the param is absent."""
|
|
122
|
+
rc = {"sql": [{"when": "count", "match": {"_default": "D"}}, {"else": "E"}]}
|
|
123
|
+
assert resolve_route_sql(rc, {"count": "anything"}) == ("D", [])
|
|
124
|
+
assert resolve_route_sql(rc, {}) == ("E", [])
|
|
125
|
+
|
|
126
|
+
def test_value_map_absent(self) -> None:
|
|
127
|
+
"""_absent fires only when the param is absent."""
|
|
128
|
+
rc = {"sql": [{"when": "count", "match": {"_absent": "A", "_truthy": "T"}}, {"else": "E"}]}
|
|
129
|
+
assert resolve_route_sql(rc, {}) == ("A", [])
|
|
130
|
+
assert resolve_route_sql(rc, {"count": "1"}) == ("T", [])
|
|
131
|
+
|
|
132
|
+
def test_value_map_falsy(self) -> None:
|
|
133
|
+
"""_falsy fires when a present value is falsy."""
|
|
134
|
+
rc = {"sql": [{"when": "flag", "match": {"_falsy": "F", "_truthy": "T"}}, {"else": "E"}]}
|
|
135
|
+
assert resolve_route_sql(rc, {"flag": "0"}) == ("F", [])
|
|
136
|
+
assert resolve_route_sql(rc, {"flag": "yes"}) == ("T", [])
|
|
137
|
+
|
|
138
|
+
def test_multi_when_all_truthy(self) -> None:
|
|
139
|
+
"""A list when with then fires only when every param is truthy (AND)."""
|
|
140
|
+
rc = {"sql": [{"when": ["a", "b"], "then": "BOTH"}, {"else": "E"}]}
|
|
141
|
+
assert resolve_route_sql(rc, {"a": "1", "b": "1"}) == ("BOTH", [])
|
|
142
|
+
assert resolve_route_sql(rc, {"a": "1"}) == ("E", [])
|
|
143
|
+
|
|
144
|
+
def test_multi_match_case_list_with_any(self) -> None:
|
|
145
|
+
"""A positional case list matches top-to-bottom, honoring _any wildcards."""
|
|
146
|
+
rc = {
|
|
147
|
+
"sql": [
|
|
148
|
+
{
|
|
149
|
+
"when": ["count", "something"],
|
|
150
|
+
"match": [
|
|
151
|
+
{"values": ["_any", "x"], "then": "SX"},
|
|
152
|
+
{"values": ["_truthy", "_absent"], "then": "CT"},
|
|
153
|
+
{"default": "DEF"},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
{"else": "E"},
|
|
157
|
+
]
|
|
158
|
+
}
|
|
159
|
+
assert resolve_route_sql(rc, {"something": "x"}) == ("SX", [])
|
|
160
|
+
assert resolve_route_sql(rc, {"count": "1"}) == ("CT", [])
|
|
161
|
+
assert resolve_route_sql(rc, {"count": "1", "something": "y"}) == ("DEF", [])
|
|
162
|
+
|
|
163
|
+
def test_nested_rule_list(self) -> None:
|
|
164
|
+
"""A branch payload may itself be a nested rule list."""
|
|
165
|
+
rc = {
|
|
166
|
+
"sql": [
|
|
167
|
+
{
|
|
168
|
+
"when": "some_value",
|
|
169
|
+
"match": {
|
|
170
|
+
"4": [{"when": "count", "then": "FOUR_COUNT"}, {"else": "FOUR"}],
|
|
171
|
+
"_default": "OTHER",
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{"else": "E"},
|
|
175
|
+
]
|
|
176
|
+
}
|
|
177
|
+
assert resolve_route_sql(rc, {"some_value": "4", "count": "1"}) == ("FOUR_COUNT", [])
|
|
178
|
+
assert resolve_route_sql(rc, {"some_value": "4"}) == ("FOUR", [])
|
|
179
|
+
assert resolve_route_sql(rc, {"some_value": "9"}) == ("OTHER", [])
|
|
180
|
+
|
|
181
|
+
def test_cookie_param_selection(self) -> None:
|
|
182
|
+
"""Cookies participate in selection via the cookies.<name> key."""
|
|
183
|
+
rc = {"sql": [{"when": "cookies.role", "equals": "admin", "then": "A"}, {"else": "E"}]}
|
|
184
|
+
assert resolve_route_sql(rc, {"cookies.role": "admin"}) == ("A", [])
|
|
185
|
+
assert resolve_route_sql(rc, {"cookies.role": "user"}) == ("E", [])
|
|
186
|
+
|
|
187
|
+
def test_no_match_raises_default_400(self) -> None:
|
|
188
|
+
"""No branch and no default raises a 400 SqlSelectionError."""
|
|
189
|
+
rc = {"sql": [{"when": "count", "then": "HIST"}]}
|
|
190
|
+
with pytest.raises(SqlSelectionError) as exc:
|
|
191
|
+
resolve_route_sql(rc, {})
|
|
192
|
+
assert exc.value.status_code == 400
|
|
193
|
+
assert "error" in exc.value.response
|
|
194
|
+
|
|
195
|
+
def test_no_match_custom_response(self) -> None:
|
|
196
|
+
"""A no_match rule provides a custom error body and status."""
|
|
197
|
+
rc = {
|
|
198
|
+
"sql": [
|
|
199
|
+
{"when": "recording", "then": "R"},
|
|
200
|
+
{"no_match": {"error": "recording required", "http_status": 422}},
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
with pytest.raises(SqlSelectionError) as exc:
|
|
204
|
+
resolve_route_sql(rc, {})
|
|
205
|
+
assert exc.value.status_code == 422
|
|
206
|
+
assert exc.value.response["error"] == "recording required"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class TestSelectorComposition:
|
|
210
|
+
"""End-to-end SQL assembly combining the selector with query_params."""
|
|
211
|
+
|
|
212
|
+
def test_count_mode_composes_where_and_group_by(self) -> None:
|
|
213
|
+
"""count=True selects the histogram base; recording filter + GROUP BY compose."""
|
|
214
|
+
sql = build_sql_query(
|
|
215
|
+
COUNT_ROUTE, DATABASE_CONFIG, {}, {"recording": "1", "count": "True"}, {}, {}
|
|
216
|
+
)
|
|
217
|
+
expected = (
|
|
218
|
+
"SELECT detections.common_name, detections.scientific_name, COUNT(*) AS count "
|
|
219
|
+
"FROM 'data/detections.parquet' AS detections "
|
|
220
|
+
"WHERE detections.recording_id = '1' "
|
|
221
|
+
"GROUP BY detections.common_name, detections.scientific_name"
|
|
222
|
+
)
|
|
223
|
+
assert sql == expected
|
|
224
|
+
|
|
225
|
+
def test_count_mode_with_shared_limit(self) -> None:
|
|
226
|
+
"""A shared LIMIT append lands after the branch GROUP BY."""
|
|
227
|
+
sql = build_sql_query(
|
|
228
|
+
COUNT_ROUTE, DATABASE_CONFIG, {},
|
|
229
|
+
{"recording": "1", "count": "1", "limit": "20"}, {}, {}
|
|
230
|
+
)
|
|
231
|
+
assert sql.endswith("GROUP BY detections.common_name, detections.scientific_name LIMIT 20")
|
|
232
|
+
|
|
233
|
+
def test_default_mode_returns_rows(self) -> None:
|
|
234
|
+
"""Without count, the else branch returns rows with the recording filter."""
|
|
235
|
+
sql = build_sql_query(COUNT_ROUTE, DATABASE_CONFIG, {}, {"recording": "1"}, {}, {})
|
|
236
|
+
expected = (
|
|
237
|
+
"SELECT detections.* FROM 'data/detections.parquet' AS detections "
|
|
238
|
+
"WHERE detections.recording_id = '1'"
|
|
239
|
+
)
|
|
240
|
+
assert sql == expected
|
|
241
|
+
|
|
242
|
+
def test_no_match_raises_through_build(self) -> None:
|
|
243
|
+
"""A selector with no default raises SqlSelectionError from build_sql_query."""
|
|
244
|
+
route = {"route": "detections", "sql": [{"when": "count", "then": "SELECT 1"}]}
|
|
245
|
+
with pytest.raises(SqlSelectionError):
|
|
246
|
+
build_sql_query(route, DATABASE_CONFIG, {}, {}, {}, {})
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class TestMapDatabaseRouteSelection:
|
|
250
|
+
"""The SqlSelectionError path surfaces as a proper URL error response."""
|
|
251
|
+
|
|
252
|
+
def _make_rm(self):
|
|
253
|
+
rm = RouteMapper.__new__(RouteMapper)
|
|
254
|
+
rm.remote_names = []
|
|
255
|
+
rm.database_names = ["mydb"]
|
|
256
|
+
rm.config = {}
|
|
257
|
+
rm.settings = {}
|
|
258
|
+
return rm
|
|
259
|
+
|
|
260
|
+
@pytest.mark.anyio
|
|
261
|
+
async def test_selection_error_returns_400(self) -> None:
|
|
262
|
+
"""No matching branch (and no default) yields a 400 with an error body."""
|
|
263
|
+
rm = self._make_rm()
|
|
264
|
+
route = {"route": "detections", "sql": [{"when": "count", "then": "SELECT 1"}]}
|
|
265
|
+
db_cfg = {"tables": {}, "routes": [route]}
|
|
266
|
+
with patch("api_dock.route_mapper.is_versioned_database", return_value=False), \
|
|
267
|
+
patch("api_dock.route_mapper.load_database_config", return_value=db_cfg), \
|
|
268
|
+
patch("api_dock.route_mapper.merge_inherited_config", side_effect=lambda c, p: c), \
|
|
269
|
+
patch("api_dock.route_mapper.filter_cookies_by_config", return_value={}), \
|
|
270
|
+
patch("api_dock.route_mapper.get_authentication_config", return_value=None), \
|
|
271
|
+
patch("api_dock.route_mapper.find_database_route", return_value=route), \
|
|
272
|
+
patch("api_dock.route_mapper.merge_query_params", side_effect=lambda r, d: r):
|
|
273
|
+
result = await rm.map_database_route("mydb", "detections", {}, {})
|
|
274
|
+
|
|
275
|
+
assert result.status_code == 400
|
|
276
|
+
parsed = json.loads(result.content)
|
|
277
|
+
assert "error" in parsed
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{api_dock-0.7.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/remotes/example_remote.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|