This is an automated email from the ASF dual-hosted git repository. radu pushed a commit to branch feature/SLING-9655 in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-graphql-core.git
commit 4181c570e6c3d95efab1fa3dbc56a79b1aa9ba2e Author: Radu Cotescu <[email protected]> AuthorDate: Mon Aug 31 11:46:32 2020 +0200 SLING-9655 - Caching support for the GraphQL core * made the suffix where the persisted queries HTTP API is enabled configurable; an empty suffix disables the API * documented the persisted queries HTTP API --- README.md | 94 +++++++++++++++++++++ pom.xml | 6 +- .../sling/graphql/core/servlet/GraphQLServlet.java | 95 +++++++++++----------- .../graphql/core/servlet/GraphQLServletTest.java | 81 ++++++++++++++++++ 4 files changed, 227 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 812b912..8137f2a 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,100 @@ This module enables the following GraphQL "styles" The GraphQL requests hit a Sling resource in all cases, there's no need for path-mounted servlets which are [not desirable](https://sling.apache.org/documentation/the-sling-engine/servlets.html#caveats-when-binding-servlets-by-path-1). +### Persisted queries API +No matter how you decide to create your Sling GraphQL endpoints, you have the option to allow GraphQL clients to use persisted queries. +Since most user agents have a character limit for GET requests and POST requests are not cacheable, a persisted query allows the best of +both worlds. + +#### How does it work? +1. An instance of the GraphQL servlet has to be configured; by default, the servlet will enable the persisted queries API on the + `/persisted` request suffix; the value is configurable, via the `persistedQueries.suffix` parameter of the factory configuration. +2. A client prepares a persisted query in advance by `POST`ing the query text to the endpoint where the GraphQL servlet is bound, plus the + `/persisted` suffix. +3. The servlet will respond with a `201 Created` status; the response's `Location` header will then instruct the client where it can then + execute the persisted query, via a `GET` request. +4. The responses for a `GET` requests to a persisted query will contain appropriate HTTP Cache headers, allowing front-end HTTP caches + (e.g. CDNs) to cache the JSON responses. +5. There's no guarantee on how long a persisted query is stored. A client that gets a `404` on a persisted query must be prepared to + re`POST` the query, in order to store the prepared query again. + +Here are a few examples: + +1. Storing a query + ```bash + curl -v 'http://localhost:8080/graphql.json/persisted' \ + -H 'Content-Type: application/json' \ + --data-binary '{"query":"{\n navigation {\n search\n sections {\n path\n name\n }\n }\n article(withText: \"virtual\") {\n path\n title\n seeAlso {\n path\n title\n tags\n }\n }\n}\n","variables":null}' \ + --compressed + > POST /graphql.json/persisted HTTP/1.1 + > Host: localhost:8080 + > User-Agent: curl/7.64.1 + > Accept: */* + > Accept-Encoding: deflate, gzip + > Content-Type: application/json + > Content-Length: 236 + > + * upload completely sent off: 236 out of 236 bytes + < HTTP/1.1 201 Created + < Date: Mon, 31 Aug 2020 16:33:48 GMT + < X-Content-Type-Options: nosniff + < X-Frame-Options: SAMEORIGIN + < Location: http://localhost:8080/graphql.json/persisted/e1ce2e205e1dfb3969627c6f417860cadab696e0e87b1c44de1438848661b62f + < Content-Length: 0 + ``` +2. Running a persisted query +```bash +curl -v http://localhost:8080/graphql.json/persisted/e1ce2e205e1dfb3969627c6f417860cadab696e0e87b1c44de1438848661b62f +> GET /graphql.json/persisted/e1ce2e205e1dfb3969627c6f417860cadab696e0e87b1c44de1438848661b62f HTTP/1.1 +> Host: localhost:8080 +> User-Agent: curl/7.64.1 +> Accept: */* +> +< HTTP/1.1 200 OK +< Date: Mon, 31 Aug 2020 16:35:18 GMT +< X-Content-Type-Options: nosniff +< X-Frame-Options: SAMEORIGIN +< Cache-Control: max-age=60 +< Content-Type: application/json;charset=utf-8 +< Transfer-Encoding: chunked +< + +{ + "data": { + "navigation": { + "search": "/content/search", + "sections": [ + { + "path": "/content/articles/travel", + "name": "Travel" + }, + { + "path": "/content/articles/music", + "name": "Music" + } + ] + } + "article": [ + { + "path": "/content/articles/travel/precious-kunze-on-the-bandwidth-of-virtual-nobis-id-aka-usb", + "title": "Travel - Precious Kunze on the bandwidth of virtual 'nobis id' (aka USB)", + "seeAlso": [ + { + "path": "/content/articles/travel/solon-davis-on-the-card-of-primary-reiciendis-omnis-aka-sql", + "title": "Travel - Solon Davis on the card of primary 'reiciendis omnis' (aka SQL)", + "tags": [ + "bandwidth", + "protocol" + ] + } + ] + } + ] + } +} +``` + + ## Resource-specific GraphQL schemas Schemas are provided by `SchemaProvider` services: diff --git a/pom.xml b/pom.xml index 3c1a881..9c52ff7 100644 --- a/pom.xml +++ b/pom.xml @@ -165,7 +165,7 @@ <dependency> <groupId>org.apache.sling</groupId> <artifactId>org.apache.sling.scripting.api</artifactId> - <version>2.1.0</version> + <version>2.1.12</version> <scope>provided</scope> </dependency> <dependency> @@ -228,8 +228,8 @@ </dependency> <dependency> <groupId>org.apache.sling</groupId> - <artifactId>org.apache.sling.testing.osgi-mock.junit4</artifactId> - <version>2.4.16</version> + <artifactId>org.apache.sling.testing.sling-mock.junit4</artifactId> + <version>2.4.0</version> <scope>test</scope> </dependency> <dependency> diff --git a/src/main/java/org/apache/sling/graphql/core/servlet/GraphQLServlet.java b/src/main/java/org/apache/sling/graphql/core/servlet/GraphQLServlet.java index 537875e..d346468 100644 --- a/src/main/java/org/apache/sling/graphql/core/servlet/GraphQLServlet.java +++ b/src/main/java/org/apache/sling/graphql/core/servlet/GraphQLServlet.java @@ -44,9 +44,6 @@ import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.ConfigurationPolicy; import org.osgi.service.component.annotations.Reference; -import org.osgi.service.component.annotations.ReferenceCardinality; -import org.osgi.service.component.annotations.ReferencePolicy; -import org.osgi.service.component.annotations.ReferencePolicyOption; import org.osgi.service.metatype.annotations.AttributeDefinition; import org.osgi.service.metatype.annotations.AttributeType; import org.osgi.service.metatype.annotations.Designate; @@ -79,9 +76,6 @@ public class GraphQLServlet extends SlingAllMethodsServlet { public static final String P_QUERY = "query"; - private static final String SUFFIX_PERSISTED = "/persisted"; - private static final Pattern PATTERN_GET_PERSISTED_QUERY = Pattern.compile("^" + SUFFIX_PERSISTED + "/([a-f0-9]{64})$"); - @ObjectClassDefinition( name = "Apache Sling GraphQL Servlet", description = "Servlet that implements GraphQL endpoints") @@ -107,6 +101,12 @@ public class GraphQLServlet extends SlingAllMethodsServlet { String[] sling_servlet_extensions() default "gql"; @AttributeDefinition( + name = "Persisted queries suffix", + description = "The request suffix under which the HTTP API for persisted queries should be made available." + ) + String persistedQueries_suffix() default "/persisted"; + + @AttributeDefinition( name = "Persisted Queries Cache-Control max-age", description = "The maximum amount of time a persisted query resource is considered fresh (in seconds). A negative value " + "will be interpreted as 0.", @@ -125,52 +125,55 @@ public class GraphQLServlet extends SlingAllMethodsServlet { @Reference private SlingScalarsProvider scalarsProvider; - @Reference( - cardinality = ReferenceCardinality.OPTIONAL, - policy = ReferencePolicy.STATIC, - policyOption = ReferencePolicyOption.GREEDY - ) + @Reference private GraphQLCacheProvider cacheProvider; - private final Config config; - private final int cacheControlMaxAge; - + private String suffixPersisted; + private Pattern patternGetPersistedQuery; + private int cacheControlMaxAge; private final JsonSerializer jsonSerializer = new JsonSerializer(); @Activate - public GraphQLServlet(Config config) { - this.config = config; + private void activate(Config config) { cacheControlMaxAge = config.cache$_$control_max$_$age() >= 0 ? config.cache$_$control_max$_$age() : 0; + String suffix = config.persistedQueries_suffix(); + if (StringUtils.isNotEmpty(suffix) && suffix.startsWith("/")) { + suffixPersisted = suffix; + patternGetPersistedQuery = Pattern.compile("^" + suffixPersisted + "/([a-f0-9]{64})$"); + } else { + suffixPersisted = null; + patternGetPersistedQuery = null; + } } @Override public void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { String suffix = request.getRequestPathInfo().getSuffix(); - if (suffix != null && suffix.startsWith(SUFFIX_PERSISTED)) { - if (cacheProvider == null) { - response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "This servlet does not support persisted queries."); - return; - } - Matcher matcher = PATTERN_GET_PERSISTED_QUERY.matcher(suffix); - if (matcher.matches()) { - String queryHash = matcher.group(1); - if (StringUtils.isNotEmpty(queryHash)) { - String query = cacheProvider.getQuery(queryHash, request.getResource().getResourceType(), - request.getRequestPathInfo().getSelectorString()); - if (query != null) { - boolean isAuthenticated = request.getHeaders("Authorization").hasMoreElements(); - StringBuilder cacheControlValue = new StringBuilder("max-age=").append(cacheControlMaxAge); - if (isAuthenticated) { - cacheControlValue.append(",private"); + if (suffix != null) { + if (StringUtils.isNotEmpty(suffixPersisted) && suffix.startsWith(suffixPersisted)) { + Matcher matcher = patternGetPersistedQuery.matcher(suffix); + if (matcher.matches()) { + String queryHash = matcher.group(1); + if (StringUtils.isNotEmpty(queryHash)) { + String query = cacheProvider.getQuery(queryHash, request.getResource().getResourceType(), + request.getRequestPathInfo().getSelectorString()); + if (query != null) { + boolean isAuthenticated = request.getHeaders("Authorization").hasMoreElements(); + StringBuilder cacheControlValue = new StringBuilder("max-age=").append(cacheControlMaxAge); + if (isAuthenticated) { + cacheControlValue.append(",private"); + } + response.addHeader("Cache-Control", cacheControlValue.toString()); + execute(query, request, response); + } else { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "Cannot find persisted query " + queryHash); } - response.addHeader("Cache-Control", cacheControlValue.toString()); - execute(query, request, response); - } else { - response.sendError(HttpServletResponse.SC_NOT_FOUND, "Cannot find persisted query " + queryHash); } + } else { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Unexpected hash."); } } else { - response.sendError(HttpServletResponse.SC_BAD_REQUEST); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Persisted queries are disabled."); } } else { execute(request.getResource(), request, response); @@ -180,16 +183,16 @@ public class GraphQLServlet extends SlingAllMethodsServlet { @Override public void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { String suffix = request.getRequestPathInfo().getSuffix(); - if (suffix != null && suffix.equals(SUFFIX_PERSISTED)) { - if (cacheProvider == null) { - response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "This servlet does not support persisted queries."); - return; + if (suffix != null) { + if (StringUtils.isNotEmpty(suffixPersisted) && suffix.equals(suffixPersisted)) { + String query = IOUtils.toString(request.getReader()); + String hash = cacheProvider.cacheQuery(query, request.getResource().getResourceType(), + request.getRequestPathInfo().getSelectorString()); + response.addHeader("Location", getLocationHeaderValue(request, hash)); + response.setStatus(HttpServletResponse.SC_CREATED); + } else { + response.sendError(HttpServletResponse.SC_BAD_REQUEST); } - String query = IOUtils.toString(request.getReader()); - String hash = cacheProvider.cacheQuery(query, request.getResource().getResourceType(), - request.getRequestPathInfo().getSelectorString()); - response.addHeader("Location", getLocationHeaderValue(request, hash)); - response.setStatus(HttpServletResponse.SC_CREATED); } else { execute(request.getResource(), request, response); } diff --git a/src/test/java/org/apache/sling/graphql/core/servlet/GraphQLServletTest.java b/src/test/java/org/apache/sling/graphql/core/servlet/GraphQLServletTest.java new file mode 100644 index 0000000..da9dbab --- /dev/null +++ b/src/test/java/org/apache/sling/graphql/core/servlet/GraphQLServletTest.java @@ -0,0 +1,81 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ~ Licensed to the Apache Software Foundation (ASF) under one + ~ or more contributor license agreements. See the NOTICE file + ~ distributed with this work for additional information + ~ regarding copyright ownership. The ASF licenses this file + ~ to you 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 org.apache.sling.graphql.core.servlet; + +import java.io.IOException; + +import javax.servlet.Servlet; + +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.servlets.ServletResolverConstants; +import org.apache.sling.graphql.core.cache.SimpleGraphQLCacheProvider; +import org.apache.sling.graphql.core.engine.SlingDataFetcherSelector; +import org.apache.sling.graphql.core.scalars.SlingScalarsProvider; +import org.apache.sling.graphql.core.schema.RankedSchemaProviders; +import org.apache.sling.testing.mock.sling.junit.SlingContext; +import org.apache.sling.testing.mock.sling.servlet.MockRequestPathInfo; +import org.apache.sling.testing.mock.sling.servlet.MockSlingHttpServletRequest; +import org.apache.sling.testing.mock.sling.servlet.MockSlingHttpServletResponse; +import org.junit.Rule; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; + +public class GraphQLServletTest { + + @Rule + public SlingContext context = new SlingContext(); + + @Test + public void testDisabledSuffix() throws IOException { + RankedSchemaProviders rankedSchemaProviders = mock(RankedSchemaProviders.class); + context.registerService(rankedSchemaProviders); + SlingDataFetcherSelector slingDataFetcherSelector = mock(SlingDataFetcherSelector.class); + context.registerService(slingDataFetcherSelector); + SlingScalarsProvider slingScalarsProvider = mock(SlingScalarsProvider.class); + context.registerService(slingScalarsProvider); + context.registerInjectActivateService(new SimpleGraphQLCacheProvider()); + context.registerInjectActivateService(new GraphQLServlet(), ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES, "a/b/c", + "persistedQueries.suffix", ""); + GraphQLServlet servlet = (GraphQLServlet) context.getService(Servlet.class); + assertNotNull(servlet); + + context.build().resource("/content/graphql", ResourceResolver.PROPERTY_RESOURCE_TYPE, "a/b/c").commit(); + Resource resource = context.resourceResolver().resolve("/content/graphql"); + + MockSlingHttpServletResponse response = context.response(); + MockSlingHttpServletRequest request = new MockSlingHttpServletRequest(context.bundleContext()); + + + request.setResource(resource); + MockRequestPathInfo requestPathInfo = (MockRequestPathInfo) request.getRequestPathInfo(); + requestPathInfo.setExtension("gql"); + requestPathInfo.setResourcePath(resource.getPath()); + requestPathInfo.setSuffix("/persisted"); + request.setPathInfo("/content/graphql/persisted/hash"); + servlet.doGet(request, response); + assertEquals(400, response.getStatus()); + assertEquals("Persisted queries are disabled.", response.getStatusMessage()); + } + + +}
