Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion terraform/infrastructure/api_gateway.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion terraform/infrastructure/consumer.tftpl
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 7 additions & 4 deletions terraform/infrastructure/modules/api_gateway/api_gateway.tf
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,20 @@ 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 {
create_before_destroy = true
}

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
]
}

Expand Down
67 changes: 67 additions & 0 deletions terraform/infrastructure/modules/api_gateway/head_responses.tf
Original file line number Diff line number Diff line change
@@ -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}'"
}
}
4 changes: 4 additions & 0 deletions terraform/infrastructure/modules/api_gateway/vars.tf
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ variable "retention" {
default = 90
type = number
}

variable "endpoint_allowed_methods" {
type = map(string)
}
2 changes: 1 addition & 1 deletion terraform/infrastructure/producer.tftpl
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 66 additions & 0 deletions tests/features/consumer/headOnGetEndpoint.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
Feature: Consumer - HEAD Requests

Scenario Outline: DocumentReference with HEAD fails (Content-Type: <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 | <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: <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 | <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: <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 | <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: <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 | <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 |
63 changes: 63 additions & 0 deletions tests/features/producer/headOnGetEndpoint.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
Feature: Producer - HEAD Requests

Scenario Outline: DocumentReference with HEAD fails (Content-Type: <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 | <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: <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 | <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: <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 | <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: <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 | <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 |
18 changes: 18 additions & 0 deletions tests/features/steps/2_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
25 changes: 25 additions & 0 deletions tests/features/steps/3_assert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions tests/utilities/api_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
)