| |
| /* |
| * 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 java.io.PrintWriter; |
| import java.io.Writer; |
| import java.util.Arrays; |
| import java.util.Map; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| import javax.json.Json; |
| import javax.json.JsonWriter; |
| import javax.servlet.Servlet; |
| import javax.servlet.http.HttpServletResponse; |
| |
| import org.apache.commons.io.IOUtils; |
| import org.apache.commons.lang3.StringUtils; |
| import org.apache.sling.api.SlingHttpServletRequest; |
| import org.apache.sling.api.SlingHttpServletResponse; |
| import org.apache.sling.api.resource.Resource; |
| import org.apache.sling.api.servlets.SlingAllMethodsServlet; |
| import org.apache.sling.commons.metrics.Counter; |
| import org.apache.sling.commons.metrics.MetricsService; |
| import org.apache.sling.commons.metrics.Timer; |
| import org.apache.sling.graphql.api.cache.GraphQLCacheProvider; |
| import org.apache.sling.graphql.api.engine.QueryExecutor; |
| import org.apache.sling.graphql.api.engine.ValidationResult; |
| import org.jetbrains.annotations.NotNull; |
| 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.Deactivate; |
| import org.osgi.service.component.annotations.Reference; |
| import org.osgi.service.metatype.annotations.AttributeDefinition; |
| import org.osgi.service.metatype.annotations.AttributeType; |
| import org.osgi.service.metatype.annotations.Designate; |
| import org.osgi.service.metatype.annotations.ObjectClassDefinition; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import com.codahale.metrics.Gauge; |
| import com.codahale.metrics.MetricRegistry; |
| |
| /** Servlet that can be activated to implement the standard |
| * GraphQL "protocol" as per https://graphql.org/learn/serving-over-http/ |
| * |
| * This servlet is only active if the corresponding OSGi configurations |
| * are created. This allows is to be mounted either on a path to support |
| * the "traditional" GraphQL single-endpoint mode, or on specific resource |
| * types and selectors to turn specific Sling Resources into GraphQL |
| * endpoints. |
| */ |
| |
| @Component( |
| service = Servlet.class, |
| name = "org.apache.sling.graphql.core.GraphQLServlet", |
| immediate = true, |
| configurationPolicy=ConfigurationPolicy.REQUIRE, |
| property = { |
| "service.description=Sling GraphQL Servlet", |
| "service.vendor=The Apache Software Foundation" |
| }) |
| @Designate(ocd = GraphQLServlet.Config.class, factory=true) |
| public class GraphQLServlet extends SlingAllMethodsServlet { |
| private static final long serialVersionUID = 1L; |
| private static final Logger LOGGER = LoggerFactory.getLogger(GraphQLServlet.class); |
| |
| public static final String P_QUERY = "query"; |
| |
| @ObjectClassDefinition( |
| name = "Apache Sling GraphQL Servlet", |
| description = "Servlet that implements GraphQL endpoints") |
| public @interface Config { |
| @AttributeDefinition( |
| name = "Selectors", |
| description="Standard Sling servlet property") |
| String[] sling_servlet_selectors() default ""; |
| |
| @AttributeDefinition( |
| name = "Resource Types", |
| description="Standard Sling servlet property") |
| String[] sling_servlet_resourceTypes() default "sling/servlet/default"; |
| |
| @AttributeDefinition( |
| name = "Methods", |
| description="Standard Sling servlet property") |
| String[] sling_servlet_methods() default "GET"; |
| |
| @AttributeDefinition( |
| name = "Extensions", |
| description="Standard Sling servlet property") |
| 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.", |
| min = "0", |
| type = AttributeType.INTEGER |
| ) |
| int cache$_$control_max$_$age() default 60; |
| } |
| |
| @Reference |
| private QueryExecutor queryExecutor; |
| |
| @Reference |
| private GraphQLCacheProvider cacheProvider; |
| |
| @Reference |
| private MetricsService metricsService; |
| |
| @Reference(target = "(name=sling)") |
| private MetricRegistry metricRegistry; |
| |
| private String suffixPersisted; |
| private Pattern patternGetPersistedQuery; |
| private int cacheControlMaxAge; |
| |
| private Counter cacheHits; |
| private Counter cacheMisses; |
| private Counter requestsServed; |
| private Timer requestTimer; |
| |
| private static final String METRIC_NS = GraphQLServlet.class.getName(); |
| private String gaugeCacheHitRate; |
| |
| @Activate |
| private void activate(Config config) { |
| String[] extensions = config.sling_servlet_extensions(); |
| StringBuilder extensionsPattern = new StringBuilder(); |
| for (String extension : extensions) { |
| if (extensionsPattern.length() > 0) { |
| extensionsPattern.append("|"); |
| } |
| extensionsPattern.append(extension); |
| } |
| if (extensionsPattern.length() > 0) { |
| extensionsPattern.insert(0, "("); |
| extensionsPattern.append(")"); |
| } |
| 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})" + (extensionsPattern.length() > 0 ? |
| "\\." + extensionsPattern.toString() + "$" : "$")); |
| } else { |
| suffixPersisted = null; |
| patternGetPersistedQuery = null; |
| } |
| StringBuilder sb = new StringBuilder(); |
| String[] resourceTypes = config.sling_servlet_resourceTypes(); |
| Arrays.sort(resourceTypes); |
| sb.append("rt:").append(String.join("_", resourceTypes)); |
| if (config.sling_servlet_methods().length > 0) { |
| String[] methods = config.sling_servlet_methods(); |
| Arrays.sort(methods); |
| sb.append(".m:").append(String.join("_", methods)); |
| } |
| if (config.sling_servlet_selectors().length > 0) { |
| String[] selectors = config.sling_servlet_selectors(); |
| Arrays.sort(selectors); |
| sb.append(".s:").append(String.join("_", selectors)); |
| } |
| if (extensions.length > 0) { |
| Arrays.sort(extensions); |
| sb.append(".e:").append(String.join("_", extensions)); |
| } |
| String servletRegistrationProperties = sb.toString(); |
| cacheHits = metricsService.counter(METRIC_NS + "." + servletRegistrationProperties + ".cache_hits"); |
| cacheMisses = metricsService.counter(METRIC_NS + "." + servletRegistrationProperties + ".cache_misses"); |
| requestsServed = metricsService.counter(METRIC_NS + "." + servletRegistrationProperties + ".requests_total"); |
| gaugeCacheHitRate = METRIC_NS + "." + servletRegistrationProperties + ".cache_hit_rate"; |
| metricRegistry.register(gaugeCacheHitRate, (Gauge<Float>) () -> { |
| float hitCount = cacheHits.getCount(); |
| float missCount = cacheMisses.getCount(); |
| return hitCount > 0 || missCount > 0 ? hitCount / (hitCount + missCount) : 0.0f; |
| }); |
| requestTimer = metricsService.timer(METRIC_NS + "." + servletRegistrationProperties + ".requests_timer"); |
| } |
| |
| @Deactivate |
| private void deactivate() { |
| if (StringUtils.isNotEmpty(gaugeCacheHitRate)) { |
| metricRegistry.remove(gaugeCacheHitRate); |
| } |
| } |
| |
| @Override |
| public void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { |
| requestsServed.increment(); |
| Timer.Context requestTimerContext = requestTimer.time(); |
| try { |
| String suffix = request.getRequestPathInfo().getSuffix(); |
| if (suffix != null) { |
| if (StringUtils.isNotEmpty(suffixPersisted) && suffix.startsWith(suffixPersisted)) { |
| Matcher matcher = patternGetPersistedQuery.matcher(suffix); |
| if (matcher.matches()) { |
| String queryHash = matcher.group(1); |
| String extension = matcher.group(2); |
| String requestExtension = request.getRequestPathInfo().getExtension(); |
| if (requestExtension != null && requestExtension.equals(extension)) { |
| 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); |
| cacheHits.increment(); |
| } else { |
| cacheMisses.increment(); |
| response.sendError(HttpServletResponse.SC_NOT_FOUND, "Cannot find persisted query " + queryHash); |
| } |
| } |
| } else { |
| response.sendError(HttpServletResponse.SC_BAD_REQUEST, "The persisted query's extension does not match the " + |
| "servlet extension."); |
| } |
| } else { |
| response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Unexpected hash."); |
| } |
| } else { |
| response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Persisted queries are disabled."); |
| } |
| } else { |
| execute(request.getResource(), request, response); |
| } |
| } finally { |
| requestTimerContext.stop(); |
| } |
| } |
| |
| @Override |
| public void doPost(@NotNull SlingHttpServletRequest request, @NotNull SlingHttpServletResponse response) throws IOException { |
| requestsServed.increment(); |
| Timer.Context requestTimerContext = requestTimer.time(); |
| try { |
| String suffix = request.getRequestPathInfo().getSuffix(); |
| if (suffix != null) { |
| if (StringUtils.isNotEmpty(suffixPersisted) && suffix.equals(suffixPersisted)) { |
| doPostPersistedQuery(request, response); |
| } else { |
| response.sendError(HttpServletResponse.SC_BAD_REQUEST); |
| } |
| } else { |
| execute(request.getResource(), request, response); |
| } |
| } finally { |
| requestTimerContext.stop(); |
| } |
| } |
| |
| private void doPostPersistedQuery(@NotNull SlingHttpServletRequest request, @NotNull SlingHttpServletResponse response) |
| throws IOException { |
| String rawQuery = IOUtils.toString(request.getReader()); |
| QueryParser.Result query = QueryParser.fromJSON(rawQuery); |
| ValidationResult validationResult = queryExecutor |
| .validate(query.getQuery(), query.getVariables(), request.getResource(), request.getRequestPathInfo().getSelectors()); |
| if (validationResult.isValid()) { |
| String hash = cacheProvider.cacheQuery(rawQuery, request.getResource().getResourceType(), |
| request.getRequestPathInfo().getSelectorString()); |
| if (hash != null) { |
| response.addHeader("Location", getLocationHeaderValue(request, hash)); |
| response.setStatus(HttpServletResponse.SC_CREATED); |
| } else { |
| response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Cannot store persisted query."); |
| } |
| } else { |
| LOGGER.error("Invalid GraphQL query: " + String.join(System.lineSeparator(), validationResult.getErrors())); |
| response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid GraphQL query."); |
| } |
| } |
| |
| private void execute(Resource resource, SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { |
| response.setContentType("application/json"); |
| response.setCharacterEncoding("UTF-8"); |
| final QueryParser.Result result = QueryParser.fromRequest(request); |
| if (result == null) { |
| response.sendError(HttpServletResponse.SC_BAD_REQUEST); |
| return; |
| } |
| final String query = result.getQuery(); |
| if (query.trim().length() == 0) { |
| response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing request parameter:" + P_QUERY); |
| return; |
| } |
| // The Response Writer cannot be flushed or closed here to avoid issues with Redirects etc |
| try (JsonWriter writer = Json.createWriter(new NoCloseWriterWrapper(response.getWriter()))) { |
| Map<String, Object> executionResult = queryExecutor.execute(query, result.getVariables(), resource, |
| request.getRequestPathInfo().getSelectors()); |
| writer.write(Json.createObjectBuilder(executionResult).build().asJsonObject()); |
| } catch(Exception ex) { |
| throw new IOException(ex); |
| } |
| } |
| |
| private void execute(@NotNull String persistedQuery, SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { |
| response.setContentType("application/json"); |
| response.setCharacterEncoding("UTF-8"); |
| // The Response Writer cannot be flushed or closed here to avoid issues with Redirects etc |
| try (JsonWriter writer = Json.createWriter(new NoCloseWriterWrapper(response.getWriter()))) { |
| final QueryParser.Result result = QueryParser.fromJSON(persistedQuery); |
| Map<String, Object> executionResult = queryExecutor.execute(result.getQuery(), result.getVariables(), request.getResource(), |
| request.getRequestPathInfo().getSelectors()); |
| writer.write(Json.createObjectBuilder(executionResult).build().asJsonObject()); |
| } catch (Exception ex) { |
| throw new IOException(ex); |
| } |
| } |
| |
| @NotNull |
| private String getLocationHeaderValue(@NotNull SlingHttpServletRequest request, @NotNull String hash) { |
| StringBuilder location = new StringBuilder(); |
| location.append(request.getScheme()).append("://"); |
| location.append(request.getServerName()); |
| int localPort = request.getServerPort(); |
| if (localPort != 80 && localPort != 443) { |
| location.append(":").append(localPort); |
| } |
| String extension = request.getRequestPathInfo().getExtension(); |
| location.append(request.getContextPath()).append(request.getPathInfo()).append("/").append(hash) |
| .append(StringUtils.isNotEmpty(extension) ? "." + extension : ""); |
| return location.toString(); |
| } |
| |
| |
| private class NoCloseWriterWrapper extends PrintWriter { |
| public NoCloseWriterWrapper(@NotNull Writer out) { |
| super(out); |
| } |
| |
| @Override |
| public void close() {} |
| } |
| } |