Skip to content

Commit ebda787

Browse files
authored
Merge pull request #71 from PandaTechAM/development
File validation tweaks
2 parents d88f52a + e5b33b8 commit ebda787

21 files changed

+512
-122
lines changed

Readme.md

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -473,18 +473,50 @@ handler. If validation fails, a `BadRequestException` is thrown with the validat
473473
474474
### FluentValidation Extensions
475475

476-
The package includes extension methods to simplify common validation scenarios:
477-
478-
- File Validations:
479-
- HasMaxFileSize(maxFileSizeInMb): Validates that an uploaded file does not exceed the specified maximum size.
480-
- FileTypeIsOneOf(allowedFileExtensions): Validates that the uploaded file has one of the allowed file
481-
extensions.
482-
- String Validations:
483-
- IsValidJson(): Validates that a string is a valid JSON.
484-
- IsXssSanitized(): Validates that a string is sanitized against XSS attacks.
485-
- IsEmail(): Validates that a string is a valid email address. Native one is not working correctly.
486-
- IsPhoneNumber(): Validates that a string is a valid phone number. Format requires area code to be in `()`.
487-
- IsEmailOrPhoneNumber(): Validates that a string is either a valid email address or a valid phone number.
476+
We ship lightweight validators and presets for common scenarios, including file uploads and string checks.
477+
All file rules use name/extension checks only (simple + fast). Deep validation still happens inside your storage layer.
478+
479+
**File upload validators**
480+
481+
**Single file (`IFormFile`)**
482+
483+
```csharp
484+
RuleFor(x => x.Avatar)
485+
.HasMaxSizeMb(6) // size cap in MB
486+
.ExtensionIn(".jpg", ".jpeg", ".png"); // or use a preset set below
487+
488+
```
489+
490+
**File collection (`IFormFileCollection`)**
491+
492+
```csharp
493+
RuleFor(x => x.Docs)
494+
.MaxCount(10) // number of files
495+
.EachHasMaxSizeMb(10) // per-file size cap (MB)
496+
.EachExtensionIn(CommonFileSets.Documents)
497+
.TotalSizeMaxMb(50); // sum of all files (MB)
498+
```
499+
500+
**Presets**
501+
502+
```csharp
503+
using SharedKernel.ValidatorAndMediatR.Validators.Files;
504+
505+
CommonFileSets.Images // .jpg, .jpeg, .png, .webp, .heic, .heif, .svg, .avif
506+
CommonFileSets.Documents // .pdf, .txt, .csv, .json, .xml, .yaml, .yml, .md, .docx, .xlsx, .pptx, .odt, .ods, .odp
507+
CommonFileSets.ImagesAndAnimations // Images + .gif
508+
CommonFileSets.ImagesAndDocuments // Images + Documents
509+
```
510+
511+
**String validators**
512+
513+
```csharp
514+
RuleFor(x => x.Email).IsEmail();
515+
RuleFor(x => x.Phone).IsPhoneNumber();
516+
RuleFor(x => x.Contact).IsEmailOrPhoneNumber(); // alias: IsPhoneNumberOrEmail
517+
RuleFor(x => x.PayloadJson).IsValidJson();
518+
RuleFor(x => x.Content).IsXssSanitized();
519+
```
488520

489521
## Cors
490522

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using FluentMinimalApiMapper;
2+
using FluentValidation;
3+
using MediatR;
4+
using Microsoft.AspNetCore.Mvc;
5+
using SharedKernel.ValidatorAndMediatR.Validators;
6+
using SharedKernel.ValidatorAndMediatR.Validators.Files;
7+
using ICommand = SharedKernel.ValidatorAndMediatR.ICommand;
8+
9+
namespace SharedKernel.Demo;
10+
11+
public class VerticalFeature : IEndpoint
12+
{
13+
public void AddRoutes(IEndpointRouteBuilder app)
14+
{
15+
app.MapPost("vertical",
16+
async ([AsParameters] VerticalCommand command, [FromServices] ISender sender) =>
17+
{
18+
await sender.Send(command);
19+
return Results.Ok();
20+
})
21+
.WithTags("vertical")
22+
.DisableAntiforgery();
23+
}
24+
}
25+
26+
public class VerticalCommand : ICommand
27+
{
28+
public IFormFile? Avatar { get; init; }
29+
public IFormFileCollection? Docs { get; init; }
30+
}
31+
32+
public class VerticalCommandHandler : IRequestHandler<VerticalCommand>
33+
{
34+
public Task Handle(VerticalCommand request, CancellationToken cancellationToken)
35+
{
36+
return Task.CompletedTask;
37+
}
38+
}
39+
40+
public class VerticalCommandValidator : AbstractValidator<VerticalCommand>
41+
{
42+
public VerticalCommandValidator()
43+
{
44+
RuleFor(x => x.Avatar)
45+
.HasMaxSizeMb(3)
46+
.ExtensionIn(".jpg", ".png");
47+
48+
RuleFor(x => x.Docs)
49+
.MaxCount(4)
50+
.EachHasMaxSizeMb(4)
51+
.EachExtensionIn(CommonFileSets.Documents)
52+
.TotalSizeMaxMb(10);
53+
}
54+
}

src/SharedKernel/Maintenance/MaintenanceCachePoller.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
namespace SharedKernel.Maintenance;
55

6+
//This is for local cache entity to poll the maintenance mode from distributed cache
7+
//This should be removed then L1 + L2 cache is implemented in hybrid cache
68
internal class MaintenanceCachePoller(HybridCache hybridCache, MaintenanceState state) : BackgroundService
79
{
810
protected override async Task ExecuteAsync(CancellationToken stoppingToken)

src/SharedKernel/Maintenance/MaintenanceMode.cs

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,4 @@ public enum MaintenanceMode
55
Disabled = 0,
66
EnabledForClients = 1,
77
EnabledForAll = 2
8-
}
9-
10-
//This is for local cache entity to poll the maintenance mode from distributed cache
11-
//This should be removed then L1 + L2 cache is implemented in hybrid cache
12-
13-
// This is a local cache entity to hold the maintenance mode in memory
14-
// This should be removed then L1 + L2 cache is implemented in hybrid cache
15-
// thread-safe local snapshot
8+
}

src/SharedKernel/Maintenance/MaintenanceState.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
namespace SharedKernel.Maintenance;
44

5+
// This is a local cache entity to hold the maintenance mode in memory
6+
// This should be removed then L1 + L2 cache is implemented in hybrid cache
7+
// thread-safe local snapshot
58
public sealed class MaintenanceState(HybridCache cache)
69
{
710
private const string Key = "maintenance-mode";
@@ -16,7 +19,6 @@ public MaintenanceMode Mode
1619
// for admin/API to change mode (updates local immediately, then L2)
1720
public async Task SetModeAsync(MaintenanceMode mode, CancellationToken ct = default)
1821
{
19-
Mode = mode;
2022
await cache.SetAsync(
2123
Key,
2224
new MaintenanceCacheEntity
@@ -30,6 +32,8 @@ await cache.SetAsync(
3032
Flags = null
3133
},
3234
cancellationToken: ct);
35+
36+
Mode = mode;
3337
}
3438

3539
// used by the poller only

src/SharedKernel/SharedKernel.csproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88
<PackageReadmeFile>Readme.md</PackageReadmeFile>
99
<Authors>Pandatech</Authors>
1010
<Copyright>MIT</Copyright>
11-
<Version>1.7.0</Version>
11+
<Version>1.8.0</Version>
1212
<PackageId>Pandatech.SharedKernel</PackageId>
1313
<Title>Pandatech Shared Kernel Library</Title>
1414
<PackageTags>Pandatech, shared kernel, library, OpenAPI, Swagger, utilities, scalar</PackageTags>
1515
<Description>Pandatech.SharedKernel provides centralized configurations, utilities, and extensions for ASP.NET Core projects. For more information refere to readme.md document.</Description>
1616
<RepositoryUrl>https://github.com/PandaTechAM/be-lib-sharedkernel</RepositoryUrl>
17-
<PackageReleaseNotes>Maintenance mode has been added</PackageReleaseNotes>
17+
<PackageReleaseNotes>Validator for files has breaking changes, but very easy to migrate (just name change)</PackageReleaseNotes>
1818
</PropertyGroup>
1919

2020
<ItemGroup>
@@ -46,14 +46,14 @@
4646
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.0.0-beta.12"/>
4747
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
4848
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
49-
<PackageReference Include="Pandatech.Crypto" Version="6.0.0"/>
49+
<PackageReference Include="Pandatech.Crypto" Version="6.1.0" />
5050
<PackageReference Include="Pandatech.DistributedCache" Version="4.0.9"/>
5151
<PackageReference Include="PandaTech.FileExporter" Version="4.1.2"/>
5252
<PackageReference Include="PandaTech.FluentImporter" Version="3.0.9"/>
5353
<PackageReference Include="Pandatech.FluentMinimalApiMapper" Version="2.0.4"/>
5454
<PackageReference Include="Pandatech.PandaVaultClient" Version="4.0.6"/>
5555
<PackageReference Include="Pandatech.ResponseCrafter" Version="5.2.2"/>
56-
<PackageReference Include="Scalar.AspNetCore" Version="2.8.1"/>
56+
<PackageReference Include="Scalar.AspNetCore" Version="2.8.4" />
5757
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
5858
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0"/>
5959
<PackageReference Include="Serilog.Sinks.Grafana.Loki" Version="8.3.1"/>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using FluentValidation.Results;
2+
using ResponseCrafter.HttpExceptions;
3+
4+
namespace SharedKernel.ValidatorAndMediatR.Behaviors;
5+
6+
internal static class ValidationAggregation
7+
{
8+
public static (string? Message, Dictionary<string, string>? Errors) ToMessageAndErrors(
9+
IEnumerable<ValidationFailure> failures)
10+
{
11+
var globalMessages = new List<string>();
12+
var errors = new Dictionary<string, string>(StringComparer.Ordinal);
13+
14+
foreach (var f in failures)
15+
{
16+
var prop = (f.PropertyName ?? string.Empty).Trim();
17+
18+
// Global messages (no property name) -> headline message
19+
if (string.IsNullOrEmpty(prop))
20+
{
21+
if (!string.IsNullOrWhiteSpace(f.ErrorMessage))
22+
{
23+
globalMessages.Add(f.ErrorMessage);
24+
}
25+
26+
continue;
27+
}
28+
29+
// Per-property errors: keep the first message per property for brevity
30+
if (!errors.ContainsKey(prop))
31+
{
32+
errors[prop] = f.ErrorMessage;
33+
}
34+
}
35+
36+
var message = globalMessages.Count > 0
37+
? string.Join(" | ", globalMessages.Distinct())
38+
: null;
39+
40+
return (message, errors.Count > 0 ? errors : null);
41+
}
42+
43+
public static BadRequestException ToBadRequestException(IEnumerable<ValidationFailure> failures)
44+
{
45+
var (message, errors) = ToMessageAndErrors(failures);
46+
47+
if (!string.IsNullOrWhiteSpace(message) && errors is not null)
48+
{
49+
return new BadRequestException(message, errors);
50+
}
51+
52+
if (!string.IsNullOrWhiteSpace(message))
53+
{
54+
return new BadRequestException(message);
55+
}
56+
57+
if (errors is not null)
58+
{
59+
return new BadRequestException(errors);
60+
}
61+
62+
// Should not happen if we had failures, but just in case:
63+
return new BadRequestException("validation_failed");
64+
}
65+
}

src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithResponse.cs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,17 @@ public async Task<TResponse> Handle(TRequest request,
1818
}
1919

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

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

2727
if (failures.Count == 0)
2828
{
2929
return await next(cancellationToken);
3030
}
3131

32-
var errors = failures.GroupBy(e => e.PropertyName, e => e.ErrorMessage)
33-
.ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.First());
34-
35-
throw new BadRequestException(errors);
32+
throw ValidationAggregation.ToBadRequestException(failures);
3633
}
3734
}

src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehaviorWithoutResponse.cs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,17 @@ public async Task<TResponse> Handle(TRequest request,
1818
}
1919

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

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

2727
if (failures.Count == 0)
2828
{
2929
return await next(cancellationToken);
3030
}
3131

32-
var errors = failures.GroupBy(e => e.PropertyName, e => e.ErrorMessage)
33-
.ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.First());
34-
35-
throw new BadRequestException(errors);
32+
throw ValidationAggregation.ToBadRequestException(failures);
3633
}
3734
}

src/SharedKernel/ValidatorAndMediatR/Validators/FileSizeValidator.cs

Lines changed: 0 additions & 31 deletions
This file was deleted.

0 commit comments

Comments
 (0)