From 27e5473214a12dcbb08eee189ecdcfd750fcdb73 Mon Sep 17 00:00:00 2001 From: russellpollock Date: Wed, 29 Apr 2026 07:19:42 +0000 Subject: [PATCH 1/2] [GPCAPIM-419]-[Required Missing Header Not Rejected (Content-Type)]-[RP] --- gateway-api/openapi.yaml | 24 ++++++++++++++ gateway-api/src/gateway_api/app.py | 2 +- gateway-api/src/gateway_api/common/error.py | 17 ++++++++-- gateway-api/src/gateway_api/conftest.py | 3 +- .../get_structured_record/request.py | 15 ++++++++- .../get_structured_record/test_request.py | 31 +++++++++++++++++++ .../get_structured_record/test_response.py | 4 +-- .../tests/schema/test_openapi_schema.py | 2 +- schemathesis.toml | 6 ++++ 9 files changed, 96 insertions(+), 8 deletions(-) diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index c596902c..1f0b47eb 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -238,6 +238,30 @@ paths: diagnostics: type: string example: "Patient not found" + '415': + description: Unsupported Media Type - Content-Type header must be "application/fhir+json" + content: + application/fhir+json: + schema: + type: object + properties: + resourceType: + type: string + example: "OperationOutcome" + issue: + type: array + items: + type: object + properties: + severity: + type: string + example: "error" + code: + type: string + example: "invalid" + diagnostics: + type: string + example: 'Unsupported "Content-Type". Expected "application/fhir+json".' '500': description: Internal server error content: diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 53803d40..18762de3 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -119,7 +119,7 @@ def get_structured_record() -> Response: log_error(e) response.add_error_response(e) except Exception: - error = UnexpectedError(traceback=traceback.format_exc()) + error = UnexpectedError() log_error(error) response.add_error_response(error) diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index 57be0565..6674a9bc 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -1,5 +1,11 @@ from dataclasses import dataclass -from http.client import BAD_GATEWAY, BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND +from http.client import ( + BAD_GATEWAY, + BAD_REQUEST, + INTERNAL_SERVER_ERROR, + NOT_FOUND, + UNSUPPORTED_MEDIA_TYPE, +) from fhir.stu3 import Issue, IssueCode, IssueSeverity, OperationOutcome @@ -110,7 +116,14 @@ class JWTValidationError(AbstractCDGError): class UnexpectedError(AbstractCDGError): - _message = "Internal Server Error: {traceback}" + _message = "Internal Server Error" status_code = INTERNAL_SERVER_ERROR severity = IssueSeverity.ERROR error_code = IssueCode.EXCEPTION + + +class UnsupportedMediaTypeError(AbstractCDGError): + _message = "Unsupported Media Type" + status_code = UNSUPPORTED_MEDIA_TYPE + severity = IssueSeverity.ERROR + error_code = IssueCode.INVALID diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 99548b5c..d9e49b46 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -75,11 +75,12 @@ def text(self) -> str: def create_mock_request(headers: dict[str, str], body: dict[str, Any]) -> Request: """Create a proper Flask Request object with headers and JSON body.""" + content_type = headers.get("Content-Type", "application/fhir+json") builder = EnvironBuilder( method="POST", path="/patient/$gpc.getstructuredrecord", data=json.dumps(body), - content_type="application/fhir+json", + content_type=content_type, headers=headers, ) env = builder.get_environ() diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index 7b8ba12f..452a490a 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -10,8 +10,11 @@ from gateway_api.common.error import ( InvalidRequestJSONError, MissingOrEmptyHeaderError, + UnsupportedMediaTypeError, ) +ACCEPTED_CONTENT_TYPE = "application/fhir+json" + # Access record structured interaction ID from # https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development.html#spine-interactions ACCESS_RECORD_STRUCTURED_INTERACTION_ID = ( @@ -32,8 +35,11 @@ class GetStructuredRecordRequest: def __init__(self, request: Request) -> None: self._http_request = request self._headers = CaseInsensitiveDict(request.headers) + self._validate_content_type() try: - self.parameters = Parameters.model_validate(request.get_json()) + self.parameters = Parameters.model_validate( + request.get_json(silent=True, force=True) + ) except (BadRequest, ValidationError) as error: raise InvalidRequestJSONError() from error @@ -41,6 +47,13 @@ def __init__(self, request: Request) -> None: self._validate_headers() + def _validate_content_type(self) -> None: + content_type = self._headers.get("Content-Type") + if content_type is None: + return + if content_type.split(";")[0].strip().lower() != ACCEPTED_CONTENT_TYPE: + raise UnsupportedMediaTypeError() + @property def trace_id(self) -> str: trace_id: str = self._headers["Ssp-TraceID"] diff --git a/gateway-api/src/gateway_api/get_structured_record/test_request.py b/gateway-api/src/gateway_api/get_structured_record/test_request.py index 688bbf2a..54b64d91 100644 --- a/gateway-api/src/gateway_api/get_structured_record/test_request.py +++ b/gateway-api/src/gateway_api/get_structured_record/test_request.py @@ -5,6 +5,7 @@ from gateway_api.common.error import ( MissingOrEmptyHeaderError, + UnsupportedMediaTypeError, ) from gateway_api.conftest import create_mock_request from gateway_api.get_structured_record.request import GetStructuredRecordRequest @@ -118,3 +119,33 @@ def test_raises_value_error_when_trace_id_header_is_whitespace( match='Missing or empty required header "Ssp-TraceID"', ): GetStructuredRecordRequest(request=mock_request) + + def test_missing_content_type_header_is_accepted( + self, valid_simple_request_payload: dict[str, Any] + ) -> None: + """Test that a missing Content-Type header does not raise an error.""" + headers = { + "Content-Type": "", + "Ssp-TraceID": "test-trace-id", + "ODS-from": "test-ods", + } + mock_request = create_mock_request(headers, valid_simple_request_payload) + + GetStructuredRecordRequest(request=mock_request) + + def test_raises_unsupported_media_type_when_content_type_is_invalid( + self, valid_simple_request_payload: dict[str, Any] + ) -> None: + """ + Test that UnsupportedMediaTypeError is raised when Content-Type + is not "application/fhir+json". + """ + headers = { + "Content-Type": "application/json", + "Ssp-TraceID": "test-trace-id", + "ODS-from": "test-ods", + } + mock_request = create_mock_request(headers, valid_simple_request_payload) + + with pytest.raises(UnsupportedMediaTypeError): + GetStructuredRecordRequest(request=mock_request) diff --git a/gateway-api/src/gateway_api/get_structured_record/test_response.py b/gateway-api/src/gateway_api/get_structured_record/test_response.py index 2c4b1a3e..70d78fd6 100644 --- a/gateway-api/src/gateway_api/get_structured_record/test_response.py +++ b/gateway-api/src/gateway_api/get_structured_record/test_response.py @@ -62,7 +62,7 @@ def test_add_provider_response_adds_200_status( assert actual == 200, f"Expected status code to be 200, but got {actual}" def test_add_error_response_adds_error_response_body(self) -> None: - error = UnexpectedError(traceback="something broke") + error = UnexpectedError() response = GetStructuredRecordResponse() response.add_error_response(error) @@ -73,7 +73,7 @@ def test_add_error_response_adds_error_response_body(self) -> None: { "severity": "error", "code": "exception", - "diagnostics": "Internal Server Error: something broke", + "diagnostics": "Internal Server Error", } ], } diff --git a/gateway-api/tests/schema/test_openapi_schema.py b/gateway-api/tests/schema/test_openapi_schema.py index b162250e..b16e6b8a 100644 --- a/gateway-api/tests/schema/test_openapi_schema.py +++ b/gateway-api/tests/schema/test_openapi_schema.py @@ -53,7 +53,7 @@ def test_api_schema_compliance(case: Case, base_url: str) -> None: # GPCAPIM-421 schemathesis.checks.not_a_server_error, # GPCAPIM-419 - schemathesis.checks.missing_required_header, + # schemathesis.checks.missing_required_header, # GPCAPIM-422 schemathesis.checks.unsupported_method, ], diff --git a/schemathesis.toml b/schemathesis.toml index 1bc77020..afb1a39b 100644 --- a/schemathesis.toml +++ b/schemathesis.toml @@ -1,2 +1,8 @@ [generation] mode = "all" + +[checks.missing_required_header] +expected-statuses = [400] + +[checks.negative_data_rejection] +expected-statuses = [400, 401, 403, 404, 406, 415, 422, 428, "5xx"] From f524b7f8258dca0dd17a24f70abe50b8fc273514 Mon Sep 17 00:00:00 2001 From: russellpollock Date: Thu, 30 Apr 2026 15:20:19 +0000 Subject: [PATCH 2/2] bruno --- .../collections/Steel_Thread/Access_Structured_Record.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bruno/gateway-api/collections/Steel_Thread/Access_Structured_Record.yml b/bruno/gateway-api/collections/Steel_Thread/Access_Structured_Record.yml index 07f21891..79b5d973 100644 --- a/bruno/gateway-api/collections/Steel_Thread/Access_Structured_Record.yml +++ b/bruno/gateway-api/collections/Steel_Thread/Access_Structured_Record.yml @@ -6,6 +6,9 @@ info: http: method: POST url: "{{base_url}}/patient/$gpc.getstructuredrecord" + headers: + - name: Content-Type + value: application/fhir+json body: type: json data: |-