Skip to content

Commit 33a4283

Browse files
authored
[server] support batch requests (ExpediaGroup#1019)
* [server] support batch requests Add support for handling both individual GraphQL requests (current) as well as batch requests (new) that specify a list of `GraphQLRequest`s in their HTTP body. Single context is created for batch requests and each one of the requests has to provide valid response. * fix example/ktor-server ktlint violations
1 parent f8ff54d commit 33a4283

File tree

11 files changed

+219
-50
lines changed

11 files changed

+219
-50
lines changed

docs/server/graphql-request-parser.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,29 @@ The `GraphQLRequestParser` interface is requrired to parse the library-specific
77

88
```kotlin
99
interface GraphQLRequestParser<Request> {
10-
suspend fun parseRequest(request: Request): GraphQLRequest?
10+
suspend fun parseRequest(request: Request): GraphQLServerRequest<*>?
1111
}
1212
```
1313

14-
While not offically part of the spec, there is a standard format used by most GraphQL clients and servers for [serving GraphQL over HTTP](https://graphql.org/learn/serving-over-http/).
14+
While not officially part of the spec, there is a standard format used by most GraphQL clients and servers for [serving GraphQL over HTTP](https://graphql.org/learn/serving-over-http/).
15+
Following the above convention, GraphQL clients should generally use HTTP POST requests with the following body structure
16+
17+
```json
18+
{
19+
"query": "...",
20+
"operationName": "...",
21+
"variables": { "myVariable": "someValue" }
22+
}
23+
```
24+
25+
where
26+
* `query` is a required field and contains operation (query, mutation or subscription) that specify their selection set to be executed
27+
* `operationName` is an optional operation name, only required if multiple operations are specified in `query` string
28+
* `variables` is an optional field that holds an arbitrary JSON objects that are referenced as input arguments from `query` string
29+
30+
GraphQL Kotlin server supports both single and batch GraphQL requests. Batch requests are represented as a list of individual
31+
GraphQL requests. When processing batch requests, same context will be used for processing all requests and server will respond
32+
with a list of GraphQL responses.
1533

1634
If the request is not a valid GraphQL format, the interface should return `null` and let the server specific code return a bad request status to the client.
1735
This is not the same as a GraphQL error or an exception thrown by the schema.

examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorGraphQLRequestParser.kt

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,36 @@
1616

1717
package com.expediagroup.graphql.examples.server.ktor
1818

19+
import com.expediagroup.graphql.server.execution.GraphQLBatchRequest
1920
import com.expediagroup.graphql.server.execution.GraphQLRequestParser
21+
import com.expediagroup.graphql.server.execution.GraphQLServerRequest
22+
import com.expediagroup.graphql.server.execution.GraphQLSingleRequest
2023
import com.expediagroup.graphql.types.GraphQLRequest
24+
import com.fasterxml.jackson.core.type.TypeReference
2125
import com.fasterxml.jackson.databind.ObjectMapper
22-
import com.fasterxml.jackson.module.kotlin.readValue
2326
import io.ktor.request.ApplicationRequest
2427
import io.ktor.request.receiveText
2528
import java.io.IOException
2629

2730
/**
28-
* Custom logic for how Ktor parses the incoming [ApplicationRequest] into the [GraphQLRequest]
31+
* Custom logic for how Ktor parses the incoming [ApplicationRequest] into the [GraphQLServerRequest]
2932
*/
3033
class KtorGraphQLRequestParser(
3134
private val mapper: ObjectMapper
3235
) : GraphQLRequestParser<ApplicationRequest> {
3336

34-
override suspend fun parseRequest(request: ApplicationRequest): GraphQLRequest {
35-
return try {
36-
mapper.readValue(request.call.receiveText())
37-
} catch (e: IOException) {
38-
throw IOException("Unable to parse GraphQL payload.")
37+
private val graphQLBatchRequestTypeReference: TypeReference<List<GraphQLRequest>> = object : TypeReference<List<GraphQLRequest>>() {}
38+
39+
@Suppress("BlockingMethodInNonBlockingContext")
40+
override suspend fun parseRequest(request: ApplicationRequest): GraphQLServerRequest<*> = try {
41+
val rawRequest = request.call.receiveText()
42+
val jsonNode = mapper.readTree(rawRequest)
43+
if (jsonNode.isArray) {
44+
GraphQLBatchRequest(mapper.convertValue(jsonNode, graphQLBatchRequestTypeReference))
45+
} else {
46+
GraphQLSingleRequest(mapper.treeToValue(jsonNode, GraphQLRequest::class.java))
3947
}
48+
} catch (e: IOException) {
49+
throw IOException("Unable to parse GraphQL payload.")
4050
}
4151
}

examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorServer.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class KtorServer {
3939

4040
if (result != null) {
4141
// write response as json
42-
val json = mapper.writeValueAsString(result)
42+
val json = mapper.writeValueAsString(result.response)
4343
applicationCall.response.call.respond(json)
4444
} else {
4545
applicationCall.response.call.respond(HttpStatusCode.BadRequest, "Invalid request")

servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/execution/GraphQLRequestParser.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,11 @@
1616

1717
package com.expediagroup.graphql.server.execution
1818

19-
import com.expediagroup.graphql.types.GraphQLRequest
20-
2119
/**
22-
* A generic server interface that handles parsing the specific server implementation request to a [GraphQLRequest].
20+
* A generic server interface that handles parsing the specific server implementation request to a [GraphQLServerRequest].
2321
* If the request is not valid return null.
2422
*/
2523
interface GraphQLRequestParser<Request> {
2624

27-
suspend fun parseRequest(request: Request): GraphQLRequest?
25+
suspend fun parseRequest(request: Request): GraphQLServerRequest<*>?
2826
}

servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/execution/GraphQLServer.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,21 @@ open class GraphQLServer<Request>(
3636
* In the case of errors or exceptions, return a response with [GraphQLResponse.errors] populated.
3737
* If you need custom logic inside this method you can override this class or choose not to use it.
3838
*/
39-
open suspend fun execute(request: Request): GraphQLResponse<*>? {
39+
open suspend fun execute(request: Request): GraphQLServerResponse<*>? {
4040
val graphQLRequest = requestParser.parseRequest(request)
4141

4242
return if (graphQLRequest != null) {
4343
val context = contextFactory.generateContext(request)
44-
requestHandler.executeRequest(graphQLRequest, context)
44+
when (graphQLRequest) {
45+
is GraphQLSingleRequest -> GraphQLSingleResponse(
46+
requestHandler.executeRequest(graphQLRequest.request, context)
47+
)
48+
is GraphQLBatchRequest -> GraphQLBatchResponse(
49+
graphQLRequest.request.map {
50+
requestHandler.executeRequest(it, context)
51+
}
52+
)
53+
}
4554
} else {
4655
null
4756
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2021 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.server.execution
18+
19+
import com.expediagroup.graphql.types.GraphQLRequest
20+
21+
/**
22+
* GraphQL server request abstraction that provides a convenient way to handle both single and batch requests.
23+
*/
24+
sealed class GraphQLServerRequest<T : Any>(val request: T)
25+
26+
/**
27+
* Wrapper that holds single GraphQLRequest to be processed within an HTTP request.
28+
*/
29+
class GraphQLSingleRequest(graphQLRequest: GraphQLRequest) : GraphQLServerRequest<GraphQLRequest>(graphQLRequest)
30+
31+
/**
32+
* Wrapper that holds list of GraphQLRequests to be processed together within a single HTTP request.
33+
*/
34+
class GraphQLBatchRequest(requests: List<GraphQLRequest>) : GraphQLServerRequest<List<GraphQLRequest>>(requests)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2021 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.server.execution
18+
19+
import com.expediagroup.graphql.types.GraphQLResponse
20+
21+
/**
22+
* GraphQL server response abstraction that provides a convenient way to handle both single and batch responses.
23+
*/
24+
sealed class GraphQLServerResponse<T : Any>(val response: T)
25+
26+
/**
27+
* Wrapper that holds single GraphQLResponse to an HTTP request.
28+
*/
29+
class GraphQLSingleResponse(graphQLResponse: GraphQLResponse<*>) : GraphQLServerResponse<GraphQLResponse<*>>(graphQLResponse)
30+
31+
/**
32+
* Wrapper that holds list of GraphQLResponses that were processed together within a single HTTP request.
33+
*/
34+
class GraphQLBatchResponse(responses: List<GraphQLResponse<*>>) : GraphQLServerResponse<List<GraphQLResponse<*>>>(responses)

servers/graphql-kotlin-server/src/test/kotlin/com/expediagroup/graphql/server/execution/GraphQLServerTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class GraphQLServerTest {
3434
@Test
3535
fun `the request handler and parser are called`() {
3636
val mockParser = mockk<GraphQLRequestParser<MockHttpRequest>> {
37-
coEvery { parseRequest(any()) } returns mockk()
37+
coEvery { parseRequest(any()) } returns GraphQLBatchRequest(requests = listOf(mockk()))
3838
}
3939
val mockContextFactory = mockk<GraphQLContextFactory<MockContext, MockHttpRequest>> {
4040
coEvery { generateContext(any()) } returns MockContext()
@@ -57,7 +57,7 @@ class GraphQLServerTest {
5757
@Test
5858
fun `null context is used and passed to the request handler`() {
5959
val mockParser = mockk<GraphQLRequestParser<MockHttpRequest>> {
60-
coEvery { parseRequest(any()) } returns mockk()
60+
coEvery { parseRequest(any()) } returns GraphQLSingleRequest(graphQLRequest = mockk())
6161
}
6262
val mockContextFactory = mockk<GraphQLContextFactory<MockContext, MockHttpRequest>> {
6363
coEvery { generateContext(any()) } returns null

servers/graphql-kotlin-spring-server/src/main/kotlin/com/expediagroup/graphql/server/spring/GraphQLRoutesConfiguration.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class GraphQLRoutesConfiguration(
4848
(isEndpointRequest and isNotWebsocketRequest).invoke { serverRequest ->
4949
val graphQLResponse = graphQLServer.execute(serverRequest)
5050
if (graphQLResponse != null) {
51-
ok().json().bodyValueAndAwait(graphQLResponse)
51+
ok().json().bodyValueAndAwait(graphQLResponse.response)
5252
} else {
5353
badRequest().buildAndAwait()
5454
}

servers/graphql-kotlin-spring-server/src/main/kotlin/com/expediagroup/graphql/server/spring/execution/SpringGraphQLRequestParser.kt

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,17 @@
1616

1717
package com.expediagroup.graphql.server.spring.execution
1818

19+
import com.expediagroup.graphql.server.execution.GraphQLBatchRequest
20+
import com.expediagroup.graphql.server.execution.GraphQLSingleRequest
1921
import com.expediagroup.graphql.server.execution.GraphQLRequestParser
22+
import com.expediagroup.graphql.server.execution.GraphQLServerRequest
2023
import com.expediagroup.graphql.types.GraphQLRequest
24+
import com.fasterxml.jackson.core.type.TypeReference
25+
import com.fasterxml.jackson.databind.JsonNode
2126
import com.fasterxml.jackson.databind.ObjectMapper
2227
import com.fasterxml.jackson.databind.type.MapType
2328
import com.fasterxml.jackson.databind.type.TypeFactory
29+
import kotlinx.coroutines.reactive.awaitFirst
2430
import org.springframework.http.HttpMethod
2531
import org.springframework.http.MediaType
2632
import org.springframework.web.reactive.function.server.ServerRequest
@@ -36,34 +42,42 @@ open class SpringGraphQLRequestParser(
3642
) : GraphQLRequestParser<ServerRequest> {
3743

3844
private val mapTypeReference: MapType = TypeFactory.defaultInstance().constructMapType(HashMap::class.java, String::class.java, Any::class.java)
45+
private val graphQLRequestListTypeReference: TypeReference<List<GraphQLRequest>> = object : TypeReference<List<GraphQLRequest>>() {}
3946

40-
override suspend fun parseRequest(request: ServerRequest): GraphQLRequest? = when {
47+
override suspend fun parseRequest(request: ServerRequest): GraphQLServerRequest<*>? = when {
4148
request.queryParam(REQUEST_PARAM_QUERY).isPresent -> { getRequestFromGet(request) }
4249
request.method() == HttpMethod.POST -> { getRequestFromPost(request) }
4350
else -> null
4451
}
4552

46-
private fun getRequestFromGet(serverRequest: ServerRequest): GraphQLRequest {
53+
private fun getRequestFromGet(serverRequest: ServerRequest): GraphQLServerRequest<*> {
4754
val query = serverRequest.queryParam(REQUEST_PARAM_QUERY).get()
4855
val operationName: String? = serverRequest.queryParam(REQUEST_PARAM_OPERATION_NAME).orElseGet { null }
4956
val variables: String? = serverRequest.queryParam(REQUEST_PARAM_VARIABLES).orElseGet { null }
5057
val graphQLVariables: Map<String, Any>? = variables?.let {
5158
objectMapper.readValue(it, mapTypeReference)
5259
}
5360

54-
return GraphQLRequest(query = query, operationName = operationName, variables = graphQLVariables)
61+
return GraphQLSingleRequest(GraphQLRequest(query = query, operationName = operationName, variables = graphQLVariables))
5562
}
5663

5764
/**
5865
* We have have to suppress the warning due to a jackson issue
5966
* https://github.com/FasterXML/jackson-module-kotlin/issues/221
6067
*/
6168
@Suppress("BlockingMethodInNonBlockingContext")
62-
private suspend fun getRequestFromPost(serverRequest: ServerRequest): GraphQLRequest? {
69+
private suspend fun getRequestFromPost(serverRequest: ServerRequest): GraphQLServerRequest<*>? {
6370
val contentType = serverRequest.headers().contentType().orElse(MediaType.APPLICATION_JSON)
6471
return when {
65-
contentType.includes(MediaType.APPLICATION_JSON) -> serverRequest.awaitBody()
66-
contentType.includes(graphQLMediaType) -> GraphQLRequest(query = serverRequest.awaitBody())
72+
contentType.includes(MediaType.APPLICATION_JSON) -> {
73+
val jsonNode = serverRequest.bodyToMono(JsonNode::class.java).awaitFirst()
74+
if (jsonNode.isArray) {
75+
GraphQLBatchRequest(objectMapper.convertValue(jsonNode, graphQLRequestListTypeReference))
76+
} else {
77+
GraphQLSingleRequest(objectMapper.treeToValue(jsonNode, GraphQLRequest::class.java))
78+
}
79+
}
80+
contentType.includes(graphQLMediaType) -> GraphQLSingleRequest(GraphQLRequest(query = serverRequest.awaitBody()))
6781
else -> null
6882
}
6983
}

0 commit comments

Comments
 (0)