diff --git a/requirements.txt b/requirements.txt index 3adaba7a..5c35ca6f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,17 @@ -connexion[swagger-ui] >= 2.6.0, <3 -swagger-ui-bundle >= 0.0.2 -python_dateutil >= 2.6.0 -setuptools >= 21.0.0 -Flask == 2.1.1 -httpx == 0.27.2 -APScheduler >= 3.10.0 -aenum == 3.1.15 -SQLAlchemy >= 2.0.23 -psycopg >= 3.1.0 -psycopg-binary -Flask_Testing==0.8.1 -python-keycloak == 4.7.0 -pyjwt == 2.3.0 -cryptography == 3.4.8 -coverage>=7.3.2 -pydantic >= 2.5.3 -tenacity == 9.1.2 \ No newline at end of file +connexion[swagger-ui] == 2.15.1 +swagger-ui-bundle == 1.1.0 +python_dateutil == 2.9.0 +setuptools == 80.10.2 +Flask == 2.3.3 +httpx == 0.28.1 +APScheduler == 3.11.2 +aenum == 3.1.16 +SQLAlchemy == 2.0.46 +psycopg == 3.3.2 +Flask_Testing == 0.8.1 +python-keycloak == 7.0.3 +pyjwt == 2.11.0 +cryptography == 46.0.4 +coverage == 7.13.3 +pydantic == 2.12.5 +tenacity == 9.1.2 diff --git a/server/.openapi-generator-ignore b/server/.openapi-generator-ignore index eadafee9..998b92e3 100644 --- a/server/.openapi-generator-ignore +++ b/server/.openapi-generator-ignore @@ -26,6 +26,7 @@ .openapi-generator-ignore fleetv2_http_api/controllers/security_controller.py +fleetv2_http_api/encoder.py requirements.txt setup.py README.md diff --git a/server/app.py b/server/app.py index 2417cb0f..9aac087b 100644 --- a/server/app.py +++ b/server/app.py @@ -17,7 +17,7 @@ set_status_wait_timeout_s, ) -from server.fleetv2_http_api.encoder import JSONEncoder # type: ignore +from server.fleetv2_http_api.encoder import CustomJSONProvider # type: ignore from server.database.security import _AdminBase as _AdminBase # type: ignore # Keep the following import to make all the tables be created by the get_test_app function @@ -26,8 +26,9 @@ def get_app() -> _connexion.FlaskApp: app = _connexion.App(__name__, specification_dir="fleetv2_http_api/openapi/") - app.app.json_encoder = JSONEncoder app.add_api("openapi.yaml") + # Flask 2.3+ uses json provider instead of json_encoder + app.app.json = CustomJSONProvider(app.app) return app diff --git a/server/fleetv2_http_api/encoder.py b/server/fleetv2_http_api/encoder.py index 337aba74..731c90d0 100644 --- a/server/fleetv2_http_api/encoder.py +++ b/server/fleetv2_http_api/encoder.py @@ -1,19 +1,34 @@ -from connexion.apps.flask_app import FlaskJSONEncoder +from enum import Enum +from typing import Any + +from flask.json.provider import DefaultJSONProvider from server.fleetv2_http_api.models.base_model import Model -class JSONEncoder(FlaskJSONEncoder): - include_nulls = False +def _serialize(obj: Any, include_nulls: bool = False) -> Any: + """Recursively serialize Model objects and Enums to JSON-compatible types.""" + if isinstance(obj, Model): + result = {} + for attr in obj.openapi_types: + value = getattr(obj, attr) + if value is None and not include_nulls: + continue + key = obj.attribute_map[attr] + result[key] = _serialize(value, include_nulls) + return result + elif isinstance(obj, Enum): + return obj.value + elif isinstance(obj, list): + return [_serialize(item, include_nulls) for item in obj] + elif isinstance(obj, dict): + return {k: _serialize(v, include_nulls) for k, v in obj.items()} + return obj + + +class CustomJSONProvider(DefaultJSONProvider): + """Custom JSON provider for Flask 2.3+ that handles OpenAPI models.""" - def default(self, o): - if isinstance(o, Model): - dikt = {} - for attr in o.openapi_types: - value = getattr(o, attr) - if value is None and not self.include_nulls: - continue - attr = o.attribute_map[attr] - dikt[attr] = value - return dikt - return FlaskJSONEncoder.default(self, o) + def dumps(self, obj: Any, **kwargs: Any) -> str: + obj = _serialize(obj) + return super().dumps(obj, **kwargs) diff --git a/tests_integration/messages/test_since_parameter.py b/tests_integration/messages/test_since_parameter.py index 72856594..744507b2 100644 --- a/tests_integration/messages/test_since_parameter.py +++ b/tests_integration/messages/test_since_parameter.py @@ -157,11 +157,21 @@ def setUp(self, mock_timestamp: Mock) -> None: def test_thread_waits_for_status_with_newer_timestamp_than_since_parameter( self, mock_timestamp: Mock ): - with _Executor(max_workers=2) as executor, self.app.app.test_client() as c: - future = executor.submit(c.get, "/status/test_company/test_car?since=50&wait=True") + status_5 = self.status_5 + + def get_status(): + with self.app.app.test_client() as c: + return c.get("/status/test_company/test_car?since=50&wait=True") + + def post_status(): + with self.app.app.test_client() as c: + return c.post("/status/test_company/test_car", json=[status_5]) + + with _Executor(max_workers=2) as executor: + future = executor.submit(get_status) mock_timestamp.return_value = 60 time.sleep(0.01) - executor.submit(c.post, "/status/test_company/test_car", json=[self.status_5]) + executor.submit(post_status) response = future.result() self.assertEqual(response.status_code, 200) self.assertEqual(response.json[0]["timestamp"], 60) # type: ignore @@ -170,11 +180,21 @@ def test_thread_waits_for_status_with_newer_timestamp_than_since_parameter( def test_sending_status_with_timestamp_older_than_since_parameter_will_not_resume_waiting_thread( self, mock_timestamp: Mock ): - with _Executor(max_workers=2) as executor, self.app.app.test_client() as c: - future = executor.submit(c.get, "/status/test_company/test_car?since=100&wait=True") + status_5 = self.status_5 + + def get_status(): + with self.app.app.test_client() as c: + return c.get("/status/test_company/test_car?since=100&wait=True") + + def post_status(): + with self.app.app.test_client() as c: + return c.post("/status/test_company/test_car", json=[status_5]) + + with _Executor(max_workers=2) as executor: + future = executor.submit(get_status) mock_timestamp.return_value = 80 time.sleep(0.01) - executor.submit(c.post, "/status/test_company/test_car", json=[self.status_5]) + executor.submit(post_status) response = future.result() self.assertEqual(response.status_code, 200) self.assertEqual(response.json, []) @@ -220,11 +240,21 @@ def setUp(self, mock_timestamp: Mock) -> None: def test_thread_waits_for_command_with_newer_timestamp_than_since_parameter( self, mock_timestamp: Mock ): - with _Executor(max_workers=2) as executor, self.app.app.test_client() as c: - future = executor.submit(c.get, "/command/test_company/test_car?since=50&wait=True") + command_4 = self.command_4 + + def get_command(): + with self.app.app.test_client() as c: + return c.get("/command/test_company/test_car?since=50&wait=True") + + def post_command(): + with self.app.app.test_client() as c: + return c.post("/command/test_company/test_car", json=[command_4]) + + with _Executor(max_workers=2) as executor: + future = executor.submit(get_command) mock_timestamp.return_value = 55 time.sleep(0.01) - executor.submit(c.post, "/command/test_company/test_car", json=[self.command_4]) + executor.submit(post_command) response = future.result() self.assertEqual(response.status_code, 200) self.assertEqual(response.json[0]["timestamp"], 55) # type: ignore @@ -233,11 +263,21 @@ def test_thread_waits_for_command_with_newer_timestamp_than_since_parameter( def test_sending_command_with_timestamp_older_than_since_parameter_will_not_resume_waiting_thread( self, mock_timestamp: Mock ): - with _Executor(max_workers=2) as executor, self.app.app.test_client() as c: - future = executor.submit(c.get, "/command/test_company/test_car?since=100&wait=True") + command_4 = self.command_4 + + def get_command(): + with self.app.app.test_client() as c: + return c.get("/command/test_company/test_car?since=100&wait=True") + + def post_command(): + with self.app.app.test_client() as c: + return c.post("/command/test_company/test_car", json=[command_4]) + + with _Executor(max_workers=2) as executor: + future = executor.submit(get_command) mock_timestamp.return_value = 80 time.sleep(0.01) - executor.submit(c.post, "/command/test_company/test_car", json=[self.command_4]) + executor.submit(post_command) response = future.result() self.assertEqual(response.status_code, 200) self.assertEqual(response.json, []) diff --git a/tests_integration/wait_mechanism/test_commands.py b/tests_integration/wait_mechanism/test_commands.py index 169a841e..9b440d3a 100644 --- a/tests_integration/wait_mechanism/test_commands.py +++ b/tests_integration/wait_mechanism/test_commands.py @@ -27,11 +27,19 @@ def setUp(self) -> None: def test_if_status_and_relevant_command_is_sent_before_timeout_code_200_and_empty_list_are_returned( self, ): - with _Executor(max_workers=2) as executor, self.app.app.test_client() as c: + def get_command(): + with self.app.app.test_client() as c: + return c.get("/command/test_company/test_car?wait=True") + + def post_command(): + with self.app.app.test_client() as c: + return c.post("/command/test_company/test_car", json=[self.command]) + + with _Executor(max_workers=2) as executor: time.sleep(0.05) - future = executor.submit(c.get, "/command/test_company/test_car?wait=True") + future = executor.submit(get_command) time.sleep(0.05) - executor.submit(c.post, "/command/test_company/test_car", json=[self.command]) + executor.submit(post_command) response = future.result() self.assertEqual(response.status_code, 200) self.assertEqual(response.json[0]["payload"]["data"]["instruction"], "start") @@ -39,8 +47,12 @@ def test_if_status_and_relevant_command_is_sent_before_timeout_code_200_and_empt def test_if_status_but_no_command_is_sent_before_timeout_code_200_and_empty_list_are_returned( self, ): - with _Executor(max_workers=2) as executor, self.app.app.test_client() as c: - future = executor.submit(c.get, "/command/test_company/test_car?wait=True") + def get_command(): + with self.app.app.test_client() as c: + return c.get("/command/test_company/test_car?wait=True") + + with _Executor(max_workers=2) as executor: + future = executor.submit(get_command) response = future.result() self.assertEqual(response.status_code, 200) self.assertEqual(response.json, []) @@ -48,8 +60,12 @@ def test_if_status_but_no_command_is_sent_before_timeout_code_200_and_empty_list def test_if_no_relevant_status_is_sent_before_timeout_code_404_and_empty_list_are_returned( self, ): - with _Executor(max_workers=2) as executor, self.app.app.test_client() as c: - future = executor.submit(c.get, "/command/other_company/other_car?wait=True") + def get_command(): + with self.app.app.test_client() as c: + return c.get("/command/other_company/other_car?wait=True") + + with _Executor(max_workers=2) as executor: + future = executor.submit(get_command) response = future.result() self.assertEqual(response.status_code, 404) self.assertEqual(response.json, []) @@ -76,9 +92,13 @@ def setUp(self) -> None: self.status = Message(device_id=self.device_id, payload=self.status_payload) def test_no_status_being_sent_before_timeout_yields_code_404_and_empty_list_after_timeout_is_exceeded(self): - with _Executor() as executor, self.app.app.test_client() as c: + def get_command(): + with self.app.app.test_client() as c: + return c.get("/command/test_company/test_car?wait=True") + + with _Executor() as executor: start_time = time.time() - future = executor.submit(c.get, "/command/test_company/test_car?wait=True") + future = executor.submit(get_command) response = future.result() response_time = time.time() - start_time self.assertEqual(response.status_code, 404) @@ -86,14 +106,30 @@ def test_no_status_being_sent_before_timeout_yields_code_404_and_empty_list_afte self.assertGreaterEqual(response_time, self._timeout) def test_commands_sent_before_car_becomes_available_are_not_sent_to_waiting_thread(self): - with _Executor() as executor, self.app.app.test_client() as c: - future = executor.submit(c.get, "/command/test_company/test_car?wait=True") + def get_command(): + with self.app.app.test_client() as c: + return c.get("/command/test_company/test_car?wait=True") + + def post_command_1(): + with self.app.app.test_client() as c: + return c.post("/command/test_company/test_car", json=[self.command_1]) + + def post_status(): + with self.app.app.test_client() as c: + return c.post("/status/test_company/test_car", json=[self.status]) + + def post_command_2(): + with self.app.app.test_client() as c: + return c.post("/command/test_company/test_car", json=[self.command_2]) + + with _Executor() as executor: + future = executor.submit(get_command) time.sleep(0.05) # this command will be ignored by the waiting thread - executor.submit(c.post, "/command/test_company/test_car", json=[self.command_1]) - executor.submit(c.post, "/status/test_company/test_car", json=[self.status]) + executor.submit(post_command_1) + executor.submit(post_status) time.sleep(0.05) - executor.submit(c.post, "/command/test_company/test_car", json=[self.command_2]) + executor.submit(post_command_2) response = future.result() self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json), 1) diff --git a/tests_integration/wait_mechanism/test_statuses.py b/tests_integration/wait_mechanism/test_statuses.py index 2000a1d3..e67cd9cc 100644 --- a/tests_integration/wait_mechanism/test_statuses.py +++ b/tests_integration/wait_mechanism/test_statuses.py @@ -26,24 +26,36 @@ def setUp(self) -> None: self.status_error = Message(device_id=self.deviceA_id, payload=self.error_payload) def test_awaited_statuses_are_returned_if_some_status_is_sent_in_other_thread(self): - with _Executor(max_workers=2) as executor, self.app.app.test_client() as c: - future = executor.submit(c.get, "/status/test_company/test_car?wait=True") + def get_status(): + with self.app.app.test_client() as c: + return c.get("/status/test_company/test_car?wait=True") + + def post_status(): + with self.app.app.test_client() as c: + return c.post("/status/test_company/test_car", json=[self.statusA, self.status_error]) + + with _Executor(max_workers=2) as executor: + future = executor.submit(get_status) time.sleep(0.1) - executor.submit(c.post, "/status/test_company/test_car", json=[self.statusA, self.status_error]) + executor.submit(post_status) response = future.result() self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json), 2) self.assertEqual(response.json[0]["device_id"], self.deviceA_id.to_dict()) def test_all_relevant_statuses_sent_in_one_thread_are_returned_in_second_waiting_thread(self): - with _Executor(max_workers=2) as executor, self.app.app.test_client() as c: - future = executor.submit(c.get, "/status/test_company/test_car?wait=True") + def get_status(): + with self.app.app.test_client() as c: + return c.get("/status/test_company/test_car?wait=True") + + def post_status(): + with self.app.app.test_client() as c: + return c.post("/status/test_company/test_car", json=[self.statusA, self.statusB, self.status_error]) + + with _Executor(max_workers=2) as executor: + future = executor.submit(get_status) time.sleep(0.1) - executor.submit( - c.post, - "/status/test_company/test_car", - json=[self.statusA, self.statusB, self.status_error], - ) + executor.submit(post_status) response = future.result() self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json), 3) @@ -51,8 +63,12 @@ def test_all_relevant_statuses_sent_in_one_thread_are_returned_in_second_waiting self.assertEqual(response.json[1]["device_id"], self.deviceB_id.to_dict()) def test_404_code_and_empty_list_of_statuses_is_returned_after_timeout_is_exceeded(self): - with _Executor(max_workers=2) as executor, self.app.app.test_client() as c: - future = executor.submit(c.get, "/status/test_company/test_car?wait=True") + def get_status(): + with self.app.app.test_client() as c: + return c.get("/status/test_company/test_car?wait=True") + + with _Executor(max_workers=2) as executor: + future = executor.submit(get_status) response = future.result() self.assertEqual(response.status_code, 404) self.assertEqual(response.json, []) @@ -76,32 +92,60 @@ def setUp(self) -> None: self.status_error = Message(device_id=self.deviceA_id, payload=self.error_payload ) def test_status_for_other_car_than_awaited_is_not_send_to_waiting_thread(self): - with _Executor(max_workers=2) as executor, self.app.app.test_client() as c: - future = executor.submit(c.get, "/status/company/car_x?wait=True") + def get_status(): + with self.app.app.test_client() as c: + return c.get("/status/company/car_x?wait=True") + + def post_status(): + with self.app.app.test_client() as c: + return c.post("/status/company/car_y", json=[self.statusA, self.statusB, self.status_error]) + + with _Executor(max_workers=2) as executor: + future = executor.submit(get_status) time.sleep(0.1) - executor.submit(c.post, "/status/company/car_y", json=[self.statusA, self.statusB, self.status_error]) + executor.submit(post_status) response = future.result() self.assertEqual(response.status_code, 404) self.assertEqual(len(response.json), 0) def test_status_for_other_company_than_awaited_is_not_send_to_waiting_thread(self): - with _Executor(max_workers=2) as executor, self.app.app.test_client() as c: - future = executor.submit(c.get, "/status/company_x/car?wait=True") + def get_status(): + with self.app.app.test_client() as c: + return c.get("/status/company_x/car?wait=True") + + def post_status(): + with self.app.app.test_client() as c: + return c.post("/status/company_y/car", json=[self.statusA, self.statusB]) + + with _Executor(max_workers=2) as executor: + future = executor.submit(get_status) time.sleep(0.1) - executor.submit(c.post, "/status/company_y/car", json=[self.statusA, self.statusB]) + executor.submit(post_status) response = future.result() self.assertEqual(response.status_code, 404) self.assertEqual(len(response.json), 0) def test_waiting_thread_responds_after_relevant_status_is_sent(self): - with _Executor(max_workers=2) as executor, self.app.app.test_client() as c: - future = executor.submit(c.get, "/status/company/car?wait=True") + def get_status(): + with self.app.app.test_client() as c: + return c.get("/status/company/car?wait=True") + + def post_other_car(): + with self.app.app.test_client() as c: + return c.post("/status/company/some_other_car", json=[self.statusA]) + + def post_car(): + with self.app.app.test_client() as c: + return c.post("/status/company/car", json=[self.statusB]) + + with _Executor(max_workers=2) as executor: + future = executor.submit(get_status) time.sleep(0.05) # This status does not trigger response from the waiting thread - executor.submit(c.post, "/status/company/some_other_car", json=[self.statusA]) + executor.submit(post_other_car) time.sleep(0.05) # This status triggers response from the waiting thread - executor.submit(c.post, "/status/company/car", json=[self.statusB]) + executor.submit(post_car) response = future.result() self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json), 1)