Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
68 changes: 68 additions & 0 deletions docs/ws/auth.md
Original file line number Diff line number Diff line change
@@ -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"}
```
51 changes: 51 additions & 0 deletions docs/ws/broadcast.md
Original file line number Diff line number Diff line change
@@ -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())
```
55 changes: 55 additions & 0 deletions docs/ws/close.md
Original file line number Diff line number Diff line change
@@ -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 <code>"}`.
3. Server closes with close code `<code>` and reason `Requested close with code <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())
```
5 changes: 3 additions & 2 deletions docs/ws/echo.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
Expand Down
59 changes: 59 additions & 0 deletions docs/ws/ticker.md
Original file line number Diff line number Diff line change
@@ -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": <n>, "interval": <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())
```
114 changes: 113 additions & 1 deletion src/routes/ws/ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Loading