diff --git a/changes.md b/changes.md index 4f64462a1e..0478474284 100644 --- a/changes.md +++ b/changes.md @@ -140,7 +140,11 @@ Changes log GraphQL over HTTP draft (`application/graphql-response+json` and `application/json`); operations run on the call's virtual thread. `GraphqlClientAdapter` sends operations through a borrowed `Client` and returns native `ExecutionResult` values, throwing - `GraphqlTransportException` when no GraphQL response is received. Issue #1581. + `GraphqlTransportException` when no GraphQL response is received. The server accepts + queries over `GET` when enabled (off by default), applies the draft's status codes per + response media type, limits the request size (1 MiB by default) and the parser per + adapter (`ParserOptions`, never the process-wide defaults), and gives data fetchers the + Restlet request and client info through `RestletGraphql` context keys. Issue #1581. - Added the `NettyConnectionUpgrade` engine hook: a Restlet returning a "101 Switching Protocols" response with this response attribute takes over the HTTP/1.1 connection (`NettyUpgradeContext`), which the WebSocket extension relies on. diff --git a/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlServerAdapter.java b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlServerAdapter.java index 5780f363c5..5c8afca210 100644 --- a/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlServerAdapter.java +++ b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlServerAdapter.java @@ -13,14 +13,24 @@ import graphql.GraphQL; import graphql.GraphQLError; import graphql.GraphqlErrorBuilder; +import graphql.language.Document; +import graphql.language.OperationDefinition; +import graphql.parser.InvalidSyntaxException; +import graphql.parser.Parser; +import graphql.parser.ParserEnvironment; +import graphql.parser.ParserOptions; import java.io.IOException; import java.io.InputStream; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.BiConsumer; import org.restlet.Context; import org.restlet.Request; import org.restlet.Response; import org.restlet.Restlet; +import org.restlet.data.Dimension; +import org.restlet.data.Form; import org.restlet.data.MediaType; import org.restlet.data.Method; import org.restlet.data.Status; @@ -40,14 +50,46 @@ * router.attach("/graphql", graphql); * * + *

Binding rules: + * + *

+ * *

Operations run on the thread of the call, a virtual thread by default. Asynchronous data - * fetchers are joined on that thread. The configuration can't change once the adapter is started, - * which happens at the latest when it handles its first request. + * fetchers are joined on that thread. The {@code graphql.GraphQLContext} of each operation holds + * the Restlet request under {@link RestletGraphql#REQUEST} and its client info under {@link + * RestletGraphql#CLIENT_INFO}. Policies such as query depth and complexity limits, introspection + * and the rendering of data fetcher exceptions are configured on the {@code GraphQL} instance with + * graphql-java's own instrumentation and handlers. + * + *

The configuration can't change once the adapter is started, which happens at the latest when + * it handles its first request. */ public class GraphqlServerAdapter extends Restlet { + /** The default maximum size of a request body: 1 MiB. */ + public static final long DEFAULT_MAX_REQUEST_BYTES = 1L << 20; + private volatile GraphQL graphQL; + private volatile long maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES; + + private volatile boolean getEnabled; + + private volatile ParserOptions parserOptions; + + private volatile BiConsumer executionInputCustomizer; + /** Constructor. */ public GraphqlServerAdapter() { this(null); @@ -81,12 +123,101 @@ public void setGraphQL(GraphQL graphQL) { this.graphQL = graphQL; } + /** + * Returns the maximum size of a request body, checked before parsing. + * + * @return The maximum size of a request body, in bytes. + */ + public long getMaxRequestBytes() { + return maxRequestBytes; + } + + /** + * Sets the maximum size of a request body, checked before parsing. Larger requests get {@code + * 413}. Defaults to {@link #DEFAULT_MAX_REQUEST_BYTES}. + * + * @param maxRequestBytes The maximum size of a request body, in bytes. + */ + public void setMaxRequestBytes(long maxRequestBytes) { + checkNotStarted(); + if (maxRequestBytes <= 0) { + throw new IllegalArgumentException("The maximum request size must be positive"); + } + this.maxRequestBytes = maxRequestBytes; + } + + /** + * Indicates if queries are accepted over {@code GET}. + * + * @return True if queries are accepted over {@code GET}. + */ + public boolean isGetEnabled() { + return getEnabled; + } + + /** + * Indicates if queries are accepted over {@code GET}. Off by default: {@code GET} requests can + * be sent cross-origin without a preflight. + * + * @param getEnabled True if queries are accepted over {@code GET}. + */ + public void setGetEnabled(boolean getEnabled) { + checkNotStarted(); + this.getEnabled = getEnabled; + } + + /** + * Returns the parser options applied to each operation. + * + * @return The parser options, or null for graphql-java's default operation parser options. + */ + public ParserOptions getParserOptions() { + return parserOptions; + } + + /** + * Sets the parser options applied to each operation, such as the maximum number of tokens, + * characters and rule depth. They are passed through the {@code GraphQLContext} of each + * operation, never through graphql-java's process-wide defaults. + * + * @param parserOptions The parser options, or null for graphql-java's default operation parser + * options, read when the adapter starts. + */ + public void setParserOptions(ParserOptions parserOptions) { + checkNotStarted(); + this.parserOptions = parserOptions; + } + + /** + * Returns the execution input customizer. + * + * @return The execution input customizer, or null. + */ + public BiConsumer getExecutionInputCustomizer() { + return executionInputCustomizer; + } + + /** + * Sets a customizer called for each operation before execution, for example to set a {@code + * DataLoaderRegistry}, a locale or extra context entries. + * + * @param executionInputCustomizer The execution input customizer, or null. + */ + public void setExecutionInputCustomizer( + BiConsumer executionInputCustomizer) { + checkNotStarted(); + this.executionInputCustomizer = executionInputCustomizer; + } + @Override public synchronized void start() throws Exception { if (isStopped()) { if (graphQL == null) { throw new IllegalStateException("No GraphQL instance set"); } + if (parserOptions == null) { + parserOptions = ParserOptions.getDefaultOperationParserOptions(); + } super.start(); } } @@ -98,12 +229,15 @@ public void handle(Request request, Response response) { return; } - if (!Method.POST.equals(request.getMethod())) { - response.setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); - response.getAllowedMethods().add(Method.POST); + Method method = request.getMethod(); + boolean get = Method.GET.equals(method); + if (!Method.POST.equals(method) && !(get && getEnabled)) { + methodNotAllowed(response, getEnabled); return; } + // The response media type is negotiated, which caches must know of. + response.getDimensions().add(Dimension.MEDIA_TYPE); MediaType responseType = GraphqlMediaTypes.negotiate(request.getClientInfo().getAcceptedMediaTypes()); if (responseType == null) { @@ -111,60 +245,237 @@ public void handle(Request request, Response response) { return; } + Map params; + try { + params = get ? decodeQuery(request) : decodeBody(request, response, responseType); + } catch (RequestException e) { + requestError(response, responseType, e.getMessage()); + return; + } + if (params == null) { + return; + } + + ExecutionInput.Builder input; + try { + String query = string(params, "query", true); + String operationName = string(params, "operationName", false); + input = ExecutionInput.newExecutionInput().query(query).operationName(operationName); + Map variables = object(params, "variables"); + if (variables != null) { + input.variables(variables); + } + Map extensions = object(params, "extensions"); + if (extensions != null) { + input.extensions(extensions); + } + } catch (RequestException e) { + requestError(response, responseType, e.getMessage()); + return; + } + + // The operation is selected once customized, as the customizer can change it. + ExecutionInput executionInput = customize(request, input).build(); + OperationDefinition.Operation operation; + try { + operation = + selectOperation(executionInput.getQuery(), executionInput.getOperationName()); + } catch (InvalidSyntaxException e) { + write(response, responseType, failure(e.toInvalidSyntaxError())); + return; + } catch (RequestException e) { + write(response, responseType, failure(error(e.getMessage()))); + return; + } + + if (get && operation != OperationDefinition.Operation.QUERY) { + methodNotAllowed(response, false); + return; + } + if (operation == OperationDefinition.Operation.SUBSCRIPTION) { + write( + response, + responseType, + failure(error("Subscriptions are not supported by this endpoint"))); + return; + } + + write(response, responseType, graphQL.execute(executionInput)); + } + + private ExecutionInput.Builder customize(Request request, ExecutionInput.Builder input) { + BiConsumer customizer = executionInputCustomizer; + if (customizer != null) { + customizer.accept(request, input); + } + // graphql-java reads per-execution parser options from the context under their class. + return input.graphQLContext( + builder -> + builder.put(ParserOptions.class, parserOptions) + .put(RestletGraphql.REQUEST, request) + .put(RestletGraphql.CLIENT_INFO, request.getClientInfo())); + } + + /** + * Parses the document with the configured limits and returns the type of the operation to + * execute. + */ + private OperationDefinition.Operation selectOperation(String query, String operationName) + throws RequestException { + Document document = + Parser.parse( + ParserEnvironment.newParserEnvironment() + .document(query) + .parserOptions(parserOptions) + .build()); + List operations = + document.getDefinitionsOfType(OperationDefinition.class); + // An empty operation name, such as an empty GET parameter, is omitted. + if (operationName == null || operationName.isEmpty()) { + if (operations.size() != 1) { + throw new RequestException( + operations.isEmpty() + ? "The document has no operation" + : "An operationName is required when the document has several" + + " operations"); + } + return operations.getFirst().getOperation(); + } + for (OperationDefinition operation : operations) { + if (operationName.equals(operation.getName())) { + return operation.getOperation(); + } + } + throw new RequestException("Unknown operation named '" + operationName + "'"); + } + + private static Map decodeQuery(Request request) throws RequestException { + Form form = request.getResourceRef().getQueryAsForm(); + Map params = new HashMap<>(); + params.put("query", form.getFirstValue("query")); + params.put("operationName", form.getFirstValue("operationName")); + params.put("variables", parseParameter(form, "variables")); + params.put("extensions", parseParameter(form, "extensions")); + return params; + } + + private static Object parseParameter(Form form, String name) throws RequestException { + String value = form.getFirstValue(name); + if (value == null || value.isEmpty()) { + return null; + } + try { + return GraphqlJson.parse(value); + } catch (GraphqlJson.InvalidJsonException e) { + throw new RequestException("Invalid JSON in '" + name + "': " + e.getMessage()); + } + } + + private Map decodeBody( + Request request, Response response, MediaType responseType) throws RequestException { Representation entity = request.getEntity(); if (entity == null || !entity.isAvailable()) { - requestError(response, responseType, "The request has no body"); - return; + throw new RequestException("The request has no body"); + } + if (entity.getMediaType() == null + || !MediaType.APPLICATION_JSON.equals(entity.getMediaType(), true)) { + response.setStatus(Status.CLIENT_ERROR_UNSUPPORTED_MEDIA_TYPE); + return null; + } + if (entity.getSize() > maxRequestBytes) { + tooLarge(response, responseType); + return null; } Object body; try (InputStream in = entity.getStream()) { - body = GraphqlJson.read(in, Long.MAX_VALUE); + body = GraphqlJson.read(in, maxRequestBytes); + } catch (GraphqlJson.TooLargeException e) { + tooLarge(response, responseType); + return null; } catch (GraphqlJson.InvalidJsonException e) { - requestError(response, responseType, "Invalid JSON body: " + e.getMessage()); - return; + throw new RequestException("Invalid JSON body: " + e.getMessage()); } catch (IOException e) { response.setStatus(Status.CLIENT_ERROR_BAD_REQUEST, e); - return; + return null; } + if (body instanceof List) { + throw new RequestException("Batched requests are not supported"); + } Map params = GraphqlJson.asObject(body); - if (params == null || !(params.get("query") instanceof String query)) { - requestError(response, responseType, "The request must have a string 'query'"); - return; + if (params == null) { + throw new RequestException("The request body must be a JSON object"); } + return params; + } - ExecutionInput.Builder input = ExecutionInput.newExecutionInput().query(query); - if (params.get("operationName") instanceof String operationName) { - input.operationName(operationName); + private static String string(Map params, String name, boolean required) + throws RequestException { + Object value = params.get(name); + if (value instanceof String string) { + return string; } - Map variables = GraphqlJson.asObject(params.get("variables")); - if (variables != null) { - input.variables(variables); + if (value == null && !required) { + return null; } - Map extensions = GraphqlJson.asObject(params.get("extensions")); - if (extensions != null) { - input.extensions(extensions); + throw new RequestException( + "The request must have a string '" + name + "'" + (required ? "" : " or none")); + } + + private static Map object(Map params, String name) + throws RequestException { + Object value = params.get(name); + Map object = GraphqlJson.asObject(value); + if (value != null && object == null) { + throw new RequestException("'" + name + "' must be a JSON object"); + } + return object; + } + + private static GraphQLError error(String message) { + return GraphqlErrorBuilder.newError().message("%s", message).build(); + } + + private static ExecutionResult failure(GraphQLError error) { + return ExecutionResult.newExecutionResult().errors(List.of(error)).build(); + } + + private static void methodNotAllowed(Response response, boolean getAllowed) { + response.setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); + if (getAllowed) { + response.getAllowedMethods().add(Method.GET); } + response.getAllowedMethods().add(Method.POST); + } - ExecutionResult result = graphQL.execute(input.build()); - write(response, responseType, result); + private static void tooLarge(Response response, MediaType responseType) { + writeEntity(response, responseType, failure(error("The request body is too large"))); + response.setStatus(Status.CLIENT_ERROR_REQUEST_ENTITY_TOO_LARGE); } private static void requestError(Response response, MediaType responseType, String message) { - GraphQLError error = GraphqlErrorBuilder.newError().message("%s", message).build(); - write( - response, - responseType, - ExecutionResult.newExecutionResult().errors(List.of(error)).build()); + writeEntity(response, responseType, failure(error(message))); response.setStatus(Status.CLIENT_ERROR_BAD_REQUEST); } + /** + * Writes a result. A result without data means that execution didn't start: a request error, + * reported with {@code 400} in {@code application/graphql-response+json} only. + */ private static void write(Response response, MediaType responseType, ExecutionResult result) { + writeEntity(response, responseType, result); + boolean requestError = + !result.isDataPresent() + && GraphqlMediaTypes.GRAPHQL_RESPONSE_JSON.equals(responseType); + response.setStatus(requestError ? Status.CLIENT_ERROR_BAD_REQUEST : Status.SUCCESS_OK); + } + + private static void writeEntity( + Response response, MediaType responseType, ExecutionResult result) { response.setEntity( new ByteArrayRepresentation( GraphqlJson.write(result.toSpecification()), responseType)); - response.setStatus(Status.SUCCESS_OK); } private void checkNotStarted() { @@ -172,4 +483,14 @@ private void checkNotStarted() { throw new IllegalStateException("The configuration can't change once started"); } } + + /** A malformed request. */ + private static final class RequestException extends Exception { + + private static final long serialVersionUID = 1L; + + RequestException(String message) { + super(message, null, false, false); + } + } } diff --git a/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/RestletGraphql.java b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/RestletGraphql.java new file mode 100644 index 0000000000..b1f8e5fc0e --- /dev/null +++ b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/RestletGraphql.java @@ -0,0 +1,30 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.graphql; + +/** + * Keys of the {@code graphql.GraphQLContext} entries set by {@link GraphqlServerAdapter} for each + * operation. Data fetchers read them with {@code env.getGraphQlContext().get(key)}. + * + *

They are set after the execution input customizer runs, so neither the customizer nor the + * {@code extensions} of a request can replace them. + */ +public final class RestletGraphql { + + /** Key of the Restlet {@link org.restlet.Request} being handled. */ + public static final String REQUEST = "org.restlet.ext.graphql.request"; + + /** + * Key of the {@link org.restlet.data.ClientInfo} of the request, including the authenticated + * user and roles set by Restlet authenticators. + */ + public static final String CLIENT_INFO = "org.restlet.ext.graphql.clientInfo"; + + private RestletGraphql() {} +} diff --git a/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlGetBindingTestCase.java b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlGetBindingTestCase.java new file mode 100644 index 0000000000..a780c93545 --- /dev/null +++ b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlGetBindingTestCase.java @@ -0,0 +1,116 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * 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)); + } +} 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..b4652d3d63 --- /dev/null +++ b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlServerBindingTestCase.java @@ -0,0 +1,313 @@ +/** + * 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); + // 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 + 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(); + } + } + } +}