Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions src/eligibility_signposting_api/api_error_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import json
import logging
import uuid
from datetime import UTC, datetime
from enum import Enum
from http import HTTPStatus
from typing import Any

from fhir.resources.operationoutcome import OperationOutcome, OperationOutcomeIssue

logger = logging.getLogger(__name__)


class FHIRIssueSeverity(str, Enum):
FATAL = "fatal"
ERROR = "error"
WARNING = "warning"
INFORMATION = "information"


class FHIRIssueCode(str, Enum):
FORBIDDEN = "forbidden"
PROCESSING = "processing"
VALUE = "value"


class FHIRSpineErrorCode(str, Enum):
INVALID_NHS_NUMBER = "INVALID_NHS_NUMBER"
INVALID_PARAMETER = "INVALID_PARAMETER"
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR"
REFERENCE_NOT_FOUND = "REFERENCE_NOT_FOUND"


class APIErrorResponse:
def __init__( # noqa: PLR0913
self,
status_code: HTTPStatus,
fhir_issue_code: FHIRIssueCode,
fhir_issue_severity: FHIRIssueSeverity,
fhir_coding_system: str,
fhir_error_code: str,
fhir_display_message: str,
) -> None:
self.status_code = status_code
self.fhir_issue_code = fhir_issue_code
self.fhir_issue_severity = fhir_issue_severity
self.fhir_coding_system = fhir_coding_system
self.fhir_error_code = fhir_error_code
self.fhir_display_message = fhir_display_message

def build_operation_outcome_issue(self, diagnostics: str, location: list[str] | None) -> OperationOutcomeIssue:
details = {
"coding": [
{
"system": self.fhir_coding_system,
"code": self.fhir_error_code,
"display": self.fhir_display_message,
}
]
}
return OperationOutcomeIssue(
severity=self.fhir_issue_severity,
code=self.fhir_issue_code,
diagnostics=diagnostics,
location=location,
details=details,
) # pyright: ignore[reportCallIssue]

def generate_response(self, diagnostics: str, location_param: str | None = None) -> dict[str, Any]:
issue_location = [f"parameters/{location_param}"] if location_param else None

problem = OperationOutcome(
id=str(uuid.uuid4()),
meta={"lastUpdated": datetime.now(UTC)},
issue=[self.build_operation_outcome_issue(diagnostics, issue_location)],
) # pyright: ignore[reportCallIssue]

response_body = json.dumps(problem.model_dump(by_alias=True, mode="json"))

return {
"statusCode": self.status_code,
"headers": {"Content-Type": "application/fhir+json"},
"body": response_body,
}

def log_and_generate_response(
self, log_message: str, diagnostics: str, location_param: str | None = None
) -> dict[str, Any]:
logger.error(log_message)
return self.generate_response(diagnostics, location_param)


INVALID_INCLUDE_ACTIONS_ERROR = APIErrorResponse(
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
fhir_issue_code=FHIRIssueCode.VALUE,
fhir_issue_severity=FHIRIssueSeverity.ERROR,
fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1",
fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER,
fhir_display_message="The supplied value was not recognised by the API.",
)

INVALID_CATEGORY_ERROR = APIErrorResponse(
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
fhir_issue_code=FHIRIssueCode.VALUE,
fhir_issue_severity=FHIRIssueSeverity.ERROR,
fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1",
fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER,
fhir_display_message="The supplied category was not recognised by the API.",
)

INVALID_CONDITION_FORMAT_ERROR = APIErrorResponse(
status_code=HTTPStatus.BAD_REQUEST,
fhir_issue_code=FHIRIssueCode.VALUE,
fhir_issue_severity=FHIRIssueSeverity.ERROR,
fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1",
fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER,
fhir_display_message="The given conditions were not in the expected format.",
)

NHS_NUMBER_NOT_FOUND_ERROR = APIErrorResponse(
status_code=HTTPStatus.NOT_FOUND,
fhir_issue_code=FHIRIssueCode.PROCESSING,
fhir_issue_severity=FHIRIssueSeverity.ERROR,
fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1",
fhir_error_code=FHIRSpineErrorCode.REFERENCE_NOT_FOUND,
fhir_display_message="The given NHS number was not found in our datasets. "
"This could be because the number is incorrect or "
"some other reason we cannot process that number.",
)

INTERNAL_SERVER_ERROR = APIErrorResponse(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
fhir_issue_code=FHIRIssueCode.PROCESSING,
fhir_issue_severity=FHIRIssueSeverity.ERROR,
fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1",
fhir_error_code=FHIRSpineErrorCode.INTERNAL_SERVER_ERROR,
fhir_display_message="An unexpected internal server error occurred.",
)

NHS_NUMBER_MISMATCH_ERROR = APIErrorResponse(
status_code=HTTPStatus.FORBIDDEN,
fhir_issue_code=FHIRIssueCode.FORBIDDEN,
fhir_issue_severity=FHIRIssueSeverity.ERROR,
fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1",
fhir_error_code=FHIRSpineErrorCode.INVALID_NHS_NUMBER,
fhir_display_message="The provided NHS number does not match the record.",
)
4 changes: 2 additions & 2 deletions src/eligibility_signposting_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from eligibility_signposting_api.config.config import config, init_logging
from eligibility_signposting_api.error_handler import handle_exception
from eligibility_signposting_api.views import eligibility_blueprint
from eligibility_signposting_api.wrapper import validate_matching_nhs_number
from eligibility_signposting_api.wrapper import validate_request_params

init_logging()
logger = logging.getLogger(__name__)
Expand All @@ -23,7 +23,7 @@ def main() -> None: # pragma: no cover
app.run(debug=config()["log_level"] == logging.DEBUG)


@validate_matching_nhs_number()
@validate_request_params()
def lambda_handler(event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: # pragma: no cover
"""Run the Flask app as an AWS Lambda."""
app = create_app()
Expand Down
15 changes: 6 additions & 9 deletions src/eligibility_signposting_api/error_handler.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import logging
import traceback
from http import HTTPStatus

from fhir.resources.operationoutcome import OperationOutcome, OperationOutcomeIssue
from flask import make_response
from flask.typing import ResponseReturnValue
from werkzeug.exceptions import HTTPException

from eligibility_signposting_api.api_error_response import INTERNAL_SERVER_ERROR

logger = logging.getLogger(__name__)


Expand All @@ -17,11 +17,8 @@ def handle_exception(e: Exception) -> ResponseReturnValue | HTTPException:
if isinstance(e, HTTPException):
return e

problem = OperationOutcome(
issue=[
OperationOutcomeIssue(
severity="severe", code="unexpected", diagnostics="".join(traceback.format_exception(e))
) # pyright: ignore[reportCallIssue]
]
full_traceback = "".join(traceback.format_exception(e))
response = INTERNAL_SERVER_ERROR.log_and_generate_response(
log_message=f"An unexpected error occurred: {full_traceback}", diagnostics="An unexpected error occurred."
)
return make_response(problem.model_dump(by_alias=True), HTTPStatus.INTERNAL_SERVER_ERROR)
return make_response(response.get("body"), response.get("statusCode"))
58 changes: 13 additions & 45 deletions src/eligibility_signposting_api/views/eligibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,16 @@
import uuid
from datetime import UTC, datetime
from http import HTTPStatus
from typing import Never

from fhir.resources.R4B.operationoutcome import OperationOutcome, OperationOutcomeIssue
from flask import Blueprint, make_response, request
from flask.typing import ResponseReturnValue
from wireup import Injected

from eligibility_signposting_api.api_error_response import NHS_NUMBER_NOT_FOUND_ERROR
from eligibility_signposting_api.audit.audit_context import AuditContext
from eligibility_signposting_api.audit.audit_service import AuditService
from eligibility_signposting_api.model.eligibility import Condition, EligibilityStatus, NHSNumber, Status
from eligibility_signposting_api.services import EligibilityService, UnknownPersonError
from eligibility_signposting_api.services.eligibility_services import InvalidQueryParamError
from eligibility_signposting_api.views.response_model import eligibility
from eligibility_signposting_api.views.response_model.eligibility import ProcessedSuggestion

Expand Down Expand Up @@ -50,8 +48,6 @@ def check_eligibility(
eligibility_status = eligibility_service.get_eligibility_status(
nhs_number, include_actions_flag=get_include_actions_flag()
)
except InvalidQueryParamError:
return handle_invalid_query_param_error()
except UnknownPersonError:
return handle_unknown_person_error(nhs_number)
else:
Expand All @@ -62,49 +58,21 @@ def check_eligibility(
)


def handle_unknown_person_error(nhs_number: NHSNumber) -> ResponseReturnValue:
logger.debug("nhs_number %r not found", nhs_number, extra={"nhs_number": nhs_number})
problem = OperationOutcome(
issue=[
OperationOutcomeIssue(
severity="information",
code="nhs-number-not-found",
diagnostics=f'NHS Number "{nhs_number}" not found.',
) # pyright: ignore[reportCallIssue]
]
)
return make_response(problem.model_dump(by_alias=True, mode="json"), HTTPStatus.NOT_FOUND)
def get_include_actions_flag() -> bool:
if not request.args.get("includeActions"):
logger.info("Defaulting includeActions query param to Y as no value was provided")
include_actions_flag = True
else:
include_actions_flag = request.args.get("includeActions") == "Y"
return include_actions_flag


def handle_invalid_query_param_error() -> ResponseReturnValue:
logger.debug(
"Invalid query param",
)
problem = OperationOutcome(
issue=[
OperationOutcomeIssue(
severity="error",
code="invalid",
diagnostics="Invalid query param key or value.",
) # pyright: ignore[reportCallIssue]
]
def handle_unknown_person_error(nhs_number: NHSNumber) -> ResponseReturnValue:
diagnostics = f"NHS Number '{nhs_number}' was not recognised by the Eligibility Signposting API"
response = NHS_NUMBER_NOT_FOUND_ERROR.log_and_generate_response(
log_message=diagnostics, diagnostics=diagnostics, location_param="id"
)
return make_response(problem.model_dump(by_alias=True, mode="json"), HTTPStatus.BAD_REQUEST)


def get_include_actions_flag() -> bool:
include_actions = request.args.get("includeActions")
if "includeActions" in request.args:
normalized = include_actions.upper() if include_actions is not None else None
if normalized not in ("Y", "N", None):
raise_invalid_query_param_error()
elif len(request.args) != 0 and "includeActions" not in request.args:
raise_invalid_query_param_error()
return include_actions is None or include_actions.upper() == "Y"


def raise_invalid_query_param_error() -> Never:
raise InvalidQueryParamError
return make_response(response.get("body"), response.get("statusCode"))


def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibility.EligibilityResponse:
Expand Down
Loading