Skip to content

Commit 6a69c54

Browse files
authored
Merge pull request #449 from NHSDigital/feature/eja-eli-414-spin-our-own-FHIR-operation-outcome
feat: replace fhir-resources with lightweight pydantic models
2 parents 60d567d + 3b2604f commit 6a69c54

File tree

7 files changed

+541
-119
lines changed

7 files changed

+541
-119
lines changed

poetry.lock

Lines changed: 2 additions & 46 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ eval-type-backport = "^0.2.2"
3030
mangum = "^0.19.0"
3131
wireup = "^2.1.0"
3232
python-json-logger = "^3.3.0"
33-
fhir-resources = "^8.0.0"
3433
python-dateutil = "^2.9.0"
3534
pyhamcrest = "^2.1.0"
3635
boto3 = "^1.40.57"
@@ -91,6 +90,8 @@ ignore = ["COM812", "D"]
9190
[tool.pyright]
9291
include = ["src/"]
9392
pythonVersion = "3.13"
93+
venvPath = "."
94+
venv = ".venv"
9495

9596
[tool.pytest.ini_options]
9697
log_cli = true

scripts/config/pre-commit.yaml

Lines changed: 76 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,77 @@
11
repos:
2-
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: v4.0.1
4-
hooks:
5-
- id: check-added-large-files
6-
- id: check-case-conflict
7-
- id: check-merge-conflict
8-
- id: check-yaml
9-
- id: detect-aws-credentials
10-
args: [--allow-missing-credentials]
11-
- id: end-of-file-fixer
12-
- id: trailing-whitespace
13-
- id: mixed-line-ending
14-
- repo: local
15-
hooks:
16-
- id: scan-secrets
17-
name: Scan secrets
18-
entry: ./scripts/githooks/scan-secrets.sh
19-
args: ["check=staged-changes"]
20-
language: script
21-
pass_filenames: false
22-
- repo: local
23-
hooks:
24-
- id: check-file-format
25-
name: Check file format
26-
entry: ./scripts/githooks/check-file-format.sh
27-
args: ["check=staged-changes"]
28-
language: script
29-
pass_filenames: false
30-
- repo: local
31-
hooks:
32-
- id: check-markdown-format
33-
name: Check Markdown format
34-
entry: ./scripts/githooks/check-markdown-format.sh
35-
args: ["check=staged-changes"]
36-
language: script
37-
pass_filenames: false
38-
- repo: local
39-
hooks:
40-
- id: check-english-usage
41-
name: Check English usage
42-
entry: ./scripts/githooks/check-english-usage.sh
43-
args: ["check=staged-changes"]
44-
language: script
45-
pass_filenames: false
46-
- repo: local
47-
hooks:
48-
- id: lint-terraform
49-
name: Lint Terraform
50-
entry: ./scripts/githooks/check-terraform-format.sh
51-
language: script
52-
pass_filenames: false
53-
- repo: https://github.com/astral-sh/ruff-pre-commit
54-
rev: v0.9.7
55-
hooks:
56-
- id: ruff
57-
args: [ --fix ]
58-
- id: ruff-format
59-
- repo: https://github.com/RobertCraigie/pyright-python
60-
rev: v1.1.394
61-
hooks:
62-
- id: pyright
63-
- repo: https://github.com/milin/giticket
64-
rev: v1.3
65-
hooks:
66-
- id: giticket
67-
args: [ '--regex=(?i)ELID-\d+', '--format=[{ticket}] {commit_msg}', '--mode=regex_match' ]
68-
stages: [commit-msg]
2+
- repo: https://github.com/pre-commit/pre-commit-hooks
3+
rev: v4.0.1
4+
hooks:
5+
- id: check-added-large-files
6+
- id: check-case-conflict
7+
- id: check-merge-conflict
8+
- id: check-yaml
9+
- id: detect-aws-credentials
10+
args: [--allow-missing-credentials]
11+
- id: end-of-file-fixer
12+
- id: trailing-whitespace
13+
- id: mixed-line-ending
14+
- repo: local
15+
hooks:
16+
- id: scan-secrets
17+
name: Scan secrets
18+
entry: ./scripts/githooks/scan-secrets.sh
19+
args: ["check=staged-changes"]
20+
language: script
21+
pass_filenames: false
22+
- repo: local
23+
hooks:
24+
- id: check-file-format
25+
name: Check file format
26+
entry: ./scripts/githooks/check-file-format.sh
27+
args: ["check=staged-changes"]
28+
language: script
29+
pass_filenames: false
30+
- repo: local
31+
hooks:
32+
- id: check-markdown-format
33+
name: Check Markdown format
34+
entry: ./scripts/githooks/check-markdown-format.sh
35+
args: ["check=staged-changes"]
36+
language: script
37+
pass_filenames: false
38+
- repo: local
39+
hooks:
40+
- id: check-english-usage
41+
name: Check English usage
42+
entry: ./scripts/githooks/check-english-usage.sh
43+
args: ["check=staged-changes"]
44+
language: script
45+
pass_filenames: false
46+
- repo: local
47+
hooks:
48+
- id: lint-terraform
49+
name: Lint Terraform
50+
entry: ./scripts/githooks/check-terraform-format.sh
51+
language: script
52+
pass_filenames: false
53+
- repo: https://github.com/astral-sh/ruff-pre-commit
54+
rev: v0.9.7
55+
hooks:
56+
- id: ruff
57+
args: [--fix]
58+
- id: ruff-format
59+
- repo: local
60+
hooks:
61+
- id: pyright
62+
name: pyright
63+
entry: poetry run pyright
64+
language: system
65+
types: [python]
66+
pass_filenames: false
67+
- repo: https://github.com/milin/giticket
68+
rev: v1.3
69+
hooks:
70+
- id: giticket
71+
args:
72+
[
73+
'--regex=(?i)ELID-\d+',
74+
"--format=[{ticket}] {commit_msg}",
75+
"--mode=regex_match",
76+
]
77+
stages: [commit-msg]

src/eligibility_signposting_api/common/api_error_response.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
from enum import Enum
66
from http import HTTPStatus
77

8-
from fhir.resources.operationoutcome import OperationOutcome, OperationOutcomeIssue
98
from flask import make_response
109
from flask.typing import ResponseReturnValue
1110

11+
from eligibility_signposting_api.model.operation_outcome import OperationOutcome, OperationOutcomeIssue
12+
1213
logger = logging.getLogger(__name__)
1314

1415

@@ -61,10 +62,10 @@ def build_operation_outcome_issue(self, diagnostics: str, location: list[str] |
6162
return OperationOutcomeIssue(
6263
severity=self.fhir_issue_severity,
6364
code=self.fhir_issue_code,
65+
details=details,
6466
diagnostics=diagnostics,
6567
location=location,
66-
details=details,
67-
) # pyright: ignore[reportCallIssue]
68+
)
6869

6970
def generate_response(self, diagnostics: str, location_param: str | None = None) -> ResponseReturnValue:
7071
issue_location = [f"parameters/{location_param}"] if location_param else None
@@ -73,9 +74,9 @@ def generate_response(self, diagnostics: str, location_param: str | None = None)
7374
id=str(uuid.uuid4()),
7475
meta={"lastUpdated": datetime.now(UTC)},
7576
issue=[self.build_operation_outcome_issue(diagnostics, issue_location)],
76-
) # pyright: ignore[reportCallIssue]
77+
)
7778

78-
response_body = json.dumps(problem.model_dump(by_alias=True, mode="json"))
79+
response_body = json.dumps(problem.model_dump(mode="json", exclude_none=True))
7980
return make_response(response_body, self.status_code, {"Content-Type": "application/fhir+json"})
8081

8182
def log_and_generate_response(
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""
2+
FHIR OperationOutcome models for API error responses.
3+
4+
Lightweight pydantic models for FHIR-compliant error responses without
5+
requiring the heavyweight fhir-resources package.
6+
7+
See: https://www.hl7.org/fhir/operationoutcome.html
8+
"""
9+
10+
from typing import Any
11+
12+
from pydantic import BaseModel, Field
13+
14+
15+
class OperationOutcomeIssue(BaseModel):
16+
"""
17+
FHIR OperationOutcome.Issue component.
18+
19+
Represents a single issue associated with an action.
20+
"""
21+
22+
severity: str = Field(
23+
...,
24+
description="fatal | error | warning | information",
25+
)
26+
code: str = Field(
27+
...,
28+
description="FHIR issue type code",
29+
)
30+
details: dict[str, Any] = Field(
31+
...,
32+
description="Additional details about the error (CodeableConcept)",
33+
)
34+
diagnostics: str = Field(
35+
...,
36+
description="Additional diagnostic information about the issue",
37+
)
38+
location: list[str] | None = Field(
39+
default=None,
40+
description="FHIRPath of element(s) related to issue",
41+
)
42+
43+
model_config = {"extra": "forbid"}
44+
45+
46+
class OperationOutcome(BaseModel):
47+
"""
48+
FHIR OperationOutcome resource.
49+
50+
A collection of error, warning, or information messages that result
51+
from a system action.
52+
"""
53+
54+
resourceType: str = Field( # noqa: N815
55+
default="OperationOutcome",
56+
frozen=True,
57+
description="FHIR resource type",
58+
)
59+
id: str | None = Field(
60+
default=None,
61+
description="Logical id of this artifact",
62+
)
63+
meta: dict[str, Any] = Field(
64+
default_factory=dict,
65+
description="Metadata about the resource",
66+
)
67+
issue: list[OperationOutcomeIssue] = Field(
68+
...,
69+
min_length=1,
70+
description="A single issue associated with the action",
71+
)
72+
73+
model_config = {"extra": "forbid"}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Hamcrest matchers for FHIR OperationOutcome models."""
2+
3+
from hamcrest.core.matcher import Matcher
4+
5+
from eligibility_signposting_api.model.operation_outcome import OperationOutcome, OperationOutcomeIssue
6+
7+
from .meta import BaseAutoMatcher
8+
9+
10+
class OperationOutcomeIssueMatcher(BaseAutoMatcher[OperationOutcomeIssue]): ...
11+
12+
13+
class OperationOutcomeMatcher(BaseAutoMatcher[OperationOutcome]): ...
14+
15+
16+
def is_operation_outcome_issue() -> Matcher[OperationOutcomeIssue]:
17+
"""Create a matcher for OperationOutcomeIssue."""
18+
return OperationOutcomeIssueMatcher()
19+
20+
21+
def is_operation_outcome() -> Matcher[OperationOutcome]:
22+
"""Create a matcher for OperationOutcome."""
23+
return OperationOutcomeMatcher()

0 commit comments

Comments
 (0)