Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cabda0e
[CDAPI-110]: Added validation for retrieving the requesting organisat…
nhsd-jack-wainwright Mar 30, 2026
2dd04e0
[CDAPI-110]: Updated Identifier classes to support system being provi…
nhsd-jack-wainwright Mar 30, 2026
bc81ea5
[CDAPI-110]: Added model validator for Extension
nhsd-jack-wainwright Mar 30, 2026
8cf496d
[CDAPI-110]: Added type_name to Extension subclasses
nhsd-jack-wainwright Mar 31, 2026
48307ed
[CDAPI-110]: Minor updates to VSCode settings
nhsd-jack-wainwright Mar 31, 2026
0078ef6
[CDAPI-110]: Added allow extra fields to Extension class
nhsd-jack-wainwright Mar 31, 2026
26bdf15
[CDAPI-110]: Improved error message when incorrect extension type is …
nhsd-jack-wainwright Mar 31, 2026
ee8d273
[CDAPI-110]: Removed unnecessary StringExtension class
nhsd-jack-wainwright Mar 31, 2026
5471465
[CDAPI-110]: Swapped Organization to reference Identifier class inste…
nhsd-jack-wainwright Mar 31, 2026
846db58
[CDAPI-110]: Bruno collection update to account for new validation
nhsd-jack-wainwright Mar 31, 2026
e3db3ba
[CDAPI-110]: Further minor tweak to vscode python analysis settings
nhsd-jack-wainwright Mar 31, 2026
ad234a9
[CDAPI-110]: Added additional handle_request unit tests and fixed exi…
nhsd-jack-wainwright Mar 31, 2026
38bad17
[CDAPI-110]: Adding additional unit tests for new components
nhsd-jack-wainwright Mar 31, 2026
4e1faa6
[CDAPI-110]: Adding additional unit tests around element classes
nhsd-jack-wainwright Apr 1, 2026
a0ee9d7
[CDAPI-110]: Fixed contract tests and existing integration tests
nhsd-jack-wainwright Apr 1, 2026
128d0db
[CDAPI-110]: Added optional stage parameter to test-remote make target
nhsd-jack-wainwright Apr 1, 2026
77c75d7
[CDAPI-110]: Further additions to python analysis exclusions within v…
nhsd-jack-wainwright Apr 1, 2026
a949cbd
[CDAPI-110]: Added new integration test scenarios for invalid Organiz…
nhsd-jack-wainwright Apr 2, 2026
f22edb0
[CDAPI-110]: Fixed acceptance tests to account for new validation
nhsd-jack-wainwright Apr 2, 2026
7f5a5e1
[CDAPI-110]: Minor unit test fixes most rebase
nhsd-jack-wainwright Apr 2, 2026
f9c2e38
[CDAPI-110]: Minor sonar fixes
nhsd-jack-wainwright Apr 2, 2026
105a755
[CDAPI-110]: Fixing noqa comments for sonar
nhsd-jack-wainwright Apr 2, 2026
ab6745e
[CDAPI-110]: Split out invalid composition integration scenarios into…
nhsd-jack-wainwright Apr 2, 2026
910109a
[CDAPI-110]: Moved invalid ServiceRequest scenarios into separate int…
nhsd-jack-wainwright Apr 2, 2026
7049535
[CDAPI-110]: Separated invalid PractitionerRole resource tests into a…
nhsd-jack-wainwright Apr 2, 2026
a39632d
[CDAPI-110]: Moved invalid organization resource integration tests in…
nhsd-jack-wainwright Apr 2, 2026
3b7945f
[CDAPI-110]: Added BundleBuilder test utility class
nhsd-jack-wainwright Apr 8, 2026
ea61045
[CDAPI-150]: Added validation ensuring an empty string cannot be prov…
nhsd-jack-wainwright Apr 16, 2026
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
16 changes: 15 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@
"python.telemetry.enable": false,
"debugpy.telemetry.enable": false,
"python.experiments.enabled": false,
// Exclude generated files from analysis
"python.analysis.exclude": [
"**/target",
"**/__pycache__",
"**/dist",
"**/build",
"**/.venv",
"**/.mypy_cache",
"**/.pytest_cache",
"**/.ruff_cache",
"**/node_modules",
"**/.*"
],
// Enabling Ruff formatter for python files.
"[python]": {
"editor.formatOnSave": true,
Expand Down Expand Up @@ -64,5 +77,6 @@
"projectKey": "NHSDigital_clinical-data-pathology-api"
},
// Disabling automatic port forwarding as the devcontainer should already have access to any required ports.
"remote.autoForwardPorts": false
"remote.autoForwardPorts": false,
"python-envs.defaultEnvManager": "ms-python.python:system"
}
38 changes: 38 additions & 0 deletions bruno/APIM/Post_Document_Bundle_via_APIM.bru
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,51 @@ body:json {
"fullUrl": "composition",
"resource": {
"resourceType": "Composition",
"extension": [
{
"url": "http://hl7.eu/fhir/StructureDefinition/composition-basedOn-order-or-requisition",
"valueReference": {
"reference": "servicerequest"
}
}
],
"subject": {
"identifier": {
"system": "https://fhir.nhs.uk/Id/nhs-number",
"value": "test-nhs-number"
}
}
}
},
{
"fullUrl": "servicerequest",
"resource": {
"resourceType": "ServiceRequest",
"requester": {
"reference": "practitionerrole"
}
}
},
{
"fullUrl": "practitionerrole",
"resource": {
"resourceType": "PractitionerRole",
"organization": {
"reference": "organization"
}
}
},
{
"fullUrl": "organization",
"resource": {
"resourceType": "Organization",
"identifier": [
{
"system": "https://fhir.nhs.uk/Id/ods-organization-code",
"value": "testOrg"
}
]
}
}
]
}
Expand Down
38 changes: 38 additions & 0 deletions pathology-api/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from collections.abc import Callable

import pytest
from pathology_api.fhir.r4.elements import (
LiteralReference,
LogicalReference,
OrganizationIdentifier,
PatientIdentifier,
ReferenceExtension,
)
from pathology_api.fhir.r4.resources import (
Bundle,
Composition,
Organization,
)
from pathology_api.test_utils import BundleBuilder


@pytest.fixture(scope="session")
def build_valid_test_result() -> Callable[[str, str], Bundle]:
def builder_function(patient: str, ods_code: str) -> Bundle:
return BundleBuilder.with_defaults(
composition_func=lambda service_request_url: Composition.create(
subject=LogicalReference(PatientIdentifier.from_nhs_number(patient)),
extension=[
# Using HTTP to match profile required by implementation guide.
ReferenceExtension(
url="http://hl7.eu/fhir/StructureDefinition/composition-basedOn-order-or-requisition", # noqa: S5332
valueReference=LiteralReference(service_request_url),
)
],
),
organisation_func=lambda: Organization.create(
identifier=[OrganizationIdentifier.from_ods_code(ods_code)]
),
).build()

return builder_function
137 changes: 119 additions & 18 deletions pathology-api/src/pathology_api/fhir/r4/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
import uuid
from abc import ABC
from dataclasses import dataclass
from typing import Annotated, ClassVar
from typing import Annotated, Any, ClassVar

from pydantic import Field, model_validator
from pydantic import (
BaseModel,
ConfigDict,
Field,
ValidatorFunctionWrapHandler,
model_validator,
)

from pathology_api.exception import ValidationError

Expand Down Expand Up @@ -36,41 +42,80 @@ def with_last_updated(cls, last_updated: datetime.datetime | None = None) -> "Me
)


@dataclass(frozen=True)
class Identifier(ABC):
class Identifier(ABC, BaseModel):
"""
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__"
__system_types: ClassVar[dict[str, type["Identifier"]]] = {}

_validate_system: ClassVar[bool] = True
expected_system: ClassVar[str] = "__unknown__"

system: str
value: str
system: str = Field(..., frozen=True)
value: str = Field(..., frozen=True)

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

@classmethod
def __init_subclass__(cls, expected_system: str) -> None:
cls._expected_system = expected_system
def __init_subclass__(
cls, expected_system: str = "__unknown__", validate_system: bool = True
) -> None:
cls.expected_system = expected_system
cls._validate_system = validate_system

cls.__system_types[expected_system] = cls

@model_validator(mode="wrap")
@classmethod
def validate_with_system(
cls, value: dict[str, Any], handler: ValidatorFunctionWrapHandler
) -> Any:
"""
Provides a model validator that instantiates the correct Identifier type based
on the supplied system. If either no system is provided, or the system provided
is not supported, the UnknownIdentifier type will be utilised.
"""

if cls != Identifier or not isinstance(value, dict):
return handler(value)

system = value.get("system")
if system is None:
# This condition is unreachable as Pydantic will validate the presence of
# the required 'system' field before this validator is called.
raise ValueError("Identifier provided without a system attribute.")

identifier_cls = cls.__system_types.get(system)
if identifier_cls is None:
return UnknownIdentifier.model_validate(value)

return identifier_cls.model_validate(value)


class UnknownIdentifier(Identifier, validate_system=False):
"""Provides a fallback Identifier type for an unknown 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__(
@classmethod
def create_with_uuid(cls, value: uuid.UUID | None = None) -> "UUIDIdentifier":
"""Create a UUIDIdentifier with the provided UUID value."""
return cls(
value=str(value or uuid.uuid4()),
system=self._expected_system,
system=cls.expected_system,
)


Expand All @@ -79,15 +124,71 @@ class PatientIdentifier(
):
"""A FHIR R4 Patient Identifier utilising the NHS Number system."""

def __init__(self, value: str):
super().__init__(value=value, system=self._expected_system)

@classmethod
def from_nhs_number(cls, nhs_number: str) -> "PatientIdentifier":
"""Create a PatientIdentifier from an NHS number."""
return cls(value=nhs_number)
return cls(value=nhs_number, system=cls.expected_system)


class OrganizationIdentifier(
Identifier, expected_system="https://fhir.nhs.uk/Id/ods-organization-code"
):
"""A FHIR R4 Organization Identifier utilising the ODS Organization Code system."""

@classmethod
def from_ods_code(cls, ods_code: str) -> "OrganizationIdentifier":
"""Create an OrganizationIdentifier from an ODS code."""
return cls(value=ods_code, system=cls.expected_system)


@dataclass(frozen=True)
class LogicalReference[T: Identifier]:
identifier: T


@dataclass(frozen=True)
class LiteralReference:
reference: str


class Extension(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow")

__extension_types: ClassVar[dict[str, type["Extension"]]] = {}
type_name: ClassVar[str] = "__unknown__"

url: str = Field(..., frozen=True)

def __init_subclass__(cls, type_name: str) -> None:
cls.type_name = type_name
cls.__extension_types[type_name] = cls
super().__init_subclass__()

@model_validator(mode="wrap")
@classmethod
def validate_with_type(
cls, value: dict[str, Any], handler: ValidatorFunctionWrapHandler
) -> Any:
"""
Provides a model validator that instantiates the correct Extension type based on
the valueX field provided.
If an Extension subclass cannot be found, the default handler is utilised
instead.
"""

# If we're not validating an Extension, or the value is not a dict,
# delegate to the default handler
if cls != Extension or not isinstance(value, dict):
return handler(value)

for key in value:
if key.startswith("value"):
type_name = key.split("value", 1)[1]
if (extension_cls := cls.__extension_types.get(type_name)) is not None:
return extension_cls.model_validate(value)

return handler(value)


class ReferenceExtension(Extension, type_name="Reference"):
value: LiteralReference = Field(..., alias="valueReference", frozen=True)
Loading
Loading