diff --git a/settings.gradle b/settings.gradle index 84dc0b0..8d1f489 100644 --- a/settings.gradle +++ b/settings.gradle @@ -20,6 +20,8 @@ include "nike-internal-util", "testonly:testonly-springboot3_0-webflux", "testonly:testonly-springboot3_1-webmvc", "testonly:testonly-springboot3_1-webflux", + "testonly:testonly-springboot3_2-webmvc", + "testonly:testonly-springboot3_2-webflux", "testonly:testonly-springboot3_3-webmvc", "testonly:testonly-springboot3_3-webflux", // Sample modules (not published) diff --git a/testonly/testonly-springboot3_2-webflux/README.md b/testonly/testonly-springboot3_2-webflux/README.md new file mode 100644 index 0000000..02f82d3 --- /dev/null +++ b/testonly/testonly-springboot3_2-webflux/README.md @@ -0,0 +1,21 @@ +# Backstopper - testonly-springboot3_2-webflux + +Backstopper is a framework-agnostic API error handling and (optional) model validation solution for Java 17 and greater. + +(NOTE: The [Backstopper 1.x branch](https://github.com/Nike-Inc/backstopper/tree/v1.x) contains a version of +Backstopper for Java 7+, and for the `javax` ecosystem. The current Backstopper supports Java 17+ and the `jakarta` +ecosystem. The Backstopper 1.x releases also contain support for Spring 4 and 5, and Springboot 1 and 2.) + +This submodule contains tests to verify that the +[backstopper-spring-web-flux](../../backstopper-spring-web-flux) module's functionality works as expected in +Spring Boot 3.2.x WebFlux (Netty) environments, for both classpath-scanning and direct-import Backstopper configuration +use cases. + +## More Info + +See the [base project README.md](../../README.md), [User Guide](../../USER_GUIDE.md), and Backstopper repository +source code and javadocs for all further information. + +## License + +Backstopper is released under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/testonly/testonly-springboot3_2-webflux/build.gradle b/testonly/testonly-springboot3_2-webflux/build.gradle new file mode 100644 index 0000000..2346d2c --- /dev/null +++ b/testonly/testonly-springboot3_2-webflux/build.gradle @@ -0,0 +1,44 @@ +evaluationDependsOn(':') + +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath("org.springframework.boot:spring-boot-gradle-plugin:${springboot3_2Version}") + } +} + +apply plugin: 'org.springframework.boot' +apply plugin: "io.spring.dependency-management" + +test { + useJUnitPlatform() +} + +dependencies { + implementation( + project(":backstopper-spring-web-flux"), + project(":backstopper-custom-validators"), + "ch.qos.logback:logback-classic", + "org.springframework.boot:spring-boot-dependencies:$springboot3_2Version", + "org.springframework.boot:spring-boot-starter-webflux", + "org.hibernate.validator:hibernate-validator", + "org.glassfish.expressly:expressly:$glassfishExpresslyVersion", + ) + testImplementation( + project(":backstopper-reusable-tests-junit5"), + project(":testonly:testonly-spring-webflux-reusable-test-support"), + "org.jetbrains:annotations:$jetbrainsAnnotationsVersion", + "org.junit.jupiter:junit-jupiter-api:$junit5Version", + "org.junit.jupiter:junit-jupiter-engine:$junit5Version", + "org.junit.jupiter:junit-jupiter-params:$junit5Version", + "org.mockito:mockito-core:$mockitoVersion", + "org.assertj:assertj-core:$assertJVersion", + "io.rest-assured:rest-assured", + ) +} + +// We're just running tests, not trying to stand up a real Springboot 2 server from gradle. +// Disable the bootJar task so gradle doesn't fall over. +bootJar.enabled = false diff --git a/testonly/testonly-springboot3_2-webflux/src/test/java/com/nike/backstopper/testonly/BackstopperSpringboot3_2WebFluxComponentTest.java b/testonly/testonly-springboot3_2-webflux/src/test/java/com/nike/backstopper/testonly/BackstopperSpringboot3_2WebFluxComponentTest.java new file mode 100644 index 0000000..5f2ba3e --- /dev/null +++ b/testonly/testonly-springboot3_2-webflux/src/test/java/com/nike/backstopper/testonly/BackstopperSpringboot3_2WebFluxComponentTest.java @@ -0,0 +1,762 @@ +package com.nike.backstopper.testonly; + +import com.nike.backstopper.apierror.ApiError; +import com.nike.backstopper.apierror.ApiErrorWithMetadata; +import com.nike.backstopper.apierror.sample.SampleCoreApiError; +import com.nike.internal.util.MapBuilder; +import com.nike.internal.util.Pair; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ConfigurableApplicationContext; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import io.restassured.http.ContentType; +import io.restassured.response.ExtractableResponse; +import serverconfig.classpathscan.Springboot3_2WebFluxClasspathScanConfig; +import serverconfig.directimport.Springboot3_2WebFluxDirectImportConfig; +import testonly.componenttest.spring.reusable.error.SampleProjectApiError; +import testonly.componenttest.spring.reusable.model.RgbColor; +import testonly.componenttest.spring.reusable.model.SampleModel; + +import static com.nike.internal.util.testing.TestUtils.findFreePort; +import static io.restassured.RestAssured.given; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static testonly.componenttest.spring.reusable.controller.SampleWebFluxController.CORE_ERROR_WRAPPER_ENDPOINT_SUBPATH; +import static testonly.componenttest.spring.reusable.controller.SampleWebFluxController.FLUX_ERROR_SUBPATH; +import static testonly.componenttest.spring.reusable.controller.SampleWebFluxController.MONO_ERROR_SUBPATH; +import static testonly.componenttest.spring.reusable.controller.SampleWebFluxController.SAMPLE_FLUX_SUBPATH; +import static testonly.componenttest.spring.reusable.controller.SampleWebFluxController.SAMPLE_FROM_ROUTER_FUNCTION_PATH; +import static testonly.componenttest.spring.reusable.controller.SampleWebFluxController.SAMPLE_PATH; +import static testonly.componenttest.spring.reusable.controller.SampleWebFluxController.TRIGGER_UNHANDLED_ERROR_SUBPATH; +import static testonly.componenttest.spring.reusable.controller.SampleWebFluxController.WITH_REQUIRED_HEADER_SUBPATH; +import static testonly.componenttest.spring.reusable.controller.SampleWebFluxController.WITH_REQUIRED_QUERY_PARAM_SUBPATH; +import static testonly.componenttest.spring.reusable.error.SampleProjectApiError.FOO_STRING_CANNOT_BE_BLANK; +import static testonly.componenttest.spring.reusable.error.SampleProjectApiError.INVALID_RANGE_VALUE; +import static testonly.componenttest.spring.reusable.error.SampleProjectApiError.NOT_RGB_COLOR_ENUM; +import static testonly.componenttest.spring.reusable.error.SampleProjectApiError.RGB_COLOR_CANNOT_BE_NULL; +import static testonly.componenttest.spring.reusable.testutil.TestUtils.randomizedSampleModel; +import static testonly.componenttest.spring.reusable.testutil.TestUtils.verifyErrorReceived; + +/** + * Component test to verify that the functionality of {@code backstopper-spring-web-flux} works as expected in a + * Spring Boot 3.2.x WebFlux environment, for both classpath-scanning and direct-import Backstopper configuration use + * cases. + * + * @author Nic Munroe + */ +@SuppressWarnings({"NewClassNamingConvention", "ClassEscapesDefinedScope"}) +public class BackstopperSpringboot3_2WebFluxComponentTest { + + private static final int CLASSPATH_SCAN_SERVER_PORT = findFreePort(); + private static final int DIRECT_IMPORT_SERVER_PORT = findFreePort(); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private static ConfigurableApplicationContext classpathScanServerAppContext; + private static ConfigurableApplicationContext directImportServerAppContext; + + @BeforeAll + public static void beforeClass() { + assertThat(CLASSPATH_SCAN_SERVER_PORT).isNotEqualTo(DIRECT_IMPORT_SERVER_PORT); + classpathScanServerAppContext = SpringApplication.run( + Springboot3_2WebFluxClasspathScanConfig.class, "--server.port=" + CLASSPATH_SCAN_SERVER_PORT + ); + directImportServerAppContext = SpringApplication.run( + Springboot3_2WebFluxDirectImportConfig.class, "--server.port=" + DIRECT_IMPORT_SERVER_PORT + ); + } + + @AfterAll + public static void afterClass() { + SpringApplication.exit(classpathScanServerAppContext); + SpringApplication.exit(directImportServerAppContext); + } + + @SuppressWarnings("unused") + private enum ServerScenario { + CLASSPATH_SCAN_SERVER(CLASSPATH_SCAN_SERVER_PORT), + DIRECT_IMPORT_SERVER(DIRECT_IMPORT_SERVER_PORT); + + public final int serverPort; + + ServerScenario(int serverPort) { + this.serverPort = serverPort; + } + } + + // *************** SUCCESSFUL (NON ERROR) CALLS ****************** + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_basic_sample_get(ServerScenario scenario) throws IOException { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + assertThat(response.statusCode()).isEqualTo(200); + SampleModel responseBody = objectMapper.readValue(response.asString(), SampleModel.class); + verifyNewSampleModel(responseBody); + } + + private void verifyNewSampleModel(SampleModel sampleModel) { + assertThat(sampleModel).isNotNull(); + assertThat(sampleModel.foo).isNotEmpty(); + assertThat(sampleModel.range_0_to_42).isNotEmpty(); + assertThat(Integer.parseInt(sampleModel.range_0_to_42)).isBetween(0, 42); + assertThat(sampleModel.rgb_color).isNotEmpty(); + assertThat(RgbColor.toRgbColor(sampleModel.rgb_color)).isNotNull(); + assertThat(sampleModel.throw_manual_error).isFalse(); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_basic_sample_post(ServerScenario scenario) throws IOException { + SampleModel requestPayload = randomizedSampleModel(); + String requestPayloadAsString = objectMapper.writeValueAsString(requestPayload); + + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .contentType(ContentType.JSON) + .basePath(SAMPLE_PATH) + .body(requestPayloadAsString) + .log().all() + .when() + .post() + .then() + .log().all() + .extract(); + + assertThat(response.statusCode()).isEqualTo(201); + SampleModel responseBody = objectMapper.readValue(response.asString(), SampleModel.class); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.foo).isEqualTo(requestPayload.foo); + assertThat(responseBody.range_0_to_42).isEqualTo(requestPayload.range_0_to_42); + assertThat(responseBody.rgb_color).isEqualTo(requestPayload.rgb_color); + assertThat(responseBody.throw_manual_error).isEqualTo(requestPayload.throw_manual_error); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_router_function_sample_get(ServerScenario scenario) throws IOException { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_FROM_ROUTER_FUNCTION_PATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + assertThat(response.statusCode()).isEqualTo(200); + SampleModel responseBody = objectMapper.readValue(response.asString(), SampleModel.class); + verifyNewSampleModel(responseBody); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_flux_sample_get(ServerScenario scenario) throws IOException { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + SAMPLE_FLUX_SUBPATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + assertThat(response.statusCode()).isEqualTo(200); + List responseBody = objectMapper.readValue( + response.asString(), new TypeReference<>() {} + ); + + assertThat(responseBody).hasSizeGreaterThan(1); + responseBody.forEach(this::verifyNewSampleModel); + } + + // *************** JSR 303 AND ENDPOINT ERRORS ****************** + + @SuppressWarnings("unused") + private enum Jsr303SampleModelValidationScenario { + BLANK_FIELD_VIOLATION( + new SampleModel("", "42", "GREEN", false), + singletonList(FOO_STRING_CANNOT_BE_BLANK) + ), + INVALID_RANGE_VIOLATION( + new SampleModel("bar", "-1", "GREEN", false), + singletonList(INVALID_RANGE_VALUE) + ), + NULL_FIELD_VIOLATION( + new SampleModel("bar", "42", null, false), + singletonList(RGB_COLOR_CANNOT_BE_NULL) + ), + STRING_CONVERTS_TO_CLASSTYPE_VIOLATION( + new SampleModel("bar", "42", "car", false), + singletonList(NOT_RGB_COLOR_ENUM) + ), + MULTIPLE_VIOLATIONS( + new SampleModel(" \n\r\t ", "99", "tree", false), + Arrays.asList(FOO_STRING_CANNOT_BE_BLANK, INVALID_RANGE_VALUE, NOT_RGB_COLOR_ENUM) + ); + + public final SampleModel model; + public final List expectedErrors; + + Jsr303SampleModelValidationScenario( + SampleModel model, List expectedErrors + ) { + this.model = model; + this.expectedErrors = expectedErrors; + } + } + + public static List jsr303ValidationErrorScenariosDataProvider() { + List result = new ArrayList<>(); + for (Jsr303SampleModelValidationScenario violationScenario : Jsr303SampleModelValidationScenario.values()) { + for (ServerScenario serverScenario : ServerScenario.values()) { + result.add(new Object[]{violationScenario, serverScenario}); + } + } + return result; + } + + @MethodSource("jsr303ValidationErrorScenariosDataProvider") + @ParameterizedTest + public void verify_jsr303_validation_errors( + Jsr303SampleModelValidationScenario violationScenario, ServerScenario serverScenario + ) throws JsonProcessingException { + String requestPayloadAsString = objectMapper.writeValueAsString(violationScenario.model); + + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(serverScenario.serverPort) + .contentType(ContentType.JSON) + .basePath(SAMPLE_PATH) + .body(requestPayloadAsString) + .log().all() + .when() + .post() + .then() + .log().all() + .extract(); + + List expectedErrors = new ArrayList<>(); + for (ApiError expectedApiError : violationScenario.expectedErrors) { + String extraMetadataFieldValue = null; + + if (INVALID_RANGE_VALUE.equals(expectedApiError)) { + extraMetadataFieldValue = "range_0_to_42"; + } + else if (RGB_COLOR_CANNOT_BE_NULL.equals(expectedApiError) || NOT_RGB_COLOR_ENUM.equals(expectedApiError)) { + extraMetadataFieldValue = "rgb_color"; + } + + if (extraMetadataFieldValue != null) { + expectedApiError = new ApiErrorWithMetadata( + expectedApiError, + MapBuilder.builder("field", (Object) extraMetadataFieldValue).build() + ); + } + + expectedErrors.add(expectedApiError); + } + verifyErrorReceived(response, expectedErrors, 400); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_MANUALLY_THROWN_ERROR_is_thrown_when_requested(ServerScenario scenario) throws IOException { + SampleModel requestPayload = new SampleModel("bar", "42", "RED", true); + String requestPayloadAsString = objectMapper.writeValueAsString(requestPayload); + + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .contentType(ContentType.JSON) + .basePath(SAMPLE_PATH) + .body(requestPayloadAsString) + .log().all() + .when() + .post() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleProjectApiError.MANUALLY_THROWN_ERROR); + // This code path also should add some custom headers to the response + assertThat(response.headers().getValues("rgbColorValue")).isEqualTo(singletonList(requestPayload.rgb_color)); + assertThat(response.headers().getValues("otherExtraMultivalueHeader")).isEqualTo(Arrays.asList("foo", "bar")); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_SOME_MEANINGFUL_ERROR_NAME_is_thrown_when_correct_endpoint_is_hit(ServerScenario scenario) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + CORE_ERROR_WRAPPER_ENDPOINT_SUBPATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleProjectApiError.SOME_MEANINGFUL_ERROR_NAME); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_GENERIC_SERVICE_ERROR_is_thrown_when_correct_endpoint_is_hit(ServerScenario scenario) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + TRIGGER_UNHANDLED_ERROR_SUBPATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.GENERIC_SERVICE_ERROR); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_WEBFLUX_MONO_ERROR_is_thrown_when_correct_endpoint_is_hit(ServerScenario scenario) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + MONO_ERROR_SUBPATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleProjectApiError.WEBFLUX_MONO_ERROR); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_WEBFLUX_FLUX_ERROR_is_thrown_when_correct_endpoint_is_hit(ServerScenario scenario) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + FLUX_ERROR_SUBPATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleProjectApiError.WEBFLUX_FLUX_ERROR); + } + + // *************** FRAMEWORK/FILTER ERRORS ****************** + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_NOT_FOUND_returned_if_unknown_path_is_requested(ServerScenario scenario) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(UUID.randomUUID().toString()) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.NOT_FOUND); + } + + private enum WebFilterErrorScenario { + EXCEPTION_THROWN_IN_WEB_FILTER( + "throw-web-filter-exception", SampleProjectApiError.ERROR_THROWN_IN_WEB_FILTER + ), + EXCEPTION_RETURNED_IN_WEB_FILTER( + "return-exception-in-web-filter-mono", SampleProjectApiError.ERROR_RETURNED_IN_WEB_FILTER_MONO + ); + + public final String triggeringHeaderName; + public final ApiError expectedError; + + WebFilterErrorScenario(String triggeringHeaderName, ApiError expectedError) { + this.triggeringHeaderName = triggeringHeaderName; + this.expectedError = expectedError; + } + } + + public static List webFilterErrorScenariosDataProvider() { + List result = new ArrayList<>(); + for (WebFilterErrorScenario webFilterErrorScenario : WebFilterErrorScenario.values()) { + for (ServerScenario serverScenario : ServerScenario.values()) { + result.add(new Object[]{webFilterErrorScenario, serverScenario}); + } + } + return result; + } + + @MethodSource("webFilterErrorScenariosDataProvider") + @ParameterizedTest + public void verify_expected_error_returned_if_web_filter_trigger_occurs( + WebFilterErrorScenario webFilterErrorScenario, ServerScenario serverScenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(serverScenario.serverPort) + .basePath("/doesnotmatter") + .header(webFilterErrorScenario.triggeringHeaderName, "true") + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, webFilterErrorScenario.expectedError); + } + + private enum RouterHandlerFilterErrorScenario { + EXCEPTION_THROWN_IN_ROUTER_HANDLER_FILTER( + "throw-handler-filter-function-exception", + SampleProjectApiError.ERROR_THROWN_IN_HANDLER_FILTER_FUNCTION + ), + EXCEPTION_RETURNED_IN_ROUTER_HANDLER_FILTER( + "return-exception-in-handler-filter-function-mono", + SampleProjectApiError.ERROR_RETURNED_IN_HANDLER_FILTER_FUNCTION_MONO + ); + + public final String triggeringHeaderName; + public final ApiError expectedError; + + RouterHandlerFilterErrorScenario(String triggeringHeaderName, ApiError expectedError) { + this.triggeringHeaderName = triggeringHeaderName; + this.expectedError = expectedError; + } + } + + public static List routerHandlerFilterErrorScenariosDataProvider() { + List result = new ArrayList<>(); + for (RouterHandlerFilterErrorScenario routerHandlerFilterErrorScenario : RouterHandlerFilterErrorScenario.values()) { + for (ServerScenario serverScenario : ServerScenario.values()) { + result.add(new Object[]{routerHandlerFilterErrorScenario, serverScenario}); + } + } + return result; + } + + @MethodSource("routerHandlerFilterErrorScenariosDataProvider") + @ParameterizedTest + public void verify_expected_error_returned_if_handler_filter_function_trigger_occurs( + RouterHandlerFilterErrorScenario routerHandlerFilterErrorScenario, ServerScenario serverScenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(serverScenario.serverPort) + .basePath(SAMPLE_FROM_ROUTER_FUNCTION_PATH) + .header(routerHandlerFilterErrorScenario.triggeringHeaderName, "true") + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, routerHandlerFilterErrorScenario.expectedError); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_METHOD_NOT_ALLOWED_returned_if_known_path_is_requested_with_invalid_http_method( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .log().all() + .when() + .delete() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.METHOD_NOT_ALLOWED); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_sample_get_fails_with_NO_ACCEPTABLE_REPRESENTATION_if_passed_invalid_accept_header( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .accept(ContentType.BINARY) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.NO_ACCEPTABLE_REPRESENTATION); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_sample_post_fails_with_UNSUPPORTED_MEDIA_TYPE_if_passed_invalid_content_type( + ServerScenario scenario + ) throws IOException { + SampleModel requestPayload = randomizedSampleModel(); + String requestPayloadAsString = objectMapper.writeValueAsString(requestPayload); + + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .body(requestPayloadAsString) + .contentType(ContentType.TEXT) + .log().all() + .when() + .post() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.UNSUPPORTED_MEDIA_TYPE); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_MALFORMED_REQUEST_is_thrown_when_required_query_param_is_missing( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + WITH_REQUIRED_QUERY_PARAM_SUBPATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived( + response, + new ApiErrorWithMetadata( + SampleCoreApiError.MALFORMED_REQUEST, + Pair.of("missing_param_name", "requiredQueryParamValue"), + Pair.of("missing_param_type", "int"), + Pair.of("required_location", "query_param") + ) + ); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_TYPE_CONVERSION_ERROR_is_thrown_when_framework_cannot_convert_type_for_query_param( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + WITH_REQUIRED_QUERY_PARAM_SUBPATH) + .queryParam("requiredQueryParamValue", "not-an-integer") + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, new ApiErrorWithMetadata( + SampleCoreApiError.TYPE_CONVERSION_ERROR, + MapBuilder.builder("bad_property_name", (Object) "requiredQueryParamValue") + .put("bad_property_value","not-an-integer") + .put("required_location","query_param") + .put("required_type", "int") + .build() + )); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_MALFORMED_REQUEST_is_thrown_when_required_header_is_missing( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + WITH_REQUIRED_HEADER_SUBPATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived( + response, + new ApiErrorWithMetadata( + SampleCoreApiError.MALFORMED_REQUEST, + Pair.of("missing_param_name", "requiredHeaderValue"), + Pair.of("missing_param_type", "int"), + Pair.of("required_location", "header") + ) + ); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_TYPE_CONVERSION_ERROR_is_thrown_when_framework_cannot_convert_type_for_header( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + WITH_REQUIRED_HEADER_SUBPATH) + .header("requiredHeaderValue", "not-an-integer") + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, new ApiErrorWithMetadata( + SampleCoreApiError.TYPE_CONVERSION_ERROR, + MapBuilder.builder("bad_property_name", (Object) "requiredHeaderValue") + .put("bad_property_value","not-an-integer") + .put("required_location","header") + .put("required_type", "int") + .build() + )); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_sample_post_fails_with_MISSING_EXPECTED_CONTENT_if_passed_empty_body( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .contentType(ContentType.JSON) + .body("") + .log().all() + .when() + .post() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.MISSING_EXPECTED_CONTENT); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_sample_post_fails_with_MALFORMED_REQUEST_if_passed_junk_json( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .contentType(ContentType.JSON) + .body("{notjson blah") + .log().all() + .when() + .post() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.MALFORMED_REQUEST); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_sample_post_fails_with_MALFORMED_REQUEST_if_passed_bad_json_body( + ServerScenario scenario + ) throws IOException { + SampleModel originalValidPayloadObj = randomizedSampleModel(); + String originalValidPayloadAsString = objectMapper.writeValueAsString(originalValidPayloadObj); + @SuppressWarnings("unchecked") + Map badRequestPayloadAsMap = objectMapper.readValue(originalValidPayloadAsString, Map.class); + badRequestPayloadAsMap.put("throw_manual_error", "not-a-boolean"); + String badJsonPayloadAsString = objectMapper.writeValueAsString(badRequestPayloadAsMap); + + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .contentType(ContentType.JSON) + .body(badJsonPayloadAsString) + .log().all() + .when() + .post() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.MALFORMED_REQUEST); + } +} diff --git a/testonly/testonly-springboot3_2-webflux/src/test/java/serverconfig/classpathscan/Springboot3_2WebFluxClasspathScanConfig.java b/testonly/testonly-springboot3_2-webflux/src/test/java/serverconfig/classpathscan/Springboot3_2WebFluxClasspathScanConfig.java new file mode 100644 index 0000000..2f9aac8 --- /dev/null +++ b/testonly/testonly-springboot3_2-webflux/src/test/java/serverconfig/classpathscan/Springboot3_2WebFluxClasspathScanConfig.java @@ -0,0 +1,64 @@ +package serverconfig.classpathscan; + +import com.nike.backstopper.apierror.projectspecificinfo.ProjectApiErrors; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.WebFilter; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import testonly.componenttest.spring.reusable.controller.SampleWebFluxController; +import testonly.componenttest.spring.reusable.error.SampleProjectApiErrorsImpl; +import testonly.componenttest.spring.reusable.filter.ExplodingHandlerFilterFunction; +import testonly.componenttest.spring.reusable.filter.ExplodingWebFilter; + +import static org.springframework.web.reactive.function.server.RequestPredicates.GET; +import static testonly.componenttest.spring.reusable.controller.SampleWebFluxController.SAMPLE_FROM_ROUTER_FUNCTION_PATH; + +/** + * Springboot config that uses {@link ComponentScan} to integrate Backstopper via classpath scanning of the + * {@code com.nike.backstopper} package. + * + * @author Nic Munroe + */ +@SpringBootApplication +@ComponentScan(basePackages = { + // Component scan the core Backstopper+Spring WebFlux support. + "com.nike.backstopper", + // Component scan the controller (note this is the reusable WebFlux controller, not the reusable servlet controller). + "testonly.componenttest.spring.reusable.controller" +}) +@SuppressWarnings("unused") +public class Springboot3_2WebFluxClasspathScanConfig { + + @Bean + public ProjectApiErrors getProjectApiErrors() { + return new SampleProjectApiErrorsImpl(); + } + + @Bean + public Validator getJsr303Validator() { + //noinspection resource + return Validation.buildDefaultValidatorFactory().getValidator(); + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public WebFilter explodingWebFilter() { + return new ExplodingWebFilter(); + } + + @Bean + public RouterFunction sampleRouterFunction(SampleWebFluxController sampleController) { + return RouterFunctions + .route(GET(SAMPLE_FROM_ROUTER_FUNCTION_PATH), sampleController::getSampleModelRouterFunction) + .filter(new ExplodingHandlerFilterFunction()); + } +} diff --git a/testonly/testonly-springboot3_2-webflux/src/test/java/serverconfig/directimport/Springboot3_2WebFluxDirectImportConfig.java b/testonly/testonly-springboot3_2-webflux/src/test/java/serverconfig/directimport/Springboot3_2WebFluxDirectImportConfig.java new file mode 100644 index 0000000..6e25fc9 --- /dev/null +++ b/testonly/testonly-springboot3_2-webflux/src/test/java/serverconfig/directimport/Springboot3_2WebFluxDirectImportConfig.java @@ -0,0 +1,65 @@ +package serverconfig.directimport; + +import com.nike.backstopper.apierror.projectspecificinfo.ProjectApiErrors; +import com.nike.backstopper.handler.spring.webflux.config.BackstopperSpringWebFluxConfig; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.WebFilter; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import testonly.componenttest.spring.reusable.controller.SampleWebFluxController; +import testonly.componenttest.spring.reusable.error.SampleProjectApiErrorsImpl; +import testonly.componenttest.spring.reusable.filter.ExplodingHandlerFilterFunction; +import testonly.componenttest.spring.reusable.filter.ExplodingWebFilter; + +import static org.springframework.web.reactive.function.server.RequestPredicates.GET; +import static testonly.componenttest.spring.reusable.controller.SampleWebFluxController.SAMPLE_FROM_ROUTER_FUNCTION_PATH; + +/** + * Springboot config that uses {@link Import} to integrate Backstopper via direct import of + * {@link BackstopperSpringWebFluxConfig}. + * + * @author Nic Munroe + */ +@SpringBootApplication +@Import({ + // Import core Backstopper+Spring WebFlux support. + BackstopperSpringWebFluxConfig.class, + // Import the controller. + SampleWebFluxController.class +}) +@SuppressWarnings("unused") +public class Springboot3_2WebFluxDirectImportConfig { + + @Bean + public ProjectApiErrors getProjectApiErrors() { + return new SampleProjectApiErrorsImpl(); + } + + @Bean + public Validator getJsr303Validator() { + //noinspection resource + return Validation.buildDefaultValidatorFactory().getValidator(); + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public WebFilter explodingWebFilter() { + return new ExplodingWebFilter(); + } + + @Bean + public RouterFunction sampleRouterFunction(SampleWebFluxController sampleController) { + return RouterFunctions + .route(GET(SAMPLE_FROM_ROUTER_FUNCTION_PATH), sampleController::getSampleModelRouterFunction) + .filter(new ExplodingHandlerFilterFunction()); + } +} diff --git a/testonly/testonly-springboot3_2-webflux/src/test/resources/logback.xml b/testonly/testonly-springboot3_2-webflux/src/test/resources/logback.xml new file mode 100644 index 0000000..80adb28 --- /dev/null +++ b/testonly/testonly-springboot3_2-webflux/src/test/resources/logback.xml @@ -0,0 +1,11 @@ + + + + %d{yyyy-MM-dd_HH:mm:ss.SSS} [%thread] |-%-5level %logger{36} - %msg%n + + + + + + + \ No newline at end of file diff --git a/testonly/testonly-springboot3_2-webmvc/README.md b/testonly/testonly-springboot3_2-webmvc/README.md new file mode 100644 index 0000000..db29465 --- /dev/null +++ b/testonly/testonly-springboot3_2-webmvc/README.md @@ -0,0 +1,21 @@ +# Backstopper - testonly-springboot3_2-webmvc + +Backstopper is a framework-agnostic API error handling and (optional) model validation solution for Java 17 and greater. + +(NOTE: The [Backstopper 1.x branch](https://github.com/Nike-Inc/backstopper/tree/v1.x) contains a version of +Backstopper for Java 7+, and for the `javax` ecosystem. The current Backstopper supports Java 17+ and the `jakarta` +ecosystem. The Backstopper 1.x releases also contain support for Spring 4 and 5, and Springboot 1 and 2.) + +This submodule contains tests to verify that the +[backstopper-spring-boot3-webmvc](../../backstopper-spring-boot3-webmvc) module's functionality works as expected in +Spring Boot 3.2.x Web MVC (Servlet) environments, for both classpath-scanning and direct-import Backstopper configuration +use cases. + +## More Info + +See the [base project README.md](../../README.md), [User Guide](../../USER_GUIDE.md), and Backstopper repository +source code and javadocs for all further information. + +## License + +Backstopper is released under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/testonly/testonly-springboot3_2-webmvc/build.gradle b/testonly/testonly-springboot3_2-webmvc/build.gradle new file mode 100644 index 0000000..306e0b1 --- /dev/null +++ b/testonly/testonly-springboot3_2-webmvc/build.gradle @@ -0,0 +1,43 @@ +evaluationDependsOn(':') + +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath("org.springframework.boot:spring-boot-gradle-plugin:${springboot3_2Version}") + } +} + +apply plugin: 'org.springframework.boot' +apply plugin: "io.spring.dependency-management" + +test { + useJUnitPlatform() +} + +dependencies { + implementation( + project(":backstopper-spring-boot3-webmvc"), + project(":backstopper-custom-validators"), + "ch.qos.logback:logback-classic", + "org.springframework.boot:spring-boot-dependencies:$springboot3_2Version", + "org.springframework.boot:spring-boot-starter-web", + "org.hibernate.validator:hibernate-validator", + "org.glassfish.expressly:expressly:$glassfishExpresslyVersion", + ) + testImplementation( + project(":backstopper-reusable-tests-junit5"), + project(":testonly:testonly-spring-webmvc-reusable-test-support"), + "org.junit.jupiter:junit-jupiter-api:$junit5Version", + "org.junit.jupiter:junit-jupiter-engine:$junit5Version", + "org.junit.jupiter:junit-jupiter-params:$junit5Version", + "org.mockito:mockito-core:$mockitoVersion", + "org.assertj:assertj-core:$assertJVersion", + "io.rest-assured:rest-assured", + ) +} + +// We're just running tests, not trying to stand up a real Springboot 3 server from gradle. +// Disable the bootJar task so gradle doesn't fall over. +bootJar.enabled = false diff --git a/testonly/testonly-springboot3_2-webmvc/src/test/java/com/nike/backstopper/testonly/BackstopperSpringboot3_2WebMvcComponentTest.java b/testonly/testonly-springboot3_2-webmvc/src/test/java/com/nike/backstopper/testonly/BackstopperSpringboot3_2WebMvcComponentTest.java new file mode 100644 index 0000000..3bd8071 --- /dev/null +++ b/testonly/testonly-springboot3_2-webmvc/src/test/java/com/nike/backstopper/testonly/BackstopperSpringboot3_2WebMvcComponentTest.java @@ -0,0 +1,574 @@ +package com.nike.backstopper.testonly; + +import com.nike.backstopper.apierror.ApiError; +import com.nike.backstopper.apierror.ApiErrorWithMetadata; +import com.nike.backstopper.apierror.sample.SampleCoreApiError; +import com.nike.internal.util.MapBuilder; +import com.nike.internal.util.Pair; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ConfigurableApplicationContext; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import io.restassured.http.ContentType; +import io.restassured.response.ExtractableResponse; +import serverconfig.classpathscan.Springboot3_2WebMvcClasspathScanConfig; +import serverconfig.directimport.Springboot3_2WebMvcDirectImportConfig; +import testonly.componenttest.spring.reusable.error.SampleProjectApiError; +import testonly.componenttest.spring.reusable.model.RgbColor; +import testonly.componenttest.spring.reusable.model.SampleModel; + +import static com.nike.internal.util.testing.TestUtils.findFreePort; +import static io.restassured.RestAssured.given; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static testonly.componenttest.spring.reusable.controller.SampleController.CORE_ERROR_WRAPPER_ENDPOINT_SUBPATH; +import static testonly.componenttest.spring.reusable.controller.SampleController.SAMPLE_PATH; +import static testonly.componenttest.spring.reusable.controller.SampleController.TRIGGER_UNHANDLED_ERROR_SUBPATH; +import static testonly.componenttest.spring.reusable.controller.SampleController.WITH_REQUIRED_HEADER_SUBPATH; +import static testonly.componenttest.spring.reusable.controller.SampleController.WITH_REQUIRED_QUERY_PARAM_SUBPATH; +import static testonly.componenttest.spring.reusable.error.SampleProjectApiError.FOO_STRING_CANNOT_BE_BLANK; +import static testonly.componenttest.spring.reusable.error.SampleProjectApiError.INVALID_RANGE_VALUE; +import static testonly.componenttest.spring.reusable.error.SampleProjectApiError.NOT_RGB_COLOR_ENUM; +import static testonly.componenttest.spring.reusable.error.SampleProjectApiError.RGB_COLOR_CANNOT_BE_NULL; +import static testonly.componenttest.spring.reusable.testutil.TestUtils.randomizedSampleModel; +import static testonly.componenttest.spring.reusable.testutil.TestUtils.verifyErrorReceived; + +/** + * Component test to verify that the functionality of {@code backstopper-spring-boot3-webmvc} works as expected in a + * Spring Boot 3.2.x Web MVC environment, for both classpath-scanning and direct-import Backstopper configuration use + * cases. + * + * @author Nic Munroe + */ +@SuppressWarnings({"ClassEscapesDefinedScope", "NewClassNamingConvention"}) +public class BackstopperSpringboot3_2WebMvcComponentTest { + + private static final int CLASSPATH_SCAN_SERVER_PORT = findFreePort(); + private static final int DIRECT_IMPORT_SERVER_PORT = findFreePort(); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private static ConfigurableApplicationContext classpathScanServerAppContext; + private static ConfigurableApplicationContext directImportServerAppContext; + + @BeforeAll + public static void beforeClass() { + assertThat(CLASSPATH_SCAN_SERVER_PORT).isNotEqualTo(DIRECT_IMPORT_SERVER_PORT); + classpathScanServerAppContext = SpringApplication.run( + Springboot3_2WebMvcClasspathScanConfig.class, "--server.port=" + CLASSPATH_SCAN_SERVER_PORT + ); + directImportServerAppContext = SpringApplication.run( + Springboot3_2WebMvcDirectImportConfig.class, "--server.port=" + DIRECT_IMPORT_SERVER_PORT + ); + } + + @AfterAll + public static void afterClass() { + SpringApplication.exit(classpathScanServerAppContext); + SpringApplication.exit(directImportServerAppContext); + } + + @SuppressWarnings("unused") + private enum ServerScenario { + CLASSPATH_SCAN_SERVER(CLASSPATH_SCAN_SERVER_PORT), + DIRECT_IMPORT_SERVER(DIRECT_IMPORT_SERVER_PORT); + + public final int serverPort; + + ServerScenario(int serverPort) { + this.serverPort = serverPort; + } + } + + // *************** SUCCESSFUL (NON ERROR) CALLS ****************** + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_basic_sample_get(ServerScenario scenario) throws IOException { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + assertThat(response.statusCode()).isEqualTo(200); + SampleModel responseBody = objectMapper.readValue(response.asString(), SampleModel.class); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.foo).isNotEmpty(); + assertThat(responseBody.range_0_to_42).isNotEmpty(); + assertThat(Integer.parseInt(responseBody.range_0_to_42)).isBetween(0, 42); + assertThat(responseBody.rgb_color).isNotEmpty(); + assertThat(RgbColor.toRgbColor(responseBody.rgb_color)).isNotNull(); + assertThat(responseBody.throw_manual_error).isFalse(); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_basic_sample_post(ServerScenario scenario) throws IOException { + SampleModel requestPayload = randomizedSampleModel(); + String requestPayloadAsString = objectMapper.writeValueAsString(requestPayload); + + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .contentType(ContentType.JSON) + .basePath(SAMPLE_PATH) + .body(requestPayloadAsString) + .log().all() + .when() + .post() + .then() + .log().all() + .extract(); + + assertThat(response.statusCode()).isEqualTo(201); + SampleModel responseBody = objectMapper.readValue(response.asString(), SampleModel.class); + assertThat(responseBody).isNotNull(); + assertThat(responseBody.foo).isEqualTo(requestPayload.foo); + assertThat(responseBody.range_0_to_42).isEqualTo(requestPayload.range_0_to_42); + assertThat(responseBody.rgb_color).isEqualTo(requestPayload.rgb_color); + assertThat(responseBody.throw_manual_error).isEqualTo(requestPayload.throw_manual_error); + } + + // *************** JSR 303 AND ENDPOINT ERRORS ****************** + + @SuppressWarnings("unused") + private enum Jsr303SampleModelValidationScenario { + BLANK_FIELD_VIOLATION( + new SampleModel("", "42", "GREEN", false), + singletonList(FOO_STRING_CANNOT_BE_BLANK) + ), + INVALID_RANGE_VIOLATION( + new SampleModel("bar", "-1", "GREEN", false), + singletonList(INVALID_RANGE_VALUE) + ), + NULL_FIELD_VIOLATION( + new SampleModel("bar", "42", null, false), + singletonList(RGB_COLOR_CANNOT_BE_NULL) + ), + STRING_CONVERTS_TO_CLASSTYPE_VIOLATION( + new SampleModel("bar", "42", "car", false), + singletonList(NOT_RGB_COLOR_ENUM) + ), + MULTIPLE_VIOLATIONS( + new SampleModel(" \n\r\t ", "99", "tree", false), + Arrays.asList(FOO_STRING_CANNOT_BE_BLANK, INVALID_RANGE_VALUE, NOT_RGB_COLOR_ENUM) + ); + + public final SampleModel model; + public final List expectedErrors; + + Jsr303SampleModelValidationScenario( + SampleModel model, List expectedErrors + ) { + this.model = model; + this.expectedErrors = expectedErrors; + } + } + + public static List jsr303ValidationErrorScenariosDataProvider() { + List result = new ArrayList<>(); + for (Jsr303SampleModelValidationScenario violationScenario : Jsr303SampleModelValidationScenario.values()) { + for (ServerScenario serverScenario : ServerScenario.values()) { + result.add(new Object[]{violationScenario, serverScenario}); + } + } + return result; + } + + @MethodSource("jsr303ValidationErrorScenariosDataProvider") + @ParameterizedTest + public void verify_jsr303_validation_errors( + Jsr303SampleModelValidationScenario violationScenario, ServerScenario serverScenario + ) throws JsonProcessingException { + String requestPayloadAsString = objectMapper.writeValueAsString(violationScenario.model); + + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(serverScenario.serverPort) + .contentType(ContentType.JSON) + .basePath(SAMPLE_PATH) + .body(requestPayloadAsString) + .log().all() + .when() + .post() + .then() + .log().all() + .extract(); + + List expectedErrors = new ArrayList<>(); + for (ApiError expectedApiError : violationScenario.expectedErrors) { + String extraMetadataFieldValue = null; + + if (INVALID_RANGE_VALUE.equals(expectedApiError)) { + extraMetadataFieldValue = "range_0_to_42"; + } + else if (RGB_COLOR_CANNOT_BE_NULL.equals(expectedApiError) || NOT_RGB_COLOR_ENUM.equals(expectedApiError)) { + extraMetadataFieldValue = "rgb_color"; + } + + if (extraMetadataFieldValue != null) { + expectedApiError = new ApiErrorWithMetadata( + expectedApiError, + MapBuilder.builder("field", (Object) extraMetadataFieldValue).build() + ); + } + + expectedErrors.add(expectedApiError); + } + verifyErrorReceived(response, expectedErrors, 400); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_MANUALLY_THROWN_ERROR_is_thrown_when_requested(ServerScenario scenario) throws IOException { + SampleModel requestPayload = new SampleModel("bar", "42", "RED", true); + String requestPayloadAsString = objectMapper.writeValueAsString(requestPayload); + + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .contentType(ContentType.JSON) + .basePath(SAMPLE_PATH) + .body(requestPayloadAsString) + .log().all() + .when() + .post() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleProjectApiError.MANUALLY_THROWN_ERROR); + // This code path also should add some custom headers to the response + assertThat(response.headers().getValues("rgbColorValue")).isEqualTo(singletonList(requestPayload.rgb_color)); + assertThat(response.headers().getValues("otherExtraMultivalueHeader")).isEqualTo(Arrays.asList("foo", "bar")); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_SOME_MEANINGFUL_ERROR_NAME_is_thrown_when_correct_endpoint_is_hit(ServerScenario scenario) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + CORE_ERROR_WRAPPER_ENDPOINT_SUBPATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleProjectApiError.SOME_MEANINGFUL_ERROR_NAME); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_GENERIC_SERVICE_ERROR_is_thrown_when_correct_endpoint_is_hit(ServerScenario scenario) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + TRIGGER_UNHANDLED_ERROR_SUBPATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.GENERIC_SERVICE_ERROR); + } + + // *************** FRAMEWORK ERRORS ****************** + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_NOT_FOUND_returned_if_unknown_path_is_requested(ServerScenario scenario) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(UUID.randomUUID().toString()) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.NOT_FOUND); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_ERROR_THROWN_IN_SERVLET_FILTER_OUTSIDE_SPRING_returned_if_servlet_filter_trigger_occurs( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .header("throw-servlet-filter-exception", "true") + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleProjectApiError.ERROR_THROWN_IN_SERVLET_FILTER_OUTSIDE_SPRING); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_METHOD_NOT_ALLOWED_returned_if_known_path_is_requested_with_invalid_http_method( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .log().all() + .when() + .delete() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.METHOD_NOT_ALLOWED); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_sample_get_fails_with_NO_ACCEPTABLE_REPRESENTATION_if_passed_invalid_accept_header( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .accept(ContentType.BINARY) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.NO_ACCEPTABLE_REPRESENTATION); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_sample_post_fails_with_UNSUPPORTED_MEDIA_TYPE_if_passed_invalid_content_type( + ServerScenario scenario + ) throws IOException { + SampleModel requestPayload = randomizedSampleModel(); + String requestPayloadAsString = objectMapper.writeValueAsString(requestPayload); + + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .body(requestPayloadAsString) + .contentType(ContentType.TEXT) + .log().all() + .when() + .post() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.UNSUPPORTED_MEDIA_TYPE); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_MALFORMED_REQUEST_is_thrown_when_required_query_param_is_missing( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + WITH_REQUIRED_QUERY_PARAM_SUBPATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived( + response, + new ApiErrorWithMetadata( + SampleCoreApiError.MALFORMED_REQUEST, + Pair.of("missing_param_name", "requiredQueryParamValue"), + Pair.of("missing_param_type", "int"), + Pair.of("required_location", "query_param") + ) + ); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_TYPE_CONVERSION_ERROR_is_thrown_when_framework_cannot_convert_type_for_query_param( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + WITH_REQUIRED_QUERY_PARAM_SUBPATH) + .queryParam("requiredQueryParamValue", "not-an-integer") + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, new ApiErrorWithMetadata( + SampleCoreApiError.TYPE_CONVERSION_ERROR, + MapBuilder.builder("bad_property_name", (Object) "requiredQueryParamValue") + .put("bad_property_value","not-an-integer") + .put("required_location","query_param") + .put("required_type", "int") + .build() + )); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_MALFORMED_REQUEST_is_thrown_when_required_header_is_missing( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + WITH_REQUIRED_HEADER_SUBPATH) + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived( + response, + new ApiErrorWithMetadata( + SampleCoreApiError.MALFORMED_REQUEST, + Pair.of("missing_param_name", "requiredHeaderValue"), + Pair.of("missing_param_type", "int"), + Pair.of("required_location", "header") + ) + ); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_TYPE_CONVERSION_ERROR_is_thrown_when_framework_cannot_convert_type_for_header( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH + WITH_REQUIRED_HEADER_SUBPATH) + .header("requiredHeaderValue", "not-an-integer") + .log().all() + .when() + .get() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, new ApiErrorWithMetadata( + SampleCoreApiError.TYPE_CONVERSION_ERROR, + MapBuilder.builder("bad_property_name", (Object) "requiredHeaderValue") + .put("bad_property_value","not-an-integer") + .put("required_location","header") + .put("required_type", "int") + .build() + )); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_sample_post_fails_with_MISSING_EXPECTED_CONTENT_if_passed_empty_body( + ServerScenario scenario + ) { + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .contentType(ContentType.JSON) + .body("") + .log().all() + .when() + .post() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.MISSING_EXPECTED_CONTENT); + } + + @EnumSource(ServerScenario.class) + @ParameterizedTest + public void verify_sample_post_fails_with_MALFORMED_REQUEST_if_passed_bad_json_body( + ServerScenario scenario + ) throws IOException { + SampleModel originalValidPayloadObj = randomizedSampleModel(); + String originalValidPayloadAsString = objectMapper.writeValueAsString(originalValidPayloadObj); + @SuppressWarnings("unchecked") + Map badRequestPayloadAsMap = objectMapper.readValue(originalValidPayloadAsString, Map.class); + badRequestPayloadAsMap.put("throw_manual_error", "not-a-boolean"); + String badJsonPayloadAsString = objectMapper.writeValueAsString(badRequestPayloadAsMap); + + ExtractableResponse response = + given() + .baseUri("http://localhost") + .port(scenario.serverPort) + .basePath(SAMPLE_PATH) + .contentType(ContentType.JSON) + .body(badJsonPayloadAsString) + .log().all() + .when() + .post() + .then() + .log().all() + .extract(); + + verifyErrorReceived(response, SampleCoreApiError.MALFORMED_REQUEST); + } +} diff --git a/testonly/testonly-springboot3_2-webmvc/src/test/java/serverconfig/classpathscan/Springboot3_2WebMvcClasspathScanConfig.java b/testonly/testonly-springboot3_2-webmvc/src/test/java/serverconfig/classpathscan/Springboot3_2WebMvcClasspathScanConfig.java new file mode 100644 index 0000000..0d53a42 --- /dev/null +++ b/testonly/testonly-springboot3_2-webmvc/src/test/java/serverconfig/classpathscan/Springboot3_2WebMvcClasspathScanConfig.java @@ -0,0 +1,49 @@ +package serverconfig.classpathscan; + +import com.nike.backstopper.apierror.projectspecificinfo.ProjectApiErrors; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.core.Ordered; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import testonly.componenttest.spring.reusable.error.SampleProjectApiErrorsImpl; +import testonly.componenttest.spring.reusable.testutil.ExplodingServletFilter; + +/** + * Springboot config that uses {@link ComponentScan} to integrate Backstopper via classpath scanning of the + * {@code com.nike.backstopper} package. + * + * @author Nic Munroe + */ +@SpringBootApplication +@ComponentScan(basePackages = { + // Component scan the core Backstopper+Springboot1 support. + "com.nike.backstopper", + // Component scan the controller. + "testonly.componenttest.spring.reusable.controller" +}) +@SuppressWarnings("unused") +public class Springboot3_2WebMvcClasspathScanConfig { + + @Bean + public ProjectApiErrors getProjectApiErrors() { + return new SampleProjectApiErrorsImpl(); + } + + @Bean + public Validator getJsr303Validator() { + //noinspection resource + return Validation.buildDefaultValidatorFactory().getValidator(); + } + + @Bean + public FilterRegistrationBean explodingServletFilter() { + FilterRegistrationBean frb = new FilterRegistrationBean<>(new ExplodingServletFilter()); + frb.setOrder(Ordered.HIGHEST_PRECEDENCE); + return frb; + } +} diff --git a/testonly/testonly-springboot3_2-webmvc/src/test/java/serverconfig/directimport/Springboot3_2WebMvcDirectImportConfig.java b/testonly/testonly-springboot3_2-webmvc/src/test/java/serverconfig/directimport/Springboot3_2WebMvcDirectImportConfig.java new file mode 100644 index 0000000..c9137df --- /dev/null +++ b/testonly/testonly-springboot3_2-webmvc/src/test/java/serverconfig/directimport/Springboot3_2WebMvcDirectImportConfig.java @@ -0,0 +1,51 @@ +package serverconfig.directimport; + +import com.nike.backstopper.apierror.projectspecificinfo.ProjectApiErrors; +import com.nike.backstopper.handler.springboot.config.BackstopperSpringboot3WebMvcConfig; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import testonly.componenttest.spring.reusable.controller.SampleController; +import testonly.componenttest.spring.reusable.error.SampleProjectApiErrorsImpl; +import testonly.componenttest.spring.reusable.testutil.ExplodingServletFilter; + +/** + * Springboot config that uses {@link Import} to integrate Backstopper via direct import of + * {@link BackstopperSpringboot3WebMvcConfig}. + * + * @author Nic Munroe + */ +@SpringBootApplication +@Import({ + // Import core Backstopper+Springboot1 support. + BackstopperSpringboot3WebMvcConfig.class, + // Import the controller. + SampleController.class +}) +@SuppressWarnings("unused") +public class Springboot3_2WebMvcDirectImportConfig { + + @Bean + public ProjectApiErrors getProjectApiErrors() { + return new SampleProjectApiErrorsImpl(); + } + + @Bean + public Validator getJsr303Validator() { + //noinspection resource + return Validation.buildDefaultValidatorFactory().getValidator(); + } + + @Bean + public FilterRegistrationBean explodingServletFilter() { + FilterRegistrationBean frb = new FilterRegistrationBean<>(new ExplodingServletFilter()); + frb.setOrder(Ordered.HIGHEST_PRECEDENCE); + return frb; + } +} diff --git a/testonly/testonly-springboot3_2-webmvc/src/test/resources/logback.xml b/testonly/testonly-springboot3_2-webmvc/src/test/resources/logback.xml new file mode 100644 index 0000000..80adb28 --- /dev/null +++ b/testonly/testonly-springboot3_2-webmvc/src/test/resources/logback.xml @@ -0,0 +1,11 @@ + + + + %d{yyyy-MM-dd_HH:mm:ss.SSS} [%thread] |-%-5level %logger{36} - %msg%n + + + + + + + \ No newline at end of file