Skip to content

Commit

Permalink
fix: move exception handling into ktor statuspages (#1936)
Browse files Browse the repository at this point in the history
### 📝 Description
Currently, the Ktor version of the GraphQL server swallows a base
exception, which disallows any custom error responses. This PR moves
exception handling to
[statuspages](https://ktor.io/docs/status-pages.html), which is the
recommended error handler.

Note: This is a breaking change for people relying on the current
functionality to handle errors

### 🔗 Related Issues
#1920

---------

Co-authored-by: c <[email protected]>
Co-authored-by: Dariusz Kuc <[email protected]>
  • Loading branch information
3 people authored Apr 12, 2024
1 parent e8b2265 commit c38f1c5
Show file tree
Hide file tree
Showing 9 changed files with 75 additions and 9 deletions.
1 change: 1 addition & 0 deletions examples/server/ktor-server/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies {
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.websockets)
implementation(libs.ktor.server.cors)
implementation(libs.ktor.server.statuspages)
implementation(libs.logback)
implementation(libs.kotlinx.coroutines.jdk8)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.expediagroup.graphql.examples.server.ktor.schema.dataloaders.BookData
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 com.expediagroup.graphql.server.ktor.defaultGraphQLStatusPages
import com.expediagroup.graphql.server.ktor.graphQLGetRoute
import com.expediagroup.graphql.server.ktor.graphQLPostRoute
import com.expediagroup.graphql.server.ktor.graphQLSDLRoute
Expand All @@ -35,6 +36,7 @@ import io.ktor.serialization.jackson.JacksonWebsocketContentConverter
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.routing.Routing
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.pingPeriod
Expand All @@ -45,6 +47,9 @@ fun Application.graphQLModule() {
pingPeriod = Duration.ofSeconds(1)
contentConverter = JacksonWebsocketContentConverter()
}
install(StatusPages) {
defaultGraphQLStatusPages()
}
install(CORS) {
anyHost()
}
Expand Down
3 changes: 2 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@ ktor-client-serialization = { group = "io.ktor", name = "ktor-client-serializati
ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", 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-cors = { group = "io.ktor", name = "ktor-server-cors", version.ref = "ktor" }
ktor-server-content = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }
ktor-server-statuspages = { group = "io.ktor", name = "ktor-server-status-pages", version.ref = "ktor" }
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors", 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" }
Expand Down
1 change: 1 addition & 0 deletions servers/graphql-kotlin-ktor-server/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies {
api(libs.ktor.server.core)
api(libs.ktor.server.content)
api(libs.ktor.server.websockets)
api(libs.ktor.server.statuspages)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.ktor.client.content)
testImplementation(libs.ktor.client.websockets)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,7 @@ internal fun List<Any>.toTopLevelObjects(): List<TopLevelObject> = this.map {
TopLevelObject(it)
}

internal suspend inline fun KtorGraphQLServer.executeRequest(call: ApplicationCall) = try {
internal suspend inline fun KtorGraphQLServer.executeRequest(call: ApplicationCall) =
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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright 2024 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 io.ktor.http.HttpStatusCode
import io.ktor.server.plugins.statuspages.StatusPagesConfig
import io.ktor.server.response.respond

/**
* Configures default exception handling using Ktor Status Pages.
*
* Returns following HTTP status codes:
* * 405 (Method Not Allowed) - when attempting to execute mutation or query through a GET request
* * 400 (Bad Request) - any other exception
*/
fun StatusPagesConfig.defaultGraphQLStatusPages(): StatusPagesConfig {
exception<Throwable> { call, cause ->
when (cause) {
is UnsupportedOperationException -> call.respond(HttpStatusCode.MethodNotAllowed)
else -> call.respond(HttpStatusCode.BadRequest)
}
}
return this
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ 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.plugins.statuspages.StatusPages
import io.ktor.server.routing.Routing
import io.ktor.server.testing.testApplication
import io.ktor.websocket.Frame
Expand Down Expand Up @@ -183,13 +184,23 @@ class GraphQLPluginTest {
}

@Test
fun `server should return Bad Request for invalid POST requests`() {
fun `server should return Bad Request for invalid POST requests with correct content type`() {
testApplication {
val response = client.post("/graphql")
val response = client.post("/graphql") {
contentType(ContentType.Application.Json)
}
assertEquals(HttpStatusCode.BadRequest, response.status)
}
}

@Test
fun `server should return Unsupported Media Type for POST requests with invalid content type`() {
testApplication {
val response = client.post("/graphql")
assertEquals(HttpStatusCode.UnsupportedMediaType, response.status)
}
}

@Test
fun `server should handle subscription requests`() {
testApplication {
Expand Down Expand Up @@ -234,6 +245,9 @@ class GraphQLPluginTest {
}

fun Application.testGraphQLModule() {
install(StatusPages) {
defaultGraphQLStatusPages()
}
install(GraphQL) {
schema {
// packages property is read from application.conf
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ fun Application.myModule() {
// install additional plugins
install(CORS) { ... }
install(Authentication) { ... }
install(StatusPages) { ... }

// install graphql plugin
install(GraphQL) {
Expand Down
10 changes: 10 additions & 0 deletions website/docs/server/ktor-server/ktor-overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ fun Application.graphQLModule() {
install(Routing) {
graphQLPostRoute()
}
install(StatusPages) {
defaultGraphQLStatusPages()
}
}
```

Expand Down Expand Up @@ -99,6 +102,13 @@ GraphQL plugin provides following `Route` extension functions
- `Route#graphQLSDLRoute` - GraphQL route for exposing schema in Schema Definition Language (SDL) format
- `Route#graphiQLRoute` - GraphQL route for exposing [an official IDE](https://github.com/graphql/graphiql) from the GraphQL Foundation

## StatusPages

`graphql-kotlin-ktor-server` plugin differs from Spring as it relies on Ktor's StatusPages plugin to perform error handling.
It is recommended to use the default settings, however, if you would like to customize your error handling you can create
your own handler. One example might be if you need to catch a custom Authorization error to return a 401 status code.
Please see [Ktor's Official Documentation for StatusPages](https://ktor.io/docs/server-status-pages.html)

## GraalVm Native Image Support

GraphQL Kotlin Ktor Server can be compiled to a [native image](https://www.graalvm.org/latest/reference-manual/native-image/)
Expand Down

0 comments on commit c38f1c5

Please sign in to comment.