11from __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
55import base64
6- from fastapi import FastAPI , Request , Response , HTTPException
6+ from fastapi import FastAPI , Request , Response , HTTPException , WebSocket , WebSocketDisconnect
77from fastapi .staticfiles import StaticFiles
88import os
99from 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" ])
4168async 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():
105160async 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" )
0 commit comments