From 81ce9111e1c66f429ecff03a5a0df41a20a0fb72 Mon Sep 17 00:00:00 2001 From: Joe Wolf Date: Mon, 4 Oct 2021 15:23:40 -0400 Subject: [PATCH] Add base RequestHandler class for custom resources Provides abstract methods for generating the Response to be sent, represented as its own type. Use Objects::nonNull instead of custom method. Addresses #558 --- .../AbstractCustomResourceHandler.java | 156 ++++++++++ .../CloudFormationResponse.java | 118 ++++---- .../powertools/cloudformation/Response.java | 92 ++++++ .../cloudformation/ResponseException.java | 13 + .../AbstractCustomResourceHandlerTest.java | 267 ++++++++++++++++++ .../CloudFormationResponseTest.java | 79 ++++-- .../cloudformation/ResponseTest.java | 100 +++++++ 7 files changed, 757 insertions(+), 68 deletions(-) create mode 100644 powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/AbstractCustomResourceHandler.java create mode 100644 powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/Response.java create mode 100644 powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/ResponseException.java create mode 100644 powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/AbstractCustomResourceHandlerTest.java create mode 100644 powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/ResponseTest.java diff --git a/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/AbstractCustomResourceHandler.java b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/AbstractCustomResourceHandler.java new file mode 100644 index 000000000..7cae98ffe --- /dev/null +++ b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/AbstractCustomResourceHandler.java @@ -0,0 +1,156 @@ +package software.amazon.lambda.powertools.cloudformation; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.CloudFormationCustomResourceEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.lambda.powertools.cloudformation.CloudFormationResponse.ResponseStatus; + +import java.io.IOException; +import java.util.Objects; + +/** + * Handler base class providing core functionality for sending responses to custom CloudFormation resources after + * receiving some event. Depending on the type of event, this class either invokes the crete, update, or delete method + * and sends the returned Response object to the custom resource. + */ +public abstract class AbstractCustomResourceHandler + implements RequestHandler { + + private static final Logger LOG = LoggerFactory.getLogger(AbstractCustomResourceHandler.class); + + private final SdkHttpClient client; + + /** + * Creates a new Handler that uses the provided HTTP client for communicating with custom CloudFormation resources. + * + * @param client cannot be null + */ + public AbstractCustomResourceHandler(SdkHttpClient client) { + this.client = Objects.requireNonNull(client, "SdkHttpClient cannot be null."); + } + + /** + * Generates the appropriate response object based on the event type and sends it as a response to the custom + * cloud formation resource using the URL provided within the event. + * + * @param event custom resources create/update/delete event + * @param context lambda execution context + * @return potentially null response object sent to the custom resource + */ + @Override + public final Response handleRequest(CloudFormationCustomResourceEvent event, Context context) { + String responseUrl = Objects.requireNonNull(event.getResponseUrl(), + "Event must have a non-null responseUrl to be able to send the response."); + + CloudFormationResponse client = buildResponseClient(); + + Response response = null; + try { + response = getResponse(event, context); + LOG.debug("Preparing to send response {} to {}.", response, responseUrl); + client.send(event, context, ResponseStatus.SUCCESS, response); + } catch (IOException ioe) { + LOG.error("Unable to send {} success to {}.", responseUrl, ioe); + onSendFailure(event, context, response, ioe); + } catch (ResponseException rse) { + LOG.error("Unable to create/serialize Response. Sending empty failure to {}", responseUrl, rse); + // send a failure with a null response on account of response serialization issues + try { + client.send(event, context, ResponseStatus.FAILED); + } catch (Exception e) { + // unable to serialize response AND send an empty response + LOG.error("Unable to send failure to {}.", responseUrl, e); + onSendFailure(event, context, null, e); + } + } + return response; + } + + private Response getResponse(CloudFormationCustomResourceEvent event, Context context) + throws ResponseException { + try { + switch (event.getRequestType()) { + case "Create": + return create(event, context); + case "Update": + return update(event, context); + case "Delete": + return delete(event, context); + default: + LOG.warn("Unexpected request type \"" + event.getRequestType() + "\" for event " + event); + return null; + } + } catch (RuntimeException e) { + throw new ResponseException("Unable to get Response", e); + } + } + + /** + * Builds a client for sending responses to the custom resource. + * + * @return a client for sending the response + */ + protected CloudFormationResponse buildResponseClient() { + return new CloudFormationResponse(client); + } + + /** + * Invoked when there is an error sending a response to the custom cloud formation resource. This method does not + * get called if there are errors constructing the response itself, which instead is handled by sending an empty + * FAILED response to the custom resource. This method will be invoked, however, if there is an error while sending + * the FAILED response. + *

+ * The method itself does nothing but subclasses may override to provide additional logging or handling logic. All + * arguments provided are for contextual purposes. + *

+ * Exceptions should not be thrown by this method. + * + * @param event the event + * @param context execution context + * @param response the response object that was attempted to be sent to the custom resource + * @param exception the exception caught when attempting to call the custom resource URL + */ + @SuppressWarnings("unused") + protected void onSendFailure(CloudFormationCustomResourceEvent event, + Context context, + Response response, + Exception exception) { + // intentionally empty + } + + /** + * Returns the response object to send to the custom CloudFormation resource upon its creation. If this method + * returns null, then the handler will send a successful but empty response to the CloudFormation resource. If this + * method throws a RuntimeException, the handler will send an empty failed response to the resource. + * + * @param event an event of request type Create + * @param context execution context + * @return the response object or null + */ + protected abstract Response create(CloudFormationCustomResourceEvent event, Context context); + + /** + * Returns the response object to send to the custom CloudFormation resource upon its modification. If the method + * returns null, then the handler will send a successful but empty response to the CloudFormation resource. If this + * method throws a RuntimeException, the handler will send an empty failed response to the resource. + * + * @param event an event of request type Update + * @param context execution context + * @return the response object or null + */ + protected abstract Response update(CloudFormationCustomResourceEvent event, Context context); + + /** + * Returns the response object to send to the custom CloudFormation resource upon its deletion. If this method + * returns null, then the handler will send a successful but empty response to the CloudFormation resource. If this + * method throws a RuntimeException, the handler will send an empty failed response to the resource. + * + * @param event an event of request type Delete + * @param context execution context + * @return the response object or null + */ + protected abstract Response delete(CloudFormationCustomResourceEvent event, Context context); +} 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 f87ed9623..e65439671 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 @@ -2,8 +2,10 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.events.CloudFormationCustomResourceEvent; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.node.ObjectNode; import software.amazon.awssdk.http.Header; import software.amazon.awssdk.http.HttpExecuteRequest; import software.amazon.awssdk.http.HttpExecuteResponse; @@ -18,12 +20,15 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** * Client for sending responses to AWS CloudFormation custom resources by way of a response URL, which is an Amazon S3 * pre-signed URL. *

* See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html + *

+ * This class is thread-safe provided the SdkHttpClient instance used is also thread-safe. */ public class CloudFormationResponse { @@ -35,10 +40,17 @@ public enum ResponseStatus { } /** - * Representation of the payload to be sent to the event target URL. + * Internal representation of the payload to be sent to the event target URL. Retains all properties of the payload + * except for "Data". This is done so that the serialization of the non-"Data" properties and the serialization of + * the value of "Data" can be handled by separate ObjectMappers, if need be. The former properties are dictated by + * the custom resource but the latter is dictated by the implementor of the custom resource handler. */ @SuppressWarnings("unused") static class ResponseBody { + static final ObjectMapper MAPPER = new ObjectMapper() + .setPropertyNamingStrategy(PropertyNamingStrategies.UPPER_CAMEL_CASE); + private static final String DATA_PROPERTY_NAME = "Data"; + private final String status; private final String reason; private final String physicalResourceId; @@ -46,16 +58,14 @@ static class ResponseBody { private final String requestId; private final String logicalResourceId; private final boolean noEcho; - private final Object data; ResponseBody(CloudFormationCustomResourceEvent event, Context context, ResponseStatus responseStatus, - Object responseData, String physicalResourceId, boolean noEcho) { - requireNonNull(event, "CloudFormationCustomResourceEvent"); - requireNonNull(context, "Context"); + Objects.requireNonNull(event, "CloudFormationCustomResourceEvent cannot be null"); + Objects.requireNonNull(context, "Context cannot be null"); this.physicalResourceId = physicalResourceId != null ? physicalResourceId : context.getLogStreamName(); this.reason = "See the details in CloudWatch Log Stream: " + context.getLogStreamName(); this.status = responseStatus == null ? ResponseStatus.SUCCESS.name() : responseStatus.name(); @@ -63,7 +73,6 @@ static class ResponseBody { this.requestId = event.getRequestId(); this.logicalResourceId = event.getLogicalResourceId(); this.noEcho = noEcho; - this.data = responseData; } public String getStatus() { @@ -94,23 +103,24 @@ public boolean isNoEcho() { return noEcho; } - public Object getData() { - return data; - } - } - - /* - * Replace with java.util.Objects::requireNonNull when dropping JDK 8 support. - */ - private static T requireNonNull(T arg, String argName) { - if (arg == null) { - throw new NullPointerException(argName + " cannot be null."); + /** + * Returns this ResponseBody as an ObjectNode with the provided JsonNode as the value of its "Data" property. + * + * @param dataNode the value of the "Data" property for the returned node; may be null + * @return an ObjectNode representation of this ResponseBody and the provided dataNode + */ + ObjectNode toObjectNode(JsonNode dataNode) { + ObjectNode node = MAPPER.valueToTree(this); + if (dataNode == null) { + node.putNull(DATA_PROPERTY_NAME); + } else { + node.set(DATA_PROPERTY_NAME, dataNode); + } + return node; } - return arg; } private final SdkHttpClient client; - private final ObjectMapper mapper; /** * Creates a new CloudFormationResponse that uses the provided HTTP client and default JSON serialization format. @@ -118,19 +128,7 @@ private static T requireNonNull(T arg, String argName) { * @param client HTTP client to use for sending requests; cannot be null */ public CloudFormationResponse(SdkHttpClient client) { - this(client, new ObjectMapper() - .setPropertyNamingStrategy(new PropertyNamingStrategies.UpperCamelCaseStrategy())); - } - - /** - * Creates a new CloudFormationResponse that uses the provided HTTP client and the JSON serialization mapper object. - * - * @param client HTTP client to use for sending requests; cannot be null - * @param mapper for serializing response data; cannot be null - */ - protected CloudFormationResponse(SdkHttpClient client, ObjectMapper mapper) { - this.client = requireNonNull(client, "SdkHttpClient"); - this.mapper = requireNonNull(mapper, "ObjectMapper"); + this.client = Objects.requireNonNull(client, "SdkHttpClient cannot be null"); } /** @@ -142,11 +140,12 @@ protected CloudFormationResponse(SdkHttpClient client, ObjectMapper mapper) { * access information from within the Lambda execution environment. * @param status whether the function successfully completed * @return the response object - * @throws IOException when unable to generate or send the request + * @throws IOException when unable to generate or send the request + * @throws ResponseException when unable to serialize the response payload */ public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, Context context, - ResponseStatus status) throws IOException { + ResponseStatus status) throws IOException, ResponseException { return send(event, context, status, null); } @@ -158,14 +157,15 @@ public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, * @param context used to specify when the function and any callbacks have completed execution, or to * access information from within the Lambda execution environment. * @param status whether the function successfully completed - * @param responseData response data, e.g. a list of name-value pairs. May be null. + * @param responseData response to send, e.g. a list of name-value pairs. May be null. * @return the response object - * @throws IOException when unable to generate or send the request + * @throws IOException when unable to generate or send the request + * @throws ResponseException when unable to serialize the response payload */ public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, Context context, ResponseStatus status, - Object responseData) throws IOException { + Response responseData) throws IOException, ResponseException { return send(event, context, status, responseData, null); } @@ -177,18 +177,19 @@ public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, * @param context used to specify when the function and any callbacks have completed execution, or to * access information from within the Lambda execution environment. * @param status whether the function successfully completed - * @param responseData response data, e.g. a list of name-value pairs. May be null. + * @param responseData response to send, e.g. a list of name-value pairs. May be null. * @param physicalResourceId Optional. The unique identifier of the custom resource that invoked the function. By * default, the module uses the name of the Amazon CloudWatch Logs log stream that's * associated with the Lambda function. * @return the response object - * @throws IOException when unable to generate or send the request + * @throws IOException when unable to send the request + * @throws ResponseException when unable to serialize the response payload */ public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, Context context, ResponseStatus status, - Object responseData, - String physicalResourceId) throws IOException { + Response responseData, + String physicalResourceId) throws IOException, ResponseException { return send(event, context, status, responseData, physicalResourceId, false); } @@ -200,7 +201,7 @@ public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, * @param context used to specify when the function and any callbacks have completed execution, or to * access information from within the Lambda execution environment. * @param status whether the function successfully completed - * @param responseData response data, e.g. a list of name-value pairs. May be null. + * @param responseData response to send, e.g. a list of name-value pairs. May be null. * @param physicalResourceId Optional. The unique identifier of the custom resource that invoked the function. By * default, the module uses the name of the Amazon CloudWatch Logs log stream that's * associated with the Lambda function. @@ -209,19 +210,18 @@ public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, * masked with asterisks (*****), except for information stored in the locations specified * below. By default, this value is false. * @return the response object - * @throws IOException when unable to generate or send the request + * @throws IOException when unable to send the request + * @throws ResponseException when unable to serialize the response payload */ public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, Context context, ResponseStatus status, - Object responseData, + Response responseData, String physicalResourceId, - boolean noEcho) throws IOException { - ResponseBody body = new ResponseBody(event, context, status, responseData, physicalResourceId, noEcho); - URI uri = URI.create(event.getResponseUrl()); - + boolean noEcho) throws IOException, ResponseException { // no need to explicitly close in-memory stream - StringInputStream stream = new StringInputStream(mapper.writeValueAsString(body)); + StringInputStream stream = responseBodyStream(event, context, status, responseData, physicalResourceId, noEcho); + URI uri = URI.create(event.getResponseUrl()); SdkHttpRequest request = SdkHttpRequest.builder() .uri(uri) .method(SdkHttpMethod.PUT) @@ -246,4 +246,24 @@ protected Map> headers(int contentLength) { headers.put(Header.CONTENT_LENGTH, Collections.singletonList(Integer.toString(contentLength))); return headers; } + + /** + * Returns the response body as an input stream, for supplying with the HTTP request to the custom resource. + * + * @throws ResponseException if unable to generate the response stream + */ + private StringInputStream responseBodyStream(CloudFormationCustomResourceEvent event, + Context context, + ResponseStatus status, + Response responseData, + String physicalResourceId, + boolean noEcho) throws ResponseException { + try { + ResponseBody body = new ResponseBody(event, context, status, physicalResourceId, noEcho); + ObjectNode node = body.toObjectNode(responseData == null ? null : responseData.getJsonNode()); + return new StringInputStream(node.toString()); + } catch (RuntimeException e) { + throw new ResponseException("Unable to generate response body.", e); + } + } } 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 new file mode 100644 index 000000000..9f475451e --- /dev/null +++ b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/Response.java @@ -0,0 +1,92 @@ +package software.amazon.lambda.powertools.cloudformation; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.Objects; + +/** + * Models the arbitrary data to be sent to the custom resource in response to a CloudFormation event. This object + * encapsulates the data and the means to serialize it. + */ +public class Response { + + /** + * For building Response instances that wrap a single, arbitrary value. + */ + public static class Builder { + private Object value; + private ObjectMapper objectMapper; + + private Builder() { + } + + /** + * Configures a custom ObjectMapper for serializing the value object. + * + * @param value cannot be null + * @return a reference to this builder + */ + public Builder value(Object value) { + this.value = value; + return this; + } + + /** + * Configures a custom ObjectMapper for serializing the value object. + * + * @param objectMapper if null, a default mapper will be used + * @return a reference to this builder + */ + public Builder objectMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + return this; + } + + /** + * Builds a Response object for the value. + * + * @return a Response object wrapping the initially provided value. + */ + public Response build() { + Object val = Objects.requireNonNull(value, "A non-null value is required"); + ObjectMapper mapper = objectMapper != null ? objectMapper : CloudFormationResponse.ResponseBody.MAPPER; + JsonNode node = mapper.valueToTree(val); + return new Response(node); + } + } + + /** + * Creates a builder for constructing a Response wrapping the provided value. + * + * @return a builder + */ + public static Builder builder() { + return new Builder(); + } + + private final JsonNode jsonNode; + + private Response(final JsonNode jsonNode) { + this.jsonNode = jsonNode; + } + + /** + * Returns a JsonNode representation of the response. + * + * @return a non-null JsonNode representation + */ + JsonNode getJsonNode() { + return jsonNode; + } + + /** + * The Response JSON. + * + * @return a String in JSON format + */ + @Override + public String toString() { + return jsonNode.toString(); + } +} diff --git a/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/ResponseException.java b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/ResponseException.java new file mode 100644 index 000000000..5bc41f6c3 --- /dev/null +++ b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/ResponseException.java @@ -0,0 +1,13 @@ +package software.amazon.lambda.powertools.cloudformation; + +/** + * Indicates an error attempting to generation serialize a response to be sent to a custom resource. + */ +public class ResponseException extends Exception { + + private static final long serialVersionUID = 20211004; + + protected ResponseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/AbstractCustomResourceHandlerTest.java b/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/AbstractCustomResourceHandlerTest.java new file mode 100644 index 000000000..23eecec58 --- /dev/null +++ b/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/AbstractCustomResourceHandlerTest.java @@ -0,0 +1,267 @@ +package software.amazon.lambda.powertools.cloudformation; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.events.CloudFormationCustomResourceEvent; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.lambda.powertools.cloudformation.CloudFormationResponse.ResponseStatus; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class AbstractCustomResourceHandlerTest { + + /** + * Uses a mocked CloudFormationResponse that will not send HTTP requests. + */ + static abstract class NoOpCustomResourceHandler extends AbstractCustomResourceHandler { + + NoOpCustomResourceHandler() { + super(mock(SdkHttpClient.class)); + } + + @Override + protected CloudFormationResponse buildResponseClient() { + return mock(CloudFormationResponse.class); + } + } + + /** + * Counts invocations of different methods (and asserts expectations about the invoked methods' arguments) + */ + static class InvocationCountingResourceHandler extends NoOpCustomResourceHandler { + private final AtomicInteger createInvocations = new AtomicInteger(0); + private final AtomicInteger updateInvocations = new AtomicInteger(0); + private final AtomicInteger deleteInvocations = new AtomicInteger(0); + private final AtomicInteger onSendFailureInvocations = new AtomicInteger(0); + + private Response delegate(CloudFormationCustomResourceEvent event, + String expectedEventType, + AtomicInteger invocationsCount) { + assertThat(event.getRequestType()).isEqualTo(expectedEventType); + invocationsCount.incrementAndGet(); + return null; + } + + int getCreateInvocationCount() { + return createInvocations.get(); + } + + int getUpdateInvocationCount() { + return updateInvocations.get(); + } + + int getDeleteInvocationCount() { + return deleteInvocations.get(); + } + + int getOnSendFailureInvocationCount() { + return onSendFailureInvocations.get(); + } + + @Override + protected Response create(CloudFormationCustomResourceEvent event, Context context) { + return delegate(event, "Create", createInvocations); + } + + @Override + protected Response update(CloudFormationCustomResourceEvent event, Context context) { + return delegate(event, "Update", updateInvocations); + } + + @Override + protected Response delete(CloudFormationCustomResourceEvent event, Context context) { + return delegate(event, "Delete", deleteInvocations); + } + + @Override + protected void onSendFailure(CloudFormationCustomResourceEvent event, + Context context, + Response response, + Exception exception) { + assertThat(exception).isNotNull(); + onSendFailureInvocations.incrementAndGet(); + } + } + + /** + * Creates a handler that will expect the Response to be sent with an expected status. Will throw an AssertionError + * if the method is sent with an unexpected status. + */ + static class ExpectedStatusResourceHandler extends InvocationCountingResourceHandler { + private final ResponseStatus expectedStatus; + + ExpectedStatusResourceHandler(ResponseStatus expectedStatus) { + this.expectedStatus = expectedStatus; + } + + @Override + protected CloudFormationResponse buildResponseClient() { + // create a CloudFormationResponse that fails if invoked with unexpected status + CloudFormationResponse cfnResponse = mock(CloudFormationResponse.class); + try { + when(cfnResponse.send(any(), any(), argThat(status -> status != expectedStatus), any())) + .thenThrow(new AssertionError("Expected response status to be " + expectedStatus)); + } catch (IOException | ResponseException e) { + // this should never happen + throw new RuntimeException("Unexpected mocking exception", e); + } + return cfnResponse; + } + } + + /** + * Always fails to send the response + */ + static class FailToSendResponseHandler extends InvocationCountingResourceHandler { + @Override + protected CloudFormationResponse buildResponseClient() { + // create a CloudFormationResponse that fails if invoked with unexpected status + CloudFormationResponse cfnResponse = mock(CloudFormationResponse.class); + try { + when(cfnResponse.send(any(), any(), any())) + .thenThrow(new IOException("Intentional send failure")); + when(cfnResponse.send(any(), any(), any(), any())) + .thenThrow(new IOException("Intentional send failure")); + } catch (IOException | ResponseException e) { + // this should never happen + throw new RuntimeException("Unexpected mocking exception", e); + } + return cfnResponse; + } + } + + /** + * Builds a valid Event with the provide request type. + */ + static CloudFormationCustomResourceEvent eventOfType(String requestType) { + CloudFormationCustomResourceEvent event = new CloudFormationCustomResourceEvent(); + event.setResponseUrl("https://mandatory-url.amazon.com"); + event.setRequestType(requestType); + return event; + } + + @Test + void eventsOfKnownRequestTypesDelegateProperly() { + InvocationCountingResourceHandler handler = new InvocationCountingResourceHandler(); + + // invoke handleRequest for different event requestTypes + Context context = mock(Context.class); + Stream.of("Create", "Create", "Create", "Update", "Update", "Delete") + .map(AbstractCustomResourceHandlerTest::eventOfType) + .forEach(event -> handler.handleRequest(event, context)); + + assertThat(handler.getCreateInvocationCount()).isEqualTo(3); + assertThat(handler.getUpdateInvocationCount()).isEqualTo(2); + assertThat(handler.getDeleteInvocationCount()).isEqualTo(1); + } + + @Test + void eventOfUnknownRequestTypeSendEmptySuccess() { + InvocationCountingResourceHandler handler = new InvocationCountingResourceHandler(); + + Context context = mock(Context.class); + CloudFormationCustomResourceEvent event = eventOfType("UNKNOWN"); + + handler.handleRequest(event, context); + + assertThat(handler.getCreateInvocationCount()).isEqualTo(0); + assertThat(handler.getUpdateInvocationCount()).isEqualTo(0); + assertThat(handler.getDeleteInvocationCount()).isEqualTo(0); + } + + @Test + void nullResponseSendsSuccess() { + ExpectedStatusResourceHandler handler = new ExpectedStatusResourceHandler(ResponseStatus.SUCCESS); + + Context context = mock(Context.class); + CloudFormationCustomResourceEvent event = eventOfType("Create"); // could be any valid requestType + + Response response = handler.handleRequest(event, context); + assertThat(response).isNull(); + assertThat(handler.getOnSendFailureInvocationCount()).isEqualTo(0); + } + + @Test + void nonNullResponseSendsSuccess() { + ExpectedStatusResourceHandler handler = new ExpectedStatusResourceHandler(ResponseStatus.SUCCESS) { + @Override + protected Response create(CloudFormationCustomResourceEvent event, Context context) { + return Response.builder().value("whatever").build(); + } + }; + + Context context = mock(Context.class); + CloudFormationCustomResourceEvent event = eventOfType("Create"); + + Response response = handler.handleRequest(event, context); + assertThat(response).isNotNull(); + assertThat(response.getJsonNode().textValue()).isEqualTo("whatever"); + assertThat(handler.getOnSendFailureInvocationCount()).isEqualTo(0); + } + + @Test + void exceptionWhenGeneratingResponseSendsFailure() { + ExpectedStatusResourceHandler handler = new ExpectedStatusResourceHandler(ResponseStatus.FAILED) { + @Override + protected Response create(CloudFormationCustomResourceEvent event, Context context) { + throw new RuntimeException("This exception is intentional for testing"); + } + }; + + Context context = mock(Context.class); + CloudFormationCustomResourceEvent event = eventOfType("Create"); + + Response response = handler.handleRequest(event, context); + assertThat(response) + .withFailMessage("The response failed to build, so it must be null.") + .isNull(); + assertThat(handler.getOnSendFailureInvocationCount()) + .withFailMessage("A failure to build a Response is not a failure to send it.") + .isEqualTo(0); + } + + @Test + void exceptionWhenSendingResponseInvokesOnSendFailure() { + // a custom handler that builds response successfully but fails to send it + FailToSendResponseHandler handler = new FailToSendResponseHandler() { + @Override + protected Response create(CloudFormationCustomResourceEvent event, Context context) { + return Response.builder().value("Failure happens on send").build(); + } + }; + + Context context = mock(Context.class); + CloudFormationCustomResourceEvent event = eventOfType("Create"); + + Response response = handler.handleRequest(event, context); + assertThat(response).isNotNull(); + assertThat(response.getJsonNode().textValue()).isEqualTo("Failure happens on send"); + assertThat(handler.getOnSendFailureInvocationCount()).isEqualTo(1); + } + + @Test + void bothResponseGenerationAndSendFail() { + // a custom handler that fails to build response _and_ fails to send a FAILED response + FailToSendResponseHandler handler = new FailToSendResponseHandler() { + @Override + protected Response create(CloudFormationCustomResourceEvent event, Context context) { + throw new RuntimeException("This exception is intentional for testing"); + } + }; + + Context context = mock(Context.class); + CloudFormationCustomResourceEvent event = eventOfType("Create"); + + Response response = handler.handleRequest(event, context); + assertThat(response).isNull(); + assertThat(handler.getOnSendFailureInvocationCount()).isEqualTo(1); + } +} 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 c01a95b8a..3c65cdf2d 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 @@ -2,6 +2,7 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.events.CloudFormationCustomResourceEvent; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.Test; import software.amazon.awssdk.http.AbortableInputStream; import software.amazon.awssdk.http.ExecutableHttpRequest; @@ -68,14 +69,6 @@ void clientRequiredToCreateInstance() { .isInstanceOf(NullPointerException.class); } - @Test - void mapperRequiredToCreateInstance() { - SdkHttpClient client = mock(SdkHttpClient.class); - - assertThatThrownBy(() -> new CloudFormationResponse(client, null)) - .isInstanceOf(NullPointerException.class); - } - @Test void eventRequiredToSend() { SdkHttpClient client = mock(SdkHttpClient.class); @@ -83,7 +76,7 @@ void eventRequiredToSend() { Context context = mock(Context.class); assertThatThrownBy(() -> response.send(null, context, ResponseStatus.SUCCESS)) - .isInstanceOf(NullPointerException.class); + .isInstanceOf(ResponseException.class); } @Test @@ -93,7 +86,7 @@ void contextRequiredToSend() { Context context = mock(Context.class); assertThatThrownBy(() -> response.send(null, context, ResponseStatus.SUCCESS)) - .isInstanceOf(NullPointerException.class); + .isInstanceOf(ResponseException.class); } @Test @@ -103,8 +96,10 @@ void eventResponseUrlRequiredToSend() { CloudFormationCustomResourceEvent event = mock(CloudFormationCustomResourceEvent.class); Context context = mock(Context.class); + // not a ResponseSerializationException since the URL is not part of the response but + // rather the location the response is sent to assertThatThrownBy(() -> response.send(event, context, ResponseStatus.SUCCESS)) - .isInstanceOf(NullPointerException.class); + .isInstanceOf(RuntimeException.class); } @Test @@ -117,7 +112,7 @@ void defaultPhysicalResponseIdIsLogStreamName() { when(context.getLogStreamName()).thenReturn(logStreamName); ResponseBody body = new ResponseBody( - event, context, ResponseStatus.SUCCESS, null, null, false); + event, context, ResponseStatus.SUCCESS, null, false); assertThat(body.getPhysicalResourceId()).isEqualTo(logStreamName); } @@ -131,17 +126,62 @@ void customPhysicalResponseId() { String customPhysicalResourceId = "Custom-Physical-Resource-ID"; ResponseBody body = new ResponseBody( - event, context, ResponseStatus.SUCCESS, null, customPhysicalResourceId, false); + event, context, ResponseStatus.SUCCESS, customPhysicalResourceId, false); assertThat(body.getPhysicalResourceId()).isEqualTo(customPhysicalResourceId); } + @Test + void responseBodyWithNullDataNode() { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + Context context = mock(Context.class); + + ResponseBody responseBody = new ResponseBody(event, context, ResponseStatus.FAILED, null, true); + String actualJson = responseBody.toObjectNode(null).toString(); + + String expectedJson = "{" + + "\"Status\":\"FAILED\"," + + "\"Reason\":\"See the details in CloudWatch Log Stream: null\"," + + "\"PhysicalResourceId\":null," + + "\"StackId\":null," + + "\"RequestId\":null," + + "\"LogicalResourceId\":null," + + "\"NoEcho\":true," + + "\"Data\":null" + + "}"; + assertThat(actualJson).isEqualTo(expectedJson); + } + + @Test + void responseBodyWithNonNullDataNode() { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + Context context = mock(Context.class); + ObjectNode dataNode = ResponseBody.MAPPER.createObjectNode(); + dataNode.put("foo", "bar"); + dataNode.put("baz", 10); + + ResponseBody responseBody = new ResponseBody(event, context, ResponseStatus.FAILED, null, true); + String actualJson = responseBody.toObjectNode(dataNode).toString(); + + String expectedJson = "{" + + "\"Status\":\"FAILED\"," + + "\"Reason\":\"See the details in CloudWatch Log Stream: null\"," + + "\"PhysicalResourceId\":null," + + "\"StackId\":null," + + "\"RequestId\":null," + + "\"LogicalResourceId\":null," + + "\"NoEcho\":true," + + "\"Data\":{\"foo\":\"bar\",\"baz\":10}" + + "}"; + assertThat(actualJson).isEqualTo(expectedJson); + } + @Test void defaultStatusIsSuccess() { CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); Context context = mock(Context.class); ResponseBody body = new ResponseBody( - event, context, null, null, null, false); + event, context, null, null, false); assertThat(body.getStatus()).isEqualTo("SUCCESS"); } @@ -151,7 +191,7 @@ void customStatus() { Context context = mock(Context.class); ResponseBody body = new ResponseBody( - event, context, ResponseStatus.FAILED, null, null, false); + event, context, ResponseStatus.FAILED, null, false); assertThat(body.getStatus()).isEqualTo("FAILED"); } @@ -164,12 +204,12 @@ void reasonIncludesLogStreamName() { when(context.getLogStreamName()).thenReturn(logStreamName); ResponseBody body = new ResponseBody( - event, context, ResponseStatus.SUCCESS, null, null, false); + event, context, ResponseStatus.SUCCESS, null, false); assertThat(body.getReason()).contains(logStreamName); } @Test - public void sendWithNoResponseData() throws IOException { + public void sendWithNoResponseData() throws Exception { CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); Context context = mock(Context.class); CloudFormationResponse cfnResponse = mockCloudFormationResponse(); @@ -191,15 +231,16 @@ public void sendWithNoResponseData() throws IOException { } @Test - public void sendWithNonNullResponseData() throws IOException { + public void sendWithNonNullResponseData() throws Exception { CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); Context context = mock(Context.class); CloudFormationResponse cfnResponse = mockCloudFormationResponse(); Map responseData = new LinkedHashMap<>(); responseData.put("Property", "Value"); + Response resp = Response.builder().value(responseData).build(); - HttpExecuteResponse response = cfnResponse.send(event, context, ResponseStatus.SUCCESS, responseData); + HttpExecuteResponse response = cfnResponse.send(event, context, ResponseStatus.SUCCESS, resp); String actualJson = responseAsString(response); String 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 new file mode 100644 index 000000000..31a48fd22 --- /dev/null +++ b/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/ResponseTest.java @@ -0,0 +1,100 @@ +package software.amazon.lambda.powertools.cloudformation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class ResponseTest { + + static class DummyBean { + private final Object propertyWithLongName; + + DummyBean(Object propertyWithLongName) { + this.propertyWithLongName = propertyWithLongName; + } + + @SuppressWarnings("unused") + public Object getPropertyWithLongName() { + return propertyWithLongName; + } + } + + @Test + void buildWithNoValueFails() { + assertThatThrownBy(() -> Response.builder().build()) + .isInstanceOf(NullPointerException.class); + } + + @Test + void buildWithNullValueFails() { + assertThatThrownBy(() -> Response.builder().value(null).build()) + .isInstanceOf(NullPointerException.class); + } + + @Test + void buildWithNoObjectMapperSucceeds() { + Response response = Response.builder() + .value("test") + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getJsonNode()).isNotNull(); + } + + @Test + void buildWithNullObjectMapperSucceeds() { + Response response = Response.builder() + .value(100) + .objectMapper(null) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getJsonNode()).isNotNull(); + } + + @Test + void jsonToStringWithDefaultMapperMapValue() { + Map value = new HashMap<>(); + value.put("foo", "bar"); + + Response response = Response.builder() + .value(value) + .build(); + + String expected = "{\"foo\":\"bar\"}"; + assertThat(response.toString()).isEqualTo(expected); + } + + @Test + void jsonToStringWithDefaultMapperObjectValue() { + DummyBean value = new DummyBean("test"); + + Response response = Response.builder() + .value(value) + .build(); + + String expected = "{\"PropertyWithLongName\":\"test\"}"; + assertThat(response.toString()).isEqualTo(expected); + } + + @Test + void jsonToStringWithCustomMapper() { + ObjectMapper customMapper = new ObjectMapper() + .setPropertyNamingStrategy(PropertyNamingStrategies.KEBAB_CASE); + + DummyBean value = new DummyBean(10); + Response response = Response.builder() + .objectMapper(customMapper) + .value(value) + .build(); + + String expected = "{\"property-with-long-name\":10}"; + assertThat(response.toString()).isEqualTo(expected); + } +}