Add LocalResponseCache filter (#2759)

* Add LocalResponseCache filter
This commit is contained in:
Ignacio Lozano
2022-10-25 22:46:44 +02:00
committed by GitHub
parent 3e83068cc2
commit 8a0caeea41
38 changed files with 2700 additions and 6 deletions

View File

@@ -21,6 +21,7 @@
|spring.cloud.gateway.filter.json-to-grpc.enabled | `+++true+++` | Enables the JSON to gRPC filter.
|spring.cloud.gateway.filter.map-request-header.enabled | `+++true+++` | Enables the map-request-header filter.
|spring.cloud.gateway.filter.modify-request-body.enabled | `+++true+++` | Enables the modify-request-body filter.
|spring.cloud.gateway.filter.local-response-cache.enabled | `+++true+++` | Enables the local-response-cache filter.
|spring.cloud.gateway.filter.modify-response-body.enabled | `+++true+++` | Enables the modify-response-body filter.
|spring.cloud.gateway.filter.prefix-path.enabled | `+++true+++` | Enables the prefix-path filter.
|spring.cloud.gateway.filter.preserve-host-header.enabled | `+++true+++` | Enables the preserve-host-header filter.

View File

@@ -1813,6 +1813,56 @@ NOTE: if the request has no body, the `RewriteFilter` will be passed `null`. `M
====
=== Local Response Cache `GatewayFilter` Factory
This filter allows to cache response body and headers to follow the next rules:
* It can only cache bodyless GET requests
* It only caches the response as long has one of the following status codes: HTTP 200 (OK), HTTP 206 (Partial Content) and HTTP 301 (Moved Permanently).
Response data will not be cached if `Cache-Control` header doesn't allow it (`no-store` present in the request, `no-store` or `private` present in the response).
* If the response is already cached and a new request is performed with no-cache value in `Cache-Control` header, it will return a bodyless response with 304 (Not Modified).
Take into account that this filter to configure local response cache per route only will be available if the local response global cache is enabled.
It accepts the first parameter to override the maximum size of the cache to evict entries for this route, it takes size format in KB, MB and GB; and a second parameter to override the time to expire a cache entry expressed in s for seconds, m for minutes and h for hours.
The following listing shows how to add local response cache `GatewayFilter`:
====
[source,java]
----
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route("rewrite_response_upper", r -> r.host("*.rewriteresponseupper.org")
.filters(f -> f.prefixPath("/httpbin")
.localResponseCache(Duration.ofMinutes(30), "500MB")
).uri(uri))
.build();
}
----
or this
.application.yaml
[source,yaml]
----
spring:
cloud:
gateway:
routes:
- id: resource
uri: http://localhost:9000
predicates:
- Path=/resource
filters:
- LocalResponseCache=30m,500MB
----
NOTE: This filter also implements the automatic calculation of the max-age value in the HTTP Cache-Control header.
Only if "max-age" is present on the original response the value will be rewritten with the number of seconds set in the timeToLive configuration parameter; and in consecutive calls this value will be recalculated with the number of seconds left until the response expires.
====
=== Modify a Response Body `GatewayFilter` Factory
You can use the `ModifyResponseBody` filter to modify the response body before it is sent back to the client.

View File

@@ -126,6 +126,11 @@
<artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>context-propagation</artifactId>

View File

@@ -0,0 +1,100 @@
/*
* Copyright 2013-2020 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.cloud.gateway.config;
import java.time.Duration;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Weigher;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.cloud.gateway.config.conditional.ConditionalOnEnabledFilter;
import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheProperties;
import org.springframework.cloud.gateway.filter.factory.cache.ResponseCacheManagerFactory;
import org.springframework.cloud.gateway.filter.factory.cache.ResponseCacheSizeWeigher;
import org.springframework.cloud.gateway.filter.factory.cache.keygenerator.CacheKeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author Ignacio Lozano
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({ LocalResponseCacheProperties.class })
@ConditionalOnClass({ Weigher.class, Caffeine.class })
@ConditionalOnProperty(name = "spring.cloud.gateway.enabled", matchIfMissing = true)
@ConditionalOnEnabledFilter(LocalResponseCacheGatewayFilterFactory.class)
public class LocalResponseCacheAutoConfiguration {
private static final Log LOGGER = LogFactory.getLog(LocalResponseCacheAutoConfiguration.class);
private static final String RESPONSE_CACHE_NAME = "response-cache";
@Bean
public LocalResponseCacheGatewayFilterFactory localResponseCacheGatewayFilterFactory(
ResponseCacheManagerFactory responseCacheManagerFactory, CacheManager cacheManager,
LocalResponseCacheProperties properties) {
return new LocalResponseCacheGatewayFilterFactory(responseCacheManagerFactory, responseCache(cacheManager),
properties.getTimeToLive());
}
@Bean
public ResponseCacheManagerFactory responseCacheManagerFactory(CacheKeyGenerator cacheKeyGenerator) {
return new ResponseCacheManagerFactory(cacheKeyGenerator);
}
@Bean
public CacheKeyGenerator cacheKeyGenerator() {
return new CacheKeyGenerator();
}
@Bean
public static CacheManager concurrentMapCacheManager(LocalResponseCacheProperties cacheProperties) {
CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager();
caffeineCacheManager.setCaffeine(caffeine(cacheProperties));
return caffeineCacheManager;
}
private static Caffeine caffeine(LocalResponseCacheProperties cacheProperties) {
Caffeine caffeine = Caffeine.newBuilder();
LOGGER.info("Initializing Caffeine");
Duration ttlSeconds = cacheProperties.getTimeToLive();
caffeine.expireAfterWrite(ttlSeconds);
if (cacheProperties.getSize() != null) {
caffeine.maximumWeight(cacheProperties.getSize().toBytes()).weigher(responseCacheSizeWeigher());
}
return caffeine;
}
private static ResponseCacheSizeWeigher responseCacheSizeWeigher() {
return new ResponseCacheSizeWeigher();
}
Cache responseCache(CacheManager cacheManager) {
return cacheManager.getCache(RESPONSE_CACHE_NAME);
}
}

View File

@@ -0,0 +1,164 @@
/*
* Copyright 2013-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.cloud.gateway.filter.factory.cache;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.zip.GZIPInputStream;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.util.FileCopyUtils;
/**
* @author Marta Medio
* @author Ignacio Lozano
*/
public final class CachedResponse implements Serializable {
private HttpStatusCode statusCode;
private HttpHeaders headers;
private List<ByteBuffer> body;
private Date timestamp;
private CachedResponse(HttpStatusCode statusCode, HttpHeaders headers, List<ByteBuffer> body, Date timestamp) {
this.statusCode = statusCode;
this.headers = headers;
this.body = body;
this.timestamp = timestamp;
}
@Serial
private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException {
statusCode = (HttpStatusCode) aInputStream.readObject();
headers = (HttpHeaders) aInputStream.readObject();
body = List.of(ByteBuffer.wrap(aInputStream.readAllBytes()).asReadOnlyBuffer());
timestamp = (Date) aInputStream.readObject();
}
@Serial
private void writeObject(ObjectOutputStream aOutputStream) throws IOException {
aOutputStream.writeObject(statusCode);
aOutputStream.writeObject(headers);
aOutputStream.write(this.bodyAsByteArray());
aOutputStream.writeObject(timestamp);
}
public static Builder create(HttpStatusCode statusCode) {
return new Builder(statusCode);
}
public HttpStatusCode statusCode() {
return this.statusCode;
}
public HttpHeaders headers() {
return this.headers;
}
public List<ByteBuffer> body() {
return Collections.unmodifiableList(body);
}
public Date timestamp() {
return this.timestamp;
}
byte[] bodyAsByteArray() throws IOException {
var bodyStream = new ByteArrayOutputStream();
var channel = Channels.newChannel(bodyStream);
for (ByteBuffer byteBuffer : body()) {
channel.write(byteBuffer);
}
return bodyStream.toByteArray();
}
String bodyAsString() throws IOException {
InputStream byteStream = new ByteArrayInputStream(bodyAsByteArray());
if (headers.getOrEmpty(HttpHeaders.CONTENT_ENCODING).contains("gzip")) {
byteStream = new GZIPInputStream(byteStream);
}
return new String(FileCopyUtils.copyToByteArray(byteStream));
}
public static class Builder {
private final HttpStatusCode statusCode;
private final HttpHeaders headers = new HttpHeaders();
private final List<ByteBuffer> body = new ArrayList<>();
private Instant timestamp;
public Builder(HttpStatusCode statusCode) {
this.statusCode = statusCode;
}
public Builder header(String name, String value) {
this.headers.add(name, value);
return this;
}
public Builder headers(HttpHeaders headers) {
this.headers.addAll(headers);
return this;
}
public Builder timestamp(Instant timestamp) {
this.timestamp = timestamp;
return this;
}
public Builder timestamp(Date timestamp) {
this.timestamp = timestamp.toInstant();
return this;
}
public Builder body(String data) {
return appendToBody(ByteBuffer.wrap(data.getBytes(StandardCharsets.UTF_8)));
}
public Builder appendToBody(ByteBuffer byteBuffer) {
this.body.add(byteBuffer);
return this;
}
public CachedResponse build() {
return new CachedResponse(statusCode, headers, body, timestamp == null ? new Date() : Date.from(timestamp));
}
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2013-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.cloud.gateway.filter.factory.cache;
import java.io.Serializable;
import java.util.List;
/**
* @author Marta Medio
* @author Ignacio Lozano
*/
public record CachedResponseMetadata(List<String> varyOnHeaders) implements Serializable {
}

View File

@@ -0,0 +1,129 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import org.springframework.cache.Cache;
import org.springframework.cloud.gateway.config.LocalResponseCacheAutoConfiguration;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.cloud.gateway.support.HasRouteId;
import org.springframework.util.unit.DataSize;
import org.springframework.validation.annotation.Validated;
/**
* {@link org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory} of
* {@link ResponseCacheGatewayFilter}.
*
* By default, a global cache (defined as properties in the application) is used. For
* specific route configuration, parameters can be added following
* {@link RouteCacheConfiguration} class.
*
* @author Marta Medio
* @author Ignacio Lozano
*/
public class LocalResponseCacheGatewayFilterFactory
extends AbstractGatewayFilterFactory<LocalResponseCacheGatewayFilterFactory.RouteCacheConfiguration> {
private final Cache globalCache;
ResponseCacheManagerFactory cacheManagerFactory;
Duration configuredTimeToLive;
public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, Cache globalCache,
Duration configuredTimeToLive) {
super(RouteCacheConfiguration.class);
this.cacheManagerFactory = cacheManagerFactory;
this.globalCache = globalCache;
this.configuredTimeToLive = configuredTimeToLive;
}
@Override
public GatewayFilter apply(RouteCacheConfiguration config) {
LocalResponseCacheProperties cacheProperties = mapRouteCacheConfig(config);
if (shouldUseGlobalCacheConfiguration(config)) {
return new ResponseCacheGatewayFilter(cacheManagerFactory.create(globalCache, configuredTimeToLive));
}
else {
Cache routeCache = LocalResponseCacheAutoConfiguration.concurrentMapCacheManager(cacheProperties)
.getCache(config.getRouteId() + "-cache");
return new ResponseCacheGatewayFilter(
cacheManagerFactory.create(routeCache, cacheProperties.getTimeToLive()));
}
}
private boolean shouldUseGlobalCacheConfiguration(RouteCacheConfiguration config) {
return Objects.isNull(config.getTimeToLive()) && Objects.isNull(config.getSize());
}
private LocalResponseCacheProperties mapRouteCacheConfig(RouteCacheConfiguration config) {
LocalResponseCacheProperties responseCacheProperties = new LocalResponseCacheProperties();
responseCacheProperties.setSize(config.getSize());
responseCacheProperties.setTimeToLive(config.getTimeToLive());
return responseCacheProperties;
}
@Override
public List<String> shortcutFieldOrder() {
return List.of("timeToLive", "size");
}
@Validated
public static class RouteCacheConfiguration implements HasRouteId {
private DataSize size;
private Duration timeToLive;
private String routeId;
public DataSize getSize() {
return size;
}
public RouteCacheConfiguration setSize(DataSize size) {
this.size = size;
return this;
}
public Duration getTimeToLive() {
return timeToLive;
}
public RouteCacheConfiguration setTimeToLive(Duration timeToLive) {
this.timeToLive = timeToLive;
return this;
}
@Override
public void setRouteId(String routeId) {
this.routeId = routeId;
}
@Override
public String getRouteId() {
return this.routeId;
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright 2013-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.cloud.gateway.filter.factory.cache;
import java.time.Duration;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.unit.DataSize;
/**
* @author Ignacio Lozano
*/
@ConfigurationProperties(prefix = LocalResponseCacheProperties.PREFIX)
public class LocalResponseCacheProperties {
static final String PREFIX = "spring.cloud.gateway.filter.local-response-cache";
private static final Log LOGGER = LogFactory.getLog(LocalResponseCacheProperties.class);
private static final Duration DEFAULT_CACHE_TTL_SECONDS = Duration.ofMinutes(5);
private DataSize size;
private Duration timeToLive;
public DataSize getSize() {
return size;
}
public void setSize(DataSize size) {
this.size = size;
}
public Duration getTimeToLive() {
if (timeToLive == null) {
LOGGER.debug(String.format(
"No TTL configuration found. Default TTL will be applied for cache entries: %s seconds",
DEFAULT_CACHE_TTL_SECONDS));
return DEFAULT_CACHE_TTL_SECONDS;
}
else {
return timeToLive;
}
}
public void setTimeToLive(Duration timeToLive) {
this.timeToLive = timeToLive;
}
@Override
public String toString() {
return "LocalResponseCacheProperties{" + "size='" + getSize() + '\'' + ", timeToLive=" + getTimeToLive() + '}';
}
}

View File

@@ -0,0 +1,107 @@
/*
* Copyright 2013-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.cloud.gateway.filter.factory.cache;
import java.util.Optional;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.web.server.ServerWebExchange;
/**
* {@literal LocalResponseCache} Gateway Filter that stores HTTP Responses in a cache, so
* latency and upstream overhead is reduced.
*
* @author Marta Medio
* @author Ignacio Lozano
*/
public class ResponseCacheGatewayFilter implements GatewayFilter, Ordered {
private final ResponseCacheManager responseCacheManager;
public ResponseCacheGatewayFilter(ResponseCacheManager responseCacheManager) {
this.responseCacheManager = responseCacheManager;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (responseCacheManager.isRequestCacheable(exchange.getRequest())) {
return filterWithCache(exchange, chain);
}
else {
return chain.filter(exchange);
}
}
@Override
public int getOrder() {
return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1;
}
private Mono<Void> filterWithCache(ServerWebExchange exchange, GatewayFilterChain chain) {
final String metadataKey = responseCacheManager.resolveMetadataKey(exchange);
Optional<CachedResponse> cached = responseCacheManager.getFromCache(exchange.getRequest(), metadataKey);
if (cached.isPresent()) {
return responseCacheManager.processFromCache(exchange, metadataKey, cached.get());
}
else {
return chain
.filter(exchange.mutate().response(new CachingResponseDecorator(metadataKey, exchange)).build());
}
}
private class CachingResponseDecorator extends ServerHttpResponseDecorator {
private final String metadataKey;
private final ServerWebExchange exchange;
CachingResponseDecorator(String metadataKey, ServerWebExchange exchange) {
super(exchange.getResponse());
this.metadataKey = metadataKey;
this.exchange = exchange;
}
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
final ServerHttpResponse response = exchange.getResponse();
Flux<DataBuffer> decoratedBody;
if (responseCacheManager.isResponseCacheable(response)) {
decoratedBody = responseCacheManager.processFromUpstream(metadataKey, exchange,
(Flux<DataBuffer>) body);
}
else {
decoratedBody = (Flux<DataBuffer>) body;
}
return super.writeWith(decoratedBody);
}
}
}

View File

@@ -0,0 +1,206 @@
/*
* Copyright 2013-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.cloud.gateway.filter.factory.cache;
import java.nio.ByteBuffer;
import java.time.Clock;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.cache.Cache;
import org.springframework.cloud.gateway.filter.factory.cache.keygenerator.CacheKeyGenerator;
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.AfterCacheExchangeMutator;
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetMaxAgeHeaderAfterCacheExchangeMutator;
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetResponseHeadersAfterCacheExchangeMutator;
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetStatusCodeAfterCacheExchangeMutator;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMessage;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.server.ServerWebExchange;
/**
* @author Marta Medio
* @author Ignacio Lozano
*/
public class ResponseCacheManager {
private static final Log LOGGER = LogFactory.getLog(ResponseCacheManager.class);
private static final List<String> forbiddenCacheControlValues = Arrays.asList("private", "no-store");
private static final String VARY_WILDCARD = "*";
final CacheKeyGenerator cacheKeyGenerator;
final List<AfterCacheExchangeMutator> afterCacheExchangeMutators;
private final Cache cache;
public ResponseCacheManager(CacheKeyGenerator cacheKeyGenerator, Cache cache, Duration configuredTimeToLive) {
this.cacheKeyGenerator = cacheKeyGenerator;
this.cache = cache;
this.afterCacheExchangeMutators = List.of(new SetResponseHeadersAfterCacheExchangeMutator(),
new SetStatusCodeAfterCacheExchangeMutator(),
new SetMaxAgeHeaderAfterCacheExchangeMutator(configuredTimeToLive, Clock.systemDefaultZone()));
}
private static final List<HttpStatus> statusesToCache = Arrays.asList(HttpStatus.OK, HttpStatus.PARTIAL_CONTENT,
HttpStatus.MOVED_PERMANENTLY);
public Optional<CachedResponse> getFromCache(ServerHttpRequest request, String metadataKey) {
CachedResponseMetadata metadata = retrieveMetadata(metadataKey);
String key = cacheKeyGenerator.generateKey(request,
metadata != null ? metadata.varyOnHeaders() : Collections.emptyList());
return getFromCache(key);
}
public Flux<DataBuffer> processFromUpstream(String metadataKey, ServerWebExchange exchange, Flux<DataBuffer> body) {
final ServerHttpResponse response = exchange.getResponse();
final CachedResponseMetadata metadata = new CachedResponseMetadata(response.getHeaders().getVary());
final String key = resolveKey(exchange, metadata.varyOnHeaders());
CachedResponse.Builder cachedResponseBuilder = CachedResponse.create(response.getStatusCode())
.headers(response.getHeaders());
CachedResponse toProcess = cachedResponseBuilder.build();
afterCacheExchangeMutators.forEach(processor -> processor.accept(exchange, toProcess));
// Note: `map` instead of `doOnNext
// `doOnNext` is only for side-effect operations (like logging or emitting other
// events). Order is not guaranteed. In some cases, the signal is not in order and
// the object will be corrupted in cache
return body.map(dataBuffer -> {
ByteBuffer byteBuffer = dataBuffer.toByteBuffer().asReadOnlyBuffer();
cachedResponseBuilder.appendToBody(byteBuffer);
return response.bufferFactory().wrap(byteBuffer);
}).doOnComplete(() -> {
CachedResponse responseToCache = cachedResponseBuilder.timestamp(toProcess.timestamp()).build();
saveMetadataInCache(metadataKey, metadata);
saveInCache(key, responseToCache);
});
}
private Optional<CachedResponse> getFromCache(String key) {
CachedResponse cachedResponse;
try {
cachedResponse = cache.get(key, CachedResponse.class);
}
catch (RuntimeException anyException) {
LOGGER.error("Error reading from cache. Data will not come from cache.", anyException);
cachedResponse = null;
}
return Optional.ofNullable(cachedResponse);
}
public String resolveMetadataKey(ServerWebExchange exchange) {
return cacheKeyGenerator.generateMetadataKey(exchange.getRequest());
}
public String resolveKey(ServerWebExchange exchange, List<String> varyOnHeaders) {
return cacheKeyGenerator.generateKey(exchange.getRequest(), varyOnHeaders);
}
Mono<Void> processFromCache(ServerWebExchange exchange, String metadataKey, CachedResponse cachedResponse) {
final ServerHttpResponse response = exchange.getResponse();
afterCacheExchangeMutators.forEach(processor -> processor.accept(exchange, cachedResponse));
saveMetadataInCache(metadataKey, new CachedResponseMetadata(cachedResponse.headers().getVary()));
if (HttpStatus.NOT_MODIFIED.equals(response.getStatusCode())) {
return response.writeWith(Mono.empty());
}
else {
return response.writeWith(
Flux.fromIterable(cachedResponse.body()).map(data -> response.bufferFactory().wrap(data)));
}
}
private CachedResponseMetadata retrieveMetadata(String metadataKey) {
CachedResponseMetadata metadata;
try {
metadata = cache.get(metadataKey, CachedResponseMetadata.class);
}
catch (RuntimeException anyException) {
LOGGER.error("Error reading from cache. Metadata Data will not come from cache.", anyException);
metadata = null;
}
return metadata;
}
boolean isResponseCacheable(ServerHttpResponse response) {
return isStatusCodeToCache(response) && isCacheControlAllowed(response) && !isVaryWildcard(response);
}
private boolean isStatusCodeToCache(ServerHttpResponse response) {
return Optional.ofNullable(response.getStatusCode()).map(HttpStatusCode::value).map(HttpStatus::resolve)
.map(statusesToCache::contains).orElse(Boolean.FALSE);
}
boolean isRequestCacheable(ServerHttpRequest request) {
return HttpMethod.GET.equals(request.getMethod()) && !hasRequestBody(request) && isCacheControlAllowed(request);
}
private boolean isVaryWildcard(ServerHttpResponse response) {
HttpHeaders headers = response.getHeaders();
List<String> varyValues = Optional.ofNullable(headers.get(HttpHeaders.VARY)).orElse(Collections.emptyList());
return varyValues.stream().anyMatch(VARY_WILDCARD::equals);
}
private boolean isCacheControlAllowed(HttpMessage request) {
HttpHeaders headers = request.getHeaders();
List<String> cacheControlHeader = Optional.ofNullable(headers.get(HttpHeaders.CACHE_CONTROL))
.orElse(Collections.emptyList());
return cacheControlHeader.stream().noneMatch(forbiddenCacheControlValues::contains);
}
private static boolean hasRequestBody(ServerHttpRequest request) {
return request.getHeaders().getContentLength() > 0;
}
private void saveInCache(String cacheKey, CachedResponse cachedResponse) {
try {
cache.put(cacheKey, cachedResponse);
}
catch (RuntimeException anyException) {
LOGGER.error("Error writing into cache. Data will not be cached", anyException);
}
}
private void saveMetadataInCache(String metadataKey, CachedResponseMetadata metadata) {
try {
cache.put(metadataKey, metadata);
}
catch (RuntimeException anyException) {
LOGGER.error("Error writing into cache. Data will not be cached", anyException);
}
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache;
import java.time.Duration;
import org.springframework.cache.Cache;
import org.springframework.cloud.gateway.filter.factory.cache.keygenerator.CacheKeyGenerator;
/**
* @author Marta Medio
* @author Ignacio Lozano
*/
public class ResponseCacheManagerFactory {
private final CacheKeyGenerator cacheKeyGenerator;
public ResponseCacheManagerFactory(CacheKeyGenerator cacheKeyGenerator) {
this.cacheKeyGenerator = cacheKeyGenerator;
}
public ResponseCacheManager create(Cache cache, Duration timeToLive) {
return new ResponseCacheManager(cacheKeyGenerator, cache, timeToLive);
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2013-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.cloud.gateway.filter.factory.cache;
import java.nio.Buffer;
import java.util.List;
import java.util.stream.Stream;
import com.github.benmanes.caffeine.cache.Weigher;
/**
* @author Marta Medio
* @author Ignacio Lozano
*/
public class ResponseCacheSizeWeigher implements Weigher<String, Object> {
@Override
public int weigh(String key, Object value) {
if (value instanceof CachedResponse cached) {
return cached.headers().getContentLength() > -1 ? (int) cached.headers().getContentLength()
: estimateContentLength(cached);
}
else {
return 0;
}
}
private int estimateContentLength(CachedResponse value) {
return Stream.ofNullable(value.body()).flatMap(List::stream).mapToInt(Buffer::limit).sum();
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2013-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.cloud.gateway.filter.factory.cache.keygenerator;
import java.io.ByteArrayOutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.http.server.reactive.ServerHttpRequest;
/**
* @author Marta Medio
* @author Ignacio Lozano
*/
public class CacheKeyGenerator {
private static final byte[] KEY_SEPARATOR_BYTES = ";".getBytes();
private final MessageDigest messageDigest;
private static final CommonKeyValueGenerator COMMON_KEY_VALUE_GENERATOR = new CommonKeyValueGenerator();
public CacheKeyGenerator() {
try {
messageDigest = MessageDigest.getInstance("MD5");
}
catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Error creating CacheKeyGenerator", e);
}
}
public String generateMetadataKey(ServerHttpRequest request, String... varyHeaders) {
return "META_" + generateKey(request, varyHeaders);
}
public String generateKey(ServerHttpRequest request, String... varyHeaders) {
return generateKey(request, varyHeaders != null ? Arrays.asList(varyHeaders) : Collections.emptyList());
}
public String generateKey(ServerHttpRequest request, List<String> varyHeaders) {
byte[] rawKey = generateRawKey(request, varyHeaders);
byte[] digest = messageDigest.digest(rawKey);
return Base64.getEncoder().encodeToString(digest);
}
private Stream<KeyValueGenerator> getKeyValueGenerators(List<String> varyHeaders) {
return Stream.concat(Stream.of(COMMON_KEY_VALUE_GENERATOR),
varyHeaders.stream().sorted().map(header -> new HeaderKeyValueGenerator(header, ",")));
}
private byte[] generateRawKey(ServerHttpRequest request, List<String> varyHeaders) {
Stream<KeyValueGenerator> keyValueGenerators = getKeyValueGenerators(varyHeaders);
final ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream();
keyValueGenerators.map(generator -> generator.apply(request)).map(String::getBytes).forEach(bytes -> {
byteOutputStream.writeBytes(bytes);
byteOutputStream.writeBytes(KEY_SEPARATOR_BYTES);
});
return byteOutputStream.toByteArray();
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2013-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.cloud.gateway.filter.factory.cache.keygenerator;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
/**
* @author Marta Medio
*/
public class CommonKeyValueGenerator implements KeyValueGenerator {
private static final String JOINING_DELIMITER = ";";
private final List<KeyValueGenerator> keyValueGenerators;
public CommonKeyValueGenerator() {
keyValueGenerators = List.of(new UriKeyValueGenerator(),
new HeaderKeyValueGenerator(HttpHeaders.AUTHORIZATION, JOINING_DELIMITER),
new CookiesKeyValueGenerator(JOINING_DELIMITER));
}
@Override
public String apply(ServerHttpRequest request) {
return keyValueGenerators.stream().map(generator -> generator.apply(request))
.collect(Collectors.joining(JOINING_DELIMITER));
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache.keygenerator;
import java.util.Collection;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.http.HttpCookie;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.util.CollectionUtils;
import org.springframework.util.MultiValueMap;
/**
* @author Marta Medio
* @author Ignacio Lozano
*/
class CookiesKeyValueGenerator implements KeyValueGenerator {
private final String valueSeparator;
CookiesKeyValueGenerator(String valueSeparator) {
this.valueSeparator = Objects.requireNonNull(valueSeparator);
}
@Override
public String apply(ServerHttpRequest request) {
return calculateCookiesData(request);
}
private String calculateCookiesData(ServerHttpRequest request) {
String cookiesData = "";
MultiValueMap<String, HttpCookie> cookies = request.getCookies();
if (!CollectionUtils.isEmpty(cookies)) {
cookiesData = cookies.values().stream().flatMap(Collection::stream)
.map(c -> String.format("%s=%s", c.getName(), c.getValue())).sorted()
.collect(Collectors.joining(valueSeparator));
}
return cookiesData;
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache.keygenerator;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.util.StringUtils;
/**
* @author Marta Medio
* @author Ignacio Lozano
*/
class HeaderKeyValueGenerator implements KeyValueGenerator {
private final String header;
private final String valueSeparator;
HeaderKeyValueGenerator(String header, String valueSeparator) {
this.valueSeparator = valueSeparator;
if (!StringUtils.hasText(header)) {
throw new IllegalArgumentException("The parameter cannot be empty or null");
}
this.header = header;
}
@Override
public String apply(ServerHttpRequest request) {
HttpHeaders headers = request.getHeaders();
if (headers.get(header) != null) {
StringBuilder keyVaryHeaders = new StringBuilder();
keyVaryHeaders.append(header).append("=")
.append(getHeaderValues(headers).sorted().collect(Collectors.joining(valueSeparator)));
return keyVaryHeaders.toString();
}
return "";
}
private Stream<String> getHeaderValues(HttpHeaders headers) {
List<String> value = headers.get(header);
return value == null ? Stream.empty() : value.stream();
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache.keygenerator;
import java.util.function.Function;
import org.springframework.http.server.reactive.ServerHttpRequest;
/**
* Function to calculate a key value based on a {@link ServerHttpRequest}.
*
* @author Marta Medio
* @author Ignacio Lozano
*/
interface KeyValueGenerator extends Function<ServerHttpRequest, String> {
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2013-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.cloud.gateway.filter.factory.cache.keygenerator;
import org.springframework.http.server.reactive.ServerHttpRequest;
/**
* It generates key value based on the URI.
*
* @author Marta Medio
* @author Ignacio Lozano
*/
public class UriKeyValueGenerator implements KeyValueGenerator {
@Override
public String apply(ServerHttpRequest request) {
return request.getURI().toString();
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2013-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.cloud.gateway.filter.factory.cache.postprocessor;
import java.util.function.BiConsumer;
import org.springframework.cloud.gateway.filter.factory.cache.CachedResponse;
import org.springframework.web.server.ServerWebExchange;
/**
* Marking interface for a {@link BiConsumer} that could alter the
* {@link ServerWebExchange} .
*
* @author Marta Medio
* @author Ignacio Lozano
*/
public interface AfterCacheExchangeMutator extends BiConsumer<ServerWebExchange, CachedResponse> {
}

View File

@@ -0,0 +1,102 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache.postprocessor;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.cloud.gateway.filter.factory.cache.CachedResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.server.ServerWebExchange;
/**
* It sets the {@link HttpHeaders#CACHE_CONTROL} {@literal max-age} value. The value is
* calculated taking the {@link #configuredTimeToLive} cache configuration and the age of
* the entry.
*
* @author Marta Medio
* @author Ignacio Lozano
*/
public class SetMaxAgeHeaderAfterCacheExchangeMutator implements AfterCacheExchangeMutator {
private static final String MAX_AGE_PREFIX = "max-age=";
private final Duration configuredTimeToLive;
private final Clock clock;
public SetMaxAgeHeaderAfterCacheExchangeMutator(Duration configuredTimeToLive, Clock clock) {
this.configuredTimeToLive = configuredTimeToLive;
this.clock = clock;
}
@Override
public void accept(ServerWebExchange exchange, CachedResponse cachedResponse) {
ServerHttpResponse response = exchange.getResponse();
long calculatedMaxAgeInSeconds = calculateMaxAgeInSeconds(cachedResponse, configuredTimeToLive);
rewriteCacheControlMaxAge(response.getHeaders(), calculatedMaxAgeInSeconds);
}
private long calculateMaxAgeInSeconds(CachedResponse cachedResponse, Duration configuredTimeToLive) {
long maxAge;
if (configuredTimeToLive.getSeconds() == -1) {
maxAge = -1;
}
else {
maxAge = Math.max(0, configuredTimeToLive.minus(getElapsedTimeInSeconds(cachedResponse)).getSeconds());
}
return maxAge;
}
private Duration getElapsedTimeInSeconds(CachedResponse cachedResponse) {
return Duration.ofMillis(clock.millis() - cachedResponse.timestamp().getTime());
}
private static void rewriteCacheControlMaxAge(HttpHeaders headers, long seconds) {
boolean isMaxAgePresent = headers.getCacheControl() != null
&& headers.getCacheControl().contains(MAX_AGE_PREFIX);
if (isMaxAgePresent) {
List<String> cacheControlHeaders = headers.get(HttpHeaders.CACHE_CONTROL);
cacheControlHeaders = cacheControlHeaders == null ? Collections.emptyList() : cacheControlHeaders;
List<String> replacedCacheControlHeaders = new ArrayList<>();
for (String value : cacheControlHeaders) {
if (value.contains(MAX_AGE_PREFIX)) {
if (seconds == -1) {
List<String> removedMaxAgeList = Arrays.stream(value.split(","))
.filter(i -> !i.trim().startsWith(MAX_AGE_PREFIX)).collect(Collectors.toList());
value = String.join(",", removedMaxAgeList);
}
else {
value = value.replaceFirst("\\bmax-age=\\d+\\b", MAX_AGE_PREFIX + seconds);
}
}
replacedCacheControlHeaders.add(value);
}
headers.remove(HttpHeaders.CACHE_CONTROL);
headers.addAll(HttpHeaders.CACHE_CONTROL, replacedCacheControlHeaders);
}
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2013-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.cloud.gateway.filter.factory.cache.postprocessor;
import org.springframework.cloud.gateway.filter.factory.cache.CachedResponse;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.server.ServerWebExchange;
/**
* It sets HTTP Headers using {@link CachedResponse} values.
*
* @author Marta Medio
* @author Ignacio Lozano
*/
public class SetResponseHeadersAfterCacheExchangeMutator implements AfterCacheExchangeMutator {
@Override
public void accept(ServerWebExchange exchange, CachedResponse cachedResponse) {
ServerHttpResponse response = exchange.getResponse();
response.getHeaders().clear();
response.getHeaders().addAll(cachedResponse.headers());
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache.postprocessor;
import org.springframework.cloud.gateway.filter.factory.cache.CachedResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.util.CollectionUtils;
import org.springframework.web.server.ServerWebExchange;
/**
* It sets HTTP Status Code depending {@literal no-cache}
* {@link HttpHeaders#CACHE_CONTROL} header.
*
* @author Marta Medio
* @author Ignacio Lozano
*/
public class SetStatusCodeAfterCacheExchangeMutator implements AfterCacheExchangeMutator {
private static final String NO_CACHE_VALUE = "no-cache";
@Override
public void accept(ServerWebExchange exchange, CachedResponse cachedResponse) {
HttpHeaders requestHeaders = exchange.getRequest().getHeaders();
ServerHttpResponse response = exchange.getResponse();
if (!CollectionUtils.isEmpty(cachedResponse.body()) && isRequestNoCache(requestHeaders)) {
response.setStatusCode(HttpStatus.NOT_MODIFIED);
}
else {
response.setStatusCode(cachedResponse.statusCode());
}
}
private boolean isRequestNoCache(HttpHeaders requestHeaders) {
return requestHeaders.getCacheControl() != null && requestHeaders.getCacheControl().contains(NO_CACHE_VALUE);
}
}

View File

@@ -19,6 +19,7 @@ package org.springframework.cloud.gateway.route.builder;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
@@ -74,6 +75,7 @@ import org.springframework.cloud.gateway.filter.factory.SetStatusGatewayFilterFa
import org.springframework.cloud.gateway.filter.factory.SpringCloudCircuitBreakerFilterFactory;
import org.springframework.cloud.gateway.filter.factory.StripPrefixGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.TokenRelayGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.rewrite.ModifyRequestBodyGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.rewrite.ModifyResponseBodyGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.rewrite.RewriteFunction;
@@ -205,6 +207,22 @@ public class GatewayFilterSpec extends UriSpec {
.apply(c -> c.setName(headerName).setValue(headerValue)));
}
/**
* A filter that adds a local cache for storing response body for repeated requests.
* <p>
* If `timeToLive` and `size` are null, a global cache is used configured by the
* global configuration
* {@link org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheProperties}.
* @param timeToLive time an entry is kept in cache. Default: 5 minutes
* @param size size expression to limit cache size (See format in {@link DataSize}.
* Default: {@code null} (no limit)
* @return a {@link GatewayFilterSpec} that can be used to apply additional filters
*/
public GatewayFilterSpec localResponseCache(Duration timeToLive, DataSize size) {
return filter(getBean(LocalResponseCacheGatewayFilterFactory.class)
.apply(c -> c.setTimeToLive(timeToLive).setSize(size)));
}
/**
* A filter that removes duplication on a response header before it is returned to the
* client by the Gateway.

View File

@@ -36,6 +36,12 @@
"description": "Enables the modify-request-body filter.",
"defaultValue": "true"
},
{
"name": "spring.cloud.gateway.filter.local-response-cache.enabled",
"type": "java.lang.Boolean",
"description": "Enables the local-response-cache filter.",
"defaultValue": "true"
},
{
"name": "spring.cloud.gateway.filter.dedupe-response-header.enabled",
"type": "java.lang.Boolean",

View File

@@ -7,4 +7,5 @@ org.springframework.cloud.gateway.config.GatewayRedisAutoConfiguration
org.springframework.cloud.gateway.discovery.GatewayDiscoveryClientAutoConfiguration
org.springframework.cloud.gateway.config.SimpleUrlHandlerMappingGlobalCorsAutoConfiguration
org.springframework.cloud.gateway.config.GatewayReactiveLoadBalancerClientAutoConfiguration
org.springframework.cloud.gateway.config.GatewayReactiveOAuth2AutoConfiguration
org.springframework.cloud.gateway.config.GatewayReactiveOAuth2AutoConfiguration
org.springframework.cloud.gateway.config.LocalResponseCacheAutoConfiguration

View File

@@ -78,13 +78,14 @@ public class DisableBuiltInFiltersTests {
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Config.class,
properties = { "spring.cloud.gateway.filter.add-request-header.enabled=false",
properties = {"spring.cloud.gateway.filter.add-request-header.enabled=false",
"spring.cloud.gateway.filter.map-request-header.enabled=false",
"spring.cloud.gateway.filter.add-request-headers-if-not-present.enabled=false",
"spring.cloud.gateway.filter.add-request-parameter.enabled=false",
"spring.cloud.gateway.filter.add-response-header.enabled=false",
"spring.cloud.gateway.filter.json-to-grpc.enabled=false",
"spring.cloud.gateway.filter.modify-request-body.enabled=false",
"spring.cloud.gateway.filter.local-response-cache.enabled=false",
"spring.cloud.gateway.filter.dedupe-response-header.enabled=false",
"spring.cloud.gateway.filter.modify-response-body.enabled=false",
"spring.cloud.gateway.filter.prefix-path.enabled=false",

View File

@@ -59,8 +59,8 @@ public class DisableBuiltInGlobalFiltersTests {
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Config.class,
properties = { "spring.cloud.gateway.global-filter.remove-cached-body.enabled=false",
"spring.cloud.gateway.global-filter.route-to-request-url.enabled=false" })
properties = {"spring.cloud.gateway.global-filter.remove-cached-body.enabled=false",
"spring.cloud.gateway.global-filter.route-to-request-url.enabled=false"})
@ActiveProfiles("disable-components")
public static class DisableSpecificsFiltersByProperty {

View File

@@ -0,0 +1,204 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.gateway.filter.factory.cache.keygenerator.CacheKeyGenerator;
import org.springframework.http.HttpCookie;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
/**
* @author Ignacio Lozano
*/
class CacheKeyGeneratorTest {
final CacheKeyGenerator cacheKeyGenerator = new CacheKeyGenerator();
@Test
public void shouldGenerateSameKeyForSameUri() {
MockServerHttpRequest request1 = MockServerHttpRequest.get("http://this").build();
MockServerHttpRequest request2 = MockServerHttpRequest.get("http://this").build();
var key1 = cacheKeyGenerator.generateKey(request1);
var key2 = cacheKeyGenerator.generateKey(request2);
assertThat(key1).isEqualTo(key2);
}
@Test
void shouldGenerateDifferentKeyWhenAuthAreDifferent() {
var uri = "https://this";
var requestWithoutAuth = MockServerHttpRequest.get(uri).build();
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.put(AUTHORIZATION, List.of("my-token"));
var requestWithAuth = MockServerHttpRequest.get(uri).headers(httpHeaders).build();
var keyWithoutAuth = cacheKeyGenerator.generateKey(requestWithoutAuth);
var keyWithAuth = cacheKeyGenerator.generateKey(requestWithAuth);
assertThat(keyWithAuth).isNotEqualTo(keyWithoutAuth);
}
@Test
void shouldGenerateDifferentKeyWhenCookiesAreDifferent() {
var httpHeaders = new HttpHeaders();
var uri = "https://this";
var requestWithoutCookies = MockServerHttpRequest.get(uri).headers(httpHeaders).build();
var cookies = new HttpCookie[] { new HttpCookie("user", "my-first-cookie") };
var requestWithCookies = MockServerHttpRequest.get(uri).headers(httpHeaders).cookie(cookies).build();
var keyWithoutCookies = cacheKeyGenerator.generateKey(requestWithoutCookies);
var keyWithCookies = cacheKeyGenerator.generateKey(requestWithCookies);
assertThat(keyWithoutCookies).isNotEqualTo(keyWithCookies);
}
@Test
void shouldGenerateSameKeyWhenSameAuthAndCookieArePresent() {
var uri = "https://this";
var cookies = new HttpCookie[] { new HttpCookie("user", "my-first-cookie") };
var httpHeaders = new HttpHeaders();
httpHeaders.put(AUTHORIZATION, List.of("my-token"));
var request1 = MockServerHttpRequest.get(uri).headers(httpHeaders).cookie(cookies).build();
var request2 = MockServerHttpRequest.get(uri).headers(httpHeaders).cookie(cookies).build();
var key1 = cacheKeyGenerator.generateKey(request1);
var key2 = cacheKeyGenerator.generateKey(request2);
assertThat(key1).isEqualTo(key2);
}
@Test
void shouldGenerateSameKeyWhenVaryHeadersAreEqual() {
final String varyHeader = "X-MY-VARY";
var uri = "https://this";
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.put(varyHeader, List.of("VALUE1"));
var withFirstVary = MockServerHttpRequest.get(uri).headers(httpHeaders).build();
HttpHeaders httpHeaders2 = new HttpHeaders();
httpHeaders2.put(varyHeader, List.of("VALUE1"));
var withSecondVary = MockServerHttpRequest.get(uri).headers(httpHeaders2).build();
var keyWithFirstVary = cacheKeyGenerator.generateKey(withFirstVary, varyHeader);
var keyWithSecondVary = cacheKeyGenerator.generateKey(withSecondVary, varyHeader);
assertThat(keyWithFirstVary).isEqualTo(keyWithSecondVary);
}
@Test
void shouldGenerateDifferentKeyWhenVaryHeadersAreDifferent() {
final String varyHeader = "X-MY-VARY";
var uri = "https://this";
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.put(varyHeader, List.of("VALUE1"));
var withFirstVary = MockServerHttpRequest.get(uri).headers(httpHeaders).build();
HttpHeaders httpHeaders2 = new HttpHeaders();
httpHeaders.put(varyHeader, List.of("VALUE2"));
var withSecondVary = MockServerHttpRequest.get(uri).headers(httpHeaders2).build();
var keyWithFirstVary = cacheKeyGenerator.generateKey(withFirstVary, varyHeader);
var keyWithSecondVary = cacheKeyGenerator.generateKey(withSecondVary, varyHeader);
assertThat(keyWithFirstVary).isNotEqualTo(keyWithSecondVary);
}
@Test
void shouldGenerateDifferentKeyWhenVaryHeaderIsMissingInSecondRequest() {
final String varyHeader = "X-MY-VARY";
var uri = "https://this";
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.put(varyHeader, List.of("VALUE1"));
var withFirstVary = MockServerHttpRequest.get(uri).headers(httpHeaders).build();
var withSecondVary = MockServerHttpRequest.get(uri).build();
var keyWithFirstVary = cacheKeyGenerator.generateKey(withFirstVary, varyHeader);
var keyWithSecondVary = cacheKeyGenerator.generateKey(withSecondVary, varyHeader);
assertThat(keyWithFirstVary).isNotEqualTo(keyWithSecondVary);
}
@Test
void shouldGenerateDifferentKeyWhenOneOfMultipleVaryHeadersIsDifferent() {
final String varyHeader = "X-MY-VARY";
String varyHeader2 = "X-MY-SEC-VARY";
var uri = "https://this";
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.put(varyHeader, List.of("VALUE1"));
httpHeaders.put(varyHeader2, List.of("VALUE1"));
var withFirstVary = MockServerHttpRequest.get(uri).headers(httpHeaders).build();
HttpHeaders httpHeaders2 = new HttpHeaders();
httpHeaders.put(varyHeader, List.of("VALUE2"));
var withSecondVary = MockServerHttpRequest.get(uri).headers(httpHeaders2).build();
var keyWithFirstVary = cacheKeyGenerator.generateKey(withFirstVary, varyHeader);
var keyWithSecondVary = cacheKeyGenerator.generateKey(withSecondVary, varyHeader);
assertThat(keyWithFirstVary).isNotEqualTo(keyWithSecondVary);
}
@Test
void shouldGenerateDifferentKeyWhenHeadersAreDifferentButValuesAreTheSame() {
var uri = "https://this";
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.put("X-MY-VARY-1", List.of("VALUE1"));
httpHeaders.put("X-MY-VARY-2", List.of("VALUE2"));
HttpHeaders httpHeaders2 = new HttpHeaders();
httpHeaders2.put("X-MY-VARY-3", List.of("VALUE1"));
httpHeaders2.put("X-MY-VARY-4", List.of("VALUE2"));
var withFirstVary = MockServerHttpRequest.get(uri).headers(httpHeaders).build();
var withSecondVary = MockServerHttpRequest.get(uri).headers(httpHeaders2).build();
var keyWithFirstVary = cacheKeyGenerator.generateKey(withFirstVary, "X-MY-VARY-1", "X-MY-VARY-2");
var keyWithSecondVary = cacheKeyGenerator.generateKey(withSecondVary, "X-MY-VARY-3", "X-MY-VARY-4");
assertThat(keyWithFirstVary).isNotEqualTo(keyWithSecondVary);
}
@Test
void whenHeaderHasEmptyValue() {
var uri = "https://this";
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.put("X-MY-VARY-1", List.of(""));
var withFirstVary = MockServerHttpRequest.get(uri).headers(httpHeaders).build();
var withoutVaryHeader = MockServerHttpRequest.get(uri).build();
var keyWithFirstVary = cacheKeyGenerator.generateKey(withFirstVary, "X-MY-VARY-1");
var keyWithoutVary = cacheKeyGenerator.generateKey(withFirstVary, "X-MY-VARY-1");
assertThat(keyWithoutVary).isEqualTo(keyWithFirstVary);
}
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.zip.GZIPOutputStream;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Ignacio Lozano
*/
class CachedResponseTest {
@Test
void bodyAsByteArray_whenEmptyBody() throws IOException {
CachedResponse cachedResponse = CachedResponse.create(HttpStatus.OK).build();
byte[] asByteArray = cachedResponse.bodyAsByteArray();
assertThat(asByteArray).isEmpty();
}
@Test
void bodyAsByteArray_whenThereIsContent() throws IOException {
String body = "example";
CachedResponse cachedResponse = CachedResponse.create(HttpStatus.OK).body(body).build();
byte[] asByteArray = cachedResponse.bodyAsByteArray();
assertThat(asByteArray).isEqualTo(body.getBytes());
}
@Test
void bodyAsString_whenEmptyBody() throws IOException {
CachedResponse cachedResponse = CachedResponse.create(HttpStatus.OK).build();
String asString = cachedResponse.bodyAsString();
assertThat(asString).isEmpty();
}
@Test
void bodyAsString_whenThereIsContent() throws IOException {
String body = "example";
CachedResponse cachedResponse = CachedResponse.create(HttpStatus.OK).body(body).build();
String asString = cachedResponse.bodyAsString();
assertThat(asString).isEqualTo(body);
}
@Test
void bodyAsString_whenThereIsGZipContent() throws IOException {
String body = "example";
CachedResponse cachedResponse = CachedResponse.create(HttpStatus.OK)
.header(HttpHeaders.CONTENT_ENCODING, "gzip").appendToBody(convertToGzip(body)).build();
String asString = cachedResponse.bodyAsString();
assertThat(asString).isEqualTo(body);
}
private ByteBuffer convertToGzip(String str) throws IOException {
var outBytes = new ByteArrayOutputStream();
var outGzip = new GZIPOutputStream(outBytes);
outGzip.write(str.getBytes());
outGzip.close();
return ByteBuffer.wrap(outBytes.toByteArray());
}
}

View File

@@ -0,0 +1,277 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.cloud.gateway.test.BaseWebClientTests;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.util.StringUtils;
import org.springframework.util.unit.DataSize;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
/**
* @author Ignacio Lozano
*/
@SpringBootTest(webEnvironment = RANDOM_PORT)
@DirtiesContext
@ActiveProfiles(profiles = "local-cache-filter")
public class LocalResponseCacheGatewayFilterFactoryTests extends BaseWebClientTests {
private static final String CUSTOM_HEADER = "X-Custom-Date";
@Test
void shouldNotCacheResponseWhenRouteDoesNotHaveFilter() {
String uri = "/" + UUID.randomUUID() + "/no-cache/headers";
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "1").exchange()
.expectBody().jsonPath("$.headers." + CUSTOM_HEADER);
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "2").exchange()
.expectBody().jsonPath("$.headers." + CUSTOM_HEADER).isEqualTo("2");
}
@Test
void shouldNotCacheResponseWhenGetRequestHasBody() {
String uri = "/" + UUID.randomUUID() + "/cache/headers";
testClient.method(HttpMethod.GET).uri(uri).header("Host", "www.localresponsecache.org")
.header(CUSTOM_HEADER, "1").bodyValue("whatever").exchange().expectBody()
.jsonPath("$.headers." + CUSTOM_HEADER);
testClient.method(HttpMethod.GET).uri(uri).header("Host", "www.localresponsecache.org").bodyValue("whatever")
.header(CUSTOM_HEADER, "2").exchange().expectBody().jsonPath("$.headers." + CUSTOM_HEADER)
.isEqualTo("2");
}
@Test
void shouldNotCacheResponseWhenPostRequestHasBody() {
String uri = "/" + UUID.randomUUID() + "/cache/headers";
testClient.method(HttpMethod.POST).uri(uri).header("Host", "www.localresponsecache.org")
.header(CUSTOM_HEADER, "1").bodyValue("whatever").exchange().expectBody()
.jsonPath("$.headers." + CUSTOM_HEADER);
testClient.method(HttpMethod.POST).uri(uri).header("Host", "www.localresponsecache.org").bodyValue("whatever")
.header(CUSTOM_HEADER, "2").exchange().expectBody().jsonPath("$.headers." + CUSTOM_HEADER)
.isEqualTo("2");
}
@Test
void shouldNotCacheWhenCacheControlAsksToDoNotCache() {
String uri = "/" + UUID.randomUUID() + "/cache/headers";
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "1").exchange()
.expectBody().jsonPath("$.headers." + CUSTOM_HEADER);
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "2")
// Cache-Control asks to not use the cached content and not store the
// response
.header(HttpHeaders.CACHE_CONTROL, CacheControl.noStore().getHeaderValue()).exchange().expectBody()
.jsonPath("$.headers." + CUSTOM_HEADER).isEqualTo("2");
}
@Test
void shouldCacheAndReturnNotModifiedStatusWhenCacheControlIsNoCache() {
String uri = "/" + UUID.randomUUID() + "/cache/headers";
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "1").exchange()
.expectBody().jsonPath("$.headers." + CUSTOM_HEADER);
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "2")
// Cache-Control asks to not return cached content because it is
// HttpHeaders.NotModified
.header(HttpHeaders.CACHE_CONTROL, CacheControl.noCache().getHeaderValue()).exchange().expectStatus()
.isNotModified().expectBody().isEmpty();
}
@Test
void shouldCacheResponseWhenOnlyNonVaryHeaderIsDifferent() {
String uri = "/" + UUID.randomUUID() + "/cache/headers";
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "1").exchange()
.expectBody().jsonPath("$.headers." + CUSTOM_HEADER)
.value(customHeaderFromReq1 -> testClient.get().uri(uri).header("Host", "www.localresponsecache.org")
.header(CUSTOM_HEADER, "2").exchange().expectBody()
.jsonPath("$.headers." + CUSTOM_HEADER, customHeaderFromReq1));
}
@Test
void shouldNotCacheResponseWhenVaryHeaderIsDifferent() {
String varyHeader = HttpHeaders.ORIGIN;
String sameUri = "/" + UUID.randomUUID() + "/cache/vary-on-header";
String firstNonVary = "1";
String secondNonVary = "2";
assertNonVaryHeaderInContent(sameUri, varyHeader, "origin-1", CUSTOM_HEADER, firstNonVary, firstNonVary);
assertNonVaryHeaderInContent(sameUri, varyHeader, "origin-1", CUSTOM_HEADER, secondNonVary, firstNonVary);
assertNonVaryHeaderInContent(sameUri, varyHeader, "origin-2", CUSTOM_HEADER, secondNonVary, secondNonVary);
}
@Test
void shouldNotCacheResponseWhenResponseVaryIsWildcard() {
String uri = "/" + UUID.randomUUID() + "/cache/vary-on-header";
// Vary: *
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "1")
.header("X-Request-Vary", "*").exchange().expectBody().jsonPath("$.headers." + CUSTOM_HEADER, "1");
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "2")
.header("X-Request-Vary", "*").exchange().expectBody().jsonPath("$.headers." + CUSTOM_HEADER, "2");
}
@Test
void shouldNotCacheResponseWhenPathIsDifferent() {
String uri = "/" + UUID.randomUUID() + "/cache/headers";
String uri2 = "/" + UUID.randomUUID() + "/cache/headers";
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "1").exchange()
.expectBody().jsonPath("$.headers." + CUSTOM_HEADER);
testClient.get().uri(uri2).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "2").exchange()
.expectBody().jsonPath("$.headers." + CUSTOM_HEADER).isEqualTo("2");
}
@Test
void shouldDecreaseCacheControlMaxAgeTimeWhenResponseIsFromCache() throws InterruptedException {
String uri = "/" + UUID.randomUUID() + "/cache/headers";
Long maxAgeRequest1 = testClient.get().uri(uri).header("Host", "www.localresponsecache.org").exchange()
.expectBody().returnResult().getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream()
.map(this::parseMaxAge).filter(Objects::nonNull).findAny().orElse(null);
Thread.sleep(2000);
Long maxAgeRequest2 = testClient.get().uri(uri).header("Host", "www.localresponsecache.org").exchange()
.expectBody().returnResult().getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream()
.map(this::parseMaxAge).filter(Objects::nonNull).findAny().orElse(null);
assertThat(maxAgeRequest2).isLessThan(maxAgeRequest1);
}
@Test
void shouldNotCacheResponseWhenTimeToLiveIsReached() {
String uri = "/" + UUID.randomUUID() + "/ephemeral-cache/headers";
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "1").exchange()
.expectBody().jsonPath("$.headers." + CUSTOM_HEADER).value(customHeaderFromReq1 -> {
try {
Thread.sleep(100); // Min time to have entry expired
testClient.get().uri(uri).header("Host", "www.localresponsecache.org")
.header(CUSTOM_HEADER, "2").exchange().expectBody()
.jsonPath("$.headers." + CUSTOM_HEADER).isEqualTo("2");
}
catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
@Test
void shouldNotCacheWhenLocalResponseCacheSizeIsReached() {
String uri = "/" + UUID.randomUUID() + "/one-byte-cache/headers";
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "1").exchange()
.expectBody().jsonPath("$.headers." + CUSTOM_HEADER);
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "2").exchange()
.expectBody().jsonPath("$.headers." + CUSTOM_HEADER, "2");
}
@Test
void shouldNotCacheWhenAuthorizationHeaderIsDifferent() {
String uri = "/" + UUID.randomUUID() + "/cache/headers";
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(HttpHeaders.AUTHORIZATION, "1")
.header(CUSTOM_HEADER, "1").exchange().expectBody().jsonPath("$.headers." + CUSTOM_HEADER);
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(HttpHeaders.AUTHORIZATION, "2")
.header(CUSTOM_HEADER, "2").exchange().expectBody().jsonPath("$.headers." + CUSTOM_HEADER, "2");
}
private Long parseMaxAge(String cacheControlValue) {
if (StringUtils.hasText(cacheControlValue)) {
Pattern maxAgePattern = Pattern.compile("\\bmax-age=(\\d+)\\b");
Matcher matcher = maxAgePattern.matcher(cacheControlValue);
if (matcher.find()) {
return Long.parseLong(matcher.group(1));
}
}
return null;
}
void assertNonVaryHeaderInContent(String uri, String varyHeader, String varyHeaderValue, String nonVaryHeader,
String nonVaryHeaderValue, String expectedNonVaryResponse) {
testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header("X-Request-Vary", varyHeader)
.header(varyHeader, varyHeaderValue).header(nonVaryHeader, nonVaryHeaderValue).exchange()
.expectBody(Map.class).consumeWith(response -> {
assertThat(response.getResponseHeaders()).hasEntrySatisfying("Vary",
o -> assertThat(o).contains(varyHeader));
assertThat((Map) response.getResponseBody().get("headers")).containsEntry(nonVaryHeader,
expectedNonVaryResponse);
});
}
@EnableAutoConfiguration
@SpringBootConfiguration
@Import(DefaultTestConfig.class)
public static class TestConfig {
@Value("${test.uri}")
String uri;
@Bean
public RouteLocator testRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("no_local_response_cache_java_test",
r -> r.path("/{namespace}/no-cache/**").and().host("{sub}.localresponsecache.org")
.filters(f -> f.stripPrefix(2).prefixPath("/httpbin")).uri(uri))
.route("local_response_cache_java_test",
r -> r.path("/{namespace}/cache/**").and().host("{sub}.localresponsecache.org")
.filters(
f -> f.stripPrefix(2).prefixPath("/httpbin").localResponseCache(null, null))
.uri(uri))
.route("100_millisec_ephemeral_prefix_local_response_cache_java_test",
r -> r.path("/{namespace}/ephemeral-cache/**").and().host("{sub}.localresponsecache.org")
.filters(f -> f.stripPrefix(2).prefixPath("/httpbin")
.localResponseCache(Duration.ofMillis(100), null))
.uri(uri))
.route("min_sized_prefix_local_response_cache_java_test",
r -> r.path("/{namespace}/one-byte-cache/**").and().host("{sub}.localresponsecache.org")
.filters(f -> f.stripPrefix(2).prefixPath("/httpbin").localResponseCache(null,
DataSize.ofBytes(1L)))
.uri(uri))
.build();
}
}
}

View File

@@ -0,0 +1,107 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Ignacio Lozano
*/
class ResponseCacheGatewayFilterTest {
ResponseCacheManager cacheManagerToTest = new ResponseCacheManager(null, null, null);
@Test
void requestShouldBeCacheable() {
var uri = "http://test.com";
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(HttpHeaders.CACHE_CONTROL, "no-transform");
var request = MockServerHttpRequest.get(uri).headers(httpHeaders).build();
assertThat(cacheManagerToTest.isRequestCacheable(request)).isTrue();
}
@Test
void requestShouldNotBeCacheable() {
var uri = "http://test.com";
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(HttpHeaders.CACHE_CONTROL, "no-store");
var request = MockServerHttpRequest.get(uri).headers(httpHeaders).build();
assertThat(cacheManagerToTest.isRequestCacheable(request)).isFalse();
httpHeaders = new HttpHeaders();
httpHeaders.add(HttpHeaders.CACHE_CONTROL, "no-transform");
request = MockServerHttpRequest.post(uri).headers(httpHeaders).build();
assertThat(cacheManagerToTest.isRequestCacheable(request)).isFalse();
}
@Test
void responseShouldBeCacheable() {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CACHE_CONTROL, "public");
var response = new MockServerHttpResponse();
response.getHeaders().putAll(headers);
response.setStatusCode(HttpStatus.OK);
assertThat(cacheManagerToTest.isResponseCacheable(response)).isTrue();
headers = new HttpHeaders();
headers.add(HttpHeaders.CACHE_CONTROL, "public");
response = new MockServerHttpResponse();
response.getHeaders().putAll(headers);
response.setStatusCode(HttpStatus.PARTIAL_CONTENT);
assertThat(cacheManagerToTest.isResponseCacheable(response)).isTrue();
}
@Test
void responseShouldNotBeCacheable() {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CACHE_CONTROL, "public");
var response = new MockServerHttpResponse();
response.getHeaders().putAll(headers);
response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
assertThat(cacheManagerToTest.isResponseCacheable(response)).isFalse();
headers = new HttpHeaders();
headers.add(HttpHeaders.CACHE_CONTROL, "private");
response = new MockServerHttpResponse();
response.getHeaders().putAll(headers);
response.setStatusCode(HttpStatus.OK);
assertThat(cacheManagerToTest.isResponseCacheable(response)).isFalse();
headers = new HttpHeaders();
headers.add(HttpHeaders.CACHE_CONTROL, "no-store");
response = new MockServerHttpResponse();
response.getHeaders().putAll(headers);
response.setStatusCode(HttpStatus.OK);
assertThat(cacheManagerToTest.isResponseCacheable(response)).isFalse();
}
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache.keygenerator;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpCookie;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Ignacio Lozano
*/
class CommonKeyValueGeneratorTest {
@Test
void uriAuthorizationAndCookiesArePresent() {
String uri = "http://myuri";
HttpHeaders headers = new HttpHeaders();
String authorization = "my-auth";
headers.set("Authorization", authorization);
String cookieName = "my-cookie";
String cookieValue = "cookie-value";
HttpCookie cookie = new HttpCookie(cookieName, cookieValue);
MockServerHttpRequest request = MockServerHttpRequest.get(uri).cookie(cookie).headers(headers).build();
String result = new CommonKeyValueGenerator().apply(request);
assertThat(result).isEqualTo(uri + ";Authorization=" + authorization + ";" + cookieName + "=" + cookieValue);
}
@Test
void uriAndCookiesArePresent() {
String uri = "http://myuri";
HttpHeaders headers = new HttpHeaders();
String cookieName = "my-cookie";
String cookieValue = "cookie-value";
HttpCookie cookie = new HttpCookie(cookieName, cookieValue);
MockServerHttpRequest request = MockServerHttpRequest.get(uri).cookie(cookie).headers(headers).build();
String result = new CommonKeyValueGenerator().apply(request);
assertThat(result).isEqualTo(uri + ";" + "" + ";" + cookieName + "=" + cookieValue);
}
@Test
void onlyUriPresent() {
String uri = "http://myuri";
MockServerHttpRequest request = MockServerHttpRequest.get(uri).build();
String result = new CommonKeyValueGenerator().apply(request);
assertThat(result).isEqualTo(uri + ";" + "" + ";" + "");
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache.keygenerator;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* @author Ignacio Lozano
*/
class HeaderKeyValueGeneratorTest {
private static final String HEADER_NAME = "X-Header";
private static final String SINGLE_HEADER_VALUE = "header-value";
private static final String VALUE1 = "value-1";
private static final String VALUE2 = "value-2";
private static final String SEPARATOR = ",";
@Test
void exceptionIsThrown_whenConstructorHeaderIsNull() {
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> new HeaderKeyValueGenerator(null, SEPARATOR));
}
@Test
void keyValuePatternIsGenerated_whenOneSingleValueHeaderIsFound() {
HttpHeaders headers = new HttpHeaders();
headers.set(HEADER_NAME, SINGLE_HEADER_VALUE);
MockServerHttpRequest request = MockServerHttpRequest.get("http://this").headers(headers).build();
String result = new HeaderKeyValueGenerator(HEADER_NAME, SEPARATOR).apply(request);
assertThat(result).isEqualTo(HEADER_NAME + "=" + SINGLE_HEADER_VALUE);
}
@Test
void keyValuePatternIsGenerated_whenOneMultipleValueHeaderIsFound() {
HttpHeaders headers = new HttpHeaders();
headers.put(HEADER_NAME, List.of(VALUE1, VALUE2));
MockServerHttpRequest request = MockServerHttpRequest.get("http://this").headers(headers).build();
String result = new HeaderKeyValueGenerator(HEADER_NAME, SEPARATOR).apply(request);
assertThat(result).isEqualTo(HEADER_NAME + "=" + VALUE1 + SEPARATOR + VALUE2);
}
@Test
void sotedKeyValuePatternIsGenerated_whenOneMultipleUnsortedValueHeaderIsFound() {
HttpHeaders headers = new HttpHeaders();
headers.put(HEADER_NAME, List.of(VALUE2, VALUE1));
MockServerHttpRequest request = MockServerHttpRequest.get("http://this").headers(headers).build();
String result = new HeaderKeyValueGenerator(HEADER_NAME, SEPARATOR).apply(request);
assertThat(result).isEqualTo(HEADER_NAME + "=" + VALUE1 + SEPARATOR + VALUE2);
}
}

View File

@@ -0,0 +1,166 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache.postprocessor;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.gateway.filter.factory.cache.CachedResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.util.StringUtils;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Ignacio Lozano
*/
class SetMaxAgeHeaderAfterCacheExchangeMutatorTest {
private static final int SECONDS_LATER = 10;
private MockServerWebExchange inputExchange;
private Clock clock;
private Clock clockSecondsLater;
@BeforeEach
void setUp() {
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setCacheControl("max-age=1234");
MockServerHttpRequest httpRequest = MockServerHttpRequest.get("https://this").build();
inputExchange = MockServerWebExchange.from(httpRequest);
MockServerHttpResponse httpResponse = inputExchange.getResponse();
httpResponse.setStatusCode(HttpStatus.OK);
httpResponse.getHeaders().putAll(responseHeaders);
clock = Clock.fixed(Instant.now(), Clock.systemDefaultZone().getZone());
clockSecondsLater = Clock.fixed(clock.instant().plusSeconds(SECONDS_LATER), clock.getZone());
}
@Test
void maxAgeIsNotAdded_whenMaxAgeIsNotPresent() {
inputExchange.getResponse().getHeaders().setCacheControl((String) null);
Duration timeToLive = Duration.ofSeconds(30);
CachedResponse inputCachedResponse = CachedResponse.create(HttpStatus.OK).timestamp(clock.instant()).build();
SetMaxAgeHeaderAfterCacheExchangeMutator toTest = new SetMaxAgeHeaderAfterCacheExchangeMutator(timeToLive,
clock);
toTest.accept(inputExchange, inputCachedResponse);
assertThat(parseMaxAge(inputExchange.getResponse())).isEmpty();
}
@Test
void maxAgeIsDecreasedByTimePassed_whenFilterIsAppliedAfterSecondsLater() {
Duration timeToLive = Duration.ofSeconds(30);
CachedResponse inputCachedResponse = CachedResponse.create(HttpStatus.OK).timestamp(clock.instant()).build();
SetMaxAgeHeaderAfterCacheExchangeMutator toTest = new SetMaxAgeHeaderAfterCacheExchangeMutator(timeToLive,
clock);
toTest.accept(inputExchange, inputCachedResponse);
Optional<Long> firstMaxAgeSeconds = parseMaxAge(inputExchange.getResponse());
SetMaxAgeHeaderAfterCacheExchangeMutator toTestSecondsLater = new SetMaxAgeHeaderAfterCacheExchangeMutator(
timeToLive, clockSecondsLater);
toTestSecondsLater.accept(inputExchange, inputCachedResponse);
Optional<Long> secondMaxAgeSeconds = parseMaxAge(inputExchange.getResponse());
assertThat(firstMaxAgeSeconds).contains(timeToLive.getSeconds());
assertThat(secondMaxAgeSeconds).contains(timeToLive.getSeconds() - SECONDS_LATER);
}
@Test
void maxAgeIsZero_whenRequestIsCachedMoreThanTimeToLive() {
Duration timeToLive = Duration.ofSeconds(SECONDS_LATER / 2); // To be staled after
// SECONDS_LATER
// passed
CachedResponse inputCachedResponse = CachedResponse.create(HttpStatus.OK).timestamp(clock.instant()).build();
SetMaxAgeHeaderAfterCacheExchangeMutator toTest = new SetMaxAgeHeaderAfterCacheExchangeMutator(timeToLive,
clock);
toTest.accept(inputExchange, inputCachedResponse);
Optional<Long> firstMaxAgeSeconds = parseMaxAge(inputExchange.getResponse());
SetMaxAgeHeaderAfterCacheExchangeMutator toTestSecondsLater = new SetMaxAgeHeaderAfterCacheExchangeMutator(
timeToLive, clockSecondsLater);
toTestSecondsLater.accept(inputExchange, inputCachedResponse);
Optional<Long> secondMaxAgeSeconds = parseMaxAge(inputExchange.getResponse());
assertThat(firstMaxAgeSeconds).contains(timeToLive.getSeconds());
assertThat(secondMaxAgeSeconds).contains(0L);
}
@Test
void otherCacheControlValuesAreNotRemoved_whenMaxAgeIsModified() {
inputExchange.getResponse().getHeaders().setCacheControl("max-stale=12, min-stale=1, max-age=1234");
Duration timeToLive = Duration.ofSeconds(30);
CachedResponse inputCachedResponse = CachedResponse.create(HttpStatus.OK).timestamp(clock.instant()).build();
SetMaxAgeHeaderAfterCacheExchangeMutator toTest = new SetMaxAgeHeaderAfterCacheExchangeMutator(timeToLive,
clock);
toTest.accept(inputExchange, inputCachedResponse);
String[] cacheControlValues = Optional.ofNullable(inputExchange.getResponse().getHeaders().getCacheControl())
.map(s -> s.split("\\s*,\\s*")).orElse(null);
assertThat(cacheControlValues).contains("max-stale=12", "min-stale=1");
}
@Test
void otherHeadersAreNotRemoved_whenMaxAgeIsModified() {
inputExchange.getResponse().getHeaders().put("X-Custom-Header", List.of("DO-NOT-REMOVE"));
Duration timeToLive = Duration.ofSeconds(30);
CachedResponse inputCachedResponse = CachedResponse.create(HttpStatus.OK).timestamp(clock.instant()).build();
SetMaxAgeHeaderAfterCacheExchangeMutator toTest = new SetMaxAgeHeaderAfterCacheExchangeMutator(timeToLive,
clock);
toTest.accept(inputExchange, inputCachedResponse);
List<String> cacheControlValues = inputExchange.getResponse().getHeaders().get("X-Custom-Header");
assertThat(cacheControlValues).contains("DO-NOT-REMOVE");
}
private Optional<Long> parseMaxAge(ServerHttpResponse response) {
return parseMaxAge(response.getHeaders().getCacheControl());
}
private Optional<Long> parseMaxAge(String cacheControlValue) {
if (StringUtils.hasText(cacheControlValue)) {
Pattern maxAgePattern = Pattern.compile("\\bmax-age=(\\d+)\\b");
Matcher matcher = maxAgePattern.matcher(cacheControlValue);
if (matcher.find()) {
return Optional.of(Long.parseLong(matcher.group(1)));
}
}
return Optional.empty();
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache.postprocessor;
import java.util.List;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.gateway.filter.factory.cache.CachedResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
import org.springframework.mock.web.server.MockServerWebExchange;
/**
* @author Ignacio Lozano
*/
class SetResponseHeadersAfterCacheExchangeMutatorTest {
private MockServerWebExchange inputExchange;
@BeforeEach
void setUp() {
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setCacheControl("max-age=1234");
MockServerHttpRequest httpRequest = MockServerHttpRequest.get("https://this").build();
inputExchange = MockServerWebExchange.from(httpRequest);
MockServerHttpResponse httpResponse = inputExchange.getResponse();
httpResponse.setStatusCode(HttpStatus.OK);
httpResponse.getHeaders().putAll(responseHeaders);
}
@Test
void headersFromCacheOverrideHeadersFromResponse() {
SetResponseHeadersAfterCacheExchangeMutator toTest = new SetResponseHeadersAfterCacheExchangeMutator();
inputExchange.getResponse().getHeaders().set("X-Header-1", "Value-original");
CachedResponse cachedResponse = new CachedResponse.Builder(HttpStatus.OK).header("X-Header-1", "Value-cached")
.build();
toTest.accept(inputExchange, cachedResponse);
Assertions.assertThat(inputExchange.getResponse().getHeaders()).containsEntry("X-Header-1",
List.of("Value-cached"));
}
@Test
void headersFromResponseAreDropped() {
SetResponseHeadersAfterCacheExchangeMutator toTest = new SetResponseHeadersAfterCacheExchangeMutator();
inputExchange.getResponse().getHeaders().set("X-Header-1", "Value-original");
CachedResponse cachedResponse = new CachedResponse.Builder(HttpStatus.OK).build();
toTest.accept(inputExchange, cachedResponse);
Assertions.assertThat(inputExchange.getResponse().getHeaders()).doesNotContainKey("X-Header-1");
}
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2013-2020 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.cloud.gateway.filter.factory.cache.postprocessor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.gateway.filter.factory.cache.CachedResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
import org.springframework.mock.web.server.MockServerWebExchange;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Ignacio Lozano
*/
class SetStatusCodeAfterCacheExchangeMutatorTest {
private MockServerWebExchange inputExchange;
@BeforeEach
void setUp() {
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setCacheControl("max-age=1234");
MockServerHttpRequest httpRequest = MockServerHttpRequest.get("https://this").build();
inputExchange = MockServerWebExchange.from(httpRequest);
MockServerHttpResponse httpResponse = inputExchange.getResponse();
httpResponse.setStatusCode(HttpStatus.OK);
httpResponse.getHeaders().putAll(responseHeaders);
}
@Test
void statusCodeIs304_whenCacheHitsAndNoCacheHeaderIsPresent() {
CachedResponse cachedResponse = CachedResponse.create(HttpStatus.OK).body("some-data").build();
MockServerHttpRequest httpRequest = MockServerHttpRequest.get("https://this")
.header("Cache-Control", "no-cache").build();
inputExchange = MockServerWebExchange.from(httpRequest);
SetStatusCodeAfterCacheExchangeMutator toTest = new SetStatusCodeAfterCacheExchangeMutator();
toTest.accept(inputExchange, cachedResponse);
assertThat(inputExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.NOT_MODIFIED);
}
@Test
void statusCodeIs200_whenCacheHitsAndNoCacheHeaderIsNotPresent() {
CachedResponse cachedResponse = CachedResponse.create(HttpStatus.OK).body("some-data").build();
SetStatusCodeAfterCacheExchangeMutator toTest = new SetStatusCodeAfterCacheExchangeMutator();
toTest.accept(inputExchange, cachedResponse);
assertThat(inputExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void statusCodeIs200_whenNoCacheHitsAndEvenNoCacheHeaderIsPresent() {
CachedResponse cachedResponse = CachedResponse.create(HttpStatus.OK).build();
MockServerHttpRequest httpRequest = MockServerHttpRequest.get("https://this")
.header("Cache-Control", "no-cache").build();
inputExchange = MockServerWebExchange.from(httpRequest);
SetStatusCodeAfterCacheExchangeMutator toTest = new SetStatusCodeAfterCacheExchangeMutator();
toTest.accept(inputExchange, cachedResponse);
assertThat(inputExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK);
}
}

View File

@@ -32,6 +32,7 @@ import org.springframework.cloud.gateway.filter.factory.FallbackHeadersGatewayFi
import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.JsonToGrpcGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.MapRequestHeaderGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory;
import org.springframework.cloud.gateway.handler.predicate.AfterRoutePredicateFactory;
import org.springframework.cloud.gateway.handler.predicate.CloudFoundryRouteServiceRoutePredicateFactory;
import org.springframework.cloud.gateway.handler.predicate.ReadBodyRoutePredicateFactory;
@@ -90,13 +91,13 @@ class NameUtilsTests {
List<Class<? extends GatewayFilterFactory<?>>> predicates = Arrays.asList(
AddRequestHeaderGatewayFilterFactory.class, DedupeResponseHeaderGatewayFilterFactory.class,
FallbackHeadersGatewayFilterFactory.class, MapRequestHeaderGatewayFilterFactory.class,
JsonToGrpcGatewayFilterFactory.class);
JsonToGrpcGatewayFilterFactory.class, LocalResponseCacheGatewayFilterFactory.class);
List<String> resultNames = predicates.stream().map(NameUtils::normalizeFilterFactoryNameAsProperty)
.collect(Collectors.toList());
List<String> expectedNames = Arrays.asList("add-request-header", "dedupe-response-header", "fallback-headers",
"map-request-header", "json-to-grpc");
"map-request-header", "json-to-grpc", "local-response-cache");
assertThat(resultNames).isEqualTo(expectedNames);
}

View File

@@ -50,6 +50,7 @@ import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@@ -61,6 +62,8 @@ public class HttpBinCompatibleController {
private static final Log log = LogFactory.getLog(HttpBinCompatibleController.class);
private static final String HEADER_REQ_VARY = "X-Request-Vary";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@GetMapping("/")
@@ -205,6 +208,19 @@ public class HttpBinCompatibleController {
return response.writeWith(Flux.just(wrap));
}
@GetMapping("/vary-on-header/**")
public ResponseEntity<Map<String, Object>> varyOnAccept(ServerWebExchange exchange,
@RequestHeader(name = HEADER_REQ_VARY, required = false) String headerToVary) {
if (headerToVary == null) {
return ResponseEntity.badRequest().body(Map.of("error", HEADER_REQ_VARY + " header is mandatory"));
}
else {
var builder = ResponseEntity.ok();
builder.varyBy(headerToVary);
return builder.body(headers(exchange));
}
}
public Map<String, String> getHeaders(ServerWebExchange exchange) {
return exchange.getRequest().getHeaders().toSingleValueMap();
}