From 146955f768cea9c62056926255f8e493c977e832 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 21 Jul 2025 12:33:32 +0100 Subject: [PATCH 1/4] Refactor package structure and class names --- src/eligibility_signposting_api/app.py | 4 +- .../audit/audit_context.py | 2 +- .../common/__init__.py | 0 .../common/api-error-response-readme.md | 73 +++++++++++++++++++ .../{ => common}/api_error_response.py | 0 .../{ => common}/error_handler.py | 2 +- .../request_validator.py} | 2 +- .../{eligibility.py => eligibility_status.py} | 0 .../repos/person_repo.py | 2 +- .../calculators/eligibility_calculator.py | 26 +++---- .../services/calculators/rule_calculator.py | 28 +++---- .../services/eligibility_services.py | 6 +- .../views/eligibility.py | 66 ++++++++--------- ...eligibility.py => eligibility_response.py} | 0 .../test_eligibility_check.py | 2 +- .../test_eligibility_check_bdd.py | 2 +- tests/fixtures/builders/model/eligibility.py | 12 +-- .../views/response_model/eligibility.py | 12 +-- tests/fixtures/matchers/eligibility.py | 8 +- tests/integration/conftest.py | 28 +++---- .../in_process/test_eligibility_endpoint.py | 2 +- .../lambda/test_app_running_as_lambda.py | 2 +- tests/integration/repo/test_person_repo.py | 2 +- tests/unit/audit/test_audit_context.py | 2 +- tests/unit/common/__init__.py | 0 .../test_request_validator.py} | 32 ++++---- tests/unit/config/__init__.py | 0 tests/unit/{ => config}/test_config.py | 0 tests/unit/model/test_status.py | 2 +- .../test_eligibility_calculator.py | 2 +- .../services/test_eligibility_services.py | 2 +- tests/unit/views/test_eligibility.py | 70 +++++++++--------- 32 files changed, 234 insertions(+), 157 deletions(-) create mode 100644 src/eligibility_signposting_api/common/__init__.py create mode 100644 src/eligibility_signposting_api/common/api-error-response-readme.md rename src/eligibility_signposting_api/{ => common}/api_error_response.py (100%) rename src/eligibility_signposting_api/{ => common}/error_handler.py (89%) rename src/eligibility_signposting_api/{wrapper.py => common/request_validator.py} (98%) rename src/eligibility_signposting_api/model/{eligibility.py => eligibility_status.py} (100%) rename src/eligibility_signposting_api/views/response_model/{eligibility.py => eligibility_response.py} (100%) create mode 100644 tests/unit/common/__init__.py rename tests/unit/{test_wrapper.py => common/test_request_validator.py} (90%) create mode 100644 tests/unit/config/__init__.py rename tests/unit/{ => config}/test_config.py (100%) diff --git a/src/eligibility_signposting_api/app.py b/src/eligibility_signposting_api/app.py index 26be07c99..6246a807c 100644 --- a/src/eligibility_signposting_api/app.py +++ b/src/eligibility_signposting_api/app.py @@ -8,10 +8,10 @@ from mangum.types import LambdaContext, LambdaEvent from eligibility_signposting_api import audit, repos, services +from eligibility_signposting_api.common.error_handler import handle_exception +from eligibility_signposting_api.common.request_validator import validate_request_params 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_request_params init_logging() logger = logging.getLogger(__name__) diff --git a/src/eligibility_signposting_api/audit/audit_context.py b/src/eligibility_signposting_api/audit/audit_context.py index b54276cbb..28a6f9072 100644 --- a/src/eligibility_signposting_api/audit/audit_context.py +++ b/src/eligibility_signposting_api/audit/audit_context.py @@ -18,7 +18,7 @@ RequestAuditQueryParams, ) from eligibility_signposting_api.audit.audit_service import AuditService -from eligibility_signposting_api.model.eligibility import ( +from eligibility_signposting_api.model.eligibility_status import ( CohortGroupResult, ConditionName, IterationResult, diff --git a/src/eligibility_signposting_api/common/__init__.py b/src/eligibility_signposting_api/common/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/eligibility_signposting_api/common/api-error-response-readme.md b/src/eligibility_signposting_api/common/api-error-response-readme.md new file mode 100644 index 000000000..88252bd07 --- /dev/null +++ b/src/eligibility_signposting_api/common/api-error-response-readme.md @@ -0,0 +1,73 @@ +# How to Use the API Error Response Module +This document outlines how to use the `api_error_response.py` module for standardized error handling within the Eligibility Signposting API. The module ensures that all API errors are consistent, logged appropriately, and conform to the FHIR `OperationOutcome` standard. +## Core Concepts +The error handling mechanism is built around the class `APIErrorResponse`. +1. **`APIErrorResponse` Class**: This class is a constructor for a specific type of error. An instance of this class holds configuration for an error, such as the `HTTPStatus`, severity, and various FHIR-specific codes. +2. **Pre-defined Error Instances**: The module defines several singleton instances of for common, application-specific errors. Examples include: + - `INVALID_CATEGORY_ERROR` + - `NHS_NUMBER_MISMATCH_ERROR` + - `INTERNAL_SERVER_ERROR` + +3. **`log_and_generate_response()` Method**: This is the primary method to be used. When called on an `APIErrorResponse` instance, it performs two actions: + - Logs the error with a detailed internal message. + - Generates a complete HTTP response dictionary (`statusCode`, `headers`, `body`) containing a FHIR-compliant `OperationOutcome` payload. + +## How to Use +The primary way to handle errors is to import a pre-defined error object from `api_error_response.py` and call its `log_and_generate_response()` method. +### 1. Handling Specific, Known Errors +For handling validation failures or other expected error conditions, use one of the pre-defined error instances. +The `wrapper.py` module uses this pattern to validate query parameters. If a parameter is invalid, it calls the corresponding error function. +**Example: Invalid "category" parameter** +``` python +# wrapper.py + +from eligibility_signposting_api.api_error_response import INVALID_CATEGORY_ERROR + +def get_category_error_response(category: str) -> dict[str, Any]: + """Generates an error response for an invalid category.""" + + return INVALID_CATEGORY_ERROR.log_and_generate_response( + log_message=f"Invalid category query param: '{category}'", + diagnostics=f"{category} is not a category that is supported by the API", + location_param="category" + ) +``` +**Key Parameters for `log_and_generate_response()`:** +- `log_message`: A detailed message for internal logging. This should contain specific information useful for debugging. +- `diagnostics`: The user-facing error message that will be included in the API response body. +- `location_param` (optional): The name of the parameter that caused the error. This helps pinpoint the issue for API consumers. + +### 2. Handling Unexpected Exceptions (Global Error Handler) +For unexpected errors, a global exception handler in `error_handler.py` catches any unhandled exception and returns a generic 500 Internal Server Error. This prevents sensitive information from leaking in stack traces. +``` python +# error_handler.py + +from eligibility_signposting_api.api_error_response import INTERNAL_SERVER_ERROR + +def handle_exception(e: Exception) -> ResponseReturnValue | HTTPException: + # Generate a generic, safe response for the client + response = INTERNAL_SERVER_ERROR.log_and_generate_response( + log_message=f"An unexpected error occurred: {traceback.format_exception(e)}", + diagnostics="An unexpected error occurred." + ) + return make_response(response.get("body"), response.get("statusCode"), response.get("headers")) +``` +### 3. Creating New Error Types +If a new, reusable error condition is identified, you should add a new instance of `APIErrorResponse` to `api_error_response.py` +Follow the existing pattern: + +``` python +# api_error_response.py + +# ... (other error definitions) + +SOME_NEW_ERROR = APIErrorResponse( + status_code=HTTPStatus.BAD_REQUEST, + fhir_issue_code=FHIRIssueCode.VALUE, + fhir_issue_severity=FHIRIssueSeverity.ERROR, + fhir_coding_system=FHIR_SPINE_ERROR_CODE_SYSTEM, + fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER, + fhir_display_message="A new specific error message for display", +) +``` +By centralizing error definitions, we ensure that the API provides a consistent and predictable experience for its consumers. diff --git a/src/eligibility_signposting_api/api_error_response.py b/src/eligibility_signposting_api/common/api_error_response.py similarity index 100% rename from src/eligibility_signposting_api/api_error_response.py rename to src/eligibility_signposting_api/common/api_error_response.py diff --git a/src/eligibility_signposting_api/error_handler.py b/src/eligibility_signposting_api/common/error_handler.py similarity index 89% rename from src/eligibility_signposting_api/error_handler.py rename to src/eligibility_signposting_api/common/error_handler.py index 3841bf170..662e8bdda 100644 --- a/src/eligibility_signposting_api/error_handler.py +++ b/src/eligibility_signposting_api/common/error_handler.py @@ -5,7 +5,7 @@ from flask.typing import ResponseReturnValue from werkzeug.exceptions import HTTPException -from eligibility_signposting_api.api_error_response import INTERNAL_SERVER_ERROR +from eligibility_signposting_api.common.api_error_response import INTERNAL_SERVER_ERROR logger = logging.getLogger(__name__) diff --git a/src/eligibility_signposting_api/wrapper.py b/src/eligibility_signposting_api/common/request_validator.py similarity index 98% rename from src/eligibility_signposting_api/wrapper.py rename to src/eligibility_signposting_api/common/request_validator.py index c3437afea..1bc8d9d86 100644 --- a/src/eligibility_signposting_api/wrapper.py +++ b/src/eligibility_signposting_api/common/request_validator.py @@ -6,7 +6,7 @@ from mangum.types import LambdaContext, LambdaEvent -from eligibility_signposting_api.api_error_response import ( +from eligibility_signposting_api.common.api_error_response import ( INVALID_CATEGORY_ERROR, INVALID_CONDITION_FORMAT_ERROR, INVALID_INCLUDE_ACTIONS_ERROR, diff --git a/src/eligibility_signposting_api/model/eligibility.py b/src/eligibility_signposting_api/model/eligibility_status.py similarity index 100% rename from src/eligibility_signposting_api/model/eligibility.py rename to src/eligibility_signposting_api/model/eligibility_status.py diff --git a/src/eligibility_signposting_api/repos/person_repo.py b/src/eligibility_signposting_api/repos/person_repo.py index 41ea20745..cfa18fa12 100644 --- a/src/eligibility_signposting_api/repos/person_repo.py +++ b/src/eligibility_signposting_api/repos/person_repo.py @@ -5,7 +5,7 @@ from boto3.resources.base import ServiceResource from wireup import Inject, service -from eligibility_signposting_api.model.eligibility import NHSNumber +from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos.exceptions import NotFoundError logger = logging.getLogger(__name__) diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index fa586a4e7..a2e4f6bd8 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -24,8 +24,8 @@ from wireup import service -from eligibility_signposting_api.model import eligibility, rules -from eligibility_signposting_api.model.eligibility import ( +from eligibility_signposting_api.model import eligibility_status, rules +from eligibility_signposting_api.model.eligibility_status import ( ActionCode, ActionDescription, ActionType, @@ -58,7 +58,7 @@ class EligibilityCalculator: person_data: Row campaign_configs: Collection[rules.CampaignConfig] - results: list[eligibility.Condition] = field(default_factory=list) + results: list[eligibility_status.Condition] = field(default_factory=list) @property def active_campaigns(self) -> list[rules.CampaignConfig]: @@ -66,7 +66,7 @@ def active_campaigns(self) -> list[rules.CampaignConfig]: def campaigns_grouped_by_condition_name( self, conditions: list[str], category: str - ) -> Iterator[tuple[eligibility.ConditionName, list[rules.CampaignConfig]]]: + ) -> Iterator[tuple[eligibility_status.ConditionName, list[rules.CampaignConfig]]]: """Generator that yields campaign groups filtered by condition names and campaign category.""" mapping = { @@ -100,9 +100,9 @@ def get_the_best_cohort_memberships( cohort_results: dict[str, CohortGroupResult], ) -> tuple[Status, list[CohortGroupResult]]: if not cohort_results: - return eligibility.Status.not_eligible, [] + return eligibility_status.Status.not_eligible, [] - best_status = eligibility.Status.best(*[result.status for result in cohort_results.values()]) + best_status = eligibility_status.Status.best(*[result.status for result in cohort_results.values()]) best_cohorts = [result for result in cohort_results.values() if result.status == best_status] best_cohorts = [ @@ -158,7 +158,7 @@ def get_action_rules_components( def evaluate_eligibility( self, include_actions: str, conditions: list[str], category: str - ) -> eligibility.EligibilityStatus: + ) -> eligibility_status.EligibilityStatus: include_actions_flag = include_actions.upper() == "Y" condition_results: dict[ConditionName, IterationResult] = {} actions: list[SuggestedAction] | None = [] @@ -186,7 +186,7 @@ def evaluate_eligibility( ), ) = max(iteration_results.items(), key=lambda item: item[1][1].status.value) else: - best_candidate = IterationResult(eligibility.Status.not_eligible, [], actions) + best_candidate = IterationResult(eligibility_status.Status.not_eligible, [], actions) best_campaign_id = None best_campaign_version = None best_active_iteration = None @@ -233,7 +233,7 @@ def evaluate_eligibility( # Consolidate all the results and return final_result = self.build_condition_results(condition_results) - return eligibility.EligibilityStatus(conditions=final_result) + return eligibility_status.EligibilityStatus(conditions=final_result) def get_iteration_results( self, actions: list[SuggestedAction] | None, campaign_group: list[CampaignConfig] @@ -406,20 +406,20 @@ def evaluate_suppression_rules( def evaluate_rules_priority_group( self, rules_group: Iterator[rules.IterationRule] - ) -> tuple[eligibility.Status, list[eligibility.Reason], bool]: + ) -> tuple[eligibility_status.Status, list[eligibility_status.Reason], bool]: is_rule_stop = False exclusion_reasons = [] - best_status = eligibility.Status.not_eligible + best_status = eligibility_status.Status.not_eligible for rule in rules_group: is_rule_stop = rule.rule_stop or is_rule_stop rule_calculator = RuleCalculator(person_data=self.person_data, rule=rule) status, reason = rule_calculator.evaluate_exclusion() if status.is_exclusion: - best_status = eligibility.Status.best(status, best_status) + best_status = eligibility_status.Status.best(status, best_status) exclusion_reasons.append(reason) else: - best_status = eligibility.Status.actionable + best_status = eligibility_status.Status.actionable return best_status, exclusion_reasons, is_rule_stop diff --git a/src/eligibility_signposting_api/services/calculators/rule_calculator.py b/src/eligibility_signposting_api/services/calculators/rule_calculator.py index 03641e3be..96b0dfd87 100644 --- a/src/eligibility_signposting_api/services/calculators/rule_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/rule_calculator.py @@ -6,7 +6,7 @@ from hamcrest.core.string_description import StringDescription -from eligibility_signposting_api.model import eligibility, rules +from eligibility_signposting_api.model import eligibility_status, rules from eligibility_signposting_api.services.rules.operators import OperatorRegistry Row = Collection[Mapping[str, Any]] @@ -17,15 +17,15 @@ class RuleCalculator: person_data: Row rule: rules.IterationRule - def evaluate_exclusion(self) -> tuple[eligibility.Status, eligibility.Reason]: + def evaluate_exclusion(self) -> tuple[eligibility_status.Status, eligibility_status.Reason]: """Evaluate if a particular rule excludes this person. Return the result, and the reason for the result.""" attribute_value = self.get_attribute_value() status, reason, matcher_matched = self.evaluate_rule(attribute_value) - reason = eligibility.Reason( - rule_name=eligibility.RuleName(self.rule.name), - rule_type=eligibility.RuleType(self.rule.type), - rule_priority=eligibility.RulePriority(str(self.rule.priority)), - rule_description=eligibility.RuleDescription(self.rule.description), + reason = eligibility_status.Reason( + rule_name=eligibility_status.RuleName(self.rule.name), + rule_type=eligibility_status.RuleType(self.rule.type), + rule_priority=eligibility_status.RulePriority(str(self.rule.priority)), + rule_description=eligibility_status.RuleDescription(self.rule.description), matcher_matched=matcher_matched, ) return status, reason @@ -71,7 +71,7 @@ def get_value(dictionary: Mapping[str, Any] | None, key: str) -> dict: v = dictionary.get(key, {}) if isinstance(dictionary, dict) else {} return v if isinstance(v, dict) else {} - def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility.Status, str, bool]: + def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility_status.Status, str, bool]: """Evaluate a rule against a person data attribute. Return the result, and the reason for the result.""" matcher_class = OperatorRegistry.get(self.rule.operator) matcher = matcher_class(rule_value=self.rule.comparator) @@ -81,12 +81,12 @@ def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility.Status if matcher_matched: matcher.describe_match(attribute_value, reason) status = { - rules.RuleType.filter: eligibility.Status.not_eligible, - rules.RuleType.suppression: eligibility.Status.not_actionable, - rules.RuleType.redirect: eligibility.Status.actionable, - rules.RuleType.not_eligible_actions: eligibility.Status.not_eligible, - rules.RuleType.not_actionable_actions: eligibility.Status.not_actionable, + rules.RuleType.filter: eligibility_status.Status.not_eligible, + rules.RuleType.suppression: eligibility_status.Status.not_actionable, + rules.RuleType.redirect: eligibility_status.Status.actionable, + rules.RuleType.not_eligible_actions: eligibility_status.Status.not_eligible, + rules.RuleType.not_actionable_actions: eligibility_status.Status.not_actionable, }[self.rule.type] return status, str(reason), matcher_matched matcher.describe_mismatch(attribute_value, reason) - return eligibility.Status.actionable, str(reason), matcher_matched + return eligibility_status.Status.actionable, str(reason), matcher_matched diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 48586290b..a4ff86ac1 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -2,7 +2,7 @@ from wireup import service -from eligibility_signposting_api.model import eligibility +from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo from eligibility_signposting_api.services.calculators import eligibility_calculator as calculator @@ -32,11 +32,11 @@ def __init__( def get_eligibility_status( self, - nhs_number: eligibility.NHSNumber, + nhs_number: eligibility_status.NHSNumber, include_actions: str, conditions: list[str], category: str, - ) -> eligibility.EligibilityStatus: + ) -> eligibility_status.EligibilityStatus: """Calculate a person's eligibility for vaccination given an NHS number.""" if nhs_number: try: diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 1ce27ca39..6f279653a 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -8,18 +8,18 @@ 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.common.api_error_response import NHS_NUMBER_NOT_FOUND_ERROR +from eligibility_signposting_api.model.eligibility_status import Condition, EligibilityStatus, NHSNumber, Status from eligibility_signposting_api.services import EligibilityService, UnknownPersonError -from eligibility_signposting_api.views.response_model import eligibility -from eligibility_signposting_api.views.response_model.eligibility import ProcessedSuggestion +from eligibility_signposting_api.views.response_model import eligibility_response +from eligibility_signposting_api.views.response_model.eligibility_response import ProcessedSuggestion STATUS_MAPPING = { - Status.actionable: eligibility.Status.actionable, - Status.not_actionable: eligibility.Status.not_actionable, - Status.not_eligible: eligibility.Status.not_eligible, + Status.actionable: eligibility_response.Status.actionable, + Status.not_actionable: eligibility_response.Status.not_actionable, + Status.not_eligible: eligibility_response.Status.not_eligible, } logger = logging.getLogger(__name__) @@ -56,11 +56,9 @@ def check_eligibility( except UnknownPersonError: return handle_unknown_person_error(nhs_number) else: - eligibility_response: eligibility.EligibilityResponse = build_eligibility_response(eligibility_status) + response: eligibility_response.EligibilityResponse = build_eligibility_response(eligibility_status) AuditContext.write_to_firehose(audit_service) - return make_response( - eligibility_response.model_dump(by_alias=True, mode="json", exclude_none=True), HTTPStatus.OK - ) + return make_response(response.model_dump(by_alias=True, mode="json", exclude_none=True), HTTPStatus.OK) def get_or_default_query_params() -> dict[str, Any]: @@ -103,7 +101,7 @@ def handle_unknown_person_error(nhs_number: NHSNumber) -> ResponseReturnValue: return make_response(response.get("body"), response.get("statusCode"), response.get("headers")) -def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibility.EligibilityResponse: +def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibility_response.EligibilityResponse: """Return an object representing the API response we are going to send, given an evaluation of the person's eligibility.""" @@ -111,9 +109,9 @@ def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibi for condition in eligibility_status.conditions: suggestions = ProcessedSuggestion( # pyright: ignore[reportCallIssue] - condition=eligibility.ConditionName(condition.condition_name), # pyright: ignore[reportCallIssue] + condition=eligibility_response.ConditionName(condition.condition_name), # pyright: ignore[reportCallIssue] status=STATUS_MAPPING[condition.status], - statusText=eligibility.StatusText(condition.status_text), # pyright: ignore[reportCallIssue] + statusText=eligibility_response.StatusText(condition.status_text), # pyright: ignore[reportCallIssue] eligibilityCohorts=build_eligibility_cohorts(condition), # pyright: ignore[reportCallIssue] suitabilityRules=build_suitability_results(condition), # pyright: ignore[reportCallIssue] actions=build_actions(condition), @@ -122,27 +120,29 @@ def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibi processed_suggestions.append(suggestions) response_id = uuid.uuid4() - updated = eligibility.LastUpdated(datetime.now(tz=UTC)) + updated = eligibility_response.LastUpdated(datetime.now(tz=UTC)) AuditContext.add_response_details(response_id, updated) - return eligibility.EligibilityResponse( # pyright: ignore[reportCallIssue] + return eligibility_response.EligibilityResponse( # pyright: ignore[reportCallIssue] responseId=response_id, # pyright: ignore[reportCallIssue] - meta=eligibility.Meta(lastUpdated=updated), + meta=eligibility_response.Meta(lastUpdated=updated), # pyright: ignore[reportCallIssue] processedSuggestions=processed_suggestions, ) -def build_actions(condition: Condition) -> list[eligibility.Action] | None: +def build_actions(condition: Condition) -> list[eligibility_response.Action] | None: if condition.actions is not None: return [ - eligibility.Action( - actionType=eligibility.ActionType(action.action_type), - actionCode=eligibility.ActionCode(action.action_code), - description=eligibility.Description(action.action_description or ""), - urlLabel=eligibility.UrlLabel(action.url_label or ""), - urlLink=eligibility.UrlLink(str(action.url_link)) if action.url_link else eligibility.UrlLink(""), + eligibility_response.Action( + actionType=eligibility_response.ActionType(action.action_type), + actionCode=eligibility_response.ActionCode(action.action_code), + description=eligibility_response.Description(action.action_description or ""), + urlLabel=eligibility_response.UrlLabel(action.url_label or ""), + urlLink=eligibility_response.UrlLink(str(action.url_link)) + if action.url_link + else eligibility_response.UrlLink(""), ) for action in condition.actions ] @@ -150,13 +150,13 @@ def build_actions(condition: Condition) -> list[eligibility.Action] | None: return None -def build_eligibility_cohorts(condition: Condition) -> list[eligibility.EligibilityCohort]: +def build_eligibility_cohorts(condition: Condition) -> list[eligibility_response.EligibilityCohort]: """Group Iteration cohorts and make only one entry per cohort group""" return [ - eligibility.EligibilityCohort( - cohortCode=eligibility.CohortCode(cohort_result.cohort_code), - cohortText=eligibility.CohortText(cohort_result.description), + eligibility_response.EligibilityCohort( + cohortCode=eligibility_response.CohortCode(cohort_result.cohort_code), + cohortText=eligibility_response.CohortText(cohort_result.description), cohortStatus=STATUS_MAPPING[cohort_result.status], ) for cohort_result in condition.cohort_results @@ -164,7 +164,7 @@ def build_eligibility_cohorts(condition: Condition) -> list[eligibility.Eligibil ] -def build_suitability_results(condition: Condition) -> list[eligibility.SuitabilityRule]: +def build_suitability_results(condition: Condition) -> list[eligibility_response.SuitabilityRule]: """Make only one entry if there are duplicate rules""" if condition.status != Status.not_actionable: return [] @@ -178,10 +178,10 @@ def build_suitability_results(condition: Condition) -> list[eligibility.Suitabil if reason.rule_name not in unique_rule_codes and reason.rule_description: unique_rule_codes.add(reason.rule_name) suitability_results.append( - eligibility.SuitabilityRule( - ruleType=eligibility.RuleType(reason.rule_type.value), - ruleCode=eligibility.RuleCode(reason.rule_name), - ruleText=eligibility.RuleText(reason.rule_description), + eligibility_response.SuitabilityRule( + ruleType=eligibility_response.RuleType(reason.rule_type.value), + ruleCode=eligibility_response.RuleCode(reason.rule_name), + ruleText=eligibility_response.RuleText(reason.rule_description), ) ) diff --git a/src/eligibility_signposting_api/views/response_model/eligibility.py b/src/eligibility_signposting_api/views/response_model/eligibility_response.py similarity index 100% rename from src/eligibility_signposting_api/views/response_model/eligibility.py rename to src/eligibility_signposting_api/views/response_model/eligibility_response.py diff --git a/tests/e2e/tests/eligibility_signposting/test_eligibility_check.py b/tests/e2e/tests/eligibility_signposting/test_eligibility_check.py index 76dbd8194..817648eb9 100644 --- a/tests/e2e/tests/eligibility_signposting/test_eligibility_check.py +++ b/tests/e2e/tests/eligibility_signposting/test_eligibility_check.py @@ -27,7 +27,7 @@ def is_api_accessible(): return response.status_code != HTTP_STATUS_NOT_FOUND -@pytest.mark.eligibility +@pytest.mark.eligibility_response class TestEligibilityCheck: """Test suite for the Eligibility Check endpoint.""" diff --git a/tests/e2e/tests/eligibility_signposting/test_eligibility_check_bdd.py b/tests/e2e/tests/eligibility_signposting/test_eligibility_check_bdd.py index 3bb0360bd..ceb94642a 100644 --- a/tests/e2e/tests/eligibility_signposting/test_eligibility_check_bdd.py +++ b/tests/e2e/tests/eligibility_signposting/test_eligibility_check_bdd.py @@ -8,7 +8,7 @@ sys.path.append(str(Path(__file__).parent.parent.parent)) # Mark all tests as BDD tests -pytestmark = [pytest.mark.bdd, pytest.mark.eligibility] +pytestmark = [pytest.mark.bdd, pytest.mark.eligibility_response] # Load the scenarios from the feature file scenarios("../../features/eligibility_check/eligibility_check.feature") diff --git a/tests/fixtures/builders/model/eligibility.py b/tests/fixtures/builders/model/eligibility.py index a1fd81f7d..911b5815d 100644 --- a/tests/fixtures/builders/model/eligibility.py +++ b/tests/fixtures/builders/model/eligibility.py @@ -4,23 +4,23 @@ from polyfactory import Use from polyfactory.factories import DataclassFactory -from eligibility_signposting_api.model import eligibility -from eligibility_signposting_api.model.eligibility import UrlLink +from eligibility_signposting_api.model import eligibility_status +from eligibility_signposting_api.model.eligibility_status import UrlLink -class SuggestedActionFactory(DataclassFactory[eligibility.SuggestedAction]): +class SuggestedActionFactory(DataclassFactory[eligibility_status.SuggestedAction]): url_link = UrlLink("https://test-example.com") -class ConditionFactory(DataclassFactory[eligibility.Condition]): +class ConditionFactory(DataclassFactory[eligibility_status.Condition]): actions = Use(SuggestedActionFactory.batch, size=2) -class EligibilityStatusFactory(DataclassFactory[eligibility.EligibilityStatus]): +class EligibilityStatusFactory(DataclassFactory[eligibility_status.EligibilityStatus]): conditions = Use(ConditionFactory.batch, size=2) -class CohortResultFactory(DataclassFactory[eligibility.CohortGroupResult]): ... +class CohortResultFactory(DataclassFactory[eligibility_status.CohortGroupResult]): ... def random_str(length: int) -> str: diff --git a/tests/fixtures/builders/views/response_model/eligibility.py b/tests/fixtures/builders/views/response_model/eligibility.py index 061036908..3b8bff75b 100644 --- a/tests/fixtures/builders/views/response_model/eligibility.py +++ b/tests/fixtures/builders/views/response_model/eligibility.py @@ -1,23 +1,23 @@ from polyfactory import Use from polyfactory.factories.pydantic_factory import ModelFactory -from eligibility_signposting_api.views.response_model import eligibility +from eligibility_signposting_api.views.response_model import eligibility_response -class EligibilityCohortFactory(ModelFactory[eligibility.EligibilityCohort]): ... +class EligibilityCohortFactory(ModelFactory[eligibility_response.EligibilityCohort]): ... -class SuitabilityRuleFactory(ModelFactory[eligibility.SuitabilityRule]): ... +class SuitabilityRuleFactory(ModelFactory[eligibility_response.SuitabilityRule]): ... -class ActionFactory(ModelFactory[eligibility.Action]): ... +class ActionFactory(ModelFactory[eligibility_response.Action]): ... -class ProcessedSuggestionFactory(ModelFactory[eligibility.ProcessedSuggestion]): +class ProcessedSuggestionFactory(ModelFactory[eligibility_response.ProcessedSuggestion]): eligibility_cohorts = Use(EligibilityCohortFactory.batch, size=2) suitability_rules = Use(SuitabilityRuleFactory.batch, size=2) actions = Use(ActionFactory.batch, size=2) -class EligibilityResponseFactory(ModelFactory[eligibility.EligibilityResponse]): +class EligibilityResponseFactory(ModelFactory[eligibility_response.EligibilityResponse]): processed_suggestions = Use(ProcessedSuggestionFactory.batch, size=2) diff --git a/tests/fixtures/matchers/eligibility.py b/tests/fixtures/matchers/eligibility.py index 98bfb4138..731a3de76 100644 --- a/tests/fixtures/matchers/eligibility.py +++ b/tests/fixtures/matchers/eligibility.py @@ -1,7 +1,11 @@ from hamcrest.core.matcher import Matcher -from eligibility_signposting_api.model.eligibility import CohortGroupResult, Condition, EligibilityStatus, Reason -from eligibility_signposting_api.views.response_model.eligibility import Action, EligibilityCohort, SuitabilityRule +from eligibility_signposting_api.model.eligibility_status import CohortGroupResult, Condition, EligibilityStatus, Reason +from eligibility_signposting_api.views.response_model.eligibility_response import ( + Action, + EligibilityCohort, + SuitabilityRule, +) from .meta import BaseAutoMatcher diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 75cf97878..f4b0856d9 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -16,7 +16,7 @@ from httpx import RequestError from yarl import URL -from eligibility_signposting_api.model import eligibility, rules +from eligibility_signposting_api.model import eligibility_status, rules from eligibility_signposting_api.repos.campaign_repo import BucketName from eligibility_signposting_api.repos.person_repo import TableName from tests.fixtures.builders.model import rule @@ -330,9 +330,9 @@ def person_table(dynamodb_resource: ServiceResource) -> Generator[Any]: @pytest.fixture -def persisted_person(person_table: Any, faker: Faker) -> Generator[eligibility.NHSNumber]: - nhs_number = eligibility.NHSNumber(faker.nhs_number()) - date_of_birth = eligibility.DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=65)) +def persisted_person(person_table: Any, faker: Faker) -> Generator[eligibility_status.NHSNumber]: + nhs_number = eligibility_status.NHSNumber(faker.nhs_number()) + date_of_birth = eligibility_status.DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=65)) for row in ( rows := person_rows_builder(nhs_number, date_of_birth=date_of_birth, postcode="hp1", cohorts=["cohort1"]) @@ -346,9 +346,9 @@ def persisted_person(person_table: Any, faker: Faker) -> Generator[eligibility.N @pytest.fixture -def persisted_77yo_person(person_table: Any, faker: Faker) -> Generator[eligibility.NHSNumber]: - nhs_number = eligibility.NHSNumber(faker.nhs_number()) - date_of_birth = eligibility.DateOfBirth(faker.date_of_birth(minimum_age=77, maximum_age=77)) +def persisted_77yo_person(person_table: Any, faker: Faker) -> Generator[eligibility_status.NHSNumber]: + nhs_number = eligibility_status.NHSNumber(faker.nhs_number()) + date_of_birth = eligibility_status.DateOfBirth(faker.date_of_birth(minimum_age=77, maximum_age=77)) for row in ( rows := person_rows_builder( @@ -367,9 +367,9 @@ def persisted_77yo_person(person_table: Any, faker: Faker) -> Generator[eligibil @pytest.fixture -def persisted_person_all_cohorts(person_table: Any, faker: Faker) -> Generator[eligibility.NHSNumber]: - nhs_number = eligibility.NHSNumber(faker.nhs_number()) - date_of_birth = eligibility.DateOfBirth(faker.date_of_birth(minimum_age=74, maximum_age=74)) +def persisted_person_all_cohorts(person_table: Any, faker: Faker) -> Generator[eligibility_status.NHSNumber]: + nhs_number = eligibility_status.NHSNumber(faker.nhs_number()) + date_of_birth = eligibility_status.DateOfBirth(faker.date_of_birth(minimum_age=74, maximum_age=74)) for row in ( rows := person_rows_builder( @@ -389,8 +389,8 @@ def persisted_person_all_cohorts(person_table: Any, faker: Faker) -> Generator[e @pytest.fixture -def persisted_person_no_cohorts(person_table: Any, faker: Faker) -> Generator[eligibility.NHSNumber]: - nhs_number = eligibility.NHSNumber(faker.nhs_number()) +def persisted_person_no_cohorts(person_table: Any, faker: Faker) -> Generator[eligibility_status.NHSNumber]: + nhs_number = eligibility_status.NHSNumber(faker.nhs_number()) for row in (rows := person_rows_builder(nhs_number)): person_table.put_item(Item=row) @@ -402,8 +402,8 @@ def persisted_person_no_cohorts(person_table: Any, faker: Faker) -> Generator[el @pytest.fixture -def persisted_person_pc_sw19(person_table: Any, faker: Faker) -> Generator[eligibility.NHSNumber]: - nhs_number = eligibility.NHSNumber( +def persisted_person_pc_sw19(person_table: Any, faker: Faker) -> Generator[eligibility_status.NHSNumber]: + nhs_number = eligibility_status.NHSNumber( faker.nhs_number(), ) for row in (rows := person_rows_builder(nhs_number, postcode="SW19", cohorts=["cohort1"])): diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 229d8652c..c39c48207 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -11,7 +11,7 @@ has_key, ) -from eligibility_signposting_api.model.eligibility import ( +from eligibility_signposting_api.model.eligibility_status import ( NHSNumber, ) from eligibility_signposting_api.model.rules import CampaignConfig diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index b157fe44f..85fb305ea 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -23,7 +23,7 @@ ) from yarl import URL -from eligibility_signposting_api.model.eligibility import NHSNumber +from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.model.rules import CampaignConfig from eligibility_signposting_api.repos.campaign_repo import BucketName diff --git a/tests/integration/repo/test_person_repo.py b/tests/integration/repo/test_person_repo.py index 7a444d20e..4dcf53383 100644 --- a/tests/integration/repo/test_person_repo.py +++ b/tests/integration/repo/test_person_repo.py @@ -4,7 +4,7 @@ from faker import Faker from hamcrest import assert_that, contains_inanyorder, has_entries -from eligibility_signposting_api.model.eligibility import NHSNumber +from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos import NotFoundError from eligibility_signposting_api.repos.person_repo import PersonRepo diff --git a/tests/unit/audit/test_audit_context.py b/tests/unit/audit/test_audit_context.py index 25897bd9a..b4f39f416 100644 --- a/tests/unit/audit/test_audit_context.py +++ b/tests/unit/audit/test_audit_context.py @@ -9,7 +9,7 @@ from eligibility_signposting_api.audit.audit_context import AuditContext from eligibility_signposting_api.audit.audit_models import AuditAction, AuditEvent from eligibility_signposting_api.audit.audit_service import AuditService -from eligibility_signposting_api.model.eligibility import ( +from eligibility_signposting_api.model.eligibility_status import ( ActionCode, ActionDescription, ActionType, diff --git a/tests/unit/common/__init__.py b/tests/unit/common/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_wrapper.py b/tests/unit/common/test_request_validator.py similarity index 90% rename from tests/unit/test_wrapper.py rename to tests/unit/common/test_request_validator.py index 18ef5d477..2787c981a 100644 --- a/tests/unit/test_wrapper.py +++ b/tests/unit/common/test_request_validator.py @@ -4,8 +4,8 @@ import pytest -from eligibility_signposting_api import wrapper -from eligibility_signposting_api.wrapper import logger +from eligibility_signposting_api.common import request_validator +from eligibility_signposting_api.common.request_validator import logger @pytest.fixture(autouse=True) @@ -27,7 +27,7 @@ def setup_logging_for_tests(): ) def test_validate_nhs_number(path_nhs, header_nhs, expected_result, expected_log_msg, caplog): with caplog.at_level(logging.ERROR): - result = wrapper.validate_nhs_number(path_nhs, header_nhs) + result = request_validator.validate_nhs_number(path_nhs, header_nhs) assert result == expected_result @@ -59,7 +59,7 @@ def test_validate_query_params_conditions(conditions_input, is_valid_expected, e params = {"conditions": conditions_input} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid == is_valid_expected if is_valid_expected: @@ -73,7 +73,7 @@ def test_validate_query_params_conditions(conditions_input, is_valid_expected, e def test_validate_query_params_conditions_default(caplog): params = {"category": "ALL", "includeActions": "Y"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is True assert problem is None assert not caplog.records @@ -97,7 +97,7 @@ def test_validate_query_params_conditions_default(caplog): def test_validate_query_params_category(category_input, is_valid_expected, expected_log_msg, caplog): params = {"category": category_input} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid == is_valid_expected if is_valid_expected: @@ -111,7 +111,7 @@ def test_validate_query_params_category(category_input, is_valid_expected, expec def test_validate_query_params_category_default(caplog): params = {"conditions": "ALL", "includeActions": "Y"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is True assert problem is None assert not caplog.records @@ -136,7 +136,7 @@ def test_validate_query_params_category_default(caplog): def test_validate_query_params_include_actions(include_actions_input, is_valid_expected, expected_log_msg, caplog): params = {"includeActions": include_actions_input} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid == is_valid_expected if is_valid_expected: @@ -150,7 +150,7 @@ def test_validate_query_params_include_actions(include_actions_input, is_valid_e def test_validate_query_params_include_actions_default(caplog): params = {"conditions": "ALL", "category": "ALL"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is True assert problem is None assert not caplog.records @@ -159,7 +159,7 @@ def test_validate_query_params_include_actions_default(caplog): def test_validate_query_params_all_valid_params(caplog): params = {"conditions": "COND1,COND2", "category": "SCREENING", "includeActions": "N"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is True assert problem is None assert not caplog.records @@ -168,7 +168,7 @@ def test_validate_query_params_all_valid_params(caplog): def test_validate_query_params_mixed_valid_invalid_conditions_fail_first(caplog): params = {"conditions": "VALID_COND,INVALID!,ANOTHER_VALID", "category": "SCREENING", "includeActions": "N"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is False assert problem is not None assert any( @@ -180,7 +180,7 @@ def test_validate_query_params_mixed_valid_invalid_conditions_fail_first(caplog) def test_validate_query_params_valid_conditions_invalid_category_fail_second(caplog): params = {"conditions": "CONDITION", "category": "BAD_CAT", "includeActions": "N"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is False assert problem is not None assert any( @@ -194,7 +194,7 @@ def test_validate_query_params_valid_conditions_invalid_category_fail_second(cap def test_validate_query_params_valid_conditions_category_invalid_actions_fail_third(caplog): params = {"conditions": "CONDITION", "category": "VACCINATIONS", "includeActions": "Nope"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is False assert problem is not None assert any( @@ -209,7 +209,7 @@ def test_validate_query_params_returns_correct_problem_details_for_conditions_er invalid_condition = "FLU&COVID" params = {"conditions": invalid_condition} - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is False assert problem is not None @@ -247,7 +247,7 @@ def test_validate_query_params_returns_correct_problem_details_for_category_erro invalid_category = "HEALTHCHECKS" params = {"category": invalid_category} - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is False assert problem is not None @@ -282,7 +282,7 @@ def test_validate_query_params_returns_correct_problem_details_for_include_actio invalid_include_actions = "NAH" params = {"includeActions": invalid_include_actions} - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is False assert problem is not None diff --git a/tests/unit/config/__init__.py b/tests/unit/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_config.py b/tests/unit/config/test_config.py similarity index 100% rename from tests/unit/test_config.py rename to tests/unit/config/test_config.py diff --git a/tests/unit/model/test_status.py b/tests/unit/model/test_status.py index 72ba73b62..eff2b68f9 100644 --- a/tests/unit/model/test_status.py +++ b/tests/unit/model/test_status.py @@ -1,4 +1,4 @@ -from eligibility_signposting_api.model.eligibility import ConditionName, Status, StatusText +from eligibility_signposting_api.model.eligibility_status import ConditionName, Status, StatusText class TestStatus: diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index baa81b8b7..7b9c39bf5 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -11,7 +11,7 @@ from eligibility_signposting_api.audit.audit_models import AuditAction, AuditEvent from eligibility_signposting_api.model import rules from eligibility_signposting_api.model import rules as rules_model -from eligibility_signposting_api.model.eligibility import ( +from eligibility_signposting_api.model.eligibility_status import ( ActionCode, ActionDescription, ActionType, diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index 872347a00..504888f12 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -3,7 +3,7 @@ import pytest from hamcrest import assert_that, empty -from eligibility_signposting_api.model.eligibility import NHSNumber +from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculatorFactory diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 7174f349f..ad39c01e7 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -14,7 +14,7 @@ from wireup.integration.flask import get_app_container from eligibility_signposting_api.audit.audit_service import AuditService -from eligibility_signposting_api.model.eligibility import ( +from eligibility_signposting_api.model.eligibility_status import ( ActionCode, ActionDescription, ActionType, @@ -39,7 +39,7 @@ build_suitability_results, get_or_default_query_params, ) -from eligibility_signposting_api.views.response_model import eligibility +from eligibility_signposting_api.views.response_model import eligibility_response from tests.fixtures.builders.model.eligibility import ( CohortResultFactory, ConditionFactory, @@ -435,12 +435,12 @@ def test_no_suitability_rules_for_actionable(): ) ], [ - eligibility.Action( - actionType=eligibility.ActionType("TYPE_A"), - actionCode=eligibility.ActionCode("CODE123"), - description=eligibility.Description("Some description"), - urlLink=eligibility.UrlLink("https://example.com"), - urlLabel=eligibility.UrlLabel("Learn more"), + eligibility_response.Action( + actionType=eligibility_response.ActionType("TYPE_A"), + actionCode=eligibility_response.ActionCode("CODE123"), + description=eligibility_response.Description("Some description"), + urlLink=eligibility_response.UrlLink("https://example.com"), + urlLabel=eligibility_response.UrlLabel("Learn more"), ) ], ), @@ -455,9 +455,9 @@ def test_no_suitability_rules_for_actionable(): ) ], [ - eligibility.Action( - actionType=eligibility.ActionType("TYPE_B"), - actionCode=eligibility.ActionCode("CODE123"), + eligibility_response.Action( + actionType=eligibility_response.ActionType("TYPE_B"), + actionCode=eligibility_response.ActionCode("CODE123"), description="", urlLink="", urlLabel="", @@ -483,23 +483,23 @@ def test_build_actions(suggested_actions, expected): def test_excludes_nulls_via_build_response(client: FlaskClient): - mocked_response = eligibility.EligibilityResponse( + mocked_response = eligibility_response.EligibilityResponse( responseId=uuid4(), - meta=eligibility.Meta(lastUpdated=eligibility.LastUpdated(datetime(2023, 1, 1, tzinfo=UTC))), + meta=eligibility_response.Meta(lastUpdated=eligibility_response.LastUpdated(datetime(2023, 1, 1, tzinfo=UTC))), processedSuggestions=[ - eligibility.ProcessedSuggestion( - condition=eligibility.ConditionName("ConditionA"), - status=eligibility.Status.actionable, - statusText=eligibility.StatusText("Go ahead"), + eligibility_response.ProcessedSuggestion( + condition=eligibility_response.ConditionName("ConditionA"), + status=eligibility_response.Status.actionable, + statusText=eligibility_response.StatusText("Go ahead"), eligibilityCohorts=[], suitabilityRules=[], actions=[ - eligibility.Action( - actionType=eligibility.ActionType("TYPE_A"), - actionCode=eligibility.ActionCode("CODE123"), - description=eligibility.Description(""), # Should be an empty string - urlLink=eligibility.UrlLink(""), # Should be an empty string - urlLabel=eligibility.UrlLabel(""), # Should be an empty string + eligibility_response.Action( + actionType=eligibility_response.ActionType("TYPE_A"), + actionCode=eligibility_response.ActionCode("CODE123"), + description=eligibility_response.Description(""), # Should be an empty string + urlLink=eligibility_response.UrlLink(""), # Should be an empty string + urlLabel=eligibility_response.UrlLabel(""), # Should be an empty string ) ], ) @@ -535,23 +535,23 @@ def test_excludes_nulls_via_build_response(client: FlaskClient): def test_build_response_include_values_that_are_not_null(client: FlaskClient): - mocked_response = eligibility.EligibilityResponse( + mocked_response = eligibility_response.EligibilityResponse( responseId=uuid4(), - meta=eligibility.Meta(lastUpdated=eligibility.LastUpdated(datetime(2023, 1, 1, tzinfo=UTC))), + meta=eligibility_response.Meta(lastUpdated=eligibility_response.LastUpdated(datetime(2023, 1, 1, tzinfo=UTC))), processedSuggestions=[ - eligibility.ProcessedSuggestion( - condition=eligibility.ConditionName("ConditionA"), - status=eligibility.Status.actionable, - statusText=eligibility.StatusText("Go ahead"), + eligibility_response.ProcessedSuggestion( + condition=eligibility_response.ConditionName("ConditionA"), + status=eligibility_response.Status.actionable, + statusText=eligibility_response.StatusText("Go ahead"), eligibilityCohorts=[], suitabilityRules=[], actions=[ - eligibility.Action( - actionType=eligibility.ActionType("TYPE_A"), - actionCode=eligibility.ActionCode("CODE123"), - description=eligibility.Description("Contact GP"), - urlLink=eligibility.UrlLink("https://example.dummy/"), - urlLabel=eligibility.UrlLabel("GP contact"), + eligibility_response.Action( + actionType=eligibility_response.ActionType("TYPE_A"), + actionCode=eligibility_response.ActionCode("CODE123"), + description=eligibility_response.Description("Contact GP"), + urlLink=eligibility_response.UrlLink("https://example.dummy/"), + urlLabel=eligibility_response.UrlLabel("GP contact"), ) ], ) From 523237d6191acd409fbceea5424f74cd23516843 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 21 Jul 2025 15:51:17 +0100 Subject: [PATCH 2/4] Fix markdown linting issues --- .../common/api-error-response-readme.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/eligibility_signposting_api/common/api-error-response-readme.md b/src/eligibility_signposting_api/common/api-error-response-readme.md index 88252bd07..67f0278c3 100644 --- a/src/eligibility_signposting_api/common/api-error-response-readme.md +++ b/src/eligibility_signposting_api/common/api-error-response-readme.md @@ -1,23 +1,30 @@ # How to Use the API Error Response Module + This document outlines how to use the `api_error_response.py` module for standardized error handling within the Eligibility Signposting API. The module ensures that all API errors are consistent, logged appropriately, and conform to the FHIR `OperationOutcome` standard. + ## Core Concepts + The error handling mechanism is built around the class `APIErrorResponse`. + 1. **`APIErrorResponse` Class**: This class is a constructor for a specific type of error. An instance of this class holds configuration for an error, such as the `HTTPStatus`, severity, and various FHIR-specific codes. 2. **Pre-defined Error Instances**: The module defines several singleton instances of for common, application-specific errors. Examples include: - `INVALID_CATEGORY_ERROR` - `NHS_NUMBER_MISMATCH_ERROR` - `INTERNAL_SERVER_ERROR` - 3. **`log_and_generate_response()` Method**: This is the primary method to be used. When called on an `APIErrorResponse` instance, it performs two actions: - Logs the error with a detailed internal message. - Generates a complete HTTP response dictionary (`statusCode`, `headers`, `body`) containing a FHIR-compliant `OperationOutcome` payload. ## How to Use + The primary way to handle errors is to import a pre-defined error object from `api_error_response.py` and call its `log_and_generate_response()` method. + ### 1. Handling Specific, Known Errors For handling validation failures or other expected error conditions, use one of the pre-defined error instances. The `wrapper.py` module uses this pattern to validate query parameters. If a parameter is invalid, it calls the corresponding error function. + **Example: Invalid "category" parameter** + ``` python # wrapper.py @@ -32,13 +39,16 @@ def get_category_error_response(category: str) -> dict[str, Any]: location_param="category" ) ``` + **Key Parameters for `log_and_generate_response()`:** - `log_message`: A detailed message for internal logging. This should contain specific information useful for debugging. - `diagnostics`: The user-facing error message that will be included in the API response body. - `location_param` (optional): The name of the parameter that caused the error. This helps pinpoint the issue for API consumers. ### 2. Handling Unexpected Exceptions (Global Error Handler) + For unexpected errors, a global exception handler in `error_handler.py` catches any unhandled exception and returns a generic 500 Internal Server Error. This prevents sensitive information from leaking in stack traces. + ``` python # error_handler.py @@ -52,7 +62,9 @@ def handle_exception(e: Exception) -> ResponseReturnValue | HTTPException: ) return make_response(response.get("body"), response.get("statusCode"), response.get("headers")) ``` + ### 3. Creating New Error Types + If a new, reusable error condition is identified, you should add a new instance of `APIErrorResponse` to `api_error_response.py` Follow the existing pattern: @@ -70,4 +82,5 @@ SOME_NEW_ERROR = APIErrorResponse( fhir_display_message="A new specific error message for display", ) ``` + By centralizing error definitions, we ensure that the API provides a consistent and predictable experience for its consumers. From 816d6c1f6fad57aaad431504ce7d2c1aba4130a3 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 21 Jul 2025 15:56:36 +0100 Subject: [PATCH 3/4] Fix markdown linting issues --- .../common/api-error-response-readme.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/eligibility_signposting_api/common/api-error-response-readme.md b/src/eligibility_signposting_api/common/api-error-response-readme.md index 67f0278c3..39aed8110 100644 --- a/src/eligibility_signposting_api/common/api-error-response-readme.md +++ b/src/eligibility_signposting_api/common/api-error-response-readme.md @@ -20,10 +20,11 @@ The error handling mechanism is built around the class `APIErrorResponse`. The primary way to handle errors is to import a pre-defined error object from `api_error_response.py` and call its `log_and_generate_response()` method. ### 1. Handling Specific, Known Errors + For handling validation failures or other expected error conditions, use one of the pre-defined error instances. The `wrapper.py` module uses this pattern to validate query parameters. If a parameter is invalid, it calls the corresponding error function. -**Example: Invalid "category" parameter** +#### Example: Invalid "category" parameter ``` python # wrapper.py @@ -40,7 +41,8 @@ def get_category_error_response(category: str) -> dict[str, Any]: ) ``` -**Key Parameters for `log_and_generate_response()`:** +#### Key Parameters for `log_and_generate_response()`: + - `log_message`: A detailed message for internal logging. This should contain specific information useful for debugging. - `diagnostics`: The user-facing error message that will be included in the API response body. - `location_param` (optional): The name of the parameter that caused the error. This helps pinpoint the issue for API consumers. From 035e39b4c4456a6d5949494b1a2ac563d5af9779 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 21 Jul 2025 15:58:14 +0100 Subject: [PATCH 4/4] Fix markdown linting issues --- .../common/api-error-response-readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eligibility_signposting_api/common/api-error-response-readme.md b/src/eligibility_signposting_api/common/api-error-response-readme.md index 39aed8110..db6190be7 100644 --- a/src/eligibility_signposting_api/common/api-error-response-readme.md +++ b/src/eligibility_signposting_api/common/api-error-response-readme.md @@ -41,7 +41,7 @@ def get_category_error_response(category: str) -> dict[str, Any]: ) ``` -#### Key Parameters for `log_and_generate_response()`: +#### Key Parameters for `log_and_generate_response()` - `log_message`: A detailed message for internal logging. This should contain specific information useful for debugging. - `diagnostics`: The user-facing error message that will be included in the API response body.