From 6958df5a95097671a90cf604d7c2dd48b6ecd9fe Mon Sep 17 00:00:00 2001 From: badnikhil Date: Sun, 5 Jul 2026 19:22:49 +0530 Subject: [PATCH 1/2] added more endpoints+tests --- src/routes/ws/ws.py | 114 +++++++++++++++++++++++++++++++++++++++++++- tests/ws/test_ws.py | 67 ++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) diff --git a/src/routes/ws/ws.py b/src/routes/ws/ws.py index 3b5bfb9..edb8e0b 100644 --- a/src/routes/ws/ws.py +++ b/src/routes/ws/ws.py @@ -10,7 +10,14 @@ async def websocket_echo(websocket: WebSocket): await websocket.accept() try: while True: - data = await websocket.receive_text() + message = await websocket.receive() + if message["type"] == "websocket.disconnect": + break + if message.get("bytes") is not None: + # Echo binary frames back unchanged. + await websocket.send_bytes(message["bytes"]) + continue + data = message.get("text", "") try: # If it's valid JSON, echo it back as text (re-serialized). json_data = json.loads(data) @@ -60,6 +67,111 @@ async def receive_messages(): for task in pending: task.cancel() +@ws_router.websocket("/ticker/{interval}") +async def websocket_ticker(websocket: WebSocket, interval: int): + """Pushes a ticker message from the server every `interval` seconds (1-60).""" + await websocket.accept() + if interval < 1 or interval > 60: + await websocket.close(code=1008, reason="interval must be between 1 and 60 seconds") + return + + async def send_ticks(): + tick = 0 + try: + while True: + await asyncio.sleep(interval) + tick += 1 + await websocket.send_json({"type": "ticker", "tick": tick, "interval": interval}) + except asyncio.CancelledError: + pass + + async def receive_messages(): + try: + while True: + # Drain incoming frames so client disconnects are detected promptly. + await websocket.receive_text() + except WebSocketDisconnect: + pass + except asyncio.CancelledError: + pass + + ticker_task = asyncio.create_task(send_ticks()) + receive_task = asyncio.create_task(receive_messages()) + + done, pending = await asyncio.wait( + [ticker_task, receive_task], + return_when=asyncio.FIRST_COMPLETED, + ) + + for task in pending: + task.cancel() + + +@ws_router.websocket("/close/{code}") +async def websocket_close(websocket: WebSocket, code: int): + """Accepts the connection, sends one message and closes with the requested close code.""" + await websocket.accept() + valid = (1000 <= code <= 1003) or (1007 <= code <= 1014) or (3000 <= code <= 4999) + if not valid: + await websocket.close(code=1008, reason="Unsupported close code") + return + try: + await websocket.send_json({"type": "close", "message": f"Closing with code {code}"}) + await websocket.close(code=code, reason=f"Requested close with code {code}") + except WebSocketDisconnect: + pass + + +WS_AUTH_TOKEN = "apidash-test-ws-token" + +@ws_router.websocket("/auth") +async def websocket_auth(websocket: WebSocket): + """ + A token-gated echo endpoint. Authenticate with either + an `Authorization: Bearer apidash-test-ws-token` header or + a `?token=apidash-test-ws-token` query parameter. + Invalid or missing tokens get closed with code 1008. + """ + await websocket.accept() + auth_header = websocket.headers.get("authorization") + query_token = websocket.query_params.get("token") + authenticated = (auth_header == f"Bearer {WS_AUTH_TOKEN}") or (query_token == WS_AUTH_TOKEN) + if not authenticated: + await websocket.close(code=1008, reason="Missing or invalid token") + return + try: + await websocket.send_json({"type": "auth", "status": "authenticated"}) + while True: + data = await websocket.receive_text() + await websocket.send_text(data) + except WebSocketDisconnect: + pass + + +broadcast_clients: set = set() + +@ws_router.websocket("/broadcast") +async def websocket_broadcast(websocket: WebSocket): + """ + A chat-style endpoint: every message sent by any client is + broadcast to all currently connected clients (including the sender). + """ + await websocket.accept() + broadcast_clients.add(websocket) + try: + while True: + data = await websocket.receive_text() + for client in list(broadcast_clients): + try: + await client.send_text(data) + except Exception: + broadcast_clients.discard(client) + except WebSocketDisconnect: + pass + finally: + broadcast_clients.discard(websocket) + + @ws_router.websocket("/timeout/{timeout}") async def websocket_strict_heartbeat(websocket: WebSocket, timeout: int): await websocket.accept() diff --git a/tests/ws/test_ws.py b/tests/ws/test_ws.py index 31c6f9b..043b789 100644 --- a/tests/ws/test_ws.py +++ b/tests/ws/test_ws.py @@ -6,6 +6,20 @@ def test_websocket_echo_text(client): data = websocket.receive_text() assert data == "Hello WebSocket" +def test_websocket_echo_binary(client): + with client.websocket_connect("/ws/echo") as websocket: + payload = bytes([0, 1, 2, 253, 254, 255]) + websocket.send_bytes(payload) + data = websocket.receive_bytes() + assert data == payload + +def test_websocket_echo_large_message(client): + with client.websocket_connect("/ws/echo") as websocket: + payload = "x" * 100_000 + websocket.send_text(payload) + data = websocket.receive_text() + assert data == payload + def test_websocket_echo_json(client): with client.websocket_connect("/ws/echo") as websocket: json_msg = json.dumps({"message": "Hello WebSocket"}) @@ -39,3 +53,56 @@ def test_websocket_strict_heartbeat_success(client): websocket.send_json({"message": "ping"}) data = websocket.receive_json() assert data == {"type": "heartbeat", "message": "pong"} + +def test_websocket_ticker(client): + with client.websocket_connect("/ws/ticker/1") as websocket: + data = websocket.receive_json() + assert data == {"type": "ticker", "tick": 1, "interval": 1} + +def test_websocket_ticker_invalid_interval(client): + with pytest.raises(WebSocketDisconnect) as exc: + with client.websocket_connect("/ws/ticker/0") as websocket: + websocket.receive_json() + assert exc.value.code == 1008 + +def test_websocket_close_with_code(client): + with pytest.raises(WebSocketDisconnect) as exc: + with client.websocket_connect("/ws/close/4001") as websocket: + data = websocket.receive_json() + assert data["type"] == "close" + websocket.receive_text() # next receive surfaces the close frame + assert exc.value.code == 4001 + +def test_websocket_close_invalid_code(client): + with pytest.raises(WebSocketDisconnect) as exc: + with client.websocket_connect("/ws/close/1005") as websocket: + websocket.receive_json() + websocket.receive_text() + assert exc.value.code == 1008 + +def test_websocket_auth_header(client): + with client.websocket_connect( + "/ws/auth", + headers={"Authorization": "Bearer apidash-test-ws-token"}) as websocket: + data = websocket.receive_json() + assert data == {"type": "auth", "status": "authenticated"} + websocket.send_text("hello") + assert websocket.receive_text() == "hello" + +def test_websocket_auth_query_token(client): + with client.websocket_connect("/ws/auth?token=apidash-test-ws-token") as websocket: + data = websocket.receive_json() + assert data["status"] == "authenticated" + +def test_websocket_auth_rejected(client): + with pytest.raises(WebSocketDisconnect) as exc: + with client.websocket_connect("/ws/auth") as websocket: + websocket.receive_json() + assert exc.value.code == 1008 + +def test_websocket_broadcast(client): + with client.websocket_connect("/ws/broadcast") as ws1: + with client.websocket_connect("/ws/broadcast") as ws2: + ws1.send_text("hello") + assert ws1.receive_text() == "hello" + assert ws2.receive_text() == "hello" From 775f9f9ac5b528308e9d51ef576fd567ce852f71 Mon Sep 17 00:00:00 2001 From: badnikhil Date: Sun, 5 Jul 2026 19:24:44 +0530 Subject: [PATCH 2/2] docs for new ws endpoints (ticker, close, auth, broadcast) + binary echo --- README.md | 2 +- docs/ws/auth.md | 68 ++++++++++++++++++++++++++++++++++++++++++++ docs/ws/broadcast.md | 51 +++++++++++++++++++++++++++++++++ docs/ws/close.md | 55 +++++++++++++++++++++++++++++++++++ docs/ws/echo.md | 5 ++-- docs/ws/ticker.md | 59 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 docs/ws/auth.md create mode 100644 docs/ws/broadcast.md create mode 100644 docs/ws/close.md create mode 100644 docs/ws/ticker.md diff --git a/README.md b/README.md index 3078375..80c53ae 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Endpoints for testing Server-Sent Events (SSE). ### 9. WebSockets -A self-hosted WebSocket echo endpoint for testing WebSocket clients without relying on public endpoints. +Self-hosted WebSocket endpoints (echo, heartbeat, ticker, auth, broadcast, close-with-code) for testing WebSocket clients without relying on public endpoints. ## Understanding the Project Structure diff --git a/docs/ws/auth.md b/docs/ws/auth.md new file mode 100644 index 0000000..23c6d92 --- /dev/null +++ b/docs/ws/auth.md @@ -0,0 +1,68 @@ +--- +method: get +title: WebSocket Auth Echo +desc: A token-gated WebSocket echo endpoint accepting a Bearer header or a token query parameter. +path: ws/auth +--- + +This is a token-gated echo endpoint for testing authenticated WebSocket connections. The mock token is public and not a secret: + +``` +apidash-test-ws-token +``` + +Authenticate in either of two ways: + +1. **Header** (for clients that support custom connection headers): `Authorization: Bearer apidash-test-ws-token` +2. **Query parameter** (works from browsers too): `?token=apidash-test-ws-token` + +## Connection URL + +Connect to the endpoint using the WebSocket scheme. Use `wss://` against the deployed (TLS) API, or `ws://` against a local server. + +``` +wss://api.foss42.com/ws/auth?token=apidash-test-ws-token +``` + +When running the API locally: + +``` +ws://127.0.0.1:8000/ws/auth?token=apidash-test-ws-token +``` + +## Behavior + +| Event | Result | +| ----------- | ----------- | +| Valid token (header or query param) | Server sends `{"type": "auth", "status": "authenticated"}`, then echoes every text message back unchanged | +| Missing or invalid token | Connection is accepted, then immediately closed with code `1008` and reason `Missing or invalid token` | + +## Sample Usage + +### Example #1: Python (`websockets`), header auth + +```python +import asyncio +import websockets + + +async def main(): + async with websockets.connect( + "wss://api.foss42.com/ws/auth", + additional_headers={"Authorization": "Bearer apidash-test-ws-token"}, + ) as ws: + print(await ws.recv()) # {"type": "auth", "status": "authenticated"} + await ws.send("hello") + print(await ws.recv()) # hello + + +asyncio.run(main()) +``` + +### Example #2: JavaScript (browser `WebSocket`), query param auth + +```javascript +const ws = new WebSocket("wss://api.foss42.com/ws/auth?token=apidash-test-ws-token"); + +ws.onmessage = (event) => console.log(event.data); // {"type": "auth", "status": "authenticated"} +``` diff --git a/docs/ws/broadcast.md b/docs/ws/broadcast.md new file mode 100644 index 0000000..1053676 --- /dev/null +++ b/docs/ws/broadcast.md @@ -0,0 +1,51 @@ +--- +method: get +title: WebSocket Broadcast +desc: A chat-style WebSocket endpoint that broadcasts every message to all connected clients. +path: ws/broadcast +--- + +This is a chat-style ("fan-out") WebSocket endpoint: every text message sent by any connected client is broadcast to **all** currently connected clients, including the sender. It is intended for testing multiple simultaneous connections (e.g. multiple tabs/windows of a WebSocket client such as API Dash). + +## Connection URL + +Connect to the endpoint using the WebSocket scheme. Use `wss://` against the deployed (TLS) API, or `ws://` against a local server. + +``` +wss://api.foss42.com/ws/broadcast +``` + +When running the API locally: + +``` +ws://127.0.0.1:8000/ws/broadcast +``` + +## Behavior + +1. Client A and Client B connect. +2. Client A sends `hello everyone`. +3. Both Client A and Client B receive `hello everyone`. + +Messages are relayed unchanged. Clients that have disconnected are dropped from the broadcast set. + +## Sample Usage + +### Example: Python (`websockets`), two clients + +```python +import asyncio +import websockets + +URL = "wss://api.foss42.com/ws/broadcast" + + +async def main(): + async with websockets.connect(URL) as a, websockets.connect(URL) as b: + await a.send("hello everyone") + print(await a.recv()) # hello everyone + print(await b.recv()) # hello everyone + + +asyncio.run(main()) +``` diff --git a/docs/ws/close.md b/docs/ws/close.md new file mode 100644 index 0000000..b0e6eda --- /dev/null +++ b/docs/ws/close.md @@ -0,0 +1,55 @@ +--- +method: get +title: WebSocket Close With Code +desc: A WebSocket endpoint that closes the connection with a client-requested close code. +path: ws/close/{code} +--- + +This is a WebSocket endpoint for testing how clients handle server-initiated closes. On connect, the server sends one JSON message and then immediately closes the connection with the requested close code and a matching reason. + +## Connection URL + +Connect to the endpoint using the WebSocket scheme. Use `wss://` against the deployed (TLS) API, or `ws://` against a local server. + +``` +wss://api.foss42.com/ws/close/4001 +``` + +When running the API locally: + +``` +ws://127.0.0.1:8000/ws/close/4001 +``` + +## Path Parameters + +| Attribute | Data Type | Required | Description | +| ----------- | ----------- | ----------- | ----------- | +| `code` | `integer` | Yes | The close code to close with. Allowed: `1000`–`1003`, `1007`–`1014`, `3000`–`4999`. Reserved / unsendable codes (e.g. `1005`, `1006`) are rejected by closing with `1008` instead. | + +## Behavior + +1. Server accepts the connection. +2. Server sends `{"type": "close", "message": "Closing with code "}`. +3. Server closes with close code `` and reason `Requested close with code `. + +## Sample Usage + +### Example: Python (`websockets`) + +```python +import asyncio +import websockets + + +async def main(): + try: + async with websockets.connect("wss://api.foss42.com/ws/close/4001") as ws: + print(await ws.recv()) # {"type": "close", "message": "Closing with code 4001"} + await ws.recv() + except websockets.exceptions.ConnectionClosed as e: + print(e.rcvd.code, e.rcvd.reason) # 4001 Requested close with code 4001 + + +asyncio.run(main()) +``` diff --git a/docs/ws/echo.md b/docs/ws/echo.md index aa7b9e8..0e88725 100644 --- a/docs/ws/echo.md +++ b/docs/ws/echo.md @@ -1,11 +1,11 @@ --- method: get title: WebSocket Echo -desc: A WebSocket endpoint that echoes back every message it receives, identically, for both plain text and JSON payloads. +desc: A WebSocket endpoint that echoes back every message it receives, identically, for plain text, JSON and binary payloads. path: ws/echo --- -This is a WebSocket endpoint that echoes back every message it receives, identically, for both plain text and JSON payloads. The connection is established through a standard HTTP `GET` upgrade handshake. Once connected, it is fully bidirectional: every text or JSON frame the server receives is sent straight back to the client unchanged. When the client disconnects, the server closes the connection cleanly. +This is a WebSocket endpoint that echoes back every message it receives, identically, for plain text, JSON and binary payloads. The connection is established through a standard HTTP `GET` upgrade handshake. Once connected, it is fully bidirectional: every text or JSON frame the server receives is sent straight back to the client unchanged. When the client disconnects, the server closes the connection cleanly. It is intended as a self-hosted echo endpoint for testing WebSocket clients (such as API Dash) without relying on public endpoints. @@ -29,6 +29,7 @@ ws://127.0.0.1:8000/ws/echo | ----------- | ----------- | | Plain text frame | The same text, unchanged | | Valid JSON text frame | The same JSON, re-serialized and semantically identical | +| Binary frame | The same bytes, unchanged, as a binary frame | | Client closes the connection | Server closes cleanly (normal closure) | ## Sample Usage diff --git a/docs/ws/ticker.md b/docs/ws/ticker.md new file mode 100644 index 0000000..c7be825 --- /dev/null +++ b/docs/ws/ticker.md @@ -0,0 +1,59 @@ +--- +method: get +title: WebSocket Ticker +desc: A WebSocket endpoint that pushes a server-side ticker message at a configurable interval. +path: ws/ticker/{interval} +--- + +This is a WebSocket endpoint that pushes a JSON ticker message from the server every `interval` seconds, without requiring the client to send anything. It is intended for testing server-push handling (streaming, unsolicited messages) in WebSocket clients such as API Dash. + +## Connection URL + +Connect to the endpoint using the WebSocket scheme. Use `wss://` against the deployed (TLS) API, or `ws://` against a local server. + +``` +wss://api.foss42.com/ws/ticker/2 +``` + +When running the API locally: + +``` +ws://127.0.0.1:8000/ws/ticker/2 +``` + +## Path Parameters + +| Attribute | Data Type | Required | Description | +| ----------- | ----------- | ----------- | ----------- | +| `interval` | `integer` | Yes | Seconds between ticker messages. Must be between `1` and `60`. Values outside this range cause the server to close the connection with code `1008`. | + +## Behavior + +| Event | Result | +| ----------- | ----------- | +| Server timer (every `interval` s) | Server sends: `{"type": "ticker", "tick": , "interval": }` | +| Client sends any message | Read and ignored (does not affect the ticker) | +| Invalid `interval` | Server closes with code `1008` | + +`tick` starts at `1` and increments forever until the client disconnects. + +## Sample Usage + +### Example: Python (`websockets`) + +```python +import asyncio +import websockets + + +async def main(): + async with websockets.connect("wss://api.foss42.com/ws/ticker/2") as ws: + for _ in range(3): + print(await ws.recv()) + # {"type": "ticker", "tick": 1, "interval": 2} + # {"type": "ticker", "tick": 2, "interval": 2} + # {"type": "ticker", "tick": 3, "interval": 2} + + +asyncio.run(main()) +```