From 8a0caeea41c21ee1b8d29aa1a49c1a110f664454 Mon Sep 17 00:00:00 2001 From: Ignacio Lozano Date: Tue, 25 Oct 2022 22:46:44 +0200 Subject: [PATCH] Add LocalResponseCache filter (#2759) * Add LocalResponseCache filter --- docs/src/main/asciidoc/_configprops.adoc | 1 + .../main/asciidoc/spring-cloud-gateway.adoc | 50 ++++ spring-cloud-gateway-server/pom.xml | 5 + .../LocalResponseCacheAutoConfiguration.java | 100 +++++++ .../filter/factory/cache/CachedResponse.java | 164 +++++++++++ .../factory/cache/CachedResponseMetadata.java | 27 ++ ...ocalResponseCacheGatewayFilterFactory.java | 129 ++++++++ .../cache/LocalResponseCacheProperties.java | 72 +++++ .../cache/ResponseCacheGatewayFilter.java | 107 +++++++ .../factory/cache/ResponseCacheManager.java | 206 +++++++++++++ .../cache/ResponseCacheManagerFactory.java | 40 +++ .../cache/ResponseCacheSizeWeigher.java | 46 +++ .../cache/keygenerator/CacheKeyGenerator.java | 83 ++++++ .../keygenerator/CommonKeyValueGenerator.java | 46 +++ .../CookiesKeyValueGenerator.java | 56 ++++ .../keygenerator/HeaderKeyValueGenerator.java | 62 ++++ .../cache/keygenerator/KeyValueGenerator.java | 31 ++ .../keygenerator/UriKeyValueGenerator.java | 34 +++ .../AfterCacheExchangeMutator.java | 33 +++ ...MaxAgeHeaderAfterCacheExchangeMutator.java | 102 +++++++ ...ponseHeadersAfterCacheExchangeMutator.java | 38 +++ ...etStatusCodeAfterCacheExchangeMutator.java | 54 ++++ .../route/builder/GatewayFilterSpec.java | 18 ++ ...itional-spring-configuration-metadata.json | 6 + ...ot.autoconfigure.AutoConfiguration.imports | 3 +- .../DisableBuiltInFiltersTests.java | 3 +- .../DisableBuiltInGlobalFiltersTests.java | 4 +- .../factory/cache/CacheKeyGeneratorTest.java | 204 +++++++++++++ .../factory/cache/CachedResponseTest.java | 93 ++++++ ...esponseCacheGatewayFilterFactoryTests.java | 277 ++++++++++++++++++ .../cache/ResponseCacheGatewayFilterTest.java | 107 +++++++ .../CommonKeyValueGeneratorTest.java | 72 +++++ .../HeaderKeyValueGeneratorTest.java | 83 ++++++ ...geHeaderAfterCacheExchangeMutatorTest.java | 166 +++++++++++ ...eHeadersAfterCacheExchangeMutatorTest.java | 75 +++++ ...atusCodeAfterCacheExchangeMutatorTest.java | 88 ++++++ .../cloud/gateway/support/NameUtilsTests.java | 5 +- .../test/HttpBinCompatibleController.java | 16 + 38 files changed, 2700 insertions(+), 6 deletions(-) create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponse.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponseMetadata.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheProperties.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilter.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManager.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManagerFactory.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheSizeWeigher.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CacheKeyGenerator.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CommonKeyValueGenerator.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CookiesKeyValueGenerator.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/HeaderKeyValueGenerator.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/KeyValueGenerator.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/UriKeyValueGenerator.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/AfterCacheExchangeMutator.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutator.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetResponseHeadersAfterCacheExchangeMutator.java create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutator.java create mode 100644 spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/CacheKeyGeneratorTest.java create mode 100644 spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponseTest.java create mode 100644 spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactoryTests.java create mode 100644 spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilterTest.java create mode 100644 spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CommonKeyValueGeneratorTest.java create mode 100644 spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/HeaderKeyValueGeneratorTest.java create mode 100644 spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutatorTest.java create mode 100644 spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetResponseHeadersAfterCacheExchangeMutatorTest.java create mode 100644 spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutatorTest.java diff --git a/docs/src/main/asciidoc/_configprops.adoc b/docs/src/main/asciidoc/_configprops.adoc index f838ad27..49b5fcc8 100644 --- a/docs/src/main/asciidoc/_configprops.adoc +++ b/docs/src/main/asciidoc/_configprops.adoc @@ -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. diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index 36062ffd..908968df 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -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. diff --git a/spring-cloud-gateway-server/pom.xml b/spring-cloud-gateway-server/pom.xml index c25121de..a9bae2e2 100644 --- a/spring-cloud-gateway-server/pom.xml +++ b/spring-cloud-gateway-server/pom.xml @@ -126,6 +126,11 @@ spring-cloud-starter-circuitbreaker-reactor-resilience4j true + + com.github.ben-manes.caffeine + caffeine + true + io.micrometer context-propagation diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java new file mode 100644 index 00000000..59caac19 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java @@ -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); + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponse.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponse.java new file mode 100644 index 00000000..fc7a78a1 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponse.java @@ -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 body; + + private Date timestamp; + + private CachedResponse(HttpStatusCode statusCode, HttpHeaders headers, List 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 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 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)); + } + + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponseMetadata.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponseMetadata.java new file mode 100644 index 00000000..8aa5ff93 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponseMetadata.java @@ -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 varyOnHeaders) implements Serializable { +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java new file mode 100644 index 00000000..98f52a2c --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java @@ -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 { + + 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 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; + } + + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheProperties.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheProperties.java new file mode 100644 index 00000000..ea3efb8c --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheProperties.java @@ -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() + '}'; + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilter.java new file mode 100644 index 00000000..f8f876c3 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilter.java @@ -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 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 filterWithCache(ServerWebExchange exchange, GatewayFilterChain chain) { + final String metadataKey = responseCacheManager.resolveMetadataKey(exchange); + Optional 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 writeWith(Publisher body) { + final ServerHttpResponse response = exchange.getResponse(); + + Flux decoratedBody; + if (responseCacheManager.isResponseCacheable(response)) { + decoratedBody = responseCacheManager.processFromUpstream(metadataKey, exchange, + (Flux) body); + } + else { + decoratedBody = (Flux) body; + } + + return super.writeWith(decoratedBody); + } + + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManager.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManager.java new file mode 100644 index 00000000..e67acc16 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManager.java @@ -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 forbiddenCacheControlValues = Arrays.asList("private", "no-store"); + + private static final String VARY_WILDCARD = "*"; + + final CacheKeyGenerator cacheKeyGenerator; + + final List 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 statusesToCache = Arrays.asList(HttpStatus.OK, HttpStatus.PARTIAL_CONTENT, + HttpStatus.MOVED_PERMANENTLY); + + public Optional 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 processFromUpstream(String metadataKey, ServerWebExchange exchange, Flux 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 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 varyOnHeaders) { + return cacheKeyGenerator.generateKey(exchange.getRequest(), varyOnHeaders); + } + + Mono 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 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 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); + } + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManagerFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManagerFactory.java new file mode 100644 index 00000000..00592068 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManagerFactory.java @@ -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); + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheSizeWeigher.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheSizeWeigher.java new file mode 100644 index 00000000..abedf5c1 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheSizeWeigher.java @@ -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 { + + @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(); + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CacheKeyGenerator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CacheKeyGenerator.java new file mode 100644 index 00000000..37012377 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CacheKeyGenerator.java @@ -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 varyHeaders) { + byte[] rawKey = generateRawKey(request, varyHeaders); + byte[] digest = messageDigest.digest(rawKey); + + return Base64.getEncoder().encodeToString(digest); + } + + private Stream getKeyValueGenerators(List varyHeaders) { + return Stream.concat(Stream.of(COMMON_KEY_VALUE_GENERATOR), + varyHeaders.stream().sorted().map(header -> new HeaderKeyValueGenerator(header, ","))); + } + + private byte[] generateRawKey(ServerHttpRequest request, List varyHeaders) { + Stream 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(); + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CommonKeyValueGenerator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CommonKeyValueGenerator.java new file mode 100644 index 00000000..53575b9b --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CommonKeyValueGenerator.java @@ -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 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)); + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CookiesKeyValueGenerator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CookiesKeyValueGenerator.java new file mode 100644 index 00000000..b3d6b17c --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CookiesKeyValueGenerator.java @@ -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 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; + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/HeaderKeyValueGenerator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/HeaderKeyValueGenerator.java new file mode 100644 index 00000000..ed6ba285 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/HeaderKeyValueGenerator.java @@ -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 getHeaderValues(HttpHeaders headers) { + List value = headers.get(header); + return value == null ? Stream.empty() : value.stream(); + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/KeyValueGenerator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/KeyValueGenerator.java new file mode 100644 index 00000000..4e688bc8 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/KeyValueGenerator.java @@ -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 { + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/UriKeyValueGenerator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/UriKeyValueGenerator.java new file mode 100644 index 00000000..60a0e744 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/UriKeyValueGenerator.java @@ -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(); + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/AfterCacheExchangeMutator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/AfterCacheExchangeMutator.java new file mode 100644 index 00000000..9106489d --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/AfterCacheExchangeMutator.java @@ -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 { + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutator.java new file mode 100644 index 00000000..7b9b5dc0 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutator.java @@ -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 cacheControlHeaders = headers.get(HttpHeaders.CACHE_CONTROL); + cacheControlHeaders = cacheControlHeaders == null ? Collections.emptyList() : cacheControlHeaders; + List replacedCacheControlHeaders = new ArrayList<>(); + for (String value : cacheControlHeaders) { + if (value.contains(MAX_AGE_PREFIX)) { + if (seconds == -1) { + List 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); + } + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetResponseHeadersAfterCacheExchangeMutator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetResponseHeadersAfterCacheExchangeMutator.java new file mode 100644 index 00000000..d1b0ec76 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetResponseHeadersAfterCacheExchangeMutator.java @@ -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()); + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutator.java new file mode 100644 index 00000000..41b2c817 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutator.java @@ -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); + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java index dc758224..f1407edf 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java @@ -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. + *

+ * 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. diff --git a/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json index c6062513..a4c1387e 100644 --- a/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -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", diff --git a/spring-cloud-gateway-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-cloud-gateway-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 10ced6f9..2c486c7c 100644 --- a/spring-cloud-gateway-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-cloud-gateway-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -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 \ No newline at end of file +org.springframework.cloud.gateway.config.GatewayReactiveOAuth2AutoConfiguration +org.springframework.cloud.gateway.config.LocalResponseCacheAutoConfiguration \ No newline at end of file diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java index 43cee407..2e70bf00 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java @@ -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", diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInGlobalFiltersTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInGlobalFiltersTests.java index 0db80831..db55ccb0 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInGlobalFiltersTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInGlobalFiltersTests.java @@ -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 { diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/CacheKeyGeneratorTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/CacheKeyGeneratorTest.java new file mode 100644 index 00000000..b22e9e2b --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/CacheKeyGeneratorTest.java @@ -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); + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponseTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponseTest.java new file mode 100644 index 00000000..bf55d1cb --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponseTest.java @@ -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()); + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactoryTests.java new file mode 100644 index 00000000..51df60a2 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactoryTests.java @@ -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(); + } + + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilterTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilterTest.java new file mode 100644 index 00000000..64616cc8 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilterTest.java @@ -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(); + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CommonKeyValueGeneratorTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CommonKeyValueGeneratorTest.java new file mode 100644 index 00000000..81b8bea6 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/CommonKeyValueGeneratorTest.java @@ -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 + ";" + "" + ";" + ""); + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/HeaderKeyValueGeneratorTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/HeaderKeyValueGeneratorTest.java new file mode 100644 index 00000000..38f2cdde --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/keygenerator/HeaderKeyValueGeneratorTest.java @@ -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); + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutatorTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutatorTest.java new file mode 100644 index 00000000..b2adcd64 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutatorTest.java @@ -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 firstMaxAgeSeconds = parseMaxAge(inputExchange.getResponse()); + + SetMaxAgeHeaderAfterCacheExchangeMutator toTestSecondsLater = new SetMaxAgeHeaderAfterCacheExchangeMutator( + timeToLive, clockSecondsLater); + toTestSecondsLater.accept(inputExchange, inputCachedResponse); + Optional 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 firstMaxAgeSeconds = parseMaxAge(inputExchange.getResponse()); + + SetMaxAgeHeaderAfterCacheExchangeMutator toTestSecondsLater = new SetMaxAgeHeaderAfterCacheExchangeMutator( + timeToLive, clockSecondsLater); + toTestSecondsLater.accept(inputExchange, inputCachedResponse); + Optional 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 cacheControlValues = inputExchange.getResponse().getHeaders().get("X-Custom-Header"); + assertThat(cacheControlValues).contains("DO-NOT-REMOVE"); + } + + private Optional parseMaxAge(ServerHttpResponse response) { + return parseMaxAge(response.getHeaders().getCacheControl()); + } + + private Optional 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(); + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetResponseHeadersAfterCacheExchangeMutatorTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetResponseHeadersAfterCacheExchangeMutatorTest.java new file mode 100644 index 00000000..735271ad --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetResponseHeadersAfterCacheExchangeMutatorTest.java @@ -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"); + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutatorTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutatorTest.java new file mode 100644 index 00000000..9ad1e802 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutatorTest.java @@ -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); + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/support/NameUtilsTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/support/NameUtilsTests.java index 8b3b8865..bb4a987c 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/support/NameUtilsTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/support/NameUtilsTests.java @@ -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>> predicates = Arrays.asList( AddRequestHeaderGatewayFilterFactory.class, DedupeResponseHeaderGatewayFilterFactory.class, FallbackHeadersGatewayFilterFactory.class, MapRequestHeaderGatewayFilterFactory.class, - JsonToGrpcGatewayFilterFactory.class); + JsonToGrpcGatewayFilterFactory.class, LocalResponseCacheGatewayFilterFactory.class); List resultNames = predicates.stream().map(NameUtils::normalizeFilterFactoryNameAsProperty) .collect(Collectors.toList()); List 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); } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/HttpBinCompatibleController.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/HttpBinCompatibleController.java index 5ae33997..7c6ff443 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/HttpBinCompatibleController.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/HttpBinCompatibleController.java @@ -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> 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 getHeaders(ServerWebExchange exchange) { return exchange.getRequest().getHeaders().toSingleValueMap(); }