Skip to content
Merged
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
56 changes: 44 additions & 12 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,18 +473,50 @@ handler. If validation fails, a `BadRequestException` is thrown with the validat

### FluentValidation Extensions

The package includes extension methods to simplify common validation scenarios:

- File Validations:
- HasMaxFileSize(maxFileSizeInMb): Validates that an uploaded file does not exceed the specified maximum size.
- FileTypeIsOneOf(allowedFileExtensions): Validates that the uploaded file has one of the allowed file
extensions.
- String Validations:
- IsValidJson(): Validates that a string is a valid JSON.
- IsXssSanitized(): Validates that a string is sanitized against XSS attacks.
- IsEmail(): Validates that a string is a valid email address. Native one is not working correctly.
- IsPhoneNumber(): Validates that a string is a valid phone number. Format requires area code to be in `()`.
- IsEmailOrPhoneNumber(): Validates that a string is either a valid email address or a valid phone number.
We ship lightweight validators and presets for common scenarios, including file uploads and string checks.
All file rules use name/extension checks only (simple + fast). Deep validation still happens inside your storage layer.

**File upload validators**

**Single file (`IFormFile`)**

```csharp
RuleFor(x => x.Avatar)
.HasMaxSizeMb(6) // size cap in MB
.ExtensionIn(".jpg", ".jpeg", ".png"); // or use a preset set below

```

**File collection (`IFormFileCollection`)**

```csharp
RuleFor(x => x.Docs)
.MaxCount(10) // number of files
.EachHasMaxSizeMb(10) // per-file size cap (MB)
.EachExtensionIn(CommonFileSets.Documents)
.TotalSizeMaxMb(50); // sum of all files (MB)
```

**Presets**

```csharp
using SharedKernel.ValidatorAndMediatR.Validators.Files;

CommonFileSets.Images // .jpg, .jpeg, .png, .webp, .heic, .heif, .svg, .avif
CommonFileSets.Documents // .pdf, .txt, .csv, .json, .xml, .yaml, .yml, .md, .docx, .xlsx, .pptx, .odt, .ods, .odp
CommonFileSets.ImagesAndAnimations // Images + .gif
CommonFileSets.ImagesAndDocuments // Images + Documents
```

**String validators**

```csharp
RuleFor(x => x.Email).IsEmail();
RuleFor(x => x.Phone).IsPhoneNumber();
RuleFor(x => x.Contact).IsEmailOrPhoneNumber(); // alias: IsPhoneNumberOrEmail
RuleFor(x => x.PayloadJson).IsValidJson();
RuleFor(x => x.Content).IsXssSanitized();
```

## Cors

Expand Down
54 changes: 54 additions & 0 deletions SharedKernel.Demo/VerticalFeature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using FluentMinimalApiMapper;
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using SharedKernel.ValidatorAndMediatR.Validators;
using SharedKernel.ValidatorAndMediatR.Validators.Files;
using ICommand = SharedKernel.ValidatorAndMediatR.ICommand;

namespace SharedKernel.Demo;

public class VerticalFeature : IEndpoint
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapPost("vertical",
async ([AsParameters] VerticalCommand command, [FromServices] ISender sender) =>
{
await sender.Send(command);
return Results.Ok();
})
.WithTags("vertical")
.DisableAntiforgery();
}
}

public class VerticalCommand : ICommand
{
public IFormFile? Avatar { get; init; }
public IFormFileCollection? Docs { get; init; }
}

public class VerticalCommandHandler : IRequestHandler<VerticalCommand>
{
public Task Handle(VerticalCommand request, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

public class VerticalCommandValidator : AbstractValidator<VerticalCommand>
{
public VerticalCommandValidator()
{
RuleFor(x => x.Avatar)
.HasMaxSizeMb(3)
.ExtensionIn(".jpg", ".png");

RuleFor(x => x.Docs)
.MaxCount(4)
.EachHasMaxSizeMb(4)
.EachExtensionIn(CommonFileSets.Documents)
.TotalSizeMaxMb(10);
}
}
2 changes: 2 additions & 0 deletions src/SharedKernel/Maintenance/MaintenanceCachePoller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

namespace SharedKernel.Maintenance;

//This is for local cache entity to poll the maintenance mode from distributed cache
//This should be removed then L1 + L2 cache is implemented in hybrid cache
internal class MaintenanceCachePoller(HybridCache hybridCache, MaintenanceState state) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
Expand Down
9 changes: 1 addition & 8 deletions src/SharedKernel/Maintenance/MaintenanceMode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,4 @@ public enum MaintenanceMode
Disabled = 0,
EnabledForClients = 1,
EnabledForAll = 2
}

//This is for local cache entity to poll the maintenance mode from distributed cache
//This should be removed then L1 + L2 cache is implemented in hybrid cache

// This is a local cache entity to hold the maintenance mode in memory
// This should be removed then L1 + L2 cache is implemented in hybrid cache
// thread-safe local snapshot
}
6 changes: 5 additions & 1 deletion src/SharedKernel/Maintenance/MaintenanceState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace SharedKernel.Maintenance;

// This is a local cache entity to hold the maintenance mode in memory
// This should be removed then L1 + L2 cache is implemented in hybrid cache
// thread-safe local snapshot
public sealed class MaintenanceState(HybridCache cache)
{
private const string Key = "maintenance-mode";
Expand All @@ -16,7 +19,6 @@ public MaintenanceMode Mode
// for admin/API to change mode (updates local immediately, then L2)
public async Task SetModeAsync(MaintenanceMode mode, CancellationToken ct = default)
{
Mode = mode;
await cache.SetAsync(
Key,
new MaintenanceCacheEntity
Expand All @@ -30,6 +32,8 @@ await cache.SetAsync(
Flags = null
},
cancellationToken: ct);

Mode = mode;
}

// used by the poller only
Expand Down
8 changes: 4 additions & 4 deletions src/SharedKernel/SharedKernel.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
<PackageReadmeFile>Readme.md</PackageReadmeFile>
<Authors>Pandatech</Authors>
<Copyright>MIT</Copyright>
<Version>1.7.0</Version>
<Version>1.8.0</Version>
<PackageId>Pandatech.SharedKernel</PackageId>
<Title>Pandatech Shared Kernel Library</Title>
<PackageTags>Pandatech, shared kernel, library, OpenAPI, Swagger, utilities, scalar</PackageTags>
<Description>Pandatech.SharedKernel provides centralized configurations, utilities, and extensions for ASP.NET Core projects. For more information refere to readme.md document.</Description>
<RepositoryUrl>https://github.com/PandaTechAM/be-lib-sharedkernel</RepositoryUrl>
<PackageReleaseNotes>Maintenance mode has been added</PackageReleaseNotes>
<PackageReleaseNotes>Validator for files has breaking changes, but very easy to migrate (just name change)</PackageReleaseNotes>
</PropertyGroup>

<ItemGroup>
Expand Down Expand Up @@ -46,14 +46,14 @@
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.0.0-beta.12"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
<PackageReference Include="Pandatech.Crypto" Version="6.0.0"/>
<PackageReference Include="Pandatech.Crypto" Version="6.1.0" />
<PackageReference Include="Pandatech.DistributedCache" Version="4.0.9"/>
<PackageReference Include="PandaTech.FileExporter" Version="4.1.2"/>
<PackageReference Include="PandaTech.FluentImporter" Version="3.0.9"/>
<PackageReference Include="Pandatech.FluentMinimalApiMapper" Version="2.0.4"/>
<PackageReference Include="Pandatech.PandaVaultClient" Version="4.0.6"/>
<PackageReference Include="Pandatech.ResponseCrafter" Version="5.2.2"/>
<PackageReference Include="Scalar.AspNetCore" Version="2.8.1"/>
<PackageReference Include="Scalar.AspNetCore" Version="2.8.4" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0"/>
<PackageReference Include="Serilog.Sinks.Grafana.Loki" Version="8.3.1"/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using FluentValidation.Results;
using ResponseCrafter.HttpExceptions;

namespace SharedKernel.ValidatorAndMediatR.Behaviors;

internal static class ValidationAggregation
{
public static (string? Message, Dictionary<string, string>? Errors) ToMessageAndErrors(
IEnumerable<ValidationFailure> failures)
{
var globalMessages = new List<string>();
var errors = new Dictionary<string, string>(StringComparer.Ordinal);

foreach (var f in failures)
{
var prop = (f.PropertyName ?? string.Empty).Trim();

// Global messages (no property name) -> headline message
if (string.IsNullOrEmpty(prop))
{
if (!string.IsNullOrWhiteSpace(f.ErrorMessage))
{
globalMessages.Add(f.ErrorMessage);
}

continue;
}

// Per-property errors: keep the first message per property for brevity
if (!errors.ContainsKey(prop))
{
errors[prop] = f.ErrorMessage;
}
}

var message = globalMessages.Count > 0
? string.Join(" | ", globalMessages.Distinct())
: null;

return (message, errors.Count > 0 ? errors : null);
}

public static BadRequestException ToBadRequestException(IEnumerable<ValidationFailure> failures)
{
var (message, errors) = ToMessageAndErrors(failures);

if (!string.IsNullOrWhiteSpace(message) && errors is not null)
{
return new BadRequestException(message, errors);
}

if (!string.IsNullOrWhiteSpace(message))
{
return new BadRequestException(message);
}

if (errors is not null)
{
return new BadRequestException(errors);
}

// Should not happen if we had failures, but just in case:
return new BadRequestException("validation_failed");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,17 @@ public async Task<TResponse> Handle(TRequest request,
}

var context = new ValidationContext<TRequest>(request);
var results = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken)));

var validationResults = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
var failures = results.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();

if (failures.Count == 0)
{
return await next(cancellationToken);
}

var errors = failures.GroupBy(e => e.PropertyName, e => e.ErrorMessage)
.ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.First());

throw new BadRequestException(errors);
throw ValidationAggregation.ToBadRequestException(failures);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,17 @@ public async Task<TResponse> Handle(TRequest request,
}

var context = new ValidationContext<TRequest>(request);
var results = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken)));

var validationResults = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
var failures = results.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();

if (failures.Count == 0)
{
return await next(cancellationToken);
}

var errors = failures.GroupBy(e => e.PropertyName, e => e.ErrorMessage)
.ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.First());

throw new BadRequestException(errors);
throw ValidationAggregation.ToBadRequestException(failures);
}
}

This file was deleted.

This file was deleted.

Loading