@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() + '}';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 + ";" + "" + ";" + "");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user