1+ """
2+ Controller layer for orchestrating calls to external services
3+ """
4+
15from __future__ import annotations
26
37import json
1418from gateway_api .pds_search import PdsClient , PdsSearchResults
1519
1620
17- class DownstreamServiceError (RuntimeError ):
18- """Raised when a downstream dependency (PDS/SDS/GP Connect) fails."""
19-
20-
2121@dataclass
2222class RequestError (Exception ):
23- """Raised (and handled) when there is a problem with the incoming request."""
23+ """
24+ Raised (and handled) when there is a problem with the incoming request.
25+
26+ Instances of this exception are caught by controller entry points and converted
27+ into an appropriate :class:`FlaskResponse`.
28+
29+ :param status_code: HTTP status code that should be returned.
30+ :param message: Human-readable error message.
31+ """
2432
2533 status_code : int
2634 message : str
2735
2836 def __str__ (self ) -> str :
37+ """
38+ Coercing this exception to a string returns the error message.
39+
40+ :returns: The error message.
41+ """
2942 return self .message
3043
3144
3245@dataclass
3346class SdsSearchResults :
3447 """
3548 Stub SDS search results dataclass.
49+
3650 Replace this with the real one once it's implemented.
51+
52+ :param asid: Accredited System ID.
53+ :param endpoint: Endpoint URL associated with the organisation, if applicable.
3754 """
3855
3956 asid : str
@@ -43,22 +60,38 @@ class SdsSearchResults:
4360class SdsClient :
4461 """
4562 Stub SDS client for obtaining ASID from ODS code.
63+
4664 Replace this with the real one once it's implemented.
4765 """
4866
4967 SANDBOX_URL = "https://example.invalid/sds"
5068
5169 def __init__ (
5270 self ,
53- auth_token : str | None = None ,
71+ auth_token : str ,
5472 base_url : str = SANDBOX_URL ,
5573 timeout : int = 10 ,
5674 ) -> None :
75+ """
76+ Create an SDS client.
77+
78+ :param auth_token: Authentication token to present to SDS.
79+ :param base_url: Base URL for SDS.
80+ :param timeout: Timeout in seconds for SDS calls.
81+ """
5782 self .auth_token = auth_token
5883 self .base_url = base_url
5984 self .timeout = timeout
6085
6186 def get_org_details (self , ods_code : str ) -> SdsSearchResults | None :
87+ """
88+ Retrieve SDS org details for a given ODS code.
89+
90+ This is a placeholder implementation that always returns an ASID and endpoint.
91+
92+ :param ods_code: ODS code to look up.
93+ :returns: SDS search results or ``None`` if not found.
94+ """
6295 # Placeholder implementation
6396 return SdsSearchResults (
6497 asid = f"asid_{ ods_code } " , endpoint = "https://example-provider.org/endpoint"
@@ -68,6 +101,7 @@ def get_org_details(self, ods_code: str) -> SdsSearchResults | None:
68101class GpConnectClient :
69102 """
70103 Stub GP Connect client for obtaining patient records.
104+
71105 Replace this with the real one once it's implemented.
72106 """
73107
@@ -79,6 +113,13 @@ def __init__(
79113 provider_asid : str ,
80114 consumer_asid : str ,
81115 ) -> None :
116+ """
117+ Create a GP Connect client.
118+
119+ :param provider_endpoint: Provider endpoint obtained from SDS.
120+ :param provider_asid: Provider ASID obtained from SDS.
121+ :param consumer_asid: Consumer ASID obtained from SDS.
122+ """
82123 self .provider_endpoint = provider_endpoint
83124 self .provider_asid = provider_asid
84125 self .consumer_asid = consumer_asid
@@ -89,6 +130,16 @@ def access_structured_record(
89130 body : json_str , # NOSONAR S1172 (ignore in stub)
90131 nhsnumber : str , # NOSONAR S1172 (ignore in stub)
91132 ) -> requests .Response | None :
133+ """
134+ Retrieve a patient's structured record from GP Connect.
135+
136+ This stub just returns None, the real thing will be more interesting!
137+
138+ :param trace_id: Correlation/trace identifier for request tracking.
139+ :param body: Original request body.
140+ :param nhsnumber: NHS number as a string.
141+ :returns: A ``requests.Response`` if the call was made, otherwise ``None``.
142+ """
92143 # Placeholder implementation
93144 return None
94145
@@ -98,11 +149,9 @@ class Controller:
98149 Orchestrates calls to PDS -> SDS -> GP Connect.
99150
100151 Entry point:
101- - call_gp_connect(request_body_json, headers, auth_token) -> requests.Response
152+ - `` call_gp_connect(request_body_json, headers, auth_token) -> FlaskResponse``
102153 """
103154
104- # TODO: Un-AI the docstrings and comments
105-
106155 gp_connect_client : GpConnectClient | None
107156
108157 def __init__ (
@@ -112,16 +161,30 @@ def __init__(
112161 nhsd_session_urid : str | None = None ,
113162 timeout : int = 10 ,
114163 ) -> None :
164+ """
165+ Create a controller instance.
166+
167+ :param pds_base_url: Base URL for PDS client.
168+ :param sds_base_url: Base URL for SDS client.
169+ :param nhsd_session_urid: Session URID for NHS Digital session handling.
170+ :param timeout: Timeout in seconds for downstream calls.
171+ """
115172 self .pds_base_url = pds_base_url
116173 self .sds_base_url = sds_base_url
117174 self .nhsd_session_urid = nhsd_session_urid
118175 self .timeout = timeout
119-
120- self .sds_client = SdsClient (base_url = sds_base_url , timeout = timeout )
121176 self .gp_connect_client = None
122177
123178 def _get_details_from_body (self , request_body : json_str ) -> int :
124- # --- Extract NHS number from request body ---
179+ """
180+ Parse request JSON and extract the NHS number as an integer.
181+
182+ :param request_body: JSON request body containing an ``"nhs-number"`` field.
183+ :returns: NHS number as an integer.
184+ :raises RequestError: If the request body is invalid, missing fields, or
185+ contains an invalid NHS number.
186+ """
187+ # Extract NHS number from request body
125188 try :
126189 body : Any = json .loads (request_body )
127190 except (TypeError , json .JSONDecodeError ):
@@ -130,6 +193,7 @@ def _get_details_from_body(self, request_body: json_str) -> int:
130193 message = 'Request body must be valid JSON with an "nhs-number" field' ,
131194 ) from None
132195
196+ # Guard: require "dict-like" semantics without relying on isinstance checks.
133197 if not (
134198 hasattr (body , "__getitem__" ) and hasattr (body , "get" )
135199 ): # Must be a dict-like object
@@ -160,7 +224,16 @@ def _get_details_from_body(self, request_body: json_str) -> int:
160224 def _get_pds_details (
161225 self , auth_token : str , consumer_ods : str , nhs_number : int
162226 ) -> str :
163- # --- PDS: find patient and extract GP ODS code (provider ODS) ---
227+ """
228+ Call PDS to find the provider ODS code (GP ODS code) for a patient.
229+
230+ :param auth_token: Authorization token to use for PDS.
231+ :param consumer_ods: Consumer organisation ODS code (from request headers).
232+ :param nhs_number: NHS number (already coerced to an integer).
233+ :returns: Provider ODS code (GP ODS code).
234+ :raises RequestError: If the patient cannot be found or has no provider ODS code
235+ """
236+ # PDS: find patient and extract GP ODS code (provider ODS)
164237 pds = PdsClient (
165238 auth_token = auth_token ,
166239 end_user_org_ods = consumer_ods ,
@@ -195,7 +268,20 @@ def _get_pds_details(
195268 def _get_sds_details (
196269 self , auth_token : str , consumer_ods : str , provider_ods : str
197270 ) -> tuple [str , str , str ]:
198- # --- SDS: Get provider details (ASID + endpoint) for provider ODS ---
271+ """
272+ Call SDS to obtain consumer ASID, provider ASID, and provider endpoint.
273+
274+ This method performs two SDS lookups:
275+ - provider details (ASID + endpoint)
276+ - consumer details (ASID)
277+
278+ :param auth_token: Authorization token to use for SDS.
279+ :param consumer_ods: Consumer organisation ODS code (from request headers).
280+ :param provider_ods: Provider organisation ODS code (from PDS).
281+ :returns: Tuple of (consumer_asid, provider_asid, provider_endpoint).
282+ :raises RequestError: If SDS data is missing or incomplete for provider/consumer
283+ """
284+ # SDS: Get provider details (ASID + endpoint) for provider ODS
199285 sds = SdsClient (
200286 auth_token = auth_token ,
201287 base_url = self .sds_base_url ,
@@ -229,7 +315,7 @@ def _get_sds_details(
229315 ),
230316 )
231317
232- # --- SDS: Get consumer details (ASID) for consumer ODS ---
318+ # SDS: Get consumer details (ASID) for consumer ODS
233319 consumer_details : SdsSearchResults | None = sds .get_org_details (consumer_ods )
234320 if consumer_details is None :
235321 raise RequestError (
@@ -256,15 +342,25 @@ def call_gp_connect(
256342 auth_token : str ,
257343 ) -> FlaskResponse :
258344 """
259- Expects a JSON request body containing an "nhs-number" field.
260- Also expects HTTP headers (from Flask) and extracts "Ods-from" as consumer_ods.
345+ Controller entry point
346+
347+ Expects a JSON request body containing an ``"nhs-number"`` field.
348+ Also expects HTTP headers (from Flask) and extracts:
349+ - ``Ods-from`` as the consumer organisation ODS code
350+ - ``X-Request-ID`` as the trace/correlation ID
261351
352+ Orchestration steps:
262353 1) Call PDS to obtain the patient's GP (provider) ODS code.
263354 2) Call SDS using provider ODS to obtain provider ASID + provider endpoint.
264355 3) Call SDS using consumer ODS to obtain consumer ASID.
265- 4) Call GP Connect to obtain patient records
266- """
356+ 4) Call GP Connect to obtain patient records.
267357
358+ :param request_body: Raw JSON request body.
359+ :param headers: HTTP headers from the request.
360+ :param auth_token: Authorization token used for downstream services.
361+ :returns: A :class:`~gateway_api.common.common.FlaskResponse` representing the
362+ outcome.
363+ """
268364 try :
269365 nhs_number = self ._get_details_from_body (request_body )
270366 except RequestError as err :
@@ -273,7 +369,7 @@ def call_gp_connect(
273369 data = str (err ),
274370 )
275371
276- # --- Extract consumer ODS from headers ---
372+ # Extract consumer ODS from headers
277373 consumer_ods = headers .get ("Ods-from" , "" ).strip ()
278374 if not consumer_ods :
279375 return FlaskResponse (
@@ -299,8 +395,7 @@ def call_gp_connect(
299395 except RequestError as err :
300396 return FlaskResponse (status_code = err .status_code , data = str (err ))
301397
302- # --- Call GP Connect with correct parameters ---
303- # (If these are dynamic per-request, reinitialise the client accordingly.)
398+ # Call GP Connect with correct parameters
304399 self .gp_connect_client = GpConnectClient (
305400 provider_endpoint = provider_endpoint ,
306401 provider_asid = provider_asid ,
@@ -313,6 +408,9 @@ def call_gp_connect(
313408 nhsnumber = str (nhs_number ),
314409 )
315410
411+ # If we get a None from GP Connect, that means that either the service did not
412+ # respond or we didn't make the request to the service in the first place.
413+ # Therefore a None is a 502, any real response just pass straight back.
316414 return FlaskResponse (
317415 status_code = response .status_code if response is not None else 502 ,
318416 data = response .text if response is not None else "GP Connect service error" ,
@@ -322,9 +420,16 @@ def call_gp_connect(
322420
323421def _coerce_nhs_number_to_int (value : str | int ) -> int :
324422 """
325- Coerce NHS number to int with basic validation.
326- NHS numbers are 10 digits, but leading zeros are not typically used.
327- Adjust validation as needed for your domain rules.
423+ Coerce an NHS number to an integer with basic validation.
424+
425+ Notes:
426+ - NHS numbers are 10 digits.
427+ - Input may include whitespace (e.g., ``"943 476 5919"``).
428+
429+ :param value: NHS number value, as a string or integer.
430+ :returns: The coerced NHS number as an integer.
431+ :raises ValueError: If the NHS number is non-numeric, the wrong length, or fails
432+ validation.
328433 """
329434 try :
330435 stripped = cast ("str" , value ).strip ().replace (" " , "" )
0 commit comments