From 44144e29f6f59e837d76cbf9b0dbe0b05ff0ff39 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:31:39 -0400 Subject: [PATCH 1/3] feat: complete the GraphQL server HTTP binding (G1) - GET for queries only, when enabled (off by default); other operations over GET get 405 with Allow: POST. - 415 for non-JSON POST bodies; 400 for batches and malformed parameters; 413 above maxRequestBytes (1 MiB), checked on the declared size and while reading. - Documents are parsed with the adapter's ParserOptions before execution to select the operation. Parse errors, validation errors and missing or unknown operations get 400 with application/graphql-response+json and 200 with application/json. Execution results are 200, even with field errors. - ParserOptions are passed per execution through the GraphQLContext, never through graphql-java's process-wide defaults. - RestletGraphql.REQUEST and CLIENT_INFO context keys, set after the execution input customizer so they can't be replaced. - Subscriptions are rejected without being executed until G3. Refs #1583 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- changes.md | 6 +- .../ext/graphql/GraphqlServerAdapter.java | 375 ++++++++++++++++-- .../restlet/ext/graphql/RestletGraphql.java | 30 ++ .../graphql/GraphqlServerBindingTestCase.java | 308 ++++++++++++++ .../ext/graphql/GraphqlTestSchemas.java | 52 ++- .../restlet/ext/graphql/TickPublisher.java | 96 +++++ 6 files changed, 823 insertions(+), 44 deletions(-) create mode 100644 org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/RestletGraphql.java create mode 100644 org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlServerBindingTestCase.java create mode 100644 org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/TickPublisher.java 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..dcc141e2ec 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,23 @@ 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.Form; import org.restlet.data.MediaType; import org.restlet.data.Method; import org.restlet.data.Status; @@ -40,14 +49,46 @@ * router.attach("/graphql", graphql); * * + *

Binding rules: + * + *

+ * *

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 +122,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,9 +228,10 @@ 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); return; } @@ -111,60 +242,232 @@ 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; + } + + String query; + String operationName; + ExecutionInput.Builder input; + try { + query = string(params, "query", true); + 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; + } + + OperationDefinition.Operation operation; + try { + operation = selectOperation(query, operationName); + } 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); + return; + } + if (operation == OperationDefinition.Operation.SUBSCRIPTION) { + write( + response, + responseType, + failure(error("Subscriptions are not supported by this endpoint"))); + return; + } + + write(response, responseType, graphQL.execute(customize(request, input).build())); + } + + 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); + if (operationName == null) { + 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; + } - ExecutionResult result = graphQL.execute(input.build()); - write(response, responseType, result); + 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) { + response.setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); + response.getAllowedMethods().add(Method.POST); + } + + 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 +475,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/GraphqlServerBindingTestCase.java b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlServerBindingTestCase.java new file mode 100644 index 0000000000..663714c3b5 --- /dev/null +++ b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlServerBindingTestCase.java @@ -0,0 +1,308 @@ +/** + * 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import graphql.parser.ParserOptions; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import org.dataloader.BatchLoader; +import org.dataloader.DataLoaderFactory; +import org.dataloader.DataLoaderRegistry; +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.data.ChallengeResponse; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.MediaType; +import org.restlet.data.Method; +import org.restlet.data.Preference; +import org.restlet.data.Protocol; +import org.restlet.data.Reference; +import org.restlet.data.Status; +import org.restlet.ext.graphql.internal.GraphqlJson; +import org.restlet.ext.graphql.internal.GraphqlMediaTypes; +import org.restlet.representation.InputRepresentation; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.security.ChallengeAuthenticator; +import org.restlet.security.MapVerifier; + +/** The GraphQL over HTTP binding of {@link GraphqlServerAdapter}. */ +class GraphqlServerBindingTestCase { + + private static final MediaType GRAPHQL = GraphqlMediaTypes.GRAPHQL_RESPONSE_JSON; + + private Component component; + + private Client client; + + private String base; + + @BeforeEach + void setUp() throws Exception { + component = new Component(); + component.getServers().add(Protocol.HTTP, 0); + + GraphqlServerAdapter post = adapter(); + component.getDefaultHost().attach("/graphql", post); + + GraphqlServerAdapter get = adapter(); + get.setGetEnabled(true); + get.setMaxRequestBytes(64); + get.setParserOptions(ParserOptions.newParserOptions().maxTokens(20).build()); + get.setExecutionInputCustomizer( + (request, input) -> { + BatchLoader upper = + keys -> + CompletableFuture.completedFuture( + keys.stream().map(String::toUpperCase).toList()); + DataLoaderRegistry registry = new DataLoaderRegistry(); + registry.register("upper", DataLoaderFactory.newDataLoader(upper)); + input.dataLoaderRegistry(registry); + // Can't replace the request set by the adapter. + input.graphQLContext(Map.of(RestletGraphql.REQUEST, "spoofed")); + }); + component.getDefaultHost().attach("/limited", get); + + MapVerifier verifier = new MapVerifier(); + verifier.getLocalSecrets().put("alice", "secret".toCharArray()); + ChallengeAuthenticator authenticator = + new ChallengeAuthenticator( + new Context(), false, ChallengeScheme.HTTP_BASIC, "test"); + authenticator.setVerifier(verifier); + authenticator.setNext(adapter()); + component.getDefaultHost().attach("/secured", authenticator); + + 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 static GraphqlServerAdapter adapter() { + GraphqlServerAdapter adapter = new GraphqlServerAdapter(); + adapter.setGraphQL(GraphqlTestSchemas.graphQL(new AtomicInteger())); + return adapter; + } + + private Response post(String path, String body, MediaType accept) { + Request request = new Request(Method.POST, base + path); + request.setEntity(new StringRepresentation(body, MediaType.APPLICATION_JSON)); + if (accept != null) { + request.getClientInfo().getAcceptedMediaTypes().add(new Preference<>(accept)); + } + return client.handle(request); + } + + private Response get(String path, Map params) { + Reference reference = new Reference(base + path); + params.forEach(reference::addQueryParameter); + Request request = new Request(Method.GET, reference); + request.getClientInfo().getAcceptedMediaTypes().add(new Preference<>(GRAPHQL)); + return client.handle(request); + } + + private static Map json(Response response) throws Exception { + return GraphqlJson.asObject(GraphqlJson.parse(response.getEntity().getText())); + } + + @Test + void getIsDisabledByDefault() { + Response response = get("/graphql", Map.of("query", "{ hello }")); + assertEquals(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED, response.getStatus()); + assertTrue(response.getAllowedMethods().contains(Method.POST)); + } + + @Test + void getExecutesQueriesOnly() throws Exception { + Response query = + get( + "/limited", + Map.of( + "query", + "query Q($n: String) { hello(name: $n) }", + "operationName", + "Q", + "variables", + "{\"n\":\"GET\"}")); + assertEquals(Status.SUCCESS_OK, query.getStatus()); + assertEquals(GRAPHQL, query.getEntity().getMediaType()); + assertEquals(Map.of("hello", "Hello GET"), json(query).get("data")); + + Response mutation = get("/limited", Map.of("query", "mutation { increment(by: 1) }")); + assertEquals(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED, mutation.getStatus()); + assertTrue(mutation.getAllowedMethods().contains(Method.POST)); + + Response badVariables = get("/limited", Map.of("query", "{ hello }", "variables", "{bad")); + assertEquals(Status.CLIENT_ERROR_BAD_REQUEST, badVariables.getStatus()); + } + + @Test + void postRequiresJson() { + Request request = new Request(Method.POST, base + "/graphql"); + request.setEntity(new StringRepresentation("{ hello }", MediaType.TEXT_PLAIN)); + assertEquals( + Status.CLIENT_ERROR_UNSUPPORTED_MEDIA_TYPE, client.handle(request).getStatus()); + } + + @Test + void malformedParametersAreRequestErrors() { + for (String body : + new String[] { + "[{\"query\": \"{ hello }\"}]", + "\"query\"", + "{\"query\": \"{ hello }\", \"operationName\": 1}", + "{\"query\": \"{ hello }\", \"variables\": []}", + "{\"query\": \"{ hello }\", \"extensions\": \"x\"}" + }) { + assertEquals( + Status.CLIENT_ERROR_BAD_REQUEST, + post("/graphql", body, null).getStatus(), + body); + } + } + + @Test + void documentErrorsDependOnTheResponseMediaType() throws Exception { + for (String body : + new String[] { + "{\"query\": \"{ hello \"}", + "{\"query\": \"{ unknownField }\"}", + "{\"query\": \"query A { hello } query B { hello }\"}", + "{\"query\": \"query A { hello }\", \"operationName\": \"B\"}", + "{\"query\": \"fragment F on Query { hello }\"}" + }) { + Response modern = post("/graphql", body, GRAPHQL); + assertEquals(Status.CLIENT_ERROR_BAD_REQUEST, modern.getStatus(), body); + assertEquals(GRAPHQL, modern.getEntity().getMediaType()); + Map result = json(modern); + assertTrue(result.containsKey("errors"), body); + assertTrue(!result.containsKey("data"), body); + + Response legacy = post("/graphql", body, MediaType.APPLICATION_JSON); + assertEquals(Status.SUCCESS_OK, legacy.getStatus(), body); + assertTrue(json(legacy).containsKey("errors"), body); + } + } + + @Test + void fieldErrorsAreOkOnceExecutionStarted() throws Exception { + Response response = post("/graphql", "{\"query\": \"{ hello failing }\"}", GRAPHQL); + assertEquals(Status.SUCCESS_OK, response.getStatus()); + Map result = json(response); + assertEquals("Hello world", GraphqlJson.asObject(result.get("data")).get("hello")); + assertEquals(1, ((List) result.get("errors")).size()); + } + + @Test + void subscriptionsAreRejectedWithoutExecution() throws Exception { + int created = TickPublisher.CREATED.get(); + Response response = + post("/graphql", "{\"query\": \"subscription { ticks(count: 1) }\"}", GRAPHQL); + assertEquals(Status.CLIENT_ERROR_BAD_REQUEST, response.getStatus()); + assertEquals(created, TickPublisher.CREATED.get()); + } + + @Test + void requestSizeIsLimitedBeforeParsing() throws Exception { + String large = + "{\"query\": \"{ hello }\", \"extensions\": {\"pad\": \"" + "x".repeat(64) + "\"}}"; + Response declared = post("/limited", large, null); + assertEquals(Status.CLIENT_ERROR_REQUEST_ENTITY_TOO_LARGE, declared.getStatus()); + + Request request = new Request(Method.POST, base + "/limited"); + Representation chunked = + new InputRepresentation( + new ByteArrayInputStream(large.getBytes(StandardCharsets.UTF_8)), + MediaType.APPLICATION_JSON); + request.setEntity(chunked); + Response streamed = client.handle(request); + assertEquals(Status.CLIENT_ERROR_REQUEST_ENTITY_TOO_LARGE, streamed.getStatus()); + } + + @Test + void parserLimitsApplyPerAdapter() throws Exception { + String query = "{" + " hello".repeat(30) + " }"; + Response limited = get("/limited", Map.of("query", query)); + assertEquals(Status.CLIENT_ERROR_BAD_REQUEST, limited.getStatus()); + assertTrue(json(limited).get("errors").toString().contains("InvalidSyntax")); + + Response unlimited = post("/graphql", "{\"query\": \"" + query + "\"}", GRAPHQL); + assertEquals(Status.SUCCESS_OK, unlimited.getStatus()); + assertEquals( + ParserOptions.MAX_QUERY_TOKENS, + ParserOptions.getDefaultOperationParserOptions().getMaxTokens(), + "The process-wide defaults must not change"); + } + + @Test + void requestContextReachesDataFetchers() throws Exception { + Request request = new Request(Method.POST, base + "/limited"); + request.setEntity( + new StringRepresentation( + "{\"query\": \"{ userAgent loaded(key: \\\"k\\\") }\"}", + MediaType.APPLICATION_JSON)); + request.getClientInfo().setAgent("graphql-test"); + Map data = GraphqlJson.asObject(json(client.handle(request)).get("data")); + assertEquals("graphql-test", data.get("userAgent")); + assertEquals("K", data.get("loaded")); + } + + @Test + void authenticatedUserReachesDataFetchers() throws Exception { + String body = "{\"query\": \"{ whoami }\"}"; + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, post("/secured", body, null).getStatus()); + + Request request = new Request(Method.POST, base + "/secured"); + request.setEntity(new StringRepresentation(body, MediaType.APPLICATION_JSON)); + request.setChallengeResponse( + new ChallengeResponse(ChallengeScheme.HTTP_BASIC, "alice", "secret")); + assertEquals(Map.of("whoami", "alice"), json(client.handle(request)).get("data")); + assertEquals(Map.of("whoami", "anonymous"), json(post("/graphql", body, null)).get("data")); + } + + @Test + void configurationIsFrozenOnceStarted() throws Exception { + GraphqlServerAdapter adapter = adapter(); + assertThrows(IllegalArgumentException.class, () -> adapter.setMaxRequestBytes(0)); + assertNull(adapter.getParserOptions()); + adapter.start(); + assertEquals( + ParserOptions.getDefaultOperationParserOptions().getMaxTokens(), + adapter.getParserOptions().getMaxTokens()); + assertEquals(GraphqlServerAdapter.DEFAULT_MAX_REQUEST_BYTES, adapter.getMaxRequestBytes()); + assertThrows(IllegalStateException.class, () -> adapter.setGetEnabled(true)); + assertThrows(IllegalStateException.class, () -> adapter.setMaxRequestBytes(1)); + assertThrows(IllegalStateException.class, () -> adapter.setParserOptions(null)); + assertThrows(IllegalStateException.class, () -> adapter.setExecutionInputCustomizer(null)); + assertThrows(IllegalStateException.class, () -> adapter.setGraphQL(null)); + assertNull(adapter.getExecutionInputCustomizer()); + adapter.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 index 4356248cd6..761a087594 100644 --- 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 @@ -9,12 +9,16 @@ package org.restlet.ext.graphql; import graphql.GraphQL; +import graphql.schema.DataFetcher; 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; +import org.dataloader.DataLoader; +import org.restlet.Request; +import org.restlet.data.ClientInfo; /** Schemas shared by the tests. */ final class GraphqlTestSchemas { @@ -26,26 +30,51 @@ final class GraphqlTestSchemas { virtualThread: Boolean asyncHello: String failing: String + whoami: String + userAgent: String + loaded(key: String!): String } type Mutation { increment(by: Int!): Int } + type Subscription { + ticks(count: Int!): Int + } """; private GraphqlTestSchemas() {} static GraphQLSchema schema(AtomicInteger counter) { + DataFetcher hello = env -> "Hello " + env.getArgumentOrDefault("name", "world"); + DataFetcher failing = + env -> { + throw new IllegalStateException("secret detail"); + }; + DataFetcher whoami = + env -> { + ClientInfo clientInfo = env.getGraphQlContext().get(RestletGraphql.CLIENT_INFO); + return clientInfo.getUser() == null + ? "anonymous" + : clientInfo.getUser().getIdentifier(); + }; + DataFetcher userAgent = + env -> { + Request request = env.getGraphQlContext().get(RestletGraphql.REQUEST); + return request.getClientInfo().getAgent(); + }; + DataFetcher loaded = + env -> { + DataLoader loader = env.getDataLoader("upper"); + return loader.load(env.getArgument("key")); + }; + DataFetcher ticks = env -> new TickPublisher(env.getArgument("count")); + RuntimeWiring wiring = RuntimeWiring.newRuntimeWiring() .type( "Query", type -> - type.dataFetcher( - "hello", - env -> - "Hello " - + env.getArgumentOrDefault( - "name", "world")) + type.dataFetcher("hello", hello) .dataFetcher( "virtualThread", env -> Thread.currentThread().isVirtual()) @@ -54,18 +83,17 @@ static GraphQLSchema schema(AtomicInteger counter) { env -> CompletableFuture.supplyAsync( () -> "async")) - .dataFetcher( - "failing", - env -> { - throw new IllegalStateException( - "secret detail"); - })) + .dataFetcher("failing", failing) + .dataFetcher("whoami", whoami) + .dataFetcher("userAgent", userAgent) + .dataFetcher("loaded", loaded)) .type( "Mutation", type -> type.dataFetcher( "increment", env -> counter.addAndGet(env.getArgument("by")))) + .type("Subscription", type -> type.dataFetcher("ticks", ticks)) .build(); return new SchemaGenerator().makeExecutableSchema(new SchemaParser().parse(SDL), wiring); } diff --git a/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/TickPublisher.java b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/TickPublisher.java new file mode 100644 index 0000000000..5d7f308eba --- /dev/null +++ b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/TickPublisher.java @@ -0,0 +1,96 @@ +/** + * 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.util.concurrent.atomic.AtomicInteger; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +/** + * Publishes 1, 2, ... up to a count, or forever when the count is negative, honoring demand and + * cancellation. Counts its instances and cancellations for the tests. + */ +final class TickPublisher implements Publisher { + + static final AtomicInteger CREATED = new AtomicInteger(); + + static final AtomicInteger CANCELLED = new AtomicInteger(); + + private final int count; + + TickPublisher(int count) { + this.count = count; + CREATED.incrementAndGet(); + } + + @Override + public void subscribe(Subscriber subscriber) { + subscriber.onSubscribe(new Ticks(subscriber)); + } + + private final class Ticks implements Subscription { + + private final Subscriber subscriber; + + private volatile boolean done; + + private long demand; + + private boolean emitting; + + private int next = 1; + + Ticks(Subscriber subscriber) { + this.subscriber = subscriber; + } + + @Override + public void request(long n) { + synchronized (this) { + demand += n; + if (emitting) { + // Requested from onNext: the running loop picks up the new demand. + return; + } + emitting = true; + } + while (true) { + int value; + synchronized (this) { + if (done || demand == 0 || (count >= 0 && next > count)) { + emitting = false; + break; + } + demand--; + value = next++; + } + subscriber.onNext(value); + } + boolean complete; + synchronized (this) { + complete = !done && count >= 0 && next > count; + if (complete) { + done = true; + } + } + if (complete) { + subscriber.onComplete(); + } + } + + @Override + public void cancel() { + if (!done) { + done = true; + CANCELLED.incrementAndGet(); + } + } + } +} From 35514e6dd275bafc713dc710087021380ef9c817 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:50:01 -0400 Subject: [PATCH 2/3] test: tolerate an early 413 on chunked GraphQL requests The server answers 413 before reading the whole chunked body and closes the connection, so the client may fail while still writing it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- .../restlet/ext/graphql/GraphqlServerBindingTestCase.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlServerBindingTestCase.java b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlServerBindingTestCase.java index 663714c3b5..b4652d3d63 100644 --- a/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlServerBindingTestCase.java +++ b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlServerBindingTestCase.java @@ -243,7 +243,12 @@ void requestSizeIsLimitedBeforeParsing() throws Exception { MediaType.APPLICATION_JSON); request.setEntity(chunked); Response streamed = client.handle(request); - assertEquals(Status.CLIENT_ERROR_REQUEST_ENTITY_TOO_LARGE, streamed.getStatus()); + // The server answers before reading the whole body and closes the connection: the client + // gets the 413, or fails while still writing. + assertTrue( + Status.CLIENT_ERROR_REQUEST_ENTITY_TOO_LARGE.equals(streamed.getStatus()) + || streamed.getStatus().isConnectorError(), + streamed.getStatus()::toString); } @Test From a923c1e69017c085f72c4f1b2c5cbc03c2f43edc Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:46:26 -0400 Subject: [PATCH 3/3] fix: check the customized GraphQL operation and refine GET responses Select the operation once the execution input is customized, so that a customizer can't run a mutation over GET or a subscription. Treat an empty operationName as omitted, advertise GET in Allow when enabled and add Vary: Accept to negotiated responses. Co-Authored-By: Claude Opus 5.5 --- .../ext/graphql/GraphqlServerAdapter.java | 28 +++-- .../graphql/GraphqlGetBindingTestCase.java | 116 ++++++++++++++++++ 2 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlGetBindingTestCase.java 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 dcc141e2ec..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 @@ -29,6 +29,7 @@ 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; @@ -231,10 +232,12 @@ public void handle(Request request, Response response) { Method method = request.getMethod(); boolean get = Method.GET.equals(method); if (!Method.POST.equals(method) && !(get && getEnabled)) { - methodNotAllowed(response); + 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) { @@ -253,12 +256,10 @@ public void handle(Request request, Response response) { return; } - String query; - String operationName; ExecutionInput.Builder input; try { - query = string(params, "query", true); - operationName = string(params, "operationName", false); + 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) { @@ -273,9 +274,12 @@ public void handle(Request request, Response response) { 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(query, operationName); + operation = + selectOperation(executionInput.getQuery(), executionInput.getOperationName()); } catch (InvalidSyntaxException e) { write(response, responseType, failure(e.toInvalidSyntaxError())); return; @@ -285,7 +289,7 @@ public void handle(Request request, Response response) { } if (get && operation != OperationDefinition.Operation.QUERY) { - methodNotAllowed(response); + methodNotAllowed(response, false); return; } if (operation == OperationDefinition.Operation.SUBSCRIPTION) { @@ -296,7 +300,7 @@ public void handle(Request request, Response response) { return; } - write(response, responseType, graphQL.execute(customize(request, input).build())); + write(response, responseType, graphQL.execute(executionInput)); } private ExecutionInput.Builder customize(Request request, ExecutionInput.Builder input) { @@ -326,7 +330,8 @@ private OperationDefinition.Operation selectOperation(String query, String opera .build()); List operations = document.getDefinitionsOfType(OperationDefinition.class); - if (operationName == null) { + // 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() @@ -436,8 +441,11 @@ private static ExecutionResult failure(GraphQLError error) { return ExecutionResult.newExecutionResult().errors(List.of(error)).build(); } - private static void methodNotAllowed(Response response) { + 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); } 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 + *

+ * 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.assertTrue; + +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.Request; +import org.restlet.Response; +import org.restlet.data.Dimension; +import org.restlet.data.Method; +import org.restlet.data.Protocol; +import org.restlet.data.Reference; +import org.restlet.data.Status; +import org.restlet.ext.graphql.internal.GraphqlJson; + +/** GET requests, with the operation selection, method and caching rules that apply to them. */ +class GraphqlGetBindingTestCase { + + private final AtomicInteger counter = new AtomicInteger(); + + private Component component; + + private Client client; + + private String base; + + @BeforeEach + void setUp() throws Exception { + component = new Component(); + component.getServers().add(Protocol.HTTP, 0); + + GraphqlServerAdapter get = new GraphqlServerAdapter(); + get.setGraphQL(GraphqlTestSchemas.graphQL(counter)); + get.setGetEnabled(true); + component.getDefaultHost().attach("/get", get); + + // A customizer that selects another operation than the request. + GraphqlServerAdapter switching = new GraphqlServerAdapter(); + switching.setGraphQL(GraphqlTestSchemas.graphQL(counter)); + switching.setGetEnabled(true); + switching.setExecutionInputCustomizer((request, input) -> input.operationName("M")); + component.getDefaultHost().attach("/switching", switching); + + 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 get(String path, Map params) { + Reference reference = new Reference(base + path); + params.forEach(reference::addQueryParameter); + return client.handle(new Request(Method.GET, reference)); + } + + @Test + void customizedOperationIsChecked() { + Response response = + get( + "/switching", + Map.of( + "query", + "query Q { hello } mutation M { increment(by: 1) }", + "operationName", + "Q")); + assertEquals(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED, response.getStatus()); + assertFalse(response.getAllowedMethods().contains(Method.GET)); + assertEquals(0, counter.get()); + } + + @Test + void emptyOperationNameIsOmitted() throws Exception { + Response response = get("/get", Map.of("query", "{ hello }", "operationName", "")); + assertEquals(Status.SUCCESS_OK, response.getStatus()); + assertEquals( + Map.of("hello", "Hello world"), + GraphqlJson.asObject(GraphqlJson.parse(response.getEntity().getText())) + .get("data")); + } + + @Test + void negotiatedResponsesVaryOnAccept() { + Response response = get("/get", Map.of("query", "{ hello }")); + assertEquals(Status.SUCCESS_OK, response.getStatus()); + assertTrue(response.getDimensions().contains(Dimension.MEDIA_TYPE)); + } + + @Test + void otherMethodsAllowGet() { + Response response = client.handle(new Request(Method.PUT, base + "/get")); + assertEquals(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED, response.getStatus()); + assertTrue(response.getAllowedMethods().contains(Method.GET)); + assertTrue(response.getAllowedMethods().contains(Method.POST)); + } +}