Skip to content

Commit

Permalink
Merge pull request #2104 from Netflix/feature/dgs-headers-backward-co…
Browse files Browse the repository at this point in the history
…mpatibility

Dgs headers backward compatibility
  • Loading branch information
paulbakker authored Jan 8, 2025
2 parents 6dccb42 + 06bb0d7 commit 7a65cde
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 166 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.netflix.graphql.dgs.DgsDataLoaderCustomizer
import com.netflix.graphql.dgs.DgsDataLoaderInstrumentation
import com.netflix.graphql.dgs.DgsDataLoaderOptionsProvider
import com.netflix.graphql.dgs.DgsDefaultPreparsedDocumentProvider
import com.netflix.graphql.dgs.DgsExecutionResult
import com.netflix.graphql.dgs.DgsFederationResolver
import com.netflix.graphql.dgs.DgsQueryExecutor
import com.netflix.graphql.dgs.DgsRuntimeWiring
Expand Down Expand Up @@ -102,6 +103,8 @@ import org.springframework.graphql.execution.RuntimeWiringConfigurer
import org.springframework.graphql.execution.SchemaReport
import org.springframework.graphql.execution.SelfDescribingDataFetcher
import org.springframework.graphql.execution.SubscriptionExceptionResolver
import org.springframework.graphql.server.WebGraphQlInterceptor
import org.springframework.graphql.server.WebGraphQlResponse
import org.springframework.http.HttpHeaders
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.web.bind.support.WebDataBinderFactory
Expand Down Expand Up @@ -520,6 +523,30 @@ open class DgsSpringGraphQLAutoConfiguration(
graphQLContextContributors,
)

/**
* Backward compatibility for setting response headers through a "dgs-response-headers" field in extensions, or using DgsExecutionResult.
* While this can easily be done through a custom WebGraphQlInterceptor, this bean provides backward compatibility with older code.
*/
@Bean
@ConditionalOnProperty(
prefix = "${AUTO_CONF_PREFIX}.dgs-response-headers",
name = ["enabled"],
havingValue = "true",
matchIfMissing = true,
)
open fun dgsHeadersInterceptor(): WebGraphQlInterceptor =
WebGraphQlInterceptor { request, chain ->
chain.next(request).doOnNext { response: WebGraphQlResponse ->
val responseHeadersExtension = response.extensions["dgs-response-headers"]
if (responseHeadersExtension is HttpHeaders) {
response.responseHeaders.addAll(responseHeadersExtension)
}
if (response.executionResult is DgsExecutionResult) {
response.responseHeaders.addAll((response.executionResult as DgsExecutionResult).headers)
}
}
}

@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
open class WebMvcConfiguration(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* Copyright 2025 Netflix, 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
*
* http://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.netflix.graphql.dgs.springgraphql.autoconfig

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.netflix.graphql.dgs.DgsComponent
import com.netflix.graphql.dgs.DgsExecutionResult
import com.netflix.graphql.dgs.DgsQuery
import graphql.ExecutionResult
import graphql.execution.instrumentation.InstrumentationState
import graphql.execution.instrumentation.SimplePerformantInstrumentation
import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration
import org.springframework.boot.autoconfigure.graphql.servlet.GraphQlWebMvcAutoConfiguration
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.post
import java.util.concurrent.CompletableFuture

@SpringBootTest(
classes = [
DgsHeadersSmokeTest.TestApp::class,
DgsSpringGraphQLAutoConfiguration::class,
GraphQlAutoConfiguration::class,
GraphQlWebMvcAutoConfiguration::class,
WebMvcAutoConfiguration::class,
],
properties = [
"dgs.graphql.schema-locations=classpath:/dgs-spring-graphql-smoke-test.graphqls",
],
)
@AutoConfigureMockMvc
class DgsHeadersSmokeTest {
@Autowired
lateinit var mockMvc: MockMvc

@Test
fun `Response headers can be set by using DgsExecutionResult`() {
val query =
"""
query {
dgsField
}
""".trimIndent()

data class GraphQlRequest(
val query: String,
)

mockMvc
.post("/graphql") {
content = jacksonObjectMapper().writeValueAsString(GraphQlRequest(query))
accept = MediaType.APPLICATION_JSON
contentType = MediaType.APPLICATION_JSON
}.andExpect {
status { isOk() }
header { string("dgsexecutionresultheader", "set from DgsExecutionResult") }
header { string("extensionheader", "set from extensions") }
}
}

@TestConfiguration
open class TestApp {
@DgsComponent
open class DgsTestDatafetcher {
@DgsQuery
fun dgsField(): String = "test from DGS"

@Component
open class MyIntrospection : SimplePerformantInstrumentation() {
override fun instrumentExecutionResult(
executionResult: ExecutionResult,
parameters: InstrumentationExecutionParameters,
state: InstrumentationState?,
): CompletableFuture<ExecutionResult> {
val headers = HttpHeaders()
headers.add("dgsexecutionresultheader", "set from DgsExecutionResult")

val extensionHeaders = HttpHeaders()
extensionHeaders.add("extensionheader", "set from extensions")
val updatedExecutionResult =
executionResult.transform { r ->
r.extensions(
mapOf(
Pair(
"dgs-response-headers",
extensionHeaders,
),
),
)
}

return CompletableFuture.completedFuture(
DgsExecutionResult
.builder()
.executionResult(updatedExecutionResult)
.headers(headers)
.build(),
)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,81 +18,14 @@ package com.netflix.graphql.dgs

import graphql.ExecutionResult
import graphql.ExecutionResultImpl
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity

class DgsExecutionResult(
private val executionResult: ExecutionResult,
private var headers: HttpHeaders,
val status: HttpStatus,
val headers: HttpHeaders,
val status: HttpStatus = HttpStatus.OK,
) : ExecutionResult by executionResult {
init {
addExtensionsHeaderKeyToHeader()
}

/** Read-Only reference to the HTTP Headers. */
fun headers(): HttpHeaders = HttpHeaders.readOnlyHttpHeaders(headers)

fun toSpringResponse(): ResponseEntity<Any> =
ResponseEntity(
toSpecification(),
headers,
status,
)

// Refer to https://github.com/Netflix/dgs-framework/pull/1261 for further details.
override fun toSpecification(): MutableMap<String, Any> {
val spec = executionResult.toSpecification()

val extensions =
spec["extensions"] as Map<*, *>?
?: return spec

if (DGS_RESPONSE_HEADERS_KEY in extensions) {
if (extensions.size == 1) {
spec -= "extensions"
} else {
spec["extensions"] = extensions - DGS_RESPONSE_HEADERS_KEY
}
}

return spec
}

// Refer to https://github.com/Netflix/dgs-framework/pull/1261 for further details.
private fun addExtensionsHeaderKeyToHeader() {
val extensions =
executionResult.extensions
?: return

val dgsResponseHeaders =
extensions[DGS_RESPONSE_HEADERS_KEY]
?: return

if (dgsResponseHeaders is Map<*, *> && dgsResponseHeaders.isNotEmpty()) {
// If the HttpHeaders are empty/read-only we need to switch to a new instance that allows us
// to store the headers that are part of the GraphQL response _extensions_.

val updatedHeaders = HttpHeaders.writableHttpHeaders(headers)

dgsResponseHeaders.forEach { (key, value) ->
if (key != null) {
updatedHeaders.add(key.toString(), value?.toString())
}
}
headers = HttpHeaders.readOnlyHttpHeaders(updatedHeaders)
} else {
logger.warn(
"{} must be of type java.util.Map, but was {}",
DGS_RESPONSE_HEADERS_KEY,
dgsResponseHeaders.javaClass.name,
)
}
}

/**
* Facilitate the construction of a [DgsExecutionResult] instance.
*/
Expand Down Expand Up @@ -128,10 +61,6 @@ class DgsExecutionResult(
}

companion object {
// defined in here and DgsRestController, for backwards compatibility. Keep these two variables synced.
const val DGS_RESPONSE_HEADERS_KEY = "dgs-response-headers"
private val logger: Logger = LoggerFactory.getLogger(DgsExecutionResult::class.java)

@JvmStatic
fun builder(): Builder = Builder()
}
Expand Down

This file was deleted.

0 comments on commit 7a65cde

Please sign in to comment.