diff --git a/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponse.java b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponse.java index 2f020aa25..404137802 100644 --- a/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponse.java +++ b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponse.java @@ -36,6 +36,7 @@ import software.amazon.awssdk.http.SdkHttpMethod; import software.amazon.awssdk.http.SdkHttpRequest; import software.amazon.awssdk.utils.StringInputStream; +import software.amazon.awssdk.utils.StringUtils; /** * Client for sending responses to AWS CloudFormation custom resources by way of a response URL, which is an Amazon S3 @@ -148,7 +149,9 @@ StringInputStream responseBodyStream(CloudFormationCustomResourceEvent event, ObjectNode node = body.toObjectNode(null); return new StringInputStream(node.toString()); } else { - + if (!StringUtils.isBlank(resp.getReason())) { + reason = resp.getReason(); + } String physicalResourceId = resp.getPhysicalResourceId() != null ? resp.getPhysicalResourceId() : event.getPhysicalResourceId() != null ? event.getPhysicalResourceId() : context.getLogStreamName(); diff --git a/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/Response.java b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/Response.java index 215151d44..8c782d957 100644 --- a/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/Response.java +++ b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/Response.java @@ -19,6 +19,7 @@ import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; +import software.amazon.awssdk.utils.StringUtils; /** * Models the arbitrary data to be sent to the custom resource in response to a CloudFormation event. This object @@ -30,12 +31,22 @@ public class Response { private final Status status; private final String physicalResourceId; private final boolean noEcho; + private final String reason; private Response(JsonNode jsonNode, Status status, String physicalResourceId, boolean noEcho) { this.jsonNode = jsonNode; this.status = status; this.physicalResourceId = physicalResourceId; this.noEcho = noEcho; + this.reason = null; + } + + private Response(JsonNode jsonNode, Status status, String physicalResourceId, boolean noEcho, String reason) { + this.jsonNode = jsonNode; + this.status = status; + this.physicalResourceId = physicalResourceId; + this.noEcho = noEcho; + this.reason = reason; } /** @@ -115,6 +126,15 @@ public boolean isNoEcho() { return noEcho; } + /** + * The reason for the failure. + * + * @return a potentially null reason + */ + public String getReason() { + return reason; + } + /** * Includes all Response attributes, including its value in JSON format * @@ -127,6 +147,7 @@ public String toString() { attributes.put("Status", status); attributes.put("PhysicalResourceId", physicalResourceId); attributes.put("NoEcho", noEcho); + attributes.put("Reason", reason); return attributes.entrySet().stream() .map(entry -> entry.getKey() + " = " + entry.getValue()) .collect(Collectors.joining(",", "[", "]")); @@ -148,6 +169,7 @@ public static class Builder { private Status status; private String physicalResourceId; private boolean noEcho; + private String reason; private Builder() { } @@ -229,6 +251,20 @@ public Builder noEcho(boolean noEcho) { return this; } + /** + * Reason for the response. + * Reason is optional for Success responses, but required for Failed responses. + * If not provided it will be replaced with cloudwatch log stream name. + * + * @param reason if null, the default reason will be used + * @return a reference to this builder + */ + + public Builder reason(String reason) { + this.reason = reason; + return this; + } + /** * Builds a Response object for the value. * @@ -243,6 +279,9 @@ public Response build() { node = mapper.valueToTree(value); } Status responseStatus = this.status != null ? this.status : Status.SUCCESS; + if (StringUtils.isNotBlank(this.reason)) { + return new Response(node, responseStatus, physicalResourceId, noEcho, reason); + } return new Response(node, responseStatus, physicalResourceId, noEcho); } } diff --git a/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponseTest.java b/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponseTest.java index 0701c98fe..9da18790c 100644 --- a/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponseTest.java +++ b/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponseTest.java @@ -324,4 +324,27 @@ void responseBodyStreamFailedResponse() throws Exception { "}"; assertThat(stream.getString()).isEqualTo(expectedJson); } + + @Test + void responseBodyStreamFailedResponseWithReason() throws Exception { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + Context context = mock(Context.class); + CloudFormationResponse cfnResponse = testableCloudFormationResponse(); + String failureReason = "Failed test reason"; + Response failedResponseWithReason = Response.builder(). + status(Response.Status.FAILED).reason(failureReason).build(); + StringInputStream stream = cfnResponse.responseBodyStream(event, context, failedResponseWithReason); + + String expectedJson = "{" + + "\"Status\":\"FAILED\"," + + "\"Reason\":\"" + failureReason + "\"," + + "\"PhysicalResourceId\":null," + + "\"StackId\":null," + + "\"RequestId\":null," + + "\"LogicalResourceId\":null," + + "\"NoEcho\":false," + + "\"Data\":null" + + "}"; + assertThat(stream.getString()).isEqualTo(expectedJson); + } } diff --git a/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/ResponseTest.java b/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/ResponseTest.java index e577aecca..3e2930541 100644 --- a/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/ResponseTest.java +++ b/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/ResponseTest.java @@ -33,11 +33,13 @@ void defaultValues() { assertThat(response.getStatus()).isEqualTo(Response.Status.SUCCESS); assertThat(response.getPhysicalResourceId()).isNull(); assertThat(response.isNoEcho()).isFalse(); + assertThat(response.getReason()).isNull(); assertThat(response.toString()).contains("JSON = null"); assertThat(response.toString()).contains("Status = SUCCESS"); assertThat(response.toString()).contains("PhysicalResourceId = null"); assertThat(response.toString()).contains("NoEcho = false"); + assertThat(response.toString()).contains("Reason = null"); } @Test @@ -61,6 +63,27 @@ void explicitNullValues() { assertThat(response.toString()).contains("NoEcho = false"); } + @Test + void explicitReasonWithDefaultValues() { + String reason = "test"; + Response response = Response.builder() + .reason(reason) + .build(); + assertThat(response).isNotNull(); + assertThat(response.getJsonNode()).isNull(); + assertThat(response.getStatus()).isEqualTo(Response.Status.SUCCESS); + assertThat(response.getPhysicalResourceId()).isNull(); + assertThat(response.isNoEcho()).isFalse(); + assertThat(response.getReason()).isNotNull(); + assertThat(response.getReason()).isEqualTo(reason); + + assertThat(response.toString()).contains("JSON = null"); + assertThat(response.toString()).contains("Status = SUCCESS"); + assertThat(response.toString()).contains("PhysicalResourceId = null"); + assertThat(response.toString()).contains("NoEcho = false"); + assertThat(response.toString()).contains("Reason = "+reason); + } + @Test void customNonJsonRelatedValues() { Response response = Response.builder()