22
33import logging
44from importlib .metadata import version
5- from typing import TYPE_CHECKING , AbstractSet , Any , Mapping , Type , TypeVar
6- from uuid import UUID
5+ from ipaddress import IPv4Address
6+ from typing import TYPE_CHECKING , Literal
7+ from weakref import WeakSet
78
9+ import aiofiles
810import aiohttp_cors
911from aiohttp import web
10- from pydantic import BaseModel , ValidationError
12+ from pydantic import Field , PrivateAttr
13+
14+ from qseek .base import Model
1115
1216if TYPE_CHECKING :
17+ from qseek .models .detection import EventDetection
1318 from qseek .search import Search
1419
1520logger = logging .getLogger (__name__ )
16- logger .setLevel (logging .WARNING )
17- MAX_TRIES = 5
1821
19- TBaseModel = TypeVar ("TBaseModel" , bound = BaseModel )
20- AbstractSetIntStr = AbstractSet [int | str ]
21- MappingIntStrAny = Mapping [int | str , Any ]
22-
23-
24- class DetectionRequest (BaseModel ):
25- uid : UUID
26-
27-
28- async def parse_pydantic_request (
29- request : web .Request , model : Type [TBaseModel ]
30- ) -> TBaseModel :
31- """Parse a aiohttp request and return a pydantic model.
32-
33- Raises:
34- web.HTTPMethodNotAllowed: If method is not GET/POST/PUT
35- web.HTTPBadRequest: _description_
36- web.HTTPError: _description_
37-
38- Returns:
39- TBaseModel: Parsed pydantic model.
40- """
41- try :
42- if request .method == "GET" :
43- query = {}
44- for key , value in request .query .items ():
45- if key .endswith ("[]" ):
46- key = key .strip ("[]" )
47- if key not in query :
48- query [key ] = []
49- query [key ].append (value )
50- else :
51- query [key ] = value
52-
53- data = model .model_validate (query )
54- elif request .method in ("POST" , "PUT" ):
55- data = model .model_validate (await request .post ())
56- else :
57- raise web .HTTPMethodNotAllowed (
58- request .method , allowed_methods = ("GET" , "POST" , "PUT" )
59- )
60- except ValidationError as exc :
61- raise web .HTTPBadRequest (
62- text = exc .json (), content_type = "application/json"
63- ) from exc
64- except Exception as exc :
65- raise web .HTTPError (text = "Cannot parse request" ) from exc
66- return data
67-
68-
69- def pydantic_response (
70- model : BaseModel ,
71- headers : dict [str , Any ] | None = None ,
72- exclude : dict | None = None ,
73- ) -> web .Response :
74- try :
75- return web .json_response (
76- text = model .model_dump_json (exclude = exclude ).replace ("NaN" , "null" ),
77- headers = headers ,
78- )
79- except TypeError as exc :
80- return web .HTTPServerError (reason = str (exc ))
22+ MAX_TRIES = 5
8123
8224
83- class WebServer :
84- def __init__ (self , search : Search ) -> None :
85- self .search = search
86- self .app = web .Application (logger = logger )
25+ class WebServer (Model ):
26+ host : IPv4Address = Field (
27+ default = IPv4Address ("0.0.0.0" ),
28+ description = "Host to bind the server to. Default is all interfaces." ,
29+ )
30+ port : int | Literal ["auto" ] = Field (
31+ default = "auto" ,
32+ description = "Port to bind the server to. Default is 'auto', which tries to "
33+ "bind to port 19000 and increments if the port is already in use." ,
34+ )
35+
36+ _search : Search = PrivateAttr ()
37+ _app : web .Application = PrivateAttr ()
38+ _site : web .TCPSite | None = PrivateAttr (default = None )
39+ _open_websockets : WeakSet [web .WebSocketResponse ] = PrivateAttr (
40+ default_factory = WeakSet
41+ )
42+
43+ def prepare (self , search : Search ) -> None :
44+ self ._search = search
45+ self ._app = web .Application (logger = logger )
8746 self .add_routes ()
8847
8948 def add_routes (self ) -> None :
90- router = self .app .router
49+ router = self ._app .router
9150 router .add_get ("/" , self .get_index )
9251 router .add_get ("/api/v1/detections" , self .get_detections )
52+ router .add_get ("/api/v1/receivers" , self .get_receivers )
9353 router .add_get ("/api/v1/search" , self .get_search )
94- router .add_get (r"/api/v1/detection/{uid}" , self .get_detection )
9554
9655 # Configure default CORS settings.
9756 cors = aiohttp_cors .setup (
98- self .app ,
57+ self ._app ,
9958 defaults = {
10059 "*" : aiohttp_cors .ResourceOptions (
10160 allow_credentials = True ,
@@ -117,34 +76,80 @@ async def get_index(self, request: web.Request) -> web.Response:
11776 qseek_version = version ("qseek" )
11877 return web .Response (text = f"{ qseek_version } HTTP server running..." )
11978
120- async def get_detections (self , request : web .Request ) -> web .Response :
121- return pydantic_response (
122- self .search ._catalog ,
123- exclude = {"detections" : {"__all__" : {"octree" , "stations" }}},
79+ async def get_detections (
80+ self , request : web .Request , n_detections : int = - 1
81+ ) -> web .Response :
82+ async with aiofiles .open (self ._search ._catalog .detections_file , "r" ) as f :
83+ lines = await f .readlines ()
84+ data = "{" + lines + "}"
85+ await web .Response (text = data , content_type = "application/json" )
86+
87+ async def get_receivers (
88+ self , request : web .Request , n_detections : int = - 1
89+ ) -> web .Response :
90+ async with aiofiles .open (self ._search ._catalog .receivers_file , "r" ) as f :
91+ lines = await f .readlines ()
92+ data = "{" + lines + "}"
93+ await web .Response (text = data , content_type = "application/json" )
94+
95+ async def get_search (self , request : web .Request ) -> web .Response :
96+ return web .Response (
97+ self ._search .model_dump_json (),
98+ content_type = "application/json" ,
12499 )
125100
126- async def get_detection (self , request : web .Request ) -> web .Response :
127- req = await parse_pydantic_request (request , model = DetectionRequest )
128- try :
129- detection = self .search ._catalog .get (req .uid )
130- except KeyError :
131- return web .HTTPNotFound ()
132- return pydantic_response (detection )
101+ async def websocket_handler (self , request : web .Request ) -> web .WebSocketResponse :
102+ ws = web .WebSocketResponse ()
103+ await ws .prepare (request )
104+ self ._open_websockets .add (ws )
105+ logger .info ("New WebSocket connection established: %s" , request .remote )
106+
107+ for msg in ws :
108+ if msg .type == web .WSMsgType .TEXT :
109+ # Handle incoming messages from the client if needed
110+ pass
111+ elif msg .type == web .WSMsgType .ERROR :
112+ logger .error (
113+ "WebSocket connection closed with exception %s" , ws .exception ()
114+ )
133115
134- async def get_search (self , request : web .Request ) -> web .Response :
135- return pydantic_response (self .search )
116+ async def new_detections (self , detections : list [EventDetection ]) -> None :
117+ if not self ._open_websockets :
118+ return
119+
120+ data = {
121+ "type" : "new_detections" ,
122+ "detections" : [d .model_dump (exclude = {"receivers" }) for d in detections ],
123+ "receivers" : [d .receivers .model_dump () for d in detections ],
124+ }
125+
126+ for ws in self ._open_websockets :
127+ await ws .send_json (data )
136128
137129 async def start (self ) -> None :
138- runner = web .AppRunner (self .app )
130+ runner = web .AppRunner (self ._app )
139131 await runner .setup ()
140132
141- port = 3445
133+ port = 19000 if self . port == "auto" else self . port
142134
143135 for _ in range (MAX_TRIES ):
144136 try :
145- site = web .TCPSite (runner , "0.0.0.0" , port )
137+ site = web .TCPSite (runner , str ( self . host ) , port )
146138 await site .start ()
147- logger .info ("started on http://0.0.0.0:%d" , port )
139+ logger .info ("started on http://%s:%d" , self .host , port )
140+ self ._site = site
148141 break
149142 except OSError :
143+ if self .port != "auto" :
144+ raise
145+ logger .warning ("port %d is in use, trying next port..." , port )
150146 port += 1
147+ else :
148+ raise OSError (f"Could not bind to a port after { MAX_TRIES } tries" )
149+
150+ async def stop (self ) -> None :
151+ logger .info ("shutting down HTTP server..." )
152+ if self ._site :
153+ await self ._site .stop ()
154+ await self ._app .shutdown ()
155+ await self ._app .cleanup ()
0 commit comments