<|start_filename|>examples/Cimpress.Extensions.Http.Caching.InMemory.Examples/InMemory_basic_example.cs<|end_filename|>
﻿using System;
using System.Diagnostics;
using System.Net.Http;
using System.Threading.Tasks;
using FluentAssertions;
using Xunit;
using Cimpress.Extensions.Http.Caching.Abstractions;

namespace Cimpress.Extensions.Http.Caching.InMemory.Examples
{
    public class InMemory_basic_example
    {
        [Fact]
        public async Task Tests_in_memory_functionality()
        {
            const string url = "http://thecatapi.com/api/images/get?format=html";
            
            var handler = new InMemoryCacheHandler(new HttpClientHandler(), CacheExpirationProvider.CreateSimple(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(5)));
            using (var client = new HttpClient(handler))
            {
                for (int i = 0; i < 5; i++)
                {
                    var sw = Stopwatch.StartNew();
                    Debug.Write($"Getting data from {url}, iteration #{i + 1}...");
                    var result = await client.GetAsync(url);
                    var content = await result.Content.ReadAsStringAsync();
                    Debug.WriteLine($" completed in {sw.ElapsedMilliseconds}ms. Content was {content}.");
                }
            }

            StatsResult stats = handler.StatsProvider.GetStatistics();
            stats.Total.CacheHit.Should().Be(4);
            stats.Total.CacheMiss.Should().Be(1);
        }

        [Fact]
        public async Task Tests_cache_invalidation()
        {
            const string url = "http://thecatapi.com/api/images/get?format=html";

            var handler = new InMemoryCacheHandler(new HttpClientHandler(), CacheExpirationProvider.CreateSimple(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(5)));
            using (var client = new HttpClient(handler))
            {
                for (int i = 0; i < 5; i++)
                {
                    var sw = Stopwatch.StartNew();
                    Debug.Write($"Getting data from {url}, iteration #{i + 1}...");
                    var result = await client.GetAsync(url);
                    var content = await result.Content.ReadAsStringAsync();
                    Debug.WriteLine($" completed in {sw.ElapsedMilliseconds}ms. Content was {content}.");
                    if (i % 2 == 0)
                    {
                        Debug.WriteLine($"Iteration {i}. Invalidating cache.");
                        handler.InvalidateCache(new Uri(url));
                    }
                }
            }

            StatsResult stats = handler.StatsProvider.GetStatistics();
            stats.Total.CacheHit.Should().Be(2);
            stats.Total.CacheMiss.Should().Be(3);
        }
    }
}


<|start_filename|>src/Cimpress.Extensions.Http.Caching.Abstractions/StatsProvider.cs<|end_filename|>
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net;

namespace Cimpress.Extensions.Http.Caching.Abstractions
{
    /// <summary>
    /// Simple implementation of an <see cref="IStatsProvider"/>.
    /// </summary>
    public class StatsProvider : IStatsProvider
    {
        private readonly string cacheType;
        private readonly ConcurrentDictionary<HttpStatusCode, StatsValue> values;

        public StatsProvider(string cacheType)
        {
            this.cacheType = cacheType;
            values = new ConcurrentDictionary<HttpStatusCode, StatsValue>();
        }

        public void ReportCacheHit(HttpStatusCode statusCode)
        {
            values.AddOrUpdate(statusCode, _ => new StatsValue {CacheHit = 1}, (_, existing) =>
            {
                existing.CacheHit++;
                return existing;
            });
        }

        public void ReportCacheMiss(HttpStatusCode statusCode)
        {
            values.AddOrUpdate(statusCode, _ => new StatsValue { CacheMiss = 1 }, (_, existing) =>
            {
                existing.CacheMiss++;
                return existing;
            });
        }

        public StatsResult GetStatistics()
        {
            return new StatsResult(cacheType) {PerStatusCode = new Dictionary<HttpStatusCode, StatsValue>(values)};
        }
    }
}

<|start_filename|>test/Cimpress.Extensions.Http.Caching.Redis.UnitTests/RedisCacheHandler_when_cache_unavailable.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Caching.Distributed;
using Moq;
using Xunit;

namespace Cimpress.Extensions.Http.Caching.Redis.UnitTests
{
    public class RedisCacheHandler_when_cache_unavailable
    {
        [Fact]
        public void Gets_the_underlying_data_on_get_error()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            cache.Setup(c => c.GetAsync(HttpMethod.Get + "http://unittest/", default(CancellationToken))).ThrowsAsync(new Exception("unittest"));
            cache.Setup(c => c.SetAsync(HttpMethod.Get + "http://unittest/", It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(true));
            var client = new HttpClient(new RedisCacheHandler(testMessageHandler, new Dictionary<HttpStatusCode, TimeSpan>(), cache.Object));

            // execute
            Func<Task<HttpResponseMessage>> func = async () => await client.GetAsync("http://unittest");
            func.ShouldNotThrow();

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(1);
            cache.Verify(c => c.GetAsync(HttpMethod.Get + "http://unittest/", It.IsAny<CancellationToken>()), Times.Once);
            cache.Verify(c => c.SetAsync(HttpMethod.Get + "http://unittest/", It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>()), Times.Once);
        }

        [Fact]
        public void Ignores_set_cache_exceptions()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            cache.Setup(c => c.GetAsync(HttpMethod.Get + "http://unittest/", default(CancellationToken))).ReturnsAsync(default(byte[]));
            cache.Setup(c => c.SetAsync(HttpMethod.Get + "http://unittest/", It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>())).Throws<Exception>();
            var client = new HttpClient(new RedisCacheHandler(testMessageHandler, new Dictionary<HttpStatusCode, TimeSpan>(), cache.Object));

            // execute twice
            Func<Task<HttpResponseMessage>> func = async () => await client.GetAsync("http://unittest");
            func.ShouldNotThrow();

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(1);
            cache.Verify(c => c.GetAsync(HttpMethod.Get + "http://unittest/", It.IsAny<CancellationToken>()), Times.Once);
            cache.Verify(c => c.SetAsync(HttpMethod.Get + "http://unittest/", It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>()), Times.Once);
        }
    }
}

<|start_filename|>src/Cimpress.Extensions.Http/MessageHandlers/MeasurementHandler.cs<|end_filename|>
﻿using System;
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

namespace Cimpress.Extensions.Http.MessageHandlers
{
    /// <summary>
    /// Observes a service call and logs invocation time and outcome.
    /// </summary>
    public class MeasurementHandler : DelegatingHandler
    {
        private readonly Action<MeasurementDetails> logFunc;
        private readonly ILogger logger;

        /// <summary>
        /// Creates a new measurement handler.
        /// </summary>
        /// <param name="logger">The logger where to send the log message to after a request has been completed.</param>
        /// <param name="innerHandler">The optional inner handler.</param>
        /// <param name="logFunc">A function to log information; defaults to a standard implementation.</param>
        public MeasurementHandler(ILogger logger = null, HttpMessageHandler innerHandler = null, Action<MeasurementDetails> logFunc = null) : base(innerHandler ?? new HttpClientHandler())
        {
            this.logFunc = logFunc ?? Log;
            this.logger = logger;
        }

        /// <summary>
        /// Measures invocation time of the underlying service call and logs it.
        /// </summary>
        /// <returns></returns>
        /// <remarks>This method logs failed HTTP calls, but does not perform any exception handling
        /// - it's up to the calling client to handle erroneous code.</remarks>
        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            // start measurement
            var sw = Stopwatch.StartNew();

            // start the task of executing the request
            var sendTask = base.SendAsync(request, cancellationToken);

            // schedule the continuation without ever awaiting it
            sendTask.ContinueWith(t => { logFunc(new MeasurementDetails(request, t, sw.ElapsedMilliseconds)); }, cancellationToken);

            // return the send task
            return sendTask;
        }

        private void Log(MeasurementDetails details)
        {
            var method = details.Request.Method.Method;
            var uri = details.Request.RequestUri;

            HttpStatusCode status = 0;
            string result = null;
            if (details.SendTask.IsCompleted)
            {
                var r = details.SendTask.Result;
                status = r.StatusCode;
                result = r.IsSuccessStatusCode ? "completed successfully" : "failed";
            }
            else if (details.SendTask.IsCanceled)
            {
                result = "canceled";
            }
            else if (details.SendTask.IsFaulted)
            {
                result = "faulted";
            }

            //generates log entry like: "HTTP GET at http://localhost/foo.bar failed with status NotFound in 19ms."
            var message = "HTTP {HttpMethod} at {ServiceUri} {HttpResult} with status {HttpStatus} in {ElapsedMilliseconds}ms.";
            logger.LogInformation(message, method, uri, result, status, details.ElapsedMilliseconds);
        }
    }
}


<|start_filename|>examples/Cimpress.Extensions.Http.Caching.InMemory.Examples/InMemory_cache_fallback_example.cs<|end_filename|>
﻿using System;
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using FluentAssertions;
using Xunit;
using Cimpress.Extensions.Http.Caching.Abstractions;

namespace Cimpress.Extensions.Http.Caching.InMemory.Examples
{
    public class InMemory_cache_fallback_example
    {
        [Theory]
        [InlineData(10, 4, 1, 2000)] // timeout always occurs, but from the 2nd instance onwards we should get served from the cache
        [InlineData(5000, 0, 5, 1)] // timeout shouldn't occur; always get it from HTTP
        public async Task Tests_in_memory_cache_fallback_functionality(int maxTimeoutInMs, int cacheHits, int cacheMisses, int timeoutBetweenExecutions)
        {
            const string url = "http://thecatapi.com/api/images/get?format=html";
            
            var handler = new InMemoryCacheFallbackHandler(new HttpClientHandler(), TimeSpan.FromMilliseconds(maxTimeoutInMs), TimeSpan.FromDays(100));
            using (var client = new HttpClient(handler))
            {
                for (int i = 0; i < 5; i++)
                {
                    var sw = Stopwatch.StartNew();
                    Debug.Write($"Getting data from {url}, iteration #{i + 1}...");
                    var result = await client.GetAsync(url);
                    var content = await result.Content.ReadAsStringAsync();
                    Debug.WriteLine($" completed in {sw.ElapsedMilliseconds}ms. Content was {content}.");
                    await Task.Delay(timeoutBetweenExecutions);
                }
            }

            StatsResult stats = handler.StatsProvider.GetStatistics();
            stats.Total.CacheHit.Should().Be(cacheHits, "cache hit mismatch");
            stats.Total.CacheMiss.Should().Be(cacheMisses, "cache miss mismatch");
        }
    }
}


<|start_filename|>src/Cimpress.Extensions.Http.Caching.Abstractions/StatusCodeExtensions.cs<|end_filename|>
using System;
using System.Collections.Generic;
using System.Net;

namespace Cimpress.Extensions.Http.Caching.Abstractions
{
    /// <summary>
    /// Extension methods to HttpStatusCode that are related to caching functionality of this library.
    /// </summary>
    public static class StatusCodeExtensions
    {
        /// <summary>
        /// Gets an expiration time for the cache entry.
        /// </summary>
        /// <param name="statusCode">The status code to inspect the mapping.</param>
        /// <param name="mapping">The mapping providing configuration of what data to use.</param>
        /// <returns>A TimeSpan that should be used for the given status code when putting it into the cache.</returns>
        public static TimeSpan GetAbsoluteExpirationRelativeToNow(this HttpStatusCode statusCode, IDictionary<HttpStatusCode, TimeSpan> mapping)
        {
            int code = (int)statusCode;
            TimeSpan expiration;

            // get the expiration settings for the given status code
            if (mapping.TryGetValue(statusCode, out expiration))
            {
                return expiration;
            }

            // get the expiration settings for the status code category (200, 300, 400 and 500)
            if (mapping.TryGetValue((HttpStatusCode)(Math.Floor(code / 100.0) * 100), out expiration))
            {
                return expiration;
            }

            // return the default expiration
            return TimeSpan.FromDays(1);
        }
    }
}

<|start_filename|>src/Cimpress.Extensions.Http.Caching.Redis/RedisCacheFallbackHandler.cs<|end_filename|>
﻿using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Cimpress.Extensions.Http.Caching.Abstractions;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Redis;

namespace Cimpress.Extensions.Http.Caching.Redis
{
    /// <summary>
    /// Tries to retrieve the result from a Redis cache, and if that's not available, gets the value from the underlying handler and caches that result.
    /// </summary>
    public class RedisCacheFallbackHandler : DelegatingHandler
    {
        public IStatsProvider StatsProvider { get; }
        private readonly TimeSpan maxTimeout;
        private readonly TimeSpan cacheDuration;
        private readonly IDistributedCache responseCache;
        internal const string CacheFallbackKeyPrefix = "cfb";

        /// <summary>
        /// Used for injecting an IMemoryCache for unit testing purposes.
        /// </summary>
        /// <param name="innerHandler">The inner handler to retrieve the content from on cache misses.</param>
        /// <param name="maxTimeout">The maximum timeout to wait for the service to respond.</param>
        /// <param name="cacheDuration">The maximum time span the item should be remained in the cache if not renewed before then.</param>
        /// <param name="options">Options to use to connect to Redis.</param>
        /// /// <param name="statsProvider">An <see cref="IStatsProvider"/> that records statistic information about the caching behavior.</param>
        public RedisCacheFallbackHandler(HttpMessageHandler innerHandler, TimeSpan maxTimeout, TimeSpan cacheDuration, RedisCacheOptions options,
            IStatsProvider statsProvider = null) : this(innerHandler, maxTimeout, cacheDuration, new RedisCache(options), statsProvider) {}

        /// <summary>
        /// Used internally only for unit testing.
        /// </summary>
        /// <param name="innerHandler">The inner handler to retrieve the content from on cache misses.</param>
        /// <param name="maxTimeout">The maximum timeout to wait for the service to respond.</param>
        /// <param name="cacheDuration">The maximum time span the item should be remained in the cache if not renewed before then.</param>
        /// <param name="cache">The distributed cache to use.</param>
        /// /// <param name="statsProvider">An <see cref="IStatsProvider"/> that records statistic information about the caching behavior.</param>
        internal RedisCacheFallbackHandler(HttpMessageHandler innerHandler, TimeSpan maxTimeout, TimeSpan cacheDuration, IDistributedCache cache,
            IStatsProvider statsProvider = null) : base(innerHandler ?? new HttpClientHandler())
        {
            this.StatsProvider = statsProvider ?? new StatsProvider(nameof(RedisCacheHandler));
            this.maxTimeout = maxTimeout;
            this.cacheDuration = cacheDuration;
            responseCache = cache;
        }

        /// <summary>
        /// Tries to get the value from the cache, and only calls the delegating handler on cache misses.
        /// </summary>
        /// <returns>The HttpResponseMessage from cache, or a newly invoked one.</returns>
        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            // only handle GET methods
            if (request.Method != HttpMethod.Get && request.Method != HttpMethod.Head)
            {
                return await base.SendAsync(request, cancellationToken);
            }

            var key = CacheFallbackKeyPrefix + request.Method + request.RequestUri;

            // start 3 tasks
            var httpSendTask = base.SendAsync(request, cancellationToken);
            var timeoutTask = Task.Delay(maxTimeout, cancellationToken);
            var cts = new CancellationTokenSource(maxTimeout);
            var cacheTask = responseCache.TryGetAsync(key, cts.Token);

            // ensure the send task completes (or the timeout task, whatever comes first).
            var firstCompletedTask = await Task.WhenAny(httpSendTask, timeoutTask);

            // timeout occurred
            if (firstCompletedTask == timeoutTask)
            {
                var data = await ExtractCachedResponse(request, cacheTask);
                if (data != null)
                {
                    // update the cache after the http task eventually completes, without awaiting it
                    var cacheResultTask = httpSendTask.ContinueWith(t => SaveToCache(t.Result, key), TaskContinuationOptions.OnlyOnRanToCompletion);

                    return data;
                }
            }

            // we got it from the HTTP data directly, or it wasn't in the cache and got it after the max timeout value; save that result to the cache and return it
            var response = await httpSendTask;

            // try to save it to the cache
            var entry = await SaveToCache(response, key);

            // when successful, return that value
            if (entry != null)
            {
                StatsProvider.ReportCacheMiss(response.StatusCode);
                return request.PrepareCachedEntry(entry);
            }

            // when unsuccessful, try to get it from the cache, which was the last successful invocation
            var cachedResponse = await ExtractCachedResponse(request, cacheTask);
            if (cachedResponse != null)
            {
                return cachedResponse;
            }

            StatsProvider.ReportCacheMiss(response.StatusCode);

            return response;
        }

        private async Task<HttpResponseMessage> ExtractCachedResponse(HttpRequestMessage request, Task<CacheData> cacheTask)
        {
            // get from cache
            var data = await cacheTask;

            // it's in the cache, return that result
            if (data != null)
            {

                // get the data from the cache
                var cachedResponse = request.PrepareCachedEntry(data);
                StatsProvider.ReportCacheHit(cachedResponse.StatusCode);
                return cachedResponse;
            }

            return null;
        }

        private async Task<CacheData> SaveToCache(HttpResponseMessage response, string key)
        {
            if ((int)response.StatusCode < 500 && TimeSpan.Zero != cacheDuration)
            {
                var entry = await response.ToCacheEntry();
                var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
                await responseCache.TrySetAsync(key, entry, cacheDuration, cts.Token);
                return entry;
            }

            return null;
        }
    }
}


<|start_filename|>src/Cimpress.Extensions.Http.Caching.Abstractions/HttpResponseMessageExtensions.cs<|end_filename|>
﻿using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;

namespace Cimpress.Extensions.Http.Caching.Abstractions
{
    /// <summary>
    /// Extension methods of the HttpResponseMessage that are related to the caching functionality.
    /// </summary>
    public static class HttpResponseMessageExtensions
    {
        public static async Task<CacheData> ToCacheEntry(this HttpResponseMessage response)
        {
            var data = await response.Content.ReadAsByteArrayAsync();
            var copy = new HttpResponseMessage{ ReasonPhrase = response.ReasonPhrase, StatusCode = response.StatusCode, Version = response.Version };
            var headers = response.Headers.Where(h => h.Value != null && h.Value.Any()).ToDictionary(h => h.Key, h => h.Value);
            var contentHeaders = response.Content.Headers.Where(h => h.Value != null && h.Value.Any()).ToDictionary(h => h.Key, h => h.Value);
            var entry = new CacheData(data, copy, headers, contentHeaders);
            return entry;
        }

        /// <summary>
        /// Prepares the cached entry to be consumed by the caller, notably by setting the content.
        /// </summary>
        /// <param name="request">The request that invoked retrieving this response and need to be attached to the response.</param>
        /// <param name="cachedData">The deserialized data from the cache.</param>
        /// <returns>A valid HttpResponseMessage that can be consumed by the caller of this message handler.</returns>
        public static HttpResponseMessage PrepareCachedEntry(this HttpRequestMessage request, CacheData cachedData)
        {
            var response = cachedData.CachableResponse;
            if (cachedData.Headers != null)
            {
                foreach (var kvp in cachedData.Headers)
                {
                    response.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value);
                }
            }

            response.Content = new ByteArrayContent(cachedData.Data);
            if (cachedData.ContentHeaders != null)
            {
                foreach (var kvp in cachedData.ContentHeaders)
                {
                    response.Content.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value);
                }
            }
            response.RequestMessage = request;
            return response;
        }
    }
}

<|start_filename|>src/Cimpress.Extensions.Http.Caching.Abstractions/CacheExpirationProvider.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Net;

namespace Cimpress.Extensions.Http.Caching.Abstractions
{
    /// <summary>
    /// Provides the parameter needed to specify expiration timeouts based on HttpStatusCode for the <see cref="InMemoryCacheHandler"/>.
    /// </summary>
    public static class CacheExpirationProvider
    {
        /// <summary>
        /// Creates a configuration for the <see cref="InMemoryCacheHandler"/> for the success, client error and server error case.
        /// </summary>
        /// <param name="success">Cache expiration time for the successful retrieval of data, based on HTTP status code 200-299.</param>
        /// <param name="clientError">Cache expiration time for the errornous retrieval of data given a client error, based on HTTP status code 400-499.</param>
        /// <param name="serverError">Cache expiration time for the errornous retrieval of data given a server error, based on HTTP status code 500-599.</param>
        /// <returns></returns>
        public static IDictionary<HttpStatusCode, TimeSpan> CreateSimple(TimeSpan success, TimeSpan clientError, TimeSpan serverError)
        {
            return new Dictionary<HttpStatusCode, TimeSpan>
            {
                {HttpStatusCode.OK, success},
                {HttpStatusCode.BadRequest, clientError},
                {HttpStatusCode.InternalServerError, serverError}
            };
        }
    }
}

<|start_filename|>test/Cimpress.Extensions.Http.Caching.Redis.UnitTests/TestMessageHandler.cs<|end_filename|>
﻿using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using System.Text;

namespace Cimpress.Extensions.Http.Caching.Redis.UnitTests
{
    internal class TestMessageHandler : HttpMessageHandler
    {
        internal const HttpStatusCode DefaultResponseStatusCode = HttpStatusCode.OK;
        internal const string DefaultContent = "unit test";
        internal const string DefaultContentType = "text/plain";

        private readonly HttpStatusCode responseStatusCode;
        private readonly string content;
        private readonly string contentType;
        private readonly TimeSpan delay;
        private readonly Encoding encoding;
        private readonly EntityTagHeaderValue etag;

        public int NumberOfCalls { get; set; }
        
        public TestMessageHandler(HttpStatusCode responseStatusCode = DefaultResponseStatusCode, string content = DefaultContent, string contentType = DefaultContentType, EntityTagHeaderValue etag = null, Encoding encoding = null, TimeSpan delay = default(TimeSpan))
        {
            this.responseStatusCode = responseStatusCode;
            this.content = content;
            this.contentType = contentType;
            this.delay = delay;
            this.encoding = encoding ?? Encoding.UTF8;
            this.etag = etag ?? default(EntityTagHeaderValue);
        }

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            NumberOfCalls++;

            // optional delay
            if (delay != default(TimeSpan))
            {
                await Task.Delay(delay, cancellationToken);
            }

            // simulate actual result
            return new HttpResponseMessage {Headers = {ETag = this.etag}, Content = new StringContent(content, this.encoding, this.contentType), StatusCode = responseStatusCode};
        }

    }
}

<|start_filename|>test/Cimpress.Extensions.Http.UnitTests/MessageHandlers/MeasurementHandler_when_logging_results.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Cimpress.Extensions.Http.MessageHandlers;
using FluentAssertions;
using Xunit;

namespace Cimpress.Extensions.Http.UnitTests.MessageHandlers
{
    public class MeasurementHandler_when_logging_results
    {
        private class TestHandler : DelegatingHandler
        {
            private readonly TimeSpan executionTime;
            private readonly HttpStatusCode statusCode;
            private readonly bool throwException;
            private readonly string content;

            public TestHandler(TimeSpan executionTime, HttpStatusCode statusCode, bool throwException, string content = null)
            {
                this.executionTime = executionTime;
                this.statusCode = statusCode;
                this.throwException = throwException;
                this.content = content;
            }

            protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
            {
                await Task.Delay(executionTime, cancellationToken);
                if (throwException)
                {
                    throw new Exception("unit test");
                }
                var msg = new HttpResponseMessage(statusCode);
                if (content != null)
                {
                    msg.Content = new StringContent(content);
                }
                return msg;
            }
        }

        public static IEnumerable<object[]> LogInput
        {
            get
            {
                yield return new object[] {new Uri("http://unit.test"), HttpStatusCode.OK, false, true, false, false};
                yield return new object[] {new Uri("http://unit.test/foo/bar"), HttpStatusCode.BadRequest, false, true, false, false};
                yield return new object[] {new Uri("http://unit.test/xyz.html"), HttpStatusCode.OK, true, true, true, false};
            }
        }

        [Theory]
        [MemberData(nameof(LogInput))]
        public async Task Only_logs_when_completed(Uri uri, HttpStatusCode statusCode, bool throwException, bool isCompleted, bool isFaulted, bool isCanceled)
        {
            // setup
            var resetEvent = new ManualResetEvent(false);
            Action<MeasurementDetails> logFunc = details =>
            {
                // validate
                details.Request.Method.Method.Should().Be("GET");
                details.Request.RequestUri.Should().Be(uri);
                details.SendTask.IsCompleted.Should().Be(isCompleted);
                details.SendTask.IsFaulted.Should().Be(isFaulted);
                details.SendTask.IsCanceled.Should().Be(isCanceled);
                if (!throwException)
                {
                    details.SendTask.Result.StatusCode.Should().Be(statusCode);
                }
                resetEvent.Set();
            };
            var handler = new MeasurementHandler(null, new TestHandler(TimeSpan.FromMilliseconds(100), statusCode, throwException), logFunc);
            var client = new HttpClient(handler);

            // execute
            try
            {
                await client.GetAsync(uri);
            }
            catch (Exception)
            {
                if (!throwException) throw;
            }

            // wait until the validation has completed (but limit to max 1 second to avoid waiting forever)
            bool awaited = resetEvent.WaitOne(TimeSpan.FromSeconds(1));
            awaited.Should().BeTrue();
        }

        [Fact]
        public async Task Does_not_await_completion_of_log_function()
        {
            // setup
            var resetEvent = new ManualResetEvent(false);
            var hasLogged = false;
            Action<MeasurementDetails> logFunc = async details =>
            {
                // validate
                await Task.Delay(100);
                hasLogged = true;
                resetEvent.Set();
            };
            var expectedResult = Guid.NewGuid().ToString();
            var handler = new MeasurementHandler(null, new TestHandler(TimeSpan.FromMilliseconds(100), HttpStatusCode.OK, false, expectedResult), logFunc);
            var client = new HttpClient(handler);

            // execute
            var result = await client.GetStringAsync("http://unit.test");
            
            // validate that the string was retrieved, but the logger hasn't completed yet
            result.ShouldBeEquivalentTo(expectedResult);
            hasLogged.Should().BeFalse();

            // ensure logging eventually happened
            resetEvent.WaitOne(TimeSpan.FromSeconds(1));
            hasLogged.Should().BeTrue();
        }
    }
}

<|start_filename|>examples/Cimpress.Extensions.Http.Caching.Redis.Examples/Redis_cache_fallback_example.cs<|end_filename|>
﻿using System;
using System.Diagnostics;
using System.Net.Http;
using System.Threading.Tasks;
using Cimpress.Extensions.Http.Caching.Abstractions;
using FluentAssertions;
using Microsoft.Extensions.Caching.Redis;
using Xunit;

namespace Cimpress.Extensions.Http.Caching.Redis.Examples
{
    public class Redis_cache_fallback_example
    {
        [Theory]
        [InlineData(10, 4, 1, 2000)] // timeout always occurs, but from the 2nd instance onwards we should get served from the cache
        [InlineData(5000, 0, 5, 1)] // timeout shouldn't occur; always get it from HTTP
        public async Task Tests_redis_cache_fallback_functionality(int maxTimeoutInMs, int cacheHits, int cacheMisses, int timeoutBetweenExecutions)
        {
            const string url = "http://thecatapi.com/api/images/get?format=html";

            RedisCacheOptions options = new RedisCacheOptions
            {
                Configuration = "127.0.0.1",
                InstanceName = "example-tests" + Guid.NewGuid() // create a new instance name to ensure a unique key naming to have consistent test results
            };

            var handler = new RedisCacheFallbackHandler(new HttpClientHandler(), TimeSpan.FromMilliseconds(maxTimeoutInMs), TimeSpan.FromDays(100), options);
            using (var client = new HttpClient(handler))
            {
                for (int i = 0; i < 5; i++)
                {
                    var sw = Stopwatch.StartNew();
                    Debug.Write($"Getting data from {url}, iteration #{i + 1}...");
                    var result = await client.GetAsync(url);
                    var content = await result.Content.ReadAsStringAsync();
                    Debug.WriteLine($" completed in {sw.ElapsedMilliseconds}ms. Content was {content}.");
                    await Task.Delay(timeoutBetweenExecutions);
                }
            }

            StatsResult stats = handler.StatsProvider.GetStatistics();
            stats.Total.CacheHit.Should().Be(cacheHits, "cache hit mismatch");
            stats.Total.CacheMiss.Should().Be(cacheMisses, "cache miss mismatch");
        }
    }
}


<|start_filename|>test/Cimpress.Extensions.Http.Caching.Redis.UnitTests/RedisCacheFallbackHandler_when_sending_requests.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Cimpress.Extensions.Http.Caching.Abstractions;
using Microsoft.Extensions.Caching.Distributed;
using Moq;
using Xunit;

namespace Cimpress.Extensions.Http.Caching.Redis.UnitTests
{
    public class RedisCacheFallbackHandler_when_sending_requests
    {
        private string url = "http://unittest/";
        public static IEnumerable<object[]> GetHeadData
        {
            get
            {
                yield return new object[] { HttpMethod.Get };
                yield return new object[] { HttpMethod.Head };
            }
        }

        [Theory, MemberData(nameof(GetHeadData))]
        public async Task Always_calls_the_http_handler(HttpMethod method)
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            cache.Setup(c => c.SetAsync(RedisCacheFallbackHandler.CacheFallbackKeyPrefix + method + url, It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(true));
            var client = new HttpClient(new RedisCacheFallbackHandler(testMessageHandler, TimeSpan.FromDays(1), TimeSpan.FromDays(1), cache.Object));

            // execute twice
            await client.SendAsync(new HttpRequestMessage(method, url));
            cache.Verify(c => c.SetAsync(RedisCacheFallbackHandler.CacheFallbackKeyPrefix + method + url, It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>()), Times.Once); // ensure it's cached before the 2nd call
            await client.SendAsync(new HttpRequestMessage(method, url));

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(2);
        }

        [Theory, MemberData(nameof(GetHeadData))]
        public async Task Always_updates_the_cache_on_success(HttpMethod method)
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            var cacheTime = TimeSpan.FromSeconds(123);
            cache.Setup(c => c.SetAsync(method + url, It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(true));
            var client = new HttpClient(new RedisCacheFallbackHandler(testMessageHandler, TimeSpan.FromDays(1), cacheTime, cache.Object));

            // execute twice, validate cache is called each time
            await client.SendAsync(new HttpRequestMessage(method, url));
            cache.Verify(c => c.SetAsync(RedisCacheFallbackHandler.CacheFallbackKeyPrefix + method + url, It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>()), Times.Once);
            await client.SendAsync(new HttpRequestMessage(method, url));
            cache.Verify(c => c.SetAsync(RedisCacheFallbackHandler.CacheFallbackKeyPrefix + method + url, It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
        }

        [Theory, MemberData(nameof(GetHeadData))]
        public async Task Never_updates_the_cache_on_failure(HttpMethod method)
        {
            // setup
            var testMessageHandler = new TestMessageHandler(HttpStatusCode.InternalServerError);
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            var cacheTime = TimeSpan.FromSeconds(123);
            cache.Setup(c => c.SetAsync(It.IsAny<string>(), It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(true));
            cache.Setup(c => c.GetAsync(method + url, default(CancellationToken))).ReturnsAsync(default(byte[]));
            var client = new HttpClient(new RedisCacheFallbackHandler(testMessageHandler, TimeSpan.FromDays(1), cacheTime, cache.Object));

            // execute
            await client.SendAsync(new HttpRequestMessage(method, url));

            // validate
            cache.Verify(c => c.SetAsync(It.IsAny<string>(), It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>()), Times.Never);
        }

        [Theory, MemberData(nameof(GetHeadData))]
        public async Task Tries_to_access_cache_on_failure_but_returns_error_if_not_in_cache(HttpMethod method)
        {
            // setup
            var testMessageHandler = new TestMessageHandler(HttpStatusCode.InternalServerError);
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            var cacheTime = TimeSpan.FromSeconds(123);
            cache.Setup(c => c.GetAsync(method + url, It.IsAny<CancellationToken>())).ReturnsAsync(default(byte[]));
            var client = new HttpClient(new RedisCacheFallbackHandler(testMessageHandler, TimeSpan.FromDays(1), cacheTime, cache.Object));

            // execute
            var result = await client.SendAsync(new HttpRequestMessage(method, url));

            // validate
            result.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
        }

        [Theory, MemberData(nameof(GetHeadData))]
        public async Task Gets_it_from_the_http_call_after_being_in_cache(HttpMethod method)
        {
            // setup
            var testMessageHandler1 = new TestMessageHandler(content: "message-1", delay: TimeSpan.FromMilliseconds(100));
            var testMessageHandler2 = new TestMessageHandler(content: "message-2");
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            cache.Setup(c => c.SetAsync(RedisCacheFallbackHandler.CacheFallbackKeyPrefix + method + url, It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(true));
            var client1 = new HttpClient(new RedisCacheFallbackHandler(testMessageHandler1, TimeSpan.FromMilliseconds(1), TimeSpan.FromDays(1), cache.Object));
            var client2 = new HttpClient(new RedisCacheFallbackHandler(testMessageHandler2, TimeSpan.FromMilliseconds(1), TimeSpan.FromDays(1), cache.Object));

            // execute twice
            var result1 = await client1.SendAsync(new HttpRequestMessage(method, url));
            cache.Verify(c => c.SetAsync(RedisCacheFallbackHandler.CacheFallbackKeyPrefix + method + url, It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>()), Times.Once);
            var result2 = await client2.SendAsync(new HttpRequestMessage(method, url));

            // validate
            // - that each message handler got called
            testMessageHandler1.NumberOfCalls.Should().Be(1);
            testMessageHandler2.NumberOfCalls.Should().Be(1);

            // - that the 2nd result got served from cache
            var data1 = await result1.Content.ReadAsStringAsync();
            var data2 = await result2.Content.ReadAsStringAsync();
            data1.Should().BeEquivalentTo("message-1");
            data2.Should().BeEquivalentTo("message-2");
        }

        [Theory, MemberData(nameof(GetHeadData))]
        public async Task Gets_it_from_the_cache_when_unsuccessful(HttpMethod method)
        {
            // setup
            var responseToSerialize = await new HttpClient(new TestMessageHandler(HttpStatusCode.OK, "message-1")).GetAsync(url);
            var cacheEntryToSerialize = await responseToSerialize.ToCacheEntry();
            var serializedCacheEntry = cacheEntryToSerialize.Serialize();

            var testMessageHandler1 = new TestMessageHandler(HttpStatusCode.OK, "message-1");
            var testMessageHandler2 = new TestMessageHandler(HttpStatusCode.InternalServerError, "message-2");
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            cache.SetupSequence(c => c.GetAsync(RedisCacheFallbackHandler.CacheFallbackKeyPrefix + method + url, It.IsAny<CancellationToken>())).ReturnsAsync(default(byte[])).ReturnsAsync(serializedCacheEntry);
            var client1 = new HttpClient(new RedisCacheFallbackHandler(testMessageHandler1, TimeSpan.FromDays(1), TimeSpan.FromDays(1), cache.Object));
            var client2 = new HttpClient(new RedisCacheFallbackHandler(testMessageHandler2, TimeSpan.FromDays(1), TimeSpan.FromDays(1), cache.Object));

            // execute twice
            var result1 = await client1.SendAsync(new HttpRequestMessage(method, url));
            var result2 = await client2.SendAsync(new HttpRequestMessage(method, url));

            // validate
            // - that each message handler got called
            testMessageHandler1.NumberOfCalls.Should().Be(1);
            testMessageHandler2.NumberOfCalls.Should().Be(1);

            // - that the 2nd result got served from cache
            var data1 = await result1.Content.ReadAsStringAsync();
            var data2 = await result2.Content.ReadAsStringAsync();
            data1.Should().BeEquivalentTo("message-1");
            data2.Should().BeEquivalentTo(data1);
        }
    }
}


<|start_filename|>test/Cimpress.Extensions.Http.UnitTests/DataUriConversion_when_converting_to_base_64.cs<|end_filename|>
﻿using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.FileProviders;
using FluentAssertions;
using Moq;
using Xunit;
using System.Linq;

namespace Cimpress.Extensions.Http.UnitTests
{
    public class DataUriConversion_when_converting_to_base_64
    {
        private const string rawDataToConvert = "unittest";
        private const string rawDataBase64 = "dW5pdHRlc3Q=";

        [Fact]
        public async Task Converts_fileinfo_to_datauri()
        {
            // setup
            var stream = SetupMemoryStream();
            var fileInfo = new Mock<IFileInfo>(MockBehavior.Strict);
            fileInfo.Setup(s => s.CreateReadStream()).Returns(stream);

            // execute
            var result = await fileInfo.Object.ToDataUri("image/unittest");

            // verify
            VerifyResult(result);
        }

        [Fact]
        public async Task Converts_stream_to_datauri()
        {
            // setup
            var stream = SetupMemoryStream();

            // execute
            var result = await stream.ToDataUri("image/unittest");

            // verify
            VerifyResult(result);
        }

        [Fact]
        public void Converts_byte_to_datauri()
        {
            // setup
            byte[] data = rawDataToConvert.ToCharArray().Select(Convert.ToByte).ToArray();

            // execute
            var result = data.ToDataUri("image/unittest");

            // verify
            VerifyResult(result);
        }

        [Fact]
        public void Converts_string_to_datauri()
        {
            // execute
            var result = rawDataBase64.ToDataUri("image/unittest");

            // verify
            VerifyResult(result);
        }

        private static MemoryStream SetupMemoryStream()
        {
            byte[] data = rawDataToConvert.ToCharArray().Select(Convert.ToByte).ToArray();
            var stream = new MemoryStream(data);
            return stream;
        }

        private void VerifyResult(string result)
        {
            var expected = $"data:image/unittest;base64,{rawDataBase64}";
            result.Should().Be(expected);
        }
    }
}


<|start_filename|>test/Cimpress.Extensions.Http.Caching.InMemory.UnitTests/InMemoryCacheHandler_when_sending_request.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Xunit;

namespace Cimpress.Extensions.Http.Caching.InMemory.UnitTests
{
    public class InMemoryCacheHandler_when_sending_request
    {
        [Fact]
        public async Task Caches_the_result()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var client = new HttpClient(new InMemoryCacheHandler(testMessageHandler));

            // execute twice
            await client.GetAsync("http://unittest");
            await client.GetAsync("http://unittest");

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(1);
        }

        [Fact]
        public async Task Gets_the_data_again_after_entry_is_gone_from_cache()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new MemoryCache(new MemoryCacheOptions());
            var client = new HttpClient(new InMemoryCacheHandler(testMessageHandler, null, null, cache));

            // execute twice
            await client.GetAsync("http://unittest");
            cache.Remove(HttpMethod.Get + new Uri("http://unittest").ToString());
            await client.GetAsync("http://unittest");

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(2);
        }

        [Fact]
        public async Task Is_case_sensitive()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new MemoryCache(new MemoryCacheOptions());
            var client = new HttpClient(new InMemoryCacheHandler(testMessageHandler, null, null, cache));

            // execute for different URLs, only different by casing
            await client.GetAsync("http://unittest/foo.html");
            await client.GetAsync("http://unittest/FOO.html");

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(2);
        }
        
        [Fact]
        public async Task Caches_per_url()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new MemoryCache(new MemoryCacheOptions());
            var client = new HttpClient(new InMemoryCacheHandler(testMessageHandler, null, null, cache));

            // execute for different URLs
            await client.GetAsync("http://unittest1");
            await client.GetAsync("http://unittest2");

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(2);
        }
        
        [Fact]
        public async Task Only_caches_get_and_head_results()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new MemoryCache(new MemoryCacheOptions());
            var client = new HttpClient(new InMemoryCacheHandler(testMessageHandler, null, null, cache));

            // execute twice for different methods
            await client.PostAsync("http://unittest", new StringContent(string.Empty));
            await client.PostAsync("http://unittest", new StringContent(string.Empty));
            await client.PutAsync("http://unittest", new StringContent(string.Empty));
            await client.PutAsync("http://unittest", new StringContent(string.Empty));
            await client.DeleteAsync("http://unittest");
            await client.DeleteAsync("http://unittest");

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(6);
        }

        [Fact]
        public async Task Caches_head_and_get_request_without_conflict()
        {
            var testMessageHandler = new TestMessageHandler();
            var cache = new MemoryCache(new MemoryCacheOptions());
            var client = new HttpClient(new InMemoryCacheHandler(testMessageHandler, null, null, cache));

            // execute twice for different methods
            await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, "http://unittest"));
            await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://unittest"));

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(2);
        }
        
        [Fact]
        public async Task Data_from_call_matches_data_from_cache()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new MemoryCache(new MemoryCacheOptions());
            var client = new HttpClient(new InMemoryCacheHandler(testMessageHandler, null, null, cache));

            // execute twice for different methods
            var originalResult = await client.GetAsync("http://unittest");
            var cachedResult = await client.GetAsync("http://unittest");
            var originalResultString = await originalResult.Content.ReadAsStringAsync();
            var cachedResultString = await cachedResult.Content.ReadAsStringAsync();

            // validate
            originalResultString.ShouldBeEquivalentTo(cachedResultString);
            originalResultString.ShouldBeEquivalentTo(TestMessageHandler.DefaultContent);
        }

        [Fact]
        public async Task Returns_response_header()
        {
            // setup
            var testMessageHandler = new TestMessageHandler(System.Net.HttpStatusCode.OK, "test content", "text/plain", System.Text.Encoding.UTF8);
            var client = new HttpClient(new InMemoryCacheHandler(testMessageHandler));

            // execute 
            HttpResponseMessage response = await client.GetAsync("http://unittest");

            // validate
            response.Content.Headers.ContentType.MediaType.Should().Be("text/plain");
            response.Content.Headers.ContentType.CharSet.Should().Be("utf-8");
        }

        [Fact]
        public async Task Disable_cache_per_statusCode()
        {
            // setup
            var cacheExpirationPerStatusCode = new Dictionary<HttpStatusCode, TimeSpan>();

            cacheExpirationPerStatusCode.Add((HttpStatusCode)200, TimeSpan.FromSeconds(0));

            var testMessageHandler = new TestMessageHandler();
            var client = new HttpClient(new InMemoryCacheHandler(testMessageHandler, cacheExpirationPerStatusCode));

            // execute twice
            await client.GetAsync("http://unittest");
            await client.GetAsync("http://unittest");

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(2);
        }

        [Fact]
        public async Task Invalidates_cache_correctly()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var handler = new InMemoryCacheHandler(testMessageHandler);
            var client = new HttpClient(handler);

            // execute twice, with cache invalidation in between
            var uri = new Uri("http://unittest");
            await client.GetAsync(uri);
            handler.InvalidateCache(uri);
            await client.GetAsync(uri);

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(2);
        }

        [Fact]
        public async Task Invalidates_cache_per_method()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var handler = new InMemoryCacheHandler(testMessageHandler);
            var client = new HttpClient(handler);

            // execute with two methods, and clean up one cache
            var uri = new Uri("http://unittest");
            await client.GetAsync(uri);
            await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, uri));
            testMessageHandler.NumberOfCalls.Should().Be(2);
            
            // clean cache
            handler.InvalidateCache(uri, HttpMethod.Head);

            // execute both actions, and only one should be retrieved from cache
            await client.GetAsync(uri);
            await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, uri));
            testMessageHandler.NumberOfCalls.Should().Be(3);
        }
    }
}

<|start_filename|>src/Cimpress.Extensions.Http.Caching.Abstractions/StatsResult.cs<|end_filename|>
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;

namespace Cimpress.Extensions.Http.Caching.Abstractions
{
    public class StatsResult
    {
        public StatsResult(string cacheType)
        {
            CacheType = cacheType;
            PerStatusCode = new Dictionary<HttpStatusCode, StatsValue>();

            // using Now instead of UtcNow to make it easier to read in local time, but still including the timezone offset
            StatsCreatedAt = DateTimeOffset.Now;
        }

        /// <summary>
        /// The cache type, in order to distinguish between various caching strategies such as InMemory caching or Redis based caching.
        /// </summary>
        public string CacheType { get; }

        /// <summary>
        /// The time when these stats have been created.
        /// </summary>
        public DateTimeOffset StatsCreatedAt { get; }

        /// <summary>
        /// The statistics per status code.
        /// </summary>
        public Dictionary<HttpStatusCode, StatsValue> PerStatusCode { get; set; }

        /// <summary>
        /// The summary of <see cref="PerStatusCode"/> statistics, providing a simple overview.
        /// </summary>
        public StatsValue Total => new StatsValue {CacheHit = PerStatusCode.Sum(v => v.Value.CacheHit), CacheMiss = PerStatusCode.Sum(v => v.Value.CacheMiss)};
    }
}

<|start_filename|>test/Cimpress.Extensions.Http.Caching.Abstractions.UnitTests/StatsProvider_when_recording_statistics.cs<|end_filename|>
using System.Net;
using FluentAssertions;
using Xunit;

namespace Cimpress.Extensions.Http.Caching.Abstractions.UnitTests
{
    public class StatsProvider_when_recording_statistics
    {
        [Fact]
        public void Records_hits_correctly()
        {
            var provider = new StatsProvider("unit-test");

            for (var i = 0; i < 10; i++)
            {
                provider.ReportCacheHit(HttpStatusCode.OK);
                provider.GetStatistics().PerStatusCode[HttpStatusCode.OK].CacheHit.Should().Be(i + 1);
                provider.ReportCacheHit(HttpStatusCode.BadRequest);
                provider.GetStatistics().PerStatusCode[HttpStatusCode.BadRequest].CacheHit.Should().Be(i + 1);
                provider.GetStatistics().Total.CacheHit.Should().Be(2 * (i + 1));
                provider.GetStatistics().Total.CacheMiss.Should().Be(0);
            }
        }

        [Fact]
        public void Records_misses_correctly()
        {
            var provider = new StatsProvider("unit-test");

            for (var i = 0; i < 10; i++)
            {
                provider.ReportCacheMiss(HttpStatusCode.OK);
                provider.GetStatistics().PerStatusCode[HttpStatusCode.OK].CacheMiss.Should().Be(i + 1);
                provider.ReportCacheMiss(HttpStatusCode.InternalServerError);
                provider.GetStatistics().PerStatusCode[HttpStatusCode.InternalServerError].CacheMiss.Should().Be(i + 1);
                provider.GetStatistics().Total.CacheMiss.Should().Be(2 * (i + 1));
                provider.GetStatistics().Total.CacheHit.Should().Be(0);
            }
        }
    }
}

<|start_filename|>src/Cimpress.Extensions.Http.Caching.Redis/RedisCacheHandler.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Cimpress.Extensions.Http.Caching.Abstractions;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Redis;

namespace Cimpress.Extensions.Http.Caching.Redis
{
    /// <summary>
    /// Tries to retrieve the result from an InMemory cache, and if that's not available, gets the value from the underlying handler and caches that result.
    /// </summary>
    public class RedisCacheHandler : DelegatingHandler
    {
        public IStatsProvider StatsProvider { get; }
        private readonly IDictionary<HttpStatusCode, TimeSpan> cacheExpirationPerHttpResponseCode;
        private readonly IDistributedCache responseCache;

        /// <summary>
        /// Used for injecting an IMemoryCache for unit testing purposes.
        /// </summary>
        /// <param name="innerHandler">The inner handler to retrieve the content from on cache misses.</param>
        /// <param name="cacheExpirationPerHttpResponseCode">A mapping of HttpStatusCode to expiration times. If unspecified takes a default value.</param>
        /// <param name="options">Options to use to connect to Redis.</param>
        /// /// <param name="statsProvider">An <see cref="IStatsProvider"/> that records statistic information about the caching behavior.</param>
        public RedisCacheHandler(HttpMessageHandler innerHandler, IDictionary<HttpStatusCode, TimeSpan> cacheExpirationPerHttpResponseCode, RedisCacheOptions options,
            IStatsProvider statsProvider = null) : this(innerHandler, cacheExpirationPerHttpResponseCode, new RedisCache(options), statsProvider) {}

        /// <summary>
        /// Used internally only for unit testing.
        /// </summary>
        /// <param name="innerHandler">The inner handler to retrieve the content from on cache misses.</param>
        /// <param name="cacheExpirationPerHttpResponseCode">A mapping of HttpStatusCode to expiration times. If unspecified takes a default value.</param>
        /// <param name="cache">The distributed cache to use.</param>
        /// /// <param name="statsProvider">An <see cref="IStatsProvider"/> that records statistic information about the caching behavior.</param>
        internal RedisCacheHandler(HttpMessageHandler innerHandler, IDictionary<HttpStatusCode, TimeSpan> cacheExpirationPerHttpResponseCode, IDistributedCache cache,
            IStatsProvider statsProvider = null) : base(innerHandler ?? new HttpClientHandler())
        {
            StatsProvider = statsProvider ?? new StatsProvider(nameof(RedisCacheHandler));
            this.cacheExpirationPerHttpResponseCode = cacheExpirationPerHttpResponseCode ?? new Dictionary<HttpStatusCode, TimeSpan>();
            responseCache = cache;
        }

        /// <summary>
        /// Allows to invalidate the cache.
        /// </summary>
        /// <param name="uri">The URI to invalidate.</param>
        /// <param name="method">An optional method to invalidate. If none is provided, the cache is cleaned for all methods.</param>
        public async Task InvalidateCache(Uri uri, HttpMethod method = null)
        {
            var methods = method != null ? new[] { method } : new[] { HttpMethod.Get, HttpMethod.Head };
            var tasks = from m in methods
                let key = m + uri.ToString()
                select responseCache.RemoveAsync(key);
            await Task.WhenAll(tasks);
        }

        /// <summary>
        /// Tries to get the value from the cache, and only calls the delegating handler on cache misses.
        /// </summary>
        /// <returns>The HttpResponseMessage from cache, or a newly invoked one.</returns>
        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            var key = request.Method + request.RequestUri.ToString();
            // gets the data from cache, and returns the data if it's a cache hit
            if (request.Method == HttpMethod.Get || request.Method == HttpMethod.Head)
            {
                var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
                var data = await responseCache.TryGetAsync(key, cts.Token);
                if (data != null)
                {
                    var cachedResponse = request.PrepareCachedEntry(data);
                    StatsProvider.ReportCacheHit(cachedResponse.StatusCode);
                    return cachedResponse;
                }
            }

            // cache misses need to ask the inner handler for an actual response
            var response = await base.SendAsync(request, cancellationToken);

            // puts the retrieved response into the cache and returns the cached entry
            if (request.Method == HttpMethod.Get || request.Method == HttpMethod.Head)
            {
                var absoluteExpirationRelativeToNow = response.StatusCode.GetAbsoluteExpirationRelativeToNow(cacheExpirationPerHttpResponseCode);

                StatsProvider.ReportCacheMiss(response.StatusCode);

                if (TimeSpan.Zero != absoluteExpirationRelativeToNow)
                {
                    var entry = await response.ToCacheEntry();
                    var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
                    await responseCache.TrySetAsync(key, entry, absoluteExpirationRelativeToNow, cts.Token);
                    return request.PrepareCachedEntry(entry);
                }
            }

            // returns the original response
            return response;
        }
    }
}


<|start_filename|>src/Cimpress.Extensions.Http/DataUriConversion.cs<|end_filename|>
﻿using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;

namespace Cimpress.Extensions.Http
{
    public static class DataUriConversion
    {
        /// <summary>
        /// Downloads an image at the provided URL and converts it to a valid Data Uri scheme (https://en.wikipedia.org/wiki/Data_URI_scheme)
        /// </summary>
        /// <param name="url">The url where the image is located.</param>
        /// <param name="logger">A logger.</param>
        /// <param name="fallbackFileInfo">A FileInfo to retrieve the local fallback image.</param>
        /// <param name="fallbackMediaType">The media type of the fallback image.</param>
        /// <param name="messageHandler">An optional message handler.</param>
        /// <returns>A string that contains the data uri of the downloaded image, or a default image on any error.</returns>
        public static async Task<string> DownloadImageAndConvertToDataUri(this string url, ILogger logger, IFileInfo fallbackFileInfo, string fallbackMediaType = "image/png", HttpMessageHandler messageHandler = null)
        {
            // exclude completely invalid URLs
            if (!string.IsNullOrWhiteSpace(url))
            {
                try
                {
                    // set a timeout to 10 seconds to avoid waiting on that forever
                    using (var client = new HttpClient(messageHandler) { Timeout = TimeSpan.FromSeconds(10) })
                    {
                        var response = await client.GetAsync(url);
                        response.EnsureSuccessStatusCode();

                        // set the media type and default to JPG if it wasn't provided
                        string mediaType = response.Content.Headers.ContentType?.MediaType;
                        mediaType = string.IsNullOrWhiteSpace(mediaType) ? "image/jpeg" : mediaType;

                        // return the data URI according to the standard
                        return (await response.Content.ReadAsByteArrayAsync()).ToDataUri(mediaType);
                    }
                }
                catch (Exception ex)
                {
                    logger.LogInformation(0, ex, "Error while downloading resource at {URL}", url);
                }
            }

            // any error or invalid URLs just return the default data uri
            return await fallbackFileInfo.ToDataUri(fallbackMediaType);
        }

        /// <summary>
        /// Converts a file info to a data URL.
        /// </summary>
        /// <param name="fileInfo">The fileInfo that points to a file which content should be converted to a Data URI format.</param>
        /// <param name="mediaType">The media type of the file.</param>
        /// <returns>A string that contains the data uri of the provided file content.</returns>
        public static Task<string> ToDataUri(this IFileInfo fileInfo, string mediaType)
        {
            return fileInfo.CreateReadStream().ToDataUri(mediaType);
        }

        /// <summary>
        /// Converts a stream to a data URL.
        /// </summary>
        /// <param name="stream">The stream which provides the content for the data URI.</param>
        /// <param name="mediaType">The media type of the stream.</param>
        /// <returns>A string that contains the data uri of the stream's content.</returns>
        public static async Task<string> ToDataUri(this Stream stream, string mediaType)
        {
            // copy to memory stream and convert the bytes to a base64 encoded string
            using (var ms = new MemoryStream())
            {
                await stream.CopyToAsync(ms);
                return ms.ToArray().ToDataUri(mediaType);
            }
        }

        /// <summary>
        /// Converts a byte array to a data URL.
        /// </summary>
        /// <param name="data">The data that should be converted to base64 and included in the data URI.</param>
        /// <param name="mediaType">The media type of the content.</param>
        /// <returns>A string that contains the data uri of the provided data.</returns>
        public static string ToDataUri(this byte[] data, string mediaType)
        {
            return Convert.ToBase64String(data).ToDataUri(mediaType);
        }

        /// <summary>
        /// Converts a byte array to a data URL.
        /// </summary>
        /// <param name="base64String">The base64 encoded string that represents the data.</param>
        /// <param name="mediaType">The media type of the content.</param>
        /// <returns>A string that contains the data uri of the provided data.</returns>
        public static string ToDataUri(this string base64String, string mediaType)
        {
            return $"data:{mediaType};base64,{base64String}";
        }
    }
}

<|start_filename|>test/Cimpress.Extensions.Http.UnitTests/MessageHandlers/ConcurrencyLimitingHandler_when_limiting_concurrency.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Cimpress.Extensions.Http.MessageHandlers;
using FluentAssertions;
using Xunit;

namespace Cimpress.Extensions.Http.UnitTests.MessageHandlers
{
    public class ConcurrencyLimitingHandler_when_limiting_concurrency
    {
        private class TestHandler : DelegatingHandler
        {
            private readonly TimeSpan executionTime = TimeSpan.FromMilliseconds(10);
            private int numberOfExecutions;
            private readonly int maxNumberOfExecutions;
            
            public int MaxSeenNumberOfExecutions { get; private set; }

            public TestHandler(int maxNumberOfExecutions)
            {
                this.maxNumberOfExecutions = maxNumberOfExecutions;
            }
            
            protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
            {
                numberOfExecutions++;
                MaxSeenNumberOfExecutions = Math.Max(numberOfExecutions, MaxSeenNumberOfExecutions);
                if (numberOfExecutions > maxNumberOfExecutions)
                {
                    throw new Exception("Max number of execution reached.");
                }
                await Task.Delay(executionTime, cancellationToken);
                var msg = new HttpResponseMessage(HttpStatusCode.OK);
                numberOfExecutions--;
                return msg;
            }
        }

        public static IEnumerable<object[]> TestData
        {
            get
            {
                var urls = Enumerable.Range(0, 100).Select(r => new Uri($"http://unit.test/{r}"));
                yield return new object[] {urls, 10};
                yield return new object[] {urls, 5};
                yield return new object[] {urls, 20};
            }
        }

        [Theory]
        [MemberData(nameof(TestData))]
        public void Does_not_exceed_concurrency(IEnumerable<Uri> uris, int maxConcurrency)
        {
            // setup
            var testHandler = new TestHandler(maxConcurrency);
            var handler = new ConcurrencyLimitingMessageHandler(maxConcurrency, testHandler);
            var client = new HttpClient(handler);

            // execute
            var parallelRequests = uris.Select(uri => client.GetAsync(uri));
            Func<Task> exec = async () => await Task.WhenAll(parallelRequests);
            
            // validate
            // when validating the maximal seen number of execution, let's allow for some error in case the
            // test ran a bit slower. Usually it should match though.
            exec.ShouldNotThrow($"Error while executing with max concurrency of {maxConcurrency}");
            testHandler.MaxSeenNumberOfExecutions.Should().BeInRange(maxConcurrency - 2, maxConcurrency);
        }

        [Fact]
        public void Respects_cancellation_token()
        {
            // setup
            var handler = new ConcurrencyLimitingMessageHandler(0, new TestHandler(0));
            var client = new HttpClient(handler) {Timeout = TimeSpan.FromMilliseconds(1)};

            // execute
            Func<Task> exec = async () => await client.GetAsync("http://localhost/bar");

            // validate
            exec.ShouldThrow<OperationCanceledException>();
        }
    }
}

<|start_filename|>src/Cimpress.Extensions.Http.Caching.InMemory/InMemoryCacheFallbackHandler.cs<|end_filename|>
﻿using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Cimpress.Extensions.Http.Caching.Abstractions;

namespace Cimpress.Extensions.Http.Caching.InMemory
{
    /// <summary>
    /// Tries to retrieve the result from the HTTP call, and if it times out or results in an unsuccessful status code, tries to get it from cache.
    /// </summary>
    public class InMemoryCacheFallbackHandler : DelegatingHandler
    {
        public IStatsProvider StatsProvider { get; }
        private readonly TimeSpan maxTimeout;
        private readonly TimeSpan cacheDuration;
        private readonly IMemoryCache responseCache;
        internal const string CacheFallbackKeyPrefix = "cfb";

        /// <summary>
        /// Create a new InMemoryCacheHandler.
        /// </summary>
        /// <param name="innerHandler">The inner handler to retrieve the content from on cache misses.</param>
        /// <param name="maxTimeout">The maximum timeout to wait for the service to respond.</param>
        /// <param name="cacheDuration">The maximum time span the item should be remained in the cache if not renewed before then.</param>
        /// <param name="statsProvider">An <see cref="IStatsProvider"/> that records statistic information about the caching behavior.</param>
        public InMemoryCacheFallbackHandler(HttpMessageHandler innerHandler, TimeSpan maxTimeout, TimeSpan cacheDuration, IStatsProvider statsProvider = null)
            : this(innerHandler, maxTimeout, cacheDuration, statsProvider, new MemoryCache(new MemoryCacheOptions())) {}

        /// <summary>
        /// Used for injecting an IMemoryCache for unit testing purposes.
        /// </summary>
        /// <param name="innerHandler">The inner handler to retrieve the content from on cache misses.</param>
        /// <param name="maxTimeout">The maximum timeout to wait for the service to respond.</param>
        /// <param name="cacheDuration">The maximum time span the item should be remained in the cache if not renewed before then.</param>
        /// <param name="statsProvider">An <see cref="IStatsProvider"/> that records statistic information about the caching behavior.</param>
        /// <param name="cache">The cache to be used.</param>
        internal InMemoryCacheFallbackHandler(HttpMessageHandler innerHandler, TimeSpan maxTimeout, TimeSpan cacheDuration, IStatsProvider statsProvider, IMemoryCache cache) : base(innerHandler ?? new HttpClientHandler())
        {
            this.StatsProvider = statsProvider ?? new StatsProvider(nameof(InMemoryCacheHandler));
            this.maxTimeout = maxTimeout;
            this.cacheDuration = cacheDuration;
            responseCache = cache ?? new MemoryCache(new MemoryCacheOptions());
        }

        /// <summary>
        /// Tries to get the value from the cache, and only calls the delegating handler on cache misses.
        /// </summary>
        /// <returns>The HttpResponseMessage from cache, or a newly invoked one.</returns>
        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            // only handle GET methods
            if (request.Method != HttpMethod.Get && request.Method != HttpMethod.Head)
            {
                return await base.SendAsync(request, cancellationToken);
            }

            var key = CacheFallbackKeyPrefix + request.Method + request.RequestUri.ToString();

            // start 3 tasks
            var httpSendTask = base.SendAsync(request, cancellationToken);
            var timeoutTask = Task.Delay(maxTimeout, cancellationToken);
            var cacheTask = responseCache.TryGetAsync(key);
            
            // ensure the send task completes (or the timeout task, whatever comes first).
            var firstCompletedTask = await Task.WhenAny(httpSendTask, timeoutTask);

            // timeout occurred
            if (firstCompletedTask == timeoutTask)
            {
                var data = await ExtractCachedResponse(request, cacheTask);
                if (data != null)
                {
                    // update the cache after the http task eventually completes, without awaiting it
                    var cacheResultTask = httpSendTask.ContinueWith(t => SaveToCache(t.Result, key), TaskContinuationOptions.OnlyOnRanToCompletion);

                    return data;
                }
            }

            // we got it from the HTTP data directly, or it wasn't in the cache and got it after the max timeout value; save that result to the cache and return it
            var response = await httpSendTask;
            
            // try to save it to the cache
            var entry = await SaveToCache(response, key);

            // when successful, return that value
            if (entry != null)
            {
                StatsProvider.ReportCacheMiss(response.StatusCode);
                return request.PrepareCachedEntry(entry);
            }

            // when unsuccessful, try to get it from the cache, which was the last successful invocation
            var cachedResponse = await ExtractCachedResponse(request, cacheTask);
            if (cachedResponse != null)
            {
                return cachedResponse;
            }

            StatsProvider.ReportCacheMiss(response.StatusCode);

            return response;
        }

        private async Task<HttpResponseMessage> ExtractCachedResponse(HttpRequestMessage request, Task<CacheData> cacheTask)
        { 
            // get from cache
            var data = await cacheTask;

            // it's in the cache, return that result
            if (data != null)
            {
                
                // get the data from the cache
                var cachedResponse = request.PrepareCachedEntry(data);
                StatsProvider.ReportCacheHit(cachedResponse.StatusCode);
                return cachedResponse;
            }

            return null;
        }

        private async Task<CacheData> SaveToCache(HttpResponseMessage response, string key)
        {
            if ((int) response.StatusCode < 500 && TimeSpan.Zero != cacheDuration)
            {
                var entry = await response.ToCacheEntry();
                await responseCache.TrySetAsync(key, entry, cacheDuration);
                return entry;
            }

            return null;
        }
    }
}


<|start_filename|>test/Cimpress.Extensions.Http.Caching.Abstractions.UnitTests/StatusCodeExtensions_when_calculating_cache_expiration.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Net;
using FluentAssertions;
using Xunit;

namespace Cimpress.Extensions.Http.Caching.Abstractions.UnitTests
{
    public class StatusCodeExtensions_when_calculating_cache_expiration
    {
        [Fact]
        public void Takes_value_from_Http_code()
        {
            // setup
            var ticks = new Random().Next(0, 100000);
            var code = HttpStatusCode.NotFound;
            var mappings = new Dictionary<HttpStatusCode, TimeSpan> { {code, TimeSpan.FromTicks(ticks)} };

            // execute
            TimeSpan result = code.GetAbsoluteExpirationRelativeToNow(mappings);

            // validate
            result.Should().Be(mappings[code]);
        }

        [Fact]
        public void Takes_value_from_http_code_category()
        {
            // setup
            var r = new Random();
            var mappings = CacheExpirationProvider.CreateSimple(TimeSpan.FromTicks(r.Next(0, 100000)), TimeSpan.FromTicks(r.Next(0, 100000)), TimeSpan.FromTicks(r.Next(0, 100000)));

            // execute
            TimeSpan successResult = HttpStatusCode.Created.GetAbsoluteExpirationRelativeToNow(mappings);
            TimeSpan clientErrorResult = HttpStatusCode.NotFound.GetAbsoluteExpirationRelativeToNow(mappings);
            TimeSpan serverErrorResult = HttpStatusCode.GatewayTimeout.GetAbsoluteExpirationRelativeToNow(mappings);

            // validate
            successResult.Should().Be(mappings[HttpStatusCode.OK]);
            clientErrorResult.Should().Be(mappings[HttpStatusCode.BadRequest]);
            serverErrorResult.Should().Be(mappings[HttpStatusCode.InternalServerError]);
        }

        [Fact]
        public void Takes_default_value()
        {
            // setup
            var mappings = new Dictionary<HttpStatusCode, TimeSpan>();

            // execute
            TimeSpan result = HttpStatusCode.OK.GetAbsoluteExpirationRelativeToNow(mappings);
            
            // validate
            result.Should().Be(TimeSpan.FromDays(1));
        }
    }
}


<|start_filename|>src/Cimpress.Extensions.Http.Caching.InMemory/IMemoryCacheExtensions.cs<|end_filename|>
﻿using System;
using System.Threading.Tasks;
using Cimpress.Extensions.Http.Caching.Abstractions;
using Microsoft.Extensions.Caching.Memory;

namespace Cimpress.Extensions.Http.Caching.InMemory
{
    /// <summary>
    /// Extension methods for an <see cref="IMemoryCache"/>.
    /// </summary>
    internal static class IMemoryCacheExtensions
    {
        /// <summary>
        /// Tries to get the data from cache, that is, ignoring all exceptions.
        /// </summary>
        /// <param name="cache">The in memory cache.</param>
        /// <param name="key">The key to retrieve from the cache.</param>
        /// <returns>The data of the cache entry, or null if not found or on any error.</returns>
        public static Task<CacheData> TryGetAsync(this IMemoryCache cache, string key)
        {
            try
            {
                byte[] binaryData = null;
                if (cache.TryGetValue(key, out binaryData))
                {
                    return Task.FromResult(binaryData.Deserialize());
                }
                return Task.FromResult(default(CacheData));
            }
            catch (Exception)
            {
                // ignore all exceptions; return null
                return Task.FromResult(default(CacheData));
            }
        }

        /// <summary>
        /// Tries to set a new value to the cache, that is, ignoring all exceptions.
        /// </summary>
        /// <param name="cache">The in memory cache.</param>
        /// <param name="key">The key for this cache entry.</param>
        /// <param name="value">The value of this cache entry.</param>
        /// <param name="absoluteExpirationRelativeToNow">Expiration relative to now.</param>
        /// <returns>A task, when completed, has tried to put the entry into the cache.</returns>
        public static Task TrySetAsync(this IMemoryCache cache, string key, CacheData value, TimeSpan absoluteExpirationRelativeToNow)
        {
            try
            {
                cache.Set(key, value.Serialize(), absoluteExpirationRelativeToNow);
                return Task.FromResult(true);
            }
            catch (Exception)
            {
                // ignore all exceptions
                return Task.FromResult(false);
            }
        }
    }
}

<|start_filename|>test/Cimpress.Extensions.Http.Caching.InMemory.UnitTests/InMemoryCacheFallbackHandler_when_sending_request.cs<|end_filename|>
﻿using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Moq;
using Xunit;

namespace Cimpress.Extensions.Http.Caching.InMemory.UnitTests
{
    public class InMemoryCacheFallbackHandler_when_sending_request
    {
        private string url = "http://unittest/";

        [Fact]
        public async Task Always_calls_the_http_handler()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new MemoryCache(new MemoryCacheOptions());
            var client = new HttpClient(new InMemoryCacheFallbackHandler(testMessageHandler, TimeSpan.FromDays(1), TimeSpan.FromDays(1), null, cache));

            // execute twice
            await client.GetAsync(url);
            cache.Get(InMemoryCacheFallbackHandler.CacheFallbackKeyPrefix + HttpMethod.Get + url).Should().NotBeNull(); // ensure it's cached before the 2nd call
            await client.GetAsync(url);

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(2);
        }

        [Fact]
        public async Task Always_updates_the_cache_on_success()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new Mock<IMemoryCache>(MockBehavior.Strict);
            var cacheTime = TimeSpan.FromSeconds(123);
            cache.Setup(c => c.CreateEntry(InMemoryCacheFallbackHandler.CacheFallbackKeyPrefix + HttpMethod.Get + url));
            var client = new HttpClient(new InMemoryCacheFallbackHandler(testMessageHandler, TimeSpan.FromDays(1), cacheTime, null, cache.Object));

            // execute twice, validate cache is called each time
            await client.GetAsync(url);
            cache.Verify(c => c.CreateEntry(InMemoryCacheFallbackHandler.CacheFallbackKeyPrefix + HttpMethod.Get + url), Times.Once);
            await client.GetAsync(url);
            cache.Verify(c => c.CreateEntry(InMemoryCacheFallbackHandler.CacheFallbackKeyPrefix + HttpMethod.Get + url), Times.Exactly(2));
        }

        [Fact]
        public async Task Updates_the_cache_for_head_and_get_independently()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new Mock<IMemoryCache>(MockBehavior.Strict);
            var cacheTime = TimeSpan.FromSeconds(123);
            cache.Setup(c => c.CreateEntry(InMemoryCacheFallbackHandler.CacheFallbackKeyPrefix + HttpMethod.Get + url));
            cache.Setup(c => c.CreateEntry(InMemoryCacheFallbackHandler.CacheFallbackKeyPrefix + HttpMethod.Head + url));
            var client = new HttpClient(new InMemoryCacheFallbackHandler(testMessageHandler, TimeSpan.FromDays(1), cacheTime, null, cache.Object));

            // execute twice, validate cache is called each time
            await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, url));
            await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, url));
            cache.Verify(c => c.CreateEntry(InMemoryCacheFallbackHandler.CacheFallbackKeyPrefix + HttpMethod.Head + url), Times.Once);
            cache.Verify(c => c.CreateEntry(InMemoryCacheFallbackHandler.CacheFallbackKeyPrefix + HttpMethod.Get + url), Times.Once);
        }
        
        [Fact]
        public async Task Never_updates_the_cache_on_failure()
        {
            // setup
            var testMessageHandler = new TestMessageHandler(HttpStatusCode.InternalServerError);
            var cache = new Mock<IMemoryCache>(MockBehavior.Strict);
            var cacheTime = TimeSpan.FromSeconds(123);
            object expectedValue;
            cache.Setup(c => c.CreateEntry(It.IsAny<string>()));
            cache.Setup(c => c.TryGetValue(url, out expectedValue)).Returns(false);
            var client = new HttpClient(new InMemoryCacheFallbackHandler(testMessageHandler, TimeSpan.FromDays(1), cacheTime, null, cache.Object));

            // execute
            await client.GetAsync(url);
            
            // validate
            cache.Verify(c => c.CreateEntry(It.IsAny<string>()), Times.Never);
        }

        [Fact]
        public async Task Tries_to_access_cache_on_failure_but_returns_error_if_not_in_cache()
        {
            // setup
            var testMessageHandler = new TestMessageHandler(HttpStatusCode.InternalServerError);
            var cache = new Mock<IMemoryCache>(MockBehavior.Strict);
            var cacheTime = TimeSpan.FromSeconds(123);
            object expectedValue;
            cache.Setup(c => c.TryGetValue(url, out expectedValue)).Returns(false);
            var client = new HttpClient(new InMemoryCacheFallbackHandler(testMessageHandler, TimeSpan.FromDays(1), cacheTime, null, cache.Object));

            // execute
            var result = await client.GetAsync(url);

            // validate
            result.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
        }

        [Fact]
        public async Task Gets_it_from_the_http_call_after_being_in_cache()
        {
            // setup
            var testMessageHandler1 = new TestMessageHandler(content: "message-1", delay: TimeSpan.FromMilliseconds(100));
            var testMessageHandler2 = new TestMessageHandler(content: "message-2");
            var cache = new MemoryCache(new MemoryCacheOptions());
            var client1 = new HttpClient(new InMemoryCacheFallbackHandler(testMessageHandler1, TimeSpan.FromMilliseconds(1), TimeSpan.FromDays(1), null, cache));
            var client2 = new HttpClient(new InMemoryCacheFallbackHandler(testMessageHandler2, TimeSpan.FromMilliseconds(1), TimeSpan.FromDays(1), null, cache));

            // execute twice
            var result1 = await client1.GetAsync(url);
            cache.Get(InMemoryCacheFallbackHandler.CacheFallbackKeyPrefix + HttpMethod.Get + url).Should().NotBeNull();
            var result2 = await client2.GetAsync(url);

            // validate
            // - that each message handler got called
            testMessageHandler1.NumberOfCalls.Should().Be(1);
            testMessageHandler2.NumberOfCalls.Should().Be(1);

            // - that the 2nd result got served from the http call
            var data1 = await result1.Content.ReadAsStringAsync();
            var data2 = await result2.Content.ReadAsStringAsync();
            data1.Should().BeEquivalentTo("message-1");
            data2.Should().BeEquivalentTo("message-2");
        }

        [Fact]
        public async Task Gets_it_from_the_cache_when_unsuccessful()
        {
            // setup
            var testMessageHandler1 = new TestMessageHandler(HttpStatusCode.OK, "message-1");
            var testMessageHandler2 = new TestMessageHandler(HttpStatusCode.InternalServerError, "message-2");
            var cache = new MemoryCache(new MemoryCacheOptions());
            var client1 = new HttpClient(new InMemoryCacheFallbackHandler(testMessageHandler1, TimeSpan.FromDays(1), TimeSpan.FromDays(1), null, cache));
            var client2 = new HttpClient(new InMemoryCacheFallbackHandler(testMessageHandler2, TimeSpan.FromDays(1), TimeSpan.FromDays(1), null, cache));

            // execute twice
            var result1 = await client1.GetAsync(url);
            var result2 = await client2.GetAsync(url);

            // validate
            // - that each message handler got called
            testMessageHandler1.NumberOfCalls.Should().Be(1);
            testMessageHandler2.NumberOfCalls.Should().Be(1);

            // - that the 2nd result got served from cache
            var data1 = await result1.Content.ReadAsStringAsync();
            var data2 = await result2.Content.ReadAsStringAsync();
            data1.Should().BeEquivalentTo("message-1");
            data2.Should().BeEquivalentTo(data1);
        }
    }
}

<|start_filename|>src/Cimpress.Extensions.Http/MessageHandlers/ConcurrencyLimitingMessageHandler.cs<|end_filename|>
﻿using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace Cimpress.Extensions.Http.MessageHandlers
{
    /// <summary>
    /// A message handler that limits concurrency.
    /// </summary>
    public class ConcurrencyLimitingMessageHandler : DelegatingHandler
    {
        private readonly SemaphoreSlim concurrencySemaphore;

        /// <summary>
        /// Creates a new concurrency limiting message handler.
        /// </summary>
        /// <param name="maxConcurrency">The max concurrency across all requests.</param>
        public ConcurrencyLimitingMessageHandler(int maxConcurrency) : this(maxConcurrency, new HttpClientHandler()) { }

        /// <summary>
        /// Creates a new concurrency limiting message handler.
        /// </summary>
        /// <param name="maxConcurrency">The max concurrency across all requests.</param>
        /// <param name="innerHandler">The inner handler to call for executing the actual request.</param>
        public ConcurrencyLimitingMessageHandler(int maxConcurrency, HttpMessageHandler innerHandler) : base(innerHandler)
        {
            concurrencySemaphore = new SemaphoreSlim(maxConcurrency);
        }

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            // Get access to continue on this code
            await concurrencySemaphore.WaitAsync(cancellationToken);
            try
            {
                // call the inner handler
                return await base.SendAsync(request, cancellationToken);
            }
            finally
            {
                // release the concurrency block again
                concurrencySemaphore.Release();
            }
        }
    }
}

<|start_filename|>examples/Cimpress.Extensions.Http.Caching.Redis.Examples/Redis_basic_example.cs<|end_filename|>
﻿using System;
using System.Diagnostics;
using System.Net.Http;
using System.Threading.Tasks;
using Cimpress.Extensions.Http.Caching.Abstractions;
using FluentAssertions;
using Microsoft.Extensions.Caching.Redis;
using Xunit;

namespace Cimpress.Extensions.Http.Caching.Redis.Examples
{
    public class Redis_basic_example
    {
        [Fact]
        public async Task Tests_redis_live_connection()
        {
            const string url = "http://thecatapi.com/api/images/get?format=html";

            RedisCacheOptions options = new RedisCacheOptions
            {
                Configuration = "127.0.0.1",
                InstanceName = "example-tests" + Guid.NewGuid() // create a new instance name to ensure a unique key naming to have consistent test results
            };

            var handler = new RedisCacheHandler(new HttpClientHandler(), CacheExpirationProvider.CreateSimple(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(5)), options);
            using (var client = new HttpClient(handler))
            {
                for (int i = 0; i < 5; i++)
                {
                    var sw = Stopwatch.StartNew();
                    Debug.Write($"Getting data from {url}, iteration #{i + 1}...");
                    var result = await client.GetAsync(url);
                    var content = await result.Content.ReadAsStringAsync();
                    Debug.WriteLine($" completed in {sw.ElapsedMilliseconds}ms. Content was {content}.");
                }
            }

            StatsResult stats = handler.StatsProvider.GetStatistics();
            stats.Total.CacheHit.Should().Be(4);
            stats.Total.CacheMiss.Should().Be(1);
        }

        [Fact]
        public async Task Tests_cache_invalidation()
        {
            const string url = "http://thecatapi.com/api/images/get?format=html";

            RedisCacheOptions options = new RedisCacheOptions
            {
                Configuration = "127.0.0.1",
                InstanceName = "example-tests" + Guid.NewGuid() // create a new instance name to ensure a unique key naming to have consistent test results
            };

            var handler = new RedisCacheHandler(new HttpClientHandler(), CacheExpirationProvider.CreateSimple(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(5)), options);
            using (var client = new HttpClient(handler))
            {
                for (int i = 0; i < 5; i++)
                {
                    var sw = Stopwatch.StartNew();
                    Debug.Write($"Getting data from {url}, iteration #{i + 1}...");
                    var result = await client.GetAsync(url);
                    var content = await result.Content.ReadAsStringAsync();
                    Debug.WriteLine($" completed in {sw.ElapsedMilliseconds}ms. Content was {content}.");
                    if (i % 2 == 0)
                    {
                        Debug.WriteLine($"Iteration {i}. Invalidating cache.");
                        await handler.InvalidateCache(new Uri(url));
                    }
                }
            }

            StatsResult stats = handler.StatsProvider.GetStatistics();
            stats.Total.CacheHit.Should().Be(2);
            stats.Total.CacheMiss.Should().Be(3);
        }
    }
}


<|start_filename|>src/Cimpress.Extensions.Http.Caching.InMemory/InMemoryCacheHandler.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Cimpress.Extensions.Http.Caching.Abstractions;

namespace Cimpress.Extensions.Http.Caching.InMemory
{
    /// <summary>
    /// Tries to retrieve the result from an InMemory cache, and if that's not available, gets the value from the underlying handler and caches that result.
    /// </summary>
    public class InMemoryCacheHandler : DelegatingHandler
    {
        public IStatsProvider StatsProvider { get; }
        private readonly IDictionary<HttpStatusCode, TimeSpan> cacheExpirationPerHttpResponseCode;
        private readonly IMemoryCache responseCache;

        /// <summary>
        /// Create a new InMemoryCacheHandler.
        /// </summary>
        /// <param name="innerHandler">The inner handler to retrieve the content from on cache misses.</param>
        /// <param name="cacheExpirationPerHttpResponseCode">A mapping of HttpStatusCode to expiration times. If unspecified takes a default value.</param>
        /// <param name="statsProvider">An <see cref="IStatsProvider"/> that records statistic information about the caching behavior.</param>
        public InMemoryCacheHandler(HttpMessageHandler innerHandler, IDictionary<HttpStatusCode, TimeSpan> cacheExpirationPerHttpResponseCode = null, IStatsProvider statsProvider = null)
            : this(innerHandler, cacheExpirationPerHttpResponseCode, statsProvider, new MemoryCache(new MemoryCacheOptions())) {}

        /// <summary>
        /// Used for injecting an IMemoryCache for unit testing purposes.
        /// </summary>
        /// <param name="innerHandler">The inner handler to retrieve the content from on cache misses.</param>
        /// <param name="cacheExpirationPerHttpResponseCode">A mapping of HttpStatusCode to expiration times. If unspecified takes a default value.</param>
        /// <param name="statsProvider">An <see cref="IStatsProvider"/> that records statistic information about the caching behavior.</param>
        /// <param name="cache">The cache to be used.</param>
        internal InMemoryCacheHandler(HttpMessageHandler innerHandler, IDictionary<HttpStatusCode, TimeSpan> cacheExpirationPerHttpResponseCode, IStatsProvider statsProvider, IMemoryCache cache) : base(innerHandler ?? new HttpClientHandler())
        {
            this.StatsProvider = statsProvider ?? new StatsProvider(nameof(InMemoryCacheHandler));
            this.cacheExpirationPerHttpResponseCode = cacheExpirationPerHttpResponseCode ?? new Dictionary<HttpStatusCode, TimeSpan>();
            responseCache = cache ?? new MemoryCache(new MemoryCacheOptions());
        }

        /// <summary>
        /// Allows to invalidate the cache.
        /// </summary>
        /// <param name="uri">The URI to invalidate.</param>
        /// <param name="method">An optional method to invalidate. If none is provided, the cache is cleaned for all methods.</param>
        public void InvalidateCache(Uri uri, HttpMethod method = null)
        {
            var methods = method != null ? new[] {method} : new[] {HttpMethod.Get, HttpMethod.Head};
            foreach (var m in methods)
            {
                var key = m + uri.ToString();
                responseCache.Remove(key);
            }
        }

        /// <summary>
        /// Tries to get the value from the cache, and only calls the delegating handler on cache misses.
        /// </summary>
        /// <returns>The HttpResponseMessage from cache, or a newly invoked one.</returns>
        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            var key = request.Method + request.RequestUri.ToString();
            // gets the data from cache, and returns the data if it's a cache hit
            if (request.Method == HttpMethod.Get || request.Method == HttpMethod.Head)
            {
                var data = await responseCache.TryGetAsync(key);
                if (data != null)
                {
                    var cachedResponse = request.PrepareCachedEntry(data);
                    StatsProvider.ReportCacheHit(cachedResponse.StatusCode);
                    return cachedResponse;
                }
            }

            // cache misses need to ask the inner handler for an actual response
            var response = await base.SendAsync(request, cancellationToken);

            // puts the retrieved response into the cache and returns the cached entry
            if (request.Method == HttpMethod.Get || request.Method == HttpMethod.Head)
            {
                var absoluteExpirationRelativeToNow = response.StatusCode.GetAbsoluteExpirationRelativeToNow(cacheExpirationPerHttpResponseCode);

                StatsProvider.ReportCacheMiss(response.StatusCode);

                if (TimeSpan.Zero != absoluteExpirationRelativeToNow)
                {
                    var entry = await response.ToCacheEntry();
                    await responseCache.TrySetAsync(key, entry, absoluteExpirationRelativeToNow);
                    return request.PrepareCachedEntry(entry);
                }
            }

            // returns the original response
            return response;
        }
    }
}


<|start_filename|>test/Cimpress.Extensions.Http.Caching.Abstractions.UnitTests/StatsValue_when_calculating.cs<|end_filename|>
using FluentAssertions;
using Xunit;

namespace Cimpress.Extensions.Http.Caching.Abstractions.UnitTests
{
    public class StatsValue_when_calculating
    {
        [Fact]
        public void Calculates_total_correctly()
        {
            // setup
            var stats = new StatsValue {CacheHit = 100, CacheMiss = 50};
            
            // validate
            stats.TotalRequests.Should().Be(150);
        }

        [Theory]
        [InlineData(100, 0, 1)]
        [InlineData(0, 154, 0)]
        [InlineData(10, 10, 0.5)]
        [InlineData(30, 10, 0.75)]
        public void Calculates_hits_percent_correctly(long hits, long misses, double percent)
        {
            // setup
            var stats = new StatsValue { CacheHit = hits, CacheMiss = misses };

            // validate
            stats.CacheHitsPercent.Should().Be(percent);
        }

        [Theory]
        [InlineData(100, 0, 0)]
        [InlineData(0, 154, 1)]
        [InlineData(10, 10, 0.5)]
        [InlineData(30, 10, 0.25)]
        public void Calculates_miss_percent_correctly(long hits, long misses, double percent)
        {
            // setup
            var stats = new StatsValue { CacheHit = hits, CacheMiss = misses };

            // validate
            stats.CacheMissPercent.Should().Be(percent);
        }
    }
}

<|start_filename|>test/Cimpress.Extensions.Http.Caching.InMemory.UnitTests/TestMessageHandler.cs<|end_filename|>
﻿using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Text;

namespace Cimpress.Extensions.Http.Caching.InMemory.UnitTests
{
    internal class TestMessageHandler : HttpMessageHandler
    {
        internal const HttpStatusCode DefaultResponseStatusCode = HttpStatusCode.OK;
        internal const string DefaultContent = "unit test";
        internal const string DefaultContentType = "text/plain";

        private readonly HttpStatusCode responseStatusCode;
        private readonly string content;
        private readonly string contentType;
        private readonly TimeSpan delay;
        private readonly Encoding encoding;

        public int NumberOfCalls { get; set; }
        
        public TestMessageHandler(HttpStatusCode responseStatusCode = DefaultResponseStatusCode, string content = DefaultContent, string contentType = DefaultContentType, Encoding encoding = null, TimeSpan delay = default(TimeSpan))
        {
            this.responseStatusCode = responseStatusCode;
            this.content = content;
            this.contentType = contentType;
            this.delay = delay;
            this.encoding = encoding ?? Encoding.UTF8;
        }

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            NumberOfCalls++;

            // optional delay
            if (delay != default(TimeSpan))
            {
                await Task.Delay(delay, cancellationToken);
            }

            // simulate actual result
            return new HttpResponseMessage {Content = new StringContent(content, this.encoding, this.contentType), StatusCode = responseStatusCode};
        }

    }
}

<|start_filename|>src/Cimpress.Extensions.Http.Caching.Abstractions/StatsValue.cs<|end_filename|>
namespace Cimpress.Extensions.Http.Caching.Abstractions
{
    /// <summary>
    /// An individual statistics value with some pre-computed calculations.
    /// </summary>
    public class StatsValue
    {
        /// <summary>
        /// The number of cache hits.
        /// </summary>
        public long CacheHit { get; set; }

        /// <summary>
        /// The number of cache misses.
        /// </summary>
        public long CacheMiss { get; set; }

        /// <summary>
        /// The total number of requests that were made. Equals <see cref="CacheHit"/> + <see cref="CacheMiss"/>.
        /// </summary>
        public long TotalRequests => CacheHit + CacheMiss;

        /// <summary>
        /// The percent of requests that were cache hits. Between 0 and 1.
        /// </summary>
        public double CacheHitsPercent => CacheHit * 1.0 / TotalRequests;

        /// <summary>
        /// The percent of requests that were cache misses. Between 0 and 1.
        /// </summary>
        public double CacheMissPercent => CacheMiss * 1.0 / TotalRequests;
    }
}

<|start_filename|>test/Cimpress.Extensions.Http.Caching.Abstractions.UnitTests/StatsResult_when_calculating.cs<|end_filename|>
using System.Net;
using FluentAssertions;
using Xunit;

namespace Cimpress.Extensions.Http.Caching.Abstractions.UnitTests
{
    public class StatsResult_when_calculating
    {
        [Fact]
        public void Calculates_total_correctly()
        {
            var stats = new StatsResult("unit-test");

            long hits = 0;
            long misses = 0;

            for (int i = 0; i < 10; i++)
            {
                stats.PerStatusCode.Add((HttpStatusCode) 400 + i, new StatsValue {CacheHit = 2 * i, CacheMiss = i});
                hits += 2 * i;
                misses += i;
            }
            stats.Total.CacheHit.Should().Be(hits);
            stats.Total.CacheMiss.Should().Be(misses);
        }
    }
}

<|start_filename|>src/Cimpress.Extensions.Http.Caching.Redis/DistributedCacheExtensions.cs<|end_filename|>
﻿using System;
using System.Threading;
using System.Threading.Tasks;
using Cimpress.Extensions.Http.Caching.Abstractions;
using Microsoft.Extensions.Caching.Distributed;

namespace Cimpress.Extensions.Http.Caching.Redis
{
    /// <summary>
    /// Extension methods for an <see cref="IDistributedCache"/>.
    /// </summary>
    internal static class DistributedCacheExtensions
    {
        /// <summary>
        /// Tries to get the data from cache, that is, ignoring all exceptions.
        /// </summary>
        /// <param name="cache">The distributed cache.</param>
        /// <param name="key">The key to retrieve from the cache.</param>
        /// <param name="cancellationToken">Optional cancellation token to avoid waiting for the cache for too long in case the cache cannot be reached.</param>
        /// <returns>The data of the cache entry, or null if not found or on any error.</returns>
        public static async Task<CacheData> TryGetAsync(this IDistributedCache cache, string key, CancellationToken cancellationToken = default(CancellationToken))
        {
            try
            {
                var binaryValue = await cache.GetAsync(key, cancellationToken);
                return binaryValue.Deserialize();
            }
            catch (Exception)
            {
                // ignore all exceptions; return null
                return null;
            }
        }

        /// <summary>
        /// Tries to set a new value to the cache, that is, ignoring all exceptions.
        /// </summary>
        /// <param name="cache">The distributed cache.</param>
        /// <param name="key">The key for this cache entry.</param>
        /// <param name="value">The value of this cache entry.</param>
        /// <param name="absoluteExpirationRelativeToNow">Expiration relative to now.</param>
        /// <param name="cancellationToken">Optional cancellation token to avoid waiting for the cache for too long in case the cache cannot be reached.</param>
        /// <returns>A task, when completed, has tried to put the entry into the cache.</returns>
        public static async Task TrySetAsync(this IDistributedCache cache, string key, CacheData value, TimeSpan absoluteExpirationRelativeToNow, CancellationToken cancellationToken = default(CancellationToken))
        {
            try
            {
                var binaryValue = value.Serialize();
                var options = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow };
                await cache.SetAsync(key, binaryValue, options, cancellationToken);
            }
            catch (Exception)
            {
                // ignore all exceptions
            }
        }
    }
}

<|start_filename|>test/Cimpress.Extensions.Http.Caching.Redis.UnitTests/RedisCacheHandler_when_sending_request.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Caching.Distributed;
using Moq;
using Xunit;
using Cimpress.Extensions.Http.Caching.Abstractions;

namespace Cimpress.Extensions.Http.Caching.Redis.UnitTests
{
    public class RedisCacheHandler_when_sending_request
    {
        public static IEnumerable<object[]> GetHeadData
        {
            get
            {
                yield return new object[] {HttpMethod.Get};
                yield return new object[] {HttpMethod.Head};
            }
        }

        [Theory, MemberData(nameof(GetHeadData))]
        public async Task Caches_the_result(HttpMethod method)
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            cache.Setup(c => c.GetAsync(method + "http://unittest/", It.IsAny<CancellationToken>())).ReturnsAsync(default(byte[]));
            cache.Setup(c => c.SetAsync(method + "http://unittest/", It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
            var client = new HttpClient(new RedisCacheHandler(testMessageHandler, new Dictionary<HttpStatusCode, TimeSpan>(), cache.Object));

            // execute twice
            await client.SendAsync(new HttpRequestMessage(method, "http://unittest"));
            cache.Setup(c => c.GetAsync(method + "http://unittest/", It.IsAny<CancellationToken>())).ReturnsAsync(() => new CacheData(new byte[0], new HttpResponseMessage(HttpStatusCode.OK), null, null).Serialize());
            await client.SendAsync(new HttpRequestMessage(method, "http://unittest"));

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(1);
            cache.Verify(c => c.GetAsync(method + "http://unittest/", It.IsAny<CancellationToken>()), Times.Exactly(2));
            cache.Verify(c => c.SetAsync(method + "http://unittest/", It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>()), Times.Once);
        }

        [Theory, MemberData(nameof(GetHeadData))]
        public async Task Gets_the_data_again_after_entry_is_gone_from_cache(HttpMethod method)
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            cache.Setup(c => c.GetAsync("http://unittest/", It.IsAny<CancellationToken>())).ReturnsAsync(default(byte[]));
            cache.Setup(c => c.SetAsync("http://unittest/", It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
            var client = new HttpClient(new RedisCacheHandler(testMessageHandler, new Dictionary<HttpStatusCode, TimeSpan>(), cache.Object));

            // execute twice
            await client.SendAsync(new HttpRequestMessage(method, "http://unittest"));
            await client.SendAsync(new HttpRequestMessage(method, "http://unittest"));
            
            // validate
            testMessageHandler.NumberOfCalls.Should().Be(2);
            cache.Verify(c => c.GetAsync(method + "http://unittest/", It.IsAny<CancellationToken>()), Times.Exactly(2));
        }

        [Theory, MemberData(nameof(GetHeadData))]
        public async Task Caches_per_url_and_method(HttpMethod method)
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            cache.Setup(c => c.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(default(byte[]));
            cache.Setup(c => c.SetAsync(It.IsAny<string>(), It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(true));
            var client = new HttpClient(new RedisCacheHandler(testMessageHandler, new Dictionary<HttpStatusCode, TimeSpan>(), cache.Object));

            // execute for different URLs
            await client.SendAsync(new HttpRequestMessage(method, "http://unittest1"));
            await client.SendAsync(new HttpRequestMessage(method, "http://unittest2"));

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(2);
            cache.Verify(c => c.SetAsync(It.IsAny<string>(), It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
        }
        
        [Fact]
        public async Task Only_caches_get_and_head_results()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var client = new HttpClient(new RedisCacheHandler(testMessageHandler, new Dictionary<HttpStatusCode, TimeSpan>(), (IDistributedCache) null));

            // execute twice for different methods
            await client.PostAsync("http://unittest", new StringContent(string.Empty));
            await client.PostAsync("http://unittest", new StringContent(string.Empty));
            await client.PutAsync("http://unittest", new StringContent(string.Empty));
            await client.PutAsync("http://unittest", new StringContent(string.Empty));
            await client.DeleteAsync("http://unittest");
            await client.DeleteAsync("http://unittest");

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(6);
        }
        
        [Fact]
        public async Task Data_from_call_matches_data_from_cache()
        {
            // setup
            var expectedContent = "test content";
            var expectedContentTypeHeader = "application/json";
            var expectedEncoding = "utf-8";
            var expectedEtag = new EntityTagHeaderValue("\"unit-test\"");
            var testMessageHandler = new TestMessageHandler(System.Net.HttpStatusCode.OK, expectedContent, expectedContentTypeHeader, expectedEtag);
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            byte[] savedData = null;
            cache.Setup(c => c.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(savedData);
            cache.Setup(c => c.SetAsync(It.IsAny<string>(), It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>())).Callback((string key, byte[] data, DistributedCacheEntryOptions o, CancellationToken token) => savedData = data)
                .Returns(Task.FromResult(true));
            var client = new HttpClient(new RedisCacheHandler(testMessageHandler, new Dictionary<HttpStatusCode, TimeSpan>(), cache.Object));

            // execute twice
            var originalResult = await client.GetAsync("http://unittest");
            var cachedResult = await client.GetAsync("http://unittest");
            var originalResultString = await originalResult.Content.ReadAsStringAsync();
            var cachedResultString = await cachedResult.Content.ReadAsStringAsync();

            // validate
            originalResultString.ShouldBeEquivalentTo(cachedResultString);
            originalResultString.ShouldBeEquivalentTo(expectedContent);
            originalResult.Headers.ETag.ShouldBeEquivalentTo(expectedEtag);
            cachedResult.Headers.ETag.ShouldBeEquivalentTo(expectedEtag);
            originalResult.Content.Headers.ContentType.MediaType.Should().Be(expectedContentTypeHeader);
            originalResult.Content.Headers.ContentType.CharSet.Should().Be(expectedEncoding);
            cachedResult.Content.Headers.ContentType.MediaType.Should().Be(expectedContentTypeHeader);
            cachedResult.Content.Headers.ContentType.CharSet.Should().Be(expectedEncoding);
        }

        [Fact]
        public async Task Disable_cache_per_statusCode()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);

            cache.Setup(c => c.GetAsync("http://unittest/", It.IsAny<CancellationToken>())).ReturnsAsync(default(byte[]));
            cache.Setup(c => c.SetAsync("http://unittest/", It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(true));

            var cacheExpirationPerStatusCode = new Dictionary<HttpStatusCode, TimeSpan> {{(HttpStatusCode) 200, TimeSpan.FromSeconds(0)}};


            var client = new HttpClient(new RedisCacheHandler(testMessageHandler, cacheExpirationPerStatusCode, cache.Object));

            // execute twice
            await client.GetAsync("http://unittest");
            await client.GetAsync("http://unittest");

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(2);
            cache.Verify(c => c.SetAsync("http://unittest/", It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>()), Times.Exactly(0));
        }
        
        [Fact]
        public async Task Invalidates_cache_correctly()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            var url = "http://unittest/";
            var key = HttpMethod.Get + url;
            var cacheResult = new CacheData(new byte[0], new HttpResponseMessage(HttpStatusCode.OK), null, null).Serialize();
            var nonCacheResult = default(byte[]);
            var currentResult = nonCacheResult;
            cache.Setup(c => c.GetAsync(key, It.IsAny<CancellationToken>())).ReturnsAsync(currentResult);
            cache.Setup(c => c.RemoveAsync(key, It.IsAny<CancellationToken>())).Returns(Task.FromResult(true)).Callback(() => currentResult = nonCacheResult);
            cache.Setup(c => c.SetAsync(key, It.IsAny<byte[]>(), It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(true))
                .Callback(() => currentResult = cacheResult);

            var cacheExpirationPerStatusCode = new Dictionary<HttpStatusCode, TimeSpan> {{(HttpStatusCode) 200, TimeSpan.FromHours(1)}};

            var handler = new RedisCacheHandler(testMessageHandler, cacheExpirationPerStatusCode, cache.Object);
            var client = new HttpClient(handler);

            // execute twice, with cache invalidation in between
            var uri = new Uri(url);
            await client.GetAsync(uri);
            await handler.InvalidateCache(uri, HttpMethod.Get);
            await client.GetAsync(uri);

            // validate
            testMessageHandler.NumberOfCalls.Should().Be(2);
        }

        [Fact]
        public void Invalidates_cache_per_method()
        {
            // setup
            var testMessageHandler = new TestMessageHandler();
            var cache = new Mock<IDistributedCache>(MockBehavior.Strict);
            var url = "http://unittest/";
            var getKey = HttpMethod.Get + url;
            var headKey = HttpMethod.Head + url;
            cache.Setup(c => c.RemoveAsync(headKey, default(CancellationToken))).Returns(Task.FromResult(true));
            cache.Setup(c => c.RemoveAsync(getKey, default(CancellationToken))).Throws<Exception>();

            var cacheExpirationPerStatusCode = new Dictionary<HttpStatusCode, TimeSpan> { { (HttpStatusCode)200, TimeSpan.FromHours(1) } };
            var handler = new RedisCacheHandler(testMessageHandler, cacheExpirationPerStatusCode, cache.Object);

            // execute
            Func<Task> func = async () => await handler.InvalidateCache(new Uri(url), HttpMethod.Head);

            // validate
            func.ShouldNotThrow();
            cache.Verify(c => c.RemoveAsync(headKey, default(CancellationToken)), Times.Once);
        }
    }
}

<|start_filename|>test/Cimpress.Extensions.Http.UnitTests/DataUriConversion_when_downloading_images_and_converting_to_base_64.cs<|end_filename|>
﻿using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;

namespace Cimpress.Extensions.Http.UnitTests
{
    public class DataUriConversion_when_downloading_images_and_converting_to_base_64
    {
        private readonly Mock<ILogger> logger;
        private readonly Mock<IFileInfo> fileInfo;

        public DataUriConversion_when_downloading_images_and_converting_to_base_64()
        {
            logger = new Mock<ILogger>(MockBehavior.Loose);
            fileInfo = new Mock<IFileInfo>(MockBehavior.Strict);
        }

        [Fact]
        public void Does_not_throw()
        {
            // execute
            Func<Task> func = async () => await "http://foo".DownloadImageAndConvertToDataUri(logger.Object, fileInfo.Object, "image/unittest", new HttpUtilsTestMessageHandler());

            // verify
            func.ShouldNotThrow();
        }

        [Fact]
        public async Task Uses_data_uri_scheme()
        {
            // execute
            var mediaType = "image/foo";
            var result = await "http://foo".DownloadImageAndConvertToDataUri(logger.Object, fileInfo.Object, "image/unittest", new HttpUtilsTestMessageHandler(HttpStatusCode.OK, mediaType));

            // verify
            result.Should().StartWith($"data:{mediaType};base64,");
        }

        [Fact]
        public async Task Converts_data_to_base64()
        {
            // execute
            var result = await "http://foo".DownloadImageAndConvertToDataUri(logger.Object, fileInfo.Object, "image/unittest", new HttpUtilsTestMessageHandler());

            // verify
            var data = Convert.ToBase64String(Enumerable.Range(0, 100).Select(x => (byte) x).ToArray());
            result.Should().EndWith(data);
        }

        [Theory]
        [InlineData(null)]
        [InlineData("")]
        [InlineData("              ")]
        public async Task Gets_default_file_on_empty_url(string url)
        {
            // setup
            fileInfo.Setup(s => s.CreateReadStream()).Returns(new MemoryStream());

            // execute
            await url.DownloadImageAndConvertToDataUri(logger.Object, fileInfo.Object, "image/unittest", new HttpClientHandler());

            // verify
            fileInfo.Verify(s => s.CreateReadStream(), Times.Once);
        }

        [Fact]
        public async Task Gets_default_file_on_exception()
        {
            // setup
            fileInfo.Setup(s => s.CreateReadStream()).Returns(new MemoryStream());

            // execute
            await "foo".DownloadImageAndConvertToDataUri(logger.Object, fileInfo.Object, "image/unittest", new HttpUtilsTestMessageHandler());

            // verify
            fileInfo.Verify(s => s.CreateReadStream(), Times.Once);
        }

        private class HttpUtilsTestMessageHandler : HttpMessageHandler
        {
            private readonly HttpStatusCode responseStatusCode;
            private readonly MediaTypeHeaderValue contentType;

            public HttpUtilsTestMessageHandler(HttpStatusCode responseStatusCode = HttpStatusCode.OK, string mediaType = "image/jpg")
            {
                this.responseStatusCode = responseStatusCode;
                contentType = new MediaTypeHeaderValue(mediaType);
            }

            protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
            {
                // simulate actual result
                var ms = new MemoryStream(Enumerable.Range(0, 100).Select(x => (byte) x).ToArray());
                var streamContent = new StreamContent(ms);
                streamContent.Headers.ContentType = contentType;
                var response = new HttpResponseMessage(HttpStatusCode.OK) {Content = streamContent, StatusCode = responseStatusCode};

                return Task.FromResult(response);
            }
        }
    }
}


<|start_filename|>src/Cimpress.Extensions.Http/MessageHandlers/MeasurementDetails.cs<|end_filename|>
﻿using System.Net.Http;
using System.Threading.Tasks;

namespace Cimpress.Extensions.Http.MessageHandlers
{
    /// <summary>
    /// Specifying details from the measurement that get passed into a log function.
    /// </summary>
    public class MeasurementDetails
    {
        public MeasurementDetails(HttpRequestMessage request, Task<HttpResponseMessage> sendTask, long elapsedMilliseconds)
        {
            Request = request;
            SendTask = sendTask;
            ElapsedMilliseconds = elapsedMilliseconds;
        }

        /// <summary>
        /// The original HTTP request that triggered the measurement.
        /// </summary>
        public HttpRequestMessage Request { get; }

        /// <summary>
        /// The completed (failed, success, canceled) task with the response.
        /// </summary>
        public Task<HttpResponseMessage> SendTask { get; }

        /// <summary>
        /// The elapsed milliseconds to complete the <see cref="SendTask"/>.
        /// </summary>
        public long ElapsedMilliseconds { get; }
    }
}

<|start_filename|>src/Cimpress.Extensions.Http.Caching.Abstractions/IStatsProvider.cs<|end_filename|>
using System.Net;

namespace Cimpress.Extensions.Http.Caching.Abstractions
{
    /// <summary>
    /// Provides statistical information of the cache hits or misses.
    /// </summary>
    public interface IStatsProvider
    {
        /// <summary>
        /// Report a cache hit.
        /// </summary>
        /// <param name="statusCode">The status code that experienced a cache hit.</param>
        void ReportCacheHit(HttpStatusCode statusCode);

        /// <summary>
        /// Report a cache miss.
        /// </summary>
        /// <param name="statusCode">The status code that experienced a cache miss.</param>
        void ReportCacheMiss(HttpStatusCode statusCode);

        /// <summary>
        /// Gets the current statistics.
        /// </summary>
        /// <returns>A <see cref="StatsResult"/> object that contains the statistics at the current time.</returns>
        StatsResult GetStatistics();
    }
}

<|start_filename|>test/Cimpress.Extensions.Http.Caching.Abstractions.UnitTests/CacheExpirationProvider_when_creating_simple_mapping.cs<|end_filename|>
﻿using System;
using System.Net;
using FluentAssertions;
using Xunit;

namespace Cimpress.Extensions.Http.Caching.Abstractions.UnitTests
{
    public class CacheExpirationProvider_when_creating_simple_mapping
    {
        [Fact]
        public void Puts_the_correct_status_codes_into_the_mapping()
        {
            // execute
            var mappings = CacheExpirationProvider.CreateSimple(TimeSpan.FromTicks(1), TimeSpan.FromTicks(2), TimeSpan.FromTicks(3));

            // validate
            mappings.Count.Should().Be(3);
            mappings[HttpStatusCode.OK].Should().Be(TimeSpan.FromTicks(1));
            mappings[HttpStatusCode.BadRequest].Should().Be(TimeSpan.FromTicks(2));
            mappings[HttpStatusCode.InternalServerError].Should().Be(TimeSpan.FromTicks(3));
        }
    }
}

<|start_filename|>src/Cimpress.Extensions.Http.Caching.Abstractions/CacheDataExtensions.cs<|end_filename|>
﻿using System;
using Newtonsoft.Json;

namespace Cimpress.Extensions.Http.Caching.Abstractions
{
    public static class CacheDataExtensions
    {
        public static byte[] Serialize(this CacheData cacheData)
        {
            string json = JsonConvert.SerializeObject(cacheData);
            byte[] bytes = new byte[json.Length * sizeof(char)];
            Buffer.BlockCopy(json.ToCharArray(), 0, bytes, 0, bytes.Length);
            return bytes;
        }

        public static CacheData Deserialize(this byte[] cacheData)
        {
            try
            {
                char[] chars = new char[cacheData.Length / sizeof(char)];
                Buffer.BlockCopy(cacheData, 0, chars, 0, cacheData.Length);
                string json = new string(chars);
                var data = JsonConvert.DeserializeObject<CacheData>(json);
                return data;
            }
            catch
            {
                return null;
            }
        }
    }
}

<|start_filename|>src/Cimpress.Extensions.Http.Caching.Abstractions/CacheData.cs<|end_filename|>
using System.Collections.Generic;
using System.Net.Http;

namespace Cimpress.Extensions.Http.Caching.Abstractions
{
    /// <summary>
    /// The data object that is used to put into the cache.
    /// </summary>
    public class CacheData
    {
        public CacheData(byte[] data, HttpResponseMessage cachableResponse, Dictionary<string, IEnumerable<string>> headers, Dictionary<string, IEnumerable<string>> contentHeaders)
        {
            Data = data;
            CachableResponse = cachableResponse;
            Headers = headers;
            ContentHeaders = contentHeaders;
        }

        /// <summary>
        /// The cachable part of a previously retrieved response (excludes the content and request).
        /// </summary>
        public HttpResponseMessage CachableResponse { get; }

        /// <summary>
        /// The content of the response.
        /// </summary>
        public byte[] Data { get; }

        /// <summary>
        /// the headers of the response.
        /// </summary>
        public Dictionary<string, IEnumerable<string>> Headers { get; }

        /// <summary>
        /// The content headers of the response.
        /// </summary>
        public Dictionary<string, IEnumerable<string>> ContentHeaders { get; }
    }
}