Add GraphQlClient and GraphQlTransport for HTTP

See gh-10
This commit is contained in:
rstoyanchev
2022-02-21 12:08:21 +00:00
parent 2729ea685a
commit 371c601c9d
13 changed files with 1369 additions and 1 deletions

View File

@@ -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'

View File

@@ -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 extends GraphQlTransport> T getTransport(Class<T> 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<String, Object> 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<ResponseSpec> execute() {
return this.transport.execute(initRequestInput())
.map(payload -> new DefaultResponseSpec(payload, this.jsonPathConfig));
}
@Override
public Flux<ResponseSpec> 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<GraphQLError> errors;
private DefaultResponseSpec(ExecutionResult result, Configuration jsonPathConfig) {
this.documentContext = JsonPath.parse(result.toSpecification(), jsonPathConfig);
this.errors = result.getErrors();
}
@Override
public <D> D toEntity(String path, Class<D> entityType) {
return this.documentContext.read(initJsonPath(path), new TypeRefAdapter<>(entityType));
}
@Override
public <D> D toEntity(String path, ParameterizedTypeReference<D> entityType) {
return this.documentContext.read(initJsonPath(path), new TypeRefAdapter<>(entityType));
}
@Override
public <D> List<D> toEntityList(String path, Class<D> elementType) {
return this.documentContext.read(initJsonPath(path), new TypeRefAdapter<>(List.class, elementType));
}
@Override
public <D> List<D> toEntityList(String path, ParameterizedTypeReference<D> 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<GraphQLError> errors() {
return this.errors;
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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.
*
* <p>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 extends GraphQlTransport> T getTransport(Class<T> 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.
* <p>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}.
* <p>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<ResponseSpec> 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:
* <ul>
* <li>Completes if the subscription completes before the connection is closed.
* <li>{@link SubscriptionErrorException} if the subscription ends with an error.
* <li>{@link IllegalStateException} if the connection is closed or lost
* before the stream terminates.
* <li>Exception for connection and GraphQL session initialization issues.
* </ul>
* <p>The {@code Flux} may be cancelled to notify the server to end the
* subscription stream.
*/
Flux<ResponseSpec> 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 <a href="https://github.com/jayway/JsonPath">JsonPath</a>
* 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 <D> the target entity type
* @return the entity resulting from the conversion
*/
<D> D toEntity(String path, Class<D> 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 <D> the target entity type
* @return the entity resulting from the conversion
*/
<D> D toEntity(String path, ParameterizedTypeReference<D> 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
* <a href="https://github.com/jayway/JsonPath">JsonPath</a>
* 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 <D> the target entity type
* @return the list of entities resulting from the conversion
*/
<D> List<D> toEntityList(String path, Class<D> 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 <D> the target entity type
* @return the list of entities resulting from the conversion
*/
<D> List<D> toEntityList(String path, ParameterizedTypeReference<D> elementType);
/**
* Return the errors from the response or an empty list.
*/
List<GraphQLError> errors();
}
}

View File

@@ -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.
* </ul>
*/
Mono<ExecutionResult> 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:
* <ul>
* <li>Completes if the subscription completes before the connection is closed.
* <li>{@link SubscriptionErrorException} if the subscription ends with an error.
* <li>{@link IllegalStateException} if the connection is closed or lost
* before the stream terminates.
* <li>Exception for connection and GraphQL session initialization issues.
* </ul>
* <p>The {@code Flux} may be cancelled to notify the server to end the
* subscription stream.
*/
Flux<ExecutionResult> executeSubscription(RequestInput input);
}

View File

@@ -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.
*
* <p>Use the builder to initialize the transport and the {@link GraphQlClient}
* in a single chain:
*
* <pre style="class">
* GraphQlClient client = HttpGraphQlTransport.builder(webClient).buildClient();
* </pre>
*
* <p>Or build the transport and the client separately:
*
* <pre style="class">
* HttpGraphQlTransport transport = HttpGraphQlTransport.create(webClient);
* GraphQlClient client = GraphQlClient.create(transport);
* </pre>
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public class HttpGraphQlTransport implements GraphQlTransport {
private static final ParameterizedTypeReference<Map<String, Object>> MAP_TYPE =
new ParameterizedTypeReference<Map<String, Object>>() {};
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<ExecutionResult> 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<ExecutionResult> 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();
}
}
}

View File

@@ -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<String, Object> map;
private final List<GraphQLError> errors;
MapExecutionResult(@Nullable Map<String, Object> map) {
this.map = (map != null ? map : Collections.emptyMap());
this.errors = MapGraphQlError.fromResultMap(map);
}
@Override
public List<GraphQLError> getErrors() {
return this.errors;
}
@SuppressWarnings("unchecked")
@Override
public <T> T getData() {
return (T) this.map.get("data");
}
@Override
public boolean isDataPresent() {
return (this.map.get("data") != null);
}
@SuppressWarnings("unchecked")
@Override
public Map<Object, Object> getExtensions() {
return (Map<Object, Object>) this.map.get("extensions");
}
@Override
public Map<String, Object> 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<String, Object> map) {
return new MapExecutionResult(Collections.singletonMap("data", map));
}
}

View File

@@ -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<String, Object> errorMap;
private final List<SourceLocation> locations;
private MapGraphQlError(Map<String, Object> errorMap) {
this.errorMap = errorMap;
this.locations = initLocations(errorMap);
}
@SuppressWarnings("unchecked")
private static List<SourceLocation> initLocations(Map<String, Object> errorMap) {
List<Map<String, Object>> locations = (List<Map<String, Object>>) 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<SourceLocation> 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<Object> getPath() {
return (List<Object>) this.errorMap.get("path");
}
@SuppressWarnings("unchecked")
@Override
@Nullable
public Map<String, Object> getExtensions() {
return (Map<String, Object>) this.errorMap.get("extensions");
}
@Override
public Map<String, Object> 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<GraphQLError> fromMapList(@Nullable List<Map<String, Object>> 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<GraphQLError> fromResultMap(@Nullable Map<String, Object> map) {
if (map == null) {
return Collections.emptyList();
}
return MapGraphQlError.fromMapList((List<Map<String, Object>>) map.get("errors"));
}
}

View File

@@ -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);
}

View File

@@ -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<String> FILE_EXTENSIONS = Arrays.asList(".graphql", ".gql");
private final List<Resource> locations;
private final List<String> 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<Resource> locations) {
this(locations, FILE_EXTENSIONS);
}
/**
* Constructor with given locations and extensions.
*/
public ResourceOperationContentLoader(List<Resource> locations, List<String> extensions) {
this.locations = new ArrayList<>(locations);
this.extensions = new ArrayList<>(extensions);
}
/**
* Return the configured locations.
*/
public List<Resource> getLocations() {
return this.locations;
}
/**
* Return the configured extensions.
*/
public List<String> 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);
}
}
}

View File

@@ -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<T> extends TypeRef<T> {
private final Type type;
TypeRefAdapter(Class<T> clazz) {
this.type = clazz;
}
TypeRefAdapter(ParameterizedTypeReference<T> 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;
}
}

View File

@@ -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;

View File

@@ -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<ExecutionResult> response;
@Nullable
private RequestInput savedRequestInput;
public TestTransport(ExecutionResult response) {
this(Mono.just(response));
}
public TestTransport(Mono<ExecutionResult> response) {
this.response = response;
}
public RequestInput getSavedRequestInput() {
Assert.notNull(this.savedRequestInput, "No saved RequestInput");
return this.savedRequestInput;
}
@Override
public Mono<ExecutionResult> execute(RequestInput input) {
this.savedRequestInput = input;
return this.response;
}
@Override
public Flux<ExecutionResult> 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<String, Object> toMap() {
Map<String, Object> map = new HashMap<>();
map.put("slug", getSlug());
map.put("name", getName());
map.put("repositoryUrl", getRepositoryUrl());
return map;
}
}
}