Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(v2): Validation failures return 400s #1489

Merged
merged 21 commits into from
Dec 1, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/utilities/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,10 @@ We support JSON schema version 4, 6, 7 and 201909 (from [jmespath-jackson librar

`@Validation` annotation is used to validate either inbound events or functions' response.

It will fail fast with `ValidationException` if an event or response doesn't conform with given JSON Schema.
It will fail fast if an event or response doesn't conform with given JSON Schema. For most type of events a `ValidationException` will be thrown.
For API gateway events associated with REST APIs and HTTP APIs - `APIGatewayProxyRequestEvent` and `APIGatewayV2HTTPEvent` - the `@Validation`
annotation will build and return a custom 400 / "Bad Request" response, with a body containing the validation errors. This saves you from having
to catch the validation exception and map it back to a meaningful user error yourself.

While it is easier to specify a json schema file in the classpath (using the notation `"classpath:/path/to/schema.json"`), you can also provide a JSON String containing the schema.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ public void shouldReturnOkStatusWhenInputIsValid() {
}

@Test
public void shouldThrowExceptionWhenRequestInInvalid() {
void shouldReturnBadRequestWhenRequestInInvalid() {
String bodyWithMissedId = "{\n" +
" \"name\": \"FooBar XY\",\n" +
" \"price\": 258\n" +
" }";
APIGatewayProxyRequestEvent request = new APIGatewayProxyRequestEvent().withBody(bodyWithMissedId);

assertThrows(ValidationException.class, () -> inboundValidation.handleRequest(request, context));
APIGatewayProxyResponseEvent response = inboundValidation.handleRequest(request, context);

assertEquals(400, response.getStatusCode());
}
}
6 changes: 6 additions & 0 deletions powertools-e2e-tests/handlers/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<module>metrics</module>
<module>idempotency</module>
<module>parameters</module>
<module>validation</module>
</modules>

<dependencyManagement>
Expand Down Expand Up @@ -79,6 +80,11 @@
<artifactId>powertools-batch</artifactId>
<version>${lambda.powertools.version}</version>
</dependency>
<dependency>
<groupId>software.amazon.lambda</groupId>
<artifactId>powertools-validation</artifactId>
<version>${lambda.powertools.version}</version>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-core</artifactId>
Expand Down
60 changes: 60 additions & 0 deletions powertools-e2e-tests/handlers/validation/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>software.amazon.lambda</groupId>
<artifactId>e2e-test-handlers-parent</artifactId>
<version>1.0.0</version>
</parent>

<artifactId>e2e-test-handler-validation</artifactId>
<packaging>jar</packaging>
<name>A Lambda function using Powertools for AWS Lambda (Java) validation</name>

<dependencies>
<dependency>
<groupId>software.amazon.lambda</groupId>
<artifactId>powertools-validation</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-events</artifactId>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>dev.aspectj</groupId>
<artifactId>aspectj-maven-plugin</artifactId>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
<complianceLevel>${maven.compiler.target}</complianceLevel>
<aspectLibraries>
<aspectLibrary>
<groupId>software.amazon.lambda</groupId>
<artifactId>powertools-validation</artifactId>
</aspectLibrary>
</aspectLibraries>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright 2023 Amazon.com, Inc. or its affiliates.
* Licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package software.amazon.lambda.powertools.e2e;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;

import software.amazon.lambda.powertools.validation.Validation;

public class Function implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
@Validation(inboundSchema = "classpath:/validation/inbound_schema.json", outboundSchema = "classpath:/validation/outbound_schema.json")
public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, Context context) {
APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent();
response.setBody(input.getBody());
response.setStatusCode(200);
response.setIsBase64Encoded(false);
return response;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
<Appenders>
<Console name="JsonAppender" target="SYSTEM_OUT">
<JsonTemplateLayout eventTemplateUri="classpath:LambdaJsonLayout.json" />
</Console>
</Appenders>
<Loggers>
<Root level="INFO">
<AppenderRef ref="JsonAppender"/>
</Root>
<Logger name="JsonLogger" level="INFO" additivity="false">
<AppenderRef ref="JsonAppender"/>
</Logger>
</Loggers>
</Configuration>
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "http://example.com/product.json",
"type": "object",
"title": "Product schema",
"description": "JSON schema to validate Products",
"default": {},
"examples": [
{
"id": 43242,
"name": "FooBar XY",
"price": 258
}
],
"required": [
"price"
],
"properties": {
"price": {
"$id": "#/properties/price",
"type": "number",
"title": "Price of the product",
"description": "Positive price of the product",
"default": 0,
"exclusiveMinimum": 0,
"examples": [
258.99
]
}
},
"additionalProperties": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "http://example.com/product.json",
"type": "object",
"title": "Product schema",
"description": "JSON schema to validate Products",
"default": {},
"examples": [
{
"id": 43242,
"name": "FooBar XY",
"price": 258
}
],
"required": [
"price"
],
"properties": {
"price": {
"$id": "#/properties/price",
"type": "number",
"title": "Price of the product",
"description": "Positive price of the product",
"default": 0,
"exclusiveMaximum": 1000,
"examples": [
258.99
]
}
},
"additionalProperties": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright 2023 Amazon.com, Inc. or its affiliates.
* Licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package software.amazon.lambda.powertools;

import static org.assertj.core.api.Assertions.assertThat;
import static software.amazon.lambda.powertools.testutils.Infrastructure.FUNCTION_NAME_OUTPUT;
import static software.amazon.lambda.powertools.testutils.lambda.LambdaInvoker.invokeFunction;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import org.apache.commons.io.IOUtils;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import software.amazon.lambda.powertools.testutils.Infrastructure;
import software.amazon.lambda.powertools.testutils.lambda.InvocationResult;

class ValidationE2ET {

private static final ObjectMapper objectMapper = new ObjectMapper();

private static Infrastructure infrastructure;
private static String functionName;

@BeforeAll
@Timeout(value = 5, unit = TimeUnit.MINUTES)
public static void setup() {
infrastructure = Infrastructure.builder().testName(ValidationE2ET.class.getSimpleName())
.pathToFunction("validation").build();
Map<String, String> outputs = infrastructure.deploy();
functionName = outputs.get(FUNCTION_NAME_OUTPUT);
}

@AfterAll
public static void tearDown() {
if (infrastructure != null) {
infrastructure.destroy();
}
}

@Test
void test_validInboundApiGWEvent() throws IOException {
InputStream inputStream = this.getClass().getResourceAsStream("/validation/valid_api_gw_in_out_event.json");
String validEvent = IOUtils.toString(inputStream, StandardCharsets.UTF_8);

// WHEN
InvocationResult invocationResult = invokeFunction(functionName, validEvent);

// THEN
// invocation should pass validation and return 200
JsonNode validJsonNode = objectMapper.readTree(invocationResult.getResult());
assertThat(validJsonNode.get("statusCode").asInt()).isEqualTo(200);
assertThat(validJsonNode.get("body").asText()).isEqualTo("{\"price\": 150}");
}

@Test
void test_invalidInboundApiGWEvent() throws IOException {
InputStream inputStream = this.getClass().getResourceAsStream("/validation/invalid_api_gw_in_event.json");
String invalidEvent = IOUtils.toString(inputStream, StandardCharsets.UTF_8);

// WHEN
InvocationResult invocationResult = invokeFunction(functionName, invalidEvent);

// THEN
// invocation should fail inbound validation and return 400
JsonNode validJsonNode = objectMapper.readTree(invocationResult.getResult());
assertThat(validJsonNode.get("statusCode").asInt()).isEqualTo(400);
assertThat(validJsonNode.get("body").asText()).contains("$.price: is missing but it is required");
}

@Test
void test_invalidOutboundApiGWEvent() throws IOException {
InputStream inputStream = this.getClass().getResourceAsStream("/validation/invalid_api_gw_out_event.json");
String invalidEvent = IOUtils.toString(inputStream, StandardCharsets.UTF_8);

// WHEN
InvocationResult invocationResult = invokeFunction(functionName, invalidEvent);

// THEN
// invocation should fail outbound validation and return 400
JsonNode validJsonNode = objectMapper.readTree(invocationResult.getResult());
assertThat(validJsonNode.get("statusCode").asInt()).isEqualTo(400);
assertThat(validJsonNode.get("body").asText())
.contains("$.price: must have an exclusive maximum value of 1000");
}
skal111 marked this conversation as resolved.
Show resolved Hide resolved
}
Loading