55
66import  asyncio 
77import  json 
8+ import  logging 
9+ from  collections .abc  import  Callable 
810from  dataclasses  import  dataclass 
911from  typing  import  Any 
1012
1416from  models_library .utils .json_serialization  import  json_dumps 
1517from  servicelib .aiohttp  import  status 
1618from  servicelib .aiohttp .rest_middlewares  import  (
19+     FMSG_INTERNAL_ERROR_USER_FRIENDLY ,
1720    envelope_middleware_factory ,
1821    error_middleware_factory ,
1922)
2023from  servicelib .aiohttp .rest_responses  import  is_enveloped , unwrap_envelope 
24+ from  servicelib .error_codes  import  parse_error_code 
2125
2226
2327@dataclass  
@@ -26,9 +30,13 @@ class Data:
2630    y : str  =  "foo" 
2731
2832
33+ class  SomeUnexpectedError (Exception ):
34+     ...
35+ 
36+ 
2937class  Handlers :
3038    @staticmethod  
31-     async  def  get_health_wrong (request : web .Request ):
39+     async  def  get_health_wrong (_request : web .Request ):
3240        return  {
3341            "name" : __name__ .split ("." )[0 ],
3442            "version" : "1.0" ,
@@ -37,7 +45,7 @@ async def get_health_wrong(request: web.Request):
3745        }
3846
3947    @staticmethod  
40-     async  def  get_health (request : web .Request ):
48+     async  def  get_health (_request : web .Request ):
4149        return  {
4250            "name" : __name__ .split ("." )[0 ],
4351            "version" : "1.0" ,
@@ -46,62 +54,126 @@ async def get_health(request: web.Request):
4654        }
4755
4856    @staticmethod  
49-     async  def  get_dict (request : web .Request ):
57+     async  def  get_dict (_request : web .Request ):
5058        return  {"x" : 3 , "y" : "3" }
5159
5260    @staticmethod  
53-     async  def  get_envelope (request : web .Request ):
61+     async  def  get_envelope (_request : web .Request ):
5462        data  =  {"x" : 3 , "y" : "3" }
5563        return  {"error" : None , "data" : data }
5664
5765    @staticmethod  
58-     async  def  get_list (request : web .Request ):
66+     async  def  get_list (_request : web .Request ):
5967        return  [{"x" : 3 , "y" : "3" }] *  3 
6068
6169    @staticmethod  
62-     async  def  get_attobj ( request : web .Request ):
70+     async  def  get_obj ( _request : web .Request ):
6371        return  Data (3 , "3" )
6472
6573    @staticmethod  
66-     async  def  get_string (request : web .Request ):
74+     async  def  get_string (_request : web .Request ):
6775        return  "foo" 
6876
6977    @staticmethod  
70-     async  def  get_number (request : web .Request ):
78+     async  def  get_number (_request : web .Request ):
7179        return  3 
7280
7381    @staticmethod  
74-     async  def  get_mixed (request : web .Request ):
82+     async  def  get_mixed (_request : web .Request ):
7583        return  [{"x" : 3 , "y" : "3" , "z" : [Data (3 , "3" )] *  2 }] *  3 
7684
7785    @classmethod  
78-     def  get (cls , suffix ):
86+     def  returns_value (cls , suffix ):
7987        handlers  =  cls ()
8088        coro  =  getattr (handlers , "get_"  +  suffix )
8189        loop  =  asyncio .get_event_loop ()
82-         data  =  loop .run_until_complete (coro (None ))
90+         returned_value  =  loop .run_until_complete (coro (None ))
91+         return  json .loads (json_dumps (returned_value ))
92+ 
93+     EXPECTED_RAISE_UNEXPECTED_REASON  =  "Unexpected error" 
94+ 
95+     @classmethod  
96+     async  def  raise_exception (cls , request : web .Request ):
97+         exc_name  =  request .query .get ("exc" )
98+         match  exc_name :
99+             case  NotImplementedError .__name__ :
100+                 raise  NotImplementedError 
101+             case  asyncio .TimeoutError .__name__ :
102+                 raise  asyncio .TimeoutError 
103+             case  web .HTTPOk .__name__ :
104+                 raise  web .HTTPOk   # 2XX 
105+             case  web .HTTPUnauthorized .__name__ :
106+                 raise  web .HTTPUnauthorized   # 4XX 
107+             case  web .HTTPServiceUnavailable .__name__ :
108+                 raise  web .HTTPServiceUnavailable   # 5XX 
109+             case  _:  # unexpected 
110+                 raise  SomeUnexpectedError (cls .EXPECTED_RAISE_UNEXPECTED_REASON )
111+ 
112+     @staticmethod  
113+     async  def  raise_error (_request : web .Request ):
114+         raise  web .HTTPNotFound 
83115
84-         return  json .loads (json_dumps (data ))
116+     @staticmethod  
117+     async  def  raise_error_with_reason (_request : web .Request ):
118+         raise  web .HTTPNotFound (reason = "I did not find it" )
119+ 
120+     @staticmethod  
121+     async  def  raise_success (_request : web .Request ):
122+         raise  web .HTTPOk 
123+ 
124+     @staticmethod  
125+     async  def  raise_success_with_reason (_request : web .Request ):
126+         raise  web .HTTPOk (reason = "I'm ok" )
127+ 
128+     @staticmethod  
129+     async  def  raise_success_with_text (_request : web .Request ):
130+         # NOTE: explicitly NOT enveloped! 
131+         raise  web .HTTPOk (reason = "I'm ok" , text = json .dumps ({"ok" : True }))
85132
86133
87134@pytest .fixture  
88- def  client (event_loop , aiohttp_client ):
135+ def  client (
136+     event_loop : asyncio .AbstractEventLoop ,
137+     aiohttp_client : Callable ,
138+     monkeypatch : pytest .MonkeyPatch ,
139+ ):
140+     monkeypatch .setenv ("SC_BUILD_TARGET" , "production" )
141+ 
89142    app  =  web .Application ()
90143
91144    # routes 
92145    app .router .add_routes (
93146        [
94-             web .get ("/v1/health" , Handlers .get_health , name = "get_health" ),
95-             web .get ("/v1/dict" , Handlers .get_dict , name = "get_dict" ),
96-             web .get ("/v1/envelope" , Handlers .get_envelope , name = "get_envelope" ),
97-             web .get ("/v1/list" , Handlers .get_list , name = "get_list" ),
98-             web .get ("/v1/attobj" , Handlers .get_attobj , name = "get_attobj" ),
99-             web .get ("/v1/string" , Handlers .get_string , name = "get_string" ),
100-             web .get ("/v1/number" , Handlers .get_number , name = "get_number" ),
101-             web .get ("/v1/mixed" , Handlers .get_mixed , name = "get_mixed" ),
147+             web .get (path , handler , name = handler .__name__ )
148+             for  path , handler  in  [
149+                 ("/v1/health" , Handlers .get_health ),
150+                 ("/v1/dict" , Handlers .get_dict ),
151+                 ("/v1/envelope" , Handlers .get_envelope ),
152+                 ("/v1/list" , Handlers .get_list ),
153+                 ("/v1/obj" , Handlers .get_obj ),
154+                 ("/v1/string" , Handlers .get_string ),
155+                 ("/v1/number" , Handlers .get_number ),
156+                 ("/v1/mixed" , Handlers .get_mixed ),
157+                 # custom use cases 
158+                 ("/v1/raise_exception" , Handlers .raise_exception ),
159+                 ("/v1/raise_error" , Handlers .raise_error ),
160+                 ("/v1/raise_error_with_reason" , Handlers .raise_error_with_reason ),
161+                 ("/v1/raise_success" , Handlers .raise_success ),
162+                 ("/v1/raise_success_with_reason" , Handlers .raise_success_with_reason ),
163+                 ("/v1/raise_success_with_text" , Handlers .raise_success_with_text ),
164+             ]
102165        ]
103166    )
104167
168+     app .router .add_routes (
169+         [
170+             web .get (
171+                 "/free/raise_exception" ,
172+                 Handlers .raise_exception ,
173+                 name = "raise_exception_without_middleware" ,
174+             )
175+         ]
176+     )
105177    # middlewares 
106178    app .middlewares .append (error_middleware_factory (api_version = "/v1" ))
107179    app .middlewares .append (envelope_middleware_factory (api_version = "/v1" ))
@@ -112,14 +184,14 @@ def client(event_loop, aiohttp_client):
112184@pytest .mark .parametrize ( 
113185    "path,expected_data" , 
114186    [ 
115-         ("/health" , Handlers .get ("health" )), 
116-         ("/dict" , Handlers .get ("dict" )), 
117-         ("/envelope" , Handlers .get ("envelope" )["data" ]), 
118-         ("/list" , Handlers .get ("list" )), 
119-         ("/attobj " , Handlers .get ( "attobj " )), 
120-         ("/string" , Handlers .get ("string" )), 
121-         ("/number" , Handlers .get ("number" )), 
122-         ("/mixed" , Handlers .get ("mixed" )), 
187+         ("/health" , Handlers .returns_value ("health" )), 
188+         ("/dict" , Handlers .returns_value ("dict" )), 
189+         ("/envelope" , Handlers .returns_value ("envelope" )["data" ]), 
190+         ("/list" , Handlers .returns_value ("list" )), 
191+         ("/obj " , Handlers .returns_value ( "obj " )), 
192+         ("/string" , Handlers .returns_value ("string" )), 
193+         ("/number" , Handlers .returns_value ("number" )), 
194+         ("/mixed" , Handlers .returns_value ("mixed" )), 
123195    ], 
124196) 
125197async  def  test_envelope_middleware (path : str , expected_data : Any , client : TestClient ):
@@ -133,7 +205,7 @@ async def test_envelope_middleware(path: str, expected_data: Any, client: TestCl
133205    assert  data  ==  expected_data 
134206
135207
136- async  def  test_404_not_found (client : TestClient ):
208+ async  def  test_404_not_found_when_entrypoint_not_exposed (client : TestClient ):
137209    response  =  await  client .get ("/some-invalid-address-outside-api" )
138210    payload  =  await  response .text ()
139211    assert  response .status  ==  status .HTTP_404_NOT_FOUND , payload 
@@ -147,3 +219,62 @@ async def test_404_not_found(client: TestClient):
147219    data , error  =  unwrap_envelope (payload )
148220    assert  error 
149221    assert  not  data 
222+ 
223+ 
224+ async  def  test_raised_unhandled_exception (
225+     client : TestClient , caplog : pytest .LogCaptureFixture 
226+ ):
227+     with  caplog .at_level (logging .ERROR ):
228+         response  =  await  client .get ("/v1/raise_exception" )
229+ 
230+         # respond the client with 500 
231+         assert  response .status  ==  status .HTTP_500_INTERNAL_SERVER_ERROR 
232+ 
233+         # response model 
234+         data , error  =  unwrap_envelope (await  response .json ())
235+         assert  not  data 
236+         assert  error 
237+ 
238+         # user friendly message with OEC reference 
239+         assert  "OEC"  in  error ["message" ]
240+         parsed_oec  =  parse_error_code (error ["message" ]).pop ()
241+         assert  FMSG_INTERNAL_ERROR_USER_FRIENDLY .format (parsed_oec ) ==  error ["message" ]
242+ 
243+         # avoids details 
244+         assert  not  error .get ("errors" )
245+         assert  not  error .get ("logs" )
246+ 
247+         # - log sufficient information to diagnose the issue 
248+         # 
249+         # ERROR    servicelib.aiohttp.rest_middlewares:rest_middlewares.py:75 We apologize ... [OEC:128594540599840]. 
250+         # { 
251+         # "exception_details": "Unexpected error", 
252+         # "error_code": "OEC:128594540599840", 
253+         # "context": { 
254+         #     "request.remote": "127.0.0.1", 
255+         #     "request.method": "GET", 
256+         #     "request.path": "/v1/raise_exception" 
257+         # }, 
258+         # "tip": null 
259+         # } 
260+         # Traceback (most recent call last): 
261+         # File "/osparc-simcore/packages/service-library/src/servicelib/aiohttp/rest_middlewares.py", line 94, in _middleware_handler 
262+         #     return await handler(request) 
263+         #         ^^^^^^^^^^^^^^^^^^^^^^ 
264+         # File "/osparc-simcore/packages/service-library/src/servicelib/aiohttp/rest_middlewares.py", line 186, in _middleware_handler 
265+         #     resp = await handler(request) 
266+         #         ^^^^^^^^^^^^^^^^^^^^^^ 
267+         # File "/osparc-simcore/packages/service-library/tests/aiohttp/test_rest_middlewares.py", line 109, in raise_exception 
268+         #     raise SomeUnexpectedError(cls.EXPECTED_RAISE_UNEXPECTED_REASON) 
269+         # tests.aiohttp.test_rest_middlewares.SomeUnexpectedError: Unexpected error 
270+ 
271+         assert  response .method  in  caplog .text 
272+         assert  response .url .path  in  caplog .text 
273+         assert  "exception_details"  in  caplog .text 
274+         assert  "request.remote"  in  caplog .text 
275+         assert  "context"  in  caplog .text 
276+         assert  SomeUnexpectedError .__name__  in  caplog .text 
277+         assert  Handlers .EXPECTED_RAISE_UNEXPECTED_REASON  in  caplog .text 
278+ 
279+         # log OEC 
280+         assert  "OEC:"  in  caplog .text 
0 commit comments