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}. + * + *

+ * GraphqlClientAdapter graphql = new GraphqlClientAdapter(getContext());
+ * graphql.setClient(restletClient);
+ * graphql.setEndpoint("https://example.com/graphql");
+ * graphql.start();
+ * ExecutionResult result = graphql.execute(document, "GetIssue", variables);
+ * 
+ * + *

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: + * + *

+ * GraphqlServerAdapter graphql = new GraphqlServerAdapter(getContext());
+ * graphql.setGraphQL(GraphQL.newGraphQL(schema).build());
+ * router.attach("/graphql", graphql);
+ * 
+ * + *

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 ANY = new TypeReference<>() {}; + + private GraphqlJson() {} + + /** + * Reads a JSON value from at most {@code maxBytes} bytes. + * + * @param in The input stream, not closed. + * @param maxBytes The maximum number of bytes to read. + * @return The value: a map, a list, a string, a number, a boolean or null. + * @throws TooLargeException If the stream has more than {@code maxBytes} bytes. + * @throws InvalidJsonException If the content is not valid JSON. + * @throws IOException If reading fails. + */ + public static Object read(InputStream in, long maxBytes) throws IOException { + return parse(readBytes(in, maxBytes)); + } + + /** + * Parses a JSON value. + * + * @param json The JSON text. + * @return The value: a map, a list, a string, a number, a boolean or null. + * @throws InvalidJsonException If the text is not valid JSON. + */ + public static Object parse(String json) { + try { + return MAPPER.readValue(json, ANY); + } catch (JacksonException e) { + throw new InvalidJsonException(e.getOriginalMessage(), e); + } + } + + /** + * Parses a JSON value. + * + * @param json The JSON bytes, in UTF-8. + * @return The value: a map, a list, a string, a number, a boolean or null. + * @throws InvalidJsonException If the bytes are not valid JSON. + */ + public static Object parse(byte[] json) { + try { + return MAPPER.readValue(json, ANY); + } catch (JacksonException e) { + throw new InvalidJsonException(e.getOriginalMessage(), e); + } + } + + /** + * Reads at most {@code maxBytes} bytes. + * + * @param in The input stream, not closed. + * @param maxBytes The maximum number of bytes to read. + * @return The bytes. + * @throws TooLargeException If the stream has more than {@code maxBytes} bytes. + * @throws IOException If reading fails. + */ + public static byte[] readBytes(InputStream in, long maxBytes) throws IOException { + int limit = (int) Math.min(maxBytes, Integer.MAX_VALUE - 8L); + byte[] bytes = in.readNBytes(limit); + if (bytes.length == limit && in.read() != -1) { + throw new TooLargeException(maxBytes); + } + return bytes; + } + + /** + * Writes a value as JSON. + * + * @param value The value, typically from {@code ExecutionResult.toSpecification()}. + * @return The UTF-8 bytes. + */ + public static byte[] write(Object value) { + return MAPPER.writeValueAsBytes(value); + } + + /** + * Writes a value as JSON. + * + * @param value The value, typically from {@code ExecutionResult.toSpecification()}. + * @return The JSON text. + */ + public static String writeString(Object value) { + return MAPPER.writeValueAsString(value); + } + + /** + * Returns a JSON object as a map. + * + * @param value A value returned by {@link #parse(String)}. + * @return The map, or null if the value is not a JSON object. + */ + @SuppressWarnings("unchecked") + public static Map asObject(Object value) { + return value instanceof Map map ? (Map) map : null; + } + + /** Thrown when content is not valid JSON. */ + public static final class InvalidJsonException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + InvalidJsonException(String message, Throwable cause) { + super(message, cause); + } + } + + /** Thrown when content exceeds its size limit. */ + public static final class TooLargeException extends IOException { + + private static final long serialVersionUID = 1L; + + TooLargeException(long maxBytes) { + super("Content exceeds " + maxBytes + " bytes"); + } + } +} diff --git a/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/internal/GraphqlMediaTypes.java b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/internal/GraphqlMediaTypes.java new file mode 100644 index 0000000000..c1fc3b5cc7 --- /dev/null +++ b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/internal/GraphqlMediaTypes.java @@ -0,0 +1,81 @@ +/** + * 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.util.List; +import org.restlet.data.MediaType; +import org.restlet.data.Preference; + +/** Media types of the GraphQL over HTTP binding and their negotiation. */ +public final class GraphqlMediaTypes { + + /** The {@code application/graphql-response+json} media type of the GraphQL over HTTP draft. */ + public static final MediaType GRAPHQL_RESPONSE_JSON = + MediaType.register("application/graphql-response+json", "GraphQL response"); + + private GraphqlMediaTypes() {} + + /** + * Selects the response media type of a single result. + * + *

{@code application/graphql-response+json} is selected only when the client names it, with + * a quality at least equal to that of {@code application/json}. Wildcards and a missing {@code + * Accept} header select {@code application/json}, as the draft requires for legacy clients. + * + * @param accepted The accepted media types of the request. + * @return The selected media type, or null if neither is acceptable. + */ + public static MediaType negotiate(List> accepted) { + if (accepted.isEmpty()) { + return MediaType.APPLICATION_JSON; + } + float graphql = quality(accepted, GRAPHQL_RESPONSE_JSON, true); + float json = quality(accepted, MediaType.APPLICATION_JSON, false); + if (graphql > 0F && graphql >= json) { + return GRAPHQL_RESPONSE_JSON; + } + return json > 0F ? MediaType.APPLICATION_JSON : null; + } + + /** + * Returns the quality with which a media type is accepted. + * + * @param accepted The accepted media types of the request. + * @param type The media type. + * @param exact True to ignore wildcard ranges. + * @return The quality of the most specific matching range (the highest one among equally + * specific ranges), or 0 if the type is not accepted. + */ + public static float quality( + List> accepted, MediaType type, boolean exact) { + float result = 0F; + int specificity = 0; + for (Preference preference : accepted) { + MediaType range = preference.getMetadata(); + boolean matches = exact ? range.equals(type, true) : range.includes(type, true); + if (matches) { + int rangeSpecificity = specificity(range); + if (rangeSpecificity > specificity) { + specificity = rangeSpecificity; + result = preference.getQuality(); + } else if (rangeSpecificity == specificity) { + result = Math.max(result, preference.getQuality()); + } + } + } + return result; + } + + private static int specificity(MediaType range) { + if (MediaType.ALL.equals(range, true)) { + return 1; + } + return "*".equals(range.getSubType()) ? 2 : 3; + } +} diff --git a/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/internal/package-info.java b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/internal/package-info.java new file mode 100644 index 0000000000..09c62ffcd9 --- /dev/null +++ b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/internal/package-info.java @@ -0,0 +1,10 @@ +/** + * 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. + */ +/** Internal classes of the GraphQL extension. Not exported. */ +package org.restlet.ext.graphql.internal; diff --git a/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/package-info.java b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/package-info.java new file mode 100644 index 0000000000..3c60c03d18 --- /dev/null +++ b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/package-info.java @@ -0,0 +1,17 @@ +/** + * 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. + */ +/** + * GraphQL server and client on graphql-java (experimental). + * + *

{@link org.restlet.ext.graphql.GraphqlServerAdapter} serves a native {@code graphql.GraphQL} + * instance as a Restlet; {@link org.restlet.ext.graphql.GraphqlClientAdapter} sends operations + * through a borrowed {@link org.restlet.Client}. Both implement the GraphQL over HTTP draft and use + * graphql-java types as their model. + */ +package org.restlet.ext.graphql; diff --git a/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlClientResponseTestCase.java b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlClientResponseTestCase.java new file mode 100644 index 0000000000..e0452a89de --- /dev/null +++ b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlClientResponseTestCase.java @@ -0,0 +1,119 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import graphql.ExecutionResult; +import java.util.List; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; +import org.restlet.Client; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.MediaType; +import org.restlet.data.Preference; +import org.restlet.data.Protocol; +import org.restlet.data.Status; +import org.restlet.ext.graphql.internal.GraphqlMediaTypes; +import org.restlet.representation.EmptyRepresentation; + +/** Conversion of responses by the client adapter, and response media type negotiation. */ +class GraphqlClientResponseTestCase { + + private static GraphqlClientAdapter adapter(Consumer responder) { + Client client = + new Client(Protocol.HTTP) { + @Override + public void handle(Request request, Response response) { + responder.accept(response); + } + }; + GraphqlClientAdapter adapter = new GraphqlClientAdapter(); + adapter.setClient(client); + adapter.setEndpoint("http://localhost/graphql"); + adapter.start(); + return adapter; + } + + @Test + void entityWithoutStreamIsTransportError() { + GraphqlClientAdapter adapter = + adapter( + response -> { + response.setStatus(Status.SUCCESS_OK); + response.setEntity(new EmptyRepresentation()); + }); + GraphqlTransportException e = + assertThrows( + GraphqlTransportException.class, + () -> adapter.execute("{ hello }", null, null)); + assertEquals(Status.SUCCESS_OK, e.getStatus()); + assertNull(e.getBodyExcerpt()); + } + + @Test + void malformedResultIsTransportError() { + GraphqlClientAdapter adapter = + adapter( + response -> + response.setEntity( + "{\"errors\":\"Unauthorized\"}", + MediaType.APPLICATION_JSON)); + GraphqlTransportException e = + assertThrows( + GraphqlTransportException.class, + () -> adapter.execute("{ hello }", null, null)); + assertEquals(Status.SUCCESS_OK, e.getStatus()); + assertEquals("{\"errors\":\"Unauthorized\"}", e.getBodyExcerpt()); + assertInstanceOf(ClassCastException.class, e.getCause()); + } + + @Test + void explicitNullDataIsPreserved() throws Exception { + GraphqlClientAdapter adapter = + adapter( + response -> + response.setEntity( + "{\"data\":null,\"errors\":[{\"message\":\"boom\"}]}", + MediaType.APPLICATION_JSON)); + ExecutionResult result = adapter.execute("{ hello }", null, null); + assertTrue(result.isDataPresent()); + assertNull(result.getData()); + assertTrue(result.toSpecification().containsKey("data")); + assertEquals(1, result.getErrors().size()); + } + + @Test + void mostSpecificRangeWins() { + assertNull( + GraphqlMediaTypes.negotiate( + List.of( + new Preference<>(MediaType.APPLICATION_JSON, 0F), + new Preference<>(MediaType.ALL, 1F)))); + assertEquals( + MediaType.APPLICATION_JSON, + GraphqlMediaTypes.negotiate( + List.of( + new Preference<>(MediaType.ALL, 0F), + new Preference<>(MediaType.APPLICATION_JSON, 0.5F)))); + assertEquals( + 0F, + GraphqlMediaTypes.quality( + List.of( + new Preference<>(MediaType.valueOf("application/*"), 0F), + new Preference<>(MediaType.ALL, 1F)), + MediaType.APPLICATION_JSON, + false)); + } +} diff --git a/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlHttpErrorsTestCase.java b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlHttpErrorsTestCase.java new file mode 100644 index 0000000000..caee4e8caa --- /dev/null +++ b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlHttpErrorsTestCase.java @@ -0,0 +1,202 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import graphql.ExecutionResult; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.restlet.Client; +import org.restlet.Component; +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.Preference; +import org.restlet.data.Protocol; +import org.restlet.data.Status; +import org.restlet.ext.graphql.internal.GraphqlMediaTypes; +import org.restlet.representation.StringRepresentation; + +/** Request errors on the server, and non-GraphQL responses on the client. */ +class GraphqlHttpErrorsTestCase { + + private Component component; + + private Client client; + + private String base; + + @BeforeEach + void setUp() throws Exception { + component = new Component(); + component.getServers().add(Protocol.HTTP, 0); + GraphqlServerAdapter adapter = new GraphqlServerAdapter(); + adapter.setGraphQL(GraphqlTestSchemas.graphQL(new AtomicInteger())); + component.getDefaultHost().attach("/graphql", adapter); + component + .getDefaultHost() + .attach( + "/html", + new Restlet() { + @Override + public void handle(Request request, Response response) { + response.setStatus(Status.SERVER_ERROR_BAD_GATEWAY); + response.setEntity("Bad gateway", MediaType.TEXT_HTML); + } + }); + component + .getDefaultHost() + .attach( + "/empty401", + new Restlet() { + @Override + public void handle(Request request, Response response) { + response.setStatus(Status.CLIENT_ERROR_UNAUTHORIZED); + } + }); + component + .getDefaultHost() + .attach( + "/badjson", + new Restlet() { + @Override + public void handle(Request request, Response response) { + response.setEntity("{not json", MediaType.APPLICATION_JSON); + } + }); + component.start(); + base = "http://localhost:" + component.getServers().get(0).getActualPort(); + client = new Client(Protocol.HTTP); + client.start(); + } + + @AfterEach + void tearDown() throws Exception { + client.stop(); + component.stop(); + } + + private Response post(String path, String body, MediaType accept) { + Request request = new Request(Method.POST, base + path); + if (body != null) { + request.setEntity(new StringRepresentation(body, MediaType.APPLICATION_JSON)); + } + if (accept != null) { + request.getClientInfo().getAcceptedMediaTypes().add(new Preference<>(accept)); + } + return client.handle(request); + } + + @Test + void otherMethodsAreNotAllowed() { + Response response = client.handle(new Request(Method.PUT, base + "/graphql")); + assertEquals(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED, response.getStatus()); + assertTrue(response.getAllowedMethods().contains(Method.POST)); + } + + @Test + void malformedRequestsAreRequestErrors() throws Exception { + for (String body : new String[] {null, "{bad", "[]", "{}", "{\"query\": 1}"}) { + Response response = post("/graphql", body, null); + assertEquals(Status.CLIENT_ERROR_BAD_REQUEST, response.getStatus(), body); + assertTrue(response.getEntity().getText().contains("\"errors\""), body); + } + } + + @Test + void responseMediaTypeIsNegotiated() throws Exception { + String query = "{\"query\": \"{ hello }\"}"; + assertEquals( + MediaType.APPLICATION_JSON, + post("/graphql", query, null).getEntity().getMediaType()); + assertEquals( + MediaType.APPLICATION_JSON, + post("/graphql", query, MediaType.ALL).getEntity().getMediaType()); + assertEquals( + GraphqlMediaTypes.GRAPHQL_RESPONSE_JSON, + post("/graphql", query, GraphqlMediaTypes.GRAPHQL_RESPONSE_JSON) + .getEntity() + .getMediaType()); + assertEquals( + MediaType.APPLICATION_JSON, + post("/graphql", query, MediaType.valueOf("application/*")) + .getEntity() + .getMediaType()); + assertEquals( + Status.CLIENT_ERROR_NOT_ACCEPTABLE, + post("/graphql", query, MediaType.TEXT_HTML).getStatus()); + } + + @Test + void operationNameVariablesAndExtensionsAreForwarded() throws Exception { + Response response = + post( + "/graphql", + """ + {"query": "query A { hello } query B($n: String) { hello(name: $n) }", + "operationName": "B", "variables": {"n": "B"}, "extensions": {"x": 1}} + """, + null); + assertEquals("{\"data\":{\"hello\":\"Hello B\"}}", response.getEntity().getText()); + } + + @Test + void adapterWithoutGraphQLFails() { + GraphqlServerAdapter adapter = new GraphqlServerAdapter(new Context()); + assertThrows(IllegalStateException.class, adapter::start); + Request request = new Request(Method.POST, "http://localhost/graphql"); + Response response = new Response(request); + adapter.handle(request, response); + assertEquals(Status.SERVER_ERROR_INTERNAL, response.getStatus()); + } + + @Test + void nonGraphqlResponsesAreTransportErrors() throws Exception { + GraphqlTransportException html = execute("/html"); + assertEquals(Status.SERVER_ERROR_BAD_GATEWAY, html.getStatus()); + assertEquals("Bad gateway", html.getBodyExcerpt()); + + GraphqlTransportException empty = execute("/empty401"); + // Restlet's status service gives the empty error response an HTML page. + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, empty.getStatus()); + + GraphqlTransportException json = execute("/badjson"); + assertEquals(Status.SUCCESS_OK, json.getStatus()); + assertEquals("{not json", json.getBodyExcerpt()); + } + + @Test + void requestErrorsAreResults() throws Exception { + GraphqlClientAdapter graphql = new GraphqlClientAdapter(); + graphql.setClient(client); + graphql.setEndpoint(base + "/graphql"); + graphql.start(); + ExecutionResult result = graphql.execute("{ unknownField }", null, null); + assertFalse(result.isDataPresent()); + assertEquals(1, result.getErrors().size()); + } + + private GraphqlTransportException execute(String path) { + GraphqlClientAdapter graphql = new GraphqlClientAdapter(); + graphql.setClient(client); + graphql.setEndpoint(base + path); + graphql.start(); + return assertThrows( + GraphqlTransportException.class, () -> graphql.execute("{ hello }", null, null)); + } +} diff --git a/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlModulePathTestCase.java b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlModulePathTestCase.java new file mode 100644 index 0000000000..1fc59a162b --- /dev/null +++ b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlModulePathTestCase.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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.lang.module.Configuration; +import java.lang.module.FindException; +import java.lang.module.ModuleFinder; +import java.lang.module.ModuleReference; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Compiles a named consumer module against the extension and runs a GraphQL round trip in a module + * layer. graphql-java, java-dataloader and reactive-streams have no module descriptor: this checks + * that their automatic module names resolve. + */ +class GraphqlModulePathTestCase { + + private static final String MODULE_INFO = + """ + module consumer { + requires org.restlet.ext.graphql; + exports consumer; + } + """; + + private static final String MAIN = + """ + package consumer; + + import graphql.ExecutionResult; + import graphql.GraphQL; + import graphql.schema.idl.RuntimeWiring; + import graphql.schema.idl.SchemaGenerator; + import graphql.schema.idl.SchemaParser; + import java.util.Map; + import java.util.function.Supplier; + import org.restlet.Client; + import org.restlet.Server; + import org.restlet.data.Protocol; + import org.restlet.ext.graphql.GraphqlClientAdapter; + import org.restlet.ext.graphql.GraphqlServerAdapter; + + public class Main implements Supplier { + @Override + public String get() { + try { + RuntimeWiring wiring = RuntimeWiring.newRuntimeWiring() + .type("Query", t -> t.dataFetcher("hello", e -> "Hello " + + e.getArgument("name"))) + .build(); + GraphqlServerAdapter adapter = new GraphqlServerAdapter(); + adapter.setGraphQL(GraphQL.newGraphQL(new SchemaGenerator() + .makeExecutableSchema(new SchemaParser() + .parse("type Query { hello(name: String): String }"), + wiring)).build()); + Server server = new Server(Protocol.HTTP, 0, adapter); + server.start(); + Client client = new Client(Protocol.HTTP); + client.start(); + try { + GraphqlClientAdapter graphql = new GraphqlClientAdapter(); + graphql.setClient(client); + graphql.setEndpoint("http://localhost:" + server.getActualPort()); + graphql.start(); + ExecutionResult result = graphql.execute( + "query($n: String) { hello(name: $n) }", null, + Map.of("n", "module")); + return result.getData().toString(); + } finally { + client.stop(); + server.stop(); + } + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + } + """; + + @Test + void namedConsumerResolvesAndRuns(@TempDir Path dir) throws Exception { + List modulePath = modulePath(); + + Path src = dir.resolve("src"); + Files.createDirectories(src.resolve("consumer")); + Files.writeString(src.resolve("module-info.java"), MODULE_INFO); + Files.writeString(src.resolve("consumer/Main.java"), MAIN); + Path out = dir.resolve("out"); + + ByteArrayOutputStream diagnostics = new ByteArrayOutputStream(); + int status = + ToolProvider.getSystemJavaCompiler() + .run( + null, + diagnostics, + diagnostics, + "--module-path", + modulePath.stream() + .map(Path::toString) + .collect(Collectors.joining(File.pathSeparator)), + "-d", + out.toString(), + src.resolve("module-info.java").toString(), + src.resolve("consumer/Main.java").toString()); + assertEquals(0, status, () -> diagnostics.toString(StandardCharsets.UTF_8)); + + List layerPath = new ArrayList<>(modulePath); + layerPath.addFirst(out); + Configuration configuration = + ModuleLayer.boot() + .configuration() + .resolve( + ModuleFinder.of(layerPath.toArray(Path[]::new)), + ModuleFinder.of(), + Set.of("consumer")); + assertNotNull(configuration.findModule("com.graphqljava").orElse(null)); + assertNotNull(configuration.findModule("org.reactivestreams").orElse(null)); + + ModuleLayer layer = + ModuleLayer.boot() + .defineModulesWithOneLoader( + configuration, ClassLoader.getPlatformClassLoader()); + Class main = layer.findLoader("consumer").loadClass("consumer.Main"); + @SuppressWarnings("unchecked") + Supplier consumer = (Supplier) main.getConstructor().newInstance(); + assertEquals("{hello=Hello module}", consumer.get()); + } + + /** The modules of the test class path, one entry per module name, test tooling excluded. */ + private static List modulePath() { + Map modules = new LinkedHashMap<>(); + for (String entry : System.getProperty("java.class.path").split(File.pathSeparator)) { + Path path = Path.of(entry); + if (entry.isEmpty() + || !Files.exists(path) + || entry.contains("test-classes") + || entry.contains("junit") + || entry.contains("opentest4j") + || entry.contains("apiguardian")) { + continue; + } + try { + for (ModuleReference module : ModuleFinder.of(path).findAll()) { + modules.putIfAbsent(module.descriptor().name(), path); + } + } catch (FindException | IllegalArgumentException _) { + // Not usable as a module, for example a directory without a descriptor + } + } + return List.copyOf(modules.values()); + } +} diff --git a/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlSharedPortTestCase.java b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlSharedPortTestCase.java new file mode 100644 index 0000000000..faf8298fc3 --- /dev/null +++ b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlSharedPortTestCase.java @@ -0,0 +1,155 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import graphql.ExecutionResult; +import graphql.parser.Parser; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.restlet.Client; +import org.restlet.Component; +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.Server; +import org.restlet.data.MediaType; +import org.restlet.data.Method; +import org.restlet.data.Protocol; +import org.restlet.routing.Router; + +/** GraphQL next to a REST resource on one HTTP/2 server connector. */ +class GraphqlSharedPortTestCase { + + private final AtomicInteger counter = new AtomicInteger(); + + private Component component; + + private Server server; + + private Client client; + + private GraphqlClientAdapter graphql; + + private String base; + + @BeforeEach + void setUp() throws Exception { + component = new Component(); + server = component.getServers().add(Protocol.HTTP, 0); + server.getContext().getParameters().add("http.transport.protocol", "HTTP2"); + Router router = component.getDefaultHost(); + GraphqlServerAdapter adapter = new GraphqlServerAdapter(); + adapter.setGraphQL(GraphqlTestSchemas.graphQL(counter)); + router.attach("/graphql", adapter); + router.attach( + "/hello", + new Restlet() { + @Override + public void handle(Request request, Response response) { + response.setEntity("REST", MediaType.TEXT_PLAIN); + } + }); + + component.start(); + base = "http://localhost:" + server.getActualPort(); + + Context clientContext = new Context(); + clientContext.getParameters().add("httpClientTransportMode", "HTTP2"); + client = new Client(clientContext, Protocol.HTTP); + client.start(); + + graphql = new GraphqlClientAdapter(); + graphql.setClient(client); + graphql.setEndpoint(base + "/graphql"); + graphql.start(); + } + + @AfterEach + void tearDown() throws Exception { + graphql.stop(); + client.stop(); + component.stop(); + } + + @Test + void queryAndMutationShareThePortWithRest() throws Exception { + Response rest = client.handle(new Request(Method.GET, base + "/hello")); + assertEquals("2.0", rest.getProtocol().getVersion()); + assertEquals("REST", rest.getEntity().getText()); + + ExecutionResult query = + graphql.execute("query Q($n: String) { hello(name: $n) }", "Q", Map.of("n", "G0")); + assertTrue(query.getErrors().isEmpty(), query.getErrors()::toString); + assertEquals(Map.of("hello", "Hello G0"), query.getData()); + + ExecutionResult mutation = + graphql.execute(Parser.parse("mutation { increment(by: 2) }"), null, Map.of()); + assertEquals(Map.of("increment", 2), mutation.getData()); + assertEquals(2, counter.get()); + } + + @Test + void dataFetchersRunOnTheCallVirtualThread() throws Exception { + ExecutionResult result = graphql.execute("{ virtualThread asyncHello }", null, null); + assertEquals(Map.of("virtualThread", true, "asyncHello", "async"), result.getData()); + } + + @Test + void fieldErrorsKeepPartialDataAndNativeErrors() throws Exception { + ExecutionResult result = graphql.execute("{ hello failing }", null, null); + Map data = result.getData(); + assertEquals("Hello world", data.get("hello")); + assertNull(data.get("failing")); + assertEquals(1, result.getErrors().size()); + // graphql-java's default exception handler includes the exception message: hiding it is + // the application's choice, with its own DataFetcherExceptionHandler. + assertEquals( + "Exception while fetching data (/failing) : secret detail", + result.getErrors().get(0).getMessage()); + assertEquals(List.of("failing"), result.getErrors().get(0).getPath()); + } + + @Test + void transportFailureThrows() throws Exception { + server.stop(); + GraphqlTransportException e = + assertThrows( + GraphqlTransportException.class, + () -> graphql.execute("{ hello }", null, null)); + assertNull(e.getStatus()); + } + + @Test + void lifecycleIsEnforced() { + GraphqlClientAdapter other = new GraphqlClientAdapter(new Context()); + assertThrows(IllegalStateException.class, other::start); + other.setClient(client); + assertThrows(IllegalStateException.class, other::start); + assertThrows(IllegalStateException.class, () -> other.execute("{ hello }", null, null)); + other.setEndpoint(base + "/graphql"); + other.start(); + assertThrows(IllegalStateException.class, () -> other.setEndpoint("x")); + assertThrows(IllegalStateException.class, () -> other.setClient(null)); + other.stop(); + assertFalse(other.isStarted()); + assertThrows(IllegalStateException.class, () -> other.execute("{ hello }", null, null)); + assertTrue(client.isStarted(), "The borrowed client must survive stop()"); + } +} diff --git a/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlTestSchemas.java b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlTestSchemas.java new file mode 100644 index 0000000000..4356248cd6 --- /dev/null +++ b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlTestSchemas.java @@ -0,0 +1,76 @@ +/** + * 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.GraphQL; +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.RuntimeWiring; +import graphql.schema.idl.SchemaGenerator; +import graphql.schema.idl.SchemaParser; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; + +/** Schemas shared by the tests. */ +final class GraphqlTestSchemas { + + static final String SDL = + """ + type Query { + hello(name: String): String + virtualThread: Boolean + asyncHello: String + failing: String + } + type Mutation { + increment(by: Int!): Int + } + """; + + private GraphqlTestSchemas() {} + + static GraphQLSchema schema(AtomicInteger counter) { + RuntimeWiring wiring = + RuntimeWiring.newRuntimeWiring() + .type( + "Query", + type -> + type.dataFetcher( + "hello", + env -> + "Hello " + + env.getArgumentOrDefault( + "name", "world")) + .dataFetcher( + "virtualThread", + env -> Thread.currentThread().isVirtual()) + .dataFetcher( + "asyncHello", + env -> + CompletableFuture.supplyAsync( + () -> "async")) + .dataFetcher( + "failing", + env -> { + throw new IllegalStateException( + "secret detail"); + })) + .type( + "Mutation", + type -> + type.dataFetcher( + "increment", + env -> counter.addAndGet(env.getArgument("by")))) + .build(); + return new SchemaGenerator().makeExecutableSchema(new SchemaParser().parse(SDL), wiring); + } + + static GraphQL graphQL(AtomicInteger counter) { + return GraphQL.newGraphQL(schema(counter)).build(); + } +} diff --git a/pom.xml b/pom.xml index eb7f2c4122..9f5152ccc8 100644 --- a/pom.xml +++ b/pom.xml @@ -53,6 +53,7 @@ 2.3.35 2.14.0 + 26.1 1.85.1 2.22.3 3.2.3 @@ -77,6 +78,7 @@ org.restlet org.restlet.ext.crypto org.restlet.ext.freemarker + org.restlet.ext.graphql org.restlet.ext.gson org.restlet.ext.grpc org.restlet.ext.jaas