Skip to content

Commit bb9cc70

Browse files
dariuszkucsmyrick
authored andcommitted
spring server updates (ExpediaGroup#361)
* spring server updates - update `GraphQLContextFactory` to use coroutines for building the context - extract `SubscriptionHandler` logic from default `WebSocketHandler`, default subscription `WebSocketHandler` will now call available `SubscriberHandler` bean - update `SimpleSubscriptionHandler` to use `GraphQLContext` - fixes ExpediaGroup#360 - add error handling to `SimpleSubscriptionHandler` * fix context web filter test ContextWebFilter returns a mono publisher that will compute the final result only after chain is subscribed to. StepVerifier was attempting to verify the context on the un-completed chain which did not populate the reactor context yet. Fix is to manually capture the context in the publisher chain, complete the publisher chain and then verify the contents of the captured reactor context.
1 parent 65fab1e commit bb9cc70

File tree

14 files changed

+381
-72
lines changed

14 files changed

+381
-72
lines changed

examples/spring/src/main/kotlin/com/expediagroup/graphql/sample/context/MyGraphQLContextFactory.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import org.springframework.stereotype.Component
1111
@Component
1212
class MyGraphQLContextFactory: GraphQLContextFactory<MyGraphQLContext> {
1313

14-
override fun generateContext(request: ServerHttpRequest, response: ServerHttpResponse): MyGraphQLContext = MyGraphQLContext(
14+
override suspend fun generateContext(request: ServerHttpRequest, response: ServerHttpResponse): MyGraphQLContext = MyGraphQLContext(
1515
myCustomValue = request.headers.getFirst("MyHeader") ?: "defaultContext",
1616
request = request,
1717
response = response)

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package com.expediagroup.graphql.spring
1818

1919
import com.expediagroup.graphql.spring.execution.SimpleSubscriptionHandler
2020
import com.expediagroup.graphql.spring.execution.SubscriptionHandler
21+
import com.expediagroup.graphql.spring.execution.SubscriptionWebSocketHandler
2122
import com.expediagroup.graphql.spring.operations.Subscription
2223
import com.fasterxml.jackson.databind.ObjectMapper
2324
import graphql.GraphQL
@@ -39,13 +40,16 @@ class SubscriptionAutoConfiguration {
3940

4041
@Bean
4142
@ConditionalOnMissingBean
42-
fun subscriptionHandler(graphQL: GraphQL, objectMapper: ObjectMapper): SubscriptionHandler = SimpleSubscriptionHandler(graphQL, objectMapper)
43+
fun subscriptionHandler(graphQL: GraphQL): SubscriptionHandler = SimpleSubscriptionHandler(graphQL)
4344

4445
@Bean
4546
@ConditionalOnMissingBean
4647
fun websocketHandlerAdapter(): WebSocketHandlerAdapter = WebSocketHandlerAdapter()
4748

4849
@Bean
49-
fun subscriptionHandlerMapping(config: GraphQLConfigurationProperties, subscriptionHandler: SubscriptionHandler): HandlerMapping =
50-
SimpleUrlHandlerMapping(mapOf(config.subscriptions.endpoint to subscriptionHandler), Ordered.HIGHEST_PRECEDENCE)
50+
fun subscriptionWebSocketHandler(handler: SubscriptionHandler, objectMapper: ObjectMapper) = SubscriptionWebSocketHandler(handler, objectMapper)
51+
52+
@Bean
53+
fun subscriptionHandlerMapping(config: GraphQLConfigurationProperties, subscriptionWebSocketHandler: SubscriptionWebSocketHandler): HandlerMapping =
54+
SimpleUrlHandlerMapping(mapOf(config.subscriptions.endpoint to subscriptionWebSocketHandler), Ordered.HIGHEST_PRECEDENCE)
5155
}

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,29 @@
1616

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

19+
import kotlinx.coroutines.reactor.mono
1920
import org.springframework.core.Ordered
20-
import org.springframework.core.annotation.Order
2121
import org.springframework.web.server.ServerWebExchange
2222
import org.springframework.web.server.WebFilter
2323
import org.springframework.web.server.WebFilterChain
2424
import reactor.core.publisher.Mono
2525

2626
/**
2727
* [org.springframework.core.Ordered] value used for the [ContextWebFilter] order in which it will be applied to the incoming requests.
28+
* Smaller value take higher precedence.
2829
*/
2930
const val GRAPHQL_CONTEXT_FILTER_ODER = 0
3031

3132
/**
3233
* Default web filter that populates GraphQL context in the reactor subscriber context.
3334
*/
34-
@Order(GRAPHQL_CONTEXT_FILTER_ODER)
3535
class ContextWebFilter(private val contextFactory: GraphQLContextFactory<Any>) : WebFilter, Ordered {
3636

3737
@Suppress("ForbiddenVoid")
38-
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
39-
val context = contextFactory.generateContext(exchange.request, exchange.response)
40-
return chain.filter(exchange).subscriberContext { it.put(GRAPHQL_CONTEXT_KEY, context) }
38+
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> = mono {
39+
contextFactory.generateContext(exchange.request, exchange.response)
40+
}.flatMap { graphQLContext ->
41+
chain.filter(exchange).subscriberContext { it.put(GRAPHQL_CONTEXT_KEY, graphQLContext) }
4142
}
4243

4344
override fun getOrder(): Int = GRAPHQL_CONTEXT_FILTER_ODER

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,13 @@ interface GraphQLContextFactory<out T : Any> {
3333
/**
3434
* Generate GraphQL context based on the incoming request and the corresponding response.
3535
*/
36-
fun generateContext(request: ServerHttpRequest, response: ServerHttpResponse): T
36+
suspend fun generateContext(request: ServerHttpRequest, response: ServerHttpResponse): T
3737
}
3838

3939
/**
4040
* Default context factory that generates empty GraphQL context.
4141
*/
4242
internal object EmptyContextFactory : GraphQLContextFactory<GraphQLContext> {
4343

44-
override fun generateContext(request: ServerHttpRequest, response: ServerHttpResponse): GraphQLContext = GraphQLContext.newContext().build()
44+
override suspend fun generateContext(request: ServerHttpRequest, response: ServerHttpResponse): GraphQLContext = GraphQLContext.newContext().build()
4545
}

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

Lines changed: 16 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -16,65 +16,41 @@
1616

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

19+
import com.expediagroup.graphql.spring.exception.SimpleKotlinGraphQLError
1920
import com.expediagroup.graphql.spring.model.GraphQLRequest
2021
import com.expediagroup.graphql.spring.model.GraphQLResponse
2122
import com.expediagroup.graphql.spring.model.toExecutionInput
2223
import com.expediagroup.graphql.spring.model.toGraphQLResponse
23-
import com.fasterxml.jackson.databind.ObjectMapper
24-
import com.fasterxml.jackson.module.kotlin.readValue
2524
import graphql.ExecutionResult
2625
import graphql.GraphQL
2726
import org.reactivestreams.Publisher
28-
import org.slf4j.LoggerFactory
29-
import org.springframework.web.reactive.socket.WebSocketHandler
30-
import org.springframework.web.reactive.socket.WebSocketSession
3127
import reactor.core.publisher.Flux
3228
import reactor.core.publisher.Mono
3329
import reactor.core.publisher.toFlux
3430

3531
/**
36-
* WebSocket handler for handling GraphQL subscriptions.
32+
* GraphQL subscription handler.
3733
*/
38-
interface SubscriptionHandler : WebSocketHandler {
34+
interface SubscriptionHandler {
3935

4036
/**
41-
* Execute GraphQL subscription request and return a Publisher that emits 0 to N [GraphQLResponse]s.
37+
* Execute GraphQL subscription request and return a Reactor Flux Publisher that emits 0 to N [GraphQLResponse]s.
4238
*/
4339
fun executeSubscription(graphQLRequest: GraphQLRequest): Flux<GraphQLResponse>
4440
}
4541

4642
/**
47-
* Default WebSocket handler for handling GraphQL subscriptions.
43+
* Default implementation of GraphQL subscription handler.
4844
*/
49-
open class SimpleSubscriptionHandler(
50-
private val graphQL: GraphQL,
51-
private val objectMapper: ObjectMapper
52-
) : SubscriptionHandler {
53-
54-
private val logger = LoggerFactory.getLogger(SubscriptionHandler::class.java)
55-
56-
@Suppress("ForbiddenVoid")
57-
override fun handle(session: WebSocketSession): Mono<Void> {
58-
val response = session.receive()
59-
.concatMap {
60-
val graphQLRequest = objectMapper.readValue<GraphQLRequest>(it.payloadAsText)
61-
executeSubscription(graphQLRequest)
62-
.doOnSubscribe { logger.trace("WebSocket GraphQL subscription subscribe, ID=${session.id}") }
63-
.doOnCancel { logger.trace("WebSocket GraphQL subscription cancel, ID=${session.id}") }
64-
.doOnComplete { logger.trace("WebSocket GraphQL subscription complete, ID=${session.id}") }
65-
.doFinally { session.close() }
66-
}
67-
.map { objectMapper.writeValueAsString(it) }
68-
.map { session.textMessage(it) }
69-
70-
return session.send(response)
71-
}
72-
73-
override fun getSubProtocols(): List<String> = listOf("graphql-ws")
74-
75-
override fun executeSubscription(graphQLRequest: GraphQLRequest): Flux<GraphQLResponse> =
76-
graphQL.execute(graphQLRequest.toExecutionInput())
77-
.getData<Publisher<ExecutionResult>>()
78-
.toFlux()
79-
.map { it.toGraphQLResponse() }
45+
open class SimpleSubscriptionHandler(private val graphQL: GraphQL) : SubscriptionHandler {
46+
47+
override fun executeSubscription(graphQLRequest: GraphQLRequest): Flux<GraphQLResponse> = Mono.subscriberContext()
48+
.flatMapMany { reactorContext ->
49+
val graphQLContext = reactorContext.getOrDefault<Any>(GRAPHQL_CONTEXT_KEY, null)
50+
graphQL.execute(graphQLRequest.toExecutionInput(graphQLContext))
51+
.getData<Publisher<ExecutionResult>>()
52+
.toFlux()
53+
.map { result -> result.toGraphQLResponse() }
54+
.onErrorResume { error -> Flux.just(GraphQLResponse(errors = listOf(SimpleKotlinGraphQLError(error)))) }
55+
}
8056
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2019 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.spring.execution
18+
19+
import com.expediagroup.graphql.spring.model.GraphQLRequest
20+
import com.fasterxml.jackson.databind.ObjectMapper
21+
import com.fasterxml.jackson.module.kotlin.readValue
22+
import org.slf4j.LoggerFactory
23+
import org.springframework.web.reactive.socket.WebSocketHandler
24+
import org.springframework.web.reactive.socket.WebSocketSession
25+
import reactor.core.publisher.Mono
26+
27+
/**
28+
* Default WebSocket handler for handling GraphQL subscriptions.
29+
*/
30+
class SubscriptionWebSocketHandler(
31+
private val subscriptionHandler: SubscriptionHandler,
32+
private val objectMapper: ObjectMapper
33+
) : WebSocketHandler {
34+
35+
private val logger = LoggerFactory.getLogger(SubscriptionWebSocketHandler::class.java)
36+
37+
@Suppress("ForbiddenVoid")
38+
override fun handle(session: WebSocketSession): Mono<Void> {
39+
val response = session.receive()
40+
.concatMap {
41+
val graphQLRequest = objectMapper.readValue<GraphQLRequest>(it.payloadAsText)
42+
subscriptionHandler.executeSubscription(graphQLRequest)
43+
.doOnSubscribe { logger.trace("WebSocket GraphQL subscription subscribe, ID=${session.id}") }
44+
.doOnCancel { logger.trace("WebSocket GraphQL subscription cancel, ID=${session.id}") }
45+
.doOnComplete { logger.trace("WebSocket GraphQL subscription complete, ID=${session.id}") }
46+
.doFinally { session.close() }
47+
}
48+
.map { objectMapper.writeValueAsString(it) }
49+
.map { session.textMessage(it) }
50+
51+
return session.send(response)
52+
}
53+
54+
override fun getSubProtocols(): List<String> = listOf("graphql-ws")
55+
}

graphql-kotlin-spring-server/src/test/kotlin/com/expediagroup/graphql/spring/FederationConfigurationTest.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
/*
2+
* Copyright 2019 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+
117
package com.expediagroup.graphql.spring
218

319
import com.expediagroup.graphql.SchemaGeneratorConfig

graphql-kotlin-spring-server/src/test/kotlin/com/expediagroup/graphql/spring/SchemaConfigurationTest.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
/*
2+
* Copyright 2019 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+
117
package com.expediagroup.graphql.spring
218

319
import com.expediagroup.graphql.SchemaGeneratorConfig

graphql-kotlin-spring-server/src/test/kotlin/com/expediagroup/graphql/spring/SubscriptionConfigurationTest.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
/*
2+
* Copyright 2019 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+
117
package com.expediagroup.graphql.spring
218

319
import com.expediagroup.graphql.SchemaGeneratorConfig
@@ -8,6 +24,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
824
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
925
import graphql.GraphQL
1026
import graphql.schema.GraphQLSchema
27+
import io.mockk.every
1128
import io.mockk.mockk
1229
import org.assertj.core.api.Assertions.assertThat
1330
import org.junit.jupiter.api.Test
@@ -99,7 +116,9 @@ class SubscriptionConfigurationTest {
99116
fun subscription(): Subscription = SimpleSubscription()
100117

101118
@Bean
102-
fun subscriptionHandler(): SubscriptionHandler = mockk()
119+
fun subscriptionHandler(): SubscriptionHandler = mockk {
120+
every { executeSubscription(any()) } returns Flux.empty()
121+
}
103122

104123
@Bean
105124
fun webSocketHandlerAdapter(): WebSocketHandlerAdapter = mockk()
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,55 @@
1+
/*
2+
* Copyright 2019 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+
117
package com.expediagroup.graphql.spring.execution
218

319
import graphql.GraphQLContext
420
import io.mockk.every
521
import io.mockk.mockk
622
import org.junit.jupiter.api.Test
7-
import org.springframework.http.server.reactive.ServerHttpRequest
823
import org.springframework.web.server.ServerWebExchange
924
import org.springframework.web.server.WebFilterChain
1025
import reactor.core.publisher.Mono
1126
import reactor.test.StepVerifier
27+
import reactor.util.context.Context
1228
import kotlin.test.assertNotNull
1329

1430
class ContextWebFilterTest {
1531

1632
@Test
33+
@Suppress("ForbiddenVoid")
1734
fun `verify web filter populates context in the subscriber context`() {
18-
val mockRequest = mockk<ServerHttpRequest>()
19-
val exchange = mockk<ServerWebExchange> {
20-
every { request } returns mockRequest
35+
var generatedContext: Context? = null
36+
val exchange: ServerWebExchange = mockk {
37+
every { request } returns mockk()
2138
every { response } returns mockk()
2239
}
23-
val chain = mockk<WebFilterChain> {
24-
every { filter(exchange) } returns Mono.empty()
40+
val chain: WebFilterChain = mockk {
41+
every { filter(any()) } returns Mono.subscriberContext().flatMap {
42+
generatedContext = it
43+
Mono.empty<Void>()
44+
}
2545
}
2646

2747
val contextFilter = ContextWebFilter(EmptyContextFactory)
2848
StepVerifier.create(contextFilter.filter(exchange, chain))
29-
.expectAccessibleContext()
30-
.hasSize(1)
31-
.hasKey(GRAPHQL_CONTEXT_KEY)
32-
.assertThat {
33-
val graphQLContext = it.getOrDefault<GraphQLContext>(GRAPHQL_CONTEXT_KEY, null)
34-
assertNotNull(graphQLContext)
35-
}
36-
.then()
3749
.verifyComplete()
50+
51+
assertNotNull(generatedContext)
52+
val graphQLContext = generatedContext?.getOrDefault<GraphQLContext>(GRAPHQL_CONTEXT_KEY, null)
53+
assertNotNull(graphQLContext)
3854
}
3955
}

0 commit comments

Comments
 (0)