Skip to content

Commit

Permalink
Add tooling for validating against API spec in tests (#32)
Browse files Browse the repository at this point in the history
* Add validating request router wrapper.

* Add convenience methods

* Fix lint and warnings
  • Loading branch information
mduesterhoeft authored Oct 23, 2019
1 parent 1b14084 commit 7c5a5cc
Show file tree
Hide file tree
Showing 5 changed files with 254 additions and 0 deletions.
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)
}
}
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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
12 changes: 12 additions & 0 deletions router/src/main/kotlin/io/moia/router/ResponseEntity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,19 @@ data class ResponseEntity<T>(
fun <T> created(body: T? = null, location: URI? = null, headers: Map<String, String> = emptyMap()) =
ResponseEntity<T>(201, body, if (location == null) headers else headers + ("location" to location.toString()))

fun <T> accepted(body: T? = null, headers: Map<String, String> = emptyMap()) =
ResponseEntity<T>(202, body, headers)

fun noContent(headers: Map<String, String> = emptyMap()) =
ResponseEntity<Unit>(204, null, headers)

fun <T> badRequest(body: T? = null, headers: Map<String, String> = emptyMap()) =
ResponseEntity<T>(400, body, headers)

fun <T> notFound(body: T? = null, headers: Map<String, String> = emptyMap()) =
ResponseEntity<T>(404, body, headers)

fun <T> unprocessableEntity(body: T? = null, headers: Map<String, String> = emptyMap()) =
ResponseEntity<T>(422, body, headers)
}
}
76 changes: 76 additions & 0 deletions router/src/test/kotlin/io/moia/router/ResponseEntityTest.kt
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()
}
}

0 comments on commit 7c5a5cc

Please sign in to comment.