Skip to content

Commit 0f05605

Browse files
authored
Support Apollo Persisted Queries (#71)
1 parent 86ad801 commit 0f05605

File tree

17 files changed

+477
-129
lines changed

17 files changed

+477
-129
lines changed

build.gradle.kts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import dev.detekt.gradle.Detekt
33
import dev.detekt.gradle.plugin.getSupportedKotlinVersion
44
import org.graalvm.buildtools.gradle.tasks.BuildNativeImageTask
55
import org.graalvm.buildtools.gradle.tasks.GenerateResourcesConfigFile
6+
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
67
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
78

89
plugins {
@@ -118,6 +119,19 @@ tasks.withType<Detekt>().configureEach {
118119
}
119120
dependencies.add("detektPlugins", libs.detektKtlintWrapper)
120121

122+
@OptIn(ExperimentalKotlinGradlePluginApi::class)
123+
powerAssert {
124+
functions.set(
125+
listOf(
126+
"kotlin.assert",
127+
"kotlin.test.assertEquals",
128+
"kotlin.test.assertTrue",
129+
"kotlin.test.assertFalse",
130+
"kotlin.test.assertNull",
131+
)
132+
)
133+
}
134+
121135
dependencies {
122136
implementation(libs.spring.boot.starter)
123137
implementation(libs.dgs.starter)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package io.github.reactivecircus.kstreamlined.backend
2+
3+
import com.github.benmanes.caffeine.cache.Caffeine
4+
import com.netflix.graphql.dgs.apq.AutomatedPersistedQueryCaffeineCache
5+
import com.netflix.graphql.dgs.apq.DgsAPQSupportProperties
6+
import graphql.execution.preparsed.PreparsedDocumentProvider
7+
import graphql.execution.preparsed.persisted.ApolloPersistedQuerySupport
8+
import org.springframework.context.annotation.Bean
9+
import org.springframework.context.annotation.Configuration
10+
11+
@Configuration
12+
class APQConfiguration {
13+
@Bean
14+
fun preparsedDocumentProvider(): PreparsedDocumentProvider {
15+
val spec = DgsAPQSupportProperties.DgsAPQDefaultCaffeineCacheProperties().caffeineSpec
16+
return ApolloPersistedQuerySupport(
17+
AutomatedPersistedQueryCaffeineCache(Caffeine.from(spec).build()),
18+
)
19+
}
20+
}

src/main/resources/META-INF/native-image/reachability-metadata.json

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,37 @@
150150
}
151151
]
152152
},
153+
{
154+
"type": "com.github.benmanes.caffeine.cache.PSWMS",
155+
"methods": [
156+
{
157+
"name": "<init>",
158+
"parameterTypes": []
159+
}
160+
]
161+
},
162+
{
163+
"type": "com.github.benmanes.caffeine.cache.SSSMS",
164+
"fields": [
165+
{
166+
"name": "maximum"
167+
},
168+
{
169+
"name": "weightedSize"
170+
}
171+
]
172+
},
173+
{
174+
"type": "com.github.benmanes.caffeine.cache.SSSMSW",
175+
"fields": [
176+
{
177+
"name": "FACTORY"
178+
},
179+
{
180+
"name": "expiresAfterWriteNanos"
181+
}
182+
]
183+
},
153184
{
154185
"type": "com.github.benmanes.caffeine.cache.SSW",
155186
"fields": [
@@ -161,6 +192,14 @@
161192
}
162193
]
163194
},
195+
{
196+
"type": "com.github.benmanes.caffeine.cache.StripedBuffer",
197+
"fields": [
198+
{
199+
"name": "tableBusy"
200+
}
201+
]
202+
},
164203
{
165204
"type": "com.github.luben.zstd.Zstd"
166205
},
@@ -283,7 +322,7 @@
283322
{
284323
"name": "setAuthorization",
285324
"parameterTypes": [
286-
"java.lang.String"
325+
"java.util.List"
287326
]
288327
},
289328
{
@@ -1281,6 +1320,12 @@
12811320
{
12821321
"type": "graphql.execution.preparsed.PreparsedDocumentProvider"
12831322
},
1323+
{
1324+
"type": "graphql.execution.preparsed.persisted.ApolloPersistedQuerySupport"
1325+
},
1326+
{
1327+
"type": "graphql.execution.preparsed.persisted.PersistedQuerySupport"
1328+
},
12841329
{
12851330
"type": "graphql.schema.Coercing"
12861331
},
@@ -1299,6 +1344,63 @@
12991344
{
13001345
"type": "groovy.lang.MetaClass"
13011346
},
1347+
{
1348+
"type": "io.github.reactivecircus.kstreamlined.backend.APQConfiguration",
1349+
"methods": [
1350+
{
1351+
"name": "preparsedDocumentProvider",
1352+
"parameterTypes": []
1353+
}
1354+
]
1355+
},
1356+
{
1357+
"type": "io.github.reactivecircus.kstreamlined.backend.APQConfiguration$$SpringCGLIB$$0",
1358+
"fields": [
1359+
{
1360+
"name": "$$beanFactory"
1361+
},
1362+
{
1363+
"name": "CGLIB$CALLBACK_FILTER"
1364+
},
1365+
{
1366+
"name": "CGLIB$FACTORY_DATA"
1367+
}
1368+
],
1369+
"methods": [
1370+
{
1371+
"name": "<init>",
1372+
"parameterTypes": []
1373+
},
1374+
{
1375+
"name": "CGLIB$SET_STATIC_CALLBACKS",
1376+
"parameterTypes": [
1377+
"org.springframework.cglib.proxy.Callback[]"
1378+
]
1379+
}
1380+
]
1381+
},
1382+
{
1383+
"type": "io.github.reactivecircus.kstreamlined.backend.APQConfiguration$$SpringCGLIB$$FastClass$$0",
1384+
"methods": [
1385+
{
1386+
"name": "<init>",
1387+
"parameterTypes": [
1388+
"java.lang.Class"
1389+
]
1390+
}
1391+
]
1392+
},
1393+
{
1394+
"type": "io.github.reactivecircus.kstreamlined.backend.APQConfiguration$$SpringCGLIB$$FastClass$$1",
1395+
"methods": [
1396+
{
1397+
"name": "<init>",
1398+
"parameterTypes": [
1399+
"java.lang.Class"
1400+
]
1401+
}
1402+
]
1403+
},
13021404
{
13031405
"type": "io.github.reactivecircus.kstreamlined.backend.KSBackendApplication",
13041406
"methods": [
@@ -2853,6 +2955,9 @@
28532955
{
28542956
"type": "io.netty.util.ReferenceCountUtil"
28552957
},
2958+
{
2959+
"type": "io.netty.util.ResourceLeakDetector$DefaultResourceLeak"
2960+
},
28562961
{
28572962
"type": "io.netty.util.concurrent.DefaultPromise"
28582963
},
@@ -7533,6 +7638,9 @@
75337638
{
75347639
"glob": "io/github/reactivecircus/kstreamlined/backend"
75357640
},
7641+
{
7642+
"glob": "io/github/reactivecircus/kstreamlined/backend/APQConfiguration.class"
7643+
},
75367644
{
75377645
"glob": "io/github/reactivecircus/kstreamlined/backend/KSConfiguration.class"
75387646
},
@@ -7983,4 +8091,4 @@
79838091
"bundle": "i18n.Validation"
79848092
}
79858093
]
7986-
}
8094+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package io.github.reactivecircus.kstreamlined.backend
2+
3+
import io.github.reactivecircus.kstreamlined.backend.datafetcher.FeedSourceDataFetcher
4+
import io.github.reactivecircus.kstreamlined.backend.datafetcher.scalar.InstantScalar
5+
import org.springframework.boot.autoconfigure.EnableAutoConfiguration
6+
import org.springframework.boot.test.context.SpringBootTest
7+
import org.springframework.boot.test.web.server.LocalServerPort
8+
import org.springframework.test.context.ContextConfiguration
9+
import org.springframework.test.web.reactive.server.WebTestClient
10+
import org.springframework.test.web.reactive.server.expectBody
11+
import java.security.MessageDigest
12+
import kotlin.test.BeforeTest
13+
import kotlin.test.Test
14+
import kotlin.test.assertEquals
15+
import kotlin.test.assertNotEquals
16+
17+
@SpringBootTest(
18+
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
19+
classes = [FeedSourceDataFetcher::class, InstantScalar::class],
20+
)
21+
@EnableAutoConfiguration
22+
@ContextConfiguration(classes = [TestKSConfiguration::class, APQConfiguration::class])
23+
class ApolloPersistedQueriesTest {
24+
@LocalServerPort
25+
private val port: Int = 0
26+
27+
private lateinit var webTestClient: WebTestClient
28+
29+
private val feedSourcesQuery = """
30+
query FeedSources {
31+
feedSources {
32+
key
33+
title
34+
}
35+
}
36+
""".trimIndent()
37+
38+
@BeforeTest
39+
fun setUp() {
40+
webTestClient = WebTestClient.bindToServer()
41+
.baseUrl("http://localhost:$port")
42+
.build()
43+
}
44+
45+
@Test
46+
fun `query with persisted query extension returns PersistedQueryNotFound for unknown hash`() {
47+
val unknownHash = "0".repeat(64)
48+
val requestBody = """
49+
{
50+
"extensions": {
51+
"persistedQuery": {
52+
"version": 1,
53+
"sha256Hash": "$unknownHash"
54+
}
55+
}
56+
}
57+
""".trimIndent()
58+
59+
webTestClient.post()
60+
.uri("/graphql")
61+
.header("Content-Type", "application/json")
62+
.bodyValue(requestBody)
63+
.exchange()
64+
.expectStatus().isOk
65+
.expectBody<String>()
66+
.consumeWith { response ->
67+
val body = response.responseBody
68+
assertEquals(body?.contains("PersistedQueryNotFound"), true)
69+
}
70+
}
71+
72+
@Test
73+
fun `query can be registered and then retrieved by hash`() {
74+
val queryHash = feedSourcesQuery.sha256Hash()
75+
76+
// First request: register the query with the hash
77+
val registerRequestBody = """
78+
{
79+
"query": ${feedSourcesQuery.toJsonString()},
80+
"extensions": {
81+
"persistedQuery": {
82+
"version": 1,
83+
"sha256Hash": "$queryHash"
84+
}
85+
}
86+
}
87+
""".trimIndent()
88+
89+
webTestClient.post()
90+
.uri("/graphql")
91+
.header("Content-Type", "application/json")
92+
.bodyValue(registerRequestBody)
93+
.exchange()
94+
.expectStatus().isOk
95+
.expectBody(String::class.java)
96+
.consumeWith { response ->
97+
val body = response.responseBody
98+
assertEquals(body?.contains("feedSources"), true)
99+
assertNotEquals(body?.contains("errors"), true)
100+
}
101+
102+
// Second request: retrieve by hash only (no query body)
103+
val retrieveRequestBody = """
104+
{
105+
"extensions": {
106+
"persistedQuery": {
107+
"version": 1,
108+
"sha256Hash": "$queryHash"
109+
}
110+
}
111+
}
112+
""".trimIndent()
113+
114+
webTestClient.post()
115+
.uri("/graphql")
116+
.header("Content-Type", "application/json")
117+
.bodyValue(retrieveRequestBody)
118+
.exchange()
119+
.expectStatus().isOk
120+
.expectBody<String>()
121+
.consumeWith { response ->
122+
val body = response.responseBody
123+
assertEquals(body?.contains("feedSources"), true)
124+
assertNotEquals(body?.contains("errors"), true)
125+
}
126+
}
127+
128+
private fun String.sha256Hash(): String {
129+
val bytes = MessageDigest.getInstance("SHA-256").digest(this.toByteArray())
130+
return bytes.joinToString("") { "%02x".format(it) }
131+
}
132+
133+
private fun String.toJsonString(): String {
134+
return "\"" + this.replace("\\", "\\\\")
135+
.replace("\"", "\\\"")
136+
.replace("\n", "\\n")
137+
.replace("\r", "\\r")
138+
.replace("\t", "\\t") + "\""
139+
}
140+
}

0 commit comments

Comments
 (0)