Skip to content

Commit 942ae0a

Browse files
committed
Implement WebSocket support and enhance request handling in the backend; update frontend to display request details and allow raw data download
1 parent 1c4c2b6 commit 942ae0a

15 files changed

Lines changed: 396 additions & 77 deletions

File tree

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,16 @@ jobs:
5050
- uses: actions/checkout@v4
5151
- name: Build Docker image
5252
run: docker build . -t httpintercepter:ci
53+
- name: Run container
54+
run: |
55+
cid=$(docker run -d -p 8181:8181 httpintercepter:ci)
56+
echo "CID=$cid" >> $GITHUB_OUTPUT
57+
# wait for app
58+
for i in {1..20}; do
59+
if curl -fsS http://localhost:8181/healthz > /dev/null; then
60+
echo "App is up"; break; fi; sleep 1; done
61+
curl -fsS http://localhost:8181/healthz
62+
- name: Stop container
63+
if: always()
64+
run: |
65+
docker ps -q --filter ancestor=httpintercepter:ci | xargs -r docker stop

.vscode/launch.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"version": "0.2.0",
3+
"configurations": [
4+
{
5+
"name": "Backend: FastAPI (uvicorn)",
6+
"type": "debugpy",
7+
"request": "launch",
8+
"module": "uvicorn",
9+
"args": [
10+
"app.main:app",
11+
"--reload",
12+
"--port",
13+
"8181",
14+
"--app-dir",
15+
"backend"
16+
],
17+
"cwd": "${workspaceFolder}",
18+
"env": {
19+
"PYTHONPATH": "${workspaceFolder}/backend"
20+
},
21+
"console": "integratedTerminal",
22+
"justMyCode": true
23+
},
24+
{
25+
"name": "Frontend: Vite dev",
26+
"type": "node",
27+
"request": "launch",
28+
"cwd": "${workspaceFolder}/frontend",
29+
"runtimeExecutable": "npm",
30+
"runtimeArgs": [
31+
"run",
32+
"dev"
33+
],
34+
"console": "integratedTerminal",
35+
"skipFiles": [
36+
"<node_internals>/**"
37+
]
38+
}
39+
],
40+
"compounds": [
41+
{
42+
"name": "Dev: Backend + Frontend",
43+
"configurations": [
44+
"Backend: FastAPI (uvicorn)",
45+
"Frontend: Vite dev"
46+
],
47+
"stopAll": true
48+
}
49+
]
50+
}

.vscode/tasks.json

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,25 @@
1010
"python -m venv .venv && .venv\\Scripts\\python -m pip install -U pip && .venv\\Scripts\\pip install -e backend[dev] && .venv\\Scripts\\pytest -q backend"
1111
],
1212
"problemMatcher": [
13-
"$pytest"
13+
"$python"
1414
],
1515
"group": "build"
16+
},
17+
{
18+
"label": "Backend: pip install",
19+
"type": "shell",
20+
"command": "${command:python.interpreterPath}",
21+
"args": ["-m", "pip", "install", "-e", "backend[dev]"],
22+
"options": { "cwd": "${workspaceFolder}" },
23+
"problemMatcher": []
24+
},
25+
{
26+
"label": "Frontend: npm install",
27+
"type": "shell",
28+
"command": "npm",
29+
"args": ["install"],
30+
"options": { "cwd": "${workspaceFolder}/frontend" },
31+
"problemMatcher": []
1632
}
1733
]
1834
}

Dockerfile

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,10 @@ WORKDIR /app
1414
ENV PYTHONDONTWRITEBYTECODE=1
1515
ENV PYTHONUNBUFFERED=1
1616

17-
# Install backend deps
18-
COPY backend/pyproject.toml ./backend/pyproject.toml
19-
RUN pip install --no-cache-dir -U pip setuptools && \
20-
pip install --no-cache-dir -e ./backend[dev]
21-
22-
# Copy backend code and static
17+
# Copy backend code and install deps
2318
COPY backend/ ./backend/
19+
RUN pip install --no-cache-dir -U pip setuptools && \
20+
pip install --no-cache-dir -e ./backend
2421
COPY --from=frontend /app/dist ./frontend-dist
2522

2623
ENV FRONTEND_DIST_DIR=/app/frontend-dist

backend/app/main.py

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from __future__ import annotations
22

3-
from datetime import datetime
4-
from typing import Dict, List, Optional
3+
from datetime import datetime, timezone
4+
from typing import Dict, List, Optional, Tuple
55
import base64
6-
from fastapi import FastAPI, Request, Response, HTTPException
6+
from fastapi import FastAPI, Request, Response, HTTPException, WebSocket, WebSocketDisconnect
77
from fastapi.staticfiles import StaticFiles
88
import os
99
from pydantic import BaseModel
@@ -27,16 +27,43 @@ class StoredRequest(BaseModel):
2727
ts: float
2828
ip: str
2929
headers: Dict[str, str]
30+
headers_ordered: List[Tuple[str, str]]
3031
query: Dict[str, str]
3132
body_text: Optional[str] = None
3233
body_bytes_b64: Optional[str] = None
3334
body_length: int = 0
35+
raw_request_b64: Optional[str] = None
3436

3537

3638
_requests: List[StoredRequest] = []
3739
_next_id = 1
3840

3941

42+
class WSManager:
43+
def __init__(self) -> None:
44+
self._clients: set[WebSocket] = set()
45+
46+
async def connect(self, ws: WebSocket):
47+
await ws.accept()
48+
self._clients.add(ws)
49+
50+
def disconnect(self, ws: WebSocket):
51+
self._clients.discard(ws)
52+
53+
async def broadcast_json(self, data: dict):
54+
dead: List[WebSocket] = []
55+
for ws in list(self._clients):
56+
try:
57+
await ws.send_json(data)
58+
except Exception:
59+
dead.append(ws)
60+
for ws in dead:
61+
self.disconnect(ws)
62+
63+
64+
ws_manager = WSManager()
65+
66+
4067
@app.api_route("/inbound", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"])
4168
async def inbound(request: Request):
4269
global _next_id
@@ -45,20 +72,48 @@ async def inbound(request: Request):
4572
body_text = body.decode("utf-8") if body else None
4673
except UnicodeDecodeError:
4774
body_text = None
75+
# Capture ordered headers as seen by ASGI scope
76+
headers_scope = request.scope.get("headers", []) # List[Tuple[bytes, bytes]]
77+
headers_ordered: List[Tuple[str, str]] = [
78+
(k.decode("latin-1"), v.decode("latin-1")) for (k, v) in headers_scope
79+
]
80+
# Reconstruct a raw-like HTTP request stream (approximate; header casing may differ)
81+
http_version = request.scope.get("http_version", "1.1")
82+
target = request.url.path
83+
if request.url.query:
84+
target += f"?{request.url.query}"
85+
start_line = f"{request.method} {target} HTTP/{http_version}\r\n".encode("latin-1", "replace")
86+
header_lines = b"".join(
87+
(name.encode("latin-1", "replace") + b": " + value.encode("latin-1", "replace") + b"\r\n")
88+
for name, value in headers_ordered
89+
)
90+
raw_bytes = start_line + header_lines + b"\r\n" + (body or b"")
4891
item = StoredRequest(
4992
id=_next_id,
5093
method=request.method,
5194
path=request.url.path,
52-
ts=datetime.utcnow().timestamp(),
95+
ts=datetime.now(timezone.utc).timestamp(),
5396
ip=request.client.host if request.client else "",
5497
headers={k: v for k, v in request.headers.items()},
98+
headers_ordered=headers_ordered,
5599
query={k: v for k, v in request.query_params.items()},
56100
body_text=body_text,
57101
body_bytes_b64=(base64.b64encode(body).decode("ascii") if body and body_text is None else None),
58102
body_length=len(body) if body else 0,
103+
raw_request_b64=base64.b64encode(raw_bytes).decode("ascii"),
59104
)
60105
_requests.append(item)
61106
_next_id += 1
107+
# Broadcast summary to websocket listeners
108+
summary = RequestSummary(
109+
id=item.id,
110+
method=item.method,
111+
path=item.path,
112+
ts=item.ts,
113+
ip=item.ip,
114+
content_length=item.body_length,
115+
).model_dump()
116+
await ws_manager.broadcast_json({"type": "new_request", "data": summary})
62117
return Response(content="OK", media_type="text/plain")
63118

64119

@@ -105,9 +160,34 @@ async def delete_all_requests():
105160
async def healthz():
106161
return {"ok": True}
107162

108-
@app.get("/")
109-
async def index():
110-
return {"name": "http-intercepter", "status": "running"}
163+
@app.get("/api/requests/{req_id}/raw")
164+
async def get_request_raw(req_id: int):
165+
for r in _requests:
166+
if r.id == req_id:
167+
if not r.raw_request_b64:
168+
raise HTTPException(status_code=404, detail="No raw data available")
169+
raw = base64.b64decode(r.raw_request_b64)
170+
return Response(
171+
content=raw,
172+
media_type="application/octet-stream",
173+
headers={
174+
"Content-Disposition": f"attachment; filename= request-{r.id}.txt"
175+
},
176+
)
177+
raise HTTPException(status_code=404, detail="Request not found")
178+
179+
180+
@app.websocket("/ws")
181+
async def websocket_endpoint(ws: WebSocket):
182+
await ws_manager.connect(ws)
183+
try:
184+
while True:
185+
# Keep the connection alive; ignore inbound messages
186+
await ws.receive_text()
187+
except WebSocketDisconnect:
188+
ws_manager.disconnect(ws)
189+
except Exception:
190+
ws_manager.disconnect(ws)
111191

112192
# Optionally serve built frontend if present (Docker production)
113193
_dist_dir = os.getenv("FRONTEND_DIST_DIR", "frontend-dist")

backend/tests/test_basic.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,11 @@ async def test_inbound_and_list():
2929
assert res.status_code == 200
3030
full = res.json()
3131
assert full["body_text"] == "hello"
32+
assert "raw_request_b64" in full and isinstance(full["raw_request_b64"], str)
33+
# Download raw
34+
res = await ac.get(f"/api/requests/{rid}/raw")
35+
assert res.status_code == 200
36+
raw = res.content
37+
assert b"HTTP/" in raw and b"\r\n\r\n" in raw and raw.endswith(b"hello")
3238
res = await ac.delete(f"/api/requests/{rid}")
3339
assert res.status_code == 200

frontend/frontend/package-lock.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
},
2020
"dependencies": {
2121
"@tailwindcss/vite": "^4.1.16",
22+
"date-fns": "^4.1.0",
2223
"pinia": "^3.0.3",
2324
"tailwindcss": "^4.1.16",
2425
"vue": "^3.5.22",

frontend/src/App.vue

Lines changed: 5 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,17 @@
11
<script setup lang="ts">
2-
import { useApiStore } from './stores/apiStore';
32
4-
5-
const apiStore = useApiStore();
6-
apiStore.updateRequestList();
73
</script>
84

95
<template>
106
<!-- Header -->
117
<header class="bg-blue-600 text-white p-4 shadow">
12-
<h1 class="text-xl font-semibold">My Page Header</h1>
8+
<h1 class="text-xl font-semibold">Http Interceptor</h1>
9+
<p class="text-sm">Send a request to: <a href="/inbound" class="text-blue-200 hover:underline">/inbound</a></p>
1310
</header>
11+
<main>
12+
<RouterView />
13+
</main>
1414

15-
<!-- Body: Sidebar + Main Content -->
16-
<div class="flex flex-1 overflow-hidden">
17-
18-
<!-- Sidebar -->
19-
<aside class="w-64 bg-white shadow-md p-4 overflow-y-auto">
20-
<h2 class="text-lg font-semibold mb-4">Menu</h2>
21-
<button class="w-full mb-4 px-3 py-2 bg-blue-500 text-white rounded hover:bg-blue-600" @click="apiStore.updateRequestList()">
22-
Refresh Requests
23-
</button>
24-
<ul class="space-y-2">
25-
<template v-for="request in apiStore.requestList" :key="request.id">
26-
<li :class="apiStore.selectedRequest?.id === request.id ? 'bg-blue-200' : ''">
27-
<button class="w-full text-left px-3 py-2 rounded hover:bg-blue-100" @click="apiStore.selectRequest(request.id)">
28-
{{ request.method }} {{ request.path }}
29-
</button>
30-
</li>
31-
</template>
32-
</ul>
33-
</aside>
34-
35-
<!-- Main Content -->
36-
<main class="flex-1 p-6 overflow-y-auto">
37-
<h2 class="text-2xl font-bold mb-4">Main Content</h2>
38-
<template v-if="apiStore.selectedRequest">
39-
<div>
40-
<h3 class="text-xl font-semibold mb-2">Request Details</h3>
41-
<p><strong>Method:</strong> {{ apiStore.selectedRequest.method }}</p>
42-
<p><strong>Path:</strong> {{ apiStore.selectedRequest.path }}</p>
43-
44-
<p><strong>Headers:</strong></p>
45-
<pre class="bg-gray-100 p-2 rounded mb-2">{{ JSON.stringify(apiStore.selectedRequest.headers, null, 2) }}</pre>
46-
<p><strong>Body:</strong></p>
47-
<pre class="bg-gray-100 p-2 rounded">{{ apiStore.selectedRequest.body_text }}</pre>
48-
<p><strong>Body (Base64):</strong></p>
49-
<pre class="bg-gray-100 p-2 rounded">{{ apiStore.selectedRequest.body_bytes_b64 }}</pre>
50-
</div>
51-
</template>
52-
<template v-else>
53-
<p>Please select a request from the sidebar to view details.</p>
54-
</template>
55-
</main>
56-
57-
</div>
5815
</template>
5916

6017
<style scoped></style>

0 commit comments

Comments
 (0)