diff --git a/examples/server/ktor-server/build.gradle.kts b/examples/server/ktor-server/build.gradle.kts index c6ee73916e..8b579aea0d 100644 --- a/examples/server/ktor-server/build.gradle.kts +++ b/examples/server/ktor-server/build.gradle.kts @@ -12,8 +12,7 @@ application { } dependencies { - implementation("com.expediagroup", "graphql-kotlin-server") - implementation(libs.ktor.server.core) + implementation("com.expediagroup", "graphql-kotlin-ktor-server") implementation(libs.ktor.server.netty) implementation(libs.logback) implementation(libs.kotlinx.coroutines.jdk8) diff --git a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorGraphQLContextFactory.kt b/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/CustomGraphQLContextFactory.kt similarity index 59% rename from examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorGraphQLContextFactory.kt rename to examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/CustomGraphQLContextFactory.kt index 05924dca50..804fe8b106 100644 --- a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorGraphQLContextFactory.kt +++ b/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/CustomGraphQLContextFactory.kt @@ -16,27 +16,29 @@ package com.expediagroup.graphql.examples.server.ktor -import com.expediagroup.graphql.generator.extensions.toGraphQLContext import com.expediagroup.graphql.examples.server.ktor.schema.models.User -import com.expediagroup.graphql.server.execution.GraphQLContextFactory +import com.expediagroup.graphql.generator.extensions.plus +import com.expediagroup.graphql.server.ktor.DefaultKtorGraphQLContextFactory import io.ktor.server.request.ApplicationRequest import graphql.GraphQLContext /** * Custom logic for how this example app should create its context given the [ApplicationRequest] */ -class KtorGraphQLContextFactory : GraphQLContextFactory { +class CustomGraphQLContextFactory : DefaultKtorGraphQLContextFactory() { override suspend fun generateContext(request: ApplicationRequest): GraphQLContext = - mutableMapOf( - "user" to User( - email = "fake@site.com", - firstName = "Someone", - lastName = "You Don't know", - universityId = 4 - ) - ).also { map -> - request.headers["my-custom-header"]?.let { customHeader -> - map["customHeader"] = customHeader + super.generateContext(request).plus( + mutableMapOf( + "user" to User( + email = "fake@site.com", + firstName = "Someone", + lastName = "You Don't know", + universityId = 4 + ) + ).also { map -> + request.headers["my-custom-header"]?.let { customHeader -> + map["customHeader"] = customHeader + } } - }.toGraphQLContext() + ) } diff --git a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/GraphQLModule.kt b/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/GraphQLModule.kt index 851ab917ec..13a36e31a9 100644 --- a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/GraphQLModule.kt +++ b/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/GraphQLModule.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2023 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,40 +13,42 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.expediagroup.graphql.examples.server.ktor -import com.expediagroup.graphql.generator.extensions.print -import io.ktor.http.ContentType +import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory +import com.expediagroup.graphql.examples.server.ktor.schema.BookQueryService +import com.expediagroup.graphql.examples.server.ktor.schema.CourseQueryService +import com.expediagroup.graphql.examples.server.ktor.schema.HelloQueryService +import com.expediagroup.graphql.examples.server.ktor.schema.LoginMutationService +import com.expediagroup.graphql.examples.server.ktor.schema.UniversityQueryService +import com.expediagroup.graphql.examples.server.ktor.schema.dataloaders.BookDataLoader +import com.expediagroup.graphql.examples.server.ktor.schema.dataloaders.CourseDataLoader +import com.expediagroup.graphql.examples.server.ktor.schema.dataloaders.UniversityDataLoader +import com.expediagroup.graphql.server.ktor.GraphQL import io.ktor.server.application.Application -import io.ktor.server.application.call import io.ktor.server.application.install -import io.ktor.server.response.respondText -import io.ktor.server.routing.Routing -import io.ktor.server.routing.get -import io.ktor.server.routing.post -import io.ktor.server.routing.routing fun Application.graphQLModule() { - install(Routing) - - routing { - post("graphql") { - KtorServer().handle(this.call) + install(GraphQL) { + schema { + packages = listOf("com.expediagroup.graphql.examples.server") + queries = listOf( + HelloQueryService(), + BookQueryService(), + CourseQueryService(), + UniversityQueryService(), + ) + mutations = listOf( + LoginMutationService() + ) } - - get("sdl") { - call.respondText(graphQLSchema.print()) + engine { + dataLoaderRegistryFactory = KotlinDataLoaderRegistryFactory( + UniversityDataLoader, CourseDataLoader, BookDataLoader + ) } - - get("playground") { - this.call.respondText(buildPlaygroundHtml("graphql", "subscriptions"), ContentType.Text.Html) + server { + contextFactory = CustomGraphQLContextFactory() } } } - -private fun buildPlaygroundHtml(graphQLEndpoint: String, subscriptionsEndpoint: String) = - Application::class.java.classLoader.getResource("graphql-playground.html")?.readText() - ?.replace("\${graphQLEndpoint}", graphQLEndpoint) - ?.replace("\${subscriptionsEndpoint}", subscriptionsEndpoint) - ?: throw IllegalStateException("graphql-playground.html cannot be found in the classpath") diff --git a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorGraphQLRequestParser.kt b/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorGraphQLRequestParser.kt deleted file mode 100644 index 44b2d251de..0000000000 --- a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorGraphQLRequestParser.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2022 Expedia, Inc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.expediagroup.graphql.examples.server.ktor - -import com.expediagroup.graphql.server.execution.GraphQLRequestParser -import com.expediagroup.graphql.server.types.GraphQLServerRequest -import com.fasterxml.jackson.databind.ObjectMapper -import io.ktor.server.request.ApplicationRequest -import io.ktor.server.request.receiveText -import java.io.IOException - -/** - * Custom logic for how Ktor parses the incoming [ApplicationRequest] into the [GraphQLServerRequest] - */ -class KtorGraphQLRequestParser( - private val mapper: ObjectMapper -) : GraphQLRequestParser { - - @Suppress("BlockingMethodInNonBlockingContext") - override suspend fun parseRequest(request: ApplicationRequest): GraphQLServerRequest = try { - val rawRequest = request.call.receiveText() - mapper.readValue(rawRequest, GraphQLServerRequest::class.java) - } catch (e: IOException) { - throw IOException("Unable to parse GraphQL payload.") - } -} diff --git a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorServer.kt b/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorServer.kt deleted file mode 100644 index 77c4dd5fe3..0000000000 --- a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorServer.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2022 Expedia, Inc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.expediagroup.graphql.examples.server.ktor - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.http.HttpStatusCode -import io.ktor.server.application.ApplicationCall -import io.ktor.server.response.respond - -/** - * The Ktor specific code to handle incoming [ApplicationCall]s, send them to GraphQL, - * and then format and send a correct response back. - */ -class KtorServer { - - private val mapper = jacksonObjectMapper() - private val ktorGraphQLServer = getGraphQLServer(mapper) - - /** - * Handle incoming Ktor Http requests and send them back to the response methods. - */ - suspend fun handle(applicationCall: ApplicationCall) { - // Execute the query against the schema - val result = ktorGraphQLServer.execute(applicationCall.request) - - if (result != null) { - // write response as json - val json = mapper.writeValueAsString(result) - applicationCall.response.call.respond(json) - } else { - applicationCall.response.call.respond(HttpStatusCode.BadRequest, "Invalid request") - } - } -} diff --git a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/ktorGraphQLSchema.kt b/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/ktorGraphQLSchema.kt deleted file mode 100644 index 7fe8cf8ec5..0000000000 --- a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/ktorGraphQLSchema.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2021 Expedia, Inc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.expediagroup.graphql.examples.server.ktor - -import com.expediagroup.graphql.examples.server.ktor.schema.BookQueryService -import com.expediagroup.graphql.examples.server.ktor.schema.CourseQueryService -import com.expediagroup.graphql.examples.server.ktor.schema.HelloQueryService -import com.expediagroup.graphql.examples.server.ktor.schema.LoginMutationService -import com.expediagroup.graphql.examples.server.ktor.schema.UniversityQueryService -import com.expediagroup.graphql.generator.SchemaGeneratorConfig -import com.expediagroup.graphql.generator.TopLevelObject -import com.expediagroup.graphql.generator.scalars.IDValueUnboxer -import com.expediagroup.graphql.generator.toSchema -import graphql.GraphQL - -/** - * Custom logic for how this Ktor server loads all the queries and configuration to create the [GraphQL] object - * needed to handle incoming requests. In a more enterprise solution you may want to load more things from - * configuration files instead of hardcoding them. - */ -private val config = SchemaGeneratorConfig(supportedPackages = listOf("com.expediagroup.graphql.examples.server.ktor")) -private val queries = listOf( - TopLevelObject(HelloQueryService()), - TopLevelObject(BookQueryService()), - TopLevelObject(CourseQueryService()), - TopLevelObject(UniversityQueryService()) -) -private val mutations = listOf(TopLevelObject(LoginMutationService())) -val graphQLSchema = toSchema(config, queries, mutations) - -fun getGraphQLObject(): GraphQL = GraphQL.newGraphQL(graphQLSchema) - .valueUnboxer(IDValueUnboxer()) - .build() diff --git a/examples/server/ktor-server/src/main/resources/graphql-playground.html b/examples/server/ktor-server/src/main/resources/graphql-playground.html deleted file mode 100644 index 894ff66f6d..0000000000 --- a/examples/server/ktor-server/src/main/resources/graphql-playground.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - GraphQL Playground - - - - - - -
- - -
Loading - GraphQL Playground -
-
- - - - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8878b64e3f..c51f81d8f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,6 +68,8 @@ ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = " ktor-client-content = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-serialization = { group = "io.ktor", name = "ktor-client-serialization", version.ref = "ktor" } ktor-serialization-jackson = { group = "io.ktor", name = "ktor-serialization-jackson", version.ref = "ktor" } +ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } +ktor-server-content = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" } maven-plugin-annotations = { group = "org.apache.maven.plugin-tools", name = "maven-plugin-annotations", version.ref = "maven-plugin-annotation" } maven-plugin-api = { group = "org.apache.maven", name = "maven-plugin-api", version.ref = "maven-plugin-api" } maven-project = { group = "org.apache.maven", name = "maven-project", version.ref = "maven-project" } @@ -91,7 +93,6 @@ kotlinx-benchmark = { group = "org.jetbrains.kotlinx", name = "kotlinx-benchmark kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } -ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" } ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" } ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" } diff --git a/servers/graphql-kotlin-ktor-server/build.gradle.kts b/servers/graphql-kotlin-ktor-server/build.gradle.kts new file mode 100644 index 0000000000..760c6af1b0 --- /dev/null +++ b/servers/graphql-kotlin-ktor-server/build.gradle.kts @@ -0,0 +1,36 @@ +description = "GraphQL plugin for Ktor servers" + +plugins { + id("com.expediagroup.graphql.conventions") +} + +dependencies { + api(projects.graphqlKotlinServer) + api(projects.graphqlKotlinFederation) + api(libs.ktor.serialization.jackson) + api(libs.ktor.server.core) + api(libs.ktor.server.content) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.ktor.client.content) + testImplementation(libs.ktor.server.cio) + testImplementation(libs.ktor.server.test.host) +} + +tasks { + jacocoTestCoverageVerification { + violationRules { + rule { + limit { + counter = "INSTRUCTION" + value = "COVEREDRATIO" + minimum = "0.65".toBigDecimal() + } + limit { + counter = "BRANCH" + value = "COVEREDRATIO" + minimum = "0.45".toBigDecimal() + } + } + } + } +} diff --git a/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/GraphQL.kt b/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/GraphQL.kt new file mode 100644 index 0000000000..5d69307742 --- /dev/null +++ b/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/GraphQL.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2023 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.server.ktor + +import com.apollographql.federation.graphqljava.tracing.FederatedTracingInstrumentation +import com.expediagroup.graphql.apq.provider.AutomaticPersistedQueriesProvider +import com.expediagroup.graphql.dataloader.instrumentation.level.DataLoaderLevelDispatchedInstrumentation +import com.expediagroup.graphql.dataloader.instrumentation.syncexhaustion.DataLoaderSyncExecutionExhaustedInstrumentation +import com.expediagroup.graphql.generator.SchemaGeneratorConfig +import com.expediagroup.graphql.generator.TopLevelObject +import com.expediagroup.graphql.generator.execution.FlowSubscriptionExecutionStrategy +import com.expediagroup.graphql.generator.extensions.print +import com.expediagroup.graphql.generator.federation.FederatedSchemaGeneratorConfig +import com.expediagroup.graphql.generator.federation.FederatedSchemaGeneratorHooks +import com.expediagroup.graphql.generator.federation.toFederatedSchema +import com.expediagroup.graphql.generator.toSchema +import com.expediagroup.graphql.server.execution.GraphQLRequestHandler +import graphql.GraphQL as GraphQLEngine +import graphql.execution.AsyncExecutionStrategy +import graphql.execution.AsyncSerialExecutionStrategy +import graphql.execution.instrumentation.ChainedInstrumentation +import graphql.execution.instrumentation.Instrumentation +import graphql.execution.preparsed.PreparsedDocumentProvider +import graphql.schema.GraphQLSchema +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.jackson.jackson +import io.ktor.server.application.Application +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.BaseApplicationPlugin +import io.ktor.server.application.call +import io.ktor.server.application.install +import io.ktor.server.application.log +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.response.respond +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import io.ktor.util.AttributeKey + +class GraphQL(config: GraphQLConfiguration) { + + private val schema: GraphQLSchema = if (config.schema.federation.enabled) { + val schemaConfig = FederatedSchemaGeneratorConfig( + supportedPackages = config.schema.packages ?: throw IllegalStateException("Missing required configuration - packages property is required"), + topLevelNames = config.schema.topLevelNames, + hooks = config.schema.hooks as? FederatedSchemaGeneratorHooks ?: throw IllegalStateException("Non federated schema generator hooks were specified when generating federated schema"), + dataFetcherFactoryProvider = config.engine.dataFetcherFactoryProvider, + introspectionEnabled = config.engine.introspection.enabled + ) + toFederatedSchema( + config = schemaConfig, + queries = config.schema.queries.toTopLevelObjects(), + mutations = config.schema.mutations.toTopLevelObjects(), + subscriptions = emptyList(), + schemaObject = config.schema.schemaObject?.let { TopLevelObject(it) } + ) + } else { + val schemaConfig = SchemaGeneratorConfig( + supportedPackages = config.schema.packages ?: throw IllegalStateException("Missing required configuration - packages property is required"), + topLevelNames = config.schema.topLevelNames, + hooks = config.schema.hooks, + dataFetcherFactoryProvider = config.engine.dataFetcherFactoryProvider, + introspectionEnabled = config.engine.introspection.enabled + ) + toSchema( + config = schemaConfig, + queries = config.schema.queries.toTopLevelObjects(), + mutations = config.schema.mutations.toTopLevelObjects(), + subscriptions = emptyList(), + schemaObject = config.schema.schemaObject?.let { TopLevelObject(it) } + ) + } + + val engine: GraphQLEngine = GraphQLEngine.newGraphQL(schema) + .queryExecutionStrategy(AsyncExecutionStrategy(config.engine.exceptionHandler)) + .mutationExecutionStrategy(AsyncSerialExecutionStrategy(config.engine.exceptionHandler)) + .subscriptionExecutionStrategy(FlowSubscriptionExecutionStrategy(config.engine.exceptionHandler)) + .valueUnboxer(config.engine.idValueUnboxer) + .also { builder -> + config.engine.executionIdProvider?.let { builder.executionIdProvider(it) } + + var preparsedDocumentProvider: PreparsedDocumentProvider? = config.engine.preparsedDocumentProvider + if (config.engine.automaticPersistedQueries.enabled) { + if (preparsedDocumentProvider != null) { + throw IllegalStateException("Custom prepared document provider and APQ specified - disable APQ or don't specify the provider") + } else { + preparsedDocumentProvider = AutomaticPersistedQueriesProvider(config.engine.automaticPersistedQueries.cache) + } + } + preparsedDocumentProvider?.let { builder.preparsedDocumentProvider(it) } + + val instrumentations = mutableListOf() + if (config.engine.batching.enabled) { + builder.doNotAddDefaultInstrumentations() + instrumentations.add( + when (config.engine.batching.strategy) { + GraphQLConfiguration.BatchingStrategy.LEVEL_DISPATCHED -> DataLoaderLevelDispatchedInstrumentation() + GraphQLConfiguration.BatchingStrategy.SYNC_EXHAUSTION -> DataLoaderSyncExecutionExhaustedInstrumentation() + } + ) + } + if (config.schema.federation.enabled && config.schema.federation.tracing.enabled) { + instrumentations.add(FederatedTracingInstrumentation(FederatedTracingInstrumentation.Options(config.schema.federation.tracing.debug))) + } + + instrumentations.addAll(config.engine.instrumentations) + builder.instrumentation(ChainedInstrumentation(instrumentations)) + } + .build() + + // TODO cannot override the request handler/server as it requires access to graphql engine + val server: KtorGraphQLServer = KtorGraphQLServer( + requestParser = config.server.requestParser, + contextFactory = config.server.contextFactory, + requestHandler = GraphQLRequestHandler( + graphQL = engine, + dataLoaderRegistryFactory = config.engine.dataLoaderRegistryFactory + ) + ) + + companion object Plugin : BaseApplicationPlugin { + override val key: AttributeKey = AttributeKey("GraphQL") + + override fun install(pipeline: Application, configure: GraphQLConfiguration.() -> Unit): GraphQL { + val config = GraphQLConfiguration(pipeline.environment.config).apply(configure) + val plugin = GraphQL(config) + + if (config.tools.sdl.printAtStartup) { + pipeline.log.info("\n${plugin.schema.print()}") + } + + // install content negotiation + pipeline.install(ContentNegotiation) { + jackson(streamRequestBody = config.server.streamingResponse) { + apply(config.server.jacksonConfiguration) + } + } + // install routing + pipeline.routing { + get(config.routes.endpoint) { + plugin.server.executeRequest(call) + } + post(config.routes.endpoint) { + plugin.server.executeRequest(call) + } + + if (config.tools.sdl.enabled) { + val sdl = plugin.schema.print() + get(config.tools.sdl.endpoint) { + call.respondText(text = sdl) + } + } + if (config.tools.graphiql.enabled) { + val contextPath = pipeline.environment.rootPath + val graphiQL = GraphQL::class.java.classLoader.getResourceAsStream("graphql-graphiql.html")?.bufferedReader()?.use { reader -> + reader.readText() + .replace("\${graphQLEndpoint}", if (contextPath.isBlank()) config.routes.endpoint else "$contextPath/${config.routes.endpoint}") + .replace("\${subscriptionsEndpoint}", if (contextPath.isBlank()) "subscriptions" else "$contextPath/subscriptions") +// ?.replace("\${subscriptionsEndpoint}", if (contextPath.isBlank()) config.routing.subscriptions.endpoint else "$contextPath/${config.routing.subscriptions.endpoint}") + } ?: throw IllegalStateException("Unable to load GraphiQL") + get(config.tools.graphiql.endpoint) { + call.respondText(graphiQL, ContentType.Text.Html) + } + } + } + return plugin + } + } +} + +internal fun List.toTopLevelObjects(): List = this.map { + TopLevelObject(it) +} + +internal suspend inline fun KtorGraphQLServer.executeRequest(call: ApplicationCall) = try { + execute(call.request)?.let { + call.respond(it) + } ?: call.respond(HttpStatusCode.BadRequest) +} catch (e: UnsupportedOperationException) { + call.respond(HttpStatusCode.MethodNotAllowed) +} catch (e: Exception) { + call.respond(HttpStatusCode.BadRequest) +} diff --git a/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/GraphQLConfiguration.kt b/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/GraphQLConfiguration.kt new file mode 100644 index 0000000000..70d2fb3ba8 --- /dev/null +++ b/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/GraphQLConfiguration.kt @@ -0,0 +1,348 @@ +/* + * Copyright 2023 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.server.ktor + +import com.expediagroup.graphql.apq.cache.AutomaticPersistedQueriesCache +import com.expediagroup.graphql.apq.cache.DefaultAutomaticPersistedQueriesCache +import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory +import com.expediagroup.graphql.generator.TopLevelNames +import com.expediagroup.graphql.generator.execution.KotlinDataFetcherFactoryProvider +import com.expediagroup.graphql.generator.execution.SimpleKotlinDataFetcherFactoryProvider +import com.expediagroup.graphql.generator.hooks.NoopSchemaGeneratorHooks +import com.expediagroup.graphql.generator.hooks.SchemaGeneratorHooks +import com.expediagroup.graphql.generator.scalars.IDValueUnboxer +import com.expediagroup.graphql.server.Schema +import com.expediagroup.graphql.server.operations.Mutation +import com.expediagroup.graphql.server.operations.Query +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import graphql.execution.DataFetcherExceptionHandler +import graphql.execution.ExecutionIdProvider +import graphql.execution.SimpleDataFetcherExceptionHandler +import graphql.execution.instrumentation.Instrumentation +import graphql.execution.preparsed.PreparsedDocumentProvider +import io.ktor.server.config.ApplicationConfig +import io.ktor.server.config.tryGetString +import io.ktor.server.config.tryGetStringList + +/** + * Configuration properties that define supported GraphQL configuration options. + * + * ``` + * schema { + * packages = listOf("com.example") + * queries = listOf() // non-federated schemas, require at least a single query + * mutations = listOf() + * subscriptions = listOf() + * schemaObject = null + * hooks = NoopSchemaGeneratorHooks + * topLevelNames = TopLevelNames() + * federation { + * enabled = false + * tracing { + * enabled = true + * debug = false + * } + * } + * } + * engine { + * automaticPersistedQueries { + * enabled = false + * } + * batching { + * enabled = true + * strategy = SYNC_EXHAUSTION + * } + * introspection { + * enabled = true + * } + * dataFetcherFactoryProvider = SimpleKotlinDataFetcherFactoryProvider() + * dataLoaderRegistryFactory = KotlinDataLoaderRegistryFactory() + * exceptionHandler = SimpleDataFetcherExceptionHandler() + * executionIdProvider = null + * idValueUnboxer = IDValueUnboxer() + * instrumentations = emptyList() + * preparsedDocumentProvider = null + * } + * server { + * contextFactory = DefaultKtorGraphQLContextFactory() + * jacksonConfiguration = { } + * requestParser = KtorGraphQLRequestParser(jacksonObjectMapper()) + * streamingResponse = true + * } + * routes { + * endpoint = "graphql" + * subscriptions { + * endpoint = "subscriptions" + * keepAliveInterval = null + * } + * } + * tools { + * graphiql { + * enabled = true + * endpoint = "graphiql" + * } + * sdl { + * enabled = true + * endpoint = "sdl" + * printAtStartup = true + * } + * } + * ``` + */ +class GraphQLConfiguration(config: ApplicationConfig) { + /** Configure schema generation process */ + val schema: SchemaConfiguration = SchemaConfiguration(config) + fun schema(schemaConfig: SchemaConfiguration.() -> Unit) { + schema.apply(schemaConfig) + } + + /** Configure GraphQL engine that will be processing the requests */ + val engine: EngineConfiguration = EngineConfiguration(config) + fun engine(engineConfig: EngineConfiguration.() -> Unit) { + engine.apply(engineConfig) + } + + /** Configure GraphQL server */ + val server: ServerConfiguration = ServerConfiguration(config) + fun server(serverConfig: ServerConfiguration.() -> Unit) { + server.apply(serverConfig) + } + + /** Configure GraphQL routes */ + val routes: RoutingConfiguration = RoutingConfiguration(config) + fun routes(routingConfig: RoutingConfiguration.() -> Unit) { + routes.apply(routingConfig) + } + + /** Configure GraphQL tools */ + val tools: ToolsConfiguration = ToolsConfiguration(config) + fun tools(toolsConfig: ToolsConfiguration.() -> Unit) { + tools.apply(toolsConfig) + } + + /** + * Configuration properties that control schema generation process. + */ + class SchemaConfiguration(config: ApplicationConfig) { + /** List of supported packages that can contain GraphQL schema type definitions */ + var packages: List? = config.tryGetStringList("graphql.schema.packages") + /** List of GraphQL queries supported by this server */ + var queries: List = emptyList() + /** List of GraphQL mutations supported by this server */ + var mutations: List = emptyList() + // TODO support subscriptions +// /** List of GraphQL subscriptions supported by this server */ +// var subscriptions: List = emptyList() + /** GraphQL schema object with any custom directives */ + var schemaObject: Schema? = null + /** The names of the top level objects in the schema, defaults to Query, Mutation and Subscription */ + var topLevelNames: TopLevelNames = TopLevelNames() + /** Custom hooks that will be used when generating the schema */ + var hooks: SchemaGeneratorHooks = NoopSchemaGeneratorHooks + /** Apollo Federation configuration */ + val federation: FederationConfiguration = FederationConfiguration(config) + fun federation(federationConfig: FederationConfiguration.() -> Unit) { + federation.apply(federationConfig) + } + } + + /** + * Apollo Federation configuration properties. + */ + class FederationConfiguration(config: ApplicationConfig) { + /** + * Boolean flag indicating whether to generate federated GraphQL model + */ + var enabled: Boolean = config.tryGetString("graphql.schema.federation.enabled").toBoolean() + + /** + * Federation tracing config + */ + var tracing: FederationTracingConfiguration = FederationTracingConfiguration(config) + fun tracing(tracingConfig: FederationTracingConfiguration.() -> Unit) { + tracing.apply(tracingConfig) + } + } + + /** + * Apollo Federation tracing configuration properties + */ + class FederationTracingConfiguration(config: ApplicationConfig) { + /** + * Flag to enable or disable field tracing for the Apollo Gateway. + * Default is true as this is only used if the parent config is enabled. + */ + var enabled: Boolean = config.tryGetString("graphql.schema.federation.tracing.enabled")?.toBoolean() ?: true + + /** + * Flag to enable or disable debug logging + */ + var debug: Boolean = config.tryGetString("graphql.schema.federation.tracing.enabled").toBoolean() + } + + /** Configuration of a GraphQL engine */ + class EngineConfiguration(config: ApplicationConfig) { + /** + * Configuration for automatic persisted queries support. + * + * Warning: If you need custom preparsed document provider, do not configure APQ settings. + */ + val automaticPersistedQueries: AutomaticPersistedQueriesConfiguration = AutomaticPersistedQueriesConfiguration(config) + fun automaticPersistedQueries(apqConfig: AutomaticPersistedQueriesConfiguration.() -> Unit) { + automaticPersistedQueries.apply(apqConfig) + } + /** Automatic batching configuration */ + var batching: BatchingConfiguration = BatchingConfiguration(config) + fun batching(batchingConfig: BatchingConfiguration.() -> Unit) { + batching.apply(batchingConfig) + } + /** Introspection configuration */ + val introspection: IntrospectionConfiguration = IntrospectionConfiguration(config) + fun introspection(introspectionConfig: IntrospectionConfiguration.() -> Unit) { + introspection.apply(introspectionConfig) + } + /** Factory for creating function and property data fetcher factories. */ + var dataFetcherFactoryProvider: KotlinDataFetcherFactoryProvider = SimpleKotlinDataFetcherFactoryProvider() + /** Factory for creating data loader registry */ + var dataLoaderRegistryFactory: KotlinDataLoaderRegistryFactory = KotlinDataLoaderRegistryFactory() + /** GraphQL exception handler */ + var exceptionHandler: DataFetcherExceptionHandler = SimpleDataFetcherExceptionHandler() + /** Execution ID provider */ + var executionIdProvider: ExecutionIdProvider? = null + /** ID value class unboxer */ + var idValueUnboxer: IDValueUnboxer = IDValueUnboxer() + /** List of instrumentations */ + var instrumentations: List = emptyList() + /** + * Preparsed document provider that allows for safe listing and/or document caching. + * + * Warning: If using APQ auto configuration settings, preparsed document provider should not be set. + */ + var preparsedDocumentProvider: PreparsedDocumentProvider? = null + } + + /** + * Introspection configuration properties. + */ + class IntrospectionConfiguration(config: ApplicationConfig) { + /** Boolean flag indicating whether introspection queries are enabled. */ + var enabled: Boolean = config.tryGetString("graphql.engine.introspection.enabled")?.toBoolean() ?: true + } + + /** + * Approaches for batching transactions of a set of GraphQL Operations. + */ + enum class BatchingStrategy { LEVEL_DISPATCHED, SYNC_EXHAUSTION } + + /** + * Batching configuration properties. + */ + class BatchingConfiguration(config: ApplicationConfig) { + /** Boolean flag to enable or disable batching for a set of GraphQL Operations. */ + var enabled: Boolean = config.tryGetString("graphql.engine.batching.enabled").toBoolean() + + /** configure the [BatchingStrategy] that will be used when batching is enabled for a set of GraphQL Operations. */ + var strategy: BatchingStrategy = config.tryGetString("graphql.engine.batching.strategy").toBatchingStrategy() + } + + /** + * Configuration for setting up automatic persisted query support. + */ + class AutomaticPersistedQueriesConfiguration(config: ApplicationConfig) { + /** Boolean flag to enable or disable Automatic Persisted Queries. */ + var enabled: Boolean = config.tryGetString("graphql.engine.automaticPersistedQueries.enabled").toBoolean() + /** APQ query cache */ + var cache: AutomaticPersistedQueriesCache = DefaultAutomaticPersistedQueriesCache() + } + + /** Configuration for configuring GraphQL server */ + class ServerConfiguration(config: ApplicationConfig) { + // TODO support custom servers/request handlers + /** Custom GraphQL context factory */ + var contextFactory: KtorGraphQLContextFactory = DefaultKtorGraphQLContextFactory() + /** Custom Jackson ObjectMapper configuration */ + var jacksonConfiguration: ObjectMapper.() -> Unit = {} + /** Custom request parser */ + var requestParser: KtorGraphQLRequestParser = KtorGraphQLRequestParser(jacksonObjectMapper().apply(jacksonConfiguration)) + /** 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. */ + var streamingResponse: Boolean = config.tryGetString("graphql.server.streamingResponse")?.toBoolean() ?: true + } + + /** GraphQL routes configuration */ + class RoutingConfiguration(config: ApplicationConfig) { + /** GraphQL server endpoint, defaults to 'graphql' */ + var endpoint: String = config.tryGetString("graphql.routes.endpoint") ?: "graphql" + // TODO support subscriptions +// /** GraphQL server subscriptions endpoint, defaults to 'subscriptions' */ +// var subscriptions: SubscriptionConfiguration = SubscriptionConfiguration(config) + } + + /** + * GraphQL subscription configuration properties. + */ + class SubscriptionConfiguration(config: ApplicationConfig) { + /** GraphQL subscriptions endpoint, defaults to 'subscriptions' */ + var endpoint: String = config.tryGetString("graphql.routing.subscriptions.endpoint") ?: "subscriptions" + + /** Keep the websocket alive and send a message to the client every interval in ms. Default to not sending messages */ + var keepAliveInterval: Long? = config.tryGetString("graphql.routing.subscriptions.keepAliveInterval")?.toLongOrNull() + } + + /** Configuration for various GraphhQL tools*/ + class ToolsConfiguration(config: ApplicationConfig) { + /** GraphiQL IDE configuration */ + val graphiql: GraphiQLConfiguration = GraphiQLConfiguration(config) + fun graphiql(graphiqlConfig: GraphiQLConfiguration.() -> Unit) { + graphiql.apply(graphiqlConfig) + } + + /** SDL endpoint configuration */ + val sdl: SDLConfiguration = SDLConfiguration(config) + fun sdl(sdlConfig: SDLConfiguration.() -> Unit) { + sdl.apply(sdlConfig) + } + } + + /** + * GraphiQL configuration properties. + */ + class GraphiQLConfiguration(config: ApplicationConfig) { + /** Boolean flag indicating whether to enabled GraphiQL GraphQL IDE */ + var enabled: Boolean = config.tryGetString("graphql.tools.graphiql.enabled")?.toBoolean() ?: true + + /** GraphiQL GraphQL IDE endpoint, defaults to 'graphiql' */ + var endpoint: String = config.tryGetString("graphql.tools.graphiql.endpoint") ?: "graphiql" + } + + /** + * SDL endpoint configuration properties. + */ + class SDLConfiguration(config: ApplicationConfig) { + /** Boolean flag indicating whether SDL endpoint is enabled */ + var enabled: Boolean = config.tryGetString("graphql.tools.sdl.enabled")?.toBoolean() ?: true + + /** GraphQL SDL endpoint */ + var endpoint: String = config.tryGetString("graphql.tools.sdl.endpoint") ?: "sdl" + + /** Boolean flag indicating whether to print the schema after generator creates it */ + var printAtStartup: Boolean = config.tryGetString("graphql.tools.sdl.print").toBoolean() + } +} + +private fun String?.toBatchingStrategy(): GraphQLConfiguration.BatchingStrategy = + GraphQLConfiguration.BatchingStrategy.values().firstOrNull { strategy -> strategy.name == this } ?: GraphQLConfiguration.BatchingStrategy.LEVEL_DISPATCHED diff --git a/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/KtorGraphQLContextFactory.kt b/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/KtorGraphQLContextFactory.kt new file mode 100644 index 0000000000..30400123e8 --- /dev/null +++ b/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/KtorGraphQLContextFactory.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.server.ktor + +import com.apollographql.federation.graphqljava.tracing.FederatedTracingInstrumentation.FEDERATED_TRACING_HEADER_NAME +import com.expediagroup.graphql.generator.extensions.toGraphQLContext +import com.expediagroup.graphql.server.execution.GraphQLContextFactory +import io.ktor.server.request.ApplicationRequest +import graphql.GraphQLContext +import io.ktor.server.request.header + +abstract class KtorGraphQLContextFactory : GraphQLContextFactory + +/** + * Basic implementation of [KtorGraphQLContextFactory] that populates Apollo tracing header. + */ +open class DefaultKtorGraphQLContextFactory : KtorGraphQLContextFactory() { + override suspend fun generateContext(request: ApplicationRequest): GraphQLContext = + mutableMapOf().also { map -> + request.header(FEDERATED_TRACING_HEADER_NAME)?.let { headerValue -> + map[FEDERATED_TRACING_HEADER_NAME] = headerValue + } + }.toGraphQLContext() +} diff --git a/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/KtorGraphQLRequestParser.kt b/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/KtorGraphQLRequestParser.kt new file mode 100644 index 0000000000..3d80d1d7d0 --- /dev/null +++ b/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/KtorGraphQLRequestParser.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2023 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.server.ktor + +import com.expediagroup.graphql.server.execution.GraphQLRequestParser +import com.expediagroup.graphql.server.types.GraphQLRequest +import com.expediagroup.graphql.server.types.GraphQLServerRequest +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.type.MapType +import com.fasterxml.jackson.databind.type.TypeFactory +import io.ktor.http.HttpMethod +import io.ktor.server.request.ApplicationRequest +import io.ktor.server.request.receiveText +import java.io.IOException + +internal const val REQUEST_PARAM_QUERY = "query" +internal const val REQUEST_PARAM_OPERATION_NAME = "operationName" +internal const val REQUEST_PARAM_VARIABLES = "variables" + +/** + * Custom logic for how Ktor parses the incoming [ApplicationRequest] into the [GraphQLServerRequest] + */ +class KtorGraphQLRequestParser( + private val mapper: ObjectMapper +) : GraphQLRequestParser { + + private val mapTypeReference: MapType = TypeFactory.defaultInstance().constructMapType(HashMap::class.java, String::class.java, Any::class.java) +// private val graphQLContentType: ContentType = ContentType.parse("application/graphql-response+json") + + override suspend fun parseRequest(request: ApplicationRequest): GraphQLServerRequest? = when (request.local.method) { + HttpMethod.Get -> parseGetRequest(request) + HttpMethod.Post -> parsePostRequest(request) + else -> null + } + + private fun parseGetRequest(request: ApplicationRequest): GraphQLServerRequest? { + val query = request.queryParameters[REQUEST_PARAM_QUERY] ?: throw IllegalStateException("Invalid HTTP request - GET request has to specify query parameter") + if (query.startsWith("mutation ") || query.startsWith("subscription ")) { + throw UnsupportedOperationException("Invalid GraphQL operation - only queries are supported for GET requests") + } + val operationName: String? = request.queryParameters[REQUEST_PARAM_OPERATION_NAME] + val variables: String? = request.queryParameters[REQUEST_PARAM_VARIABLES] + val graphQLVariables: Map? = variables?.let { + mapper.readValue(it, mapTypeReference) + } + return GraphQLRequest(query = query, operationName = operationName, variables = graphQLVariables) + } + + private suspend fun parsePostRequest(request: ApplicationRequest): GraphQLServerRequest? = try { + val rawRequest = request.call.receiveText() + mapper.readValue(rawRequest, GraphQLServerRequest::class.java) + } catch (e: IOException) { + throw IllegalStateException("Invalid HTTP request - unable to parse GraphQL request from POST payload") + } +} diff --git a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorGraphQLServer.kt b/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/KtorGraphQLServer.kt similarity index 52% rename from examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorGraphQLServer.kt rename to servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/KtorGraphQLServer.kt index 00b086600d..484270d7f4 100644 --- a/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorGraphQLServer.kt +++ b/servers/graphql-kotlin-ktor-server/src/main/kotlin/com/expediagroup/graphql/server/ktor/KtorGraphQLServer.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2023 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,15 +14,10 @@ * limitations under the License. */ -package com.expediagroup.graphql.examples.server.ktor +package com.expediagroup.graphql.server.ktor -import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory -import com.expediagroup.graphql.examples.server.ktor.schema.dataloaders.BookDataLoader -import com.expediagroup.graphql.examples.server.ktor.schema.dataloaders.CourseDataLoader -import com.expediagroup.graphql.examples.server.ktor.schema.dataloaders.UniversityDataLoader import com.expediagroup.graphql.server.execution.GraphQLRequestHandler import com.expediagroup.graphql.server.execution.GraphQLServer -import com.fasterxml.jackson.databind.ObjectMapper import io.ktor.server.request.ApplicationRequest /** @@ -34,15 +29,3 @@ class KtorGraphQLServer( contextFactory: KtorGraphQLContextFactory, requestHandler: GraphQLRequestHandler ) : GraphQLServer(requestParser, contextFactory, requestHandler) - -fun getGraphQLServer(mapper: ObjectMapper): KtorGraphQLServer { - val dataLoaderRegistryFactory = KotlinDataLoaderRegistryFactory( - UniversityDataLoader, CourseDataLoader, BookDataLoader - ) - val requestParser = KtorGraphQLRequestParser(mapper) - val contextFactory = KtorGraphQLContextFactory() - val graphQL = getGraphQLObject() - val requestHandler = GraphQLRequestHandler(graphQL, dataLoaderRegistryFactory) - - return KtorGraphQLServer(requestParser, contextFactory, requestHandler) -} diff --git a/servers/graphql-kotlin-ktor-server/src/main/resources/graphql-graphiql.html b/servers/graphql-kotlin-ktor-server/src/main/resources/graphql-graphiql.html new file mode 100644 index 0000000000..51ced094e2 --- /dev/null +++ b/servers/graphql-kotlin-ktor-server/src/main/resources/graphql-graphiql.html @@ -0,0 +1,145 @@ + + + + + + + + GraphiQL + + + + + + + + +
Loading...
+ + + + + + + + + + + diff --git a/servers/graphql-kotlin-ktor-server/src/test/kotlin/com/expediagroup/graphql/server/ktor/GraphQLPluginConfigurationTest.kt b/servers/graphql-kotlin-ktor-server/src/test/kotlin/com/expediagroup/graphql/server/ktor/GraphQLPluginConfigurationTest.kt new file mode 100644 index 0000000000..5436997396 --- /dev/null +++ b/servers/graphql-kotlin-ktor-server/src/test/kotlin/com/expediagroup/graphql/server/ktor/GraphQLPluginConfigurationTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2023 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.server.ktor + +import com.expediagroup.graphql.generator.exceptions.EmptyQueryTypeException +import com.expediagroup.graphql.server.operations.Query +import graphql.execution.preparsed.NoOpPreparsedDocumentProvider +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.cio.CIO +import io.ktor.server.engine.embeddedServer +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +class GraphQLPluginConfigurationTest { + + @Test + fun `verify exception will be thrown if packages properties is missing`() { + assertFailsWith { + embeddedServer(CIO, port = 0, module = Application::missingPackageGraphQLModule).start(wait = true) + } + } + + @Test + fun `verify exception will be thrown if no queries are specified`() { + assertFailsWith { + embeddedServer(CIO, port = 0, module = Application::missingQueriesGraphQLModule).start(wait = true) + } + } + + @Test + fun `verify exception will be thrown when generating federated schema without hooks`() { + assertFailsWith { + embeddedServer(CIO, port = 0, module = Application::missingFederatedHooksGraphQLModule).start(wait = true) + } + } + + @Test + fun `verify exception will be thrown if preparsed document provider and APQs are configured`() { + assertFailsWith { + embeddedServer(CIO, port = 0, module = Application::misconfiguredAPQGraphQLModule).start(wait = true) + } + } +} + +class ConfigurationTestQuery : Query { + fun foo(): String = TODO() +} + +fun Application.missingPackageGraphQLModule() { + install(GraphQL) { + schema { + queries = listOf( + ConfigurationTestQuery(), + ) + } + } +} + +fun Application.missingQueriesGraphQLModule() { + install(GraphQL) { + schema { + packages = listOf("com.expediagroup.graphql.server.ktor") + } + } +} + +fun Application.missingFederatedHooksGraphQLModule() { + install(GraphQL) { + schema { + packages = listOf("com.expediagroup.graphql.server.ktor") + queries = listOf( + ConfigurationTestQuery(), + ) + federation { + enabled = true + tracing { + enabled = true + debug = true + } + } + } + } +} + +fun Application.misconfiguredAPQGraphQLModule() { + install(GraphQL) { + schema { + packages = listOf("com.expediagroup.graphql.server.ktor") + queries = listOf( + ConfigurationTestQuery(), + ) + } + engine { + preparsedDocumentProvider = NoOpPreparsedDocumentProvider() + automaticPersistedQueries { + enabled = true + } + } + } +} diff --git a/servers/graphql-kotlin-ktor-server/src/test/kotlin/com/expediagroup/graphql/server/ktor/GraphQLPluginTest.kt b/servers/graphql-kotlin-ktor-server/src/test/kotlin/com/expediagroup/graphql/server/ktor/GraphQLPluginTest.kt new file mode 100644 index 0000000000..4075b56eb9 --- /dev/null +++ b/servers/graphql-kotlin-ktor-server/src/test/kotlin/com/expediagroup/graphql/server/ktor/GraphQLPluginTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2023 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.server.ktor + +import com.expediagroup.graphql.server.operations.Query +import com.expediagroup.graphql.server.types.GraphQLRequest +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.jackson.jackson +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.testing.testApplication +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class GraphQLPluginTest { + + class TestQuery : Query { + fun hello(name: String?): String = if (name == null) { + "Hello World" + } else { + "Hello $name" + } + } + + @Test + fun `SDL route test`() { + val expectedSchema = """ + schema { + query: Query + } + + "Marks the field, argument, input field or enum value as deprecated" + directive @deprecated( + "The reason for the deprecation" + reason: String = "No longer supported" + ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION + + "Directs the executor to include this field or fragment only when the `if` argument is true" + directive @include( + "Included when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + "Directs the executor to skip this field or fragment when the `if` argument is true." + directive @skip( + "Skipped when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + "Exposes a URL that specifies the behaviour of this scalar." + directive @specifiedBy( + "The URL that specifies the behaviour of this scalar." + url: String! + ) on SCALAR + + type Query { + hello(name: String): String! + } + """.trimIndent() + testApplication { + val response = client.get("/sdl") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(expectedSchema, response.bodyAsText().trim()) + } + } + + @Test + fun `server should handle valid GET requests`() { + testApplication { + val response = client.get("/graphql") { + parameter("query", "query HelloQuery(\$name: String){ hello(name: \$name) }") + parameter("operationName", "HelloQuery") + parameter("variables", """{"name":"junit"}""") + } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("""{"data":{"hello":"Hello junit"}}""", response.bodyAsText().trim()) + } + } + + @Test + fun `server should return Method Not Allowed for Mutation GET requests`() { + testApplication { + val response = client.get("/graphql") { + parameter("query", "mutation { foo }") + } + assertEquals(HttpStatusCode.MethodNotAllowed, response.status) + } + } + + @Test + fun `server should return Bad Request for invalid GET requests`() { + testApplication { + val response = client.get("/graphql") + assertEquals(HttpStatusCode.BadRequest, response.status) + } + } + + @Test + fun `server should handle valid POST requests`() { + testApplication { + val client = createClient { + install(ContentNegotiation) { + jackson() + } + } + val response = client.post("/graphql") { + contentType(ContentType.Application.Json) + setBody(GraphQLRequest(query = "query HelloQuery(\$name: String){ hello(name: \$name) }", operationName = "HelloQuery", variables = mapOf("name" to "junit"))) + } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("""{"data":{"hello":"Hello junit"}}""", response.bodyAsText().trim()) + } + } + + @Test + fun `server should return Bad Request for invalid POST requests`() { + testApplication { + val response = client.post("/graphql") + assertEquals(HttpStatusCode.BadRequest, response.status) + } + } +} + +fun Application.testGraphQLModule() { + install(GraphQL) { + schema { + // packages property is read from application.conf + queries = listOf( + GraphQLPluginTest.TestQuery(), + ) + } + } +} diff --git a/servers/graphql-kotlin-ktor-server/src/test/kotlin/com/expediagroup/graphql/server/ktor/KtorGraphQLRequestParserTest.kt b/servers/graphql-kotlin-ktor-server/src/test/kotlin/com/expediagroup/graphql/server/ktor/KtorGraphQLRequestParserTest.kt new file mode 100644 index 0000000000..39e3e7c212 --- /dev/null +++ b/servers/graphql-kotlin-ktor-server/src/test/kotlin/com/expediagroup/graphql/server/ktor/KtorGraphQLRequestParserTest.kt @@ -0,0 +1,145 @@ +package com.expediagroup.graphql.server.ktor + +import com.expediagroup.graphql.server.types.GraphQLBatchRequest +import com.expediagroup.graphql.server.types.GraphQLRequest +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.ktor.http.HttpMethod +import io.ktor.server.request.ApplicationRequest +import io.ktor.server.testing.TestApplicationRequest +import io.ktor.utils.io.ByteReadChannel +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +class KtorGraphQLRequestParserTest { + private val mapper = jacksonObjectMapper() + private val parser = KtorGraphQLRequestParser(mapper) + + @Test + fun `parseRequest should return null if request method is not valid`() = runTest { + val request = mockk(relaxed = true) { + every { local.method } returns HttpMethod.Put + } + assertNull(parser.parseRequest(request)) + } + + @Test + fun `parseRequest should throw IllegalStateException if request method is GET without query`() = runTest { + val request = mockk(relaxed = true) { + every { queryParameters[REQUEST_PARAM_QUERY] } returns null + every { local.method } returns HttpMethod.Get + } + assertFailsWith { + parser.parseRequest(request) + } + } + + @Test + fun `parseRequest should throw UnsupportedOperationException if request method is GET and specifies mutation operation`() = + runTest { + val request = mockk(relaxed = true) { + every { queryParameters[REQUEST_PARAM_QUERY] } returns "mutation { foo }" + every { local.method } returns HttpMethod.Get + } + assertFailsWith { + parser.parseRequest(request) + } + } + + @Test + fun `parseRequest should return request if method is GET with simple query`() = runTest { + val serverRequest = mockk(relaxed = true) { + every { queryParameters[REQUEST_PARAM_QUERY] } returns "{ foo }" + every { queryParameters[REQUEST_PARAM_OPERATION_NAME] } returns null + every { queryParameters[REQUEST_PARAM_VARIABLES] } returns null + every { local.method } returns HttpMethod.Get + } + val graphQLRequest = parser.parseRequest(serverRequest) + assertNotNull(graphQLRequest) + assertTrue(graphQLRequest is GraphQLRequest) + assertEquals("{ foo }", graphQLRequest.query) + assertNull(graphQLRequest.operationName) + assertNull(graphQLRequest.variables) + } + + @Test + fun `parseRequest should return request if method is GET with full query`() = runTest { + val serverRequest = mockk(relaxed = true) { + every { queryParameters[REQUEST_PARAM_QUERY] } returns "query MyFoo { foo }" + every { queryParameters[REQUEST_PARAM_OPERATION_NAME] } returns "MyFoo" + every { queryParameters[REQUEST_PARAM_VARIABLES] } returns """{"a":1}""" + every { local.method } returns HttpMethod.Get + } + val graphQLRequest = parser.parseRequest(serverRequest) + assertNotNull(graphQLRequest) + assertTrue(graphQLRequest is GraphQLRequest) + assertEquals("query MyFoo { foo }", graphQLRequest.query) + assertEquals("MyFoo", graphQLRequest.operationName) + assertEquals(1, graphQLRequest.variables?.get("a")) + } + + @Test + fun `parseRequest should return request if method is POST`() = runTest { + val mockRequest = GraphQLRequest("query MyFoo { foo }", "MyFoo", mapOf("a" to 1)) + val serverRequest = mockk(relaxed = true) { + every { call } returns mockk(relaxed = true) { + every { attributes.getOrNull(any()) } returns null + coEvery { request.pipeline.execute(any(), any()) } returns ByteReadChannel(mapper.writeValueAsBytes(mockRequest)) + } + every { local.method } returns HttpMethod.Post + } + + val graphQLRequest = parser.parseRequest(serverRequest) + assertNotNull(graphQLRequest) + assertTrue(graphQLRequest is GraphQLRequest) + assertEquals("query MyFoo { foo }", graphQLRequest.query) + assertEquals("MyFoo", graphQLRequest.operationName) + assertEquals(1, graphQLRequest.variables?.get("a")) + } + + @Test + fun `parseRequest should return list of requests if method is POST with array body`() = runTest { + val mockRequest1 = GraphQLRequest("query MyFoo { foo }", "MyFoo", mapOf("a" to 1)) + val mockRequest2 = GraphQLRequest("query MyBar { bar }", "MyBar") + val mockRequest = GraphQLBatchRequest(listOf(mockRequest1, mockRequest2)) + + val serverRequest = mockk(relaxed = true) { + every { call } returns mockk(relaxed = true) { + every { attributes.getOrNull(any()) } returns null + coEvery { request.pipeline.execute(any(), any()) } returns ByteReadChannel(mapper.writeValueAsBytes(mockRequest)) + } + every { local.method } returns HttpMethod.Post + } + + val graphQLServerRequest = parser.parseRequest(serverRequest) + assertNotNull(graphQLServerRequest) + assertTrue(graphQLServerRequest is GraphQLBatchRequest) + + val graphQLRequests = graphQLServerRequest.requests + assertEquals(2, graphQLRequests.size) + + val firstRequest = graphQLRequests[0] + assertEquals("query MyFoo { foo }", firstRequest.query) + assertEquals("MyFoo", firstRequest.operationName) + assertEquals(1, firstRequest.variables?.get("a")) + + val secondRequest = graphQLRequests[1] + assertEquals("query MyBar { bar }", secondRequest.query) + assertEquals("MyBar", secondRequest.operationName) + assertNull(secondRequest.variables) + } +} + +/* + @Suppress("BlockingMethodInNonBlockingContext") + + */ diff --git a/servers/graphql-kotlin-ktor-server/src/test/resources/application.conf b/servers/graphql-kotlin-ktor-server/src/test/resources/application.conf new file mode 100644 index 0000000000..e843323ecc --- /dev/null +++ b/servers/graphql-kotlin-ktor-server/src/test/resources/application.conf @@ -0,0 +1,15 @@ +ktor { + application { + modules = [ + com.expediagroup.graphql.server.ktor.GraphQLPluginTestKt.testGraphQLModule + ] + } +} + +graphql { + schema { + packages = [ + "com.expediagroup.graphql.server.ktor" + ] + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d12bd3991d..b74c7d7bfe 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ include(":graphql-kotlin-federated-hooks-provider") // Servers include(":graphql-kotlin-server") include(":graphql-kotlin-spring-server") +include(":graphql-kotlin-ktor-server") // Executions include(":graphql-kotlin-dataloader") @@ -56,6 +57,7 @@ project(":graphql-kotlin-federated-hooks-provider").projectDir = file("plugins/s // Servers project(":graphql-kotlin-server").projectDir = file("servers/graphql-kotlin-server") project(":graphql-kotlin-spring-server").projectDir = file("servers/graphql-kotlin-spring-server") +project(":graphql-kotlin-ktor-server").projectDir = file("servers/graphql-kotlin-ktor-server") // Executions project(":graphql-kotlin-dataloader").projectDir = file("executions/graphql-kotlin-dataloader") diff --git a/website/docs/assets/ktor-initializer.png b/website/docs/assets/ktor-initializer.png new file mode 100644 index 0000000000..8062374e79 Binary files /dev/null and b/website/docs/assets/ktor-initializer.png differ diff --git a/website/docs/server/ktor-server/ktor-configuration.md b/website/docs/server/ktor-server/ktor-configuration.md new file mode 100644 index 0000000000..199edcf4ff --- /dev/null +++ b/website/docs/server/ktor-server/ktor-configuration.md @@ -0,0 +1,199 @@ +--- +id: ktor-configuration +title: Ktor Plugin Configuration +--- + +`graphql-kotlin-ktor-server` plugin can be configured by using DSL when installing the plugin. Configuration is broken into multiple +groups related to specific functionality. See sections below for details. + +```kotlin +install(GraphQL) { + schema { + // configuration that controls schema generation logic + } + engine { + // configurations that control GraphQL execution engine + } + server { + // configurations that control GraphQL HTTP server + } + routes { + // routing configurations + } + tools { + // configurations for various tools + } +} +``` + +## Configuration Files + +Ktor supports specifying configurations in `application.conf` (HOCON) or `application.yaml` file. By default, only HOCON format +is supported. To use a YAML configuration file, you need to add the `ktor-server-config-yaml` dependency to your project dependencies. +See [Ktor documentation](https://ktor.io/docs/configuration-file.html) for details. + +:::caution +Not all configuration properties can be specified in your configuration file. You will need to use DSL to configure more advanced features +that cannot be represented in the property file syntax (e.g. any instantiated objects). +::: + +All configuration options in `application.conf` format, with their default values are provided below. + +```kotlin +graphql { + schema { + // this is a required property that you need to set to appropriate value + // example value is just provided for illustration purposes + packages = [ + "com.example" + ] + federation { + enabled = false + tracing { + enabled = true + debug = false + } + } + } + engine { + automaticPersistedQueries { + enabled = false + } + batching { + enabled = false + strategy = LEVEL_DISPATCHED + } + introspection { + enabled = true + } + } + server { + streamingResponse = true + } + routes { + endpoint = "graphql" + } + tools { + graphiql { + enabled = true + endpoint = "graphiql" + } + sdl { + enabled = true + endpoint = "sdl" + printAtStartup = false + } + } +} +``` + +## Schema Configuration + +This section configures `graphql-kotlin-schema-generation` logic and is the **only** section that has to be configured. +At a minimum you need to configure the list of packages that can contain your GraphQL schema definitions and a list of queries. + +All configuration options, with their default values are provided below. + +```kotlin +schema { + // this is a required property that you need to set to appropriate value + // example value is just provided for illustration purposes + packages = listOf("com.example") + // non-federated schemas, require at least a single query + queries = listOf() + mutations = listOf() + schemaObject = null + // federated schemas require federated hooks + hooks = NoopSchemaGeneratorHooks + topLevelNames = TopLevelNames() + federation { + enabled = false + tracing { + enabled = true + debug = false + } + } +} +``` + +## GraphQL Execution Engine Configuration + +This section configures `graphql-java` execution engine that will be used to process your GraphQL requests. + +All configuration options, with their default values are provided below. + +```kotlin +engine { + automaticPersistedQueries { + enabled = false + } + // DO NOT enable default batching logic if specifying custom provider + batching { + enabled = false + strategy = SYNC_EXHAUSTION + } + introspection { + enabled = true + } + dataFetcherFactoryProvider = SimpleKotlinDataFetcherFactoryProvider() + dataLoaderRegistryFactory = KotlinDataLoaderRegistryFactory() + exceptionHandler = SimpleDataFetcherExceptionHandler() + executionIdProvider = null + idValueUnboxer = IDValueUnboxer() + instrumentations = emptyList() + // DO NOT specify custom provider if enabling default batching logic + preparsedDocumentProvider = null +} +``` + +## Server Configuration + +This section configures your GraphQL HTTP server. + +All configuration options, with their default values are provided below. + +```kotlin +server { + contextFactory = DefaultKtorGraphQLContextFactory() + jacksonConfiguration = { } + requestParser = KtorGraphQLRequestParser(jacksonObjectMapper().apply(jacksonConfiguration)) + streamingResponse = true +} +``` + +## Routes Configuration + +:::info +Subscriptions are currently not supported. +::: + +This section configures your GraphQL HTTP routes. + +All configuration options, with their default values are provided below. + +```kotlin +routes { + endpoint = "graphql" +} +``` + +## Tools Configuration + +This section configures various GraphQL tools to improve your developer experience. Currently, we provide support for +[GraphiQL IDE](https://github.com/graphql/graphiql) and an SDL endpoint. + +All configuration options, with their default values are provided below. + +```kotlin +tools { + graphiql { + enabled = true + endpoint = "graphiql" + } + sdl { + enabled = true + endpoint = "sdl" + printAtStartup = false + } +} +``` diff --git a/website/docs/server/ktor-server/ktor-graphql-context.md b/website/docs/server/ktor-server/ktor-graphql-context.md new file mode 100644 index 0000000000..a8ae6b7d1f --- /dev/null +++ b/website/docs/server/ktor-server/ktor-graphql-context.md @@ -0,0 +1,33 @@ +--- +id: ktor-graphql-context +title: Generating GraphQL Context +--- + +`graphql-kotlin-ktor-server` provides a Ktor specific implementation of [GraphQLContextFactory](../graphql-context-factory.md) +and the context. + +* `KtorGraphQLContextFactory` - Generates GraphQL context map with federated tracing information per request + +If you are using `graphql-kotlin-ktor-server`, you should extend `DefaultKtorGraphQLContextFactory` to automatically +support federated tracing. + +```kotlin +class CustomGraphQLContextFactory : DefaultKtorGraphQLContextFactory() { + override suspend fun generateContext(request: ApplicationRequest): GraphQLContext = + super.generateContext(request).plus( + mapOf("myCustomValue" to (request.headers["my-custom-header"] ?: "defaultContext")) + ) +} +``` + +Once your application is configured to build your custom GraphQL context, you can then access it through a data fetching +environment argument. While executing the query, data fetching environment will be automatically injected to the function input arguments. +This argument will not appear in the GraphQL schema. + +For more details, see the [Contextual Data Documentation](../../schema-generator/execution/contextual-data.md). + +## Federated Context + +If you need [federation tracing support](../../schema-generator/federation/federation-tracing.md), you can set the appropriate [configuration properties](./ktor-configuration.md). +The provided `DefaultKtorGraphQLContextFactory` populates the required information for federated tracing, so as long as +you extend this context class you will maintain feature support. diff --git a/website/docs/server/ktor-server/ktor-http-request-response.md b/website/docs/server/ktor-server/ktor-http-request-response.md new file mode 100644 index 0000000000..8c5b2fc24f --- /dev/null +++ b/website/docs/server/ktor-server/ktor-http-request-response.md @@ -0,0 +1,57 @@ +--- +id: ktor-http-request-response +title: HTTP request and response +--- + +Ktor HTTP request/response can be intercepted by installing various plugins to your module or by intercepting specific +phases of application call pipeline. By installing `graphql-kotlin-ktor-server` plugin you will configure following pipeline + +```mermaid +flowchart LR + A(Request) --> B(ContentNegotiation) + B --> C(Routing) + C --> D(GraphQL) + D --> E(Response) +``` + +## Installing Additional Plugins + +You can install additional plugins in your module next to the `GraphQL` module. See [Ktor docs](https://ktor.io/docs/plugins.html) +for details. + +```kotlin +fun Application.myModule() { + // install additional plugins + install(CORS) + + // install graphql plugin + install(GraphQL) { + schema { + packages = listOf("com.example") + queries = listOf(TestQuery()) + } + } +} +``` + +## Intercepting Pipeline Phases + +You can intercept requests/responses in various phases of application call pipeline by specifying an interceptor. See +[Ktor docs](https://ktor.io/docs/custom-plugins-base-api.html#call-handling) for details. + +```kotlin +fun Application.myModule() { + install(GraphQL) { + schema { + packages = listOf("com.example") + queries = listOf(TestQuery()) + } + } + + intercept(ApplicationCallPipeline.Monitoring) { + call.request.origin.apply { + println("Request URL: $scheme://$localHost:$localPort$uri") + } + } +} +``` diff --git a/website/docs/server/ktor-server/ktor-overview.mdx b/website/docs/server/ktor-server/ktor-overview.mdx new file mode 100644 index 0000000000..de18fd2ebe --- /dev/null +++ b/website/docs/server/ktor-server/ktor-overview.mdx @@ -0,0 +1,98 @@ +--- +id: ktor-overview +title: Ktor Server Overview +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +[graphql-kotlin-ktor-server](https://github.com/ExpediaGroup/graphql-kotlin/tree/master/servers/graphql-kotlin-ktor-server) +is a Ktor Server Plugin that simplifies setup of your GraphQL server. + +## Setup + +The simplest way to create a new Ktor Server app is by generating one using https://start.ktor.io/. + +![Image of https://start.ktor.io/](../../assets/ktor-initializer.png) + +Once you get the sample application setup locally, you will need to add `graphql-kotlin-ktor-server` dependency: + + + + + +```kotlin +implementation("com.expediagroup", "graphql-kotlin-ktor-server", latestVersion) +``` + + + + +```xml + + com.expediagroup + graphql-kotlin-ktor-server + ${latestVersion} + +``` + + + + +## Configuration + +`graphql-kotlin-ktor-server` is a Ktor Server Plugin and you to manually install it in your [module](https://ktor.io/docs/modules.html). + +```kotlin +class HelloWorldQuery : Query { + fun hello(): String = "Hello World!" +} + +fun Application.graphQLModule() { + install(GraphQL) { + schema { + packages = listOf("com.example") + queries = listOf( + HelloWorldQuery() + ) + } + } +} +``` + +If you use `EngineMain` to start your Ktor server, you can specify your module configuration in your `application.conf` (default) +or `application.yaml` (requires additional `ktor-server-config-yaml` dependency) file. + +``` +ktor { + application { + modules = [ com.example.ApplicationKt.graphQLModule ] + } +} +``` + +## Content Negotiation + +:::caution +`graphql-kotlin-ktor-server` automatically configures `ContentNegotiation` plugin with [Jackson](https://github.com/FasterXML/jackson) +serialization. `kotlinx-serialization` is currently not supported. +::: + +## Default Routes + +:::caution +`graphql-kotlin-ktor-server` automatically configures `Routing` plugin if it wasn't configured yet. Attempting to re-install +(vs applying additional configuration) `Routing` after `GraphQL` plugin may result in errors. +::: + +Your newly created GraphQL server starts up with following preconfigured default routes: + +- **/graphql** - GraphQL server endpoint used for processing queries and mutations +- **/sdl** - Convenience endpoint that returns current schema in Schema Definition Language format +- **/graphiql** - [An official IDE](https://github.com/graphql/graphiql) under the GraphQL Foundation diff --git a/website/docs/server/ktor-server/ktor-schema.md b/website/docs/server/ktor-server/ktor-schema.md new file mode 100644 index 0000000000..974be23956 --- /dev/null +++ b/website/docs/server/ktor-server/ktor-schema.md @@ -0,0 +1,58 @@ +--- +id: ktor-schema +title: Writing Schemas with Ktor +--- + +GraphQL schema, queries and mutation objects have to implement the corresponding marker interface. You can then configure +GraphQL plugin with references to your objects. + +```kotlin +@ContactDirective( + name = "My Team Name", + url = "https://myteam.slack.com/archives/teams-chat-room-url", + description = "send urgent issues to [#oncall](https://yourteam.slack.com/archives/oncall)." +) +@GraphQLDescription("My schema description") +class MySchema : Schema + + +class HelloWorldQuery : Query { + fun hello(): String = "Hello World!" +} + +class UpdateGreetingMutation : Mutation { + fun updateGreeting(greeting: String): String = TODO() +} + +fun Application.graphQLModule() { + install(GraphQL) { + schema { + packages = listOf("com.example") + queries = listOf( + HelloWorldQuery() + ) + mutations = listOf( + UpdateGreetingMutation() + ) + schemaObject = MySchema() + } + } +} +``` + +Above code will generate following GraphQL schema + +```graphql +schema @contact(description : "send urgent issues to [#oncall](https://yourteam.slack.com/archives/oncall).", name : "My Team Name", url : "https://myteam.slack.com/archives/teams-chat-room-url"){ + query: Query + mutation: Mutation +} + +type Query { + hello: String! +} + +type Mutation { + updateGreeting(greeting: String!): String! +} +``` diff --git a/website/sidebars.js b/website/sidebars.js index 834f7f66b7..eb2a2ae0c1 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -93,6 +93,17 @@ module.exports = { 'server/spring-server/spring-properties', 'server/spring-server/spring-subscriptions' ] + }, + { + type: 'category', + label: "Ktor Server Plugin", + items: [ + 'server/ktor-server/ktor-overview', + 'server/ktor-server/ktor-schema', + 'server/ktor-server/ktor-graphql-context', + 'server/ktor-server/ktor-http-request-response', + 'server/ktor-server/ktor-configuration' + ] } ], 'Client': [