From 71cd404e032254d57099efc8d3c98b0a98a314e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20D=C3=BCsterh=C3=B6ft?= Date: Tue, 25 Jun 2019 10:18:05 +0200 Subject: [PATCH 1/2] Add handling of accept headers like application/hal+json Let request predicates produce sth like application/*+json --- .../proto/ProtoDeserializationHandler.kt | 12 +++-- .../router/proto/ProtoSerializationHandler.kt | 4 +- .../moia/router/proto/RequestHandlerTest.kt | 17 +++++++ .../io/moia/router/DeserializationHandler.kt | 8 +++- .../io/moia/router/MediaTypeExtensions.kt | 11 +++++ .../kotlin/io/moia/router/RequestPredicate.kt | 5 +- .../io/moia/router/SerializationHandler.kt | 5 +- .../kotlin/io/moia/router/MediaTypeTest.kt | 45 ++++++++++++++++++ .../io/moia/router/RequestHandlerTest.kt | 47 ++++++++++++++++++- 9 files changed, 144 insertions(+), 10 deletions(-) create mode 100644 router/src/main/kotlin/io/moia/router/MediaTypeExtensions.kt create mode 100644 router/src/test/kotlin/io/moia/router/MediaTypeTest.kt diff --git a/router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoDeserializationHandler.kt b/router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoDeserializationHandler.kt index 83f05eb0..68dd76d7 100644 --- a/router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoDeserializationHandler.kt +++ b/router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoDeserializationHandler.kt @@ -1,10 +1,11 @@ package io.moia.router.proto import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent -import io.moia.router.DeserializationHandler -import io.moia.router.contentType import com.google.common.net.MediaType import com.google.protobuf.Parser +import io.moia.router.DeserializationHandler +import io.moia.router.contentType +import isCompatibleWith import java.util.Base64 import kotlin.reflect.KClass import kotlin.reflect.KType @@ -12,9 +13,14 @@ import kotlin.reflect.full.staticFunctions class ProtoDeserializationHandler : DeserializationHandler { private val proto = MediaType.parse("application/x-protobuf") + private val protoStructuredSuffixWildcard = MediaType.parse("application/*+x-protobuf") override fun supports(input: APIGatewayProxyRequestEvent): Boolean = - input.contentType() != null && MediaType.parse(input.contentType()).`is`(proto) + if (input.contentType() == null) + false + else { + MediaType.parse(input.contentType()).let { it.isCompatibleWith(proto) || it.isCompatibleWith(protoStructuredSuffixWildcard) } + } override fun deserialize(input: APIGatewayProxyRequestEvent, target: KType?): Any { val bytes = Base64.getDecoder().decode(input.body) diff --git a/router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoSerializationHandler.kt b/router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoSerializationHandler.kt index f0424536..bfd9c366 100644 --- a/router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoSerializationHandler.kt +++ b/router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoSerializationHandler.kt @@ -3,18 +3,20 @@ package io.moia.router.proto import com.google.common.net.MediaType import com.google.protobuf.GeneratedMessageV3 import io.moia.router.SerializationHandler +import isCompatibleWith import java.util.Base64 class ProtoSerializationHandler : SerializationHandler { private val json = MediaType.parse("application/json") + private val jsonStructuredSuffixWildcard = MediaType.parse("application/*+json") override fun supports(acceptHeader: MediaType, body: Any): Boolean = body is GeneratedMessageV3 override fun serialize(acceptHeader: MediaType, body: Any): String { val message = body as GeneratedMessageV3 - return if (acceptHeader.`is`(json)) { + return if (json.isCompatibleWith(acceptHeader) || jsonStructuredSuffixWildcard.isCompatibleWith(acceptHeader)) { ProtoBufUtils.toJsonWithoutWrappers(message) } else { Base64.getEncoder().encodeToString(message.toByteArray()) diff --git a/router-protobuf/src/test/kotlin/io/moia/router/proto/RequestHandlerTest.kt b/router-protobuf/src/test/kotlin/io/moia/router/proto/RequestHandlerTest.kt index 20bcf3f7..f5802890 100644 --- a/router-protobuf/src/test/kotlin/io/moia/router/proto/RequestHandlerTest.kt +++ b/router-protobuf/src/test/kotlin/io/moia/router/proto/RequestHandlerTest.kt @@ -35,6 +35,20 @@ class RequestHandlerTest { assert(response.body).isEqualTo("""{"hello":"Hello","request":""}""") } + @Test + fun `should match request to proto handler with version accept header and return json`() { + + val response = testRequestHandler.handleRequest( + APIGatewayProxyRequestEvent() + .withPath("/some-proto") + .withHttpMethod("GET") + .withHeaders(mapOf("Accept" to "application/vnd.moia.v1+json")), mockk() + ) + + assert(response.statusCode).isEqualTo(200) + assert(response.body).isEqualTo("""{"hello":"v1","request":""}""") + } + @Test fun `should match request to proto handler and return proto`() { @@ -109,6 +123,9 @@ class RequestHandlerTest { defaultContentType = "application/x-protobuf" + GET("/some-proto") { _: Request -> ResponseEntity.ok(Sample.newBuilder().setHello("v1").build()) } + .producing("application/vnd.moia.v1+x-protobuf", "application/vnd.moia.v1+json") + GET("/some-proto") { _: Request -> ResponseEntity.ok(Sample.newBuilder().setHello("Hello").build()) } .producing("application/x-protobuf", "application/json") POST("/some-proto") { r: Request -> ResponseEntity.ok(r.body) } diff --git a/router/src/main/kotlin/io/moia/router/DeserializationHandler.kt b/router/src/main/kotlin/io/moia/router/DeserializationHandler.kt index 276312de..5be54973 100644 --- a/router/src/main/kotlin/io/moia/router/DeserializationHandler.kt +++ b/router/src/main/kotlin/io/moia/router/DeserializationHandler.kt @@ -4,6 +4,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.type.TypeFactory import com.google.common.net.MediaType +import isCompatibleWith import kotlin.reflect.KClass import kotlin.reflect.KType import kotlin.reflect.full.isSubclassOf @@ -28,9 +29,14 @@ class DeserializationHandlerChain(private val handlers: List diff --git a/router/src/main/kotlin/io/moia/router/MediaTypeExtensions.kt b/router/src/main/kotlin/io/moia/router/MediaTypeExtensions.kt new file mode 100644 index 00000000..f2950cc7 --- /dev/null +++ b/router/src/main/kotlin/io/moia/router/MediaTypeExtensions.kt @@ -0,0 +1,11 @@ +import com.google.common.net.MediaType + +fun MediaType.isCompatibleWith(other: MediaType): Boolean = + if (this.`is`(other)) + true + else { + type() == other.type() && + (subtype().contains("+") && other.subtype().contains("+")) && + this.subtype().substringBeforeLast("+") == "*" && + this.subtype().substringAfterLast("+") == other.subtype().substringAfterLast("+") + } \ No newline at end of file diff --git a/router/src/main/kotlin/io/moia/router/RequestPredicate.kt b/router/src/main/kotlin/io/moia/router/RequestPredicate.kt index f1978c03..849abd8b 100644 --- a/router/src/main/kotlin/io/moia/router/RequestPredicate.kt +++ b/router/src/main/kotlin/io/moia/router/RequestPredicate.kt @@ -2,6 +2,7 @@ package io.moia.router import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent import com.google.common.net.MediaType +import isCompatibleWith data class RequestPredicate( val method: String, @@ -49,7 +50,7 @@ data class RequestPredicate( fun matchedAcceptType(acceptedMediaTypes: List) = produces .map { MediaType.parse(it) } - .firstOrNull { acceptedMediaTypes.any { acceptedType -> it.`is`(acceptedType) } } + .firstOrNull { acceptedMediaTypes.any { acceptedType -> it.isCompatibleWith(acceptedType) } } private fun acceptMatches(acceptedMediaTypes: List) = matchedAcceptType(acceptedMediaTypes) != null @@ -58,7 +59,7 @@ data class RequestPredicate( when { consumes.isEmpty() -> true contentType == null -> false - else -> consumes.any { MediaType.parse(contentType).`is`(MediaType.parse(it)) } + else -> consumes.any { MediaType.parse(contentType).isCompatibleWith(MediaType.parse(it)) } } } diff --git a/router/src/main/kotlin/io/moia/router/SerializationHandler.kt b/router/src/main/kotlin/io/moia/router/SerializationHandler.kt index d2ea1ff2..3d110270 100644 --- a/router/src/main/kotlin/io/moia/router/SerializationHandler.kt +++ b/router/src/main/kotlin/io/moia/router/SerializationHandler.kt @@ -2,6 +2,7 @@ package io.moia.router import com.fasterxml.jackson.databind.ObjectMapper import com.google.common.net.MediaType +import isCompatibleWith interface SerializationHandler { @@ -23,8 +24,10 @@ class SerializationHandlerChain(private val handlers: List class JsonSerializationHandler(private val objectMapper: ObjectMapper) : SerializationHandler { private val json = MediaType.parse("application/json") + private val jsonStructuredSuffixWildcard = MediaType.parse("application/*+json") - override fun supports(acceptHeader: MediaType, body: Any): Boolean = acceptHeader.`is`(json) + override fun supports(acceptHeader: MediaType, body: Any): Boolean = + json.isCompatibleWith(acceptHeader) || jsonStructuredSuffixWildcard.isCompatibleWith(acceptHeader) override fun serialize(acceptHeader: MediaType, body: Any): String = objectMapper.writeValueAsString(body) diff --git a/router/src/test/kotlin/io/moia/router/MediaTypeTest.kt b/router/src/test/kotlin/io/moia/router/MediaTypeTest.kt new file mode 100644 index 00000000..87adbd5b --- /dev/null +++ b/router/src/test/kotlin/io/moia/router/MediaTypeTest.kt @@ -0,0 +1,45 @@ +package io.moia.router + +import com.google.common.net.MediaType +import isCompatibleWith +import org.assertj.core.api.BDDAssertions.then +import org.junit.jupiter.api.Test + +class MediaTypeTest { + + @Test + fun `should match`() { + then(MediaType.parse("application/json").isCompatibleWith(MediaType.parse("application/json"))).isTrue() + } + + @Test + fun `should match subtype wildcard`() { + then(MediaType.parse("application/json").isCompatibleWith(MediaType.parse("application/*"))).isTrue() + } + + @Test + fun `should not match subtype wildcard in different tpye`() { + then(MediaType.parse("application/json").isCompatibleWith(MediaType.parse("image/*"))).isFalse() + } + + @Test + fun `should match wildcard`() { + then(MediaType.parse("application/json").isCompatibleWith(MediaType.parse("*/*"))).isTrue() + } + + @Test + fun `should match wildcard structured syntax suffix`() { + then(MediaType.parse("application/*+json").isCompatibleWith(MediaType.parse("application/vnd.moia+json"))).isTrue() + then(MediaType.parse("application/*+json").isCompatibleWith(MediaType.parse("application/vnd.moia.v1+json"))).isTrue() + } + + @Test + fun `should not match wildcard structured syntax suffix on non suffix type`() { + then(MediaType.parse("application/*+json").isCompatibleWith(MediaType.parse("application/json"))).isFalse() + } + + @Test + fun `should not match wildcard structured syntax suffix on differnt suffix`() { + then(MediaType.parse("application/*+json").isCompatibleWith(MediaType.parse("application/*+x-protobuf"))).isFalse() + } +} \ No newline at end of file diff --git a/router/src/test/kotlin/io/moia/router/RequestHandlerTest.kt b/router/src/test/kotlin/io/moia/router/RequestHandlerTest.kt index 694100e3..ed2ae52b 100644 --- a/router/src/test/kotlin/io/moia/router/RequestHandlerTest.kt +++ b/router/src/test/kotlin/io/moia/router/RequestHandlerTest.kt @@ -331,11 +331,44 @@ class RequestHandlerTest { ) assert(response.statusCode).isEqualTo(200) - assert(response.getHeaderCaseInsensitive("content-type")).isEqualTo("application/json") + assert(response.getHeaderCaseInsensitive("content-type")).isEqualTo("application/vnd.moia.v2+json") + + assert(response.body).isEqualTo("""{"greeting":"v2"}""") + } + + @Test + fun `should handle subtype structured suffix wildcard`() { + + val response = testRequestHandler.handleRequest( + POST("/some") + .withHeaders(mapOf( + "Accept" to "application/vnd.moia.v1+json", + "Content-Type" to "application/json" + )) + .withBody("""{ "greeting": "some" }"""), mockk() + ) + assert(response.statusCode).isEqualTo(200) assert(response.body).isEqualTo("""{"greeting":"some"}""") } + @Test + fun `should match version`() { + + val response = testRequestHandler.handleRequest( + POST("/some") + .withHeaders(mapOf( + "Accept" to "application/vnd.moia.v2+json", + "Content-Type" to "application/json" + )) + .withBody("""{ "greeting": "v2" }"""), mockk() + ) + + assert(response.statusCode).isEqualTo(200) + assert(response.body).isEqualTo("""{"greeting":"v2"}""") + assert(response.getHeaderCaseInsensitive("content-type")).isEqualTo("application/vnd.moia.v2+json") + } + @Test fun `should fail with 406 Not Acceptable on an unparsable media type`() { @@ -614,13 +647,23 @@ class RequestHandlerTest { ) ) } + + POST("/some") { r: Request -> + ResponseEntity.ok( + TestResponse( + "v2" + ) + ) + }.producing("application/vnd.moia.v2+json") + POST("/some") { r: Request -> ResponseEntity.ok( TestResponse( r.body.greeting ) ) - } + }.producing("application/json", "application/*+json") + POST("/somes") { r: Request> -> ResponseEntity.ok(r.body.map { TestResponse( From 9b8fb3de2004d2dd5191726300c7c5b3d4356b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20D=C3=BCsterh=C3=B6ft?= Date: Tue, 25 Jun 2019 11:09:03 +0200 Subject: [PATCH 2/2] Add tests for deserialization handler --- .../proto/ProtoDeserializationHandler.kt | 2 +- .../proto/ProtoDeserializationHandlerTest.kt | 1 + .../io/moia/router/DeserializationHandler.kt | 2 +- .../router/JsonDeserializationHandlerTest.kt | 28 +++++++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 router/src/test/kotlin/io/moia/router/JsonDeserializationHandlerTest.kt diff --git a/router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoDeserializationHandler.kt b/router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoDeserializationHandler.kt index 68dd76d7..5150662f 100644 --- a/router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoDeserializationHandler.kt +++ b/router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoDeserializationHandler.kt @@ -19,7 +19,7 @@ class ProtoDeserializationHandler : DeserializationHandler { if (input.contentType() == null) false else { - MediaType.parse(input.contentType()).let { it.isCompatibleWith(proto) || it.isCompatibleWith(protoStructuredSuffixWildcard) } + MediaType.parse(input.contentType()).let { proto.isCompatibleWith(it) || protoStructuredSuffixWildcard.isCompatibleWith(it) } } override fun deserialize(input: APIGatewayProxyRequestEvent, target: KType?): Any { diff --git a/router-protobuf/src/test/kotlin/io/moia/router/proto/ProtoDeserializationHandlerTest.kt b/router-protobuf/src/test/kotlin/io/moia/router/proto/ProtoDeserializationHandlerTest.kt index d90bc7fa..af32e68b 100644 --- a/router-protobuf/src/test/kotlin/io/moia/router/proto/ProtoDeserializationHandlerTest.kt +++ b/router-protobuf/src/test/kotlin/io/moia/router/proto/ProtoDeserializationHandlerTest.kt @@ -21,5 +21,6 @@ internal class ProtoDeserializationHandlerTest { @Test fun `Deserializer should support if the content type of the input is protobuf`() { assertTrue(ProtoDeserializationHandler().supports(APIGatewayProxyRequestEvent().withHeader("content-type", "application/x-protobuf"))) + assertTrue(ProtoDeserializationHandler().supports(APIGatewayProxyRequestEvent().withHeader("content-type", "application/vnd.moia.v1+x-protobuf"))) } } \ No newline at end of file diff --git a/router/src/main/kotlin/io/moia/router/DeserializationHandler.kt b/router/src/main/kotlin/io/moia/router/DeserializationHandler.kt index 5be54973..9f950f79 100644 --- a/router/src/main/kotlin/io/moia/router/DeserializationHandler.kt +++ b/router/src/main/kotlin/io/moia/router/DeserializationHandler.kt @@ -35,7 +35,7 @@ class JsonDeserializationHandler(private val objectMapper: ObjectMapper) : Deser if (input.contentType() == null) false else { - MediaType.parse(input.contentType()).let { it.isCompatibleWith(json) || it.isCompatibleWith(jsonStructuredSuffixWildcard) } + MediaType.parse(input.contentType()).let { json.isCompatibleWith(it) || jsonStructuredSuffixWildcard.isCompatibleWith(it) } } override fun deserialize(input: APIGatewayProxyRequestEvent, target: KType?): Any? { diff --git a/router/src/test/kotlin/io/moia/router/JsonDeserializationHandlerTest.kt b/router/src/test/kotlin/io/moia/router/JsonDeserializationHandlerTest.kt new file mode 100644 index 00000000..65fe2aa9 --- /dev/null +++ b/router/src/test/kotlin/io/moia/router/JsonDeserializationHandlerTest.kt @@ -0,0 +1,28 @@ +package io.moia.router + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class JsonDeserializationHandlerTest { + + val deserializationHandler = JsonDeserializationHandler(jacksonObjectMapper()) + + @Test + fun `should support json`() { + assertTrue(deserializationHandler.supports(APIGatewayProxyRequestEvent() + .withHeader("content-type", "application/json"))) + assertTrue(deserializationHandler.supports(APIGatewayProxyRequestEvent() + .withHeader("content-type", "application/vnd.moia.v1+json"))) + } + + @Test + fun `should not support anything else than json`() { + assertFalse(deserializationHandler.supports(APIGatewayProxyRequestEvent() + .withHeader("content-type", "image/png"))) + assertFalse(deserializationHandler.supports(APIGatewayProxyRequestEvent() + .withHeader("content-type", "text/plain"))) + } +} \ No newline at end of file