Skip to content

Commit 5bf2198

Browse files
mduesterhoeftruibritopt
authored andcommitted
Add handling of accept headers like application/hal+json (#26)
* Add handling of accept headers like application/hal+json Let request predicates produce sth like application/*+json * Add tests for deserialization handler
1 parent c4b6f71 commit 5bf2198

File tree

11 files changed

+173
-10
lines changed

11 files changed

+173
-10
lines changed

router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoDeserializationHandler.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
11
package io.moia.router.proto
22

33
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent
4-
import io.moia.router.DeserializationHandler
5-
import io.moia.router.contentType
64
import com.google.common.net.MediaType
75
import com.google.protobuf.Parser
6+
import io.moia.router.DeserializationHandler
7+
import io.moia.router.contentType
8+
import isCompatibleWith
89
import java.util.Base64
910
import kotlin.reflect.KClass
1011
import kotlin.reflect.KType
1112
import kotlin.reflect.full.staticFunctions
1213

1314
class ProtoDeserializationHandler : DeserializationHandler {
1415
private val proto = MediaType.parse("application/x-protobuf")
16+
private val protoStructuredSuffixWildcard = MediaType.parse("application/*+x-protobuf")
1517

1618
override fun supports(input: APIGatewayProxyRequestEvent): Boolean =
17-
input.contentType() != null && MediaType.parse(input.contentType()).`is`(proto)
19+
if (input.contentType() == null)
20+
false
21+
else {
22+
MediaType.parse(input.contentType()).let { proto.isCompatibleWith(it) || protoStructuredSuffixWildcard.isCompatibleWith(it) }
23+
}
1824

1925
override fun deserialize(input: APIGatewayProxyRequestEvent, target: KType?): Any {
2026
val bytes = Base64.getDecoder().decode(input.body)

router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoSerializationHandler.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,20 @@ package io.moia.router.proto
33
import com.google.common.net.MediaType
44
import com.google.protobuf.GeneratedMessageV3
55
import io.moia.router.SerializationHandler
6+
import isCompatibleWith
67
import java.util.Base64
78

89
class ProtoSerializationHandler : SerializationHandler {
910

1011
private val json = MediaType.parse("application/json")
12+
private val jsonStructuredSuffixWildcard = MediaType.parse("application/*+json")
1113

1214
override fun supports(acceptHeader: MediaType, body: Any): Boolean =
1315
body is GeneratedMessageV3
1416

1517
override fun serialize(acceptHeader: MediaType, body: Any): String {
1618
val message = body as GeneratedMessageV3
17-
return if (acceptHeader.`is`(json)) {
19+
return if (json.isCompatibleWith(acceptHeader) || jsonStructuredSuffixWildcard.isCompatibleWith(acceptHeader)) {
1820
ProtoBufUtils.toJsonWithoutWrappers(message)
1921
} else {
2022
Base64.getEncoder().encodeToString(message.toByteArray())

router-protobuf/src/test/kotlin/io/moia/router/proto/ProtoDeserializationHandlerTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@ internal class ProtoDeserializationHandlerTest {
2121
@Test
2222
fun `Deserializer should support if the content type of the input is protobuf`() {
2323
assertTrue(ProtoDeserializationHandler().supports(APIGatewayProxyRequestEvent().withHeader("content-type", "application/x-protobuf")))
24+
assertTrue(ProtoDeserializationHandler().supports(APIGatewayProxyRequestEvent().withHeader("content-type", "application/vnd.moia.v1+x-protobuf")))
2425
}
2526
}

router-protobuf/src/test/kotlin/io/moia/router/proto/RequestHandlerTest.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ class RequestHandlerTest {
3535
assert(response.body).isEqualTo("""{"hello":"Hello","request":""}""")
3636
}
3737

38+
@Test
39+
fun `should match request to proto handler with version accept header and return json`() {
40+
41+
val response = testRequestHandler.handleRequest(
42+
APIGatewayProxyRequestEvent()
43+
.withPath("/some-proto")
44+
.withHttpMethod("GET")
45+
.withHeaders(mapOf("Accept" to "application/vnd.moia.v1+json")), mockk()
46+
)
47+
48+
assert(response.statusCode).isEqualTo(200)
49+
assert(response.body).isEqualTo("""{"hello":"v1","request":""}""")
50+
}
51+
3852
@Test
3953
fun `should match request to proto handler and return proto`() {
4054

@@ -109,6 +123,9 @@ class RequestHandlerTest {
109123

110124
defaultContentType = "application/x-protobuf"
111125

126+
GET("/some-proto") { _: Request<Unit> -> ResponseEntity.ok(Sample.newBuilder().setHello("v1").build()) }
127+
.producing("application/vnd.moia.v1+x-protobuf", "application/vnd.moia.v1+json")
128+
112129
GET("/some-proto") { _: Request<Unit> -> ResponseEntity.ok(Sample.newBuilder().setHello("Hello").build()) }
113130
.producing("application/x-protobuf", "application/json")
114131
POST("/some-proto") { r: Request<Sample> -> ResponseEntity.ok(r.body) }

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent
44
import com.fasterxml.jackson.databind.ObjectMapper
55
import com.fasterxml.jackson.databind.type.TypeFactory
66
import com.google.common.net.MediaType
7+
import isCompatibleWith
78
import kotlin.reflect.KClass
89
import kotlin.reflect.KType
910
import kotlin.reflect.full.isSubclassOf
@@ -28,9 +29,14 @@ class DeserializationHandlerChain(private val handlers: List<DeserializationHand
2829
class JsonDeserializationHandler(private val objectMapper: ObjectMapper) : DeserializationHandler {
2930

3031
private val json = MediaType.parse("application/json")
32+
private val jsonStructuredSuffixWildcard = MediaType.parse("application/*+json")
3133

3234
override fun supports(input: APIGatewayProxyRequestEvent) =
33-
input.contentType() != null && MediaType.parse(input.contentType()!!).`is`(json)
35+
if (input.contentType() == null)
36+
false
37+
else {
38+
MediaType.parse(input.contentType()).let { json.isCompatibleWith(it) || jsonStructuredSuffixWildcard.isCompatibleWith(it) }
39+
}
3440

3541
override fun deserialize(input: APIGatewayProxyRequestEvent, target: KType?): Any? {
3642
val targetClass = target?.classifier as KClass<*>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import com.google.common.net.MediaType
2+
3+
fun MediaType.isCompatibleWith(other: MediaType): Boolean =
4+
if (this.`is`(other))
5+
true
6+
else {
7+
type() == other.type() &&
8+
(subtype().contains("+") && other.subtype().contains("+")) &&
9+
this.subtype().substringBeforeLast("+") == "*" &&
10+
this.subtype().substringAfterLast("+") == other.subtype().substringAfterLast("+")
11+
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.moia.router
22

33
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent
44
import com.google.common.net.MediaType
5+
import isCompatibleWith
56

67
data class RequestPredicate(
78
val method: String,
@@ -49,7 +50,7 @@ data class RequestPredicate(
4950
fun matchedAcceptType(acceptedMediaTypes: List<MediaType>) =
5051
produces
5152
.map { MediaType.parse(it) }
52-
.firstOrNull { acceptedMediaTypes.any { acceptedType -> it.`is`(acceptedType) } }
53+
.firstOrNull { acceptedMediaTypes.any { acceptedType -> it.isCompatibleWith(acceptedType) } }
5354

5455
private fun acceptMatches(acceptedMediaTypes: List<MediaType>) =
5556
matchedAcceptType(acceptedMediaTypes) != null
@@ -58,7 +59,7 @@ data class RequestPredicate(
5859
when {
5960
consumes.isEmpty() -> true
6061
contentType == null -> false
61-
else -> consumes.any { MediaType.parse(contentType).`is`(MediaType.parse(it)) }
62+
else -> consumes.any { MediaType.parse(contentType).isCompatibleWith(MediaType.parse(it)) }
6263
}
6364
}
6465

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.moia.router
22

33
import com.fasterxml.jackson.databind.ObjectMapper
44
import com.google.common.net.MediaType
5+
import isCompatibleWith
56

67
interface SerializationHandler {
78

@@ -23,8 +24,10 @@ class SerializationHandlerChain(private val handlers: List<SerializationHandler>
2324
class JsonSerializationHandler(private val objectMapper: ObjectMapper) : SerializationHandler {
2425

2526
private val json = MediaType.parse("application/json")
27+
private val jsonStructuredSuffixWildcard = MediaType.parse("application/*+json")
2628

27-
override fun supports(acceptHeader: MediaType, body: Any): Boolean = acceptHeader.`is`(json)
29+
override fun supports(acceptHeader: MediaType, body: Any): Boolean =
30+
json.isCompatibleWith(acceptHeader) || jsonStructuredSuffixWildcard.isCompatibleWith(acceptHeader)
2831

2932
override fun serialize(acceptHeader: MediaType, body: Any): String =
3033
objectMapper.writeValueAsString(body)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package io.moia.router
2+
3+
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent
4+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
5+
import org.junit.jupiter.api.Assertions.assertFalse
6+
import org.junit.jupiter.api.Assertions.assertTrue
7+
import org.junit.jupiter.api.Test
8+
9+
class JsonDeserializationHandlerTest {
10+
11+
val deserializationHandler = JsonDeserializationHandler(jacksonObjectMapper())
12+
13+
@Test
14+
fun `should support json`() {
15+
assertTrue(deserializationHandler.supports(APIGatewayProxyRequestEvent()
16+
.withHeader("content-type", "application/json")))
17+
assertTrue(deserializationHandler.supports(APIGatewayProxyRequestEvent()
18+
.withHeader("content-type", "application/vnd.moia.v1+json")))
19+
}
20+
21+
@Test
22+
fun `should not support anything else than json`() {
23+
assertFalse(deserializationHandler.supports(APIGatewayProxyRequestEvent()
24+
.withHeader("content-type", "image/png")))
25+
assertFalse(deserializationHandler.supports(APIGatewayProxyRequestEvent()
26+
.withHeader("content-type", "text/plain")))
27+
}
28+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package io.moia.router
2+
3+
import com.google.common.net.MediaType
4+
import isCompatibleWith
5+
import org.assertj.core.api.BDDAssertions.then
6+
import org.junit.jupiter.api.Test
7+
8+
class MediaTypeTest {
9+
10+
@Test
11+
fun `should match`() {
12+
then(MediaType.parse("application/json").isCompatibleWith(MediaType.parse("application/json"))).isTrue()
13+
}
14+
15+
@Test
16+
fun `should match subtype wildcard`() {
17+
then(MediaType.parse("application/json").isCompatibleWith(MediaType.parse("application/*"))).isTrue()
18+
}
19+
20+
@Test
21+
fun `should not match subtype wildcard in different tpye`() {
22+
then(MediaType.parse("application/json").isCompatibleWith(MediaType.parse("image/*"))).isFalse()
23+
}
24+
25+
@Test
26+
fun `should match wildcard`() {
27+
then(MediaType.parse("application/json").isCompatibleWith(MediaType.parse("*/*"))).isTrue()
28+
}
29+
30+
@Test
31+
fun `should match wildcard structured syntax suffix`() {
32+
then(MediaType.parse("application/*+json").isCompatibleWith(MediaType.parse("application/vnd.moia+json"))).isTrue()
33+
then(MediaType.parse("application/*+json").isCompatibleWith(MediaType.parse("application/vnd.moia.v1+json"))).isTrue()
34+
}
35+
36+
@Test
37+
fun `should not match wildcard structured syntax suffix on non suffix type`() {
38+
then(MediaType.parse("application/*+json").isCompatibleWith(MediaType.parse("application/json"))).isFalse()
39+
}
40+
41+
@Test
42+
fun `should not match wildcard structured syntax suffix on differnt suffix`() {
43+
then(MediaType.parse("application/*+json").isCompatibleWith(MediaType.parse("application/*+x-protobuf"))).isFalse()
44+
}
45+
}

0 commit comments

Comments
 (0)