diff --git a/changes.md b/changes.md index 0478474284..2ff021aef3 100644 --- a/changes.md +++ b/changes.md @@ -144,7 +144,10 @@ Changes log 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. + Restlet request and client info through `RestletGraphql` context keys. The client can + validate documents locally against a `GraphQLSchema` without a network call, bounds + responses (10 MiB by default, never truncated) and accepts a request customizer for API + key headers. 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/GraphqlClientAdapter.java b/org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlClientAdapter.java index cad0e5bd0b..6457e1dc22 100644 --- 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 @@ -9,14 +9,25 @@ package org.restlet.ext.graphql; import graphql.ExecutionResult; +import graphql.GraphQLError; +import graphql.ParseAndValidate; import graphql.language.AstPrinter; import graphql.language.Document; +import graphql.parser.InvalidSyntaxException; +import graphql.parser.Parser; +import graphql.parser.ParserEnvironment; +import graphql.parser.ParserOptions; +import graphql.schema.GraphQLSchema; +import graphql.validation.ValidationError; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Consumer; import org.restlet.Client; import org.restlet.Context; import org.restlet.Request; @@ -38,19 +49,29 @@ * GraphqlClientAdapter graphql = new GraphqlClientAdapter(getContext()); * graphql.setClient(restletClient); * graphql.setEndpoint("https://example.com/graphql"); + * graphql.setSchema(schema); // optional: validate before sending * 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}. + *

Operations are sent with {@code POST}, accepting {@code application/graphql-response+json} and + * {@code application/json}. Variables are serialized as JSON values, never templated as text. + * + *

Errors reported by the server, including request errors sent with a {@code 4xx} status and a + * GraphQL body, are returned in the {@link ExecutionResult}: without data when execution didn't + * start. Failures to obtain a GraphQL response throw a {@link GraphqlTransportException}: + * connection failures and timeouts, responses larger than {@link #getMaxResponseBytes()}, and + * responses that are not GraphQL, such as an HTML error page or an empty {@code 401}. + * + *

The adapter never starts or stops the borrowed client, and never fetches a schema. Its + * configuration can't change once started. */ public class GraphqlClientAdapter { - private static final int EXCERPT_BYTES = 1024; + /** The default maximum size of a response body: 10 MiB. */ + public static final long DEFAULT_MAX_RESPONSE_BYTES = 10L << 20; - private static final long MAX_RESPONSE_BYTES = 10L << 20; + private static final int EXCERPT_BYTES = 1024; private enum State { NEW, @@ -64,6 +85,14 @@ private enum State { private volatile String endpoint; + private volatile GraphQLSchema schema; + + private volatile ParserOptions parserOptions; + + private volatile long maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES; + + private volatile Consumer requestCustomizer; + private volatile State state = State.NEW; /** Constructor. */ @@ -99,7 +128,8 @@ public Client getClient() { } /** - * Sets the borrowed client. The adapter never starts or stops it. + * Sets the borrowed client. The adapter never starts or stops it. Its authentication, proxy and + * timeout settings apply to every operation. * * @param client The borrowed client. */ @@ -127,6 +157,92 @@ public void setEndpoint(String endpoint) { this.endpoint = endpoint; } + /** + * Returns the schema used to validate documents before sending them. + * + * @return The schema, or null. + */ + public GraphQLSchema getSchema() { + return schema; + } + + /** + * Sets a schema used to validate documents before sending them. Invalid documents are returned + * as an {@link ExecutionResult} with errors, without a network call. The schema can be built + * without data fetchers, for example from SDL with {@code UnExecutableSchemaGenerator}. The + * adapter never fetches a schema by itself. + * + * @param schema The schema, or null to send documents unchecked. + */ + public void setSchema(GraphQLSchema schema) { + checkNew(); + this.schema = schema; + } + + /** + * Returns the parser options used to validate documents locally. + * + * @return The parser options, or null for graphql-java's default operation parser options. + */ + public ParserOptions getParserOptions() { + return parserOptions; + } + + /** + * Sets the parser options used to validate documents locally, when a schema is set. + * + * @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) { + checkNew(); + this.parserOptions = parserOptions; + } + + /** + * Returns the maximum size of a response body. + * + * @return The maximum size of a response body, in bytes. + */ + public long getMaxResponseBytes() { + return maxResponseBytes; + } + + /** + * Sets the maximum size of a response body. Larger responses throw a {@link + * GraphqlTransportException}; they are never truncated. Defaults to {@link + * #DEFAULT_MAX_RESPONSE_BYTES}. + * + * @param maxResponseBytes The maximum size of a response body, in bytes. + */ + public void setMaxResponseBytes(long maxResponseBytes) { + checkNew(); + if (maxResponseBytes <= 0) { + throw new IllegalArgumentException("The maximum response size must be positive"); + } + this.maxResponseBytes = maxResponseBytes; + } + + /** + * Returns the request customizer. + * + * @return The request customizer, or null. + */ + public Consumer getRequestCustomizer() { + return requestCustomizer; + } + + /** + * Sets a customizer called on each HTTP request before it is sent, for example to add an API + * key header or a {@code ChallengeResponse}. + * + * @param requestCustomizer The request customizer, or null. + */ + public void setRequestCustomizer(Consumer requestCustomizer) { + checkNew(); + this.requestCustomizer = requestCustomizer; + } + /** * Validates and freezes the configuration. * @@ -141,6 +257,9 @@ public synchronized void start() { if (endpoint == null) { throw new IllegalStateException("No endpoint set"); } + if (parserOptions == null) { + parserOptions = ParserOptions.getDefaultOperationParserOptions(); + } state = State.STARTED; } @@ -170,7 +289,12 @@ public boolean isStarted() { public ExecutionResult execute( Document document, String operationName, Map variables) throws GraphqlTransportException { - return execute(AstPrinter.printAst(document), operationName, variables); + Objects.requireNonNull(document, "document"); + checkStarted(); + ExecutionResult invalid = validate(document); + return invalid != null + ? invalid + : send(AstPrinter.printAst(document), operationName, variables); } /** @@ -186,10 +310,52 @@ 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"); + checkStarted(); + if (schema != null) { + ExecutionResult invalid; + try { + invalid = validate(parse(document)); + } catch (InvalidSyntaxException e) { + invalid = failure(List.of(e.toInvalidSyntaxError())); + } + if (invalid != null) { + return invalid; + } } + return send(document, operationName, variables); + } + + private Document parse(String document) { + return Parser.parse( + ParserEnvironment.newParserEnvironment() + .document(document) + .parserOptions(parserOptions) + .build()); + } + + /** Returns a result with the validation errors, or null if the document is valid. */ + private ExecutionResult validate(Document document) { + GraphQLSchema validationSchema = schema; + if (validationSchema == null) { + return null; + } + List errors = ParseAndValidate.validate(validationSchema, document); + return errors.isEmpty() ? null : failure(new ArrayList(errors)); + } + + private static ExecutionResult failure(List errors) { + return ExecutionResult.newExecutionResult().errors(errors).build(); + } + /** + * Creates the HTTP request of 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 request, customized. + */ + Request createRequest(String document, String operationName, Map variables) { Map body = new LinkedHashMap<>(); body.put("query", document); if (operationName != null) { @@ -206,6 +372,17 @@ public ExecutionResult execute( MediaType.APPLICATION_JSON, null, CharacterSet.UTF_8)); + Consumer customizer = requestCustomizer; + if (customizer != null) { + customizer.accept(request); + } + return request; + } + + private ExecutionResult send( + String document, String operationName, Map variables) + throws GraphqlTransportException { + Request request = createRequest(document, operationName, variables); request.getClientInfo() .getAcceptedMediaTypes() .add(new Preference<>(GraphqlMediaTypes.GRAPHQL_RESPONSE_JSON, 1F)); @@ -224,7 +401,7 @@ public ExecutionResult execute( } } - private static ExecutionResult toResult(Response response) throws GraphqlTransportException { + private ExecutionResult toResult(Response response) throws GraphqlTransportException { Status status = response.getStatus(); if (status.isConnectorError()) { throw new GraphqlTransportException( @@ -235,27 +412,21 @@ private static ExecutionResult toResult(Response response) throws GraphqlTranspo } Representation entity = response.getEntity(); + long limit = maxResponseBytes; + if (entity != null && entity.getSize() > limit) { + throw tooLarge(status, limit, null); + } byte[] bytes; try (InputStream in = stream(entity)) { - bytes = GraphqlJson.readBytes(in, MAX_RESPONSE_BYTES); + bytes = GraphqlJson.readBytes(in, limit); } catch (GraphqlJson.TooLargeException e) { - throw new GraphqlTransportException( - "The GraphQL response exceeds " + MAX_RESPONSE_BYTES + " bytes", - status, - null, - e); + throw tooLarge(status, limit, 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())) { + if (isJson(entity) && (status.isSuccess() || status.isClientError())) { Map result = null; try { result = GraphqlJson.asObject(GraphqlJson.parse(bytes)); @@ -286,6 +457,18 @@ private static InputStream stream(Representation entity) throws IOException { return in == null ? InputStream.nullInputStream() : in; } + private static boolean isJson(Representation entity) { + MediaType type = entity == null ? null : entity.getMediaType(); + return type != null + && (type.equals(GraphqlMediaTypes.GRAPHQL_RESPONSE_JSON, true) + || type.equals(MediaType.APPLICATION_JSON, true)); + } + + private static GraphqlTransportException tooLarge(Status status, long limit, Throwable cause) { + return new GraphqlTransportException( + "The GraphQL response exceeds " + limit + " bytes", status, null, cause); + } + private static String excerpt(byte[] bytes) { if (bytes.length == 0) { return null; @@ -298,4 +481,10 @@ private void checkNew() { throw new IllegalStateException("The configuration can't change once started"); } } + + private void checkStarted() { + if (state != State.STARTED) { + throw new IllegalStateException("The adapter is not started"); + } + } } diff --git a/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlClientTestCase.java b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlClientTestCase.java new file mode 100644 index 0000000000..8b88bee509 --- /dev/null +++ b/org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlClientTestCase.java @@ -0,0 +1,229 @@ +/** + * 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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import graphql.ErrorType; +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import graphql.GraphQL; +import graphql.parser.Parser; +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.SchemaParser; +import graphql.schema.idl.UnExecutableSchemaGenerator; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +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.data.Protocol; +import org.restlet.data.Status; +import org.restlet.ext.graphql.internal.GraphqlJson; + +/** {@link GraphqlClientAdapter} against the extension's server and a third-party server. */ +class GraphqlClientTestCase { + + private static final GraphQLSchema REMOTE_SCHEMA = + UnExecutableSchemaGenerator.makeUnExecutableSchema( + new SchemaParser().parse(GraphqlTestSchemas.SDL)); + + private Component component; + + private HttpServer thirdParty; + + private Client client; + + private String base; + + private String thirdPartyBase; + + private final AtomicInteger thirdPartyCalls = new AtomicInteger(); + + @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.start(); + base = "http://localhost:" + component.getServers().get(0).getActualPort(); + + // An independent HTTP stack serving graphql-java the legacy way: application/json and + // 200 for every well-formed request, 422 with a GraphQL body for unknown paths. + GraphQL graphQL = GraphqlTestSchemas.graphQL(new AtomicInteger()); + thirdParty = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + thirdParty.createContext("/", exchange -> thirdParty(exchange, graphQL)); + thirdParty.start(); + thirdPartyBase = "http://localhost:" + thirdParty.getAddress().getPort(); + + client = new Client(Protocol.HTTP); + client.start(); + } + + @AfterEach + void tearDown() throws Exception { + client.stop(); + component.stop(); + thirdParty.stop(0); + } + + private void thirdParty(HttpExchange exchange, GraphQL graphQL) throws IOException { + thirdPartyCalls.incrementAndGet(); + Map params = + GraphqlJson.asObject(GraphqlJson.read(exchange.getRequestBody(), 1 << 20)); + ExecutionInput.Builder input = + ExecutionInput.newExecutionInput().query((String) params.get("query")); + Map variables = GraphqlJson.asObject(params.get("variables")); + if (variables != null) { + input.variables(variables); + } + int status = exchange.getRequestURI().getPath().equals("/graphql") ? 200 : 422; + byte[] body = GraphqlJson.write(graphQL.execute(input).toSpecification()); + exchange.getResponseHeaders() + .add( + "Content-Type", + status == 200 + ? "application/json; charset=utf-8" + : "application/graphql-response+json"); + exchange.sendResponseHeaders(status, 0); // chunked + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } + + private GraphqlClientAdapter adapter(String endpoint) { + GraphqlClientAdapter adapter = new GraphqlClientAdapter(); + adapter.setClient(client); + adapter.setEndpoint(endpoint); + return adapter; + } + + @Test + void variablesAreJsonValuesNotTemplates() throws Exception { + String text = "{{name}} \"quoted\" {key=value} \\ é\n"; + for (String endpoint : List.of(base + "/graphql", thirdPartyBase + "/graphql")) { + GraphqlClientAdapter graphql = adapter(endpoint); + graphql.start(); + ExecutionResult result = + graphql.execute( + "query($in: EchoInput!) { echo(input: $in) }", + null, + Map.of("in", Map.of("text", text, "tags", List.of("a", "b")))); + assertTrue(result.getErrors().isEmpty(), result.getErrors()::toString); + assertEquals(Map.of("echo", text + "|2"), result.getData(), endpoint); + } + } + + @Test + void legacyServerErrorsAreResults() throws Exception { + GraphqlClientAdapter graphql = adapter(thirdPartyBase + "/graphql"); + graphql.start(); + ExecutionResult invalid = graphql.execute("{ unknownField }", null, null); + assertFalse(invalid.isDataPresent()); + assertEquals(1, invalid.getErrors().size()); + + GraphqlClientAdapter unprocessable = adapter(thirdPartyBase + "/other"); + unprocessable.start(); + ExecutionResult result = unprocessable.execute("{ hello }", null, null); + assertEquals(Map.of("hello", "Hello world"), result.getData()); + } + + @Test + void schemaValidatesLocallyWithoutNetworkCall() throws Exception { + GraphqlClientAdapter graphql = adapter(thirdPartyBase + "/graphql"); + graphql.setSchema(REMOTE_SCHEMA); + graphql.start(); + assertSame(REMOTE_SCHEMA, graphql.getSchema()); + + ExecutionResult unknown = graphql.execute("{ unknownField }", null, null); + assertFalse(unknown.isDataPresent()); + assertEquals(ErrorType.ValidationError, unknown.getErrors().get(0).getErrorType()); + + ExecutionResult syntax = graphql.execute("{ hello ", null, null); + assertEquals(ErrorType.InvalidSyntax, syntax.getErrors().get(0).getErrorType()); + + ExecutionResult parsed = graphql.execute(Parser.parse("{ unknownField }"), null, Map.of()); + assertEquals(ErrorType.ValidationError, parsed.getErrors().get(0).getErrorType()); + assertEquals(0, thirdPartyCalls.get()); + + ExecutionResult valid = graphql.execute(Parser.parse("{ hello }"), null, null); + assertEquals(Map.of("hello", "Hello world"), valid.getData()); + assertEquals(1, thirdPartyCalls.get()); + } + + @Test + void largeResponsesFailInsteadOfBeingTruncated() { + GraphqlClientAdapter graphql = adapter(base + "/graphql"); + graphql.setMaxResponseBytes(64); + graphql.start(); + GraphqlTransportException e = + assertThrows( + GraphqlTransportException.class, + () -> + graphql.execute( + "query($n: String) { hello(name: $n) }", + null, + Map.of("n", "x".repeat(100)))); + assertEquals(Status.SUCCESS_OK, e.getStatus()); + assertNull(e.getBodyExcerpt()); + + GraphqlClientAdapter chunked = adapter(thirdPartyBase + "/graphql"); + chunked.setMaxResponseBytes(64); + chunked.start(); + assertThrows( + GraphqlTransportException.class, + () -> + chunked.execute( + "query($n: String) { hello(name: $n) }", + null, + Map.of("n", "x".repeat(100)))); + } + + @Test + void requestCustomizerAddsHeaders() throws Exception { + GraphqlClientAdapter graphql = adapter(base + "/graphql"); + graphql.setRequestCustomizer(request -> request.getHeaders().add("X-Api-Key", "k123")); + graphql.start(); + ExecutionResult result = graphql.execute("{ header(name: \"X-Api-Key\") }", null, null); + assertEquals(Map.of("header", "k123"), result.getData()); + } + + @Test + void configurationIsFrozenOnceStarted() { + GraphqlClientAdapter graphql = adapter(base + "/graphql"); + assertThrows(IllegalArgumentException.class, () -> graphql.setMaxResponseBytes(0)); + assertNull(graphql.getParserOptions()); + graphql.start(); + assertEquals( + GraphqlClientAdapter.DEFAULT_MAX_RESPONSE_BYTES, graphql.getMaxResponseBytes()); + assertTrue(graphql.getParserOptions() != null); + assertNull(graphql.getRequestCustomizer()); + assertNull(graphql.getContext()); + assertThrows(IllegalStateException.class, () -> graphql.setSchema(null)); + assertThrows(IllegalStateException.class, () -> graphql.setParserOptions(null)); + assertThrows(IllegalStateException.class, () -> graphql.setMaxResponseBytes(1)); + assertThrows(IllegalStateException.class, () -> graphql.setRequestCustomizer(null)); + assertThrows(IllegalStateException.class, graphql::start); + } +} 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 761a087594..c08d563360 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 @@ -14,6 +14,8 @@ import graphql.schema.idl.RuntimeWiring; import graphql.schema.idl.SchemaGenerator; import graphql.schema.idl.SchemaParser; +import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicInteger; import org.dataloader.DataLoader; @@ -33,6 +35,12 @@ final class GraphqlTestSchemas { whoami: String userAgent: String loaded(key: String!): String + echo(input: EchoInput!): String + header(name: String!): String + } + input EchoInput { + text: String! + tags: [String!] } type Mutation { increment(by: Int!): Int @@ -67,6 +75,17 @@ static GraphQLSchema schema(AtomicInteger counter) { DataLoader loader = env.getDataLoader("upper"); return loader.load(env.getArgument("key")); }; + DataFetcher echo = + env -> { + Map input = env.getArgument("input"); + List tags = (List) input.getOrDefault("tags", List.of()); + return input.get("text") + "|" + tags.size(); + }; + DataFetcher header = + env -> { + Request request = env.getGraphQlContext().get(RestletGraphql.REQUEST); + return request.getHeaders().getFirstValue(env.getArgument("name"), true); + }; DataFetcher ticks = env -> new TickPublisher(env.getArgument("count")); RuntimeWiring wiring = @@ -86,7 +105,9 @@ static GraphQLSchema schema(AtomicInteger counter) { .dataFetcher("failing", failing) .dataFetcher("whoami", whoami) .dataFetcher("userAgent", userAgent) - .dataFetcher("loaded", loaded)) + .dataFetcher("loaded", loaded) + .dataFetcher("echo", echo) + .dataFetcher("header", header)) .type( "Mutation", type ->