Skip to content

Commit 3f05f51

Browse files
authored
Refactor package structure (#247)
1 parent f891528 commit 3f05f51

32 files changed

Lines changed: 250 additions & 158 deletions

src/eligibility_signposting_api/app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
from mangum.types import LambdaContext, LambdaEvent
99

1010
from eligibility_signposting_api import audit, repos, services
11+
from eligibility_signposting_api.common.error_handler import handle_exception
12+
from eligibility_signposting_api.common.request_validator import validate_request_params
1113
from eligibility_signposting_api.config.config import config, init_logging
12-
from eligibility_signposting_api.error_handler import handle_exception
1314
from eligibility_signposting_api.views import eligibility_blueprint
14-
from eligibility_signposting_api.wrapper import validate_request_params
1515

1616
init_logging()
1717
logger = logging.getLogger(__name__)

src/eligibility_signposting_api/audit/audit_context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
RequestAuditQueryParams,
1919
)
2020
from eligibility_signposting_api.audit.audit_service import AuditService
21-
from eligibility_signposting_api.model.eligibility import (
21+
from eligibility_signposting_api.model.eligibility_status import (
2222
CohortGroupResult,
2323
ConditionName,
2424
IterationResult,

src/eligibility_signposting_api/common/__init__.py

Whitespace-only changes.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# How to Use the API Error Response Module
2+
3+
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.
4+
5+
## Core Concepts
6+
7+
The error handling mechanism is built around the class `APIErrorResponse`.
8+
9+
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.
10+
2. **Pre-defined Error Instances**: The module defines several singleton instances of for common, application-specific errors. Examples include:
11+
- `INVALID_CATEGORY_ERROR`
12+
- `NHS_NUMBER_MISMATCH_ERROR`
13+
- `INTERNAL_SERVER_ERROR`
14+
3. **`log_and_generate_response()` Method**: This is the primary method to be used. When called on an `APIErrorResponse` instance, it performs two actions:
15+
- Logs the error with a detailed internal message.
16+
- Generates a complete HTTP response dictionary (`statusCode`, `headers`, `body`) containing a FHIR-compliant `OperationOutcome` payload.
17+
18+
## How to Use
19+
20+
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.
21+
22+
### 1. Handling Specific, Known Errors
23+
24+
For handling validation failures or other expected error conditions, use one of the pre-defined error instances.
25+
The `wrapper.py` module uses this pattern to validate query parameters. If a parameter is invalid, it calls the corresponding error function.
26+
27+
#### Example: Invalid "category" parameter
28+
29+
``` python
30+
# wrapper.py
31+
32+
from eligibility_signposting_api.api_error_response import INVALID_CATEGORY_ERROR
33+
34+
def get_category_error_response(category: str) -> dict[str, Any]:
35+
"""Generates an error response for an invalid category."""
36+
37+
return INVALID_CATEGORY_ERROR.log_and_generate_response(
38+
log_message=f"Invalid category query param: '{category}'",
39+
diagnostics=f"{category} is not a category that is supported by the API",
40+
location_param="category"
41+
)
42+
```
43+
44+
#### Key Parameters for `log_and_generate_response()`
45+
46+
- `log_message`: A detailed message for internal logging. This should contain specific information useful for debugging.
47+
- `diagnostics`: The user-facing error message that will be included in the API response body.
48+
- `location_param` (optional): The name of the parameter that caused the error. This helps pinpoint the issue for API consumers.
49+
50+
### 2. Handling Unexpected Exceptions (Global Error Handler)
51+
52+
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.
53+
54+
``` python
55+
# error_handler.py
56+
57+
from eligibility_signposting_api.api_error_response import INTERNAL_SERVER_ERROR
58+
59+
def handle_exception(e: Exception) -> ResponseReturnValue | HTTPException:
60+
# Generate a generic, safe response for the client
61+
response = INTERNAL_SERVER_ERROR.log_and_generate_response(
62+
log_message=f"An unexpected error occurred: {traceback.format_exception(e)}",
63+
diagnostics="An unexpected error occurred."
64+
)
65+
return make_response(response.get("body"), response.get("statusCode"), response.get("headers"))
66+
```
67+
68+
### 3. Creating New Error Types
69+
70+
If a new, reusable error condition is identified, you should add a new instance of `APIErrorResponse` to `api_error_response.py`
71+
Follow the existing pattern:
72+
73+
``` python
74+
# api_error_response.py
75+
76+
# ... (other error definitions)
77+
78+
SOME_NEW_ERROR = APIErrorResponse(
79+
status_code=HTTPStatus.BAD_REQUEST,
80+
fhir_issue_code=FHIRIssueCode.VALUE,
81+
fhir_issue_severity=FHIRIssueSeverity.ERROR,
82+
fhir_coding_system=FHIR_SPINE_ERROR_CODE_SYSTEM,
83+
fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER,
84+
fhir_display_message="A new specific error message for display",
85+
)
86+
```
87+
88+
By centralizing error definitions, we ensure that the API provides a consistent and predictable experience for its consumers.

src/eligibility_signposting_api/api_error_response.py renamed to src/eligibility_signposting_api/common/api_error_response.py

File renamed without changes.

src/eligibility_signposting_api/error_handler.py renamed to src/eligibility_signposting_api/common/error_handler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from flask.typing import ResponseReturnValue
66
from werkzeug.exceptions import HTTPException
77

8-
from eligibility_signposting_api.api_error_response import INTERNAL_SERVER_ERROR
8+
from eligibility_signposting_api.common.api_error_response import INTERNAL_SERVER_ERROR
99

1010
logger = logging.getLogger(__name__)
1111

src/eligibility_signposting_api/wrapper.py renamed to src/eligibility_signposting_api/common/request_validator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from mangum.types import LambdaContext, LambdaEvent
88

9-
from eligibility_signposting_api.api_error_response import (
9+
from eligibility_signposting_api.common.api_error_response import (
1010
INVALID_CATEGORY_ERROR,
1111
INVALID_CONDITION_FORMAT_ERROR,
1212
INVALID_INCLUDE_ACTIONS_ERROR,

src/eligibility_signposting_api/model/eligibility.py renamed to src/eligibility_signposting_api/model/eligibility_status.py

File renamed without changes.

src/eligibility_signposting_api/repos/person_repo.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from boto3.resources.base import ServiceResource
66
from wireup import Inject, service
77

8-
from eligibility_signposting_api.model.eligibility import NHSNumber
8+
from eligibility_signposting_api.model.eligibility_status import NHSNumber
99
from eligibility_signposting_api.repos.exceptions import NotFoundError
1010

1111
logger = logging.getLogger(__name__)

src/eligibility_signposting_api/services/calculators/eligibility_calculator.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424

2525
from wireup import service
2626

27-
from eligibility_signposting_api.model import eligibility, rules
28-
from eligibility_signposting_api.model.eligibility import (
27+
from eligibility_signposting_api.model import eligibility_status, rules
28+
from eligibility_signposting_api.model.eligibility_status import (
2929
ActionCode,
3030
ActionDescription,
3131
ActionType,
@@ -58,15 +58,15 @@ class EligibilityCalculator:
5858
person_data: Row
5959
campaign_configs: Collection[rules.CampaignConfig]
6060

61-
results: list[eligibility.Condition] = field(default_factory=list)
61+
results: list[eligibility_status.Condition] = field(default_factory=list)
6262

6363
@property
6464
def active_campaigns(self) -> list[rules.CampaignConfig]:
6565
return [cc for cc in self.campaign_configs if cc.campaign_live]
6666

6767
def campaigns_grouped_by_condition_name(
6868
self, conditions: list[str], category: str
69-
) -> Iterator[tuple[eligibility.ConditionName, list[rules.CampaignConfig]]]:
69+
) -> Iterator[tuple[eligibility_status.ConditionName, list[rules.CampaignConfig]]]:
7070
"""Generator that yields campaign groups filtered by condition names and campaign category."""
7171

7272
mapping = {
@@ -100,9 +100,9 @@ def get_the_best_cohort_memberships(
100100
cohort_results: dict[str, CohortGroupResult],
101101
) -> tuple[Status, list[CohortGroupResult]]:
102102
if not cohort_results:
103-
return eligibility.Status.not_eligible, []
103+
return eligibility_status.Status.not_eligible, []
104104

105-
best_status = eligibility.Status.best(*[result.status for result in cohort_results.values()])
105+
best_status = eligibility_status.Status.best(*[result.status for result in cohort_results.values()])
106106
best_cohorts = [result for result in cohort_results.values() if result.status == best_status]
107107

108108
best_cohorts = [
@@ -158,7 +158,7 @@ def get_action_rules_components(
158158

159159
def evaluate_eligibility(
160160
self, include_actions: str, conditions: list[str], category: str
161-
) -> eligibility.EligibilityStatus:
161+
) -> eligibility_status.EligibilityStatus:
162162
include_actions_flag = include_actions.upper() == "Y"
163163
condition_results: dict[ConditionName, IterationResult] = {}
164164
actions: list[SuggestedAction] | None = []
@@ -186,7 +186,7 @@ def evaluate_eligibility(
186186
),
187187
) = max(iteration_results.items(), key=lambda item: item[1][1].status.value)
188188
else:
189-
best_candidate = IterationResult(eligibility.Status.not_eligible, [], actions)
189+
best_candidate = IterationResult(eligibility_status.Status.not_eligible, [], actions)
190190
best_campaign_id = None
191191
best_campaign_version = None
192192
best_active_iteration = None
@@ -233,7 +233,7 @@ def evaluate_eligibility(
233233

234234
# Consolidate all the results and return
235235
final_result = self.build_condition_results(condition_results)
236-
return eligibility.EligibilityStatus(conditions=final_result)
236+
return eligibility_status.EligibilityStatus(conditions=final_result)
237237

238238
def get_iteration_results(
239239
self, actions: list[SuggestedAction] | None, campaign_group: list[CampaignConfig]
@@ -406,20 +406,20 @@ def evaluate_suppression_rules(
406406

407407
def evaluate_rules_priority_group(
408408
self, rules_group: Iterator[rules.IterationRule]
409-
) -> tuple[eligibility.Status, list[eligibility.Reason], bool]:
409+
) -> tuple[eligibility_status.Status, list[eligibility_status.Reason], bool]:
410410
is_rule_stop = False
411411
exclusion_reasons = []
412-
best_status = eligibility.Status.not_eligible
412+
best_status = eligibility_status.Status.not_eligible
413413

414414
for rule in rules_group:
415415
is_rule_stop = rule.rule_stop or is_rule_stop
416416
rule_calculator = RuleCalculator(person_data=self.person_data, rule=rule)
417417
status, reason = rule_calculator.evaluate_exclusion()
418418
if status.is_exclusion:
419-
best_status = eligibility.Status.best(status, best_status)
419+
best_status = eligibility_status.Status.best(status, best_status)
420420
exclusion_reasons.append(reason)
421421
else:
422-
best_status = eligibility.Status.actionable
422+
best_status = eligibility_status.Status.actionable
423423

424424
return best_status, exclusion_reasons, is_rule_stop
425425

0 commit comments

Comments
 (0)