Skip to content

Commit 7c46e08

Browse files
Merge pull request #938 from NHSDigital/feature/axkr1-NRL-512-head-requests
NRL-512 Implementation for rejecting HEAD requests
2 parents 961c4ae + e959122 commit 7c46e08

File tree

11 files changed

+294
-7
lines changed

11 files changed

+294
-7
lines changed

terraform/infrastructure/api_gateway.tf

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ module "consumer__gateway" {
1010
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"
1111
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"
1212
}
13+
endpoint_allowed_methods = {
14+
"/DocumentReference" = "GET",
15+
"/DocumentReference/{id}" = "GET"
16+
}
1317
kms_key_id = module.kms__cloudwatch.kms_arn
1418
domain = local.apis.domain
1519
path = local.apis.consumer.path
@@ -35,7 +39,10 @@ module "producer__gateway" {
3539
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"
3640
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"
3741
}
38-
42+
endpoint_allowed_methods = {
43+
"/DocumentReference" = "GET,POST",
44+
"/DocumentReference/{id}" = "GET,PUT,DELETE"
45+
}
3946
kms_key_id = module.kms__cloudwatch.kms_arn
4047
domain = local.apis.domain
4148
path = local.apis.producer.path

terraform/infrastructure/consumer.tftpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
{
6464
"type": "DocumentReference",
6565
"profile": "http://hl7.org/fhir/R4/documentreference.html",
66-
"documentation": "Additional business rules apply to constrain patient, organisation and type/category content.",
66+
"documentation": "Additional business rules apply to constrain patient, organisation and type/category content. Note that HEAD requests are not supported.",
6767
"interaction": [
6868
{
6969
"code": "read",

terraform/infrastructure/modules/api_gateway/api_gateway.tf

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,20 @@ resource "aws_api_gateway_deployment" "api_gateway_deployment" {
5050
rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id
5151

5252
triggers = {
53-
redeployment = sha1(jsonencode(aws_api_gateway_rest_api.api_gateway_rest_api.body))
54-
resource_change = "${md5(file("${path.module}/api_gateway.tf"))}"
55-
capabilities = sha1(var.capability_statement_content)
53+
redeployment = sha1(jsonencode(aws_api_gateway_rest_api.api_gateway_rest_api.body))
54+
resource_change = "${md5(file("${path.module}/api_gateway.tf"))}"
55+
capabilities = sha1(var.capability_statement_content)
56+
head_responses_change = md5(file("${path.module}/head_responses.tf"))
57+
parent_api_gateway_change = md5(file("${path.module}/../../api_gateway.tf"))
5658
}
5759

5860
lifecycle {
5961
create_before_destroy = true
6062
}
6163

6264
depends_on = [
63-
aws_api_gateway_rest_api.api_gateway_rest_api
65+
aws_api_gateway_rest_api.api_gateway_rest_api,
66+
aws_api_gateway_integration_response.head_integration_response
6467
]
6568
}
6669

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Define a reusable map of allowed methods for each path
2+
data "aws_api_gateway_resource" "resource" {
3+
for_each = var.endpoint_allowed_methods
4+
rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id
5+
path = each.key
6+
7+
depends_on = [aws_api_gateway_rest_api.api_gateway_rest_api]
8+
}
9+
10+
# Add HEAD method to each resource with 405 response
11+
resource "aws_api_gateway_method" "head_method" {
12+
for_each = var.endpoint_allowed_methods
13+
14+
rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id
15+
resource_id = data.aws_api_gateway_resource.resource[each.key].id
16+
http_method = "HEAD"
17+
authorization = "NONE"
18+
}
19+
20+
resource "aws_api_gateway_integration" "head_integration" {
21+
for_each = var.endpoint_allowed_methods
22+
23+
rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id
24+
resource_id = data.aws_api_gateway_resource.resource[each.key].id
25+
http_method = aws_api_gateway_method.head_method[each.key].http_method
26+
type = "MOCK"
27+
passthrough_behavior = "WHEN_NO_TEMPLATES"
28+
29+
request_templates = {
30+
"application/json" = <<-EOF
31+
{ "statusCode": 405 }
32+
EOF
33+
"application/json+fhir" = <<-EOF
34+
{ "statusCode": 405 }
35+
EOF
36+
"application/fhir+json" = <<-EOF
37+
{ "statusCode": 405 }
38+
EOF
39+
}
40+
}
41+
42+
resource "aws_api_gateway_method_response" "head_method_response" {
43+
for_each = var.endpoint_allowed_methods
44+
45+
rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id
46+
resource_id = data.aws_api_gateway_resource.resource[each.key].id
47+
http_method = aws_api_gateway_method.head_method[each.key].http_method
48+
status_code = "405"
49+
50+
response_parameters = {
51+
"method.response.header.Allow" = true
52+
}
53+
}
54+
55+
resource "aws_api_gateway_integration_response" "head_integration_response" {
56+
for_each = var.endpoint_allowed_methods
57+
58+
rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id
59+
resource_id = data.aws_api_gateway_resource.resource[each.key].id
60+
http_method = aws_api_gateway_method.head_method[each.key].http_method
61+
status_code = aws_api_gateway_method_response.head_method_response[each.key].status_code
62+
selection_pattern = "" # default catch-all
63+
64+
response_parameters = {
65+
"method.response.header.Allow" = "'${each.value}'"
66+
}
67+
}

terraform/infrastructure/modules/api_gateway/vars.tf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,7 @@ variable "retention" {
2222
default = 90
2323
type = number
2424
}
25+
26+
variable "endpoint_allowed_methods" {
27+
type = map(string)
28+
}

terraform/infrastructure/producer.tftpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
{
6363
"type": "DocumentReference",
6464
"profile": "http://hl7.org/fhir/R4/documentreference.html",
65-
"documentation": "Additional business rules apply to validate patient, organisation and type/category content.",
65+
"documentation": "Additional business rules apply to validate patient, organisation and type/category content. Note that HEAD requests are not supported.",
6666
"interaction": [
6767
{
6868
"code": "read",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
Feature: Consumer - HEAD Requests
2+
3+
Scenario Outline: DocumentReference with HEAD fails (Content-Type: <content_type>)
4+
Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API
5+
When consumer 'RX898' sends HEAD request to 'DocumentReference' endpoint with headers:
6+
| header | value |
7+
| Content-Type | <content_type> |
8+
Then the response status code is 405
9+
And the response has an empty body
10+
And the Allow header is 'GET'
11+
And the Content-Length header is '0'
12+
13+
Examples:
14+
| content_type |
15+
| application/json |
16+
| application/json+fhir |
17+
| application/fhir+json |
18+
19+
Scenario Outline: DocumentReference/{id} with HEAD fails (Content-Type: <content_type>)
20+
Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API
21+
When consumer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint with headers:
22+
| header | value |
23+
| Content-Type | <content_type> |
24+
Then the response status code is 405
25+
And the response has an empty body
26+
And the Allow header is 'GET'
27+
And the Content-Length header is '0'
28+
29+
Examples:
30+
| content_type |
31+
| application/json |
32+
| application/json+fhir |
33+
| application/fhir+json |
34+
35+
Scenario Outline: DocumentReference with HEAD fails with 415 with unsupported (Content-Type: <content_type>)
36+
Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API
37+
When consumer 'RX898' sends HEAD request to 'DocumentReference' endpoint with headers:
38+
| header | value |
39+
| Content-Type | <content_type> |
40+
Then the response status code is 415
41+
And the Content-Type header is 'application/json'
42+
And the Content-Length header is '0'
43+
And the Allow header is not present
44+
45+
Examples:
46+
| content_type |
47+
| application/notsupported |
48+
| application/helloworld |
49+
| application/harold |
50+
51+
Scenario Outline: DocumentReference/{id} with HEAD fails (Content-Type: <content_type>)
52+
Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API
53+
When consumer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint with headers:
54+
| header | value |
55+
| Content-Type | <content_type> |
56+
Then the response status code is 415
57+
And the response has an empty body
58+
And the Content-Type header is 'application/json'
59+
And the Content-Length header is '0'
60+
And the Allow header is not present
61+
62+
Examples:
63+
| content_type |
64+
| application/notsupported |
65+
| application/helloworld |
66+
| application/harold |
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
Feature: Producer - HEAD Requests
2+
3+
Scenario Outline: DocumentReference with HEAD fails (Content-Type: <content_type>)
4+
Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API
5+
When producer 'RX898' sends HEAD request to 'DocumentReference' endpoint with headers:
6+
| header | value |
7+
| Content-Type | <content_type> |
8+
Then the response status code is 405
9+
And the response has an empty body
10+
And the Allow header is 'GET,POST'
11+
12+
Examples:
13+
| content_type |
14+
| application/json |
15+
| application/json+fhir |
16+
| application/fhir+json |
17+
18+
Scenario Outline: DocumentReference/{id} with HEAD fails (Content-Type: <content_type>)
19+
Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API
20+
When producer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint with headers:
21+
| header | value |
22+
| Content-Type | <content_type> |
23+
Then the response status code is 405
24+
And the response has an empty body
25+
And the Allow header is 'GET,PUT,DELETE'
26+
27+
Examples:
28+
| content_type |
29+
| application/json |
30+
| application/json+fhir |
31+
| application/fhir+json |
32+
33+
Scenario Outline: DocumentReference with HEAD fails with 415 with unsupported (Content-Type: <content_type>)
34+
Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API
35+
When producer 'RX898' sends HEAD request to 'DocumentReference' endpoint with headers:
36+
| header | value |
37+
| Content-Type | <content_type> |
38+
Then the response status code is 415
39+
And the Content-Type header is 'application/json'
40+
And the response has an empty body
41+
And the Allow header is not present
42+
43+
Examples:
44+
| content_type |
45+
| application/notsupported |
46+
| application/helloworld |
47+
| application/harold |
48+
49+
Scenario Outline: DocumentReference/{id} with HEAD fails with 415 with unsupported (Content-Type: <content_type>)
50+
Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API
51+
When producer 'RX898' sends HEAD request to 'DocumentReference/random-id' endpoint with headers:
52+
| header | value |
53+
| Content-Type | <content_type> |
54+
Then the response status code is 415
55+
And the Content-Type header is 'application/json'
56+
And the response has an empty body
57+
And the Allow header is not present
58+
59+
Examples:
60+
| content_type |
61+
| application/notsupported |
62+
| application/helloworld |
63+
| application/harold |

tests/features/steps/2_request.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,21 @@ def producer_search_document_reference_step(context: Context, ods_code: str):
247247
pointer_type=pointer_type,
248248
extra_params=items,
249249
)
250+
251+
252+
@when(
253+
"{consumer_or_producer} '{ods_code}' sends HEAD request to '{endpoint}' endpoint with headers"
254+
)
255+
def consumer_head_request_step(
256+
context: Context, consumer_or_producer: str, ods_code: str, endpoint: str
257+
):
258+
if not context.table:
259+
raise ValueError("No headers table provided")
260+
261+
headers = {row["header"]: row["value"] for row in context.table}
262+
263+
if consumer_or_producer == "producer":
264+
client = producer_client_from_context(context, ods_code)
265+
elif consumer_or_producer == "consumer":
266+
client = consumer_client_from_context(context, ods_code)
267+
context.response = client.head(endpoint, headers=headers)

tests/features/steps/3_assert.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ def assert_bundle_step(context: Context, bundle_type: str):
5151
context.bundle = Bundle.model_validate(body)
5252

5353

54+
@then("the response has an empty body")
55+
def assert_empty_body_step(context: Context):
56+
"""
57+
Asserts that the response body is empty.
58+
"""
59+
assert context.response.text == "", format_error(
60+
"Response body is not empty",
61+
"empty",
62+
context.response.text,
63+
context.response.text,
64+
)
65+
context.bundle = None
66+
67+
5468
@then("the Bundle has a total of {total}")
5569
def assert_bundle_total_step(context: Context, total: str):
5670
assert (
@@ -333,6 +347,17 @@ def assert_location_header(context: Context, header_name: str):
333347
context.pointer_id = generated_id
334348

335349

350+
@then("the {header_name} header is not present")
351+
def assert_header_not_present(context: Context, header_name: str):
352+
header_value = context.response.headers.get(header_name)
353+
assert header_value is None, format_error(
354+
f"Header {header_name} should not be present",
355+
"not present",
356+
header_value,
357+
context.response.text,
358+
)
359+
360+
336361
@then("the {header_name} header starts with '{starts_with}'")
337362
def assert_header_starts_with(context: Context, header_name: str, starts_with: str):
338363
header_value = context.response.headers.get(header_name)

0 commit comments

Comments
 (0)