diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index cdb095fd..af3ed273 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -12,6 +12,7 @@ dependencies { compileOnly 'org.springframework:spring-webmvc' compileOnly 'org.springframework:spring-websocket' compileOnly 'javax.servlet:javax.servlet-api' + compileOnly 'javax.validation:validation-api' compileOnly 'org.springframework.security:spring-security-core' @@ -22,7 +23,8 @@ dependencies { compileOnly 'org.jetbrains.kotlin:kotlin-stdlib' compileOnly 'org.jetbrains.kotlinx:kotlinx-coroutines-core' - compileOnly 'javax.validation:validation-api' + compileOnly 'com.jayway.jsonpath:json-path' + compileOnly 'com.fasterxml.jackson.core:jackson-databind' testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'org.assertj:assertj-core' @@ -47,6 +49,7 @@ dependencies { testImplementation 'com.querydsl:querydsl-core' testImplementation 'com.querydsl:querydsl-collections' testImplementation 'javax.servlet:javax.servlet-api' + testImplementation 'com.squareup.okhttp3:mockwebserver:3.14.9' testImplementation 'javax.validation:validation-api' testImplementation 'com.jayway.jsonpath:json-path' testImplementation 'com.fasterxml.jackson.core:jackson-databind' diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java new file mode 100644 index 00000000..f1295669 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java @@ -0,0 +1,181 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.client; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import graphql.ExecutionResult; +import graphql.GraphQLError; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.graphql.RequestInput; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Default implementation of {@link GraphQlClient}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +final class DefaultGraphQlClient implements GraphQlClient { + + private final GraphQlTransport transport; + + private final Configuration jsonPathConfig; + + private final OperationContentLoader operationContentLoader; + + + public DefaultGraphQlClient(GraphQlTransport transport, Configuration jsonPathConfig, + OperationContentLoader operationContentLoader) { + + Assert.notNull(transport, "GraphQlTransport is required"); + Assert.notNull(jsonPathConfig, "'jsonPathConfig' is required"); + Assert.notNull(operationContentLoader, "RequestNameResolver is required"); + this.transport = transport; + this.jsonPathConfig = jsonPathConfig; + this.operationContentLoader = operationContentLoader; + } + + + @SuppressWarnings("unchecked") + @Override + public T getTransport(Class requiredType) { + return (requiredType.isInstance(this.transport) ? (T) this.transport : null); + } + + @Override + public RequestSpec operation(String operation) { + return new DefaultRequestSpec(operation, this.transport, this.jsonPathConfig); + } + + @Override + public RequestSpec loadOperationContent(String key) { + String requestContent = this.operationContentLoader.loadOperation(key); + Assert.notNull(requestContent, "Failed to load operation content for key: " + key); + return new DefaultRequestSpec(requestContent, this.transport, this.jsonPathConfig); + } + + + private static final class DefaultRequestSpec implements RequestSpec { + + private final String operation; + + @Nullable + private String operationName; + + private final Map variables = new LinkedHashMap<>(); + + private final GraphQlTransport transport; + + private final Configuration jsonPathConfig; + + DefaultRequestSpec(String operation, GraphQlTransport transport, Configuration jsonPathConfig) { + Assert.hasText(operation, "'operation' is required"); + this.operation = operation; + this.transport = transport; + this.jsonPathConfig = jsonPathConfig; + } + + @Override + public DefaultRequestSpec operationName(@Nullable String name) { + this.operationName = name; + return this; + } + + @Override + public DefaultRequestSpec variable(String name, Object value) { + this.variables.put(name, value); + return this; + } + + @Override + public Mono execute() { + return this.transport.execute(initRequestInput()) + .map(payload -> new DefaultResponseSpec(payload, this.jsonPathConfig)); + } + + @Override + public Flux executeSubscription() { + return this.transport.executeSubscription(initRequestInput()) + .map(payload -> new DefaultResponseSpec(payload, this.jsonPathConfig)); + } + + private RequestInput initRequestInput() { + return new RequestInput(this.operation, this.operationName, this.variables, null, "1"); + } + + } + + + private static class DefaultResponseSpec implements ResponseSpec { + + private final DocumentContext documentContext; + + private final List errors; + + private DefaultResponseSpec(ExecutionResult result, Configuration jsonPathConfig) { + this.documentContext = JsonPath.parse(result.toSpecification(), jsonPathConfig); + this.errors = result.getErrors(); + } + + @Override + public D toEntity(String path, Class entityType) { + return this.documentContext.read(initJsonPath(path), new TypeRefAdapter<>(entityType)); + } + + @Override + public D toEntity(String path, ParameterizedTypeReference entityType) { + return this.documentContext.read(initJsonPath(path), new TypeRefAdapter<>(entityType)); + } + + @Override + public List toEntityList(String path, Class elementType) { + return this.documentContext.read(initJsonPath(path), new TypeRefAdapter<>(List.class, elementType)); + } + + @Override + public List toEntityList(String path, ParameterizedTypeReference elementType) { + return this.documentContext.read(initJsonPath(path), new TypeRefAdapter<>(List.class, elementType)); + } + + private static JsonPath initJsonPath(String path) { + if (!StringUtils.hasText(path)) { + path = "$.data"; + } + else if (!path.startsWith("$") && !path.startsWith("data.")) { + path = "$.data." + path; + } + return JsonPath.compile(path); + } + + @Override + public List errors() { + return this.errors; + } + + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClientBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClientBuilder.java new file mode 100644 index 00000000..adc683a7 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClientBuilder.java @@ -0,0 +1,103 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.client; + +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.spi.json.JacksonJsonProvider; +import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * Default implementation of {@link GraphQlClient.Builder}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +class DefaultGraphQlClientBuilder implements GraphQlClient.Builder { + + private static final boolean jackson2Present; + + static { + ClassLoader classLoader = DefaultGraphQlClientBuilder.class.getClassLoader(); + jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) + && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader); + } + + + private final GraphQlTransport transport; + + @Nullable + private Configuration jsonPathConfig; + + @Nullable + private OperationContentLoader operationContentLoader; + + + DefaultGraphQlClientBuilder(GraphQlTransport transport) { + Assert.notNull(transport, "GraphQlTransport is required"); + this.transport = transport; + } + + + @Override + public GraphQlClient.Builder jsonPathConfig(@Nullable Configuration config) { + this.jsonPathConfig = config; + return this; + } + + @Override + public GraphQlClient.Builder operationContentLoader(@Nullable OperationContentLoader contentLoader) { + this.operationContentLoader = contentLoader; + return this; + } + + @Override + public GraphQlClient build() { + return new DefaultGraphQlClient(this.transport, initJsonPathConfig(), initRequestNameResolver()); + } + + private Configuration initJsonPathConfig() { + if (this.jsonPathConfig != null) { + return this.jsonPathConfig; + } + else if (jackson2Present) { + return Jackson2Configuration.create(); + } + else { + return Configuration.builder().build(); + } + } + + private OperationContentLoader initRequestNameResolver() { + return (this.operationContentLoader == null ? + new ResourceOperationContentLoader() : this.operationContentLoader); + } + + + private static class Jackson2Configuration { + + static Configuration create() { + return Configuration.builder() + .jsonProvider(new JacksonJsonProvider()) + .mappingProvider(new JacksonMappingProvider()) + .build(); + } + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java new file mode 100644 index 00000000..2bae1353 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java @@ -0,0 +1,218 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.client; + +import java.util.List; + +import com.jayway.jsonpath.Configuration; +import graphql.GraphQLError; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.lang.Nullable; + +/** + * Defines a workflow to prepare and execute GraphQL requests and to decode and + * handle responses. + * + *

To create a {@link GraphQlClient}, use the builder in this class, and see + * examples in {@link HttpGraphQlTransport} and {@link WebSocketGraphQlTransport} + * for initializing both the client and the transport it runs over. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + * @see HttpGraphQlTransport + * @see WebSocketGraphQlTransport + */ +public interface GraphQlClient { + + /** + * Return the underlying transport or {@code null} if the required type + * does not match the transport type. See {@link GraphQlTransport} + */ + @Nullable + T getTransport(Class requiredType); + + /** + * Start a new request with the given GraphQL operation, which can be a + * query, a mutation, or subscription. + * @param operation the operation to perform + * @return spec to further define and execute the request + */ + RequestSpec operation(String operation); + + /** + * Variant of {@link #operation(String)} that loads the content of the + * operation from the given key through the configured + * {@link OperationContentLoader}. + * @throws IllegalArgumentException if the content could not be loaded + */ + RequestSpec loadOperationContent(String key); + + + /** + * Return a builder to initialize a {@link GraphQlClient} instance. + * @param transport the transport for executing requests over + * @see HttpGraphQlTransport + * @see WebSocketGraphQlTransport + */ + static Builder builder(GraphQlTransport transport) { + return new DefaultGraphQlClientBuilder(transport); + } + + + /** + * Defines a builder for creating {@link GraphQlClient} instances. + */ + interface Builder { + + /** + * Provide JSONPath configuration settings. + *

By default, the Jackson JSON library is used if present. + */ + Builder jsonPathConfig(@Nullable Configuration config); + + /** + * Configure an {@link OperationContentLoader} to use with + * {@link #loadOperationContent(String) GraphQlClient#loadOperationContent}. + *

By default, {@link ResourceOperationContentLoader} is used. + */ + Builder operationContentLoader(@Nullable OperationContentLoader contentLoader); + + /** + * Build the {@code GraphQlClient} instance. + */ + GraphQlClient build(); + + } + + + /** + * Declare options to execute a request. + */ + interface ExecuteSpec { + + /** + * Execute as a single-response operation such as "query" or "mutation". + * @return a {@code Mono} with a {@code ResponseSpec} for further + * decoding of the response. The {@code Mono} may end wth an error due + * to transport level issues. + */ + Mono execute(); + + /** + * Execute a "subscription" request and stream a stream of responses. + * @return a {@code Flux} with a {@code ResponseSpec} for further + * decoding of the response. The {@code Flux} may terminate as follows: + *

    + *
  • Completes if the subscription completes before the connection is closed. + *
  • {@link SubscriptionErrorException} if the subscription ends with an error. + *
  • {@link IllegalStateException} if the connection is closed or lost + * before the stream terminates. + *
  • Exception for connection and GraphQL session initialization issues. + *
+ *

The {@code Flux} may be cancelled to notify the server to end the + * subscription stream. + */ + Flux executeSubscription(); + + } + + + /** + * Declare options to gather input for a GraphQL request and execute it. + */ + interface RequestSpec extends ExecuteSpec { + + /** + * Set the operation name. + * @param name the operation name + * @return this request spec + */ + RequestSpec operationName(@Nullable String name); + + /** + * Add a variable. + * @param name the variable name + * @param value the variable value + * @return this request spec + */ + RequestSpec variable(String name, Object value); + + } + + + /** + * Declare options to decode a response. + */ + interface ResponseSpec { + + /** + * Switch to the given the "data" path of the GraphQL response and + * convert the data to the target type. The path can be an operation + * root type name, e.g. "book", or a nested path such as "book.name", + * or any JsonPath + * relative to the "data" key of the response. + * @param path a JSON path to the data of interest + * @param entityType the type to convert to + * @param the target entity type + * @return the entity resulting from the conversion + */ + D toEntity(String path, Class entityType); + + /** + * Variant of {@link #toEntity(String, Class)} for entity classes with + * generic types. + * @param path a JSON path to the data of interest + * @param entityType the type to convert to + * @param the target entity type + * @return the entity resulting from the conversion + */ + D toEntity(String path, ParameterizedTypeReference entityType); + + /** + * Switch to the given the "data" path of the GraphQL response and + * convert the data to a List with the given element type. + * The path can be an operation root type name, e.g. "book", or a + * nested path such as "book.name", or any + * JsonPath + * relative to the "data" key of the response. + * @param path a JSON path to the data of interest + * @param elementType the type of element to convert to + * @param the target entity type + * @return the list of entities resulting from the conversion + */ + List toEntityList(String path, Class elementType); + + /** + * Variant of {@link #toEntityList(String, Class)} for entity classes + * with generic types. + * @param path a JSON path to the data of interest + * @param elementType the type to convert to + * @param the target entity type + * @return the list of entities resulting from the conversion + */ + List toEntityList(String path, ParameterizedTypeReference elementType); + + /** + * Return the errors from the response or an empty list. + */ + List errors(); + + } + +} \ No newline at end of file diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlTransport.java b/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlTransport.java new file mode 100644 index 00000000..0713383e --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlTransport.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.client; + +import graphql.ExecutionResult; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.graphql.RequestInput; + +/** + * Contract for a transport, over which to execute GraphQL requests. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public interface GraphQlTransport { + + /** + * Execute a single-response operation such as "query" or "mutation". + * @param input the request to execute + * @return a {@code Mono} with the {@code ExecutionResult} for the response. + * The {@code Mono} may end wth an error due to transport or other issues + * such as failures to encode the request or decode the response. + * + */ + Mono execute(RequestInput input); + + /** + * Execute a "subscription" request and stream the responses. + * @param input the request to execute + * @return a {@code Flux} with an {@code ExecutionResult} for each response. + * The {@code Flux} may terminate as follows: + *

    + *
  • Completes if the subscription completes before the connection is closed. + *
  • {@link SubscriptionErrorException} if the subscription ends with an error. + *
  • {@link IllegalStateException} if the connection is closed or lost + * before the stream terminates. + *
  • Exception for connection and GraphQL session initialization issues. + *
+ *

The {@code Flux} may be cancelled to notify the server to end the + * subscription stream. + */ + Flux executeSubscription(RequestInput input); + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlTransport.java b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlTransport.java new file mode 100644 index 00000000..ed8723c8 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlTransport.java @@ -0,0 +1,153 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.client; + +import java.util.Map; + +import graphql.ExecutionResult; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.graphql.RequestInput; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Transport to execute GraphQL requests over HTTP via {@link WebClient}. + * Single-response requests are performed over HTTP POST while subscriptions + * over HTTP are not supported. + * + *

Use the builder to initialize the transport and the {@link GraphQlClient} + * in a single chain: + * + *

+ * GraphQlClient client = HttpGraphQlTransport.builder(webClient).buildClient();
+ * 
+ * + *

Or build the transport and the client separately: + * + *

+ * HttpGraphQlTransport transport = HttpGraphQlTransport.create(webClient);
+ * GraphQlClient client = GraphQlClient.create(transport);
+ * 
+ * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class HttpGraphQlTransport implements GraphQlTransport { + + private static final ParameterizedTypeReference> MAP_TYPE = + new ParameterizedTypeReference>() {}; + + + private final WebClient webClient; + + + private HttpGraphQlTransport(WebClient webClient) { + Assert.notNull(webClient, "WebClient is required"); + this.webClient = webClient; + } + + + /** + * Return the underlying {@code WebClient}. + */ + public WebClient getWebClient() { + return this.webClient; + } + + + @Override + public Mono execute(RequestInput requestInput) { + return this.webClient.post() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(requestInput.toMap()) + .retrieve() + .bodyToMono(MAP_TYPE) + .map(MapExecutionResult::new); + } + + @Override + public Flux executeSubscription(RequestInput requestInput) { + throw new UnsupportedOperationException("Subscriptions not supported over HTTP"); + } + + + /** + * Static factory method with a {@code WebClient} to use. + */ + public static HttpGraphQlTransport create(WebClient webClient) { + return new HttpGraphQlTransport(webClient); + } + + /** + * Static method to obtain a {@code Builder}. + */ + public static Builder builder(WebClient webClient) { + return new Builder(webClient); + } + + + /** + * Builder for {@link HttpGraphQlTransport} or a {@link GraphQlClient} + * configured with the transport. + */ + public static class Builder { + + private WebClient webClient; + + private Builder(WebClient webClient) { + this.webClient = webClient; + } + + /** + * Set the {@code WebClient} to use. + */ + public Builder webClient(WebClient webClient) { + this.webClient = webClient; + return this; + } + + /** + * Build the {@code HttpGraphQlTransport} instance. + */ + public HttpGraphQlTransport build() { + return new HttpGraphQlTransport(this.webClient); + } + + /** + * Continue on to build a {@link GraphQlClient} configured with the + * transport configured here so far. + */ + public GraphQlClient.Builder configureClient() { + return GraphQlClient.builder(build()); + } + + /** + * Shortcut to build a {@link GraphQlClient} configured with the + * transport configured here. + */ + public GraphQlClient buildClient() { + return GraphQlClient.builder(build()).build(); + } + + } + + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/MapExecutionResult.java b/spring-graphql/src/main/java/org/springframework/graphql/client/MapExecutionResult.java new file mode 100644 index 00000000..6d67c86b --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/MapExecutionResult.java @@ -0,0 +1,98 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.client; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import graphql.ExecutionResult; +import graphql.ExecutionResultImpl; +import graphql.GraphQLError; + +import org.springframework.lang.Nullable; + +/** + * Implementation of {@link ExecutionResult} backed by a {@link Map}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +final class MapExecutionResult implements ExecutionResult { + + private final Map map; + + private final List errors; + + + MapExecutionResult(@Nullable Map map) { + this.map = (map != null ? map : Collections.emptyMap()); + this.errors = MapGraphQlError.fromResultMap(map); + } + + + @Override + public List getErrors() { + return this.errors; + } + + @SuppressWarnings("unchecked") + @Override + public T getData() { + return (T) this.map.get("data"); + } + + @Override + public boolean isDataPresent() { + return (this.map.get("data") != null); + } + + @SuppressWarnings("unchecked") + @Override + public Map getExtensions() { + return (Map) this.map.get("extensions"); + } + + @Override + public Map toSpecification() { + return ExecutionResultImpl.newExecutionResult().from(this).build().toSpecification(); + } + + @Override + public boolean equals(Object other) { + return (other instanceof MapExecutionResult && this.map.equals(((MapExecutionResult) other).map)); + } + + @Override + public int hashCode() { + return this.map.hashCode(); + } + + @Override + public String toString() { + return this.map.toString(); + } + + + /** + * Static factory method to create an instance of this class. + */ + public static ExecutionResult forData(@Nullable Map map) { + return new MapExecutionResult(Collections.singletonMap("data", map)); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/MapGraphQlError.java b/spring-graphql/src/main/java/org/springframework/graphql/client/MapGraphQlError.java new file mode 100644 index 00000000..079932c9 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/MapGraphQlError.java @@ -0,0 +1,161 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.client; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import graphql.ErrorClassification; +import graphql.GraphQLError; +import graphql.GraphqlErrorHelper; +import graphql.language.SourceLocation; + +import org.springframework.graphql.execution.ErrorType; +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; + +/** + * Implementation of {@link GraphQLError} backed by a {@link Map}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +@SuppressWarnings("serial") +final class MapGraphQlError implements GraphQLError { + + private final Map errorMap; + + private final List locations; + + + private MapGraphQlError(Map errorMap) { + this.errorMap = errorMap; + this.locations = initLocations(errorMap); + } + + @SuppressWarnings("unchecked") + private static List initLocations(Map errorMap) { + List> locations = (List>) errorMap.get("locations"); + if (locations == null) { + return Collections.emptyList(); + } + return locations.stream() + .map(map -> new SourceLocation( + (int) map.getOrDefault("line", 0), + (int) map.getOrDefault("column", 0), + (String) map.get("sourceName"))) + .collect(Collectors.toList()); + + } + + + + @Override + @Nullable + public String getMessage() { + return (String) errorMap.get("message"); + } + + @Override + public List getLocations() { + return this.locations; + } + + @Override + @Nullable + public ErrorClassification getErrorType() { + // Attempt the reverse of how errorType is serialized in GraphqlErrorHelper.toSpecification. + // However, we can only do that for ErrorClassification enums that we know of. + String value = (getExtensions() != null ? (String) getExtensions().get("classification") : null); + if (value != null) { + try { + return graphql.ErrorType.valueOf(value); + } + catch (IllegalArgumentException ex) { + // ignore + } + try { + return ErrorType.valueOf(value); + } + catch (IllegalArgumentException ex) { + // ignore + } + } + return null; + } + + @SuppressWarnings("unchecked") + @Override + @Nullable + public List getPath() { + return (List) this.errorMap.get("path"); + } + + @SuppressWarnings("unchecked") + @Override + @Nullable + public Map getExtensions() { + return (Map) this.errorMap.get("extensions"); + } + + @Override + public Map toSpecification() { + return GraphqlErrorHelper.toSpecification(this); + } + + @Override + public boolean equals(Object other) { + return GraphqlErrorHelper.equals(this, other); + } + + @Override + public int hashCode() { + return GraphqlErrorHelper.hashCode(this); + } + + @Override + public String toString() { + return toSpecification().toString(); + } + + + /** + * Static factory method to create an instance from a list of maps, each + * containing an error. + */ + public static List fromMapList(@Nullable List> errorMaps) { + if (CollectionUtils.isEmpty(errorMaps)) { + return Collections.emptyList(); + } + return errorMaps.stream().map(MapGraphQlError::new).collect(Collectors.toList()); + } + + /** + * Static factory method to create an instance from an + * {@link graphql.ExecutionResult} map. + */ + @SuppressWarnings("unchecked") + public static List fromResultMap(@Nullable Map map) { + if (map == null) { + return Collections.emptyList(); + } + return MapGraphQlError.fromMapList((List>) map.get("errors")); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/OperationContentLoader.java b/spring-graphql/src/main/java/org/springframework/graphql/client/OperationContentLoader.java new file mode 100644 index 00000000..5abb6c1f --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/OperationContentLoader.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.client; + +import org.springframework.lang.Nullable; + +/** + * Strategy to load the content of a GraphQL operation from a key. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public interface OperationContentLoader { + + /** + * Return the operation for the given key. + * @param key the key to look up the operation content + * @return the content of the operation, if found + */ + @Nullable + String loadOperation(String key); + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/ResourceOperationContentLoader.java b/spring-graphql/src/main/java/org/springframework/graphql/client/ResourceOperationContentLoader.java new file mode 100644 index 00000000..63d40c4a --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/ResourceOperationContentLoader.java @@ -0,0 +1,115 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.client; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.util.FileCopyUtils; + +/** + * {@link OperationContentLoader} that looks for resources relative to a list of + * {@link Resource} locations with a list of extensions. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class ResourceOperationContentLoader implements OperationContentLoader { + + private static final List FILE_EXTENSIONS = Arrays.asList(".graphql", ".gql"); + + + private final List locations; + + private final List extensions; + + + /** + * Default constructor to look under {@code graphql/} on the classpath for + * resources with extensions ".graphql" and ".gql". + */ + public ResourceOperationContentLoader() { + this(Collections.singletonList(new ClassPathResource("graphql/"))); + } + + /** + * Constructor with custom locations with extensions ".graphql" and ".gql". + */ + public ResourceOperationContentLoader(List locations) { + this(locations, FILE_EXTENSIONS); + } + + /** + * Constructor with given locations and extensions. + */ + public ResourceOperationContentLoader(List locations, List extensions) { + this.locations = new ArrayList<>(locations); + this.extensions = new ArrayList<>(extensions); + } + + + /** + * Return the configured locations. + */ + public List getLocations() { + return this.locations; + } + + /** + * Return the configured extensions. + */ + public List getExtensions() { + return this.extensions; + } + + + @Override + public String loadOperation(String key) { + return this.locations.stream() + .flatMap(location -> this.extensions.stream().map(ext -> getRelativeResource(location, key, ext))) + .filter(Resource::exists) + .findFirst() + .map(resource -> { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + FileCopyUtils.copy(resource.getInputStream(), out); + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + catch (IOException ex) { + throw new IllegalArgumentException( + "Found resource: " + resource.getDescription() + " but failed to read it", ex); + } + }) + .orElse(null); + } + + private Resource getRelativeResource(Resource location, String name, String ext) { + try { + return location.createRelative(name + ext); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/TypeRefAdapter.java b/spring-graphql/src/main/java/org/springframework/graphql/client/TypeRefAdapter.java new file mode 100644 index 00000000..d227b020 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/TypeRefAdapter.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.client; + +import java.lang.reflect.Type; + +import com.jayway.jsonpath.TypeRef; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; + +/** + * Adapt a JSONPath {@link TypeRef} to {@link ParameterizedTypeReference} and + * {@link ResolvableType} for classes with generics. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +final class TypeRefAdapter extends TypeRef { + + private final Type type; + + TypeRefAdapter(Class clazz) { + this.type = clazz; + } + + TypeRefAdapter(ParameterizedTypeReference typeReference) { + this.type = typeReference.getType(); + } + + TypeRefAdapter(Class clazz, Class generic) { + this.type = ResolvableType.forClassWithGenerics(clazz, generic).getType(); + } + + TypeRefAdapter(Class clazz, ParameterizedTypeReference generic) { + this.type = ResolvableType.forClassWithGenerics(clazz, ResolvableType.forType(generic)).getType(); + } + + + @Override + public Type getType() { + return this.type; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/package-info.java b/spring-graphql/src/main/java/org/springframework/graphql/client/package-info.java new file mode 100644 index 00000000..f44e1a2d --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * GraphQL client. + */ +@NonNullApi +@NonNullFields +package org.springframework.graphql.client; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/DefaultGraphQlClientTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/DefaultGraphQlClientTests.java new file mode 100644 index 00000000..04acd9d9 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/DefaultGraphQlClientTests.java @@ -0,0 +1,157 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.client; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import graphql.ExecutionResult; +import graphql.ExecutionResultImpl; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.graphql.RequestInput; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +/** + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class DefaultGraphQlClientTests { + + @Test + void executeQuery() { + String query = "{" + + " project(slug: \"spring-framework\") {" + + " slug" + + " name" + + " repositoryUrl" + + " }" + + "}"; + + Project expectedProject = new Project( + "spring-framework", "Spring Framework", "https://github.com/spring-projects/spring-framework"); + + ExecutionResultImpl result = ExecutionResultImpl.newExecutionResult() + .data(Collections.singletonMap("project", expectedProject.toMap())) + .build(); + + TestTransport transport = new TestTransport(result); + + Project project = GraphQlClient.builder(transport).build() + .operation(query) + .execute() + .map(spec -> spec.toEntity("project", Project.class)) + .block(); + + assertThat(project).isNotNull(); + assertThat(project.getSlug()).isEqualTo(expectedProject.getSlug()); + assertThat(project.getName()).isEqualTo(expectedProject.getName()); + assertThat(project.getRepositoryUrl()).isEqualTo(expectedProject.getRepositoryUrl()); + } + + + private static class TestTransport implements GraphQlTransport { + + private final Mono response; + + @Nullable + private RequestInput savedRequestInput; + + public TestTransport(ExecutionResult response) { + this(Mono.just(response)); + } + + public TestTransport(Mono response) { + this.response = response; + } + + public RequestInput getSavedRequestInput() { + Assert.notNull(this.savedRequestInput, "No saved RequestInput"); + return this.savedRequestInput; + } + + @Override + public Mono execute(RequestInput input) { + this.savedRequestInput = input; + return this.response; + } + + @Override + public Flux executeSubscription(RequestInput input) { + throw new UnsupportedOperationException(); + } + } + + + private static class Project { + + private String slug; + + private String name; + + private String repositoryUrl; + + public Project() { + } + + public Project(String slug, String name, String repositoryUrl) { + this.slug = slug; + this.name = name; + this.repositoryUrl = repositoryUrl; + } + + public String getSlug() { + return this.slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public String getRepositoryUrl() { + return this.repositoryUrl; + } + + public void setRepositoryUrl(String repositoryUrl) { + this.repositoryUrl = repositoryUrl; + } + + public Map toMap() { + Map map = new HashMap<>(); + map.put("slug", getSlug()); + map.put("name", getName()); + map.put("repositoryUrl", getRepositoryUrl()); + return map; + } + + } + +}