DocumentSource is async and supports caching
See gh-10
This commit is contained in:
@@ -29,6 +29,7 @@ import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.graphql.GraphQlRequest;
|
||||
import org.springframework.graphql.support.DocumentSource;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -69,20 +70,19 @@ final class DefaultGraphQlClient implements GraphQlClient {
|
||||
|
||||
@Override
|
||||
public RequestSpec document(String document) {
|
||||
return new DefaultRequestSpec(document, this.transport, this.jsonPathConfig);
|
||||
return new DefaultRequestSpec(Mono.just(document), this.transport, this.jsonPathConfig);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RequestSpec documentName(String name) {
|
||||
String document = this.documentSource.getDocument(name);
|
||||
Assert.notNull(document, "Failed to find document for name: '" + name + "'");
|
||||
Mono<String> document = this.documentSource.getDocument(name);
|
||||
return new DefaultRequestSpec(document, this.transport, this.jsonPathConfig);
|
||||
}
|
||||
|
||||
|
||||
private static final class DefaultRequestSpec implements RequestSpec {
|
||||
|
||||
private final String document;
|
||||
private final Mono<String> documentMono;
|
||||
|
||||
@Nullable
|
||||
private String operationName;
|
||||
@@ -93,9 +93,9 @@ final class DefaultGraphQlClient implements GraphQlClient {
|
||||
|
||||
private final Configuration jsonPathConfig;
|
||||
|
||||
DefaultRequestSpec(String document, GraphQlTransport transport, Configuration jsonPathConfig) {
|
||||
Assert.hasText(document, "'document' is required");
|
||||
this.document = document;
|
||||
DefaultRequestSpec(Mono<String> documentMono, GraphQlTransport transport, Configuration jsonPathConfig) {
|
||||
Assert.notNull(documentMono, "'documentMono' is required");
|
||||
this.documentMono = documentMono;
|
||||
this.transport = transport;
|
||||
this.jsonPathConfig = jsonPathConfig;
|
||||
}
|
||||
@@ -114,18 +114,21 @@ final class DefaultGraphQlClient implements GraphQlClient {
|
||||
|
||||
@Override
|
||||
public Mono<ResponseSpec> execute() {
|
||||
return this.transport.execute(createRequest())
|
||||
return getRequestMono()
|
||||
.flatMap(this.transport::execute)
|
||||
.map(payload -> new DefaultResponseSpec(payload, this.jsonPathConfig));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<ResponseSpec> executeSubscription() {
|
||||
return this.transport.executeSubscription(createRequest())
|
||||
return getRequestMono()
|
||||
.flatMapMany(this.transport::executeSubscription)
|
||||
.map(payload -> new DefaultResponseSpec(payload, this.jsonPathConfig));
|
||||
}
|
||||
|
||||
private GraphQlRequest createRequest() {
|
||||
return new GraphQlRequest(this.document, this.operationName, this.variables);
|
||||
private Mono<GraphQlRequest> getRequestMono() {
|
||||
return this.documentMono.map(document ->
|
||||
new GraphQlRequest(document, this.operationName, this.variables));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import com.jayway.jsonpath.Configuration;
|
||||
import com.jayway.jsonpath.spi.json.JacksonJsonProvider;
|
||||
import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider;
|
||||
|
||||
import org.springframework.graphql.support.DocumentSource;
|
||||
import org.springframework.graphql.support.ResourceDocumentSource;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
@@ -23,6 +23,8 @@ import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.graphql.support.DocumentSource;
|
||||
import org.springframework.graphql.support.ResourceDocumentSource;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.graphql.GraphQlRequest;
|
||||
import org.springframework.graphql.support.MapExecutionResult;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
@@ -80,7 +81,7 @@ public class HttpGraphQlTransport implements GraphQlTransport {
|
||||
.bodyValue(request.toMap())
|
||||
.retrieve()
|
||||
.bodyToMono(MAP_TYPE)
|
||||
.map(MapExecutionResult::new);
|
||||
.map(MapExecutionResult::from);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/*
|
||||
* 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 DocumentSource} that looks under a set of locations for a
|
||||
* {@link Resource} with the document name and a list of configured extensions.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public class ResourceDocumentSource implements DocumentSource {
|
||||
|
||||
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 ResourceDocumentSource() {
|
||||
this(Collections.singletonList(new ClassPathResource("graphql/")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor with custom locations with extensions ".graphql" and ".gql".
|
||||
*/
|
||||
public ResourceDocumentSource(List<Resource> locations) {
|
||||
this(locations, FILE_EXTENSIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor with given locations and extensions.
|
||||
*/
|
||||
public ResourceDocumentSource(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 getDocument(String name) {
|
||||
return this.locations.stream()
|
||||
.flatMap(location -> this.extensions.stream().map(ext -> getRelativeResource(location, name, 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import org.springframework.graphql.GraphQlRequest;
|
||||
import org.springframework.graphql.support.MapExecutionResult;
|
||||
import org.springframework.graphql.support.MapGraphQlError;
|
||||
import org.springframework.graphql.web.webflux.GraphQlWebSocketMessage;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.codec.ClientCodecConfigurer;
|
||||
@@ -614,7 +616,9 @@ public final class WebSocketGraphQlTransport implements GraphQlTransport {
|
||||
return;
|
||||
}
|
||||
|
||||
ExecutionResult result = new MapExecutionResult(message.getPayload());
|
||||
Map<String, Object> resultMap = message.getPayloadOrDefault(Collections.emptyMap());
|
||||
ExecutionResult result = MapExecutionResult.from(resultMap);
|
||||
|
||||
Sinks.EmitResult emitResult = (sink != null ? sink.tryEmitValue(result) : streamingSink.tryEmitNext(result));
|
||||
if (emitResult.isFailure()) {
|
||||
// Just log: cannot overflow, is serialized, and cancel is handled in doOnCancel
|
||||
@@ -640,15 +644,15 @@ public final class WebSocketGraphQlTransport implements GraphQlTransport {
|
||||
return;
|
||||
}
|
||||
|
||||
List<Map<String, Object>> payload = message.getPayload();
|
||||
List<Map<String, Object>> payload = message.getPayloadOrDefault(Collections.emptyList());
|
||||
|
||||
Sinks.EmitResult emitResult;
|
||||
if (sink != null) {
|
||||
ExecutionResult result = new MapExecutionResult(Collections.singletonMap("errors", payload));
|
||||
ExecutionResult result = MapExecutionResult.forErrorsOnly(payload);
|
||||
emitResult = sink.tryEmitValue(result);
|
||||
}
|
||||
else {
|
||||
List<GraphQLError> graphQLErrors = MapGraphQlError.fromMapList(payload);
|
||||
List<GraphQLError> graphQLErrors = MapGraphQlError.from(payload);
|
||||
Exception ex = new SubscriptionErrorException(graphQLErrors);
|
||||
emitResult = streamingSink.tryEmitError(ex);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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.support;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Base class for {@link DocumentSource} implementations providing support for
|
||||
* caching loaded documents.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public class CachingDocumentSource implements DocumentSource {
|
||||
|
||||
private final DocumentSource delegate;
|
||||
|
||||
private boolean cacheEnabled = true;
|
||||
|
||||
private final Map<String, Mono<String>> documentCache = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
/**
|
||||
* Constructor with the {@code DocumentSource} to actually load documents.
|
||||
*/
|
||||
public CachingDocumentSource(DocumentSource delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Enable or disable caching of resolved documents.
|
||||
* <p>By default, set to {@code true}.
|
||||
* @param cacheEnabled enable if {@code true} and disable if {@code false}
|
||||
*/
|
||||
public void setCacheEnabled(boolean cacheEnabled) {
|
||||
this.cacheEnabled = cacheEnabled;
|
||||
if (!cacheEnabled) {
|
||||
this.documentCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@link #setCacheEnabled(boolean) caching} is enabled.
|
||||
*/
|
||||
public boolean isCacheEnabled() {
|
||||
return cacheEnabled;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Mono<String> getDocument(String name) {
|
||||
return (isCacheEnabled() ?
|
||||
this.documentCache.computeIfAbsent(name, k -> this.delegate.getDocument(name).cache()) :
|
||||
this.delegate.getDocument(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all entries from the document cache.
|
||||
*/
|
||||
public void clearCache() {
|
||||
this.documentCache.clear();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,12 +13,12 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.graphql.client;
|
||||
package org.springframework.graphql.support;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Strategy to locate a GraphQL document identified by name.
|
||||
* Strategy to locate a GraphQL document by a name.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 1.0.0
|
||||
@@ -28,9 +28,8 @@ public interface DocumentSource {
|
||||
/**
|
||||
* Return the document that matches the given name.
|
||||
* @param name the name to use for the lookup
|
||||
* @return the document, or {@code null}
|
||||
* @return {@code Mono} that provides the document or returns an error
|
||||
*/
|
||||
@Nullable
|
||||
String getDocument(String name);
|
||||
Mono<String> getDocument(String name);
|
||||
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.graphql.client;
|
||||
package org.springframework.graphql.support;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -24,7 +24,7 @@ import graphql.ExecutionResult;
|
||||
import graphql.ExecutionResultImpl;
|
||||
import graphql.GraphQLError;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* Implementation of {@link ExecutionResult} backed by a {@link Map}.
|
||||
@@ -32,16 +32,18 @@ import org.springframework.lang.Nullable;
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 1.0.0
|
||||
*/
|
||||
final class MapExecutionResult implements ExecutionResult {
|
||||
public final class MapExecutionResult implements ExecutionResult {
|
||||
|
||||
private final Map<String, Object> map;
|
||||
private final Map<String, Object> resultMap;
|
||||
|
||||
private final List<GraphQLError> errors;
|
||||
|
||||
|
||||
MapExecutionResult(@Nullable Map<String, Object> map) {
|
||||
this.map = (map != null ? map : Collections.emptyMap());
|
||||
this.errors = MapGraphQlError.fromResultMap(map);
|
||||
@SuppressWarnings("unchecked")
|
||||
private MapExecutionResult(Map<String, Object> resultMap) {
|
||||
Assert.notNull(resultMap, "'resultMap' is required");
|
||||
this.resultMap = resultMap;
|
||||
this.errors = MapGraphQlError.from((List<Map<String, Object>>) resultMap.get("errors"));
|
||||
}
|
||||
|
||||
|
||||
@@ -53,18 +55,18 @@ final class MapExecutionResult implements ExecutionResult {
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public <T> T getData() {
|
||||
return (T) this.map.get("data");
|
||||
return (T) this.resultMap.get("data");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isDataPresent() {
|
||||
return (this.map.get("data") != null);
|
||||
return (this.resultMap.get("data") != null);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public Map<Object, Object> getExtensions() {
|
||||
return (Map<Object, Object>) this.map.get("extensions");
|
||||
return (Map<Object, Object>) this.resultMap.get("extensions");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -74,25 +76,43 @@ final class MapExecutionResult implements ExecutionResult {
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return (other instanceof MapExecutionResult && this.map.equals(((MapExecutionResult) other).map));
|
||||
return (other instanceof MapExecutionResult &&
|
||||
this.resultMap.equals(((MapExecutionResult) other).resultMap));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return this.map.hashCode();
|
||||
return this.resultMap.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return this.map.toString();
|
||||
return this.resultMap.toString();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Static factory method to create an instance of this class.
|
||||
* Create an instance from an {@code ExecutionResult} serialized to map via
|
||||
* {@link ExecutionResult#toSpecification()}.
|
||||
*/
|
||||
public static ExecutionResult forData(@Nullable Map<String, Object> map) {
|
||||
public static ExecutionResult from(Map<String, Object> map) {
|
||||
return new MapExecutionResult(map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an {@code ExecutionResult} with a "data" key that returns the
|
||||
* given map.
|
||||
*/
|
||||
public static ExecutionResult forDataOnly(Map<String, Object> map) {
|
||||
return new MapExecutionResult(Collections.singletonMap("data", map));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an {@code ExecutionResult} with an "errors" key that returns the
|
||||
* given serialized errors.
|
||||
*/
|
||||
public static ExecutionResult forErrorsOnly(List<Map<String, Object>> errors) {
|
||||
return new MapExecutionResult(Collections.singletonMap("errors", errors));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.graphql.client;
|
||||
package org.springframework.graphql.support;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -28,6 +28,7 @@ import graphql.language.SourceLocation;
|
||||
|
||||
import org.springframework.graphql.execution.ErrorType;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
/**
|
||||
@@ -37,7 +38,7 @@ import org.springframework.util.CollectionUtils;
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@SuppressWarnings("serial")
|
||||
final class MapGraphQlError implements GraphQLError {
|
||||
public final class MapGraphQlError implements GraphQLError {
|
||||
|
||||
private final Map<String, Object> errorMap;
|
||||
|
||||
@@ -45,6 +46,7 @@ final class MapGraphQlError implements GraphQLError {
|
||||
|
||||
|
||||
private MapGraphQlError(Map<String, Object> errorMap) {
|
||||
Assert.notNull(errorMap, "'errorMap' is required");
|
||||
this.errorMap = errorMap;
|
||||
this.locations = initLocations(errorMap);
|
||||
}
|
||||
@@ -64,8 +66,6 @@ final class MapGraphQlError implements GraphQLError {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public String getMessage() {
|
||||
@@ -119,6 +119,7 @@ final class MapGraphQlError implements GraphQLError {
|
||||
return GraphqlErrorHelper.toSpecification(this);
|
||||
}
|
||||
|
||||
@SuppressWarnings("EqualsWhichDoesntCheckParameterClass")
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return GraphqlErrorHelper.equals(this, other);
|
||||
@@ -136,26 +137,12 @@ final class MapGraphQlError implements GraphQLError {
|
||||
|
||||
|
||||
/**
|
||||
* Static factory method to create an instance from a list of maps, each
|
||||
* containing an error.
|
||||
* Create a list of {@code GraphQlError} instances from the given
|
||||
* deserialized content.
|
||||
*/
|
||||
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"));
|
||||
public static List<GraphQLError> from(@Nullable List<Map<String, Object>> errors) {
|
||||
errors = (errors != null ? errors : Collections.emptyList());
|
||||
return errors.stream().map(MapGraphQlError::new).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.support;
|
||||
|
||||
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 java.util.stream.Collectors;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.util.FileCopyUtils;
|
||||
|
||||
/**
|
||||
* {@link DocumentSource} that looks for a document {@link Resource} under a set
|
||||
* of locations and trying a number of different file extension.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public class ResourceDocumentSource implements DocumentSource {
|
||||
|
||||
/**
|
||||
* The default file extensions, ".graphql" and ".gql".
|
||||
*/
|
||||
public static final List<String> FILE_EXTENSIONS = Arrays.asList(".graphql", ".gql");
|
||||
|
||||
|
||||
private final List<Resource> locations;
|
||||
|
||||
private final List<String> extensions;
|
||||
|
||||
|
||||
/**
|
||||
* Default constructor that sets the location to {@code "classpath:graphql/"}
|
||||
* and the extensions to ".graphql" and ".gql".
|
||||
*/
|
||||
public ResourceDocumentSource() {
|
||||
this(Collections.singletonList(new ClassPathResource("graphql/")), FILE_EXTENSIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor with given locations and extensions.
|
||||
*/
|
||||
public ResourceDocumentSource(List<Resource> locations, List<String> extensions) {
|
||||
this.locations = new ArrayList<>(locations);
|
||||
this.extensions = new ArrayList<>(extensions);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return the configured locations where to check for documents.
|
||||
*/
|
||||
public List<Resource> getLocations() {
|
||||
return this.locations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the file extensions to try when checking for documents by name.
|
||||
*/
|
||||
public List<String> getExtensions() {
|
||||
return this.extensions;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Mono<String> getDocument(String name) {
|
||||
return Flux.fromIterable(this.locations)
|
||||
.flatMapIterable(location -> getCandidateResources(name, location))
|
||||
.filter(Resource::exists)
|
||||
.next()
|
||||
.map(this::resourceToString)
|
||||
.switchIfEmpty(Mono.fromRunnable(() -> {
|
||||
throw new IllegalStateException(
|
||||
"Failed to find document, name='" + name + "', under location(s)=" +
|
||||
this.locations.stream().map(Resource::toString).collect(Collectors.toList()));
|
||||
}))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
private List<Resource> getCandidateResources(String name, Resource location) {
|
||||
return this.extensions.stream()
|
||||
.map(ext -> {
|
||||
try {
|
||||
return location.createRelative(name + ext);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private String resourceToString(Resource 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2020-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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Support classes for Spring GraphQL.
|
||||
*/
|
||||
@NonNullApi
|
||||
@NonNullFields
|
||||
package org.springframework.graphql.support;
|
||||
|
||||
import org.springframework.lang.NonNullApi;
|
||||
import org.springframework.lang.NonNullFields;
|
||||
@@ -34,6 +34,7 @@ import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import org.springframework.graphql.GraphQlRequest;
|
||||
import org.springframework.graphql.support.MapExecutionResult;
|
||||
import org.springframework.graphql.web.webflux.GraphQlWebSocketMessage;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.web.reactive.socket.CloseStatus;
|
||||
@@ -64,9 +65,9 @@ public class MockWebSocketGraphQlTransportTests {
|
||||
private final WebSocketGraphQlTransport transport =
|
||||
WebSocketGraphQlTransport.builder(URI.create("/"), this.testClient).build();
|
||||
|
||||
private final ExecutionResult result1 = MapExecutionResult.forData(Collections.singletonMap("key1", "value1"));
|
||||
private final ExecutionResult result1 = MapExecutionResult.forDataOnly(Collections.singletonMap("key1", "value1"));
|
||||
|
||||
private final ExecutionResult result2 = MapExecutionResult.forData(Collections.singletonMap("key2", "value2"));
|
||||
private final ExecutionResult result2 = MapExecutionResult.forDataOnly(Collections.singletonMap("key2", "value2"));
|
||||
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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.support;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ResourceDocumentSource}.
|
||||
* @author Rossen Stoyanchev
|
||||
*/
|
||||
public class CachingDocumentSourceTests {
|
||||
|
||||
private CachingDocumentSource source;
|
||||
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
DocumentSource resourceSource = new ResourceDocumentSource(
|
||||
Collections.singletonList(new ClassPathResource("books/")),
|
||||
ResourceDocumentSource.FILE_EXTENSIONS);
|
||||
|
||||
this.source = new CachingDocumentSource(resourceSource);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void cachingDefault() {
|
||||
assertThat(this.source.isCacheEnabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cachingOn() {
|
||||
this.source.setCacheEnabled(true);
|
||||
|
||||
Mono<String> documentMono1 = source.getDocument("book-document");
|
||||
Mono<String> documentMono2 = source.getDocument("book-document");
|
||||
|
||||
assertThat(documentMono1).isSameAs(documentMono2);
|
||||
|
||||
String document1 = documentMono1.block();
|
||||
String document2 = documentMono2.block();
|
||||
|
||||
assertThat(document1).isSameAs(document2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cachingOff() {
|
||||
this.source.setCacheEnabled(false);
|
||||
|
||||
Mono<String> documentMono1 = source.getDocument("book-document");
|
||||
Mono<String> documentMono2 = source.getDocument("book-document");
|
||||
|
||||
assertThat(documentMono1).isNotSameAs(documentMono2);
|
||||
|
||||
String document1 = documentMono1.block();
|
||||
String document2 = documentMono2.block();
|
||||
|
||||
assertThat(document1).isNotSameAs(document2);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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.support;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ResourceDocumentSource}.
|
||||
* @author Rossen Stoyanchev
|
||||
*/
|
||||
public class ResourceDocumentSourceTests {
|
||||
|
||||
private ResourceDocumentSource source;
|
||||
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
this.source = new ResourceDocumentSource(
|
||||
Collections.singletonList(new ClassPathResource("books/")),
|
||||
ResourceDocumentSource.FILE_EXTENSIONS);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void getDocument() {
|
||||
String content = this.source.getDocument("book-document").block(Duration.ofSeconds(5));
|
||||
assertThat(content).startsWith("bookById(id:\"1\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDocumentNotFound() {
|
||||
StepVerifier.create(this.source.getDocument("invalid"))
|
||||
.expectErrorMessage(
|
||||
"Failed to find document, name='invalid', " +
|
||||
"under location(s)=[class path resource [books/]]")
|
||||
.verify(Duration.ofSeconds(5));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
bookById(id:"1") {
|
||||
id
|
||||
name
|
||||
}
|
||||
Reference in New Issue
Block a user