diff --git a/terraform/infrastructure/api_gateway.tf b/terraform/infrastructure/api_gateway.tf index 2e6a42fbd..224096e3e 100644 --- a/terraform/infrastructure/api_gateway.tf +++ b/terraform/infrastructure/api_gateway.tf @@ -10,6 +10,10 @@ module "consumer__gateway" { method_readDocumentReference = "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:${local.aws_account_id}:function:${substr("${local.prefix}--api--consumer--readDocumentReference", 0, 64)}/invocations" method_status = "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:${local.aws_account_id}:function:${substr("${local.prefix}--api--consumer--status", 0, 64)}/invocations" } + endpoint_allowed_methods = { + "/DocumentReference" = "GET", + "/DocumentReference/{id}" = "GET" + } kms_key_id = module.kms__cloudwatch.kms_arn domain = local.apis.domain path = local.apis.consumer.path @@ -35,7 +39,10 @@ module "producer__gateway" { method_deleteDocumentReference = "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:${local.aws_account_id}:function:${substr("${local.prefix}--api--producer--deleteDocumentReference", 0, 64)}/invocations" method_status = "arn:aws:apigateway:eu-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-2:${local.aws_account_id}:function:${substr("${local.prefix}--api--producer--status", 0, 64)}/invocations" } - + endpoint_allowed_methods = { + "/DocumentReference" = "GET,POST", + "/DocumentReference/{id}" = "GET,PUT,DELETE" + } kms_key_id = module.kms__cloudwatch.kms_arn domain = local.apis.domain path = local.apis.producer.path diff --git a/terraform/infrastructure/consumer.tftpl b/terraform/infrastructure/consumer.tftpl index 363ead22f..60b8a2d88 100644 --- a/terraform/infrastructure/consumer.tftpl +++ b/terraform/infrastructure/consumer.tftpl @@ -63,7 +63,7 @@ { "type": "DocumentReference", "profile": "http://hl7.org/fhir/R4/documentreference.html", - "documentation": "Additional business rules apply to constrain patient, organisation and type/category content.", + "documentation": "Additional business rules apply to constrain patient, organisation and type/category content. Note that HEAD requests are not supported.", "interaction": [ { "code": "read", diff --git a/terraform/infrastructure/modules/api_gateway/api_gateway.tf b/terraform/infrastructure/modules/api_gateway/api_gateway.tf index 40dca31cf..57ca9e75c 100644 --- a/terraform/infrastructure/modules/api_gateway/api_gateway.tf +++ b/terraform/infrastructure/modules/api_gateway/api_gateway.tf @@ -50,9 +50,11 @@ resource "aws_api_gateway_deployment" "api_gateway_deployment" { rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id triggers = { - redeployment = sha1(jsonencode(aws_api_gateway_rest_api.api_gateway_rest_api.body)) - resource_change = "${md5(file("${path.module}/api_gateway.tf"))}" - capabilities = sha1(var.capability_statement_content) + redeployment = sha1(jsonencode(aws_api_gateway_rest_api.api_gateway_rest_api.body)) + resource_change = "${md5(file("${path.module}/api_gateway.tf"))}" + capabilities = sha1(var.capability_statement_content) + head_responses_change = md5(file("${path.module}/head_responses.tf")) + parent_api_gateway_change = md5(file("${path.module}/../../api_gateway.tf")) } lifecycle { @@ -60,7 +62,8 @@ resource "aws_api_gateway_deployment" "api_gateway_deployment" { } depends_on = [ - aws_api_gateway_rest_api.api_gateway_rest_api + aws_api_gateway_rest_api.api_gateway_rest_api, + aws_api_gateway_integration_response.head_integration_response ] } diff --git a/terraform/infrastructure/modules/api_gateway/head_responses.tf b/terraform/infrastructure/modules/api_gateway/head_responses.tf new file mode 100644 index 000000000..bc3c61ffe --- /dev/null +++ b/terraform/infrastructure/modules/api_gateway/head_responses.tf @@ -0,0 +1,67 @@ +# Define a reusable map of allowed methods for each path +data "aws_api_gateway_resource" "resource" { + for_each = var.endpoint_allowed_methods + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + path = each.key + + depends_on = [aws_api_gateway_rest_api.api_gateway_rest_api] +} + +# Add HEAD method to each resource with 405 response +resource "aws_api_gateway_method" "head_method" { + for_each = var.endpoint_allowed_methods + + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + resource_id = data.aws_api_gateway_resource.resource[each.key].id + http_method = "HEAD" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "head_integration" { + for_each = var.endpoint_allowed_methods + + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + resource_id = data.aws_api_gateway_resource.resource[each.key].id + http_method = aws_api_gateway_method.head_method[each.key].http_method + type = "MOCK" + passthrough_behavior = "WHEN_NO_TEMPLATES" + + request_templates = { + "application/json" = <<-EOF + { "statusCode": 405 } + EOF + "application/json+fhir" = <<-EOF + { "statusCode": 405 } + EOF + "application/fhir+json" = <<-EOF + { "statusCode": 405 } + EOF + } +} + +resource "aws_api_gateway_method_response" "head_method_response" { + for_each = var.endpoint_allowed_methods + + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + resource_id = data.aws_api_gateway_resource.resource[each.key].id + http_method = aws_api_gateway_method.head_method[each.key].http_method + status_code = "405" + + response_parameters = { + "method.response.header.Allow" = true + } +} + +resource "aws_api_gateway_integration_response" "head_integration_response" { + for_each = var.endpoint_allowed_methods + + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + resource_id = data.aws_api_gateway_resource.resource[each.key].id + http_method = aws_api_gateway_method.head_method[each.key].http_method + status_code = aws_api_gateway_method_response.head_method_response[each.key].status_code + selection_pattern = "" # default catch-all + + response_parameters = { + "method.response.header.Allow" = "'${each.value}'" + } +} diff --git a/terraform/infrastructure/modules/api_gateway/vars.tf b/terraform/infrastructure/modules/api_gateway/vars.tf index 0a9d0c6f6..a71eda145 100644 --- a/terraform/infrastructure/modules/api_gateway/vars.tf +++ b/terraform/infrastructure/modules/api_gateway/vars.tf @@ -22,3 +22,7 @@ variable "retention" { default = 90 type = number } + +variable "endpoint_allowed_methods" { + type = map(string) +} diff --git a/terraform/infrastructure/producer.tftpl b/terraform/infrastructure/producer.tftpl index fafe92700..e57375e1d 100644 --- a/terraform/infrastructure/producer.tftpl +++ b/terraform/infrastructure/producer.tftpl @@ -62,7 +62,7 @@ { "type": "DocumentReference", "profile": "http://hl7.org/fhir/R4/documentreference.html", - "documentation": "Additional business rules apply to validate patient, organisation and type/category content.", + "documentation": "Additional business rules apply to validate patient, organisation and type/category content. Note that HEAD requests are not supported.", "interaction": [ { "code": "read", diff --git a/tests/features/consumer/headOnGetEndpoint.feature b/tests/features/consumer/headOnGetEndpoint.feature new file mode 100644 index 000000000..28ae7d72e --- /dev/null +++ b/tests/features/consumer/headOnGetEndpoint.feature @@ -0,0 +1,66 @@ +Feature: Consumer - HEAD Requests + + Scenario Outline: DocumentReference with HEAD fails (Content-Type: ) + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When consumer 'RX898' sends HEAD request to 'DocumentReference' endpoint with headers: + | header | value | + | Content-Type | | + Then the response status code is 405 + And the response has an empty body + And the Allow header is 'GET' + And the Content-Length header is '0' + + Examples: + | content_type | + | application/json | + | application/json+fhir | + | application/fhir+json | + + Scenario Outline: DocumentReference/{id} with HEAD fails (Content-Type: ) + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When consumer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint with headers: + | header | value | + | Content-Type | | + Then the response status code is 405 + And the response has an empty body + And the Allow header is 'GET' + And the Content-Length header is '0' + + Examples: + | content_type | + | application/json | + | application/json+fhir | + | application/fhir+json | + + Scenario Outline: DocumentReference with HEAD fails with 415 with unsupported (Content-Type: ) + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When consumer 'RX898' sends HEAD request to 'DocumentReference' endpoint with headers: + | header | value | + | Content-Type | | + Then the response status code is 415 + And the Content-Type header is 'application/json' + And the Content-Length header is '0' + And the Allow header is not present + + Examples: + | content_type | + | application/notsupported | + | application/helloworld | + | application/harold | + + Scenario Outline: DocumentReference/{id} with HEAD fails (Content-Type: ) + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When consumer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint with headers: + | header | value | + | Content-Type | | + Then the response status code is 415 + And the response has an empty body + And the Content-Type header is 'application/json' + And the Content-Length header is '0' + And the Allow header is not present + + Examples: + | content_type | + | application/notsupported | + | application/helloworld | + | application/harold | diff --git a/tests/features/producer/headOnGetEndpoint.feature b/tests/features/producer/headOnGetEndpoint.feature new file mode 100644 index 000000000..00fdb0d53 --- /dev/null +++ b/tests/features/producer/headOnGetEndpoint.feature @@ -0,0 +1,63 @@ +Feature: Producer - HEAD Requests + + Scenario Outline: DocumentReference with HEAD fails (Content-Type: ) + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When producer 'RX898' sends HEAD request to 'DocumentReference' endpoint with headers: + | header | value | + | Content-Type | | + Then the response status code is 405 + And the response has an empty body + And the Allow header is 'GET,POST' + + Examples: + | content_type | + | application/json | + | application/json+fhir | + | application/fhir+json | + + Scenario Outline: DocumentReference/{id} with HEAD fails (Content-Type: ) + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When producer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint with headers: + | header | value | + | Content-Type | | + Then the response status code is 405 + And the response has an empty body + And the Allow header is 'GET,PUT,DELETE' + + Examples: + | content_type | + | application/json | + | application/json+fhir | + | application/fhir+json | + + Scenario Outline: DocumentReference with HEAD fails with 415 with unsupported (Content-Type: ) + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When producer 'RX898' sends HEAD request to 'DocumentReference' endpoint with headers: + | header | value | + | Content-Type | | + Then the response status code is 415 + And the Content-Type header is 'application/json' + And the response has an empty body + And the Allow header is not present + + Examples: + | content_type | + | application/notsupported | + | application/helloworld | + | application/harold | + + Scenario Outline: DocumentReference/{id} with HEAD fails with 415 with unsupported (Content-Type: ) + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When producer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint with headers: + | header | value | + | Content-Type | | + Then the response status code is 415 + And the Content-Type header is 'application/json' + And the response has an empty body + And the Allow header is not present + + Examples: + | content_type | + | application/notsupported | + | application/helloworld | + | application/harold | diff --git a/tests/features/steps/2_request.py b/tests/features/steps/2_request.py index a904982a7..304228fec 100644 --- a/tests/features/steps/2_request.py +++ b/tests/features/steps/2_request.py @@ -247,3 +247,21 @@ def producer_search_document_reference_step(context: Context, ods_code: str): pointer_type=pointer_type, extra_params=items, ) + + +@when( + "{consumer_or_producer} '{ods_code}' sends HEAD request to '{endpoint}' endpoint with headers" +) +def consumer_head_request_step( + context: Context, consumer_or_producer: str, ods_code: str, endpoint: str +): + if not context.table: + raise ValueError("No headers table provided") + + headers = {row["header"]: row["value"] for row in context.table} + + if consumer_or_producer == "producer": + client = producer_client_from_context(context, ods_code) + elif consumer_or_producer == "consumer": + client = consumer_client_from_context(context, ods_code) + context.response = client.head(endpoint, headers=headers) diff --git a/tests/features/steps/3_assert.py b/tests/features/steps/3_assert.py index 445f2268d..2ffbe625d 100644 --- a/tests/features/steps/3_assert.py +++ b/tests/features/steps/3_assert.py @@ -51,6 +51,20 @@ def assert_bundle_step(context: Context, bundle_type: str): context.bundle = Bundle.model_validate(body) +@then("the response has an empty body") +def assert_empty_body_step(context: Context): + """ + Asserts that the response body is empty. + """ + assert context.response.text == "", format_error( + "Response body is not empty", + "empty", + context.response.text, + context.response.text, + ) + context.bundle = None + + @then("the Bundle has a total of {total}") def assert_bundle_total_step(context: Context, total: str): assert ( @@ -333,6 +347,17 @@ def assert_location_header(context: Context, header_name: str): context.pointer_id = generated_id +@then("the {header_name} header is not present") +def assert_header_not_present(context: Context, header_name: str): + header_value = context.response.headers.get(header_name) + assert header_value is None, format_error( + f"Header {header_name} should not be present", + "not present", + header_value, + context.response.text, + ) + + @then("the {header_name} header starts with '{starts_with}'") def assert_header_starts_with(context: Context, header_name: str, starts_with: str): header_value = context.response.headers.get(header_name) diff --git a/tests/utilities/api_clients.py b/tests/utilities/api_clients.py index 21b91f9c5..0c21b7f47 100644 --- a/tests/utilities/api_clients.py +++ b/tests/utilities/api_clients.py @@ -192,6 +192,23 @@ def search_post( cert=self.config.client_cert, ) + @retry_if([502]) + def head( + self, + endpoint: str, + headers: dict[str, str] | None = None, + extra_params: dict[str, str] | None = None, + ) -> Response: + headers = {**(headers or {}), **self.request_headers} + params = {**(extra_params or {})} + url = f"{self.api_url}/{endpoint}" + return requests.head( + url, + params=params, + headers=headers, + cert=self.config.client_cert, + ) + @retry_if([502]) def read_capability_statement(self) -> Response: return requests.get( @@ -360,3 +377,20 @@ def read_capability_statement(self) -> Response: headers=self.request_headers, cert=self.config.client_cert, ) + + @retry_if([502]) + def head( + self, + endpoint: str, + headers: dict[str, str] | None = None, + extra_params: dict[str, str] | None = None, + ) -> Response: + headers = {**(headers or {}), **self.request_headers} + params = {**(extra_params or {})} + url = f"{self.api_url}/{endpoint}" + return requests.head( + url, + params=params, + headers=headers, + cert=self.config.client_cert, + )