diff --git a/src/HttpClient.Cache/CacheDataExtensions.cs b/src/HttpClient.Cache/CacheDataExtensions.cs index 75b888d..cce68fe 100644 --- a/src/HttpClient.Cache/CacheDataExtensions.cs +++ b/src/HttpClient.Cache/CacheDataExtensions.cs @@ -1,30 +1,33 @@ -using System.Text.Json; +using System.Diagnostics; +using Newtonsoft.Json; namespace HttpClient.Cache; public static class CacheDataExtensions { - public static byte[] Serialize(this CacheData cacheData) + public static byte[] Pack(this CacheData cacheData) { - string json = JsonSerializer.Serialize(cacheData); - byte[] bytes = new byte[json.Length * sizeof(char)]; + var json = JsonConvert.SerializeObject(cacheData); + var 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) + public static CacheData? Unpack(this byte[] cacheData) { try { - char[] chars = new char[cacheData.Length / sizeof(char)]; + var chars = new char[cacheData.Length / sizeof(char)]; Buffer.BlockCopy(cacheData, 0, chars, 0, cacheData.Length); - string json = new string(chars); - CacheData? data = JsonSerializer.Deserialize(json); + var json = new string(chars); + + var data = JsonConvert.DeserializeObject(json); return data; } - catch + catch (Exception ex) { + Debug.WriteLine($"{ex}"); return null; } } diff --git a/src/HttpClient.Cache/CacheEntryExtensions.cs b/src/HttpClient.Cache/CacheEntryExtensions.cs new file mode 100644 index 0000000..590dc7a --- /dev/null +++ b/src/HttpClient.Cache/CacheEntryExtensions.cs @@ -0,0 +1,15 @@ +namespace HttpClient.Cache; + +public static class CacheEntryExtensions +{ + public static ICacheEntry AddExpirationToken(this ICacheEntry entry, IChangeToken token) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + entry.ExpirationTokens.Add(token); + return entry; + } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/CacheItemPriority.cs b/src/HttpClient.Cache/CacheEntryPriority.cs similarity index 72% rename from src/HttpClient.Cache/CacheItemPriority.cs rename to src/HttpClient.Cache/CacheEntryPriority.cs index add89ff..2b4c8b2 100644 --- a/src/HttpClient.Cache/CacheItemPriority.cs +++ b/src/HttpClient.Cache/CacheEntryPriority.cs @@ -1,6 +1,6 @@ namespace HttpClient.Cache; -public enum CacheItemPriority +public enum CacheEntryPriority { Low, Normal, diff --git a/src/HttpClient.Cache/DefaultCacheKeysProvider.cs b/src/HttpClient.Cache/DefaultCacheKeysProvider.cs new file mode 100644 index 0000000..3e86dc9 --- /dev/null +++ b/src/HttpClient.Cache/DefaultCacheKeysProvider.cs @@ -0,0 +1,14 @@ +namespace HttpClient.Cache; + +public class DefaultCacheKeysProvider : ICacheKeysProvider +{ + public string GetKey(HttpRequestMessage? request) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return $"MET_{request.Method};URI_{request.RequestUri}"; + } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/EvictionReason.cs b/src/HttpClient.Cache/EvictionReason.cs new file mode 100644 index 0000000..f707301 --- /dev/null +++ b/src/HttpClient.Cache/EvictionReason.cs @@ -0,0 +1,11 @@ +namespace HttpClient.Cache; + +public enum EvictionReason +{ + None, + Removed, + Replaced, + Expired, + TokenExpired, + Capacity +} \ No newline at end of file diff --git a/src/HttpClient.Cache/HttpClient.Cache.csproj b/src/HttpClient.Cache/HttpClient.Cache.csproj index eb2460e..5022b2d 100644 --- a/src/HttpClient.Cache/HttpClient.Cache.csproj +++ b/src/HttpClient.Cache/HttpClient.Cache.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/src/HttpClient.Cache/ICacheEntry.cs b/src/HttpClient.Cache/ICacheEntry.cs index 454ff14..b447493 100644 --- a/src/HttpClient.Cache/ICacheEntry.cs +++ b/src/HttpClient.Cache/ICacheEntry.cs @@ -14,5 +14,5 @@ public interface ICacheEntry: IDisposable IList PostEvictionCallbacks { get; } - CacheItemPriority Priority { get; set; } + CacheEntryPriority Priority { get; set; } } \ No newline at end of file diff --git a/src/HttpClient.Cache/ICacheKeysProvider.cs b/src/HttpClient.Cache/ICacheKeysProvider.cs new file mode 100644 index 0000000..317249d --- /dev/null +++ b/src/HttpClient.Cache/ICacheKeysProvider.cs @@ -0,0 +1,6 @@ +namespace HttpClient.Cache; + +public interface ICacheKeysProvider +{ + string GetKey(HttpRequestMessage request); +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/CacheEntry.cs b/src/HttpClient.Cache/InMemory/CacheEntry.cs new file mode 100644 index 0000000..2678d45 --- /dev/null +++ b/src/HttpClient.Cache/InMemory/CacheEntry.cs @@ -0,0 +1,262 @@ +using System.Diagnostics; + +namespace HttpClient.Cache.InMemory; + +internal class CacheEntry : ICacheEntry +{ + private readonly object _lock = new(); + private static readonly Action ExpirationCallback = ExpirationTokensExpired; + + private readonly Action _notifyCacheEntryDisposed; + private readonly Action _notifyCacheOfExpiration; + private DateTimeOffset? _absoluteExpiration; + private TimeSpan? _absoluteExpirationRelativeToNow; + private TimeSpan? _slidingExpiration; + + private IList? _expirationTokenRegistrations; + private IList? _expirationTokens; + private IList? _postEvictionCallbacks; + + private bool _isDisposed; + private bool _isExpired; + + internal CacheEntry( + object key, + Action notifyCacheEntryDisposed, + Action notifyCacheOfExpiration) + { + Key = key ?? throw new ArgumentNullException(nameof(key)); + + _notifyCacheEntryDisposed = notifyCacheEntryDisposed ?? + throw new ArgumentNullException(nameof(notifyCacheEntryDisposed)); + _notifyCacheOfExpiration = + notifyCacheOfExpiration ?? throw new ArgumentNullException(nameof(notifyCacheOfExpiration)); + } + + public object Key { get; } + public object Value { get; set; } + + public CacheEntryPriority Priority { get; set; } = CacheEntryPriority.Normal; + + public DateTimeOffset? AbsoluteExpiration + { + get => _absoluteExpiration; + set => _absoluteExpiration = value; + } + + public TimeSpan? AbsoluteExpirationRelativeToNow + { + get => _absoluteExpirationRelativeToNow; + set + { + if ((value.HasValue ? value.GetValueOrDefault() <= TimeSpan.Zero ? 1 : 0 : 0) != 0) + { + throw new ArgumentOutOfRangeException(nameof(AbsoluteExpirationRelativeToNow), value, + "The relative expiration must be positive"); + } + + _absoluteExpirationRelativeToNow = value; + } + } + + public TimeSpan? SlidingExpiration + { + get => _slidingExpiration; + set + { + if ((value.HasValue ? value.GetValueOrDefault() <= TimeSpan.Zero ? 1 : 0 : 0) != 0) + { + throw new ArgumentOutOfRangeException(nameof(SlidingExpiration), value, + "The sliding expiration must be positive"); + } + + _slidingExpiration = value; + } + } + + public IList ExpirationTokens + { + get + { + return _expirationTokens ??= new List(); + } + } + + public IList PostEvictionCallbacks => + _postEvictionCallbacks ?? new List(); + + internal DateTimeOffset LastAccessed { get; set; } + + internal EvictionReason EvictionReason { get; private set; } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + _notifyCacheEntryDisposed(this); + } + + internal bool IsExpired(DateTimeOffset currentTime) + { + if (!_isExpired && !IsExpiredNow(currentTime)) + { + return IsAnyExpirationTokenExpired(); + } + return true; + } + + private bool IsExpiredNow(DateTimeOffset currentTime) + { + if (_absoluteExpiration.HasValue && _absoluteExpiration.Value <= currentTime) + { + ExpireEntryByReason(EvictionReason.Expired); + return true; + } + + if (!_slidingExpiration.HasValue) + { + return false; + } + + var accessedOffset = currentTime - LastAccessed; + var expiration = _slidingExpiration; + if ((expiration.HasValue ? (accessedOffset >= expiration.GetValueOrDefault() ? 1 : 0) : 0) != 0) + { + ExpireEntryByReason(EvictionReason.Expired); + return true; + } + + return false; + } + + private bool IsAnyExpirationTokenExpired() + { + if (_expirationTokens == null) + { + return false; + } + + foreach (var token in _expirationTokens) + { + if (!token.HasChanged) + { + continue; + } + + ExpireEntryByReason(EvictionReason.TokenExpired); + return true; + } + + return false; + } + + private static void ExpirationTokensExpired(object entry) + { + Task.Factory.StartNew(state => + { + CacheEntry? cacheEntry = state as CacheEntry; + cacheEntry?.ExpireEntryByReason(EvictionReason.TokenExpired); + cacheEntry?._notifyCacheOfExpiration(cacheEntry); + }, entry, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + internal void ExpireEntryByReason(EvictionReason reason) + { + if (EvictionReason == EvictionReason.None) + { + EvictionReason = reason; + } + + _isExpired = true; + DetachTokens(); + } + + internal void AttachTokens() + { + if (_expirationTokens == null) + { + return; + } + + lock (_lock) + { + foreach (var token in _expirationTokens) + { + if (!token.ActiveChangeCallbacks) + { + continue; + } + + _expirationTokenRegistrations ??= new List(); + _expirationTokenRegistrations.Add(token.RegisterChangeCallback(ExpirationCallback, this)); + } + } + } + + internal void DetachTokens() + { + lock (_lock) + { + var tokenRegistrations = _expirationTokenRegistrations; + if (tokenRegistrations == null) + { + return; + } + + _expirationTokenRegistrations = null; + foreach (IDisposable registration in tokenRegistrations) + { + registration.Dispose(); + } + } + } + + internal void InvokeEvictionCallbacks() + { + if (_postEvictionCallbacks == null) + { + return; + } + + Task.Factory.StartNew(state => InvokeCallbacks((CacheEntry)state), this, CancellationToken.None, + TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + private static void InvokeCallbacks(CacheEntry entry) + { + var evictionCallbacks = + Interlocked.Exchange(ref entry._postEvictionCallbacks, null); + + if (evictionCallbacks == null) + { + return; + } + + foreach (var callback in evictionCallbacks) + { + try + { + var evictionCallback = callback.EvictionCallback; + if (evictionCallback == null) + { + continue; + } + + object key = entry.Key; + object? value = entry.Value; + EvictionReason reason = entry.EvictionReason; + object? state = callback.State; + + evictionCallback.Invoke(key, value, reason.ToString(), state); + } + catch (Exception ex) + { + Debug.WriteLine($"{ex}"); + } + } + } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/Clock/ISystemClock.cs b/src/HttpClient.Cache/InMemory/Clock/ISystemClock.cs new file mode 100644 index 0000000..8662ee2 --- /dev/null +++ b/src/HttpClient.Cache/InMemory/Clock/ISystemClock.cs @@ -0,0 +1,6 @@ +namespace HttpClient.Cache.InMemory.Clock; + +public interface ISystemClock +{ + DateTimeOffset UtcNow { get; } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/Clock/SystemClock.cs b/src/HttpClient.Cache/InMemory/Clock/SystemClock.cs new file mode 100644 index 0000000..8cbccef --- /dev/null +++ b/src/HttpClient.Cache/InMemory/Clock/SystemClock.cs @@ -0,0 +1,6 @@ +namespace HttpClient.Cache.InMemory.Clock; + +public class SystemClock: ISystemClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/IMemoryCache.cs b/src/HttpClient.Cache/InMemory/IMemoryCache.cs new file mode 100644 index 0000000..b054304 --- /dev/null +++ b/src/HttpClient.Cache/InMemory/IMemoryCache.cs @@ -0,0 +1,12 @@ +namespace HttpClient.Cache.InMemory; + +public interface IMemoryCache: IDisposable +{ + bool TryGetValue(object key, out object? value); + + ICacheEntry CreateEntry(object key); + + void Remove(object key); + + void Clear(); +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs b/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs new file mode 100644 index 0000000..4198656 --- /dev/null +++ b/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs @@ -0,0 +1,94 @@ +using System.Net; +using HttpClient.Cache.Stats; +using HttpClient.Cache.Utils; + +namespace HttpClient.Cache.InMemory; + +public class InMemoryCacheHandler : DelegatingHandler +{ + private readonly IMemoryCache _responseCache; + private readonly IDictionary _cacheExpirationPerHttpResponseCode; + + public InMemoryCacheHandler( + HttpMessageHandler? innerHandler, + IDictionary? cacheExpirationPerHttpResponseCode = null, + ICacheStatsProvider? statsProvider = null, + ICacheKeysProvider? cacheKeysProvider = null) + :this(innerHandler, + cacheExpirationPerHttpResponseCode, + statsProvider, + new MemoryCache(new MemoryCacheOptions()), + cacheKeysProvider) + { } + + internal InMemoryCacheHandler( + HttpMessageHandler? innerHandler, + IDictionary? cacheExpirationPerHttpResponseCode, + ICacheStatsProvider? statsProvider, + IMemoryCache? responseCache, + ICacheKeysProvider? cacheKeysProvider) + : base(innerHandler ?? new HttpClientHandler()) + { + StatsProvider = statsProvider ?? new DefaultCacheStatsProvider(nameof(InMemoryCacheHandler)); + CacheKeysProvider = cacheKeysProvider ?? new DefaultCacheKeysProvider(); + + _cacheExpirationPerHttpResponseCode = + cacheExpirationPerHttpResponseCode ?? new Dictionary(); + + _responseCache = responseCache ?? new MemoryCache(); + } + + public ICacheKeysProvider CacheKeysProvider { get; } + + public ICacheStatsProvider StatsProvider { get; } + + public void InvalidateCache(Uri uri, HttpMethod? method = null) + { + var methods = method != null + ? new[] { method } + : new[] { HttpMethod.Get, HttpMethod.Head, }; + + foreach (var actionMethod in methods) + { + var request = new HttpRequestMessage(actionMethod, uri); + var key = CacheKeysProvider.GetKey(request); + _responseCache.Remove(key); + } + } + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + var key = CacheKeysProvider.GetKey(request); + if (request.Method == HttpMethod.Get || request.Method == HttpMethod.Head) + { + if (await _responseCache.TryGetAsync(key, out var cachedData) && cachedData != default) + { + var cachedResponse = request.RestoreResponseFromCache(cachedData); + StatsProvider.ReportHit(cachedResponse.StatusCode); + + return cachedResponse; + }; + } + + var response = await base.SendAsync(request, cancellationToken); + + if (request.Method == HttpMethod.Get || request.Method == HttpMethod.Head) + { + var absoluteExpirationRelativeToNow = + response.StatusCode.GetAbsoluteExpirationRelativeToNow(_cacheExpirationPerHttpResponseCode); + + StatsProvider.ReportMiss(response.StatusCode); + + if (TimeSpan.Zero != absoluteExpirationRelativeToNow) + { + var entry = await response.ToCacheDataAsync(); + await _responseCache.TrySetAsync(key, entry, absoluteExpirationRelativeToNow); + return request.RestoreResponseFromCache(entry); + } + } + + return response; + } + +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/MemoryCache.cs b/src/HttpClient.Cache/InMemory/MemoryCache.cs new file mode 100644 index 0000000..137b9f0 --- /dev/null +++ b/src/HttpClient.Cache/InMemory/MemoryCache.cs @@ -0,0 +1,266 @@ +using System.Collections.Concurrent; +using HttpClient.Cache.InMemory.Clock; + +namespace HttpClient.Cache.InMemory; + +public sealed class MemoryCache : IMemoryCache +{ + private readonly ISystemClock _clock; + private readonly ConcurrentDictionary _cacheEntries; + private readonly Action _entryExpirationNotification; + + private readonly TimeSpan _expirationScanFrequency; + private readonly Action _setEntry; + + private bool _isDisposed; + private DateTimeOffset _lastExpirationScan; + + private ICollection> CacheEntries => _cacheEntries; + + public MemoryCache() + : this(new MemoryCacheOptions()) + { + } + + public MemoryCache(MemoryCacheOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + _cacheEntries = new ConcurrentDictionary(); + _setEntry = SetEntry; + _entryExpirationNotification = EntryExpired; + + _clock = options.Clock ?? new SystemClock(); + _expirationScanFrequency = options.ExpirationScanFrequency; + _lastExpirationScan = _clock.UtcNow; + } + + ~MemoryCache() + { + Dispose(false); + } + + public int Count => _cacheEntries.Count; + + public ICacheEntry CreateEntry(object key) + { + if (_isDisposed) + { + throw new ObjectDisposedException(typeof(MemoryCache).FullName); + } + + return new CacheEntry(key, _setEntry, _entryExpirationNotification); + } + + public bool TryGetValue(object key, out object? value) + { + if (_isDisposed) + { + throw new ObjectDisposedException(typeof(MemoryCache).FullName); + } + + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + value = null; + var currentTime = _clock.UtcNow; + + bool isEntryFound = false; + if (_cacheEntries.TryGetValue(key, out CacheEntry? entry)) + { + if (entry.IsExpired(currentTime) && entry.EvictionReason != EvictionReason.Replaced) + { + RemoveEntry(entry); + } + else + { + isEntryFound = true; + entry.LastAccessed = currentTime; + value = entry.Value; + } + } + + StartScanForExpiredItems(); + return isEntryFound; + } + + public void Remove(object key) + { + if (_isDisposed) + { + throw new ObjectDisposedException(typeof(MemoryCache).FullName); + } + + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (_cacheEntries.TryRemove(key, out CacheEntry? cacheEntry)) + { + cacheEntry.ExpireEntryByReason(EvictionReason.Removed); + cacheEntry.InvokeEvictionCallbacks(); + } + + StartScanForExpiredItems(); + } + + public void Clear() + { + if (_isDisposed) + { + throw new ObjectDisposedException(typeof(MemoryCache).FullName); + } + + var keys = _cacheEntries.Keys.ToList(); + foreach (object key in keys) + { + if (_cacheEntries.TryRemove(key, out CacheEntry? cacheEntry)) + { + cacheEntry.ExpireEntryByReason(EvictionReason.Removed); + cacheEntry.InvokeEvictionCallbacks(); + } + } + + StartScanForExpiredItems(); + } + + public void Dispose() + { + Dispose(true); + } + + private void SetEntry(CacheEntry entry) + { + if (_isDisposed) + { + return; + } + + var currentTime = _clock.UtcNow; + var entryAbsoluteExpiration = new DateTimeOffset?(); + + if (entry.AbsoluteExpirationRelativeToNow.HasValue) + { + entryAbsoluteExpiration = currentTime + entry.AbsoluteExpirationRelativeToNow; + } + else if (entry.AbsoluteExpiration.HasValue) + { + entryAbsoluteExpiration = entry.AbsoluteExpiration; + } + + if (entryAbsoluteExpiration.HasValue && + (!entry.AbsoluteExpiration.HasValue || entryAbsoluteExpiration.Value < entry.AbsoluteExpiration.Value)) + { + entry.AbsoluteExpiration = entryAbsoluteExpiration; + } + + entry.LastAccessed = currentTime; + + if (_cacheEntries.TryGetValue(entry.Key, out CacheEntry? cacheEntry)) + { + cacheEntry.ExpireEntryByReason(EvictionReason.Replaced); + } + + if (!entry.IsExpired(currentTime)) + { + bool isEntryAdded; + if (cacheEntry == null) + { + isEntryAdded = _cacheEntries.TryAdd(entry.Key, entry); + } + else + { + isEntryAdded = _cacheEntries.TryUpdate(entry.Key, entry, cacheEntry); + if (!isEntryAdded) + { + isEntryAdded = _cacheEntries.TryAdd(entry.Key, entry); + } + } + + if (isEntryAdded) + { + entry.AttachTokens(); + } + else + { + entry.ExpireEntryByReason(EvictionReason.Replaced); + entry.InvokeEvictionCallbacks(); + } + + cacheEntry?.InvokeEvictionCallbacks(); + } + else + { + entry.InvokeEvictionCallbacks(); + if (cacheEntry != null) + { + RemoveEntry(cacheEntry); + } + } + + StartScanForExpiredItems(); + } + + private void RemoveEntry(CacheEntry entry) + { + if (!CacheEntries.Remove(new KeyValuePair(entry.Key, entry))) + { + return; + } + + entry.InvokeEvictionCallbacks(); + } + + private void EntryExpired(CacheEntry entry) + { + RemoveEntry(entry); + StartScanForExpiredItems(); + } + + private void StartScanForExpiredItems() + { + var currentTime = _clock.UtcNow; + if (!(_expirationScanFrequency < currentTime - _lastExpirationScan)) + { + return; + } + + _lastExpirationScan = currentTime; + + Task.Factory.StartNew(state => ScanForExpiredItems((MemoryCache)state), this, CancellationToken.None, + TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + private static void ScanForExpiredItems(MemoryCache cache) + { + var currentTime = cache._clock.UtcNow; + foreach (CacheEntry entry in cache._cacheEntries.Values) + { + if (entry.IsExpired(currentTime)) + { + cache.RemoveEntry(entry); + } + } + } + + private void Dispose(bool disposing) + { + if (_isDisposed) + { + return; + } + + if (disposing) + { + GC.SuppressFinalize(this); + } + + _isDisposed = true; + } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/MemoryCacheExtensions.cs b/src/HttpClient.Cache/InMemory/MemoryCacheExtensions.cs new file mode 100644 index 0000000..49a8f32 --- /dev/null +++ b/src/HttpClient.Cache/InMemory/MemoryCacheExtensions.cs @@ -0,0 +1,95 @@ +using System.Diagnostics; + +namespace HttpClient.Cache.InMemory; + +public static class MemoryCacheExtensions +{ + public static Task TryGetAsync(this IMemoryCache cache, string key, out CacheData? cacheData) + { + cacheData = default; + try + { + if (!cache.TryGetValue(key, out var data)) + { + return Task.FromResult(false); + } + + if (data is null) + { + return Task.FromResult(false); + } + + var binaryData = (byte[])data; + cacheData = binaryData.Unpack(); + + return Task.FromResult(true); + + } + catch (Exception ex) + { + Debug.WriteLine($"{ex}"); + return Task.FromResult(false); + } + } + + public static Task TrySetAsync(this IMemoryCache cache, string key, CacheData cacheData, + TimeSpan absoluteExpirationRelativeToNow) + { + try + { + using (var entry = cache.CreateEntry(key)) + { + entry.AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow; + entry.Value = cacheData.Pack(); + } + + return Task.FromResult(true); + } + catch (Exception ex) + { + Debug.WriteLine($"{ex}"); + return Task.FromResult(false); + } + } + + public static Task TrySetAsync(this IMemoryCache cache, string key, CacheData cacheData, + DateTimeOffset absoluteExpiration) + { + try + { + using (var entry = cache.CreateEntry(key)) + { + entry.AbsoluteExpiration = absoluteExpiration; + entry.Value = cacheData.Pack(); + } + + return Task.FromResult(true); + } + catch (Exception ex) + { + Debug.WriteLine($"{ex}"); + return Task.FromResult(false); + } + } + + public static Task TrySetAsync(this IMemoryCache cache, string key, CacheData cacheData, CacheEntryPriority priority, + TimeSpan? slidingExpiration) + { + try + { + using (var entry = cache.CreateEntry(key)) + { + entry.Priority = priority; + entry.SlidingExpiration = slidingExpiration; + entry.Value = cacheData.Pack(); + } + + return Task.FromResult(true); + } + catch (Exception ex) + { + Debug.WriteLine($"{ex}"); + return Task.FromResult(false); + } + } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/MemoryCacheOptions.cs b/src/HttpClient.Cache/InMemory/MemoryCacheOptions.cs new file mode 100644 index 0000000..bd61cbc --- /dev/null +++ b/src/HttpClient.Cache/InMemory/MemoryCacheOptions.cs @@ -0,0 +1,10 @@ +using HttpClient.Cache.InMemory.Clock; + +namespace HttpClient.Cache.InMemory; + +public class MemoryCacheOptions +{ + public TimeSpan ExpirationScanFrequency { get; } = TimeSpan.FromMinutes(1.0); + + public ISystemClock? Clock { get; set; } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/PostEvictionCallbackRegistration.cs b/src/HttpClient.Cache/PostEvictionCallbackRegistration.cs index d03f387..7403c68 100644 --- a/src/HttpClient.Cache/PostEvictionCallbackRegistration.cs +++ b/src/HttpClient.Cache/PostEvictionCallbackRegistration.cs @@ -2,9 +2,10 @@ public class PostEvictionCallbackRegistration { - public PostEvictionDelegate EvictionCallback { get; set; } + //TODO: Replace with constructor - callback is not exists without state + public PostEvictionDelegate? EvictionCallback { get; set; } - public object State { get; set; } + public object? State { get; set; } } -public delegate void PostEvictionDelegate(object key, object value, string reason, object state); \ No newline at end of file +public delegate void PostEvictionDelegate(object key, object? value, string reason, object? state); \ No newline at end of file diff --git a/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs b/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000..4045fa8 --- /dev/null +++ b/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs @@ -0,0 +1,29 @@ +namespace HttpClient.Cache.Utils; + +public static class HttpResponseMessageExtensions +{ + public static async Task ToCacheDataAsync(this HttpResponseMessage response) + { + var data = await response.Content.ReadAsByteArrayAsync(); + var copy = new HttpResponseMessage + { + ReasonPhrase = response.ReasonPhrase, StatusCode = response.StatusCode, Version = response.Version + }; + + //TODO: headers are important. Will be added later; + + var entry = new CacheData(data, copy); + return entry; + } + + public static HttpResponseMessage RestoreResponseFromCache(this HttpRequestMessage request, CacheData cacheData) + { + var response = cacheData.Response; + response.Content = new ByteArrayContent(cacheData.Data); + response.RequestMessage = request; + + //TODO: headers are important. Will be added later; + + return response; + } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/Utils/HttpStatusCodeExtensions.cs b/src/HttpClient.Cache/Utils/HttpStatusCodeExtensions.cs new file mode 100644 index 0000000..5ea3820 --- /dev/null +++ b/src/HttpClient.Cache/Utils/HttpStatusCodeExtensions.cs @@ -0,0 +1,23 @@ +using System.Net; + +namespace HttpClient.Cache.Utils; + +public static class HttpStatusCodeExtensions +{ + public static TimeSpan GetAbsoluteExpirationRelativeToNow(this HttpStatusCode statusCode, + IDictionary mapping) + { + if (mapping.TryGetValue(statusCode, out var expiration)) + { + return expiration; + } + + var code = (int)statusCode; + if (mapping.TryGetValue((HttpStatusCode)(Math.Floor(code / 100.0) * 100), out expiration)) + { + return expiration; + } + + return TimeSpan.FromDays(1); + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/CacheDataExtensionsTests.cs b/tests/HttpClient.Cache.Tests/CacheDataExtensionsTests.cs new file mode 100644 index 0000000..1dd31a2 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/CacheDataExtensionsTests.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Text; +using FluentAssertions; +using FluentAssertions.Execution; +using Newtonsoft.Json; + +namespace HttpClient.Cache.Tests; + +public class CacheDataExtensionsTests +{ + [Fact] + public void Pack_CreateABytesFromCacheData_ReturnByteArray() + { + var message = Encoding.UTF8.GetBytes("Here is a message"); + var response = + new HttpResponseMessage { Content = new ByteArrayContent(message), StatusCode = HttpStatusCode.OK }; + var data = new CacheData(message, response); + + var packData = data.Pack(); + + using (new AssertionScope()) + { + var chars = new char[packData.Length / sizeof(char)]; + Buffer.BlockCopy(packData, 0, chars, 0, packData.Length); + var json = new string(chars); + + var unpackedData = JsonConvert.DeserializeObject(json); + + packData.Should().NotBeNullOrEmpty(); + unpackedData.Should().BeEquivalentTo(data); + } + } + + [Fact] + public void UnPack_CreateCacheDataEn_ReturnByteArray() + { + var message = Encoding.UTF8.GetBytes("Here is a message"); + var response = + new HttpResponseMessage { Content = new ByteArrayContent(message), StatusCode = HttpStatusCode.OK }; + var cacheData = new CacheData(message, response); + var serializeObject = JsonConvert.SerializeObject(cacheData); + var bytes = new byte[serializeObject.Length * sizeof(char)]; + Buffer.BlockCopy(serializeObject.ToCharArray(), 0, bytes, 0, bytes.Length); + + var unpackData = bytes.Unpack(); + + unpackData.Should().NotBeNull().And.BeEquivalentTo(cacheData); + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/DefaultCacheKeysProviderTests.cs b/tests/HttpClient.Cache.Tests/DefaultCacheKeysProviderTests.cs new file mode 100644 index 0000000..72eb1ed --- /dev/null +++ b/tests/HttpClient.Cache.Tests/DefaultCacheKeysProviderTests.cs @@ -0,0 +1,28 @@ +using FluentAssertions; + +namespace HttpClient.Cache.Tests; + +public class DefaultCacheKeysProviderTests +{ + [Fact] + public void GetKey_GetKeyForMessage_ReturnKey() + { + var request = new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = new Uri("http://testurl") }; + var cacheProvider = new DefaultCacheKeysProvider(); + + var cacheKey = cacheProvider.GetKey(request); + + cacheKey.Should().Be($"MET_{request.Method};URI_{request.RequestUri}"); + } + + [Fact] + public void GetKey_RequestIsNull_ThrowException() + { + var request = new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = new Uri("http://testurl") }; + var cacheProvider = new DefaultCacheKeysProvider(); + + var action = () => cacheProvider.GetKey(null); + + action.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj b/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj index 2974db9..f8fee8d 100644 --- a/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj +++ b/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj @@ -15,6 +15,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/HttpClient.Cache.Tests/InMemory/Clock/SystemClockTests.cs b/tests/HttpClient.Cache.Tests/InMemory/Clock/SystemClockTests.cs new file mode 100644 index 0000000..4377044 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/InMemory/Clock/SystemClockTests.cs @@ -0,0 +1,15 @@ +using FluentAssertions; +using HttpClient.Cache.InMemory.Clock; + +namespace HttpClient.Cache.Tests.InMemory.Clock; + +public class SystemClockTests +{ + [Fact] + public void GetTime_CheckCurrentTime_ReturnUtcNow() + { + var systemClock = new SystemClock(); + + systemClock.UtcNow.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1)); + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/InMemory/InMemoryCacheHandlerTests.cs b/tests/HttpClient.Cache.Tests/InMemory/InMemoryCacheHandlerTests.cs new file mode 100644 index 0000000..8cc86d3 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/InMemory/InMemoryCacheHandlerTests.cs @@ -0,0 +1,19 @@ +using FluentAssertions; +using HttpClient.Cache.InMemory; + +namespace HttpClient.Cache.Tests.InMemory; + +public class InMemoryCacheHandlerTests +{ + [Fact] + public async Task Cache_HandleNewCacheMessage_Successful() + { + var testHandler = new TestHandler(); + var client = new System.Net.Http.HttpClient(new InMemoryCacheHandler(testHandler)); + + await client.GetAsync("http://the-url"); + await client.GetAsync("http://the-url"); + + testHandler.CallsMade.Should().Be(1); + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheExtensionsTests.cs b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheExtensionsTests.cs new file mode 100644 index 0000000..a00c503 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheExtensionsTests.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Text; +using FluentAssertions; +using FluentAssertions.Execution; +using HttpClient.Cache.InMemory; + +namespace HttpClient.Cache.Tests.InMemory; + +public class MemoryCacheExtensionsTests +{ + [Fact] + public async Task TryGetAsync_GetCacheItemFromMemoryCacheOut_ReturnTrue() + { + const string cacheKey = "key"; + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var cacheData = new CacheData(Encoding.UTF8.GetBytes("message"), + new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + + using (var entry = memoryCache.CreateEntry(cacheKey)) + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1); + entry.Value = cacheData.Pack(); + } + + var result = await memoryCache.TryGetAsync(cacheKey, out var packedData); + + using (new AssertionScope()) + { + result.Should().BeTrue(); + packedData.Should().BeEquivalentTo(cacheData); + } + } + + [Fact] + public async Task TrySetAsync_SetCacheDataByKeyAndExpRelativeToNow_ReturnTrue() + { + const string cacheKey = "key"; + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var cacheData = new CacheData(Encoding.UTF8.GetBytes("message"), + new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + var absoluteTimeoutRelativeToNow = TimeSpan.FromDays(1); + + var result = await memoryCache.TrySetAsync(cacheKey, cacheData, absoluteTimeoutRelativeToNow); + + result.Should().BeTrue(); + } + + [Fact] + public async Task TrySetAsync_SetCacheDataByKeyAndAbsoluteExp_ReturnTrue() + { + const string cacheKey = "key"; + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var cacheData = new CacheData(Encoding.UTF8.GetBytes("message"), + new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + var absoluteExpiration = DateTimeOffset.UtcNow.AddDays(1); + + var result = await memoryCache.TrySetAsync(cacheKey, cacheData, absoluteExpiration); + + result.Should().BeTrue(); + } + + [Fact] + public async Task TrySetAsync_SetCacheDataByKeySlidingWindow_ReturnTrue() + { + const string cacheKey = "key"; + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var cacheData = new CacheData(Encoding.UTF8.GetBytes("message"), + new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + var slidingExpiration = TimeSpan.FromDays(1); + + var result = await memoryCache.TrySetAsync(cacheKey, cacheData, CacheEntryPriority.Normal, slidingExpiration); + + result.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs new file mode 100644 index 0000000..b028e17 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs @@ -0,0 +1,104 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using HttpClient.Cache.InMemory; + +namespace HttpClient.Cache.Tests.InMemory; + +[CollectionDefinition("Sequential", DisableParallelization = true)] +public class MemoryCacheTests +{ + [Fact] + public void CreateEntry_Create10NewEntryAndSetValue_CacheContains10Items() + { + var expiration = TimeSpan.FromHours(1); + var options = new MemoryCacheOptions(); + var cache = new MemoryCache(options); + + for (var i = 1; i <= 10; ++i) + { + using var entry = cache.CreateEntry($"{i}"); + entry.AbsoluteExpirationRelativeToNow = expiration; + entry.Value = $"value-{i}"; + } + + using (new AssertionScope()) + { + cache.Count.Should().Be(10); + + var val = cache.TryGetValue("9", out var cachedItem); + val.Should().BeTrue(); + cachedItem.Should().Be("value-9"); + } + } + + [Fact] + public async Task CreateEntry_ExpireAbsoluteExpirationRelativeToNow_CacheIsEmpty() + { + var options = new MemoryCacheOptions(); + var cache = new MemoryCache(options); + + using(var entry = cache.CreateEntry("key")){ + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3); + entry.Value = $"value"; + } + + using (new AssertionScope()) + { + cache.Count.Should().Be(1); + await Task.Delay(TimeSpan.FromSeconds(4)); + + var value = cache.TryGetValue("key", out var cacheEntry); + value.Should().BeFalse(); + cacheEntry.Should().BeNull(); + cache.Count.Should().Be(0); + } + } + + [Fact] + public async Task CreateEntry_ExpireSlidingExpiration_CacheIsEmpty() + { + var expiration = TimeSpan.FromSeconds(2); + var options = new MemoryCacheOptions(); + var cache = new MemoryCache(options); + + using(var entry = cache.CreateEntry("key")){ + entry.SlidingExpiration = expiration; + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddSeconds(1); + entry.Value = $"value"; + } + + using (new AssertionScope()) + { + cache.Count.Should().Be(1); + await Task.Delay(expiration); + + var value = cache.TryGetValue("key", out var cacheEntry); + value.Should().BeFalse(); + cacheEntry.Should().BeNull(); + cache.Count.Should().Be(0); + } + } + + [Fact] + public async Task CreateEntry_ExpireAbsoluteDate_CacheIsEmpty() + { + var options = new MemoryCacheOptions(); + var cache = new MemoryCache(options); + + using(var entry = cache.CreateEntry("key")){ + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddSeconds(2); + entry.Value = $"value"; + } + + using (new AssertionScope()) + { + cache.Count.Should().Be(1); + await Task.Delay(TimeSpan.FromSeconds(3)); + + var value = cache.TryGetValue("key", out var cacheEntry); + value.Should().BeFalse(); + cacheEntry.Should().BeNull(); + cache.Count.Should().Be(0); + } + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/TestHandler.cs b/tests/HttpClient.Cache.Tests/TestHandler.cs new file mode 100644 index 0000000..61d8224 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/TestHandler.cs @@ -0,0 +1,51 @@ +using System.Net; +using System.Text; + +namespace HttpClient.Cache.Tests; + +public class TestHandler : HttpMessageHandler +{ + internal const HttpStatusCode DefaultCode = HttpStatusCode.OK; + internal const string DefaultContent = "The response content"; + internal const string DefaultContentType = "text/plain"; + + private readonly string _content; + private readonly string _contentType; + private readonly TimeSpan _delay; + private readonly Encoding? _encoding; + + private readonly HttpStatusCode _responseCode; + + public TestHandler( + HttpStatusCode responseCode = DefaultCode, + string content = DefaultContent, + string contentType = DefaultContentType, + Encoding? encoding = null, + TimeSpan delay = default + ) + { + _responseCode = responseCode; + _content = content; + _contentType = contentType; + _encoding = encoding ?? Encoding.UTF8; + _delay = delay; + } + + public int CallsMade { get; set; } + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + CallsMade++; + if (_delay != default) + { + await Task.Delay(_delay, cancellationToken); + } + + return new HttpResponseMessage + { + Content = new StringContent(_content, _encoding, _contentType), + StatusCode = _responseCode + }; + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/Utils/HttpResponseMessageExtensionsTests.cs b/tests/HttpClient.Cache.Tests/Utils/HttpResponseMessageExtensionsTests.cs new file mode 100644 index 0000000..8bc57c5 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/Utils/HttpResponseMessageExtensionsTests.cs @@ -0,0 +1,52 @@ +using System.Net; +using System.Text; +using FluentAssertions; +using FluentAssertions.Execution; +using HttpClient.Cache.Utils; + +namespace HttpClient.Cache.Tests.Utils; + +public class HttpResponseMessageExtensionsTests +{ + [Fact] + public async Task ToCacheEntry_ConvertResponseToCacheData_ReturnCacheData() + { + var content = Encoding.UTF8.GetBytes("The message"); + var response = new HttpResponseMessage + { + Content = new ByteArrayContent(content), + ReasonPhrase = "Reason", + StatusCode = HttpStatusCode.Found, + Version = Version.Parse("1.0") + }; + + var cacheData = await response.ToCacheDataAsync(); + + cacheData.Data.Should().BeEquivalentTo(content); + cacheData.Response.Should().NotBeNull().And.NotBe(response); + } + + [Fact] + public void RestoreResponseFromCache_RestoreHttpResponse_ReturnHttpResponseMessage() + { + var content = Encoding.UTF8.GetBytes("The message"); + var cachedResponse = new HttpResponseMessage + { + Content = new ByteArrayContent(content), + ReasonPhrase = "Reason", + StatusCode = HttpStatusCode.Found, + Version = Version.Parse("1.0") + }; + var cacheData = new CacheData(content, cachedResponse); + var newRequest = new HttpRequestMessage { Method = HttpMethod.Get }; + + var restoredResponse = newRequest.RestoreResponseFromCache(cacheData); + + using (new AssertionScope()) + { + restoredResponse.Should().NotBeNull(); + restoredResponse.Content.Should().NotBeNull(); + restoredResponse.RequestMessage.Should().Be(newRequest); + } + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/Utils/HttpStatusCodeExtensionsTests.cs b/tests/HttpClient.Cache.Tests/Utils/HttpStatusCodeExtensionsTests.cs new file mode 100644 index 0000000..d02acdc --- /dev/null +++ b/tests/HttpClient.Cache.Tests/Utils/HttpStatusCodeExtensionsTests.cs @@ -0,0 +1,43 @@ +using System.Net; +using FluentAssertions; +using HttpClient.Cache.Utils; + +namespace HttpClient.Cache.Tests.Utils; + +public class HttpStatusCodeExtensionsTests +{ + private readonly Dictionary _cachingTime = new() + { + { HttpStatusCode.OK, TimeSpan.FromMinutes(1) }, { HttpStatusCode.BadRequest, TimeSpan.FromSeconds(30) } + }; + + [Fact] + public void GetAbsoluteExpirationRelativeToNow_ConvertExistedCodeToTimeSpan_Return1min() + { + const HttpStatusCode existingCode = HttpStatusCode.OK; + + var relativeCacheTime = existingCode.GetAbsoluteExpirationRelativeToNow(_cachingTime); + + relativeCacheTime.Should().Be(TimeSpan.FromMinutes(1)); + } + + [Fact] + public void GetAbsoluteExpirationRelativeToNow_ConvertCloseCodeToParentCode_Return30s() + { + const HttpStatusCode existingCode = HttpStatusCode.Gone; + + var relativeCacheTime = existingCode.GetAbsoluteExpirationRelativeToNow(_cachingTime); + + relativeCacheTime.Should().Be(TimeSpan.FromSeconds(30)); + } + + [Fact] + public void GetAbsoluteExpirationRelativeToNow_GetDefaultCacheIfCodeUnknown_Return1Day() + { + const HttpStatusCode existingCode = HttpStatusCode.InternalServerError; + + var relativeCacheTime = existingCode.GetAbsoluteExpirationRelativeToNow(_cachingTime); + + relativeCacheTime.Should().Be(TimeSpan.FromDays(1)); + } +} \ No newline at end of file