Skip to content

Commit f773327

Browse files
samuelAndalonsamvazquez
andauthored
[executions] Apollo Automatic Persisted Queries (APQ) support (ExpediaGroup#1474)
* feat: async implementation of APQ Co-authored-by: samvazquez <samvazquez@expedia.com>
1 parent 1615bcb commit f773327

File tree

22 files changed

+15803
-9195
lines changed

22 files changed

+15803
-9195
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# GraphQL Kotlin Automatic Persisted Queries Support (APQ)
2+
[![Maven Central](https://img.shields.io/maven-central/v/com.expediagroup/graphql-kotlin-automatic-persisted-queries.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:%22com.expediagroup%22%20AND%20a:%22graphql-kotlin-automatic-persisted-queries%22)
3+
[![Javadocs](https://img.shields.io/maven-central/v/com.expediagroup/graphql-kotlin-automatic-persisted-queries.svg?label=javadoc&colorB=brightgreen)](https://www.javadoc.io/doc/com.expediagroup/graphql-kotlin-automatic-persisted-queries)
4+
5+
`graphql-kotlin-automatic-persisted-queries` is the `graphql-kotlin` implementation of Automatic Persisted Queries (APQ).
6+
7+
[APQ is technique created by Apollo](https://www.apollographql.com/docs/apollo-server/performance/apq/) to improve
8+
GraphQL network performance with zero build-time configuration by sending smaller [GraphQL HTTP requests](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md),
9+
a smaller request payload reduces bandwidth utilization and speeds up GraphQL client loading times.
10+
11+
A persisted query is a query string that is cached on a GraphQL server, along with a unique identifier (SHA-256 hash), that way,
12+
Clients can send this identifier instead of his corresponding query which will drastically reduce the request size.
13+
14+
To persist a query, a GraphQL Server must first receive it from a client, then, subsequent requests can just include the identifier
15+
instead of the query.
16+
17+
```mermaid
18+
sequenceDiagram;
19+
Client->>GraphQL Kotlin Server: Sends SHA-256 hash of query to execute
20+
Note over GraphQL Kotlin Server: Fails to find persisted query
21+
GraphQL Kotlin Server->>Client: Responds with error
22+
Client->>GraphQL Kotlin Server: Sends both query AND hash
23+
Note over GraphQL Kotlin Server: Persists query and hash
24+
GraphQL Kotlin Server->>Client: Executes query and returns result
25+
Note over Client: Time passes
26+
Client->>GraphQL Kotlin Server: Sends SHA-256 hash of query to execute
27+
Note over GraphQL Kotlin Server: Finds persisted query
28+
GraphQL Kotlin Server->>Client: Executes query and returns result
29+
```
30+
31+
The `APQ` implementation of `graphql-kotlin` relies on the [graphql-java PreparsedDocumentProvider](https://github.com/graphql-java/graphql-java/blob/master/src/main/java/graphql/execution/preparsed/PreparsedDocumentProvider.java)
32+
which is an interface that allows clients to cache GraphQL Documents (AST) that were parsed and validated.
33+
34+
The `AutomaticPersistedQueriesProvider` class implements `PreparsedDocumentProvider` and contains all the logic required to fulfil the APQ spec.
35+
36+
## Install it
37+
38+
Using a JVM dependency manager, link `graphql-kotlin-automatic-persisted-queries` to your project.
39+
40+
With Maven:
41+
42+
```xml
43+
<dependency>
44+
<groupId>com.expediagroup</groupId>
45+
<artifactId>graphql-kotlin-automatic-persisted-queries</artifactId>
46+
<version>${latestVersion}</version>
47+
</dependency>
48+
```
49+
50+
With Gradle (example using kts):
51+
52+
```kotlin
53+
implementation("com.expediagroup:graphql-kotlin-automatic-persisted-queries:$latestVersion")
54+
```
55+
56+
## Use it
57+
58+
1. Create an instance of `AutomaticPersistedQueriesProvider` by specifying as a constructor argument an implementation of an
59+
`AutomaticPersistedQueriesCache` interface, which is the place where the queries will be persisted by their unique identifier.
60+
61+
**Note:** `graphql-kotlin` provides a default in-memory cache implementation of `AutomaticPersistedQueriesCache` called `DefaultAutomaticPersistedQueriesCache`.
62+
63+
2. Provide the instance of `AutomaticPersistedQueriesProvider` to the [GraphQLBuilder preparsedDocumentProvider method](https://github.com/graphql-java/graphql-java/blob/master/src/main/java/graphql/GraphQL.java#L261).
64+
65+
**Note:** In order to take full advantage of APQ it's recommended to use a different cache mechanism like REDIS.
66+
67+
```kotlin
68+
val schema = "your schema"
69+
val runtimeWiring = RuntimeWiring.newRuntimeWiring().build() // your runtime wiring
70+
val automaticPersistedQueryProvider = AutomaticPersistedQueriesProvider(DefaultAutomaticPersistedQueriesCache())
71+
72+
val graphQL = GraphQL
73+
.newGraphQL(SchemaGenerator().makeExecutableSchema(SchemaParser().parse(schema), runtimeWiring))
74+
.preparsedDocumentProvider(automaticPersistedQueryProvider)
75+
.build()
76+
```
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
description = "Automatic Persisted Queries"
2+
3+
val junitVersion: String by project
4+
val graphQLJavaVersion: String by project
5+
6+
dependencies {
7+
api("com.graphql-java:graphql-java:$graphQLJavaVersion")
8+
testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion")
9+
testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
10+
}
11+
12+
tasks {
13+
jacocoTestCoverageVerification {
14+
violationRules {
15+
rule {
16+
limit {
17+
counter = "INSTRUCTION"
18+
value = "COVEREDRATIO"
19+
minimum = "0.76".toBigDecimal()
20+
}
21+
limit {
22+
counter = "BRANCH"
23+
value = "COVEREDRATIO"
24+
minimum = "0.50".toBigDecimal()
25+
}
26+
}
27+
}
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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.apq.cache
18+
19+
import graphql.ExecutionInput
20+
import graphql.execution.preparsed.PreparsedDocumentEntry
21+
import graphql.execution.preparsed.persisted.PersistedQueryCache
22+
import graphql.execution.preparsed.persisted.PersistedQueryCacheMiss
23+
import java.util.concurrent.CompletableFuture
24+
25+
interface AutomaticPersistedQueriesCache : PersistedQueryCache {
26+
27+
@Deprecated(
28+
message = "deprecated in favor of async retrieval of PreparsedDocumentEntry",
29+
replaceWith = ReplaceWith("getPersistedQueryDocumentAsync(persistedQueryId, executionInput, onCacheMiss)")
30+
)
31+
override fun getPersistedQueryDocument(
32+
persistedQueryId: Any,
33+
executionInput: ExecutionInput,
34+
onCacheMiss: PersistedQueryCacheMiss
35+
): PreparsedDocumentEntry =
36+
getPersistedQueryDocumentAsync(persistedQueryId, executionInput, onCacheMiss).get()
37+
38+
override fun getPersistedQueryDocumentAsync(
39+
persistedQueryId: Any,
40+
executionInput: ExecutionInput,
41+
onCacheMiss: PersistedQueryCacheMiss
42+
): CompletableFuture<PreparsedDocumentEntry> =
43+
getOrElse(persistedQueryId.toString()) {
44+
onCacheMiss.apply(executionInput.query)
45+
}
46+
47+
/**
48+
* Get the [PreparsedDocumentEntry] associated with the [key] from the cache.
49+
*
50+
* If the [PreparsedDocumentEntry] is missing in the cache, the [supplier] will provide one,
51+
* and then it should be added to the cache.
52+
*
53+
* @param key The hash of the requested query.
54+
* @param supplier that will provide the document in case there is a cache miss.
55+
*/
56+
fun getOrElse(
57+
key: String,
58+
supplier: () -> PreparsedDocumentEntry
59+
): CompletableFuture<PreparsedDocumentEntry>
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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.apq.cache
18+
19+
import graphql.execution.preparsed.PreparsedDocumentEntry
20+
import java.util.concurrent.CompletableFuture
21+
import java.util.concurrent.ConcurrentHashMap
22+
23+
class DefaultAutomaticPersistedQueriesCache : AutomaticPersistedQueriesCache {
24+
25+
private val cache: ConcurrentHashMap<String, PreparsedDocumentEntry> = ConcurrentHashMap()
26+
27+
override fun getOrElse(
28+
key: String,
29+
supplier: () -> PreparsedDocumentEntry
30+
): CompletableFuture<PreparsedDocumentEntry> =
31+
cache[key]?.let { entry ->
32+
CompletableFuture.completedFuture(entry)
33+
} ?: run {
34+
val entry = supplier.invoke()
35+
cache[key] = entry
36+
CompletableFuture.completedFuture(supplier.invoke())
37+
}
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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.apq.extensions
18+
19+
import com.expediagroup.graphql.apq.provider.AutomaticPersistedQueriesExtension
20+
import graphql.ExecutionInput
21+
import java.math.BigInteger
22+
import java.nio.charset.StandardCharsets
23+
import java.security.MessageDigest
24+
25+
internal const val APQ_EXTENSION_KEY: String = "persistedQuery"
26+
internal val MESSAGE_DIGEST: MessageDigest = MessageDigest.getInstance("SHA-256")
27+
28+
@Suppress("UNCHECKED_CAST")
29+
fun ExecutionInput.getAutomaticPersistedQueriesExtension(): AutomaticPersistedQueriesExtension? =
30+
try {
31+
(this.extensions[APQ_EXTENSION_KEY] as? Map<String, Any?>)?.let(::AutomaticPersistedQueriesExtension)
32+
} catch (e: NoSuchElementException) {
33+
// could not create persistedQuery extension
34+
null
35+
}
36+
37+
fun ExecutionInput.getQueryId(): String =
38+
String.format(
39+
"%064x",
40+
BigInteger(1, MESSAGE_DIGEST.digest(this.query.toByteArray(StandardCharsets.UTF_8)))
41+
).also {
42+
MESSAGE_DIGEST.reset()
43+
}
44+
45+
fun ExecutionInput.isAutomaticPersistedQueriesExtensionInvalid(
46+
extension: AutomaticPersistedQueriesExtension
47+
): Boolean =
48+
!this.getQueryId().equals(extension.sha256Hash, ignoreCase = true)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.expediagroup.graphql.apq.provider
2+
3+
class AutomaticPersistedQueriesExtension(map: Map<String, Any?>) {
4+
val version: Int by map
5+
val sha256Hash: String by map
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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.apq.provider
18+
19+
import com.expediagroup.graphql.apq.cache.AutomaticPersistedQueriesCache
20+
import com.expediagroup.graphql.apq.extensions.getAutomaticPersistedQueriesExtension
21+
import com.expediagroup.graphql.apq.extensions.getQueryId
22+
import com.expediagroup.graphql.apq.extensions.isAutomaticPersistedQueriesExtensionInvalid
23+
import graphql.ExecutionInput
24+
import graphql.GraphqlErrorBuilder
25+
import graphql.execution.preparsed.PreparsedDocumentEntry
26+
import graphql.execution.preparsed.PreparsedDocumentProvider
27+
import graphql.execution.preparsed.persisted.PersistedQueryError
28+
import graphql.execution.preparsed.persisted.PersistedQueryIdInvalid
29+
import graphql.execution.preparsed.persisted.PersistedQueryNotFound
30+
import java.util.concurrent.CompletableFuture
31+
import java.util.function.Function
32+
33+
class AutomaticPersistedQueriesProvider(
34+
private val cache: AutomaticPersistedQueriesCache
35+
) : PreparsedDocumentProvider {
36+
37+
@Deprecated(
38+
"deprecated in favor of async retrieval of Document",
39+
ReplaceWith("this.getDocumentAsync(executionInput, parseAndValidateFunction).get()")
40+
)
41+
override fun getDocument(
42+
executionInput: ExecutionInput,
43+
parseAndValidateFunction: Function<ExecutionInput, PreparsedDocumentEntry>
44+
): PreparsedDocumentEntry =
45+
this.getDocumentAsync(
46+
executionInput,
47+
parseAndValidateFunction
48+
).get()
49+
50+
override fun getDocumentAsync(
51+
executionInput: ExecutionInput,
52+
parseAndValidateFunction: Function<ExecutionInput, PreparsedDocumentEntry>
53+
): CompletableFuture<PreparsedDocumentEntry> =
54+
try {
55+
executionInput.getAutomaticPersistedQueriesExtension()?.let { apqExtension ->
56+
cache.getPersistedQueryDocumentAsync(apqExtension.sha256Hash, executionInput) { query ->
57+
when {
58+
query.isBlank() -> {
59+
throw PersistedQueryNotFound(apqExtension.sha256Hash)
60+
}
61+
executionInput.isAutomaticPersistedQueriesExtensionInvalid(apqExtension) -> {
62+
throw PersistedQueryIdInvalid(apqExtension.sha256Hash)
63+
}
64+
else -> {
65+
parseAndValidateFunction.apply(
66+
executionInput.transform { builder -> builder.query(query) }
67+
)
68+
}
69+
}
70+
}
71+
} ?: run {
72+
// no apqExtension, not a persisted query,
73+
// but we still want to cache the parsed and validated document
74+
cache.getOrElse(executionInput.getQueryId()) {
75+
parseAndValidateFunction.apply(executionInput)
76+
}
77+
}
78+
} catch (persistedQueryError: PersistedQueryError) {
79+
CompletableFuture.completedFuture(
80+
PreparsedDocumentEntry(
81+
GraphqlErrorBuilder.newError()
82+
.errorType(persistedQueryError)
83+
.message(persistedQueryError.message)
84+
.extensions(
85+
when (persistedQueryError) {
86+
// persistedQueryError.getExtensions()
87+
// Cannot access 'getExtensions': it is package-private in 'PersistedQueryError'
88+
is PersistedQueryNotFound -> persistedQueryError.extensions
89+
is PersistedQueryIdInvalid -> persistedQueryError.extensions
90+
else -> emptyMap()
91+
}
92+
).build()
93+
)
94+
)
95+
}
96+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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.apq.fixture
18+
19+
data class Product(
20+
val id: Int,
21+
val summary: ProductSummary,
22+
val details: ProductDetails
23+
)
24+
25+
data class ProductSummary(val name: String)
26+
data class ProductDetails(val rating: String)

0 commit comments

Comments
 (0)