diff --git a/changes.md b/changes.md
index dd954dc6c5..4f64462a1e 100644
--- a/changes.md
+++ b/changes.md
@@ -135,6 +135,12 @@ Changes log
parameters. Server and client support "permessage-deflate" compression (RFC 7692),
enabled by default. `WebSocketClient.setClient` shares the I/O threads of a borrowed,
started Netty HTTP `Client` instead of creating its own.
+ - Added the experimental `org.restlet.ext.graphql` extension, on graphql-java.
+ `GraphqlServerAdapter` serves a `graphql.GraphQL` instance as a Restlet, following the
+ 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.
- 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.
@@ -192,6 +198,7 @@ Changes log
without adding a production module dependency.
- Pinned the Maven Deploy plugin to version 3.2.0 (avoids resolving incompatible Maven 4 beta versions).
- Added Netty 4.2.19 (imported with the `netty-bom`). CI builds with Java 25.
+ - Added graphql-java 26.1 (GraphQL extension).
- The `dangling-doc-comments` lint, new in Java 23, is disabled because license headers
use Javadoc-style comments.
diff --git a/org.restlet.ext.graphql/pom.xml b/org.restlet.ext.graphql/pom.xml
new file mode 100644
index 0000000000..fbf42d79d1
--- /dev/null
+++ b/org.restlet.ext.graphql/pom.xml
@@ -0,0 +1,39 @@
+
+
+ 4.0.0
+
+ org.restlet
+ org.restlet.parent
+ 3.0.0-SNAPSHOT
+ ../pom.xml
+
+
+ org.restlet.ext.graphql
+ Restlet Framework - GraphQL extension
+ GraphQL server and client on graphql-java (experimental).
+
+
+
+ org.restlet
+ org.restlet
+ ${project.version}
+
+
+ com.graphql-java
+ graphql-java
+ ${lib-graphql-java-version}
+
+
+ tools.jackson.core
+ jackson-databind
+ ${lib-jackson3-version}
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ ${lib-junit-version}
+ test
+
+
+
diff --git a/org.restlet.ext.graphql/src/main/java/module-info.java b/org.restlet.ext.graphql/src/main/java/module-info.java
new file mode 100644
index 0000000000..7f6f6e54d2
--- /dev/null
+++ b/org.restlet.ext.graphql/src/main/java/module-info.java
@@ -0,0 +1,24 @@
+/**
+ * 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.
+ */
+/** Restlet Framework GraphQL extension (experimental). */
+@SuppressWarnings({"requires-automatic", "requires-transitive-automatic"})
+module org.restlet.ext.graphql {
+ requires transitive org.restlet;
+
+ // graphql-java types are the public model of the extension. graphql-java 26.1 has no module
+ // descriptor: its automatic module name comes from its manifest.
+ requires transitive com.graphqljava;
+ requires transitive org.reactivestreams;
+
+ // JSON is an implementation detail: required, not re-exported.
+ requires tools.jackson.core;
+ requires tools.jackson.databind;
+
+ exports org.restlet.ext.graphql;
+}
diff --git a/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlClientAdapter.java b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlClientAdapter.java
new file mode 100644
index 0000000000..cad0e5bd0b
--- /dev/null
+++ b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlClientAdapter.java
@@ -0,0 +1,301 @@
+/**
+ * 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;
+
+import graphql.ExecutionResult;
+import graphql.language.AstPrinter;
+import graphql.language.Document;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import org.restlet.Client;
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.data.CharacterSet;
+import org.restlet.data.MediaType;
+import org.restlet.data.Method;
+import org.restlet.data.Preference;
+import org.restlet.data.Status;
+import org.restlet.ext.graphql.internal.GraphqlJson;
+import org.restlet.ext.graphql.internal.GraphqlMediaTypes;
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+
+/**
+ * Sends GraphQL operations over HTTP through a borrowed Restlet {@link Client}.
+ *
+ *
The adapter never starts or stops the borrowed client. Its configuration can't change once
+ * started. Errors reported by the server are returned in the {@link ExecutionResult}; failures to
+ * obtain a GraphQL response throw a {@link GraphqlTransportException}.
+ */
+public class GraphqlClientAdapter {
+
+ private static final int EXCERPT_BYTES = 1024;
+
+ private static final long MAX_RESPONSE_BYTES = 10L << 20;
+
+ private enum State {
+ NEW,
+ STARTED,
+ STOPPED
+ }
+
+ private final Context context;
+
+ private volatile Client client;
+
+ private volatile String endpoint;
+
+ private volatile State state = State.NEW;
+
+ /** Constructor. */
+ public GraphqlClientAdapter() {
+ this(null);
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param context The context, or null.
+ */
+ public GraphqlClientAdapter(Context context) {
+ this.context = context;
+ }
+
+ /**
+ * Returns the context.
+ *
+ * @return The context, or null.
+ */
+ public Context getContext() {
+ return context;
+ }
+
+ /**
+ * Returns the borrowed client.
+ *
+ * @return The borrowed client.
+ */
+ public Client getClient() {
+ return client;
+ }
+
+ /**
+ * Sets the borrowed client. The adapter never starts or stops it.
+ *
+ * @param client The borrowed client.
+ */
+ public void setClient(Client client) {
+ checkNew();
+ this.client = client;
+ }
+
+ /**
+ * Returns the URI of the GraphQL endpoint.
+ *
+ * @return The URI of the GraphQL endpoint.
+ */
+ public String getEndpoint() {
+ return endpoint;
+ }
+
+ /**
+ * Sets the URI of the GraphQL endpoint.
+ *
+ * @param endpoint The URI of the GraphQL endpoint.
+ */
+ public void setEndpoint(String endpoint) {
+ checkNew();
+ this.endpoint = endpoint;
+ }
+
+ /**
+ * Validates and freezes the configuration.
+ *
+ * @throws IllegalStateException If the client or the endpoint is missing, or if the adapter was
+ * already started.
+ */
+ public synchronized void start() {
+ checkNew();
+ if (client == null) {
+ throw new IllegalStateException("No client set");
+ }
+ if (endpoint == null) {
+ throw new IllegalStateException("No endpoint set");
+ }
+ state = State.STARTED;
+ }
+
+ /** Stops the adapter. The borrowed client is left running. */
+ public synchronized void stop() {
+ state = State.STOPPED;
+ }
+
+ /**
+ * Indicates if the adapter is started.
+ *
+ * @return True if the adapter is started.
+ */
+ public boolean isStarted() {
+ return state == State.STARTED;
+ }
+
+ /**
+ * Executes a parsed operation.
+ *
+ * @param document The document, printed before sending.
+ * @param operationName The name of the operation to execute, or null.
+ * @param variables The variables, or null.
+ * @return The result.
+ * @throws GraphqlTransportException If no GraphQL response could be obtained.
+ */
+ public ExecutionResult execute(
+ Document document, String operationName, Map variables)
+ throws GraphqlTransportException {
+ return execute(AstPrinter.printAst(document), operationName, variables);
+ }
+
+ /**
+ * Executes an operation.
+ *
+ * @param document The document.
+ * @param operationName The name of the operation to execute, or null.
+ * @param variables The variables, or null.
+ * @return The result.
+ * @throws GraphqlTransportException If no GraphQL response could be obtained.
+ */
+ public ExecutionResult execute(
+ String document, String operationName, Map variables)
+ throws GraphqlTransportException {
+ Objects.requireNonNull(document, "document");
+ if (state != State.STARTED) {
+ throw new IllegalStateException("The adapter is not started");
+ }
+
+ Map body = new LinkedHashMap<>();
+ body.put("query", document);
+ if (operationName != null) {
+ body.put("operationName", operationName);
+ }
+ if (variables != null && !variables.isEmpty()) {
+ body.put("variables", variables);
+ }
+
+ Request request = new Request(Method.POST, endpoint);
+ request.setEntity(
+ new StringRepresentation(
+ GraphqlJson.writeString(body),
+ MediaType.APPLICATION_JSON,
+ null,
+ CharacterSet.UTF_8));
+ request.getClientInfo()
+ .getAcceptedMediaTypes()
+ .add(new Preference<>(GraphqlMediaTypes.GRAPHQL_RESPONSE_JSON, 1F));
+ request.getClientInfo()
+ .getAcceptedMediaTypes()
+ .add(new Preference<>(MediaType.APPLICATION_JSON, 0.9F));
+
+ Response response = client.handle(request);
+ try {
+ return toResult(response);
+ } finally {
+ Representation entity = response.getEntity();
+ if (entity != null) {
+ entity.release();
+ }
+ }
+ }
+
+ private static ExecutionResult toResult(Response response) throws GraphqlTransportException {
+ Status status = response.getStatus();
+ if (status.isConnectorError()) {
+ throw new GraphqlTransportException(
+ "No response from the GraphQL endpoint: " + status,
+ null,
+ null,
+ status.getThrowable());
+ }
+
+ Representation entity = response.getEntity();
+ byte[] bytes;
+ try (InputStream in = stream(entity)) {
+ bytes = GraphqlJson.readBytes(in, MAX_RESPONSE_BYTES);
+ } catch (GraphqlJson.TooLargeException e) {
+ throw new GraphqlTransportException(
+ "The GraphQL response exceeds " + MAX_RESPONSE_BYTES + " bytes",
+ status,
+ null,
+ e);
+ } catch (IOException e) {
+ throw new GraphqlTransportException(
+ "Unable to read the GraphQL response", status, null, e);
+ }
+
+ boolean json =
+ entity != null
+ && entity.getMediaType() != null
+ && (entity.getMediaType()
+ .equals(GraphqlMediaTypes.GRAPHQL_RESPONSE_JSON, true)
+ || entity.getMediaType().equals(MediaType.APPLICATION_JSON, true));
+ if (json && (status.isSuccess() || status.isClientError())) {
+ Map result = null;
+ try {
+ result = GraphqlJson.asObject(GraphqlJson.parse(bytes));
+ } catch (GraphqlJson.InvalidJsonException _) {
+ // Reported below as a non-GraphQL response
+ }
+ if (result != null && (result.containsKey("data") || result.containsKey("errors"))) {
+ try {
+ ExecutionResult executionResult = ExecutionResult.fromSpecification(result);
+ if (result.containsKey("data") && result.get("data") == null) {
+ // fromSpecification() drops an explicit null data entry
+ executionResult = executionResult.transform(builder -> builder.data(null));
+ }
+ return executionResult;
+ } catch (RuntimeException e) {
+ throw new GraphqlTransportException(
+ "Not a GraphQL response: " + status, status, excerpt(bytes), e);
+ }
+ }
+ }
+
+ throw new GraphqlTransportException(
+ "Not a GraphQL response: " + status, status, excerpt(bytes), null);
+ }
+
+ private static InputStream stream(Representation entity) throws IOException {
+ InputStream in = entity == null ? null : entity.getStream();
+ return in == null ? InputStream.nullInputStream() : in;
+ }
+
+ private static String excerpt(byte[] bytes) {
+ if (bytes.length == 0) {
+ return null;
+ }
+ return new String(bytes, 0, Math.min(bytes.length, EXCERPT_BYTES), StandardCharsets.UTF_8);
+ }
+
+ private void checkNew() {
+ if (state != State.NEW) {
+ throw new IllegalStateException("The configuration can't change once started");
+ }
+ }
+}
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
new file mode 100644
index 0000000000..5780f363c5
--- /dev/null
+++ b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlServerAdapter.java
@@ -0,0 +1,175 @@
+/**
+ * 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;
+
+import graphql.ExecutionInput;
+import graphql.ExecutionResult;
+import graphql.GraphQL;
+import graphql.GraphQLError;
+import graphql.GraphqlErrorBuilder;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Map;
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+import org.restlet.data.MediaType;
+import org.restlet.data.Method;
+import org.restlet.data.Status;
+import org.restlet.ext.graphql.internal.GraphqlJson;
+import org.restlet.ext.graphql.internal.GraphqlMediaTypes;
+import org.restlet.representation.ByteArrayRepresentation;
+import org.restlet.representation.Representation;
+
+/**
+ * Serves a native {@link GraphQL} instance over HTTP, following the GraphQL over HTTP draft.
+ *
+ *
Attach it to a router next to other resources:
+ *
+ *
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.
+ */
+public class GraphqlServerAdapter extends Restlet {
+
+ private volatile GraphQL graphQL;
+
+ /** Constructor. */
+ public GraphqlServerAdapter() {
+ this(null);
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param context The context.
+ */
+ public GraphqlServerAdapter(Context context) {
+ super(context);
+ }
+
+ /**
+ * Returns the GraphQL instance that executes operations.
+ *
+ * @return The GraphQL instance.
+ */
+ public GraphQL getGraphQL() {
+ return graphQL;
+ }
+
+ /**
+ * Sets the GraphQL instance that executes operations.
+ *
+ * @param graphQL The GraphQL instance.
+ */
+ public void setGraphQL(GraphQL graphQL) {
+ checkNotStarted();
+ this.graphQL = graphQL;
+ }
+
+ @Override
+ public synchronized void start() throws Exception {
+ if (isStopped()) {
+ if (graphQL == null) {
+ throw new IllegalStateException("No GraphQL instance set");
+ }
+ super.start();
+ }
+ }
+
+ @Override
+ public void handle(Request request, Response response) {
+ super.handle(request, response);
+ if (!isStarted()) {
+ return;
+ }
+
+ if (!Method.POST.equals(request.getMethod())) {
+ response.setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED);
+ response.getAllowedMethods().add(Method.POST);
+ return;
+ }
+
+ MediaType responseType =
+ GraphqlMediaTypes.negotiate(request.getClientInfo().getAcceptedMediaTypes());
+ if (responseType == null) {
+ response.setStatus(Status.CLIENT_ERROR_NOT_ACCEPTABLE);
+ return;
+ }
+
+ Representation entity = request.getEntity();
+ if (entity == null || !entity.isAvailable()) {
+ requestError(response, responseType, "The request has no body");
+ return;
+ }
+
+ Object body;
+ try (InputStream in = entity.getStream()) {
+ body = GraphqlJson.read(in, Long.MAX_VALUE);
+ } catch (GraphqlJson.InvalidJsonException e) {
+ requestError(response, responseType, "Invalid JSON body: " + e.getMessage());
+ return;
+ } catch (IOException e) {
+ response.setStatus(Status.CLIENT_ERROR_BAD_REQUEST, e);
+ return;
+ }
+
+ 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;
+ }
+
+ ExecutionInput.Builder input = ExecutionInput.newExecutionInput().query(query);
+ if (params.get("operationName") instanceof String operationName) {
+ input.operationName(operationName);
+ }
+ Map variables = GraphqlJson.asObject(params.get("variables"));
+ if (variables != null) {
+ input.variables(variables);
+ }
+ Map extensions = GraphqlJson.asObject(params.get("extensions"));
+ if (extensions != null) {
+ input.extensions(extensions);
+ }
+
+ ExecutionResult result = graphQL.execute(input.build());
+ write(response, responseType, result);
+ }
+
+ 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());
+ response.setStatus(Status.CLIENT_ERROR_BAD_REQUEST);
+ }
+
+ private static void write(Response response, MediaType responseType, ExecutionResult result) {
+ response.setEntity(
+ new ByteArrayRepresentation(
+ GraphqlJson.write(result.toSpecification()), responseType));
+ response.setStatus(Status.SUCCESS_OK);
+ }
+
+ private void checkNotStarted() {
+ if (isStarted()) {
+ throw new IllegalStateException("The configuration can't change once started");
+ }
+ }
+}
diff --git a/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlTransportException.java b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlTransportException.java
new file mode 100644
index 0000000000..66f0baf6fe
--- /dev/null
+++ b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlTransportException.java
@@ -0,0 +1,60 @@
+/**
+ * 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;
+
+import java.io.IOException;
+import org.restlet.data.Status;
+
+/**
+ * Thrown by {@link GraphqlClientAdapter} when no GraphQL response could be obtained: connection
+ * failures, timeouts, responses that are too large, and responses that are not GraphQL, such as an
+ * HTML error page or an empty {@code 401}. Errors reported by the GraphQL server are returned in
+ * the {@code ExecutionResult} instead.
+ */
+public class GraphqlTransportException extends IOException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final transient Status status;
+
+ private final String bodyExcerpt;
+
+ /**
+ * Constructor.
+ *
+ * @param message The message.
+ * @param status The response status, or null if no response was received.
+ * @param bodyExcerpt The beginning of the response body, or null.
+ * @param cause The cause, or null.
+ */
+ public GraphqlTransportException(
+ String message, Status status, String bodyExcerpt, Throwable cause) {
+ super(message, cause);
+ this.status = status;
+ this.bodyExcerpt = bodyExcerpt;
+ }
+
+ /**
+ * Returns the response status.
+ *
+ * @return The response status, or null if no response was received.
+ */
+ public Status getStatus() {
+ return status;
+ }
+
+ /**
+ * Returns the beginning of the response body, decoded as UTF-8 and bounded in size.
+ *
+ * @return The beginning of the response body, or null.
+ */
+ public String getBodyExcerpt() {
+ return bodyExcerpt;
+ }
+}
diff --git a/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/internal/GraphqlJson.java b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/internal/GraphqlJson.java
new file mode 100644
index 0000000000..8b21ee479f
--- /dev/null
+++ b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/internal/GraphqlJson.java
@@ -0,0 +1,142 @@
+/**
+ * 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.internal;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Map;
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.json.JsonMapper;
+
+/**
+ * JSON encoding of GraphQL requests and results. GraphQL values are plain maps, lists, strings,
+ * numbers, booleans and nulls, so no binding to Java types is needed.
+ */
+public final class GraphqlJson {
+
+ private static final JsonMapper MAPPER = JsonMapper.builder().build();
+
+ private static final TypeReference