Skip to content

Commit

Permalink
feat: refactor validation behavior to use ErrorOr
Browse files Browse the repository at this point in the history
  • Loading branch information
nadirbad committed Dec 16, 2024
1 parent bee7d50 commit c1f6a7f
Show file tree
Hide file tree
Showing 16 changed files with 246 additions and 122 deletions.
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.1" />
<!-- Open source packages -->
<PackageVersion Include="CsvHelper" Version="15.0.10" />
<PackageVersion Include="ErrorOr" Version="1.6.0" />
<PackageVersion Include="ErrorOr" Version="1.10.0" />
<PackageVersion Include="FluentValidation" Version="11.9.0" />
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageVersion Include="MediatR" Version="12.2.0" />
Expand Down
9 changes: 9 additions & 0 deletions requests/TodoItems/DeleteTodoItem.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@host = https://localhost:7098


### Delete Todo itemId = 10 with wrong itemId. Check for 404 Not Found
DELETE {{host}}/api/todo-items/10


### Delete Todo itemId = 1, should return 204 No Content
DELETE {{host}}/api/todo-items/1
14 changes: 14 additions & 0 deletions requests/TodoItems/UpdateTodoItemDetails.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@host = https://localhost:7098
@itemId = 4
@listId = 1

### Update Todo itemId = 1 with PriorityLevel = 1 and Note = "Updated Todo Item 1"
PUT {{host}}/api/todo-items/UpdateItemDetails?id={{itemId}}
Content-Type: application/json

{
"id": {{itemId}},
"listId": {{listId}},
"priorityLevel": 1,
"note": "Updated Todo Item 1"
}
18 changes: 17 additions & 1 deletion requests/TodoLists/CreateTodoList.http
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,21 @@ POST {{host}}/api/todo-lists
Content-Type: application/json

{
"title": "List 2"
"title": "List 1"
}

### Create Todo List item, validate that it handles empty title
POST {{host}}/api/todo-lists
Content-Type: application/json

{
"title": ""
}

### Create Todo List item, validate that it duplicate title
POST {{host}}/api/todo-lists
Content-Type: application/json

{
"title": "List 1"
}
9 changes: 9 additions & 0 deletions requests/TodoLists/DeleteTodoList.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@host = https://localhost:7098


### Delete Todo itemId = 10 with wrong itemId. Check for 404 Not Found
DELETE {{host}}/api/todo-lists/10


### Delete Todo itemId = 1, should return 204 No Content
DELETE {{host}}/api/todo-lists/1
42 changes: 42 additions & 0 deletions src/Application/Common/ApiControllerBase.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
using ErrorOr;

using MediatR;

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.DependencyInjection;

namespace VerticalSliceArchitecture.Application.Common;
Expand All @@ -12,4 +16,42 @@ public abstract class ApiControllerBase : ControllerBase
private ISender? _mediator;

protected ISender Mediator => _mediator ??= HttpContext.RequestServices.GetService<ISender>()!;

protected ActionResult Problem(List<Error> errors)
{
if (errors.Count is 0)
{
return Problem();
}

if (errors.All(error => error.Type == ErrorType.Validation))
{
return ValidationProblem(errors);
}

return Problem(errors[0]);
}

private ObjectResult Problem(Error error)
{
var statusCode = error.Type switch
{
ErrorType.Conflict => StatusCodes.Status409Conflict,
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Unauthorized => StatusCodes.Status403Forbidden,
_ => StatusCodes.Status500InternalServerError,
};

return Problem(statusCode: statusCode, title: error.Description);
}

private ActionResult ValidationProblem(List<Error> errors)
{
var modelStateDictionary = new ModelStateDictionary();

errors.ForEach(error => modelStateDictionary.AddModelError(error.Code, error.Description));

return ValidationProblem(modelStateDictionary);
}
}
32 changes: 0 additions & 32 deletions src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs

This file was deleted.

46 changes: 25 additions & 21 deletions src/Application/Common/Behaviours/ValidationBehaviour.cs
Original file line number Diff line number Diff line change
@@ -1,36 +1,40 @@
using FluentValidation;
using ErrorOr;

using MediatR;
using FluentValidation;

using ValidationException = VerticalSliceArchitecture.Application.Common.Exceptions.ValidationException;
using MediatR;

namespace VerticalSliceArchitecture.Application.Common.Behaviours;

public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
public class ValidationBehavior<TRequest, TResponse>(IValidator<TRequest>? validator = null)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
where TResponse : IErrorOr
{
private readonly IEnumerable<IValidator<TRequest>> _validators;

public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
private readonly IValidator<TRequest>? _validator = validator;

public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (_validators.Any())
if (_validator is null)
{
var context = new ValidationContext<TRequest>(request);
return await next();
}

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 validationResult = await _validator.ValidateAsync(request, cancellationToken);

if (failures.Count != 0)
{
throw new ValidationException(failures);
}
if (validationResult.IsValid)
{
return await next();
}

return await next();
var errors = validationResult.Errors
.ConvertAll(error => Error.Validation(
code: error.PropertyName,
description: error.ErrorMessage));

return (dynamic)errors;
}
}
7 changes: 3 additions & 4 deletions src/Application/ConfigureServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,17 @@ public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);

services.AddMediatR(options =>
{
options.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);

options.AddOpenBehavior(typeof(AuthorizationBehaviour<,>));
options.AddOpenBehavior(typeof(ValidationBehaviour<,>));
options.AddOpenBehavior(typeof(PerformanceBehaviour<,>));
options.AddOpenBehavior(typeof(UnhandledExceptionBehaviour<,>));
options.AddOpenBehavior(typeof(ValidationBehavior<,>));
});

services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly, includeInternalTypes: true);

return services;
}

Expand Down
18 changes: 12 additions & 6 deletions src/Application/Features/TodoItems/CreateTodoItem.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using FluentValidation;
using ErrorOr;

using FluentValidation;

using MediatR;

Expand All @@ -13,13 +15,17 @@ namespace VerticalSliceArchitecture.Application.Features.TodoItems;
public class CreateTodoItemController : ApiControllerBase
{
[HttpPost("/api/todo-items")]
public async Task<ActionResult<int>> Create(CreateTodoItemCommand command)
public async Task<IActionResult> Create(CreateTodoItemCommand command)
{
return await Mediator.Send(command);
var result = await Mediator.Send(command);

return result.Match(
id => Ok(id),
Problem);
}
}

public record CreateTodoItemCommand(int ListId, string? Title) : IRequest<int>;
public record CreateTodoItemCommand(int ListId, string? Title) : IRequest<ErrorOr<int>>;

internal sealed class CreateTodoItemCommandValidator : AbstractValidator<CreateTodoItemCommand>
{
Expand All @@ -31,11 +37,11 @@ public CreateTodoItemCommandValidator()
}
}

internal sealed class CreateTodoItemCommandHandler(ApplicationDbContext context) : IRequestHandler<CreateTodoItemCommand, int>
internal sealed class CreateTodoItemCommandHandler(ApplicationDbContext context) : IRequestHandler<CreateTodoItemCommand, ErrorOr<int>>
{
private readonly ApplicationDbContext _context = context;

public async Task<int> Handle(CreateTodoItemCommand request, CancellationToken cancellationToken)
public async Task<ErrorOr<int>> Handle(CreateTodoItemCommand request, CancellationToken cancellationToken)
{
var entity = new TodoItem
{
Expand Down
35 changes: 23 additions & 12 deletions src/Application/Features/TodoItems/DeleteTodoItem.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
using MediatR;
using ErrorOr;

using MediatR;

using Microsoft.AspNetCore.Mvc;

using VerticalSliceArchitecture.Application.Common;
using VerticalSliceArchitecture.Application.Common.Exceptions;
using VerticalSliceArchitecture.Application.Domain.Todos;
using VerticalSliceArchitecture.Application.Infrastructure.Persistence;

Expand All @@ -12,28 +13,38 @@ namespace VerticalSliceArchitecture.Application.Features.TodoItems;
public class DeleteTodoItemController : ApiControllerBase
{
[HttpDelete("/api/todo-items/{id}")]
public async Task<ActionResult> Delete(int id)
public async Task<IActionResult> Delete(int id)
{
await Mediator.Send(new DeleteTodoItemCommand(id));
var result = await Mediator.Send(new DeleteTodoItemCommand(id));

return NoContent();
return result.Match(
_ => NoContent(),
Problem);
}
}

public record DeleteTodoItemCommand(int Id) : IRequest;
public record DeleteTodoItemCommand(int Id) : IRequest<ErrorOr<Success>>;

internal sealed class DeleteTodoItemCommandHandler(ApplicationDbContext context) : IRequestHandler<DeleteTodoItemCommand>
internal sealed class DeleteTodoItemCommandHandler(ApplicationDbContext context) : IRequestHandler<DeleteTodoItemCommand, ErrorOr<Success>>
{
private readonly ApplicationDbContext _context = context;

public async Task Handle(DeleteTodoItemCommand request, CancellationToken cancellationToken)
public async Task<ErrorOr<Success>> Handle(DeleteTodoItemCommand request, CancellationToken cancellationToken)
{
var entity = await _context.TodoItems
.FindAsync(new object[] { request.Id }, cancellationToken) ?? throw new NotFoundException(nameof(TodoItem), request.Id);
_context.TodoItems.Remove(entity);
var todoItem = await _context.TodoItems
.FindAsync([request.Id], cancellationToken);

if (todoItem is null)
{
return Error.NotFound(description: "Todo item not found.");
}

entity.DomainEvents.Add(new TodoItemDeletedEvent(entity));
_context.TodoItems.Remove(todoItem);

todoItem.DomainEvents.Add(new TodoItemDeletedEvent(todoItem));

await _context.SaveChangesAsync(cancellationToken);

return Result.Success;
}
}
Loading

0 comments on commit c1f6a7f

Please sign in to comment.