diff --git a/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/GraphQLClient.kt b/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/GraphQLClient.kt index 231a4743f4..53a25fa77e 100644 --- a/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/GraphQLClient.kt +++ b/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/GraphQLClient.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 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. @@ -16,6 +16,7 @@ package com.expediagroup.graphql.client +import com.expediagroup.graphql.client.types.AutomaticPersistedQueriesSettings import com.expediagroup.graphql.client.types.GraphQLClientRequest import com.expediagroup.graphql.client.types.GraphQLClientResponse @@ -23,6 +24,7 @@ import com.expediagroup.graphql.client.types.GraphQLClientResponse * A lightweight typesafe GraphQL HTTP client. */ interface GraphQLClient { + val automaticPersistedQueriesSettings: AutomaticPersistedQueriesSettings /** * Executes [GraphQLClientRequest] and returns corresponding [GraphQLClientResponse]. diff --git a/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/extensions/AutomaticPersistedQueriesExtensions.kt b/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/extensions/AutomaticPersistedQueriesExtensions.kt new file mode 100644 index 0000000000..eedc787f72 --- /dev/null +++ b/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/extensions/AutomaticPersistedQueriesExtensions.kt @@ -0,0 +1,25 @@ +package com.expediagroup.graphql.client.extensions + +import com.expediagroup.graphql.client.types.AutomaticPersistedQueriesExtension +import com.expediagroup.graphql.client.types.GraphQLClientRequest +import java.math.BigInteger +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +internal val MESSAGE_DIGEST: MessageDigest = MessageDigest.getInstance("SHA-256") + +fun GraphQLClientRequest<*>.getQueryId(): String = + String.format( + "%064x", + BigInteger(1, MESSAGE_DIGEST.digest(this.query?.toByteArray(StandardCharsets.UTF_8))) + ).also { + MESSAGE_DIGEST.reset() + } + +fun AutomaticPersistedQueriesExtension.toQueryParamString() = """{"persistedQuery":{"version":$version,"sha256Hash":"$sha256Hash"}}""" +fun AutomaticPersistedQueriesExtension.toExtentionsBodyMap() = mapOf( + "persistedQuery" to mapOf( + "version" to version, + "sha256Hash" to sha256Hash + ) +) diff --git a/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/types/AutomaticPersistedQueriesExtension.kt b/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/types/AutomaticPersistedQueriesExtension.kt new file mode 100644 index 0000000000..2c26dbcfa7 --- /dev/null +++ b/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/types/AutomaticPersistedQueriesExtension.kt @@ -0,0 +1,22 @@ +/* + * 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.client.types + +data class AutomaticPersistedQueriesExtension( + val version: Int, + val sha256Hash: String +) diff --git a/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/types/AutomaticPersistedQueriesSettings.kt b/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/types/AutomaticPersistedQueriesSettings.kt new file mode 100644 index 0000000000..26f28ad3a3 --- /dev/null +++ b/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/types/AutomaticPersistedQueriesSettings.kt @@ -0,0 +1,30 @@ +/* + * 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.client.types + +data class AutomaticPersistedQueriesSettings( + val enabled: Boolean = false, + val httpMethod: HttpMethod = HttpMethod.GET +) { + companion object { + const val VERSION: Int = 1 + } + sealed class HttpMethod { + object GET : HttpMethod() + object POST : HttpMethod() + } +} diff --git a/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/types/GraphQLClientRequest.kt b/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/types/GraphQLClientRequest.kt index 943642e0e8..a9ef6a68ca 100644 --- a/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/types/GraphQLClientRequest.kt +++ b/clients/graphql-kotlin-client/src/main/kotlin/com/expediagroup/graphql/client/types/GraphQLClientRequest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 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. @@ -24,11 +24,14 @@ import kotlin.reflect.KClass * @see [GraphQL Over HTTP](https://graphql.org/learn/serving-over-http/#post-request) for additional details */ interface GraphQLClientRequest { - val query: String + val query: String? + get() = null val operationName: String? get() = null val variables: Any? get() = null + val extensions: Map? + get() = null /** * Parameterized type of a corresponding GraphQLResponse. diff --git a/clients/graphql-kotlin-ktor-client/src/main/kotlin/com/expediagroup/graphql/client/ktor/GraphQLKtorClient.kt b/clients/graphql-kotlin-ktor-client/src/main/kotlin/com/expediagroup/graphql/client/ktor/GraphQLKtorClient.kt index 57c8d172eb..f7e4a9e36d 100644 --- a/clients/graphql-kotlin-ktor-client/src/main/kotlin/com/expediagroup/graphql/client/ktor/GraphQLKtorClient.kt +++ b/clients/graphql-kotlin-ktor-client/src/main/kotlin/com/expediagroup/graphql/client/ktor/GraphQLKtorClient.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. @@ -17,8 +17,13 @@ package com.expediagroup.graphql.client.ktor import com.expediagroup.graphql.client.GraphQLClient +import com.expediagroup.graphql.client.extensions.getQueryId +import com.expediagroup.graphql.client.extensions.toExtentionsBodyMap +import com.expediagroup.graphql.client.extensions.toQueryParamString import com.expediagroup.graphql.client.serializer.GraphQLClientSerializer import com.expediagroup.graphql.client.serializer.defaultGraphQLSerializer +import com.expediagroup.graphql.client.types.AutomaticPersistedQueriesExtension +import com.expediagroup.graphql.client.types.AutomaticPersistedQueriesSettings import com.expediagroup.graphql.client.types.GraphQLClientRequest import com.expediagroup.graphql.client.types.GraphQLClientResponse import io.ktor.client.HttpClient @@ -26,9 +31,13 @@ import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.expectSuccess import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.content.TextContent import java.io.Closeable import java.net.URL @@ -39,16 +48,90 @@ import java.net.URL open class GraphQLKtorClient( private val url: URL, private val httpClient: HttpClient = HttpClient(engineFactory = CIO), - private val serializer: GraphQLClientSerializer = defaultGraphQLSerializer() + private val serializer: GraphQLClientSerializer = defaultGraphQLSerializer(), + override val automaticPersistedQueriesSettings: AutomaticPersistedQueriesSettings = AutomaticPersistedQueriesSettings() ) : GraphQLClient, Closeable { override suspend fun execute(request: GraphQLClientRequest, requestCustomizer: HttpRequestBuilder.() -> Unit): GraphQLClientResponse { - val rawResult: String = httpClient.post(url) { - expectSuccess = true - apply(requestCustomizer) - setBody(TextContent(serializer.serialize(request), ContentType.Application.Json)) - }.body() - return serializer.deserialize(rawResult, request.responseType()) + return if (automaticPersistedQueriesSettings.enabled) { + val queryId = request.getQueryId() + val automaticPersistedQueriesExtension = AutomaticPersistedQueriesExtension( + version = AutomaticPersistedQueriesSettings.VERSION, + sha256Hash = queryId + ) + val extensions = request.extensions?.let { + automaticPersistedQueriesExtension.toExtentionsBodyMap().plus(it) + } ?: automaticPersistedQueriesExtension.toExtentionsBodyMap() + + val apqRawResultWithoutQuery: String = when (automaticPersistedQueriesSettings.httpMethod) { + is AutomaticPersistedQueriesSettings.HttpMethod.GET -> { + httpClient + .get(url) { + expectSuccess = true + header(HttpHeaders.ContentType, ContentType.Application.FormUrlEncoded) + accept(ContentType.Application.Json) + url { + parameters.append("extension", automaticPersistedQueriesExtension.toQueryParamString()) + } + }.body() + } + + is AutomaticPersistedQueriesSettings.HttpMethod.POST -> { + val requestWithoutQuery = object : GraphQLClientRequest by request { + override val query = null + override val extensions = extensions + } + httpClient + .post(url) { + expectSuccess = true + apply(requestCustomizer) + accept(ContentType.Application.Json) + setBody(TextContent(serializer.serialize(requestWithoutQuery), ContentType.Application.Json)) + }.body() + } + } + + serializer.deserialize(apqRawResultWithoutQuery, request.responseType()).let { + if (it.errors.isNullOrEmpty() && it.data != null) return it + } + + val apqRawResultWithQuery: String = when (automaticPersistedQueriesSettings.httpMethod) { + is AutomaticPersistedQueriesSettings.HttpMethod.GET -> { + httpClient + .get(url) { + expectSuccess = true + header(HttpHeaders.ContentType, ContentType.Application.FormUrlEncoded) + accept(ContentType.Application.Json) + url { + parameters.append("query", serializer.serialize(request)) + parameters.append("extension", automaticPersistedQueriesExtension.toQueryParamString()) + } + }.body() + } + + is AutomaticPersistedQueriesSettings.HttpMethod.POST -> { + val requestWithQuery = object : GraphQLClientRequest by request { + override val extensions = extensions + } + httpClient + .post(url) { + expectSuccess = true + apply(requestCustomizer) + accept(ContentType.Application.Json) + setBody(TextContent(serializer.serialize(requestWithQuery), ContentType.Application.Json)) + }.body() + } + } + + serializer.deserialize(apqRawResultWithQuery, request.responseType()) + } else { + val rawResult: String = httpClient.post(url) { + expectSuccess = true + apply(requestCustomizer) + setBody(TextContent(serializer.serialize(request), ContentType.Application.Json)) + }.body() + serializer.deserialize(rawResult, request.responseType()) + } } override suspend fun execute(requests: List>, requestCustomizer: HttpRequestBuilder.() -> Unit): List> { diff --git a/clients/graphql-kotlin-ktor-client/src/test/kotlin/com/expediagroup/graphql/client/ktor/GraphQLKtorClientTest.kt b/clients/graphql-kotlin-ktor-client/src/test/kotlin/com/expediagroup/graphql/client/ktor/GraphQLKtorClientTest.kt index ddda194b53..ebd64aad66 100644 --- a/clients/graphql-kotlin-ktor-client/src/test/kotlin/com/expediagroup/graphql/client/ktor/GraphQLKtorClientTest.kt +++ b/clients/graphql-kotlin-ktor-client/src/test/kotlin/com/expediagroup/graphql/client/ktor/GraphQLKtorClientTest.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. @@ -25,6 +25,7 @@ import com.expediagroup.graphql.client.serialization.serializers.AnyKSerializer import com.expediagroup.graphql.client.serialization.types.KotlinxGraphQLError import com.expediagroup.graphql.client.serialization.types.KotlinxGraphQLResponse import com.expediagroup.graphql.client.serialization.types.KotlinxGraphQLSourceLocation +import com.expediagroup.graphql.client.types.AutomaticPersistedQueriesSettings import com.expediagroup.graphql.client.types.GraphQLClientRequest import com.expediagroup.graphql.client.types.GraphQLClientResponse import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @@ -32,6 +33,7 @@ import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.MappingBuilder import com.github.tomakehurst.wiremock.client.WireMock import com.github.tomakehurst.wiremock.core.WireMockConfiguration +import com.github.tomakehurst.wiremock.matching.EqualToJsonPattern import com.github.tomakehurst.wiremock.matching.EqualToPattern import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp @@ -240,6 +242,216 @@ class GraphQLKtorClientTest { } } + @Test + fun `verifies spring web client can execute query using GET with persisted query`() { + val expectedResponse = JacksonGraphQLResponse(data = HelloWorldResult("Hello World!")) + WireMock.stubFor( + WireMock + .get("""/graphql?extension=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae%22%7D%7D""") + .withHeader("Content-Type", EqualToPattern("application/x-www-form-urlencoded")) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(expectedResponse)) + ) + ) + + val client = GraphQLKtorClient( + url = URL("${wireMockServer.baseUrl()}/graphql"), + serializer = GraphQLClientJacksonSerializer(), + automaticPersistedQueriesSettings = AutomaticPersistedQueriesSettings(enabled = true) + ) + + runBlocking { + val result: GraphQLClientResponse = client.execute(HelloWorldQuery()) + + assertNotNull(result) + assertNotNull(result.data) + assertEquals(expectedResponse.data?.helloWorld, result.data?.helloWorld) + assertNull(result.errors) + assertNull(result.extensions) + } + } + + @Test + fun `verifies spring web client can execute query using POST with persisted query`() { + val expectedResponse = JacksonGraphQLResponse(data = HelloWorldResult("Hello World!")) + WireMock.stubFor( + WireMock + .post("/graphql") + .withHeader("Content-Type", EqualToPattern("application/json")) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(expectedResponse)) + ) + ) + + val client = GraphQLKtorClient( + url = URL("${wireMockServer.baseUrl()}/graphql"), + serializer = GraphQLClientJacksonSerializer(), + automaticPersistedQueriesSettings = AutomaticPersistedQueriesSettings(enabled = true, httpMethod = AutomaticPersistedQueriesSettings.HttpMethod.POST) + ) + + runBlocking { + val result: GraphQLClientResponse = client.execute(HelloWorldQuery()) + + assertNotNull(result) + assertNotNull(result.data) + assertEquals(expectedResponse.data?.helloWorld, result.data?.helloWorld) + assertNull(result.errors) + assertNull(result.extensions) + } + } + + @Test + fun `verifies spring web client can execute query using GET with not persisted query`() { + val expectedErrorResponse = JacksonGraphQLResponse( + data = null, + errors = listOf( + JacksonGraphQLError( + message = "PersistedQueryNotFound" + ) + ), + extensions = mapOf("persistedQueryId" to "dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae", "classification" to "PersistedQueryNotFound") + ) + WireMock.stubFor( + WireMock + .get("""/graphql?extension=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae%22%7D%7D""") + .withHeader("Content-Type", EqualToPattern("application/x-www-form-urlencoded")) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(expectedErrorResponse)) + ) + ) + + val expectedResponse = JacksonGraphQLResponse(data = HelloWorldResult("Hello World!")) + WireMock.stubFor( + WireMock + .get( + """ + |/graphql?query=%7B%22query%22%3A%22query+HelloWorldQuery+%7B+helloWorld+%7D%22%2C%22operationName%22%3A%22HelloWorld%22%7D& + |extension=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae%22%7D%7D + """.trimMargin().replace("\n", "") + ) + .withHeader("Content-Type", EqualToPattern("application/x-www-form-urlencoded")) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(expectedResponse)) + ) + ) + + val client = GraphQLKtorClient( + url = URL("${wireMockServer.baseUrl()}/graphql"), + serializer = GraphQLClientJacksonSerializer(), + automaticPersistedQueriesSettings = AutomaticPersistedQueriesSettings(enabled = true) + ) + + runBlocking { + val result: GraphQLClientResponse = client.execute(HelloWorldQuery()) + + assertNotNull(result) + assertNotNull(result.data) + assertEquals(expectedResponse.data?.helloWorld, result.data?.helloWorld) + assertNull(result.errors) + assertNull(result.extensions) + } + } + + @Test + fun `verifies spring web client can execute query using POST with not persisted query`() { + val expectedErrorResponse = JacksonGraphQLResponse( + data = null, + errors = listOf( + JacksonGraphQLError( + message = "PersistedQueryNotFound" + ) + ), + extensions = mapOf("persistedQueryId" to "dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae", "classification" to "PersistedQueryNotFound") + ) + WireMock.stubFor( + WireMock + .post("/graphql") + .withHeader("Content-Type", EqualToPattern("application/json")) + .withRequestBody( + EqualToJsonPattern( + """ + { + "operationName":"HelloWorld", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae" + } + } + } + """.trimIndent(), + true, + true + ) + ) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(expectedErrorResponse)) + ) + ) + + val expectedResponse = JacksonGraphQLResponse(data = HelloWorldResult("Hello World!")) + WireMock.stubFor( + WireMock + .post("/graphql") + .withHeader("Content-Type", EqualToPattern("application/json")) + .withRequestBody( + EqualToJsonPattern( + """ + { + "query": "query HelloWorldQuery { helloWorld }", + "operationName":"HelloWorld", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae" + } + } + } + """.trimIndent(), + true, + true + ) + ) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(expectedResponse)) + ) + ) + + val client = GraphQLKtorClient( + url = URL("${wireMockServer.baseUrl()}/graphql"), + serializer = GraphQLClientJacksonSerializer(), + automaticPersistedQueriesSettings = AutomaticPersistedQueriesSettings(enabled = true, httpMethod = AutomaticPersistedQueriesSettings.HttpMethod.POST) + ) + + runBlocking { + val result: GraphQLClientResponse = client.execute(HelloWorldQuery()) + + assertNotNull(result) + assertNotNull(result.data) + assertEquals(expectedResponse.data?.helloWorld, result.data?.helloWorld) + assertNull(result.errors) + assertNull(result.extensions) + } + } + @Test fun `verifies Non-OK HTTP responses will throw error`() { WireMock.stubFor( diff --git a/clients/graphql-kotlin-spring-client/src/main/kotlin/com/expediagroup/graphql/client/spring/GraphQLWebClient.kt b/clients/graphql-kotlin-spring-client/src/main/kotlin/com/expediagroup/graphql/client/spring/GraphQLWebClient.kt index b8a65003d9..1518f5b446 100644 --- a/clients/graphql-kotlin-spring-client/src/main/kotlin/com/expediagroup/graphql/client/spring/GraphQLWebClient.kt +++ b/clients/graphql-kotlin-spring-client/src/main/kotlin/com/expediagroup/graphql/client/spring/GraphQLWebClient.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 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. @@ -17,13 +17,22 @@ package com.expediagroup.graphql.client.spring import com.expediagroup.graphql.client.GraphQLClient +import com.expediagroup.graphql.client.extensions.getQueryId +import com.expediagroup.graphql.client.extensions.toExtentionsBodyMap +import com.expediagroup.graphql.client.extensions.toQueryParamString import com.expediagroup.graphql.client.serializer.GraphQLClientSerializer import com.expediagroup.graphql.client.serializer.defaultGraphQLSerializer +import com.expediagroup.graphql.client.types.AutomaticPersistedQueriesExtension +import com.expediagroup.graphql.client.types.AutomaticPersistedQueriesSettings import com.expediagroup.graphql.client.types.GraphQLClientRequest import com.expediagroup.graphql.client.types.GraphQLClientResponse import kotlinx.coroutines.reactive.awaitSingle +import org.springframework.http.HttpHeaders import org.springframework.http.MediaType +import org.springframework.web.reactive.function.client.ClientRequest import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody +import java.net.URI /** * A lightweight typesafe GraphQL HTTP client using Spring WebClient engine. @@ -31,21 +40,109 @@ import org.springframework.web.reactive.function.client.WebClient open class GraphQLWebClient( url: String, private val serializer: GraphQLClientSerializer = defaultGraphQLSerializer(), - builder: WebClient.Builder = WebClient.builder() + builder: WebClient.Builder = WebClient.builder(), + override val automaticPersistedQueriesSettings: AutomaticPersistedQueriesSettings = AutomaticPersistedQueriesSettings() ) : GraphQLClient { - private val client: WebClient = builder.baseUrl(url).build() + private val client: WebClient = builder + .baseUrl(url) + .filter { request, next -> + val encodedUri = request.url().toString().replace("%20", "+") + val filtered = ClientRequest + .from(request) + .url(URI.create(encodedUri)) + .build() + next.exchange(filtered) + }.build() override suspend fun execute(request: GraphQLClientRequest, requestCustomizer: WebClient.RequestBodyUriSpec.() -> Unit): GraphQLClientResponse { - val rawResult = client.post() - .apply(requestCustomizer) - .accept(MediaType.APPLICATION_JSON) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(serializer.serialize(request)) - .retrieve() - .bodyToMono(String::class.java) - .awaitSingle() - return serializer.deserialize(rawResult, request.responseType()) + return if (automaticPersistedQueriesSettings.enabled) { + val queryId = request.getQueryId() + val automaticPersistedQueriesExtension = AutomaticPersistedQueriesExtension( + version = AutomaticPersistedQueriesSettings.VERSION, + sha256Hash = queryId + ) + val extensions = request.extensions?.let { + automaticPersistedQueriesExtension.toExtentionsBodyMap().plus(it) + } ?: automaticPersistedQueriesExtension.toExtentionsBodyMap() + + val apqRawResultWithoutQuery: String = when (automaticPersistedQueriesSettings.httpMethod) { + is AutomaticPersistedQueriesSettings.HttpMethod.GET -> { + client + .get() + .uri { + it.queryParam("extension", "{extension}").build(automaticPersistedQueriesExtension.toQueryParamString()) + } + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .awaitBody() + } + + is AutomaticPersistedQueriesSettings.HttpMethod.POST -> { + val requestWithoutQuery = object : GraphQLClientRequest by request { + override val query = null + override val extensions = extensions + } + client + .post() + .apply(requestCustomizer) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(serializer.serialize(requestWithoutQuery)) + .retrieve() + .awaitBody() + } + } + + serializer.deserialize(apqRawResultWithoutQuery, request.responseType()).let { + if (it.errors.isNullOrEmpty() && it.data != null) return it + } + + val apqRawResultWithQuery: String = when (automaticPersistedQueriesSettings.httpMethod) { + is AutomaticPersistedQueriesSettings.HttpMethod.GET -> { + client + .get() + .uri { + it + .queryParam("query", "{query}") + .queryParam("extension", "{extension}") + .build(serializer.serialize(request), automaticPersistedQueriesExtension.toQueryParamString()) + } + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .awaitBody() + } + + is AutomaticPersistedQueriesSettings.HttpMethod.POST -> { + val requestWithQuery = object : GraphQLClientRequest by request { + override val extensions = extensions + } + client + .post() + .apply(requestCustomizer) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(serializer.serialize(requestWithQuery)) + .retrieve() + .awaitBody() + } + } + + serializer.deserialize(apqRawResultWithQuery, request.responseType()) + } else { + val rawResult = client.post() + .apply(requestCustomizer) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(serializer.serialize(request)) + .retrieve() + .bodyToMono(String::class.java) + .awaitSingle() + + serializer.deserialize(rawResult, request.responseType()) + } } override suspend fun execute(requests: List>, requestCustomizer: WebClient.RequestBodyUriSpec.() -> Unit): List> { diff --git a/clients/graphql-kotlin-spring-client/src/test/kotlin/com/expediagroup/graphql/client/spring/GraphQLWebClientTest.kt b/clients/graphql-kotlin-spring-client/src/test/kotlin/com/expediagroup/graphql/client/spring/GraphQLWebClientTest.kt index 477d015286..7ebcbdca46 100644 --- a/clients/graphql-kotlin-spring-client/src/test/kotlin/com/expediagroup/graphql/client/spring/GraphQLWebClientTest.kt +++ b/clients/graphql-kotlin-spring-client/src/test/kotlin/com/expediagroup/graphql/client/spring/GraphQLWebClientTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 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. @@ -25,6 +25,7 @@ import com.expediagroup.graphql.client.serialization.serializers.AnyKSerializer import com.expediagroup.graphql.client.serialization.types.KotlinxGraphQLError import com.expediagroup.graphql.client.serialization.types.KotlinxGraphQLResponse import com.expediagroup.graphql.client.serialization.types.KotlinxGraphQLSourceLocation +import com.expediagroup.graphql.client.types.AutomaticPersistedQueriesSettings import com.expediagroup.graphql.client.types.GraphQLClientRequest import com.expediagroup.graphql.client.types.GraphQLClientResponse import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @@ -32,6 +33,7 @@ import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.MappingBuilder import com.github.tomakehurst.wiremock.client.WireMock import com.github.tomakehurst.wiremock.core.WireMockConfiguration +import com.github.tomakehurst.wiremock.matching.EqualToJsonPattern import com.github.tomakehurst.wiremock.matching.EqualToPattern import io.netty.channel.ChannelOption import io.netty.handler.timeout.ReadTimeoutException @@ -247,6 +249,216 @@ class GraphQLWebClientTest { } } + @Test + fun `verifies spring web client can execute query using GET with persisted query`() { + val expectedResponse = JacksonGraphQLResponse(data = HelloWorldResult("Hello World!")) + WireMock.stubFor( + WireMock + .get("""/graphql?extension=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae%22%7D%7D""") + .withHeader("Content-Type", EqualToPattern("application/x-www-form-urlencoded")) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(expectedResponse)) + ) + ) + + val client = GraphQLWebClient( + url = "${wireMockServer.baseUrl()}/graphql", + serializer = GraphQLClientJacksonSerializer(), + automaticPersistedQueriesSettings = AutomaticPersistedQueriesSettings(enabled = true) + ) + + runBlocking { + val result: GraphQLClientResponse = client.execute(HelloWorldQuery()) + + assertNotNull(result) + assertNotNull(result.data) + assertEquals(expectedResponse.data?.helloWorld, result.data?.helloWorld) + assertNull(result.errors) + assertNull(result.extensions) + } + } + + @Test + fun `verifies spring web client can execute query using POST with persisted query`() { + val expectedResponse = JacksonGraphQLResponse(data = HelloWorldResult("Hello World!")) + WireMock.stubFor( + WireMock + .post("/graphql") + .withHeader("Content-Type", EqualToPattern("application/json")) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(expectedResponse)) + ) + ) + + val client = GraphQLWebClient( + url = "${wireMockServer.baseUrl()}/graphql", + serializer = GraphQLClientJacksonSerializer(), + automaticPersistedQueriesSettings = AutomaticPersistedQueriesSettings(enabled = true, httpMethod = AutomaticPersistedQueriesSettings.HttpMethod.POST) + ) + + runBlocking { + val result: GraphQLClientResponse = client.execute(HelloWorldQuery()) + + assertNotNull(result) + assertNotNull(result.data) + assertEquals(expectedResponse.data?.helloWorld, result.data?.helloWorld) + assertNull(result.errors) + assertNull(result.extensions) + } + } + + @Test + fun `verifies spring web client can execute query using GET with not persisted query`() { + val expectedErrorResponse = JacksonGraphQLResponse( + data = null, + errors = listOf( + JacksonGraphQLError( + message = "PersistedQueryNotFound" + ) + ), + extensions = mapOf("persistedQueryId" to "dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae", "classification" to "PersistedQueryNotFound") + ) + WireMock.stubFor( + WireMock + .get("""/graphql?extension=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae%22%7D%7D""") + .withHeader("Content-Type", EqualToPattern("application/x-www-form-urlencoded")) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(expectedErrorResponse)) + ) + ) + + val expectedResponse = JacksonGraphQLResponse(data = HelloWorldResult("Hello World!")) + WireMock.stubFor( + WireMock + .get( + """ + |/graphql?query=%7B%22query%22%3A%22query+HelloWorldQuery+%7B+helloWorld+%7D%22%2C%22operationName%22%3A%22HelloWorld%22%7D& + |extension=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae%22%7D%7D + """.trimMargin().replace("\n", "") + ) + .withHeader("Content-Type", EqualToPattern("application/x-www-form-urlencoded")) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(expectedResponse)) + ) + ) + + val client = GraphQLWebClient( + url = "${wireMockServer.baseUrl()}/graphql", + serializer = GraphQLClientJacksonSerializer(), + automaticPersistedQueriesSettings = AutomaticPersistedQueriesSettings(enabled = true) + ) + + runBlocking { + val result: GraphQLClientResponse = client.execute(HelloWorldQuery()) + + assertNotNull(result) + assertNotNull(result.data) + assertEquals(expectedResponse.data?.helloWorld, result.data?.helloWorld) + assertNull(result.errors) + assertNull(result.extensions) + } + } + + @Test + fun `verifies spring web client can execute query using POST with not persisted query`() { + val expectedErrorResponse = JacksonGraphQLResponse( + data = null, + errors = listOf( + JacksonGraphQLError( + message = "PersistedQueryNotFound" + ) + ), + extensions = mapOf("persistedQueryId" to "dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae", "classification" to "PersistedQueryNotFound") + ) + WireMock.stubFor( + WireMock + .post("/graphql") + .withHeader("Content-Type", EqualToPattern("application/json")) + .withRequestBody( + EqualToJsonPattern( + """ + { + "operationName":"HelloWorld", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae" + } + } + } + """.trimIndent(), + true, + true + ) + ) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(expectedErrorResponse)) + ) + ) + + val expectedResponse = JacksonGraphQLResponse(data = HelloWorldResult("Hello World!")) + WireMock.stubFor( + WireMock + .post("/graphql") + .withHeader("Content-Type", EqualToPattern("application/json")) + .withRequestBody( + EqualToJsonPattern( + """ + { + "query": "query HelloWorldQuery { helloWorld }", + "operationName":"HelloWorld", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "dd79d72356e3cfd09a542b572c3c73e4e8d90c1c7d5c27d74bcff4e7423178ae" + } + } + } + """.trimIndent(), + true, + true + ) + ) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(objectMapper.writeValueAsString(expectedResponse)) + ) + ) + + val client = GraphQLWebClient( + url = "${wireMockServer.baseUrl()}/graphql", + serializer = GraphQLClientJacksonSerializer(), + automaticPersistedQueriesSettings = AutomaticPersistedQueriesSettings(enabled = true, httpMethod = AutomaticPersistedQueriesSettings.HttpMethod.POST) + ) + + runBlocking { + val result: GraphQLClientResponse = client.execute(HelloWorldQuery()) + + assertNotNull(result) + assertNotNull(result.data) + assertEquals(expectedResponse.data?.helloWorld, result.data?.helloWorld) + assertNull(result.errors) + assertNull(result.extensions) + } + } + @Test fun `verifies Non-OK HTTP responses will throw error`() { WireMock.stubFor(