Skip to content

Commit e08ac64

Browse files
committed
Add GraalVM profile to powertools-cloudformation. Make unit tests compatible with subclass mock maker and native mode.
1 parent 5647e0d commit e08ac64

21 files changed

+660
-362
lines changed

GraalVM.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,31 @@ java.lang.InternalError: com.oracle.svm.core.jdk.UnsupportedFeatureError: Defini
5656
```
5757
- This has been [fixed](https://github.com/apache/logging-log4j2/discussions/2364#discussioncomment-8950077) in Log4j 2.24.x. PT has been updated to use this version of Log4j
5858

59+
3. **Test Class Organization**
60+
- **Issue**: Anonymous inner classes and lambda expressions in Mockito matchers cause `NoSuchMethodError` in GraalVM native tests
61+
- **Solution**:
62+
- Extract static inner test classes to separate concrete classes in the same package as the class under test
63+
- Replace lambda expressions in `ArgumentMatcher` with concrete implementations
64+
- Use `mockito-subclass` dependency in GraalVM profiles
65+
- **Example**: Replace `argThat(resp -> resp.getStatus() != expectedStatus)` with:
66+
```java
67+
argThat(new ArgumentMatcher<Response>() {
68+
@Override
69+
public boolean matches(Response resp) {
70+
return resp != null && resp.getStatus() != expectedStatus;
71+
}
72+
})
73+
```
74+
75+
4. **Package Visibility Issues**
76+
- **Issue**: Test handler classes cannot access package-private methods when placed in subpackages
77+
- **Solution**: Place test handler classes in the same package as the class under test, not in subpackages like `handlers/`
78+
- **Example**: Use `software.amazon.lambda.powertools.cloudformation` instead of `software.amazon.lambda.powertools.cloudformation.handlers`
79+
80+
5. **Test Stubs Best Practice**
81+
- **Best Practice**: Avoid mocking where possible and use concrete test stubs provided by `powertools-common` package
82+
- **Solution**: Use `TestLambdaContext` and other test stubs from `powertools-common` test-jar instead of Mockito mocks
83+
- **Implementation**: Add `powertools-common` test-jar dependency and replace `mock(Context.class)` with `new TestLambdaContext()`
84+
5985
## Reference Implementation
6086
Working example is available in the [examples](examples/powertools-examples-core-utilities/sam-graalvm).

powertools-cloudformation/pom.xml

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,5 +95,92 @@
9595
<artifactId>wiremock</artifactId>
9696
<scope>test</scope>
9797
</dependency>
98+
<dependency>
99+
<groupId>software.amazon.lambda</groupId>
100+
<artifactId>powertools-common</artifactId>
101+
<version>${project.version}</version>
102+
<type>test-jar</type>
103+
<scope>test</scope>
104+
</dependency>
98105
</dependencies>
106+
107+
<profiles>
108+
<profile>
109+
<id>generate-graalvm-files</id>
110+
<dependencies>
111+
<dependency>
112+
<groupId>org.mockito</groupId>
113+
<artifactId>mockito-subclass</artifactId>
114+
<scope>test</scope>
115+
</dependency>
116+
</dependencies>
117+
<build>
118+
<plugins>
119+
<plugin>
120+
<groupId>org.apache.maven.plugins</groupId>
121+
<artifactId>maven-surefire-plugin</artifactId>
122+
<configuration>
123+
<argLine>
124+
-Dorg.graalvm.nativeimage.imagecode=agent
125+
-agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image/software.amazon.lambda/powertools-cloudformation,experimental-class-define-support
126+
--add-opens java.base/java.util=ALL-UNNAMED
127+
--add-opens java.base/java.lang=ALL-UNNAMED
128+
</argLine>
129+
</configuration>
130+
</plugin>
131+
</plugins>
132+
</build>
133+
</profile>
134+
<profile>
135+
<id>graalvm-native</id>
136+
<dependencies>
137+
<dependency>
138+
<groupId>org.mockito</groupId>
139+
<artifactId>mockito-subclass</artifactId>
140+
<scope>test</scope>
141+
</dependency>
142+
</dependencies>
143+
<build>
144+
<plugins>
145+
<plugin>
146+
<groupId>org.graalvm.buildtools</groupId>
147+
<artifactId>native-maven-plugin</artifactId>
148+
<version>0.11.0</version>
149+
<extensions>true</extensions>
150+
<executions>
151+
<execution>
152+
<id>test-native</id>
153+
<goals>
154+
<goal>test</goal>
155+
</goals>
156+
<phase>test</phase>
157+
</execution>
158+
</executions>
159+
<configuration>
160+
<imageName>powertools-cloudformation</imageName>
161+
<buildArgs>
162+
<buildArg>--add-opens java.base/java.util=ALL-UNNAMED</buildArg>
163+
<buildArg>--add-opens java.base/java.lang=ALL-UNNAMED</buildArg>
164+
<buildArg>--enable-url-protocols=http</buildArg>
165+
<buildArg>--no-fallback</buildArg>
166+
<buildArg>--verbose</buildArg>
167+
<buildArg>--native-image-info</buildArg>
168+
<buildArg>-H:+UnlockExperimentalVMOptions</buildArg>
169+
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
170+
</buildArgs>
171+
</configuration>
172+
</plugin>
173+
</plugins>
174+
</build>
175+
</profile>
176+
</profiles>
177+
178+
<build>
179+
<resources>
180+
<!-- GraalVM Native Image Configuration Files -->
181+
<resource>
182+
<directory>src/main/resources</directory>
183+
</resource>
184+
</resources>
185+
</build>
99186
</project>

powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponse.java

Lines changed: 74 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,24 @@
1414

1515
package software.amazon.lambda.powertools.cloudformation;
1616

17-
import com.amazonaws.services.lambda.runtime.Context;
18-
import com.amazonaws.services.lambda.runtime.events.CloudFormationCustomResourceEvent;
19-
import com.fasterxml.jackson.databind.JsonNode;
20-
import com.fasterxml.jackson.databind.ObjectMapper;
21-
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
22-
import com.fasterxml.jackson.databind.node.ObjectNode;
2317
import java.io.IOException;
2418
import java.net.URI;
2519
import java.util.Collections;
2620
import java.util.HashMap;
2721
import java.util.List;
2822
import java.util.Map;
2923
import java.util.Objects;
24+
3025
import org.slf4j.Logger;
3126
import org.slf4j.LoggerFactory;
27+
28+
import com.amazonaws.services.lambda.runtime.Context;
29+
import com.amazonaws.services.lambda.runtime.events.CloudFormationCustomResourceEvent;
30+
import com.fasterxml.jackson.databind.JsonNode;
31+
import com.fasterxml.jackson.databind.ObjectMapper;
32+
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
33+
import com.fasterxml.jackson.databind.node.ObjectNode;
34+
3235
import software.amazon.awssdk.http.Header;
3336
import software.amazon.awssdk.http.HttpExecuteRequest;
3437
import software.amazon.awssdk.http.HttpExecuteResponse;
@@ -39,20 +42,24 @@
3942
import software.amazon.awssdk.utils.StringUtils;
4043

4144
/**
42-
* Client for sending responses to AWS CloudFormation custom resources by way of a response URL, which is an Amazon S3
45+
* Client for sending responses to AWS CloudFormation custom resources by way of
46+
* a response URL, which is an Amazon S3
4347
* pre-signed URL.
4448
* <p>
45-
* See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html
49+
* See
50+
* https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html
4651
* <p>
47-
* This class is thread-safe provided the SdkHttpClient instance used is also thread-safe.
52+
* This class is thread-safe provided the SdkHttpClient instance used is also
53+
* thread-safe.
4854
*/
49-
class CloudFormationResponse {
55+
public class CloudFormationResponse {
5056

5157
private static final Logger LOG = LoggerFactory.getLogger(CloudFormationResponse.class);
5258
private final SdkHttpClient client;
5359

5460
/**
55-
* Creates a new CloudFormationResponse that uses the provided HTTP client and default JSON serialization format.
61+
* Creates a new CloudFormationResponse that uses the provided HTTP client and
62+
* default JSON serialization format.
5663
*
5764
* @param client HTTP client to use for sending requests; cannot be null
5865
*/
@@ -70,36 +77,46 @@ SdkHttpClient getClient() {
7077
}
7178

7279
/**
73-
* Forwards a response containing a custom payload to the target resource specified by the event. The payload is
80+
* Forwards a response containing a custom payload to the target resource
81+
* specified by the event. The payload is
7482
* formed from the event and context data. Status is assumed to be SUCCESS.
7583
*
7684
* @param event custom CF resource event. Cannot be null.
77-
* @param context used to specify when the function and any callbacks have completed execution, or to
78-
* access information from within the Lambda execution environment. Cannot be null.
85+
* @param context used to specify when the function and any callbacks have
86+
* completed execution, or to
87+
* access information from within the Lambda execution
88+
* environment. Cannot be null.
7989
* @return the response object
8090
* @throws IOException when unable to send the request
81-
* @throws CustomResourceResponseException when unable to synthesize or serialize the response payload
91+
* @throws CustomResourceResponseException when unable to synthesize or
92+
* serialize the response payload
8293
*/
8394
public HttpExecuteResponse send(CloudFormationCustomResourceEvent event,
84-
Context context) throws IOException, CustomResourceResponseException {
95+
Context context) throws IOException, CustomResourceResponseException {
8596
return send(event, context, null);
8697
}
8798

8899
/**
89-
* Forwards a response containing a custom payload to the target resource specified by the event. The payload is
100+
* Forwards a response containing a custom payload to the target resource
101+
* specified by the event. The payload is
90102
* formed from the event, context, and response data.
91103
*
92104
* @param event custom CF resource event. Cannot be null.
93-
* @param context used to specify when the function and any callbacks have completed execution, or to
94-
* access information from within the Lambda execution environment. Cannot be null.
95-
* @param responseData response to send, e.g. a list of name-value pairs. If null, an empty success is assumed.
105+
* @param context used to specify when the function and any callbacks have
106+
* completed execution, or to
107+
* access information from within the Lambda execution
108+
* environment. Cannot be null.
109+
* @param responseData response to send, e.g. a list of name-value pairs. If
110+
* null, an empty success is assumed.
96111
* @return the response object
97-
* @throws IOException when unable to generate or send the request
98-
* @throws CustomResourceResponseException when unable to serialize the response payload
112+
* @throws IOException when unable to generate or send the
113+
* request
114+
* @throws CustomResourceResponseException when unable to serialize the response
115+
* payload
99116
*/
100117
public HttpExecuteResponse send(CloudFormationCustomResourceEvent event,
101-
Context context,
102-
Response responseData) throws IOException, CustomResourceResponseException {
118+
Context context,
119+
Response responseData) throws IOException, CustomResourceResponseException {
103120
// no need to explicitly close in-memory stream
104121
StringInputStream stream = responseBodyStream(event, context, responseData);
105122
URI uri = URI.create(event.getResponseUrl());
@@ -129,20 +146,23 @@ protected Map<String, List<String>> headers(int contentLength) {
129146
}
130147

131148
/**
132-
* Returns the response body as an input stream, for supplying with the HTTP request to the custom resource.
149+
* Returns the response body as an input stream, for supplying with the HTTP
150+
* request to the custom resource.
133151
* <p>
134-
* If PhysicalResourceId is null at this point it will be replaced with the Lambda LogStreamName.
152+
* If PhysicalResourceId is null at this point it will be replaced with the
153+
* Lambda LogStreamName.
135154
*
136-
* @throws CustomResourceResponseException if unable to generate the response stream
155+
* @throws CustomResourceResponseException if unable to generate the response
156+
* stream
137157
*/
138158
StringInputStream responseBodyStream(CloudFormationCustomResourceEvent event,
139-
Context context,
140-
Response resp) throws CustomResourceResponseException {
159+
Context context,
160+
Response resp) throws CustomResourceResponseException {
141161
try {
142162
String reason = "See the details in CloudWatch Log Stream: " + context.getLogStreamName();
143163
if (resp == null) {
144-
String physicalResourceId = event.getPhysicalResourceId() != null ? event.getPhysicalResourceId() :
145-
context.getLogStreamName();
164+
String physicalResourceId = event.getPhysicalResourceId() != null ? event.getPhysicalResourceId()
165+
: context.getLogStreamName();
146166

147167
ResponseBody body = new ResponseBody(event, Response.Status.SUCCESS, physicalResourceId, false, reason);
148168
LOG.debug("ResponseBody: {}", body);
@@ -152,12 +172,12 @@ StringInputStream responseBodyStream(CloudFormationCustomResourceEvent event,
152172
if (!StringUtils.isBlank(resp.getReason())) {
153173
reason = resp.getReason();
154174
}
155-
String physicalResourceId = resp.getPhysicalResourceId() != null ? resp.getPhysicalResourceId() :
156-
event.getPhysicalResourceId() != null ? event.getPhysicalResourceId() :
157-
context.getLogStreamName();
175+
String physicalResourceId = resp.getPhysicalResourceId() != null ? resp.getPhysicalResourceId()
176+
: event.getPhysicalResourceId() != null ? event.getPhysicalResourceId()
177+
: context.getLogStreamName();
158178

159-
ResponseBody body =
160-
new ResponseBody(event, resp.getStatus(), physicalResourceId, resp.isNoEcho(), reason);
179+
ResponseBody body = new ResponseBody(event, resp.getStatus(), physicalResourceId, resp.isNoEcho(),
180+
reason);
161181
LOG.debug("ResponseBody: {}", body);
162182
ObjectNode node = body.toObjectNode(resp.getJsonNode());
163183
return new StringInputStream(node.toString());
@@ -169,10 +189,14 @@ StringInputStream responseBodyStream(CloudFormationCustomResourceEvent event,
169189
}
170190

171191
/**
172-
* Internal representation of the payload to be sent to the event target URL. Retains all properties of the payload
173-
* except for "Data". This is done so that the serialization of the non-"Data" properties and the serialization of
174-
* the value of "Data" can be handled by separate ObjectMappers, if need be. The former properties are dictated by
175-
* the custom resource but the latter is dictated by the implementor of the custom resource handler.
192+
* Internal representation of the payload to be sent to the event target URL.
193+
* Retains all properties of the payload
194+
* except for "Data". This is done so that the serialization of the non-"Data"
195+
* properties and the serialization of
196+
* the value of "Data" can be handled by separate ObjectMappers, if need be. The
197+
* former properties are dictated by
198+
* the custom resource but the latter is dictated by the implementor of the
199+
* custom resource handler.
176200
*/
177201
@SuppressWarnings("unused")
178202
static class ResponseBody {
@@ -189,10 +213,10 @@ static class ResponseBody {
189213
private final boolean noEcho;
190214

191215
ResponseBody(CloudFormationCustomResourceEvent event,
192-
Response.Status responseStatus,
193-
String physicalResourceId,
194-
boolean noEcho,
195-
String reason) {
216+
Response.Status responseStatus,
217+
String physicalResourceId,
218+
boolean noEcho,
219+
String reason) {
196220
Objects.requireNonNull(event, "CloudFormationCustomResourceEvent cannot be null");
197221

198222
this.physicalResourceId = physicalResourceId;
@@ -233,10 +257,13 @@ public boolean isNoEcho() {
233257
}
234258

235259
/**
236-
* Returns this ResponseBody as an ObjectNode with the provided JsonNode as the value of its "Data" property.
260+
* Returns this ResponseBody as an ObjectNode with the provided JsonNode as the
261+
* value of its "Data" property.
237262
*
238-
* @param dataNode the value of the "Data" property for the returned node; may be null
239-
* @return an ObjectNode representation of this ResponseBody and the provided dataNode
263+
* @param dataNode the value of the "Data" property for the returned node; may
264+
* be null
265+
* @return an ObjectNode representation of this ResponseBody and the provided
266+
* dataNode
240267
*/
241268
ObjectNode toObjectNode(JsonNode dataNode) {
242269
ObjectNode node = MAPPER.valueToTree(this);

0 commit comments

Comments
 (0)