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.
Files changed (33) hide show
  1. {api_dock-0.6.0 → api_dock-0.7.1}/PKG-INFO +164 -18
  2. {api_dock-0.6.0 → api_dock-0.7.1}/README.md +163 -17
  3. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/config.py +8 -4
  4. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/database_config.py +4 -0
  5. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/config.yaml +1 -0
  6. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/databases/example_db.yaml +21 -0
  7. api_dock-0.7.1/api_dock/fast_api.py +265 -0
  8. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/flask_api.py +7 -1
  9. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/route_mapper.py +171 -31
  10. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/sql_builder.py +457 -8
  11. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/types.py +33 -1
  12. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock.egg-info/PKG-INFO +164 -18
  13. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock.egg-info/SOURCES.txt +2 -0
  14. {api_dock-0.6.0 → api_dock-0.7.1}/pyproject.toml +1 -1
  15. {api_dock-0.6.0 → api_dock-0.7.1}/tests/test_proxy_pipeline.py +333 -2
  16. api_dock-0.7.1/tests/test_sql_builder.py +137 -0
  17. api_dock-0.7.1/tests/test_sql_selector.py +277 -0
  18. {api_dock-0.6.0 → api_dock-0.7.1}/tests/test_types.py +51 -2
  19. api_dock-0.6.0/api_dock/fast_api.py +0 -155
  20. {api_dock-0.6.0 → api_dock-0.7.1}/LICENSE.md +0 -0
  21. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/__init__.py +0 -0
  22. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/auth.py +0 -0
  23. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/cli.py +0 -0
  24. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/config_discovery.py +0 -0
  25. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/encryption.py +0 -0
  26. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/remotes/example_remote.yaml +0 -0
  27. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock/storage_auth.py +0 -0
  28. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock.egg-info/dependency_links.txt +0 -0
  29. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock.egg-info/entry_points.txt +0 -0
  30. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock.egg-info/requires.txt +0 -0
  31. {api_dock-0.6.0 → api_dock-0.7.1}/api_dock.egg-info/top_level.txt +0 -0
  32. {api_dock-0.6.0 → api_dock-0.7.1}/setup.cfg +0 -0
  33. {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.6.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.0: proxy passthrough fixes, cookie injection"
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.0
1190
- git push origin main v0.6.0
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.0 dist/api_dock-0.6.0-py3-none-any.whl \
1201
- --title "v0.6.0" --notes "$(cat <<'EOF'
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
- - Cookie injection: dict entries in the `cookies` list inject server-side cookies into upstream requests, with `env:MY_VAR` env var support and literal value support
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
- - Binary responses (image, audio) no longer corrupted — raw bytes passed through with correct content-type
1206
- - 3xx redirects returned to client when `follow_redirects: false` (previously followed internally, doubling egress)
1207
- - Upstream response headers (Cache-Control, ETag, Last-Modified, etc.) now forwarded to client
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 `ProxyResponse` typed dataclass as the return contract for `map_route()` and `map_database_route()`
1212
- - Added test suite (38 tests covering proxy pipeline and cookie injection)
1213
- - Removed broken root `__init__.py` that caused pytest import conflicts
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.0: proxy passthrough fixes, cookie injection"
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.0
1151
- git push origin main v0.6.0
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.0 dist/api_dock-0.6.0-py3-none-any.whl \
1162
- --title "v0.6.0" --notes "$(cat <<'EOF'
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
- - Cookie injection: dict entries in the `cookies` list inject server-side cookies into upstream requests, with `env:MY_VAR` env var support and literal value support
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
- - Binary responses (image, audio) no longer corrupted — raw bytes passed through with correct content-type
1167
- - 3xx redirects returned to client when `follow_redirects: false` (previously followed internally, doubling egress)
1168
- - Upstream response headers (Cache-Control, ETag, Last-Modified, etc.) now forwarded to client
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 `ProxyResponse` typed dataclass as the return contract for `map_route()` and `map_database_route()`
1173
- - Added test suite (38 tests covering proxy pipeline and cookie injection)
1174
- - Removed broken root `__init__.py` that caused pytest import conflicts
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, 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, 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:
@@ -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}}'"