diff --git a/changes.md b/changes.md
index 4f64462a1e..0478474284 100644
--- a/changes.md
+++ b/changes.md
@@ -140,7 +140,11 @@ Changes log
GraphQL over HTTP draft (`application/graphql-response+json` and `application/json`);
operations run on the call's virtual thread. `GraphqlClientAdapter` sends operations
through a borrowed `Client` and returns native `ExecutionResult` values, throwing
- `GraphqlTransportException` when no GraphQL response is received. Issue #1581.
+ `GraphqlTransportException` when no GraphQL response is received. The server accepts
+ queries over `GET` when enabled (off by default), applies the draft's status codes per
+ response media type, limits the request size (1 MiB by default) and the parser per
+ adapter (`ParserOptions`, never the process-wide defaults), and gives data fetchers the
+ Restlet request and client info through `RestletGraphql` context keys. Issue #1581.
- Added the `NettyConnectionUpgrade` engine hook: a Restlet returning a "101 Switching
Protocols" response with this response attribute takes over the HTTP/1.1 connection
(`NettyUpgradeContext`), which the WebSocket extension relies on.
diff --git a/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlServerAdapter.java b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlServerAdapter.java
index 5780f363c5..5c8afca210 100644
--- a/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlServerAdapter.java
+++ b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlServerAdapter.java
@@ -13,14 +13,24 @@
import graphql.GraphQL;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
+import graphql.language.Document;
+import graphql.language.OperationDefinition;
+import graphql.parser.InvalidSyntaxException;
+import graphql.parser.Parser;
+import graphql.parser.ParserEnvironment;
+import graphql.parser.ParserOptions;
import java.io.IOException;
import java.io.InputStream;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.function.BiConsumer;
import org.restlet.Context;
import org.restlet.Request;
import org.restlet.Response;
import org.restlet.Restlet;
+import org.restlet.data.Dimension;
+import org.restlet.data.Form;
import org.restlet.data.MediaType;
import org.restlet.data.Method;
import org.restlet.data.Status;
@@ -40,14 +50,46 @@
* router.attach("/graphql", graphql);
*
*
+ *
Binding rules:
+ *
+ *
+ *
{@code POST} with an {@code application/json} body accepts all operations; other request
+ * media types get {@code 415}. A JSON array (a batch) is rejected, never executed.
+ *
{@code GET} accepts queries only, and only when {@link #setGetEnabled(boolean)} is set.
+ * Other operations over {@code GET} get {@code 405} with {@code Allow: POST}.
+ *
{@code application/graphql-response+json} is returned when the client names it, otherwise
+ * {@code application/json}; {@code 406} if neither is acceptable.
+ *
Malformed requests get {@code 400}. Documents that fail to parse or validate, or that don't
+ * select an operation, get {@code 400} with {@code application/graphql-response+json} and
+ * {@code 200} with {@code application/json}. Once execution starts, the status is {@code
+ * 200}, even with field errors.
+ *
+ *
*
Operations run on the thread of the call, a virtual thread by default. Asynchronous data
- * fetchers are joined on that thread. The configuration can't change once the adapter is started,
- * which happens at the latest when it handles its first request.
+ * fetchers are joined on that thread. The {@code graphql.GraphQLContext} of each operation holds
+ * the Restlet request under {@link RestletGraphql#REQUEST} and its client info under {@link
+ * RestletGraphql#CLIENT_INFO}. Policies such as query depth and complexity limits, introspection
+ * and the rendering of data fetcher exceptions are configured on the {@code GraphQL} instance with
+ * graphql-java's own instrumentation and handlers.
+ *
+ *
The configuration can't change once the adapter is started, which happens at the latest when
+ * it handles its first request.
*/
public class GraphqlServerAdapter extends Restlet {
+ /** The default maximum size of a request body: 1 MiB. */
+ public static final long DEFAULT_MAX_REQUEST_BYTES = 1L << 20;
+
private volatile GraphQL graphQL;
+ private volatile long maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES;
+
+ private volatile boolean getEnabled;
+
+ private volatile ParserOptions parserOptions;
+
+ private volatile BiConsumer executionInputCustomizer;
+
/** Constructor. */
public GraphqlServerAdapter() {
this(null);
@@ -81,12 +123,101 @@ public void setGraphQL(GraphQL graphQL) {
this.graphQL = graphQL;
}
+ /**
+ * Returns the maximum size of a request body, checked before parsing.
+ *
+ * @return The maximum size of a request body, in bytes.
+ */
+ public long getMaxRequestBytes() {
+ return maxRequestBytes;
+ }
+
+ /**
+ * Sets the maximum size of a request body, checked before parsing. Larger requests get {@code
+ * 413}. Defaults to {@link #DEFAULT_MAX_REQUEST_BYTES}.
+ *
+ * @param maxRequestBytes The maximum size of a request body, in bytes.
+ */
+ public void setMaxRequestBytes(long maxRequestBytes) {
+ checkNotStarted();
+ if (maxRequestBytes <= 0) {
+ throw new IllegalArgumentException("The maximum request size must be positive");
+ }
+ this.maxRequestBytes = maxRequestBytes;
+ }
+
+ /**
+ * Indicates if queries are accepted over {@code GET}.
+ *
+ * @return True if queries are accepted over {@code GET}.
+ */
+ public boolean isGetEnabled() {
+ return getEnabled;
+ }
+
+ /**
+ * Indicates if queries are accepted over {@code GET}. Off by default: {@code GET} requests can
+ * be sent cross-origin without a preflight.
+ *
+ * @param getEnabled True if queries are accepted over {@code GET}.
+ */
+ public void setGetEnabled(boolean getEnabled) {
+ checkNotStarted();
+ this.getEnabled = getEnabled;
+ }
+
+ /**
+ * Returns the parser options applied to each operation.
+ *
+ * @return The parser options, or null for graphql-java's default operation parser options.
+ */
+ public ParserOptions getParserOptions() {
+ return parserOptions;
+ }
+
+ /**
+ * Sets the parser options applied to each operation, such as the maximum number of tokens,
+ * characters and rule depth. They are passed through the {@code GraphQLContext} of each
+ * operation, never through graphql-java's process-wide defaults.
+ *
+ * @param parserOptions The parser options, or null for graphql-java's default operation parser
+ * options, read when the adapter starts.
+ */
+ public void setParserOptions(ParserOptions parserOptions) {
+ checkNotStarted();
+ this.parserOptions = parserOptions;
+ }
+
+ /**
+ * Returns the execution input customizer.
+ *
+ * @return The execution input customizer, or null.
+ */
+ public BiConsumer getExecutionInputCustomizer() {
+ return executionInputCustomizer;
+ }
+
+ /**
+ * Sets a customizer called for each operation before execution, for example to set a {@code
+ * DataLoaderRegistry}, a locale or extra context entries.
+ *
+ * @param executionInputCustomizer The execution input customizer, or null.
+ */
+ public void setExecutionInputCustomizer(
+ BiConsumer executionInputCustomizer) {
+ checkNotStarted();
+ this.executionInputCustomizer = executionInputCustomizer;
+ }
+
@Override
public synchronized void start() throws Exception {
if (isStopped()) {
if (graphQL == null) {
throw new IllegalStateException("No GraphQL instance set");
}
+ if (parserOptions == null) {
+ parserOptions = ParserOptions.getDefaultOperationParserOptions();
+ }
super.start();
}
}
@@ -98,12 +229,15 @@ public void handle(Request request, Response response) {
return;
}
- if (!Method.POST.equals(request.getMethod())) {
- response.setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED);
- response.getAllowedMethods().add(Method.POST);
+ Method method = request.getMethod();
+ boolean get = Method.GET.equals(method);
+ if (!Method.POST.equals(method) && !(get && getEnabled)) {
+ methodNotAllowed(response, getEnabled);
return;
}
+ // The response media type is negotiated, which caches must know of.
+ response.getDimensions().add(Dimension.MEDIA_TYPE);
MediaType responseType =
GraphqlMediaTypes.negotiate(request.getClientInfo().getAcceptedMediaTypes());
if (responseType == null) {
@@ -111,60 +245,237 @@ public void handle(Request request, Response response) {
return;
}
+ Map params;
+ try {
+ params = get ? decodeQuery(request) : decodeBody(request, response, responseType);
+ } catch (RequestException e) {
+ requestError(response, responseType, e.getMessage());
+ return;
+ }
+ if (params == null) {
+ return;
+ }
+
+ ExecutionInput.Builder input;
+ try {
+ String query = string(params, "query", true);
+ String operationName = string(params, "operationName", false);
+ input = ExecutionInput.newExecutionInput().query(query).operationName(operationName);
+ Map variables = object(params, "variables");
+ if (variables != null) {
+ input.variables(variables);
+ }
+ Map extensions = object(params, "extensions");
+ if (extensions != null) {
+ input.extensions(extensions);
+ }
+ } catch (RequestException e) {
+ requestError(response, responseType, e.getMessage());
+ return;
+ }
+
+ // The operation is selected once customized, as the customizer can change it.
+ ExecutionInput executionInput = customize(request, input).build();
+ OperationDefinition.Operation operation;
+ try {
+ operation =
+ selectOperation(executionInput.getQuery(), executionInput.getOperationName());
+ } catch (InvalidSyntaxException e) {
+ write(response, responseType, failure(e.toInvalidSyntaxError()));
+ return;
+ } catch (RequestException e) {
+ write(response, responseType, failure(error(e.getMessage())));
+ return;
+ }
+
+ if (get && operation != OperationDefinition.Operation.QUERY) {
+ methodNotAllowed(response, false);
+ return;
+ }
+ if (operation == OperationDefinition.Operation.SUBSCRIPTION) {
+ write(
+ response,
+ responseType,
+ failure(error("Subscriptions are not supported by this endpoint")));
+ return;
+ }
+
+ write(response, responseType, graphQL.execute(executionInput));
+ }
+
+ private ExecutionInput.Builder customize(Request request, ExecutionInput.Builder input) {
+ BiConsumer customizer = executionInputCustomizer;
+ if (customizer != null) {
+ customizer.accept(request, input);
+ }
+ // graphql-java reads per-execution parser options from the context under their class.
+ return input.graphQLContext(
+ builder ->
+ builder.put(ParserOptions.class, parserOptions)
+ .put(RestletGraphql.REQUEST, request)
+ .put(RestletGraphql.CLIENT_INFO, request.getClientInfo()));
+ }
+
+ /**
+ * Parses the document with the configured limits and returns the type of the operation to
+ * execute.
+ */
+ private OperationDefinition.Operation selectOperation(String query, String operationName)
+ throws RequestException {
+ Document document =
+ Parser.parse(
+ ParserEnvironment.newParserEnvironment()
+ .document(query)
+ .parserOptions(parserOptions)
+ .build());
+ List operations =
+ document.getDefinitionsOfType(OperationDefinition.class);
+ // An empty operation name, such as an empty GET parameter, is omitted.
+ if (operationName == null || operationName.isEmpty()) {
+ if (operations.size() != 1) {
+ throw new RequestException(
+ operations.isEmpty()
+ ? "The document has no operation"
+ : "An operationName is required when the document has several"
+ + " operations");
+ }
+ return operations.getFirst().getOperation();
+ }
+ for (OperationDefinition operation : operations) {
+ if (operationName.equals(operation.getName())) {
+ return operation.getOperation();
+ }
+ }
+ throw new RequestException("Unknown operation named '" + operationName + "'");
+ }
+
+ private static Map decodeQuery(Request request) throws RequestException {
+ Form form = request.getResourceRef().getQueryAsForm();
+ Map params = new HashMap<>();
+ params.put("query", form.getFirstValue("query"));
+ params.put("operationName", form.getFirstValue("operationName"));
+ params.put("variables", parseParameter(form, "variables"));
+ params.put("extensions", parseParameter(form, "extensions"));
+ return params;
+ }
+
+ private static Object parseParameter(Form form, String name) throws RequestException {
+ String value = form.getFirstValue(name);
+ if (value == null || value.isEmpty()) {
+ return null;
+ }
+ try {
+ return GraphqlJson.parse(value);
+ } catch (GraphqlJson.InvalidJsonException e) {
+ throw new RequestException("Invalid JSON in '" + name + "': " + e.getMessage());
+ }
+ }
+
+ private Map decodeBody(
+ Request request, Response response, MediaType responseType) throws RequestException {
Representation entity = request.getEntity();
if (entity == null || !entity.isAvailable()) {
- requestError(response, responseType, "The request has no body");
- return;
+ throw new RequestException("The request has no body");
+ }
+ if (entity.getMediaType() == null
+ || !MediaType.APPLICATION_JSON.equals(entity.getMediaType(), true)) {
+ response.setStatus(Status.CLIENT_ERROR_UNSUPPORTED_MEDIA_TYPE);
+ return null;
+ }
+ if (entity.getSize() > maxRequestBytes) {
+ tooLarge(response, responseType);
+ return null;
}
Object body;
try (InputStream in = entity.getStream()) {
- body = GraphqlJson.read(in, Long.MAX_VALUE);
+ body = GraphqlJson.read(in, maxRequestBytes);
+ } catch (GraphqlJson.TooLargeException e) {
+ tooLarge(response, responseType);
+ return null;
} catch (GraphqlJson.InvalidJsonException e) {
- requestError(response, responseType, "Invalid JSON body: " + e.getMessage());
- return;
+ throw new RequestException("Invalid JSON body: " + e.getMessage());
} catch (IOException e) {
response.setStatus(Status.CLIENT_ERROR_BAD_REQUEST, e);
- return;
+ return null;
}
+ if (body instanceof List>) {
+ throw new RequestException("Batched requests are not supported");
+ }
Map params = GraphqlJson.asObject(body);
- if (params == null || !(params.get("query") instanceof String query)) {
- requestError(response, responseType, "The request must have a string 'query'");
- return;
+ if (params == null) {
+ throw new RequestException("The request body must be a JSON object");
}
+ return params;
+ }
- ExecutionInput.Builder input = ExecutionInput.newExecutionInput().query(query);
- if (params.get("operationName") instanceof String operationName) {
- input.operationName(operationName);
+ private static String string(Map params, String name, boolean required)
+ throws RequestException {
+ Object value = params.get(name);
+ if (value instanceof String string) {
+ return string;
}
- Map variables = GraphqlJson.asObject(params.get("variables"));
- if (variables != null) {
- input.variables(variables);
+ if (value == null && !required) {
+ return null;
}
- Map extensions = GraphqlJson.asObject(params.get("extensions"));
- if (extensions != null) {
- input.extensions(extensions);
+ throw new RequestException(
+ "The request must have a string '" + name + "'" + (required ? "" : " or none"));
+ }
+
+ private static Map object(Map params, String name)
+ throws RequestException {
+ Object value = params.get(name);
+ Map object = GraphqlJson.asObject(value);
+ if (value != null && object == null) {
+ throw new RequestException("'" + name + "' must be a JSON object");
+ }
+ return object;
+ }
+
+ private static GraphQLError error(String message) {
+ return GraphqlErrorBuilder.newError().message("%s", message).build();
+ }
+
+ private static ExecutionResult failure(GraphQLError error) {
+ return ExecutionResult.newExecutionResult().errors(List.of(error)).build();
+ }
+
+ private static void methodNotAllowed(Response response, boolean getAllowed) {
+ response.setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED);
+ if (getAllowed) {
+ response.getAllowedMethods().add(Method.GET);
}
+ response.getAllowedMethods().add(Method.POST);
+ }
- ExecutionResult result = graphQL.execute(input.build());
- write(response, responseType, result);
+ private static void tooLarge(Response response, MediaType responseType) {
+ writeEntity(response, responseType, failure(error("The request body is too large")));
+ response.setStatus(Status.CLIENT_ERROR_REQUEST_ENTITY_TOO_LARGE);
}
private static void requestError(Response response, MediaType responseType, String message) {
- GraphQLError error = GraphqlErrorBuilder.newError().message("%s", message).build();
- write(
- response,
- responseType,
- ExecutionResult.newExecutionResult().errors(List.of(error)).build());
+ writeEntity(response, responseType, failure(error(message)));
response.setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
}
+ /**
+ * Writes a result. A result without data means that execution didn't start: a request error,
+ * reported with {@code 400} in {@code application/graphql-response+json} only.
+ */
private static void write(Response response, MediaType responseType, ExecutionResult result) {
+ writeEntity(response, responseType, result);
+ boolean requestError =
+ !result.isDataPresent()
+ && GraphqlMediaTypes.GRAPHQL_RESPONSE_JSON.equals(responseType);
+ response.setStatus(requestError ? Status.CLIENT_ERROR_BAD_REQUEST : Status.SUCCESS_OK);
+ }
+
+ private static void writeEntity(
+ Response response, MediaType responseType, ExecutionResult result) {
response.setEntity(
new ByteArrayRepresentation(
GraphqlJson.write(result.toSpecification()), responseType));
- response.setStatus(Status.SUCCESS_OK);
}
private void checkNotStarted() {
@@ -172,4 +483,14 @@ private void checkNotStarted() {
throw new IllegalStateException("The configuration can't change once started");
}
}
+
+ /** A malformed request. */
+ private static final class RequestException extends Exception {
+
+ private static final long serialVersionUID = 1L;
+
+ RequestException(String message) {
+ super(message, null, false, false);
+ }
+ }
}
diff --git a/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/RestletGraphql.java b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/RestletGraphql.java
new file mode 100644
index 0000000000..b1f8e5fc0e
--- /dev/null
+++ b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/RestletGraphql.java
@@ -0,0 +1,30 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * The content of this file is subject to the terms of the Apache 2.0 open
+ * source license available at https://www.opensource.org/licenses/apache-2.0
+ *
+ * Restlet is a registered trademark of QlikTech International AB.
+ */
+package org.restlet.ext.graphql;
+
+/**
+ * Keys of the {@code graphql.GraphQLContext} entries set by {@link GraphqlServerAdapter} for each
+ * operation. Data fetchers read them with {@code env.getGraphQlContext().get(key)}.
+ *
+ *
They are set after the execution input customizer runs, so neither the customizer nor the
+ * {@code extensions} of a request can replace them.
+ */
+public final class RestletGraphql {
+
+ /** Key of the Restlet {@link org.restlet.Request} being handled. */
+ public static final String REQUEST = "org.restlet.ext.graphql.request";
+
+ /**
+ * Key of the {@link org.restlet.data.ClientInfo} of the request, including the authenticated
+ * user and roles set by Restlet authenticators.
+ */
+ public static final String CLIENT_INFO = "org.restlet.ext.graphql.clientInfo";
+
+ private RestletGraphql() {}
+}
diff --git a/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlGetBindingTestCase.java b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlGetBindingTestCase.java
new file mode 100644
index 0000000000..a780c93545
--- /dev/null
+++ b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlGetBindingTestCase.java
@@ -0,0 +1,116 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * The content of this file is subject to the terms of the Apache 2.0 open
+ * source license available at https://www.opensource.org/licenses/apache-2.0
+ *
+ * The content of this file is subject to the terms of the Apache 2.0 open
+ * source license available at https://www.opensource.org/licenses/apache-2.0
+ *