diff --git a/global.json b/global.json new file mode 100644 index 0000000..b777c9b --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "9.0.304", + "rollForward": "latestFeature" + } +} diff --git a/samples/FastCrud.Samples.Api/Data/AppDbContext.cs b/samples/FastCrud.Samples.Api/Data/AppDbContext.cs index 58cb23b..e3495f9 100644 --- a/samples/FastCrud.Samples.Api/Data/AppDbContext.cs +++ b/samples/FastCrud.Samples.Api/Data/AppDbContext.cs @@ -1,5 +1,6 @@ using FastCrud.Samples.Api.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace FastCrud.Samples.Api.Data; @@ -7,4 +8,19 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon { public DbSet Customers => Set(); public DbSet Orders => Set(); + public DbSet AuditEntries => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.Property(e => e.OldValues) + .HasColumnType("nvarchar(max)"); + + entity.Property(e => e.NewValues) + .HasColumnType("nvarchar(max)"); + }); + } } \ No newline at end of file diff --git a/samples/FastCrud.Samples.Api/Dtos/AuditLogDto.cs b/samples/FastCrud.Samples.Api/Dtos/AuditLogDto.cs new file mode 100644 index 0000000..6957d22 --- /dev/null +++ b/samples/FastCrud.Samples.Api/Dtos/AuditLogDto.cs @@ -0,0 +1,15 @@ +namespace FastCrud.Samples.Api.Dtos +{ + public record AuditLogDto( + object Id, + string Entity, + string EntityId, + string Action, + string Timestamp, + string User, + string? OldValues, + string? NewValues + ); + + public record AuditLogsResponse(int TotalLogs, IEnumerable Logs); +} diff --git a/samples/FastCrud.Samples.Api/FastCrud.Samples.Api.csproj b/samples/FastCrud.Samples.Api/FastCrud.Samples.Api.csproj index 3c089e9..3c18529 100644 --- a/samples/FastCrud.Samples.Api/FastCrud.Samples.Api.csproj +++ b/samples/FastCrud.Samples.Api/FastCrud.Samples.Api.csproj @@ -16,6 +16,7 @@ + diff --git a/samples/FastCrud.Samples.Api/Models/AuditEntry.cs b/samples/FastCrud.Samples.Api/Models/AuditEntry.cs new file mode 100644 index 0000000..14e58c0 --- /dev/null +++ b/samples/FastCrud.Samples.Api/Models/AuditEntry.cs @@ -0,0 +1,18 @@ +using FastCrud.Abstractions.Abstractions; +using FastCrud.Abstractions.Primitives; + +namespace FastCrud.Samples.Api.Models +{ + public sealed class AuditEntry : IAuditEntry + { + public long Id { get; set; } + public string EntityName { get; set; } = default!; + public string EntityId { get; set; } = default!; + public AuditAction Action { get; set; } + public string? OldValues { get; set; } + public string? NewValues { get; set; } + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public string? UserId { get; set; } + public string? UserName { get; set; } + } +} diff --git a/samples/FastCrud.Samples.Api/Models/Customer.cs b/samples/FastCrud.Samples.Api/Models/Customer.cs index 03adbdf..1cfe819 100644 --- a/samples/FastCrud.Samples.Api/Models/Customer.cs +++ b/samples/FastCrud.Samples.Api/Models/Customer.cs @@ -2,30 +2,10 @@ namespace FastCrud.Samples.Api.Models; public sealed class Customer { - private Customer() - { - } - public Customer( - Guid id, - string firstName, - string lastName, - string email, - DateTime createdUtc, - List tags - ) - { - Id = id; - FirstName = firstName; - LastName = lastName; - Email = email; - CreatedUtc = createdUtc; - Tags = tags; - } - public Guid Id { get; private set; } - public string FirstName { get; private set; } - public string LastName { get; private set; } - public string Email { get; private set; } - public DateTime CreatedUtc { get; private set; } - + public Guid Id { get; set; } = Guid.NewGuid(); + public string FirstName { get; set; } = default!; + public string LastName { get; set; } = default!; + public string Email { get; set; } = default!; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public List Tags { get; set; } = []; -} \ No newline at end of file +} diff --git a/samples/FastCrud.Samples.Api/Models/Tag.cs b/samples/FastCrud.Samples.Api/Models/Tag.cs index 9e5dc57..4d73ffd 100644 --- a/samples/FastCrud.Samples.Api/Models/Tag.cs +++ b/samples/FastCrud.Samples.Api/Models/Tag.cs @@ -2,9 +2,9 @@ namespace FastCrud.Samples.Api.Models; public class Tag { - public Guid Id { get; private set; } - public string Name { get; set; } - public string Description { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = default!; + public string Description { get; set; } = default!; public List Customers { get; set; } = []; -} \ No newline at end of file +} diff --git a/samples/FastCrud.Samples.Api/Program.cs b/samples/FastCrud.Samples.Api/Program.cs index 3fc1d4a..d604234 100644 --- a/samples/FastCrud.Samples.Api/Program.cs +++ b/samples/FastCrud.Samples.Api/Program.cs @@ -1,5 +1,6 @@ -using FastCrud.Core.DI; +using FastCrud.Core.DI; using FastCrud.Mapping.Mapster.DI; +using FastCrud.Persistence.EFCore; using FastCrud.Persistence.EFCore.DI; using FastCrud.Query.Gridify.DI; using FastCrud.Samples.Api.Data; @@ -12,8 +13,14 @@ var builder = WebApplication.CreateBuilder(args); -builder.Services.AddDbContext(options => - options.UseInMemoryDatabase("fastcrud-demo")); +// Add audit logging +builder.Services.AddEfAuditing(); + +builder.Services.AddDbContext((serviceProvider, options) => +{ + options.UseInMemoryDatabase("fastcrud-demo"); + options.AddInterceptors(serviceProvider.GetRequiredService>()); +}); // FastCrud core services builder.Services.AddFastCrudCore(); @@ -22,15 +29,17 @@ builder.Services.UseFluentValidationAdapter(); // Register EF repositories per aggregate. Required for CrudService to resolve IRepository. + builder.Services.AddEfRepository(); builder.Services.AddEfRepository(); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { - Title = "FastCrud Sample API", + Title = "FastCrud Sample API with Audit Logging", Version = "v1", Description = "Demo API showcasing FastCrud with Customer and Order entities using EF Core InMemory" }); @@ -47,10 +56,9 @@ // var c1 = new Customer { FirstName = "Samane", LastName = "Yaghoubi", Email = "samane@example.com" }; // var c2 = new Customer { FirstName = "Ashkan", LastName = "Rahmani", Email = "ashkan@example.com" }; // db.Customers.AddRange(c1, c2); - // db.Orders.AddRange( - // new Order { CustomerId = c1.Id, Number = "ORD-2025-0001", Amount = 120.50m }, - // new Order { CustomerId = c2.Id, Number = "ORD-2025-0002", Amount = 260.00m } + // new Order { CustomerId = c1.Id, Number = "ORD-2025-0001", Amount = 120.50m }, + // new Order { CustomerId = c2.Id, Number = "ORD-2025-0002", Amount = 260.00m } // ); // db.SaveChanges(); } @@ -59,6 +67,14 @@ app.UseSwagger(); app.UseSwaggerUI(); +app.MapAuditLogs( + "/api/audit-logs", + includeEntities: null, + excludeEntities: null, + tagName: nameof(AuditEntry), + groupName: "v1" +); + // Map CRUD endpoints for entities using FastCrud. app.MapFastCrud( "/api/customers", @@ -68,8 +84,8 @@ app.MapFastCrud( "/api/orders", - ops: ~CrudOps.Delete, - tagName: nameof(Order), + ops: ~CrudOps.Delete, + tagName: nameof(Order), groupName: "v1"); app.Run(); \ No newline at end of file diff --git a/src/FastCrud.Abstractions/Abstractions/IAuditEntry.cs b/src/FastCrud.Abstractions/Abstractions/IAuditEntry.cs new file mode 100644 index 0000000..49c9434 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditEntry.cs @@ -0,0 +1,16 @@ +using FastCrud.Abstractions.Primitives; + +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditEntry + { + string EntityName { get; set; } + string EntityId { get; set; } + AuditAction Action { get; set; } + string? OldValues { get; set; } + string? NewValues { get; set; } + DateTime Timestamp { get; set; } + string? UserId { get; set; } + string? UserName { get; set; } + } +} diff --git a/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs b/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs new file mode 100644 index 0000000..e1b3890 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs @@ -0,0 +1,9 @@ +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditQueryService + where TAuditEntry : class, IAuditEntry + { + Task GetRecentAuditLogsAsync(int count = 100, CancellationToken cancellationToken = default); + Task GetAuditLogsByEntityAsync(string entityName, int count = 100, CancellationToken cancellationToken = default); + } +} diff --git a/src/FastCrud.Abstractions/Abstractions/IAuditUserProvider.cs b/src/FastCrud.Abstractions/Abstractions/IAuditUserProvider.cs new file mode 100644 index 0000000..16fae45 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditUserProvider.cs @@ -0,0 +1,7 @@ +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditUserProvider + { + (string? UserId, string? UserName) GetCurrentUser(); + } +} diff --git a/src/FastCrud.Abstractions/FastCrud.Abstractions.csproj b/src/FastCrud.Abstractions/FastCrud.Abstractions.csproj index 35e3d84..6b512ec 100644 --- a/src/FastCrud.Abstractions/FastCrud.Abstractions.csproj +++ b/src/FastCrud.Abstractions/FastCrud.Abstractions.csproj @@ -1,2 +1 @@ - - + diff --git a/src/FastCrud.Abstractions/Primitives/AuditAction.cs b/src/FastCrud.Abstractions/Primitives/AuditAction.cs new file mode 100644 index 0000000..5208a43 --- /dev/null +++ b/src/FastCrud.Abstractions/Primitives/AuditAction.cs @@ -0,0 +1,9 @@ +namespace FastCrud.Abstractions.Primitives +{ + public enum AuditAction + { + Create, + Update, + Delete + } +} diff --git a/src/FastCrud.Core/Services/CrudService.cs b/src/FastCrud.Core/Services/CrudService.cs index a344293..9007749 100644 --- a/src/FastCrud.Core/Services/CrudService.cs +++ b/src/FastCrud.Core/Services/CrudService.cs @@ -1,29 +1,33 @@ -using System.Collections; using FastCrud.Abstractions.Abstractions; using FastCrud.Abstractions.Primitives; using FastCrud.Abstractions.Query; +using System.Collections; namespace FastCrud.Core.Services; public class CrudService( - IRepository repository, - IObjectMapper mapper, - IEnumerable> validators, - IServiceProvider serviceProvider, - IQueryEngine queryEngine) - : ICrudService + IRepository repository, + IObjectMapper mapper, + IEnumerable> validators, + IServiceProvider serviceProvider, + IQueryEngine queryEngine) + : ICrudService { public async Task> CreateAsync(TCreateDto input, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(input); var (ok, message) = await ValidateDtoAsync(input!, serviceProvider, cancellationToken); - if(!ok) return new OpResult(false, message); + if (!ok) return new OpResult(false, message); + var entity = mapper.Map(input); - await ValidateModelAsync(entity, cancellationToken); + var (modelOk, modelMessage) = await ValidateModelAsync(entity, cancellationToken); + if (!modelOk) return new OpResult(false, modelMessage); + await repository.AddAsync(entity, cancellationToken); await repository.SaveChangesAsync(cancellationToken); + return new OpResult(true, string.Empty, entity); } @@ -31,6 +35,7 @@ public async Task DeleteAsync(TId id, CancellationToken cancellationToken) { var entity = await repository.FindAsync(id, cancellationToken); if (entity is null) return; + await repository.DeleteAsync(entity, cancellationToken); await repository.SaveChangesAsync(cancellationToken); } @@ -53,12 +58,13 @@ public async Task> UpdateAsync(TId id, TUpdateDto input, Cancella mapper.Map(input, entity); var (ok, message) = await ValidateModelAsync(entity, ct); - if(!ok) return new OpResult(false, message); + if (!ok) return new OpResult(false, message); + await repository.SaveChangesAsync(ct); + return new OpResult(true, string.Empty, entity); } - private async Task<(bool ok, string message)> ValidateModelAsync( TAgg entity, CancellationToken cancellationToken) @@ -89,11 +95,8 @@ public async Task> UpdateAsync(TId id, TUpdateDto input, Cancella var method = v.GetType().GetMethod("ValidateAsync", [dtoType, typeof(CancellationToken)])!; var task = (Task<(bool ok, string message)>)method.Invoke(v, [dto, cancellationToken])!; var result = await task.ConfigureAwait(false); - if(!result.ok) return (false, result.message); + if (!result.ok) return (false, result.message); } return (true, string.Empty); } } - - - diff --git a/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs new file mode 100644 index 0000000..6bea611 --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs @@ -0,0 +1,52 @@ +using FastCrud.Abstractions.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FastCrud.Persistence.EFCore.DI; + +public static class AuditServiceCollectionExtensions +{ + public static IServiceCollection AddEfAuditing(this IServiceCollection services) + where TDbContext : DbContext + where TAuditEntry : class, IAuditEntry, new() + { + services.AddScoped(); + services.AddScoped>(); + services.AddScoped>(sp => + new EfAuditQueryService(sp.GetRequiredService())); + + return services; + } + + public static IServiceCollection AddCustomAuditUserProvider(this IServiceCollection services) + where TProvider : class, IAuditUserProvider + { + services.AddScoped(); + return services; + } + + public static IServiceCollection AddEfAuditing(this IServiceCollection services, + Type auditEntryType) + where TDbContext : DbContext + { + if (!typeof(IAuditEntry).IsAssignableFrom(auditEntryType)) + { + throw new ArgumentException($"Type: {auditEntryType.Name} and IAuditEntry", nameof(auditEntryType)); + } + + services.AddScoped(); + + var interceptorType = typeof(EntityAuditingInterceptor<>).MakeGenericType(auditEntryType); + services.AddScoped(interceptorType); + + var queryServiceInterface = typeof(IAuditQueryService<>).MakeGenericType(auditEntryType); + var queryServiceType = typeof(EfAuditQueryService<>).MakeGenericType(auditEntryType); + services.AddScoped(queryServiceInterface, sp => + { + var dbContext = sp.GetRequiredService(); + return Activator.CreateInstance(queryServiceType, dbContext)!; + }); + + return services; + } +} diff --git a/src/FastCrud.PersistenceEfCore/DefaultAuditUserProvider.cs b/src/FastCrud.PersistenceEfCore/DefaultAuditUserProvider.cs new file mode 100644 index 0000000..95c317f --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/DefaultAuditUserProvider.cs @@ -0,0 +1,11 @@ +using FastCrud.Abstractions.Abstractions; + +namespace FastCrud.Persistence.EFCore; + +public sealed class DefaultAuditUserProvider : IAuditUserProvider +{ + public (string? UserId, string? UserName) GetCurrentUser() + { + return ("system", "System"); + } +} \ No newline at end of file diff --git a/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs b/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs new file mode 100644 index 0000000..f65c3a8 --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs @@ -0,0 +1,75 @@ +using FastCrud.Abstractions.Abstractions; +using Microsoft.EntityFrameworkCore; + +namespace FastCrud.Persistence.EFCore; + +public class EfAuditQueryService : IAuditQueryService + where TAuditEntry : class, IAuditEntry +{ + private readonly DbContext _context; + + public EfAuditQueryService(DbContext context) + { + _context = context; + } + + public async Task GetRecentAuditLogsAsync(int count = 100, CancellationToken cancellationToken = default) + { + var auditLogs = await _context.Set() + .OrderByDescending(x => x.Timestamp) + .Take(count) + .ToListAsync(cancellationToken); + + var logs = auditLogs.Select(log => new + { + Id = GetAuditEntryId(log), + Entity = log.EntityName, + EntityId = log.EntityId.Length > 8 ? log.EntityId[..8] : log.EntityId, + Action = log.Action.ToString(), + Timestamp = log.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"), + User = $"{log.UserName ?? "Unknown"} ({log.UserId ?? "N/A"})", + OldValues = log.OldValues, + NewValues = log.NewValues + }); + + return new + { + TotalLogs = auditLogs.Count, + Logs = logs + }; + } + + public async Task GetAuditLogsByEntityAsync(string entityName, int count = 100, CancellationToken cancellationToken = default) + { + var auditLogs = await _context.Set() + .Where(x => x.EntityName == entityName) + .OrderByDescending(x => x.Timestamp) + .Take(count) + .ToListAsync(cancellationToken); + + var logs = auditLogs.Select(log => new + { + Id = GetAuditEntryId(log), + Entity = log.EntityName, + EntityId = log.EntityId.Length > 8 ? log.EntityId[..8] : log.EntityId, + Action = log.Action.ToString(), + Timestamp = log.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"), + User = $"{log.UserName ?? "Unknown"} ({log.UserId ?? "N/A"})", + OldValues = log.OldValues, + NewValues = log.NewValues + }); + + return new + { + EntityName = entityName, + TotalLogs = auditLogs.Count, + Logs = logs + }; + } + + private static object GetAuditEntryId(IAuditEntry entry) + { + var idProperty = entry.GetType().GetProperty("Id"); + return idProperty?.GetValue(entry) ?? 0; + } +} \ No newline at end of file diff --git a/src/FastCrud.PersistenceEfCore/EntityAuditingInterceptor.cs b/src/FastCrud.PersistenceEfCore/EntityAuditingInterceptor.cs new file mode 100644 index 0000000..6f7b714 --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/EntityAuditingInterceptor.cs @@ -0,0 +1,194 @@ +using FastCrud.Abstractions.Abstractions; +using FastCrud.Abstractions.Primitives; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using System.Text.Json; + +namespace FastCrud.Persistence.EFCore; + +public sealed class EntityAuditingInterceptor : SaveChangesInterceptor + where TAuditEntry : class, IAuditEntry, new() +{ + private readonly IAuditUserProvider _userProvider; + + public EntityAuditingInterceptor(IAuditUserProvider userProvider) + { + _userProvider = userProvider; + } + + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + AddAuditEntries(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + AddAuditEntries(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private void AddAuditEntries(DbContext? context) + { + if (context == null) return; + + try + { + var user = _userProvider.GetCurrentUser(); + var auditEntries = new List(); + + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.Entity is IAuditEntry) continue; + if (!HasIdProperty(entry.Entity)) continue; + + var auditEntry = CreateAuditEntry(entry, user); + if (auditEntry != null) + { + auditEntries.Add(auditEntry); + } + } + + if (auditEntries.Any()) + { + context.Set().AddRange(auditEntries); + } + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + } + + private static bool HasIdProperty(object entity) + { + return entity.GetType().GetProperty("Id") != null; + } + + private TAuditEntry? CreateAuditEntry(EntityEntry entry, (string? UserId, string? UserName) user) + { + try + { + var entityName = entry.Entity.GetType().Name; + var entityId = GetEntityId(entry); + + if (string.IsNullOrEmpty(entityId)) return null; + + var action = entry.State switch + { + EntityState.Added => AuditAction.Create, + EntityState.Modified => AuditAction.Update, + EntityState.Deleted => AuditAction.Delete, + _ => (AuditAction?)null + }; + + if (action == null) return null; + + var auditEntry = new TAuditEntry + { + EntityName = entityName, + EntityId = entityId, + Action = action.Value, + Timestamp = DateTime.UtcNow, + UserId = user.UserId, + UserName = user.UserName + }; + + switch (entry.State) + { + case EntityState.Added: + auditEntry.NewValues = SerializeValues(entry, entry.CurrentValues); + break; + case EntityState.Modified: + var modifiedProperties = entry.Properties + .Where(p => p.IsModified && !p.Metadata.IsPrimaryKey()) + .ToList(); + + if (modifiedProperties.Any()) + { + auditEntry.OldValues = SerializeModifiedValues(entry, entry.OriginalValues, modifiedProperties); + auditEntry.NewValues = SerializeModifiedValues(entry, entry.CurrentValues, modifiedProperties); + } + break; + case EntityState.Deleted: + auditEntry.OldValues = SerializeValues(entry, entry.OriginalValues); + break; + } + + return auditEntry; + } + catch + { + return null; + } + } + + private static string? GetEntityId(EntityEntry entry) + { + var keyProperty = entry.Properties.FirstOrDefault(p => p.Metadata.IsPrimaryKey()); + return keyProperty?.CurrentValue?.ToString(); + } + + private static string? SerializeValues(EntityEntry entry, PropertyValues values) + { + try + { + var result = new Dictionary(); + + foreach (var property in entry.Properties) + { + if (property.Metadata.IsForeignKey() || + property.Metadata.IsShadowProperty() || + property.Metadata.IsKey()) continue; + + var propertyName = property.Metadata.Name; + var value = values[propertyName]; + + if (value is DateTime dt) + value = dt.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + + result[propertyName] = value; + } + + return result.Any() ? JsonSerializer.Serialize(result, new JsonSerializerOptions + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }) : null; + } + catch + { + return null; + } + } + + private static string? SerializeModifiedValues(EntityEntry entry, PropertyValues values, List modifiedProperties) + { + try + { + var result = new Dictionary(); + + foreach (var property in modifiedProperties) + { + var propertyName = property.Metadata.Name; + var value = values[propertyName]; + + if (value is DateTime dt) + value = dt.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + + result[propertyName] = value; + } + + return result.Any() ? JsonSerializer.Serialize(result, new JsonSerializerOptions + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }) : null; + } + catch + { + return null; + } + } +} diff --git a/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs b/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs new file mode 100644 index 0000000..54ad580 --- /dev/null +++ b/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs @@ -0,0 +1,115 @@ +using FastCrud.Abstractions.Abstractions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using System.Reflection; + +namespace FastCrud.Web.MinimalApi; + +public static class AuditEndpointExtensions +{ + public static IEndpointRouteBuilder MapAuditLogs( + this IEndpointRouteBuilder builder, + string routePrefix = "/api/audit-logs", + IEnumerable? includeEntities = null, + IEnumerable? excludeEntities = null, + string? tagName = null, + string? groupName = null) + where TAuditEntry : class, IAuditEntry + { + tagName ??= "Audit"; + var prefix = routePrefix.StartsWith('/') ? routePrefix : $"/{routePrefix}"; + var group = builder.MapGroup(prefix).WithTags(tagName); + + if (groupName != null) + { + group.WithGroupName(groupName); + } + + group.MapGet("/", async ( + IAuditQueryService auditService, + CancellationToken ct, + [FromQuery] int count = 100) => + { + var result = await auditService.GetRecentAuditLogsAsync(count, ct); + return Results.Ok(result); + }); + + var auditableEntities = GetAuditableEntities(includeEntities, excludeEntities); + + foreach (var entityName in auditableEntities) + { + var entityRoute = $"/{entityName.ToLowerInvariant()}"; + group.MapGet(entityRoute, async ( + IAuditQueryService auditService, + CancellationToken ct, + [FromQuery] int count = 100) => + { + var result = await auditService.GetAuditLogsByEntityAsync(entityName, count, ct); + return Results.Ok(result); + }) + .WithName($"GetAuditLogsFor{entityName}"); + } + + return builder; + } + + public static IEndpointRouteBuilder MapAuditLogsFor( + this IEndpointRouteBuilder builder, + string routePrefix, + string? tagName = null, + string? groupName = null, + params Type[] entityTypes) + where TAuditEntry : class, IAuditEntry + { + return MapAuditLogs( + builder, + routePrefix, + includeEntities: entityTypes.Length > 0 ? entityTypes : null, + excludeEntities: null, + tagName: tagName, + groupName: groupName); + } + + private static IEnumerable GetAuditableEntities( + IEnumerable? includeEntities = null, + IEnumerable? excludeEntities = null) + where TAuditEntry : class, IAuditEntry + { + var dbContextType = typeof(TDbContext); + var auditEntryType = typeof(TAuditEntry); + var iAuditEntryType = typeof(IAuditEntry); + + var allEntityTypes = dbContextType.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.PropertyType.IsGenericType && + p.PropertyType.GetGenericTypeDefinition().FullName == "Microsoft.EntityFrameworkCore.DbSet`1") + .Select(p => p.PropertyType.GetGenericArguments()[0]) + .Where(entityType => + !entityType.IsAssignableTo(iAuditEntryType) && + entityType != auditEntryType && + entityType.GetProperty("Id") != null) + .ToList(); + + if (includeEntities != null && includeEntities.Any()) + { + var includeSet = new HashSet(includeEntities); + allEntityTypes = allEntityTypes + .Where(t => includeSet.Contains(t)) + .ToList(); + } + + if (excludeEntities != null && excludeEntities.Any()) + { + var excludeSet = new HashSet(excludeEntities); + allEntityTypes = allEntityTypes + .Where(t => !excludeSet.Contains(t)) + .ToList(); + } + + return allEntityTypes + .Select(entityType => entityType.Name) + .Distinct() + .OrderBy(name => name); + } +}