From 6f566be86b67dfcc178c8a5f7b3e8a4ee3dcd0e0 Mon Sep 17 00:00:00 2001 From: Paul Welter Date: Sat, 19 Oct 2024 09:43:32 -0500 Subject: [PATCH] add HybridCache, misc refactors --- .github/workflows/dotnet.yml | 3 +- .../DomainServiceExtensions.cs | 28 --------- .../DispatcherEndpoint.cs | 5 +- .../EntityQueryEndpointBase.cs | 2 +- .../FeatureEndpointExtensions.cs | 11 ++-- .../DomainServiceExtensions.cs | 57 ++++--------------- .../DomainServiceExtensions.cs | 34 ++--------- .../DistributedCacheQueryBehavior.cs | 19 ++++--- .../Behaviors/HybridCacheExpireBehavior.cs | 43 ++++++++++++++ .../Behaviors/HybridCacheQueryBehavior.cs | 50 ++++++++++++++++ .../Behaviors/MemoryCacheQueryBehavior.cs | 9 +-- .../Commands/EntityCreateCommand.cs | 8 ++- .../Commands/EntityDeleteCommand.cs | 8 ++- .../Commands/EntityPatchCommand.cs | 14 ++++- .../Commands/EntityUpdateCommand.cs | 13 ++++- .../Commands/EntityUpsertCommand.cs | 11 +++- .../Converters/ClaimsPrincipalConverter.cs | 9 +-- .../Definitions/ICacheExpire.cs | 6 ++ .../{ICacheQueryResult.cs => ICacheResult.cs} | 6 +- .../Definitions/IPrincipalReader.cs | 8 ++- .../Dispatcher/DispatcherOptions.cs | 4 +- .../Dispatcher/RemoteDispatcher.cs | 47 ++++++++++++++- .../MediatorServiceExtensions.cs | 48 ++++++++++++++++ .../Queries/CacheableQueryBase.cs | 16 ++++-- .../Queries/EntityIdentifierQuery.cs | 12 ++-- .../Queries/EntityIdentifiersQuery.cs | 10 +++- .../Queries/EntityPagedQuery.cs | 10 ++-- .../Queries/EntitySelectQuery.cs | 11 ++-- .../Queries/EntitySort.cs | 2 +- .../Services/ActivityTimer.cs | 4 +- .../Services/CacheTagger.cs | 42 ++++++++++++++ .../Services/ClaimNames.cs | 21 +++++++ .../Services/PrincipalReader.cs | 36 +++++++++++- 33 files changed, 429 insertions(+), 178 deletions(-) create mode 100644 src/MediatR.CommandQuery/Behaviors/HybridCacheExpireBehavior.cs create mode 100644 src/MediatR.CommandQuery/Behaviors/HybridCacheQueryBehavior.cs create mode 100644 src/MediatR.CommandQuery/Definitions/ICacheExpire.cs rename src/MediatR.CommandQuery/Definitions/{ICacheQueryResult.cs => ICacheResult.cs} (74%) create mode 100644 src/MediatR.CommandQuery/Services/CacheTagger.cs create mode 100644 src/MediatR.CommandQuery/Services/ClaimNames.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b553bdb5..0123ae2c 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -57,9 +57,8 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | - 6.0.x - 7.0.x 8.0.x + 9.0.x - name: Restore Dependencies run: dotnet restore diff --git a/src/MediatR.CommandQuery.Cosmos/DomainServiceExtensions.cs b/src/MediatR.CommandQuery.Cosmos/DomainServiceExtensions.cs index 368dfb18..e2dbb085 100644 --- a/src/MediatR.CommandQuery.Cosmos/DomainServiceExtensions.cs +++ b/src/MediatR.CommandQuery.Cosmos/DomainServiceExtensions.cs @@ -46,34 +46,6 @@ public static IServiceCollection AddEntityQueries(this IServiceCollection services) - where TRepository : ICosmosRepository - where TEntity : class, IHaveIdentifier, new() - { - ArgumentNullException.ThrowIfNull(services); - - services.AddTransient, TReadModel>, MemoryCacheQueryBehavior, TReadModel>>(); - services.AddTransient, IReadOnlyCollection>, MemoryCacheQueryBehavior, IReadOnlyCollection>>(); - services.AddTransient, EntityPagedResult>, MemoryCacheQueryBehavior, EntityPagedResult>>(); - services.AddTransient, IReadOnlyCollection>, MemoryCacheQueryBehavior, IReadOnlyCollection>>(); - - return services; - } - - public static IServiceCollection AddEntityQueryDistributedCache(this IServiceCollection services) - where TRepository : ICosmosRepository - where TEntity : class, IHaveIdentifier, new() - { - ArgumentNullException.ThrowIfNull(services); - - services.AddTransient, TReadModel>, DistributedCacheQueryBehavior, TReadModel>>(); - services.AddTransient, IReadOnlyCollection>, DistributedCacheQueryBehavior, IReadOnlyCollection>>(); - services.AddTransient, EntityPagedResult>, DistributedCacheQueryBehavior, EntityPagedResult>>(); - services.AddTransient, IReadOnlyCollection>, DistributedCacheQueryBehavior, IReadOnlyCollection>>(); - - return services; - } - public static IServiceCollection AddEntityCommands(this IServiceCollection services) where TRepository : ICosmosRepository diff --git a/src/MediatR.CommandQuery.Endpoints/DispatcherEndpoint.cs b/src/MediatR.CommandQuery.Endpoints/DispatcherEndpoint.cs index e7469c42..a79efda3 100644 --- a/src/MediatR.CommandQuery.Endpoints/DispatcherEndpoint.cs +++ b/src/MediatR.CommandQuery.Endpoints/DispatcherEndpoint.cs @@ -28,7 +28,10 @@ public void AddRoutes(IEndpointRouteBuilder app) group .MapPost(_dispatcherOptions.SendRoute, Send) - .ExcludeFromDescription(); + .WithTags("Dispatcher") + .WithName($"Send") + .WithSummary("Send Mediator command") + .WithDescription("Send Mediator command"); } protected virtual async Task Send( diff --git a/src/MediatR.CommandQuery.Endpoints/EntityQueryEndpointBase.cs b/src/MediatR.CommandQuery.Endpoints/EntityQueryEndpointBase.cs index e862adcc..1e59ef45 100644 --- a/src/MediatR.CommandQuery.Endpoints/EntityQueryEndpointBase.cs +++ b/src/MediatR.CommandQuery.Endpoints/EntityQueryEndpointBase.cs @@ -21,7 +21,7 @@ public abstract class EntityQueryEndpointBase protected EntityQueryEndpointBase(IMediator mediator, string entityName) : base(mediator) { EntityName = entityName; - RoutePrefix = $"/api/{EntityName}"; + RoutePrefix = EntityName; } public string EntityName { get; } diff --git a/src/MediatR.CommandQuery.Endpoints/FeatureEndpointExtensions.cs b/src/MediatR.CommandQuery.Endpoints/FeatureEndpointExtensions.cs index bf9177df..77c778f4 100644 --- a/src/MediatR.CommandQuery.Endpoints/FeatureEndpointExtensions.cs +++ b/src/MediatR.CommandQuery.Endpoints/FeatureEndpointExtensions.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -12,12 +13,14 @@ public static IServiceCollection AddFeatureEndpoints(this IServiceCollection ser return services; } - public static IEndpointRouteBuilder MapFeatureEndpoints(this IEndpointRouteBuilder builder) + public static IEndpointConventionBuilder MapFeatureEndpoints(this IEndpointRouteBuilder builder, string prefix = "/api") { + var featureGroup = builder.MapGroup(prefix); + var features = builder.ServiceProvider.GetServices(); foreach (var feature in features) - feature.AddRoutes(builder); - - return builder; + feature.AddRoutes(featureGroup); + + return featureGroup; } } diff --git a/src/MediatR.CommandQuery.EntityFrameworkCore/DomainServiceExtensions.cs b/src/MediatR.CommandQuery.EntityFrameworkCore/DomainServiceExtensions.cs index e623ec2b..ebcaff26 100644 --- a/src/MediatR.CommandQuery.EntityFrameworkCore/DomainServiceExtensions.cs +++ b/src/MediatR.CommandQuery.EntityFrameworkCore/DomainServiceExtensions.cs @@ -19,8 +19,9 @@ public static IServiceCollection AddEntityQueries, new() where TReadModel : class { - if (services is null) - throw new System.ArgumentNullException(nameof(services)); + ArgumentNullException.ThrowIfNull(services); + + CacheTagger.SetTag(); // standard queries services.TryAddTransient, TReadModel>, EntityIdentifierQueryHandler>(); @@ -46,36 +47,6 @@ public static IServiceCollection AddEntityQueries(this IServiceCollection services) - where TContext : DbContext - where TEntity : class, IHaveIdentifier, new() - { - if (services is null) - throw new System.ArgumentNullException(nameof(services)); - - services.AddTransient, TReadModel>, MemoryCacheQueryBehavior, TReadModel>>(); - services.AddTransient, IReadOnlyCollection>, MemoryCacheQueryBehavior, IReadOnlyCollection>>(); - services.AddTransient, EntityPagedResult>, MemoryCacheQueryBehavior, EntityPagedResult>>(); - services.AddTransient, IReadOnlyCollection>, MemoryCacheQueryBehavior, IReadOnlyCollection>>(); - - return services; - } - - public static IServiceCollection AddEntityQueryDistributedCache(this IServiceCollection services) - where TContext : DbContext - where TEntity : class, IHaveIdentifier, new() - { - if (services is null) - throw new System.ArgumentNullException(nameof(services)); - - services.AddTransient, TReadModel>, DistributedCacheQueryBehavior, TReadModel>>(); - services.AddTransient, IReadOnlyCollection>, DistributedCacheQueryBehavior, IReadOnlyCollection>>(); - services.AddTransient, EntityPagedResult>, DistributedCacheQueryBehavior, EntityPagedResult>>(); - services.AddTransient, IReadOnlyCollection>, DistributedCacheQueryBehavior, IReadOnlyCollection>>(); - - return services; - } - public static IServiceCollection AddEntityCommands(this IServiceCollection services) where TContext : DbContext @@ -83,10 +54,11 @@ public static IServiceCollection AddEntityCommands(); + CacheTagger.SetTag(); + CacheTagger.SetTag(); + CacheTagger.SetTag(); services .AddEntityCreateCommand() @@ -104,8 +76,7 @@ public static IServiceCollection AddEntityCreateCommand, new() where TCreateModel : class { - if (services is null) - throw new System.ArgumentNullException(nameof(services)); + ArgumentNullException.ThrowIfNull(services); // standard crud commands services.TryAddTransient, TReadModel>, EntityCreateCommandHandler>(); @@ -134,8 +105,7 @@ public static IServiceCollection AddEntityUpdateCommand, new() where TUpdateModel : class { - if (services is null) - throw new System.ArgumentNullException(nameof(services)); + ArgumentNullException.ThrowIfNull(services); // allow query for update models services.TryAddTransient, TUpdateModel>, EntityIdentifierQueryHandler>(); @@ -168,8 +138,7 @@ public static IServiceCollection AddEntityUpsertCommand, new() where TUpdateModel : class { - if (services is null) - throw new System.ArgumentNullException(nameof(services)); + ArgumentNullException.ThrowIfNull(services); // standard crud commands services.TryAddTransient, TReadModel>, EntityUpsertCommandHandler>(); @@ -197,8 +166,7 @@ public static IServiceCollection AddEntityPatchCommand, new() { - if (services is null) - throw new System.ArgumentNullException(nameof(services)); + ArgumentNullException.ThrowIfNull(services); // standard crud commands services.TryAddTransient, TReadModel>, EntityPatchCommandHandler>(); @@ -213,8 +181,7 @@ public static IServiceCollection AddEntityDeleteCommand, new() { - if (services is null) - throw new System.ArgumentNullException(nameof(services)); + ArgumentNullException.ThrowIfNull(services); // standard crud commands services.TryAddTransient, TReadModel>, EntityDeleteCommandHandler>(); diff --git a/src/MediatR.CommandQuery.MongoDB/DomainServiceExtensions.cs b/src/MediatR.CommandQuery.MongoDB/DomainServiceExtensions.cs index 485f5d92..715cb321 100644 --- a/src/MediatR.CommandQuery.MongoDB/DomainServiceExtensions.cs +++ b/src/MediatR.CommandQuery.MongoDB/DomainServiceExtensions.cs @@ -22,6 +22,8 @@ public static IServiceCollection AddEntityQueries(); + // standard queries services.TryAddTransient, TReadModel>, EntityIdentifierQueryHandler>(); services.TryAddTransient, IReadOnlyCollection>, EntityIdentifiersQueryHandler>(); @@ -46,34 +48,6 @@ public static IServiceCollection AddEntityQueries(this IServiceCollection services) - where TRepository : IMongoRepository - where TEntity : class, IHaveIdentifier, new() - { - ArgumentNullException.ThrowIfNull(services); - - services.AddTransient, TReadModel>, MemoryCacheQueryBehavior, TReadModel>>(); - services.AddTransient, IReadOnlyCollection>, MemoryCacheQueryBehavior, IReadOnlyCollection>>(); - services.AddTransient, EntityPagedResult>, MemoryCacheQueryBehavior, EntityPagedResult>>(); - services.AddTransient, IReadOnlyCollection>, MemoryCacheQueryBehavior, IReadOnlyCollection>>(); - - return services; - } - - public static IServiceCollection AddEntityQueryDistributedCache(this IServiceCollection services) - where TRepository : IMongoRepository - where TEntity : class, IHaveIdentifier, new() - { - ArgumentNullException.ThrowIfNull(services); - - services.AddTransient, TReadModel>, DistributedCacheQueryBehavior, TReadModel>>(); - services.AddTransient, IReadOnlyCollection>, DistributedCacheQueryBehavior, IReadOnlyCollection>>(); - services.AddTransient, EntityPagedResult>, DistributedCacheQueryBehavior, EntityPagedResult>>(); - services.AddTransient, IReadOnlyCollection>, DistributedCacheQueryBehavior, IReadOnlyCollection>>(); - - return services; - } - public static IServiceCollection AddEntityCommands(this IServiceCollection services) where TRepository : IMongoRepository @@ -83,6 +57,10 @@ public static IServiceCollection AddEntityCommands(); + CacheTagger.SetTag(); + CacheTagger.SetTag(); + services .AddEntityCreateCommand() .AddEntityUpdateCommand() diff --git a/src/MediatR.CommandQuery/Behaviors/DistributedCacheQueryBehavior.cs b/src/MediatR.CommandQuery/Behaviors/DistributedCacheQueryBehavior.cs index 750a56e0..aa3c26bb 100644 --- a/src/MediatR.CommandQuery/Behaviors/DistributedCacheQueryBehavior.cs +++ b/src/MediatR.CommandQuery/Behaviors/DistributedCacheQueryBehavior.cs @@ -17,8 +17,11 @@ public DistributedCacheQueryBehavior( IDistributedCacheSerializer distributedCacheSerializer) : base(loggerFactory) { - _distributedCache = distributedCache ?? throw new ArgumentNullException(nameof(distributedCache)); - _distributedCacheSerializer = distributedCacheSerializer ?? throw new ArgumentNullException(nameof(distributedCacheSerializer)); + ArgumentNullException.ThrowIfNull(distributedCache); + ArgumentNullException.ThrowIfNull(distributedCacheSerializer); + + _distributedCache = distributedCache; + _distributedCacheSerializer = distributedCacheSerializer; } protected override async Task Process( @@ -26,14 +29,11 @@ protected override async Task Process( RequestHandlerDelegate next, CancellationToken cancellationToken) { - if (next is null) - throw new ArgumentNullException(nameof(next)); - - if (next is null) - throw new ArgumentNullException(nameof(next)); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(next); // cache only if implements interface - var cacheRequest = request as ICacheQueryResult; + var cacheRequest = request as ICacheResult; if (cacheRequest?.IsCacheable() != true) return await next().ConfigureAwait(false); @@ -70,7 +70,8 @@ protected override async Task Process( var options = new DistributedCacheEntryOptions { SlidingExpiration = cacheRequest.SlidingExpiration(), - AbsoluteExpiration = cacheRequest.AbsoluteExpiration() + AbsoluteExpiration = cacheRequest.AbsoluteExpiration(), + }; await _distributedCache diff --git a/src/MediatR.CommandQuery/Behaviors/HybridCacheExpireBehavior.cs b/src/MediatR.CommandQuery/Behaviors/HybridCacheExpireBehavior.cs new file mode 100644 index 00000000..fd36c6f5 --- /dev/null +++ b/src/MediatR.CommandQuery/Behaviors/HybridCacheExpireBehavior.cs @@ -0,0 +1,43 @@ +using MediatR.CommandQuery.Definitions; + +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +namespace MediatR.CommandQuery.Behaviors; + +public partial class HybridCacheExpireBehavior : PipelineBehaviorBase + where TRequest : class, IRequest +{ + private readonly HybridCache _hybridCache; + + public HybridCacheExpireBehavior( + ILoggerFactory loggerFactory, + HybridCache hybridCache) + : base(loggerFactory) + { + ArgumentNullException.ThrowIfNull(hybridCache); + + _hybridCache = hybridCache; + } + + protected override async Task Process( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(next); + + var response = await next().ConfigureAwait(false); + + // expire cache + if (request is not ICacheExpire cacheRequest) + return response; + + var cacheTag = cacheRequest.GetCacheTag(); + if (!string.IsNullOrEmpty(cacheTag)) + await _hybridCache.RemoveByTagAsync(cacheTag, cancellationToken); + + return response; + } +} diff --git a/src/MediatR.CommandQuery/Behaviors/HybridCacheQueryBehavior.cs b/src/MediatR.CommandQuery/Behaviors/HybridCacheQueryBehavior.cs new file mode 100644 index 00000000..e3244ba6 --- /dev/null +++ b/src/MediatR.CommandQuery/Behaviors/HybridCacheQueryBehavior.cs @@ -0,0 +1,50 @@ +using MediatR.CommandQuery.Definitions; + +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; + +namespace MediatR.CommandQuery.Behaviors; + +public partial class HybridCacheQueryBehavior : PipelineBehaviorBase + where TRequest : class, IRequest +{ + private readonly HybridCache _hybridCache; + + public HybridCacheQueryBehavior( + ILoggerFactory loggerFactory, + HybridCache hybridCache) + : base(loggerFactory) + { + _hybridCache = hybridCache ?? throw new ArgumentNullException(nameof(hybridCache)); + } + + protected override async Task Process( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(next); + + // cache only if implements interface + var cacheRequest = request as ICacheResult; + if (cacheRequest?.IsCacheable() != true) + return await next().ConfigureAwait(false); + + var cacheKey = cacheRequest.GetCacheKey(); + var cacheTag = cacheRequest.GetCacheTag(); + + var cacheOptions = new HybridCacheEntryOptions + { + Expiration = cacheRequest.SlidingExpiration(), + LocalCacheExpiration = cacheRequest.SlidingExpiration(), + }; + + return await _hybridCache.GetOrCreateAsync( + key: cacheKey, + factory: async token => await next().ConfigureAwait(false), + options: cacheOptions, + tags: string.IsNullOrEmpty(cacheTag) ? null : [cacheTag], + cancellationToken: cancellationToken); + } +} diff --git a/src/MediatR.CommandQuery/Behaviors/MemoryCacheQueryBehavior.cs b/src/MediatR.CommandQuery/Behaviors/MemoryCacheQueryBehavior.cs index d31c2038..5da3426b 100644 --- a/src/MediatR.CommandQuery/Behaviors/MemoryCacheQueryBehavior.cs +++ b/src/MediatR.CommandQuery/Behaviors/MemoryCacheQueryBehavior.cs @@ -20,14 +20,11 @@ protected override async Task Process( RequestHandlerDelegate next, CancellationToken cancellationToken) { - if (request is null) - throw new ArgumentNullException(nameof(request)); - - if (next is null) - throw new ArgumentNullException(nameof(next)); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(next); // cache only if implements interface - var cacheRequest = request as ICacheQueryResult; + var cacheRequest = request as ICacheResult; if (cacheRequest?.IsCacheable() != true) return await next().ConfigureAwait(false); diff --git a/src/MediatR.CommandQuery/Commands/EntityCreateCommand.cs b/src/MediatR.CommandQuery/Commands/EntityCreateCommand.cs index f7726268..a81b65d0 100644 --- a/src/MediatR.CommandQuery/Commands/EntityCreateCommand.cs +++ b/src/MediatR.CommandQuery/Commands/EntityCreateCommand.cs @@ -1,13 +1,19 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Claims; +using MediatR.CommandQuery.Definitions; +using MediatR.CommandQuery.Services; + namespace MediatR.CommandQuery.Commands; public record EntityCreateCommand - : EntityModelCommand + : EntityModelCommand, ICacheExpire { public EntityCreateCommand(ClaimsPrincipal? principal, [NotNull] TCreateModel model) : base(principal, model) { } + + string? ICacheExpire.GetCacheTag() + => CacheTagger.GetTag(); } diff --git a/src/MediatR.CommandQuery/Commands/EntityDeleteCommand.cs b/src/MediatR.CommandQuery/Commands/EntityDeleteCommand.cs index c542f53d..9e18e02a 100644 --- a/src/MediatR.CommandQuery/Commands/EntityDeleteCommand.cs +++ b/src/MediatR.CommandQuery/Commands/EntityDeleteCommand.cs @@ -1,12 +1,18 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Claims; +using MediatR.CommandQuery.Definitions; +using MediatR.CommandQuery.Services; + namespace MediatR.CommandQuery.Commands; public record EntityDeleteCommand - : EntityIdentifierCommand + : EntityIdentifierCommand, ICacheExpire { public EntityDeleteCommand(ClaimsPrincipal? principal, [NotNull] TKey id) : base(principal, id) { } + + string? ICacheExpire.GetCacheTag() + => CacheTagger.GetTag(); } diff --git a/src/MediatR.CommandQuery/Commands/EntityPatchCommand.cs b/src/MediatR.CommandQuery/Commands/EntityPatchCommand.cs index 9e7c6dc4..17284015 100644 --- a/src/MediatR.CommandQuery/Commands/EntityPatchCommand.cs +++ b/src/MediatR.CommandQuery/Commands/EntityPatchCommand.cs @@ -1,17 +1,27 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Claims; +using MediatR.CommandQuery.Definitions; +using MediatR.CommandQuery.Services; + +using Microsoft.Extensions.Logging; + using SystemTextJsonPatch; namespace MediatR.CommandQuery.Commands; public record EntityPatchCommand - : EntityIdentifierCommand + : EntityIdentifierCommand, ICacheExpire { public EntityPatchCommand(ClaimsPrincipal? principal, [NotNull] TKey id, [NotNull] JsonPatchDocument patch) : base(principal, id) { - Patch = patch ?? throw new ArgumentNullException(nameof(patch)); + ArgumentNullException.ThrowIfNull(patch); + + Patch = patch; } public JsonPatchDocument Patch { get; } + + string? ICacheExpire.GetCacheTag() + => CacheTagger.GetTag(); } diff --git a/src/MediatR.CommandQuery/Commands/EntityUpdateCommand.cs b/src/MediatR.CommandQuery/Commands/EntityUpdateCommand.cs index ea39bae9..e3eff173 100644 --- a/src/MediatR.CommandQuery/Commands/EntityUpdateCommand.cs +++ b/src/MediatR.CommandQuery/Commands/EntityUpdateCommand.cs @@ -1,19 +1,26 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Claims; +using MediatR.CommandQuery.Definitions; +using MediatR.CommandQuery.Services; + +using Microsoft.Extensions.Logging; + namespace MediatR.CommandQuery.Commands; public record EntityUpdateCommand - : EntityModelCommand + : EntityModelCommand, ICacheExpire { public EntityUpdateCommand(ClaimsPrincipal? principal, [NotNull] TKey id, TUpdateModel model) : base(principal, model) { - if (id == null) - throw new ArgumentNullException(nameof(id)); + ArgumentNullException.ThrowIfNull(id); Id = id; } [NotNull] public TKey Id { get; } + + string? ICacheExpire.GetCacheTag() + => CacheTagger.GetTag(); } diff --git a/src/MediatR.CommandQuery/Commands/EntityUpsertCommand.cs b/src/MediatR.CommandQuery/Commands/EntityUpsertCommand.cs index 2df99f13..24c32e18 100644 --- a/src/MediatR.CommandQuery/Commands/EntityUpsertCommand.cs +++ b/src/MediatR.CommandQuery/Commands/EntityUpsertCommand.cs @@ -1,19 +1,24 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Claims; +using MediatR.CommandQuery.Definitions; +using MediatR.CommandQuery.Services; + namespace MediatR.CommandQuery.Commands; public record EntityUpsertCommand - : EntityModelCommand + : EntityModelCommand, ICacheExpire { public EntityUpsertCommand(ClaimsPrincipal? principal, [NotNull] TKey id, TUpdateModel model) : base(principal, model) { - if (id == null) - throw new ArgumentNullException(nameof(id)); + ArgumentNullException.ThrowIfNull(id); Id = id; } [NotNull] public TKey Id { get; } + + string? ICacheExpire.GetCacheTag() + => CacheTagger.GetTag(); } diff --git a/src/MediatR.CommandQuery/Converters/ClaimsPrincipalConverter.cs b/src/MediatR.CommandQuery/Converters/ClaimsPrincipalConverter.cs index 0259afaf..30b507bd 100644 --- a/src/MediatR.CommandQuery/Converters/ClaimsPrincipalConverter.cs +++ b/src/MediatR.CommandQuery/Converters/ClaimsPrincipalConverter.cs @@ -12,16 +12,11 @@ public class ClaimsPrincipalConverter : JsonConverter JsonSerializerOptions options ) { - var claimsPrincipalProxy = JsonSerializer.Deserialize( - ref reader, - options - ); + var claimsPrincipalProxy = JsonSerializer.Deserialize(ref reader, options); if (claimsPrincipalProxy is null) - { return null; - } - + return new( new ClaimsIdentity( claimsPrincipalProxy.Claims.Select(c => new Claim(c.Type, c.Value)), diff --git a/src/MediatR.CommandQuery/Definitions/ICacheExpire.cs b/src/MediatR.CommandQuery/Definitions/ICacheExpire.cs new file mode 100644 index 00000000..0847f74a --- /dev/null +++ b/src/MediatR.CommandQuery/Definitions/ICacheExpire.cs @@ -0,0 +1,6 @@ +namespace MediatR.CommandQuery.Definitions; + +public interface ICacheExpire +{ + string? GetCacheTag(); +} diff --git a/src/MediatR.CommandQuery/Definitions/ICacheQueryResult.cs b/src/MediatR.CommandQuery/Definitions/ICacheResult.cs similarity index 74% rename from src/MediatR.CommandQuery/Definitions/ICacheQueryResult.cs rename to src/MediatR.CommandQuery/Definitions/ICacheResult.cs index a0c14a03..3009789a 100644 --- a/src/MediatR.CommandQuery/Definitions/ICacheQueryResult.cs +++ b/src/MediatR.CommandQuery/Definitions/ICacheResult.cs @@ -1,11 +1,15 @@ namespace MediatR.CommandQuery.Definitions; -public interface ICacheQueryResult +public interface ICacheResult { bool IsCacheable(); + string GetCacheKey(); + string? GetCacheTag(); + + TimeSpan? SlidingExpiration(); DateTimeOffset? AbsoluteExpiration(); diff --git a/src/MediatR.CommandQuery/Definitions/IPrincipalReader.cs b/src/MediatR.CommandQuery/Definitions/IPrincipalReader.cs index 249c6e87..c7000ade 100644 --- a/src/MediatR.CommandQuery/Definitions/IPrincipalReader.cs +++ b/src/MediatR.CommandQuery/Definitions/IPrincipalReader.cs @@ -4,9 +4,11 @@ namespace MediatR.CommandQuery.Definitions; public interface IPrincipalReader { - public string? GetIdentifier(IPrincipal? principal); + string? GetIdentifier(IPrincipal? principal); - public string? GetName(IPrincipal? principal); + string? GetName(IPrincipal? principal); - public string? GetEmail(IPrincipal? principal); + string? GetEmail(IPrincipal? principal); + + Guid? GetObjectId(IPrincipal? principal); } diff --git a/src/MediatR.CommandQuery/Dispatcher/DispatcherOptions.cs b/src/MediatR.CommandQuery/Dispatcher/DispatcherOptions.cs index 35cdd993..cf098e82 100644 --- a/src/MediatR.CommandQuery/Dispatcher/DispatcherOptions.cs +++ b/src/MediatR.CommandQuery/Dispatcher/DispatcherOptions.cs @@ -2,8 +2,8 @@ namespace MediatR.CommandQuery.Dispatcher; public class DispatcherOptions { - public string RoutePrefix { get; set; } = "/api"; + public string RoutePrefix { get; set; } = "/dispatcher"; - public string SendRoute { get; set; } = "/dispatcher"; + public string SendRoute { get; set; } = "/send"; } diff --git a/src/MediatR.CommandQuery/Dispatcher/RemoteDispatcher.cs b/src/MediatR.CommandQuery/Dispatcher/RemoteDispatcher.cs index 9b231758..5cdf6923 100644 --- a/src/MediatR.CommandQuery/Dispatcher/RemoteDispatcher.cs +++ b/src/MediatR.CommandQuery/Dispatcher/RemoteDispatcher.cs @@ -1,8 +1,10 @@ using System.Net.Http.Json; using System.Text.Json; +using MediatR.CommandQuery.Definitions; using MediatR.CommandQuery.Models; +using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Options; namespace MediatR.CommandQuery.Dispatcher; @@ -12,15 +14,46 @@ public class RemoteDispatcher : IDispatcher private readonly HttpClient _httpClient; private readonly JsonSerializerOptions _serializerOptions; private readonly DispatcherOptions _dispatcherOptions; + private readonly HybridCache _hybridCache; - public RemoteDispatcher(HttpClient httpClient, JsonSerializerOptions serializerOptions, IOptions dispatcherOptions) + public RemoteDispatcher(HttpClient httpClient, JsonSerializerOptions serializerOptions, IOptions dispatcherOptions, HybridCache hybridCache) { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(serializerOptions); + ArgumentNullException.ThrowIfNull(dispatcherOptions); + ArgumentNullException.ThrowIfNull(hybridCache); + _httpClient = httpClient; _serializerOptions = serializerOptions; _dispatcherOptions = dispatcherOptions.Value; + _hybridCache = hybridCache; } public async Task Send(IRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + // cache only if implements interface + var cacheRequest = request as ICacheResult; + if (cacheRequest?.IsCacheable() != true) + return await SendCore(request, cancellationToken); + + var cacheKey = cacheRequest.GetCacheKey(); + var cacheTag = cacheRequest.GetCacheTag(); + var cacheOptions = new HybridCacheEntryOptions + { + Expiration = cacheRequest.SlidingExpiration() + }; + + return await _hybridCache.GetOrCreateAsync( + key: cacheKey, + factory: async token => await SendCore(request, token), + options: cacheOptions, + tags: string.IsNullOrEmpty(cacheTag) ? null : [cacheTag], + cancellationToken: cancellationToken); + } + + private async Task SendCore(IRequest request, CancellationToken cancellationToken) { var requestUri = Combine(_dispatcherOptions.RoutePrefix, _dispatcherOptions.SendRoute); @@ -34,9 +67,19 @@ public RemoteDispatcher(HttpClient httpClient, JsonSerializerOptions serializerO await EnsureSuccessStatusCode(responseMessage, cancellationToken); - return await responseMessage.Content.ReadFromJsonAsync( + var response = await responseMessage.Content.ReadFromJsonAsync( options: _serializerOptions, cancellationToken: cancellationToken); + + // expire cache + if (request is not ICacheExpire cacheRequest) + return response; + + var cacheTag = cacheRequest.GetCacheTag(); + if (!string.IsNullOrEmpty(cacheTag)) + await _hybridCache.RemoveByTagAsync(cacheTag, cancellationToken); + + return response; } private async Task EnsureSuccessStatusCode(HttpResponseMessage responseMessage, CancellationToken cancellationToken = default) diff --git a/src/MediatR.CommandQuery/MediatorServiceExtensions.cs b/src/MediatR.CommandQuery/MediatorServiceExtensions.cs index a32a77f7..1ca52a9f 100644 --- a/src/MediatR.CommandQuery/MediatorServiceExtensions.cs +++ b/src/MediatR.CommandQuery/MediatorServiceExtensions.cs @@ -1,7 +1,10 @@ using FluentValidation; +using MediatR.CommandQuery.Behaviors; +using MediatR.CommandQuery.Commands; using MediatR.CommandQuery.Definitions; using MediatR.CommandQuery.Dispatcher; +using MediatR.CommandQuery.Queries; using MediatR.CommandQuery.Services; using MediatR.NotificationPublishers; @@ -43,6 +46,7 @@ public static IServiceCollection AddValidatorsFromAssembly(this IServiceColle return services; } + public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); @@ -62,4 +66,48 @@ public static IServiceCollection AddServerDispatcher(this IServiceCollection ser return services; } + + + public static IServiceCollection AddEntityQueryMemoryCache(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddTransient, TReadModel>, MemoryCacheQueryBehavior, TReadModel>>(); + services.AddTransient, IReadOnlyCollection>, MemoryCacheQueryBehavior, IReadOnlyCollection>>(); + services.AddTransient, EntityPagedResult>, MemoryCacheQueryBehavior, EntityPagedResult>>(); + services.AddTransient, IReadOnlyCollection>, MemoryCacheQueryBehavior, IReadOnlyCollection>>(); + + return services; + } + + public static IServiceCollection AddEntityQueryDistributedCache(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddTransient, TReadModel>, DistributedCacheQueryBehavior, TReadModel>>(); + services.AddTransient, IReadOnlyCollection>, DistributedCacheQueryBehavior, IReadOnlyCollection>>(); + services.AddTransient, EntityPagedResult>, DistributedCacheQueryBehavior, EntityPagedResult>>(); + services.AddTransient, IReadOnlyCollection>, DistributedCacheQueryBehavior, IReadOnlyCollection>>(); + + return services; + } + + + public static IServiceCollection AddEntityHybridCache(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddTransient, TReadModel>, HybridCacheQueryBehavior, TReadModel>>(); + services.AddTransient, IReadOnlyCollection>, HybridCacheQueryBehavior, IReadOnlyCollection>>(); + services.AddTransient, EntityPagedResult>, HybridCacheQueryBehavior, EntityPagedResult>>(); + services.AddTransient, IReadOnlyCollection>, HybridCacheQueryBehavior, IReadOnlyCollection>>(); + + services.AddTransient, TReadModel>, HybridCacheExpireBehavior, TReadModel>>(); + services.AddTransient, TReadModel>, HybridCacheExpireBehavior, TReadModel>>(); + services.AddTransient, TReadModel>, HybridCacheExpireBehavior, TReadModel>>(); + services.AddTransient, TReadModel>, HybridCacheExpireBehavior, TReadModel>>(); + services.AddTransient, TReadModel>, HybridCacheExpireBehavior, TReadModel>>(); + + return services; + } } diff --git a/src/MediatR.CommandQuery/Queries/CacheableQueryBase.cs b/src/MediatR.CommandQuery/Queries/CacheableQueryBase.cs index dbae019a..962b8566 100644 --- a/src/MediatR.CommandQuery/Queries/CacheableQueryBase.cs +++ b/src/MediatR.CommandQuery/Queries/CacheableQueryBase.cs @@ -6,7 +6,7 @@ namespace MediatR.CommandQuery.Queries; -public abstract record CacheableQueryBase : PrincipalQueryBase, ICacheQueryResult +public abstract record CacheableQueryBase : PrincipalQueryBase, ICacheResult { private DateTimeOffset? _absoluteExpiration; private TimeSpan? _slidingExpiration; @@ -18,6 +18,8 @@ protected CacheableQueryBase(ClaimsPrincipal? principal) : base(principal) public abstract string GetCacheKey(); + public abstract string? GetCacheTag(); + public bool IsCacheable() { return _absoluteExpiration.HasValue @@ -25,19 +27,23 @@ public bool IsCacheable() } - public void Cache(DateTimeOffset? absoluteExpiration = null, TimeSpan? slidingExpiration = null) + public void Cache(DateTimeOffset absoluteExpiration) { _absoluteExpiration = absoluteExpiration; - _slidingExpiration = slidingExpiration; + } + + public void Cache(TimeSpan expiration) + { + _slidingExpiration = expiration; } - DateTimeOffset? ICacheQueryResult.AbsoluteExpiration() + DateTimeOffset? ICacheResult.AbsoluteExpiration() { return _absoluteExpiration; } - TimeSpan? ICacheQueryResult.SlidingExpiration() + TimeSpan? ICacheResult.SlidingExpiration() { return _slidingExpiration; } diff --git a/src/MediatR.CommandQuery/Queries/EntityIdentifierQuery.cs b/src/MediatR.CommandQuery/Queries/EntityIdentifierQuery.cs index 67ebe0ce..f5f9e34f 100644 --- a/src/MediatR.CommandQuery/Queries/EntityIdentifierQuery.cs +++ b/src/MediatR.CommandQuery/Queries/EntityIdentifierQuery.cs @@ -1,6 +1,8 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Claims; +using MediatR.CommandQuery.Services; + namespace MediatR.CommandQuery.Queries; public record EntityIdentifierQuery : CacheableQueryBase @@ -9,8 +11,7 @@ public record EntityIdentifierQuery : CacheableQueryBase CacheTagger.GetKey(CacheTagger.Buckets.Identifier, Id); + + public override string? GetCacheTag() + => CacheTagger.GetTag(); } diff --git a/src/MediatR.CommandQuery/Queries/EntityIdentifiersQuery.cs b/src/MediatR.CommandQuery/Queries/EntityIdentifiersQuery.cs index c7b95ffe..6810b60d 100644 --- a/src/MediatR.CommandQuery/Queries/EntityIdentifiersQuery.cs +++ b/src/MediatR.CommandQuery/Queries/EntityIdentifiersQuery.cs @@ -1,6 +1,8 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Claims; +using MediatR.CommandQuery.Services; + namespace MediatR.CommandQuery.Queries; public record EntityIdentifiersQuery : CacheableQueryBase> @@ -8,8 +10,7 @@ public record EntityIdentifiersQuery : CacheableQueryBase ids) : base(principal) { - if (ids is null) - throw new ArgumentNullException(nameof(ids)); + ArgumentNullException.ThrowIfNull(ids); Ids = ids.ToList(); } @@ -24,6 +25,9 @@ public override string GetCacheKey() foreach (var id in Ids) hash.Add(id); - return $"{typeof(TReadModel).FullName}-{hash.ToHashCode()}"; + return CacheTagger.GetKey(CacheTagger.Buckets.Identifiers, hash.ToHashCode()); } + + public override string? GetCacheTag() + => CacheTagger.GetTag(); } diff --git a/src/MediatR.CommandQuery/Queries/EntityPagedQuery.cs b/src/MediatR.CommandQuery/Queries/EntityPagedQuery.cs index 817b13f1..229870d1 100644 --- a/src/MediatR.CommandQuery/Queries/EntityPagedQuery.cs +++ b/src/MediatR.CommandQuery/Queries/EntityPagedQuery.cs @@ -1,5 +1,7 @@ using System.Security.Claims; +using MediatR.CommandQuery.Services; + namespace MediatR.CommandQuery.Queries; public record EntityPagedQuery : CacheableQueryBase> @@ -14,8 +16,8 @@ public EntityPagedQuery(ClaimsPrincipal? principal, EntityQuery? query) public override string GetCacheKey() - { - var hash = Query.GetHashCode(); - return $"{typeof(TReadModel).FullName}-Paged-{hash}"; - } + => CacheTagger.GetKey(CacheTagger.Buckets.Paged, Query.GetHashCode()); + + public override string? GetCacheTag() + => CacheTagger.GetTag(); } diff --git a/src/MediatR.CommandQuery/Queries/EntitySelectQuery.cs b/src/MediatR.CommandQuery/Queries/EntitySelectQuery.cs index 1d94ca6a..21c68459 100644 --- a/src/MediatR.CommandQuery/Queries/EntitySelectQuery.cs +++ b/src/MediatR.CommandQuery/Queries/EntitySelectQuery.cs @@ -1,5 +1,7 @@ using System.Security.Claims; +using MediatR.CommandQuery.Services; + namespace MediatR.CommandQuery.Queries; public record EntitySelectQuery : CacheableQueryBase> @@ -15,7 +17,7 @@ public EntitySelectQuery(ClaimsPrincipal? principal, EntityFilter filter) } public EntitySelectQuery(ClaimsPrincipal? principal, EntityFilter filter, EntitySort sort) - : this(principal, filter, new[] { sort }) + : this(principal, filter, [sort]) { } @@ -35,9 +37,8 @@ public EntitySelectQuery(ClaimsPrincipal? principal, EntitySelect select) public override string GetCacheKey() - { - var hash = Select.GetHashCode(); + => CacheTagger.GetKey(CacheTagger.Buckets.List, Select.GetHashCode()); - return $"{typeof(TReadModel).FullName}-Select-{hash}"; - } + public override string? GetCacheTag() + => CacheTagger.GetTag(); } diff --git a/src/MediatR.CommandQuery/Queries/EntitySort.cs b/src/MediatR.CommandQuery/Queries/EntitySort.cs index e88d2546..8373dc8a 100644 --- a/src/MediatR.CommandQuery/Queries/EntitySort.cs +++ b/src/MediatR.CommandQuery/Queries/EntitySort.cs @@ -16,7 +16,7 @@ public class EntitySort if (string.IsNullOrEmpty(sortString)) return null; - var parts = sortString.Split(new[] { ":" }, StringSplitOptions.RemoveEmptyEntries); + var parts = sortString.Split([':'], StringSplitOptions.RemoveEmptyEntries); if (parts is null || parts.Length == 0) return null; diff --git a/src/MediatR.CommandQuery/Services/ActivityTimer.cs b/src/MediatR.CommandQuery/Services/ActivityTimer.cs index 401615c5..f013471f 100644 --- a/src/MediatR.CommandQuery/Services/ActivityTimer.cs +++ b/src/MediatR.CommandQuery/Services/ActivityTimer.cs @@ -4,13 +4,11 @@ namespace MediatR.CommandQuery.Services; public static class ActivityTimer { - private static readonly double _tickFrequency = (double)TimeSpan.TicksPerSecond / Stopwatch.Frequency; - public static long GetTimestamp() => Stopwatch.GetTimestamp(); public static TimeSpan GetElapsedTime(long startingTimestamp) => GetElapsedTime(startingTimestamp, GetTimestamp()); public static TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp) - => new((long)((endingTimestamp - startingTimestamp) * _tickFrequency)); + => Stopwatch.GetElapsedTime(startingTimestamp, endingTimestamp); } diff --git a/src/MediatR.CommandQuery/Services/CacheTagger.cs b/src/MediatR.CommandQuery/Services/CacheTagger.cs new file mode 100644 index 00000000..b085290b --- /dev/null +++ b/src/MediatR.CommandQuery/Services/CacheTagger.cs @@ -0,0 +1,42 @@ +using System.Collections.Concurrent; + +namespace MediatR.CommandQuery.Services; + +public static class CacheTagger +{ + private static readonly ConcurrentDictionary _typeTags = new(); + + public static void SetTag(string? tag) + { + _typeTags.TryAdd(typeof(TModel), tag); + } + + public static void SetTag() + { + _typeTags.TryAdd(typeof(TModel), typeof(TEntity).FullName); + } + + public static string? GetTag() + { + if (_typeTags.TryGetValue(typeof(TModel), out var tag)) + return tag; + + return typeof(TModel).FullName; + } + + public static string GetKey(string bucket, TValue value, string delimiter = ".") + { + _typeTags.TryGetValue(typeof(TModel), out var tag); + tag ??= typeof(TModel).FullName; + + return $"{tag}{delimiter}{bucket}{delimiter}{value}"; + } + + public static class Buckets + { + public const string Identifier = "id"; + public const string Identifiers = "ids"; + public const string Paged = "page"; + public const string List = "list"; + } +} diff --git a/src/MediatR.CommandQuery/Services/ClaimNames.cs b/src/MediatR.CommandQuery/Services/ClaimNames.cs new file mode 100644 index 00000000..0d8b9e2b --- /dev/null +++ b/src/MediatR.CommandQuery/Services/ClaimNames.cs @@ -0,0 +1,21 @@ +namespace MediatR.CommandQuery.Services; + +public static class ClaimNames +{ + public const string ObjectIdenttifier = "oid"; + + public const string Subject = "sub"; + public const string NameClaim = "name"; + public const string EmailClaim = "email"; + public const string EmailsClaim = "emails"; + public const string ProviderClaim = "idp"; + public const string PreferredUserName = "preferred_username"; + + public const string IdentityClaim = "http://schemas.microsoft.com/identity/claims/identityprovider"; + public const string IdentifierClaim = "http://schemas.microsoft.com/identity/claims/objectidentifier"; + + public const string UserId = "oid"; + public const string EmployeeNumber = "emp"; + + public const string RuleClaim = "rules"; +} diff --git a/src/MediatR.CommandQuery/Services/PrincipalReader.cs b/src/MediatR.CommandQuery/Services/PrincipalReader.cs index f50b2ff7..1436e297 100644 --- a/src/MediatR.CommandQuery/Services/PrincipalReader.cs +++ b/src/MediatR.CommandQuery/Services/PrincipalReader.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using System.Security.Principal; +using System.Xml.Linq; using MediatR.CommandQuery.Definitions; @@ -18,10 +19,15 @@ public PrincipalReader(ILogger logger) public string? GetEmail(IPrincipal? principal) { + if (principal is null) + return null; + var claimPrincipal = principal as ClaimsPrincipal; - var emailClaim = claimPrincipal?.FindFirst(ClaimTypes.Email); + var claim = claimPrincipal?.FindFirst(ClaimTypes.Email) + ?? claimPrincipal?.FindFirst(ClaimNames.EmailClaim) + ?? claimPrincipal?.FindFirst(ClaimNames.EmailsClaim); - var email = emailClaim?.Value; + var email = claim?.Value; LogPrincipal(_logger, "Email", email); @@ -30,6 +36,9 @@ public PrincipalReader(ILogger logger) public string? GetIdentifier(IPrincipal? principal) { + if (principal is null) + return null; + var name = principal?.Identity?.Name; LogPrincipal(_logger, "Identifier", name); @@ -39,13 +48,34 @@ public PrincipalReader(ILogger logger) public string? GetName(IPrincipal? principal) { - var name = principal?.Identity?.Name; + if (principal is null) + return null; + + var claimPrincipal = principal as ClaimsPrincipal; + var claim = claimPrincipal?.FindFirst(ClaimNames.NameClaim) + ?? claimPrincipal?.FindFirst(ClaimTypes.Name) + ?? claimPrincipal?.FindFirst(ClaimNames.Subject); + + var name = claim?.Value ?? principal.Identity?.Name; LogPrincipal(_logger, "Name", name); return name; } + public Guid? GetObjectId(IPrincipal? principal) + { + if (principal is null) + return null; + + var claimPrincipal = principal as ClaimsPrincipal; + var claim = claimPrincipal?.FindFirst(ClaimNames.IdentifierClaim) + ?? claimPrincipal?.FindFirst(ClaimNames.ObjectIdenttifier) + ?? claimPrincipal?.FindFirst(ClaimTypes.NameIdentifier); + + return Guid.TryParse(claim?.Value, out var oid) ? oid : null; + } + [LoggerMessage(1, LogLevel.Trace, "Resolved principal claim {Type}: {Value}")] static partial void LogPrincipal(ILogger logger, string type, string? value); }