Init
This commit is contained in:
62
Prefab/Config.cs
Normal file
62
Prefab/Config.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Handler.Decorators;
|
||||
|
||||
namespace Prefab;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for registering core Prefab services with the dependency injection container.
|
||||
/// </summary>
|
||||
public static class Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Prefab handler pipeline services and decorators to the specified service collection.
|
||||
/// </summary>
|
||||
/// <remarks>This method registers the default handler pipeline decorators and invoker required for
|
||||
/// Prefab. Decorators are added in the order of execution, similar to ASP.NET Core middleware.</remarks>
|
||||
/// <param name="services">The service collection to which the Prefab services will be added. Cannot be null.</param>
|
||||
/// <param name="configuration">The application configuration used to configure Prefab services. Cannot be null.</param>
|
||||
/// <returns>The same instance of <see cref="IServiceCollection"/> that was provided, to support method chaining.</returns>
|
||||
public static IServiceCollection AddPrefab(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<IHandlerContextAccessor, HandlerContextAccessor>();
|
||||
|
||||
// Default decorators for handler pipeline. Place in order of execution, same way as .NET Core middleware does.
|
||||
services.AddScoped<IHandlerDecorator, HandlerContextDecorator>();
|
||||
services.AddScoped<IHandlerDecorator, ValidationDecorator>();
|
||||
services.AddScoped<IHandlerDecorator, LoggingDecorator>();
|
||||
|
||||
services.AddScoped<HandlerInvoker>();
|
||||
|
||||
services.Scan(scan => scan
|
||||
.FromApplicationDependencies(assembly => assembly.GetName().Name?.StartsWith(nameof(Prefab)) == true)
|
||||
.AddClasses(classes => classes.AssignableTo(typeof(IValidator<>)), publicOnly: false)
|
||||
.AsImplementedInterfaces()
|
||||
.WithScopedLifetime());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans Prefab assemblies for handler implementations and registers them with the dependency injection container.
|
||||
/// </summary>
|
||||
/// <remarks>Call this after all Prefab modules are loaded so Scrutor can discover handlers defined within them.</remarks>
|
||||
public static IServiceCollection AddPrefabHandlers(this IServiceCollection services)
|
||||
{
|
||||
services.Scan(scan => scan
|
||||
.FromApplicationDependencies(assembly => assembly.GetName().Name?.StartsWith(nameof(Prefab)) == true)
|
||||
.AddClasses(classes => classes.AssignableTo(typeof(IHandler<,>)), publicOnly: false)
|
||||
.AsImplementedInterfaces()
|
||||
.WithScopedLifetime()
|
||||
.AddClasses(classes => classes.AssignableTo(typeof(IHandler<>)), publicOnly: false)
|
||||
.AsImplementedInterfaces()
|
||||
.WithScopedLifetime());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
34
Prefab/Culture/CultureController.cs
Normal file
34
Prefab/Culture/CultureController.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Microsoft.AspNetCore.Localization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Prefab.Culture;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for setting the culture.
|
||||
/// </summary>
|
||||
[Route("[controller]/[action]")]
|
||||
public class CultureController : Controller
|
||||
{
|
||||
/// <summary>
|
||||
/// Set the culture and redirect to the specified URL.
|
||||
/// </summary>
|
||||
/// <param name="culture"></param>
|
||||
/// <param name="redirectUri"></param>
|
||||
/// <returns>LocalRedirect ActionResult</returns>
|
||||
/// <remarks>
|
||||
/// Use LocalRedirect action result to prevent open redirect attacks.
|
||||
/// For more information <see href="https://learn.microsoft.com/en-us/aspnet/core/security/preventing-open-redirects?view=aspnetcore-9.0">Prevent open redirect attacks in ASP.NET Core</see>
|
||||
/// </remarks>
|
||||
public IActionResult Set(string? culture, string redirectUri)
|
||||
{
|
||||
if (culture != null)
|
||||
{
|
||||
HttpContext.Response.Cookies.Append(
|
||||
CookieRequestCultureProvider.DefaultCookieName,
|
||||
CookieRequestCultureProvider.MakeCookieValue(
|
||||
new RequestCulture(culture, culture)));
|
||||
}
|
||||
|
||||
return LocalRedirect(redirectUri);
|
||||
}
|
||||
}
|
||||
48
Prefab/Data/Configs/AuditLogConfig.cs
Normal file
48
Prefab/Data/Configs/AuditLogConfig.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
namespace Prefab.Data.Configs
|
||||
{
|
||||
/// <summary>
|
||||
/// Entity Framework Core configuration for the <see cref="AuditLog"/> entity.
|
||||
/// </summary>
|
||||
public class AuditLogConfig() : Prefab.Data.Configs.EntityConfig<AuditLog>(nameof(IPrefabDb.AuditLogs))
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the properties and relationships for the <see cref="AuditLog"/> entity.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder to be used to configure the entity type.</param>
|
||||
public override void Configure(EntityTypeBuilder<AuditLog> builder)
|
||||
{
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Entity)
|
||||
.HasMaxLength(Rules.EntityMaxLength)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.State)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.CreatedBy)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.CreatedOn)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasMany(e => e.Items)
|
||||
.WithOne(i => i.AuditLog)
|
||||
.HasForeignKey(i => i.AuditLogId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules and settings for <see cref="AuditLog"/> properties.
|
||||
/// </summary>
|
||||
public static class Rules
|
||||
{
|
||||
/// <summary>Maximum length for the Entity name.</summary>
|
||||
public const int EntityMaxLength = 200;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
Prefab/Data/Configs/AuditLogItemConfig.cs
Normal file
48
Prefab/Data/Configs/AuditLogItemConfig.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
namespace Prefab.Data.Configs
|
||||
{
|
||||
/// <summary>
|
||||
/// Entity Framework Core configuration for the <see cref="AuditLogItem"/> entity.
|
||||
/// </summary>
|
||||
public class AuditLogItemConfig() : Prefab.Data.Configs.EntityConfig<AuditLogItem>(nameof(IPrefabDb.AuditLogItems))
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the properties and relationships for the <see cref="AuditLogItem"/> entity.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder to be used to configure the entity type.</param>
|
||||
public override void Configure(EntityTypeBuilder<AuditLogItem> builder)
|
||||
{
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Property)
|
||||
.HasMaxLength(Rules.PropertyMaxLength)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.OldValue)
|
||||
.HasMaxLength(Rules.ValueMaxLength);
|
||||
|
||||
builder.Property(e => e.NewValue)
|
||||
.HasMaxLength(Rules.ValueMaxLength);
|
||||
|
||||
builder.HasOne(e => e.AuditLog)
|
||||
.WithMany(a => a.Items)
|
||||
.HasForeignKey(e => e.AuditLogId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules and settings for <see cref="AuditLogItem"/> properties.
|
||||
/// </summary>
|
||||
public static class Rules
|
||||
{
|
||||
/// <summary>Maximum length for the property name.</summary>
|
||||
public const int PropertyMaxLength = 100;
|
||||
/// <summary>Maximum length for the value fields.</summary>
|
||||
public const int ValueMaxLength = 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
Prefab/Data/Configs/EntityConfig.cs
Normal file
27
Prefab/Data/Configs/EntityConfig.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Prefab.Data.Configs
|
||||
{
|
||||
/// <summary>
|
||||
/// Base configuration for all entities, sets items like table name and schema.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The entity type.</typeparam>
|
||||
public abstract class EntityConfig<T> : IEntityTypeConfiguration<T> where T : class
|
||||
{
|
||||
private readonly string _schema;
|
||||
private readonly string _tableName;
|
||||
|
||||
protected EntityConfig(string tableName, string? schema = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
|
||||
_tableName = tableName;
|
||||
_schema = string.IsNullOrWhiteSpace(schema) ? "dbo" : schema;
|
||||
}
|
||||
|
||||
public virtual void Configure(EntityTypeBuilder<T> builder)
|
||||
{
|
||||
builder.ToTable(_tableName, _schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
Prefab/Data/Configs/GenericAttributeConfig.cs
Normal file
72
Prefab/Data/Configs/GenericAttributeConfig.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
namespace Prefab.Data.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration class for the <see cref="GenericAttribute"/> entity within the dbo schema.
|
||||
/// </summary>
|
||||
public class GenericAttributeConfig() : EntityConfig<GenericAttribute>(nameof(IPrefabDb.GenericAttributes))
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration of Entity Framework mapping for the <see cref="GenericAttribute"/> entity.
|
||||
/// </summary>
|
||||
public override void Configure(EntityTypeBuilder<GenericAttribute> builder)
|
||||
{
|
||||
builder
|
||||
.Ignore(x => x.Id);
|
||||
|
||||
// Define the composite key (and clustered index) for the GenericAttribute entity.
|
||||
builder
|
||||
.HasKey(x => new { x.EntityId, x.KeyGroup, x.Key });
|
||||
|
||||
builder.HasIndex(x => new { x.EntityId, x.KeyGroup })
|
||||
.HasDatabaseName("IX_GenericAttributes_Entity_Group");
|
||||
|
||||
builder.HasIndex(x => x.DeletedOn)
|
||||
.HasFilter("[DeletedOn] IS NULL")
|
||||
.HasDatabaseName("IX_GenericAttributes_Active");
|
||||
|
||||
builder
|
||||
.Property(x => x.KeyGroup)
|
||||
.HasMaxLength(Rules.KeyGroupMaxLength)
|
||||
.IsRequired();
|
||||
|
||||
builder
|
||||
.Property(x => x.Key)
|
||||
.HasMaxLength(Rules.KeyMaxLength)
|
||||
.IsRequired();
|
||||
|
||||
builder
|
||||
.Property(x => x.Value)
|
||||
.HasMaxLength(Rules.ValueMaxLength)
|
||||
.IsRequired();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for the <see cref="GenericAttribute"/> entity.
|
||||
/// </summary>
|
||||
public static class Rules
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum length to set for the generic attribute key group.
|
||||
/// </summary>
|
||||
public const int KeyGroupMaxLength = 400;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum length to set for the generic attribute key.
|
||||
/// </summary>
|
||||
public const int KeyMinLength = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum length to set for the generic attribute key.
|
||||
/// </summary>
|
||||
public const int KeyMaxLength = 400;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum length to set for the generic attribute value.
|
||||
/// </summary>
|
||||
public const int ValueMaxLength = int.MaxValue;
|
||||
}
|
||||
}
|
||||
53
Prefab/Data/Configs/SeederLogConfig.cs
Normal file
53
Prefab/Data/Configs/SeederLogConfig.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
namespace Prefab.Data.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration class for the <see cref="SeederLog"/> entity.
|
||||
/// </summary>
|
||||
public class SeederLogConfig() : Prefab.Data.Configs.EntityConfig<SeederLog>(nameof(IPrefabDb.SeederLogs))
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration of Entity Framework mapping for the <see cref="SeederLog"/> entity.
|
||||
/// </summary>
|
||||
public override void Configure(EntityTypeBuilder<SeederLog> builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder
|
||||
.HasKey(x => x.Id);
|
||||
|
||||
builder
|
||||
.Property(x => x.Id)
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
builder.Property(x => x.SeederName)
|
||||
.HasMaxLength(Rules.SeederNameMaxLength)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(x => x.RunMode)
|
||||
.HasMaxLength(Rules.SeederRunModeMaxLength)
|
||||
.IsRequired();
|
||||
|
||||
builder
|
||||
.Property(x => x.RunAt)
|
||||
.IsRequired();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for the <see cref="SeederLog"/> entity.
|
||||
/// </summary>
|
||||
public static class Rules
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum length to set for the seeder name.
|
||||
/// </summary>
|
||||
public const int SeederNameMaxLength = 256;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum length to set for the seeder name.
|
||||
/// </summary>
|
||||
public const int SeederRunModeMaxLength = 64;
|
||||
}
|
||||
}
|
||||
39
Prefab/Data/Entities/AuditLog.cs
Normal file
39
Prefab/Data/Entities/AuditLog.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an audit log entry that captures an entity state transition.
|
||||
/// </summary>
|
||||
public class AuditLog : Entity<int>, ICreated
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the correlation identifier tying related audit events together.
|
||||
/// </summary>
|
||||
public Guid CorrelationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the fully qualified entity name that generated the audit record.
|
||||
/// </summary>
|
||||
public string Entity { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized state of the entity when the audit was captured.
|
||||
/// </summary>
|
||||
public string State { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of field-level changes associated with the audit.
|
||||
/// </summary>
|
||||
public virtual ICollection<AuditLogItem> Items { get; set; } = new List<AuditLogItem>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the user who triggered the change.
|
||||
/// </summary>
|
||||
public Guid CreatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when the change occurred.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedOn { get; set; }
|
||||
}
|
||||
34
Prefab/Data/Entities/AuditLogItem.cs
Normal file
34
Prefab/Data/Entities/AuditLogItem.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a field-level change within an audit log entry.
|
||||
/// </summary>
|
||||
public class AuditLogItem : Entity<int>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the parent audit log identifier.
|
||||
/// </summary>
|
||||
public int AuditLogId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the property name that changed.
|
||||
/// </summary>
|
||||
public string Property { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the previous serialized value.
|
||||
/// </summary>
|
||||
public string? OldValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the new serialized value.
|
||||
/// </summary>
|
||||
public string? NewValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the navigation back to the owning audit log.
|
||||
/// </summary>
|
||||
public virtual AuditLog AuditLog { get; set; } = null!;
|
||||
}
|
||||
34
Prefab/Data/Entities/GenericAttribute.cs
Normal file
34
Prefab/Data/Entities/GenericAttribute.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a persisted generic attribute for an entity.
|
||||
/// </summary>
|
||||
public class GenericAttribute : EntityWithAuditAndStatus<int>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the owning entity identifier.
|
||||
/// </summary>
|
||||
public Guid EntityId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the logical group used to partition attributes by entity type.
|
||||
/// </summary>
|
||||
public string KeyGroup { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the attribute key.
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the fully qualified type name of the serialized value.
|
||||
/// </summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized attribute value.
|
||||
/// </summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
24
Prefab/Data/Entities/SeederLog.cs
Normal file
24
Prefab/Data/Entities/SeederLog.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Captures metadata about seeder executions to prevent duplicate runs.
|
||||
/// </summary>
|
||||
public class SeederLog : Entity<int>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the seeder identifier.
|
||||
/// </summary>
|
||||
public string SeederName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run mode (e.g., Development or Production) used for the execution.
|
||||
/// </summary>
|
||||
public string RunMode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the seeder completed.
|
||||
/// </summary>
|
||||
public DateTime RunAt { get; init; }
|
||||
}
|
||||
14
Prefab/Data/Enums.cs
Normal file
14
Prefab/Data/Enums.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Prefab.Data;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Represents the available statuses that an entity's record can be in.
|
||||
/// </summary>
|
||||
public enum AuditStatus
|
||||
{
|
||||
Active,
|
||||
|
||||
Inactive,
|
||||
|
||||
Deleted
|
||||
}
|
||||
26
Prefab/Data/Extensions.cs
Normal file
26
Prefab/Data/Extensions.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="ModelBuilder"/> when configuring the database model.
|
||||
/// </summary>
|
||||
public static class ModelBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Ignores the domain event collection for all entities implementing <see cref="IHasDomainEvents"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The model builder to configure.</param>
|
||||
public static void IgnoreDomainEvents(this ModelBuilder builder)
|
||||
{
|
||||
builder.Model
|
||||
.GetEntityTypes()
|
||||
.Where(e => typeof(IHasDomainEvents).IsAssignableFrom(e.ClrType))
|
||||
.ToList()
|
||||
.ForEach(e =>
|
||||
{
|
||||
builder.Entity(e.ClrType).Ignore(nameof(IHasDomainEvents.Events));
|
||||
});
|
||||
}
|
||||
}
|
||||
49
Prefab/Data/IPrefabDb.cs
Normal file
49
Prefab/Data/IPrefabDb.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
namespace Prefab.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the Prefab relational database context.
|
||||
/// </summary>
|
||||
public interface IPrefabDb : IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the EF Core database facade for executing commands and migrations.
|
||||
/// </summary>
|
||||
DatabaseFacade Database { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Persists pending changes to the database.
|
||||
/// </summary>
|
||||
int SaveChanges();
|
||||
|
||||
/// <summary>
|
||||
/// Persists pending changes to the database asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token used to cancel the operation.</param>
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit log set.
|
||||
/// </summary>
|
||||
DbSet<AuditLog> AuditLogs { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit log item set.
|
||||
/// </summary>
|
||||
DbSet<AuditLogItem> AuditLogItems { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generic attribute set.
|
||||
/// </summary>
|
||||
DbSet<GenericAttribute> GenericAttributes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the seeder audit set.
|
||||
/// </summary>
|
||||
DbSet<SeederLog> SeederLogs { get; }
|
||||
}
|
||||
|
||||
|
||||
8
Prefab/Data/IPrefabDbReadOnly.cs
Normal file
8
Prefab/Data/IPrefabDbReadOnly.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Prefab.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface to represent read-only access to the Prefab database.
|
||||
/// </summary>
|
||||
public interface IPrefabDbReadOnly : IPrefabDb
|
||||
{
|
||||
}
|
||||
250
Prefab/Data/PrefabDb.cs
Normal file
250
Prefab/Data/PrefabDb.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Prefab.Data.Entities;
|
||||
using Prefab.Domain.Common;
|
||||
using Prefab.Handler;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Prefab.Data;
|
||||
|
||||
public abstract class PrefabDb(DbContextOptions options, IHandlerContextAccessor accessor)
|
||||
: DbContext(options)
|
||||
{
|
||||
/// <summary>
|
||||
/// Exposes the database facade for provider-specific operations.
|
||||
/// </summary>
|
||||
public new DatabaseFacade Database => base.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Applies Prefab conventions in addition to consumer-provided model configuration.
|
||||
/// </summary>
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(IPrefabDb).Assembly);
|
||||
|
||||
PrefabOnModelCreating(modelBuilder);
|
||||
modelBuilder.IgnoreDomainEvents();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allows derived contexts to extend model configuration.
|
||||
/// </summary>
|
||||
/// <param name="builder">The model builder supplied by EF Core.</param>
|
||||
protected abstract void PrefabOnModelCreating(ModelBuilder builder);
|
||||
|
||||
/// <summary>
|
||||
/// Applies Prefab defaults followed by consumer configuration hooks.
|
||||
/// </summary>
|
||||
/// <param name="optionsBuilder">The options builder used to configure the context.</param>
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
PrefabOnConfiguring(optionsBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allows derived contexts to customise provider-specific options.
|
||||
/// </summary>
|
||||
/// <param name="optionsBuilder">The options builder supplied by EF Core.</param>
|
||||
protected abstract void PrefabOnConfiguring(DbContextOptionsBuilder optionsBuilder);
|
||||
|
||||
/// <summary>
|
||||
/// Persists changes to the database while executing auditing and outbox processing.
|
||||
/// </summary>
|
||||
public override int SaveChanges() => SaveChangesAsync().ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Persists changes to the database while executing auditing and outbox processing.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token used to cancel the save operation.</param>
|
||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new())
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var correlationId = Guid.TryParse(accessor?.Current?.CorrelationId, out var parsedCorrelationId)
|
||||
? parsedCorrelationId
|
||||
: Guid.Empty;
|
||||
|
||||
var auditEntitiesCache = new Dictionary<Type, bool>();
|
||||
var propertiesCache = new Dictionary<Type, (PropertyInfo? CreatedBy, PropertyInfo? CreatedOn, PropertyInfo? ModifiedBy, PropertyInfo? ModifiedOn)>();
|
||||
var auditEntries = new List<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry>();
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries())
|
||||
{
|
||||
var entityType = entry.Entity.GetType();
|
||||
if (!auditEntitiesCache.TryGetValue(entityType, out var entityIsAuditable))
|
||||
{
|
||||
entityIsAuditable = entityType.GetInterfaces()
|
||||
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEntityWithAudit<>));
|
||||
auditEntitiesCache[entityType] = entityIsAuditable;
|
||||
}
|
||||
|
||||
if (!entityIsAuditable)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!propertiesCache.TryGetValue(entityType, out var props))
|
||||
{
|
||||
props = (
|
||||
CreatedBy: entityType.GetProperty(nameof(IAudit.CreatedBy)),
|
||||
CreatedOn: entityType.GetProperty(nameof(IAudit.CreatedOn)),
|
||||
ModifiedBy: entityType.GetProperty(nameof(IAudit.LastModifiedBy)),
|
||||
ModifiedOn: entityType.GetProperty(nameof(IAudit.LastModifiedOn))
|
||||
);
|
||||
propertiesCache[entityType] = props;
|
||||
}
|
||||
|
||||
switch (entry.State)
|
||||
{
|
||||
case EntityState.Added:
|
||||
props.CreatedBy?.SetValue(entry.Entity, props.CreatedBy.GetValue(entry.Entity) ?? accessor?.Current?.UserId);
|
||||
props.CreatedOn?.SetValue(entry.Entity, now);
|
||||
auditEntries.Add(entry);
|
||||
break;
|
||||
case EntityState.Modified:
|
||||
props.ModifiedBy?.SetValue(entry.Entity, accessor?.Current?.UserId ?? accessor?.Current?.UserId);
|
||||
props.ModifiedOn?.SetValue(entry.Entity, now);
|
||||
auditEntries.Add(entry);
|
||||
break;
|
||||
case EntityState.Deleted:
|
||||
if (TrySoftDelete(entry, props, accessor?.Current?.UserId, now))
|
||||
{
|
||||
entry.State = EntityState.Modified;
|
||||
auditEntries.Add(entry);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in auditEntries)
|
||||
{
|
||||
var audit = new AuditLog
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
Entity = entry.Entity.GetType().Name,
|
||||
State = entry.State.ToString(),
|
||||
CreatedBy = Guid.TryParse(accessor?.Current?.UserId, out var userId) ? userId : Guid.Empty,
|
||||
CreatedOn = now,
|
||||
Items = new List<AuditLogItem>()
|
||||
};
|
||||
|
||||
foreach (var prop in entry.Properties)
|
||||
{
|
||||
if (entry.State == EntityState.Modified && !prop.IsModified)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var oldVal = entry.State == EntityState.Modified ? prop.OriginalValue?.ToString() : null;
|
||||
var newVal = prop.CurrentValue?.ToString();
|
||||
audit.Items.Add(new AuditLogItem
|
||||
{
|
||||
Property = prop.Metadata.Name,
|
||||
OldValue = oldVal,
|
||||
NewValue = newVal
|
||||
});
|
||||
}
|
||||
|
||||
AuditLogs.Add(audit);
|
||||
}
|
||||
|
||||
var domainEventEntities = ChangeTracker
|
||||
.Entries<IHasDomainEvents>()
|
||||
.Where(e => e.Entity.Events.Count > 0)
|
||||
.Select(e => e.Entity)
|
||||
.ToList();
|
||||
|
||||
if (domainEventEntities.Count > 0)
|
||||
{
|
||||
var domainEvents = domainEventEntities.SelectMany(e => e.Events).ToList();
|
||||
foreach (var ev in domainEvents)
|
||||
{
|
||||
//Outbox.Add(Prefab.Queuing.Domain.Outbox.New(ev));
|
||||
}
|
||||
|
||||
domainEventEntities.ForEach(e => e.ClearEvents());
|
||||
}
|
||||
|
||||
return await base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static bool TrySoftDelete(
|
||||
Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry,
|
||||
(PropertyInfo? CreatedBy, PropertyInfo? CreatedOn, PropertyInfo? ModifiedBy, PropertyInfo? ModifiedOn) props,
|
||||
string? userId,
|
||||
DateTimeOffset deletedOn)
|
||||
{
|
||||
var entityType = entry.Entity.GetType();
|
||||
var supportsSoftDelete = entityType.GetInterfaces()
|
||||
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEntityWithAuditAndStatus<>));
|
||||
|
||||
if (!supportsSoftDelete)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var deletedOnProp = entityType.GetProperty(nameof(EntityWithAuditAndStatus<Guid>.DeletedOn));
|
||||
var deletedByProp = entityType.GetProperty(nameof(EntityWithAuditAndStatus<Guid>.DeletedBy));
|
||||
|
||||
deletedOnProp?.SetValue(entry.Entity, deletedOn);
|
||||
if (deletedByProp is not null)
|
||||
{
|
||||
if (Guid.TryParse(userId, out var parsedUserId))
|
||||
{
|
||||
deletedByProp.SetValue(entry.Entity, parsedUserId);
|
||||
}
|
||||
else
|
||||
{
|
||||
deletedByProp.SetValue(entry.Entity, null);
|
||||
}
|
||||
}
|
||||
|
||||
const string deletedOnPropertyName = nameof(EntityWithAuditAndStatus<Guid>.DeletedOn);
|
||||
if (entry.Metadata.FindProperty(deletedOnPropertyName) is not null)
|
||||
{
|
||||
entry.Property(deletedOnPropertyName).IsModified = true;
|
||||
}
|
||||
|
||||
const string deletedByPropertyName = nameof(EntityWithAuditAndStatus<Guid>.DeletedBy);
|
||||
if (entry.Metadata.FindProperty(deletedByPropertyName) is not null)
|
||||
{
|
||||
entry.Property(deletedByPropertyName).IsModified = true;
|
||||
}
|
||||
|
||||
props.ModifiedOn?.SetValue(entry.Entity, deletedOn);
|
||||
if (props.ModifiedBy is not null)
|
||||
{
|
||||
if (Guid.TryParse(userId, out var parsedUserId))
|
||||
{
|
||||
props.ModifiedBy.SetValue(entry.Entity, parsedUserId);
|
||||
}
|
||||
else
|
||||
{
|
||||
props.ModifiedBy.SetValue(entry.Entity, null);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit log set.
|
||||
/// </summary>
|
||||
public DbSet<AuditLog> AuditLogs { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit log item set.
|
||||
/// </summary>
|
||||
public DbSet<AuditLogItem> AuditLogItems { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generic attribute set.
|
||||
/// </summary>
|
||||
public DbSet<GenericAttribute> GenericAttributes => Set<GenericAttribute>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the seeder log set.
|
||||
/// </summary>
|
||||
public DbSet<SeederLog> SeederLogs => Set<SeederLog>();
|
||||
}
|
||||
39
Prefab/Data/Queries/EntityAuditQueries.cs
Normal file
39
Prefab/Data/Queries/EntityAuditQueries.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Provides reusable audit-related query filters for entity sets.
|
||||
/// </summary>
|
||||
public static class EntityAuditQueries
|
||||
{
|
||||
/// <summary>
|
||||
/// Filters entities created before the specified timestamp.
|
||||
/// </summary>
|
||||
public static IQueryable<IAudit> ThatWasCreateBefore(this IQueryable<IAudit> query, DateTime dateTime) => query.Where(x => x.CreatedOn < dateTime);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities created after the specified timestamp.
|
||||
/// </summary>
|
||||
public static IQueryable<IAudit> ThatWasCreatedAfter(this IQueryable<IAudit> query, DateTime dateTime) => query.Where(x => x.CreatedOn > dateTime);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities created between the specified start and end timestamps.
|
||||
/// </summary>
|
||||
public static IQueryable<IAudit> ThatWasCreatedBetween(this IQueryable<IAudit> query, DateTime start, DateTime end) => query.ThatWasCreatedAfter(start).ThatWasCreateBefore(end);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities last modified before the specified timestamp.
|
||||
/// </summary>
|
||||
public static IQueryable<IAudit> ThatWasLastModifiedBefore(this IQueryable<IAudit> query, DateTime dateTime) => query.Where(x => x.LastModifiedOn < dateTime);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities last modified after the specified timestamp.
|
||||
/// </summary>
|
||||
public static IQueryable<IAudit> ThatWasLastModifiedAfter(this IQueryable<IAudit> query, DateTime dateTime) => query.Where(x => x.LastModifiedOn > dateTime);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities last modified between the specified start and end timestamps.
|
||||
/// </summary>
|
||||
public static IQueryable<IAudit> ThatWasLastModifiedBetween(this IQueryable<IAudit> query, DateTime start, DateTime end) => query.ThatWasLastModifiedAfter(start).ThatWasLastModifiedBefore(end);
|
||||
}
|
||||
24
Prefab/Data/Queries/EntityStatusQueries.cs
Normal file
24
Prefab/Data/Queries/EntityStatusQueries.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Provides query helpers for filtering entities by audit status.
|
||||
/// </summary>
|
||||
public static class EntityStatusQueries
|
||||
{
|
||||
/// <summary>
|
||||
/// Filters entities with an <see cref="AuditStatus.Active"/> status.
|
||||
/// </summary>
|
||||
public static IQueryable<IStatus> WithAnActiveStatus(this IQueryable<IStatus> query) => query.Where(x => x.AuditStatus == AuditStatus.Active);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities with an <see cref="AuditStatus.Inactive"/> status.
|
||||
/// </summary>
|
||||
public static IQueryable<IStatus> WithAnInactiveStatus(this IQueryable<IStatus> query) => query.Where(x => x.AuditStatus == AuditStatus.Inactive);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities with an <see cref="AuditStatus.Deleted"/> status.
|
||||
/// </summary>
|
||||
public static IQueryable<IStatus> WithADeletedStatus(this IQueryable<IStatus> query) => query.Where(x => x.AuditStatus == AuditStatus.Deleted);
|
||||
}
|
||||
107
Prefab/Data/Queries/GenericAttributeQueries.cs
Normal file
107
Prefab/Data/Queries/GenericAttributeQueries.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for the <see cref="GenericAttribute"/> entity.
|
||||
/// </summary>
|
||||
public static class GenericAttributeQueries
|
||||
{
|
||||
/// <summary>
|
||||
/// Query for records where the <see cref="GenericAttribute.EntityId"/> field matches the given entity.
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> ForEntity(this IQueryable<GenericAttribute> query, IEntity<Guid> entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
|
||||
return query.Where(x => x.EntityId == entity.Id && x.KeyGroup == entity.GetType().FullName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query for records in a module-specific view where the EntityId matches the given entity.
|
||||
/// </summary>
|
||||
public static IQueryable<TView> ForEntity<TView>(this IQueryable<TView> query, IEntity<Guid> entity, string keyGroup)
|
||||
where TView : class
|
||||
{
|
||||
return GenericAttributeViewQueries.ForEntity(query, entity, keyGroup);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query for records where the <see cref="GenericAttribute.Value"/> field contains characters from the given string value.
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> WhereTheValueContains(this IQueryable<GenericAttribute> query, string value)
|
||||
{
|
||||
return query.Where(x => x.Value.Contains(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter by key (exact match).
|
||||
/// </summary>
|
||||
private static IQueryable<GenericAttribute> WhereKey(
|
||||
this IQueryable<GenericAttribute> q, string key) =>
|
||||
q.Where(x => x.Key == key);
|
||||
|
||||
/// <summary>
|
||||
/// Filter by key-group (exact match).
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> WhereGroup(
|
||||
this IQueryable<GenericAttribute> q, string keyGroup) =>
|
||||
q.Where(x => x.KeyGroup == keyGroup);
|
||||
|
||||
/// <summary>
|
||||
/// Filter by type (exact match).
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> WhereType(
|
||||
this IQueryable<GenericAttribute> q, string type) =>
|
||||
q.Where(x => x.Type == type);
|
||||
|
||||
/// <summary>
|
||||
/// Filter by any of the given keys.
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> WhereKeys(
|
||||
this IQueryable<GenericAttribute> q, params string[] keys) =>
|
||||
q.Where(x => keys.Contains(x.Key));
|
||||
|
||||
/// <summary>
|
||||
/// Case-insensitive substring search on Value using EF.Functions.Like.
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> WhereValueLike(
|
||||
this IQueryable<GenericAttribute> q, string pattern) =>
|
||||
q.Where(x => EF.Functions.Like(x.Value, pattern));
|
||||
|
||||
/// <summary>
|
||||
/// Project into a key→value dictionary.
|
||||
/// </summary>
|
||||
public static async Task<Dictionary<string, string>> ToDictionaryAsync(
|
||||
this IQueryable<GenericAttribute> q,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await q
|
||||
.Select(x => new { x.Key, x.Value })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Value, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shortcut to fetch a single attribute value (or null).
|
||||
/// </summary>
|
||||
public static async Task<string?> GetValueAsync(
|
||||
this IQueryable<GenericAttribute> q, string key, CancellationToken ct = default)
|
||||
{
|
||||
return await q
|
||||
.WhereKey(key)
|
||||
.Select(x => x.Value)
|
||||
.FirstOrDefaultAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply AsNoTracking for read-only queries.
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> AsReadOnly(
|
||||
this IQueryable<GenericAttribute> q) =>
|
||||
q.AsNoTracking();
|
||||
}
|
||||
30
Prefab/Data/Queries/GenericAttributeViewQueries.cs
Normal file
30
Prefab/Data/Queries/GenericAttributeViewQueries.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Provides shared filters for working with generic attribute views.
|
||||
/// </summary>
|
||||
public static class GenericAttributeViewQueries
|
||||
{
|
||||
/// <summary>
|
||||
/// Filters a module-specific view by entity identifier and key group.
|
||||
/// </summary>
|
||||
public static IQueryable<TView> ForEntity<TView>(this IQueryable<TView> query, IEntity<Guid> entity, string keyGroup)
|
||||
where TView : class
|
||||
{
|
||||
if (entity == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entity));
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(keyGroup))
|
||||
{
|
||||
throw new ArgumentException("KeyGroup cannot be null or empty.", nameof(keyGroup));
|
||||
}
|
||||
|
||||
return query.Where(x => EF.Property<Guid>(x, "EntityId") == entity.Id &&
|
||||
EF.Property<string>(x, "KeyGroup") == keyGroup);
|
||||
}
|
||||
}
|
||||
29
Prefab/Data/Seeder/Extensions.cs
Normal file
29
Prefab/Data/Seeder/Extensions.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Prefab.Data.Seeder;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a seeder and its options so it can participate in environment-aware execution.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSeeder">Seeder implementation.</typeparam>
|
||||
/// <param name="services">The host service collection.</param>
|
||||
/// <param name="runMode">Determines when the seeder is allowed to run.</param>
|
||||
/// <returns>The provided service collection.</returns>
|
||||
public static IServiceCollection AddSeeder<TSeeder>(this IServiceCollection services, RunMode runMode = RunMode.RunOnFirstLoadOnly) where TSeeder : class, ISeeder
|
||||
{
|
||||
services.AddScoped<ISeeder, TSeeder>();
|
||||
services.AddScoped<TSeeder>();
|
||||
services.Configure<SeederOptions<TSeeder>>(opts => opts.RunMode = runMode);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the channel used to queue seeder operations for background execution.
|
||||
/// </summary>
|
||||
public static Channel<Func<IServiceProvider, CancellationToken, Task>> Channel { get; }
|
||||
= System.Threading.Channels.Channel.CreateUnbounded<Func<IServiceProvider, CancellationToken, Task>>();
|
||||
}
|
||||
12
Prefab/Data/Seeder/ISeeder.cs
Normal file
12
Prefab/Data/Seeder/ISeeder.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Prefab.Data.Seeder;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a seeder that can be used to seed the database with data.
|
||||
/// </summary>
|
||||
public interface ISeeder
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes the seed task.
|
||||
/// </summary>
|
||||
Task Execute(IServiceProvider serviceProvider, CancellationToken cancellationToken);
|
||||
}
|
||||
13
Prefab/Data/Seeder/Options.cs
Normal file
13
Prefab/Data/Seeder/Options.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Prefab.Data.Seeder;
|
||||
|
||||
/// <summary>
|
||||
/// Options for a specific <see cref="ISeeder"/> implementation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSeeder">The seeder type these options apply to.</typeparam>
|
||||
public class SeederOptions<TSeeder>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets when the seeder should execute.
|
||||
/// </summary>
|
||||
public RunMode RunMode { get; set; } = RunMode.RunOnFirstLoadOnly;
|
||||
}
|
||||
199
Prefab/Data/Seeder/PrefabDbSeeder.cs
Normal file
199
Prefab/Data/Seeder/PrefabDbSeeder.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Prefab.Base;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
namespace Prefab.Data.Seeder;
|
||||
|
||||
/// <summary>
|
||||
/// Base infrastructure for executing database seeders with lookup synchronization support.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSeeder">The concrete seeder implementation.</typeparam>
|
||||
/// <typeparam name="TDb">The database abstraction used by the seeder.</typeparam>
|
||||
public abstract class PrefabDbSeeder<TSeeder, TDb> : ISeeder where TDb : IPrefabDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task Execute(IServiceProvider serviceProvider, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var db = serviceProvider.GetRequiredService<TDb>();
|
||||
var options = serviceProvider.GetService<IOptions<SeederOptions<TSeeder>>>()?.Value ?? new SeederOptions<TSeeder>();
|
||||
var seederName = typeof(TSeeder).FullName ?? typeof(TSeeder).Name;
|
||||
var runMode = options.RunMode;
|
||||
var shouldSeedData = false;
|
||||
|
||||
await SeedLookups(db, cancellationToken);
|
||||
await ApplyViews(db, serviceProvider, cancellationToken);
|
||||
|
||||
switch (runMode)
|
||||
{
|
||||
case RunMode.RunAlways:
|
||||
shouldSeedData = true;
|
||||
break;
|
||||
case RunMode.SingleRun:
|
||||
case RunMode.RunOnFirstLoadOnly:
|
||||
shouldSeedData = !db.SeederLogs.Any(x => x.SeederName == seederName);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!shouldSeedData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SeedData(db, serviceProvider, cancellationToken);
|
||||
|
||||
db.SeederLogs.Add(new SeederLog
|
||||
{
|
||||
SeederName = seederName,
|
||||
RunMode = runMode.ToString(),
|
||||
RunAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies database views, ensuring dependent queries exist before data seeding runs.
|
||||
/// </summary>
|
||||
protected abstract Task ApplyViews(TDb db, IServiceProvider serviceProvider, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Seeds data specific to the derived context.
|
||||
/// </summary>
|
||||
protected abstract Task SeedData(TDb db, IServiceProvider serviceProvider, CancellationToken cancellationToken);
|
||||
|
||||
private static async Task SeedLookups(TDb dbContext, CancellationToken cancellationToken)
|
||||
{
|
||||
var processedTypes = new HashSet<Type>();
|
||||
var dbContextType = dbContext.GetType();
|
||||
var dbSetProperties = dbContextType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(prop => prop.PropertyType.IsGenericType &&
|
||||
prop.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>));
|
||||
|
||||
foreach (var prop in dbSetProperties)
|
||||
{
|
||||
var entityType = prop.PropertyType.GetGenericArguments()[0];
|
||||
if (!processedTypes.Add(entityType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsSubclassOfGeneric(entityType, typeof(LookupEntity<>)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var baseType = entityType.BaseType;
|
||||
if (baseType is not { IsGenericType: true } ||
|
||||
baseType.GetGenericTypeDefinition() != typeof(LookupEntity<>))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var enumType = baseType.GetGenericArguments()[0];
|
||||
var method = typeof(PrefabDbSeeder<TSeeder, TDb>).GetMethod(nameof(SeedLookupAsync), BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (method is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var genericMethod = method.MakeGenericMethod(entityType, enumType);
|
||||
await (Task)genericMethod.Invoke(null, new object[] { dbContext, cancellationToken })!;
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
static bool IsSubclassOfGeneric(Type type, Type generic)
|
||||
{
|
||||
while (type is not null && type != typeof(object))
|
||||
{
|
||||
var current = type.IsGenericType ? type.GetGenericTypeDefinition() : type;
|
||||
if (current == generic)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
type = type.BaseType!;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SeedLookupAsync<TLookup, TEnum>(DbContext dbContext, CancellationToken cancellationToken)
|
||||
where TLookup : LookupEntity<TEnum>, new()
|
||||
where TEnum : PrefabEnum<TEnum>
|
||||
{
|
||||
var dbSet = dbContext.Set<TLookup>();
|
||||
|
||||
var enumMembers = PrefabEnum<TEnum>.List
|
||||
.Select(e => new
|
||||
{
|
||||
Value = e.Value,
|
||||
Name = e.Name,
|
||||
ShortName = GetOptionalString(e, "ShortName"),
|
||||
Description = GetOptionalString(e, "Description"),
|
||||
SortOrder = e.Value
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var existingRows = await dbSet.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var member in enumMembers)
|
||||
{
|
||||
var lookupRow = existingRows.FirstOrDefault(r => r.Id == member.Value);
|
||||
if (lookupRow is null)
|
||||
{
|
||||
lookupRow = new TLookup
|
||||
{
|
||||
Id = member.Value,
|
||||
Name = member.Name,
|
||||
ShortName = member.ShortName,
|
||||
Description = member.Description,
|
||||
SortOrder = member.SortOrder
|
||||
};
|
||||
dbSet.Add(lookupRow);
|
||||
}
|
||||
else if (lookupRow.Name != member.Name ||
|
||||
lookupRow.ShortName != member.ShortName ||
|
||||
lookupRow.Description != member.Description ||
|
||||
lookupRow.SortOrder != member.SortOrder)
|
||||
{
|
||||
lookupRow.Name = member.Name;
|
||||
lookupRow.ShortName = member.ShortName;
|
||||
lookupRow.Description = member.Description;
|
||||
lookupRow.SortOrder = member.SortOrder;
|
||||
dbSet.Update(lookupRow);
|
||||
}
|
||||
}
|
||||
|
||||
var validValues = enumMembers.Select(m => m.Value).ToHashSet();
|
||||
foreach (var row in existingRows.Where(row => !validValues.Contains(row.Id)))
|
||||
{
|
||||
dbSet.Remove(row);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
static string? GetOptionalString(PrefabEnum<TEnum> value, string propertyName)
|
||||
{
|
||||
var property = value.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy);
|
||||
if (property is null || property.PropertyType != typeof(string))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.GetValue(value) as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
Prefab/Data/Seeder/RunMode.cs
Normal file
21
Prefab/Data/Seeder/RunMode.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace Prefab.Data.Seeder;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates when a data loader should be run.
|
||||
/// </summary>
|
||||
public enum RunMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Data loader will be run only for a newly-initialized database
|
||||
/// </summary>
|
||||
RunOnFirstLoadOnly,
|
||||
/// <summary>
|
||||
/// Data loader will be run once only in a given database
|
||||
/// </summary>
|
||||
SingleRun,
|
||||
/// <summary>
|
||||
/// Data loader will run every time the app starts up
|
||||
/// </summary>
|
||||
RunAlways
|
||||
}
|
||||
|
||||
37
Prefab/Domain/Common/Entity.cs
Normal file
37
Prefab/Domain/Common/Entity.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Base implementation for aggregate entities that support domain events.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Entity identifier type.</typeparam>
|
||||
public abstract class Entity<T> : IEntity<T>, IHasDomainEvents
|
||||
{
|
||||
private readonly List<Event> _events = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public T Id { get; set; } = default!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyCollection<Event> Events => _events.AsReadOnly();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddEvent(Event e) => _events.Add(e);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RemoveEvent(Event e) => _events.Remove(e);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearEvents() => _events.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Generates a UUIDv7 identifier using the supplied timestamp, primarily for testing scenarios.
|
||||
/// </summary>
|
||||
/// <param name="timestamp">The timestamp used to seed the UUID generator.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the entity identifier type is not <see cref="Guid"/>.</exception>
|
||||
protected void GenerateIdWithTimestamp(DateTime timestamp)
|
||||
{
|
||||
Id = typeof(T) == typeof(Guid)
|
||||
? (T)(object)Guid.CreateVersion7(timestamp)
|
||||
: throw new InvalidOperationException("Cannot generate a UUIDv7 for a non-Guid entity.");
|
||||
}
|
||||
}
|
||||
25
Prefab/Domain/Common/EntityWithAudit.cs
Normal file
25
Prefab/Domain/Common/EntityWithAudit.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Interface representation of an entity. All entities that require auditing should implement this.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type that will represent the entity's ID field.</typeparam>
|
||||
public interface IEntityWithAudit<T> : IEntity<T>, IAudit
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IEntityWithAudit{T}" />
|
||||
public abstract class EntityWithAudit<T> : Entity<T>, IEntityWithAudit<T>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Guid CreatedBy { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset CreatedOn { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid LastModifiedBy { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastModifiedOn { get; init; }
|
||||
}
|
||||
118
Prefab/Domain/Common/EntityWithAuditAndStatus.cs
Normal file
118
Prefab/Domain/Common/EntityWithAuditAndStatus.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Prefab.Data;
|
||||
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an audited entity that also tracks status transitions such as deactivate and delete.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The identifier type.</typeparam>
|
||||
public interface IEntityWithAuditAndStatus<T> : IEntityWithAudit<T>, IStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the user that deactivated the entity.
|
||||
/// </summary>
|
||||
Guid? InactivatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the entity was deactivated.
|
||||
/// </summary>
|
||||
DateTimeOffset? InactivatedOn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the user that soft-deleted the entity.
|
||||
/// </summary>
|
||||
Guid? DeletedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the entity was soft-deleted.
|
||||
/// </summary>
|
||||
DateTimeOffset? DeletedOn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the concurrency token used for optimistic locking.
|
||||
/// </summary>
|
||||
byte[] RowVersion { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base type for audited entities that support activation, deactivation, and soft-delete flows.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The identifier type.</typeparam>
|
||||
public abstract class EntityWithAuditAndStatus<T> : EntityWithAudit<T>, IEntityWithAuditAndStatus<T>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public AuditStatus AuditStatus => DeletedOn.HasValue
|
||||
? AuditStatus.Deleted
|
||||
: InactivatedOn.HasValue
|
||||
? AuditStatus.Inactive
|
||||
: AuditStatus.Active;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid? InactivatedBy { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset? InactivatedOn { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid? DeletedBy { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset? DeletedOn { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
[Timestamp]
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Marks the entity as active by clearing any inactive markers.
|
||||
/// </summary>
|
||||
/// <param name="userId">Identifier of the user performing the change.</param>
|
||||
public virtual void Activate(Guid? userId = null)
|
||||
{
|
||||
if (InactivatedOn.HasValue)
|
||||
{
|
||||
InactivatedOn = null;
|
||||
InactivatedBy = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the entity as inactive if it is not already inactive.
|
||||
/// </summary>
|
||||
/// <param name="userId">Identifier of the user performing the change.</param>
|
||||
public virtual void Deactivate(Guid? userId = null)
|
||||
{
|
||||
if (!InactivatedOn.HasValue)
|
||||
{
|
||||
InactivatedOn = DateTimeOffset.UtcNow;
|
||||
InactivatedBy = userId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft deletes the entity when it has not already been deleted.
|
||||
/// </summary>
|
||||
/// <param name="userId">Identifier of the user performing the change.</param>
|
||||
public virtual void Delete(Guid? userId = null)
|
||||
{
|
||||
if (!DeletedOn.HasValue)
|
||||
{
|
||||
DeletedOn = DateTimeOffset.UtcNow;
|
||||
DeletedBy = userId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores a previously soft-deleted entity to an inactive state.
|
||||
/// </summary>
|
||||
/// <param name="userId">Identifier of the user performing the change.</param>
|
||||
public virtual void Restore(Guid? userId = null)
|
||||
{
|
||||
if (DeletedOn.HasValue)
|
||||
{
|
||||
DeletedOn = null;
|
||||
DeletedBy = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
Prefab/Domain/Common/EntityWithAuditAndStatusAndTenant.cs
Normal file
17
Prefab/Domain/Common/EntityWithAuditAndStatusAndTenant.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// All entities that require auditing, statuses and tenant information should use this.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type that will represent the entity's ID field.</typeparam>
|
||||
public interface IEntityWithAuditAndStatusAndTenant<T> : IEntityWithAuditAndStatus<T>, ITenant
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IEntityWithAuditAndStatusAndTenant{T}" />
|
||||
public abstract class EntityWithAuditAndStatusAndTenant<T> : EntityWithAuditAndStatus<T>,
|
||||
IEntityWithAuditAndStatusAndTenant<T>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
16
Prefab/Domain/Common/EntityWithAuditAndTenant.cs
Normal file
16
Prefab/Domain/Common/EntityWithAuditAndTenant.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// All entities that require auditing, statuses and tenant information should use this.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type that will represent the entity's ID field.</typeparam>
|
||||
public interface IEntityWithAuditAndTenant<T> : IEntityWithAudit<T>, ITenant
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IEntityWithAuditAndTenant{T}" />
|
||||
public class EntityWithAuditAndTenant<T> : EntityWithAudit<T>, IEntityWithAuditAndTenant<T>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
17
Prefab/Domain/Common/EntityWithTenant.cs
Normal file
17
Prefab/Domain/Common/EntityWithTenant.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// All entities that require tenant information should use this. Use
|
||||
/// <see cref="EntityWithAuditAndStatusAndTenant{T}" />
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type that will represent the entity's ID field.</typeparam>
|
||||
public interface IEntityWithTenant<T> : IEntity<T>, ITenant
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IEntityWithTenant{T}" />
|
||||
public abstract class EntityWithTenant<T> : Entity<T>, IEntityWithTenant<T>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
22
Prefab/Domain/Common/Event.cs
Normal file
22
Prefab/Domain/Common/Event.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Prefab.Base;
|
||||
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Base type for domain events captured during aggregate processing.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public abstract record Event : IEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the event has been dispatched to external transports.
|
||||
/// </summary>
|
||||
public bool IsPublished { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp for when the event occurred.
|
||||
/// </summary>
|
||||
public DateTimeOffset DateOccurred { get; protected set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
17
Prefab/Domain/Common/IAudit.cs
Normal file
17
Prefab/Domain/Common/IAudit.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Describes audit metadata captured on entities.
|
||||
/// </summary>
|
||||
public interface IAudit : ICreated
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the identifier of the user that last updated the entity.
|
||||
/// </summary>
|
||||
Guid LastModifiedBy { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the entity was last updated.
|
||||
/// </summary>
|
||||
DateTimeOffset LastModifiedOn { get; }
|
||||
}
|
||||
17
Prefab/Domain/Common/ICreated.cs
Normal file
17
Prefab/Domain/Common/ICreated.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Captures creation metadata for auditable entities.
|
||||
/// </summary>
|
||||
public interface ICreated
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the identifier of the user that created the entity.
|
||||
/// </summary>
|
||||
Guid CreatedBy { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the entity was created.
|
||||
/// </summary>
|
||||
DateTimeOffset CreatedOn { get; }
|
||||
}
|
||||
13
Prefab/Domain/Common/IEntity.cs
Normal file
13
Prefab/Domain/Common/IEntity.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Interface representation of an entity. All entities should implement this.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type that will represent the entity's ID field.</typeparam>
|
||||
public interface IEntity<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the entity.
|
||||
/// </summary>
|
||||
T Id { get; set; }
|
||||
}
|
||||
29
Prefab/Domain/Common/IHasDomainEvents.cs
Normal file
29
Prefab/Domain/Common/IHasDomainEvents.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Prefab.Base;
|
||||
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Interface representation of an event in the domain model.
|
||||
/// </summary>
|
||||
public interface IHasDomainEvents : IEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the events of the entity.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<Event> Events { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds an event to the entity.
|
||||
/// </summary>
|
||||
public void AddEvent(Event e);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an event from the entity.
|
||||
/// </summary>
|
||||
public void RemoveEvent(Event e);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all events from the entity.
|
||||
/// </summary>
|
||||
public void ClearEvents();
|
||||
}
|
||||
8
Prefab/Domain/Common/IRoot.cs
Normal file
8
Prefab/Domain/Common/IRoot.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Interface to represent a marker on an entity, informing that the entity is considered an aggregate root.
|
||||
/// </summary>
|
||||
public interface IRoot
|
||||
{
|
||||
}
|
||||
14
Prefab/Domain/Common/IStatus.cs
Normal file
14
Prefab/Domain/Common/IStatus.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using Prefab.Data;
|
||||
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Interface representation of an entity's status.
|
||||
/// </summary>
|
||||
public interface IStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current status of the entity.
|
||||
/// </summary>
|
||||
public AuditStatus AuditStatus { get; }
|
||||
}
|
||||
12
Prefab/Domain/Common/ITenant.cs
Normal file
12
Prefab/Domain/Common/ITenant.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Prefab.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Marks an entity as belonging to a tenant.
|
||||
/// </summary>
|
||||
public interface ITenant
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique tenant identifier.
|
||||
/// </summary>
|
||||
Guid TenantId { get; }
|
||||
}
|
||||
11
Prefab/Domain/Events/GenericAttributeAddedOrUpdatedEvent.cs
Normal file
11
Prefab/Domain/Events/GenericAttributeAddedOrUpdatedEvent.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a generic attribute is added or updated on an entity.
|
||||
/// </summary>
|
||||
/// <param name="Entity">The entity that owns the attribute.</param>
|
||||
/// <param name="Key">The attribute key.</param>
|
||||
/// <param name="Value">The new attribute value.</param>
|
||||
public record GenericAttributeAddedOrUpdatedEvent(object Entity, string Key, object Value) : Event;
|
||||
25
Prefab/Domain/Events/GenericAttributeChangedEvent.cs
Normal file
25
Prefab/Domain/Events/GenericAttributeChangedEvent.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised whenever a generic attribute is added, updated, or removed from an entity.
|
||||
/// </summary>
|
||||
/// <param name="Entity">The entity that owns the attribute.</param>
|
||||
/// <param name="Key">The attribute key.</param>
|
||||
/// <param name="Value">The attribute value involved in the change.</param>
|
||||
/// <param name="ChangeType">Indicates the type of mutation performed.</param>
|
||||
public record GenericAttributeChangedEvent(object Entity, string Key, object? Value, GenericAttributeChangeType ChangeType) : Event;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the types of mutations that can occur for a generic attribute.
|
||||
/// </summary>
|
||||
public enum GenericAttributeChangeType
|
||||
{
|
||||
/// <summary>Attribute was added for the first time.</summary>
|
||||
Add,
|
||||
/// <summary>Attribute was updated with a new value.</summary>
|
||||
Update,
|
||||
/// <summary>Attribute was removed.</summary>
|
||||
Remove
|
||||
}
|
||||
10
Prefab/Domain/Events/GenericAttributeRemovedEvent.cs
Normal file
10
Prefab/Domain/Events/GenericAttributeRemovedEvent.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a generic attribute is removed from an entity.
|
||||
/// </summary>
|
||||
/// <param name="Entity">The entity that owned the attribute.</param>
|
||||
/// <param name="Key">The key that was removed.</param>
|
||||
public record GenericAttributeRemovedEvent(object Entity, string Key) : Event;
|
||||
9
Prefab/Domain/Exceptions/ConcurrencyException.cs
Normal file
9
Prefab/Domain/Exceptions/ConcurrencyException.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Prefab.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a concurrency conflict is detected.
|
||||
/// </summary>
|
||||
public class ConcurrencyException : DomainException
|
||||
{
|
||||
public ConcurrencyException() : base("The record you attempted to modify was changed by another process.") { }
|
||||
}
|
||||
6
Prefab/Domain/Exceptions/DomainException.cs
Normal file
6
Prefab/Domain/Exceptions/DomainException.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Prefab.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Root of all domain‐rule violations.
|
||||
/// </summary>
|
||||
public class DomainException(string message) : Exception(message);
|
||||
59
Prefab/Endpoints/ApiProblemDetails.cs
Normal file
59
Prefab/Endpoints/ApiProblemDetails.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Prefab.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for building consistent HTTP problem responses across modules.
|
||||
/// </summary>
|
||||
public static class ApiProblemDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a 404 <see cref="ProblemDetails"/> payload with standard metadata and resource identifiers.
|
||||
/// </summary>
|
||||
/// <param name="resource">Logical name of the missing resource (for example, "Product").</param>
|
||||
/// <param name="identifier">Identifier value supplied by the caller.</param>
|
||||
/// <param name="detail">Human-readable explanation for logging or the client.</param>
|
||||
public static ProblemDetails NotFound(string resource, string identifier, string detail) =>
|
||||
new()
|
||||
{
|
||||
Title = "Resource not found.",
|
||||
Detail = detail,
|
||||
Type = "https://prefab.dev/problems/not-found",
|
||||
Status = StatusCodes.Status404NotFound,
|
||||
Extensions =
|
||||
{
|
||||
["resource"] = resource,
|
||||
["identifier"] = identifier
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 409 <see cref="ProblemDetails"/> payload used when a request cannot be completed due to a resource conflict.
|
||||
/// </summary>
|
||||
/// <param name="detail">Human-readable explanation of the conflict.</param>
|
||||
/// <param name="resource">Logical name of the resource involved, when known.</param>
|
||||
/// <param name="identifier">Identifier value supplied by the caller, when known.</param>
|
||||
public static ProblemDetails Conflict(string detail, string? resource = null, string? identifier = null)
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = "Request conflict.",
|
||||
Detail = detail,
|
||||
Type = "https://prefab.dev/problems/conflict",
|
||||
Status = StatusCodes.Status409Conflict
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resource))
|
||||
{
|
||||
problem.Extensions["resource"] = resource;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
problem.Extensions["identifier"] = identifier;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
}
|
||||
79
Prefab/Endpoints/EndpointName.cs
Normal file
79
Prefab/Endpoints/EndpointName.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Prefab.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Provides utility methods for generating standardized endpoint names based on type and module information.
|
||||
/// </summary>
|
||||
/// <remarks>This class is intended for internal use to construct endpoint names that reflect the module and
|
||||
/// feature structure of the application. The generated names are typically used for routing, messaging, or service
|
||||
/// registration scenarios where consistent naming conventions are required. Endpoint names are constructed using the
|
||||
/// namespace or assembly segments of the provided type, followed by the specified endpoint name.</remarks>
|
||||
internal static class EndpointName
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default name for the specified type parameter.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type for which to retrieve the default name.</typeparam>
|
||||
/// <returns>A string containing the default name of the type parameter <typeparamref name="T"/>.</returns>
|
||||
public static string For<T>() => For<T>(typeof(T).Name);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the endpoint name for the specified type and endpoint identifier.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type for which to retrieve the endpoint name.</typeparam>
|
||||
/// <param name="endpointName">The logical name of the endpoint. Cannot be null or empty.</param>
|
||||
/// <returns>A string representing the endpoint name associated with the specified type and endpoint identifier.</returns>
|
||||
public static string For<T>(string endpointName) => For(typeof(T), endpointName);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a fully qualified endpoint name by combining the module segments derived from the specified type with
|
||||
/// the provided endpoint name.
|
||||
/// </summary>
|
||||
/// <param name="type">The type from which to resolve module segments. Typically, represents the class or module associated with the
|
||||
/// endpoint.</param>
|
||||
/// <param name="endpointName">The name of the endpoint to append. Cannot be null, empty, or consist only of white-space characters.</param>
|
||||
/// <returns>A string representing the fully qualified endpoint name in the format 'Module.Segment.EndpointName'.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown if endpointName is null, empty, or consists only of white-space characters.</exception>
|
||||
public static string For(Type type, string endpointName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(endpointName))
|
||||
{
|
||||
throw new ArgumentException("Endpoint name cannot be null or whitespace.", nameof(endpointName));
|
||||
}
|
||||
|
||||
var segments = ResolveModuleSegments(type);
|
||||
return $"{string.Join('.', segments)}.{endpointName}";
|
||||
}
|
||||
|
||||
private static string[] ResolveModuleSegments(MemberInfo type)
|
||||
{
|
||||
if (type is Type concreteType)
|
||||
{
|
||||
var namespaceSegments = ExtractNamespaceSegments(concreteType.Namespace);
|
||||
if (namespaceSegments.Length > 0)
|
||||
{
|
||||
return namespaceSegments;
|
||||
}
|
||||
}
|
||||
|
||||
var assemblySegments = ExtractNamespaceSegments(type.Module?.Assembly?.GetName().Name);
|
||||
return assemblySegments.Length > 0 ? assemblySegments : [type.Name];
|
||||
}
|
||||
|
||||
private static string[] ExtractNamespaceSegments(string? root)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var segments = root.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Convention: Prefab.<Module>.(App.)<Feature>...
|
||||
return segments
|
||||
.SkipWhile(segment => string.Equals(segment, "Prefab", StringComparison.Ordinal))
|
||||
.Where(segment => !string.Equals(segment, "App", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
29
Prefab/Endpoints/Extensions.cs
Normal file
29
Prefab/Endpoints/Extensions.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace Prefab.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for configuring endpoint names with module-based naming conventions in ASP.NET Core
|
||||
/// routing.
|
||||
/// </summary>
|
||||
/// <remarks>These methods help standardize endpoint naming by prefixing endpoint names with a module name
|
||||
/// inferred from a specified type. This can improve discoverability and organization of endpoints, especially in
|
||||
/// modular applications. The extensions are intended for use with ASP.NET Core's routing infrastructure and are
|
||||
/// typically called when configuring endpoints in minimal APIs or similar scenarios.</remarks>
|
||||
public static class Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies an endpoint name that combines the module name inferred from <typeparamref name="T"/> with the type name.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">A type that belongs to the module the endpoint lives in, typically the endpoint class itself.</typeparam>
|
||||
public static RouteHandlerBuilder WithModuleName<T>(this RouteHandlerBuilder builder)
|
||||
=> builder.WithModuleName<T>(typeof(T).Name);
|
||||
|
||||
/// <summary>
|
||||
/// Applies an endpoint name that combines the module name inferred from <typeparamref name="T"/> with the provided endpoint name.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">A type that belongs to the module the endpoint lives in, typically the endpoint class itself.</typeparam>
|
||||
/// <param name="endpointName">The base endpoint name that will be prefixed with the module name.</param>
|
||||
public static RouteHandlerBuilder WithModuleName<T>(this RouteHandlerBuilder builder, string endpointName)
|
||||
=> builder.WithName(EndpointName.For<T>(endpointName));
|
||||
}
|
||||
17
Prefab/Endpoints/IEndpointRegistrar.cs
Normal file
17
Prefab/Endpoints/IEndpointRegistrar.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Prefab.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a contract for registering application endpoints with an endpoint route builder.
|
||||
/// </summary>
|
||||
/// <remarks>Implement this interface to configure and map endpoints, such as HTTP routes, to the application's
|
||||
/// routing system. This is typically used during application startup to organize endpoint registration logic.</remarks>
|
||||
public interface IEndpointRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures application endpoints using the specified endpoint route builder.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder used to map routes for the application. Cannot be null.</param>
|
||||
void MapEndpoints(IEndpointRouteBuilder endpoints);
|
||||
}
|
||||
123
Prefab/Handler/Decorators/HandlerContextDecorator.cs
Normal file
123
Prefab/Handler/Decorators/HandlerContextDecorator.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Prefab.Handler.Decorators;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a decorator for handler execution that establishes and manages the current HandlerContext for each request,
|
||||
/// supporting HTTP, Blazor Server, and background scenarios.
|
||||
/// </summary>
|
||||
/// <remarks>This decorator ensures that a HandlerContext is available for each handler invocation, propagating
|
||||
/// correlation and request identifiers as appropriate for the execution environment. It supports HTTP requests, Blazor
|
||||
/// Server interactive sessions, and background processing, automatically selecting the appropriate context source. If
|
||||
/// an existing HandlerContext is present, it is reused with a new request identifier. This class is typically used in
|
||||
/// middleware or pipeline scenarios to provide consistent context information for logging, tracing, or auditing
|
||||
/// purposes.</remarks>
|
||||
/// <param name="handlerAccessor">The handlerAccessor used to get or set the current HandlerContext for the executing request. Cannot be null.</param>
|
||||
/// <param name="httpAccessor">The HTTP context handlerAccessor used to extract request and user information when handling HTTP requests. May be null if
|
||||
/// HTTP context is not available.</param>
|
||||
/// <param name="auth">The authentication state provider used to obtain user information in Blazor Server scenarios. May be null if
|
||||
/// authentication state is not required.</param>
|
||||
public class HandlerContextDecorator(
|
||||
IHandlerContextAccessor handlerAccessor,
|
||||
IHttpContextAccessor? httpAccessor,
|
||||
AuthenticationStateProvider? auth = null) : IHandlerDecorator
|
||||
{
|
||||
public async Task<TResponse> Invoke<TRequest, TResponse>(Func<TRequest, CancellationToken, Task<TResponse>> next, TRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// If a parent context exists, reuse correlation; mint a new request id
|
||||
if (handlerAccessor.Current is { } parent)
|
||||
{
|
||||
var child = parent with { RequestId = NewId() };
|
||||
|
||||
using var scope = handlerAccessor.Set(child);
|
||||
return await next(request, cancellationToken);
|
||||
}
|
||||
|
||||
if (httpAccessor?.HttpContext is HttpContext httpContext)
|
||||
{
|
||||
var ctx = FromHttp(httpContext);
|
||||
var stored = TryStoreHandlerContext(httpContext, ctx);
|
||||
|
||||
using var scope = handlerAccessor.Set(ctx);
|
||||
try
|
||||
{
|
||||
return await next(request, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (stored)
|
||||
{
|
||||
httpContext.Items.Remove(HandlerContextItems.HttpContextKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (auth is not null)
|
||||
{
|
||||
var state = await auth.GetAuthenticationStateAsync();
|
||||
var ctx = ForInteractive(state.User);
|
||||
|
||||
using var scope = handlerAccessor.Set(ctx);
|
||||
return await next(request, cancellationToken);
|
||||
}
|
||||
|
||||
// Background/default
|
||||
{
|
||||
var ctx = ForBackground();
|
||||
using var scope = handlerAccessor.Set(ctx);
|
||||
return await next(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static HandlerContext FromHttp(HttpContext httpContext)
|
||||
{
|
||||
httpContext.Request.Headers.TryGetValue("Idempotency-Key", out var idem);
|
||||
var userId = GetUserId(httpContext.User);
|
||||
|
||||
string FirstNonEmpty(string a, string b) => !string.IsNullOrWhiteSpace(a) ? a : b;
|
||||
var corr = FirstNonEmpty(httpContext.Request.Headers["X-Correlation-Id"].ToString(), httpContext.TraceIdentifier);
|
||||
var req = FirstNonEmpty(httpContext.Request.Headers["X-Request-Id"].ToString(), httpContext.TraceIdentifier);
|
||||
|
||||
return new HandlerContext(
|
||||
CorrelationId: string.IsNullOrWhiteSpace(corr) ? NewId() : corr,
|
||||
RequestId: string.IsNullOrWhiteSpace(req) ? NewId() : req,
|
||||
IdempotencyKey: string.IsNullOrWhiteSpace(idem) ? null : idem.ToString(),
|
||||
UserId: userId);
|
||||
}
|
||||
|
||||
private static HandlerContext ForInteractive(ClaimsPrincipal? user) => new(
|
||||
UserId: GetUserId(user), RequestId: NewId(), CorrelationId: NewId(), IdempotencyKey: null);
|
||||
|
||||
private static HandlerContext ForBackground(string? correlationId = null, string? userId = null) => new(
|
||||
UserId: userId, RequestId: NewId(), CorrelationId: correlationId ?? NewId(), IdempotencyKey: null);
|
||||
|
||||
private static string? GetUserId(ClaimsPrincipal? user)
|
||||
{
|
||||
if (user?.Identity?.IsAuthenticated != true) return null;
|
||||
return user.FindFirst("sub")?.Value
|
||||
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user.Identity!.Name;
|
||||
}
|
||||
|
||||
private static string NewId() => Guid.NewGuid().ToString("N");
|
||||
|
||||
private static bool TryStoreHandlerContext(HttpContext httpContext, HandlerContext context)
|
||||
{
|
||||
if (httpContext.Items.ContainsKey(HandlerContextItems.HttpContextKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
httpContext.Items[HandlerContextItems.HttpContextKey] = context;
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
|
||||
15
Prefab/Handler/Decorators/IHandlerDecorator.cs
Normal file
15
Prefab/Handler/Decorators/IHandlerDecorator.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace Prefab.Handler.Decorators;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a contract for decorating handler execution with additional behavior, such as logging, validation, or
|
||||
/// exception handling.
|
||||
/// </summary>
|
||||
/// <remarks>Implementations of this interface can be used to add cross-cutting concerns around handler invocation
|
||||
/// in a pipeline. Decorators can inspect or modify the request, context, or response, and may perform actions before or
|
||||
/// after calling the next handler in the chain. This interface is typically used in scenarios where handler logic needs
|
||||
/// to be extended without modifying the original handler implementation.</remarks>
|
||||
public interface IHandlerDecorator
|
||||
{
|
||||
Task<TResponse> Invoke<TRequest, TResponse>(Func<TRequest, CancellationToken, Task<TResponse>> next,
|
||||
TRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
161
Prefab/Handler/Decorators/LoggingDecorator.cs
Normal file
161
Prefab/Handler/Decorators/LoggingDecorator.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Prefab.Handler.Decorators;
|
||||
|
||||
public sealed class LoggingDecoratorOptions
|
||||
{
|
||||
/// <summary>Log at Warning when handler time >= this threshold (ms).</summary>
|
||||
public int SlowThresholdInMilliseconds { get; init; } = 1000;
|
||||
}
|
||||
|
||||
public sealed class LoggingDecorator(ILogger<LoggingDecorator> logger, IHandlerContextAccessor handlerContext, IOptions<LoggingDecoratorOptions>? options = null) : IHandlerDecorator
|
||||
{
|
||||
private readonly LoggingDecoratorOptions _options = options?.Value ?? new LoggingDecoratorOptions();
|
||||
|
||||
|
||||
public async Task<TResponse> Invoke<TRequest, TResponse>(Func<TRequest, CancellationToken, Task<TResponse>> next,
|
||||
TRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Friendly operation label
|
||||
var requestType = DescribeMessageType(typeof(TRequest));
|
||||
var responseType = DescribeMessageType(typeof(TResponse));
|
||||
var operation = $"{requestType}->{responseType}";
|
||||
|
||||
var currentContext = handlerContext.Current ?? new HandlerContext(null, null, null, null);
|
||||
|
||||
using var scope = logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["Operation"] = operation,
|
||||
["RequestType"] = requestType,
|
||||
["ResponseType"] = responseType,
|
||||
["CorrelationId"] = currentContext.CorrelationId,
|
||||
["IdempotencyKey"] = currentContext.IdempotencyKey,
|
||||
["RequestId"] = currentContext.RequestId,
|
||||
["UserId"] = currentContext.UserId
|
||||
});
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
// (Optional) OpenTelemetry tagging
|
||||
var activity = Activity.Current;
|
||||
activity?.SetTag("handler.operation", operation);
|
||||
activity?.SetTag("handler.request_type", requestType);
|
||||
activity?.SetTag("handler.response_type", responseType);
|
||||
activity?.SetTag("handler.correlation_id", currentContext.CorrelationId);
|
||||
activity?.SetTag("handler.request_id", currentContext.RequestId);
|
||||
|
||||
try
|
||||
{
|
||||
if (logger.IsEnabled(LogLevel.Debug))
|
||||
logger.LogDebug("Handling {Operation}", operation);
|
||||
|
||||
var result = await next(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (logger.IsEnabled(LogLevel.Warning))
|
||||
logger.LogWarning("Canceled {Operation}", operation);
|
||||
|
||||
activity?.SetStatus(ActivityStatusCode.Error, "canceled");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (logger.IsEnabled(LogLevel.Error))
|
||||
logger.LogError(ex, "Error handling {Operation}", operation);
|
||||
|
||||
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var ms = stopwatch.ElapsedMilliseconds;
|
||||
activity?.SetTag("handler.duration_ms", ms);
|
||||
|
||||
if (ms >= _options.SlowThresholdInMilliseconds && logger.IsEnabled(LogLevel.Warning))
|
||||
logger.LogWarning("Handled {Operation} in {ElapsedMs} ms (slow)", operation, ms);
|
||||
else if (logger.IsEnabled(LogLevel.Information))
|
||||
logger.LogInformation("Handled {Operation} in {ElapsedMs} ms", operation, ms);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Pretty type-name helpers with caching ----
|
||||
|
||||
private static string DescribeMessageType(Type type) =>
|
||||
MessageTypeCache.GetOrAdd(type, static t =>
|
||||
{
|
||||
if (t == typeof(NoRequest))
|
||||
{
|
||||
return nameof(NoRequest);
|
||||
}
|
||||
|
||||
if (t.FullName is { } fullName &&
|
||||
fullName.StartsWith("Prefab.", StringComparison.Ordinal))
|
||||
{
|
||||
var formatted = FormatPrefabTypeName(fullName);
|
||||
if (!string.IsNullOrEmpty(formatted))
|
||||
{
|
||||
return formatted;
|
||||
}
|
||||
}
|
||||
|
||||
return PrettyTypeName(t);
|
||||
});
|
||||
|
||||
private static string FormatPrefabTypeName(string fullName)
|
||||
{
|
||||
const string prefix = "Prefab.";
|
||||
var span = fullName.AsSpan(prefix.Length);
|
||||
var buffer = new List<string>();
|
||||
|
||||
foreach (var segment in span.ToString().Replace('+', '.')
|
||||
.Split('.', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (SegmentsToSkip.Contains(segment))
|
||||
continue;
|
||||
|
||||
buffer.Add(segment);
|
||||
}
|
||||
|
||||
if (buffer.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.Join('.', buffer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a human-readable type name (e.g., <c>Dictionary<string, List<int>></c>)
|
||||
/// instead of framework names like <c>Dictionary`2</c>.
|
||||
/// </summary>
|
||||
private static string PrettyTypeName(Type type) =>
|
||||
PrettyCache.GetOrAdd(type, static t => PrettyTypeNameUncached(t));
|
||||
|
||||
private static string PrettyTypeNameUncached(Type t)
|
||||
{
|
||||
if (!t.IsGenericType) return t.Name;
|
||||
|
||||
var name = t.Name;
|
||||
var tick = name.IndexOf('`');
|
||||
var baseName = tick >= 0 ? name[..tick] : name;
|
||||
|
||||
var args = t.GetGenericArguments().Select(PrettyTypeNameUncached);
|
||||
return $"{baseName}<{string.Join(",", args)}>";
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<Type, string> MessageTypeCache = new();
|
||||
private static readonly ConcurrentDictionary<Type, string> PrettyCache = new();
|
||||
private static readonly HashSet<string> SegmentsToSkip = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Shared",
|
||||
"App",
|
||||
"Handler",
|
||||
"Base"
|
||||
};
|
||||
}
|
||||
65
Prefab/Handler/Decorators/ValidationDecorator.cs
Normal file
65
Prefab/Handler/Decorators/ValidationDecorator.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Prefab.Handler.Decorators;
|
||||
|
||||
/// <summary>
|
||||
/// Decorator that runs all registered FluentValidation validators for the current request type
|
||||
/// before the handler executes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Validators are resolved on demand per request, allowing pages and modules to register request-specific
|
||||
/// validators without touching handler implementations. When no validators are registered, the decorator
|
||||
/// simply forwards execution to the next component in the pipeline.
|
||||
/// </remarks>
|
||||
public sealed class ValidationDecorator(IServiceProvider serviceProvider) : IHandlerDecorator
|
||||
{
|
||||
public async Task<TResponse> Invoke<TRequest, TResponse>(
|
||||
Func<TRequest, CancellationToken, Task<TResponse>> next,
|
||||
TRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var failures = await ValidateAsync(request, cancellationToken);
|
||||
|
||||
if (failures.Count != 0)
|
||||
{
|
||||
throw new ValidationException(failures);
|
||||
}
|
||||
|
||||
return await next(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<ValidationFailure>> ValidateAsync<TRequest>(
|
||||
TRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var validators = serviceProvider.GetServices<IValidator<TRequest>>();
|
||||
|
||||
var enumerable = validators as IValidator<TRequest>[] ?? validators.ToArray();
|
||||
if (enumerable.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var context = new ValidationContext<TRequest>(request);
|
||||
var failures = new List<ValidationFailure>();
|
||||
|
||||
foreach (var validator in enumerable)
|
||||
{
|
||||
var result = await validator.ValidateAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
failures.AddRange(result.Errors.Where(f => f is not null));
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
}
|
||||
15
Prefab/Handler/HandlerBase.cs
Normal file
15
Prefab/Handler/HandlerBase.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Prefab.Handler.Decorators;
|
||||
|
||||
namespace Prefab.Handler;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a base class for handler implementations that require access to the current handler context.
|
||||
/// </summary>
|
||||
/// <param name="accessor">The accessor used to retrieve the current handler context. Cannot be null.</param>
|
||||
public abstract class HandlerBase(IHandlerContextAccessor accessor)
|
||||
{
|
||||
protected IHandlerContextAccessor Accessor { get; } = accessor;
|
||||
|
||||
protected HandlerContext Context => Accessor.Current ??
|
||||
throw new InvalidOperationException($"HandlerContext not set ({nameof(HandlerContextDecorator)}) must run first).");
|
||||
}
|
||||
20
Prefab/Handler/HandlerContext.cs
Normal file
20
Prefab/Handler/HandlerContext.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Prefab.Handler;
|
||||
|
||||
/// <summary>
|
||||
/// Represents contextual information about the current request and user for use in request handling and logging.
|
||||
/// </summary>
|
||||
/// <remarks>This record is typically used to pass request-scoped metadata through application layers, enabling
|
||||
/// consistent logging, tracing, and idempotency handling. All properties are optional and may be null if the
|
||||
/// corresponding information is unavailable.</remarks>
|
||||
/// <param name="UserId">The unique identifier of the user associated with the request, or null if the user is unauthenticated.</param>
|
||||
/// <param name="RequestId">The unique identifier for the current request, used for tracing and diagnostics. Can be null if not set.</param>
|
||||
/// <param name="CorrelationId">The identifier used to correlate this request with related operations across services or components. Can be null if
|
||||
/// not set.</param>
|
||||
/// <param name="IdempotencyKey">A key that uniquely identifies the request for idempotency purposes, allowing safe retries. Can be null if
|
||||
/// idempotency is not required.</param>
|
||||
public sealed record HandlerContext(
|
||||
string? UserId,
|
||||
string? RequestId,
|
||||
string? CorrelationId,
|
||||
string? IdempotencyKey);
|
||||
|
||||
12
Prefab/Handler/HandlerContextItems.cs
Normal file
12
Prefab/Handler/HandlerContextItems.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Prefab.Handler;
|
||||
|
||||
/// <summary>
|
||||
/// Provides shared keys for storing handler context metadata in request-scoped containers.
|
||||
/// </summary>
|
||||
public static class HandlerContextItems
|
||||
{
|
||||
/// <summary>
|
||||
/// Key used when storing <see cref="HandlerContext"/> in <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>.
|
||||
/// </summary>
|
||||
public const string HttpContextKey = "__prefab.handler-context";
|
||||
}
|
||||
100
Prefab/Handler/HandlerInvoker.cs
Normal file
100
Prefab/Handler/HandlerInvoker.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Prefab.Handler.Decorators;
|
||||
|
||||
namespace Prefab.Handler;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Provides a mechanism to execute handler pipelines with support for applying a sequence of handler decorators. This
|
||||
/// class enables the composition of cross-cutting concerns around handler execution.
|
||||
/// </summary>
|
||||
/// <remarks>HandlerInvoker is typically used to execute handlers with additional behaviors, such as logging,
|
||||
/// validation, or exception handling, by composing the provided decorators. The order of decorators affects the
|
||||
/// execution flow: the first-registered decorator is invoked first and wraps subsequent decorators and the handler
|
||||
/// itself. This class is thread-safe for concurrent handler executions.</remarks>
|
||||
/// <param name="decorators">The collection of handler decorators to apply to each handler invocation. Decorators are applied in the order they
|
||||
/// are provided, with the first decorator wrapping the outermost layer of the pipeline. Cannot be null.</param>
|
||||
public sealed class HandlerInvoker(IEnumerable<IHandlerDecorator> decorators, IServiceProvider serviceProvider)
|
||||
{
|
||||
private readonly IHandlerDecorator[] _decorators = decorators.Reverse().ToArray();
|
||||
|
||||
public Task<TResponse> Execute<TResponse>(CancellationToken cancellationToken)
|
||||
{
|
||||
var inner = serviceProvider.GetRequiredService<IHandler<TResponse>>();
|
||||
|
||||
// No decorators? Call the handler directly.
|
||||
if (_decorators.Length == 0)
|
||||
return inner.Execute(cancellationToken);
|
||||
|
||||
var noRequest = new NoRequestAdapter<TResponse>(inner);
|
||||
|
||||
var pipeline = BuildPipeline(noRequest);
|
||||
|
||||
return pipeline(NoRequest.Instance, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the specified handler for the given request within the provided context and cancellation token,
|
||||
/// returning the handler's response asynchronously.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The type of the request to be handled. Must not be null.</typeparam>
|
||||
/// <typeparam name="TResponse">The type of the response returned by the handler.</typeparam>
|
||||
/// <param name="request">The request object to be processed by the handler.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains the response produced by the
|
||||
/// handler.</returns>
|
||||
public Task<TResponse> Execute<TRequest, TResponse>(TRequest request, CancellationToken cancellationToken) where TRequest : notnull
|
||||
{
|
||||
var handler = serviceProvider.GetRequiredService<IHandler<TRequest, TResponse>>();
|
||||
|
||||
// No decorators? Call the handler directly.
|
||||
if (_decorators.Length == 0)
|
||||
return handler.Execute(request, cancellationToken);
|
||||
|
||||
var pipeline = BuildPipeline(handler);
|
||||
return pipeline(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a delegate representing the request handling pipeline, applying all registered decorators to the
|
||||
/// specified handler.
|
||||
/// </summary>
|
||||
/// <remarks>Decorators are applied in the order they were registered, with the first-registered decorator
|
||||
/// wrapping the outermost layer of the pipeline. The returned delegate can be invoked with a request, handler
|
||||
/// context, and cancellation token to execute the full pipeline.</remarks>
|
||||
/// <typeparam name="TRequest">The type of the request message to be handled.</typeparam>
|
||||
/// <typeparam name="TResponse">The type of the response message returned by the handler.</typeparam>
|
||||
/// <param name="handler">The core handler to which decorators will be applied. Cannot be null.</param>
|
||||
/// <returns>A delegate that processes a request through all registered decorators and the specified handler.</returns>
|
||||
private Func<TRequest, CancellationToken, Task<TResponse>> BuildPipeline<TRequest, TResponse>(IHandler<TRequest, TResponse> handler)
|
||||
{
|
||||
// Start from the final operation: the handler itself.
|
||||
Func<TRequest, CancellationToken, Task<TResponse>> pipeline = handler.Execute;
|
||||
|
||||
// Wrap in reverse registration order so the first-registered decorator runs outermost.
|
||||
foreach (var decorator in _decorators)
|
||||
{
|
||||
var next = pipeline;
|
||||
|
||||
pipeline = (request, cancellationToken) =>
|
||||
decorator.Invoke(next, request, cancellationToken);
|
||||
}
|
||||
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides an adapter that allows an existing handler to be used where a handler for a request type of NoRequest
|
||||
/// is required.
|
||||
/// </summary>
|
||||
/// <remarks>This class is useful for scenarios where a handler expects no input request data but must
|
||||
/// conform to an interface requiring a request parameter. It delegates execution to the specified inner handler,
|
||||
/// ignoring the NoRequest parameter.</remarks>
|
||||
/// <typeparam name="TResponse">The type of the response returned by the handler.</typeparam>
|
||||
/// <param name="innerHandler">The handler that processes requests and produces a response of type TResponse.</param>
|
||||
private sealed class NoRequestAdapter<TResponse>(IHandler<TResponse> innerHandler) : IHandler<NoRequest, TResponse>
|
||||
{
|
||||
public Task<TResponse> Execute(NoRequest _, CancellationToken cancellationToken)
|
||||
=> innerHandler.Execute(cancellationToken);
|
||||
}
|
||||
}
|
||||
25
Prefab/Handler/IHandler.cs
Normal file
25
Prefab/Handler/IHandler.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace Prefab.Handler;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a handler that processes a request and returns a response asynchronously.
|
||||
/// </summary>
|
||||
/// <remarks>Implementations of this interface encapsulate the logic required to handle a specific request and
|
||||
/// produce a result. Handlers are typically used in command or query processing pipelines to separate request handling
|
||||
/// logic from other application concerns.</remarks>
|
||||
/// <typeparam name="TResponse">The type of the response returned by the handler.</typeparam>
|
||||
public interface IHandler<TResponse>
|
||||
{
|
||||
Task<TResponse> Execute(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines a contract for handling a request and producing a response asynchronously.
|
||||
/// </summary>
|
||||
/// <remarks>Implementations of this interface should be thread-safe if they are intended to be used concurrently.
|
||||
/// The interface is typically used in request/response or command/query processing pipelines.</remarks>
|
||||
/// <typeparam name="TRequest">The type of the request to be handled.</typeparam>
|
||||
/// <typeparam name="TResponse">The type of the response returned after handling the request.</typeparam>
|
||||
public interface IHandler<in TRequest, TResponse>
|
||||
{
|
||||
Task<TResponse> Execute(TRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
66
Prefab/Handler/IHandlerContextAccessor.cs
Normal file
66
Prefab/Handler/IHandlerContextAccessor.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace Prefab.Handler;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the current handler context for the executing operation.
|
||||
/// </summary>
|
||||
/// <remarks>Implementations of this interface allow components to retrieve or temporarily override the current
|
||||
/// HandlerContext within a given scope. This is typically used to flow contextual information, such as user or request
|
||||
/// data, through asynchronous or nested operations.</remarks>
|
||||
public interface IHandlerContextAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current handler context for the ongoing operation, if one is available.
|
||||
/// </summary>
|
||||
HandlerContext? Current { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sets the specified handler context as the current context for the duration of the returned disposable object's
|
||||
/// lifetime.
|
||||
/// </summary>
|
||||
/// <remarks>Use the returned IDisposable in a using statement to ensure the previous context is restored
|
||||
/// even if an exception occurs.</remarks>
|
||||
/// <param name="handlerContext">The handler context to set as current. Cannot be null.</param>
|
||||
/// <returns>An IDisposable that, when disposed, restores the previous handler context.</returns>
|
||||
IDisposable Set(HandlerContext handlerContext);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IHandlerContextAccessor"/>
|
||||
public sealed class HandlerContextAccessor : IHandlerContextAccessor
|
||||
{
|
||||
private static readonly AsyncLocal<HandlerContext?> CurrentContext = new();
|
||||
|
||||
/// <inheritdoc cref="IHandlerContextAccessor"/>
|
||||
public HandlerContext? Current => CurrentContext.Value;
|
||||
|
||||
/// <inheritdoc cref="IHandlerContextAccessor"/>
|
||||
public IDisposable Set(HandlerContext handlerContext)
|
||||
{
|
||||
var previous = CurrentContext.Value;
|
||||
|
||||
CurrentContext.Value = handlerContext;
|
||||
|
||||
return new Scope(() => CurrentContext.Value = previous);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides a mechanism for executing a specified delegate when the scope is disposed, typically used to restore
|
||||
/// state or perform cleanup actions.
|
||||
/// </summary>
|
||||
/// <remarks>This type is intended for use with the 'using' statement to ensure that the specified
|
||||
/// delegate is executed exactly once when the scope ends. The delegate is not invoked if Dispose is called more
|
||||
/// than once.</remarks>
|
||||
/// <param name="restore">The delegate to invoke when the scope is disposed. Cannot be null.</param>
|
||||
private sealed class Scope(Action restore) : IDisposable
|
||||
{
|
||||
private bool _done;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if(_done)
|
||||
return;
|
||||
|
||||
_done = true;
|
||||
restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Prefab/Handler/NoRequest.cs
Normal file
11
Prefab/Handler/NoRequest.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Prefab.Handler;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a sentinel value indicating the absence of a request.
|
||||
/// </summary>
|
||||
/// <remarks>Use this type when an operation or API requires a value to signify that no request is present. This
|
||||
/// can be useful in scenarios where a method or handler expects a request type but no data is needed.</remarks>
|
||||
public readonly record struct NoRequest
|
||||
{
|
||||
public static readonly NoRequest Instance = default;
|
||||
}
|
||||
177
Prefab/Module/Extensions.cs
Normal file
177
Prefab/Module/Extensions.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Prefab.Data;
|
||||
using Prefab.Data.Seeder;
|
||||
using Prefab.Endpoints;
|
||||
|
||||
namespace Prefab.Module;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for registering and configuring Prefab modules and endpoint registrars within an ASP.NET
|
||||
/// Core application.
|
||||
/// </summary>
|
||||
/// <remarks>The Extensions class contains methods intended to be called from the application's Program.cs file to
|
||||
/// integrate Prefab modules into the application's dependency injection and request pipeline. These methods enable
|
||||
/// modular composition by discovering and registering implementations of IModule and IEndpointRegistrar from
|
||||
/// application dependencies. This approach allows modules to participate in both the application's build and
|
||||
/// configuration phases, supporting extensibility and separation of concerns.</remarks>
|
||||
public static class Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Prefab modules, endpoint registrars, and related services to the specified WebApplicationBuilder for
|
||||
/// modular application composition.
|
||||
/// </summary>
|
||||
/// <remarks>This method scans application dependencies for types implementing IModule and
|
||||
/// IEndpointRegistrar, registers them as singletons, and invokes their Build methods to allow modules to
|
||||
/// participate in application setup. Call this method early in the application's startup configuration to ensure
|
||||
/// all modules are properly registered and initialized.</remarks>
|
||||
/// <param name="builder">The WebApplicationBuilder to which Prefab modules and services will be added. Cannot be null.</param>
|
||||
/// <returns>The same WebApplicationBuilder instance, configured with Prefab modules and services.</returns>
|
||||
public static WebApplicationBuilder AddPrefab(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddPrefab(builder.Configuration);
|
||||
|
||||
// Discover endpoint registrars through Scrutor so modules can contribute HTTP endpoints.
|
||||
builder.Services.Scan(scan => scan
|
||||
.FromApplicationDependencies(assembly => assembly.GetName().Name?.StartsWith(nameof(Prefab)) == true)
|
||||
.AddClasses(classes => classes.AssignableTo(typeof(IEndpointRegistrar)))
|
||||
.As<IEndpointRegistrar>()
|
||||
.WithSingletonLifetime());
|
||||
|
||||
// Discover modules via Scrutor and register them as singletons so they can participate in Build/Configure.
|
||||
builder.Services.Scan(scan => scan
|
||||
.FromApplicationDependencies(assembly => assembly.GetName().Name?.StartsWith(nameof(Prefab)) == true)
|
||||
.AddClasses(classes => classes.AssignableTo<IModule>())
|
||||
.As<IModule>()
|
||||
.WithSingletonLifetime());
|
||||
|
||||
var modules = new List<IModule>();
|
||||
|
||||
// Build a temporary provider so DI can instantiate modules before the real host is built.
|
||||
// Disposes it immediately but keeps the instances so Build/Configure run on the same objects.
|
||||
using (var provider = builder.Services.BuildServiceProvider(new ServiceProviderOptions
|
||||
{
|
||||
ValidateOnBuild = true,
|
||||
ValidateScopes = true
|
||||
}))
|
||||
{
|
||||
modules.AddRange(provider.GetServices<IModule>());
|
||||
}
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
module.Build(builder);
|
||||
}
|
||||
|
||||
builder.Services.RemoveAll<IModule>();
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
builder.Services.AddSingleton(module);
|
||||
}
|
||||
|
||||
builder.Services.AddPrefabHandlers();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the application by invoking all registered modules and mapping endpoints using registered endpoint
|
||||
/// registrars.
|
||||
/// </summary>
|
||||
/// <remarks>This method discovers and executes all services implementing <see cref="IModule"/> and <see
|
||||
/// cref="IEndpointRegistrar"/> from the application's dependency injection container. Each module's configuration
|
||||
/// and endpoint mapping logic will be applied in the order they are registered.</remarks>
|
||||
/// <param name="app">The <see cref="WebApplication"/> instance to configure. Cannot be null.</param>
|
||||
/// <returns>The same <see cref="WebApplication"/> instance, enabling method chaining.</returns>
|
||||
public static WebApplication UsePrefab(this WebApplication app)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var seederTypes = scope.ServiceProvider.GetServices<ISeeder>()
|
||||
.Select(seeder => seeder.GetType())
|
||||
.ToList();
|
||||
|
||||
foreach (var type in seederTypes)
|
||||
{
|
||||
var mutexName = $"Global\\PrefabSeeder_{type.FullName}";
|
||||
var mutex = new Mutex(initiallyOwned: false, mutexName, out _);
|
||||
var acquired = false;
|
||||
|
||||
try
|
||||
{
|
||||
acquired = mutex.WaitOne(0);
|
||||
}
|
||||
catch (AbandonedMutexException)
|
||||
{
|
||||
acquired = true;
|
||||
}
|
||||
|
||||
if (!acquired)
|
||||
{
|
||||
mutex.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
Prefab.Data.Seeder.Extensions.Channel.Writer.TryWrite(async (_, cancellationToken) =>
|
||||
{
|
||||
using var workerScope = app.Services.CreateScope();
|
||||
var seeder = (ISeeder)workerScope.ServiceProvider.GetRequiredService(type);
|
||||
try
|
||||
{
|
||||
await seeder.Execute(workerScope.ServiceProvider, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
mutex.ReleaseMutex();
|
||||
mutex.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Allow each module to run its Configure step
|
||||
foreach (var module in app.Services.GetServices<IModule>())
|
||||
{
|
||||
module.Configure(app);
|
||||
}
|
||||
|
||||
// Allow registered endpoint registrars to map HTTP endpoints.
|
||||
foreach (var registrar in app.Services.GetServices<IEndpointRegistrar>())
|
||||
{
|
||||
registrar.MapEndpoints(app);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers all interfaces implemented by the specified DbContext type that derive from IPrefabDb as scoped
|
||||
/// services in the dependency injection container.
|
||||
/// </summary>
|
||||
/// <remarks>Each interface derived from IPrefabDb that is implemented by the specified DbContext type is
|
||||
/// registered as a scoped service, resolving to the same DbContext instance. This ensures that all such interfaces
|
||||
/// share the same context and change tracking within a request scope.</remarks>
|
||||
/// <param name="services">The IServiceCollection to add the service registrations to.</param>
|
||||
/// <param name="dbContextType">The type of the DbContext whose IPrefabDb-derived interfaces will be registered. Must implement one or more
|
||||
/// interfaces that derive from IPrefabDb.</param>
|
||||
/// <returns>The IServiceCollection instance with the added service registrations. This enables method chaining.</returns>
|
||||
public static IServiceCollection AddModuleDbInterfaces(this IServiceCollection services, Type dbContextType)
|
||||
{
|
||||
var dbContextInterfaces = dbContextType.GetInterfaces()
|
||||
.Where(i => typeof(IPrefabDb).IsAssignableFrom(i))
|
||||
//.Where(i => typeof(IPrefabDb).IsAssignableFrom(i) || i == typeof(ISagaDb) || i == typeof(IQueueDb))
|
||||
.ToList();
|
||||
|
||||
foreach (var dbContextInterface in dbContextInterfaces)
|
||||
{
|
||||
// resolve module interfaces to the same DbContext instance that was registered
|
||||
// via AddDbContext so that all changes are tracked by a single context
|
||||
services.TryAdd(new ServiceDescriptor(
|
||||
dbContextInterface,
|
||||
sp => sp.GetRequiredService(dbContextType),
|
||||
ServiceLifetime.Scoped));
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
31
Prefab/Module/IModule.cs
Normal file
31
Prefab/Module/IModule.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace Prefab.Module;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a contract for modular components that can register services and configure the application pipeline within a
|
||||
/// web application.
|
||||
/// </summary>
|
||||
/// <remarks>Implement this interface to encapsulate the registration of services and middleware required by a
|
||||
/// module. This enables modular composition of application features and promotes separation of concerns.</remarks>
|
||||
public interface IModule
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures and returns the specified web application builder.
|
||||
/// </summary>
|
||||
/// <param name="builder">The WebApplicationBuilder instance to configure. Cannot be null.</param>
|
||||
/// <returns>The configured WebApplicationBuilder instance.</returns>
|
||||
WebApplicationBuilder Build(WebApplicationBuilder builder);
|
||||
|
||||
/// <summary>
|
||||
/// Configures the specified web application by adding middleware, services, or other components required for
|
||||
/// application startup.
|
||||
/// </summary>
|
||||
/// <remarks>Call this method during application startup to apply custom configuration to the web
|
||||
/// application pipeline. This method is commonly used to register middleware, endpoints, or other application
|
||||
/// features before the application is run.</remarks>
|
||||
/// <param name="app">The <see cref="WebApplication"/> instance to configure. Must not be null.</param>
|
||||
/// <returns>The configured <see cref="WebApplication"/> instance. This is typically the same instance as the input
|
||||
/// parameter, with additional configuration applied.</returns>
|
||||
WebApplication Configure(WebApplication app);
|
||||
}
|
||||
31
Prefab/Prefab.csproj
Normal file
31
Prefab/Prefab.csproj
Normal file
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- REMOVE THIS WHEN .NET 10 IS RELEASED -->
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);NU1903</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0-rc.2.25502.107">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Prefab.Base\Prefab.Base.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
35
Prefab/Test/Detector.cs
Normal file
35
Prefab/Test/Detector.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace Prefab.Test;
|
||||
|
||||
/// <summary>
|
||||
/// Used for determining whether the system is under test or not.
|
||||
/// </summary>
|
||||
public static class Detector
|
||||
{
|
||||
/// <summary>
|
||||
/// Static constructor for the Detector class.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// None.
|
||||
/// </returns>
|
||||
static Detector()
|
||||
{
|
||||
var testAssemblyNames = new[]
|
||||
{
|
||||
"Microsoft.TestPlatform",
|
||||
"xunit.core",
|
||||
"xunit.assert",
|
||||
"xunit.extensibility.core",
|
||||
"xunit.extensibility.execution",
|
||||
"nunit.framework"
|
||||
};
|
||||
|
||||
SystemIsUnderTest = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.Any(a => testAssemblyNames.Any(t => a.FullName != null && a.FullName.StartsWith(t)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the code is currently running in a test.
|
||||
/// </summary>
|
||||
/// <returns>true if the code is running in a test; otherwise, false.</returns>
|
||||
public static bool SystemIsUnderTest { get; }
|
||||
}
|
||||
Reference in New Issue
Block a user