Skip to content

Commit 31544ab

Browse files
committed
require token; add relay toggle endpoints
- move websocket endpoint to {path_prefix}/websocket and reserve {path_prefix}/enable|disable - add token-gated enable/disable routes; disabling closes active websocket and clears override paths - client: support --token / FASTAPI_DEV_PROXY_TOKEN and inject token into relay URL - update docs, examples, and tests for new websocket path
1 parent 02337e6 commit 31544ab

7 files changed

Lines changed: 301 additions & 90 deletions

File tree

README.md

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,18 @@ FastAPI webhook relay/proxy library for local development. Production webhooks a
77
forwarded over WebSocket to a developer machine, replayed against a local server,
88
and the response is sent back to production.
99

10-
### Status
10+
### Why
1111

12-
- v0 software: unstable APIs and behavior, expect breaking changes.
13-
- Extracted from another project; not all original features are included yet.
12+
Webhook integrations are awkward to build locally because **the third-party needs to reach your machine**:
13+
14+
- Your FastAPI server is on `localhost`, behind NAT/firewalls, and often on an unreliable dev network.
15+
- “Just open a port” is rarely an option (corporate networks, Wi‑Fi, security policies).
16+
17+
Tools like ngrok (and similar tunneling/reverse-proxy setups) can solve this, but for webhook development they can also feel **overly complex**: extra moving parts, extra configuration, and yet another service to pay for.
18+
19+
This project is the alternative I wanted: **a simple, free, open-source relay** specifically for developing webhook handlers. Instead of exposing your laptop to the internet, updating your webhook registration, you run a tiny websocket “relay” websocket in production. Then you connect a local client, receive real webhook requests, replay them against `http://localhost:...`, and send the response back upstream.
20+
21+
A nice side-effect: you can keep the **same third-party webhook URL** (pointing at your production app) and **toggle forwarding on/off** with a config change (e.g. `enabled=False`) or by simply connecting/disconnecting the dev client—no redeploy and no “update webhook URL” dance.
1422

1523
### Install
1624

@@ -32,39 +40,46 @@ async def sms_webhook() -> Response:
3240
return Response(status_code=200)
3341
```
3442

35-
Alternatively, use the class method for a one-line setup: `relay = RelayManager.for_app(app, token="secret", ...)`. Or create the relay first and call `relay.install(app)` later.
43+
If you prefer, you can create the relay first and call `relay.install(app)` later.
44+
45+
When installed (default `path_prefix` is `/fastapi-dev-proxy`), the relay registers:
46+
47+
- `{path_prefix}/websocket` (WebSocket): dev client connects here
48+
- `{path_prefix}/enable` (POST): enable forwarding (requires `?token=...`)
49+
- `{path_prefix}/disable` (POST): disable forwarding (requires `?token=...`)
3650

3751
### Client usage
3852

39-
```python
40-
import asyncio
53+
The client must include the shared token as the `token` query param when connecting (e.g. `...?token=...`). To avoid hardcoding secrets in source control, read it from an environment variable.
4154

42-
from fastapi_dev_proxy.client import run_client
55+
### CLI
4356

44-
asyncio.run(
45-
run_client(
46-
relay_url="wss://prod.example.com/fastapi-dev-proxy?token=secret",
47-
target_base_url="http://localhost:8080",
48-
override_paths=["/webhook/sms", "/webhook/items/{item_id}"],
49-
)
50-
)
5157
```
58+
export FASTAPI_DEV_PROXY_TOKEN="your-shared-token"
5259
53-
Override paths can include simple patterns:
54-
55-
- `{param}` or `<param>` matches a single path segment.
56-
- `*` matches a single path segment.
57-
- `/**` at the end matches any remaining path segments.
60+
fastapi-dev-proxy \
61+
--relay-url "wss://prod.example.com/fastapi-dev-proxy/websocket" \
62+
--target-base-url http://localhost:8080 \
63+
--override-path /webhook/sms \
64+
--override-path /webhook/user/<uuid>
65+
```
5866

59-
### CLI
67+
Or pass it explicitly:
6068

6169
```
6270
fastapi-dev-proxy \
63-
--relay-url wss://prod.example.com/fastapi-dev-proxy?token=secret \
71+
--relay-url "wss://prod.example.com/fastapi-dev-proxy/websocket" \
72+
--token "your-shared-token" \
6473
--target-base-url http://localhost:8080 \
6574
--override-path /webhook/sms
6675
```
6776

77+
#### Override paths can include simple patterns:
78+
79+
- `{param}` or `<param>` matches a single path segment.
80+
- `*` matches a single path segment.
81+
- `/**` at the end matches any remaining path segments.
82+
6883
### Development
6984

7085
Install dev dependencies:
@@ -104,5 +119,3 @@ python -m pytest \
104119
--cov-report=xml:coverage.xml \
105120
--cov-report=html:htmlcov
106121
```
107-
108-
CI uploads `coverage.xml` and `htmlcov/` as workflow artifacts. If you enable Codecov for the repo, CI will also upload coverage there (badge above).

examples/basic_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
def main() -> None:
99
asyncio.run(
1010
run_client(
11-
relay_url="ws://localhost:8000/fastapi-dev-proxy?token=secret",
11+
relay_url="ws://localhost:8000/fastapi-dev-proxy/websocket?token=secret",
1212
target_base_url="http://localhost:8080",
1313
override_paths=["/webhook/sms"],
1414
)

src/fastapi_dev_proxy/client.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
import asyncio
77
import json
88
import logging
9+
import os
910
from typing import Iterable
11+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
1012

1113
from httpx import AsyncClient
1214
from websockets import connect as connect_websocket
@@ -24,6 +26,30 @@
2426

2527
logger = logging.getLogger(__name__)
2628

29+
DEFAULT_TOKEN_ENV_VAR = "FASTAPI_DEV_PROXY_TOKEN"
30+
31+
32+
def _apply_token(relay_url: str, token: str | None) -> str:
33+
"""Return relay_url with token injected/overridden as query param."""
34+
if not token:
35+
return relay_url
36+
parts = urlsplit(relay_url)
37+
query_pairs = [(k, v) for (k, v) in parse_qsl(parts.query, keep_blank_values=True) if k != "token"]
38+
query_pairs.append(("token", token))
39+
new_query = urlencode(query_pairs)
40+
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
41+
42+
43+
def _resolve_token(cli_token: str | None, token_env_var: str | None) -> str:
44+
if cli_token:
45+
return cli_token
46+
if not token_env_var:
47+
raise ValueError("Token is required. Pass --token or set FASTAPI_DEV_PROXY_TOKEN.")
48+
value = os.environ.get(token_env_var)
49+
if value:
50+
return value
51+
raise ValueError(f"Token is required. Pass --token or set {token_env_var}.")
52+
2753

2854
async def _forward_request(
2955
client: AsyncClient,
@@ -118,6 +144,15 @@ def _parse_override_paths(values: list[str], csv_values: list[str]) -> list[str]
118144
def _build_parser() -> argparse.ArgumentParser: # pragma: no cover
119145
parser = argparse.ArgumentParser(description="Run dev proxy client.")
120146
parser.add_argument("--relay-url", required=True, help="Relay websocket URL.")
147+
parser.add_argument(
148+
"--token",
149+
help="Shared token for the relay (appended to --relay-url as ?token=...).",
150+
)
151+
parser.add_argument(
152+
"--token-env",
153+
default=DEFAULT_TOKEN_ENV_VAR,
154+
help=f"Env var name to read token from if --token is unset (default: {DEFAULT_TOKEN_ENV_VAR}).",
155+
)
121156
parser.add_argument(
122157
"--target-base-url",
123158
required=True,
@@ -160,11 +195,16 @@ def main(argv: list[str] | None = None) -> None: # pragma: no cover
160195
parser = _build_parser()
161196
args = parser.parse_args(argv)
162197
override_paths = _parse_override_paths(args.override_path, args.override_paths)
198+
try:
199+
token = _resolve_token(args.token, args.token_env)
200+
except ValueError as exc:
201+
parser.error(str(exc))
202+
relay_url = _apply_token(args.relay_url, token)
163203

164204
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
165205
asyncio.run(
166206
run_client(
167-
relay_url=args.relay_url,
207+
relay_url=relay_url,
168208
target_base_url=args.target_base_url,
169209
override_paths=override_paths,
170210
timeout=args.timeout,

src/fastapi_dev_proxy/server.py

Lines changed: 77 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import cast
99
from uuid import uuid4
1010

11-
from fastapi import FastAPI, Request, Response, WebSocket, status
11+
from fastapi import FastAPI, HTTPException, Request, Response, WebSocket, status
1212
from fastapi.websockets import WebSocketDisconnect
1313
from starlette.types import ASGIApp, Receive, Scope, Send
1414

@@ -36,19 +36,27 @@ def __init__(
3636
app: ASGIApp,
3737
*,
3838
relay: RelayManager,
39-
websocket_path: str = "/fastapi-dev-proxy",
39+
path_prefix: str = "/fastapi-dev-proxy",
4040
) -> None:
4141
self._app = app
4242
self._relay = relay
43-
self._websocket_path = normalize_path(websocket_path.rstrip("/") or "/")
43+
prefix = normalize_path(path_prefix.rstrip("/") or "/")
44+
if prefix == "/":
45+
self._reserved_paths = {"/websocket", "/enable", "/disable"}
46+
else:
47+
self._reserved_paths = {
48+
f"{prefix}/websocket",
49+
f"{prefix}/enable",
50+
f"{prefix}/disable",
51+
}
4452

4553
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
4654
if scope.get("type") != "http":
4755
await self._app(scope, receive, send)
4856
return
4957

5058
path = scope.get("path") or "/"
51-
if normalize_path(path) == self._websocket_path:
59+
if normalize_path(path) in self._reserved_paths:
5260
await self._app(scope, receive, send)
5361
return
5462

@@ -67,11 +75,13 @@ def __init__(
6775
self,
6876
*,
6977
app: FastAPI | None = None,
70-
websocket_path: str = "/fastapi-dev-proxy",
71-
token: str | None = None,
78+
path_prefix: str = "/fastapi-dev-proxy",
79+
token: str,
7280
enabled: bool = True,
7381
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
7482
) -> None:
83+
if not token:
84+
raise ValueError("RelayManager token is required (non-empty).")
7585
self._token = token
7686
self._enabled = enabled
7787
self._timeout_seconds = timeout_seconds
@@ -80,7 +90,7 @@ def __init__(
8090
self._pending: dict[str, asyncio.Future[WebhookResponse]] = {}
8191
self._lock = asyncio.Lock()
8292
if app is not None:
83-
self._install(app, websocket_path=websocket_path)
93+
self._install(app, path_prefix=path_prefix)
8494

8595
def is_enabled(self) -> bool:
8696
return self._enabled
@@ -95,45 +105,82 @@ def install(
95105
self,
96106
app: FastAPI,
97107
*,
98-
websocket_path: str = "/fastapi-dev-proxy",
108+
path_prefix: str = "/fastapi-dev-proxy",
99109
) -> None:
100-
"""Register the dev proxy websocket endpoint and HTTP proxy middleware on an existing instance."""
101-
self._install(app, websocket_path=websocket_path)
110+
"""Register the dev proxy endpoints and HTTP proxy middleware on an existing instance.
102111
103-
@classmethod
104-
def for_app(
105-
cls,
106-
app: FastAPI,
107-
*,
108-
websocket_path: str = "/fastapi-dev-proxy",
109-
token: str | None = None,
110-
enabled: bool = True,
111-
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
112-
) -> RelayManager:
113-
"""Create a RelayManager and register it on the app (websocket + middleware). Returns the relay."""
114-
relay = cls(token=token, enabled=enabled, timeout_seconds=timeout_seconds)
115-
relay._install(app, websocket_path=websocket_path)
116-
return relay
112+
Endpoints:
113+
114+
- `{path_prefix}/websocket` (websocket) connects the dev client
115+
- `{path_prefix}/enable` (HTTP) enables forwarding
116+
- `{path_prefix}/disable` (HTTP) disables forwarding
117+
"""
118+
self._install(app, path_prefix=path_prefix)
117119

118120
def _install(
119121
self,
120122
app: FastAPI,
121123
*,
122-
websocket_path: str = "/fastapi-dev-proxy",
124+
path_prefix: str = "/fastapi-dev-proxy",
123125
) -> None:
124-
"""Register the dev proxy websocket endpoint and HTTP proxy middleware."""
126+
"""Register the dev proxy endpoints and HTTP proxy middleware."""
127+
prefix = normalize_path(path_prefix.rstrip("/") or "/")
128+
websocket_path = f"{prefix}/websocket" if prefix != "/" else "/websocket"
129+
enable_path = f"{prefix}/enable" if prefix != "/" else "/enable"
130+
disable_path = f"{prefix}/disable" if prefix != "/" else "/disable"
131+
125132
self._register_websocket(app, websocket_path)
133+
self._register_toggle_routes(app, enable_path=enable_path, disable_path=disable_path)
126134
app.add_middleware(
127135
RelayProxyMiddleware,
128136
relay=self,
129-
websocket_path=websocket_path,
137+
path_prefix=prefix,
130138
)
131139

132140
def _register_websocket(self, app: FastAPI, websocket_path: str) -> None:
133141
@app.websocket(websocket_path)
134142
async def dev_proxy_ws(ws: WebSocket) -> None:
135143
await self.handle_connection(ws)
136144

145+
def _register_toggle_routes(self, app: FastAPI, *, enable_path: str, disable_path: str) -> None:
146+
@app.post(enable_path)
147+
async def dev_proxy_enable(request: Request) -> dict[str, bool]:
148+
if not self._is_token_valid(request.query_params.get("token")):
149+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
150+
await self.set_enabled(True)
151+
return {"enabled": True}
152+
153+
@app.post(disable_path)
154+
async def dev_proxy_disable(request: Request) -> dict[str, bool]:
155+
if not self._is_token_valid(request.query_params.get("token")):
156+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
157+
await self.set_enabled(False)
158+
return {"enabled": False}
159+
160+
def _is_token_valid(self, token: str | None) -> bool:
161+
return bool(token) and token == self._token
162+
163+
async def set_enabled(self, enabled: bool) -> None:
164+
"""Enable/disable forwarding.
165+
166+
If disabling while a dev client is connected, closes the websocket and clears override paths.
167+
"""
168+
async with self._lock:
169+
self._enabled = enabled
170+
if enabled:
171+
return
172+
173+
websocket = self._websocket
174+
if websocket is None:
175+
self._override_paths = set()
176+
return
177+
178+
try:
179+
await websocket.close(code=status.WS_1001_GOING_AWAY)
180+
except Exception:
181+
logger.debug("Failed to close dev proxy websocket on disable")
182+
await self._clear_connection(websocket)
183+
137184

138185
async def handle_connection(self, websocket: WebSocket) -> None:
139186
"""Handle websocket lifecycle and response messages."""
@@ -142,11 +189,9 @@ async def handle_connection(self, websocket: WebSocket) -> None:
142189
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
143190
return
144191

145-
if self._token is not None:
146-
token = websocket.query_params.get("token")
147-
if not token or token != self._token:
148-
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
149-
return
192+
if not self._is_token_valid(websocket.query_params.get("token")):
193+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
194+
return
150195

151196
await websocket.accept()
152197

0 commit comments

Comments
 (0)