Skip to content

Commit fc5b194

Browse files
committed
Sort out docstrings
1 parent 18039c2 commit fc5b194

4 files changed

Lines changed: 350 additions & 47 deletions

File tree

gateway-api/src/gateway_api/common/common.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,44 @@
1+
"""
2+
Shared lightweight types and helpers used across the gateway API.
3+
"""
4+
15
import re
26
from dataclasses import dataclass
37

8+
# This project uses JSON request/response bodies as strings in the controller layer.
9+
# The alias is used to make intent clearer in function signatures.
410
type json_str = str
511

612

713
@dataclass
814
class FlaskResponse:
15+
"""
16+
Lightweight response container returned by controller entry points.
17+
18+
This mirrors the minimal set of fields used by the surrounding web framework.
19+
20+
:param status_code: HTTP status code for the response (e.g., 200, 400, 404).
21+
:param data: Response body as text, if any.
22+
:param headers: Response headers, if any.
23+
"""
24+
25+
# TODO: Un-ai all these docstrings
26+
927
status_code: int
1028
data: str | None = None
1129
headers: dict[str, str] | None = None
1230

1331

1432
def validate_nhs_number(value: str | int) -> bool:
15-
# TODO: Un-AI all these docstrings
1633
"""
1734
Validate an NHS number using the NHS modulus-11 check digit algorithm.
1835
19-
Algorithm summary:
20-
- NHS number is 10 digits: d1..d9 + check digit d10
21-
- Compute: total = d1*10 + d2*9 + ... + d9*2
22-
- remainder = total % 11
23-
- check = 11 - remainder
24-
- If check == 11 => check digit must be 0
25-
- If check == 10 => check digit must be 10 (impossible as digit) => invalid
26-
- If remainder == 1 => check would be 10 => invalid
27-
- Else check digit must match d10
36+
The input may be a string or integer. Any non-digit separators in string
37+
inputs (spaces, hyphens, etc.) are ignored.
38+
39+
:param value: NHS number as a string or integer. Non-digit characters
40+
are ignored when a string is provided.
41+
:returns: ``True`` if the number is a valid NHS number, otherwise ``False``.
2842
"""
2943
str_value = str(value) # Just in case they passed an integer
3044
digits = re.sub(r"\D", "", str_value or "")

gateway-api/src/gateway_api/common/test_common.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
1-
# tests/test_common.py
1+
"""
2+
Unit tests for :mod:`gateway_api.common.common`.
3+
"""
24

35
from gateway_api.common import common
46

57

6-
def test_flask_response_defaults() -> None:
7-
r = common.FlaskResponse(status_code=200)
8-
assert r.status_code == 200
9-
assert r.data is None
10-
assert r.headers is None
11-
12-
138
def test_validate_nhs_number_accepts_valid_number_with_separators() -> None:
9+
"""
10+
Validate that separators (spaces, hyphens) are ignored and valid numbers pass.
11+
"""
1412
assert common.validate_nhs_number("943 476 5919") is True
1513
assert common.validate_nhs_number("943-476-5919") is True
1614
assert common.validate_nhs_number(9434765919) is True
1715

1816

1917
def test_validate_nhs_number_rejects_wrong_length_and_bad_check_digit() -> None:
18+
"""Validate that incorrect lengths and invalid check digits are rejected."""
2019
assert common.validate_nhs_number("") is False
2120
assert common.validate_nhs_number("943476591") is False # 9 digits
2221
assert common.validate_nhs_number("94347659190") is False # 11 digits

gateway-api/src/gateway_api/controller.py

Lines changed: 130 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
"""
2+
Controller layer for orchestrating calls to external services
3+
"""
4+
15
from __future__ import annotations
26

37
import json
@@ -14,26 +18,39 @@
1418
from 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
2222
class 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
3346
class 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:
4360
class 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:
68101
class 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

323421
def _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

Comments
 (0)