Skip to content

Commit

Permalink
Vastly improved and simplified offline support services
Browse files Browse the repository at this point in the history
  • Loading branch information
aritchie committed Oct 2, 2024
1 parent 965c587 commit 8ac4513
Show file tree
Hide file tree
Showing 9 changed files with 210 additions and 142 deletions.
4 changes: 4 additions & 0 deletions src/Shiny.Mediator.AppSupport/AppSupportExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Shiny.Mediator.Infrastructure;
using Shiny.Mediator.Middleware;

namespace Shiny.Mediator;
Expand Down Expand Up @@ -39,6 +41,8 @@ public static ShinyConfigurator AddUserErrorNotificationsRequestMiddleware(this
/// <returns></returns>
public static ShinyConfigurator AddOfflineAvailabilityMiddleware(this ShinyConfigurator cfg)
{
cfg.Services.TryAddSingleton<IOfflineService, OfflineService>();
cfg.Services.TryAddSingleton<ISerializerService, SerializerService>();
cfg.Services.AddSingletonAsImplementedInterfaces<OfflineAvailableFlushRequestHandler>();
cfg.AddOpenRequestMiddleware(typeof(OfflineAvailableRequestMiddleware<,>));

Expand Down
137 changes: 137 additions & 0 deletions src/Shiny.Mediator.AppSupport/Infrastructure/IOfflineService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
namespace Shiny.Mediator.Infrastructure;


public interface IOfflineService
{
Task<string> Set(object request, object result);
Task<OfflineResult<TResult>?> Get<TResult>(object request);
Task ClearByType(Type requestType);
Task ClearByRequest(object request);
Task Clear();
}

public record OfflineResult<TResult>(
string RequestKey,
DateTimeOffset Timestamp,
TResult Value
);

public class OfflineService(IStorageService storage, ISerializerService serializer) : IOfflineService
{
public async Task<string> Set(object request, object result)
{
var requestKey = this.GetRequestKey(request);
await this.DoTransaction(dict =>
{
dict[requestKey] = new OfflineStore(
this.GetTypeKey(request.GetType()),
requestKey,
DateTimeOffset.UtcNow,
serializer.Serialize(result)
);
return true;
});
return requestKey;
}


public async Task<OfflineResult<TResult>?> Get<TResult>(object request)
{
OfflineResult<TResult>? result = null;
await this
.DoTransaction(dict =>
{
var requestKey = this.GetRequestKey(request);
if (dict.TryGetValue(requestKey, out var store))
{
var jsonObj = serializer.Deserialize<TResult>(store.Json);
result = new OfflineResult<TResult>(store.RequestKey, store.Timestamp, jsonObj);
}
return false;
})
.ConfigureAwait(false);

return result;
}


public Task ClearByType(Type requestType) => this.DoTransaction(dict =>
{
var typeKey = this.GetTypeKey(requestType);
var keys = dict
.Where(x => x.Value.TypeName == typeKey)
.Select(x => x.Key)
.ToList();

if (keys.Count == 0)
return false;

foreach (var key in keys)
dict.Remove(key);

return false;
});


public Task ClearByRequest(object request) => this.DoTransaction(dict =>
{
var requestKey = this.GetRequestKey(request);
if (dict.ContainsKey(requestKey))
{
dict.Remove(requestKey);
return true;
}
return false;
});


public Task Clear() => this.DoTransaction(dict =>
{
dict.Clear();
return true;
});


SemaphoreSlim semaphore = new(1, 1);
Dictionary<string, OfflineStore> cache = null!;

Task DoTransaction(Func<IDictionary<string, OfflineStore>, bool> action) => Task.Run(async () =>
{
await this.semaphore.WaitAsync();
if (this.cache == null)
{
var dict = await storage
.Get<Dictionary<string, OfflineStore>>(nameof(IStorageService))
.ConfigureAwait(false);

this.cache = dict ?? new();
}
var result = action(this.cache);
if (result)
await storage.Set(nameof(IStorageService), this.cache).ConfigureAwait(false);

this.semaphore.Release();
});


protected virtual string GetRequestKey(object request)
{
if (request is IRequestKey keyProvider)
return keyProvider.GetKey();

var t = request.GetType();
var key = $"{t.Namespace}_{t.Name}";

return key;
}


protected string GetTypeKey(Type type) => $"{type.Namespace}.{type.Name}";
}

record OfflineStore(
string TypeName,
string RequestKey,
DateTimeOffset Timestamp,
string Json
);
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ namespace Shiny.Mediator.Infrastructure;

public interface IStorageService
{
Task Store(object request, object result);
Task<TResult?> Get<TResult>(object request);
// Task ClearByRequestKey(IRequestKey requestKey)
// Task ClearByType(Type requestType);
Task Clear();
Task Set<T>(string key, T value);
Task<T> Get<T>(string key);
Task Remove(string key);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace Shiny.Mediator.Middleware;
public class OfflineAvailableRequestMiddleware<TRequest, TResult>(
ILogger<OfflineAvailableRequestMiddleware<TRequest, TResult>> logger,
IInternetService connectivity,
IStorageService storage,
IOfflineService offline,
IConfiguration configuration
) : IRequestMiddleware<TRequest, TResult>
{
Expand All @@ -17,32 +17,35 @@ public async Task<TResult> Process(
RequestHandlerDelegate<TResult> next
)
{
// TODO: request key
if (typeof(TResult) == typeof(Unit))
return await next().ConfigureAwait(false);

if (!this.IsEnabled(context.RequestHandler, context.Request))
return await next().ConfigureAwait(false);

var result = default(TResult);
if (connectivity.IsAvailable)
{
result = await next().ConfigureAwait(false);
if (this.IsEnabled(context.RequestHandler, context.Request))
{
var timestampedResult = new TimestampedResult<TResult>(DateTimeOffset.UtcNow, result);
await storage.Store(context.Request!, timestampedResult);
logger.LogDebug("Offline: {Request} - Key: {RequestKey}", context.Request, "TODO");
}
var requestKey = await offline.Set(context.Request!, result!);
logger.LogDebug("Offline: {Request} - Key: {RequestKey}", context.Request, requestKey);
}
else
{
var timestampedResult = await storage.Get<TimestampedResult<TResult>>(context.Request!);
context.Offline(new OfflineAvailableContext("TODO", timestampedResult!.Timestamp));
result = timestampedResult!.Value;
logger.LogDebug(
"Offline Hit: {Request} - Timestamp: {Timestamp} - Key: {RequestKey}",
context.Request,
timestampedResult.Value,
"TODO"
);
var offlineResult = await offline.Get<TResult>(context.Request!);

// TODO: offline miss to context
if (offlineResult != null)
{
context.Offline(new OfflineAvailableContext(offlineResult.RequestKey, offlineResult.Timestamp));
result = offlineResult.Value;
logger.LogDebug(
"Offline Hit: {Request} - Timestamp: {Timestamp} - Key: {RequestKey}",
context.Request,
offlineResult.Timestamp,
offlineResult.RequestKey
);
}
}
return result;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

namespace Shiny.Mediator.Middleware;

// TODO: pump when connectivity is restored

/// <summary>
/// Replays the last result before requesting a new one
Expand All @@ -13,7 +14,7 @@ namespace Shiny.Mediator.Middleware;
/// <typeparam name="TResult"></typeparam>
public class ReplayStreamMiddleware<TRequest, TResult>(
ILogger<ReplayStreamMiddleware<TRequest, TResult>> logger,
IStorageService storage,
IOfflineService offline,
IConfiguration configuration
) : IStreamRequestMiddleware<TRequest, TResult> where TRequest : IStreamRequest<TResult>
{
Expand Down Expand Up @@ -57,15 +58,15 @@ protected virtual async IAsyncEnumerable<TResult> Iterate(
[EnumeratorCancellation] CancellationToken ct
)
{
var store = await storage.Get<TResult>(request);
var store = await offline.Get<TResult>(request);
if (store != null)
yield return store;
yield return store.Value;

var nxt = next().GetAsyncEnumerator(ct);
while (await nxt.MoveNextAsync() && !ct.IsCancellationRequested)
{
// TODO: if current is null, remove?
await storage.Store(request, nxt.Current!);
await offline.Set(request, nxt.Current!);
yield return nxt.Current;
}
}
Expand Down
29 changes: 8 additions & 21 deletions src/Shiny.Mediator.Blazor/Infrastructure/StorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,43 +7,30 @@ namespace Shiny.Mediator.Blazor.Infrastructure;

public class StorageService(IJSRuntime jsruntime) : IStorageService
{
public Task Store(object request, object result)
public Task Set<T>(string key, T value)
{
var key = this.GetStoreKeyFromRequest(request);
var json = JsonSerializer.Serialize(result);
var json = JsonSerializer.Serialize(value);
((IJSInProcessRuntime)jsruntime).Invoke<string?>("localStorage.setItem", key, json);

return Task.CompletedTask;
return Task.FromResult(key);
}


public Task<TResult?> Get<TResult>(object request)
public Task<T> Get<T>(string key)
{
var key = this.GetStoreKeyFromRequest(request);
var stringValue = ((IJSInProcessRuntime)jsruntime).Invoke<string?>("localStorage.getItem", key);
if (String.IsNullOrWhiteSpace(stringValue))
return null!;

var final = JsonSerializer.Deserialize<TResult>(stringValue);
var final = JsonSerializer.Deserialize<T>(stringValue);
return Task.FromResult(final);
}

public Task Clear()

public Task Remove(string key)
{
var inproc = (IJSInProcessRuntime)jsruntime;
inproc.InvokeVoid("localStorage.clear");
inproc.InvokeVoid("localStorage.removeItem", key);
return Task.CompletedTask;
}


protected virtual string GetStoreKeyFromRequest(object request)
{
if (request is IRequestKey keyProvider)
return keyProvider.GetKey();

var t = request.GetType();
var key = $"{t.Namespace}_{t.Name}";

return key;
}
}
Loading

0 comments on commit 8ac4513

Please sign in to comment.