Skip to content

Commit f9e0ea4

Browse files
authored
feat(ktor-server): skip internal ContentNegotiation install when already configured (#2174)
### 📝 Description `Route.graphQLGetRoute()` and `Route.graphQLPostRoute()` unconditionally install `ContentNegotiation` on the route scope. When the host application has already installed `ContentNegotiation` at the application level — a common pattern when the app serves other JSON endpoints or uses `StatusPages` with JSON error responses — Ktor throws `DuplicatePluginException`: ``` Installing RouteScopedPlugin to application and route is not supported. Consider moving application level install to routing root. ``` This PR makes the internal install conditional on `application.pluginOrNull(ContentNegotiation)`: - If `ContentNegotiation` is already installed at the application level, the route-scoped install is skipped and the existing global configuration is reused. - If no global install is present, behaviour is unchanged: the route-scoped install proceeds exactly as today, honouring `streamingResponse` and `jacksonConfiguration`. When the global install wins, `streamingResponse` and `jacksonConfiguration` are not applied, since the application-level configuration takes precedence; this trade-off is documented in the KDoc for both routes. No API change; fully backward compatible. Added two regression tests in `GraphQLPluginTest` covering the globally-installed case for both the GET and POST routes. ### 🔗 Related Issues Fixes #2025
1 parent d5c4458 commit f9e0ea4

2 files changed

Lines changed: 98 additions & 12 deletions

File tree

servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/GraphQLRoutes.kt

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.expediagroup.graphql.server.execution.subscription.GRAPHQL_WS_PROTOCO
2121
import io.ktor.http.ContentType
2222
import io.ktor.serialization.jackson3.jackson
2323
import io.ktor.server.application.plugin
24+
import io.ktor.server.application.pluginOrNull
2425
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
2526
import io.ktor.server.response.respondText
2627
import io.ktor.server.routing.Route
@@ -33,40 +34,65 @@ import kotlinx.coroutines.flow.collect
3334
import tools.jackson.databind.json.JsonMapper
3435

3536
/**
36-
* Configures GraphQL GET route
37+
* Configures GraphQL GET route.
38+
*
39+
* Installs the Jackson `ContentNegotiation` plugin on the route scope. If the application has
40+
* already installed `ContentNegotiation` at the application level, the route-scoped install is
41+
* skipped and the existing configuration is reused — this avoids Ktor's `DuplicatePluginException`
42+
* when the host application serves other JSON endpoints. In that case the provided
43+
* [streamingResponse] and [jacksonConfiguration] arguments are not applied; callers that need
44+
* custom Jackson settings should configure them on their application-level install.
3745
*
3846
* @param endpoint GraphQL server GET endpoint, defaults to 'graphql'
39-
* @param streamingResponse Enable streaming response body without keeping it fully in memory. If set to true (default) it will set `Transfer-Encoding: chunked` header on the responses.
40-
* @param jacksonConfiguration a configuration block for [JsonMapper.Builder], passed to ktor
47+
* @param streamingResponse Enable streaming response body without keeping it fully in memory. If set to true (default)
48+
* it will set `Transfer-Encoding: chunked` header on the responses. Ignored if `ContentNegotiation` is already
49+
* installed at the application level.
50+
* @param jacksonConfiguration a configuration block for [JsonMapper.Builder], passed to ktor. Ignored if
51+
* `ContentNegotiation` is already installed at the application level.
4152
*/
4253
fun Route.graphQLGetRoute(endpoint: String = "graphql", streamingResponse: Boolean = true, jacksonConfiguration: JsonMapper.Builder.() -> Unit = {}): Route {
4354
val graphQLPlugin = this.application.plugin(GraphQL)
4455
val route = get(endpoint) {
4556
graphQLPlugin.server.executeRequest(call)
4657
}
47-
route.install(ContentNegotiation) {
48-
jackson(streamRequestBody = streamingResponse) {
49-
apply(jacksonConfiguration)
58+
if (this.application.pluginOrNull(ContentNegotiation) == null) {
59+
route.install(ContentNegotiation) {
60+
jackson(streamRequestBody = streamingResponse) {
61+
apply(jacksonConfiguration)
62+
}
5063
}
5164
}
5265
return route
5366
}
5467

5568
/**
56-
* Configures GraphQL POST route
69+
* Configures GraphQL POST route.
70+
*
71+
* Installs the Jackson `ContentNegotiation` plugin on the route scope. If the application has
72+
* already installed `ContentNegotiation` at the application level, the route-scoped install is
73+
* skipped and the existing configuration is reused — this avoids Ktor's `DuplicatePluginException`
74+
* when the host application serves other JSON endpoints (e.g. a global `StatusPages` handler
75+
* returning JSON error responses). In that case the provided [streamingResponse] and
76+
* [jacksonConfiguration] arguments are not applied; callers that need custom Jackson settings
77+
* should configure them on their application-level install.
5778
*
5879
* @param endpoint GraphQL server POST endpoint, defaults to 'graphql'
59-
* @param streamingResponse Enable streaming response body without keeping it fully in memory. If set to true (default) it will set `Transfer-Encoding: chunked` header on the responses.
60-
* @param jacksonConfiguration a configuration block for [JsonMapper.Builder], passed to ktor
80+
* @param streamingResponse Enable streaming response body without keeping it fully in memory. If set to true (default)
81+
* it will set `Transfer-Encoding: chunked` header on the responses. Ignored if `ContentNegotiation` is already
82+
* installed at the application level.
83+
* @param jacksonConfiguration a configuration block for [JsonMapper.Builder], passed to ktor. Ignored if
84+
* `ContentNegotiation` is already installed at the application level.
6185
*/
6286
fun Route.graphQLPostRoute(endpoint: String = "graphql", streamingResponse: Boolean = true, jacksonConfiguration: JsonMapper.Builder.() -> Unit = {}): Route {
6387
val graphQLPlugin = this.application.plugin(GraphQL)
6488
val route = post(endpoint) {
6589
graphQLPlugin.server.executeRequest(call)
6690
}
67-
route.install(ContentNegotiation) {
68-
jackson(streamRequestBody = streamingResponse) {
69-
apply(jacksonConfiguration)
91+
if (this.application.pluginOrNull(ContentNegotiation) == null) {
92+
route.install(ContentNegotiation) {
93+
jackson(streamRequestBody = streamingResponse) {
94+
apply(jacksonConfiguration)
95+
}
7096
}
7197
}
7298
return route

servers/graphql-kotlin-ktor-server/src/test/kotlin/com/expediagroup/graphql/server/ktor/GraphQLPluginTest.kt

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,66 @@ class GraphQLPluginTest {
256256
assertContains(html, """var subscriptionUrl = new URL("/subscriptions", location.href);""")
257257
}
258258
}
259+
260+
@Test
261+
fun `graphQLPostRoute should not duplicate ContentNegotiation when already installed at application level`() {
262+
// Reproduces https://github.com/ExpediaGroup/graphql-kotlin/issues/2025: installing
263+
// ContentNegotiation at both application and route scope would throw
264+
// DuplicatePluginException. The route helpers now detect an existing application-level
265+
// install and skip the route-scoped install.
266+
testApplication {
267+
application {
268+
install(io.ktor.server.plugins.contentnegotiation.ContentNegotiation) {
269+
jackson()
270+
}
271+
install(GraphQL) {
272+
schema {
273+
packages = listOf("com.expediagroup.graphql.server.ktor")
274+
queries = listOf(TestQuery())
275+
}
276+
}
277+
routing {
278+
graphQLPostRoute()
279+
}
280+
}
281+
val client = createClient {
282+
install(ContentNegotiation) {
283+
jackson()
284+
}
285+
}
286+
val response = client.post("/graphql") {
287+
contentType(ContentType.Application.Json)
288+
setBody(GraphQLRequest(query = "query HelloWorldQuery { hello }"))
289+
}
290+
assertEquals(HttpStatusCode.OK, response.status)
291+
assertEquals("""{"data":{"hello":"Hello World"}}""", response.bodyAsText().trim())
292+
}
293+
}
294+
295+
@Test
296+
fun `graphQLGetRoute should not duplicate ContentNegotiation when already installed at application level`() {
297+
testApplication {
298+
application {
299+
install(io.ktor.server.plugins.contentnegotiation.ContentNegotiation) {
300+
jackson()
301+
}
302+
install(GraphQL) {
303+
schema {
304+
packages = listOf("com.expediagroup.graphql.server.ktor")
305+
queries = listOf(TestQuery())
306+
}
307+
}
308+
routing {
309+
graphQLGetRoute()
310+
}
311+
}
312+
val response = client.get("/graphql") {
313+
parameter("query", "query HelloWorldQuery { hello }")
314+
}
315+
assertEquals(HttpStatusCode.OK, response.status)
316+
assertEquals("""{"data":{"hello":"Hello World"}}""", response.bodyAsText().trim())
317+
}
318+
}
259319
}
260320

261321
fun Application.testGraphQLModule() {

0 commit comments

Comments
 (0)