Skip to content

Commit 7c5a5cc

Browse files
Add tooling for validating against API spec in tests (#32)
* Add validating request router wrapper. * Add convenience methods * Fix lint and warnings
1 parent 1b14084 commit 7c5a5cc

File tree

5 files changed

+254
-0
lines changed

5 files changed

+254
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package io.moia.router.openapi
2+
3+
import com.amazonaws.services.lambda.runtime.Context
4+
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent
5+
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent
6+
import io.moia.router.RequestHandler
7+
import org.slf4j.LoggerFactory
8+
9+
/**
10+
* A wrapper around a [io.moia.router.RequestHandler] that transparently validates every request/response against the OpenAPI spec.
11+
*
12+
* This can be used in tests to make sure the actual requests and responses match the API specification.
13+
*
14+
* It uses [OpenApiValidator] to do the validation.
15+
*
16+
* @property delegate the actual [io.moia.router.RequestHandler] to forward requests to.
17+
* @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]]
18+
*/
19+
class ValidatingRequestRouterWrapper(
20+
val delegate: RequestHandler,
21+
specUrlOrPayload: String,
22+
private val additionalRequestValidationFunctions: List<(APIGatewayProxyRequestEvent) -> Unit> = emptyList(),
23+
private val additionalResponseValidationFunctions: List<(APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent) -> Unit> = emptyList()
24+
) {
25+
private val openApiValidator = OpenApiValidator(specUrlOrPayload)
26+
27+
fun handleRequest(input: APIGatewayProxyRequestEvent, context: Context): APIGatewayProxyResponseEvent =
28+
handleRequest(input = input, context = context, skipRequestValidation = false, skipResponseValidation = false)
29+
30+
fun handleRequestSkippingRequestAndResponseValidation(input: APIGatewayProxyRequestEvent, context: Context): APIGatewayProxyResponseEvent =
31+
handleRequest(input = input, context = context, skipRequestValidation = true, skipResponseValidation = true)
32+
33+
private fun handleRequest(input: APIGatewayProxyRequestEvent, context: Context, skipRequestValidation: Boolean, skipResponseValidation: Boolean): APIGatewayProxyResponseEvent {
34+
35+
if (!skipRequestValidation) {
36+
try {
37+
openApiValidator.assertValidRequest(input)
38+
runAdditionalRequestValidations(input)
39+
} catch (e: Exception) {
40+
log.error("Validation failed for request $input", e)
41+
throw e
42+
}
43+
}
44+
val response = delegate.handleRequest(input, context)
45+
if (!skipResponseValidation) {
46+
try {
47+
runAdditionalResponseValidations(input, response)
48+
openApiValidator.assertValidResponse(input, response)
49+
} catch (e: Exception) {
50+
log.error("Validation failed for response $response", e)
51+
throw e
52+
}
53+
}
54+
55+
return response
56+
}
57+
58+
private fun runAdditionalRequestValidations(requestEvent: APIGatewayProxyRequestEvent) {
59+
additionalRequestValidationFunctions.forEach { it(requestEvent) }
60+
}
61+
62+
private fun runAdditionalResponseValidations(requestEvent: APIGatewayProxyRequestEvent, responseEvent: APIGatewayProxyResponseEvent) {
63+
additionalResponseValidationFunctions.forEach { it(requestEvent, responseEvent) }
64+
}
65+
66+
companion object {
67+
private val log = LoggerFactory.getLogger(ValidatingRequestRouterWrapper::class.java)
68+
}
69+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package io.moia.router.openapi
2+
3+
import io.mockk.mockk
4+
import io.moia.router.GET
5+
import io.moia.router.Request
6+
import io.moia.router.RequestHandler
7+
import io.moia.router.ResponseEntity
8+
import io.moia.router.Router.Companion.router
9+
import io.moia.router.withAcceptHeader
10+
import org.assertj.core.api.BDDAssertions.then
11+
import org.assertj.core.api.BDDAssertions.thenThrownBy
12+
import org.junit.jupiter.api.Test
13+
14+
class ValidatingRequestRouterWrapperTest {
15+
16+
@Test
17+
fun `should return response on successful validation`() {
18+
val response = ValidatingRequestRouterWrapper(TestRequestHandler(), "openapi.yml")
19+
.handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk())
20+
21+
then(response.statusCode).isEqualTo(200)
22+
}
23+
24+
@Test
25+
fun `should fail on response validation error`() {
26+
thenThrownBy {
27+
ValidatingRequestRouterWrapper(InvalidTestRequestHandler(), "openapi.yml")
28+
.handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk())
29+
}
30+
.isInstanceOf(OpenApiValidator.ApiInteractionInvalid::class.java)
31+
.hasMessageContaining("Response status 404 not defined for path")
32+
}
33+
34+
@Test
35+
fun `should fail on request validation error`() {
36+
thenThrownBy {
37+
ValidatingRequestRouterWrapper(InvalidTestRequestHandler(), "openapi.yml")
38+
.handleRequest(GET("/path-not-documented").withAcceptHeader("application/json"), mockk())
39+
}
40+
.isInstanceOf(OpenApiValidator.ApiInteractionInvalid::class.java)
41+
.hasMessageContaining("No API path found that matches request")
42+
}
43+
44+
@Test
45+
fun `should skip validation`() {
46+
val response = ValidatingRequestRouterWrapper(InvalidTestRequestHandler(), "openapi.yml")
47+
.handleRequestSkippingRequestAndResponseValidation(GET("/path-not-documented").withAcceptHeader("application/json"), mockk())
48+
then(response.statusCode).isEqualTo(404)
49+
}
50+
51+
@Test
52+
fun `should apply additional request validation`() {
53+
thenThrownBy { ValidatingRequestRouterWrapper(
54+
delegate = OpenApiValidatorTest.TestRequestHandler(),
55+
specUrlOrPayload = "openapi.yml",
56+
additionalRequestValidationFunctions = listOf({ _ -> throw RequestValidationFailedException() }))
57+
.handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk())
58+
}
59+
.isInstanceOf(RequestValidationFailedException::class.java)
60+
}
61+
62+
@Test
63+
fun `should apply additional response validation`() {
64+
thenThrownBy { ValidatingRequestRouterWrapper(
65+
delegate = OpenApiValidatorTest.TestRequestHandler(),
66+
specUrlOrPayload = "openapi.yml",
67+
additionalResponseValidationFunctions = listOf({ _, _ -> throw ResponseValidationFailedException() }))
68+
.handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk())
69+
}
70+
.isInstanceOf(ResponseValidationFailedException::class.java)
71+
}
72+
73+
private class RequestValidationFailedException : RuntimeException("request validation failed")
74+
private class ResponseValidationFailedException : RuntimeException("request validation failed")
75+
76+
private class TestRequestHandler : RequestHandler() {
77+
override val router = router {
78+
GET("/tests") { _: Request<Unit> ->
79+
ResponseEntity.ok("""{"name": "some"}""")
80+
}
81+
}
82+
}
83+
84+
private class InvalidTestRequestHandler : RequestHandler() {
85+
override val router = router {
86+
GET("/tests") { _: Request<Unit> ->
87+
ResponseEntity.notFound(Unit)
88+
}
89+
}
90+
}
91+
}

router/src/main/kotlin/io/moia/router/APIGatewayProxyEventExtensions.kt

+6
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ fun APIGatewayProxyRequestEvent.withHeader(name: String, value: String) =
6060
fun APIGatewayProxyRequestEvent.withHeader(header: Header) =
6161
this.withHeader(header.name, header.value)
6262

63+
fun APIGatewayProxyRequestEvent.withAcceptHeader(accept: String) =
64+
this.withHeader("accept", accept)
65+
66+
fun APIGatewayProxyRequestEvent.withContentTypeHeader(contentType: String) =
67+
this.withHeader("content-type", contentType)
68+
6369
fun APIGatewayProxyResponseEvent.withHeader(name: String, value: String) =
6470
this.also { if (headers == null) headers = mutableMapOf() }.also { headers[name] = value }
6571

router/src/main/kotlin/io/moia/router/ResponseEntity.kt

+12
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,19 @@ data class ResponseEntity<T>(
1414
fun <T> created(body: T? = null, location: URI? = null, headers: Map<String, String> = emptyMap()) =
1515
ResponseEntity<T>(201, body, if (location == null) headers else headers + ("location" to location.toString()))
1616

17+
fun <T> accepted(body: T? = null, headers: Map<String, String> = emptyMap()) =
18+
ResponseEntity<T>(202, body, headers)
19+
1720
fun noContent(headers: Map<String, String> = emptyMap()) =
1821
ResponseEntity<Unit>(204, null, headers)
22+
23+
fun <T> badRequest(body: T? = null, headers: Map<String, String> = emptyMap()) =
24+
ResponseEntity<T>(400, body, headers)
25+
26+
fun <T> notFound(body: T? = null, headers: Map<String, String> = emptyMap()) =
27+
ResponseEntity<T>(404, body, headers)
28+
29+
fun <T> unprocessableEntity(body: T? = null, headers: Map<String, String> = emptyMap()) =
30+
ResponseEntity<T>(422, body, headers)
1931
}
2032
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package io.moia.router
2+
3+
import assertk.assert
4+
import assertk.assertions.isEqualTo
5+
import assertk.assertions.isNotEmpty
6+
import assertk.assertions.isNotNull
7+
import assertk.assertions.isNull
8+
import org.junit.jupiter.api.Test
9+
10+
class ResponseEntityTest {
11+
12+
private val body = "body"
13+
private val headers = mapOf(
14+
"content-type" to "text/plain"
15+
)
16+
17+
@Test
18+
fun `should process ok response`() {
19+
20+
val response = ResponseEntity.ok(body, headers)
21+
22+
assert(response.statusCode).isEqualTo(200)
23+
assert(response.headers).isNotEmpty()
24+
assert(response.body).isNotNull()
25+
}
26+
27+
@Test
28+
fun `should process accepted response`() {
29+
30+
val response = ResponseEntity.accepted(body, headers)
31+
32+
assert(response.statusCode).isEqualTo(202)
33+
assert(response.headers).isNotEmpty()
34+
assert(response.body).isNotNull()
35+
}
36+
37+
@Test
38+
fun `should process no content response`() {
39+
40+
val response = ResponseEntity.noContent(headers)
41+
42+
assert(response.statusCode).isEqualTo(204)
43+
assert(response.headers).isNotEmpty()
44+
assert(response.body).isNull()
45+
}
46+
47+
@Test
48+
fun `should process bad request response`() {
49+
50+
val response = ResponseEntity.badRequest(body, headers)
51+
52+
assert(response.statusCode).isEqualTo(400)
53+
assert(response.headers).isNotEmpty()
54+
assert(response.body).isNotNull()
55+
}
56+
57+
@Test
58+
fun `should process not found response`() {
59+
60+
val response = ResponseEntity.notFound(body, headers)
61+
62+
assert(response.statusCode).isEqualTo(404)
63+
assert(response.headers).isNotEmpty()
64+
assert(response.body).isNotNull()
65+
}
66+
67+
@Test
68+
fun `should process unprocessable entity response`() {
69+
70+
val response = ResponseEntity.unprocessableEntity(body, headers)
71+
72+
assert(response.statusCode).isEqualTo(422)
73+
assert(response.headers).isNotEmpty()
74+
assert(response.body).isNotNull()
75+
}
76+
}

0 commit comments

Comments
 (0)