From 51dc779513a3017a70be6cb29414b846b4feb4de Mon Sep 17 00:00:00 2001 From: juniorCoder18 Date: Wed, 10 Sep 2025 23:28:38 +0330 Subject: [PATCH 1/9] FC01_Feature_Audit_Logging: add audit --- .../FastCrud.Samples.Api/Models/AuditEntry.cs | 18 ++ samples/FastCrud.Samples.Api/Program.cs | 26 ++- .../Abstractions/IAuditEntry.cs | 16 ++ .../Abstractions/IAuditService.cs | 9 + .../Abstractions/IAuditUserProvider.cs | 7 + .../Abstractions/IAuditable.cs | 10 + .../Primitives/AuditAction.cs | 9 + src/FastCrud.Core/Services/CrudService.cs | 65 ++++-- .../DI/AuditServiceCollectionExtensions.cs | 53 +++++ .../DefaultAuditUserProvider.cs | 11 + .../EfAuditService.cs | 56 +++++ .../EntityAuditingInterceptor .cs | 215 ++++++++++++++++++ 12 files changed, 468 insertions(+), 27 deletions(-) create mode 100644 samples/FastCrud.Samples.Api/Models/AuditEntry.cs create mode 100644 src/FastCrud.Abstractions/Abstractions/IAuditEntry.cs create mode 100644 src/FastCrud.Abstractions/Abstractions/IAuditService.cs create mode 100644 src/FastCrud.Abstractions/Abstractions/IAuditUserProvider.cs create mode 100644 src/FastCrud.Abstractions/Abstractions/IAuditable.cs create mode 100644 src/FastCrud.Abstractions/Primitives/AuditAction.cs create mode 100644 src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs create mode 100644 src/FastCrud.PersistenceEfCore/DefaultAuditUserProvider.cs create mode 100644 src/FastCrud.PersistenceEfCore/EfAuditService.cs create mode 100644 src/FastCrud.PersistenceEfCore/EntityAuditingInterceptor .cs 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/Program.cs b/samples/FastCrud.Samples.Api/Program.cs index 3fc1d4a..ba7d012 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,15 @@ 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"); + var auditInterceptor = serviceProvider.GetRequiredService>(); + options.AddInterceptors(auditInterceptor); +}); // FastCrud core services builder.Services.AddFastCrudCore(); @@ -22,6 +30,7 @@ builder.Services.UseFluentValidationAdapter(); // Register EF repositories per aggregate. Required for CrudService to resolve IRepository. + builder.Services.AddEfRepository(); builder.Services.AddEfRepository(); @@ -30,7 +39,7 @@ { 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(); } @@ -68,8 +76,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/IAuditService.cs b/src/FastCrud.Abstractions/Abstractions/IAuditService.cs new file mode 100644 index 0000000..96b19b6 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditService.cs @@ -0,0 +1,9 @@ +using FastCrud.Abstractions.Primitives; + +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditService + { + Task LogAsync(T entity, AuditAction action, object? oldValues = null, object? newValues = null, 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/Abstractions/IAuditable.cs b/src/FastCrud.Abstractions/Abstractions/IAuditable.cs new file mode 100644 index 0000000..d28c017 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditable.cs @@ -0,0 +1,10 @@ +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditable + { + DateTime CreatedAt { get; set; } + string? CreatedBy { get; set; } + DateTime? UpdatedAt { get; set; } + string? UpdatedBy { get; set; } + } +} \ No newline at end of file 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 9e0176d..5b81ef0 100644 --- a/src/FastCrud.Core/Services/CrudService.cs +++ b/src/FastCrud.Core/Services/CrudService.cs @@ -1,7 +1,7 @@ -using System.Collections; using FastCrud.Abstractions.Abstractions; using FastCrud.Abstractions.Primitives; using FastCrud.Abstractions.Query; +using System.Collections; namespace FastCrud.Core.Services { @@ -10,7 +10,8 @@ public class CrudService( IObjectMapper mapper, IEnumerable> validators, IServiceProvider serviceProvider, - IQueryEngine queryEngine) + IQueryEngine queryEngine, + IAuditService? auditService = null) : ICrudService { public async Task CreateAsync(object input, CancellationToken cancellationToken) @@ -20,11 +21,17 @@ public async Task CreateAsync(object input, CancellationToken cancellation await ValidateDtoAsync(input, serviceProvider, cancellationToken); var entity = input is TAgg agg ? agg : mapper.Map(input); - + await ValidateModelAsync(entity, cancellationToken); - + await repository.AddAsync(entity, cancellationToken); await repository.SaveChangesAsync(cancellationToken); + + if (auditService != null) + { + await auditService.LogAsync(entity, AuditAction.Create, newValues: entity, cancellationToken: cancellationToken); + } + return entity; } @@ -33,6 +40,11 @@ public async Task DeleteAsync(TId id, CancellationToken cancellationToken) var entity = await repository.FindAsync(id, cancellationToken); if (entity != null) { + if (auditService != null) + { + await auditService.LogAsync(entity, AuditAction.Delete, oldValues: entity, cancellationToken: cancellationToken); + } + await repository.DeleteAsync(entity, cancellationToken); await repository.SaveChangesAsync(cancellationToken); } @@ -51,7 +63,7 @@ public async Task> GetListAsync( var q = repository.Query(); return await queryEngine.ApplyQueryAsync(q, spec, projector, ct); } - + public async Task UpdateAsync(TId id, object input, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(input); @@ -60,7 +72,9 @@ public async Task UpdateAsync(TId id, object input, CancellationToken canc { throw new InvalidOperationException($"{typeof(TAgg).Name} with id {id} not found"); } - + + TAgg? oldValues = auditService != null ? CloneEntity(entity) : default(TAgg?); + if (input is TAgg agg) { mapper.Map(agg, entity); @@ -69,15 +83,33 @@ public async Task UpdateAsync(TId id, object input, CancellationToken canc { mapper.Map(input, entity); } - + await ValidateModelAsync(entity, cancellationToken); - + await repository.SaveChangesAsync(cancellationToken); + + if (auditService != null && oldValues != null) + { + await auditService.LogAsync(entity, AuditAction.Update, oldValues: oldValues, newValues: entity, cancellationToken: cancellationToken); + } + return entity; } - + + private TAgg? CloneEntity(TAgg entity) + { + try + { + return mapper.Map(entity); + } + catch + { + return default(TAgg?); + } + } + private async Task ValidateModelAsync( - TAgg entity, + TAgg entity, CancellationToken cancellationToken) { foreach (var validator in validators) @@ -85,15 +117,15 @@ private async Task ValidateModelAsync( await validator.ValidateAsync(entity, cancellationToken); } } - + private static async Task ValidateDtoAsync( object dto, - IServiceProvider serviceProvider, + IServiceProvider serviceProvider, CancellationToken cancellationToken) { var dtoType = dto.GetType(); var validatorInterface = typeof(IModelValidator<>).MakeGenericType(dtoType); - var enumerableType = typeof(IEnumerable<>).MakeGenericType(validatorInterface); + var enumerableType = typeof(IEnumerable<>).MakeGenericType(validatorInterface); if (serviceProvider.GetService(enumerableType) is not IEnumerable validators) return; @@ -101,12 +133,9 @@ private static async Task ValidateDtoAsync( foreach (var v in validators) { var method = v.GetType().GetMethod("ValidateAsync", [dtoType, typeof(CancellationToken)])!; - var task = (Task)method.Invoke(v, [dto, cancellationToken])!; + var task = (Task)method.Invoke(v, [dto, cancellationToken])!; await task.ConfigureAwait(false); } } } - - - -} +} \ No newline at end of file diff --git a/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs new file mode 100644 index 0000000..8c1e592 --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +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 EfAuditService( + sp.GetRequiredService(), + 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} ans IAuditEntry", nameof(auditEntryType)); + } + + services.AddScoped(); + var interceptorType = typeof(EntityAuditingInterceptor<>).MakeGenericType(auditEntryType); + services.AddScoped(interceptorType); + + services.AddScoped(sp => + { + var auditServiceType = typeof(EfAuditService<,>).MakeGenericType(typeof(TDbContext), auditEntryType); + return (IAuditService)Activator.CreateInstance(auditServiceType, + sp.GetRequiredService(), + sp.GetRequiredService())!; + }); + + return services; + } +} \ No newline at end of file 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/EfAuditService.cs b/src/FastCrud.PersistenceEfCore/EfAuditService.cs new file mode 100644 index 0000000..c499fa2 --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/EfAuditService.cs @@ -0,0 +1,56 @@ +using FastCrud.Abstractions.Abstractions; +using FastCrud.Abstractions.Primitives; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; + +namespace FastCrud.Persistence.EFCore; + +public sealed class EfAuditService : IAuditService + where TDbContext : DbContext + where TAuditEntry : class, IAuditEntry, new() +{ + private readonly TDbContext _context; + private readonly IAuditUserProvider _userProvider; + + public EfAuditService(TDbContext context, IAuditUserProvider userProvider) + { + _context = context; + _userProvider = userProvider; + } + + public async Task LogAsync(T entity, AuditAction action, object? oldValues = null, object? newValues = null, CancellationToken cancellationToken = default) + { + try + { + var entityId = GetEntityId(entity); + if (string.IsNullOrEmpty(entityId)) return; + + var user = _userProvider.GetCurrentUser(); + + var auditEntry = new TAuditEntry + { + EntityName = typeof(T).Name, + EntityId = entityId, + Action = action, + OldValues = oldValues != null ? JsonSerializer.Serialize(oldValues) : null, + NewValues = newValues != null ? JsonSerializer.Serialize(newValues) : null, + Timestamp = DateTime.UtcNow, + UserId = user.UserId, + UserName = user.UserName + }; + + _context.Set().Add(auditEntry); + await _context.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + } + + private static string? GetEntityId(T entity) + { + var idProperty = typeof(T).GetProperty("Id"); + return idProperty?.GetValue(entity)?.ToString(); + } +} \ 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..16fbe8c --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/EntityAuditingInterceptor .cs @@ -0,0 +1,215 @@ +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); + } + UpdateAuditableFields(entry, user); + } + + 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 void UpdateAuditableFields(EntityEntry entry, (string? UserId, string? UserName) user) + { + if (entry.Entity is not IAuditable auditable) return; + + var now = DateTime.UtcNow; + var userName = user.UserName ?? user.UserId ?? "System"; + + switch (entry.State) + { + case EntityState.Added: + auditable.CreatedAt = now; + auditable.CreatedBy = userName; + break; + case EntityState.Modified: + auditable.UpdatedAt = now; + auditable.UpdatedBy = userName; + break; + } + } + + 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; + } + } +} \ No newline at end of file From c6750c0cbf8a06a319269c8b75a6a225f4e7b65f Mon Sep 17 00:00:00 2001 From: juniorCoder18 Date: Thu, 11 Sep 2025 21:10:29 +0330 Subject: [PATCH 2/9] FC01_Feature_Audit_Logging: improvement and clean code --- .../FastCrud.Samples.Api/Data/AppDbContext.cs | 1 + .../FastCrud.Samples.Api/Dtos/AuditLogDto.cs | 15 ++++++ samples/FastCrud.Samples.Api/Program.cs | 7 +++ .../Abstractions/IAuditQueryService.cs | 8 ++++ .../FastCrud.Abstractions.csproj | 3 +- src/FastCrud.Core/Services/CrudService.cs | 15 ------ .../DI/AuditServiceCollectionExtensions.cs | 3 ++ .../EfAuditQueryService.cs | 47 +++++++++++++++++++ .../AuditEndpointExtensions.cs | 36 ++++++++++++++ 9 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 samples/FastCrud.Samples.Api/Dtos/AuditLogDto.cs create mode 100644 src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs create mode 100644 src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs create mode 100644 src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs diff --git a/samples/FastCrud.Samples.Api/Data/AppDbContext.cs b/samples/FastCrud.Samples.Api/Data/AppDbContext.cs index 58cb23b..0a4a558 100644 --- a/samples/FastCrud.Samples.Api/Data/AppDbContext.cs +++ b/samples/FastCrud.Samples.Api/Data/AppDbContext.cs @@ -7,4 +7,5 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon { public DbSet Customers => Set(); public DbSet Orders => Set(); + public DbSet AuditEntries => Set(); } \ 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/Program.cs b/samples/FastCrud.Samples.Api/Program.cs index ba7d012..f75eafe 100644 --- a/samples/FastCrud.Samples.Api/Program.cs +++ b/samples/FastCrud.Samples.Api/Program.cs @@ -34,6 +34,7 @@ builder.Services.AddEfRepository(); builder.Services.AddEfRepository(); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { @@ -67,6 +68,12 @@ app.UseSwagger(); app.UseSwaggerUI(); +app.MapAuditLogs( + "/api/audit-logs", + tagName: nameof(AuditEntry), + groupName: "v1" +); + // Map CRUD endpoints for entities using FastCrud. app.MapFastCrud( "/api/customers", diff --git a/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs b/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs new file mode 100644 index 0000000..55ae794 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs @@ -0,0 +1,8 @@ +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditQueryService + where TAuditEntry : class, IAuditEntry + { + Task GetRecentAuditLogsAsync(int count = 100, CancellationToken cancellationToken = default); + } +} 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.Core/Services/CrudService.cs b/src/FastCrud.Core/Services/CrudService.cs index 5b81ef0..b69c0ea 100644 --- a/src/FastCrud.Core/Services/CrudService.cs +++ b/src/FastCrud.Core/Services/CrudService.cs @@ -27,11 +27,6 @@ public async Task CreateAsync(object input, CancellationToken cancellation await repository.AddAsync(entity, cancellationToken); await repository.SaveChangesAsync(cancellationToken); - if (auditService != null) - { - await auditService.LogAsync(entity, AuditAction.Create, newValues: entity, cancellationToken: cancellationToken); - } - return entity; } @@ -40,11 +35,6 @@ public async Task DeleteAsync(TId id, CancellationToken cancellationToken) var entity = await repository.FindAsync(id, cancellationToken); if (entity != null) { - if (auditService != null) - { - await auditService.LogAsync(entity, AuditAction.Delete, oldValues: entity, cancellationToken: cancellationToken); - } - await repository.DeleteAsync(entity, cancellationToken); await repository.SaveChangesAsync(cancellationToken); } @@ -88,11 +78,6 @@ public async Task UpdateAsync(TId id, object input, CancellationToken canc await repository.SaveChangesAsync(cancellationToken); - if (auditService != null && oldValues != null) - { - await auditService.LogAsync(entity, AuditAction.Update, oldValues: oldValues, newValues: entity, cancellationToken: cancellationToken); - } - return entity; } diff --git a/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs index 8c1e592..4009545 100644 --- a/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs +++ b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs @@ -17,6 +17,9 @@ public static IServiceCollection AddEfAuditing(this ISe sp.GetRequiredService(), sp.GetRequiredService())); + services.AddScoped>(sp => + new EfAuditQueryService(sp.GetRequiredService())); + return services; } diff --git a/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs b/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs new file mode 100644 index 0000000..94fbefa --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs @@ -0,0 +1,47 @@ +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 + }; + } + + 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.Web.MinimalApi/AuditEndpointExtensions.cs b/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs new file mode 100644 index 0000000..fec6658 --- /dev/null +++ b/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs @@ -0,0 +1,36 @@ +using FastCrud.Abstractions.Abstractions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FastCrud.Web.MinimalApi; + +public static class AuditEndpointExtensions +{ + public static IEndpointRouteBuilder MapAuditLogs( + this IEndpointRouteBuilder builder, + string routePrefix = "/api/audit-logs", + 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) => + { + var result = await auditService.GetRecentAuditLogsAsync(100, ct); + return Results.Ok(result); + }); + + return builder; + } +} \ No newline at end of file From 7ff6f3190655176004a36f63617c3dbf828be2ee Mon Sep 17 00:00:00 2001 From: juniorCoder18 Date: Thu, 11 Sep 2025 21:11:10 +0330 Subject: [PATCH 3/9] FC01_Feature_Audit_Logging: typo mistake --- .../DI/AuditServiceCollectionExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs index 4009545..5eebb3e 100644 --- a/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs +++ b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs @@ -36,7 +36,7 @@ public static IServiceCollection AddEfAuditing(this IServiceCollecti { if (!typeof(IAuditEntry).IsAssignableFrom(auditEntryType)) { - throw new ArgumentException($"Type: {auditEntryType.Name} ans IAuditEntry", nameof(auditEntryType)); + throw new ArgumentException($"Type: {auditEntryType.Name} and IAuditEntry", nameof(auditEntryType)); } services.AddScoped(); From 5b67ac057187a59b9df420c20e99055cadbc9031 Mon Sep 17 00:00:00 2001 From: juniorCoder18 Date: Thu, 18 Sep 2025 13:52:41 +0330 Subject: [PATCH 4/9] FC01_Feature_Audit_Logging: resolve conflicts --- .../FastCrud.Samples.Api/Models/AuditEntry.cs | 18 ++ samples/FastCrud.Samples.Api/Program.cs | 26 ++- .../Abstractions/IAuditEntry.cs | 16 ++ .../Abstractions/IAuditService.cs | 9 + .../Abstractions/IAuditUserProvider.cs | 7 + .../Abstractions/IAuditable.cs | 10 + .../Primitives/AuditAction.cs | 9 + src/FastCrud.Core/Services/CrudService.cs | 56 +++-- .../DI/AuditServiceCollectionExtensions.cs | 53 +++++ .../DefaultAuditUserProvider.cs | 11 + .../EfAuditService.cs | 56 +++++ .../EntityAuditingInterceptor .cs | 215 ++++++++++++++++++ 12 files changed, 462 insertions(+), 24 deletions(-) create mode 100644 samples/FastCrud.Samples.Api/Models/AuditEntry.cs create mode 100644 src/FastCrud.Abstractions/Abstractions/IAuditEntry.cs create mode 100644 src/FastCrud.Abstractions/Abstractions/IAuditService.cs create mode 100644 src/FastCrud.Abstractions/Abstractions/IAuditUserProvider.cs create mode 100644 src/FastCrud.Abstractions/Abstractions/IAuditable.cs create mode 100644 src/FastCrud.Abstractions/Primitives/AuditAction.cs create mode 100644 src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs create mode 100644 src/FastCrud.PersistenceEfCore/DefaultAuditUserProvider.cs create mode 100644 src/FastCrud.PersistenceEfCore/EfAuditService.cs create mode 100644 src/FastCrud.PersistenceEfCore/EntityAuditingInterceptor .cs 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/Program.cs b/samples/FastCrud.Samples.Api/Program.cs index 3fc1d4a..ba7d012 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,15 @@ 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"); + var auditInterceptor = serviceProvider.GetRequiredService>(); + options.AddInterceptors(auditInterceptor); +}); // FastCrud core services builder.Services.AddFastCrudCore(); @@ -22,6 +30,7 @@ builder.Services.UseFluentValidationAdapter(); // Register EF repositories per aggregate. Required for CrudService to resolve IRepository. + builder.Services.AddEfRepository(); builder.Services.AddEfRepository(); @@ -30,7 +39,7 @@ { 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(); } @@ -68,8 +76,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/IAuditService.cs b/src/FastCrud.Abstractions/Abstractions/IAuditService.cs new file mode 100644 index 0000000..96b19b6 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditService.cs @@ -0,0 +1,9 @@ +using FastCrud.Abstractions.Primitives; + +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditService + { + Task LogAsync(T entity, AuditAction action, object? oldValues = null, object? newValues = null, 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/Abstractions/IAuditable.cs b/src/FastCrud.Abstractions/Abstractions/IAuditable.cs new file mode 100644 index 0000000..d28c017 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditable.cs @@ -0,0 +1,10 @@ +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditable + { + DateTime CreatedAt { get; set; } + string? CreatedBy { get; set; } + DateTime? UpdatedAt { get; set; } + string? UpdatedBy { get; set; } + } +} \ No newline at end of file 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 57f4f13..4ca2d8f 100644 --- a/src/FastCrud.Core/Services/CrudService.cs +++ b/src/FastCrud.Core/Services/CrudService.cs @@ -1,29 +1,34 @@ -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, + IAuditService? auditService = null) + : ICrudService { public async Task CreateAsync(TCreateDto input, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(input); - await ValidateDtoAsync(input!, serviceProvider, cancellationToken); - - var entity = mapper.Map(input); + var entity = mapper.Map(input); await ValidateModelAsync(entity, cancellationToken); await repository.AddAsync(entity, cancellationToken); await repository.SaveChangesAsync(cancellationToken); + + if (auditService != null) + { + await auditService.LogAsync(entity, AuditAction.Create, newValues: entity, cancellationToken: cancellationToken); + } + return entity; } @@ -31,6 +36,12 @@ public async Task DeleteAsync(TId id, CancellationToken cancellationToken) { var entity = await repository.FindAsync(id, cancellationToken); if (entity is null) return; + + if (auditService != null) + { + await auditService.LogAsync(entity, AuditAction.Delete, oldValues: entity, cancellationToken: cancellationToken); + } + await repository.DeleteAsync(entity, cancellationToken); await repository.SaveChangesAsync(cancellationToken); } @@ -50,13 +61,31 @@ public async Task UpdateAsync(TId id, TUpdateDto input, CancellationToken var entity = await repository.FindAsync(id, ct) ?? throw new InvalidOperationException($"{typeof(TAgg).Name} with id {id} not found"); - mapper.Map(input, entity); + TAgg? oldValues = auditService != null ? CloneEntity(entity) : default(TAgg?); + mapper.Map(input, entity); await ValidateModelAsync(entity, ct); await repository.SaveChangesAsync(ct); + + if (auditService != null && oldValues != null) + { + await auditService.LogAsync(entity, AuditAction.Update, oldValues: oldValues, newValues: entity, cancellationToken: ct); + } + return entity; } + private TAgg? CloneEntity(TAgg entity) + { + try + { + return mapper.Map(entity); + } + catch + { + return default(TAgg?); + } + } private async Task ValidateModelAsync( TAgg entity, @@ -87,7 +116,4 @@ private static async Task ValidateDtoAsync( await task.ConfigureAwait(false); } } -} - - - +} \ No newline at end of file diff --git a/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs new file mode 100644 index 0000000..8c1e592 --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +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 EfAuditService( + sp.GetRequiredService(), + 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} ans IAuditEntry", nameof(auditEntryType)); + } + + services.AddScoped(); + var interceptorType = typeof(EntityAuditingInterceptor<>).MakeGenericType(auditEntryType); + services.AddScoped(interceptorType); + + services.AddScoped(sp => + { + var auditServiceType = typeof(EfAuditService<,>).MakeGenericType(typeof(TDbContext), auditEntryType); + return (IAuditService)Activator.CreateInstance(auditServiceType, + sp.GetRequiredService(), + sp.GetRequiredService())!; + }); + + return services; + } +} \ No newline at end of file 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/EfAuditService.cs b/src/FastCrud.PersistenceEfCore/EfAuditService.cs new file mode 100644 index 0000000..c499fa2 --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/EfAuditService.cs @@ -0,0 +1,56 @@ +using FastCrud.Abstractions.Abstractions; +using FastCrud.Abstractions.Primitives; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; + +namespace FastCrud.Persistence.EFCore; + +public sealed class EfAuditService : IAuditService + where TDbContext : DbContext + where TAuditEntry : class, IAuditEntry, new() +{ + private readonly TDbContext _context; + private readonly IAuditUserProvider _userProvider; + + public EfAuditService(TDbContext context, IAuditUserProvider userProvider) + { + _context = context; + _userProvider = userProvider; + } + + public async Task LogAsync(T entity, AuditAction action, object? oldValues = null, object? newValues = null, CancellationToken cancellationToken = default) + { + try + { + var entityId = GetEntityId(entity); + if (string.IsNullOrEmpty(entityId)) return; + + var user = _userProvider.GetCurrentUser(); + + var auditEntry = new TAuditEntry + { + EntityName = typeof(T).Name, + EntityId = entityId, + Action = action, + OldValues = oldValues != null ? JsonSerializer.Serialize(oldValues) : null, + NewValues = newValues != null ? JsonSerializer.Serialize(newValues) : null, + Timestamp = DateTime.UtcNow, + UserId = user.UserId, + UserName = user.UserName + }; + + _context.Set().Add(auditEntry); + await _context.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + } + + private static string? GetEntityId(T entity) + { + var idProperty = typeof(T).GetProperty("Id"); + return idProperty?.GetValue(entity)?.ToString(); + } +} \ 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..16fbe8c --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/EntityAuditingInterceptor .cs @@ -0,0 +1,215 @@ +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); + } + UpdateAuditableFields(entry, user); + } + + 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 void UpdateAuditableFields(EntityEntry entry, (string? UserId, string? UserName) user) + { + if (entry.Entity is not IAuditable auditable) return; + + var now = DateTime.UtcNow; + var userName = user.UserName ?? user.UserId ?? "System"; + + switch (entry.State) + { + case EntityState.Added: + auditable.CreatedAt = now; + auditable.CreatedBy = userName; + break; + case EntityState.Modified: + auditable.UpdatedAt = now; + auditable.UpdatedBy = userName; + break; + } + } + + 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; + } + } +} \ No newline at end of file From 4c422a56a8ad08e7d68f893f637ee2d9d35842d9 Mon Sep 17 00:00:00 2001 From: juniorCoder18 Date: Thu, 18 Sep 2025 14:16:52 +0330 Subject: [PATCH 5/9] FC01_Feature_Audit_Logging: resolve conflicts --- .../FastCrud.Samples.Api/Data/AppDbContext.cs | 1 + .../FastCrud.Samples.Api/Dtos/AuditLogDto.cs | 15 ++++++ samples/FastCrud.Samples.Api/Program.cs | 7 +++ .../Abstractions/IAuditQueryService.cs | 8 ++++ .../FastCrud.Abstractions.csproj | 3 +- .../DI/AuditServiceCollectionExtensions.cs | 3 ++ .../EfAuditQueryService.cs | 47 +++++++++++++++++++ .../AuditEndpointExtensions.cs | 36 ++++++++++++++ 8 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 samples/FastCrud.Samples.Api/Dtos/AuditLogDto.cs create mode 100644 src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs create mode 100644 src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs create mode 100644 src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs diff --git a/samples/FastCrud.Samples.Api/Data/AppDbContext.cs b/samples/FastCrud.Samples.Api/Data/AppDbContext.cs index 58cb23b..0a4a558 100644 --- a/samples/FastCrud.Samples.Api/Data/AppDbContext.cs +++ b/samples/FastCrud.Samples.Api/Data/AppDbContext.cs @@ -7,4 +7,5 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon { public DbSet Customers => Set(); public DbSet Orders => Set(); + public DbSet AuditEntries => Set(); } \ 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/Program.cs b/samples/FastCrud.Samples.Api/Program.cs index ba7d012..f75eafe 100644 --- a/samples/FastCrud.Samples.Api/Program.cs +++ b/samples/FastCrud.Samples.Api/Program.cs @@ -34,6 +34,7 @@ builder.Services.AddEfRepository(); builder.Services.AddEfRepository(); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { @@ -67,6 +68,12 @@ app.UseSwagger(); app.UseSwaggerUI(); +app.MapAuditLogs( + "/api/audit-logs", + tagName: nameof(AuditEntry), + groupName: "v1" +); + // Map CRUD endpoints for entities using FastCrud. app.MapFastCrud( "/api/customers", diff --git a/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs b/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs new file mode 100644 index 0000000..55ae794 --- /dev/null +++ b/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs @@ -0,0 +1,8 @@ +namespace FastCrud.Abstractions.Abstractions +{ + public interface IAuditQueryService + where TAuditEntry : class, IAuditEntry + { + Task GetRecentAuditLogsAsync(int count = 100, CancellationToken cancellationToken = default); + } +} 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.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs index 8c1e592..4009545 100644 --- a/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs +++ b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs @@ -17,6 +17,9 @@ public static IServiceCollection AddEfAuditing(this ISe sp.GetRequiredService(), sp.GetRequiredService())); + services.AddScoped>(sp => + new EfAuditQueryService(sp.GetRequiredService())); + return services; } diff --git a/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs b/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs new file mode 100644 index 0000000..94fbefa --- /dev/null +++ b/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs @@ -0,0 +1,47 @@ +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 + }; + } + + 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.Web.MinimalApi/AuditEndpointExtensions.cs b/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs new file mode 100644 index 0000000..fec6658 --- /dev/null +++ b/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs @@ -0,0 +1,36 @@ +using FastCrud.Abstractions.Abstractions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FastCrud.Web.MinimalApi; + +public static class AuditEndpointExtensions +{ + public static IEndpointRouteBuilder MapAuditLogs( + this IEndpointRouteBuilder builder, + string routePrefix = "/api/audit-logs", + 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) => + { + var result = await auditService.GetRecentAuditLogsAsync(100, ct); + return Results.Ok(result); + }); + + return builder; + } +} \ No newline at end of file From daada6fa170b86ebf3dc3d20dce78096495961a7 Mon Sep 17 00:00:00 2001 From: juniorCoder18 Date: Thu, 11 Sep 2025 21:11:10 +0330 Subject: [PATCH 6/9] FC01_Feature_Audit_Logging: typo mistake --- .../DI/AuditServiceCollectionExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs index 4009545..5eebb3e 100644 --- a/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs +++ b/src/FastCrud.PersistenceEfCore/DI/AuditServiceCollectionExtensions.cs @@ -36,7 +36,7 @@ public static IServiceCollection AddEfAuditing(this IServiceCollecti { if (!typeof(IAuditEntry).IsAssignableFrom(auditEntryType)) { - throw new ArgumentException($"Type: {auditEntryType.Name} ans IAuditEntry", nameof(auditEntryType)); + throw new ArgumentException($"Type: {auditEntryType.Name} and IAuditEntry", nameof(auditEntryType)); } services.AddScoped(); From 68919656083e4f22c78907078cba975ea413aacb Mon Sep 17 00:00:00 2001 From: yasaminashoori Date: Mon, 22 Dec 2025 22:04:11 +0330 Subject: [PATCH 7/9] Address maintainer feedback --- .../FastCrud.Samples.Api/Data/AppDbContext.cs | 14 ++++++ samples/FastCrud.Samples.Api/Program.cs | 4 +- .../Abstractions/IAuditQueryService.cs | 3 +- .../EfAuditQueryService.cs | 30 ++++++++++++- .../AuditEndpointExtensions.cs | 44 ++++++++++++++++++- 5 files changed, 89 insertions(+), 6 deletions(-) diff --git a/samples/FastCrud.Samples.Api/Data/AppDbContext.cs b/samples/FastCrud.Samples.Api/Data/AppDbContext.cs index 0a4a558..e0bd102 100644 --- a/samples/FastCrud.Samples.Api/Data/AppDbContext.cs +++ b/samples/FastCrud.Samples.Api/Data/AppDbContext.cs @@ -8,4 +8,18 @@ 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/Program.cs b/samples/FastCrud.Samples.Api/Program.cs index f75eafe..3d74b35 100644 --- a/samples/FastCrud.Samples.Api/Program.cs +++ b/samples/FastCrud.Samples.Api/Program.cs @@ -1,4 +1,4 @@ -using FastCrud.Core.DI; +using FastCrud.Core.DI; using FastCrud.Mapping.Mapster.DI; using FastCrud.Persistence.EFCore; using FastCrud.Persistence.EFCore.DI; @@ -68,7 +68,7 @@ app.UseSwagger(); app.UseSwaggerUI(); -app.MapAuditLogs( +app.MapAuditLogs( "/api/audit-logs", tagName: nameof(AuditEntry), groupName: "v1" diff --git a/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs b/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs index 55ae794..e1b3890 100644 --- a/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs +++ b/src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs @@ -1,8 +1,9 @@ -namespace FastCrud.Abstractions.Abstractions +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.PersistenceEfCore/EfAuditQueryService.cs b/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs index 94fbefa..f65c3a8 100644 --- a/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs +++ b/src/FastCrud.PersistenceEfCore/EfAuditQueryService.cs @@ -1,4 +1,4 @@ -using FastCrud.Abstractions.Abstractions; +using FastCrud.Abstractions.Abstractions; using Microsoft.EntityFrameworkCore; namespace FastCrud.Persistence.EFCore; @@ -39,6 +39,34 @@ public async Task GetRecentAuditLogsAsync(int count = 100, CancellationT }; } + 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"); diff --git a/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs b/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs index fec6658..8929ecb 100644 --- a/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs +++ b/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs @@ -1,18 +1,21 @@ -using FastCrud.Abstractions.Abstractions; +using FastCrud.Abstractions.Abstractions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using System.Reflection; namespace FastCrud.Web.MinimalApi; public static class AuditEndpointExtensions { - public static IEndpointRouteBuilder MapAuditLogs( + public static IEndpointRouteBuilder MapAuditLogs( this IEndpointRouteBuilder builder, string routePrefix = "/api/audit-logs", string? tagName = null, string? groupName = null) where TAuditEntry : class, IAuditEntry + where TDbContext : DbContext { tagName ??= "Audit"; var prefix = routePrefix.StartsWith('/') ? routePrefix : $"/{routePrefix}"; @@ -31,6 +34,43 @@ public static IEndpointRouteBuilder MapAuditLogs( return Results.Ok(result); }); + var auditableEntities = GetAuditableEntities(); + foreach (var entityName in auditableEntities) + { + var entityRoute = $"/{entityName.ToLowerInvariant()}"; + group.MapGet(entityRoute, async ( + IAuditQueryService auditService, + CancellationToken ct) => + { + var result = await auditService.GetAuditLogsByEntityAsync(entityName, 100, ct); + return Results.Ok(result); + }) + .WithName($"GetAuditLogsFor{entityName}"); + } + return builder; } + + private static IEnumerable GetAuditableEntities() + where TDbContext : DbContext + where TAuditEntry : class, IAuditEntry + { + var dbContextType = typeof(TDbContext); + var auditEntryType = typeof(TAuditEntry); + var iAuditEntryType = typeof(IAuditEntry); + + var dbSetProperties = dbContextType.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.PropertyType.IsGenericType && + p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)) + .Select(p => p.PropertyType.GetGenericArguments()[0]) + .Where(entityType => + !entityType.IsAssignableTo(iAuditEntryType) && + entityType != auditEntryType && + entityType.GetProperty("Id") != null) + .Select(entityType => entityType.Name) + .Distinct() + .OrderBy(name => name); + + return dbSetProperties; + } } \ No newline at end of file From 90ebdfb52f8af30bb6e5a441142deb58b663584e Mon Sep 17 00:00:00 2001 From: yasaminashoori Date: Mon, 22 Dec 2025 23:48:07 +0330 Subject: [PATCH 8/9] Configure OldValues and NewValues, add endpoints, remove unnecessary dependency --- .../FastCrud.Samples.Api/Data/AppDbContext.cs | 1 + .../FastCrud.Samples.Api.csproj | 1 + samples/FastCrud.Samples.Api/Program.cs | 2 + .../AuditEndpointExtensions.cs | 56 +++++++++++++++---- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/samples/FastCrud.Samples.Api/Data/AppDbContext.cs b/samples/FastCrud.Samples.Api/Data/AppDbContext.cs index e0bd102..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; 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/Program.cs b/samples/FastCrud.Samples.Api/Program.cs index 3d74b35..125512c 100644 --- a/samples/FastCrud.Samples.Api/Program.cs +++ b/samples/FastCrud.Samples.Api/Program.cs @@ -70,6 +70,8 @@ app.MapAuditLogs( "/api/audit-logs", + includeEntities: null, + excludeEntities: null, tagName: nameof(AuditEntry), groupName: "v1" ); diff --git a/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs b/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs index 8929ecb..7a87175 100644 --- a/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs +++ b/src/FastCrud.Web.MinimalApi/AuditEndpointExtensions.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using Microsoft.EntityFrameworkCore; using System.Reflection; namespace FastCrud.Web.MinimalApi; @@ -12,10 +11,11 @@ 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 - where TDbContext : DbContext { tagName ??= "Audit"; var prefix = routePrefix.StartsWith('/') ? routePrefix : $"/{routePrefix}"; @@ -34,7 +34,8 @@ public static IEndpointRouteBuilder MapAuditLogs( return Results.Ok(result); }); - var auditableEntities = GetAuditableEntities(); + var auditableEntities = GetAuditableEntities(includeEntities, excludeEntities); + foreach (var entityName in auditableEntities) { var entityRoute = $"/{entityName.ToLowerInvariant()}"; @@ -51,26 +52,61 @@ public static IEndpointRouteBuilder MapAuditLogs( return builder; } - private static IEnumerable GetAuditableEntities() - where TDbContext : DbContext + 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 dbSetProperties = dbContextType.GetProperties(BindingFlags.Public | BindingFlags.Instance) + var allEntityTypes = dbContextType.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.PropertyType.IsGenericType && - p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)) + p.PropertyType.GetGenericTypeDefinition().FullName == "Microsoft.EntityFrameworkCore.DbSet`1") .Select(p => p.PropertyType.GetGenericArguments()[0]) - .Where(entityType => + .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); - - return dbSetProperties; } } \ No newline at end of file From 4de94f42065185a1036e83da0a18b9d378d02b32 Mon Sep 17 00:00:00 2001 From: yasaminashoori Date: Tue, 23 Dec 2025 00:05:13 +0330 Subject: [PATCH 9/9] formatting --- src/FastCrud.Core/Services/CrudService.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/FastCrud.Core/Services/CrudService.cs b/src/FastCrud.Core/Services/CrudService.cs index 14052e8..8e2b9bd 100644 --- a/src/FastCrud.Core/Services/CrudService.cs +++ b/src/FastCrud.Core/Services/CrudService.cs @@ -1,12 +1,7 @@ using FastCrud.Abstractions.Abstractions; using FastCrud.Abstractions.Primitives; using FastCrud.Abstractions.Query; -using System; using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; namespace FastCrud.Core.Services;