Skip to content

Serve HTTP cache headers. #51

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package io.leangen.graphql.spqr.spring.web;

import graphql.cachecontrol.CacheControl;
import org.springframework.http.HttpHeaders;

import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class CacheControlHeader {

/**
* For any response whose overall cache policy has a non-zero maxAge, This method will automatically set the
* Cache-Control HTTP response header to an appropriate value describing the maxAge and scope,
* such as Cache-Control: max-age=60, private.
* https://www.apollographql.com/docs/apollo-server/features/caching/#serving-http-cache-headers
*
* @param response graphql response.
* @param headers response headers.
*/
public static void addCacheControlHeader(Object response, HttpHeaders headers) {
CachePolicy cachePolicy;
if (response instanceof CompletableFuture) {
try {
cachePolicy = computeOverallCacheMaxAge(((CompletableFuture<Map<String, Object>>) response).get());
} catch (ExecutionException | InterruptedException e) {
throw new RuntimeException("", e);
}
} else {
cachePolicy = computeOverallCacheMaxAge((Map<String, Object>) response);
}

if (cachePolicy != null) {
headers.add(HttpHeaders.CACHE_CONTROL, "max-age=" + cachePolicy.getMaxAge() + ", " + cachePolicy.getScope().name().toLowerCase());
}
}

static private <T> T get(
String keyName,
Map<String, Object> executionResult
) {
if (executionResult == null || executionResult.get(keyName) == null) {
return null;
}
return (T) executionResult.get(keyName);
}

// reference https://github.com/apollographql/apollo-server/blob/d5015f4ea00cadb2a74b09956344e6f65c084629/packages/apollo-cache-control/src/index.ts#L180
static private CachePolicy computeOverallCacheMaxAge(
Map<String, Object> executionResult
) {
Map<String, Object> extensions = get("extensions", executionResult);
Map<String, Object> cacheControl = get("cacheControl", extensions);
List<Map<Object, Object>> hints = get("hints", cacheControl);
if (hints == null) {
return null;
}

// find lowest maxAge by hints.
Integer lowestMaxAge = null;
CacheControl.Scope scope = CacheControl.Scope.PUBLIC;
for (Map<Object, Object> hint : hints) {
Integer maxAge = (Integer) hint.get("maxAge");
lowestMaxAge = lowestMaxAge == null ? maxAge : Math.min(maxAge, lowestMaxAge);
if (CacheControl.Scope.PRIVATE.name().equals(hint.get("scope"))) {
scope = CacheControl.Scope.PRIVATE;
}
}

// check all data fields has hints.
Map<String, Object> data = get("data", executionResult);
if (data == null) {
return null;
}
boolean isExistHint = data.entrySet().stream()
.allMatch((entry) -> hints.stream()
.anyMatch((it) -> String.join(".", ((List<String>) it.get("path"))).equals(entry.getKey())));

// if hints don't exists, then return null(not cacheable).
return isExistHint ? new CachePolicy(lowestMaxAge, scope) : null;
}

static class CachePolicy {
private Integer maxAge;
private CacheControl.Scope scope;

CachePolicy(Integer maxAge, CacheControl.Scope scope) {
this.maxAge = maxAge;
this.scope = scope;
}

public Integer getMaxAge() {
return maxAge;
}

public CacheControl.Scope getScope() {
return scope;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

import graphql.GraphQL;
import io.leangen.graphql.spqr.spring.web.dto.GraphQLRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
Expand All @@ -28,50 +31,65 @@ public GraphQLController(GraphQL graphQL, GraphQLExecutor<R> executor) {

@PostMapping(
value = "${graphql.spqr.http.endpoint:/graphql}",
consumes = {MediaType.APPLICATION_JSON_UTF8_VALUE, MediaType.APPLICATION_JSON_VALUE},
consumes = { MediaType.APPLICATION_JSON_UTF8_VALUE, MediaType.APPLICATION_JSON_VALUE },
produces = MediaType.APPLICATION_JSON_UTF8_VALUE
)
@ResponseBody
public Object executeJsonPost(@RequestBody GraphQLRequest requestBody,
GraphQLRequest requestParams,
R request) {
public ResponseEntity<Object> executeJsonPost(@RequestBody GraphQLRequest requestBody,
GraphQLRequest requestParams,
R request) {
String query = requestParams.getQuery() == null ? requestBody.getQuery() : requestParams.getQuery();
String operationName = requestParams.getOperationName() == null ? requestBody.getOperationName() : requestParams.getOperationName();
Map<String, Object> variables = requestParams.getVariables() == null ? requestBody.getVariables() : requestParams.getVariables();
String operationName =
requestParams.getOperationName() == null ? requestBody.getOperationName() : requestParams.getOperationName();

return executor.execute(graphQL, new GraphQLRequest(query, operationName, variables), request);
Object result = executor.execute(graphQL, new GraphQLRequest(query, operationName, variables), request);

HttpHeaders headers = new HttpHeaders();
CacheControlHeader.addCacheControlHeader(result, headers);
return new ResponseEntity<>(result, headers, HttpStatus.OK);
}

@PostMapping(
value = "${graphql.spqr.http.endpoint:/graphql}",
consumes = {"application/graphql", "application/graphql;charset=UTF-8"},
consumes = { "application/graphql", "application/graphql;charset=UTF-8" },
produces = MediaType.APPLICATION_JSON_UTF8_VALUE
)
@ResponseBody
public Object executeGraphQLPost(@RequestBody String queryBody,
GraphQLRequest graphQLRequest,
R request) {
public ResponseEntity<Object> executeGraphQLPost(@RequestBody String queryBody,
GraphQLRequest graphQLRequest,
R request) {
String query = graphQLRequest.getQuery() == null ? queryBody : graphQLRequest.getQuery();
return executor.execute(graphQL, new GraphQLRequest(query, graphQLRequest.getOperationName(), graphQLRequest.getVariables()), request);
Object result = executor.execute(graphQL,
new GraphQLRequest(query, graphQLRequest.getOperationName(), graphQLRequest.getVariables()), request);

HttpHeaders headers = new HttpHeaders();
CacheControlHeader.addCacheControlHeader(result, headers);
return new ResponseEntity<>(result, headers, HttpStatus.OK);
}

@RequestMapping(
method = RequestMethod.POST,
value = "${graphql.spqr.http.endpoint:/graphql}",
consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE, "application/x-www-form-urlencoded;charset=UTF-8"},
consumes = { MediaType.APPLICATION_FORM_URLENCODED_VALUE, "application/x-www-form-urlencoded;charset=UTF-8" },
produces = MediaType.APPLICATION_JSON_UTF8_VALUE
)
@ResponseBody
public Object executeFormPost(@RequestParam Map<String, String> queryParams,
GraphQLRequest graphQLRequest,
R request) {
public ResponseEntity<Object> executeFormPost(@RequestParam Map<String, String> queryParams,
GraphQLRequest graphQLRequest,
R request) {
String queryParam = queryParams.get("query");
String operationNameParam = queryParams.get("operationName");

String query = StringUtils.isEmpty(queryParam) ? graphQLRequest.getQuery() : queryParam;
String operationName = StringUtils.isEmpty(operationNameParam) ? graphQLRequest.getOperationName() : operationNameParam;

return executor.execute(graphQL, new GraphQLRequest(query, operationName, graphQLRequest.getVariables()), request);
Object result = executor.execute(graphQL, new GraphQLRequest(query, operationName, graphQLRequest.getVariables()),
request);

HttpHeaders headers = new HttpHeaders();
CacheControlHeader.addCacheControlHeader(result, headers);
return new ResponseEntity<>(result, headers, HttpStatus.OK);
}

@GetMapping(
Expand All @@ -80,7 +98,12 @@ public Object executeFormPost(@RequestParam Map<String, String> queryParams,
headers = "Connection!=Upgrade"
)
@ResponseBody
public Object executeGet(GraphQLRequest graphQLRequest, R request) {
return executor.execute(graphQL, graphQLRequest, request);
public ResponseEntity<Object> executeGet(GraphQLRequest graphQLRequest, R request) {
Object result = executor.execute(graphQL, graphQLRequest, request);

HttpHeaders headers = new HttpHeaders();
CacheControlHeader.addCacheControlHeader(result, headers);
return new ResponseEntity<>(result, headers, HttpStatus.OK);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import graphql.ExecutionInput;
import graphql.GraphQL;
import graphql.cachecontrol.CacheControl;
import io.leangen.graphql.spqr.spring.autoconfigure.ContextFactory;
import io.leangen.graphql.spqr.spring.autoconfigure.ContextFactoryParams;
import io.leangen.graphql.spqr.spring.autoconfigure.DataLoaderRegistryFactory;
Expand All @@ -13,15 +14,16 @@ public interface GraphQLExecutor<R> {
Object execute(GraphQL graphQL, GraphQLRequest graphQLRequest, R request);

default ExecutionInput buildInput(GraphQLRequest graphQLRequest, R request, ContextFactory<R> contextFactory,
DataLoaderRegistryFactory loaderFactory) {
DataLoaderRegistryFactory loaderFactory, CacheControl cacheControl) {
ExecutionInput.Builder inputBuilder = ExecutionInput.newExecutionInput()
.query(graphQLRequest.getQuery())
.operationName(graphQLRequest.getOperationName())
.variables(graphQLRequest.getVariables())
.context(contextFactory.createGlobalContext(ContextFactoryParams.<R>builder()
.withGraphQLRequest(graphQLRequest)
.withNativeRequest(request)
.build()));
.build()))
.cacheControl(cacheControl);
if (loaderFactory != null) {
inputBuilder.dataLoaderRegistry(loaderFactory.createDataLoaderRegistry());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package io.leangen.graphql.spqr.spring.web.reactive;

import graphql.ExecutionResult;
import graphql.GraphQL;
import graphql.cachecontrol.CacheControl;
import io.leangen.graphql.spqr.spring.autoconfigure.DataLoaderRegistryFactory;
import io.leangen.graphql.spqr.spring.autoconfigure.ReactiveContextFactory;
import io.leangen.graphql.spqr.spring.web.dto.GraphQLRequest;
Expand All @@ -22,7 +22,8 @@ public DefaultGraphQLExecutor(ReactiveContextFactory contextFactory, DataLoaderR

@Override
public CompletableFuture<Map<String, Object>> execute(GraphQL graphQL, GraphQLRequest graphQLRequest, ServerWebExchange request) {
return graphQL.executeAsync(buildInput(graphQLRequest, request, contextFactory, dataLoaderRegistryFactory))
.thenApply(ExecutionResult::toSpecification);
CacheControl cacheControl = CacheControl.newCacheControl();
return graphQL.executeAsync(buildInput(graphQLRequest, request, contextFactory, dataLoaderRegistryFactory, cacheControl))
.thenApply((executionResult1) -> cacheControl.addTo(executionResult1).toSpecification());
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.leangen.graphql.spqr.spring.web.servlet;

import graphql.ExecutionResult;
import graphql.GraphQL;
import graphql.cachecontrol.CacheControl;
import io.leangen.graphql.spqr.spring.autoconfigure.DataLoaderRegistryFactory;
import io.leangen.graphql.spqr.spring.autoconfigure.ServletContextFactory;
import io.leangen.graphql.spqr.spring.web.dto.GraphQLRequest;
Expand All @@ -20,6 +22,9 @@ public DefaultGraphQLExecutor(ServletContextFactory contextFactory, DataLoaderRe

@Override
public Map<String, Object> execute(GraphQL graphQL, GraphQLRequest graphQLRequest, NativeWebRequest nativeRequest) {
return graphQL.execute(buildInput(graphQLRequest, nativeRequest, contextFactory, dataLoaderRegistryFactory)).toSpecification();
CacheControl cacheControl = CacheControl.newCacheControl();
ExecutionResult executionResult = graphQL.execute(buildInput(graphQLRequest, nativeRequest, contextFactory, dataLoaderRegistryFactory, cacheControl));
executionResult = cacheControl.addTo(executionResult);
return executionResult.toSpecification();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import graphql.ExecutionResult;
import graphql.GraphQL;
import graphql.cachecontrol.CacheControl;
import io.leangen.graphql.spqr.spring.autoconfigure.DataLoaderRegistryFactory;
import io.leangen.graphql.spqr.spring.autoconfigure.WebSocketContextFactory;
import io.leangen.graphql.spqr.spring.web.dto.GraphQLRequest;
Expand All @@ -19,6 +20,9 @@ public DefaultGraphQLExecutor(WebSocketContextFactory contextFactory, DataLoader

@Override
public ExecutionResult execute(GraphQL graphQL, GraphQLRequest graphQLRequest, WebSocketSession request) {
return graphQL.execute(buildInput(graphQLRequest, request, contextFactory, dataLoaderRegistryFactory));
CacheControl cacheControl = CacheControl.newCacheControl();
ExecutionResult executionResult = graphQL.execute(buildInput(graphQLRequest, request, contextFactory, dataLoaderRegistryFactory, cacheControl));
executionResult = cacheControl.addTo(executionResult);
return executionResult;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public void schemaConfigTest() {
// -using default resolver builders
Assert.assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byMethodName"));
Assert.assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byAnnotation"));
Assert.assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byAnnotation_withCacheHint"));
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unit Test Fails.
I don't know how to solve it.
Please let me know.

// -using custom resolver builders
Assert.assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byStringQualifiedCustomResolverBuilder_wiredAsBean"));
Assert.assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byStringQualifiedCustomResolverBuilder_wiredAsComponent"));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package io.leangen.graphql.spqr.spring.test;

import graphql.cachecontrol.CacheControl;
import io.leangen.graphql.ExtensionProvider;
import io.leangen.graphql.GeneratorConfiguration;
import io.leangen.graphql.annotations.GraphQLEnvironment;
import io.leangen.graphql.annotations.GraphQLQuery;
import io.leangen.graphql.execution.ResolutionEnvironment;
import io.leangen.graphql.metadata.strategy.query.BeanResolverBuilder;
import io.leangen.graphql.metadata.strategy.query.PublicResolverBuilder;
import io.leangen.graphql.metadata.strategy.query.ResolverBuilder;
Expand Down Expand Up @@ -135,6 +138,12 @@ public String getGreeting(){
return "Hello world !";
}

@GraphQLQuery(name = "greetingFromBeanSource_wiredAsComponent_byAnnotation_withCacheHint")
public String getGreeting2(@GraphQLEnvironment ResolutionEnvironment env){
Copy link
Author

@ttyniwa ttyniwa Aug 26, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I remove argument, success to pass schemaConfigTest

env.dataFetchingEnvironment.getCacheControl().hint(env.dataFetchingEnvironment, 100, CacheControl.Scope.PUBLIC);
return "Hello world !";
}

public String greetingFromBeanSource_wiredAsComponent_byStringQualifiedCustomResolverBuilder_wiredAsBean() {
return "Hello world !";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
Expand All @@ -21,6 +22,7 @@
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
Expand All @@ -38,12 +40,36 @@ public class GraphQLControllerTest {

@Test
public void defaultControllerTest_POST_applicationGraphql_noQueryParams() throws Exception {
mockMvc.perform(
post("/"+apiContext)
.contentType("application/graphql")
.content("{greetingFromBeanSource_wiredAsComponent_byAnnotation}"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Hello world")))
.andExpect(header().doesNotExist(HttpHeaders.CACHE_CONTROL));
}

@Test
public void defaultControllerTest_POST_applicationGraphql_noQueryParams_withCacheHint() throws Exception {
mockMvc.perform(
post("/"+apiContext)
.contentType("application/graphql")
.content("{greetingFromBeanSource_wiredAsComponent_byAnnotation_withCacheHint}"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Hello world")))
.andExpect(header().exists(HttpHeaders.CACHE_CONTROL))
.andExpect(header().string(HttpHeaders.CACHE_CONTROL, "max-age=100, public"));
}

@Test
public void defaultControllerTest_POST_applicationGraphql_noQueryParams_withAndWithoutCacheHint() throws Exception {
mockMvc.perform(
post("/"+apiContext)
.contentType("application/graphql")
.content("{greetingFromBeanSource_wiredAsComponent_byAnnotation}"))
.content("{greetingFromBeanSource_wiredAsComponent_byAnnotation_withCacheHint greetingFromBeanSource_wiredAsComponent_byAnnotation}"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Hello world")));
.andExpect(content().string(containsString("Hello world")))
.andExpect(header().doesNotExist(HttpHeaders.CACHE_CONTROL));
}

@Test
Expand Down