From 2f1636aaae8fc4a319db7acd22441c255039707e Mon Sep 17 00:00:00 2001 From: Joe Wolf Date: Tue, 28 Sep 2021 09:05:03 -0400 Subject: [PATCH] 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); + } +}