From 2f1636aaae8fc4a319db7acd22441c255039707e Mon Sep 17 00:00:00 2001 From: Joe Wolf Date: Tue, 28 Sep 2021 09:05:03 -0400 Subject: [PATCH 1/7] Create Java cfn-response equivalent (#558) Introduces Java implementation of cfn-response module based on NodeJS design. Put the sole class in a separate powertools-cloudformation Maven module. 100% code coverage Addresses #558 --- pom.xml | 6 + powertools-cloudformation/pom.xml | 89 +++++++ .../CloudFormationResponse.java | 249 ++++++++++++++++++ .../CloudFormationResponseTest.java | 217 +++++++++++++++ 4 files changed, 561 insertions(+) create mode 100644 powertools-cloudformation/pom.xml create mode 100644 powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponse.java create mode 100644 powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponseTest.java diff --git a/pom.xml b/pom.xml index 5bf4117e5..36b196aef 100644 --- a/pom.xml +++ b/pom.xml @@ -35,6 +35,7 @@ powertools-parameters powertools-validation powertools-test-suite + powertools-cloudformation @@ -121,6 +122,11 @@ pom import + + software.amazon.awssdk + http-client-spi + ${aws.sdk.version} + io.burt jmespath-jackson diff --git a/powertools-cloudformation/pom.xml b/powertools-cloudformation/pom.xml new file mode 100644 index 000000000..3ab5a4c19 --- /dev/null +++ b/powertools-cloudformation/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + + powertools-cloudformation + jar + + + powertools-parent + software.amazon.lambda + 1.7.3 + + + AWS Lambda Powertools Java library Cloudformation + + A suite of utilities for AWS Lambda Functions that makes tracing with AWS X-Ray, structured logging and creating + custom metrics asynchronously easier. + + https://aws.amazon.com/lambda/ + + GitHub Issues + https://github.com/awslabs/aws-lambda-powertools-java/issues + + + https://github.com/awslabs/aws-lambda-powertools-java.git + + + + AWS Lambda Powertools team + Amazon Web Services + https://aws.amazon.com/ + + + + + + ossrh + https://aws.oss.sonatype.org/content/repositories/snapshots + + + + + + software.amazon.awssdk + http-client-spi + + + com.amazonaws + aws-lambda-java-core + + + com.amazonaws + aws-lambda-java-events + + + com.fasterxml.jackson.core + jackson-databind + + + org.aspectj + aspectjrt + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.mockito + mockito-core + test + + + org.assertj + assertj-core + test + + + + \ No newline at end of file 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 new file mode 100644 index 000000000..f87ed9623 --- /dev/null +++ b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponse.java @@ -0,0 +1,249 @@ +package software.amazon.lambda.powertools.cloudformation; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.events.CloudFormationCustomResourceEvent; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import software.amazon.awssdk.http.Header; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.utils.StringInputStream; + +import java.io.IOException; +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 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 + */ +public class CloudFormationResponse { + + /** + * Indicates if the function invoking send had successfully completed. + */ + public enum ResponseStatus { + SUCCESS, FAILED + } + + /** + * Representation of the payload to be sent to the event target URL. + */ + @SuppressWarnings("unused") + static class ResponseBody { + private final String status; + private final String reason; + private final String physicalResourceId; + private final String stackId; + 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"); + 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(); + this.stackId = event.getStackId(); + this.requestId = event.getRequestId(); + this.logicalResourceId = event.getLogicalResourceId(); + this.noEcho = noEcho; + this.data = responseData; + } + + public String getStatus() { + return status; + } + + public String getReason() { + return reason; + } + + public String getPhysicalResourceId() { + return physicalResourceId; + } + + public String getStackId() { + return stackId; + } + + public String getRequestId() { + return requestId; + } + + public String getLogicalResourceId() { + return logicalResourceId; + } + + 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."); + } + 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. + * + * @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"); + } + + /** + * Forwards a request containing a custom payload to the target resource specified by the event. The payload is + * formed from the event and context data. + * + * @param event custom CF resource 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 + * @return the response object + * @throws IOException when unable to generate or send the request + */ + public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, + Context context, + ResponseStatus status) throws IOException { + return send(event, context, status, null); + } + + /** + * Forwards a request containing a custom payload to the target resource specified by the event. The payload is + * formed from the event, status, and context data. + * + * @param event custom CF resource 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. + * @return the response object + * @throws IOException when unable to generate or send the request + */ + public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, + Context context, + ResponseStatus status, + Object responseData) throws IOException { + return send(event, context, status, responseData, null); + } + + /** + * Forwards a request containing a custom payload to the target resource specified by the event. The payload is + * formed from the event, status, response, and context data. + * + * @param event custom CF resource 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 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 + */ + public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, + Context context, + ResponseStatus status, + Object responseData, + String physicalResourceId) throws IOException { + return send(event, context, status, responseData, physicalResourceId, false); + } + + /** + * Forwards a request containing a custom payload to the target resource specified by the event. The payload is + * formed from the event, status, response, and context data. + * + * @param event custom CF resource 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 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. + * @param noEcho Optional. Indicates whether to mask the output of the custom resource when it's + * retrieved by using the Fn::GetAtt function. If set to true, all returned values are + * 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 + */ + public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, + Context context, + ResponseStatus status, + Object responseData, + String physicalResourceId, + boolean noEcho) throws IOException { + ResponseBody body = new ResponseBody(event, context, status, responseData, physicalResourceId, noEcho); + URI uri = URI.create(event.getResponseUrl()); + + // no need to explicitly close in-memory stream + StringInputStream stream = new StringInputStream(mapper.writeValueAsString(body)); + SdkHttpRequest request = SdkHttpRequest.builder() + .uri(uri) + .method(SdkHttpMethod.PUT) + .headers(headers(stream.available())) + .build(); + HttpExecuteRequest httpExecuteRequest = HttpExecuteRequest.builder() + .request(request) + .contentStreamProvider(() -> stream) + .build(); + return client.prepareRequest(httpExecuteRequest).call(); + } + + /** + * Generates HTTP headers to be supplied in the CloudFormation request. + * + * @param contentLength the length of the payload + * @return HTTP headers + */ + protected Map> headers(int contentLength) { + Map> headers = new HashMap<>(); + headers.put(Header.CONTENT_TYPE, Collections.emptyList()); // intentionally empty + headers.put(Header.CONTENT_LENGTH, Collections.singletonList(Integer.toString(contentLength))); + return headers; + } +} 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 new file mode 100644 index 000000000..c01a95b8a --- /dev/null +++ b/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponseTest.java @@ -0,0 +1,217 @@ +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.AbortableInputStream; +import software.amazon.awssdk.http.ExecutableHttpRequest; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.utils.IoUtils; +import software.amazon.lambda.powertools.cloudformation.CloudFormationResponse.ResponseBody; +import software.amazon.lambda.powertools.cloudformation.CloudFormationResponse.ResponseStatus; + +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class CloudFormationResponseTest { + + /** + * Creates a mock CloudFormationCustomResourceEvent with a non-null response URL. + */ + static CloudFormationCustomResourceEvent mockCloudFormationCustomResourceEvent() { + CloudFormationCustomResourceEvent event = mock(CloudFormationCustomResourceEvent.class); + when(event.getResponseUrl()).thenReturn("https://aws.amazon.com"); + return event; + } + + /** + * Creates a mock CloudFormationResponse whose response body is the request body. + */ + static CloudFormationResponse mockCloudFormationResponse() { + SdkHttpClient client = mock(SdkHttpClient.class); + ExecutableHttpRequest executableRequest = mock(ExecutableHttpRequest.class); + + when(client.prepareRequest(any(HttpExecuteRequest.class))).thenAnswer(args -> { + HttpExecuteRequest request = args.getArgument(0, HttpExecuteRequest.class); + assertThat(request.contentStreamProvider()).isPresent(); + + InputStream inputStream = request.contentStreamProvider().get().newStream(); + HttpExecuteResponse response = mock(HttpExecuteResponse.class); + when(response.responseBody()).thenReturn(Optional.of(AbortableInputStream.create(inputStream))); + when(executableRequest.call()).thenReturn(response); + return executableRequest; + }); + + return new CloudFormationResponse(client); + } + + static String responseAsString(HttpExecuteResponse response) throws IOException { + assertThat(response.responseBody()).isPresent(); + InputStream bodyStream = response.responseBody().orElse(null); + return bodyStream == null ? null : IoUtils.toUtf8String(bodyStream); + } + + @Test + void clientRequiredToCreateInstance() { + assertThatThrownBy(() -> new CloudFormationResponse(null)) + .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); + CloudFormationResponse response = new CloudFormationResponse(client); + + Context context = mock(Context.class); + assertThatThrownBy(() -> response.send(null, context, ResponseStatus.SUCCESS)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void contextRequiredToSend() { + SdkHttpClient client = mock(SdkHttpClient.class); + CloudFormationResponse response = new CloudFormationResponse(client); + + Context context = mock(Context.class); + assertThatThrownBy(() -> response.send(null, context, ResponseStatus.SUCCESS)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void eventResponseUrlRequiredToSend() { + SdkHttpClient client = mock(SdkHttpClient.class); + CloudFormationResponse response = new CloudFormationResponse(client); + + CloudFormationCustomResourceEvent event = mock(CloudFormationCustomResourceEvent.class); + Context context = mock(Context.class); + assertThatThrownBy(() -> response.send(event, context, ResponseStatus.SUCCESS)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void defaultPhysicalResponseIdIsLogStreamName() { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + when(event.getPhysicalResourceId()).thenReturn("This-Is-Ignored"); + + String logStreamName = "My-Log-Stream-Name"; + Context context = mock(Context.class); + when(context.getLogStreamName()).thenReturn(logStreamName); + + ResponseBody body = new ResponseBody( + event, context, ResponseStatus.SUCCESS, null, null, false); + assertThat(body.getPhysicalResourceId()).isEqualTo(logStreamName); + } + + @Test + void customPhysicalResponseId() { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + when(event.getPhysicalResourceId()).thenReturn("This-Is-Ignored"); + + Context context = mock(Context.class); + when(context.getLogStreamName()).thenReturn("My-Log-Stream-Name"); + + String customPhysicalResourceId = "Custom-Physical-Resource-ID"; + ResponseBody body = new ResponseBody( + event, context, ResponseStatus.SUCCESS, null, customPhysicalResourceId, false); + assertThat(body.getPhysicalResourceId()).isEqualTo(customPhysicalResourceId); + } + + @Test + void defaultStatusIsSuccess() { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + Context context = mock(Context.class); + + ResponseBody body = new ResponseBody( + event, context, null, null, null, false); + assertThat(body.getStatus()).isEqualTo("SUCCESS"); + } + + @Test + void customStatus() { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + Context context = mock(Context.class); + + ResponseBody body = new ResponseBody( + event, context, ResponseStatus.FAILED, null, null, false); + assertThat(body.getStatus()).isEqualTo("FAILED"); + } + + @Test + void reasonIncludesLogStreamName() { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + + String logStreamName = "My-Log-Stream-Name"; + Context context = mock(Context.class); + when(context.getLogStreamName()).thenReturn(logStreamName); + + ResponseBody body = new ResponseBody( + event, context, ResponseStatus.SUCCESS, null, null, false); + assertThat(body.getReason()).contains(logStreamName); + } + + @Test + public void sendWithNoResponseData() throws IOException { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + Context context = mock(Context.class); + CloudFormationResponse cfnResponse = mockCloudFormationResponse(); + + HttpExecuteResponse response = cfnResponse.send(event, context, ResponseStatus.SUCCESS); + + String actualJson = responseAsString(response); + String expectedJson = "{" + + "\"Status\":\"SUCCESS\"," + + "\"Reason\":\"See the details in CloudWatch Log Stream: null\"," + + "\"PhysicalResourceId\":null," + + "\"StackId\":null," + + "\"RequestId\":null," + + "\"LogicalResourceId\":null," + + "\"NoEcho\":false," + + "\"Data\":null" + + "}"; + assertThat(actualJson).isEqualTo(expectedJson); + } + + @Test + public void sendWithNonNullResponseData() throws IOException { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + Context context = mock(Context.class); + CloudFormationResponse cfnResponse = mockCloudFormationResponse(); + + Map responseData = new LinkedHashMap<>(); + responseData.put("Property", "Value"); + + HttpExecuteResponse response = cfnResponse.send(event, context, ResponseStatus.SUCCESS, responseData); + + String actualJson = responseAsString(response); + String expectedJson = "{" + + "\"Status\":\"SUCCESS\"," + + "\"Reason\":\"See the details in CloudWatch Log Stream: null\"," + + "\"PhysicalResourceId\":null," + + "\"StackId\":null," + + "\"RequestId\":null," + + "\"LogicalResourceId\":null," + + "\"NoEcho\":false," + + "\"Data\":{\"Property\":\"Value\"}" + + "}"; + assertThat(actualJson).isEqualTo(expectedJson); + } +} From 5e835aee2a048f37b8db6d211f2b9b65ee38f186 Mon Sep 17 00:00:00 2001 From: Joe Wolf Date: Mon, 4 Oct 2021 15:23:40 -0400 Subject: [PATCH 2/7] 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); + } +} From 1a3554b5e2d9444d3cc22fedb89b11cc43956d99 Mon Sep 17 00:00:00 2001 From: Joe Wolf Date: Mon, 11 Oct 2021 14:12:25 -0400 Subject: [PATCH 3/7] Put Response body attributes (status, noEcho, etc) in Response builder itself Instead of method args to various CloudFormationResponse::send methods, reducing the number of polymorphic send methods to two: one with a Response arg and one without. Rename ResponseException to CustomResourceResponseException. AbstractCustomResourceHandlerTest simplifications. --- powertools-cloudformation/pom.xml | 5 + .../AbstractCustomResourceHandler.java | 13 +- .../CloudFormationResponse.java | 128 ++++-------- .../CustomResourceResponseException.java | 13 ++ .../powertools/cloudformation/Response.java | 146 +++++++++++++- .../cloudformation/ResponseException.java | 13 -- .../AbstractCustomResourceHandlerTest.java | 185 +++++++++--------- .../CloudFormationResponseTest.java | 102 ++++++++-- .../cloudformation/ResponseTest.java | 87 +++++--- 9 files changed, 431 insertions(+), 261 deletions(-) create mode 100644 powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CustomResourceResponseException.java delete mode 100644 powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/ResponseException.java diff --git a/powertools-cloudformation/pom.xml b/powertools-cloudformation/pom.xml index 3ab5a4c19..7a58d08f3 100644 --- a/powertools-cloudformation/pom.xml +++ b/powertools-cloudformation/pom.xml @@ -74,6 +74,11 @@ junit-jupiter-engine test + + org.junit.jupiter + junit-jupiter-params + test + org.mockito mockito-core 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 index 7cae98ffe..0047c1d80 100644 --- 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 @@ -6,7 +6,6 @@ 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; @@ -51,18 +50,18 @@ public final Response handleRequest(CloudFormationCustomResourceEvent event, Con try { response = getResponse(event, context); LOG.debug("Preparing to send response {} to {}.", response, responseUrl); - client.send(event, context, ResponseStatus.SUCCESS, response); + client.send(event, context, response); } catch (IOException ioe) { LOG.error("Unable to send {} success to {}.", responseUrl, ioe); onSendFailure(event, context, response, ioe); - } catch (ResponseException rse) { + } catch (CustomResourceResponseException 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); + client.send(event, context, Response.failed()); } catch (Exception e) { // unable to serialize response AND send an empty response - LOG.error("Unable to send failure to {}.", responseUrl, e); + LOG.error("Unable to send empty failure to {}.", responseUrl, e); onSendFailure(event, context, null, e); } } @@ -70,7 +69,7 @@ public final Response handleRequest(CloudFormationCustomResourceEvent event, Con } private Response getResponse(CloudFormationCustomResourceEvent event, Context context) - throws ResponseException { + throws CustomResourceResponseException { try { switch (event.getRequestType()) { case "Create": @@ -84,7 +83,7 @@ private Response getResponse(CloudFormationCustomResourceEvent event, Context co return null; } } catch (RuntimeException e) { - throw new ResponseException("Unable to get Response", e); + throw new CustomResourceResponseException("Unable to get Response", e); } } 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 e65439671..860a628ef 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 @@ -32,13 +32,6 @@ */ public class CloudFormationResponse { - /** - * Indicates if the function invoking send had successfully completed. - */ - public enum ResponseStatus { - SUCCESS, FAILED - } - /** * 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 @@ -61,14 +54,14 @@ static class ResponseBody { ResponseBody(CloudFormationCustomResourceEvent event, Context context, - ResponseStatus responseStatus, + Response.Status responseStatus, String physicalResourceId, boolean noEcho) { 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(); + this.status = responseStatus == null ? Response.Status.SUCCESS.name() : responseStatus.name(); this.stackId = event.getStackId(); this.requestId = event.getRequestId(); this.logicalResourceId = event.getLogicalResourceId(); @@ -132,95 +125,38 @@ public CloudFormationResponse(SdkHttpClient client) { } /** - * Forwards a request containing a custom payload to the target resource specified by the event. The payload is - * formed from the event and context data. + * Forwards a response containing a custom payload to the target resource specified by the event. The payload is + * formed from the event and context data. Status is assumed to be SUCCESS. * - * @param event custom CF resource event + * @param event custom CF resource event. Cannot be null. * @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 + * access information from within the Lambda execution environment. Cannot be null. * @return the response object - * @throws IOException when unable to generate or send the request - * @throws ResponseException when unable to serialize the response payload + * @throws IOException when unable to send the request + * @throws CustomResourceResponseException when unable to synthesize or serialize the response payload */ public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, - Context context, - ResponseStatus status) throws IOException, ResponseException { - return send(event, context, status, null); + Context context) throws IOException, CustomResourceResponseException { + return send(event, context, null); } /** - * Forwards a request containing a custom payload to the target resource specified by the event. The payload is - * formed from the event, status, and context data. + * Forwards a response containing a custom payload to the target resource specified by the event. The payload is + * formed from the event, context, and response data. * - * @param event custom CF resource event + * @param event custom CF resource event. Cannot be null. * @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 to send, e.g. a list of name-value pairs. May be null. + * access information from within the Lambda execution environment. Cannot be null. + * @param responseData response to send, e.g. a list of name-value pairs. If null, an empty success is assumed. * @return the response object - * @throws IOException when unable to generate or send the request - * @throws ResponseException when unable to serialize the response payload + * @throws IOException when unable to generate or send the request + * @throws CustomResourceResponseException when unable to serialize the response payload */ public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, Context context, - ResponseStatus status, - Response responseData) throws IOException, ResponseException { - return send(event, context, status, responseData, null); - } - - /** - * Forwards a request containing a custom payload to the target resource specified by the event. The payload is - * formed from the event, status, response, and context data. - * - * @param event custom CF resource 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 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 send the request - * @throws ResponseException when unable to serialize the response payload - */ - public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, - Context context, - ResponseStatus status, - Response responseData, - String physicalResourceId) throws IOException, ResponseException { - return send(event, context, status, responseData, physicalResourceId, false); - } - - /** - * Forwards a request containing a custom payload to the target resource specified by the event. The payload is - * formed from the event, status, response, and context data. - * - * @param event custom CF resource 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 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. - * @param noEcho Optional. Indicates whether to mask the output of the custom resource when it's - * retrieved by using the Fn::GetAtt function. If set to true, all returned values are - * 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 send the request - * @throws ResponseException when unable to serialize the response payload - */ - public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, - Context context, - ResponseStatus status, - Response responseData, - String physicalResourceId, - boolean noEcho) throws IOException, ResponseException { + Response responseData) throws IOException, CustomResourceResponseException { // no need to explicitly close in-memory stream - StringInputStream stream = responseBodyStream(event, context, status, responseData, physicalResourceId, noEcho); + StringInputStream stream = responseBodyStream(event, context, responseData); URI uri = URI.create(event.getResponseUrl()); SdkHttpRequest request = SdkHttpRequest.builder() .uri(uri) @@ -250,20 +186,24 @@ protected Map> headers(int contentLength) { /** * 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 + * @throws CustomResourceResponseException if unable to generate the response stream */ - private StringInputStream responseBodyStream(CloudFormationCustomResourceEvent event, - Context context, - ResponseStatus status, - Response responseData, - String physicalResourceId, - boolean noEcho) throws ResponseException { + StringInputStream responseBodyStream(CloudFormationCustomResourceEvent event, + Context context, + Response resp) throws CustomResourceResponseException { try { - ResponseBody body = new ResponseBody(event, context, status, physicalResourceId, noEcho); - ObjectNode node = body.toObjectNode(responseData == null ? null : responseData.getJsonNode()); - return new StringInputStream(node.toString()); + if (resp == null) { + ResponseBody body = new ResponseBody(event, context, Response.Status.SUCCESS, null, false); + ObjectNode node = body.toObjectNode(null); + return new StringInputStream(node.toString()); + } else { + ResponseBody body = new ResponseBody( + event, context, resp.getStatus(), resp.getPhysicalResourceId(), resp.isNoEcho()); + ObjectNode node = body.toObjectNode(resp.getJsonNode()); + return new StringInputStream(node.toString()); + } } catch (RuntimeException e) { - throw new ResponseException("Unable to generate response body.", e); + throw new CustomResourceResponseException("Unable to generate response body.", e); } } } diff --git a/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CustomResourceResponseException.java b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CustomResourceResponseException.java new file mode 100644 index 000000000..ead912392 --- /dev/null +++ b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CustomResourceResponseException.java @@ -0,0 +1,13 @@ +package software.amazon.lambda.powertools.cloudformation; + +/** + * Indicates an error while generating or serializing a response to be sent to a custom resource. + */ +public class CustomResourceResponseException extends Exception { + + private static final long serialVersionUID = 20211004; + + protected CustomResourceResponseException(String message, Throwable cause) { + super(message, cause); + } +} 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 9f475451e..bff648b76 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 @@ -3,7 +3,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.Objects; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; /** * Models the arbitrary data to be sent to the custom resource in response to a CloudFormation event. This object @@ -11,20 +13,30 @@ */ public class Response { + /** + * Indicates whether this response should be sent to the custom resource as a success or failure. + */ + public enum Status { + SUCCESS, FAILED + } + /** * For building Response instances that wrap a single, arbitrary value. */ public static class Builder { private Object value; private ObjectMapper objectMapper; + private Status status; + private String physicalResourceId; + private boolean noEcho; private Builder() { } /** - * Configures a custom ObjectMapper for serializing the value object. + * Configures the value of this Response, typically a Map of name/value pairs. * - * @param value cannot be null + * @param value if null, the Response will be empty * @return a reference to this builder */ public Builder value(Object value) { @@ -43,16 +55,74 @@ public Builder objectMapper(ObjectMapper objectMapper) { return this; } + /** + * Configures the status of this response. + * + * @param status if null, SUCCESS will be assumed + * @return a reference to this builder + */ + public Builder status(Status status) { + this.status = status; + return this; + } + + /** + * A unique identifier for the custom resource being responded to. By default, the identifier is the name of the + * Amazon CloudWatch Logs log stream associated with the Lambda function. + * + * @param physicalResourceId if null, the default resource ID will be used + * @return a reference to this builder + */ + public Builder physicalResourceId(String physicalResourceId) { + this.physicalResourceId = physicalResourceId; + return this; + } + + /** + * Indicates whether to mask the output of the custom resource when it's retrieved by using the Fn::GetAtt + * function. If set to true, values will be masked with asterisks (*****), except for information stored in the + * these locations: + *

    + *
  • The Metadata template section. CloudFormation does not transform, modify, or redact any information + * included in the Metadata section. See + * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/metadata-section-structure.html
  • + *
  • The Outputs template section. See + * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/outputs-section-structure.html
  • + *
  • The Metadata attribute of a resource definition. See + * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-attribute-metadata.html
  • + *
+ *

+ * We strongly recommend not using these mechanisms to include sensitive information, such as passwords or + * secrets. + *

+ * For more information about using noEcho to mask sensitive information, see + * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/best-practices.html#creds + *

+ * By default, this value is false. + * + * @param noEcho when true, masks certain output + * @return a reference to this builder + */ + public Builder noEcho(boolean noEcho) { + this.noEcho = noEcho; + 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); + JsonNode node; + if (value == null) { + node = null; + } else { + ObjectMapper mapper = objectMapper != null ? objectMapper : CloudFormationResponse.ResponseBody.MAPPER; + node = mapper.valueToTree(value); + } + Status responseStatus = this.status != null ? this.status : Status.SUCCESS; + return new Response(node, responseStatus, physicalResourceId, noEcho); } } @@ -65,10 +135,34 @@ public static Builder builder() { return new Builder(); } + /** + * Creates an empty, failed Response. + * + * @return a failed Response with no value. + */ + public static Response failed() { + return new Response(null, Status.FAILED, null, false); + } + + /** + * Creates an empty, successful Response. + * + * @return a failed Response with no value. + */ + public static Response success() { + return new Response(null, Status.SUCCESS, null, false); + } + private final JsonNode jsonNode; + private final Status status; + private final String physicalResourceId; + private final boolean noEcho; - private Response(final JsonNode jsonNode) { + private Response(JsonNode jsonNode, Status status, String physicalResourceId, boolean noEcho) { this.jsonNode = jsonNode; + this.status = status; + this.physicalResourceId = physicalResourceId; + this.noEcho = noEcho; } /** @@ -80,6 +174,33 @@ JsonNode getJsonNode() { return jsonNode; } + /** + * The success/failed status of the response. + * + * @return a non-null Status + */ + public Status getStatus() { + return status; + } + + /** + * The physical resource ID of the custom resource. If null, the default resource ID will be used. + * + * @return a potentially null physical resource ID + */ + public String getPhysicalResourceId() { + return physicalResourceId; + } + + /** + * Whether to mask custom resource output (true) or not (false). + * + * @return true if custom resource output is to be masked, false otherwise + */ + public boolean isNoEcho() { + return noEcho; + } + /** * The Response JSON. * @@ -87,6 +208,13 @@ JsonNode getJsonNode() { */ @Override public String toString() { - return jsonNode.toString(); + Map attributes = new HashMap<>(); + attributes.put("JSON", jsonNode == null ? null : jsonNode.toString()); + attributes.put("Status", status); + attributes.put("PhysicalResourceId", physicalResourceId); + attributes.put("NoEcho", noEcho); + return attributes.entrySet().stream() + .map(entry -> entry.getKey() + " = " + entry.getValue()) + .collect(Collectors.joining(",", "[", "]")); } } 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 deleted file mode 100644 index 5bc41f6c3..000000000 --- a/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/ResponseException.java +++ /dev/null @@ -1,13 +0,0 @@ -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 index 23eecec58..9711788ab 100644 --- 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 @@ -3,25 +3,30 @@ import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.events.CloudFormationCustomResourceEvent; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import software.amazon.awssdk.http.SdkHttpClient; -import software.amazon.lambda.powertools.cloudformation.CloudFormationResponse.ResponseStatus; +import software.amazon.lambda.powertools.cloudformation.Response.Status; 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.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class AbstractCustomResourceHandlerTest { /** - * Uses a mocked CloudFormationResponse that will not send HTTP requests. + * Uses a mocked CloudFormationResponse to avoid sending actual HTTP requests. */ - static abstract class NoOpCustomResourceHandler extends AbstractCustomResourceHandler { + static class NoOpCustomResourceHandler extends AbstractCustomResourceHandler { NoOpCustomResourceHandler() { super(mock(SdkHttpClient.class)); @@ -31,63 +36,20 @@ static abstract class NoOpCustomResourceHandler extends AbstractCustomResourceHa 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); + return null; } @Override protected Response update(CloudFormationCustomResourceEvent event, Context context) { - return delegate(event, "Update", updateInvocations); + return null; } @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(); + return null; } } @@ -95,10 +57,10 @@ protected void onSendFailure(CloudFormationCustomResourceEvent event, * 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; + static class ExpectedStatusResourceHandler extends NoOpCustomResourceHandler { + private final Status expectedStatus; - ExpectedStatusResourceHandler(ResponseStatus expectedStatus) { + ExpectedStatusResourceHandler(Status expectedStatus) { this.expectedStatus = expectedStatus; } @@ -107,9 +69,9 @@ 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) { + when(cfnResponse.send(any(), any(), argThat(resp -> resp.getStatus() != expectedStatus))) + .thenThrow(new AssertionError("Expected response's status to be " + expectedStatus)); + } catch (IOException | CustomResourceResponseException e) { // this should never happen throw new RuntimeException("Unexpected mocking exception", e); } @@ -120,17 +82,16 @@ protected CloudFormationResponse buildResponseClient() { /** * Always fails to send the response */ - static class FailToSendResponseHandler extends InvocationCountingResourceHandler { + static class FailToSendResponseHandler extends NoOpCustomResourceHandler { @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())) + when(cfnResponse.send(any(), any())) .thenThrow(new IOException("Intentional send failure")); - when(cfnResponse.send(any(), any(), any(), any())) + when(cfnResponse.send(any(), any(), any())) .thenThrow(new IOException("Intentional send failure")); - } catch (IOException | ResponseException e) { + } catch (IOException | CustomResourceResponseException e) { // this should never happen throw new RuntimeException("Unexpected mocking exception", e); } @@ -148,55 +109,85 @@ static CloudFormationCustomResourceEvent eventOfType(String requestType) { return event; } - @Test - void eventsOfKnownRequestTypesDelegateProperly() { - InvocationCountingResourceHandler handler = new InvocationCountingResourceHandler(); + @ParameterizedTest + @CsvSource(value = {"Create,1,0,0", "Update,0,1,0", "Delete,0,0,1"}, delimiter = ',') + void eventsDelegateToCorrectHandlerMethod(String eventType, int createCount, int updateCount, int deleteCount) { + AbstractCustomResourceHandler handler = spy(new NoOpCustomResourceHandler()); - // 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)); + handler.handleRequest(eventOfType(eventType), context); - assertThat(handler.getCreateInvocationCount()).isEqualTo(3); - assertThat(handler.getUpdateInvocationCount()).isEqualTo(2); - assertThat(handler.getDeleteInvocationCount()).isEqualTo(1); + verify(handler, times(createCount)).create(any(), eq(context)); + verify(handler, times(updateCount)).update(any(), eq(context)); + verify(handler, times(deleteCount)).delete(any(), eq(context)); } @Test void eventOfUnknownRequestTypeSendEmptySuccess() { - InvocationCountingResourceHandler handler = new InvocationCountingResourceHandler(); + AbstractCustomResourceHandler handler = spy(new NoOpCustomResourceHandler()); 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); + verify(handler, times(0)).create(any(), any()); + verify(handler, times(0)).update(any(), any()); + verify(handler, times(0)).delete(any(), any()); } @Test - void nullResponseSendsSuccess() { - ExpectedStatusResourceHandler handler = new ExpectedStatusResourceHandler(ResponseStatus.SUCCESS); + void defaultStatusResponseSendsSuccess() { + ExpectedStatusResourceHandler handler = spy(new ExpectedStatusResourceHandler(Status.SUCCESS) { + @Override + protected Response create(CloudFormationCustomResourceEvent event, Context context) { + return Response.builder() + .value("whatever") + .build(); + } + }); Context context = mock(Context.class); - CloudFormationCustomResourceEvent event = eventOfType("Create"); // could be any valid requestType + CloudFormationCustomResourceEvent event = eventOfType("Create"); Response response = handler.handleRequest(event, context); - assertThat(response).isNull(); - assertThat(handler.getOnSendFailureInvocationCount()).isEqualTo(0); + assertThat(response).isNotNull(); + assertThat(response.getJsonNode().textValue()).isEqualTo("whatever"); + verify(handler, times(0)).onSendFailure(any(), any(), any(), any()); + } + + @Test + void explicitResponseWithStatusSuccessSendsSuccess() { + ExpectedStatusResourceHandler handler = spy(new ExpectedStatusResourceHandler(Status.SUCCESS) { + @Override + protected Response create(CloudFormationCustomResourceEvent event, Context context) { + return Response.builder() + .value("whatever") + .status(Status.SUCCESS) + .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"); + verify(handler, times(0)).onSendFailure(any(), any(), any(), any()); } @Test - void nonNullResponseSendsSuccess() { - ExpectedStatusResourceHandler handler = new ExpectedStatusResourceHandler(ResponseStatus.SUCCESS) { + void explicitResponseWithStatusFailedSendsFailure() { + ExpectedStatusResourceHandler handler = spy(new ExpectedStatusResourceHandler(Status.FAILED) { @Override protected Response create(CloudFormationCustomResourceEvent event, Context context) { - return Response.builder().value("whatever").build(); + return Response.builder() + .value("whatever") + .status(Status.FAILED) + .build(); } - }; + }); Context context = mock(Context.class); CloudFormationCustomResourceEvent event = eventOfType("Create"); @@ -204,17 +195,17 @@ protected Response create(CloudFormationCustomResourceEvent event, Context conte Response response = handler.handleRequest(event, context); assertThat(response).isNotNull(); assertThat(response.getJsonNode().textValue()).isEqualTo("whatever"); - assertThat(handler.getOnSendFailureInvocationCount()).isEqualTo(0); + verify(handler, times(0)).onSendFailure(any(), any(), any(), any()); } @Test void exceptionWhenGeneratingResponseSendsFailure() { - ExpectedStatusResourceHandler handler = new ExpectedStatusResourceHandler(ResponseStatus.FAILED) { + ExpectedStatusResourceHandler handler = spy(new ExpectedStatusResourceHandler(Status.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"); @@ -223,20 +214,18 @@ protected Response create(CloudFormationCustomResourceEvent event, Context conte 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); + verify(handler, times(0)).onSendFailure(any(), any(), any(), any()); } @Test void exceptionWhenSendingResponseInvokesOnSendFailure() { // a custom handler that builds response successfully but fails to send it - FailToSendResponseHandler handler = new FailToSendResponseHandler() { + FailToSendResponseHandler handler = spy(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"); @@ -244,24 +233,26 @@ protected Response create(CloudFormationCustomResourceEvent event, Context conte Response response = handler.handleRequest(event, context); assertThat(response).isNotNull(); assertThat(response.getJsonNode().textValue()).isEqualTo("Failure happens on send"); - assertThat(handler.getOnSendFailureInvocationCount()).isEqualTo(1); + verify(handler, times(1)) + .onSendFailure(eq(event), eq(context), eq(response), any(IOException.class)); } @Test void bothResponseGenerationAndSendFail() { // a custom handler that fails to build response _and_ fails to send a FAILED response - FailToSendResponseHandler handler = new FailToSendResponseHandler() { + FailToSendResponseHandler handler = spy(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); + verify(handler, times(1)) + .onSendFailure(eq(event), eq(context), isNull(), any(IOException.class)); } } 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 3c65cdf2d..207eb9b7f 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 @@ -10,8 +10,8 @@ import software.amazon.awssdk.http.HttpExecuteResponse; import software.amazon.awssdk.http.SdkHttpClient; import software.amazon.awssdk.utils.IoUtils; +import software.amazon.awssdk.utils.StringInputStream; import software.amazon.lambda.powertools.cloudformation.CloudFormationResponse.ResponseBody; -import software.amazon.lambda.powertools.cloudformation.CloudFormationResponse.ResponseStatus; import java.io.IOException; import java.io.InputStream; @@ -37,9 +37,10 @@ static CloudFormationCustomResourceEvent mockCloudFormationCustomResourceEvent() } /** - * Creates a mock CloudFormationResponse whose response body is the request body. + * Creates a CloudFormationResponse that does not make actual HTTP requests. The HTTP response body is the request + * body. */ - static CloudFormationResponse mockCloudFormationResponse() { + static CloudFormationResponse testableCloudFormationResponse() { SdkHttpClient client = mock(SdkHttpClient.class); ExecutableHttpRequest executableRequest = mock(ExecutableHttpRequest.class); @@ -75,8 +76,8 @@ void eventRequiredToSend() { CloudFormationResponse response = new CloudFormationResponse(client); Context context = mock(Context.class); - assertThatThrownBy(() -> response.send(null, context, ResponseStatus.SUCCESS)) - .isInstanceOf(ResponseException.class); + assertThatThrownBy(() -> response.send(null, context)) + .isInstanceOf(CustomResourceResponseException.class); } @Test @@ -85,8 +86,8 @@ void contextRequiredToSend() { CloudFormationResponse response = new CloudFormationResponse(client); Context context = mock(Context.class); - assertThatThrownBy(() -> response.send(null, context, ResponseStatus.SUCCESS)) - .isInstanceOf(ResponseException.class); + assertThatThrownBy(() -> response.send(null, context)) + .isInstanceOf(CustomResourceResponseException.class); } @Test @@ -96,9 +97,9 @@ 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 + // not a CustomResourceResponseException 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)) + assertThatThrownBy(() -> response.send(event, context)) .isInstanceOf(RuntimeException.class); } @@ -112,7 +113,7 @@ void defaultPhysicalResponseIdIsLogStreamName() { when(context.getLogStreamName()).thenReturn(logStreamName); ResponseBody body = new ResponseBody( - event, context, ResponseStatus.SUCCESS, null, false); + event, context, Response.Status.SUCCESS, null, false); assertThat(body.getPhysicalResourceId()).isEqualTo(logStreamName); } @@ -126,7 +127,7 @@ void customPhysicalResponseId() { String customPhysicalResourceId = "Custom-Physical-Resource-ID"; ResponseBody body = new ResponseBody( - event, context, ResponseStatus.SUCCESS, customPhysicalResourceId, false); + event, context, Response.Status.SUCCESS, customPhysicalResourceId, false); assertThat(body.getPhysicalResourceId()).isEqualTo(customPhysicalResourceId); } @@ -135,7 +136,7 @@ void responseBodyWithNullDataNode() { CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); Context context = mock(Context.class); - ResponseBody responseBody = new ResponseBody(event, context, ResponseStatus.FAILED, null, true); + ResponseBody responseBody = new ResponseBody(event, context, Response.Status.FAILED, null, true); String actualJson = responseBody.toObjectNode(null).toString(); String expectedJson = "{" + @@ -159,7 +160,7 @@ void responseBodyWithNonNullDataNode() { dataNode.put("foo", "bar"); dataNode.put("baz", 10); - ResponseBody responseBody = new ResponseBody(event, context, ResponseStatus.FAILED, null, true); + ResponseBody responseBody = new ResponseBody(event, context, Response.Status.FAILED, null, true); String actualJson = responseBody.toObjectNode(dataNode).toString(); String expectedJson = "{" + @@ -191,7 +192,7 @@ void customStatus() { Context context = mock(Context.class); ResponseBody body = new ResponseBody( - event, context, ResponseStatus.FAILED, null, false); + event, context, Response.Status.FAILED, null, false); assertThat(body.getStatus()).isEqualTo("FAILED"); } @@ -204,7 +205,7 @@ void reasonIncludesLogStreamName() { when(context.getLogStreamName()).thenReturn(logStreamName); ResponseBody body = new ResponseBody( - event, context, ResponseStatus.SUCCESS, null, false); + event, context, Response.Status.SUCCESS, null, false); assertThat(body.getReason()).contains(logStreamName); } @@ -212,9 +213,9 @@ void reasonIncludesLogStreamName() { public void sendWithNoResponseData() throws Exception { CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); Context context = mock(Context.class); - CloudFormationResponse cfnResponse = mockCloudFormationResponse(); + CloudFormationResponse cfnResponse = testableCloudFormationResponse(); - HttpExecuteResponse response = cfnResponse.send(event, context, ResponseStatus.SUCCESS); + HttpExecuteResponse response = cfnResponse.send(event, context); String actualJson = responseAsString(response); String expectedJson = "{" + @@ -234,13 +235,13 @@ public void sendWithNoResponseData() throws Exception { public void sendWithNonNullResponseData() throws Exception { CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); Context context = mock(Context.class); - CloudFormationResponse cfnResponse = mockCloudFormationResponse(); + CloudFormationResponse cfnResponse = testableCloudFormationResponse(); Map responseData = new LinkedHashMap<>(); responseData.put("Property", "Value"); Response resp = Response.builder().value(responseData).build(); - HttpExecuteResponse response = cfnResponse.send(event, context, ResponseStatus.SUCCESS, resp); + HttpExecuteResponse response = cfnResponse.send(event, context, resp); String actualJson = responseAsString(response); String expectedJson = "{" + @@ -255,4 +256,67 @@ public void sendWithNonNullResponseData() throws Exception { "}"; assertThat(actualJson).isEqualTo(expectedJson); } + + @Test + void responseBodyStreamNullResponseDefaultsToSuccessStatus() throws Exception { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + Context context = mock(Context.class); + CloudFormationResponse cfnResponse = testableCloudFormationResponse(); + + StringInputStream stream = cfnResponse.responseBodyStream(event, context, null); + + String expectedJson = "{" + + "\"Status\":\"SUCCESS\"," + + "\"Reason\":\"See the details in CloudWatch Log Stream: null\"," + + "\"PhysicalResourceId\":null," + + "\"StackId\":null," + + "\"RequestId\":null," + + "\"LogicalResourceId\":null," + + "\"NoEcho\":false," + + "\"Data\":null" + + "}"; + assertThat(stream.getString()).isEqualTo(expectedJson); + } + + @Test + void responseBodyStreamSuccessResponse() throws Exception { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + Context context = mock(Context.class); + CloudFormationResponse cfnResponse = testableCloudFormationResponse(); + + StringInputStream stream = cfnResponse.responseBodyStream(event, context, Response.success()); + + String expectedJson = "{" + + "\"Status\":\"SUCCESS\"," + + "\"Reason\":\"See the details in CloudWatch Log Stream: null\"," + + "\"PhysicalResourceId\":null," + + "\"StackId\":null," + + "\"RequestId\":null," + + "\"LogicalResourceId\":null," + + "\"NoEcho\":false," + + "\"Data\":null" + + "}"; + assertThat(stream.getString()).isEqualTo(expectedJson); + } + + @Test + void responseBodyStreamFailedResponse() throws Exception { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + Context context = mock(Context.class); + CloudFormationResponse cfnResponse = testableCloudFormationResponse(); + + StringInputStream stream = cfnResponse.responseBodyStream(event, context, Response.failed()); + + String expectedJson = "{" + + "\"Status\":\"FAILED\"," + + "\"Reason\":\"See the details in CloudWatch Log Stream: null\"," + + "\"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 31a48fd22..714d04948 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 @@ -8,7 +8,6 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; public class ResponseTest { @@ -26,40 +25,64 @@ public Object getPropertyWithLongName() { } @Test - void buildWithNoValueFails() { - assertThatThrownBy(() -> Response.builder().build()) - .isInstanceOf(NullPointerException.class); - } + void defaultValues() { + Response response = Response.builder().build(); - @Test - void buildWithNullValueFails() { - assertThatThrownBy(() -> Response.builder().value(null).build()) - .isInstanceOf(NullPointerException.class); + assertThat(response).isNotNull(); + assertThat(response.getJsonNode()).isNull(); + assertThat(response.getStatus()).isEqualTo(Response.Status.SUCCESS); + assertThat(response.getPhysicalResourceId()).isNull(); + assertThat(response.isNoEcho()).isFalse(); + + assertThat(response.toString()).contains("JSON = null"); + assertThat(response.toString()).contains("Status = SUCCESS"); + assertThat(response.toString()).contains("PhysicalResourceId = null"); + assertThat(response.toString()).contains("NoEcho = false"); } @Test - void buildWithNoObjectMapperSucceeds() { + void explicitNullValues() { Response response = Response.builder() - .value("test") + .value(null) + .objectMapper(null) + .physicalResourceId(null) + .status(null) .build(); assertThat(response).isNotNull(); - assertThat(response.getJsonNode()).isNotNull(); + assertThat(response.getJsonNode()).isNull(); + assertThat(response.getStatus()).isEqualTo(Response.Status.SUCCESS); + assertThat(response.getPhysicalResourceId()).isNull(); + assertThat(response.isNoEcho()).isFalse(); + + assertThat(response.toString()).contains("JSON = null"); + assertThat(response.toString()).contains("Status = SUCCESS"); + assertThat(response.toString()).contains("PhysicalResourceId = null"); + assertThat(response.toString()).contains("NoEcho = false"); } @Test - void buildWithNullObjectMapperSucceeds() { + void customNonJsonRelatedValues() { Response response = Response.builder() - .value(100) - .objectMapper(null) + .status(Response.Status.FAILED) + .physicalResourceId("test") + .noEcho(true) .build(); assertThat(response).isNotNull(); - assertThat(response.getJsonNode()).isNotNull(); + assertThat(response.getJsonNode()).isNull(); + assertThat(response.getStatus()).isEqualTo(Response.Status.FAILED); + assertThat(response.getPhysicalResourceId()).isEqualTo("test"); + assertThat(response.isNoEcho()).isTrue(); + + assertThat(response.toString()).contains("JSON = null"); + assertThat(response.toString()).contains("Status = FAILED"); + assertThat(response.toString()).contains("PhysicalResourceId = test"); + assertThat(response.toString()).contains("NoEcho = true"); } @Test - void jsonToStringWithDefaultMapperMapValue() { + void jsonMapValueWithDefaultObjectMapper() { Map value = new HashMap<>(); value.put("foo", "bar"); @@ -68,11 +91,13 @@ void jsonToStringWithDefaultMapperMapValue() { .build(); String expected = "{\"foo\":\"bar\"}"; - assertThat(response.toString()).isEqualTo(expected); + assertThat(response.getJsonNode()).isNotNull(); + assertThat(response.getJsonNode().toString()).isEqualTo(expected); + assertThat(response.toString()).contains("JSON = " + expected); } @Test - void jsonToStringWithDefaultMapperObjectValue() { + void jsonObjectValueWithDefaultObjectMapper() { DummyBean value = new DummyBean("test"); Response response = Response.builder() @@ -80,11 +105,12 @@ void jsonToStringWithDefaultMapperObjectValue() { .build(); String expected = "{\"PropertyWithLongName\":\"test\"}"; - assertThat(response.toString()).isEqualTo(expected); + assertThat(response.getJsonNode().toString()).isEqualTo(expected); + assertThat(response.toString()).contains("JSON = " + expected); } @Test - void jsonToStringWithCustomMapper() { + void jsonObjectValueWithCustomObjectMapper() { ObjectMapper customMapper = new ObjectMapper() .setPropertyNamingStrategy(PropertyNamingStrategies.KEBAB_CASE); @@ -95,6 +121,23 @@ void jsonToStringWithCustomMapper() { .build(); String expected = "{\"property-with-long-name\":10}"; - assertThat(response.toString()).isEqualTo(expected); + assertThat(response.getJsonNode().toString()).isEqualTo(expected); + assertThat(response.toString()).contains("JSON = " + expected); + } + + @Test + void successFactoryMethod() { + Response response = Response.success(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(Response.Status.SUCCESS); + } + + @Test + void failedFactoryMethod() { + Response response = Response.failed(); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(Response.Status.FAILED); } } From 4b9e7c0654dd3e992ee02ab67789d71b6fe5be45 Mon Sep 17 00:00:00 2001 From: Joe Wolf Date: Mon, 18 Oct 2021 08:26:28 -0400 Subject: [PATCH 4/7] Lock down CloudFormationResponse; default SdkHttpClient - Include powertools-cloudformation in .github config - Address mutatable ObjectMapper spotbugs finding - JavaDoc cleanup --- .github/workflows/build.yml | 2 + .github/workflows/spotbugs.yml | 1 + pom.xml | 5 ++ powertools-cloudformation/pom.xml | 4 ++ .../AbstractCustomResourceHandler.java | 21 +++++--- .../CloudFormationResponse.java | 13 ++++- .../powertools/cloudformation/Response.java | 20 ++++---- .../AbstractCustomResourceHandlerTest.java | 49 ++++++++++++++++--- .../cloudformation/ResponseTest.java | 33 +++++++++++++ 9 files changed, 122 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5505477f0..e59564080 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,7 @@ on: branches: - master paths: + - 'powertools-cloudformation/**' - 'powertools-core/**' - 'powertools-logging/**' - 'powertools-sqs/**' @@ -19,6 +20,7 @@ on: branches: - master paths: + - 'powertools-cloudformation/**' - 'powertools-core/**' - 'powertools-logging/**' - 'powertools-sqs/**' diff --git a/.github/workflows/spotbugs.yml b/.github/workflows/spotbugs.yml index be5f074fa..8976c5042 100644 --- a/.github/workflows/spotbugs.yml +++ b/.github/workflows/spotbugs.yml @@ -5,6 +5,7 @@ on: branches: - master paths: + - 'powertools-cloudformation/**' - 'powertools-core/**' - 'powertools-logging/**' - 'powertools-sqs/**' diff --git a/pom.xml b/pom.xml index 36b196aef..4144b38ef 100644 --- a/pom.xml +++ b/pom.xml @@ -127,6 +127,11 @@ http-client-spi ${aws.sdk.version} + + software.amazon.awssdk + url-connection-client + ${aws.sdk.version} + io.burt jmespath-jackson diff --git a/powertools-cloudformation/pom.xml b/powertools-cloudformation/pom.xml index 7a58d08f3..2258a402d 100644 --- a/powertools-cloudformation/pom.xml +++ b/powertools-cloudformation/pom.xml @@ -46,6 +46,10 @@ software.amazon.awssdk http-client-spi + + software.amazon.awssdk + url-connection-client + com.amazonaws aws-lambda-java-core 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 index 0047c1d80..05f1a0f27 100644 --- 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 @@ -6,6 +6,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; import java.io.IOException; import java.util.Objects; @@ -22,12 +23,19 @@ public abstract class AbstractCustomResourceHandler private final SdkHttpClient client; + /** + * Creates a new Handler that uses the default HTTP client for communicating with custom CloudFormation resources. + */ + protected AbstractCustomResourceHandler() { + this.client = UrlConnectionHttpClient.create(); + } + /** * 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) { + protected AbstractCustomResourceHandler(SdkHttpClient client) { this.client = Objects.requireNonNull(client, "SdkHttpClient cannot be null."); } @@ -52,16 +60,15 @@ public final Response handleRequest(CloudFormationCustomResourceEvent event, Con LOG.debug("Preparing to send response {} to {}.", response, responseUrl); client.send(event, context, response); } catch (IOException ioe) { - LOG.error("Unable to send {} success to {}.", responseUrl, ioe); + LOG.error("Unable to send response {} to {}.", response, responseUrl, ioe); onSendFailure(event, context, response, ioe); } catch (CustomResourceResponseException 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 + LOG.error("Unable to generate response. Sending empty failure to {}", responseUrl, rse); try { client.send(event, context, Response.failed()); } catch (Exception e) { - // unable to serialize response AND send an empty response - LOG.error("Unable to send empty failure to {}.", responseUrl, e); + // unable to generate response AND send the failure + LOG.error("Unable to send failure response to {}.", responseUrl, e); onSendFailure(event, context, null, e); } } @@ -92,7 +99,7 @@ private Response getResponse(CloudFormationCustomResourceEvent event, Context co * * @return a client for sending the response */ - protected CloudFormationResponse buildResponseClient() { + CloudFormationResponse buildResponseClient() { return new CloudFormationResponse(client); } 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 860a628ef..33cc533d2 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 @@ -30,7 +30,7 @@ *

* This class is thread-safe provided the SdkHttpClient instance used is also thread-safe. */ -public class CloudFormationResponse { +class CloudFormationResponse { /** * Internal representation of the payload to be sent to the event target URL. Retains all properties of the payload @@ -120,10 +120,19 @@ ObjectNode toObjectNode(JsonNode dataNode) { * * @param client HTTP client to use for sending requests; cannot be null */ - public CloudFormationResponse(SdkHttpClient client) { + CloudFormationResponse(SdkHttpClient client) { this.client = Objects.requireNonNull(client, "SdkHttpClient cannot be null"); } + /** + * The underlying SdkHttpClient used by this class. + * + * @return a non-null client + */ + SdkHttpClient getClient() { + return client; + } + /** * Forwards a response containing a custom payload to the target resource specified by the event. The payload is * formed from the event and context data. Status is assumed to be SUCCESS. 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 bff648b76..3ae6b9296 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 @@ -14,14 +14,14 @@ public class Response { /** - * Indicates whether this response should be sent to the custom resource as a success or failure. + * Indicates whether a response is a success or failure. */ public enum Status { SUCCESS, FAILED } /** - * For building Response instances that wrap a single, arbitrary value. + * For building Response instances. */ public static class Builder { private Object value; @@ -45,13 +45,15 @@ public Builder value(Object value) { } /** - * Configures a custom ObjectMapper for serializing the value object. + * Configures a custom ObjectMapper for serializing the value object. Creates a copy of the mapper provided; + * future mutations of the ObjectMapper made using the provided reference will not affect Response + * serialization. * * @param objectMapper if null, a default mapper will be used * @return a reference to this builder */ public Builder objectMapper(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; + this.objectMapper = objectMapper == null ? null : objectMapper.copy(); return this; } @@ -166,7 +168,7 @@ private Response(JsonNode jsonNode, Status status, String physicalResourceId, bo } /** - * Returns a JsonNode representation of the response. + * Returns a JsonNode representation of the Response. * * @return a non-null JsonNode representation */ @@ -175,7 +177,7 @@ JsonNode getJsonNode() { } /** - * The success/failed status of the response. + * The success/failed status of the Response. * * @return a non-null Status */ @@ -184,7 +186,7 @@ public Status getStatus() { } /** - * The physical resource ID of the custom resource. If null, the default resource ID will be used. + * The physical resource ID. If null, the default physical resource ID will be provided to the custom resource. * * @return a potentially null physical resource ID */ @@ -202,9 +204,9 @@ public boolean isNoEcho() { } /** - * The Response JSON. + * Includes all Response attributes, including its value in JSON format * - * @return a String in JSON format + * @return a full description of the Response */ @Override public String toString() { 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 index 9711788ab..d68b434d6 100644 --- 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 @@ -24,17 +24,14 @@ public class AbstractCustomResourceHandlerTest { /** - * Uses a mocked CloudFormationResponse to avoid sending actual HTTP requests. + * Bare-bones implementation that returns null for abstract methods. */ - static class NoOpCustomResourceHandler extends AbstractCustomResourceHandler { - - NoOpCustomResourceHandler() { - super(mock(SdkHttpClient.class)); + static class NullCustomResourceHandler extends AbstractCustomResourceHandler { + NullCustomResourceHandler() { } - @Override - protected CloudFormationResponse buildResponseClient() { - return mock(CloudFormationResponse.class); + NullCustomResourceHandler(SdkHttpClient client) { + super(client); } @Override @@ -53,6 +50,21 @@ protected Response delete(CloudFormationCustomResourceEvent event, Context conte } } + /** + * Uses a mocked CloudFormationResponse to avoid sending actual HTTP requests. + */ + static class NoOpCustomResourceHandler extends NullCustomResourceHandler { + + NoOpCustomResourceHandler() { + super(mock(SdkHttpClient.class)); + } + + @Override + protected CloudFormationResponse buildResponseClient() { + return mock(CloudFormationResponse.class); + } + } + /** * 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. @@ -109,6 +121,27 @@ static CloudFormationCustomResourceEvent eventOfType(String requestType) { return event; } + @Test + void defaultAndCustomSdkHttpClients() { + AbstractCustomResourceHandler defaultClientHandler = new NullCustomResourceHandler(); + + SdkHttpClient defaultClient = defaultClientHandler.buildResponseClient().getClient(); + assertThat(defaultClient).isNotNull(); + + String customClientName = "mockCustomClient"; + SdkHttpClient customClientArg = mock(SdkHttpClient.class); + when(customClientArg.clientName()).thenReturn(customClientName); + AbstractCustomResourceHandler customClientHandler = new NullCustomResourceHandler(customClientArg); + + SdkHttpClient customClient = customClientHandler.buildResponseClient().getClient(); + assertThat(customClient).isNotNull(); + assertThat(customClient.clientName()) + .isEqualTo(customClientName); + + assertThat(customClient.clientName()) + .isNotEqualTo(defaultClient.clientName()); + } + @ParameterizedTest @CsvSource(value = {"Create,1,0,0", "Update,0,1,0", "Delete,0,0,1"}, delimiter = ',') void eventsDelegateToCorrectHandlerMethod(String eventType, int createCount, int updateCount, int deleteCount) { 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 714d04948..e97a1a5ba 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 @@ -109,6 +109,20 @@ void jsonObjectValueWithDefaultObjectMapper() { assertThat(response.toString()).contains("JSON = " + expected); } + @Test + void jsonObjectValueWithNullObjectMapper() { + DummyBean value = new DummyBean("test"); + + Response response = Response.builder() + .objectMapper(null) + .value(value) + .build(); + + String expected = "{\"PropertyWithLongName\":\"test\"}"; + assertThat(response.getJsonNode().toString()).isEqualTo(expected); + assertThat(response.toString()).contains("JSON = " + expected); + } + @Test void jsonObjectValueWithCustomObjectMapper() { ObjectMapper customMapper = new ObjectMapper() @@ -125,6 +139,25 @@ void jsonObjectValueWithCustomObjectMapper() { assertThat(response.toString()).contains("JSON = " + expected); } + @Test + void jsonObjectValueWithPostConfiguredObjectMapper() { + ObjectMapper customMapper = new ObjectMapper() + .setPropertyNamingStrategy(PropertyNamingStrategies.KEBAB_CASE); + + DummyBean value = new DummyBean(10); + Response response = Response.builder() + .objectMapper(customMapper) + .value(value) + .build(); + + // changing the mapper config should not affect serialization + customMapper.setPropertyNamingStrategy(PropertyNamingStrategies.UPPER_CAMEL_CASE); + + String expected = "{\"property-with-long-name\":10}"; + assertThat(response.getJsonNode().toString()).isEqualTo(expected); + assertThat(response.toString()).contains("JSON = " + expected); + } + @Test void successFactoryMethod() { Response response = Response.success(); From 043afc5018a1fa8f83eed329295aa27cd587148e Mon Sep 17 00:00:00 2001 From: Joe Wolf Date: Wed, 20 Oct 2021 10:02:26 -0400 Subject: [PATCH 5/7] Cloudformation module documentation --- docs/utilities/custom_resources.md | 179 +++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/utilities/custom_resources.md diff --git a/docs/utilities/custom_resources.md b/docs/utilities/custom_resources.md new file mode 100644 index 000000000..2dd060f63 --- /dev/null +++ b/docs/utilities/custom_resources.md @@ -0,0 +1,179 @@ +--- +title: Custom Resources description: Utility +--- + +[Custom resources](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources.html) +provide a way for [AWS lambdas]( +https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources-lambda.html) to execute +provisioning logic whenever CloudFormation stacks are created, updated, or deleted. The CloudFormation utility enables +developers to write these lambdas in Java. + +The utility provides a base `AbstractCustomResourceHandler` class that listens for [custom resource request events]( +https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html), constructs +[custom resource responses](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-responses.html), and +sends them to the custom resources. Subclasses implement the provisioning logic and configure certain properties of +these response objects. + +## Install + +To install this utility, add the following dependency to your project. + +=== "Maven" + + ```xml + + software.amazon.lambda + powertools-cloudformation + 1.7.3 + + ``` + +=== "Gradle" + + ```groovy + dependencies { + ... + implementation 'software.amazon.lambda:powertools-cloudformation:1.7.3' + aspectpath 'software.amazon.lambda:powertools-cloudformation:1.7.3' + } + ``` + +## Usage + +Create a new `AbstractCustomResourceHandler` subclass and implement the `create`, `update`, and `delete` methods with +provisioning logic in the appropriate methods(s). + +As an example, if a lambda only needs to provision something when a stack is created, put the provisioning logic +exclusively within the `create` method; the other methods can just return `null`. + +```java hl_lines="8 9 10 11" +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.events.CloudFormationCustomResourceEvent; +import software.amazon.lambda.powertools.cloudformation.AbstractCustomResourceHandler; +import software.amazon.lambda.powertools.cloudformation.Response; + +public class ProvisionOnCreateHandler extends AbstractCustomResourceHandler { + + @Override + protected Response create(CloudFormationCustomResourceEvent createEvent, Context context) { + doProvisioning(); + return Response.success(); + } + + @Override + protected Response update(CloudFormationCustomResourceEvent updateEvent, Context context) { + return null; + } + + @Override + protected Response delete(CloudFormationCustomResourceEvent deleteEvent, Context context) { + return null; + } +} +``` + +### Signaling Provisioning Failures + +If provisioning fails, the stack creation/modification/deletion as a whole can be failed by either throwing a +`RuntimeException` or by explicitly returning a `Response` with a failed status, e.g. `Response.failure()`. + +### Configuring Response Objects + +When provisioning results in data to be shared with other parts of the stack, include this data within the returned +`Response` instance. + +This lambda creates a [Chime AppInstance](https://docs.aws.amazon.com/chime/latest/dg/create-app-instance.html) and maps +the returned ARN to a "ChimeAppInstanceArn" attribute. + +```java hl_lines="11 12 13 14" +public class ChimeAppInstanceHandler extends AbstractCustomResourceHandler { + @Override + protected Response create(CloudFormationCustomResourceEvent createEvent, Context context) { + CreateAppInstanceRequest chimeRequest = CreateAppInstanceRequest.builder() + .name("my-app-name") + .build(); + CreateAppInstanceResponse chimeResponse = ChimeClient.builder() + .region("us-east-1") + .createAppInstance(chimeRequest); + + Map chimeAtts = Map.of("ChimeAppInstanceArn", chimeResponse.appInstanceArn()); + return Response.builder() + .value(chimeAtts) + .build(); + } +} +``` + +Assuming the method executes successfully, the handler will send a response to the custom resource with a payload that +looks something like the following: + +```json +{ + "Status": "SUCCESS", + "PhysicalResourceId": "2021/10/01/e3a37e552eff4718a5675c1e31f0649e", + "StackId": "arn:aws:cloudformation:us-east-1:123456789000:stack/Custom-stack/59e4d2d0-2fe2-10ec-b00e-124d7c1c5f15", + "RequestId": "7cae0346-0359-4dff-b80a-a82f247467b6", + "LogicalResourceId:": "ChimeTriggerResource", + "NoEcho": false, + "Data": { + "ChimeAppInstanceArn": "arn:aws:chime:us-east-1:123456789000:app-instance/150972c2-5490-49a9-8ba7-e7da4257c16a" + } +} +``` + +Once the custom resource receives this response, it's "ChimeAppInstanceArn" attribute is set and the +[Fn::GetAtt function]( +https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html) may be used to +retrieve the attribute value and make it available to other resources in the stack. + +#### Sensitive Response Data + +If any attributes are sensitive, enable the "noEcho" flag to mask the output of the custom resource when it's retrieved +with the Fn::GetAtt function. + +```java hl_lines="6" +public class SensitiveDataHandler extends AbstractResourceHandler { + @Override + protected Response create(CloudFormationCustomResourceEvent createEvent, Context context) { + return Response.builder() + .value(Map.of("SomeSecret", sensitiveValue)) + .noEcho(true) + .build(); + } +} +``` + +#### Customizing Serialization + +Although using a `Map` as the Response's value is the most straightforward way to provide attribute name/value pairs, +any arbitrary `java.lang.Object` may be used. By default, these objects are serialized with an internal Jackson +`ObjectMapper`. If the object requires special serialization logic, a custom `ObjectMapper` can be specified. + +```java hl_lines="21 22 23 24" +public class CustomSerializationHandler extends AbstractResourceHandler { + /** + * Type representing the custom response Data. + */ + static class Policy { + public ZonedDateTime getExpires() { + return ZonedDateTime.now().plusDays(10); + } + } + + /** + * Mapper for serializing Policy instances. + */ + private final ObjectMapper policyMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + @Override + protected Response create(CloudFormationCustomResourceEvent createEvent, Context context) { + Policy policy = new Policy(); + return Response.builder() + .value(policy) + .objectMapper(policyMapper) // customize serialization + .build(); + } +} +``` \ No newline at end of file From 3627d4d825d4424f74c1c8e00eb94bd7ccd23368 Mon Sep 17 00:00:00 2001 From: Joe Wolf Date: Wed, 20 Oct 2021 11:58:35 -0400 Subject: [PATCH 6/7] CloudFormation documentation tweaks --- docs/utilities/custom_resources.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/utilities/custom_resources.md b/docs/utilities/custom_resources.md index 2dd060f63..caf4c7abc 100644 --- a/docs/utilities/custom_resources.md +++ b/docs/utilities/custom_resources.md @@ -3,12 +3,12 @@ title: Custom Resources description: Utility --- [Custom resources](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources.html) -provide a way for [AWS lambdas]( +provide a way for [AWS Lambda functions]( https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources-lambda.html) to execute provisioning logic whenever CloudFormation stacks are created, updated, or deleted. The CloudFormation utility enables -developers to write these lambdas in Java. +developers to write these Lambda functions in Java. -The utility provides a base `AbstractCustomResourceHandler` class that listens for [custom resource request events]( +The utility provides a base `AbstractCustomResourceHandler` class which handles [custom resource request events]( https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html), constructs [custom resource responses](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-responses.html), and sends them to the custom resources. Subclasses implement the provisioning logic and configure certain properties of @@ -43,8 +43,8 @@ To install this utility, add the following dependency to your project. Create a new `AbstractCustomResourceHandler` subclass and implement the `create`, `update`, and `delete` methods with provisioning logic in the appropriate methods(s). -As an example, if a lambda only needs to provision something when a stack is created, put the provisioning logic -exclusively within the `create` method; the other methods can just return `null`. +As an example, if a Lambda function only needs to provision something when a stack is created, put the provisioning +logic exclusively within the `create` method; the other methods can just return `null`. ```java hl_lines="8 9 10 11" import com.amazonaws.services.lambda.runtime.Context; @@ -82,8 +82,8 @@ If provisioning fails, the stack creation/modification/deletion as a whole can b When provisioning results in data to be shared with other parts of the stack, include this data within the returned `Response` instance. -This lambda creates a [Chime AppInstance](https://docs.aws.amazon.com/chime/latest/dg/create-app-instance.html) and maps -the returned ARN to a "ChimeAppInstanceArn" attribute. +This Lambda function creates a [Chime AppInstance](https://docs.aws.amazon.com/chime/latest/dg/create-app-instance.html) +and maps the returned ARN to a "ChimeAppInstanceArn" attribute. ```java hl_lines="11 12 13 14" public class ChimeAppInstanceHandler extends AbstractCustomResourceHandler { @@ -104,8 +104,7 @@ public class ChimeAppInstanceHandler extends AbstractCustomResourceHandler { } ``` -Assuming the method executes successfully, the handler will send a response to the custom resource with a payload that -looks something like the following: +For the example above the following response payload will be sent. ```json { From 89686efc1a1b1a9c84d589f32ed44b9772499a8b Mon Sep 17 00:00:00 2001 From: Joe Wolf Date: Fri, 22 Oct 2021 14:34:59 -0400 Subject: [PATCH 7/7] Include custom resources doc in menu --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index c9ca4e86d..049977c45 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,7 @@ nav: - utilities/sqs_large_message_handling.md - utilities/batch.md - utilities/validation.md + - utilities/custom_resources.md theme: name: material