-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add tooling for validating against API spec in tests (#32)
* Add validating request router wrapper. * Add convenience methods * Fix lint and warnings
- Loading branch information
1 parent
1b14084
commit 7c5a5cc
Showing
5 changed files
with
254 additions
and
0 deletions.
There are no files selected for viewing
69 changes: 69 additions & 0 deletions
69
...equest-validator/src/main/kotlin/io/moia/router/openapi/ValidatingRequestRouterWrapper.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} |
91 changes: 91 additions & 0 deletions
91
...st-validator/src/test/kotlin/io/moia/router/openapi/ValidatingRequestRouterWrapperTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Unit> -> | ||
ResponseEntity.ok("""{"name": "some"}""") | ||
} | ||
} | ||
} | ||
|
||
private class InvalidTestRequestHandler : RequestHandler() { | ||
override val router = router { | ||
GET("/tests") { _: Request<Unit> -> | ||
ResponseEntity.notFound(Unit) | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
76 changes: 76 additions & 0 deletions
76
router/src/test/kotlin/io/moia/router/ResponseEntityTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() | ||
} | ||
} |