spring-bootgraphql-java

How to set response headers from a resolver in graphql-java


I'm using graphql-java 20.0 (in spring-boot-starter-graphql in Kotlin) and want to add custom headers to my resolver's responses. Currently, my resolvers return just the entity (of type MyEntity) that should be included in the graphql response - which works:

@QueryMapping
fun myResolver(
    @Argument arg1: String,
    @Argument arg2: MyInput,
): MyEntity = service.getMyEntity(arg1, arg2)

I tried changing this to a ResponseEntity as recommended in this SO answer but that's apparently not handled by graphql-java and results in my responses' data being null:

/* this does not work, since graphql-java doe not map the ResponseEntity's body
   and results in a graphql response with null in the data object */
@QueryMapping
fun myResolver(
    @Argument arg1: String,
    @Argument arg2: MyInput,
): ResponseEntity<MyEntity> = ResponseEntity.ok().run {
        this.header("X-My-Header", "whatever")
    }.body(service.getMyEntity(arg1, arg2))

I could not, however, find any alternative that allows me to set custom headers alongside my response. On StackOverflow, I only found answers for Laravel and Astro. On the graphql-spring-boot repo there's a similar question left unanswered since almost two years.

Does anyone know of a way to set custom headers in my graphql resolvers? I need to do this because my headers need to be different based on some request properties.


Solution

  • If someone find's a cleaner solution, please let me know. But for now, this works:

    I created a ThreadLocal to hold the header value:

    object GraphQLMyHeaderThreadLocalStorage {
        private val context = ThreadLocal<String>()
    
        var value: String?
            get() = context.get()
            set(value) = value?.let { context.set(it) } ?: context.remove()
    
        fun clear() = context.remove()
    }
    

    In my resolver, I can now set this ThreadLocal with my request-specific header value:

    @QueryMapping
    fun myResolver(
        @Argument arg1: String,
        @Argument arg2: MyInput,
    ): MyEntity = service.getMyEntity(arg1, arg2).also {
        GraphQLMyHeaderThreadLocalStorage.value = "whatever inferred from ${it}"
    }
    

    And I can still modify my response in a Filter if I wrap it in advance and do the modification after chain.doFilter():

    class GraphQLMyHeaderFilter : Filter {
        @Throws(IOException::class, ServletException::class)
        override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
            if (!response.isCommitted) {
                val responseWrapper = object : HttpServletResponseWrapper(response as HttpServletResponse) {
                    fun updateMyHeader(value: String?) {
                        setHeader("X-My-Header", value ?: "default value")
                    }
                }
                chain.doFilter(request, responseWrapper)
    
                // modify the response after the resolver was called
                if (!response.isCommitted) {
                    val headerValue = try {
                        GraphQLMyHeaderThreadLocalStorage.value
                    } finally {
                        GraphQLMyHeaderThreadLocalStorage.clear()
                    }
                    responseWrapper.updateMyHeader(headerValue)
                }
            } else {
                chain.doFilter(request, response)
            }
        }
    }
    
    @Configuration
    class FilterConfig {
        @Bean
        fun graphQLMyHeaderFilter(): FilterRegistrationBean<GraphQLMyHeaderFilter> {
            val registrationBean = FilterRegistrationBean<GraphQLMyHeaderFilter>()
            registrationBean.filter = GraphQLMyHeaderFilter()
            registrationBean.addUrlPatterns("/graphql")
            return registrationBean
        }
    }
    

    Notes: