diff --git a/src/main/java/io/github/nstdio/http/ext/Cache.java b/src/main/java/io/github/nstdio/http/ext/Cache.java index 9d6ee7e..aa13e15 100644 --- a/src/main/java/io/github/nstdio/http/ext/Cache.java +++ b/src/main/java/io/github/nstdio/http/ext/Cache.java @@ -91,6 +91,14 @@ static Cache noop() { */ void evictAll(); + /** + * Gets the statistics for this cache. + * + * @return The statistics for this cache. + * @see CacheStats + */ + CacheStats stats(); + /** * Creates a {@code Writer}. * @@ -131,6 +139,22 @@ interface CacheBuilder { Cache build(); } + interface CacheStats { + /** + * The number the cache serves stored response. + * + * @return The number of times the cache serves stored response. + */ + long hit(); + + /** + * The number the cache does not have stored response which resulted in network call. + * + * @return The number the cache does not have stored response which resulted in network call. + */ + long miss(); + } + /** * The builder for in memory cache. */ diff --git a/src/main/java/io/github/nstdio/http/ext/CachingInterceptor.java b/src/main/java/io/github/nstdio/http/ext/CachingInterceptor.java index 14cecd3..af7f1ae 100644 --- a/src/main/java/io/github/nstdio/http/ext/CachingInterceptor.java +++ b/src/main/java/io/github/nstdio/http/ext/CachingInterceptor.java @@ -24,6 +24,7 @@ import static java.util.stream.Collectors.toList; import io.github.nstdio.http.ext.Cache.CacheEntry; +import io.github.nstdio.http.ext.Cache.CacheStats; import lombok.RequiredArgsConstructor; import java.net.http.HttpRequest; @@ -33,6 +34,7 @@ import java.time.Clock; import java.util.List; import java.util.Optional; +import java.util.function.Consumer; import java.util.stream.Stream; @RequiredArgsConstructor @@ -147,6 +149,7 @@ private BodyHandler cacheAware(RequestContext ctx) { info, ctx.request(), clock); if (metadata.isApplicable()) { + trackMiss(); var writer = cache.writer(metadata); sub = new CachingBodySubscriber<>(sub, writer.subscriber(), writer.finisher()); } @@ -238,6 +241,23 @@ private HttpResponse possiblyCached(RequestContext ctx, CacheEntry entry, } private HttpResponse createCachedResponse(RequestContext ctx, CacheEntry entry) { + trackHit(); return new CachedHttpResponse<>(ctx.bodyHandler(), ctx.request(), entry); } + + + private void trackMiss() { + executeIfTrackable(TrackableCacheStats::trackMiss); + } + + private void trackHit() { + executeIfTrackable(TrackableCacheStats::trackHit); + } + + private void executeIfTrackable(Consumer action) { + CacheStats stats = cache.stats(); + if (stats instanceof TrackableCacheStats) { + action.accept((TrackableCacheStats) stats); + } + } } diff --git a/src/main/java/io/github/nstdio/http/ext/DefaultCacheStats.java b/src/main/java/io/github/nstdio/http/ext/DefaultCacheStats.java new file mode 100644 index 0000000..14e2c6a --- /dev/null +++ b/src/main/java/io/github/nstdio/http/ext/DefaultCacheStats.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2022 Edgar Asatryan + * + * 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 + * + * http://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 io.github.nstdio.http.ext; + +import java.util.concurrent.atomic.LongAdder; + +class DefaultCacheStats implements TrackableCacheStats { + private final LongAdder hit = new LongAdder(); + private final LongAdder miss = new LongAdder(); + + @Override + public long hit() { + return hit.longValue(); + } + + @Override + public long miss() { + return miss.longValue(); + } + + @Override + public void trackHit() { + hit.increment(); + } + + @Override + public void trackMiss() { + miss.increment(); + } +} diff --git a/src/main/java/io/github/nstdio/http/ext/FilteringCache.java b/src/main/java/io/github/nstdio/http/ext/FilteringCache.java index f4fe313..2e42275 100644 --- a/src/main/java/io/github/nstdio/http/ext/FilteringCache.java +++ b/src/main/java/io/github/nstdio/http/ext/FilteringCache.java @@ -66,6 +66,11 @@ public void evictAll() { delegate.evictAll(); } + @Override + public CacheStats stats() { + return delegate.stats(); + } + @Override public Writer writer(CacheEntryMetadata metadata) { if (responseFilter.test(metadata.response()) && requestFilter.test(metadata.request())) { diff --git a/src/main/java/io/github/nstdio/http/ext/NullCache.java b/src/main/java/io/github/nstdio/http/ext/NullCache.java index fd3c473..3012721 100644 --- a/src/main/java/io/github/nstdio/http/ext/NullCache.java +++ b/src/main/java/io/github/nstdio/http/ext/NullCache.java @@ -71,6 +71,11 @@ public void evictAll() { // intentional noop } + @Override + public CacheStats stats() { + return null; + } + @Override public Writer writer(CacheEntryMetadata metadata) { return writer(); diff --git a/src/main/java/io/github/nstdio/http/ext/SizeConstrainedCache.java b/src/main/java/io/github/nstdio/http/ext/SizeConstrainedCache.java index 319d97e..5e5c370 100644 --- a/src/main/java/io/github/nstdio/http/ext/SizeConstrainedCache.java +++ b/src/main/java/io/github/nstdio/http/ext/SizeConstrainedCache.java @@ -26,6 +26,7 @@ abstract class SizeConstrainedCache implements Cache { private final LruMultimap cache; + private final CacheStats stats = new DefaultCacheStats(); private final long maxBytes; private long size; @@ -68,6 +69,11 @@ public void evictAll() { cache.clear(); } + @Override + public CacheStats stats() { + return stats; + } + private void putInternal(HttpRequest k, CacheEntry e) { size += e.bodySize(); cache.putSingle(k.uri(), e, idxFn(k)); diff --git a/src/main/java/io/github/nstdio/http/ext/SynchronizedCache.java b/src/main/java/io/github/nstdio/http/ext/SynchronizedCache.java index 663c9f9..e4f0112 100644 --- a/src/main/java/io/github/nstdio/http/ext/SynchronizedCache.java +++ b/src/main/java/io/github/nstdio/http/ext/SynchronizedCache.java @@ -52,6 +52,11 @@ public synchronized void evictAll() { delegate.evictAll(); } + @Override + public /* synchronized */ CacheStats stats() { + return delegate.stats(); + } + @Override public Writer writer(CacheEntryMetadata metadata) { Writer writer = delegate.writer(metadata); diff --git a/src/main/java/io/github/nstdio/http/ext/Throwables.java b/src/main/java/io/github/nstdio/http/ext/Throwables.java index b684fff..91f7b4e 100644 --- a/src/main/java/io/github/nstdio/http/ext/Throwables.java +++ b/src/main/java/io/github/nstdio/http/ext/Throwables.java @@ -16,9 +16,6 @@ package io.github.nstdio.http.ext; -import java.io.IOException; -import java.util.concurrent.CompletionException; - class Throwables { private Throwables() { } @@ -32,14 +29,4 @@ private static T sneakyThrow0(Throwable t) throws T { throw (T) t; } - static void unwrap(CompletionException e) throws IOException, InterruptedException { - Throwable cause = e.getCause(); - if (cause instanceof IOException) { - throw (IOException) cause; - } else if (cause instanceof InterruptedException) { - throw (InterruptedException) cause; - } - - throw e; - } } diff --git a/src/main/java/io/github/nstdio/http/ext/TrackableCacheStats.java b/src/main/java/io/github/nstdio/http/ext/TrackableCacheStats.java new file mode 100644 index 0000000..545614b --- /dev/null +++ b/src/main/java/io/github/nstdio/http/ext/TrackableCacheStats.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 Edgar Asatryan + * + * 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 + * + * http://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 io.github.nstdio.http.ext; + +import io.github.nstdio.http.ext.Cache.CacheStats; + +interface TrackableCacheStats extends CacheStats { + void trackHit(); + + void trackMiss(); +} diff --git a/src/test/java/io/github/nstdio/http/ext/Assertions.java b/src/test/java/io/github/nstdio/http/ext/Assertions.java index 7134be9..8e4359e 100644 --- a/src/test/java/io/github/nstdio/http/ext/Assertions.java +++ b/src/test/java/io/github/nstdio/http/ext/Assertions.java @@ -49,6 +49,10 @@ public static HttpHeadersAssertion assertThat(HttpHeaders h) { return new HttpHeadersAssertion(h); } + public static CacheAssertion assertThat(Cache c) { + return new CacheAssertion(c); + } + public static LruMultimapAssertion assertThat(LruMultimap m) { return new LruMultimapAssertion<>(m); } @@ -234,4 +238,35 @@ public HttpResponseAssertion hasNoHeader(String headers) { return this; } } + + public static class CacheAssertion extends ObjectAssert { + public CacheAssertion(Cache cache) { + super(cache); + } + + public CacheAssertion hasHits(long expectedHit) { + assertEquals(expectedHit, stats().hit(), "cache does not have expected hit count"); + return this; + } + + private Cache.CacheStats stats() { + return actual.stats(); + } + + public CacheAssertion hasNoHits() { + assertEquals(0, stats().hit(), "cache does expected to have hits, but there is some"); + return this; + } + + public CacheAssertion hasMiss(long expectedMiss) { + assertEquals(expectedMiss, stats().miss(), "cache does not have expected miss count"); + return this; + } + + public CacheAssertion hasAtLeastMiss(long expectedMiss) { + org.assertj.core.api.Assertions.assertThat(stats().miss()) + .isGreaterThanOrEqualTo(expectedMiss); + return this; + } + } } diff --git a/src/test/java/io/github/nstdio/http/ext/DiskExtendedHttpClientIntegrationTest.java b/src/test/java/io/github/nstdio/http/ext/DiskExtendedHttpClientIntegrationTest.java index a0b281b..7f938fd 100644 --- a/src/test/java/io/github/nstdio/http/ext/DiskExtendedHttpClientIntegrationTest.java +++ b/src/test/java/io/github/nstdio/http/ext/DiskExtendedHttpClientIntegrationTest.java @@ -52,6 +52,11 @@ public ExtendedHttpClient client() { return client; } + @Override + public Cache cache() { + return cache; + } + @Override public ExtendedHttpClient client(Clock clock) { return new ExtendedHttpClient(HttpClient.newHttpClient(), cache, clock); diff --git a/src/test/java/io/github/nstdio/http/ext/ExtendedHttpClientContract.java b/src/test/java/io/github/nstdio/http/ext/ExtendedHttpClientContract.java index f6b6b9d..4f0e5e2 100644 --- a/src/test/java/io/github/nstdio/http/ext/ExtendedHttpClientContract.java +++ b/src/test/java/io/github/nstdio/http/ext/ExtendedHttpClientContract.java @@ -85,6 +85,11 @@ static URI resolve(String path) { */ ExtendedHttpClient client(); + /** + * The cache (if any) used by {@linkplain #client()}. + */ + Cache cache(); + /** * The client created with {@code clock} under the test. */ @@ -112,6 +117,7 @@ default HttpRequest.Builder requestBuilder() { @Test default void shouldSupportETagForCaching() throws Exception { //given + Cache cache = cache(); var etag = "v1"; stubFor(get(urlEqualTo(path())) .willReturn(ok() @@ -127,6 +133,7 @@ default void shouldSupportETagForCaching() throws Exception { //when + then var r1 = send(requestBuilder().build()); assertThat(r1).isNotCached(); + assertThat(cache).hasNoHits().hasMiss(1); awaitFor(() -> { var r2 = send(requestBuilder().build()); @@ -134,11 +141,13 @@ default void shouldSupportETagForCaching() throws Exception { .hasStatusCode(200) .hasBody("abc"); }); + assertThat(cache).hasHits(1).hasMiss(1); } @Test default void shouldApplyHeuristicFreshness() throws Exception { //given + Cache cache = cache(); stubFor(get(urlEqualTo(path())) .willReturn(ok() .withHeader(HEADER_LAST_MODIFIED, Headers.toRFC1123(Instant.now().minusSeconds(60))) @@ -149,11 +158,10 @@ default void shouldApplyHeuristicFreshness() throws Exception { //when + then var r1 = send(requestBuilder().build()); assertThat(r1).isNotCached(); + assertThat(cache).hasNoHits().hasMiss(1); - awaitFor(() -> { - var r2 = send(requestBuilder().build()); - assertThat(r2).isCached(); - }); + awaitFor(() -> assertThat(send(requestBuilder().build())).isCached()); + assertThat(cache).hasHits(1).hasAtLeastMiss(1); } @Test @@ -263,6 +271,7 @@ default void shouldRespectMinFreshRequests() throws Exception { }) default void shouldNotRespondWithCacheWhenNoCacheProvided(String cacheControl) throws IOException, InterruptedException { //given + var cache = cache(); var urlPattern = urlEqualTo(path()); stubFor(get(urlPattern) .willReturn(ok() @@ -280,6 +289,8 @@ default void shouldNotRespondWithCacheWhenNoCacheProvided(String cacheControl) t var noCacheControlRequest = requestBuilder().build(); //when + then + assertThat(cache).hasHits(i).hasMiss(i); + var r1 = send(request); assertThat(r1).isNotCached().hasBody("abc"); @@ -287,6 +298,8 @@ default void shouldNotRespondWithCacheWhenNoCacheProvided(String cacheControl) t var r2 = send(noCacheControlRequest); assertThat(r2).isCached().hasBody("abc"); }); + + assertThat(cache).hasHits(i + 1).hasAtLeastMiss(i + 1); } verify(count, getRequestedFor(urlPattern).withHeader(HEADER_CACHE_CONTROL, equalTo(cacheControl))); diff --git a/src/test/java/io/github/nstdio/http/ext/InMemoryExtendedHttpClientIntegrationTest.java b/src/test/java/io/github/nstdio/http/ext/InMemoryExtendedHttpClientIntegrationTest.java index cee8582..0f2c1ef 100644 --- a/src/test/java/io/github/nstdio/http/ext/InMemoryExtendedHttpClientIntegrationTest.java +++ b/src/test/java/io/github/nstdio/http/ext/InMemoryExtendedHttpClientIntegrationTest.java @@ -76,6 +76,11 @@ public ExtendedHttpClient client() { return client; } + @Override + public Cache cache() { + return cache; + } + @Override public ExtendedHttpClient client(Clock clock) { return new ExtendedHttpClient(HttpClient.newHttpClient(), cache, clock); diff --git a/src/test/java/io/github/nstdio/http/ext/NullCacheTest.java b/src/test/java/io/github/nstdio/http/ext/NullCacheTest.java new file mode 100644 index 0000000..62fba49 --- /dev/null +++ b/src/test/java/io/github/nstdio/http/ext/NullCacheTest.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2022 Edgar Asatryan + * + * 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 + * + * http://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 io.github.nstdio.http.ext; + +class NullCacheTest { + +} \ No newline at end of file diff --git a/src/test/java/io/github/nstdio/http/ext/archunit/VisibilityTest.java b/src/test/java/io/github/nstdio/http/ext/archunit/VisibilityTest.java index 629c960..0c28347 100644 --- a/src/test/java/io/github/nstdio/http/ext/archunit/VisibilityTest.java +++ b/src/test/java/io/github/nstdio/http/ext/archunit/VisibilityTest.java @@ -55,6 +55,7 @@ class VisibilityTest { .and(not(Cache.Writer.class)) .and(not(CacheEntryMetadata.class)) .and(not(Cache.class)) + .and(not(Cache.CacheStats.class)) .and(not(CacheControl.CacheControlBuilder.class)) .and(not(CacheControl.class)) .and(not(Predicates.class))