Skip to content

Comments

[GPCAPIM-275]: Harmonise the Controller, Flask entry point and Provider System modules#75

Open
davidhamill1-nhs wants to merge 42 commits intomainfrom
feature/GPCAPIM-275_harmonise
Open

[GPCAPIM-275]: Harmonise the Controller, Flask entry point and Provider System modules#75
davidhamill1-nhs wants to merge 42 commits intomainfrom
feature/GPCAPIM-275_harmonise

Conversation

@davidhamill1-nhs
Copy link
Contributor

@davidhamill1-nhs davidhamill1-nhs commented Feb 5, 2026

Description

  1. Bring the three separate pieces of work, accepting APIM requests/Flask app, Controller module and provider client, in line.
  2. Improve readability of the code by bringing greater consistency in the three code style.
  3. Reduce reliance on mocking during unit tests.

Context

As three pieces of work were complete in parallel by three different developers, there were differences in approaches and code styles that mean some harmonisation is required.

Type of changes

  • Refactoring (non-breaking change)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would change existing functionality)
  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • I have followed the code style of the project
  • I have added tests to cover my changes
  • I have updated the documentation accordingly
  • This PR is a result of pair or mob programming
  • Exceptions/Exclusions to coding standards (e.g. #noqa or #NOSONAR) are included within this Pull Request.

Sensitive Information Declaration

To ensure the utmost confidentiality and protect your and others privacy, we kindly ask you to NOT including PII (Personal Identifiable Information) / PID (Personal Identifiable Data) or any other sensitive data in this PR (Pull Request) and the codebase changes. We will remove any PR that do contain any sensitive information. We really appreciate your cooperation in this matter.

  • I confirm that neither PII/PID nor sensitive data are included in this PR and the codebase changes.

@davidhamill1-nhs davidhamill1-nhs requested a review from a team as a code owner February 5, 2026 12:55
@github-actions
Copy link

github-actions bot commented Feb 5, 2026

Trivy gate: no Critical/High vulnerabilities.

Trivy Image Scan Summary

Image: 900119715266.dkr.ecr.eu-west-2.amazonaws.com/whoami:feature-gpcapim-275-harmonise

Severity Count
CRITICAL 0
HIGH 0
MEDIUM 0
LOW 1
UNKNOWN 0
Findings (top 50)
Severity ID Package Installed Fixed Source
LOW CVE-2026-1703 pip 25.3 26.0 Python

@DWolfsNHS
Copy link
Collaborator

DWolfsNHS commented Feb 5, 2026

Some general suggestions to consider on this PR:

  • take a consistent approach to filing modules (e.g. provider_request.py) in their own subfolders with __init__.py
  • consistent naming for the client modules? e.g. (provider_request.py to provider_client.py)
  • separate file with example patient bundle in
  • consistent application of fhir types
  • common error class
  • per discussion with @Vox-Ben, remove from __future__ import annotations: we're not likely to use Python 3.6

@davidhamill1-nhs davidhamill1-nhs changed the title [GPCAPIM-275]: Remove duplicate lines. [GPCAPIM-275]: Harmonise the Controller, Flask entry point and Provider System modules Feb 5, 2026
…ns will/should bubble up under the same Error class
…ns will/should bubble up under the same Error class
… test, TestGetStructuredRecord.test_happy_path*
@github-actions
Copy link

github-actions bot commented Feb 10, 2026

Trivy gate: no Critical/High issues.

Trivy IaC (Terraform) Summary

Severity Count
CRITICAL 0
HIGH 0
MEDIUM 0
LOW 0
UNKNOWN 0
Findings (top 50)
Severity ID Title File

"""
# Placeholder implementation
return "PLACEHOLDER_AUTH_TOKEN"
return "AUTH_TOKEN123"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is accepted by the PDS sandbox.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@sonarqubecloud
Copy link

@github-actions
Copy link

Deployment Complete

Copy link
Contributor

@Vox-Ben Vox-Ben left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, nothing major. :-)

Comment on lines 1 to 18
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [

{
"name": "Python Debugger: Flask",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/infrastructure/images/gateway-api/resources/build/gateway-api/gateway_api/app.py",
"envFile": "${workspaceFolder}/.env",
"jinja": true,
"console": "integratedTerminal"
}
]
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to add this? As I understand it we're about to be asked to drop Flask.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is quite a generic configuration (the name is arbitrary - but will change to make it a more specific to our project/this command); a move away from Flask will not affect this.


system: str
value: str
period: Period
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should Period be NotRequired if it's optional?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK, period is not optional; however, period.end is for generalPractitioner (and most fields).

"""FHIR Period type."""

start: str
end: NotRequired[str]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we better to have end be required, but if there isn't an end date then be None? I don't know, this way is conceptually neater and makes type handling simpler, but the other means you can just assume that a Period has an end attribute.

Don't mind, whichever you prefer, just a thought. :-)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For FHIR, empty objects are not sent. So if an element has no period.end then the Period object will only contain "start". Making NotRequired[str] a more suitable type.

I suspect we will move away from these TypedDicts as pathology have written their own FHIR library using Pydantic which will handle request/response bodies better.

I have a TODO to write up a ticket to make this move.



@dataclass
class BaseError(Exception):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth calling this something more specific? CdgError, BaseCdgError, something? BaseError is rather close to BaseException, which obviously you aren't supposed to use directly, and I wouldn't want anyone to mix them up down the line.

severity: str = "error"
error_code: ErrorCode = ErrorCode.EXCEPTION

def __init__(self, **additional_details: str):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for convention should **additional_details be called **kwargs? I think they're the same thing, you're just collecting any keyword arguments passed to the exception?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the type of these additional details be slightly different here also, would a dict[str,str] make more sense here if we're expecting an arbitrary set of key, value pairs?

else:
entries = cast("list[BundleEntry]", body.get("entry", []))
if not entries:
raise RuntimeError("PDS response contains no patient entries")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this now be a BaseError subclass?


nhs_number = str(patient.get("id", "")).strip()
if not nhs_number:
raise RuntimeError("PDS patient resource missing NHS number")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should also be a BaseError subclass

"""
# Placeholder implementation
return "PLACEHOLDER_AUTH_TOKEN"
return "AUTH_TOKEN123"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines +6 to +20
class Bundles:
@staticmethod
def _wrap_patient_in_bundle(patient: dict[str, Any]) -> dict[str, Any]:
return {
"resourceType": "Bundle",
"type": "collection",
"meta": {
"profile": [
"https://fhir.nhs.uk/STU3/StructureDefinition/GPConnect-StructuredRecord-Bundle-1"
]
},
"entry": [{"resource": patient}],
}

ALICE_JONES_9999999999 = _wrap_patient_in_bundle(Patients.ALICE_JONES_9999999999)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be in its own file rather than in __init.py__? It does make a certain amount of sense (I'm assuming that the Bundles class itself isn't ever really used elsewhere) but I always think code hidden in __init__.py is hard to find, because you don't necessarily think to look there.

Comment on lines +10 to +28
class Patients:
@staticmethod
def load_patient(filename: str) -> dict[str, Any]:
with open(_path_to_here() / filename, encoding="utf-8") as f:
patient: dict[str, Any] = json.load(f)
return patient

JANE_SMITH_9000000009 = load_patient("jane_smith_9000000009.json")
NO_SDS_RESULT_9000000010 = load_patient("no_sds_result_9000000010.json")
BLANK_ASID_SDS_RESULT_9000000011 = load_patient(
"blank_asid_sds_result_9000000011.json"
)
INDUCE_PROVIDER_ERROR_9000000012 = load_patient(
"induce_provider_error_9000000012.json"
)
BLANK_ENDPOINT_SDS_RESULT_9000000013 = load_patient(
"blank_endpoint_sds_result_9000000013.json"
)
ALICE_JONES_9999999999 = load_patient("alice_jones_9999999999.json")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment about whether this is best here or in its own file.

Copy link
Collaborator

@nhsd-jack-wainwright nhsd-jack-wainwright left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have had quite a high level look through these changes now, mostly focusing on the main code rather than the changes to the stubs/tests. Happy to talk through any of the suggestions I've included as a lot of them are more around style rather than requiring specific changes 🙂 .

"sonarsource.sonarlint-vscode",
"alexkrechik.cucumberautocomplete"
"alexkrechik.cucumberautocomplete",
"streetsidesoftware.code-spell-checker"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're already using vale to provide some spell checking within the repository. Does this extension integrate with this?

Comment on lines +20 to +23
_message = "Internal Server Error"
status_code: int = INTERNAL_SERVER_ERROR
severity: str = "error"
error_code: ErrorCode = ErrorCode.EXCEPTION
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A minor style point but I'm not sure I like these values being declared by default by the BaseError here. Would it be nicer if these were instead defined as constructor arguments to force subclasses to provide them?

severity: str = "error"
error_code: ErrorCode = ErrorCode.EXCEPTION

def __init__(self, **additional_details: str):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the type of these additional details be slightly different here also, would a dict[str,str] make more sense here if we're expecting an arbitrary set of key, value pairs?

return self.message


class InvalidRequestJSON(BaseError):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A very minor style point, but given these classes are exceptions is it worth including Error in the name somewhere? Perhaps this class could be named InvalidRequestError for example?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also not sure if Python makes a distinction between Exceptions and Errors like some languages do (like Java for example). Are these classes actually more like exceptions, or are errors and exceptions interchangeable in Python?


class GetStructuredRecordRequest:
INTERACTION_ID: str = "urn:nhs:names:services:gpconnect:gpc.getstructuredrecord-1"
INTERACTION_ID: str = ACCESS_RECORD_STRUCTURED_INTERACTION_ID
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From there name and value I think these are static constants that are associated more to the class than a particular instance. If so maybe it's worth annotating the type with ClassVar?

Suggested change
INTERACTION_ID: str = ACCESS_RECORD_STRUCTURED_INTERACTION_ID
from typing import ClassVar
....
INTERACTION_ID: ClassVar[str] = ACCESS_RECORD_STRUCTURED_INTERACTION_ID

from stubs.pds.stub import PdsFhirApiStub

pds = PdsFhirApiStub()
post = pds.post # type: ignore
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to ignore typing here? Could we instead define post as a Callable that both requests.post and pds.post comply with?

Suggested change
post = pds.post # type: ignore
post: Callable[[...], requests.Response] = requests.post

"""

# URLs for different PDS environments. Requires authentication to use live.
SANDBOX_URL = "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than being included as constants here, I wonder if the URL should be provided as an environment variable that's supplied when starting the container?

Comment on lines +108 to +110
request_id: str | None = None,
correlation_id: str | None = None,
timeout: int | None = None,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these values ever be None?


# This normally calls requests.get, but if STUB_PDS is set it uses the stub.
response = post(
url, # TODO: URL points to sandbox env even when STUB_PDS
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this TODO still valid? I think this method should always post to url, it's just that the base_url may point to a different environment?

get = sds.get # type: ignore

# Recursive JSON-like structure typing used for parsed FHIR bodies.
type ResultStructure = str | dict[str, "ResultStructure"] | list["ResultStructure"]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very minor but there is a bit of duplication here I think, could this union utilise ResultStructureDict?

Suggested change
type ResultStructure = str | dict[str, "ResultStructure"] | list["ResultStructure"]
type ResultStructureDict = dict[str, "ResultStructure"]
type ResultStructure = str | ResultStructureDict | list["ResultStructure"]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants