Skip to content

Commit 98e900e

Browse files
feat: Add subscriptions support to ktor server (#1774)
* feat: Add subscriptions support to ktor server * chore: typo fix * chore: remove Legacy word and update KDoc * chore: remove legacy subscriptions impl * chore: remove wildcard import * feat: add subscriptionsEndpoint to graphiQLRoute * chore: remove unused import * chore: update GQL plugin test * chore: remove wildcard imports * chore: update jacoco coverage config for ktor module * chore: make subscriptionsHandler lazy * chore: update ktor-server GQL subscriptions docs * Update website/docs/server/ktor-server/ktor-subscriptions.md Co-authored-by: Dariusz Kuc <[email protected]> * Update website/docs/server/ktor-server/ktor-subscriptions.md Co-authored-by: Dariusz Kuc <[email protected]> --------- Co-authored-by: Dariusz Kuc <[email protected]>
1 parent 1ff6886 commit 98e900e

File tree

23 files changed

+1308
-19
lines changed

23 files changed

+1308
-19
lines changed

examples/server/ktor-server/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ application {
1414
dependencies {
1515
implementation("com.expediagroup", "graphql-kotlin-ktor-server")
1616
implementation(libs.ktor.server.netty)
17+
implementation(libs.ktor.server.websockets)
18+
implementation(libs.ktor.server.cors)
1719
implementation(libs.logback)
1820
implementation(libs.kotlinx.coroutines.jdk8)
1921
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package com.expediagroup.graphql.examples.server.ktor
1818
import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory
1919
import com.expediagroup.graphql.examples.server.ktor.schema.BookQueryService
2020
import com.expediagroup.graphql.examples.server.ktor.schema.CourseQueryService
21+
import com.expediagroup.graphql.examples.server.ktor.schema.ExampleSubscriptionService
2122
import com.expediagroup.graphql.examples.server.ktor.schema.HelloQueryService
2223
import com.expediagroup.graphql.examples.server.ktor.schema.LoginMutationService
2324
import com.expediagroup.graphql.examples.server.ktor.schema.UniversityQueryService
@@ -28,12 +29,25 @@ import com.expediagroup.graphql.server.ktor.GraphQL
2829
import com.expediagroup.graphql.server.ktor.graphQLGetRoute
2930
import com.expediagroup.graphql.server.ktor.graphQLPostRoute
3031
import com.expediagroup.graphql.server.ktor.graphQLSDLRoute
32+
import com.expediagroup.graphql.server.ktor.graphQLSubscriptionsRoute
3133
import com.expediagroup.graphql.server.ktor.graphiQLRoute
34+
import io.ktor.serialization.jackson.JacksonWebsocketContentConverter
3235
import io.ktor.server.application.Application
3336
import io.ktor.server.application.install
37+
import io.ktor.server.plugins.cors.routing.CORS
3438
import io.ktor.server.routing.Routing
39+
import io.ktor.server.websocket.WebSockets
40+
import io.ktor.server.websocket.pingPeriod
41+
import java.time.Duration
3542

3643
fun Application.graphQLModule() {
44+
install(WebSockets) {
45+
pingPeriod = Duration.ofSeconds(1)
46+
contentConverter = JacksonWebsocketContentConverter()
47+
}
48+
install(CORS) {
49+
anyHost()
50+
}
3751
install(GraphQL) {
3852
schema {
3953
packages = listOf("com.expediagroup.graphql.examples.server")
@@ -46,6 +60,9 @@ fun Application.graphQLModule() {
4660
mutations = listOf(
4761
LoginMutationService()
4862
)
63+
subscriptions = listOf(
64+
ExampleSubscriptionService()
65+
)
4966
}
5067
engine {
5168
dataLoaderRegistryFactory = KotlinDataLoaderRegistryFactory(
@@ -59,6 +76,7 @@ fun Application.graphQLModule() {
5976
install(Routing) {
6077
graphQLGetRoute()
6178
graphQLPostRoute()
79+
graphQLSubscriptionsRoute()
6280
graphiQLRoute()
6381
graphQLSDLRoute()
6482
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright 2023 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.examples.server.ktor.schema
18+
19+
import com.expediagroup.graphql.generator.annotations.GraphQLDescription
20+
import com.expediagroup.graphql.server.operations.Subscription
21+
import graphql.GraphqlErrorException
22+
import graphql.execution.DataFetcherResult
23+
import kotlinx.coroutines.delay
24+
import kotlinx.coroutines.flow.Flow
25+
import kotlinx.coroutines.flow.flow
26+
import kotlinx.coroutines.flow.flowOf
27+
import kotlinx.coroutines.flow.map
28+
import kotlinx.coroutines.reactive.asPublisher
29+
import org.reactivestreams.Publisher
30+
import kotlin.random.Random
31+
32+
class ExampleSubscriptionService : Subscription {
33+
34+
@GraphQLDescription("Returns a single value")
35+
fun singleValue(): Flow<Int> = flowOf(1)
36+
37+
@GraphQLDescription("Returns stream of values")
38+
fun multipleValues(): Flow<Int> = flowOf(1, 2, 3)
39+
40+
@GraphQLDescription("Returns a random number every second")
41+
suspend fun counter(limit: Int? = null): Flow<Int> = flow {
42+
var count = 0
43+
while (true) {
44+
count++
45+
if (limit != null) {
46+
if (count > limit) break
47+
}
48+
emit(Random.nextInt())
49+
delay(1000)
50+
}
51+
}
52+
53+
@GraphQLDescription("Returns a random number every second, errors if even")
54+
fun counterWithError(): Flow<Int> = flow {
55+
while (true) {
56+
val value = Random.nextInt()
57+
if (value % 2 == 0) {
58+
throw Exception("Value is even $value")
59+
} else emit(value)
60+
delay(1000)
61+
}
62+
}
63+
64+
@GraphQLDescription("Returns one value then an error")
65+
fun singleValueThenError(): Flow<Int> = flowOf(1, 2)
66+
.map { if (it == 2) throw Exception("Second value") else it }
67+
68+
@GraphQLDescription("Returns stream of errors")
69+
fun flowOfErrors(): Publisher<DataFetcherResult<String?>> {
70+
val dfr: DataFetcherResult<String?> = DataFetcherResult.newResult<String?>()
71+
.data(null)
72+
.error(GraphqlErrorException.newErrorException().cause(Exception("error thrown")).build())
73+
.build()
74+
75+
return flowOf(dfr, dfr).asPublisher()
76+
}
77+
}

examples/server/ktor-server/src/main/resources/logback.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
55
</encoder>
66
</appender>
7-
<root level="trace">
7+
<root level="debug">
88
<appender-ref ref="STDOUT"/>
99
</root>
1010
<logger name="org.eclipse.jetty" level="INFO"/>

gradle/libs.versions.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,12 @@ ktor-client-apache = { group = "io.ktor", name = "ktor-client-apache", version.r
6868
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }
6969
ktor-client-content = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
7070
ktor-client-serialization = { group = "io.ktor", name = "ktor-client-serialization", version.ref = "ktor" }
71+
ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" }
7172
ktor-serialization-jackson = { group = "io.ktor", name = "ktor-serialization-jackson", version.ref = "ktor" }
7273
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
7374
ktor-server-content = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }
75+
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
76+
ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors", version.ref = "ktor" }
7477
maven-plugin-annotations = { group = "org.apache.maven.plugin-tools", name = "maven-plugin-annotations", version.ref = "maven-plugin-annotation" }
7578
maven-plugin-api = { group = "org.apache.maven", name = "maven-plugin-api", version.ref = "maven-plugin-api" }
7679
maven-project = { group = "org.apache.maven", name = "maven-project", version.ref = "maven-project" }

servers/graphql-kotlin-ktor-server/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ dependencies {
1010
api(libs.ktor.serialization.jackson)
1111
api(libs.ktor.server.core)
1212
api(libs.ktor.server.content)
13+
api(libs.ktor.server.websockets)
1314
testImplementation(libs.kotlinx.coroutines.test)
1415
testImplementation(libs.ktor.client.content)
16+
testImplementation(libs.ktor.client.websockets)
1517
testImplementation(libs.ktor.server.cio)
1618
testImplementation(libs.ktor.server.test.host)
1719
}

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ import com.expediagroup.graphql.generator.federation.FederatedSchemaGeneratorHoo
3232
import com.expediagroup.graphql.generator.federation.FederatedSimpleTypeResolver
3333
import com.expediagroup.graphql.generator.federation.toFederatedSchema
3434
import com.expediagroup.graphql.generator.internal.state.ClassScanner
35+
import com.expediagroup.graphql.server.execution.DefaultGraphQLSubscriptionExecutor
3536
import com.expediagroup.graphql.server.execution.GraphQLRequestHandler
37+
import com.expediagroup.graphql.server.ktor.subscriptions.KtorGraphQLSubscriptionHandler
38+
import com.expediagroup.graphql.server.ktor.subscriptions.DefaultKtorGraphQLSubscriptionHooks
39+
import com.expediagroup.graphql.server.ktor.subscriptions.graphqlws.KtorGraphQLWebSocketProtocolHandler
40+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
3641
import graphql.execution.AsyncExecutionStrategy
3742
import graphql.execution.AsyncSerialExecutionStrategy
3843
import graphql.execution.instrumentation.ChainedInstrumentation
@@ -90,7 +95,7 @@ class GraphQL(config: GraphQLConfiguration) {
9095
config = schemaConfig,
9196
queries = config.schema.queries.toTopLevelObjects(),
9297
mutations = config.schema.mutations.toTopLevelObjects(),
93-
subscriptions = emptyList(),
98+
subscriptions = config.schema.subscriptions.toTopLevelObjects(),
9499
schemaObject = config.schema.schemaObject?.let { TopLevelObject(it) }
95100
)
96101
} else {
@@ -107,7 +112,7 @@ class GraphQL(config: GraphQLConfiguration) {
107112
gen.generateSchema(
108113
queries = config.schema.queries.toTopLevelObjects(),
109114
mutations = config.schema.mutations.toTopLevelObjects(),
110-
subscriptions = emptyList(),
115+
subscriptions = config.schema.subscriptions.toTopLevelObjects(),
111116
schemaObject = config.schema.schemaObject?.let { TopLevelObject(it) }
112117
)
113118
}
@@ -160,6 +165,17 @@ class GraphQL(config: GraphQLConfiguration) {
160165
)
161166
)
162167

168+
val subscriptionsHandler: KtorGraphQLSubscriptionHandler by lazy {
169+
KtorGraphQLWebSocketProtocolHandler(
170+
subscriptionExecutor = DefaultGraphQLSubscriptionExecutor(
171+
graphQL = engine,
172+
dataLoaderRegistryFactory = config.engine.dataLoaderRegistryFactory,
173+
),
174+
objectMapper = jacksonObjectMapper().apply(config.server.jacksonConfiguration),
175+
subscriptionHooks = DefaultKtorGraphQLSubscriptionHooks(),
176+
)
177+
}
178+
163179
companion object Plugin : BaseApplicationPlugin<Application, GraphQLConfiguration, GraphQL> {
164180
override val key: AttributeKey<GraphQL> = AttributeKey("GraphQL")
165181

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory
2222
import com.expediagroup.graphql.generator.TopLevelNames
2323
import com.expediagroup.graphql.generator.execution.KotlinDataFetcherFactoryProvider
2424
import com.expediagroup.graphql.generator.execution.SimpleKotlinDataFetcherFactoryProvider
25-
import com.expediagroup.graphql.generator.hooks.NoopSchemaGeneratorHooks
25+
import com.expediagroup.graphql.generator.hooks.FlowSubscriptionSchemaGeneratorHooks
2626
import com.expediagroup.graphql.generator.hooks.SchemaGeneratorHooks
2727
import com.expediagroup.graphql.generator.scalars.IDValueUnboxer
2828
import com.expediagroup.graphql.server.Schema
2929
import com.expediagroup.graphql.server.operations.Mutation
3030
import com.expediagroup.graphql.server.operations.Query
31+
import com.expediagroup.graphql.server.operations.Subscription
3132
import com.fasterxml.jackson.databind.ObjectMapper
3233
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
3334
import graphql.execution.DataFetcherExceptionHandler
@@ -116,15 +117,14 @@ class GraphQLConfiguration(config: ApplicationConfig) {
116117
var queries: List<Query> = emptyList()
117118
/** List of GraphQL mutations supported by this server */
118119
var mutations: List<Mutation> = emptyList()
119-
// TODO support subscriptions
120-
// /** List of GraphQL subscriptions supported by this server */
121-
// var subscriptions: List<Subscription> = emptyList()
120+
/** List of GraphQL subscriptions supported by this server */
121+
var subscriptions: List<Subscription> = emptyList()
122122
/** GraphQL schema object with any custom directives */
123123
var schemaObject: Schema? = null
124124
/** The names of the top level objects in the schema, defaults to Query, Mutation and Subscription */
125125
var topLevelNames: TopLevelNames = TopLevelNames()
126126
/** Custom hooks that will be used when generating the schema */
127-
var hooks: SchemaGeneratorHooks = NoopSchemaGeneratorHooks
127+
var hooks: SchemaGeneratorHooks = FlowSubscriptionSchemaGeneratorHooks()
128128
/** Apollo Federation configuration */
129129
val federation: FederationConfiguration = FederationConfiguration(config)
130130
fun federation(federationConfig: FederationConfiguration.() -> Unit) {

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.expediagroup.graphql.server.ktor
1818

1919
import com.expediagroup.graphql.generator.extensions.print
20+
import com.expediagroup.graphql.server.ktor.subscriptions.KtorGraphQLSubscriptionHandler
2021
import com.fasterxml.jackson.databind.ObjectMapper
2122
import io.ktor.http.ContentType
2223
import io.ktor.serialization.jackson.jackson
@@ -29,6 +30,7 @@ import io.ktor.server.routing.Route
2930
import io.ktor.server.routing.application
3031
import io.ktor.server.routing.get
3132
import io.ktor.server.routing.post
33+
import io.ktor.server.websocket.webSocket
3234

3335
/**
3436
* Configures GraphQL GET route
@@ -70,6 +72,27 @@ fun Route.graphQLPostRoute(endpoint: String = "graphql", streamingResponse: Bool
7072
return route
7173
}
7274

75+
/**
76+
* Configures GraphQL subscriptions route
77+
*
78+
* @param endpoint GraphQL server subscriptions endpoint, defaults to 'subscriptions'
79+
* @param handlerOverride Alternative KtorGraphQLSubscriptionHandler to handle subscriptions logic
80+
*/
81+
fun Route.graphQLSubscriptionsRoute(
82+
endpoint: String = "subscriptions",
83+
protocol: String? = null,
84+
handlerOverride: KtorGraphQLSubscriptionHandler? = null,
85+
) {
86+
val handler = handlerOverride ?: run {
87+
val graphQLPlugin = this.application.plugin(GraphQL)
88+
graphQLPlugin.subscriptionsHandler
89+
}
90+
91+
webSocket(path = endpoint, protocol = protocol) {
92+
handler.handle(this)
93+
}
94+
}
95+
7396
/**
7497
* Configures GraphQL SDL route.
7598
*
@@ -88,14 +111,18 @@ fun Route.graphQLSDLRoute(endpoint: String = "sdl"): Route {
88111
*
89112
* @param endpoint GET endpoint that will return instance of GraphiQL IDE, defaults to 'graphiql'
90113
* @param graphQLEndpoint your GraphQL endpoint for processing requests
114+
* @param subscriptionsEndpoint your GraphQL subscriptions endpoint
91115
*/
92-
fun Route.graphiQLRoute(endpoint: String = "graphiql", graphQLEndpoint: String = "graphql"): Route {
116+
fun Route.graphiQLRoute(
117+
endpoint: String = "graphiql",
118+
graphQLEndpoint: String = "graphql",
119+
subscriptionsEndpoint: String = "subscriptions",
120+
): Route {
93121
val contextPath = this.environment?.rootPath
94122
val graphiQL = GraphQL::class.java.classLoader.getResourceAsStream("graphql-graphiql.html")?.bufferedReader()?.use { reader ->
95123
reader.readText()
96124
.replace("\${graphQLEndpoint}", if (contextPath.isNullOrBlank()) graphQLEndpoint else "$contextPath/$graphQLEndpoint")
97-
.replace("\${subscriptionsEndpoint}", if (contextPath.isNullOrBlank()) "subscriptions" else "$contextPath/subscriptions")
98-
// .replace("\${subscriptionsEndpoint}", if (contextPath.isBlank()) config.routing.subscriptions.endpoint else "$contextPath/${config.routing.subscriptions.endpoint}")
125+
.replace("\${subscriptionsEndpoint}", if (contextPath.isNullOrBlank()) subscriptionsEndpoint else "$contextPath/$subscriptionsEndpoint")
99126
} ?: throw IllegalStateException("Unable to load GraphiQL")
100127
return get(endpoint) {
101128
call.respondText(graphiQL, ContentType.Text.Html)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,5 @@ import io.ktor.server.request.ApplicationRequest
2626
class KtorGraphQLServer(
2727
requestParser: KtorGraphQLRequestParser,
2828
contextFactory: KtorGraphQLContextFactory,
29-
requestHandler: GraphQLRequestHandler
29+
requestHandler: GraphQLRequestHandler,
3030
) : GraphQLServer<ApplicationRequest>(requestParser, contextFactory, requestHandler)

0 commit comments

Comments
 (0)