From 6e0a0075da63f1bcfd44c24d54b3104d942ac182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urs=20St=C3=B6ckli?= Date: Tue, 27 Jan 2026 17:09:26 +0100 Subject: [PATCH 01/11] SKIPLUS-1012: refactor servers. --- api/ErrorHandler.py | 22 ++++ api/ErrorResponseContentProvider.py | 6 ++ api/FareService.py | 19 ++++ api/FareServiceOjp1.py | 65 +++++++++++ api/FareServiceOjp2.py | 66 ++++++++++++ api/OjpErrorResponseContentProvider.py | 9 ++ api/__init__.py | 0 api/errors/ApiError.py | 15 +++ api/errors/InternalServerError.py | 10 ++ api/errors/InvalidNovaResponseError.py | 9 ++ api/errors/InvalidOjpRequestError.py | 10 ++ api/errors/NoNovaResponseError.py | 9 ++ api/errors/OjpRequestParseError.py | 11 ++ api/errors/__init__.py | 0 configuration.py | 3 + map_nova_to_ojp2.py | 3 +- server.py | 102 +----------------- server_ojp2.py | 142 +------------------------ test_network_flow.py | 20 ++-- 19 files changed, 275 insertions(+), 246 deletions(-) create mode 100644 api/ErrorHandler.py create mode 100644 api/ErrorResponseContentProvider.py create mode 100644 api/FareService.py create mode 100644 api/FareServiceOjp1.py create mode 100644 api/FareServiceOjp2.py create mode 100644 api/OjpErrorResponseContentProvider.py create mode 100644 api/__init__.py create mode 100644 api/errors/ApiError.py create mode 100644 api/errors/InternalServerError.py create mode 100644 api/errors/InvalidNovaResponseError.py create mode 100644 api/errors/InvalidOjpRequestError.py create mode 100644 api/errors/NoNovaResponseError.py create mode 100644 api/errors/OjpRequestParseError.py create mode 100644 api/errors/__init__.py diff --git a/api/ErrorHandler.py b/api/ErrorHandler.py new file mode 100644 index 0000000..fbbe7e8 --- /dev/null +++ b/api/ErrorHandler.py @@ -0,0 +1,22 @@ +import logging + +from fastapi import Response + +from api.ErrorResponseContentProvider import ErrorResponseContentProvider +from api.errors import ApiError + +class ErrorHandler: + + def __init__(self, response_provider: ErrorResponseContentProvider): + self.response_provider = response_provider + self.logger = logging.getLogger(__name__) + + def handle_error(self, error: ApiError) -> Response: + error.log_error() + return Response( + self.response_provider.provide_error_response_content(error.message), + status_code=error.status_code, + media_type="application/xml; charset=utf-8", + ) + + diff --git a/api/ErrorResponseContentProvider.py b/api/ErrorResponseContentProvider.py new file mode 100644 index 0000000..a824aa0 --- /dev/null +++ b/api/ErrorResponseContentProvider.py @@ -0,0 +1,6 @@ +from abc import abstractmethod, ABC + +class ErrorResponseContentProvider(ABC): + @abstractmethod + def provide_error_response_content(self, message: str) -> str: + return message \ No newline at end of file diff --git a/api/FareService.py b/api/FareService.py new file mode 100644 index 0000000..645a6b7 --- /dev/null +++ b/api/FareService.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +import logging +from abc import abstractmethod, ABC +from fastapi import Request, Response +from xsdata.formats.dataclass.serializers import XmlSerializer +from xsdata.formats.dataclass.serializers.config import SerializerConfig + + +class FareService(ABC): + ns_map = {"": "http://www.siri.org.uk/siri", "ojp": "http://www.vdv.de/ojp"} + _serializer_config = SerializerConfig(ignore_default_attributes=True, pretty_print=True) + serializer = XmlSerializer(config=_serializer_config) + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + @abstractmethod + def handle_request(self, body: bytes) -> Response: + return Response(body) diff --git a/api/FareServiceOjp1.py b/api/FareServiceOjp1.py new file mode 100644 index 0000000..d5e5ce1 --- /dev/null +++ b/api/FareServiceOjp1.py @@ -0,0 +1,65 @@ +import logging +from fastapi import Response + +from api.ErrorHandler import ErrorHandler +from api.FareService import FareService +from api.errors import ApiError +from api.errors.InvalidOjpRequestError import InvalidOjpRequestError +from api.errors.OjpRequestParseError import OjpRequestParseError +from map_nova_to_ojp import map_nova_reply_to_ojp_fare_delivery +from map_ojp_to_ojp import parse_ojp +from ojp import OjpfareDelivery, Ojpresponse, ServiceDelivery +from ojp2 import Ojp, ParticipantRefStructure +from test_network_flow import test_nova_request_reply + + +class FareServiceOjp1(FareService): + def __init__(self, *args, **kwargs) -> None: + self.logger = logging.getLogger(__name__) + super().__init__(*args, **kwargs) + + def handle_request(self, body: bytes) -> Response: + error_handler = ErrorHandler() + try: + ojp_fare_request = _parse_request(body) + _validate_request(ojp_fare_request) + + self.logger.debug("Query to NOVA: " + str(ojp_fare_request)) + nova_response = test_nova_request_reply(ojp_fare_request) + ojp_fare_delivery = map_nova_reply_to_ojp_fare_delivery(nova_response) + + self.logger.debug("Workable NOVA response put into OJP: " + str(ojp_fare_delivery)) + return _create_response(ojp_fare_delivery) + + except ApiError as error: + error_handler.handle_error(error) + +def _validate_request(ojp_fare_request: Ojp): + if ojp_fare_request.ojprequest is None: + raise InvalidOjpRequestError(message="missing Element OJPRequest."); + + if ojp_fare_request.ojprequest.service_request.ojpfare_request is None: + raise InvalidOjpRequestError() + +def _parse_request(body: bytes) -> Ojp: + try: + return parse_ojp(body.decode("utf-8")) + except Exception as e: + raise OjpRequestParseError(cause=e) + +def _create_response(ojp_fare_delivery: OjpfareDelivery) -> Response: + xml = FareServiceOjp1.serializer.render( + Ojp( + ojpresponse=Ojpresponse( + service_delivery=ServiceDelivery( + response_timestamp=ojp_fare_delivery.response_timestamp, + producer_ref=ParticipantRefStructure( + value="OJP2NOVA" + ), + ojpfare_delivery=[ojp_fare_delivery], + ) + ) + ), + ns_map=FareServiceOjp1.ns_map, + ) + return Response(xml, media_type="application/xml; charset=utf-8") \ No newline at end of file diff --git a/api/FareServiceOjp2.py b/api/FareServiceOjp2.py new file mode 100644 index 0000000..fcf43b5 --- /dev/null +++ b/api/FareServiceOjp2.py @@ -0,0 +1,66 @@ +import logging +from fastapi import Response + +from api.ErrorHandler import ErrorHandler +from api.FareService import FareService +from api.errors import ApiError +from api.errors.InvalidOjpRequestError import InvalidOjpRequestError +from api.errors.OjpRequestParseError import OjpRequestParseError +from map_nova_to_ojp2 import map_nova_reply_to_ojp_fare_delivery +from map_ojp2_to_ojp2 import parse_ojp2 +from ojp2 import Ojp, OjpfareDelivery, Ojpresponse, ServiceDelivery, ParticipantRefStructure +from test_network_flow import test_nova_request_reply_for_ojp2 + +class FareServiceOjp2(FareService): + def __init__(self, *args, **kwargs) -> None: + self.logger = logging.getLogger(__name__) + super().__init__(*args, **kwargs) + + def handle_request(self, body: bytes) -> Response: + error_handler = ErrorHandler() + try: + ojp_fare_request = _parse_request(body) + _validate_request(ojp_fare_request) + + self.logger.debug("Query to NOVA: " + str(ojp_fare_request)) + nova_response = test_nova_request_reply_for_ojp2(ojp_fare_request) + ojp_fare_delivery = map_nova_reply_to_ojp_fare_delivery(nova_response) + + self.logger.debug("Workable NOVA response put into OJP: " + str(ojp_fare_delivery)) + return _create_response(ojp_fare_delivery) + + except ApiError as error: + error_handler.handle_error(error) + +def _validate_request(ojp_fare_request: Ojp): + if ojp_fare_request.ojprequest is None: + raise InvalidOjpRequestError(message="missing Element OJPRequest."); + + if ojp_fare_request.ojprequest.service_request.ojpfare_request is None: + raise InvalidOjpRequestError() + +def _parse_request(body: bytes) -> Ojp: + try: + return parse_ojp2(body.decode("utf-8")) + except Exception as e: + raise OjpRequestParseError(cause=e) + +def _create_response(ojp_fare_delivery: OjpfareDelivery) -> Response: + xml = FareServiceOjp2.serializer.render( + Ojp( + ojpresponse=Ojpresponse( + service_delivery=ServiceDelivery( + response_timestamp=ojp_fare_delivery.response_timestamp, + producer_ref=ParticipantRefStructure( + value="OJP2NOVA" + ), + ojpfare_delivery=[ojp_fare_delivery], + ) + ) + ), + ns_map=FareServiceOjp2.ns_map, + ) + return Response(xml, media_type="application/xml; charset=utf-8") + + + diff --git a/api/OjpErrorResponseContentProvider.py b/api/OjpErrorResponseContentProvider.py new file mode 100644 index 0000000..5a8b59d --- /dev/null +++ b/api/OjpErrorResponseContentProvider.py @@ -0,0 +1,9 @@ +from api.ErrorResponseContentProvider import ErrorResponseContentProvider + +class OjpErrorResponseContentProvider(ErrorResponseContentProvider): + + def __init__(self): + pass + + def provide_error_response_content(self, message: str) -> str: + return message \ No newline at end of file diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/errors/ApiError.py b/api/errors/ApiError.py new file mode 100644 index 0000000..6fa061b --- /dev/null +++ b/api/errors/ApiError.py @@ -0,0 +1,15 @@ +import logging +from abc import abstractmethod, ABC + +class ApiError(Exception, ABC): + def __init__(self, message="An unspecific error occurred."): + self.message = message + self.status_code = 500 + self.logger = logging.getLogger(__name__) + super().__init__(self.message) + + @abstractmethod + def log_error(self): + self.logger.warning(self.message) + + diff --git a/api/errors/InternalServerError.py b/api/errors/InternalServerError.py new file mode 100644 index 0000000..d54f722 --- /dev/null +++ b/api/errors/InternalServerError.py @@ -0,0 +1,10 @@ +from api.errors.ApiError import ApiError + +class InternalServerError(ApiError): + def __init__(self, message="An internal server error occurred."): + self.message = message + self.status_code = 500 + super().__init__(self.message) + + def log_error(self): + self.logger.error(self.message) diff --git a/api/errors/InvalidNovaResponseError.py b/api/errors/InvalidNovaResponseError.py new file mode 100644 index 0000000..375aae2 --- /dev/null +++ b/api/errors/InvalidNovaResponseError.py @@ -0,0 +1,9 @@ +from api.errors.ApiError import ApiError + +class InvalidNovaResponseError(ApiError): + def __init__(self, message="There was no valid NOVA response."): + self.message = message + super().__init__(self.message) + + def log_error(self): + self.logger.warning(self.message) \ No newline at end of file diff --git a/api/errors/InvalidOjpRequestError.py b/api/errors/InvalidOjpRequestError.py new file mode 100644 index 0000000..1f9f632 --- /dev/null +++ b/api/errors/InvalidOjpRequestError.py @@ -0,0 +1,10 @@ +from api.errors.ApiError import ApiError + +class InvalidOjpRequestError(ApiError): + def __init__(self, message="There was no (valid) OJP request."): + self.message = message + self.status_code = 400 + super().__init__(self.message) + + def log_error(self): + self.logger.warning(self.message) \ No newline at end of file diff --git a/api/errors/NoNovaResponseError.py b/api/errors/NoNovaResponseError.py new file mode 100644 index 0000000..17c3432 --- /dev/null +++ b/api/errors/NoNovaResponseError.py @@ -0,0 +1,9 @@ +from api.errors.ApiError import ApiError + +class NoNovaResponseError(ApiError): + def __init__(self, message="There was no NOVA response."): + self.message = message + super().__init__(self.message) + + def log_error(self): + self.logger.warning(self.message) \ No newline at end of file diff --git a/api/errors/OjpRequestParseError.py b/api/errors/OjpRequestParseError.py new file mode 100644 index 0000000..df3996d --- /dev/null +++ b/api/errors/OjpRequestParseError.py @@ -0,0 +1,11 @@ +from api.errors.ApiError import ApiError + +class OjpRequestParseError(ApiError): + def __init__(self, cause: Exception = None, message="Failed to parse OJP request."): + self.message = message + self.status_code = 400 + self.cause = cause + super().__init__(self.message) + + def log_error(self): + self.logger.warning(self.message + ": "+ str(self.cause)) \ No newline at end of file diff --git a/api/errors/__init__.py b/api/errors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/configuration.py b/configuration.py index ea81f14..0efc141 100644 --- a/configuration.py +++ b/configuration.py @@ -46,7 +46,10 @@ SSL_CERTFILE = '' HTTP_HOST = '127.0.0.1' HTTP_PORT = 8000 +HTTP_PORT_OJPFARE = 8081 HTTP_SLUG = "ojp2023" +HTTP_SLUG_OJPFARE = "ojpfare" +HTTP_SLUG_PUNKT = "PUNKT" READTRIPREQUESTFILE = True VATRATE= 8.1 # Percent USE_HTA = True # if in the tests half price should be used diff --git a/map_nova_to_ojp2.py b/map_nova_to_ojp2.py index f946707..60ff5da 100644 --- a/map_nova_to_ojp2.py +++ b/map_nova_to_ojp2.py @@ -47,9 +47,10 @@ def map_preis_auspraegung_to_trip_fare_result(preis_auspraegungen: List[PreisAus return FareResultStructure(id=id, trip_fare_result=tripfareresults) + def map_nova_reply_to_ojp_fare_delivery(soap: PreisAuskunftServicePortTypeSoapv14ErstellePreisAuskunftOutput) -> Optional[OjpfareDelivery]: if not soap.body.erstelle_preis_auskunft_response.preis_auskunft_response.preis_auspraegung: - return None + raise InvalidNovaResponseError() bonded_trips :Dict[str,PreisAuspraegung]= {} for preis_auspraegung in soap.body.erstelle_preis_auskunft_response.preis_auskunft_response.preis_auspraegung: diff --git a/server.py b/server.py index de180cb..01c7e4d 100644 --- a/server.py +++ b/server.py @@ -1,16 +1,8 @@ #!/usr/bin/env python3 from fastapi import FastAPI, Request, Response -from xsdata.formats.dataclass.serializers import XmlSerializer -from xsdata.formats.dataclass.serializers.config import SerializerConfig - -from map_nova_to_ojp import test_nova_to_ojp -from map_ojp_to_ojp import parse_ojp -from support import error_response -from ojp import Ojp, Ojpresponse, ServiceDelivery -from test_network_flow import test_nova_request_reply, call_ojp_2000 +from api.FareServiceOjp1 import FareServiceOjp1 from configuration import HTTP_HOST, HTTP_PORT, HTTPS, SSL_CERTFILE, SSL_KEYFILE, HTTP_SLUG - import logging import app_logging @@ -18,101 +10,15 @@ app_logging.setup_logging() logger = logging.getLogger(__name__) - app = FastAPI(title="OJPTONOVA") -serializer_config = SerializerConfig(ignore_default_attributes=True, pretty_print=True) -serializer = XmlSerializer(config=serializer_config) - -ns_map = {'': 'http://www.siri.org.uk/siri', 'ojp': 'http://www.vdv.de/ojp'} - -# implements basic liveness probe -@app.get("/health/liveness", tags=["Health"]) -async def liveness(fastapi_req: Request): - return Response("Liveness: OK", media_type="text/plain; charset=utf-8") - - -# implements basic readiness probe -@app.get("/health/readiness", tags=["Health"]) -async def readiness(fastapi_req: Request): - return Response("Readiness: OK", media_type="text/plain; charset=utf-8") +fare_service = FareServiceOjp1() @app.post("/" + HTTP_SLUG, tags=["Open Journey Planner"]) -async def post_request(fastapi_req: Request) -> Response: +async def post_request(fastapi_req: Request) ->Response: body = await fastapi_req.body() logger.debug("Received request: " + str(body)) - - ojp_fare_request = None - error = None - try: - ojp_fare_request = parse_ojp(body.decode('utf-8')) - except Exception as e: - error = e - - try: - if ojp_fare_request and ojp_fare_request.ojprequest: - # a request was made and it seems legit - if ojp_fare_request.ojprequest.service_request and ojp_fare_request.ojprequest.service_request.ojpfare_request: - # we deal with a OJPFare Request and will ask NOVA - logger.debug("Query to NOVA: " + str(ojp_fare_request)) - nova_response = test_nova_request_reply(ojp_fare_request) - if nova_response: - # we got a valid response - ojp_fare_delivery = test_nova_to_ojp(nova_response) - if ojp_fare_delivery: - # we have a OJPFareDelivery to work with - # we add the warnings - logger.debug("Workable NOVA response put into OJP: " + str(ojp_fare_delivery)) - # ojp_fare_delivery=add_error_response(ojp_fare_delivery) - xml = serializer.render( - Ojp( - ojpresponse=Ojpresponse( - service_delivery=ServiceDelivery( - response_timestamp=ojp_fare_delivery.response_timestamp, producer_ref="OJP2NOVA", ojpfare_delivery=[ojp_fare_delivery] - ) - ) - ), - ns_map=ns_map, - ) - return Response(xml, media_type="application/xml; charset=utf-8") - else: - logger.warning("There was a NOVA response, but it can't be used:" + str(nova_response)) - return Response( - serializer.render(error_response("There was a NOVA response, but it cannot be used"), ns_map=ns_map), - status_code=400, - media_type="application/xml; charset=utf-8", - ) - logger.error("There was no NOVA response") - return Response( - serializer.render(error_response("There was no NOVA response"), ns_map=ns_map), status_code=400, media_type="application/xml; charset=utf-8" - ) - else: - logger.debug("Returning the call to the OJP server:" + str(body.decode('utf-8'))) - s, r = call_ojp_2000(body.decode('utf-8')) - return Response(r, media_type="application/xml; charset=utf-8", status_code=s) - else: - # very general errors - if error: - # an error message was provided in the exception - logger.error("Couldn't extract a valid OJP request") - return Response( - serializer.render(error_response("There was no (valid) OJP request\n" + str(error)), ns_map=ns_map), - status_code=400, - media_type="application/xml; charset=utf-8", - ) - else: - # no error message was provided in the exception - logger.warning("No valid OJP request") - return Response( - serializer.render(error_response("There was no (valid) OJP request"), ns_map=ns_map), - status_code=400, - media_type="application/xml; charset=utf-8", - ) - except Exception as e: - # not yet really sophisticated handling of all other errors during the work (should be regular OJPDeliveries with OtherError set - logger.exception(e) - return Response(serializer.render(error_response(str(e)), ns_map=ns_map), status_code=400, media_type="application/xml; charset=utf-8") - + return fare_service.handle_request(body) if __name__ == "__main__": import uvicorn diff --git a/server_ojp2.py b/server_ojp2.py index b37e64b..8461909 100644 --- a/server_ojp2.py +++ b/server_ojp2.py @@ -1,20 +1,7 @@ #!/usr/bin/env python3 from fastapi import FastAPI, Request, Response -from xsdata.formats.dataclass.serializers import XmlSerializer -from xsdata.formats.dataclass.serializers.config import SerializerConfig - -from map_nova_to_ojp2 import test_nova_to_ojp2 -from map_ojp2_to_ojp2 import parse_ojp2 -from support import error_response - -from ojp2 import ( - Ojp, - Ojpresponse, - ServiceDelivery, - ParticipantRefStructure, -) -from test_network_flow import call_ojp_20, test_nova_request_reply_for_ojp2 +from api.FareServiceOjp2 import FareServiceOjp2 from configuration import ( HTTP_HOST, HTTP_PORT, @@ -29,138 +16,15 @@ app_logging.setup_logging() logger = logging.getLogger(__name__) - app = FastAPI(title="OJP2TONOVA") -serializer_config = SerializerConfig(ignore_default_attributes=True, pretty_print=True) -serializer = XmlSerializer(config=serializer_config) - -ns_map = {"": "http://www.siri.org.uk/siri", "ojp": "http://www.vdv.de/ojp"} - -# implements basic liveness probe -@app.get("/health/liveness", tags=["Health"]) -async def liveness(fastapi_req: Request): - return Response("Liveness: OK", media_type="text/plain; charset=utf-8") - - -# implements basic readiness probe -@app.get("/health/readiness", tags=["Health"]) -async def readiness(fastapi_req: Request): - return Response("Readiness: OK", media_type="text/plain; charset=utf-8") +fare_service = FareServiceOjp2() @app.post("/" + HTTP_SLUG, tags=["Open Journey Planner"]) async def post_request(fastapi_req: Request) ->Response: body = await fastapi_req.body() logger.debug("Received request: " + str(body)) - - ojp_fare_request = None - error = None - try: - ojp_fare_request = parse_ojp2(body.decode("utf-8")) - except Exception as e: - error = e - - try: - if ojp_fare_request and ojp_fare_request.ojprequest: - # a request was made and it seems legit - if ojp_fare_request.ojprequest.service_request.ojpfare_request: - # we deal with a OJPFare Request and will ask NOVA - logger.debug("Query to NOVA: " + str(ojp_fare_request)) - nova_response = test_nova_request_reply_for_ojp2(ojp_fare_request) - if nova_response: - # we got a valid response - ojp_fare_delivery = test_nova_to_ojp2(nova_response) - if ojp_fare_delivery: - # we have a OJPFareDelivery to work with - # we add the warnings - logger.debug( - "Workable NOVA response put into OJP: " - + str(ojp_fare_delivery) - ) - # ojp_fare_delivery=add_error_response(ojp_fare_delivery) - xml = serializer.render( - Ojp( - ojpresponse=Ojpresponse( - service_delivery=ServiceDelivery( - response_timestamp=ojp_fare_delivery.response_timestamp, - producer_ref=ParticipantRefStructure( - value="OJP2NOVA" - ), - ojpfare_delivery=[ojp_fare_delivery], - ) - ) - ), - ns_map=ns_map, - ) - return Response( - xml, media_type="application/xml; charset=utf-8" - ) - else: - logger.warning( - "There was a NOVA response, but it can't be used:" - + str(nova_response) - ) - return Response( - serializer.render( - error_response( - "There was a NOVA response, but it cannot be used" - ), - ns_map=ns_map, - ), - status_code=400, - media_type="application/xml; charset=utf-8", - ) - logger.error("There was no NOVA response") - return Response( - serializer.render( - error_response("There was no NOVA response"), ns_map=ns_map - ), - status_code=400, - media_type="application/xml; charset=utf-8", - ) - else: - logger.debug( - "Returning the call to the OJP server:" + str(body.decode("utf-8")) - ) - s, r = call_ojp_20(body.decode("utf-8")) - return Response( - r, media_type="application/xml; charset=utf-8", status_code=s - ) - else: - # very general errors - if error: - # an error message was provided in the exception - logger.error("Couldn't extract a valid OJP request") - return Response( - serializer.render( - error_response( - "There was no (valid) OJP request\n" + str(error) - ), - ns_map=ns_map, - ), - status_code=400, - media_type="application/xml; charset=utf-8", - ) - else: - # no error message was provided in the exception - logger.warning("No valid OJP request") - return Response( - serializer.render( - error_response("There was no (valid) OJP request"), - ns_map=ns_map, - ), - status_code=400, - media_type="application/xml; charset=utf-8", - ) - except Exception as e: - # not yet really sophisticated handling of all other errors during the work (should be regular OJPDeliveries with OtherError set - logger.exception(e) - return Response( - serializer.render(error_response(str(e)), ns_map=ns_map), - status_code=400, - media_type="application/xml; charset=utf-8", - ) - + return fare_service.handle_request(body) if __name__ == "__main__": import uvicorn diff --git a/test_network_flow.py b/test_network_flow.py index 5149e7e..5ad4a31 100644 --- a/test_network_flow.py +++ b/test_network_flow.py @@ -13,6 +13,7 @@ from xsdata.formats.dataclass.serializers.config import SerializerConfig import ojp.fare_result_structure +from api.errors.NoNovaResponseError import NoNovaResponseError from configuration import * from support import OJPError from test_create_ojp_request import * @@ -108,10 +109,12 @@ def test_nova_request_reply(ojp: Ojp)->Optional[PreisAuskunftServicePortTypeSoap nova_request = test_ojp_fare_request_to_nova_request(ojp) nova_client = get_nova_client() nova_response :Optional[PreisAuskunftServicePortTypeSoapv14ErstellePreisAuskunftOutput] = nova_client.send(nova_request, headers=headers) - if nova_response: - xml_logger.log_object_as_xml('nova_response.xml',nova_response) - return nova_response - return None + if nova_response is None: + raise NoNovaResponseError() + xml_logger.log_object_as_xml('nova_response.xml',nova_response) + return nova_response + + def test_nova_request_reply_for_ojp2(ojp: Ojp2)->Optional[PreisAuskunftServicePortTypeSoapv14ErstellePreisAuskunftOutput]: oauth_helper = OAuth2Helper(client_id=NOVA_CLIENT_ID, client_secret=NOVA_CLIENT_SECRET) access_token = oauth_helper.get_token() @@ -119,10 +122,11 @@ def test_nova_request_reply_for_ojp2(ojp: Ojp2)->Optional[PreisAuskunftServicePo nova_request = test_ojp2_fare_request_to_nova_request(ojp) nova_client = get_nova_client() nova_response : PreisAuskunftServicePortTypeSoapv14ErstellePreisAuskunftOutput = nova_client.send(nova_request, headers=headers) - if nova_response: - xml_logger.log_object_as_xml('nova_response.xml',nova_response) - return nova_response - return None + if nova_response is None: + raise NoNovaResponseError() + xml_logger.log_object_as_xml('nova_response.xml',nova_response) + return nova_response + def check_configuration() ->None: if (len(NOVA_CLIENT_SECRET)==0): logger.error("Nova client secret not set in the configuration") From 9f0896b596b9c661c3f9b8f5282ba3640e8763f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urs=20St=C3=B6ckli?= Date: Fri, 30 Jan 2026 07:49:17 +0100 Subject: [PATCH 02/11] SKIPLUS-1012: add OjpVersionParser and unit test for ErrorHandler. --- api/ErrorHandler.py | 9 ++-- api/ErrorHandlerTest.py | 54 +++++++++++++++++++ ...ntProvider.py => ErrorResponseProvider.py} | 5 +- api/FareService.py | 9 +--- api/OjpErrorResponseContentProvider.py | 9 ---- api/OjpVersionParser.py | 46 ++++++++++++++++ api/SerializerUtil.py | 7 +++ api/errors/ApiError.py | 6 +-- api/errors/InternalServerError.py | 5 +- api/errors/InvalidNovaResponseError.py | 6 +-- api/errors/InvalidOjpRequestError.py | 7 ++- api/errors/NoNovaResponseError.py | 6 +-- api/errors/OjpRequestParseError.py | 7 ++- api/ojp1/ErrorResponseProviderOjp1.py | 21 ++++++++ api/{ => ojp1}/FareServiceOjp1.py | 44 +++++++++------ api/ojp1/__init__.py | 0 api/ojp2/ErrorResponseProviderOjp2.py | 21 ++++++++ api/{ => ojp2}/FareServiceOjp2.py | 39 +++++++++----- api/ojp2/__init__.py | 0 map_nova_to_ojp.py | 3 +- server.py | 29 ++++++++-- server_ojp1.py | 39 ++++++++++++++ server_ojp2.py | 12 ++++- 23 files changed, 303 insertions(+), 81 deletions(-) create mode 100644 api/ErrorHandlerTest.py rename api/{ErrorResponseContentProvider.py => ErrorResponseProvider.py} (66%) delete mode 100644 api/OjpErrorResponseContentProvider.py create mode 100644 api/OjpVersionParser.py create mode 100644 api/SerializerUtil.py create mode 100644 api/ojp1/ErrorResponseProviderOjp1.py rename api/{ => ojp1}/FareServiceOjp1.py (55%) create mode 100644 api/ojp1/__init__.py create mode 100644 api/ojp2/ErrorResponseProviderOjp2.py rename api/{ => ojp2}/FareServiceOjp2.py (62%) create mode 100644 api/ojp2/__init__.py create mode 100644 server_ojp1.py diff --git a/api/ErrorHandler.py b/api/ErrorHandler.py index fbbe7e8..4933120 100644 --- a/api/ErrorHandler.py +++ b/api/ErrorHandler.py @@ -2,21 +2,20 @@ from fastapi import Response -from api.ErrorResponseContentProvider import ErrorResponseContentProvider +from api.ErrorResponseProvider import ErrorResponseProvider from api.errors import ApiError + class ErrorHandler: - def __init__(self, response_provider: ErrorResponseContentProvider): + def __init__(self, response_provider: ErrorResponseProvider): self.response_provider = response_provider self.logger = logging.getLogger(__name__) - def handle_error(self, error: ApiError) -> Response: + def handle_error(self, error: type[ApiError]) -> Response: error.log_error() return Response( self.response_provider.provide_error_response_content(error.message), status_code=error.status_code, media_type="application/xml; charset=utf-8", ) - - diff --git a/api/ErrorHandlerTest.py b/api/ErrorHandlerTest.py new file mode 100644 index 0000000..b3845ee --- /dev/null +++ b/api/ErrorHandlerTest.py @@ -0,0 +1,54 @@ +import unittest + +from api.ErrorHandler import ErrorHandler +from api.ErrorResponseProvider import ErrorResponseProvider +from api.errors.ApiError import ApiError +from api.errors.InternalServerError import InternalServerError +from api.ojp1.ErrorResponseProviderOjp1 import ErrorResponseProviderOjp1 +from api.ojp2.ErrorResponseProviderOjp2 import ErrorResponseProviderOjp2 + +class ErrorHandlerTest(unittest.TestCase): + + def test_internal_server_error_ojp1(self): + self._catch_internal_server_error(ErrorResponseProviderOjp1()) + + def test_api_error(self): + self._catch_api_error(ErrorResponseProviderOjp1()) + + def test_internal_server_error_ojp2(self): + self._catch_internal_server_error(ErrorResponseProviderOjp2()) + + def _catch_internal_server_error(self, error_response_provider: ErrorResponseProvider): + + # prepare test case + error_handler = ErrorHandler(error_response_provider) + self.assertIsNotNone(error_handler) + message = "Oups, Terrible Failure ;-)" + try: + raise InternalServerError(message) + + # run test case + except InternalServerError as e: + response = error_handler.handle_error(e) + + # assert expectations + self.assertIsNotNone(response) + + def _catch_api_error(self, error_response_provider: ErrorResponseProvider): + + # prepare test case + error_handler = ErrorHandler(error_response_provider) + self.assertIsNotNone(error_handler) + message = "Oups, Terrible Failure ;-)" + try: + raise InternalServerError(message) + + # run test case + except ApiError as e: + response = error_handler.handle_error(e) + + # assert expectations + self.assertIsNotNone(response) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/api/ErrorResponseContentProvider.py b/api/ErrorResponseProvider.py similarity index 66% rename from api/ErrorResponseContentProvider.py rename to api/ErrorResponseProvider.py index a824aa0..c63a445 100644 --- a/api/ErrorResponseContentProvider.py +++ b/api/ErrorResponseProvider.py @@ -1,6 +1,7 @@ from abc import abstractmethod, ABC -class ErrorResponseContentProvider(ABC): + +class ErrorResponseProvider(ABC): @abstractmethod def provide_error_response_content(self, message: str) -> str: - return message \ No newline at end of file + return message diff --git a/api/FareService.py b/api/FareService.py index 645a6b7..0ef5306 100644 --- a/api/FareService.py +++ b/api/FareService.py @@ -1,15 +1,10 @@ #!/usr/bin/env python3 -import logging from abc import abstractmethod, ABC -from fastapi import Request, Response -from xsdata.formats.dataclass.serializers import XmlSerializer -from xsdata.formats.dataclass.serializers.config import SerializerConfig + +from fastapi import Response class FareService(ABC): - ns_map = {"": "http://www.siri.org.uk/siri", "ojp": "http://www.vdv.de/ojp"} - _serializer_config = SerializerConfig(ignore_default_attributes=True, pretty_print=True) - serializer = XmlSerializer(config=_serializer_config) def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) diff --git a/api/OjpErrorResponseContentProvider.py b/api/OjpErrorResponseContentProvider.py deleted file mode 100644 index 5a8b59d..0000000 --- a/api/OjpErrorResponseContentProvider.py +++ /dev/null @@ -1,9 +0,0 @@ -from api.ErrorResponseContentProvider import ErrorResponseContentProvider - -class OjpErrorResponseContentProvider(ErrorResponseContentProvider): - - def __init__(self): - pass - - def provide_error_response_content(self, message: str) -> str: - return message \ No newline at end of file diff --git a/api/OjpVersionParser.py b/api/OjpVersionParser.py new file mode 100644 index 0000000..56864da --- /dev/null +++ b/api/OjpVersionParser.py @@ -0,0 +1,46 @@ +from xml.etree import ElementTree + +from api.errors.InvalidOjpRequestError import InvalidOjpRequestError + + +class OjpVersionParser: + chunk_size = 8192 # ausreichend gross, um alle Root-Attribute zu erwischen + root_element_name = "OJP" + + def parse_version(self, xml_content: str) -> str: + """ + Reads until first start tag and gets the value of the 'version' attribute + of the OJP root element. + + Raises: + InvalidOjpRequestError upon invalid XML, unexcepted root element or missing version attribute. + """ + + parser = ElementTree.XMLPullParser(events=('start',)) + pos = 0 + + while True: + if pos >= len(xml_content): + # end of the document reached without having seen the root element + try: + parser.close() + except ElementTree.ParseError as e: + raise InvalidOjpRequestError(f"Invalid XML: {e}") from e + raise InvalidOjpRequestError("Root element not found.") + + parser.feed(xml_content[pos:pos + self.chunk_size]) + pos += self.chunk_size + + for event, elem in parser.read_events(): + # first start event is the root + tag = elem.tag + local = tag.split('}', 1)[1] if tag.startswith('{') else tag + + if local != self.root_element_name: + raise InvalidOjpRequestError(f"Root-Element ist '{local}', erwartet '{self.root_element_name}'") + + ver = elem.get("version") + if ver is None or ver.strip() == "": + raise InvalidOjpRequestError(f"'{self.root_element_name}'-Element hat kein 'version'-Attribut.") + + return ver.strip() diff --git a/api/SerializerUtil.py b/api/SerializerUtil.py new file mode 100644 index 0000000..89e2bc2 --- /dev/null +++ b/api/SerializerUtil.py @@ -0,0 +1,7 @@ +from xsdata.formats.dataclass.serializers import XmlSerializer +from xsdata.formats.dataclass.serializers.config import SerializerConfig + +class SerializerUtil: + ns_map = {"": "http://www.siri.org.uk/siri", "ojp": "http://www.vdv.de/ojp"} + _serializer_config = SerializerConfig(ignore_default_attributes=True, pretty_print=True) + serializer = XmlSerializer(config=_serializer_config) diff --git a/api/errors/ApiError.py b/api/errors/ApiError.py index 6fa061b..70d6237 100644 --- a/api/errors/ApiError.py +++ b/api/errors/ApiError.py @@ -2,14 +2,12 @@ from abc import abstractmethod, ABC class ApiError(Exception, ABC): - def __init__(self, message="An unspecific error occurred."): + def __init__(self, message="An unspecific error occurred.", status_code=500): self.message = message - self.status_code = 500 + self.status_code = status_code self.logger = logging.getLogger(__name__) super().__init__(self.message) @abstractmethod def log_error(self): self.logger.warning(self.message) - - diff --git a/api/errors/InternalServerError.py b/api/errors/InternalServerError.py index d54f722..43d4d93 100644 --- a/api/errors/InternalServerError.py +++ b/api/errors/InternalServerError.py @@ -1,10 +1,9 @@ from api.errors.ApiError import ApiError + class InternalServerError(ApiError): def __init__(self, message="An internal server error occurred."): - self.message = message - self.status_code = 500 - super().__init__(self.message) + super().__init__(message) def log_error(self): self.logger.error(self.message) diff --git a/api/errors/InvalidNovaResponseError.py b/api/errors/InvalidNovaResponseError.py index 375aae2..59b2522 100644 --- a/api/errors/InvalidNovaResponseError.py +++ b/api/errors/InvalidNovaResponseError.py @@ -1,9 +1,9 @@ from api.errors.ApiError import ApiError + class InvalidNovaResponseError(ApiError): def __init__(self, message="There was no valid NOVA response."): - self.message = message - super().__init__(self.message) + super().__init__(message,400) def log_error(self): - self.logger.warning(self.message) \ No newline at end of file + self.logger.warning(self.message) diff --git a/api/errors/InvalidOjpRequestError.py b/api/errors/InvalidOjpRequestError.py index 1f9f632..0d2c214 100644 --- a/api/errors/InvalidOjpRequestError.py +++ b/api/errors/InvalidOjpRequestError.py @@ -1,10 +1,9 @@ from api.errors.ApiError import ApiError + class InvalidOjpRequestError(ApiError): def __init__(self, message="There was no (valid) OJP request."): - self.message = message - self.status_code = 400 - super().__init__(self.message) + super().__init__(message, 400) def log_error(self): - self.logger.warning(self.message) \ No newline at end of file + self.logger.warning(self.message) diff --git a/api/errors/NoNovaResponseError.py b/api/errors/NoNovaResponseError.py index 17c3432..614f224 100644 --- a/api/errors/NoNovaResponseError.py +++ b/api/errors/NoNovaResponseError.py @@ -1,9 +1,9 @@ from api.errors.ApiError import ApiError + class NoNovaResponseError(ApiError): def __init__(self, message="There was no NOVA response."): - self.message = message - super().__init__(self.message) + super().__init__(message) def log_error(self): - self.logger.warning(self.message) \ No newline at end of file + self.logger.warning(self.message) diff --git a/api/errors/OjpRequestParseError.py b/api/errors/OjpRequestParseError.py index df3996d..e464a65 100644 --- a/api/errors/OjpRequestParseError.py +++ b/api/errors/OjpRequestParseError.py @@ -1,11 +1,10 @@ from api.errors.ApiError import ApiError + class OjpRequestParseError(ApiError): def __init__(self, cause: Exception = None, message="Failed to parse OJP request."): - self.message = message - self.status_code = 400 self.cause = cause - super().__init__(self.message) + super().__init__(message, 400) def log_error(self): - self.logger.warning(self.message + ": "+ str(self.cause)) \ No newline at end of file + self.logger.warning(self.message + ": " + str(self.cause)) diff --git a/api/ojp1/ErrorResponseProviderOjp1.py b/api/ojp1/ErrorResponseProviderOjp1.py new file mode 100644 index 0000000..a1f8fb1 --- /dev/null +++ b/api/ojp1/ErrorResponseProviderOjp1.py @@ -0,0 +1,21 @@ +import datetime + +from xsdata.models.datatype import XmlDateTime + +from api.ErrorResponseProvider import ErrorResponseProvider +from api.SerializerUtil import SerializerUtil +from ojp import Ojp, Ojpresponse, ServiceDelivery, ServiceDeliveryStructure, OtherError + + +class ErrorResponseProviderOjp1(ErrorResponseProvider): + ns_map = SerializerUtil.ns_map + serializer = SerializerUtil.serializer + + def provide_error_response_content(self, message: str) -> str: + ojp = Ojp(ojpresponse=Ojpresponse(service_delivery= + ServiceDelivery(response_timestamp=XmlDateTime.from_datetime( + datetime.datetime.now(datetime.timezone.utc)), + producer_ref="OJP2NOVA", + error_condition=ServiceDeliveryStructure.ErrorCondition( + other_error=OtherError(message))))) + return self.serializer.render(ojp, ns_map=self.ns_map) diff --git a/api/FareServiceOjp1.py b/api/ojp1/FareServiceOjp1.py similarity index 55% rename from api/FareServiceOjp1.py rename to api/ojp1/FareServiceOjp1.py index d5e5ce1..102264e 100644 --- a/api/FareServiceOjp1.py +++ b/api/ojp1/FareServiceOjp1.py @@ -1,16 +1,18 @@ import logging + from fastapi import Response from api.ErrorHandler import ErrorHandler from api.FareService import FareService -from api.errors import ApiError +from api.SerializerUtil import SerializerUtil +from api.errors.ApiError import ApiError from api.errors.InvalidOjpRequestError import InvalidOjpRequestError from api.errors.OjpRequestParseError import OjpRequestParseError +from api.ojp1.ErrorResponseProviderOjp1 import ErrorResponseProviderOjp1 from map_nova_to_ojp import map_nova_reply_to_ojp_fare_delivery from map_ojp_to_ojp import parse_ojp -from ojp import OjpfareDelivery, Ojpresponse, ServiceDelivery -from ojp2 import Ojp, ParticipantRefStructure -from test_network_flow import test_nova_request_reply +from ojp import OjpfareDelivery, Ojpresponse, ServiceDelivery, Ojp +from test_network_flow import test_nova_request_reply,call_ojp_2000 class FareServiceOjp1(FareService): @@ -19,20 +21,28 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) def handle_request(self, body: bytes) -> Response: - error_handler = ErrorHandler() + error_handler = ErrorHandler(ErrorResponseProviderOjp1()) try: ojp_fare_request = _parse_request(body) _validate_request(ojp_fare_request) - self.logger.debug("Query to NOVA: " + str(ojp_fare_request)) - nova_response = test_nova_request_reply(ojp_fare_request) - ojp_fare_delivery = map_nova_reply_to_ojp_fare_delivery(nova_response) + if ojp_fare_request.ojprequest.service_request.ojpfare_request: + self.logger.debug("Query to NOVA: " + str(ojp_fare_request)) + nova_response = test_nova_request_reply(ojp_fare_request) + + + ojp_fare_delivery = map_nova_reply_to_ojp_fare_delivery(nova_response) - self.logger.debug("Workable NOVA response put into OJP: " + str(ojp_fare_delivery)) - return _create_response(ojp_fare_delivery) + self.logger.debug("Workable NOVA response put into OJP: " + str(ojp_fare_delivery)) + return _create_response(ojp_fare_delivery) + else: + self.logger.debug("Returning the call to the OJP server:" + str(body.decode("utf-8"))) + s, r = call_ojp_2000(body.decode("utf-8")) + return Response(r, media_type="application/xml; charset=utf-8", status_code=s) except ApiError as error: - error_handler.handle_error(error) + return error_handler.handle_error(error) + def _validate_request(ojp_fare_request: Ojp): if ojp_fare_request.ojprequest is None: @@ -41,25 +51,25 @@ def _validate_request(ojp_fare_request: Ojp): if ojp_fare_request.ojprequest.service_request.ojpfare_request is None: raise InvalidOjpRequestError() + def _parse_request(body: bytes) -> Ojp: try: return parse_ojp(body.decode("utf-8")) except Exception as e: raise OjpRequestParseError(cause=e) + def _create_response(ojp_fare_delivery: OjpfareDelivery) -> Response: - xml = FareServiceOjp1.serializer.render( + xml = SerializerUtil.serializer.render( Ojp( ojpresponse=Ojpresponse( service_delivery=ServiceDelivery( response_timestamp=ojp_fare_delivery.response_timestamp, - producer_ref=ParticipantRefStructure( - value="OJP2NOVA" - ), + producer_ref="OJP2NOVA", ojpfare_delivery=[ojp_fare_delivery], ) ) ), - ns_map=FareServiceOjp1.ns_map, + ns_map=SerializerUtil.ns_map, ) - return Response(xml, media_type="application/xml; charset=utf-8") \ No newline at end of file + return Response(xml, media_type="application/xml; charset=utf-8") diff --git a/api/ojp1/__init__.py b/api/ojp1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/ojp2/ErrorResponseProviderOjp2.py b/api/ojp2/ErrorResponseProviderOjp2.py new file mode 100644 index 0000000..f44fe08 --- /dev/null +++ b/api/ojp2/ErrorResponseProviderOjp2.py @@ -0,0 +1,21 @@ +import datetime + +from xsdata.models.datatype import XmlDateTime + +from api.SerializerUtil import SerializerUtil +from api.ErrorResponseProvider import ErrorResponseProvider +from ojp2 import Ojp, Ojpresponse, ServiceDelivery, ServiceDeliveryStructure, OtherError + + +class ErrorResponseProviderOjp2(ErrorResponseProvider): + ns_map = SerializerUtil.ns_map + serializer = SerializerUtil.serializer + + def provide_error_response_content(self, message: str) -> str: + ojp = Ojp(ojpresponse=Ojpresponse(service_delivery= + ServiceDelivery(response_timestamp=XmlDateTime.from_datetime( + datetime.datetime.now(datetime.timezone.utc)), + producer_ref="OJP2NOVA", + error_condition=ServiceDeliveryStructure.ErrorCondition( + other_error=OtherError(message))))) + return self.serializer.render(ojp, ns_map=self.ns_map) diff --git a/api/FareServiceOjp2.py b/api/ojp2/FareServiceOjp2.py similarity index 62% rename from api/FareServiceOjp2.py rename to api/ojp2/FareServiceOjp2.py index fcf43b5..8716c88 100644 --- a/api/FareServiceOjp2.py +++ b/api/ojp2/FareServiceOjp2.py @@ -1,15 +1,19 @@ import logging + from fastapi import Response from api.ErrorHandler import ErrorHandler from api.FareService import FareService -from api.errors import ApiError +from api.SerializerUtil import SerializerUtil +from api.errors.ApiError import ApiError from api.errors.InvalidOjpRequestError import InvalidOjpRequestError from api.errors.OjpRequestParseError import OjpRequestParseError +from api.ojp2.ErrorResponseProviderOjp2 import ErrorResponseProviderOjp2 from map_nova_to_ojp2 import map_nova_reply_to_ojp_fare_delivery from map_ojp2_to_ojp2 import parse_ojp2 from ojp2 import Ojp, OjpfareDelivery, Ojpresponse, ServiceDelivery, ParticipantRefStructure -from test_network_flow import test_nova_request_reply_for_ojp2 +from test_network_flow import test_nova_request_reply_for_ojp2, call_ojp_20 + class FareServiceOjp2(FareService): def __init__(self, *args, **kwargs) -> None: @@ -17,20 +21,30 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) def handle_request(self, body: bytes) -> Response: - error_handler = ErrorHandler() + error_handler = ErrorHandler(ErrorResponseProviderOjp2()) try: ojp_fare_request = _parse_request(body) _validate_request(ojp_fare_request) self.logger.debug("Query to NOVA: " + str(ojp_fare_request)) - nova_response = test_nova_request_reply_for_ojp2(ojp_fare_request) - ojp_fare_delivery = map_nova_reply_to_ojp_fare_delivery(nova_response) - self.logger.debug("Workable NOVA response put into OJP: " + str(ojp_fare_delivery)) - return _create_response(ojp_fare_delivery) + if ojp_fare_request.ojprequest.service_request.ojpfare_request: + nova_response = test_nova_request_reply_for_ojp2(ojp_fare_request) + ojp_fare_delivery = map_nova_reply_to_ojp_fare_delivery(nova_response) + + self.logger.debug("Workable NOVA response put into OJP: " + str(ojp_fare_delivery)) + return _create_response(ojp_fare_delivery) + else: + self.logger.debug("Returning the call to the OJP server:" + str(body.decode("utf-8"))) + + s, r = call_ojp_20(body.decode("utf-8")) + return Response( + r, media_type="application/xml; charset=utf-8", status_code=s + ) except ApiError as error: - error_handler.handle_error(error) + return error_handler.handle_error(error) + def _validate_request(ojp_fare_request: Ojp): if ojp_fare_request.ojprequest is None: @@ -39,14 +53,16 @@ def _validate_request(ojp_fare_request: Ojp): if ojp_fare_request.ojprequest.service_request.ojpfare_request is None: raise InvalidOjpRequestError() + def _parse_request(body: bytes) -> Ojp: try: return parse_ojp2(body.decode("utf-8")) except Exception as e: raise OjpRequestParseError(cause=e) + def _create_response(ojp_fare_delivery: OjpfareDelivery) -> Response: - xml = FareServiceOjp2.serializer.render( + xml = SerializerUtil.serializer.render( Ojp( ojpresponse=Ojpresponse( service_delivery=ServiceDelivery( @@ -58,9 +74,6 @@ def _create_response(ojp_fare_delivery: OjpfareDelivery) -> Response: ) ) ), - ns_map=FareServiceOjp2.ns_map, + ns_map=SerializerUtil.ns_map, ) return Response(xml, media_type="application/xml; charset=utf-8") - - - diff --git a/api/ojp2/__init__.py b/api/ojp2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/map_nova_to_ojp.py b/map_nova_to_ojp.py index 15aeff6..81eb281 100644 --- a/map_nova_to_ojp.py +++ b/map_nova_to_ojp.py @@ -5,6 +5,7 @@ from xsdata.models.datatype import XmlDateTime +from api.errors.InvalidNovaResponseError import InvalidNovaResponseError from nova import ErstellePreisAuskunftResponse, KlassenTypCode, PreisAuspraegung, \ PreisAuskunftServicePortTypeSoapv14ErstellePreisAuskunftOutput from ojp import OjpfareDelivery, FareResultStructure, FareProductStructure, TripFareResultStructure, \ @@ -52,7 +53,7 @@ def map_preis_auspraegung_to_trip_fare_result(preis_auspraegungen: List[PreisAus def map_nova_reply_to_ojp_fare_delivery(soap: PreisAuskunftServicePortTypeSoapv14ErstellePreisAuskunftOutput) -> Optional[OjpfareDelivery]: if not soap.body.erstelle_preis_auskunft_response.preis_auskunft_response.preis_auspraegung: - return None + raise InvalidNovaResponseError() bonded_trips: dict[str,PreisAuspraegung] = {} for preis_auspraegung in soap.body.erstelle_preis_auskunft_response.preis_auskunft_response.preis_auspraegung: diff --git a/server.py b/server.py index 01c7e4d..2d72b3b 100644 --- a/server.py +++ b/server.py @@ -1,24 +1,43 @@ #!/usr/bin/env python3 from fastapi import FastAPI, Request, Response -from api.FareServiceOjp1 import FareServiceOjp1 + +from api.OjpVersionParser import OjpVersionParser +from api.ojp1.FareServiceOjp1 import FareServiceOjp1 +from api.ojp2.FareServiceOjp2 import FareServiceOjp2 from configuration import HTTP_HOST, HTTP_PORT, HTTPS, SSL_CERTFILE, SSL_KEYFILE, HTTP_SLUG import logging import app_logging -# from support import add_error_response - app_logging.setup_logging() logger = logging.getLogger(__name__) app = FastAPI(title="OJPTONOVA") -fare_service = FareServiceOjp1() +fare_service1 = FareServiceOjp1() +fare_service2 = FareServiceOjp2() + +version_parser = OjpVersionParser() + +# implements basic liveness probe +@app.get("/health/liveness", tags=["Health"]) +async def liveness(fastapi_req: Request): + return Response("Liveness: OK", media_type="text/plain; charset=utf-8") + +# implements basic readiness probe +@app.get("/health/readiness", tags=["Health"]) +async def readiness(fastapi_req: Request): + return Response("Readiness: OK", media_type="text/plain; charset=utf-8") @app.post("/" + HTTP_SLUG, tags=["Open Journey Planner"]) async def post_request(fastapi_req: Request) ->Response: body = await fastapi_req.body() logger.debug("Received request: " + str(body)) - return fare_service.handle_request(body) + + version = version_parser.parse_version(str(body)) + if version == "1.0": + return fare_service1.handle_request(body) + else: + return fare_service2.handle_request(body) if __name__ == "__main__": import uvicorn diff --git a/server_ojp1.py b/server_ojp1.py new file mode 100644 index 0000000..106188c --- /dev/null +++ b/server_ojp1.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +from fastapi import FastAPI, Request, Response + +from api.OjpVersionParser import OjpVersionParser +from api.ojp1.FareServiceOjp1 import FareServiceOjp1 +from api.ojp2.FareServiceOjp2 import FareServiceOjp2 +from configuration import HTTP_HOST, HTTP_PORT, HTTPS, SSL_CERTFILE, SSL_KEYFILE, HTTP_SLUG +import logging +import app_logging + +app_logging.setup_logging() +logger = logging.getLogger(__name__) +app = FastAPI(title="OJPTONOVA") +fare_service1 = FareServiceOjp1() + +# implements basic liveness probe +@app.get("/health/liveness", tags=["Health"]) +async def liveness(fastapi_req: Request): + return Response("Liveness: OK", media_type="text/plain; charset=utf-8") + +# implements basic readiness probe +@app.get("/health/readiness", tags=["Health"]) +async def readiness(fastapi_req: Request): + return Response("Readiness: OK", media_type="text/plain; charset=utf-8") + +@app.post("/" + HTTP_SLUG, tags=["Open Journey Planner"]) +async def post_request(fastapi_req: Request) ->Response: + body = await fastapi_req.body() + logger.debug("Received request: " + str(body)) + return fare_service1.handle_request(body) + +if __name__ == "__main__": + import uvicorn + + if HTTPS: + uvicorn.run(app, host=HTTP_HOST, port=HTTP_PORT, ssl_keyfile=SSL_KEYFILE, ssl_certfile=SSL_CERTFILE) + else: + uvicorn.run(app, host=HTTP_HOST, port=HTTP_PORT) diff --git a/server_ojp2.py b/server_ojp2.py index 8461909..c8f3852 100644 --- a/server_ojp2.py +++ b/server_ojp2.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 from fastapi import FastAPI, Request, Response -from api.FareServiceOjp2 import FareServiceOjp2 +from api.ojp2.FareServiceOjp2 import FareServiceOjp2 from configuration import ( HTTP_HOST, HTTP_PORT, @@ -20,6 +20,16 @@ fare_service = FareServiceOjp2() +# implements basic liveness probe +@app.get("/health/liveness", tags=["Health"]) +async def liveness(fastapi_req: Request): + return Response("Liveness: OK", media_type="text/plain; charset=utf-8") + +# implements basic readiness probe +@app.get("/health/readiness", tags=["Health"]) +async def readiness(fastapi_req: Request): + return Response("Readiness: OK", media_type="text/plain; charset=utf-8") + @app.post("/" + HTTP_SLUG, tags=["Open Journey Planner"]) async def post_request(fastapi_req: Request) ->Response: body = await fastapi_req.body() From 738b0f8608f5e5b27b84c5c7486023fde93aa9d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urs=20St=C3=B6ckli?= Date: Fri, 30 Jan 2026 08:00:00 +0100 Subject: [PATCH 03/11] SKIPLUS-1012: cleanup logging code. --- api/ojp1/FareServiceOjp1.py | 8 +++----- api/ojp2/FareServiceOjp2.py | 12 ++++-------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/api/ojp1/FareServiceOjp1.py b/api/ojp1/FareServiceOjp1.py index 102264e..a5eea38 100644 --- a/api/ojp1/FareServiceOjp1.py +++ b/api/ojp1/FareServiceOjp1.py @@ -25,18 +25,16 @@ def handle_request(self, body: bytes) -> Response: try: ojp_fare_request = _parse_request(body) _validate_request(ojp_fare_request) + self.logger.debug("Request passed validation: " + str(ojp_fare_request)) if ojp_fare_request.ojprequest.service_request.ojpfare_request: - self.logger.debug("Query to NOVA: " + str(ojp_fare_request)) + self.logger.debug("Fare request - about to query NOVA: " + str(ojp_fare_request)) nova_response = test_nova_request_reply(ojp_fare_request) - - ojp_fare_delivery = map_nova_reply_to_ojp_fare_delivery(nova_response) - self.logger.debug("Workable NOVA response put into OJP: " + str(ojp_fare_delivery)) return _create_response(ojp_fare_delivery) else: - self.logger.debug("Returning the call to the OJP server:" + str(body.decode("utf-8"))) + self.logger.debug("OJP request - returning the call to the OJP server:" + str(body.decode("utf-8"))) s, r = call_ojp_2000(body.decode("utf-8")) return Response(r, media_type="application/xml; charset=utf-8", status_code=s) diff --git a/api/ojp2/FareServiceOjp2.py b/api/ojp2/FareServiceOjp2.py index 8716c88..528f2db 100644 --- a/api/ojp2/FareServiceOjp2.py +++ b/api/ojp2/FareServiceOjp2.py @@ -25,22 +25,18 @@ def handle_request(self, body: bytes) -> Response: try: ojp_fare_request = _parse_request(body) _validate_request(ojp_fare_request) - - self.logger.debug("Query to NOVA: " + str(ojp_fare_request)) + self.logger.debug("Request passed validation: " + str(ojp_fare_request)) if ojp_fare_request.ojprequest.service_request.ojpfare_request: + self.logger.debug("Fare request - about to query NOVA: " + str(ojp_fare_request)) nova_response = test_nova_request_reply_for_ojp2(ojp_fare_request) ojp_fare_delivery = map_nova_reply_to_ojp_fare_delivery(nova_response) - self.logger.debug("Workable NOVA response put into OJP: " + str(ojp_fare_delivery)) return _create_response(ojp_fare_delivery) else: - self.logger.debug("Returning the call to the OJP server:" + str(body.decode("utf-8"))) - + self.logger.debug("OJP request - returning the call to the OJP server:" + str(body.decode("utf-8"))) s, r = call_ojp_20(body.decode("utf-8")) - return Response( - r, media_type="application/xml; charset=utf-8", status_code=s - ) + return Response(r, media_type="application/xml; charset=utf-8", status_code=s) except ApiError as error: return error_handler.handle_error(error) From cdaf0666736da8e9bf5b0de4deebf119c3748264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urs=20St=C3=B6ckli?= Date: Fri, 30 Jan 2026 08:55:58 +0100 Subject: [PATCH 04/11] SKIPLUS-1012: add unit test for OjpVersionParser. --- api/ErrorHandlerTest.py | 19 ++++++++++------ api/OjpVersionParserTest.py | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 api/OjpVersionParserTest.py diff --git a/api/ErrorHandlerTest.py b/api/ErrorHandlerTest.py index b3845ee..e60cfbc 100644 --- a/api/ErrorHandlerTest.py +++ b/api/ErrorHandlerTest.py @@ -1,3 +1,4 @@ +import logging import unittest from api.ErrorHandler import ErrorHandler @@ -9,26 +10,29 @@ class ErrorHandlerTest(unittest.TestCase): - def test_internal_server_error_ojp1(self): - self._catch_internal_server_error(ErrorResponseProviderOjp1()) + logger = logging.getLogger(__name__) - def test_api_error(self): - self._catch_api_error(ErrorResponseProviderOjp1()) + def test_ojp1_WHEN_internal_server_error_EXPECT_error_response(self): + self._catch_internal_server_error(ErrorResponseProviderOjp1()) - def test_internal_server_error_ojp2(self): + def test_ojp2_WHEN_internal_server_EXPECT_error_response(self): self._catch_internal_server_error(ErrorResponseProviderOjp2()) + def test_ojp1_WHEN_api_error_EXPECT_error_response(self): + self._catch_api_error(ErrorResponseProviderOjp1()) + def _catch_internal_server_error(self, error_response_provider: ErrorResponseProvider): # prepare test case error_handler = ErrorHandler(error_response_provider) self.assertIsNotNone(error_handler) - message = "Oups, Terrible Failure ;-)" + message = "Oups, terrible failure ;-)" try: raise InternalServerError(message) # run test case except InternalServerError as e: + self.logger.info("caught InternalServerError ...") response = error_handler.handle_error(e) # assert expectations @@ -39,12 +43,13 @@ def _catch_api_error(self, error_response_provider: ErrorResponseProvider): # prepare test case error_handler = ErrorHandler(error_response_provider) self.assertIsNotNone(error_handler) - message = "Oups, Terrible Failure ;-)" + message = "Oups, terrible failure ;-)" try: raise InternalServerError(message) # run test case except ApiError as e: + self.logger.info("caught ApiError ...") response = error_handler.handle_error(e) # assert expectations diff --git a/api/OjpVersionParserTest.py b/api/OjpVersionParserTest.py new file mode 100644 index 0000000..4b0c9b6 --- /dev/null +++ b/api/OjpVersionParserTest.py @@ -0,0 +1,43 @@ +import logging +import unittest + +from api.OjpVersionParser import OjpVersionParser +from api.errors.InvalidOjpRequestError import InvalidOjpRequestError + + +class OjpVersionParserTest(unittest.TestCase): + + logger = logging.getLogger(__name__) + + def test_ojp1_WHEN_parse_EXPECT_1(self): + version_parser = OjpVersionParser() + payload = '' + version = version_parser.parse_version(payload) + self.assertEqual(version, '1.0') + + def test_ojp2_WHEN_parse_EXPECT_2(self): + version_parser = OjpVersionParser() + payload = '' + version = version_parser.parse_version(payload) + self.assertEqual(version, '2.0') + + def test_missing_version_WHEN_parse_EXPECT_failure(self): + version_parser = OjpVersionParser() + payload = '' + with self.assertRaises(InvalidOjpRequestError): + version_parser.parse_version(payload) + + def test_missing_ojp_element_WHEN_parse_EXPECT_failure(self): + version_parser = OjpVersionParser() + payload = '' + with self.assertRaises(InvalidOjpRequestError): + version_parser.parse_version(payload) + + def test_no_xml_WHEN_parse_EXPECT_failure(self): + version_parser = OjpVersionParser() + payload = 'Test' + with self.assertRaises(InvalidOjpRequestError): + version_parser.parse_version(payload) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From 974afabadda5337c7185874865baab1d125f0ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urs=20St=C3=B6ckli?= Date: Tue, 3 Feb 2026 13:14:51 +0100 Subject: [PATCH 05/11] SKIPLUS-1012: rename some classes. --- api/ErrorHandler.py | 4 ++-- api/ErrorHandlerTest.py | 14 +++++++------- ...Provider.py => ErrorResponseContentProvider.py} | 2 +- api/{FareService.py => OjpFareService.py} | 2 +- ...Ojp1.py => ErrorResponseContentProviderOjp1.py} | 4 ++-- .../{FareServiceOjp1.py => OjpFareServiceOjp1.py} | 8 ++++---- ...Ojp2.py => ErrorResponseContentProviderOjp2.py} | 4 ++-- .../{FareServiceOjp2.py => OjpFareServiceOjp2.py} | 6 +++--- server.py | 4 ++-- server_ojp1.py | 4 ++-- server_ojp2.py | 2 +- 11 files changed, 27 insertions(+), 27 deletions(-) rename api/{ErrorResponseProvider.py => ErrorResponseContentProvider.py} (78%) rename api/{FareService.py => OjpFareService.py} (91%) rename api/ojp1/{ErrorResponseProviderOjp1.py => ErrorResponseContentProviderOjp1.py} (86%) rename api/ojp1/{FareServiceOjp1.py => OjpFareServiceOjp1.py} (92%) rename api/ojp2/{ErrorResponseProviderOjp2.py => ErrorResponseContentProviderOjp2.py} (87%) rename api/ojp2/{FareServiceOjp2.py => OjpFareServiceOjp2.py} (94%) diff --git a/api/ErrorHandler.py b/api/ErrorHandler.py index 4933120..e783ce9 100644 --- a/api/ErrorHandler.py +++ b/api/ErrorHandler.py @@ -2,13 +2,13 @@ from fastapi import Response -from api.ErrorResponseProvider import ErrorResponseProvider +from api.ErrorResponseContentProvider import ErrorResponseContentProvider from api.errors import ApiError class ErrorHandler: - def __init__(self, response_provider: ErrorResponseProvider): + def __init__(self, response_provider: ErrorResponseContentProvider): self.response_provider = response_provider self.logger = logging.getLogger(__name__) diff --git a/api/ErrorHandlerTest.py b/api/ErrorHandlerTest.py index e60cfbc..cc82587 100644 --- a/api/ErrorHandlerTest.py +++ b/api/ErrorHandlerTest.py @@ -2,26 +2,26 @@ import unittest from api.ErrorHandler import ErrorHandler -from api.ErrorResponseProvider import ErrorResponseProvider +from api.ErrorResponseContentProvider import ErrorResponseContentProvider from api.errors.ApiError import ApiError from api.errors.InternalServerError import InternalServerError -from api.ojp1.ErrorResponseProviderOjp1 import ErrorResponseProviderOjp1 -from api.ojp2.ErrorResponseProviderOjp2 import ErrorResponseProviderOjp2 +from api.ojp1.ErrorResponseContentProviderOjp1 import ErrorResponseContentProviderOjp1 +from api.ojp2.ErrorResponseContentProviderOjp2 import ErrorResponseProviderOjp2 class ErrorHandlerTest(unittest.TestCase): logger = logging.getLogger(__name__) def test_ojp1_WHEN_internal_server_error_EXPECT_error_response(self): - self._catch_internal_server_error(ErrorResponseProviderOjp1()) + self._catch_internal_server_error(ErrorResponseContentProviderOjp1()) def test_ojp2_WHEN_internal_server_EXPECT_error_response(self): self._catch_internal_server_error(ErrorResponseProviderOjp2()) def test_ojp1_WHEN_api_error_EXPECT_error_response(self): - self._catch_api_error(ErrorResponseProviderOjp1()) + self._catch_api_error(ErrorResponseContentProviderOjp1()) - def _catch_internal_server_error(self, error_response_provider: ErrorResponseProvider): + def _catch_internal_server_error(self, error_response_provider: ErrorResponseContentProvider): # prepare test case error_handler = ErrorHandler(error_response_provider) @@ -38,7 +38,7 @@ def _catch_internal_server_error(self, error_response_provider: ErrorResponsePro # assert expectations self.assertIsNotNone(response) - def _catch_api_error(self, error_response_provider: ErrorResponseProvider): + def _catch_api_error(self, error_response_provider: ErrorResponseContentProvider): # prepare test case error_handler = ErrorHandler(error_response_provider) diff --git a/api/ErrorResponseProvider.py b/api/ErrorResponseContentProvider.py similarity index 78% rename from api/ErrorResponseProvider.py rename to api/ErrorResponseContentProvider.py index c63a445..c7d4b8d 100644 --- a/api/ErrorResponseProvider.py +++ b/api/ErrorResponseContentProvider.py @@ -1,7 +1,7 @@ from abc import abstractmethod, ABC -class ErrorResponseProvider(ABC): +class ErrorResponseContentProvider(ABC): @abstractmethod def provide_error_response_content(self, message: str) -> str: return message diff --git a/api/FareService.py b/api/OjpFareService.py similarity index 91% rename from api/FareService.py rename to api/OjpFareService.py index 0ef5306..a8f989b 100644 --- a/api/FareService.py +++ b/api/OjpFareService.py @@ -4,7 +4,7 @@ from fastapi import Response -class FareService(ABC): +class OjpFareService(ABC): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) diff --git a/api/ojp1/ErrorResponseProviderOjp1.py b/api/ojp1/ErrorResponseContentProviderOjp1.py similarity index 86% rename from api/ojp1/ErrorResponseProviderOjp1.py rename to api/ojp1/ErrorResponseContentProviderOjp1.py index a1f8fb1..2bfa821 100644 --- a/api/ojp1/ErrorResponseProviderOjp1.py +++ b/api/ojp1/ErrorResponseContentProviderOjp1.py @@ -2,12 +2,12 @@ from xsdata.models.datatype import XmlDateTime -from api.ErrorResponseProvider import ErrorResponseProvider +from api.ErrorResponseContentProvider import ErrorResponseContentProvider from api.SerializerUtil import SerializerUtil from ojp import Ojp, Ojpresponse, ServiceDelivery, ServiceDeliveryStructure, OtherError -class ErrorResponseProviderOjp1(ErrorResponseProvider): +class ErrorResponseContentProviderOjp1(ErrorResponseContentProvider): ns_map = SerializerUtil.ns_map serializer = SerializerUtil.serializer diff --git a/api/ojp1/FareServiceOjp1.py b/api/ojp1/OjpFareServiceOjp1.py similarity index 92% rename from api/ojp1/FareServiceOjp1.py rename to api/ojp1/OjpFareServiceOjp1.py index a5eea38..1194686 100644 --- a/api/ojp1/FareServiceOjp1.py +++ b/api/ojp1/OjpFareServiceOjp1.py @@ -3,25 +3,25 @@ from fastapi import Response from api.ErrorHandler import ErrorHandler -from api.FareService import FareService +from api.OjpFareService import OjpFareService from api.SerializerUtil import SerializerUtil from api.errors.ApiError import ApiError from api.errors.InvalidOjpRequestError import InvalidOjpRequestError from api.errors.OjpRequestParseError import OjpRequestParseError -from api.ojp1.ErrorResponseProviderOjp1 import ErrorResponseProviderOjp1 +from api.ojp1.ErrorResponseContentProviderOjp1 import ErrorResponseContentProviderOjp1 from map_nova_to_ojp import map_nova_reply_to_ojp_fare_delivery from map_ojp_to_ojp import parse_ojp from ojp import OjpfareDelivery, Ojpresponse, ServiceDelivery, Ojp from test_network_flow import test_nova_request_reply,call_ojp_2000 -class FareServiceOjp1(FareService): +class FareServiceOjp1(OjpFareService): def __init__(self, *args, **kwargs) -> None: self.logger = logging.getLogger(__name__) super().__init__(*args, **kwargs) def handle_request(self, body: bytes) -> Response: - error_handler = ErrorHandler(ErrorResponseProviderOjp1()) + error_handler = ErrorHandler(ErrorResponseContentProviderOjp1()) try: ojp_fare_request = _parse_request(body) _validate_request(ojp_fare_request) diff --git a/api/ojp2/ErrorResponseProviderOjp2.py b/api/ojp2/ErrorResponseContentProviderOjp2.py similarity index 87% rename from api/ojp2/ErrorResponseProviderOjp2.py rename to api/ojp2/ErrorResponseContentProviderOjp2.py index f44fe08..1d77163 100644 --- a/api/ojp2/ErrorResponseProviderOjp2.py +++ b/api/ojp2/ErrorResponseContentProviderOjp2.py @@ -3,11 +3,11 @@ from xsdata.models.datatype import XmlDateTime from api.SerializerUtil import SerializerUtil -from api.ErrorResponseProvider import ErrorResponseProvider +from api.ErrorResponseContentProvider import ErrorResponseContentProvider from ojp2 import Ojp, Ojpresponse, ServiceDelivery, ServiceDeliveryStructure, OtherError -class ErrorResponseProviderOjp2(ErrorResponseProvider): +class ErrorResponseProviderOjp2(ErrorResponseContentProvider): ns_map = SerializerUtil.ns_map serializer = SerializerUtil.serializer diff --git a/api/ojp2/FareServiceOjp2.py b/api/ojp2/OjpFareServiceOjp2.py similarity index 94% rename from api/ojp2/FareServiceOjp2.py rename to api/ojp2/OjpFareServiceOjp2.py index 528f2db..f9c5670 100644 --- a/api/ojp2/FareServiceOjp2.py +++ b/api/ojp2/OjpFareServiceOjp2.py @@ -3,19 +3,19 @@ from fastapi import Response from api.ErrorHandler import ErrorHandler -from api.FareService import FareService +from api.OjpFareService import OjpFareService from api.SerializerUtil import SerializerUtil from api.errors.ApiError import ApiError from api.errors.InvalidOjpRequestError import InvalidOjpRequestError from api.errors.OjpRequestParseError import OjpRequestParseError -from api.ojp2.ErrorResponseProviderOjp2 import ErrorResponseProviderOjp2 +from api.ojp2.ErrorResponseContentProviderOjp2 import ErrorResponseProviderOjp2 from map_nova_to_ojp2 import map_nova_reply_to_ojp_fare_delivery from map_ojp2_to_ojp2 import parse_ojp2 from ojp2 import Ojp, OjpfareDelivery, Ojpresponse, ServiceDelivery, ParticipantRefStructure from test_network_flow import test_nova_request_reply_for_ojp2, call_ojp_20 -class FareServiceOjp2(FareService): +class FareServiceOjp2(OjpFareService): def __init__(self, *args, **kwargs) -> None: self.logger = logging.getLogger(__name__) super().__init__(*args, **kwargs) diff --git a/server.py b/server.py index 2d72b3b..452bae0 100644 --- a/server.py +++ b/server.py @@ -3,8 +3,8 @@ from fastapi import FastAPI, Request, Response from api.OjpVersionParser import OjpVersionParser -from api.ojp1.FareServiceOjp1 import FareServiceOjp1 -from api.ojp2.FareServiceOjp2 import FareServiceOjp2 +from api.ojp1.OjpFareServiceOjp1 import FareServiceOjp1 +from api.ojp2.OjpFareServiceOjp2 import FareServiceOjp2 from configuration import HTTP_HOST, HTTP_PORT, HTTPS, SSL_CERTFILE, SSL_KEYFILE, HTTP_SLUG import logging import app_logging diff --git a/server_ojp1.py b/server_ojp1.py index 106188c..876bd44 100644 --- a/server_ojp1.py +++ b/server_ojp1.py @@ -3,8 +3,8 @@ from fastapi import FastAPI, Request, Response from api.OjpVersionParser import OjpVersionParser -from api.ojp1.FareServiceOjp1 import FareServiceOjp1 -from api.ojp2.FareServiceOjp2 import FareServiceOjp2 +from api.ojp1.OjpFareServiceOjp1 import FareServiceOjp1 +from api.ojp2.OjpFareServiceOjp2 import FareServiceOjp2 from configuration import HTTP_HOST, HTTP_PORT, HTTPS, SSL_CERTFILE, SSL_KEYFILE, HTTP_SLUG import logging import app_logging diff --git a/server_ojp2.py b/server_ojp2.py index c8f3852..b317dc9 100644 --- a/server_ojp2.py +++ b/server_ojp2.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 from fastapi import FastAPI, Request, Response -from api.ojp2.FareServiceOjp2 import FareServiceOjp2 +from api.ojp2.OjpFareServiceOjp2 import FareServiceOjp2 from configuration import ( HTTP_HOST, HTTP_PORT, From 439d610fa1ba099045f5dde25183f47b8f1c5a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urs=20St=C3=B6ckli?= Date: Tue, 3 Feb 2026 14:03:42 +0100 Subject: [PATCH 06/11] SKIPLUS-1012: add some Docstring comments, optimize imports. --- api/ErrorHandler.py | 3 +++ api/ErrorHandlerTest.py | 1 + api/ErrorResponseContentProvider.py | 3 +++ api/OjpFareService.py | 3 +++ api/ojp1/ErrorResponseContentProviderOjp1.py | 3 +++ api/ojp1/OjpFareServiceOjp1.py | 5 ++++- api/ojp2/ErrorResponseContentProviderOjp2.py | 5 ++++- api/ojp2/OjpFareServiceOjp2.py | 3 +++ server.py | 16 ++++++++++++---- server_ojp1.py | 18 ++++++++++++------ server_ojp2.py | 17 +++++++++++++---- 11 files changed, 61 insertions(+), 16 deletions(-) diff --git a/api/ErrorHandler.py b/api/ErrorHandler.py index e783ce9..a6d2e10 100644 --- a/api/ErrorHandler.py +++ b/api/ErrorHandler.py @@ -13,6 +13,9 @@ def __init__(self, response_provider: ErrorResponseContentProvider): self.logger = logging.getLogger(__name__) def handle_error(self, error: type[ApiError]) -> Response: + """ + Handles an ApiError and creates an according Response using a ErrorResponseContentProvider. + """ error.log_error() return Response( self.response_provider.provide_error_response_content(error.message), diff --git a/api/ErrorHandlerTest.py b/api/ErrorHandlerTest.py index cc82587..dbb4c57 100644 --- a/api/ErrorHandlerTest.py +++ b/api/ErrorHandlerTest.py @@ -8,6 +8,7 @@ from api.ojp1.ErrorResponseContentProviderOjp1 import ErrorResponseContentProviderOjp1 from api.ojp2.ErrorResponseContentProviderOjp2 import ErrorResponseProviderOjp2 + class ErrorHandlerTest(unittest.TestCase): logger = logging.getLogger(__name__) diff --git a/api/ErrorResponseContentProvider.py b/api/ErrorResponseContentProvider.py index c7d4b8d..d2e7d36 100644 --- a/api/ErrorResponseContentProvider.py +++ b/api/ErrorResponseContentProvider.py @@ -4,4 +4,7 @@ class ErrorResponseContentProvider(ABC): @abstractmethod def provide_error_response_content(self, message: str) -> str: + """ + Provides the error response content for the given error message. + """ return message diff --git a/api/OjpFareService.py b/api/OjpFareService.py index a8f989b..82c8057 100644 --- a/api/OjpFareService.py +++ b/api/OjpFareService.py @@ -11,4 +11,7 @@ def __init__(self, *args, **kwargs) -> None: @abstractmethod def handle_request(self, body: bytes) -> Response: + """ + Handles an ojp request. + """ return Response(body) diff --git a/api/ojp1/ErrorResponseContentProviderOjp1.py b/api/ojp1/ErrorResponseContentProviderOjp1.py index 2bfa821..b079b58 100644 --- a/api/ojp1/ErrorResponseContentProviderOjp1.py +++ b/api/ojp1/ErrorResponseContentProviderOjp1.py @@ -12,6 +12,9 @@ class ErrorResponseContentProviderOjp1(ErrorResponseContentProvider): serializer = SerializerUtil.serializer def provide_error_response_content(self, message: str) -> str: + """ + Provides the ojp1 error response content for the given error message. + """ ojp = Ojp(ojpresponse=Ojpresponse(service_delivery= ServiceDelivery(response_timestamp=XmlDateTime.from_datetime( datetime.datetime.now(datetime.timezone.utc)), diff --git a/api/ojp1/OjpFareServiceOjp1.py b/api/ojp1/OjpFareServiceOjp1.py index 1194686..95d9732 100644 --- a/api/ojp1/OjpFareServiceOjp1.py +++ b/api/ojp1/OjpFareServiceOjp1.py @@ -12,7 +12,7 @@ from map_nova_to_ojp import map_nova_reply_to_ojp_fare_delivery from map_ojp_to_ojp import parse_ojp from ojp import OjpfareDelivery, Ojpresponse, ServiceDelivery, Ojp -from test_network_flow import test_nova_request_reply,call_ojp_2000 +from test_network_flow import test_nova_request_reply, call_ojp_2000 class FareServiceOjp1(OjpFareService): @@ -21,6 +21,9 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) def handle_request(self, body: bytes) -> Response: + """ + Handles an ojp1 request. + """ error_handler = ErrorHandler(ErrorResponseContentProviderOjp1()) try: ojp_fare_request = _parse_request(body) diff --git a/api/ojp2/ErrorResponseContentProviderOjp2.py b/api/ojp2/ErrorResponseContentProviderOjp2.py index 1d77163..013473a 100644 --- a/api/ojp2/ErrorResponseContentProviderOjp2.py +++ b/api/ojp2/ErrorResponseContentProviderOjp2.py @@ -2,8 +2,8 @@ from xsdata.models.datatype import XmlDateTime -from api.SerializerUtil import SerializerUtil from api.ErrorResponseContentProvider import ErrorResponseContentProvider +from api.SerializerUtil import SerializerUtil from ojp2 import Ojp, Ojpresponse, ServiceDelivery, ServiceDeliveryStructure, OtherError @@ -12,6 +12,9 @@ class ErrorResponseProviderOjp2(ErrorResponseContentProvider): serializer = SerializerUtil.serializer def provide_error_response_content(self, message: str) -> str: + """ + Provides the ojp2 error response content for the given error message. + """ ojp = Ojp(ojpresponse=Ojpresponse(service_delivery= ServiceDelivery(response_timestamp=XmlDateTime.from_datetime( datetime.datetime.now(datetime.timezone.utc)), diff --git a/api/ojp2/OjpFareServiceOjp2.py b/api/ojp2/OjpFareServiceOjp2.py index f9c5670..42d21c2 100644 --- a/api/ojp2/OjpFareServiceOjp2.py +++ b/api/ojp2/OjpFareServiceOjp2.py @@ -21,6 +21,9 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) def handle_request(self, body: bytes) -> Response: + """ + Handles an ojp2 request. + """ error_handler = ErrorHandler(ErrorResponseProviderOjp2()) try: ojp_fare_request = _parse_request(body) diff --git a/server.py b/server.py index 452bae0..6045a84 100644 --- a/server.py +++ b/server.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 +import logging + from fastapi import FastAPI, Request, Response +import app_logging from api.OjpVersionParser import OjpVersionParser from api.ojp1.OjpFareServiceOjp1 import FareServiceOjp1 from api.ojp2.OjpFareServiceOjp2 import FareServiceOjp2 from configuration import HTTP_HOST, HTTP_PORT, HTTPS, SSL_CERTFILE, SSL_KEYFILE, HTTP_SLUG -import logging -import app_logging app_logging.setup_logging() logger = logging.getLogger(__name__) @@ -18,18 +19,25 @@ version_parser = OjpVersionParser() -# implements basic liveness probe @app.get("/health/liveness", tags=["Health"]) async def liveness(fastapi_req: Request): + """ + Implements liveness probe. + """ return Response("Liveness: OK", media_type="text/plain; charset=utf-8") -# implements basic readiness probe @app.get("/health/readiness", tags=["Health"]) async def readiness(fastapi_req: Request): + """ + Implements readiness probe. + """ return Response("Readiness: OK", media_type="text/plain; charset=utf-8") @app.post("/" + HTTP_SLUG, tags=["Open Journey Planner"]) async def post_request(fastapi_req: Request) ->Response: + """ + Handles OjpFare requests: for ojp1 or ojp2. + """ body = await fastapi_req.body() logger.debug("Received request: " + str(body)) diff --git a/server_ojp1.py b/server_ojp1.py index 876bd44..2975e61 100644 --- a/server_ojp1.py +++ b/server_ojp1.py @@ -1,31 +1,37 @@ #!/usr/bin/env python3 +import logging + from fastapi import FastAPI, Request, Response -from api.OjpVersionParser import OjpVersionParser +import app_logging from api.ojp1.OjpFareServiceOjp1 import FareServiceOjp1 -from api.ojp2.OjpFareServiceOjp2 import FareServiceOjp2 from configuration import HTTP_HOST, HTTP_PORT, HTTPS, SSL_CERTFILE, SSL_KEYFILE, HTTP_SLUG -import logging -import app_logging app_logging.setup_logging() logger = logging.getLogger(__name__) app = FastAPI(title="OJPTONOVA") fare_service1 = FareServiceOjp1() -# implements basic liveness probe @app.get("/health/liveness", tags=["Health"]) async def liveness(fastapi_req: Request): + """ + Implements liveness probe. + """ return Response("Liveness: OK", media_type="text/plain; charset=utf-8") -# implements basic readiness probe @app.get("/health/readiness", tags=["Health"]) async def readiness(fastapi_req: Request): + """ + Implements readiness probe. + """ return Response("Readiness: OK", media_type="text/plain; charset=utf-8") @app.post("/" + HTTP_SLUG, tags=["Open Journey Planner"]) async def post_request(fastapi_req: Request) ->Response: + """ + Handles OjpFare requests: for ojp1 only. + """ body = await fastapi_req.body() logger.debug("Received request: " + str(body)) return fare_service1.handle_request(body) diff --git a/server_ojp2.py b/server_ojp2.py index b317dc9..e416778 100644 --- a/server_ojp2.py +++ b/server_ojp2.py @@ -1,6 +1,10 @@ #!/usr/bin/env python3 +import logging + from fastapi import FastAPI, Request, Response + +import app_logging from api.ojp2.OjpFareServiceOjp2 import FareServiceOjp2 from configuration import ( HTTP_HOST, @@ -11,27 +15,32 @@ HTTP_SLUG, ) -import logging -import app_logging - app_logging.setup_logging() logger = logging.getLogger(__name__) app = FastAPI(title="OJP2TONOVA") fare_service = FareServiceOjp2() -# implements basic liveness probe @app.get("/health/liveness", tags=["Health"]) async def liveness(fastapi_req: Request): + """ + Implements liveness probe. + """ return Response("Liveness: OK", media_type="text/plain; charset=utf-8") # implements basic readiness probe @app.get("/health/readiness", tags=["Health"]) async def readiness(fastapi_req: Request): + """ + Implements readiness probe. + """ return Response("Readiness: OK", media_type="text/plain; charset=utf-8") @app.post("/" + HTTP_SLUG, tags=["Open Journey Planner"]) async def post_request(fastapi_req: Request) ->Response: + """ + Handles OjpFare requests: for ojp2 only. + """ body = await fastapi_req.body() logger.debug("Received request: " + str(body)) return fare_service.handle_request(body) From 090558b33f10cc7382f73afec61857467fbca1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urs=20St=C3=B6ckli?= Date: Tue, 3 Feb 2026 14:32:50 +0100 Subject: [PATCH 07/11] SKIPLUS-1012: fixes encoding issue. --- server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server.py b/server.py index 6045a84..3d426f7 100644 --- a/server.py +++ b/server.py @@ -41,7 +41,8 @@ async def post_request(fastapi_req: Request) ->Response: body = await fastapi_req.body() logger.debug("Received request: " + str(body)) - version = version_parser.parse_version(str(body)) + version = version_parser.parse_version(body.decode("utf-8")) + logger.debug("Received version: " + str(version)) if version == "1.0": return fare_service1.handle_request(body) else: From f3b13b6219cc2f2e2c047b259f3b7258069cf6da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urs=20St=C3=B6ckli?= Date: Tue, 3 Feb 2026 15:14:49 +0100 Subject: [PATCH 08/11] SKIPLUS-1012: fix issues of error response content provider. --- api/ErrorHandlerTest.py | 4 ++-- api/ojp2/ErrorResponseContentProviderOjp2.py | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/api/ErrorHandlerTest.py b/api/ErrorHandlerTest.py index dbb4c57..f983d17 100644 --- a/api/ErrorHandlerTest.py +++ b/api/ErrorHandlerTest.py @@ -6,7 +6,7 @@ from api.errors.ApiError import ApiError from api.errors.InternalServerError import InternalServerError from api.ojp1.ErrorResponseContentProviderOjp1 import ErrorResponseContentProviderOjp1 -from api.ojp2.ErrorResponseContentProviderOjp2 import ErrorResponseProviderOjp2 +from api.ojp2.ErrorResponseContentProviderOjp2 import ErrorResponseContentProviderOjp2 class ErrorHandlerTest(unittest.TestCase): @@ -17,7 +17,7 @@ def test_ojp1_WHEN_internal_server_error_EXPECT_error_response(self): self._catch_internal_server_error(ErrorResponseContentProviderOjp1()) def test_ojp2_WHEN_internal_server_EXPECT_error_response(self): - self._catch_internal_server_error(ErrorResponseProviderOjp2()) + self._catch_internal_server_error(ErrorResponseContentProviderOjp2()) def test_ojp1_WHEN_api_error_EXPECT_error_response(self): self._catch_api_error(ErrorResponseContentProviderOjp1()) diff --git a/api/ojp2/ErrorResponseContentProviderOjp2.py b/api/ojp2/ErrorResponseContentProviderOjp2.py index 013473a..dda8e55 100644 --- a/api/ojp2/ErrorResponseContentProviderOjp2.py +++ b/api/ojp2/ErrorResponseContentProviderOjp2.py @@ -4,10 +4,11 @@ from api.ErrorResponseContentProvider import ErrorResponseContentProvider from api.SerializerUtil import SerializerUtil -from ojp2 import Ojp, Ojpresponse, ServiceDelivery, ServiceDeliveryStructure, OtherError +from ojp2 import Ojp, Ojpresponse, ServiceDelivery, ServiceDeliveryStructure, OtherError, ParticipantRefStructure, \ + ResponseTimestamp -class ErrorResponseProviderOjp2(ErrorResponseContentProvider): +class ErrorResponseContentProviderOjp2(ErrorResponseContentProvider): ns_map = SerializerUtil.ns_map serializer = SerializerUtil.serializer @@ -16,9 +17,10 @@ def provide_error_response_content(self, message: str) -> str: Provides the ojp2 error response content for the given error message. """ ojp = Ojp(ojpresponse=Ojpresponse(service_delivery= - ServiceDelivery(response_timestamp=XmlDateTime.from_datetime( - datetime.datetime.now(datetime.timezone.utc)), - producer_ref="OJP2NOVA", - error_condition=ServiceDeliveryStructure.ErrorCondition( + ServiceDelivery( + response_timestamp=ResponseTimestamp(XmlDateTime.from_datetime( + datetime.datetime.now(datetime.timezone.utc))), + producer_ref=ParticipantRefStructure("OJP2NOVA"), + error_condition=ServiceDeliveryStructure.ErrorCondition( other_error=OtherError(message))))) - return self.serializer.render(ojp, ns_map=self.ns_map) + return self.serializer.render(ojp, ns_map=self.ns_map) \ No newline at end of file From 0ec898170b9c0f0ab7e55b2043597081ea3eb5dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urs=20St=C3=B6ckli?= Date: Tue, 3 Feb 2026 15:16:31 +0100 Subject: [PATCH 09/11] SKIPLUS-1012: fix issues of error response content provider. --- api/ojp2/OjpFareServiceOjp2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/ojp2/OjpFareServiceOjp2.py b/api/ojp2/OjpFareServiceOjp2.py index 42d21c2..f42e43e 100644 --- a/api/ojp2/OjpFareServiceOjp2.py +++ b/api/ojp2/OjpFareServiceOjp2.py @@ -8,7 +8,7 @@ from api.errors.ApiError import ApiError from api.errors.InvalidOjpRequestError import InvalidOjpRequestError from api.errors.OjpRequestParseError import OjpRequestParseError -from api.ojp2.ErrorResponseContentProviderOjp2 import ErrorResponseProviderOjp2 +from api.ojp2.ErrorResponseContentProviderOjp2 import ErrorResponseContentProviderOjp2 from map_nova_to_ojp2 import map_nova_reply_to_ojp_fare_delivery from map_ojp2_to_ojp2 import parse_ojp2 from ojp2 import Ojp, OjpfareDelivery, Ojpresponse, ServiceDelivery, ParticipantRefStructure @@ -24,7 +24,7 @@ def handle_request(self, body: bytes) -> Response: """ Handles an ojp2 request. """ - error_handler = ErrorHandler(ErrorResponseProviderOjp2()) + error_handler = ErrorHandler(ErrorResponseContentProviderOjp2()) try: ojp_fare_request = _parse_request(body) _validate_request(ojp_fare_request) From fc9cbdd0f037f29b645e7cb08e3b7d569fbf16ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urs=20St=C3=B6ckli?= Date: Tue, 3 Feb 2026 17:10:32 +0100 Subject: [PATCH 10/11] SKIPLUS-1012: fix issues detected while testing with ojp 2.0. --- map_nova_to_ojp2.py | 1 + map_ojp2_to_nova.py | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/map_nova_to_ojp2.py b/map_nova_to_ojp2.py index 60ff5da..e9733c2 100644 --- a/map_nova_to_ojp2.py +++ b/map_nova_to_ojp2.py @@ -7,6 +7,7 @@ from xsdata.models.datatype import XmlDateTime +from api.errors.InvalidNovaResponseError import InvalidNovaResponseError from nova import ErstellePreisAuskunftResponse, KlassenTypCode, PreisAuspraegung, \ PreisAuskunftServicePortTypeSoapv14ErstellePreisAuskunftOutput from ojp2 import OjpfareDelivery, FareResultStructure, FareProductStructure, TripFareResultStructure, \ diff --git a/map_ojp2_to_nova.py b/map_ojp2_to_nova.py index e7d310c..5598add 100644 --- a/map_ojp2_to_nova.py +++ b/map_ojp2_to_nova.py @@ -13,8 +13,8 @@ import uuid def map_timed_leg_to_segment(timed_leg: TimedLegStructure) -> FahrplanVerbindungsSegment: - einstieg = sloid2didok(timed_leg.leg_board.stop_point_ref) - ausstieg = sloid2didok(timed_leg.leg_alight.stop_point_ref) + einstieg = sloid2didok(timed_leg.leg_board.stop_point_ref.value) + ausstieg = sloid2didok(timed_leg.leg_alight.stop_point_ref.value) abfahrts_zeit = timed_leg.leg_board.service_departure.timetabled_time ankunfts_zeit = timed_leg.leg_alight.service_arrival.timetabled_time line_ref = timed_leg.service.line_ref.value @@ -34,8 +34,8 @@ def map_timed_leg_to_segment(timed_leg: TimedLegStructure) -> FahrplanVerbindung verwaltungs_code= process_operating_ref_ojp2(operator_ref) leg_intermediates = timed_leg.leg_intermediate - zwischenhalten = [sloid2didok(timed_leg.leg_board.stop_point_ref)] + [sloid2didok(leg_intermediate.stop_point_ref) - for leg_intermediate in leg_intermediates] + [sloid2didok(timed_leg.leg_alight.stop_point_ref)] + zwischenhalten = [sloid2didok(timed_leg.leg_board.stop_point_ref.value)] + [sloid2didok(leg_intermediate.stop_point_ref.value) + for leg_intermediate in leg_intermediates] + [sloid2didok(timed_leg.leg_alight.stop_point_ref.value)] #handling of Tariff code TC attr2=timed_leg.service.attribute From ad97161061dadfce1eb4aa5473f7ca86f1351550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urs=20St=C3=B6ckli?= Date: Wed, 4 Feb 2026 15:13:59 +0100 Subject: [PATCH 11/11] SKIPLUS-1012: fix nova test. --- test_nova_r_r.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_nova_r_r.py b/test_nova_r_r.py index fe41266..59de3c1 100644 --- a/test_nova_r_r.py +++ b/test_nova_r_r.py @@ -73,7 +73,7 @@ def send_nova(url: str, headers: dict, xml_body: str) -> requests.Response: with open(READFILE, 'r', encoding='utf-8') as inputfile: nova_request = inputfile.read() # send it to NOVA - oauth_helper = OAuth2Helper(client_id=NOVA_CLIENT_ID, client_secret=NOVA_CLIENT_SECRET) + oauth_helper = OAuth2Helper(client_id=NOVA_CLIENT_ID, client_secret=NOVA_CLIENT_SECRET, scope=NOVA_SCOPE) access_token = oauth_helper.get_token() headers = {'Authorization': 'Bearer ' + access_token, 'SOAPAction': 'http://nova.voev.ch/services/v14/preisauskunft/erstellePreisAuskunft',