Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions samples/FastCrud.Samples.Api/Data/AppDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
using FastCrud.Samples.Api.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace FastCrud.Samples.Api.Data;

public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);

modelBuilder.Entity<AuditEntry>(entity =>
{
entity.Property(e => e.OldValues)
.HasColumnType("nvarchar(max)");

entity.Property(e => e.NewValues)
.HasColumnType("nvarchar(max)");
});
}
}
15 changes: 15 additions & 0 deletions samples/FastCrud.Samples.Api/Dtos/AuditLogDto.cs
Original file line number Diff line number Diff line change
@@ -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<AuditLogDto> Logs);
}
1 change: 1 addition & 0 deletions samples/FastCrud.Samples.Api/FastCrud.Samples.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="FluentValidation" />
</ItemGroup>
Expand Down
18 changes: 18 additions & 0 deletions samples/FastCrud.Samples.Api/Models/AuditEntry.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
33 changes: 25 additions & 8 deletions samples/FastCrud.Samples.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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;
Expand All @@ -12,8 +13,15 @@

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("fastcrud-demo"));
// Add audit logging
builder.Services.AddEfAuditing<AppDbContext, AuditEntry>();

builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
options.UseInMemoryDatabase("fastcrud-demo");
var auditInterceptor = serviceProvider.GetRequiredService<EntityAuditingInterceptor<AuditEntry>>();
options.AddInterceptors(auditInterceptor);
});

// FastCrud core services
builder.Services.AddFastCrudCore();
Expand All @@ -22,15 +30,17 @@
builder.Services.UseFluentValidationAdapter();

// Register EF repositories per aggregate. Required for CrudService to resolve IRepository.

builder.Services.AddEfRepository<Customer, Guid, AppDbContext>();
builder.Services.AddEfRepository<Order, Guid, AppDbContext>();


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"
});
Expand All @@ -47,10 +57,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();
}
Expand All @@ -59,6 +68,14 @@
app.UseSwagger();
app.UseSwaggerUI();

app.MapAuditLogs<AuditEntry, AppDbContext>(
"/api/audit-logs",
includeEntities: null,
excludeEntities: null,
tagName: nameof(AuditEntry),
groupName: "v1"
);

// Map CRUD endpoints for entities using FastCrud.
app.MapFastCrud<Customer, Guid, CustomerCreateDto, CustomerUpdateDto, CustomerReadDto>(
"/api/customers",
Expand All @@ -68,8 +85,8 @@

app.MapFastCrud<Order, Guid, OrderCreateDto, OrderUpdateDto, OrderReadDto>(
"/api/orders",
ops: ~CrudOps.Delete,
tagName: nameof(Order),
ops: ~CrudOps.Delete,
tagName: nameof(Order),
groupName: "v1");

app.Run();
16 changes: 16 additions & 0 deletions src/FastCrud.Abstractions/Abstractions/IAuditEntry.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
9 changes: 9 additions & 0 deletions src/FastCrud.Abstractions/Abstractions/IAuditQueryService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace FastCrud.Abstractions.Abstractions
{
public interface IAuditQueryService<TAuditEntry>
where TAuditEntry : class, IAuditEntry
{
Task<object> GetRecentAuditLogsAsync(int count = 100, CancellationToken cancellationToken = default);
Task<object> GetAuditLogsByEntityAsync(string entityName, int count = 100, CancellationToken cancellationToken = default);
}
}
9 changes: 9 additions & 0 deletions src/FastCrud.Abstractions/Abstractions/IAuditService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using FastCrud.Abstractions.Primitives;

namespace FastCrud.Abstractions.Abstractions
{
public interface IAuditService
{
Task LogAsync<T>(T entity, AuditAction action, object? oldValues = null, object? newValues = null, CancellationToken cancellationToken = default);
}
}
7 changes: 7 additions & 0 deletions src/FastCrud.Abstractions/Abstractions/IAuditUserProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace FastCrud.Abstractions.Abstractions
{
public interface IAuditUserProvider
{
(string? UserId, string? UserName) GetCurrentUser();
}
}
10 changes: 10 additions & 0 deletions src/FastCrud.Abstractions/Abstractions/IAuditable.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
3 changes: 1 addition & 2 deletions src/FastCrud.Abstractions/FastCrud.Abstractions.csproj
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
<Project Sdk="Microsoft.NET.Sdk">
</Project>
<Project Sdk="Microsoft.NET.Sdk" />
9 changes: 9 additions & 0 deletions src/FastCrud.Abstractions/Primitives/AuditAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace FastCrud.Abstractions.Primitives
{
public enum AuditAction
{
Create,
Update,
Delete
}
}
63 changes: 48 additions & 15 deletions src/FastCrud.Core/Services/CrudService.cs
Original file line number Diff line number Diff line change
@@ -1,36 +1,52 @@
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<TAgg, TId, TCreateDto, TUpdateDto>(
IRepository<TAgg, TId> repository,
IObjectMapper mapper,
IEnumerable<IModelValidator<TAgg>> validators,
IServiceProvider serviceProvider,
IQueryEngine queryEngine)
: ICrudService<TAgg, TId, TCreateDto, TUpdateDto>
IRepository<TAgg, TId> repository,
IObjectMapper mapper,
IEnumerable<IModelValidator<TAgg>> validators,
IServiceProvider serviceProvider,
IQueryEngine queryEngine,
IAuditService? auditService = null)
: ICrudService<TAgg, TId, TCreateDto, TUpdateDto>
{
public async Task<OpResult<TAgg>> CreateAsync(TCreateDto input, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(input);

var (ok, message) = await ValidateDtoAsync(input!, serviceProvider, cancellationToken);
if(!ok) return new OpResult<TAgg>(false, message);
if (!ok) return new OpResult<TAgg>(false, message);

var entity = mapper.Map<TAgg>(input);

await ValidateModelAsync(entity, cancellationToken);
var (modelOk, modelMessage) = await ValidateModelAsync(entity, cancellationToken);
if (!modelOk) return new OpResult<TAgg>(false, modelMessage);

await repository.AddAsync(entity, cancellationToken);
await repository.SaveChangesAsync(cancellationToken);

if (auditService != null)
{
await auditService.LogAsync(entity, AuditAction.Create, newValues: entity, cancellationToken: cancellationToken);
}

return new OpResult<TAgg>(true, string.Empty, entity);
}

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);
}
Expand All @@ -50,14 +66,34 @@ public async Task<OpResult<TAgg>> UpdateAsync(TId id, TUpdateDto input, Cancella
var entity = await repository.FindAsync(id, ct)
?? throw new InvalidOperationException($"{typeof(TAgg).Name} with id {id} not found");

TAgg? oldValues = auditService != null ? CloneEntity(entity) : default;

mapper.Map(input, entity);

var (ok, message) = await ValidateModelAsync(entity, ct);
if(!ok) return new OpResult<TAgg>(false, message);
if (!ok) return new OpResult<TAgg>(false, message);

await repository.SaveChangesAsync(ct);

if (auditService != null && oldValues != null)
{
await auditService.LogAsync(entity, AuditAction.Update, oldValues: oldValues, newValues: entity, cancellationToken: ct);
}

return new OpResult<TAgg>(true, string.Empty, entity);
}

private TAgg? CloneEntity(TAgg entity)
{
try
{
return mapper.Map<TAgg>(entity);
}
catch
{
return default;
}
}

private async Task<(bool ok, string message)> ValidateModelAsync(
TAgg entity,
Expand Down Expand Up @@ -89,11 +125,8 @@ public async Task<OpResult<TAgg>> 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);
}
}



}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using FastCrud.Abstractions.Abstractions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace FastCrud.Persistence.EFCore.DI;

public static class AuditServiceCollectionExtensions
{
public static IServiceCollection AddEfAuditing<TDbContext, TAuditEntry>(this IServiceCollection services)
where TDbContext : DbContext
where TAuditEntry : class, IAuditEntry, new()
{
services.AddScoped<IAuditUserProvider, DefaultAuditUserProvider>();
services.AddScoped<EntityAuditingInterceptor<TAuditEntry>>();
services.AddScoped<IAuditService>(sp =>
new EfAuditService<TDbContext, TAuditEntry>(
sp.GetRequiredService<TDbContext>(),
sp.GetRequiredService<IAuditUserProvider>()));

services.AddScoped<IAuditQueryService<TAuditEntry>>(sp =>
new EfAuditQueryService<TAuditEntry>(sp.GetRequiredService<TDbContext>()));

return services;
}

public static IServiceCollection AddCustomAuditUserProvider<TProvider>(this IServiceCollection services)
where TProvider : class, IAuditUserProvider
{
services.AddScoped<IAuditUserProvider, TProvider>();
return services;
}

public static IServiceCollection AddEfAuditing<TDbContext>(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<IAuditUserProvider, DefaultAuditUserProvider>();
var interceptorType = typeof(EntityAuditingInterceptor<>).MakeGenericType(auditEntryType);
services.AddScoped(interceptorType);

services.AddScoped<IAuditService>(sp =>
{
var auditServiceType = typeof(EfAuditService<,>).MakeGenericType(typeof(TDbContext), auditEntryType);
return (IAuditService)Activator.CreateInstance(auditServiceType,
sp.GetRequiredService<TDbContext>(),
sp.GetRequiredService<IAuditUserProvider>())!;
});

return services;
}
}
Loading