diff --git a/src/Shiny.Mediator.Maui/MauiMediatorExtensions.cs b/src/Shiny.Mediator.Maui/MauiMediatorExtensions.cs index 3e9c434..2b3c10a 100644 --- a/src/Shiny.Mediator.Maui/MauiMediatorExtensions.cs +++ b/src/Shiny.Mediator.Maui/MauiMediatorExtensions.cs @@ -16,7 +16,8 @@ public static ShinyConfigurator AddTimedMiddleware(this ShinyConfigurator cfg, T public static ShinyConfigurator AddConnectivityCacheMiddleware(this ShinyConfigurator cfg) { - cfg.AddOpenRequestMiddleware(typeof(ConnectivityCacheRequestMiddleware<,>)); + // TODO: how to clear memory + cfg.AddOpenRequestMiddleware(typeof(CacheRequestMiddleware<,>)); return cfg; } diff --git a/src/Shiny.Mediator.Maui/Middleware/CacheRequestMiddleware.cs b/src/Shiny.Mediator.Maui/Middleware/CacheRequestMiddleware.cs new file mode 100644 index 0000000..2e28715 --- /dev/null +++ b/src/Shiny.Mediator.Maui/Middleware/CacheRequestMiddleware.cs @@ -0,0 +1,206 @@ +using System.Text.Json; +using Microsoft.Extensions.Configuration; + +namespace Shiny.Mediator.Middleware; + + +public class CacheRequestMiddleware( + IConfiguration configuration, + IConnectivity connectivity, + IFileSystem fileSystem +) : IRequestMiddleware where TRequest : IRequest + // IRequestHandler, + // IRequestHandler +{ + public async Task Process(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + // no caching for void requests + if (typeof(TResult) == typeof(Unit)) + return await next().ConfigureAwait(false); + + // no config no cache - TODO: could consider ICacheItem taking default values + var cfg = GetConfiguration(request); + if (cfg == null) + return await next().ConfigureAwait(false); + + var result = default(TResult); + var connected = connectivity.NetworkAccess == NetworkAccess.Internet; + if (cfg.OnlyForOffline && connected) + { + result = await this.GetAndStore(request, next, cfg).ConfigureAwait(false); + return result; + } + + var item = this.GetFromStore(request, cfg); + if (item == null) + { + if (connected) + result = await this.GetAndStore(request, next, cfg).ConfigureAwait(false); + } + else if (this.IsExpired(item, cfg)) + { + result = item.Value; + if (connected) + result = await this.GetAndStore(request, next, cfg).ConfigureAwait(false); + } + else + result = item.Value; + + return result; + } + + + protected virtual bool IsExpired(CachedItem item, CacheConfiguration cfg) + { + if (cfg.MaxAge == null) + return false; + + var expiry = item.CreatedOn.Add(cfg.MaxAge.Value); + var expired = expiry < DateTimeOffset.UtcNow; + return expired; + } + + + protected virtual async Task GetAndStore(TRequest request, RequestHandlerDelegate next, CacheConfiguration cfg) + { + var result = await next().ConfigureAwait(false); + this.Store(request, result, cfg); + return result; + } + + protected virtual string GetCacheKey(TRequest request) + { + if (request is ICacheItem item) + return item.CacheKey; + + var key = $"{typeof(TRequest).Namespace}.{typeof(TRequest).Name}"; + return key; + } + + + protected virtual string GetCacheFilePath(TRequest request) + { + var key = this.GetCacheKey(request); + var path = Path.Combine(fileSystem.CacheDirectory, $"{key}.cache"); + return path; + } + + + protected virtual void Store(TRequest request, TResult result, CacheConfiguration cfg) + { + switch (cfg.Storage) + { + case StoreType.File: + var path = this.GetCacheFilePath(request); + var json = JsonSerializer.Serialize(result); + File.WriteAllText(path, json); + break; + + case StoreType.Memory: + var key = this.GetCacheKey(request); + lock (this.memCache) + { + this.memCache[key] = new CachedItem( + DateTimeOffset.UtcNow, + result! + ); + } + break; + } + } + + + protected virtual CachedItem? GetFromStore(TRequest request, CacheConfiguration cfg) + { + CachedItem? returnValue = null; + + switch (cfg.Storage) + { + case StoreType.File: + var path = this.GetCacheFilePath(request); + if (File.Exists(path)) + { + var json = File.ReadAllText(path); + var obj = JsonSerializer.Deserialize(json)!; + var createdOn = File.GetCreationTimeUtc(path); + returnValue = new CachedItem(createdOn, obj); + } + break; + + + case StoreType.Memory: + var key = this.GetCacheKey(request); + lock (this.memCache) + { + if (this.memCache.ContainsKey(key)) + { + var item = this.memCache[key]; + returnValue = new CachedItem(item.CreatedOn, (TResult)item.Value); + } + } + break; + } + + return returnValue; + } + + + readonly Dictionary> memCache = new(); + readonly object syncLock = new(); + CacheConfiguration[]? configurations; + + + protected virtual CacheConfiguration? GetConfiguration(TRequest request) + { + var type = request.GetType(); + var key = $"{type.Namespace}.{type.Name}"; + + if (this.configurations == null) + { + lock (this.syncLock) + { + this.configurations = configuration + .GetSection("Cache") + .Get(); + } + } + + var cfg = this.configurations? + .FirstOrDefault(x => x + .RequestType + .Equals( + key, + StringComparison.InvariantCultureIgnoreCase + ) + ); + + return cfg; + } +} + +// public record FlushCacheItemRequest(Type RequestType) : IRequest; +// public record FlushAllCacheRequest : IRequest; + +public interface ICacheItem +{ + string CacheKey { get; } +} + +public record CachedItem( + DateTimeOffset CreatedOn, + T Value +); + +public enum StoreType +{ + File, + Memory +} + +public class CacheConfiguration +{ + public string RequestType { get; set; } + public bool OnlyForOffline { get; set; } + public TimeSpan? MaxAge { get; set; } + public StoreType Storage { get; set; } +} \ No newline at end of file diff --git a/src/Shiny.Mediator.Maui/Middleware/ConnectivityCacheRequestMiddleware.cs b/src/Shiny.Mediator.Maui/Middleware/ConnectivityCacheRequestMiddleware.cs deleted file mode 100644 index de5deed..0000000 --- a/src/Shiny.Mediator.Maui/Middleware/ConnectivityCacheRequestMiddleware.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Text.Json; - -namespace Shiny.Mediator.Middleware; - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = true, AllowMultiple = false)] -public class CacheAttribute : Attribute -{ - // TODO: duration? configuration? -} - - -public class ConnectivityCacheRequestMiddleware(IConnectivity connectivity, IFileSystem fileSystem) : IRequestMiddleware where TRequest : IRequest -{ - public async Task Process(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) - { - var config = typeof(TRequest).GetCustomAttribute(); - if (config == null) - return await next().ConfigureAwait(false); - - var result = default(TResult); - var storePath = Path.Combine(fileSystem.CacheDirectory, $"{typeof(TRequest).Name}.cache"); - - if (connectivity.NetworkAccess != NetworkAccess.Internet) - { - result = JsonSerializer.Deserialize(storePath); - } - else - { - result = await next().ConfigureAwait(false); - var json = JsonSerializer.Serialize(result); - await File.WriteAllTextAsync(storePath, json, cancellationToken).ConfigureAwait(false); - } - return result; - } -} \ No newline at end of file diff --git a/src/Shiny.Mediator.Maui/Shiny.Mediator.Maui.csproj b/src/Shiny.Mediator.Maui/Shiny.Mediator.Maui.csproj index 215dbce..c30d876 100644 --- a/src/Shiny.Mediator.Maui/Shiny.Mediator.Maui.csproj +++ b/src/Shiny.Mediator.Maui/Shiny.Mediator.Maui.csproj @@ -7,6 +7,7 @@ + diff --git a/tests/Shiny.Mediator.Tests/CacheRequestMiddlewareTests.cs b/tests/Shiny.Mediator.Tests/CacheRequestMiddlewareTests.cs new file mode 100644 index 0000000..40e26cd --- /dev/null +++ b/tests/Shiny.Mediator.Tests/CacheRequestMiddlewareTests.cs @@ -0,0 +1,6 @@ +namespace Shiny.Mediator.Tests; + +public class CacheRequestMiddlewareTests +{ + +} \ No newline at end of file