mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Compare commits
2 Commits
fbede956e9
...
a6a9046457
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6a9046457 | ||
|
|
40ae75808c |
@@ -0,0 +1,9 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Shared.Application.Tests.Behaviors;
|
||||||
|
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
public sealed record TestRequest(string Username, string Password) : IRequest<TestResponse>;
|
||||||
|
|
||||||
|
public sealed record TestResponse(string Value);
|
||||||
|
// ReSharper restore NotAccessedPositionalProperty.Global
|
||||||
@@ -14,15 +14,18 @@ public class UnhandledExceptionLoggingBehaviorTests
|
|||||||
ILogger<UnhandledExceptionLoggingBehavior<TestRequest, TestResponse>>
|
ILogger<UnhandledExceptionLoggingBehavior<TestRequest, TestResponse>>
|
||||||
> _loggerMock = new();
|
> _loggerMock = new();
|
||||||
|
|
||||||
private UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> CreateBehavior() =>
|
private UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> CreateBehavior()
|
||||||
new(_loggerMock.Object);
|
{
|
||||||
|
_loggerMock.Setup(l => l.IsEnabled(LogLevel.Error)).Returns(true);
|
||||||
|
return new(_loggerMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WhenHandlerThrowsGenericException_LogsOnceAndRethrows()
|
public async Task Handle_WhenHandlerThrowsGenericException_LogsOnceAndRethrows()
|
||||||
{
|
{
|
||||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||||
TestRequest request = new("aaron", "hunter2");
|
TestRequest request = new("aaron", "pa$$word123");
|
||||||
InvalidOperationException thrown = new("boom");
|
InvalidOperationException thrown = new("Something went wrong.");
|
||||||
RequestHandlerDelegate<TestResponse> next = () => throw thrown;
|
RequestHandlerDelegate<TestResponse> next = () => throw thrown;
|
||||||
|
|
||||||
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||||
@@ -37,7 +40,7 @@ public class UnhandledExceptionLoggingBehaviorTests
|
|||||||
public async Task Handle_WhenValidationExceptionThrown_DoesNotLogAndRethrows()
|
public async Task Handle_WhenValidationExceptionThrown_DoesNotLogAndRethrows()
|
||||||
{
|
{
|
||||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||||
TestRequest request = new("aaron", "hunter2");
|
TestRequest request = new("aaron", "pa$$word123");
|
||||||
ValidationException thrown = new([new ValidationFailure("Username", "is required")]);
|
ValidationException thrown = new([new ValidationFailure("Username", "is required")]);
|
||||||
RequestHandlerDelegate<TestResponse> next = () => throw thrown;
|
RequestHandlerDelegate<TestResponse> next = () => throw thrown;
|
||||||
|
|
||||||
@@ -51,7 +54,7 @@ public class UnhandledExceptionLoggingBehaviorTests
|
|||||||
public async Task Handle_WhenHandlerSucceeds_DoesNotLogAndReturnsResponse()
|
public async Task Handle_WhenHandlerSucceeds_DoesNotLogAndReturnsResponse()
|
||||||
{
|
{
|
||||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||||
TestRequest request = new("aaron", "hunter2");
|
TestRequest request = new("aaron", "pa$$word123");
|
||||||
TestResponse response = new("ok");
|
TestResponse response = new("ok");
|
||||||
RequestHandlerDelegate<TestResponse> next = () => Task.FromResult(response);
|
RequestHandlerDelegate<TestResponse> next = () => Task.FromResult(response);
|
||||||
|
|
||||||
@@ -65,15 +68,15 @@ public class UnhandledExceptionLoggingBehaviorTests
|
|||||||
public async Task Handle_WhenHandlerThrows_RedactsSensitiveFieldsInLoggedPayload()
|
public async Task Handle_WhenHandlerThrows_RedactsSensitiveFieldsInLoggedPayload()
|
||||||
{
|
{
|
||||||
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
|
||||||
TestRequest request = new("aaron", "hunter2");
|
TestRequest request = new("aaron", "pa$$word123");
|
||||||
RequestHandlerDelegate<TestResponse> next = () =>
|
RequestHandlerDelegate<TestResponse> next = () =>
|
||||||
throw new InvalidOperationException("boom");
|
throw new InvalidOperationException("Something went wrong.");
|
||||||
|
|
||||||
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
|
||||||
string loggedMessage = CaptureLoggedMessage();
|
string loggedMessage = CaptureLoggedMessage();
|
||||||
loggedMessage.Should().Contain("aaron").And.NotContain("hunter2");
|
loggedMessage.Should().Contain("aaron").And.NotContain("pa$$word123");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void VerifyLogError(Times times) =>
|
private void VerifyLogError(Times times) =>
|
||||||
@@ -91,21 +94,17 @@ public class UnhandledExceptionLoggingBehaviorTests
|
|||||||
|
|
||||||
private string CaptureLoggedMessage()
|
private string CaptureLoggedMessage()
|
||||||
{
|
{
|
||||||
var invocation = _loggerMock
|
IInvocation? invocation = _loggerMock
|
||||||
.Invocations.Where(i => i.Method.Name == nameof(ILogger.Log))
|
.Invocations.Where(i => i.Method.Name == nameof(ILogger.Log))
|
||||||
.Should()
|
.Should()
|
||||||
.ContainSingle()
|
.ContainSingle()
|
||||||
.Subject;
|
.Subject;
|
||||||
|
|
||||||
object state = invocation.Arguments[2];
|
object state = invocation.Arguments[2];
|
||||||
object? exception = invocation.Arguments[3];
|
object exception = invocation.Arguments[3];
|
||||||
object formatter = invocation.Arguments[4];
|
object formatter = invocation.Arguments[4];
|
||||||
System.Reflection.MethodInfo invokeMethod = formatter.GetType().GetMethod("Invoke")!;
|
System.Reflection.MethodInfo invokeMethod = formatter.GetType().GetMethod("Invoke")!;
|
||||||
|
|
||||||
return (string)invokeMethod.Invoke(formatter, [state, exception])!;
|
return (string)invokeMethod.Invoke(formatter, [state, exception])!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record TestRequest(string Username, string Password) : IRequest<TestResponse>;
|
|
||||||
|
|
||||||
public record TestResponse(string Value);
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using FluentValidation;
|
||||||
|
using FluentValidation.Results;
|
||||||
|
using MediatR;
|
||||||
|
using Moq;
|
||||||
|
using Shared.Application.Behaviors;
|
||||||
|
|
||||||
|
namespace Shared.Application.Tests.Behaviors;
|
||||||
|
|
||||||
|
public class ValidationBehaviorTests
|
||||||
|
{
|
||||||
|
private static ValidationBehavior<TestRequest, TestResponse> CreateBehavior(
|
||||||
|
params IValidator<TestRequest>[] validators
|
||||||
|
) => new(validators);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenRequestPassesAllValidators_InvokesNextUnchanged()
|
||||||
|
{
|
||||||
|
Mock<IValidator<TestRequest>> validatorMock = new();
|
||||||
|
validatorMock
|
||||||
|
.Setup(v =>
|
||||||
|
v.ValidateAsync(
|
||||||
|
It.IsAny<ValidationContext<TestRequest>>(),
|
||||||
|
It.IsAny<CancellationToken>()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(new ValidationResult());
|
||||||
|
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
|
||||||
|
validatorMock.Object
|
||||||
|
);
|
||||||
|
TestRequest request = new("aaron", "pa$$word123");
|
||||||
|
TestResponse response = new("ok");
|
||||||
|
bool nextInvoked = false;
|
||||||
|
RequestHandlerDelegate<TestResponse> next = () =>
|
||||||
|
{
|
||||||
|
nextInvoked = true;
|
||||||
|
return Task.FromResult(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
TestResponse result = await behavior.Handle(
|
||||||
|
request,
|
||||||
|
next,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
nextInvoked.Should().BeTrue();
|
||||||
|
result.Should().BeSameAs(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenValidatorReportsFailures_ThrowsValidationExceptionAndNeverInvokesNext()
|
||||||
|
{
|
||||||
|
ValidationFailure failure = new("Username", "is required");
|
||||||
|
Mock<IValidator<TestRequest>> validatorMock = new();
|
||||||
|
validatorMock
|
||||||
|
.Setup(v =>
|
||||||
|
v.ValidateAsync(
|
||||||
|
It.IsAny<ValidationContext<TestRequest>>(),
|
||||||
|
It.IsAny<CancellationToken>()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(new ValidationResult([failure]));
|
||||||
|
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
|
||||||
|
validatorMock.Object
|
||||||
|
);
|
||||||
|
TestRequest request = new("", "pa$$word123");
|
||||||
|
bool nextInvoked = false;
|
||||||
|
RequestHandlerDelegate<TestResponse> next = () =>
|
||||||
|
{
|
||||||
|
nextInvoked = true;
|
||||||
|
return Task.FromResult(new TestResponse("ok"));
|
||||||
|
};
|
||||||
|
|
||||||
|
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
|
.Which.Errors.Should()
|
||||||
|
.ContainSingle()
|
||||||
|
.Which.Should()
|
||||||
|
.BeSameAs(failure);
|
||||||
|
nextInvoked.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenMultipleValidatorsAreRegistered_RunsAllOfThem()
|
||||||
|
{
|
||||||
|
Mock<IValidator<TestRequest>> firstValidatorMock = new();
|
||||||
|
firstValidatorMock
|
||||||
|
.Setup(v =>
|
||||||
|
v.ValidateAsync(
|
||||||
|
It.IsAny<ValidationContext<TestRequest>>(),
|
||||||
|
It.IsAny<CancellationToken>()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(new ValidationResult());
|
||||||
|
Mock<IValidator<TestRequest>> secondValidatorMock = new();
|
||||||
|
secondValidatorMock
|
||||||
|
.Setup(v =>
|
||||||
|
v.ValidateAsync(
|
||||||
|
It.IsAny<ValidationContext<TestRequest>>(),
|
||||||
|
It.IsAny<CancellationToken>()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(new ValidationResult());
|
||||||
|
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
|
||||||
|
firstValidatorMock.Object,
|
||||||
|
secondValidatorMock.Object
|
||||||
|
);
|
||||||
|
TestRequest request = new("aaron", "pa$$word123");
|
||||||
|
RequestHandlerDelegate<TestResponse> next = () =>
|
||||||
|
Task.FromResult(new TestResponse("ok"));
|
||||||
|
|
||||||
|
await behavior.Handle(request, next, CancellationToken.None);
|
||||||
|
|
||||||
|
firstValidatorMock.Verify(
|
||||||
|
v =>
|
||||||
|
v.ValidateAsync(
|
||||||
|
It.IsAny<ValidationContext<TestRequest>>(),
|
||||||
|
It.IsAny<CancellationToken>()
|
||||||
|
),
|
||||||
|
Times.Once()
|
||||||
|
);
|
||||||
|
secondValidatorMock.Verify(
|
||||||
|
v =>
|
||||||
|
v.ValidateAsync(
|
||||||
|
It.IsAny<ValidationContext<TestRequest>>(),
|
||||||
|
It.IsAny<CancellationToken>()
|
||||||
|
),
|
||||||
|
Times.Once()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenMultipleValidatorsReportFailures_AggregatesErrorsFromAll()
|
||||||
|
{
|
||||||
|
ValidationFailure firstFailure = new("Username", "is required");
|
||||||
|
ValidationFailure secondFailure = new("Username", "must be at least 3 characters");
|
||||||
|
Mock<IValidator<TestRequest>> firstValidatorMock = new();
|
||||||
|
firstValidatorMock
|
||||||
|
.Setup(v =>
|
||||||
|
v.ValidateAsync(
|
||||||
|
It.IsAny<ValidationContext<TestRequest>>(),
|
||||||
|
It.IsAny<CancellationToken>()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(new ValidationResult([firstFailure]));
|
||||||
|
Mock<IValidator<TestRequest>> secondValidatorMock = new();
|
||||||
|
secondValidatorMock
|
||||||
|
.Setup(v =>
|
||||||
|
v.ValidateAsync(
|
||||||
|
It.IsAny<ValidationContext<TestRequest>>(),
|
||||||
|
It.IsAny<CancellationToken>()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(new ValidationResult([secondFailure]));
|
||||||
|
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
|
||||||
|
firstValidatorMock.Object,
|
||||||
|
secondValidatorMock.Object
|
||||||
|
);
|
||||||
|
TestRequest request = new("", "pa$$word123");
|
||||||
|
RequestHandlerDelegate<TestResponse> next = () =>
|
||||||
|
Task.FromResult(new TestResponse("ok"));
|
||||||
|
|
||||||
|
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
|
.Which.Errors.Should()
|
||||||
|
.BeEquivalentTo([firstFailure, secondFailure]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenNoValidatorsAreRegistered_InvokesNext()
|
||||||
|
{
|
||||||
|
ValidationBehavior<TestRequest, TestResponse> behavior =
|
||||||
|
CreateBehavior();
|
||||||
|
TestRequest request = new("aaron", "pa$$word123");
|
||||||
|
TestResponse response = new("ok");
|
||||||
|
RequestHandlerDelegate<TestResponse> next = () => Task.FromResult(response);
|
||||||
|
|
||||||
|
TestResponse result = await behavior.Handle(
|
||||||
|
request,
|
||||||
|
next,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
result.Should().BeSameAs(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,18 +6,11 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Shared.Application.Behaviors;
|
namespace Shared.Application.Behaviors;
|
||||||
|
|
||||||
/// <summary>
|
public abstract class JsonLogger
|
||||||
/// MediatR pipeline behavior that logs unhandled exceptions together with the request type and
|
|
||||||
/// payload, then rethrows them unchanged for <c>GlobalExceptionFilter</c> to map to a status code.
|
|
||||||
/// </summary>
|
|
||||||
public class UnhandledExceptionLoggingBehavior<TRequest, TResponse>(
|
|
||||||
ILogger<UnhandledExceptionLoggingBehavior<TRequest, TResponse>> logger
|
|
||||||
) : IPipelineBehavior<TRequest, TResponse>
|
|
||||||
where TRequest : IRequest<TResponse>
|
|
||||||
{
|
{
|
||||||
private static readonly string[] SensitiveFieldNames = ["password", "token"];
|
private static readonly string[] SensitiveFieldNames = ["password", "token"];
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
protected static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
{
|
{
|
||||||
TypeInfoResolver = new DefaultJsonTypeInfoResolver
|
TypeInfoResolver = new DefaultJsonTypeInfoResolver
|
||||||
{
|
{
|
||||||
@@ -25,46 +18,6 @@ public class UnhandledExceptionLoggingBehavior<TRequest, TResponse>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public async Task<TResponse> Handle(
|
|
||||||
TRequest request,
|
|
||||||
RequestHandlerDelegate<TResponse> next,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await next();
|
|
||||||
}
|
|
||||||
catch (ValidationException)
|
|
||||||
{
|
|
||||||
// Expected: ValidationBehavior already reports these as request errors, not incidents.
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
ex,
|
|
||||||
"Unhandled exception while processing {RequestName}: {RequestPayload}",
|
|
||||||
typeof(TRequest).Name,
|
|
||||||
Serialize(request)
|
|
||||||
);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Serialize(TRequest request)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return JsonSerializer.Serialize(request, SerializerOptions);
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
return "<unable to serialize request payload>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void RedactSensitiveProperties(JsonTypeInfo typeInfo)
|
private static void RedactSensitiveProperties(JsonTypeInfo typeInfo)
|
||||||
{
|
{
|
||||||
if (typeInfo.Kind != JsonTypeInfoKind.Object)
|
if (typeInfo.Kind != JsonTypeInfoKind.Object)
|
||||||
@@ -84,3 +37,71 @@ public class UnhandledExceptionLoggingBehavior<TRequest, TResponse>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MediatR pipeline behavior that logs unhandled exceptions together with the
|
||||||
|
/// request type and
|
||||||
|
/// payload, then rethrows them unchanged for <c>GlobalExceptionFilter</c> to
|
||||||
|
/// map to a status code.
|
||||||
|
/// </summary>
|
||||||
|
public partial class UnhandledExceptionLoggingBehavior<TRequest, TResponse>
|
||||||
|
: JsonLogger,
|
||||||
|
IPipelineBehavior<TRequest, TResponse>
|
||||||
|
where TRequest : IRequest<TResponse>
|
||||||
|
{
|
||||||
|
private readonly ILogger<UnhandledExceptionLoggingBehavior<TRequest, TResponse>> _logger;
|
||||||
|
|
||||||
|
public UnhandledExceptionLoggingBehavior(
|
||||||
|
ILogger<UnhandledExceptionLoggingBehavior<TRequest, TResponse>> logger
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<TResponse> Handle(
|
||||||
|
TRequest request,
|
||||||
|
RequestHandlerDelegate<TResponse> next,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
catch (ValidationException)
|
||||||
|
{
|
||||||
|
// Expected: ValidationBehavior already reports these as request errors
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (_logger.IsEnabled(LogLevel.Error))
|
||||||
|
LogUnhandledException(ex, typeof(TRequest).Name, Serialize(request));
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Serialize(TRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(request, SerializerOptions);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return "<unable to serialize request payload>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
Level = LogLevel.Error,
|
||||||
|
Message = "Unhandled exception while processing {RequestName}: {RequestPayload}"
|
||||||
|
)]
|
||||||
|
private partial void LogUnhandledException(
|
||||||
|
Exception ex,
|
||||||
|
string requestName,
|
||||||
|
string requestPayload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ using MediatR;
|
|||||||
namespace Shared.Application.Behaviors;
|
namespace Shared.Application.Behaviors;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MediatR pipeline behavior that runs registered FluentValidation validators before the handler,
|
/// MediatR pipeline behavior that runs registered FluentValidation validators
|
||||||
|
/// before the handler,
|
||||||
/// throwing <see cref="ValidationException" /> if any report failures.
|
/// throwing <see cref="ValidationException" /> if any report failures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
||||||
|
|||||||
Reference in New Issue
Block a user