api-dock 0.6.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {api_dock-0.6.0 → api_dock-0.7.0}/PKG-INFO +45 -18
  2. {api_dock-0.6.0 → api_dock-0.7.0}/README.md +44 -17
  3. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/config.py +8 -4
  4. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/example_api_dock_config/config.yaml +1 -0
  5. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/example_api_dock_config/databases/example_db.yaml +7 -0
  6. api_dock-0.7.0/api_dock/fast_api.py +265 -0
  7. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/flask_api.py +7 -1
  8. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/route_mapper.py +163 -30
  9. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/sql_builder.py +67 -5
  10. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/types.py +33 -1
  11. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock.egg-info/PKG-INFO +45 -18
  12. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock.egg-info/SOURCES.txt +1 -0
  13. {api_dock-0.6.0 → api_dock-0.7.0}/pyproject.toml +1 -1
  14. {api_dock-0.6.0 → api_dock-0.7.0}/tests/test_proxy_pipeline.py +333 -2
  15. api_dock-0.7.0/tests/test_sql_builder.py +137 -0
  16. {api_dock-0.6.0 → api_dock-0.7.0}/tests/test_types.py +51 -2
  17. api_dock-0.6.0/api_dock/fast_api.py +0 -155
  18. {api_dock-0.6.0 → api_dock-0.7.0}/LICENSE.md +0 -0
  19. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/__init__.py +0 -0
  20. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/auth.py +0 -0
  21. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/cli.py +0 -0
  22. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/config_discovery.py +0 -0
  23. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/database_config.py +0 -0
  24. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/encryption.py +0 -0
  25. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/example_api_dock_config/remotes/example_remote.yaml +0 -0
  26. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/storage_auth.py +0 -0
  27. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock.egg-info/dependency_links.txt +0 -0
  28. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock.egg-info/entry_points.txt +0 -0
  29. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock.egg-info/requires.txt +0 -0
  30. {api_dock-0.6.0 → api_dock-0.7.0}/api_dock.egg-info/top_level.txt +0 -0
  31. {api_dock-0.6.0 → api_dock-0.7.0}/setup.cfg +0 -0
  32. {api_dock-0.6.0 → api_dock-0.7.0}/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.0
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).
@@ -1170,8 +1201,6 @@ pixi run python scripts/hello_world.py
1170
1201
 
1171
1202
  ---
1172
1203
 
1173
- ---
1174
-
1175
1204
  # Development
1176
1205
 
1177
1206
  ## Publishing a Release
@@ -1183,11 +1212,11 @@ pixi run python scripts/hello_world.py
1183
1212
 
1184
1213
  # 2. Commit everything
1185
1214
  git add -A
1186
- git commit -m "v0.6.0: proxy passthrough fixes, cookie injection"
1215
+ git commit -m "v0.6.1: stream proxy responses (fix large-response 502 + content-encoding)"
1187
1216
 
1188
1217
  # 3. Tag and push
1189
- git tag v0.6.0
1190
- git push origin main v0.6.0
1218
+ git tag v0.6.1
1219
+ git push origin main v0.6.1
1191
1220
 
1192
1221
  # 4. Build the wheel (requires the `dev` pixi environment)
1193
1222
  rm -rf dist/
@@ -1197,21 +1226,19 @@ pixi run -e dev python -m build --wheel
1197
1226
  ls dist/*.whl
1198
1227
 
1199
1228
  # 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'
1229
+ gh release create v0.6.1 dist/api_dock-0.6.1-py3-none-any.whl \
1230
+ --title "v0.6.1" --notes "$(cat <<'EOF'
1202
1231
  * 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
1232
+ - Remote proxy responses are now streamed (FastAPI) — upstream bytes are piped to the client as they arrive instead of being buffered fully in memory
1233
+ - New `timeout` setting (default 10s) for the upstream request; set to `null`/`false` to disable
1204
1234
  * 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
1235
+ - Large upstream responses no longer return 502 — streamed via `StreamingResponse` instead of reading the whole body into memory
1236
+ - `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
1237
+ - 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
1238
  * 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
1239
+ - Added `PreparedRequest` dataclass and split route validation/resolution into `RouteMapper.prepare_remote_request()`; the FastAPI adapter issues the streaming HTTP call
1240
+ - `map_route()` (buffered) retained for the Flask/sync path
1241
+ - Added streaming test coverage (`TestStreamUpstream`, plus `prepare_remote_request` and streaming-header tests) — 53 tests total
1215
1242
  EOF
1216
1243
  )"
1217
1244
 
@@ -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).
@@ -1131,8 +1162,6 @@ pixi run python scripts/hello_world.py
1131
1162
 
1132
1163
  ---
1133
1164
 
1134
- ---
1135
-
1136
1165
  # Development
1137
1166
 
1138
1167
  ## Publishing a Release
@@ -1144,11 +1173,11 @@ pixi run python scripts/hello_world.py
1144
1173
 
1145
1174
  # 2. Commit everything
1146
1175
  git add -A
1147
- git commit -m "v0.6.0: proxy passthrough fixes, cookie injection"
1176
+ git commit -m "v0.6.1: stream proxy responses (fix large-response 502 + content-encoding)"
1148
1177
 
1149
1178
  # 3. Tag and push
1150
- git tag v0.6.0
1151
- git push origin main v0.6.0
1179
+ git tag v0.6.1
1180
+ git push origin main v0.6.1
1152
1181
 
1153
1182
  # 4. Build the wheel (requires the `dev` pixi environment)
1154
1183
  rm -rf dist/
@@ -1158,21 +1187,19 @@ pixi run -e dev python -m build --wheel
1158
1187
  ls dist/*.whl
1159
1188
 
1160
1189
  # 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'
1190
+ gh release create v0.6.1 dist/api_dock-0.6.1-py3-none-any.whl \
1191
+ --title "v0.6.1" --notes "$(cat <<'EOF'
1163
1192
  * 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
1193
+ - Remote proxy responses are now streamed (FastAPI) — upstream bytes are piped to the client as they arrive instead of being buffered fully in memory
1194
+ - New `timeout` setting (default 10s) for the upstream request; set to `null`/`false` to disable
1165
1195
  * 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
1196
+ - Large upstream responses no longer return 502 — streamed via `StreamingResponse` instead of reading the whole body into memory
1197
+ - `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
1198
+ - 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
1199
  * 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
1200
+ - Added `PreparedRequest` dataclass and split route validation/resolution into `RouteMapper.prepare_remote_request()`; the FastAPI adapter issues the streaming HTTP call
1201
+ - `map_route()` (buffered) retained for the Flask/sync path
1202
+ - Added streaming test coverage (`TestStreamUpstream`, plus `prepare_remote_request` and streaming-header tests) — 53 tests total
1176
1203
  EOF
1177
1204
  )"
1178
1205
 
@@ -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
 
@@ -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}}
@@ -0,0 +1,265 @@
1
+ """
2
+
3
+ Main FastAPI Application for API Dock
4
+
5
+ Core FastAPI application that handles routing to remote APIs and serves config data.
6
+
7
+ License: BSD 3-Clause
8
+
9
+ """
10
+
11
+ #
12
+ # IMPORTS
13
+ #
14
+ import json
15
+ import httpx
16
+ from fastapi import FastAPI, Request
17
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
18
+ from typing import Any, Dict, Optional
19
+
20
+ from api_dock.route_mapper import collect_multi_query_params, HOP_BY_HOP_HEADERS, RouteMapper
21
+ from api_dock.types import PreparedRequest, ProxyResponse
22
+
23
+
24
+ #
25
+ # CONSTANTS
26
+ #
27
+ # Headers excluded when forwarding a streaming (raw-byte) upstream response.
28
+ # Derived from route_mapper.HOP_BY_HOP_HEADERS but keeps content-encoding:
29
+ # aiter_raw() yields the upstream's compressed bytes unchanged, so the
30
+ # Content-Encoding header still describes the body correctly and must reach the
31
+ # client so it can decompress. (The buffered map_route() path strips it instead,
32
+ # because httpx decompresses there and the header would otherwise be wrong.)
33
+ _STREAMING_EXCLUDED_HEADERS: frozenset = HOP_BY_HOP_HEADERS - frozenset({"content-encoding"})
34
+
35
+
36
+ #
37
+ # PUBLIC
38
+ #
39
+ def create_app(config_path: Optional[str] = None) -> FastAPI:
40
+ """Create and configure the FastAPI application.
41
+
42
+ Args:
43
+ config_path: Path to main config file. If None, uses default.
44
+
45
+ Returns:
46
+ Configured FastAPI application.
47
+ """
48
+ route_mapper = RouteMapper(config_path)
49
+
50
+ metadata = route_mapper.get_config_metadata()
51
+
52
+ app = FastAPI(
53
+ title=metadata.get("name", "API Dock"),
54
+ description=metadata.get("description", "API wrapper using configuration files"),
55
+ version="0.1.0"
56
+ )
57
+
58
+ app.state.route_mapper = route_mapper
59
+
60
+ _add_main_routes(app, route_mapper)
61
+ _add_remote_routes(app, route_mapper)
62
+ _add_error_handlers(app)
63
+
64
+ return app
65
+
66
+
67
+ #
68
+ # INTERNAL
69
+ #
70
+ def _add_main_routes(app: FastAPI, route_mapper: RouteMapper) -> None:
71
+ """Add main API routes to the FastAPI app.
72
+
73
+ Args:
74
+ app: FastAPI application instance.
75
+ route_mapper: RouteMapper instance.
76
+ """
77
+
78
+ @app.get("/")
79
+ async def get_meta() -> Dict[str, Any]:
80
+ """Return metadata from main config."""
81
+ return route_mapper.get_config_metadata()
82
+
83
+
84
+ def _add_remote_routes(app: FastAPI, route_mapper: RouteMapper) -> None:
85
+ """Add remote API proxy routes to the FastAPI app.
86
+
87
+ Args:
88
+ app: FastAPI application instance.
89
+ route_mapper: RouteMapper instance.
90
+ """
91
+
92
+ @app.api_route("/{remote_name}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
93
+ async def proxy_to_remote(remote_name: str, path: str, request: Request) -> Response:
94
+ """Proxy requests to remote APIs or databases.
95
+
96
+ Database routes are handled with a buffered response. Remote API routes
97
+ are streamed: upstream bytes are piped to the client as they arrive via
98
+ StreamingResponse, avoiding buffering large bodies in memory. Raw bytes
99
+ are forwarded via aiter_raw() so any upstream content-encoding (gzip,
100
+ br, deflate) is preserved end-to-end with the correct Content-Encoding
101
+ header.
102
+
103
+ Args:
104
+ remote_name: Name of the remote API or database.
105
+ path: The path to proxy to the remote API or query from database.
106
+ request: The incoming request.
107
+
108
+ Returns:
109
+ Response from the upstream with original status, headers, and body.
110
+ """
111
+ cookies = dict(request.cookies) if request.cookies else {}
112
+
113
+ if remote_name in route_mapper.database_names:
114
+ proxy_resp = await route_mapper.map_database_route(
115
+ database_name=remote_name,
116
+ path=path,
117
+ query_params=dict(request.query_params),
118
+ cookies=cookies,
119
+ multi_query_params=collect_multi_query_params(
120
+ request.query_params.multi_items()
121
+ ),
122
+ )
123
+ return Response(
124
+ content=proxy_resp.content,
125
+ status_code=proxy_resp.status_code,
126
+ headers=proxy_resp.headers,
127
+ media_type=proxy_resp.content_type,
128
+ )
129
+
130
+ body = None
131
+ if request.method in ["POST", "PUT", "PATCH"]:
132
+ body = await request.body()
133
+
134
+ prepared = await route_mapper.prepare_remote_request(
135
+ remote_name=remote_name,
136
+ path=path,
137
+ method=request.method,
138
+ headers=dict(request.headers),
139
+ body=body,
140
+ query_params=dict(request.query_params),
141
+ cookies=cookies,
142
+ multi_query_params=collect_multi_query_params(
143
+ request.query_params.multi_items()
144
+ ),
145
+ )
146
+
147
+ if isinstance(prepared, ProxyResponse):
148
+ return Response(
149
+ content=prepared.content,
150
+ status_code=prepared.status_code,
151
+ headers=prepared.headers,
152
+ media_type=prepared.content_type,
153
+ )
154
+
155
+ return await _stream_upstream(prepared)
156
+
157
+
158
+ def _add_error_handlers(app: FastAPI) -> None:
159
+ """Add custom error handlers to return JSON responses.
160
+
161
+ Args:
162
+ app: FastAPI application instance.
163
+ """
164
+
165
+ @app.exception_handler(404)
166
+ async def not_found_handler(request: Request, exc):
167
+ """Return JSON response for 404 errors."""
168
+ return JSONResponse(content={"error": "Not found"}, status_code=404)
169
+
170
+ @app.exception_handler(405)
171
+ async def method_not_allowed_handler(request: Request, exc):
172
+ """Return JSON response for 405 errors."""
173
+ return JSONResponse(content={"error": "Method not allowed"}, status_code=405)
174
+
175
+ @app.exception_handler(500)
176
+ async def internal_error_handler(request: Request, exc):
177
+ """Return JSON response for 500 errors."""
178
+ return JSONResponse(content={"error": "Internal server error"}, status_code=500)
179
+
180
+
181
+ def _filter_streaming_response_headers(headers: Dict[str, str]) -> Dict[str, str]:
182
+ """Strip headers that must not be forwarded in a streaming response.
183
+
184
+ Like route_mapper._filter_response_headers but preserves content-encoding.
185
+ When streaming raw bytes via aiter_raw(), compressed bytes pass through
186
+ unchanged, so Content-Encoding correctly describes the response body.
187
+
188
+ Args:
189
+ headers: Raw headers from the upstream httpx response.
190
+
191
+ Returns:
192
+ Filtered dict containing only headers safe to forward.
193
+ """
194
+ return {
195
+ key: value
196
+ for key, value in headers.items()
197
+ if key.lower() not in _STREAMING_EXCLUDED_HEADERS
198
+ }
199
+
200
+
201
+ async def _stream_upstream(prepared: PreparedRequest) -> Response:
202
+ """Stream a prepared upstream request back to the client.
203
+
204
+ Opens an httpx streaming connection, reads response status and headers,
205
+ then returns a StreamingResponse that pipes raw bytes to the client as
206
+ they arrive. Uses aiter_raw() so compressed content (gzip, br, deflate)
207
+ passes through unchanged with its Content-Encoding header intact.
208
+
209
+ If the upstream connection itself fails (before any bytes are received),
210
+ a plain 502 or 500 Response is returned instead.
211
+
212
+ Args:
213
+ prepared: Resolved upstream request from RouteMapper.prepare_remote_request().
214
+
215
+ Returns:
216
+ StreamingResponse on successful upstream connection,
217
+ or plain Response on 502/500 if the connection cannot be established.
218
+ """
219
+ client = httpx.AsyncClient(
220
+ follow_redirects=prepared.follow_redirects, timeout=prepared.timeout
221
+ )
222
+ req = client.build_request(
223
+ method=prepared.method,
224
+ url=prepared.url,
225
+ headers=prepared.headers,
226
+ params=prepared.params,
227
+ cookies=prepared.cookies,
228
+ content=prepared.body,
229
+ )
230
+ try:
231
+ upstream = await client.send(req, stream=True)
232
+ except httpx.RequestError as exc:
233
+ await client.aclose()
234
+ return Response(
235
+ content=json.dumps({"error": f"Error connecting to remote API: {str(exc)}"}).encode(),
236
+ status_code=502,
237
+ media_type="application/json",
238
+ )
239
+ except Exception as exc:
240
+ await client.aclose()
241
+ return Response(
242
+ content=json.dumps({"error": f"Internal server error: {str(exc)}"}).encode(),
243
+ status_code=500,
244
+ media_type="application/json",
245
+ )
246
+
247
+ async def _generate():
248
+ try:
249
+ async for chunk in upstream.aiter_raw():
250
+ yield chunk
251
+ finally:
252
+ await upstream.aclose()
253
+ await client.aclose()
254
+
255
+ headers = _filter_streaming_response_headers(dict(upstream.headers))
256
+ return StreamingResponse(
257
+ _generate(),
258
+ status_code=upstream.status_code,
259
+ headers=headers,
260
+ media_type=upstream.headers.get("content-type", "application/octet-stream"),
261
+ )
262
+
263
+
264
+ # Default app instance
265
+ app = create_app()
@@ -15,7 +15,7 @@ import asyncio
15
15
  from flask import Flask, jsonify, request, Response as FlaskResponse
16
16
  from typing import Any, Dict, Optional
17
17
 
18
- from api_dock.route_mapper import RouteMapper
18
+ from api_dock.route_mapper import collect_multi_query_params, RouteMapper
19
19
 
20
20
 
21
21
  #
@@ -126,6 +126,9 @@ def _handle_proxy(route_mapper: RouteMapper, remote_name: str, path: str) -> Fla
126
126
  path=path,
127
127
  query_params=dict(request.args),
128
128
  cookies=cookies,
129
+ multi_query_params=collect_multi_query_params(
130
+ request.args.items(multi=True)
131
+ ),
129
132
  )
130
133
  )
131
134
  else:
@@ -141,6 +144,9 @@ def _handle_proxy(route_mapper: RouteMapper, remote_name: str, path: str) -> Fla
141
144
  body=body,
142
145
  query_params=dict(request.args),
143
146
  cookies=cookies,
147
+ multi_query_params=collect_multi_query_params(
148
+ request.args.items(multi=True)
149
+ ),
144
150
  )
145
151
 
146
152
  response = FlaskResponse(