diff --git a/router-openapi-request-validator/src/main/kotlin/io/moia/router/openapi/ValidatingRequestRouterWrapper.kt b/router-openapi-request-validator/src/main/kotlin/io/moia/router/openapi/ValidatingRequestRouterWrapper.kt new file mode 100644 index 00000000..9923a7ea --- /dev/null +++ b/router-openapi-request-validator/src/main/kotlin/io/moia/router/openapi/ValidatingRequestRouterWrapper.kt @@ -0,0 +1,69 @@ +package io.moia.router.openapi + +import com.amazonaws.services.lambda.runtime.Context +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent +import io.moia.router.RequestHandler +import org.slf4j.LoggerFactory + +/** + * A wrapper around a [io.moia.router.RequestHandler] that transparently validates every request/response against the OpenAPI spec. + * + * This can be used in tests to make sure the actual requests and responses match the API specification. + * + * It uses [OpenApiValidator] to do the validation. + * + * @property delegate the actual [io.moia.router.RequestHandler] to forward requests to. + * @property specFile the location of the OpenAPI / Swagger specification to use in the validator, or the inline specification to use. See also [com.atlassian.oai.validator.OpenApiInteractionValidator.createFor]] + */ +class ValidatingRequestRouterWrapper( + val delegate: RequestHandler, + specUrlOrPayload: String, + private val additionalRequestValidationFunctions: List<(APIGatewayProxyRequestEvent) -> Unit> = emptyList(), + private val additionalResponseValidationFunctions: List<(APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent) -> Unit> = emptyList() +) { + private val openApiValidator = OpenApiValidator(specUrlOrPayload) + + fun handleRequest(input: APIGatewayProxyRequestEvent, context: Context): APIGatewayProxyResponseEvent = + handleRequest(input = input, context = context, skipRequestValidation = false, skipResponseValidation = false) + + fun handleRequestSkippingRequestAndResponseValidation(input: APIGatewayProxyRequestEvent, context: Context): APIGatewayProxyResponseEvent = + handleRequest(input = input, context = context, skipRequestValidation = true, skipResponseValidation = true) + + private fun handleRequest(input: APIGatewayProxyRequestEvent, context: Context, skipRequestValidation: Boolean, skipResponseValidation: Boolean): APIGatewayProxyResponseEvent { + + if (!skipRequestValidation) { + try { + openApiValidator.assertValidRequest(input) + runAdditionalRequestValidations(input) + } catch (e: Exception) { + log.error("Validation failed for request $input", e) + throw e + } + } + val response = delegate.handleRequest(input, context) + if (!skipResponseValidation) { + try { + runAdditionalResponseValidations(input, response) + openApiValidator.assertValidResponse(input, response) + } catch (e: Exception) { + log.error("Validation failed for response $response", e) + throw e + } + } + + return response + } + + private fun runAdditionalRequestValidations(requestEvent: APIGatewayProxyRequestEvent) { + additionalRequestValidationFunctions.forEach { it(requestEvent) } + } + + private fun runAdditionalResponseValidations(requestEvent: APIGatewayProxyRequestEvent, responseEvent: APIGatewayProxyResponseEvent) { + additionalResponseValidationFunctions.forEach { it(requestEvent, responseEvent) } + } + + companion object { + private val log = LoggerFactory.getLogger(ValidatingRequestRouterWrapper::class.java) + } +} \ No newline at end of file diff --git a/router-openapi-request-validator/src/test/kotlin/io/moia/router/openapi/ValidatingRequestRouterWrapperTest.kt b/router-openapi-request-validator/src/test/kotlin/io/moia/router/openapi/ValidatingRequestRouterWrapperTest.kt new file mode 100644 index 00000000..4ddbc257 --- /dev/null +++ b/router-openapi-request-validator/src/test/kotlin/io/moia/router/openapi/ValidatingRequestRouterWrapperTest.kt @@ -0,0 +1,91 @@ +package io.moia.router.openapi + +import io.mockk.mockk +import io.moia.router.GET +import io.moia.router.Request +import io.moia.router.RequestHandler +import io.moia.router.ResponseEntity +import io.moia.router.Router.Companion.router +import io.moia.router.withAcceptHeader +import org.assertj.core.api.BDDAssertions.then +import org.assertj.core.api.BDDAssertions.thenThrownBy +import org.junit.jupiter.api.Test + +class ValidatingRequestRouterWrapperTest { + + @Test + fun `should return response on successful validation`() { + val response = ValidatingRequestRouterWrapper(TestRequestHandler(), "openapi.yml") + .handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk()) + + then(response.statusCode).isEqualTo(200) + } + + @Test + fun `should fail on response validation error`() { + thenThrownBy { + ValidatingRequestRouterWrapper(InvalidTestRequestHandler(), "openapi.yml") + .handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk()) + } + .isInstanceOf(OpenApiValidator.ApiInteractionInvalid::class.java) + .hasMessageContaining("Response status 404 not defined for path") + } + + @Test + fun `should fail on request validation error`() { + thenThrownBy { + ValidatingRequestRouterWrapper(InvalidTestRequestHandler(), "openapi.yml") + .handleRequest(GET("/path-not-documented").withAcceptHeader("application/json"), mockk()) + } + .isInstanceOf(OpenApiValidator.ApiInteractionInvalid::class.java) + .hasMessageContaining("No API path found that matches request") + } + + @Test + fun `should skip validation`() { + val response = ValidatingRequestRouterWrapper(InvalidTestRequestHandler(), "openapi.yml") + .handleRequestSkippingRequestAndResponseValidation(GET("/path-not-documented").withAcceptHeader("application/json"), mockk()) + then(response.statusCode).isEqualTo(404) + } + + @Test + fun `should apply additional request validation`() { + thenThrownBy { ValidatingRequestRouterWrapper( + delegate = OpenApiValidatorTest.TestRequestHandler(), + specUrlOrPayload = "openapi.yml", + additionalRequestValidationFunctions = listOf({ _ -> throw RequestValidationFailedException() })) + .handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk()) + } + .isInstanceOf(RequestValidationFailedException::class.java) + } + + @Test + fun `should apply additional response validation`() { + thenThrownBy { ValidatingRequestRouterWrapper( + delegate = OpenApiValidatorTest.TestRequestHandler(), + specUrlOrPayload = "openapi.yml", + additionalResponseValidationFunctions = listOf({ _, _ -> throw ResponseValidationFailedException() })) + .handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk()) + } + .isInstanceOf(ResponseValidationFailedException::class.java) + } + + private class RequestValidationFailedException : RuntimeException("request validation failed") + private class ResponseValidationFailedException : RuntimeException("request validation failed") + + private class TestRequestHandler : RequestHandler() { + override val router = router { + GET("/tests") { _: Request -> + ResponseEntity.ok("""{"name": "some"}""") + } + } + } + + private class InvalidTestRequestHandler : RequestHandler() { + override val router = router { + GET("/tests") { _: Request -> + ResponseEntity.notFound(Unit) + } + } + } +} \ No newline at end of file diff --git a/router/src/main/kotlin/io/moia/router/APIGatewayProxyEventExtensions.kt b/router/src/main/kotlin/io/moia/router/APIGatewayProxyEventExtensions.kt index 5adb7394..5ef16a56 100644 --- a/router/src/main/kotlin/io/moia/router/APIGatewayProxyEventExtensions.kt +++ b/router/src/main/kotlin/io/moia/router/APIGatewayProxyEventExtensions.kt @@ -60,6 +60,12 @@ fun APIGatewayProxyRequestEvent.withHeader(name: String, value: String) = fun APIGatewayProxyRequestEvent.withHeader(header: Header) = this.withHeader(header.name, header.value) +fun APIGatewayProxyRequestEvent.withAcceptHeader(accept: String) = + this.withHeader("accept", accept) + +fun APIGatewayProxyRequestEvent.withContentTypeHeader(contentType: String) = + this.withHeader("content-type", contentType) + fun APIGatewayProxyResponseEvent.withHeader(name: String, value: String) = this.also { if (headers == null) headers = mutableMapOf() }.also { headers[name] = value } diff --git a/router/src/main/kotlin/io/moia/router/ResponseEntity.kt b/router/src/main/kotlin/io/moia/router/ResponseEntity.kt index 1bc5a3a8..3842991a 100644 --- a/router/src/main/kotlin/io/moia/router/ResponseEntity.kt +++ b/router/src/main/kotlin/io/moia/router/ResponseEntity.kt @@ -14,7 +14,19 @@ data class ResponseEntity( fun created(body: T? = null, location: URI? = null, headers: Map = emptyMap()) = ResponseEntity(201, body, if (location == null) headers else headers + ("location" to location.toString())) + fun accepted(body: T? = null, headers: Map = emptyMap()) = + ResponseEntity(202, body, headers) + fun noContent(headers: Map = emptyMap()) = ResponseEntity(204, null, headers) + + fun badRequest(body: T? = null, headers: Map = emptyMap()) = + ResponseEntity(400, body, headers) + + fun notFound(body: T? = null, headers: Map = emptyMap()) = + ResponseEntity(404, body, headers) + + fun unprocessableEntity(body: T? = null, headers: Map = emptyMap()) = + ResponseEntity(422, body, headers) } } \ No newline at end of file diff --git a/router/src/test/kotlin/io/moia/router/ResponseEntityTest.kt b/router/src/test/kotlin/io/moia/router/ResponseEntityTest.kt new file mode 100644 index 00000000..972589d2 --- /dev/null +++ b/router/src/test/kotlin/io/moia/router/ResponseEntityTest.kt @@ -0,0 +1,76 @@ +package io.moia.router + +import assertk.assert +import assertk.assertions.isEqualTo +import assertk.assertions.isNotEmpty +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import org.junit.jupiter.api.Test + +class ResponseEntityTest { + + private val body = "body" + private val headers = mapOf( + "content-type" to "text/plain" + ) + + @Test + fun `should process ok response`() { + + val response = ResponseEntity.ok(body, headers) + + assert(response.statusCode).isEqualTo(200) + assert(response.headers).isNotEmpty() + assert(response.body).isNotNull() + } + + @Test + fun `should process accepted response`() { + + val response = ResponseEntity.accepted(body, headers) + + assert(response.statusCode).isEqualTo(202) + assert(response.headers).isNotEmpty() + assert(response.body).isNotNull() + } + + @Test + fun `should process no content response`() { + + val response = ResponseEntity.noContent(headers) + + assert(response.statusCode).isEqualTo(204) + assert(response.headers).isNotEmpty() + assert(response.body).isNull() + } + + @Test + fun `should process bad request response`() { + + val response = ResponseEntity.badRequest(body, headers) + + assert(response.statusCode).isEqualTo(400) + assert(response.headers).isNotEmpty() + assert(response.body).isNotNull() + } + + @Test + fun `should process not found response`() { + + val response = ResponseEntity.notFound(body, headers) + + assert(response.statusCode).isEqualTo(404) + assert(response.headers).isNotEmpty() + assert(response.body).isNotNull() + } + + @Test + fun `should process unprocessable entity response`() { + + val response = ResponseEntity.unprocessableEntity(body, headers) + + assert(response.statusCode).isEqualTo(422) + assert(response.headers).isNotEmpty() + assert(response.body).isNotNull() + } +} \ No newline at end of file