1- """Utils to check, convert and compose server responses for the RESTApi"""
1+ from typing import Any , Final , TypedDict
22
3- import inspect
4- from typing import Any
5-
6- from aiohttp import web , web_exceptions
7- from aiohttp .web_exceptions import HTTPError , HTTPException
3+ from aiohttp import web
4+ from aiohttp .web_exceptions import HTTPError
85from common_library .error_codes import ErrorCodeStr
96from common_library .json_serialization import json_dumps
107from models_library .rest_error import ErrorGet , ErrorItemType
1310from ..mimetype_constants import MIMETYPE_APPLICATION_JSON
1411from ..rest_constants import RESPONSE_MODEL_POLICY
1512from ..rest_responses import is_enveloped
16- from ..status_codes_utils import get_code_description
13+ from ..status_codes_utils import get_code_description , is_error
14+
15+
16+ class EnvelopeDict (TypedDict ):
17+ data : Any
18+ error : Any
1719
1820
1921def wrap_as_envelope (
20- data : Any = None ,
21- error : Any = None ,
22- ) -> dict [ str , Any ] :
22+ data : Any | None = None ,
23+ error : Any | None = None ,
24+ ) -> EnvelopeDict :
2325 return {"data" : data , "error" : error }
2426
2527
26- # RESPONSES FACTORIES -------------------------------
28+ def create_data_response (data : Any , * , status : int = HTTP_200_OK ) -> web .Response :
29+ """Creates a JSON response with the given data and ensures it is wrapped in an envelope."""
2730
31+ assert ( # nosec
32+ is_error (status ) is False
33+ ), f"Expected a non-error status code, got { status = } "
2834
29- def create_data_response (
30- data : Any , * , skip_internal_error_details = False , status = HTTP_200_OK
31- ) -> web .Response :
32- response = None
33- try :
34- payload = wrap_as_envelope (data ) if not is_enveloped (data ) else data
35+ enveloped_payload = wrap_as_envelope (data ) if not is_enveloped (data ) else data
36+ return web .json_response (enveloped_payload , dumps = json_dumps , status = status )
3537
36- response = web .json_response (payload , dumps = json_dumps , status = status )
37- except (TypeError , ValueError ) as err :
38- response = exception_to_response (
39- create_http_error (
40- [
41- err ,
42- ],
43- str (err ),
44- web .HTTPInternalServerError ,
45- skip_internal_error_details = skip_internal_error_details ,
46- )
47- )
48- return response
38+
39+ MAX_STATUS_MESSAGE_LENGTH : Final [int ] = 100
40+
41+
42+ def safe_status_message (
43+ message : str | None , max_length : int = MAX_STATUS_MESSAGE_LENGTH
44+ ) -> str | None :
45+ """
46+ Truncates a status-message (i.e. `reason` in HTTP errors) to a maximum length, replacing newlines with spaces.
47+
48+ If the message is longer than max_length, it will be truncated and "..." will be appended.
49+
50+ This prevents issues such as:
51+ - `aiohttp.http_exceptions.LineTooLong`: 400, message: Got more than 8190 bytes when reading Status line is too long.
52+ - Multiline not allowed in HTTP reason attribute (aiohttp now raises ValueError).
53+
54+ See:
55+ - When to use http status and/or text messages https://github.com/ITISFoundation/osparc-simcore/pull/7760
56+ - [RFC 9112, Section 4.1: HTTP/1.1 Message Syntax and Routing](https://datatracker.ietf.org/doc/html/rfc9112#section-4.1) (status line length limits)
57+ - [RFC 9110, Section 15.5: Reason Phrase](https://datatracker.ietf.org/doc/html/rfc9110#section-15.5) (reason phrase definition)
58+ """
59+ assert max_length > 0 # nosec
60+
61+ if not message :
62+ return None
63+
64+ flat_message = message .replace ("\n " , " " )
65+ if len (flat_message ) <= max_length :
66+ return flat_message
67+
68+ # Truncate and add ellipsis
69+ return flat_message [: max_length - 3 ] + "..."
4970
5071
5172def create_http_error (
5273 errors : list [Exception ] | Exception ,
53- reason : str | None = None ,
74+ error_message : str | None = None ,
5475 http_error_cls : type [HTTPError ] = web .HTTPInternalServerError ,
5576 * ,
77+ status_reason : str | None = None ,
5678 skip_internal_error_details : bool = False ,
5779 error_code : ErrorCodeStr | None = None ,
5880) -> HTTPError :
@@ -64,14 +86,18 @@ def create_http_error(
6486 if not isinstance (errors , list ):
6587 errors = [errors ]
6688
67- is_internal_error : bool = http_error_cls == web .HTTPInternalServerError
68- default_message = reason or get_code_description (http_error_cls .status_code )
89+ is_internal_error = bool (http_error_cls == web .HTTPInternalServerError )
90+
91+ status_reason = status_reason or get_code_description (http_error_cls .status_code )
92+ error_message = error_message or get_code_description (http_error_cls .status_code )
93+
94+ assert len (status_reason ) < MAX_STATUS_MESSAGE_LENGTH # nosec
6995
7096 if is_internal_error and skip_internal_error_details :
7197 error = ErrorGet .model_validate (
7298 {
7399 "status" : http_error_cls .status_code ,
74- "message" : default_message ,
100+ "message" : error_message ,
75101 "support_id" : error_code ,
76102 }
77103 )
@@ -81,7 +107,7 @@ def create_http_error(
81107 {
82108 "errors" : items , # NOTE: deprecated!
83109 "status" : http_error_cls .status_code ,
84- "message" : default_message ,
110+ "message" : error_message ,
85111 "support_id" : error_code ,
86112 }
87113 )
@@ -92,7 +118,7 @@ def create_http_error(
92118 )
93119
94120 return http_error_cls (
95- reason = safe_status_message (reason ),
121+ reason = safe_status_message (status_reason ),
96122 text = json_dumps (
97123 payload ,
98124 ),
@@ -110,47 +136,3 @@ def exception_to_response(exc: HTTPError) -> web.Response:
110136 reason = exc .reason ,
111137 text = exc .text ,
112138 )
113-
114-
115- # Inverse map from code to HTTPException classes
116- def _collect_http_exceptions (exception_cls : type [HTTPException ] = HTTPException ):
117- def _pred (obj ) -> bool :
118- return (
119- inspect .isclass (obj )
120- and issubclass (obj , exception_cls )
121- and getattr (obj , "status_code" , 0 ) > 0
122- )
123-
124- found : list [tuple [str , Any ]] = inspect .getmembers (web_exceptions , _pred )
125- assert found # nosec
126-
127- http_statuses = {cls .status_code : cls for _ , cls in found }
128- assert len (http_statuses ) == len (found ), "No duplicates" # nosec
129-
130- return http_statuses
131-
132-
133- def safe_status_message (message : str | None , max_length : int = 50 ) -> str | None :
134- """
135- Truncates a status-message (i.e. `reason` in HTTP errors) to a maximum length, replacing newlines with spaces.
136-
137- If the message is longer than max_length, it will be truncated and "..." will be appended.
138-
139- This prevents issues such as:
140- - `aiohttp.http_exceptions.LineTooLong`: 400, message: Got more than 8190 bytes when reading Status line is too long.
141- - Multiline not allowed in HTTP reason attribute (aiohttp now raises ValueError).
142-
143- See:
144- - When to use http status and/or text messages https://github.com/ITISFoundation/osparc-simcore/pull/7760
145- - [RFC 9112, Section 4.1: HTTP/1.1 Message Syntax and Routing](https://datatracker.ietf.org/doc/html/rfc9112#section-4.1) (status line length limits)
146- - [RFC 9110, Section 15.5: Reason Phrase](https://datatracker.ietf.org/doc/html/rfc9110#section-15.5) (reason phrase definition)
147- """
148- if not message :
149- return None
150-
151- flat_message = message .replace ("\n " , " " )
152- if len (flat_message ) <= max_length :
153- return flat_message
154-
155- # Truncate and add ellipsis
156- return flat_message [: max_length - 3 ] + "..."
0 commit comments