Skip to content
Open
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
1 change: 1 addition & 0 deletions .vscode/cspell-dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ asid
fhir
getstructuredrecord
gpconnect
searchset
usefixtures
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ build-gateway-api: dependencies
@poetry run mypy --no-namespace-packages .
@echo "Packaging dependencies..."
@poetry build --format=wheel
@pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_2_x86_64 --only-binary=:all:
@pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_2_x86_64 --platform musllinux_1_1_x86_64 --only-binary=:all:
# Copy main file separately as it is not included within the package.
@rm -rf ../infrastructure/images/gateway-api/resources/build/
@mkdir ../infrastructure/images/gateway-api/resources/build/
Expand Down
2 changes: 1 addition & 1 deletion gateway-api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ paths:
properties:
system:
type: string
minLength: 1
enum: ["https://fhir.nhs.uk/Id/nhs-number"]
example: "https://fhir.nhs.uk/Id/nhs-number"
value:
type: string
Expand Down
1,747 changes: 934 additions & 813 deletions gateway-api/poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions gateway-api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ flask = "^3.1.3"
types-flask = "^1.1.6"
requests = "^2.32.5"
pyjwt = "^2.11.0"
pydantic = "^2.12.5"

[tool.poetry]
packages = [{include = "gateway_api", from = "src"},
Expand Down
76 changes: 76 additions & 0 deletions gateway-api/src/fhir/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# FHIR Types in Gateway API

## What is FHIR?

FHIR (Fast Healthcare Interoperability Resources) is the HL7 standard for exchanging healthcare information as structured resources over HTTP APIs.

Read more on the standards: [R4](https://hl7.org/fhir/R4/overview.html) and [STU3](https://hl7.org/fhir/R4/overview.html).

In this codebase, the FHIR package provides strongly typed Python models for request validation, response parsing, and safe serialization.

## FHIR versions in Clinical Data Sharing APIs

Two FHIR versions are used:

- STU3: used only for inbound Gateway API operation messages with `resourceType` Parameters (the Access Record Structured request payload).
- R4: used for all other typed resources in this module, including PDS FHIR resources such as Patient.

Version behaviour in the current flow:

- Inbound request body is validated as STU3 Parameters.
- Outbound provider response body is returned without transformation (mirrored payload).
- PDS, SDS, and internal typed handling use R4 resource models.

## How Pydantic is used

This package uses Pydantic to make FHIR payload handling explicit and safe:

- Model validation: model_validate(...) is used to parse inbound JSON into typed models.
- Field aliasing: FHIR JSON names like `resourceType`, `fullUrl`, `lastUpdated` are mapped with `Field(alias=...)`.
- Type constraints: `Annotated`, `Literal`, and `min_length` constraints enforce schema-like rules.
- Runtime guards: validators check that `resourceType` and identifier system values match expected FHIR semantics.
- Polymorphism: the Resource base type dispatches to the correct subclass from `resourceType`.
- Serialization: `model_dump()`/`model_dump_json()` default to exclude_none=True to avoid emitting empty FHIR fields.

Typical patterns in this code:

- Parse JSON from API input or upstream systems into typed models.
- Access domain properties (for example, `Patient.nhs_number`) instead of raw dictionary traversal.
- Serialize models back to canonical FHIR JSON with aliases preserved.

## Example usage

The example below shows how to load a simple FHIR R4 Patient payload and obtain the GP ODS code.

```python
from fhir import Patient

payload = {
"resourceType": "Patient",
"identifier": [
{
"system": "https://fhir.nhs.uk/Id/nhs-number",
"value": "9000000009",
}
],
"generalPractitioner": [
{
"type": "Organization",
"identifier": {
"system": "https://fhir.nhs.uk/Id/ods-organization-code",
"value": "A12345",
},
}
],
}

patient = Patient.model_validate(payload)

nhs_number = patient.nhs_number
gp_ods_code = patient.gp_ods_code

print(nhs_number) # 9000000009
print(gp_ods_code) # A12345
```

If `generalPractitioner` is missing, `patient.gp_ods_code` returns `None`.
22 changes: 0 additions & 22 deletions gateway-api/src/fhir/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +0,0 @@
"""FHIR data types and resources."""

from fhir.bundle import Bundle, BundleEntry
from fhir.general_practitioner import GeneralPractitioner
from fhir.human_name import HumanName
from fhir.identifier import Identifier
from fhir.operation_outcome import OperationOutcome, OperationOutcomeIssue
from fhir.parameters import Parameter, Parameters
from fhir.patient import Patient

__all__ = [
"Bundle",
"BundleEntry",
"HumanName",
"Identifier",
"OperationOutcome",
"OperationOutcomeIssue",
"Parameter",
"Parameters",
"Patient",
"GeneralPractitioner",
]
18 changes: 0 additions & 18 deletions gateway-api/src/fhir/bundle.py

This file was deleted.

21 changes: 0 additions & 21 deletions gateway-api/src/fhir/general_practitioner.py

This file was deleted.

12 changes: 0 additions & 12 deletions gateway-api/src/fhir/human_name.py

This file was deleted.

8 changes: 0 additions & 8 deletions gateway-api/src/fhir/identifier.py

This file was deleted.

14 changes: 0 additions & 14 deletions gateway-api/src/fhir/operation_outcome.py

This file was deleted.

15 changes: 0 additions & 15 deletions gateway-api/src/fhir/parameters.py

This file was deleted.

17 changes: 0 additions & 17 deletions gateway-api/src/fhir/patient.py

This file was deleted.

10 changes: 0 additions & 10 deletions gateway-api/src/fhir/period.py

This file was deleted.

27 changes: 27 additions & 0 deletions gateway-api/src/fhir/r4/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""FHIR R4 data types and resources."""

from .elements.identifier import Identifier, NHSNumberValueIdentifier, UUIDIdentifier
from .elements.issue import Issue, IssueCode, IssueSeverity
from .elements.meta import Meta
from .elements.reference import Reference
from .resources.bundle import Bundle
from .resources.device import Device
from .resources.endpoint import Endpoint
from .resources.operation_outcome import OperationOutcome
from .resources.patient import Patient

__all__ = [
"Bundle",
"Device",
"Endpoint",
"Identifier",
"Issue",
"IssueCode",
"IssueSeverity",
"Meta",
"NHSNumberValueIdentifier",
"OperationOutcome",
"Patient",
"Reference",
"UUIDIdentifier",
]
Empty file.
50 changes: 50 additions & 0 deletions gateway-api/src/fhir/r4/elements/identifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import uuid
from abc import ABC
from dataclasses import dataclass
from typing import ClassVar

from pydantic import model_validator


@dataclass(frozen=True)
class Identifier(ABC):
"""
A FHIR R4 Identifier element. See https://hl7.org/fhir/R4/datatypes.html#Identifier.
Attributes:
system: The namespace for the identifier value.
value: The value that is unique within the system.
"""

_expected_system: ClassVar[str] = "__unknown__"

value: str
system: str

@model_validator(mode="after")
def validate_system(self) -> "Identifier":
if self.system != self._expected_system:
raise ValueError(
f"Identifier system '{self.system}' does not match expected "
f"system '{self._expected_system}'."
)
return self

@classmethod
def __init_subclass__(cls, expected_system: str) -> None:
cls._expected_system = expected_system


class UUIDIdentifier(Identifier, expected_system="https://tools.ietf.org/html/rfc4122"):
"""A UUID identifier utilising the standard RFC 4122 system."""

def __init__(self, value: uuid.UUID | None = None):
super().__init__(
value=str(value or uuid.uuid4()),
system=self._expected_system,
)


class NHSNumberValueIdentifier(
Identifier, expected_system="https://fhir.nhs.uk/Id/nhs-number"
):
"""A valueIdentifier NHS numbers - used in Parameter"""
26 changes: 26 additions & 0 deletions gateway-api/src/fhir/r4/elements/issue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from abc import ABC
from dataclasses import dataclass
from enum import StrEnum


class IssueSeverity(StrEnum):
FATAL = "fatal"
ERROR = "error"
WARNING = "warning"
INFORMATION = "information"


class IssueCode(StrEnum):
INVALID = "invalid"
EXCEPTION = "exception"


@dataclass(frozen=True)
class Issue(ABC):
"""
A FHIR R4 OperationOutcome Issue element. See https://hl7.org/fhir/R4/datatypes.html#OperationOutcome.
"""

severity: IssueSeverity
code: IssueCode
diagnostics: str | None = None
31 changes: 31 additions & 0 deletions gateway-api/src/fhir/r4/elements/meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import datetime
from dataclasses import dataclass
from typing import Annotated

from pydantic import Field


@dataclass(frozen=True)
class Meta:
"""
A FHIR R4 Meta element. See https://hl7.org/fhir/R4/datatypes.html#Meta.
Attributes:
version_id: The version id of the resource.
last_updated: The last updated timestamp of the resource.
"""

last_updated: Annotated[datetime.datetime | None, Field(alias="lastUpdated")] = None
version_id: Annotated[str | None, Field(alias="versionId")] = None

@classmethod
def with_last_updated(cls, last_updated: datetime.datetime | None = None) -> "Meta":
"""
Create a Meta instance with the provided last_updated timestamp.
Args:
last_updated: The last updated timestamp.
Returns:
A Meta instance with the specified last_updated.
"""
return cls(
last_updated=last_updated or datetime.datetime.now(tz=datetime.timezone.utc)
)
Empty file.
Loading
Loading