From 4e7e08da9f3d84f072ed86ad0a2ef4540603b6ac Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Tue, 21 Nov 2023 11:57:50 +0100 Subject: [PATCH 01/20] add integration tests --- build.gradle | 3 + ...penApiInteractionValidatorWrapperTest.java | 3 +- .../spring-boot-starter-web/build.gradle | 51 ++++++++ .../integration/NoExceptionHandlerTest.java | 97 +++++++++++++++ .../SpringBootTestApplication.java | 11 ++ .../integration/WithExceptionHandlerTest.java | 110 ++++++++++++++++++ .../controller/SomeRestController.java | 26 +++++ .../WithResponseStatusException.java | 12 ++ .../WithoutResponseStatusException.java | 8 ++ .../src/test/resources/application.yaml | 4 + .../src/test/resources/openapi.yaml | 66 +++++++++++ 11 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestApplication.java create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/SomeRestController.java create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithResponseStatusException.java create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithoutResponseStatusException.java create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/resources/application.yaml create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml diff --git a/build.gradle b/build.gradle index 94821141..6df508c0 100644 --- a/build.gradle +++ b/build.gradle @@ -70,6 +70,9 @@ subprojects { toolVersion = libs.versions.checkstyle.get() configDirectory.set(file("$rootProject.projectDir/config")) checkstyleMain.source = "src/main/java" + checkstyleMain.exclude('**/build/generated/**') + checkstyleTest.source = "src/main/java" + checkstyleTest.exclude('**/build/generated/**') } pmd { diff --git a/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/validator/MultipleSpecOpenApiInteractionValidatorWrapperTest.java b/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/validator/MultipleSpecOpenApiInteractionValidatorWrapperTest.java index 1ee1354c..df4de8bb 100644 --- a/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/validator/MultipleSpecOpenApiInteractionValidatorWrapperTest.java +++ b/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/validator/MultipleSpecOpenApiInteractionValidatorWrapperTest.java @@ -61,8 +61,7 @@ private static MockValidatorResult mockValidator() { var catchAllValidationReport = mock(ValidationReport.class); when(catchAllValidator.validateRequest(any())).thenReturn(catchAllValidationReport); when(catchAllValidator.validateResponse(any(), any(), any())).thenReturn(catchAllValidationReport); - MockValidatorResult result = new MockValidatorResult(catchAllValidator, catchAllValidationReport); - return result; + return new MockValidatorResult(catchAllValidator, catchAllValidationReport); } private record MockValidatorResult( diff --git a/spring-boot-starter/spring-boot-starter-web/build.gradle b/spring-boot-starter/spring-boot-starter-web/build.gradle index 78822d15..5ddaa41c 100644 --- a/spring-boot-starter/spring-boot-starter-web/build.gradle +++ b/spring-boot-starter/spring-boot-starter-web/build.gradle @@ -2,6 +2,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { alias(libs.plugins.spring.boot) apply false + alias(libs.plugins.openapi.generator) } apply from: "${rootDir}/gradle/publish-module.gradle" @@ -21,7 +22,57 @@ dependencies { implementation(libs.find.bugs) testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-starter-validation' testImplementation 'org.springframework:spring-web' testImplementation 'org.springframework:spring-webmvc' testImplementation 'org.apache.tomcat.embed:tomcat-embed-core' // For jakarta.servlet.ServletContext + testImplementation(libs.jakarta.validation.api) + testImplementation(libs.swagger.annotations) + testImplementation(libs.openapi.tools.jacksonDatabindNullable) } + + +def generatedSourceDirectory = "$buildDir/generated/openapi" +sourceSets { + test { + java { + srcDir generatedSourceDirectory + '/src/test/java' + } + } +} + +openApiValidate { + inputSpec = "$projectDir/src/test/resources/openapi.yaml" + recommend = true +} + +openApiGenerate { + generatorName = "spring" + inputSpec = "$projectDir/src/test/resources/openapi.yaml" + outputDir = generatedSourceDirectory + apiPackage = "com.getyourguide.openapi.validation.example.openapi" + invokerPackage = "com.getyourguide.openapi.validation.example.openapi" + modelPackage = "com.getyourguide.openapi.validation.example.openapi.model" + configOptions = [ + useSpringBoot3 : "true", + dateLibrary : "java8", + performBeanValidation : "true", + hideGenerationTimestamp: "true", + serializableModel : "true", + interfaceOnly : "true", + skipDefaultInterface : "true", + useTags : "true" + ] +} + +tasks.register("openApiGenerateMove") { + doLast { + file("$generatedSourceDirectory/src/main").renameTo(file("$generatedSourceDirectory/src/test")) + } +} + + + +tasks.openApiGenerate.dependsOn tasks.openApiValidate +tasks.openApiGenerateMove.dependsOn tasks.openApiGenerate +tasks.compileTestJava.dependsOn tasks.openApiGenerateMove diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java new file mode 100644 index 00000000..dc1ee0f4 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java @@ -0,0 +1,97 @@ +package com.getyourguide.openapi.validation.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; +import com.getyourguide.openapi.validation.integration.exception.WithoutResponseStatusException; +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ExtendWith(SpringExtension.class) +public class NoExceptionHandlerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void whenTestSuccessfulResponseThenReturns200() throws Exception { + mockMvc.perform(get("/test").accept("application/json")) + .andDo(print()) + .andExpectAll( + status().isOk(), + jsonPath("$.value").value("test") + ); + } + + @Test + void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() throws Exception { + mockMvc.perform(get("/test").queryParam("date", "not-a-date").contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().is4xxClientError()); + Thread.sleep(10); + + // TODO check there is no reported violation if spec has correct response in there + } + + @Test + void whenTestThrowExceptionWithResponseStatusThenReturns500WithoutViolationLogged() + throws Exception { + // Note: This case tests that an endpoint that throws an exception that is not handled by any code (no global error handler either) + // is correctly intercepted by the library with the response body. + mockMvc + .perform( + get("/test").queryParam("testCase", "throwExceptionWithResponseStatus") + .contentType(MediaType.APPLICATION_JSON) + ) + .andDo(print()) + .andExpectAll( + status().is5xxServerError(), + result -> assertEquals(WithResponseStatusException.class, result.getResolvedException().getClass()) + ); + Thread.sleep(10); + + // TODO check there is no reported violation if spec has correct response in there + + } + + // Note: Throwing a RuntimeException that has no `@ResponseStatus` annotation will cause `.perform()` to throw. + + @Test + void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViolationLogged() + throws Exception { + // Note: This case tests that an endpoint that throws an exception that is not handled by any code (no global error handler either) + // is correctly intercepted by the library with the response body. + + var exception = assertThrows(ServletException.class, () -> { + mockMvc + .perform( + get("/test").queryParam("testCase", "throwExceptionWithoutResponseStatus") + .contentType(MediaType.APPLICATION_JSON) + ) + .andDo(print()) + .andExpect(status().is5xxServerError()); + }); + Thread.sleep(10); + + var cause = exception.getCause(); + assertEquals(WithoutResponseStatusException.class, cause.getClass()); + assertEquals("Unhandled exception", cause.getMessage()); + + // TODO check there is no reported violation if spec has correct response in there + } + +} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestApplication.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestApplication.java new file mode 100644 index 00000000..be2a0f45 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestApplication.java @@ -0,0 +1,11 @@ +package com.getyourguide.openapi.validation.integration; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SpringBootTestApplication { + public static void main(String[] args) { + SpringApplication.run(SpringBootTestApplication.class, args); + } +} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java new file mode 100644 index 00000000..352298b0 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java @@ -0,0 +1,110 @@ +package com.getyourguide.openapi.validation.integration; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.getyourguide.openapi.validation.example.openapi.model.BadRequestResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@SpringBootTest(classes = {SpringBootTestApplication.class, + WithExceptionHandlerTest.ExceptionHandlerConfiguration.class}) +@AutoConfigureMockMvc +@ExtendWith(SpringExtension.class) +public class WithExceptionHandlerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void whenTestSuccessfulResponseThenReturns200() throws Exception { + mockMvc.perform(get("/test").accept("application/json")) + .andDo(print()) + .andExpectAll( + status().isOk(), + jsonPath("$.value").value("test") + ); + } + + @Test + void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() throws Exception { + mockMvc.perform(get("/test").queryParam("date", "not-a-date").contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpectAll( + status().is4xxClientError(), + jsonPath("$.error").value("Invalid parameter") + ); + Thread.sleep(10); + + // TODO check there is no reported violation if spec has correct response in there + } + + @Test + void whenTestThrowExceptionWithResponseStatusThenReturns500WithoutViolationLogged() + throws Exception { + // Note: This case tests that an endpoint that throws an exception that is not handled by any code (no global error handler either) + // is correctly intercepted by the library with the response body. + mockMvc + .perform( + get("/test").queryParam("testCase", "throwExceptionWithResponseStatus") + .contentType(MediaType.APPLICATION_JSON) + ) + .andDo(print()) + .andExpectAll( + status().is5xxServerError(), + jsonPath("$.error").value("Unhandled exception") + ); + Thread.sleep(10); + + // TODO check there is no reported violation if spec has correct response in there + + } + + // Note: Throwing a RuntimeException that has no `@ResponseStatus` annotation will cause `.perform()` to throw. + + @Test + void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViolationLogged() + throws Exception { + // Note: This case tests that an endpoint that throws an exception that is not handled by any code (no global error handler either) + // is correctly intercepted by the library with the response body. + + mockMvc + .perform( + get("/test").queryParam("testCase", "throwExceptionWithoutResponseStatus") + .contentType(MediaType.APPLICATION_JSON) + ) + .andDo(print()) + .andExpectAll( + status().is5xxServerError(), + jsonPath("$.error").value("Unhandled exception") + ); + Thread.sleep(10); + + // TODO check there is no reported violation if spec has correct response in there + } + + @ControllerAdvice + public static class ExceptionHandlerConfiguration { + @ExceptionHandler(Exception.class) + public ResponseEntity handle(Exception exception) { + return ResponseEntity.internalServerError().body(new BadRequestResponse().error("Unhandled exception")); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handle(MethodArgumentTypeMismatchException exception) { + return ResponseEntity.badRequest().body(new BadRequestResponse().error("Invalid parameter")); + } + } +} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/SomeRestController.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/SomeRestController.java new file mode 100644 index 00000000..e2e6e9b1 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/SomeRestController.java @@ -0,0 +1,26 @@ +package com.getyourguide.openapi.validation.integration.controller; + +import com.getyourguide.openapi.validation.example.openapi.DefaultApi; +import com.getyourguide.openapi.validation.example.openapi.model.TestResponse; +import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; +import com.getyourguide.openapi.validation.integration.exception.WithoutResponseStatusException; +import java.time.LocalDate; +import java.util.Objects; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SomeRestController implements DefaultApi { + + @Override + public ResponseEntity getTest(String testCase, LocalDate date) { + if (Objects.equals(testCase, "throwExceptionWithResponseStatus")) { + throw new WithResponseStatusException("Unhandled exception"); + } + if (Objects.equals(testCase, "throwExceptionWithoutResponseStatus")) { + throw new WithoutResponseStatusException("Unhandled exception"); + } + + return ResponseEntity.ok(new TestResponse().value("test")); + } +} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithResponseStatusException.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithResponseStatusException.java new file mode 100644 index 00000000..751b3384 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithResponseStatusException.java @@ -0,0 +1,12 @@ +package com.getyourguide.openapi.validation.integration.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) +public class WithResponseStatusException extends RuntimeException { + + public WithResponseStatusException(String message) { + super(message); + } +} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithoutResponseStatusException.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithoutResponseStatusException.java new file mode 100644 index 00000000..175f48f5 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithoutResponseStatusException.java @@ -0,0 +1,8 @@ +package com.getyourguide.openapi.validation.integration.exception; + +public class WithoutResponseStatusException extends RuntimeException { + + public WithoutResponseStatusException(String message) { + super(message); + } +} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/resources/application.yaml b/spring-boot-starter/spring-boot-starter-web/src/test/resources/application.yaml new file mode 100644 index 00000000..7a7ceee9 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/resources/application.yaml @@ -0,0 +1,4 @@ +# Required as for some reason it tries to load some things from local code and as well from locally built jar +spring.main.allow-bean-definition-overriding: true + +openapi.validation.sample-rate: 1 diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml b/spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml new file mode 100644 index 00000000..9028081e --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml @@ -0,0 +1,66 @@ +--- +openapi: 3.0.3 +info: + title: GYG OpenAPI validator example + version: 1.0.0 +paths: + "/test": + get: + description: Get test + operationId: getTest + parameters: + - description: Test case + example: 'fail' + in: query + name: testCase + schema: + type: string + enum: + - throwExceptionWithResponseStatus + - throwExceptionWithoutResponseStatus + - description: Date + example: '2023-11-20' + in: query + name: date + schema: + type: string + format: date + responses: + '200': + description: Successful response + content: + application/json: + schema: + "$ref": "#/components/schemas/TestResponse" + '400': + description: Bad request response + content: + application/json: + schema: + "$ref": "#/components/schemas/BadRequestResponse" + '500': + description: Internal server error +components: + schemas: + TestResponse: + description: Index response + type: object + properties: + value: + description: Value + example: some value + nullable: false + type: string + required: + - value + BadRequestResponse: + description: Bad request response + type: object + properties: + error: + description: Description of the error + example: Missing query parameter fromDate + nullable: false + type: string + required: + - error From 3ba070261ef44a216f735c66a2e2649ee97833a5 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Tue, 21 Nov 2023 14:04:26 +0100 Subject: [PATCH 02/20] add violation logging and make test fail that we need to fix --- .../integration/NoExceptionHandlerTest.java | 54 ++++++++++++++----- .../integration/WithExceptionHandlerTest.java | 50 ++++++++++++----- .../WithResponseStatusException.java | 2 +- .../openapi/TestViolationLogger.java | 23 ++++++++ 4 files changed, 102 insertions(+), 27 deletions(-) create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/openapi/TestViolationLogger.java diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java index dc1ee0f4..a1f75682 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java @@ -4,12 +4,18 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.getyourguide.openapi.validation.api.model.OpenApiViolation; import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; import com.getyourguide.openapi.validation.integration.exception.WithoutResponseStatusException; +import com.getyourguide.openapi.validation.integration.openapi.TestViolationLogger; import jakarta.servlet.ServletException; +import java.util.Optional; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -27,28 +33,44 @@ public class NoExceptionHandlerTest { @Autowired private MockMvc mockMvc; + @Autowired + private TestViolationLogger openApiViolationLogger; + + @BeforeEach + public void setup() { + openApiViolationLogger.clearViolations(); + } + @Test - void whenTestSuccessfulResponseThenReturns200() throws Exception { + public void whenTestSuccessfulResponseThenReturns200() throws Exception { mockMvc.perform(get("/test").accept("application/json")) .andDo(print()) .andExpectAll( status().isOk(), jsonPath("$.value").value("test") ); + + assertEquals(0, openApiViolationLogger.getViolations().size()); } @Test - void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() throws Exception { + public void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() throws Exception { mockMvc.perform(get("/test").queryParam("date", "not-a-date").contentType(MediaType.APPLICATION_JSON)) .andDo(print()) - .andExpect(status().is4xxClientError()); - Thread.sleep(10); + .andExpectAll( + status().is4xxClientError(), + content().string(Matchers.blankOrNullString()) + ); + Thread.sleep(100); - // TODO check there is no reported violation if spec has correct response in there + assertEquals(1, openApiViolationLogger.getViolations().size()); + var violation = openApiViolationLogger.getViolations().get(0); + assertEquals("validation.response.body.missing", violation.getRule()); + assertEquals(Optional.of(400), violation.getResponseStatus()); } @Test - void whenTestThrowExceptionWithResponseStatusThenReturns500WithoutViolationLogged() + public void whenTestThrowExceptionWithResponseStatusThenReturns400WithoutViolationLogged() throws Exception { // Note: This case tests that an endpoint that throws an exception that is not handled by any code (no global error handler either) // is correctly intercepted by the library with the response body. @@ -59,19 +81,24 @@ void whenTestThrowExceptionWithResponseStatusThenReturns500WithoutViolationLogge ) .andDo(print()) .andExpectAll( - status().is5xxServerError(), + status().is4xxClientError(), + content().string(Matchers.blankOrNullString()), result -> assertEquals(WithResponseStatusException.class, result.getResolvedException().getClass()) ); - Thread.sleep(10); + Thread.sleep(100); + openApiViolationLogger.getViolations().stream().map(OpenApiViolation::getLogMessage).forEach(System.out::println); // TODO check there is no reported violation if spec has correct response in there - + assertEquals(1, openApiViolationLogger.getViolations().size()); + var violation = openApiViolationLogger.getViolations().get(0); + assertEquals("validation.response.body.missing", violation.getRule()); + assertEquals(Optional.of(400), violation.getResponseStatus()); } // Note: Throwing a RuntimeException that has no `@ResponseStatus` annotation will cause `.perform()` to throw. @Test - void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViolationLogged() + public void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViolationLogged() throws Exception { // Note: This case tests that an endpoint that throws an exception that is not handled by any code (no global error handler either) // is correctly intercepted by the library with the response body. @@ -85,13 +112,16 @@ void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViolationLo .andDo(print()) .andExpect(status().is5xxServerError()); }); - Thread.sleep(10); + Thread.sleep(100); var cause = exception.getCause(); assertEquals(WithoutResponseStatusException.class, cause.getClass()); assertEquals("Unhandled exception", cause.getMessage()); - // TODO check there is no reported violation if spec has correct response in there + assertEquals(1, openApiViolationLogger.getViolations().size()); + var violation = openApiViolationLogger.getViolations().get(0); + assertEquals("validation.response.body.missing", violation.getRule()); + assertEquals(Optional.of(500), violation.getResponseStatus()); } } diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java index 352298b0..f0f9f2ac 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java @@ -1,11 +1,18 @@ package com.getyourguide.openapi.validation.integration; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.getyourguide.openapi.validation.example.openapi.model.BadRequestResponse; +import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; +import com.getyourguide.openapi.validation.integration.openapi.TestViolationLogger; +import java.util.Optional; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -28,31 +35,42 @@ public class WithExceptionHandlerTest { @Autowired private MockMvc mockMvc; + @Autowired + private TestViolationLogger openApiViolationLogger; + + @BeforeEach + public void setup() { + openApiViolationLogger.clearViolations(); + } + @Test - void whenTestSuccessfulResponseThenReturns200() throws Exception { + public void whenTestSuccessfulResponseThenReturns200() throws Exception { mockMvc.perform(get("/test").accept("application/json")) .andDo(print()) .andExpectAll( status().isOk(), jsonPath("$.value").value("test") ); + + + assertEquals(0, openApiViolationLogger.getViolations().size()); } @Test - void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() throws Exception { + public void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() throws Exception { mockMvc.perform(get("/test").queryParam("date", "not-a-date").contentType(MediaType.APPLICATION_JSON)) .andDo(print()) .andExpectAll( status().is4xxClientError(), jsonPath("$.error").value("Invalid parameter") ); - Thread.sleep(10); + Thread.sleep(100); - // TODO check there is no reported violation if spec has correct response in there + assertEquals(0, openApiViolationLogger.getViolations().size()); } @Test - void whenTestThrowExceptionWithResponseStatusThenReturns500WithoutViolationLogged() + public void whenTestThrowExceptionWithResponseStatusThenReturns400WithoutViolationLogged() throws Exception { // Note: This case tests that an endpoint that throws an exception that is not handled by any code (no global error handler either) // is correctly intercepted by the library with the response body. @@ -63,19 +81,18 @@ void whenTestThrowExceptionWithResponseStatusThenReturns500WithoutViolationLogge ) .andDo(print()) .andExpectAll( - status().is5xxServerError(), + status().is4xxClientError(), jsonPath("$.error").value("Unhandled exception") ); - Thread.sleep(10); - - // TODO check there is no reported violation if spec has correct response in there + Thread.sleep(100); + assertEquals(0, openApiViolationLogger.getViolations().size()); } // Note: Throwing a RuntimeException that has no `@ResponseStatus` annotation will cause `.perform()` to throw. @Test - void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViolationLogged() + public void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViolationLogged() throws Exception { // Note: This case tests that an endpoint that throws an exception that is not handled by any code (no global error handler either) // is correctly intercepted by the library with the response body. @@ -88,18 +105,23 @@ void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViolationLo .andDo(print()) .andExpectAll( status().is5xxServerError(), - jsonPath("$.error").value("Unhandled exception") + content().string(Matchers.blankOrNullString()) ); - Thread.sleep(10); + Thread.sleep(100); - // TODO check there is no reported violation if spec has correct response in there + assertEquals(0, openApiViolationLogger.getViolations().size()); } @ControllerAdvice public static class ExceptionHandlerConfiguration { @ExceptionHandler(Exception.class) public ResponseEntity handle(Exception exception) { - return ResponseEntity.internalServerError().body(new BadRequestResponse().error("Unhandled exception")); + return ResponseEntity.internalServerError().build(); + } + + @ExceptionHandler(WithResponseStatusException.class) + public ResponseEntity handle(WithResponseStatusException exception) { + return ResponseEntity.badRequest().body(new BadRequestResponse().error("Unhandled exception")); } @ExceptionHandler(MethodArgumentTypeMismatchException.class) diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithResponseStatusException.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithResponseStatusException.java index 751b3384..c3537a32 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithResponseStatusException.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithResponseStatusException.java @@ -3,7 +3,7 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; -@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) +@ResponseStatus(HttpStatus.BAD_REQUEST) public class WithResponseStatusException extends RuntimeException { public WithResponseStatusException(String message) { diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/openapi/TestViolationLogger.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/openapi/TestViolationLogger.java new file mode 100644 index 00000000..34a9da16 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/openapi/TestViolationLogger.java @@ -0,0 +1,23 @@ +package com.getyourguide.openapi.validation.integration.openapi; + +import com.getyourguide.openapi.validation.api.log.ViolationLogger; +import com.getyourguide.openapi.validation.api.model.OpenApiViolation; +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import org.springframework.stereotype.Component; + +@Getter +@Component +public class TestViolationLogger implements ViolationLogger { + private final List violations = new ArrayList<>(); + + @Override + public void log(OpenApiViolation violation) { + violations.add(violation); + } + + public void clearViolations() { + violations.clear(); + } +} From e3da02174ae8f4b31a9b2d0c8d36523457b2bf40 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Tue, 21 Nov 2023 14:45:07 +0100 Subject: [PATCH 03/20] improve code --- .../validation/integration/NoExceptionHandlerTest.java | 7 ------- .../validation/integration/WithExceptionHandlerTest.java | 6 ------ 2 files changed, 13 deletions(-) diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java index a1f75682..5cec2496 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java @@ -3,12 +3,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.getyourguide.openapi.validation.api.model.OpenApiViolation; import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; import com.getyourguide.openapi.validation.integration.exception.WithoutResponseStatusException; import com.getyourguide.openapi.validation.integration.openapi.TestViolationLogger; @@ -44,7 +42,6 @@ public void setup() { @Test public void whenTestSuccessfulResponseThenReturns200() throws Exception { mockMvc.perform(get("/test").accept("application/json")) - .andDo(print()) .andExpectAll( status().isOk(), jsonPath("$.value").value("test") @@ -56,7 +53,6 @@ public void whenTestSuccessfulResponseThenReturns200() throws Exception { @Test public void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() throws Exception { mockMvc.perform(get("/test").queryParam("date", "not-a-date").contentType(MediaType.APPLICATION_JSON)) - .andDo(print()) .andExpectAll( status().is4xxClientError(), content().string(Matchers.blankOrNullString()) @@ -79,7 +75,6 @@ public void whenTestThrowExceptionWithResponseStatusThenReturns400WithoutViolati get("/test").queryParam("testCase", "throwExceptionWithResponseStatus") .contentType(MediaType.APPLICATION_JSON) ) - .andDo(print()) .andExpectAll( status().is4xxClientError(), content().string(Matchers.blankOrNullString()), @@ -87,7 +82,6 @@ public void whenTestThrowExceptionWithResponseStatusThenReturns400WithoutViolati ); Thread.sleep(100); - openApiViolationLogger.getViolations().stream().map(OpenApiViolation::getLogMessage).forEach(System.out::println); // TODO check there is no reported violation if spec has correct response in there assertEquals(1, openApiViolationLogger.getViolations().size()); var violation = openApiViolationLogger.getViolations().get(0); @@ -109,7 +103,6 @@ public void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViol get("/test").queryParam("testCase", "throwExceptionWithoutResponseStatus") .contentType(MediaType.APPLICATION_JSON) ) - .andDo(print()) .andExpect(status().is5xxServerError()); }); Thread.sleep(100); diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java index f0f9f2ac..3556f781 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java @@ -2,7 +2,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -10,7 +9,6 @@ import com.getyourguide.openapi.validation.example.openapi.model.BadRequestResponse; import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; import com.getyourguide.openapi.validation.integration.openapi.TestViolationLogger; -import java.util.Optional; import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -46,7 +44,6 @@ public void setup() { @Test public void whenTestSuccessfulResponseThenReturns200() throws Exception { mockMvc.perform(get("/test").accept("application/json")) - .andDo(print()) .andExpectAll( status().isOk(), jsonPath("$.value").value("test") @@ -59,7 +56,6 @@ public void whenTestSuccessfulResponseThenReturns200() throws Exception { @Test public void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() throws Exception { mockMvc.perform(get("/test").queryParam("date", "not-a-date").contentType(MediaType.APPLICATION_JSON)) - .andDo(print()) .andExpectAll( status().is4xxClientError(), jsonPath("$.error").value("Invalid parameter") @@ -79,7 +75,6 @@ public void whenTestThrowExceptionWithResponseStatusThenReturns400WithoutViolati get("/test").queryParam("testCase", "throwExceptionWithResponseStatus") .contentType(MediaType.APPLICATION_JSON) ) - .andDo(print()) .andExpectAll( status().is4xxClientError(), jsonPath("$.error").value("Unhandled exception") @@ -102,7 +97,6 @@ public void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViol get("/test").queryParam("testCase", "throwExceptionWithoutResponseStatus") .contentType(MediaType.APPLICATION_JSON) ) - .andDo(print()) .andExpectAll( status().is5xxServerError(), content().string(Matchers.blankOrNullString()) From 41aaba960a133b8b843cb5583879a6880777d8d2 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 10:22:48 +0100 Subject: [PATCH 04/20] update integration tests --- ...toConfigurationApplicationContextTest.java | 8 +-- ... => ExceptionsNoExceptionHandlerTest.java} | 27 +++------- ...> ExceptionsWithExceptionHandlerTest.java} | 32 ++++-------- .../OpenApiValidationIntegrationTest.java | 49 +++++++++++++++++++ .../SpringBootTestApplication.java | 11 ----- .../SpringBootTestConfiguration.java | 15 ++++++ ...roller.java => DefaultRestController.java} | 7 +-- .../src/test/resources/openapi.yaml | 26 +++++++++- 8 files changed, 112 insertions(+), 63 deletions(-) rename spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/{NoExceptionHandlerTest.java => ExceptionsNoExceptionHandlerTest.java} (81%) rename spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/{WithExceptionHandlerTest.java => ExceptionsWithExceptionHandlerTest.java} (78%) create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java delete mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestApplication.java create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java rename spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/{SomeRestController.java => DefaultRestController.java} (81%) diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/autoconfigure/SpringWebLibraryAutoConfigurationApplicationContextTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/autoconfigure/SpringWebLibraryAutoConfigurationApplicationContextTest.java index d0b35ea8..29bc530c 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/autoconfigure/SpringWebLibraryAutoConfigurationApplicationContextTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/autoconfigure/SpringWebLibraryAutoConfigurationApplicationContextTest.java @@ -2,7 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.getyourguide.openapi.validation.filter.OpenApiValidationHttpFilter; import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -11,6 +10,7 @@ import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.mock.web.MockServletContext; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; class SpringWebLibraryAutoConfigurationApplicationContextTest { @@ -26,21 +26,21 @@ void tearDown() { void webApplicationWithServletContext() { context = servletWebApplicationContext(); - assertThat(context.getBeansOfType(OpenApiValidationHttpFilter.class)).size().isEqualTo(1); + assertThat(context.getBeansOfType(WebMvcConfigurer.class)).size().isEqualTo(1); } @Test void webApplicationWithReactiveContext() { context = reactiveWebApplicationContext(); - assertThat(context.getBeansOfType(OpenApiValidationHttpFilter.class)).size().isEqualTo(0); + assertThat(context.getBeansOfType(WebMvcConfigurer.class)).size().isEqualTo(0); } @Test void nonWebApplicationContextShouldHaveNoFilterBeans() { context = nonWebApplicationContext(); - assertThat(context.getBeansOfType(OpenApiValidationHttpFilter.class)).size().isEqualTo(0); + assertThat(context.getBeansOfType(WebMvcConfigurer.class)).size().isEqualTo(0); } private AnnotationConfigServletWebApplicationContext servletWebApplicationContext() { diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java similarity index 81% rename from spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java rename to spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java index 5cec2496..79f45986 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/NoExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java @@ -4,7 +4,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; @@ -26,7 +25,10 @@ @SpringBootTest @AutoConfigureMockMvc @ExtendWith(SpringExtension.class) -public class NoExceptionHandlerTest { +public class ExceptionsNoExceptionHandlerTest { + + // These test cases test that requests to an endpoint that throws an exception + // that is not handled by any code (no global error handler either) is correctly intercepted by the library. @Autowired private MockMvc mockMvc; @@ -39,17 +41,6 @@ public void setup() { openApiViolationLogger.clearViolations(); } - @Test - public void whenTestSuccessfulResponseThenReturns200() throws Exception { - mockMvc.perform(get("/test").accept("application/json")) - .andExpectAll( - status().isOk(), - jsonPath("$.value").value("test") - ); - - assertEquals(0, openApiViolationLogger.getViolations().size()); - } - @Test public void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() throws Exception { mockMvc.perform(get("/test").queryParam("date", "not-a-date").contentType(MediaType.APPLICATION_JSON)) @@ -59,6 +50,7 @@ public void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() thro ); Thread.sleep(100); + // No body as this one is not handled by an exception handler and therefore default body is added by spring boot assertEquals(1, openApiViolationLogger.getViolations().size()); var violation = openApiViolationLogger.getViolations().get(0); assertEquals("validation.response.body.missing", violation.getRule()); @@ -68,8 +60,6 @@ public void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() thro @Test public void whenTestThrowExceptionWithResponseStatusThenReturns400WithoutViolationLogged() throws Exception { - // Note: This case tests that an endpoint that throws an exception that is not handled by any code (no global error handler either) - // is correctly intercepted by the library with the response body. mockMvc .perform( get("/test").queryParam("testCase", "throwExceptionWithResponseStatus") @@ -82,7 +72,7 @@ public void whenTestThrowExceptionWithResponseStatusThenReturns400WithoutViolati ); Thread.sleep(100); - // TODO check there is no reported violation if spec has correct response in there + // No body as this one is not handled by an exception handler and therefore default body is added by spring boot assertEquals(1, openApiViolationLogger.getViolations().size()); var violation = openApiViolationLogger.getViolations().get(0); assertEquals("validation.response.body.missing", violation.getRule()); @@ -94,9 +84,6 @@ public void whenTestThrowExceptionWithResponseStatusThenReturns400WithoutViolati @Test public void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViolationLogged() throws Exception { - // Note: This case tests that an endpoint that throws an exception that is not handled by any code (no global error handler either) - // is correctly intercepted by the library with the response body. - var exception = assertThrows(ServletException.class, () -> { mockMvc .perform( @@ -111,10 +98,10 @@ public void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViol assertEquals(WithoutResponseStatusException.class, cause.getClass()); assertEquals("Unhandled exception", cause.getMessage()); + // No body as this one is not handled by an exception handler and therefore default body is added by spring boot assertEquals(1, openApiViolationLogger.getViolations().size()); var violation = openApiViolationLogger.getViolations().get(0); assertEquals("validation.response.body.missing", violation.getRule()); assertEquals(Optional.of(500), violation.getResponseStatus()); } - } diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java similarity index 78% rename from spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java rename to spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java index 3556f781..cad5b0eb 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/WithExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java @@ -9,6 +9,7 @@ import com.getyourguide.openapi.validation.example.openapi.model.BadRequestResponse; import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; import com.getyourguide.openapi.validation.integration.openapi.TestViolationLogger; +import java.util.Optional; import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -24,11 +25,11 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -@SpringBootTest(classes = {SpringBootTestApplication.class, - WithExceptionHandlerTest.ExceptionHandlerConfiguration.class}) +@SpringBootTest(classes = {SpringBootTestConfiguration.class, + ExceptionsWithExceptionHandlerTest.ExceptionHandlerConfiguration.class}) @AutoConfigureMockMvc @ExtendWith(SpringExtension.class) -public class WithExceptionHandlerTest { +public class ExceptionsWithExceptionHandlerTest { @Autowired private MockMvc mockMvc; @@ -41,18 +42,6 @@ public void setup() { openApiViolationLogger.clearViolations(); } - @Test - public void whenTestSuccessfulResponseThenReturns200() throws Exception { - mockMvc.perform(get("/test").accept("application/json")) - .andExpectAll( - status().isOk(), - jsonPath("$.value").value("test") - ); - - - assertEquals(0, openApiViolationLogger.getViolations().size()); - } - @Test public void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() throws Exception { mockMvc.perform(get("/test").queryParam("date", "not-a-date").contentType(MediaType.APPLICATION_JSON)) @@ -68,8 +57,6 @@ public void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() thro @Test public void whenTestThrowExceptionWithResponseStatusThenReturns400WithoutViolationLogged() throws Exception { - // Note: This case tests that an endpoint that throws an exception that is not handled by any code (no global error handler either) - // is correctly intercepted by the library with the response body. mockMvc .perform( get("/test").queryParam("testCase", "throwExceptionWithResponseStatus") @@ -84,14 +71,9 @@ public void whenTestThrowExceptionWithResponseStatusThenReturns400WithoutViolati assertEquals(0, openApiViolationLogger.getViolations().size()); } - // Note: Throwing a RuntimeException that has no `@ResponseStatus` annotation will cause `.perform()` to throw. - @Test public void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViolationLogged() throws Exception { - // Note: This case tests that an endpoint that throws an exception that is not handled by any code (no global error handler either) - // is correctly intercepted by the library with the response body. - mockMvc .perform( get("/test").queryParam("testCase", "throwExceptionWithoutResponseStatus") @@ -103,7 +85,11 @@ public void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViol ); Thread.sleep(100); - assertEquals(0, openApiViolationLogger.getViolations().size()); + // No body as this one is not handled by an exception handler and therefore default body is added by spring boot + assertEquals(1, openApiViolationLogger.getViolations().size()); + var violation = openApiViolationLogger.getViolations().get(0); + assertEquals("validation.response.body.missing", violation.getRule()); + assertEquals(Optional.of(500), violation.getResponseStatus()); } @ControllerAdvice diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java new file mode 100644 index 00000000..4f86d49f --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java @@ -0,0 +1,49 @@ +package com.getyourguide.openapi.validation.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.getyourguide.openapi.validation.integration.openapi.TestViolationLogger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ExtendWith(SpringExtension.class) +public class OpenApiValidationIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private TestViolationLogger openApiViolationLogger; + + @BeforeEach + public void setup() { + openApiViolationLogger.clearViolations(); + } + + @Test + public void whenTestSuccessfulResponseThenReturns200() throws Exception { + mockMvc.perform(get("/test").accept("application/json")) + .andExpectAll( + status().isOk(), + jsonPath("$.value").value("test") + ); + + assertEquals(0, openApiViolationLogger.getViolations().size()); + } + + // TODO Add test with request violation + // TODO Add test with response violation + // TODO Add test with request & response violation in same request + // TODO Add test that fails on request violation immediately (maybe needs separate test class & setup) +} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestApplication.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestApplication.java deleted file mode 100644 index be2a0f45..00000000 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestApplication.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.getyourguide.openapi.validation.integration; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class SpringBootTestApplication { - public static void main(String[] args) { - SpringApplication.run(SpringBootTestApplication.class, args); - } -} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java new file mode 100644 index 00000000..b1fa0a27 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java @@ -0,0 +1,15 @@ +package com.getyourguide.openapi.validation.integration; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.TypeExcludeFilter; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; + +@SpringBootConfiguration +@EnableAutoConfiguration +@ComponentScan(excludeFilters = {@ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class), + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class)}) +public class SpringBootTestConfiguration { +} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/SomeRestController.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java similarity index 81% rename from spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/SomeRestController.java rename to spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java index e2e6e9b1..fdbaba3d 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/SomeRestController.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java @@ -10,10 +10,10 @@ import org.springframework.web.bind.annotation.RestController; @RestController -public class SomeRestController implements DefaultApi { +public class DefaultRestController implements DefaultApi { @Override - public ResponseEntity getTest(String testCase, LocalDate date) { + public ResponseEntity getTest(String testCase, LocalDate date, String value) { if (Objects.equals(testCase, "throwExceptionWithResponseStatus")) { throw new WithResponseStatusException("Unhandled exception"); } @@ -21,6 +21,7 @@ public ResponseEntity getTest(String testCase, LocalDate date) { throw new WithoutResponseStatusException("Unhandled exception"); } - return ResponseEntity.ok(new TestResponse().value("test")); + var responseValue = value != null ? value : "test"; + return ResponseEntity.ok(new TestResponse().value(responseValue)); } } diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml b/spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml index 9028081e..90a40a89 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml +++ b/spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml @@ -25,6 +25,12 @@ paths: schema: type: string format: date + - description: Value + example: 'value' + in: query + name: value + schema: + type: string responses: '200': description: Successful response @@ -40,6 +46,10 @@ paths: "$ref": "#/components/schemas/BadRequestResponse" '500': description: Internal server error + content: + application/json: + schema: + "$ref": "#/components/schemas/InternalServerErrorResponse" components: schemas: TestResponse: @@ -47,10 +57,11 @@ components: type: object properties: value: - description: Value - example: some value + description: Value only consisting out of letters + example: value nullable: false type: string + pattern: ^[a-zA-Z]*$ required: - value BadRequestResponse: @@ -64,3 +75,14 @@ components: type: string required: - error + InternalServerErrorResponse: + description: Internal server error response + type: object + properties: + error: + description: Description of the error + example: Something bad happened + nullable: false + type: string + required: + - error From d3f37bfd9459f8599b4d11ccf468c9783c37cb9b Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 10:55:47 +0100 Subject: [PATCH 05/20] add more integration tests --- .../OpenApiValidationIntegrationTest.java | 82 +++++++++++++++++-- .../controller/DefaultRestController.java | 10 +++ .../src/test/resources/openapi.yaml | 28 +++++++ 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java index 4f86d49f..e6fe0898 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java @@ -1,17 +1,26 @@ package com.getyourguide.openapi.validation.integration; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.getyourguide.openapi.validation.api.model.OpenApiViolation; import com.getyourguide.openapi.validation.integration.openapi.TestViolationLogger; +import java.util.List; +import java.util.Optional; +import javax.annotation.Nullable; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MockMvc; @@ -32,18 +41,81 @@ public void setup() { } @Test - public void whenTestSuccessfulResponseThenReturns200() throws Exception { - mockMvc.perform(get("/test").accept("application/json")) + public void whenTestSuccessfulResponseThenShouldNotLogViolation() throws Exception { + mockMvc.perform(get("/test")) .andExpectAll( status().isOk(), jsonPath("$.value").value("test") ); + Thread.sleep(100); assertEquals(0, openApiViolationLogger.getViolations().size()); } - // TODO Add test with request violation - // TODO Add test with response violation - // TODO Add test with request & response violation in same request + @Test + public void whenTestValidRequestWithInvalidResponseThenShouldReturnSuccessAndLogViolation() throws Exception { + mockMvc.perform(get("/test").queryParam("value", "invalid-response-value!")) + .andExpectAll( + status().isOk(), + jsonPath("$.value").value("invalid-response-value!") + ); + Thread.sleep(100); + + assertEquals(1, openApiViolationLogger.getViolations().size()); + var violation = openApiViolationLogger.getViolations().get(0); + assertEquals("validation.response.body.schema.pattern", violation.getRule()); + assertEquals(Optional.of(200), violation.getResponseStatus()); + assertEquals(Optional.of("/value"), violation.getInstance()); + } + + @Test + public void whenTestInvalidRequestNotHandledBySpringBootThenShouldReturnSuccessAndLogViolation() throws Exception { + mockMvc.perform(post("/test").content("{ \"value\": 1 }").contentType(MediaType.APPLICATION_JSON)) + .andExpectAll( + status().isNoContent(), + content().string(Matchers.blankOrNullString()) + ); + Thread.sleep(100); + + assertEquals(1, openApiViolationLogger.getViolations().size()); + var violation = openApiViolationLogger.getViolations().get(0); + assertEquals("validation.request.body.schema.type", violation.getRule()); + assertEquals(Optional.of(204), violation.getResponseStatus()); + assertEquals(Optional.of("/value"), violation.getInstance()); + } + + @Test + public void whenTestInvalidRequestAndInvalidResponseThenShouldReturnSuccessAndLogViolation() throws Exception { + mockMvc.perform( + post("/test") + .content("{ \"value\": 1, \"responseStatusCode\": 200 }") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpectAll( + status().isOk(), + jsonPath("$.value").value("1") + ); + Thread.sleep(100); + + var violations = openApiViolationLogger.getViolations(); + assertEquals(2, violations.size()); + var violation = getViolationByRule(violations, "validation.response.body.schema.pattern"); + assertNotNull(violation); + assertEquals(Optional.of(200), violation.getResponseStatus()); + assertEquals(Optional.of("/value"), violation.getInstance()); + var violation2 = getViolationByRule(violations, "validation.request.body.schema.type"); + assertNotNull(violation2); + assertEquals(Optional.of(200), violation2.getResponseStatus()); + assertEquals(Optional.of("/value"), violation2.getInstance()); + } + + @Nullable + private OpenApiViolation getViolationByRule(List violations, String rule) { + return violations.stream() + .filter(violation -> violation.getRule().equals(rule)) + .findFirst() + .orElse(null); + } + // TODO Add test that fails on request violation immediately (maybe needs separate test class & setup) } diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java index fdbaba3d..0024b9c1 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java @@ -1,6 +1,7 @@ package com.getyourguide.openapi.validation.integration.controller; import com.getyourguide.openapi.validation.example.openapi.DefaultApi; +import com.getyourguide.openapi.validation.example.openapi.model.PostTestRequest; import com.getyourguide.openapi.validation.example.openapi.model.TestResponse; import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; import com.getyourguide.openapi.validation.integration.exception.WithoutResponseStatusException; @@ -24,4 +25,13 @@ public ResponseEntity getTest(String testCase, LocalDate date, Str var responseValue = value != null ? value : "test"; return ResponseEntity.ok(new TestResponse().value(responseValue)); } + + @Override + public ResponseEntity postTest(PostTestRequest postTestRequest) { + var responseStatus = postTestRequest.getResponseStatusCode(); + if (responseStatus != null && responseStatus == 200) { + return ResponseEntity.ok(new TestResponse().value(postTestRequest.getValue())); + } + return ResponseEntity.noContent().build(); + } } diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml b/spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml index 90a40a89..41833e8a 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml +++ b/spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml @@ -31,6 +31,7 @@ paths: name: value schema: type: string + pattern: ^[^0-9]*$ responses: '200': description: Successful response @@ -50,6 +51,33 @@ paths: application/json: schema: "$ref": "#/components/schemas/InternalServerErrorResponse" + post: + description: Post test + operationId: postTest + requestBody: + description: Test request body + content: + application/json: + schema: + type: object + properties: + value: + description: Some value + example: value + type: string + responseStatusCode: + description: Optional response status code + example: 10 + type: integer + responses: + '200': + description: Successful response + content: + application/json: + schema: + "$ref": "#/components/schemas/TestResponse" + '204': + description: Successful response without content components: schemas: TestResponse: From adfa25448f9c7a498fb4c212f9566afc9309140e Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 11:42:17 +0100 Subject: [PATCH 06/20] add one more integration test --- .../OpenApiValidationIntegrationTest.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java index e6fe0898..41ec98c1 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -109,6 +110,21 @@ public void whenTestInvalidRequestAndInvalidResponseThenShouldReturnSuccessAndLo assertEquals(Optional.of("/value"), violation2.getInstance()); } + @Test + public void whenTestOptionsCallThenShouldNotValidate() throws Exception { + // Note: Options is not in the spec and would report a violation if it was validated. + mockMvc.perform(options("/test")) + .andExpectAll( + status().isOk(), + content().string(Matchers.blankOrNullString()) + ); + Thread.sleep(100); + + assertEquals(0, openApiViolationLogger.getViolations().size()); + } + + // TODO Add test that fails on request violation immediately (maybe needs separate test class & setup) should not log violation + @Nullable private OpenApiViolation getViolationByRule(List violations, String rule) { return violations.stream() @@ -116,6 +132,4 @@ private OpenApiViolation getViolationByRule(List violations, S .findFirst() .orElse(null); } - - // TODO Add test that fails on request violation immediately (maybe needs separate test class & setup) } From 0fc3b6385f162cf42c6bc8ddbcf3bf26cfebce94 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 12:18:08 +0100 Subject: [PATCH 07/20] switch to filter & interceptor combo --- .../SpringWebLibraryAutoConfiguration.java | 32 +- .../factory/ContentCachingWrapperFactory.java | 20 ++ .../factory/ServletMetaDataFactory.java | 7 +- .../filter/OpenApiValidationFilter.java | 56 ++++ .../filter/OpenApiValidationHttpFilter.java | 158 --------- .../filter/OpenApiValidationInterceptor.java | 179 +++++++++++ .../validation/filter/BaseFilterTest.java | 169 ++++++++++ .../filter/OpenApiValidationFilterTest.java | 57 ++++ .../OpenApiValidationHttpFilterTest.java | 304 ------------------ .../OpenApiValidationInterceptorTest.java | 161 ++++++++++ 10 files changed, 677 insertions(+), 466 deletions(-) create mode 100644 spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationFilter.java delete mode 100644 spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationHttpFilter.java create mode 100644 spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationInterceptor.java create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/BaseFilterTest.java create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/OpenApiValidationFilterTest.java delete mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/OpenApiValidationHttpFilterTest.java create mode 100644 spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/OpenApiValidationInterceptorTest.java diff --git a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/autoconfigure/SpringWebLibraryAutoConfiguration.java b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/autoconfigure/SpringWebLibraryAutoConfiguration.java index 697ab078..9be988a6 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/autoconfigure/SpringWebLibraryAutoConfiguration.java +++ b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/autoconfigure/SpringWebLibraryAutoConfiguration.java @@ -6,11 +6,14 @@ import com.getyourguide.openapi.validation.core.OpenApiRequestValidator; import com.getyourguide.openapi.validation.factory.ContentCachingWrapperFactory; import com.getyourguide.openapi.validation.factory.ServletMetaDataFactory; -import com.getyourguide.openapi.validation.filter.OpenApiValidationHttpFilter; +import com.getyourguide.openapi.validation.filter.OpenApiValidationFilter; +import com.getyourguide.openapi.validation.filter.OpenApiValidationInterceptor; import lombok.AllArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @AllArgsConstructor @@ -30,17 +33,40 @@ public ContentCachingWrapperFactory contentCachingWrapperFactory() { @Bean @ConditionalOnWebApplication(type = Type.SERVLET) - public OpenApiValidationHttpFilter openApiValidationHttpFilter( + public OpenApiValidationFilter openApiValidationFilter( OpenApiRequestValidator validator, TrafficSelector trafficSelector, ServletMetaDataFactory metaDataFactory, ContentCachingWrapperFactory contentCachingWrapperFactory ) { - return new OpenApiValidationHttpFilter( + return new OpenApiValidationFilter( validator, trafficSelector, metaDataFactory, contentCachingWrapperFactory ); } + + @Bean + @ConditionalOnWebApplication(type = Type.SERVLET) + public WebMvcConfigurer addOpenApiValidationInterceptor( + + OpenApiRequestValidator validator, + TrafficSelector trafficSelector, + ServletMetaDataFactory metaDataFactory, + ContentCachingWrapperFactory contentCachingWrapperFactory + ) { + var interceptor = new OpenApiValidationInterceptor( + validator, + trafficSelector, + metaDataFactory, + contentCachingWrapperFactory + ); + return new WebMvcConfigurer() { + @Override + public void addInterceptors(final InterceptorRegistry registry) { + registry.addInterceptor(interceptor); + } + }; + } } diff --git a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/factory/ContentCachingWrapperFactory.java b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/factory/ContentCachingWrapperFactory.java index decd6d1f..19e323b5 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/factory/ContentCachingWrapperFactory.java +++ b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/factory/ContentCachingWrapperFactory.java @@ -2,15 +2,35 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import javax.annotation.Nullable; import org.springframework.web.util.ContentCachingRequestWrapper; import org.springframework.web.util.ContentCachingResponseWrapper; +import org.springframework.web.util.WebUtils; public class ContentCachingWrapperFactory { public ContentCachingRequestWrapper buildContentCachingRequestWrapper(HttpServletRequest request) { + if (request instanceof ContentCachingRequestWrapper) { + return (ContentCachingRequestWrapper) request; + } + return new ContentCachingRequestWrapper(request); } public ContentCachingResponseWrapper buildContentCachingResponseWrapper(HttpServletResponse response) { + var cachingResponse = getCachingResponse(response); + if (cachingResponse != null) { + return cachingResponse; + } + return new ContentCachingResponseWrapper(response); } + + @Nullable + public ContentCachingResponseWrapper getCachingResponse(final HttpServletResponse response) { + return WebUtils.getNativeResponse(response, ContentCachingResponseWrapper.class); + } + + public ContentCachingRequestWrapper getCachingRequest(HttpServletRequest request) { + return request instanceof ContentCachingRequestWrapper ? (ContentCachingRequestWrapper) request : null; + } } diff --git a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/factory/ServletMetaDataFactory.java b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/factory/ServletMetaDataFactory.java index 10624a64..988ef201 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/factory/ServletMetaDataFactory.java +++ b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/factory/ServletMetaDataFactory.java @@ -17,7 +17,12 @@ public RequestMetaData buildRequestMetaData(HttpServletRequest request) { } public ResponseMetaData buildResponseMetaData(HttpServletResponse response) { - return new ResponseMetaData(response.getStatus(), response.getContentType(), getHeaders(response)); + return buildResponseMetaData(response, null); + } + + public ResponseMetaData buildResponseMetaData(HttpServletResponse response, Exception exception) { + var status = response.getStatus() == 200 && exception != null ? 500 : response.getStatus(); + return new ResponseMetaData(status, response.getContentType(), getHeaders(response)); } private static TreeMap getHeaders(HttpServletRequest request) { diff --git a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationFilter.java b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationFilter.java new file mode 100644 index 00000000..90436c18 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationFilter.java @@ -0,0 +1,56 @@ +package com.getyourguide.openapi.validation.filter; + +import com.getyourguide.openapi.validation.api.selector.TrafficSelector; +import com.getyourguide.openapi.validation.core.OpenApiRequestValidator; +import com.getyourguide.openapi.validation.factory.ContentCachingWrapperFactory; +import com.getyourguide.openapi.validation.factory.ServletMetaDataFactory; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@AllArgsConstructor +public class OpenApiValidationFilter extends OncePerRequestFilter { + static final String ATTRIBUTE_SKIP_VALIDATION = "gyg.openapi-validation.skipValidation"; + static final String ATTRIBUTE_REQUEST_META_DATA = "gyg.openapi-validation.requestMetaData"; + + private final OpenApiRequestValidator validator; + private final TrafficSelector trafficSelector; + private final ServletMetaDataFactory metaDataFactory; + private final ContentCachingWrapperFactory contentCachingWrapperFactory; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + var requestMetaData = metaDataFactory.buildRequestMetaData(request); + request.setAttribute(ATTRIBUTE_REQUEST_META_DATA, requestMetaData); + if (!validator.isReady() || !trafficSelector.shouldRequestBeValidated(requestMetaData)) { + request.setAttribute(ATTRIBUTE_SKIP_VALIDATION, true); + request.setAttribute(ATTRIBUTE_SKIP_VALIDATION, true); + filterChain.doFilter(request, response); + return; + } + + var requestToUse = contentCachingWrapperFactory.buildContentCachingRequestWrapper(request); + var responseToUse = contentCachingWrapperFactory.buildContentCachingResponseWrapper(response); + filterChain.doFilter(requestToUse, responseToUse); + + // in case the response was cached it has to be written to the original response + if (!isAsyncStarted(requestToUse)) { + var cachingResponse = contentCachingWrapperFactory.getCachingResponse(responseToUse); + if (cachingResponse != null) { + cachingResponse.copyBodyToResponse(); + } + } + } + + @Override + protected boolean shouldNotFilterAsyncDispatch() { + return false; + } +} diff --git a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationHttpFilter.java b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationHttpFilter.java deleted file mode 100644 index 7cec46e6..00000000 --- a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationHttpFilter.java +++ /dev/null @@ -1,158 +0,0 @@ -package com.getyourguide.openapi.validation.filter; - -import com.getyourguide.openapi.validation.api.model.RequestMetaData; -import com.getyourguide.openapi.validation.api.model.ResponseMetaData; -import com.getyourguide.openapi.validation.api.model.ValidationResult; -import com.getyourguide.openapi.validation.api.selector.TrafficSelector; -import com.getyourguide.openapi.validation.core.OpenApiRequestValidator; -import com.getyourguide.openapi.validation.factory.ContentCachingWrapperFactory; -import com.getyourguide.openapi.validation.factory.ServletMetaDataFactory; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; -import jakarta.servlet.http.HttpFilter; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import javax.annotation.Nullable; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.http.HttpStatusCode; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.web.util.ContentCachingRequestWrapper; -import org.springframework.web.util.ContentCachingResponseWrapper; - -@Component -@Order(Ordered.HIGHEST_PRECEDENCE) -@Slf4j -@AllArgsConstructor -public class OpenApiValidationHttpFilter extends HttpFilter { - - private final OpenApiRequestValidator validator; - private final TrafficSelector trafficSelector; - private final ServletMetaDataFactory metaDataFactory; - private final ContentCachingWrapperFactory contentCachingWrapperFactory; - - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) - throws IOException, ServletException { - if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) { - super.doFilter(request, response, chain); - return; - } - - var httpServletRequest = (HttpServletRequest) request; - var httpServletResponse = (HttpServletResponse) response; - var requestMetaData = metaDataFactory.buildRequestMetaData(httpServletRequest); - if (!validator.isReady() || !trafficSelector.shouldRequestBeValidated(requestMetaData)) { - super.doFilter(request, response, chain); - return; - } - - var requestWrapper = contentCachingWrapperFactory.buildContentCachingRequestWrapper(httpServletRequest); - var responseWrapper = contentCachingWrapperFactory.buildContentCachingResponseWrapper(httpServletResponse); - - var alreadyDidRequestValidation = validateRequestWithFailOnViolation(requestWrapper, requestMetaData); - try { - super.doFilter(requestWrapper, responseWrapper, chain); - } finally { - // TODO problem here is that if there is something thrown that is not handled in an ErrorHandler, - // the request/response object that gets updated by the general handler is not the same object, - // therefore the actual response is not visible here. - // - Maybe we just need to remove the finally. But then we won't validate anymore on exceptions thrown. - // - Or we say optionally here that it should ignore `validation.response.body.missing` - var responseMetaData = metaDataFactory.buildResponseMetaData(responseWrapper); - if (!alreadyDidRequestValidation) { - validateRequest(requestWrapper, requestMetaData, responseMetaData, RunType.ASYNC); - } - - var validateResponseResult = validateResponse( - responseWrapper, - requestMetaData, - responseMetaData, - getRunTypeForResponseValidation(requestMetaData) - ); - throwStatusExceptionOnViolation(validateResponseResult, "Response validation failed"); - - responseWrapper.copyBodyToResponse(); // Needs to be done on every call, otherwise there won't be a response body - } - } - - private RunType getRunTypeForResponseValidation(RequestMetaData requestMetaData) { - if (trafficSelector.shouldFailOnResponseViolation(requestMetaData)) { - return RunType.SYNC; - } else { - return RunType.ASYNC; - } - } - - private boolean validateRequestWithFailOnViolation( - ContentCachingRequestWrapper request, - RequestMetaData requestMetaData - ) { - if (!trafficSelector.shouldFailOnRequestViolation(requestMetaData)) { - return false; - } - - var validateRequestResult = validateRequest(request, requestMetaData, null, RunType.SYNC); - throwStatusExceptionOnViolation(validateRequestResult, "Request validation failed"); - return true; - } - - private ValidationResult validateRequest( - ContentCachingRequestWrapper request, - RequestMetaData requestMetaData, - @Nullable ResponseMetaData responseMetaData, - RunType runType - ) { - if (!trafficSelector.canRequestBeValidated(requestMetaData)) { - return ValidationResult.NOT_APPLICABLE; - } - - var requestBody = request.getContentType() != null - ? new String(request.getContentAsByteArray(), StandardCharsets.UTF_8) - : null; - - if (runType == RunType.ASYNC) { - validator.validateRequestObjectAsync(requestMetaData, responseMetaData, requestBody); - return ValidationResult.NOT_APPLICABLE; - } else { - return validator.validateRequestObject(requestMetaData, requestBody); - } - } - - private ValidationResult validateResponse( - ContentCachingResponseWrapper response, - RequestMetaData requestMetaData, - ResponseMetaData responseMetaData, - RunType runType - ) { - if (!trafficSelector.canResponseBeValidated(requestMetaData, responseMetaData)) { - return ValidationResult.NOT_APPLICABLE; - } - - var responseBody = response.getContentType() != null - ? new String(response.getContentAsByteArray(), StandardCharsets.UTF_8) - : null; - - if (runType == RunType.ASYNC) { - validator.validateResponseObjectAsync(requestMetaData, responseMetaData, responseBody); - return ValidationResult.NOT_APPLICABLE; - } else { - return validator.validateResponseObject(requestMetaData, responseMetaData, responseBody); - } - } - - private void throwStatusExceptionOnViolation(ValidationResult validateRequestResult, String message) { - if (validateRequestResult == ValidationResult.INVALID) { - throw new ResponseStatusException(HttpStatusCode.valueOf(400), message); - } - } - - private enum RunType { SYNC, ASYNC } -} diff --git a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationInterceptor.java b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationInterceptor.java new file mode 100644 index 00000000..179b1276 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationInterceptor.java @@ -0,0 +1,179 @@ +package com.getyourguide.openapi.validation.filter; + +import com.getyourguide.openapi.validation.api.model.RequestMetaData; +import com.getyourguide.openapi.validation.api.model.ResponseMetaData; +import com.getyourguide.openapi.validation.api.model.ValidationResult; +import com.getyourguide.openapi.validation.api.selector.TrafficSelector; +import com.getyourguide.openapi.validation.core.OpenApiRequestValidator; +import com.getyourguide.openapi.validation.factory.ContentCachingWrapperFactory; +import com.getyourguide.openapi.validation.factory.ServletMetaDataFactory; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; +import javax.annotation.Nullable; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.AsyncHandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +@Slf4j +@AllArgsConstructor +public class OpenApiValidationInterceptor implements AsyncHandlerInterceptor { + private static final String ATTRIBUTE_SKIP_REQUEST_VALIDATION = "gyg.openapi-validation.skipRequestValidation"; + private static final String ATTRIBUTE_SKIP_RESPONSE_VALIDATION = "gyg.openapi-validation.skipResponseValidation"; + + private final OpenApiRequestValidator validator; + private final TrafficSelector trafficSelector; + private final ServletMetaDataFactory metaDataFactory; + private final ContentCachingWrapperFactory contentCachingWrapperFactory; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + boolean skipValidation = request.getAttribute(OpenApiValidationFilter.ATTRIBUTE_SKIP_VALIDATION) != null; + if (skipValidation) { + return true; + } + + var requestToUse = contentCachingWrapperFactory.getCachingRequest(request); + var requestMetaData = getRequestMetaData(request); + if (requestToUse != null + && requestMetaData != null + && trafficSelector.shouldFailOnRequestViolation(requestMetaData)) { + var validateRequestResult = validateRequest(requestToUse, requestMetaData, null, RunType.SYNC); + if (validateRequestResult == ValidationResult.INVALID) { + request.setAttribute(OpenApiValidationFilter.ATTRIBUTE_SKIP_VALIDATION, true); + throw new ResponseStatusException(HttpStatusCode.valueOf(400), "Request validation failed"); + } + return false; + } + + return true; + } + + @Override + public void postHandle( + HttpServletRequest request, + HttpServletResponse response, + Object handler, + ModelAndView modelAndView + ) { + boolean skipValidation = request.getAttribute(OpenApiValidationFilter.ATTRIBUTE_SKIP_VALIDATION) != null; + if (skipValidation) { + return; + } + + var requestMetaData = getRequestMetaData(request); + var responseMetaData = metaDataFactory.buildResponseMetaData(response); + var requestToUse = contentCachingWrapperFactory.getCachingRequest(request); + if (requestToUse != null) { + validateRequest(requestToUse, requestMetaData, responseMetaData, RunType.ASYNC); + } + + validateResponse(request, response); + } + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object handler, + Exception ex + ) { + boolean skipValidation = request.getAttribute(OpenApiValidationFilter.ATTRIBUTE_SKIP_VALIDATION) != null; + if (skipValidation) { + return; + } + + validateResponse(request, response, ex); + } + + private void validateResponse(HttpServletRequest request, HttpServletResponse response) { + validateResponse(request, response, null); + } + + private void validateResponse( + HttpServletRequest request, + HttpServletResponse response, + @Nullable Exception exception + ) { + var requestMetaData = getRequestMetaData(request); + var responseMetaData = metaDataFactory.buildResponseMetaData(response, exception); + var requestToUse = contentCachingWrapperFactory.getCachingRequest(request); + var responseToUse = contentCachingWrapperFactory.getCachingResponse(response); + if (responseToUse != null) { + var validateResponseResult = validateResponse( + requestToUse, + responseToUse, + requestMetaData, + responseMetaData, + trafficSelector.shouldFailOnResponseViolation(requestMetaData) ? RunType.SYNC : RunType.ASYNC + ); + // Note: validateResponseResult will always be null on ASYNC + if (validateResponseResult == ValidationResult.INVALID) { + throw new ResponseStatusException(HttpStatusCode.valueOf(500), "Response validation failed"); + } + } + } + + @Nullable + private static RequestMetaData getRequestMetaData(HttpServletRequest request) { + var metaData = request.getAttribute(OpenApiValidationFilter.ATTRIBUTE_REQUEST_META_DATA); + return metaData instanceof RequestMetaData ? (RequestMetaData) metaData : null; + } + + private ValidationResult validateRequest( + ContentCachingRequestWrapper request, + RequestMetaData requestMetaData, + @Nullable ResponseMetaData responseMetaData, + RunType runType + ) { + boolean skipRequestValidation = request.getAttribute(ATTRIBUTE_SKIP_REQUEST_VALIDATION) != null; + request.setAttribute(ATTRIBUTE_SKIP_REQUEST_VALIDATION, true); + if (skipRequestValidation || !trafficSelector.canRequestBeValidated(requestMetaData)) { + return ValidationResult.NOT_APPLICABLE; + } + + var requestBody = request.getContentType() != null + ? new String(request.getContentAsByteArray(), StandardCharsets.UTF_8) + : null; + + if (runType == RunType.ASYNC) { + validator.validateRequestObjectAsync(requestMetaData, responseMetaData, requestBody); + return ValidationResult.NOT_APPLICABLE; + } else { + return validator.validateRequestObject(requestMetaData, requestBody); + } + } + + private ValidationResult validateResponse( + HttpServletRequest request, + ContentCachingResponseWrapper response, + RequestMetaData requestMetaData, + ResponseMetaData responseMetaData, + RunType runType + ) { + boolean skipResponseValidation = request.getAttribute(ATTRIBUTE_SKIP_RESPONSE_VALIDATION) != null; + request.setAttribute(ATTRIBUTE_SKIP_RESPONSE_VALIDATION, true); + + if (skipResponseValidation || !trafficSelector.canResponseBeValidated(requestMetaData, responseMetaData)) { + return ValidationResult.NOT_APPLICABLE; + } + + var responseBody = response.getContentType() != null + ? new String(response.getContentAsByteArray(), StandardCharsets.UTF_8) + : null; + + if (runType == RunType.ASYNC) { + validator.validateResponseObjectAsync(requestMetaData, responseMetaData, responseBody); + return ValidationResult.NOT_APPLICABLE; + } else { + return validator.validateResponseObject(requestMetaData, responseMetaData, responseBody); + } + } + + private enum RunType { SYNC, ASYNC } +} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/BaseFilterTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/BaseFilterTest.java new file mode 100644 index 00000000..3b2068a5 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/BaseFilterTest.java @@ -0,0 +1,169 @@ +package com.getyourguide.openapi.validation.filter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.getyourguide.openapi.validation.api.model.RequestMetaData; +import com.getyourguide.openapi.validation.api.model.ResponseMetaData; +import com.getyourguide.openapi.validation.api.selector.TrafficSelector; +import com.getyourguide.openapi.validation.core.OpenApiRequestValidator; +import com.getyourguide.openapi.validation.factory.ContentCachingWrapperFactory; +import com.getyourguide.openapi.validation.factory.ServletMetaDataFactory; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import lombok.Builder; +import lombok.Getter; +import org.mockito.Mockito; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +public class BaseFilterTest { + + protected static final String REQUEST_BODY = ""; + protected static final String RESPONSE_BODY = ""; + + protected final OpenApiRequestValidator validator = mock(); + protected final TrafficSelector trafficSelector = mock(); + protected final ServletMetaDataFactory metaDataFactory = mock(); + protected final ContentCachingWrapperFactory contentCachingWrapperFactory = mock(); + + protected static void mockRequestAttributes(ServletRequest... requests) { + var requestAttributes = new HashMap(); + for (ServletRequest request : requests) { + mockRequestAttributes(request, requestAttributes); + } + } + + private static void mockRequestAttributes(ServletRequest request, HashMap requestAttributes) { + when(request.getAttribute(any())) + .then(invocation -> requestAttributes.get((String) invocation.getArgument(0))); + Mockito.doAnswer(invocation -> requestAttributes.put(invocation.getArgument(0), invocation.getArgument(1))) + .when(request).setAttribute(any(), any()); + } + + protected MockSetupData mockSetup(MockConfiguration configuration) { + var request = mock(ContentCachingRequestWrapper.class); + var response = mock(ContentCachingResponseWrapper.class); + var cachingRequest = mockContentCachingRequest(request, configuration); + var cachingResponse = mockContentCachingResponse(response, configuration); + mockRequestAttributes(request, cachingRequest); + + when(request.getContentType()).thenReturn("application/json"); + when(request.getContentAsByteArray()).thenReturn(configuration.requestBody.getBytes(StandardCharsets.UTF_8)); + + when(response.getContentType()).thenReturn("application/json"); + when(response.getContentAsByteArray()).thenReturn(configuration.responseBody.getBytes(StandardCharsets.UTF_8)); + + var requestMetaData = mock(RequestMetaData.class); + when(metaDataFactory.buildRequestMetaData(request)).thenReturn(requestMetaData); + when(request.getAttribute(OpenApiValidationFilter.ATTRIBUTE_REQUEST_META_DATA)).thenReturn(requestMetaData); + + var responseMetaData = mock(ResponseMetaData.class); + when(metaDataFactory.buildResponseMetaData(response)).thenReturn(responseMetaData); + when(metaDataFactory.buildResponseMetaData(eq(response), any())).thenReturn(responseMetaData); + + when(metaDataFactory.buildResponseMetaData(cachingResponse)).thenReturn(responseMetaData); + + when(validator.isReady()).thenReturn(configuration.isReady); + mockTrafficSelectorMethods(requestMetaData, responseMetaData, configuration); + + when(contentCachingWrapperFactory.getCachingRequest(request)).thenReturn(request); + when(contentCachingWrapperFactory.getCachingResponse(response)).thenReturn(response); + + + return MockSetupData.builder() + .request(request) + .response(response) + .cachingRequest(cachingRequest) + .cachingResponse(cachingResponse) + .requestMetaData(requestMetaData) + .responseMetaData(responseMetaData) + .filterChain(mock(FilterChain.class)) + .build(); + } + + private ContentCachingResponseWrapper mockContentCachingResponse( + HttpServletResponse response, + MockConfiguration configuration + ) { + var cachingResponse = mock(ContentCachingResponseWrapper.class); + when(contentCachingWrapperFactory.buildContentCachingResponseWrapper(response)).thenReturn(cachingResponse); + if (configuration.responseBody != null) { + when(cachingResponse.getContentType()).thenReturn("application/json"); + when(cachingResponse.getContentAsByteArray()) + .thenReturn(configuration.responseBody.getBytes(StandardCharsets.UTF_8)); + } + return cachingResponse; + } + + private ContentCachingRequestWrapper mockContentCachingRequest( + HttpServletRequest request, + MockConfiguration configuration + ) { + var cachingRequest = mock(ContentCachingRequestWrapper.class); + when(contentCachingWrapperFactory.buildContentCachingRequestWrapper(request)).thenReturn(cachingRequest); + if (configuration.responseBody != null) { + when(cachingRequest.getContentType()).thenReturn("application/json"); + when(cachingRequest.getContentAsByteArray()) + .thenReturn(configuration.requestBody.getBytes(StandardCharsets.UTF_8)); + } + return cachingRequest; + } + + private void mockTrafficSelectorMethods( + RequestMetaData requestMetaData, + ResponseMetaData responseMetaData, + MockConfiguration configuration + ) { + when(trafficSelector.shouldRequestBeValidated(any())).thenReturn(configuration.shouldRequestBeValidated); + when(trafficSelector.canRequestBeValidated(requestMetaData)).thenReturn(configuration.canRequestBeValidated); + when(trafficSelector.canResponseBeValidated(requestMetaData, responseMetaData)) + .thenReturn(configuration.canResponseBeValidated); + when(trafficSelector.shouldFailOnRequestViolation(requestMetaData)).thenReturn( + configuration.shouldFailOnRequestViolation); + when(trafficSelector.shouldFailOnResponseViolation(requestMetaData)).thenReturn( + configuration.shouldFailOnResponseViolation); + } + + @Builder + protected static class MockConfiguration { + @Builder.Default + private boolean isReady = true; + @Builder.Default + private boolean shouldRequestBeValidated = true; + + @Builder.Default + private boolean canRequestBeValidated = true; + @Builder.Default + private boolean canResponseBeValidated = true; + + @Builder.Default + private boolean shouldFailOnRequestViolation = false; + @Builder.Default + private boolean shouldFailOnResponseViolation = false; + + @Builder.Default + private String requestBody = REQUEST_BODY; + @Builder.Default + private String responseBody = RESPONSE_BODY; + } + + @Builder + protected record MockSetupData( + HttpServletRequest request, + HttpServletResponse response, + ServletRequest cachingRequest, + ServletResponse cachingResponse, + RequestMetaData requestMetaData, + ResponseMetaData responseMetaData, + FilterChain filterChain + ) { + } +} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/OpenApiValidationFilterTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/OpenApiValidationFilterTest.java new file mode 100644 index 00000000..d7450fd4 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/OpenApiValidationFilterTest.java @@ -0,0 +1,57 @@ +package com.getyourguide.openapi.validation.filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class OpenApiValidationFilterTest extends BaseFilterTest { + private final OpenApiValidationFilter httpFilter = + new OpenApiValidationFilter(validator, trafficSelector, metaDataFactory, contentCachingWrapperFactory); + + @Test + public void testWhenNotReadyThenSkipValidation() throws ServletException, IOException { + var mockData = mockSetup(MockConfiguration.builder().isReady(false).build()); + + httpFilter.doFilterInternal(mockData.request(), mockData.response(), mockData.filterChain()); + + verifyChainCalled(mockData.filterChain(), mockData.request(), mockData.response()); + verifyValidationDisabledPerAttribute(mockData.request()); + } + + @Test + public void testWhenShouldRequestBeValidatedFalseThenSkipValidation() throws ServletException, IOException { + var mockData = mockSetup(MockConfiguration.builder().shouldRequestBeValidated(false).build()); + + httpFilter.doFilterInternal(mockData.request(), mockData.response(), mockData.filterChain()); + + verifyChainCalled(mockData.filterChain(), mockData.request(), mockData.response()); + verifyValidationDisabledPerAttribute(mockData.request()); + } + + @Test + public void testWhenValidationThenCorrectlyHandled() throws ServletException, IOException { + var mockData = mockSetup(MockConfiguration.builder().build()); + + httpFilter.doFilterInternal(mockData.request(), mockData.response(), mockData.filterChain()); + + verifyChainCalled(mockData.filterChain(), mockData.cachingRequest(), mockData.cachingResponse()); + assertEquals(mockData.requestMetaData(), + mockData.request().getAttribute(OpenApiValidationFilter.ATTRIBUTE_REQUEST_META_DATA)); + } + + private static void verifyChainCalled(FilterChain chain, ServletRequest request, ServletResponse response) + throws ServletException, IOException { + verify(chain).doFilter(request, response); + } + + private void verifyValidationDisabledPerAttribute(HttpServletRequest request) { + assertEquals(true, request.getAttribute(OpenApiValidationFilter.ATTRIBUTE_SKIP_VALIDATION)); + } +} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/OpenApiValidationHttpFilterTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/OpenApiValidationHttpFilterTest.java deleted file mode 100644 index d1048122..00000000 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/OpenApiValidationHttpFilterTest.java +++ /dev/null @@ -1,304 +0,0 @@ -package com.getyourguide.openapi.validation.filter; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.getyourguide.openapi.validation.api.model.RequestMetaData; -import com.getyourguide.openapi.validation.api.model.ResponseMetaData; -import com.getyourguide.openapi.validation.api.model.ValidationResult; -import com.getyourguide.openapi.validation.api.selector.TrafficSelector; -import com.getyourguide.openapi.validation.core.OpenApiRequestValidator; -import com.getyourguide.openapi.validation.factory.ContentCachingWrapperFactory; -import com.getyourguide.openapi.validation.factory.ServletMetaDataFactory; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import lombok.Builder; -import org.junit.jupiter.api.Test; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.web.util.ContentCachingRequestWrapper; -import org.springframework.web.util.ContentCachingResponseWrapper; - -class OpenApiValidationHttpFilterTest { - - public static final String REQUEST_BODY = ""; - public static final String RESPONSE_BODY = ""; - - private final OpenApiRequestValidator validator = mock(); - private final TrafficSelector trafficSelector = mock(); - private final ServletMetaDataFactory metaDataFactory = mock(); - private final ContentCachingWrapperFactory contentCachingWrapperFactory = mock(); - - private final OpenApiValidationHttpFilter httpFilter = - new OpenApiValidationHttpFilter(validator, trafficSelector, metaDataFactory, contentCachingWrapperFactory); - - @Test - public void testNormalFlowWithValidation() throws ServletException, IOException { - var mockData = mockSetup(MockConfiguration.builder().build()); - - httpFilter.doFilter(mockData.request, mockData.response, mockData.chain); - - verifyChainCalled(mockData.chain, mockData.cachingRequest, mockData.cachingResponse); - verifyRequestValidatedAsync(mockData); - verifyResponseValidatedAsync(mockData); - } - - @Test - public void testNoValidationIfNotReady() throws ServletException, IOException { - var mockData = mockSetup(MockConfiguration.builder().isReady(false).build()); - - httpFilter.doFilter(mockData.request, mockData.response, mockData.chain); - - verifyChainCalled(mockData.chain, mockData.request, mockData.response); - verifyNoValidation(); - } - - @Test - public void testNoValidationIfNotShouldRequestBeValidated() throws ServletException, IOException { - var mockData = mockSetup(MockConfiguration.builder().shouldRequestBeValidated(false).build()); - - httpFilter.doFilter(mockData.request, mockData.response, mockData.chain); - - verifyChainCalled(mockData.chain, mockData.request, mockData.response); - verifyNoValidation(); - } - - @Test - public void testNoValidationIfNotCanRequestBeValidated() throws ServletException, IOException { - var mockData = mockSetup(MockConfiguration.builder().canRequestBeValidated(false).build()); - - httpFilter.doFilter(mockData.request, mockData.response, mockData.chain); - - verifyChainCalled(mockData.chain, mockData.cachingRequest, mockData.cachingResponse); - verifyNoRequestValidation(); - verifyResponseValidatedAsync(mockData); - } - - @Test - public void testNoValidationIfNotCanResponseBeValidated() throws ServletException, IOException { - var mockData = mockSetup(MockConfiguration.builder().canResponseBeValidated(false).build()); - - httpFilter.doFilter(mockData.request, mockData.response, mockData.chain); - - verifyChainCalled(mockData.chain, mockData.cachingRequest, mockData.cachingResponse); - verifyRequestValidatedAsync(mockData); - verifyNoResponseValidation(); - } - - @Test - public void testShouldFailOnRequestViolationWithoutViolation() throws ServletException, IOException { - var mockData = mockSetup(MockConfiguration.builder().shouldFailOnRequestViolation(true).build()); - - httpFilter.doFilter(mockData.request, mockData.response, mockData.chain); - - verifyChainCalled(mockData.chain, mockData.cachingRequest, mockData.cachingResponse); - verifyRequestValidatedSync(mockData); - verifyResponseValidatedAsync(mockData); - } - - @Test - public void testShouldFailOnReResponseViolationWithoutViolation() throws ServletException, IOException { - var mockData = mockSetup(MockConfiguration.builder().shouldFailOnResponseViolation(true).build()); - - httpFilter.doFilter(mockData.request, mockData.response, mockData.chain); - - verifyChainCalled(mockData.chain, mockData.cachingRequest, mockData.cachingResponse); - verifyRequestValidatedAsync(mockData); - verifyResponseValidatedSync(mockData); - } - - @Test - public void testShouldFailOnRequestViolationWithViolation() throws ServletException, IOException { - var mockData = mockSetup(MockConfiguration.builder().shouldFailOnRequestViolation(true).build()); - when(validator.validateRequestObject(eq(mockData.requestMetaData), eq(REQUEST_BODY))) - .thenReturn(ValidationResult.INVALID); - - assertThrows(ResponseStatusException.class, - () -> httpFilter.doFilter(mockData.request, mockData.response, mockData.chain)); - - verifyChainNotCalled(mockData.chain); - verifyRequestValidatedSync(mockData); - verifyNoResponseValidation(); - } - - @Test - public void testShouldFailOnResponseViolationWithViolation() throws ServletException, IOException { - var mockData = mockSetup(MockConfiguration.builder().shouldFailOnResponseViolation(true).build()); - when( - validator.validateResponseObject( - eq(mockData.requestMetaData), - eq(mockData.responseMetaData), eq(REQUEST_BODY) - ) - ).thenReturn(ValidationResult.INVALID); - - assertThrows(ResponseStatusException.class, - () -> httpFilter.doFilter(mockData.request, mockData.response, mockData.chain)); - - verifyChainCalled(mockData.chain, mockData.cachingRequest, mockData.cachingResponse); - verifyRequestValidatedAsync(mockData); - verifyResponseValidatedSync(mockData); - } - - private void verifyNoValidation() { - verifyNoRequestValidation(); - verifyNoResponseValidation(); - } - - private void verifyNoRequestValidation() { - verify(validator, never()).validateRequestObjectAsync(any(), any(), anyString()); - verify(validator, never()).validateRequestObject(any(), anyString()); - } - - private void verifyNoResponseValidation() { - verify(validator, never()).validateResponseObjectAsync(any(), any(), anyString()); - verify(validator, never()).validateResponseObject(any(), any(), anyString()); - } - - private void verifyRequestValidatedAsync(MockSetupData mockData) { - verify(validator).validateRequestObjectAsync(eq(mockData.requestMetaData), eq(mockData.responseMetaData), eq(REQUEST_BODY)); - } - - private void verifyRequestValidatedSync(MockSetupData mockData) { - verify(validator).validateRequestObject(eq(mockData.requestMetaData), eq(REQUEST_BODY)); - } - - private void verifyResponseValidatedAsync(MockSetupData mockData) { - verify(validator).validateResponseObjectAsync( - eq(mockData.requestMetaData), - eq(mockData.responseMetaData), - eq(RESPONSE_BODY) - ); - } - - private void verifyResponseValidatedSync(MockSetupData mockData) { - verify(validator) - .validateResponseObject(eq(mockData.requestMetaData), eq(mockData.responseMetaData), eq(RESPONSE_BODY)); - } - - private void mockTrafficSelectorMethods( - RequestMetaData requestMetaData, - ResponseMetaData responseMetaData, - MockConfiguration configuration - ) { - when(trafficSelector.shouldRequestBeValidated(any())).thenReturn(configuration.shouldRequestBeValidated); - when(trafficSelector.canRequestBeValidated(requestMetaData)).thenReturn(configuration.canRequestBeValidated); - when(trafficSelector.canResponseBeValidated(requestMetaData, responseMetaData)).thenReturn( - configuration.canResponseBeValidated); - when(trafficSelector.shouldFailOnRequestViolation(requestMetaData)).thenReturn( - configuration.shouldFailOnRequestViolation); - when(trafficSelector.shouldFailOnResponseViolation(requestMetaData)).thenReturn( - configuration.shouldFailOnResponseViolation); - } - - private ContentCachingResponseWrapper mockContentCachingResponse( - HttpServletResponse response, - MockConfiguration configuration - ) { - var cachingResponse = mock(ContentCachingResponseWrapper.class); - when(contentCachingWrapperFactory.buildContentCachingResponseWrapper(response)).thenReturn(cachingResponse); - if (configuration.responseBody != null) { - when(cachingResponse.getContentType()).thenReturn("application/json"); - when(cachingResponse.getContentAsByteArray()) - .thenReturn(configuration.responseBody.getBytes(StandardCharsets.UTF_8)); - } - return cachingResponse; - } - - private ContentCachingRequestWrapper mockContentCachingRequest( - HttpServletRequest request, - MockConfiguration configuration - ) { - var cachingRequest = mock(ContentCachingRequestWrapper.class); - when(contentCachingWrapperFactory.buildContentCachingRequestWrapper(request)).thenReturn(cachingRequest); - if (configuration.responseBody != null) { - when(cachingRequest.getContentType()).thenReturn("application/json"); - when(cachingRequest.getContentAsByteArray()) - .thenReturn(configuration.requestBody.getBytes(StandardCharsets.UTF_8)); - } - return cachingRequest; - } - - private static void verifyChainCalled(FilterChain chain, ServletRequest request, ServletResponse response) - throws ServletException, IOException { - verify(chain).doFilter(request, response); - } - - private static void verifyChainNotCalled(FilterChain chain) throws ServletException, IOException { - verify(chain, never()).doFilter(any(), any()); - } - - private MockSetupData mockSetup(MockConfiguration configuration) { - var request = mock(HttpServletRequest.class); - var response = mock(HttpServletResponse.class); - - var requestMetaData = mock(RequestMetaData.class); - when(metaDataFactory.buildRequestMetaData(request)).thenReturn(requestMetaData); - - var responseMetaData = mock(ResponseMetaData.class); - when(metaDataFactory.buildResponseMetaData(response)).thenReturn(responseMetaData); - - var cachingRequest = mockContentCachingRequest(request, configuration); - var cachingResponse = mockContentCachingResponse(response, configuration); - when(metaDataFactory.buildResponseMetaData(cachingResponse)).thenReturn(responseMetaData); - - var chain = mock(FilterChain.class); - when(validator.isReady()).thenReturn(configuration.isReady); - mockTrafficSelectorMethods(requestMetaData, responseMetaData, configuration); - - return MockSetupData.builder() - .request(request) - .response(response) - .cachingRequest(cachingRequest) - .cachingResponse(cachingResponse) - .chain(chain) - .requestMetaData(requestMetaData) - .responseMetaData(responseMetaData) - .build(); - } - - @Builder - private static class MockConfiguration { - @Builder.Default - private boolean isReady = true; - @Builder.Default - private boolean shouldRequestBeValidated = true; - - @Builder.Default - private boolean canRequestBeValidated = true; - @Builder.Default - private boolean canResponseBeValidated = true; - - @Builder.Default - private boolean shouldFailOnRequestViolation = false; - @Builder.Default - private boolean shouldFailOnResponseViolation = false; - - @Builder.Default - private String requestBody = REQUEST_BODY; - @Builder.Default - private String responseBody = RESPONSE_BODY; - } - - @Builder - private record MockSetupData( - ServletRequest request, - ServletResponse response, - ServletRequest cachingRequest, - ServletResponse cachingResponse, - FilterChain chain, - RequestMetaData requestMetaData, - ResponseMetaData responseMetaData - ) { - } -} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/OpenApiValidationInterceptorTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/OpenApiValidationInterceptorTest.java new file mode 100644 index 00000000..b60cab20 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/OpenApiValidationInterceptorTest.java @@ -0,0 +1,161 @@ +package com.getyourguide.openapi.validation.filter; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.getyourguide.openapi.validation.api.model.ValidationResult; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; + +class OpenApiValidationInterceptorTest extends BaseFilterTest { + + private final OpenApiValidationInterceptor httpInterceptor = + new OpenApiValidationInterceptor(validator, trafficSelector, metaDataFactory, contentCachingWrapperFactory); + + @Test + public void testNormalFlowWithValidation() { + var mockData = mockSetup(MockConfiguration.builder().build()); + + httpInterceptor.postHandle(mockData.request(), mockData.response(), new Object(), null); + + verifyRequestValidatedAsync(mockData); + verifyResponseValidatedAsync(mockData); + } + + @Test + public void testNoValidationIfNotReady() { + var mockData = mockSetup(MockConfiguration.builder().build()); + mockData.request().setAttribute(OpenApiValidationFilter.ATTRIBUTE_SKIP_VALIDATION, true); + + httpInterceptor.preHandle(mockData.request(), mockData.response(), new Object()); + httpInterceptor.postHandle(mockData.request(), mockData.response(), new Object(), null); + httpInterceptor.afterCompletion(mockData.request(), mockData.response(), new Object(), null); + + verifyNoValidation(); + } + + @Test + public void testNoValidationIfNotCanRequestBeValidated() { + var mockData = mockSetup(MockConfiguration.builder().canRequestBeValidated(false).build()); + + httpInterceptor.preHandle(mockData.request(), mockData.response(), new Object()); + httpInterceptor.postHandle(mockData.request(), mockData.response(), new Object(), null); + httpInterceptor.afterCompletion(mockData.request(), mockData.response(), new Object(), null); + + verifyNoRequestValidation(); + verifyResponseValidatedAsync(mockData); + } + + @Test + public void testNoValidationIfNotCanResponseBeValidated() { + var mockData = mockSetup(MockConfiguration.builder().canResponseBeValidated(false).build()); + + httpInterceptor.preHandle(mockData.request(), mockData.response(), new Object()); + httpInterceptor.postHandle(mockData.request(), mockData.response(), new Object(), null); + httpInterceptor.afterCompletion(mockData.request(), mockData.response(), new Object(), null); + + verifyRequestValidatedAsync(mockData); + verifyNoResponseValidation(); + } + + @Test + public void testShouldFailOnRequestViolationWithoutViolation() { + var mockData = mockSetup(MockConfiguration.builder().shouldFailOnRequestViolation(true).build()); + + httpInterceptor.preHandle(mockData.request(), mockData.response(), new Object()); + httpInterceptor.postHandle(mockData.request(), mockData.response(), new Object(), null); + httpInterceptor.afterCompletion(mockData.request(), mockData.response(), new Object(), null); + + verifyRequestValidatedSync(mockData); + verifyResponseValidatedAsync(mockData); + } + + @Test + public void testShouldFailOnReResponseViolationWithoutViolation() { + var mockData = mockSetup(MockConfiguration.builder().shouldFailOnResponseViolation(true).build()); + + httpInterceptor.preHandle(mockData.request(), mockData.response(), new Object()); + httpInterceptor.postHandle(mockData.request(), mockData.response(), new Object(), null); + httpInterceptor.afterCompletion(mockData.request(), mockData.response(), new Object(), null); + + verifyRequestValidatedAsync(mockData); + verifyResponseValidatedSync(mockData); + } + + @Test + public void testShouldFailOnRequestViolationWithViolation() { + var mockData = mockSetup(MockConfiguration.builder().shouldFailOnRequestViolation(true).build()); + when(validator.validateRequestObject(eq(mockData.requestMetaData()), eq(REQUEST_BODY))) + .thenReturn(ValidationResult.INVALID); + + assertThrows(ResponseStatusException.class, + () -> httpInterceptor.preHandle(mockData.request(), mockData.response(), new Object())); + httpInterceptor.postHandle(mockData.request(), mockData.response(), new Object(), null); + httpInterceptor.afterCompletion(mockData.request(), mockData.response(), new Object(), null); + + verifyRequestValidatedSync(mockData); + verifyNoResponseValidation(); + } + + @Test + public void testShouldFailOnResponseViolationWithViolation() { + var mockData = mockSetup(MockConfiguration.builder().shouldFailOnResponseViolation(true).build()); + when( + validator.validateResponseObject( + eq(mockData.requestMetaData()), + eq(mockData.responseMetaData()), eq(REQUEST_BODY) + ) + ).thenReturn(ValidationResult.INVALID); + + + httpInterceptor.preHandle(mockData.request(), mockData.response(), new Object()); + assertThrows(ResponseStatusException.class, + () -> httpInterceptor.postHandle(mockData.request(), mockData.response(), new Object(), null)); + httpInterceptor.afterCompletion(mockData.request(), mockData.response(), new Object(), null); + + verifyRequestValidatedAsync(mockData); + verifyResponseValidatedSync(mockData); + } + + private void verifyNoValidation() { + verifyNoRequestValidation(); + verifyNoResponseValidation(); + } + + private void verifyNoRequestValidation() { + verify(validator, never()).validateRequestObjectAsync(any(), any(), anyString()); + verify(validator, never()).validateRequestObject(any(), anyString()); + } + + private void verifyNoResponseValidation() { + verify(validator, never()).validateResponseObjectAsync(any(), any(), anyString()); + verify(validator, never()).validateResponseObject(any(), any(), anyString()); + } + + private void verifyRequestValidatedAsync(MockSetupData mockData) { + verify(validator).validateRequestObjectAsync(eq(mockData.requestMetaData()), eq(mockData.responseMetaData()), + eq(REQUEST_BODY)); + } + + private void verifyRequestValidatedSync(MockSetupData mockData) { + verify(validator).validateRequestObject(eq(mockData.requestMetaData()), eq(REQUEST_BODY)); + } + + private void verifyResponseValidatedAsync(MockSetupData mockData) { + verify(validator).validateResponseObjectAsync( + eq(mockData.requestMetaData()), + eq(mockData.responseMetaData()), + eq(RESPONSE_BODY) + ); + } + + private void verifyResponseValidatedSync(MockSetupData mockData) { + verify(validator) + .validateResponseObject(eq(mockData.requestMetaData()), eq(mockData.responseMetaData()), eq(RESPONSE_BODY)); + } +} From 6ac8ec3de0c23419266cbdafb31d128be18a717f Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 12:21:30 +0100 Subject: [PATCH 08/20] use public scope for attributes --- .../openapi/validation/filter/OpenApiValidationFilter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationFilter.java b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationFilter.java index 90436c18..aa5be939 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationFilter.java +++ b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationFilter.java @@ -16,8 +16,8 @@ @Slf4j @AllArgsConstructor public class OpenApiValidationFilter extends OncePerRequestFilter { - static final String ATTRIBUTE_SKIP_VALIDATION = "gyg.openapi-validation.skipValidation"; - static final String ATTRIBUTE_REQUEST_META_DATA = "gyg.openapi-validation.requestMetaData"; + public static final String ATTRIBUTE_SKIP_VALIDATION = "gyg.openapi-validation.skipValidation"; + public static final String ATTRIBUTE_REQUEST_META_DATA = "gyg.openapi-validation.requestMetaData"; private final OpenApiRequestValidator validator; private final TrafficSelector trafficSelector; From 0e8b52ffdb6181f3b94b5bdc517b5c9e5fdf0b43 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 12:40:11 +0100 Subject: [PATCH 09/20] remove unused import --- .../getyourguide/openapi/validation/filter/BaseFilterTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/BaseFilterTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/BaseFilterTest.java index 3b2068a5..d293af6c 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/BaseFilterTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/BaseFilterTest.java @@ -19,7 +19,6 @@ import java.nio.charset.StandardCharsets; import java.util.HashMap; import lombok.Builder; -import lombok.Getter; import org.mockito.Mockito; import org.springframework.web.util.ContentCachingRequestWrapper; import org.springframework.web.util.ContentCachingResponseWrapper; From 9b4f4d0d264806a87279fbae1f267dd649c60698 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 12:51:30 +0100 Subject: [PATCH 10/20] small code improvements --- .../autoconfigure/SpringWebLibraryAutoConfiguration.java | 1 - .../validation/factory/ContentCachingWrapperFactory.java | 1 + .../validation/integration/SpringBootTestConfiguration.java | 6 ++++-- .../src/test/resources/application.yaml | 2 -- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/autoconfigure/SpringWebLibraryAutoConfiguration.java b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/autoconfigure/SpringWebLibraryAutoConfiguration.java index 9be988a6..88bd42e4 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/autoconfigure/SpringWebLibraryAutoConfiguration.java +++ b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/autoconfigure/SpringWebLibraryAutoConfiguration.java @@ -50,7 +50,6 @@ public OpenApiValidationFilter openApiValidationFilter( @Bean @ConditionalOnWebApplication(type = Type.SERVLET) public WebMvcConfigurer addOpenApiValidationInterceptor( - OpenApiRequestValidator validator, TrafficSelector trafficSelector, ServletMetaDataFactory metaDataFactory, diff --git a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/factory/ContentCachingWrapperFactory.java b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/factory/ContentCachingWrapperFactory.java index 19e323b5..c4f705d1 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/factory/ContentCachingWrapperFactory.java +++ b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/factory/ContentCachingWrapperFactory.java @@ -30,6 +30,7 @@ public ContentCachingResponseWrapper getCachingResponse(final HttpServletRespons return WebUtils.getNativeResponse(response, ContentCachingResponseWrapper.class); } + @Nullable public ContentCachingRequestWrapper getCachingRequest(HttpServletRequest request) { return request instanceof ContentCachingRequestWrapper ? (ContentCachingRequestWrapper) request : null; } diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java index b1fa0a27..d41c1aa4 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java @@ -9,7 +9,9 @@ @SpringBootConfiguration @EnableAutoConfiguration -@ComponentScan(excludeFilters = {@ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class), - @ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class)}) +@ComponentScan(excludeFilters = { + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class), + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) +}) public class SpringBootTestConfiguration { } diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/resources/application.yaml b/spring-boot-starter/spring-boot-starter-web/src/test/resources/application.yaml index 7a7ceee9..a2348634 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/resources/application.yaml +++ b/spring-boot-starter/spring-boot-starter-web/src/test/resources/application.yaml @@ -1,4 +1,2 @@ # Required as for some reason it tries to load some things from local code and as well from locally built jar spring.main.allow-bean-definition-overriding: true - -openapi.validation.sample-rate: 1 From cb6fcc85cf7dcb78d2353037b3e47e2a1ccbd0de Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 12:52:00 +0100 Subject: [PATCH 11/20] refactor: extract method for duplicate code --- .../filter/OpenApiValidationInterceptor.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationInterceptor.java b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationInterceptor.java index 179b1276..58a5789c 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationInterceptor.java +++ b/spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationInterceptor.java @@ -33,8 +33,7 @@ public class OpenApiValidationInterceptor implements AsyncHandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { - boolean skipValidation = request.getAttribute(OpenApiValidationFilter.ATTRIBUTE_SKIP_VALIDATION) != null; - if (skipValidation) { + if (shouldSkipValidation(request)) { return true; } @@ -48,7 +47,6 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons request.setAttribute(OpenApiValidationFilter.ATTRIBUTE_SKIP_VALIDATION, true); throw new ResponseStatusException(HttpStatusCode.valueOf(400), "Request validation failed"); } - return false; } return true; @@ -61,8 +59,7 @@ public void postHandle( Object handler, ModelAndView modelAndView ) { - boolean skipValidation = request.getAttribute(OpenApiValidationFilter.ATTRIBUTE_SKIP_VALIDATION) != null; - if (skipValidation) { + if (shouldSkipValidation(request)) { return; } @@ -83,14 +80,17 @@ public void afterCompletion( Object handler, Exception ex ) { - boolean skipValidation = request.getAttribute(OpenApiValidationFilter.ATTRIBUTE_SKIP_VALIDATION) != null; - if (skipValidation) { + if (shouldSkipValidation(request)) { return; } validateResponse(request, response, ex); } + private boolean shouldSkipValidation(HttpServletRequest request) { + return request.getAttribute(OpenApiValidationFilter.ATTRIBUTE_SKIP_VALIDATION) != null; + } + private void validateResponse(HttpServletRequest request, HttpServletResponse response) { validateResponse(request, response, null); } @@ -131,7 +131,7 @@ private ValidationResult validateRequest( @Nullable ResponseMetaData responseMetaData, RunType runType ) { - boolean skipRequestValidation = request.getAttribute(ATTRIBUTE_SKIP_REQUEST_VALIDATION) != null; + var skipRequestValidation = request.getAttribute(ATTRIBUTE_SKIP_REQUEST_VALIDATION) != null; request.setAttribute(ATTRIBUTE_SKIP_REQUEST_VALIDATION, true); if (skipRequestValidation || !trafficSelector.canRequestBeValidated(requestMetaData)) { return ValidationResult.NOT_APPLICABLE; @@ -156,7 +156,7 @@ private ValidationResult validateResponse( ResponseMetaData responseMetaData, RunType runType ) { - boolean skipResponseValidation = request.getAttribute(ATTRIBUTE_SKIP_RESPONSE_VALIDATION) != null; + var skipResponseValidation = request.getAttribute(ATTRIBUTE_SKIP_RESPONSE_VALIDATION) != null; request.setAttribute(ATTRIBUTE_SKIP_RESPONSE_VALIDATION, true); if (skipResponseValidation || !trafficSelector.canResponseBeValidated(requestMetaData, responseMetaData)) { From a2347c44566fd00e885d47a6d46df4095f19ca31 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 13:30:09 +0100 Subject: [PATCH 12/20] extract reusable test code to separate module --- build.gradle | 2 +- settings.gradle | 2 + .../spring-boot-starter-web/build.gradle | 54 +-------------- .../ExceptionsWithExceptionHandlerTest.java | 2 +- .../controller/DefaultRestController.java | 6 +- .../filter/OpenApiValidationWebFilter.java | 1 - test/test-utils/build.gradle | 68 +++++++++++++++++++ .../src/main}/resources/openapi.yaml | 0 8 files changed, 76 insertions(+), 59 deletions(-) create mode 100644 test/test-utils/build.gradle rename {spring-boot-starter/spring-boot-starter-web/src/test => test/test-utils/src/main}/resources/openapi.yaml (100%) diff --git a/build.gradle b/build.gradle index 6df508c0..216f516f 100644 --- a/build.gradle +++ b/build.gradle @@ -24,7 +24,7 @@ allprojects { } subprojects { - if(it.parent.name == 'examples' || it.parent.name == 'test') { + if(it.parent.name == 'examples') { apply plugin: 'java' } else { apply plugin: 'java-library' diff --git a/settings.gradle b/settings.gradle index 208f66a6..4f9e9103 100644 --- a/settings.gradle +++ b/settings.gradle @@ -13,3 +13,5 @@ include(':metrics-reporter:metrics-reporter-datadog-spring-boot') include(':examples:examples-common') include(':examples:example-spring-boot-starter-web') include(':examples:example-spring-boot-starter-webflux') + +include(':test:test-utils') diff --git a/spring-boot-starter/spring-boot-starter-web/build.gradle b/spring-boot-starter/spring-boot-starter-web/build.gradle index 5ddaa41c..0118afec 100644 --- a/spring-boot-starter/spring-boot-starter-web/build.gradle +++ b/spring-boot-starter/spring-boot-starter-web/build.gradle @@ -5,8 +5,6 @@ plugins { alias(libs.plugins.openapi.generator) } -apply from: "${rootDir}/gradle/publish-module.gradle" - dependencies { implementation platform(SpringBootPlugin.BOM_COORDINATES) @@ -21,58 +19,8 @@ dependencies { // TODO use spotbugs instead and also apply to all modules? implementation(libs.find.bugs) - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.boot:spring-boot-starter-validation' + testImplementation project(':test:test-utils') testImplementation 'org.springframework:spring-web' testImplementation 'org.springframework:spring-webmvc' testImplementation 'org.apache.tomcat.embed:tomcat-embed-core' // For jakarta.servlet.ServletContext - testImplementation(libs.jakarta.validation.api) - testImplementation(libs.swagger.annotations) - testImplementation(libs.openapi.tools.jacksonDatabindNullable) -} - - -def generatedSourceDirectory = "$buildDir/generated/openapi" -sourceSets { - test { - java { - srcDir generatedSourceDirectory + '/src/test/java' - } - } } - -openApiValidate { - inputSpec = "$projectDir/src/test/resources/openapi.yaml" - recommend = true -} - -openApiGenerate { - generatorName = "spring" - inputSpec = "$projectDir/src/test/resources/openapi.yaml" - outputDir = generatedSourceDirectory - apiPackage = "com.getyourguide.openapi.validation.example.openapi" - invokerPackage = "com.getyourguide.openapi.validation.example.openapi" - modelPackage = "com.getyourguide.openapi.validation.example.openapi.model" - configOptions = [ - useSpringBoot3 : "true", - dateLibrary : "java8", - performBeanValidation : "true", - hideGenerationTimestamp: "true", - serializableModel : "true", - interfaceOnly : "true", - skipDefaultInterface : "true", - useTags : "true" - ] -} - -tasks.register("openApiGenerateMove") { - doLast { - file("$generatedSourceDirectory/src/main").renameTo(file("$generatedSourceDirectory/src/test")) - } -} - - - -tasks.openApiGenerate.dependsOn tasks.openApiValidate -tasks.openApiGenerateMove.dependsOn tasks.openApiGenerate -tasks.compileTestJava.dependsOn tasks.openApiGenerateMove diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java index cad5b0eb..7d8fd4c1 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java @@ -6,7 +6,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.getyourguide.openapi.validation.example.openapi.model.BadRequestResponse; +import com.getyourguide.openapi.validation.test.openapi.model.BadRequestResponse; import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; import com.getyourguide.openapi.validation.integration.openapi.TestViolationLogger; import java.util.Optional; diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java index 0024b9c1..d71176da 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java @@ -1,8 +1,8 @@ package com.getyourguide.openapi.validation.integration.controller; -import com.getyourguide.openapi.validation.example.openapi.DefaultApi; -import com.getyourguide.openapi.validation.example.openapi.model.PostTestRequest; -import com.getyourguide.openapi.validation.example.openapi.model.TestResponse; +import com.getyourguide.openapi.validation.test.openapi.DefaultApi; +import com.getyourguide.openapi.validation.test.openapi.model.PostTestRequest; +import com.getyourguide.openapi.validation.test.openapi.model.TestResponse; import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; import com.getyourguide.openapi.validation.integration.exception.WithoutResponseStatusException; import java.time.LocalDate; diff --git a/spring-boot-starter/spring-boot-starter-webflux/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationWebFilter.java b/spring-boot-starter/spring-boot-starter-webflux/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationWebFilter.java index 0339babe..6834c82f 100644 --- a/spring-boot-starter/spring-boot-starter-webflux/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationWebFilter.java +++ b/spring-boot-starter/spring-boot-starter-webflux/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationWebFilter.java @@ -27,7 +27,6 @@ @Order(Ordered.HIGHEST_PRECEDENCE) @AllArgsConstructor public class OpenApiValidationWebFilter implements WebFilter { - private final OpenApiRequestValidator validator; private final TrafficSelector trafficSelector; private final ReactiveMetaDataFactory metaDataFactory; diff --git a/test/test-utils/build.gradle b/test/test-utils/build.gradle new file mode 100644 index 00000000..fd23dd26 --- /dev/null +++ b/test/test-utils/build.gradle @@ -0,0 +1,68 @@ +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + id 'java-library' + alias(libs.plugins.spring.boot) apply false + alias(libs.plugins.openapi.generator) +} + +apply from: "${rootDir}/gradle/publish-module.gradle" + +dependencies { + implementation platform(SpringBootPlugin.BOM_COORDINATES) + + compileOnly project(':openapi-validation-api') + compileOnly project(':openapi-validation-core') + + compileOnly 'org.springframework.boot:spring-boot-starter' + compileOnly 'org.springframework:spring-web' + + // TODO use spotbugs instead and also apply to all modules? + implementation(libs.find.bugs) + + api 'org.springframework.boot:spring-boot-starter-test' + + // For openapi generated code + implementation(libs.openapi.tools.jacksonDatabindNullable) + implementation(libs.swagger.annotations) + implementation(libs.jakarta.validation.api) + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.apache.tomcat.embed:tomcat-embed-core' // For jakarta.servlet.ServletContext +} + + +def generatedSourceDirectory = "$buildDir/generated/openapi" +sourceSets { + main { + java { + srcDir generatedSourceDirectory + '/src/main/java' + } + } +} + +openApiValidate { + inputSpec = "$projectDir/src/main/resources/openapi.yaml" + recommend = true +} + +openApiGenerate { + generatorName = "spring" + inputSpec = "$projectDir/src/main/resources/openapi.yaml" + outputDir = generatedSourceDirectory + apiPackage = "com.getyourguide.openapi.validation.test.openapi" + invokerPackage = "com.getyourguide.openapi.validation.test.openapi" + modelPackage = "com.getyourguide.openapi.validation.test.openapi.model" + configOptions = [ + useSpringBoot3 : "true", + dateLibrary : "java8", + performBeanValidation : "true", + hideGenerationTimestamp: "true", + serializableModel : "true", + interfaceOnly : "true", + skipDefaultInterface : "true", + useTags : "true" + ] +} + +tasks.openApiGenerate.dependsOn tasks.openApiValidate +tasks.compileJava.dependsOn tasks.openApiGenerate diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml b/test/test-utils/src/main/resources/openapi.yaml similarity index 100% rename from spring-boot-starter/spring-boot-starter-web/src/test/resources/openapi.yaml rename to test/test-utils/src/main/resources/openapi.yaml From 85c622d4b2ebdf863e11550c308932956f3d60a2 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 13:41:36 +0100 Subject: [PATCH 13/20] move around again --- settings.gradle | 1 + .../spring-boot-starter-web/build.gradle | 2 +- .../ExceptionsWithExceptionHandlerTest.java | 2 +- .../controller/DefaultRestController.java | 6 +- test/openapi-web/build.gradle | 65 +++++++++++++++++++ .../src/main/resources/openapi.yaml | 1 + .../src/main/resources => }/openapi.yaml | 0 test/test-utils/build.gradle | 47 -------------- 8 files changed, 72 insertions(+), 52 deletions(-) create mode 100644 test/openapi-web/build.gradle create mode 120000 test/openapi-web/src/main/resources/openapi.yaml rename test/{test-utils/src/main/resources => }/openapi.yaml (100%) diff --git a/settings.gradle b/settings.gradle index 4f9e9103..b8c0971b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -14,4 +14,5 @@ include(':examples:examples-common') include(':examples:example-spring-boot-starter-web') include(':examples:example-spring-boot-starter-webflux') +include(':test:openapi-web') include(':test:test-utils') diff --git a/spring-boot-starter/spring-boot-starter-web/build.gradle b/spring-boot-starter/spring-boot-starter-web/build.gradle index 0118afec..df4f72d9 100644 --- a/spring-boot-starter/spring-boot-starter-web/build.gradle +++ b/spring-boot-starter/spring-boot-starter-web/build.gradle @@ -19,7 +19,7 @@ dependencies { // TODO use spotbugs instead and also apply to all modules? implementation(libs.find.bugs) - testImplementation project(':test:test-utils') + testImplementation project(':test:openapi-web') testImplementation 'org.springframework:spring-web' testImplementation 'org.springframework:spring-webmvc' testImplementation 'org.apache.tomcat.embed:tomcat-embed-core' // For jakarta.servlet.ServletContext diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java index 7d8fd4c1..154fdbda 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java @@ -6,7 +6,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.getyourguide.openapi.validation.test.openapi.model.BadRequestResponse; +import com.getyourguide.openapi.validation.test.openapi.web.model.BadRequestResponse; import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; import com.getyourguide.openapi.validation.integration.openapi.TestViolationLogger; import java.util.Optional; diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java index d71176da..aaad8d33 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java @@ -1,8 +1,8 @@ package com.getyourguide.openapi.validation.integration.controller; -import com.getyourguide.openapi.validation.test.openapi.DefaultApi; -import com.getyourguide.openapi.validation.test.openapi.model.PostTestRequest; -import com.getyourguide.openapi.validation.test.openapi.model.TestResponse; +import com.getyourguide.openapi.validation.test.openapi.web.DefaultApi; +import com.getyourguide.openapi.validation.test.openapi.web.model.PostTestRequest; +import com.getyourguide.openapi.validation.test.openapi.web.model.TestResponse; import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; import com.getyourguide.openapi.validation.integration.exception.WithoutResponseStatusException; import java.time.LocalDate; diff --git a/test/openapi-web/build.gradle b/test/openapi-web/build.gradle new file mode 100644 index 00000000..1097d65a --- /dev/null +++ b/test/openapi-web/build.gradle @@ -0,0 +1,65 @@ +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + id 'java-library' + alias(libs.plugins.spring.boot) apply false + alias(libs.plugins.openapi.generator) +} + +dependencies { + implementation platform(SpringBootPlugin.BOM_COORDINATES) + + compileOnly project(':openapi-validation-api') + compileOnly project(':openapi-validation-core') + + compileOnly 'org.springframework.boot:spring-boot-starter' + compileOnly 'org.springframework:spring-web' + + // TODO use spotbugs instead and also apply to all modules? + implementation(libs.find.bugs) + + api 'org.springframework.boot:spring-boot-starter-test' + // For openapi generated code + implementation(libs.openapi.tools.jacksonDatabindNullable) + implementation(libs.swagger.annotations) + implementation(libs.jakarta.validation.api) + implementation 'org.springframework.boot:spring-boot-starter-validation' + api 'org.apache.tomcat.embed:tomcat-embed-core' // For jakarta.servlet.ServletContext +} + + +def generatedSourceDirectory = "$buildDir/generated/openapi" +sourceSets { + main { + java { + srcDir generatedSourceDirectory + '/src/main/java' + } + } +} + +openApiValidate { + inputSpec = "$projectDir/../openapi.yaml" + recommend = true +} + +openApiGenerate { + generatorName = "spring" + inputSpec = "$projectDir/../openapi.yaml" + outputDir = generatedSourceDirectory + apiPackage = "com.getyourguide.openapi.validation.test.openapi.web" + invokerPackage = "com.getyourguide.openapi.validation.test.openapi.web" + modelPackage = "com.getyourguide.openapi.validation.test.openapi.web.model" + configOptions = [ + useSpringBoot3 : "true", + dateLibrary : "java8", + performBeanValidation : "true", + hideGenerationTimestamp: "true", + serializableModel : "true", + interfaceOnly : "true", + skipDefaultInterface : "true", + useTags : "true" + ] +} + +tasks.openApiGenerate.dependsOn tasks.openApiValidate +tasks.compileJava.dependsOn tasks.openApiGenerate diff --git a/test/openapi-web/src/main/resources/openapi.yaml b/test/openapi-web/src/main/resources/openapi.yaml new file mode 120000 index 00000000..cbe0efcc --- /dev/null +++ b/test/openapi-web/src/main/resources/openapi.yaml @@ -0,0 +1 @@ +../../../../openapi.yaml \ No newline at end of file diff --git a/test/test-utils/src/main/resources/openapi.yaml b/test/openapi.yaml similarity index 100% rename from test/test-utils/src/main/resources/openapi.yaml rename to test/openapi.yaml diff --git a/test/test-utils/build.gradle b/test/test-utils/build.gradle index fd23dd26..20f98155 100644 --- a/test/test-utils/build.gradle +++ b/test/test-utils/build.gradle @@ -3,11 +3,8 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { id 'java-library' alias(libs.plugins.spring.boot) apply false - alias(libs.plugins.openapi.generator) } -apply from: "${rootDir}/gradle/publish-module.gradle" - dependencies { implementation platform(SpringBootPlugin.BOM_COORDINATES) @@ -21,48 +18,4 @@ dependencies { implementation(libs.find.bugs) api 'org.springframework.boot:spring-boot-starter-test' - - // For openapi generated code - implementation(libs.openapi.tools.jacksonDatabindNullable) - implementation(libs.swagger.annotations) - implementation(libs.jakarta.validation.api) - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.apache.tomcat.embed:tomcat-embed-core' // For jakarta.servlet.ServletContext -} - - -def generatedSourceDirectory = "$buildDir/generated/openapi" -sourceSets { - main { - java { - srcDir generatedSourceDirectory + '/src/main/java' - } - } } - -openApiValidate { - inputSpec = "$projectDir/src/main/resources/openapi.yaml" - recommend = true -} - -openApiGenerate { - generatorName = "spring" - inputSpec = "$projectDir/src/main/resources/openapi.yaml" - outputDir = generatedSourceDirectory - apiPackage = "com.getyourguide.openapi.validation.test.openapi" - invokerPackage = "com.getyourguide.openapi.validation.test.openapi" - modelPackage = "com.getyourguide.openapi.validation.test.openapi.model" - configOptions = [ - useSpringBoot3 : "true", - dateLibrary : "java8", - performBeanValidation : "true", - hideGenerationTimestamp: "true", - serializableModel : "true", - interfaceOnly : "true", - skipDefaultInterface : "true", - useTags : "true" - ] -} - -tasks.openApiGenerate.dependsOn tasks.openApiValidate -tasks.compileJava.dependsOn tasks.openApiGenerate From fe491791ec6c98074c1f7a56c253e972c5eb61cc Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 13:42:50 +0100 Subject: [PATCH 14/20] remove unused module --- settings.gradle | 1 - test/test-utils/build.gradle | 21 --------------------- 2 files changed, 22 deletions(-) delete mode 100644 test/test-utils/build.gradle diff --git a/settings.gradle b/settings.gradle index b8c0971b..49cf76ea 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,4 +15,3 @@ include(':examples:example-spring-boot-starter-web') include(':examples:example-spring-boot-starter-webflux') include(':test:openapi-web') -include(':test:test-utils') diff --git a/test/test-utils/build.gradle b/test/test-utils/build.gradle deleted file mode 100644 index 20f98155..00000000 --- a/test/test-utils/build.gradle +++ /dev/null @@ -1,21 +0,0 @@ -import org.springframework.boot.gradle.plugin.SpringBootPlugin - -plugins { - id 'java-library' - alias(libs.plugins.spring.boot) apply false -} - -dependencies { - implementation platform(SpringBootPlugin.BOM_COORDINATES) - - compileOnly project(':openapi-validation-api') - compileOnly project(':openapi-validation-core') - - compileOnly 'org.springframework.boot:spring-boot-starter' - compileOnly 'org.springframework:spring-web' - - // TODO use spotbugs instead and also apply to all modules? - implementation(libs.find.bugs) - - api 'org.springframework.boot:spring-boot-starter-test' -} From 9d970c806092c7e6b43000b8f7508b7b1123e1fc Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 13:55:08 +0100 Subject: [PATCH 15/20] move dependencies --- test/openapi-web/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/openapi-web/build.gradle b/test/openapi-web/build.gradle index 1097d65a..1778508a 100644 --- a/test/openapi-web/build.gradle +++ b/test/openapi-web/build.gradle @@ -20,10 +20,10 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-test' // For openapi generated code + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation(libs.openapi.tools.jacksonDatabindNullable) - implementation(libs.swagger.annotations) implementation(libs.jakarta.validation.api) - implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation(libs.swagger.annotations) api 'org.apache.tomcat.embed:tomcat-embed-core' // For jakarta.servlet.ServletContext } From ea0943df020a3ec280e41810940ddee701719d32 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 14:01:01 +0100 Subject: [PATCH 16/20] move stuff to test-utils --- settings.gradle | 2 ++ .../spring-boot-starter-web/build.gradle | 1 + .../ExceptionsNoExceptionHandlerTest.java | 2 +- .../ExceptionsWithExceptionHandlerTest.java | 2 +- .../OpenApiValidationIntegrationTest.java | 2 +- .../integration/SpringBootTestConfiguration.java | 7 +++++++ test/test-utils/build.gradle | 16 ++++++++++++++++ .../test}/openapi/TestViolationLogger.java | 4 +--- 8 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 test/test-utils/build.gradle rename {spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration => test/test-utils/src/main/java/com/getyourguide/openapi/validation/test}/openapi/TestViolationLogger.java (81%) diff --git a/settings.gradle b/settings.gradle index 49cf76ea..14bb59d4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,3 +15,5 @@ include(':examples:example-spring-boot-starter-web') include(':examples:example-spring-boot-starter-webflux') include(':test:openapi-web') +include(':test:openapi-webflux') +include(':test:test-utils') diff --git a/spring-boot-starter/spring-boot-starter-web/build.gradle b/spring-boot-starter/spring-boot-starter-web/build.gradle index df4f72d9..0f5d9e45 100644 --- a/spring-boot-starter/spring-boot-starter-web/build.gradle +++ b/spring-boot-starter/spring-boot-starter-web/build.gradle @@ -19,6 +19,7 @@ dependencies { // TODO use spotbugs instead and also apply to all modules? implementation(libs.find.bugs) + testImplementation project(':test:test-utils') testImplementation project(':test:openapi-web') testImplementation 'org.springframework:spring-web' testImplementation 'org.springframework:spring-webmvc' diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java index 79f45986..4f036d24 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java @@ -8,7 +8,7 @@ import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; import com.getyourguide.openapi.validation.integration.exception.WithoutResponseStatusException; -import com.getyourguide.openapi.validation.integration.openapi.TestViolationLogger; +import com.getyourguide.openapi.validation.test.openapi.TestViolationLogger; import jakarta.servlet.ServletException; import java.util.Optional; import org.hamcrest.Matchers; diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java index 154fdbda..45c40239 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java @@ -6,9 +6,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.getyourguide.openapi.validation.test.openapi.TestViolationLogger; import com.getyourguide.openapi.validation.test.openapi.web.model.BadRequestResponse; import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; -import com.getyourguide.openapi.validation.integration.openapi.TestViolationLogger; import java.util.Optional; import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java index 41ec98c1..24bb28ad 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java @@ -10,7 +10,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.getyourguide.openapi.validation.api.model.OpenApiViolation; -import com.getyourguide.openapi.validation.integration.openapi.TestViolationLogger; +import com.getyourguide.openapi.validation.test.openapi.TestViolationLogger; import java.util.List; import java.util.Optional; import javax.annotation.Nullable; diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java index d41c1aa4..37f7d4e2 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java @@ -1,9 +1,12 @@ package com.getyourguide.openapi.validation.integration; +import com.getyourguide.openapi.validation.api.log.ViolationLogger; +import com.getyourguide.openapi.validation.test.openapi.TestViolationLogger; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.context.TypeExcludeFilter; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.FilterType; @@ -14,4 +17,8 @@ @ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) }) public class SpringBootTestConfiguration { + @Bean + public ViolationLogger testViolationLogger() { + return new TestViolationLogger(); + } } diff --git a/test/test-utils/build.gradle b/test/test-utils/build.gradle new file mode 100644 index 00000000..25aaffa5 --- /dev/null +++ b/test/test-utils/build.gradle @@ -0,0 +1,16 @@ +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + id 'java-library' + alias(libs.plugins.spring.boot) apply false +} + +dependencies { + implementation platform(SpringBootPlugin.BOM_COORDINATES) + + compileOnly project(':openapi-validation-api') + compileOnly project(':openapi-validation-core') + + // TODO use spotbugs instead and also apply to all modules? + implementation(libs.find.bugs) +} diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/openapi/TestViolationLogger.java b/test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/openapi/TestViolationLogger.java similarity index 81% rename from spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/openapi/TestViolationLogger.java rename to test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/openapi/TestViolationLogger.java index 34a9da16..43fbf3a6 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/openapi/TestViolationLogger.java +++ b/test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/openapi/TestViolationLogger.java @@ -1,14 +1,12 @@ -package com.getyourguide.openapi.validation.integration.openapi; +package com.getyourguide.openapi.validation.test.openapi; import com.getyourguide.openapi.validation.api.log.ViolationLogger; import com.getyourguide.openapi.validation.api.model.OpenApiViolation; import java.util.ArrayList; import java.util.List; import lombok.Getter; -import org.springframework.stereotype.Component; @Getter -@Component public class TestViolationLogger implements ViolationLogger { private final List violations = new ArrayList<>(); From 06ef4a59373d44d72d8a40bf714f1faa307beb32 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 14:09:52 +0100 Subject: [PATCH 17/20] move more files --- .../integration/ExceptionsNoExceptionHandlerTest.java | 6 +++--- .../integration/ExceptionsWithExceptionHandlerTest.java | 4 ++-- .../integration/OpenApiValidationIntegrationTest.java | 2 +- .../validation/integration/SpringBootTestConfiguration.java | 2 +- .../integration/controller/DefaultRestController.java | 4 ++-- test/test-utils/build.gradle | 2 ++ .../validation/test/{openapi => }/TestViolationLogger.java | 2 +- .../test}/exception/WithResponseStatusException.java | 2 +- .../test}/exception/WithoutResponseStatusException.java | 2 +- 9 files changed, 14 insertions(+), 12 deletions(-) rename test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/{openapi => }/TestViolationLogger.java (90%) rename {spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration => test/test-utils/src/main/java/com/getyourguide/openapi/validation/test}/exception/WithResponseStatusException.java (82%) rename {spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration => test/test-utils/src/main/java/com/getyourguide/openapi/validation/test}/exception/WithoutResponseStatusException.java (71%) diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java index 4f036d24..2896c22e 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java @@ -6,9 +6,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; -import com.getyourguide.openapi.validation.integration.exception.WithoutResponseStatusException; -import com.getyourguide.openapi.validation.test.openapi.TestViolationLogger; +import com.getyourguide.openapi.validation.test.TestViolationLogger; +import com.getyourguide.openapi.validation.test.exception.WithResponseStatusException; +import com.getyourguide.openapi.validation.test.exception.WithoutResponseStatusException; import jakarta.servlet.ServletException; import java.util.Optional; import org.hamcrest.Matchers; diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java index 45c40239..6af4fd54 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java @@ -6,9 +6,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.getyourguide.openapi.validation.test.openapi.TestViolationLogger; +import com.getyourguide.openapi.validation.test.TestViolationLogger; +import com.getyourguide.openapi.validation.test.exception.WithResponseStatusException; import com.getyourguide.openapi.validation.test.openapi.web.model.BadRequestResponse; -import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; import java.util.Optional; import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java index 24bb28ad..f4dfb541 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java @@ -10,7 +10,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.getyourguide.openapi.validation.api.model.OpenApiViolation; -import com.getyourguide.openapi.validation.test.openapi.TestViolationLogger; +import com.getyourguide.openapi.validation.test.TestViolationLogger; import java.util.List; import java.util.Optional; import javax.annotation.Nullable; diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java index 37f7d4e2..7ea7cd2c 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java @@ -1,7 +1,7 @@ package com.getyourguide.openapi.validation.integration; import com.getyourguide.openapi.validation.api.log.ViolationLogger; -import com.getyourguide.openapi.validation.test.openapi.TestViolationLogger; +import com.getyourguide.openapi.validation.test.TestViolationLogger; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java index aaad8d33..7baaea7c 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java @@ -1,10 +1,10 @@ package com.getyourguide.openapi.validation.integration.controller; +import com.getyourguide.openapi.validation.test.exception.WithResponseStatusException; +import com.getyourguide.openapi.validation.test.exception.WithoutResponseStatusException; import com.getyourguide.openapi.validation.test.openapi.web.DefaultApi; import com.getyourguide.openapi.validation.test.openapi.web.model.PostTestRequest; import com.getyourguide.openapi.validation.test.openapi.web.model.TestResponse; -import com.getyourguide.openapi.validation.integration.exception.WithResponseStatusException; -import com.getyourguide.openapi.validation.integration.exception.WithoutResponseStatusException; import java.time.LocalDate; import java.util.Objects; import org.springframework.http.ResponseEntity; diff --git a/test/test-utils/build.gradle b/test/test-utils/build.gradle index 25aaffa5..d7c5a177 100644 --- a/test/test-utils/build.gradle +++ b/test/test-utils/build.gradle @@ -13,4 +13,6 @@ dependencies { // TODO use spotbugs instead and also apply to all modules? implementation(libs.find.bugs) + + compileOnly 'org.springframework:spring-web' } diff --git a/test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/openapi/TestViolationLogger.java b/test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/TestViolationLogger.java similarity index 90% rename from test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/openapi/TestViolationLogger.java rename to test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/TestViolationLogger.java index 43fbf3a6..42ecb0f2 100644 --- a/test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/openapi/TestViolationLogger.java +++ b/test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/TestViolationLogger.java @@ -1,4 +1,4 @@ -package com.getyourguide.openapi.validation.test.openapi; +package com.getyourguide.openapi.validation.test; import com.getyourguide.openapi.validation.api.log.ViolationLogger; import com.getyourguide.openapi.validation.api.model.OpenApiViolation; diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithResponseStatusException.java b/test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/exception/WithResponseStatusException.java similarity index 82% rename from spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithResponseStatusException.java rename to test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/exception/WithResponseStatusException.java index c3537a32..5be4edb7 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithResponseStatusException.java +++ b/test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/exception/WithResponseStatusException.java @@ -1,4 +1,4 @@ -package com.getyourguide.openapi.validation.integration.exception; +package com.getyourguide.openapi.validation.test.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithoutResponseStatusException.java b/test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/exception/WithoutResponseStatusException.java similarity index 71% rename from spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithoutResponseStatusException.java rename to test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/exception/WithoutResponseStatusException.java index 175f48f5..e22106fd 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/exception/WithoutResponseStatusException.java +++ b/test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/exception/WithoutResponseStatusException.java @@ -1,4 +1,4 @@ -package com.getyourguide.openapi.validation.integration.exception; +package com.getyourguide.openapi.validation.test.exception; public class WithoutResponseStatusException extends RuntimeException { From 55f127cc1b666623e401937312b7ac0ed033062a Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 14:43:09 +0100 Subject: [PATCH 18/20] improve test code --- .../ExceptionsNoExceptionHandlerTest.java | 13 +++-------- .../ExceptionsWithExceptionHandlerTest.java | 6 +++-- .../SpringBootTestConfiguration.java | 7 ++---- .../DefaultSpringBootTestConfiguration.java | 23 +++++++++++++++++++ 4 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/DefaultSpringBootTestConfiguration.java diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java index 2896c22e..6778be25 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java @@ -18,7 +18,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MockMvc; @@ -43,7 +42,7 @@ public void setup() { @Test public void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() throws Exception { - mockMvc.perform(get("/test").queryParam("date", "not-a-date").contentType(MediaType.APPLICATION_JSON)) + mockMvc.perform(get("/test").queryParam("date", "not-a-date")) .andExpectAll( status().is4xxClientError(), content().string(Matchers.blankOrNullString()) @@ -61,10 +60,7 @@ public void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() thro public void whenTestThrowExceptionWithResponseStatusThenReturns400WithoutViolationLogged() throws Exception { mockMvc - .perform( - get("/test").queryParam("testCase", "throwExceptionWithResponseStatus") - .contentType(MediaType.APPLICATION_JSON) - ) + .perform(get("/test").queryParam("testCase", "throwExceptionWithResponseStatus")) .andExpectAll( status().is4xxClientError(), content().string(Matchers.blankOrNullString()), @@ -86,10 +82,7 @@ public void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViol throws Exception { var exception = assertThrows(ServletException.class, () -> { mockMvc - .perform( - get("/test").queryParam("testCase", "throwExceptionWithoutResponseStatus") - .contentType(MediaType.APPLICATION_JSON) - ) + .perform(get("/test").queryParam("testCase", "throwExceptionWithoutResponseStatus")) .andExpect(status().is5xxServerError()); }); Thread.sleep(100); diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java index 6af4fd54..ead6545e 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java @@ -25,8 +25,10 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -@SpringBootTest(classes = {SpringBootTestConfiguration.class, - ExceptionsWithExceptionHandlerTest.ExceptionHandlerConfiguration.class}) +@SpringBootTest(classes = { + SpringBootTestConfiguration.class, + ExceptionsWithExceptionHandlerTest.ExceptionHandlerConfiguration.class, +}) @AutoConfigureMockMvc @ExtendWith(SpringExtension.class) public class ExceptionsWithExceptionHandlerTest { diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java index 7ea7cd2c..6b3e6ae2 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java @@ -1,6 +1,7 @@ package com.getyourguide.openapi.validation.integration; import com.getyourguide.openapi.validation.api.log.ViolationLogger; +import com.getyourguide.openapi.validation.test.DefaultSpringBootTestConfiguration; import com.getyourguide.openapi.validation.test.TestViolationLogger; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter; @@ -16,9 +17,5 @@ @ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class), @ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) }) -public class SpringBootTestConfiguration { - @Bean - public ViolationLogger testViolationLogger() { - return new TestViolationLogger(); - } +public class SpringBootTestConfiguration extends DefaultSpringBootTestConfiguration { } diff --git a/test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/DefaultSpringBootTestConfiguration.java b/test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/DefaultSpringBootTestConfiguration.java new file mode 100644 index 00000000..d23dde2e --- /dev/null +++ b/test/test-utils/src/main/java/com/getyourguide/openapi/validation/test/DefaultSpringBootTestConfiguration.java @@ -0,0 +1,23 @@ +package com.getyourguide.openapi.validation.test; + +import com.getyourguide.openapi.validation.api.log.ViolationLogger; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.TypeExcludeFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; + +@SpringBootConfiguration +@EnableAutoConfiguration +@ComponentScan(excludeFilters = { + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class), + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) +}) +public class DefaultSpringBootTestConfiguration { + @Bean + public ViolationLogger testViolationLogger() { + return new TestViolationLogger(); + } +} From 312f3ac0c364cb0a81c5afc7c5487f6e4ee0eb95 Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 15:09:49 +0100 Subject: [PATCH 19/20] add missing dependency --- test/test-utils/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test-utils/build.gradle b/test/test-utils/build.gradle index d7c5a177..d20371d0 100644 --- a/test/test-utils/build.gradle +++ b/test/test-utils/build.gradle @@ -14,5 +14,6 @@ dependencies { // TODO use spotbugs instead and also apply to all modules? implementation(libs.find.bugs) + compileOnly 'org.springframework.boot:spring-boot-starter' compileOnly 'org.springframework:spring-web' } From 4a1e00c713472b72d7a35cd88c129c89ef0b4c5e Mon Sep 17 00:00:00 2001 From: Patrick Boos Date: Wed, 22 Nov 2023 15:12:12 +0100 Subject: [PATCH 20/20] add integration tests to webflux --- .../SpringBootTestConfiguration.java | 3 - .../spring-boot-starter-webflux/build.gradle | 5 +- .../ExceptionsNoExceptionHandlerTest.java | 84 ++++++++++ .../ExceptionsWithExceptionHandlerTest.java | 111 +++++++++++++ .../OpenApiValidationIntegrationTest.java | 146 ++++++++++++++++++ .../SpringBootTestConfiguration.java | 18 +++ .../controller/DefaultRestController.java | 46 ++++++ test/openapi-webflux/build.gradle | 66 ++++++++ .../src/main/resources/openapi.yaml | 1 + 9 files changed, 475 insertions(+), 5 deletions(-) create mode 100644 spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java create mode 100644 spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java create mode 100644 spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java create mode 100644 spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java create mode 100644 spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java create mode 100644 test/openapi-webflux/build.gradle create mode 120000 test/openapi-webflux/src/main/resources/openapi.yaml diff --git a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java index 6b3e6ae2..8f45924a 100644 --- a/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java +++ b/spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java @@ -1,13 +1,10 @@ package com.getyourguide.openapi.validation.integration; -import com.getyourguide.openapi.validation.api.log.ViolationLogger; import com.getyourguide.openapi.validation.test.DefaultSpringBootTestConfiguration; -import com.getyourguide.openapi.validation.test.TestViolationLogger; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.context.TypeExcludeFilter; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.FilterType; diff --git a/spring-boot-starter/spring-boot-starter-webflux/build.gradle b/spring-boot-starter/spring-boot-starter-webflux/build.gradle index 1a6a9e71..5681a0c4 100644 --- a/spring-boot-starter/spring-boot-starter-webflux/build.gradle +++ b/spring-boot-starter/spring-boot-starter-webflux/build.gradle @@ -20,8 +20,9 @@ dependencies { // TODO use spotbugs instead and also apply to all modules? implementation(libs.find.bugs) - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework:spring-webflux' + testImplementation project(':test:test-utils') + testImplementation project(':test:openapi-webflux') + testImplementation 'org.springframework.boot:spring-boot-starter-webflux' testImplementation 'io.projectreactor:reactor-test' testImplementation 'org.apache.tomcat.embed:tomcat-embed-core' // For jakarta.servlet.ServletContext } diff --git a/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java new file mode 100644 index 00000000..c0d90433 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsNoExceptionHandlerTest.java @@ -0,0 +1,84 @@ +package com.getyourguide.openapi.validation.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.getyourguide.openapi.validation.test.TestViolationLogger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.reactive.server.WebTestClient; + +@SpringBootTest +@AutoConfigureMockMvc +@ExtendWith(SpringExtension.class) +public class ExceptionsNoExceptionHandlerTest { + + // These test cases test that requests to an endpoint that throws an exception (Mono.error) + // that is not handled by any code (no global error handler either) is correctly intercepted by the library. + + @Autowired + private WebTestClient webTestClient; + + @Autowired + private TestViolationLogger openApiViolationLogger; + + @BeforeEach + public void setup() { + openApiViolationLogger.clearViolations(); + } + + @Test + public void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() throws Exception { + webTestClient + .get().uri("/test?date=not-a-date") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isBadRequest() + .expectBody() + .jsonPath("$.status").isEqualTo(400) + .jsonPath("$.path").isEqualTo("/test") + .jsonPath("$.error").isEqualTo("Bad Request"); + Thread.sleep(100); + + assertEquals(0, openApiViolationLogger.getViolations().size()); + } + + @Test + public void whenTestThrowExceptionWithResponseStatusThenReturns400WithoutViolationLogged() + throws Exception { + webTestClient + .get().uri("/test?testCase=throwExceptionWithResponseStatus") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isBadRequest() + .expectBody() + .jsonPath("$.status").isEqualTo(400) + .jsonPath("$.path").isEqualTo("/test") + .jsonPath("$.error").isEqualTo("Bad Request"); + Thread.sleep(100); + + assertEquals(0, openApiViolationLogger.getViolations().size()); + } + + @Test + public void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViolationLogged() + throws Exception { + webTestClient + .get().uri("/test?testCase=throwExceptionWithoutResponseStatus") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().is5xxServerError() + .expectBody() + .jsonPath("$.status").isEqualTo(500) + .jsonPath("$.path").isEqualTo("/test") + .jsonPath("$.error").isEqualTo("Internal Server Error"); + Thread.sleep(100); + + assertEquals(0, openApiViolationLogger.getViolations().size()); + } +} diff --git a/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java b/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java new file mode 100644 index 00000000..fab0f090 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/ExceptionsWithExceptionHandlerTest.java @@ -0,0 +1,111 @@ +package com.getyourguide.openapi.validation.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.getyourguide.openapi.validation.test.TestViolationLogger; +import com.getyourguide.openapi.validation.test.exception.WithResponseStatusException; +import com.getyourguide.openapi.validation.test.exception.WithoutResponseStatusException; +import com.getyourguide.openapi.validation.test.openapi.webflux.model.BadRequestResponse; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.server.ServerWebInputException; + +@SpringBootTest(classes = { + SpringBootTestConfiguration.class, + ExceptionsWithExceptionHandlerTest.ExceptionHandlerConfiguration.class, +}) +@AutoConfigureMockMvc +@ExtendWith(SpringExtension.class) +public class ExceptionsWithExceptionHandlerTest { + + @Autowired + private WebTestClient webTestClient; + + @Autowired + private TestViolationLogger openApiViolationLogger; + + @BeforeEach + public void setup() { + openApiViolationLogger.clearViolations(); + } + + @Test + public void whenTestInvalidQueryParamThenReturns400WithoutViolationLogged() throws Exception { + webTestClient + .get().uri("/test?date=not-a-date") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isBadRequest() + .expectBody().jsonPath("$.error").isEqualTo("ServerWebInputException"); + Thread.sleep(100); + + assertEquals(0, openApiViolationLogger.getViolations().size()); + } + + @Test + public void whenTestThrowExceptionWithResponseStatusThenReturns400WithoutViolationLogged() + throws Exception { + webTestClient + .get().uri("/test?testCase=throwExceptionWithResponseStatus") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isBadRequest() + .expectBody().jsonPath("$.error").isEqualTo("Unhandled exception"); + Thread.sleep(100); + + assertEquals(0, openApiViolationLogger.getViolations().size()); + } + + @Test + public void whenTestThrowExceptionWithoutResponseStatusThenReturns500WithoutViolationLogged() + throws Exception { + webTestClient + .get().uri("/test?testCase=throwExceptionWithoutResponseStatus") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().is5xxServerError() + .expectBody().isEmpty(); + Thread.sleep(100); + + // Note: We return no body on purpose in the exception handler below to test this violation appears. + assertEquals(1, openApiViolationLogger.getViolations().size()); + var violation = openApiViolationLogger.getViolations().get(0); + assertEquals("validation.response.body.missing", violation.getRule()); + assertEquals(Optional.of(500), violation.getResponseStatus()); + } + + @ControllerAdvice + public static class ExceptionHandlerConfiguration { + @ExceptionHandler(ServerWebInputException.class) + public ResponseEntity handle(ServerWebInputException exception) { + return ResponseEntity.badRequest().body(new BadRequestResponse().error("ServerWebInputException")); + } + + @ExceptionHandler(WithResponseStatusException.class) + public ResponseEntity handle(WithResponseStatusException exception) { + return ResponseEntity.badRequest().body(new BadRequestResponse().error("Unhandled exception")); + } + + @ExceptionHandler(WithoutResponseStatusException.class) + public ResponseEntity handle(WithoutResponseStatusException exception) { + return ResponseEntity.internalServerError().build(); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handle(MethodArgumentTypeMismatchException exception) { + return ResponseEntity.badRequest().body(new BadRequestResponse().error("Invalid parameter")); + } + } +} diff --git a/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java b/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java new file mode 100644 index 00000000..7faf80d1 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.java @@ -0,0 +1,146 @@ +package com.getyourguide.openapi.validation.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.getyourguide.openapi.validation.api.model.OpenApiViolation; +import com.getyourguide.openapi.validation.test.TestViolationLogger; +import com.getyourguide.openapi.validation.test.openapi.webflux.model.TestResponse; +import java.util.List; +import java.util.Optional; +import javax.annotation.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.reactive.server.WebTestClient; + +@SpringBootTest +@AutoConfigureMockMvc +@ExtendWith(SpringExtension.class) +public class OpenApiValidationIntegrationTest { + @Autowired + private WebTestClient webTestClient; + + @Autowired + private TestViolationLogger openApiViolationLogger; + + @BeforeEach + public void setup() { + openApiViolationLogger.clearViolations(); + } + + @Test + public void whenTestSuccessfulResponseThenShouldNotLogViolation() throws Exception { + webTestClient + .get().uri("/test") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody(TestResponse.class) + .consumeWith(serverResponse -> { + assertNotNull(serverResponse.getResponseBody()); + assertEquals("test", serverResponse.getResponseBody().getValue()); + }); + Thread.sleep(100); + + assertEquals(0, openApiViolationLogger.getViolations().size()); + } + + @Test + public void whenTestValidRequestWithInvalidResponseThenShouldReturnSuccessAndLogViolation() throws Exception { + webTestClient + .get().uri("/test?value=invalid-response-value!") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody(TestResponse.class) + .consumeWith(serverResponse -> { + assertNotNull(serverResponse.getResponseBody()); + assertEquals("invalid-response-value!", serverResponse.getResponseBody().getValue()); + }); + Thread.sleep(100); + + assertEquals(1, openApiViolationLogger.getViolations().size()); + var violation = openApiViolationLogger.getViolations().get(0); + assertEquals("validation.response.body.schema.pattern", violation.getRule()); + assertEquals(Optional.of(200), violation.getResponseStatus()); + assertEquals(Optional.of("/value"), violation.getInstance()); + } + + @Test + public void whenTestInvalidRequestNotHandledBySpringBootThenShouldReturnSuccessAndLogViolation() throws Exception { + webTestClient + .post().uri("/test") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("{ \"value\": 1 }") + .exchange() + .expectStatus().isNoContent() + .expectBody().isEmpty(); + Thread.sleep(100); + + assertEquals(1, openApiViolationLogger.getViolations().size()); + var violation = openApiViolationLogger.getViolations().get(0); + assertEquals("validation.request.body.schema.type", violation.getRule()); + assertEquals(Optional.of(204), violation.getResponseStatus()); + assertEquals(Optional.of("/value"), violation.getInstance()); + } + + + @Test + public void whenTestInvalidRequestAndInvalidResponseThenShouldReturnSuccessAndLogViolation() throws Exception { + webTestClient + .post().uri("/test") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("{ \"value\": 1, \"responseStatusCode\": 200 }") + .exchange() + .expectStatus().isOk() + .expectBody(TestResponse.class) + .consumeWith(serverResponse -> { + assertNotNull(serverResponse.getResponseBody()); + assertEquals("1", serverResponse.getResponseBody().getValue()); + }); + Thread.sleep(100); + + var violations = openApiViolationLogger.getViolations(); + assertEquals(2, violations.size()); + var violation = getViolationByRule(violations, "validation.response.body.schema.pattern"); + assertNotNull(violation); + assertEquals(Optional.of(200), violation.getResponseStatus()); + assertEquals(Optional.of("/value"), violation.getInstance()); + var violation2 = getViolationByRule(violations, "validation.request.body.schema.type"); + assertNotNull(violation2); + assertEquals(Optional.of(200), violation2.getResponseStatus()); + assertEquals(Optional.of("/value"), violation2.getInstance()); + } + + @Test + public void whenTestOptionsCallThenShouldNotValidate() throws Exception { + // Note: Options is not in the spec and would report a violation if it was validated. + webTestClient + .options().uri("/test") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody().isEmpty(); + Thread.sleep(100); + + assertEquals(0, openApiViolationLogger.getViolations().size()); + } + + // TODO Add test that fails on request violation immediately (maybe needs separate test class & setup) should not log violation + + @Nullable + private OpenApiViolation getViolationByRule(List violations, String rule) { + return violations.stream() + .filter(violation -> violation.getRule().equals(rule)) + .findFirst() + .orElse(null); + } +} diff --git a/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java b/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java new file mode 100644 index 00000000..8f45924a --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/SpringBootTestConfiguration.java @@ -0,0 +1,18 @@ +package com.getyourguide.openapi.validation.integration; + +import com.getyourguide.openapi.validation.test.DefaultSpringBootTestConfiguration; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.TypeExcludeFilter; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; + +@SpringBootConfiguration +@EnableAutoConfiguration +@ComponentScan(excludeFilters = { + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class), + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) +}) +public class SpringBootTestConfiguration extends DefaultSpringBootTestConfiguration { +} diff --git a/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java b/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java new file mode 100644 index 00000000..e1362bc7 --- /dev/null +++ b/spring-boot-starter/spring-boot-starter-webflux/src/test/java/com/getyourguide/openapi/validation/integration/controller/DefaultRestController.java @@ -0,0 +1,46 @@ +package com.getyourguide.openapi.validation.integration.controller; + +import com.getyourguide.openapi.validation.test.exception.WithResponseStatusException; +import com.getyourguide.openapi.validation.test.exception.WithoutResponseStatusException; +import com.getyourguide.openapi.validation.test.openapi.webflux.DefaultApi; +import com.getyourguide.openapi.validation.test.openapi.webflux.model.PostTestRequest; +import com.getyourguide.openapi.validation.test.openapi.webflux.model.TestResponse; +import java.time.LocalDate; +import java.util.Objects; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@RestController +public class DefaultRestController implements DefaultApi { + @Override + public Mono> getTest( + String testCase, LocalDate date, String value, + ServerWebExchange exchange + ) { + if (Objects.equals(testCase, "throwExceptionWithResponseStatus")) { + return Mono.error(new WithResponseStatusException("Unhandled exception")); + } + if (Objects.equals(testCase, "throwExceptionWithoutResponseStatus")) { + return Mono.error(new WithoutResponseStatusException("Unhandled exception")); + } + + var responseValue = value != null ? value : "test"; + return Mono.just(ResponseEntity.ok(new TestResponse().value(responseValue))); + } + + @Override + public Mono> postTest( + Mono postTestRequest, + ServerWebExchange exchange + ) { + return postTestRequest.flatMap( request -> { + var responseStatus = request.getResponseStatusCode(); + if (responseStatus != null && responseStatus == 200) { + return Mono.just(ResponseEntity.ok(new TestResponse().value(request.getValue()))); + } + return Mono.just(ResponseEntity.noContent().build()); + }); + } +} diff --git a/test/openapi-webflux/build.gradle b/test/openapi-webflux/build.gradle new file mode 100644 index 00000000..3613cb36 --- /dev/null +++ b/test/openapi-webflux/build.gradle @@ -0,0 +1,66 @@ +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + id 'java-library' + alias(libs.plugins.spring.boot) apply false + alias(libs.plugins.openapi.generator) +} + +dependencies { + implementation platform(SpringBootPlugin.BOM_COORDINATES) + + compileOnly project(':openapi-validation-api') + compileOnly project(':openapi-validation-core') + + compileOnly 'org.springframework.boot:spring-boot-starter' + compileOnly 'org.springframework:spring-webflux' + + // TODO use spotbugs instead and also apply to all modules? + implementation(libs.find.bugs) + + api 'org.springframework.boot:spring-boot-starter-test' + // For openapi generated code + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation(libs.openapi.tools.jacksonDatabindNullable) + implementation(libs.jakarta.validation.api) + implementation(libs.swagger.annotations) + api 'org.apache.tomcat.embed:tomcat-embed-core' // For jakarta.servlet.ServletContext +} + + +def generatedSourceDirectory = "$buildDir/generated/openapi" +sourceSets { + main { + java { + srcDir generatedSourceDirectory + '/src/main/java' + } + } +} + +openApiValidate { + inputSpec = "$projectDir/../openapi.yaml" + recommend = true +} + +openApiGenerate { + generatorName = "spring" + inputSpec = "$projectDir/../openapi.yaml" + outputDir = generatedSourceDirectory + apiPackage = "com.getyourguide.openapi.validation.test.openapi.webflux" + invokerPackage = "com.getyourguide.openapi.validation.test.openapi.webflux" + modelPackage = "com.getyourguide.openapi.validation.test.openapi.webflux.model" + configOptions = [ + useSpringBoot3 : "true", + reactive : "true", + dateLibrary : "java8", + performBeanValidation : "true", + hideGenerationTimestamp: "true", + serializableModel : "true", + interfaceOnly : "true", + skipDefaultInterface : "true", + useTags : "true" + ] +} + +tasks.openApiGenerate.dependsOn tasks.openApiValidate +tasks.compileJava.dependsOn tasks.openApiGenerate diff --git a/test/openapi-webflux/src/main/resources/openapi.yaml b/test/openapi-webflux/src/main/resources/openapi.yaml new file mode 120000 index 00000000..cbe0efcc --- /dev/null +++ b/test/openapi-webflux/src/main/resources/openapi.yaml @@ -0,0 +1 @@ +../../../../openapi.yaml \ No newline at end of file