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/docs/utilities/custom_resources.md b/docs/utilities/custom_resources.md new file mode 100644 index 000000000..caf4c7abc --- /dev/null +++ b/docs/utilities/custom_resources.md @@ -0,0 +1,178 @@ +--- +title: Custom Resources description: Utility +--- + +[Custom resources](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources.html) +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 Lambda functions in Java. + +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 +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 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; +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 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 { + @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(); + } +} +``` + +For the example above the following response payload will be sent. + +```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 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 diff --git a/pom.xml b/pom.xml index 5bf4117e5..4144b38ef 100644 --- a/pom.xml +++ b/pom.xml @@ -35,6 +35,7 @@ powertools-parameters powertools-validation powertools-test-suite + powertools-cloudformation @@ -121,6 +122,16 @@ pom import + + software.amazon.awssdk + 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 new file mode 100644 index 000000000..2258a402d --- /dev/null +++ b/powertools-cloudformation/pom.xml @@ -0,0 +1,98 @@ + + + 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 + + + software.amazon.awssdk + url-connection-client + + + 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.junit.jupiter + junit-jupiter-params + 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/AbstractCustomResourceHandler.java b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/AbstractCustomResourceHandler.java new file mode 100644 index 000000000..05f1a0f27 --- /dev/null +++ b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/AbstractCustomResourceHandler.java @@ -0,0 +1,162 @@ +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.awssdk.http.urlconnection.UrlConnectionHttpClient; + +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 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 + */ + protected 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, response); + } catch (IOException ioe) { + LOG.error("Unable to send response {} to {}.", response, responseUrl, ioe); + onSendFailure(event, context, response, ioe); + } catch (CustomResourceResponseException rse) { + LOG.error("Unable to generate response. Sending empty failure to {}", responseUrl, rse); + try { + client.send(event, context, Response.failed()); + } catch (Exception e) { + // unable to generate response AND send the failure + LOG.error("Unable to send failure response to {}.", responseUrl, e); + onSendFailure(event, context, null, e); + } + } + return response; + } + + private Response getResponse(CloudFormationCustomResourceEvent event, Context context) + throws CustomResourceResponseException { + 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 CustomResourceResponseException("Unable to get Response", e); + } + } + + /** + * Builds a client for sending responses to the custom resource. + * + * @return a client for sending the response + */ + 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 new file mode 100644 index 000000000..33cc533d2 --- /dev/null +++ b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponse.java @@ -0,0 +1,218 @@ +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.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; +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; +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. + */ +class CloudFormationResponse { + + /** + * 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; + private final String stackId; + private final String requestId; + private final String logicalResourceId; + private final boolean noEcho; + + ResponseBody(CloudFormationCustomResourceEvent event, + Context context, + 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 ? Response.Status.SUCCESS.name() : responseStatus.name(); + this.stackId = event.getStackId(); + this.requestId = event.getRequestId(); + this.logicalResourceId = event.getLogicalResourceId(); + this.noEcho = noEcho; + } + + 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; + } + + /** + * 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; + } + } + + private final SdkHttpClient client; + + /** + * 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 + */ + 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. + * + * @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. Cannot be null. + * @return the response object + * @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) throws IOException, CustomResourceResponseException { + return send(event, context, null); + } + + /** + * 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. 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. 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 CustomResourceResponseException when unable to serialize the response payload + */ + public HttpExecuteResponse send(CloudFormationCustomResourceEvent event, + Context context, + Response responseData) throws IOException, CustomResourceResponseException { + // no need to explicitly close in-memory stream + StringInputStream stream = responseBodyStream(event, context, responseData); + URI uri = URI.create(event.getResponseUrl()); + 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; + } + + /** + * Returns the response body as an input stream, for supplying with the HTTP request to the custom resource. + * + * @throws CustomResourceResponseException if unable to generate the response stream + */ + StringInputStream responseBodyStream(CloudFormationCustomResourceEvent event, + Context context, + Response resp) throws CustomResourceResponseException { + try { + 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 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 new file mode 100644 index 000000000..3ae6b9296 --- /dev/null +++ b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/Response.java @@ -0,0 +1,222 @@ +package software.amazon.lambda.powertools.cloudformation; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +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 + * encapsulates the data and the means to serialize it. + */ +public class Response { + + /** + * Indicates whether a response is a success or failure. + */ + public enum Status { + SUCCESS, FAILED + } + + /** + * For building Response instances. + */ + public static class Builder { + private Object value; + private ObjectMapper objectMapper; + private Status status; + private String physicalResourceId; + private boolean noEcho; + + private Builder() { + } + + /** + * Configures the value of this Response, typically a Map of name/value pairs. + * + * @param value if null, the Response will be empty + * @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. 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 == null ? null : objectMapper.copy(); + 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() { + 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); + } + } + + /** + * Creates a builder for constructing a Response wrapping the provided value. + * + * @return a builder + */ + 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(JsonNode jsonNode, Status status, String physicalResourceId, boolean noEcho) { + this.jsonNode = jsonNode; + this.status = status; + this.physicalResourceId = physicalResourceId; + this.noEcho = noEcho; + } + + /** + * Returns a JsonNode representation of the Response. + * + * @return a non-null JsonNode representation + */ + 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. If null, the default physical resource ID will be provided to the custom resource. + * + * @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; + } + + /** + * Includes all Response attributes, including its value in JSON format + * + * @return a full description of the Response + */ + @Override + public String 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/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..d68b434d6 --- /dev/null +++ b/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/AbstractCustomResourceHandlerTest.java @@ -0,0 +1,291 @@ +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 org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.lambda.powertools.cloudformation.Response.Status; + +import java.io.IOException; + +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 { + + /** + * Bare-bones implementation that returns null for abstract methods. + */ + static class NullCustomResourceHandler extends AbstractCustomResourceHandler { + NullCustomResourceHandler() { + } + + NullCustomResourceHandler(SdkHttpClient client) { + super(client); + } + + @Override + protected Response create(CloudFormationCustomResourceEvent event, Context context) { + return null; + } + + @Override + protected Response update(CloudFormationCustomResourceEvent event, Context context) { + return null; + } + + @Override + protected Response delete(CloudFormationCustomResourceEvent event, Context context) { + return null; + } + } + + /** + * 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. + */ + static class ExpectedStatusResourceHandler extends NoOpCustomResourceHandler { + private final Status expectedStatus; + + ExpectedStatusResourceHandler(Status 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(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); + } + return cfnResponse; + } + } + + /** + * Always fails to send the response + */ + static class FailToSendResponseHandler extends NoOpCustomResourceHandler { + @Override + protected CloudFormationResponse buildResponseClient() { + CloudFormationResponse cfnResponse = mock(CloudFormationResponse.class); + try { + when(cfnResponse.send(any(), any())) + .thenThrow(new IOException("Intentional send failure")); + when(cfnResponse.send(any(), any(), any())) + .thenThrow(new IOException("Intentional send failure")); + } catch (IOException | CustomResourceResponseException 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 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) { + AbstractCustomResourceHandler handler = spy(new NoOpCustomResourceHandler()); + + Context context = mock(Context.class); + handler.handleRequest(eventOfType(eventType), context); + + 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() { + AbstractCustomResourceHandler handler = spy(new NoOpCustomResourceHandler()); + + Context context = mock(Context.class); + CloudFormationCustomResourceEvent event = eventOfType("UNKNOWN"); + + handler.handleRequest(event, context); + + verify(handler, times(0)).create(any(), any()); + verify(handler, times(0)).update(any(), any()); + verify(handler, times(0)).delete(any(), any()); + } + + @Test + 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"); + + 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 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 explicitResponseWithStatusFailedSendsFailure() { + ExpectedStatusResourceHandler handler = spy(new ExpectedStatusResourceHandler(Status.FAILED) { + @Override + protected Response create(CloudFormationCustomResourceEvent event, Context context) { + return Response.builder() + .value("whatever") + .status(Status.FAILED) + .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 exceptionWhenGeneratingResponseSendsFailure() { + 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"); + + Response response = handler.handleRequest(event, context); + assertThat(response) + .withFailMessage("The response failed to build, so it must be null.") + .isNull(); + 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 = 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"); + + Response response = handler.handleRequest(event, context); + assertThat(response).isNotNull(); + assertThat(response.getJsonNode().textValue()).isEqualTo("Failure happens on send"); + 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 = 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(); + 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 new file mode 100644 index 000000000..207eb9b7f --- /dev/null +++ b/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponseTest.java @@ -0,0 +1,322 @@ +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.node.ObjectNode; +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.awssdk.utils.StringInputStream; +import software.amazon.lambda.powertools.cloudformation.CloudFormationResponse.ResponseBody; + +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 CloudFormationResponse that does not make actual HTTP requests. The HTTP response body is the request + * body. + */ + static CloudFormationResponse testableCloudFormationResponse() { + 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 eventRequiredToSend() { + SdkHttpClient client = mock(SdkHttpClient.class); + CloudFormationResponse response = new CloudFormationResponse(client); + + Context context = mock(Context.class); + assertThatThrownBy(() -> response.send(null, context)) + .isInstanceOf(CustomResourceResponseException.class); + } + + @Test + void contextRequiredToSend() { + SdkHttpClient client = mock(SdkHttpClient.class); + CloudFormationResponse response = new CloudFormationResponse(client); + + Context context = mock(Context.class); + assertThatThrownBy(() -> response.send(null, context)) + .isInstanceOf(CustomResourceResponseException.class); + } + + @Test + void eventResponseUrlRequiredToSend() { + SdkHttpClient client = mock(SdkHttpClient.class); + CloudFormationResponse response = new CloudFormationResponse(client); + + CloudFormationCustomResourceEvent event = mock(CloudFormationCustomResourceEvent.class); + Context context = mock(Context.class); + // 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)) + .isInstanceOf(RuntimeException.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, Response.Status.SUCCESS, 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, Response.Status.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, Response.Status.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, Response.Status.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, false); + assertThat(body.getStatus()).isEqualTo("SUCCESS"); + } + + @Test + void customStatus() { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + Context context = mock(Context.class); + + ResponseBody body = new ResponseBody( + event, context, Response.Status.FAILED, 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, Response.Status.SUCCESS, null, false); + assertThat(body.getReason()).contains(logStreamName); + } + + @Test + public void sendWithNoResponseData() throws Exception { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + Context context = mock(Context.class); + CloudFormationResponse cfnResponse = testableCloudFormationResponse(); + + HttpExecuteResponse response = cfnResponse.send(event, context); + + 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 Exception { + CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent(); + Context context = mock(Context.class); + CloudFormationResponse cfnResponse = testableCloudFormationResponse(); + + Map responseData = new LinkedHashMap<>(); + responseData.put("Property", "Value"); + Response resp = Response.builder().value(responseData).build(); + + HttpExecuteResponse response = cfnResponse.send(event, context, resp); + + 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); + } + + @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 new file mode 100644 index 000000000..e97a1a5ba --- /dev/null +++ b/powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/ResponseTest.java @@ -0,0 +1,176 @@ +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; + +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 defaultValues() { + Response response = Response.builder().build(); + + assertThat(response).isNotNull(); + assertThat(response.getJsonNode()).isNull(); + assertThat(response.getStatus()).isEqualTo(Response.Status.SUCCESS); + assertThat(response.getPhysicalResourceId()).isNull(); + assertThat(response.isNoEcho()).isFalse(); + + assertThat(response.toString()).contains("JSON = null"); + assertThat(response.toString()).contains("Status = SUCCESS"); + assertThat(response.toString()).contains("PhysicalResourceId = null"); + assertThat(response.toString()).contains("NoEcho = false"); + } + + @Test + void explicitNullValues() { + Response response = Response.builder() + .value(null) + .objectMapper(null) + .physicalResourceId(null) + .status(null) + .build(); + + assertThat(response).isNotNull(); + assertThat(response.getJsonNode()).isNull(); + assertThat(response.getStatus()).isEqualTo(Response.Status.SUCCESS); + assertThat(response.getPhysicalResourceId()).isNull(); + assertThat(response.isNoEcho()).isFalse(); + + assertThat(response.toString()).contains("JSON = null"); + assertThat(response.toString()).contains("Status = SUCCESS"); + assertThat(response.toString()).contains("PhysicalResourceId = null"); + assertThat(response.toString()).contains("NoEcho = false"); + } + + @Test + void customNonJsonRelatedValues() { + Response response = Response.builder() + .status(Response.Status.FAILED) + .physicalResourceId("test") + .noEcho(true) + .build(); + + assertThat(response).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 jsonMapValueWithDefaultObjectMapper() { + Map value = new HashMap<>(); + value.put("foo", "bar"); + + Response response = Response.builder() + .value(value) + .build(); + + String expected = "{\"foo\":\"bar\"}"; + assertThat(response.getJsonNode()).isNotNull(); + assertThat(response.getJsonNode().toString()).isEqualTo(expected); + assertThat(response.toString()).contains("JSON = " + expected); + } + + @Test + void jsonObjectValueWithDefaultObjectMapper() { + DummyBean value = new DummyBean("test"); + + Response response = Response.builder() + .value(value) + .build(); + + String expected = "{\"PropertyWithLongName\":\"test\"}"; + assertThat(response.getJsonNode().toString()).isEqualTo(expected); + 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() + .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.getJsonNode().toString()).isEqualTo(expected); + 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(); + + 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); + } +}