diff --git a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/CacheControlHeader.java b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/CacheControlHeader.java new file mode 100644 index 0000000..452e721 --- /dev/null +++ b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/CacheControlHeader.java @@ -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>) response).get()); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException("", e); + } + } else { + cachePolicy = computeOverallCacheMaxAge((Map) response); + } + + if (cachePolicy != null) { + headers.add(HttpHeaders.CACHE_CONTROL, "max-age=" + cachePolicy.getMaxAge() + ", " + cachePolicy.getScope().name().toLowerCase()); + } + } + + static private T get( + String keyName, + Map 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 executionResult + ) { + Map extensions = get("extensions", executionResult); + Map cacheControl = get("cacheControl", extensions); + List> 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 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 data = get("data", executionResult); + if (data == null) { + return null; + } + boolean isExistHint = data.entrySet().stream() + .allMatch((entry) -> hints.stream() + .anyMatch((it) -> String.join(".", ((List) 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; + } + } +} diff --git a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/GraphQLController.java b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/GraphQLController.java index a14bcf7..79b824e 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/GraphQLController.java +++ b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/GraphQLController.java @@ -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; @@ -28,50 +31,65 @@ public GraphQLController(GraphQL graphQL, GraphQLExecutor 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 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 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 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 queryParams, - GraphQLRequest graphQLRequest, - R request) { + public ResponseEntity executeFormPost(@RequestParam Map 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( @@ -80,7 +98,12 @@ public Object executeFormPost(@RequestParam Map queryParams, headers = "Connection!=Upgrade" ) @ResponseBody - public Object executeGet(GraphQLRequest graphQLRequest, R request) { - return executor.execute(graphQL, graphQLRequest, request); + public ResponseEntity 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); } + } diff --git a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/GraphQLExecutor.java b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/GraphQLExecutor.java index 9e5dfce..7102d57 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/GraphQLExecutor.java +++ b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/GraphQLExecutor.java @@ -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; @@ -13,7 +14,7 @@ public interface GraphQLExecutor { Object execute(GraphQL graphQL, GraphQLRequest graphQLRequest, R request); default ExecutionInput buildInput(GraphQLRequest graphQLRequest, R request, ContextFactory contextFactory, - DataLoaderRegistryFactory loaderFactory) { + DataLoaderRegistryFactory loaderFactory, CacheControl cacheControl) { ExecutionInput.Builder inputBuilder = ExecutionInput.newExecutionInput() .query(graphQLRequest.getQuery()) .operationName(graphQLRequest.getOperationName()) @@ -21,7 +22,8 @@ default ExecutionInput buildInput(GraphQLRequest graphQLRequest, R request, Cont .context(contextFactory.createGlobalContext(ContextFactoryParams.builder() .withGraphQLRequest(graphQLRequest) .withNativeRequest(request) - .build())); + .build())) + .cacheControl(cacheControl); if (loaderFactory != null) { inputBuilder.dataLoaderRegistry(loaderFactory.createDataLoaderRegistry()); } diff --git a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/reactive/DefaultGraphQLExecutor.java b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/reactive/DefaultGraphQLExecutor.java index aedbd0f..3ee55d4 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/reactive/DefaultGraphQLExecutor.java +++ b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/reactive/DefaultGraphQLExecutor.java @@ -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; @@ -22,7 +22,8 @@ public DefaultGraphQLExecutor(ReactiveContextFactory contextFactory, DataLoaderR @Override public CompletableFuture> 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()); } } diff --git a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/servlet/DefaultGraphQLExecutor.java b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/servlet/DefaultGraphQLExecutor.java index 5d7bf98..829ed5d 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/servlet/DefaultGraphQLExecutor.java +++ b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/servlet/DefaultGraphQLExecutor.java @@ -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; @@ -20,6 +22,9 @@ public DefaultGraphQLExecutor(ServletContextFactory contextFactory, DataLoaderRe @Override public Map 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(); } } diff --git a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/servlet/websocket/DefaultGraphQLExecutor.java b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/servlet/websocket/DefaultGraphQLExecutor.java index 87ea0af..86d0910 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/servlet/websocket/DefaultGraphQLExecutor.java +++ b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/servlet/websocket/DefaultGraphQLExecutor.java @@ -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; @@ -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; } } diff --git a/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/autoconfigure/ResolverBuilder_SpqrAutoConfigurationTest.java b/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/autoconfigure/ResolverBuilder_SpqrAutoConfigurationTest.java index 0c35d32..8d37b8a 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/autoconfigure/ResolverBuilder_SpqrAutoConfigurationTest.java +++ b/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/autoconfigure/ResolverBuilder_SpqrAutoConfigurationTest.java @@ -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")); // -using custom resolver builders Assert.assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byStringQualifiedCustomResolverBuilder_wiredAsBean")); Assert.assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byStringQualifiedCustomResolverBuilder_wiredAsComponent")); diff --git a/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/test/ResolverBuilder_TestConfig.java b/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/test/ResolverBuilder_TestConfig.java index 5068bfe..c4032bb 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/test/ResolverBuilder_TestConfig.java +++ b/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/test/ResolverBuilder_TestConfig.java @@ -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; @@ -135,6 +138,12 @@ public String getGreeting(){ return "Hello world !"; } + @GraphQLQuery(name = "greetingFromBeanSource_wiredAsComponent_byAnnotation_withCacheHint") + public String getGreeting2(@GraphQLEnvironment ResolutionEnvironment env){ + env.dataFetchingEnvironment.getCacheControl().hint(env.dataFetchingEnvironment, 100, CacheControl.Scope.PUBLIC); + return "Hello world !"; + } + public String greetingFromBeanSource_wiredAsComponent_byStringQualifiedCustomResolverBuilder_wiredAsBean() { return "Hello world !"; } diff --git a/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/web/GraphQLControllerTest.java b/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/web/GraphQLControllerTest.java index 12d2543..23efcc1 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/web/GraphQLControllerTest.java +++ b/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/web/GraphQLControllerTest.java @@ -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; @@ -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) @@ -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