Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
39 changes: 39 additions & 0 deletions org.restlet.ext.graphql/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.restlet</groupId>
<artifactId>org.restlet.parent</artifactId>
<version>3.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>

<artifactId>org.restlet.ext.graphql</artifactId>
<name>Restlet Framework - GraphQL extension</name>
<description>GraphQL server and client on graphql-java (experimental).</description>

<dependencies>
<dependency>
<groupId>org.restlet</groupId>
<artifactId>org.restlet</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java</artifactId>
<version>${lib-graphql-java-version}</version>
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${lib-jackson3-version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${lib-junit-version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
24 changes: 24 additions & 0 deletions org.restlet.ext.graphql/src/main/java/module-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Copyright 2005-2026 Qlik
*
* <p>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
*
* <p>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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
/**
* Copyright 2005-2026 Qlik
*<p>
* 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
*<p>
* 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}.
*
* <pre>
* GraphqlClientAdapter graphql = new GraphqlClientAdapter(getContext());
* graphql.setClient(restletClient);
* graphql.setEndpoint("https://example.com/graphql");
* graphql.start();
* ExecutionResult result = graphql.execute(document, "GetIssue", variables);
* </pre>
*
* <p>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<String, Object> 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<String, Object> variables)
throws GraphqlTransportException {
Objects.requireNonNull(document, "document");
if (state != State.STARTED) {
throw new IllegalStateException("The adapter is not started");
}

Map<String, Object> 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 {

Check failure on line 227 in org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlClientAdapter.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=restlet_restlet-framework-java&issues=AaGOU9vIUO3G-jaZrSS8&open=AaGOU9vIUO3G-jaZrSS8&pullRequest=1589
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<String, Object> 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");
}
}
}
Loading
Loading