Skip to content

Commit f1a8603

Browse files
authored
feat: implemented persisted queries for GET methods with only SHA-256 hash of query string (#2067)
### 📝 Description According to the GraphQL APQ flow description, GET requests containing only SHA-256 hash of the query should be checked in cache and respond with PERSISTED_QUERY_NOT_FOUND error if request is not cached. Both Ktor and Spring server implementations didn't handle this first query without a query param. I tried to implement the change without breaking existing behaviours, as a query param is expected to take precedence over post body, for example, as in one of the tests in RouteConfigurationIT. ### 🔗 Related Issues #2065
1 parent 681c70d commit f1a8603

File tree

6 files changed

+208
-10
lines changed

6 files changed

+208
-10
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright 2022 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.spring.query
18+
19+
import com.expediagroup.graphql.examples.server.spring.GRAPHQL_MEDIA_TYPE
20+
import com.expediagroup.graphql.examples.server.spring.verifyData
21+
import org.junit.jupiter.api.Test
22+
import org.junit.jupiter.api.TestInstance
23+
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
24+
import org.springframework.beans.factory.annotation.Autowired
25+
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
26+
import org.springframework.boot.test.context.SpringBootTest
27+
import org.springframework.http.MediaType.APPLICATION_JSON
28+
import org.springframework.test.web.reactive.server.WebTestClient
29+
30+
@SpringBootTest(
31+
properties = ["graphql.automaticPersistedQueries.enabled=true"]
32+
)
33+
@AutoConfigureWebTestClient
34+
@TestInstance(PER_CLASS)
35+
class APQQueryIT(@Autowired private val testClient: WebTestClient) {
36+
37+
@Test
38+
fun `verify GET persisted query with hash only followed by POST with hash`() {
39+
val query = "simpleDeprecatedQuery"
40+
41+
testClient.get()
42+
.uri { builder ->
43+
builder.path("/graphql")
44+
.queryParam("extensions", "{extension}")
45+
.build("""{"persistedQuery":{"version":1,"sha256Hash":"aee64e0a941589ff06b717d4930405f3eafb089e687bef6ece5719ea6a4e7f35"}}""")
46+
}
47+
.exchange()
48+
.expectBody().json(
49+
"""
50+
{
51+
errors: [
52+
{
53+
message: "PersistedQueryNotFound"
54+
}
55+
]
56+
}
57+
""".trimIndent()
58+
)
59+
60+
val expectedData = "false"
61+
62+
testClient.post()
63+
.uri { builder ->
64+
builder.path("/graphql")
65+
.queryParam("extensions", "{extension}")
66+
.build("""{"persistedQuery":{"version":1,"sha256Hash":"aee64e0a941589ff06b717d4930405f3eafb089e687bef6ece5719ea6a4e7f35"}}""")
67+
}
68+
.accept(APPLICATION_JSON)
69+
.contentType(GRAPHQL_MEDIA_TYPE)
70+
.bodyValue("query { $query }")
71+
.exchange()
72+
.verifyData(query, expectedData)
73+
74+
testClient.get()
75+
.uri { builder ->
76+
builder.path("/graphql")
77+
.queryParam("extensions", "{extension}")
78+
.build("""{"persistedQuery":{"version":1,"sha256Hash":"aee64e0a941589ff06b717d4930405f3eafb089e687bef6ece5719ea6a4e7f35"}}""")
79+
}
80+
.exchange()
81+
.verifyData(query, expectedData)
82+
}
83+
}

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

+17-3
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import java.io.IOException
3030
internal const val REQUEST_PARAM_QUERY = "query"
3131
internal const val REQUEST_PARAM_OPERATION_NAME = "operationName"
3232
internal const val REQUEST_PARAM_VARIABLES = "variables"
33+
internal const val REQUEST_PARAM_EXTENSIONS = "extensions"
34+
internal const val REQUEST_PARAM_PERSISTED_QUERY = "persistedQuery"
3335

3436
/**
3537
* GraphQL Ktor [ApplicationRequest] parser.
@@ -46,8 +48,12 @@ open class KtorGraphQLRequestParser(
4648
else -> null
4749
}
4850

49-
private fun parseGetRequest(request: ApplicationRequest): GraphQLServerRequest? {
50-
val query = request.queryParameters[REQUEST_PARAM_QUERY] ?: throw IllegalStateException("Invalid HTTP request - GET request has to specify query parameter")
51+
private fun parseGetRequest(request: ApplicationRequest): GraphQLServerRequest {
52+
val extensions = request.queryParameters[REQUEST_PARAM_EXTENSIONS]
53+
val query = request.queryParameters[REQUEST_PARAM_QUERY] ?: ""
54+
check(query.isNotEmpty() || extensions?.contains(REQUEST_PARAM_PERSISTED_QUERY) == true) {
55+
"Invalid HTTP request - GET request has to specify either query parameter or persisted query extension"
56+
}
5157
if (query.startsWith("mutation ") || query.startsWith("subscription ")) {
5258
throw UnsupportedOperationException("Invalid GraphQL operation - only queries are supported for GET requests")
5359
}
@@ -56,7 +62,15 @@ open class KtorGraphQLRequestParser(
5662
val graphQLVariables: Map<String, Any>? = variables?.let {
5763
mapper.readValue(it, mapTypeReference)
5864
}
59-
return GraphQLRequest(query = query, operationName = operationName, variables = graphQLVariables)
65+
val extensionsMap: Map<String, Any>? = request.queryParameters[REQUEST_PARAM_EXTENSIONS]?.let {
66+
mapper.readValue(it, mapTypeReference)
67+
}
68+
return GraphQLRequest(
69+
query = query,
70+
operationName = operationName,
71+
variables = graphQLVariables,
72+
extensions = extensionsMap
73+
)
6074
}
6175

6276
private suspend fun parsePostRequest(request: ApplicationRequest): GraphQLServerRequest? = try {

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

+10
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,16 @@ class GraphQLPluginTest {
134134
}
135135
}
136136

137+
@Test
138+
fun `server should return Method Not Allowed for Mutation GET requests with persisted query`() {
139+
testApplication {
140+
val response = client.get("/graphql") {
141+
parameter("query", "mutation { foo }")
142+
}
143+
assertEquals(HttpStatusCode.MethodNotAllowed, response.status)
144+
}
145+
}
146+
137147
@Test
138148
fun `server should return Bad Request for invalid GET requests`() {
139149
testApplication {

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

+24
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class KtorGraphQLRequestParserTest {
3535
fun `parseRequest should throw IllegalStateException if request method is GET without query`() = runTest {
3636
val request = mockk<ApplicationRequest>(relaxed = true) {
3737
every { queryParameters[REQUEST_PARAM_QUERY] } returns null
38+
every { queryParameters[REQUEST_PARAM_EXTENSIONS] } returns null
3839
every { local.method } returns HttpMethod.Get
3940
}
4041
assertFailsWith<IllegalStateException> {
@@ -60,6 +61,7 @@ class KtorGraphQLRequestParserTest {
6061
every { queryParameters[REQUEST_PARAM_QUERY] } returns "{ foo }"
6162
every { queryParameters[REQUEST_PARAM_OPERATION_NAME] } returns null
6263
every { queryParameters[REQUEST_PARAM_VARIABLES] } returns null
64+
every { queryParameters[REQUEST_PARAM_EXTENSIONS] } returns null
6365
every { local.method } returns HttpMethod.Get
6466
}
6567
val graphQLRequest = parser.parseRequest(serverRequest)
@@ -76,6 +78,7 @@ class KtorGraphQLRequestParserTest {
7678
every { queryParameters[REQUEST_PARAM_QUERY] } returns "query MyFoo { foo }"
7779
every { queryParameters[REQUEST_PARAM_OPERATION_NAME] } returns "MyFoo"
7880
every { queryParameters[REQUEST_PARAM_VARIABLES] } returns """{"a":1}"""
81+
every { queryParameters[REQUEST_PARAM_EXTENSIONS] } returns null
7982
every { local.method } returns HttpMethod.Get
8083
}
8184
val graphQLRequest = parser.parseRequest(serverRequest)
@@ -86,6 +89,27 @@ class KtorGraphQLRequestParserTest {
8689
assertEquals(1, graphQLRequest.variables?.get("a"))
8790
}
8891

92+
@Test
93+
fun `parseRequest should return request if method is GET with hash only`() = runTest {
94+
val serverRequest = mockk<ApplicationRequest>(relaxed = true) {
95+
every { queryParameters[REQUEST_PARAM_QUERY] } returns null
96+
every { queryParameters[REQUEST_PARAM_OPERATION_NAME] } returns "MyFoo"
97+
every { queryParameters[REQUEST_PARAM_VARIABLES] } returns """{"a":1}"""
98+
every { queryParameters[REQUEST_PARAM_EXTENSIONS] } returns """{"persistedQuery":{"version":1,"sha256Hash":"some-hash"}}"""
99+
every { local.method } returns HttpMethod.Get
100+
}
101+
val graphQLRequest = parser.parseRequest(serverRequest)
102+
assertNotNull(graphQLRequest)
103+
assertTrue(graphQLRequest is GraphQLRequest)
104+
assertEquals("", graphQLRequest.query)
105+
assertEquals("MyFoo", graphQLRequest.operationName)
106+
assertEquals(1, graphQLRequest.variables?.get("a"))
107+
assertEquals(
108+
mapOf("version" to 1, "sha256Hash" to "some-hash"),
109+
graphQLRequest.extensions?.get("persistedQuery")
110+
)
111+
}
112+
89113
@Test
90114
fun `parseRequest should return request if method is POST`() = runTest {
91115
val mockRequest = GraphQLRequest("query MyFoo { foo }", "MyFoo", mapOf("a" to 1))

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

+20-4
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,13 @@ import org.springframework.web.reactive.function.server.ServerRequest
3030
import org.springframework.web.reactive.function.server.awaitBody
3131
import org.springframework.web.reactive.function.server.bodyToMono
3232
import org.springframework.web.server.ResponseStatusException
33+
import kotlin.jvm.optionals.getOrNull
3334

3435
internal const val REQUEST_PARAM_QUERY = "query"
3536
internal const val REQUEST_PARAM_OPERATION_NAME = "operationName"
3637
internal const val REQUEST_PARAM_VARIABLES = "variables"
38+
internal const val REQUEST_PARAM_EXTENSIONS = "extensions"
39+
internal const val REQUEST_PARAM_PERSISTED_QUERY = "persistedQuery"
3740
internal val graphQLMediaType = MediaType("application", "graphql")
3841

3942
open class SpringGraphQLRequestParser(
@@ -43,20 +46,33 @@ open class SpringGraphQLRequestParser(
4346
private val mapTypeReference: MapType = TypeFactory.defaultInstance().constructMapType(HashMap::class.java, String::class.java, Any::class.java)
4447

4548
override suspend fun parseRequest(request: ServerRequest): GraphQLServerRequest? = when {
46-
request.queryParam(REQUEST_PARAM_QUERY).isPresent -> { getRequestFromGet(request) }
47-
request.method().equals(HttpMethod.POST) -> { getRequestFromPost(request) }
49+
request.isGetPersistedQuery() || request.hasQueryParam() -> { getRequestFromGet(request) }
50+
request.method() == HttpMethod.POST -> getRequestFromPost(request)
4851
else -> null
4952
}
5053

54+
private fun ServerRequest.hasQueryParam() = queryParam(REQUEST_PARAM_QUERY).isPresent
55+
56+
private fun ServerRequest.isGetPersistedQuery() =
57+
method() == HttpMethod.GET && queryParam(REQUEST_PARAM_EXTENSIONS).getOrNull()?.contains(REQUEST_PARAM_PERSISTED_QUERY) == true
58+
5159
private fun getRequestFromGet(serverRequest: ServerRequest): GraphQLServerRequest {
52-
val query = serverRequest.queryParam(REQUEST_PARAM_QUERY).get()
60+
val query = serverRequest.queryParam(REQUEST_PARAM_QUERY).orElse("")
5361
val operationName: String? = serverRequest.queryParam(REQUEST_PARAM_OPERATION_NAME).orElseGet { null }
5462
val variables: String? = serverRequest.queryParam(REQUEST_PARAM_VARIABLES).orElseGet { null }
5563
val graphQLVariables: Map<String, Any>? = variables?.let {
5664
objectMapper.readValue(it, mapTypeReference)
5765
}
66+
val extensions: Map<String, Any>? = serverRequest.queryParam(REQUEST_PARAM_EXTENSIONS).takeIf { it.isPresent }?.get()?.let {
67+
objectMapper.readValue(it, mapTypeReference)
68+
}
5869

59-
return GraphQLRequest(query = query, operationName = operationName, variables = graphQLVariables)
70+
return GraphQLRequest(
71+
query = query,
72+
operationName = operationName,
73+
variables = graphQLVariables,
74+
extensions = extensions
75+
)
6076
}
6177

6278
private suspend fun getRequestFromPost(serverRequest: ServerRequest): GraphQLServerRequest? {

servers/graphql-kotlin-spring-server/src/test/kotlin/com/expediagroup/graphql/server/spring/execution/SpringGraphQLRequestParserTest.kt

+54-3
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
2222
import io.mockk.every
2323
import io.mockk.mockk
2424
import kotlinx.coroutines.ExperimentalCoroutinesApi
25-
import kotlinx.coroutines.test.runBlockingTest
2625
import kotlinx.coroutines.test.runTest
2726
import org.junit.jupiter.api.Test
2827
import org.springframework.http.HttpHeaders
@@ -44,18 +43,20 @@ class SpringGraphQLRequestParserTest {
4443
private val parser = SpringGraphQLRequestParser(objectMapper)
4544

4645
@Test
47-
fun `parseRequest should return null if request method is not valid`() = runBlockingTest {
46+
fun `parseRequest should return null if request method is not valid`() = runTest {
4847
val request = mockk<ServerRequest>(relaxed = true) {
4948
every { queryParam(REQUEST_PARAM_QUERY) } returns Optional.empty()
49+
every { queryParam(REQUEST_PARAM_EXTENSIONS) } returns Optional.empty()
5050
every { method() } returns HttpMethod.PUT
5151
}
5252
assertNull(parser.parseRequest(request))
5353
}
5454

5555
@Test
56-
fun `parseRequest should return null if request method is GET without query`() = runBlockingTest {
56+
fun `parseRequest should return null if request method is GET without query`() = runTest {
5757
val request = mockk<ServerRequest>(relaxed = true) {
5858
every { queryParam(REQUEST_PARAM_QUERY) } returns Optional.empty()
59+
every { queryParam(REQUEST_PARAM_EXTENSIONS) } returns Optional.empty()
5960
every { method() } returns HttpMethod.GET
6061
}
6162
assertNull(parser.parseRequest(request))
@@ -65,6 +66,7 @@ class SpringGraphQLRequestParserTest {
6566
fun `parseRequest should return request if method is GET with simple query`() = runTest {
6667
val serverRequest = mockk<ServerRequest>(relaxed = true) {
6768
every { queryParam(REQUEST_PARAM_QUERY) } returns Optional.of("{ foo }")
69+
every { queryParam(REQUEST_PARAM_EXTENSIONS) } returns Optional.empty()
6870
every { queryParam(REQUEST_PARAM_OPERATION_NAME) } returns Optional.empty()
6971
every { queryParam(REQUEST_PARAM_VARIABLES) } returns Optional.empty()
7072
every { method() } returns HttpMethod.GET
@@ -82,6 +84,7 @@ class SpringGraphQLRequestParserTest {
8284
val serverRequest = mockk<ServerRequest>(relaxed = true) {
8385
every { queryParam(REQUEST_PARAM_QUERY) } returns Optional.of("query MyFoo { foo }")
8486
every { queryParam(REQUEST_PARAM_OPERATION_NAME) } returns Optional.of("MyFoo")
87+
every { queryParam(REQUEST_PARAM_EXTENSIONS) } returns Optional.empty()
8588
every { queryParam(REQUEST_PARAM_VARIABLES) } returns Optional.of("""{ "a": 1 }""")
8689
every { method() } returns HttpMethod.GET
8790
}
@@ -93,6 +96,54 @@ class SpringGraphQLRequestParserTest {
9396
assertEquals(1, graphQLRequest.variables?.get("a"))
9497
}
9598

99+
@Test
100+
fun `parseRequest should return request if method is GET with hash only`() = runTest {
101+
val serverRequest = mockk<ServerRequest>(relaxed = true) {
102+
every { queryParam(REQUEST_PARAM_QUERY) } returns Optional.empty()
103+
every { queryParam(REQUEST_PARAM_EXTENSIONS) } returns Optional.of("""{"persistedQuery":{"version":1,"sha256Hash":"some-hash"}}""")
104+
every { queryParam(REQUEST_PARAM_OPERATION_NAME) } returns Optional.empty()
105+
every { queryParam(REQUEST_PARAM_VARIABLES) } returns Optional.empty()
106+
every { method() } returns HttpMethod.GET
107+
}
108+
val graphQLRequest = parser.parseRequest(serverRequest)
109+
assertNotNull(graphQLRequest)
110+
assertTrue(graphQLRequest is GraphQLRequest)
111+
assertEquals("", graphQLRequest.query)
112+
assertNull(graphQLRequest.operationName)
113+
assertNull(graphQLRequest.variables)
114+
assertEquals(
115+
mapOf("version" to 1, "sha256Hash" to "some-hash"),
116+
graphQLRequest.extensions?.get("persistedQuery")
117+
)
118+
}
119+
120+
@Test
121+
fun `parseRequest should return request if method is POST with content-type json and persisted query extension`() = runTest {
122+
val mockRequest = GraphQLRequest("query MyFoo { foo }", "MyFoo", mapOf("a" to 1))
123+
val serverRequest = MockServerRequest.builder()
124+
.method(HttpMethod.POST)
125+
.queryParam(
126+
REQUEST_PARAM_EXTENSIONS,
127+
"""
128+
{
129+
"persistedQuery": {
130+
"version": 1,
131+
"sha256Hash": "some-hash"
132+
}
133+
}
134+
""".trimIndent()
135+
)
136+
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
137+
.body(Mono.justOrEmpty(mockRequest))
138+
139+
val graphQLRequest = parser.parseRequest(serverRequest)
140+
assertNotNull(graphQLRequest)
141+
assertTrue(graphQLRequest is GraphQLRequest)
142+
assertEquals("query MyFoo { foo }", graphQLRequest.query)
143+
assertEquals("MyFoo", graphQLRequest.operationName)
144+
assertEquals(1, graphQLRequest.variables?.get("a"))
145+
}
146+
96147
@Test
97148
fun `parseRequest should return request if method is POST with no content-type`() = runTest {
98149
val mockRequest = GraphQLRequest("query MyFoo { foo }", "MyFoo", mapOf("a" to 1))

0 commit comments

Comments
 (0)