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.
- {api_dock-0.6.0 → api_dock-0.7.0}/PKG-INFO +45 -18
- {api_dock-0.6.0 → api_dock-0.7.0}/README.md +44 -17
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/config.py +8 -4
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/example_api_dock_config/config.yaml +1 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/example_api_dock_config/databases/example_db.yaml +7 -0
- api_dock-0.7.0/api_dock/fast_api.py +265 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/flask_api.py +7 -1
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/route_mapper.py +163 -30
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/sql_builder.py +67 -5
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/types.py +33 -1
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock.egg-info/PKG-INFO +45 -18
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock.egg-info/SOURCES.txt +1 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/pyproject.toml +1 -1
- {api_dock-0.6.0 → api_dock-0.7.0}/tests/test_proxy_pipeline.py +333 -2
- api_dock-0.7.0/tests/test_sql_builder.py +137 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/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.0}/LICENSE.md +0 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/__init__.py +0 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/auth.py +0 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/cli.py +0 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/config_discovery.py +0 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/database_config.py +0 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/encryption.py +0 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/example_api_dock_config/remotes/example_remote.yaml +0 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock/storage_auth.py +0 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock.egg-info/dependency_links.txt +0 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock.egg-info/entry_points.txt +0 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock.egg-info/requires.txt +0 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/api_dock.egg-info/top_level.txt +0 -0
- {api_dock-0.6.0 → api_dock-0.7.0}/setup.cfg +0 -0
- {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.
|
|
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.
|
|
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.
|
|
1190
|
-
git push origin main v0.6.
|
|
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.
|
|
1201
|
-
--title "v0.6.
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
|
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 `
|
|
1212
|
-
-
|
|
1213
|
-
-
|
|
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.
|
|
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.
|
|
1151
|
-
git push origin main v0.6.
|
|
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.
|
|
1162
|
-
--title "v0.6.
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
|
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 `
|
|
1173
|
-
-
|
|
1174
|
-
-
|
|
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,
|
|
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
|
|
|
@@ -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.0}/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}}
|
|
@@ -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(
|