api-dock 0.6.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.6.0 → api_dock-0.7.1}/PKG-INFO +164 -18
- {api_dock-0.6.0 → api_dock-0.7.1}/README.md +163 -17
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/config.py +8 -4
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/database_config.py +4 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/config.yaml +1 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/databases/example_db.yaml +21 -0
- api_dock-0.7.1/api_dock/fast_api.py +265 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/flask_api.py +7 -1
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/route_mapper.py +171 -31
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/sql_builder.py +457 -8
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/types.py +33 -1
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock.egg-info/PKG-INFO +164 -18
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock.egg-info/SOURCES.txt +2 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/pyproject.toml +1 -1
- {api_dock-0.6.0 → api_dock-0.7.1}/tests/test_proxy_pipeline.py +333 -2
- api_dock-0.7.1/tests/test_sql_builder.py +137 -0
- api_dock-0.7.1/tests/test_sql_selector.py +277 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/tests/test_types.py +51 -2
- api_dock-0.6.0/api_dock/fast_api.py +0 -155
- {api_dock-0.6.0 → api_dock-0.7.1}/LICENSE.md +0 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/__init__.py +0 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/auth.py +0 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/cli.py +0 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/config_discovery.py +0 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/encryption.py +0 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/remotes/example_remote.yaml +0 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/storage_auth.py +0 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock.egg-info/dependency_links.txt +0 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock.egg-info/entry_points.txt +0 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock.egg-info/requires.txt +0 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/api_dock.egg-info/top_level.txt +0 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/setup.cfg +0 -0
- {api_dock-0.6.0 → api_dock-0.7.1}/tests/test_inject_cookies.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: api_dock
|
|
3
|
-
Version: 0.
|
|
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
|
|
@@ -218,6 +218,7 @@ remotes:
|
|
|
218
218
|
settings:
|
|
219
219
|
add_trailing_slash: true # Auto-add trailing slash to paths (default: true)
|
|
220
220
|
follow_protocol_downgrades: false # Allow HTTPS->HTTP redirects (default: false)
|
|
221
|
+
timeout: 10 # Upstream request timeout in seconds (default: 10)
|
|
221
222
|
```
|
|
222
223
|
|
|
223
224
|
### HTTP behavior Settings
|
|
@@ -228,6 +229,8 @@ The optional `settings` section controls HTTP behavior:
|
|
|
228
229
|
|
|
229
230
|
- **`follow_protocol_downgrades`** (default: `false`): Control how HTTP redirects are handled. When `false` (recommended), HTTPS→HTTP redirects are blocked for security. When `true`, allows following redirects that downgrade from HTTPS to HTTP (not recommended for production).
|
|
230
231
|
|
|
232
|
+
- **`timeout`** (default: `10`): Upstream request timeout in seconds, applied to both the streaming and buffered proxy paths. Raise it for slow upstreams (e.g. large aggregation queries) that would otherwise return a 502 on timeout. Set to `null` or `false` to disable the timeout entirely (not recommended — a stalled upstream can hold the connection open indefinitely).
|
|
233
|
+
|
|
231
234
|
---
|
|
232
235
|
|
|
233
236
|
## Remote Configurations
|
|
@@ -463,6 +466,34 @@ GET /db/users
|
|
|
463
466
|
# SQL: SELECT * FROM users WHERE height < 200
|
|
464
467
|
```
|
|
465
468
|
|
|
469
|
+
### Repeated Parameters with `multivalue_sql`
|
|
470
|
+
|
|
471
|
+
A query parameter key can appear more than once in the URL (e.g. `?recording_id=1&recording_id=4`). Add a `multivalue_sql` template alongside `sql` to handle this: when **more than one** value is passed for the key, `multivalue_sql` is used instead of `sql`, and `{{param}}` expands to a parenthesized, quote-escaped SQL value list suitable for an `IN` clause.
|
|
472
|
+
|
|
473
|
+
Behavior is unchanged when `multivalue_sql` is absent, and when only a single value is passed the normal `sql` template is used.
|
|
474
|
+
|
|
475
|
+
```yaml
|
|
476
|
+
routes:
|
|
477
|
+
- route: detections
|
|
478
|
+
sql: SELECT [[detections]].* FROM [[detections]]
|
|
479
|
+
query_params:
|
|
480
|
+
- recording_id:
|
|
481
|
+
sql: "[[detections]].recording_id = {{recording_id}}" # single value
|
|
482
|
+
multivalue_sql: "[[detections]].recording_id IN {{recording_id}}" # 2+ values
|
|
483
|
+
- scientific_name:
|
|
484
|
+
sql: "[[detections]].scientific_name = '{{scientific_name}}'"
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
```bash
|
|
488
|
+
GET /db/detections?recording_id=4&scientific_name=Gryllus%20fultoni
|
|
489
|
+
# SQL: SELECT detections.* FROM detections
|
|
490
|
+
# WHERE detections.recording_id = '4' AND detections.scientific_name = 'Gryllus fultoni'
|
|
491
|
+
|
|
492
|
+
GET /db/detections?recording_id=4&recording_id=1&scientific_name=Gryllus%20fultoni
|
|
493
|
+
# SQL: SELECT detections.* FROM detections
|
|
494
|
+
# WHERE detections.recording_id IN ('4', '1') AND detections.scientific_name = 'Gryllus fultoni'
|
|
495
|
+
```
|
|
496
|
+
|
|
466
497
|
### Sorting and Pagination with `sql_append`
|
|
467
498
|
|
|
468
499
|
Use `sql_append` to append clauses *after* the WHERE clause — for `ORDER BY`, `LIMIT`, `OFFSET`, etc. Fragments are appended in the order they appear in the YAML config, so **the YAML order must match valid SQL order** (ORDER BY before LIMIT before OFFSET).
|
|
@@ -641,6 +672,125 @@ Parameters are processed in this order (first match wins for early returns):
|
|
|
641
672
|
|
|
642
673
|
---
|
|
643
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
|
+
|
|
644
794
|
# CLI
|
|
645
795
|
|
|
646
796
|
## Commands
|
|
@@ -1170,8 +1320,6 @@ pixi run python scripts/hello_world.py
|
|
|
1170
1320
|
|
|
1171
1321
|
---
|
|
1172
1322
|
|
|
1173
|
-
---
|
|
1174
|
-
|
|
1175
1323
|
# Development
|
|
1176
1324
|
|
|
1177
1325
|
## Publishing a Release
|
|
@@ -1183,11 +1331,11 @@ pixi run python scripts/hello_world.py
|
|
|
1183
1331
|
|
|
1184
1332
|
# 2. Commit everything
|
|
1185
1333
|
git add -A
|
|
1186
|
-
git commit -m "v0.6.
|
|
1334
|
+
git commit -m "v0.6.1: stream proxy responses (fix large-response 502 + content-encoding)"
|
|
1187
1335
|
|
|
1188
1336
|
# 3. Tag and push
|
|
1189
|
-
git tag v0.6.
|
|
1190
|
-
git push origin main v0.6.
|
|
1337
|
+
git tag v0.6.1
|
|
1338
|
+
git push origin main v0.6.1
|
|
1191
1339
|
|
|
1192
1340
|
# 4. Build the wheel (requires the `dev` pixi environment)
|
|
1193
1341
|
rm -rf dist/
|
|
@@ -1197,21 +1345,19 @@ pixi run -e dev python -m build --wheel
|
|
|
1197
1345
|
ls dist/*.whl
|
|
1198
1346
|
|
|
1199
1347
|
# 5. Create GitHub release with the wheel attached
|
|
1200
|
-
gh release create v0.6.
|
|
1201
|
-
--title "v0.6.
|
|
1348
|
+
gh release create v0.6.1 dist/api_dock-0.6.1-py3-none-any.whl \
|
|
1349
|
+
--title "v0.6.1" --notes "$(cat <<'EOF'
|
|
1202
1350
|
* new features
|
|
1203
|
-
-
|
|
1351
|
+
- Remote proxy responses are now streamed (FastAPI) — upstream bytes are piped to the client as they arrive instead of being buffered fully in memory
|
|
1352
|
+
- New `timeout` setting (default 10s) for the upstream request; set to `null`/`false` to disable
|
|
1204
1353
|
* bug fixes
|
|
1205
|
-
-
|
|
1206
|
-
-
|
|
1207
|
-
-
|
|
1208
|
-
- Upstream 4xx/5xx error bodies passed through verbatim (previously wrapped/swallowed)
|
|
1209
|
-
- JSON responses no longer re-serialized — raw bytes returned as-is, preserving exact upstream payload
|
|
1354
|
+
- Large upstream responses no longer return 502 — streamed via `StreamingResponse` instead of reading the whole body into memory
|
|
1355
|
+
- `Content-Encoding` (gzip/br/deflate) is now preserved on compressed responses — raw bytes are streamed via `aiter_raw()` so the header stays valid and the client can decompress
|
|
1356
|
+
- Slow upstreams (e.g. large aggregation queries) no longer 502 at httpx's hardcoded 5s default — the timeout is now configurable via the `timeout` setting
|
|
1210
1357
|
* cleanup / other improvements
|
|
1211
|
-
- Added `
|
|
1212
|
-
-
|
|
1213
|
-
-
|
|
1214
|
-
- Bumped cryptography dependency to >=48.0.0,<49.0.0
|
|
1358
|
+
- Added `PreparedRequest` dataclass and split route validation/resolution into `RouteMapper.prepare_remote_request()`; the FastAPI adapter issues the streaming HTTP call
|
|
1359
|
+
- `map_route()` (buffered) retained for the Flask/sync path
|
|
1360
|
+
- Added streaming test coverage (`TestStreamUpstream`, plus `prepare_remote_request` and streaming-header tests) — 53 tests total
|
|
1215
1361
|
EOF
|
|
1216
1362
|
)"
|
|
1217
1363
|
|
|
@@ -179,6 +179,7 @@ remotes:
|
|
|
179
179
|
settings:
|
|
180
180
|
add_trailing_slash: true # Auto-add trailing slash to paths (default: true)
|
|
181
181
|
follow_protocol_downgrades: false # Allow HTTPS->HTTP redirects (default: false)
|
|
182
|
+
timeout: 10 # Upstream request timeout in seconds (default: 10)
|
|
182
183
|
```
|
|
183
184
|
|
|
184
185
|
### HTTP behavior Settings
|
|
@@ -189,6 +190,8 @@ The optional `settings` section controls HTTP behavior:
|
|
|
189
190
|
|
|
190
191
|
- **`follow_protocol_downgrades`** (default: `false`): Control how HTTP redirects are handled. When `false` (recommended), HTTPS→HTTP redirects are blocked for security. When `true`, allows following redirects that downgrade from HTTPS to HTTP (not recommended for production).
|
|
191
192
|
|
|
193
|
+
- **`timeout`** (default: `10`): Upstream request timeout in seconds, applied to both the streaming and buffered proxy paths. Raise it for slow upstreams (e.g. large aggregation queries) that would otherwise return a 502 on timeout. Set to `null` or `false` to disable the timeout entirely (not recommended — a stalled upstream can hold the connection open indefinitely).
|
|
194
|
+
|
|
192
195
|
---
|
|
193
196
|
|
|
194
197
|
## Remote Configurations
|
|
@@ -424,6 +427,34 @@ GET /db/users
|
|
|
424
427
|
# SQL: SELECT * FROM users WHERE height < 200
|
|
425
428
|
```
|
|
426
429
|
|
|
430
|
+
### Repeated Parameters with `multivalue_sql`
|
|
431
|
+
|
|
432
|
+
A query parameter key can appear more than once in the URL (e.g. `?recording_id=1&recording_id=4`). Add a `multivalue_sql` template alongside `sql` to handle this: when **more than one** value is passed for the key, `multivalue_sql` is used instead of `sql`, and `{{param}}` expands to a parenthesized, quote-escaped SQL value list suitable for an `IN` clause.
|
|
433
|
+
|
|
434
|
+
Behavior is unchanged when `multivalue_sql` is absent, and when only a single value is passed the normal `sql` template is used.
|
|
435
|
+
|
|
436
|
+
```yaml
|
|
437
|
+
routes:
|
|
438
|
+
- route: detections
|
|
439
|
+
sql: SELECT [[detections]].* FROM [[detections]]
|
|
440
|
+
query_params:
|
|
441
|
+
- recording_id:
|
|
442
|
+
sql: "[[detections]].recording_id = {{recording_id}}" # single value
|
|
443
|
+
multivalue_sql: "[[detections]].recording_id IN {{recording_id}}" # 2+ values
|
|
444
|
+
- scientific_name:
|
|
445
|
+
sql: "[[detections]].scientific_name = '{{scientific_name}}'"
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
```bash
|
|
449
|
+
GET /db/detections?recording_id=4&scientific_name=Gryllus%20fultoni
|
|
450
|
+
# SQL: SELECT detections.* FROM detections
|
|
451
|
+
# WHERE detections.recording_id = '4' AND detections.scientific_name = 'Gryllus fultoni'
|
|
452
|
+
|
|
453
|
+
GET /db/detections?recording_id=4&recording_id=1&scientific_name=Gryllus%20fultoni
|
|
454
|
+
# SQL: SELECT detections.* FROM detections
|
|
455
|
+
# WHERE detections.recording_id IN ('4', '1') AND detections.scientific_name = 'Gryllus fultoni'
|
|
456
|
+
```
|
|
457
|
+
|
|
427
458
|
### Sorting and Pagination with `sql_append`
|
|
428
459
|
|
|
429
460
|
Use `sql_append` to append clauses *after* the WHERE clause — for `ORDER BY`, `LIMIT`, `OFFSET`, etc. Fragments are appended in the order they appear in the YAML config, so **the YAML order must match valid SQL order** (ORDER BY before LIMIT before OFFSET).
|
|
@@ -602,6 +633,125 @@ Parameters are processed in this order (first match wins for early returns):
|
|
|
602
633
|
|
|
603
634
|
---
|
|
604
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
|
+
|
|
605
755
|
# CLI
|
|
606
756
|
|
|
607
757
|
## Commands
|
|
@@ -1131,8 +1281,6 @@ pixi run python scripts/hello_world.py
|
|
|
1131
1281
|
|
|
1132
1282
|
---
|
|
1133
1283
|
|
|
1134
|
-
---
|
|
1135
|
-
|
|
1136
1284
|
# Development
|
|
1137
1285
|
|
|
1138
1286
|
## Publishing a Release
|
|
@@ -1144,11 +1292,11 @@ pixi run python scripts/hello_world.py
|
|
|
1144
1292
|
|
|
1145
1293
|
# 2. Commit everything
|
|
1146
1294
|
git add -A
|
|
1147
|
-
git commit -m "v0.6.
|
|
1295
|
+
git commit -m "v0.6.1: stream proxy responses (fix large-response 502 + content-encoding)"
|
|
1148
1296
|
|
|
1149
1297
|
# 3. Tag and push
|
|
1150
|
-
git tag v0.6.
|
|
1151
|
-
git push origin main v0.6.
|
|
1298
|
+
git tag v0.6.1
|
|
1299
|
+
git push origin main v0.6.1
|
|
1152
1300
|
|
|
1153
1301
|
# 4. Build the wheel (requires the `dev` pixi environment)
|
|
1154
1302
|
rm -rf dist/
|
|
@@ -1158,21 +1306,19 @@ pixi run -e dev python -m build --wheel
|
|
|
1158
1306
|
ls dist/*.whl
|
|
1159
1307
|
|
|
1160
1308
|
# 5. Create GitHub release with the wheel attached
|
|
1161
|
-
gh release create v0.6.
|
|
1162
|
-
--title "v0.6.
|
|
1309
|
+
gh release create v0.6.1 dist/api_dock-0.6.1-py3-none-any.whl \
|
|
1310
|
+
--title "v0.6.1" --notes "$(cat <<'EOF'
|
|
1163
1311
|
* new features
|
|
1164
|
-
-
|
|
1312
|
+
- Remote proxy responses are now streamed (FastAPI) — upstream bytes are piped to the client as they arrive instead of being buffered fully in memory
|
|
1313
|
+
- New `timeout` setting (default 10s) for the upstream request; set to `null`/`false` to disable
|
|
1165
1314
|
* bug fixes
|
|
1166
|
-
-
|
|
1167
|
-
-
|
|
1168
|
-
-
|
|
1169
|
-
- Upstream 4xx/5xx error bodies passed through verbatim (previously wrapped/swallowed)
|
|
1170
|
-
- JSON responses no longer re-serialized — raw bytes returned as-is, preserving exact upstream payload
|
|
1315
|
+
- Large upstream responses no longer return 502 — streamed via `StreamingResponse` instead of reading the whole body into memory
|
|
1316
|
+
- `Content-Encoding` (gzip/br/deflate) is now preserved on compressed responses — raw bytes are streamed via `aiter_raw()` so the header stays valid and the client can decompress
|
|
1317
|
+
- Slow upstreams (e.g. large aggregation queries) no longer 502 at httpx's hardcoded 5s default — the timeout is now configurable via the `timeout` setting
|
|
1171
1318
|
* cleanup / other improvements
|
|
1172
|
-
- Added `
|
|
1173
|
-
-
|
|
1174
|
-
-
|
|
1175
|
-
- Bumped cryptography dependency to >=48.0.0,<49.0.0
|
|
1319
|
+
- Added `PreparedRequest` dataclass and split route validation/resolution into `RouteMapper.prepare_remote_request()`; the FastAPI adapter issues the streaming HTTP call
|
|
1320
|
+
- `map_route()` (buffered) retained for the Flask/sync path
|
|
1321
|
+
- Added streaming test coverage (`TestStreamUpstream`, plus `prepare_remote_request` and streaming-header tests) — 53 tests total
|
|
1176
1322
|
EOF
|
|
1177
1323
|
)"
|
|
1178
1324
|
|
|
@@ -670,25 +670,29 @@ def find_route_mapping(full_route: str, method: str, remote_config: Dict[str, An
|
|
|
670
670
|
|
|
671
671
|
|
|
672
672
|
def filter_remote_query_params(
|
|
673
|
-
query_params: Dict[str,
|
|
673
|
+
query_params: Dict[str, Any],
|
|
674
674
|
route: str,
|
|
675
675
|
method: str,
|
|
676
676
|
remote_config: Dict[str, Any]
|
|
677
|
-
) -> Dict[str,
|
|
677
|
+
) -> Dict[str, Any]:
|
|
678
678
|
"""Filter query parameters based on remote config settings.
|
|
679
679
|
|
|
680
680
|
Checks for a route-level query_params setting first, then falls back
|
|
681
681
|
to the top-level remote config query_params. If neither exists, all
|
|
682
682
|
params are passed through unchanged (backward compatible).
|
|
683
683
|
|
|
684
|
+
Filtering is by key only, so values may be strings or lists of strings
|
|
685
|
+
(the latter forwarding repeated keys such as ?id=1&id=2 intact).
|
|
686
|
+
|
|
684
687
|
Args:
|
|
685
|
-
query_params: Original query parameters from the request.
|
|
688
|
+
query_params: Original query parameters from the request. Values may be
|
|
689
|
+
strings or lists of strings.
|
|
686
690
|
route: The actual route path (e.g., "users/123").
|
|
687
691
|
method: HTTP method (e.g., "GET", "POST").
|
|
688
692
|
remote_config: Remote configuration dictionary.
|
|
689
693
|
|
|
690
694
|
Returns:
|
|
691
|
-
Filtered query parameters dictionary.
|
|
695
|
+
Filtered query parameters dictionary (value types preserved).
|
|
692
696
|
"""
|
|
693
697
|
routes = remote_config.get("routes", [])
|
|
694
698
|
|
|
@@ -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']
|
|
@@ -14,6 +14,7 @@ databases:
|
|
|
14
14
|
settings:
|
|
15
15
|
add_trailing_slash: false # Set true to auto-append trailing slash to proxied paths
|
|
16
16
|
follow_redirects: true # Set false to pass 3xx redirects through to the client
|
|
17
|
+
timeout: 10 # Upstream request timeout (seconds); null/false to disable
|
|
17
18
|
|
|
18
19
|
# Global route restrictions — applied to all remotes unless overridden per-remote.
|
|
19
20
|
# Uncomment to block DELETE on every remote:
|
{api_dock-0.6.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/databases/example_db.yaml
RENAMED
|
@@ -20,6 +20,13 @@ routes:
|
|
|
20
20
|
- category:
|
|
21
21
|
sql: "[[items]].category = '{{category}}'"
|
|
22
22
|
|
|
23
|
+
# Repeated param — add multivalue_sql to support ?id=1&id=2. When more than
|
|
24
|
+
# one value is passed, multivalue_sql is used and {{id}} expands to a
|
|
25
|
+
# parenthesized value list (e.g. ('1', '2')) for use with IN.
|
|
26
|
+
- id:
|
|
27
|
+
sql: "[[items]].id = {{id}}"
|
|
28
|
+
multivalue_sql: "[[items]].id IN {{id}}"
|
|
29
|
+
|
|
23
30
|
# Sorting — sql_append clauses are appended after the WHERE clause in YAML order.
|
|
24
31
|
- sort:
|
|
25
32
|
sql_append: ORDER BY {{sort}} {{direction}}
|
|
@@ -73,3 +80,17 @@ routes:
|
|
|
73
80
|
# - debug:
|
|
74
81
|
# response:
|
|
75
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}}'"
|