Podbijając wersje .NET Core w projekcie, doświadczymy sytuacji zmiany logiki związanej z walidacją modelu oraz typem domyślnym dla odpowiedzi HTTP 400. Automatyczne sprawdzenie stanu modelu jest świetnym rozwiązaniem. Jednak co w przypadku scenariusza, gdy chcemy podmienić domyślny obiekt odpowiedzi HTTP 400 na własną implementację. I o tym jak to zrobić będzie ten wpis. Zapraszam do lektury.
Domyślna automatyczna odpowiedź HTTP 400
W ASP.NET Core, gdy stosujemy atrybut ApiController na kontrolerze następuje automatyczne sprawdzenie poprawności przesłanego obiektu. W przypadku nieprawidłowej walidacji obiektu zostanie automatycznie zwrócony kod błędu HTTP 400. Nie nastąpi tu wejście do ciała akcji.
Na początek przekonajmy się jak to działa w praktyce. Stworzyłem VikingsController z akcją POST. Walidacja dla modelu Viking została skonfigurowana za pomocą FluentValidation. Osoby zainteresowane podstawami biblioteki do walidacji, odsyłam do mojego wcześniejszego artykułu „Fluent Validation z ASP.NET Core Web API„.
using API.Interfaces; using API.Models; using Microsoft.AspNetCore.Mvc; namespace API.Controllers { [Route("api/[controller]")] [ApiController] public class VikingsController : ControllerBase { private readonly IVikingsService _vikingsService; public VikingsController(IVikingsService vikingsService) => _vikingsService = vikingsService; [HttpPost] public IActionResult Post(Viking viking) { _vikingsService.Add(viking); return Created("", null); } } }
namespace API.Models { public class Viking { public string Name { get; set; } public string Email { get; set; } public int Age { get; set; } } }
using API.Models; using FluentValidation; namespace API.Validators { public class VikingValidator : AbstractValidator<Viking> { public VikingValidator() { RuleFor(v => v.Name).NotNull().NotEmpty(); RuleFor(v => v.Email).NotNull().NotEmpty().EmailAddress(); RuleFor(v => v.Age).InclusiveBetween(15, 50); } } }
Wykorzystując narzędzie Postman wysyłam żądanie POST z obiektem nie spełniającym reguły walidacji.
W odpowiedzi uzyskałem kod 400 Bad Request. Dla .NET Core 2.2 i nowszego domyślnym typem odpowiedzi jest obiekt ValidationProblemDetails. Odpowiedź zawiera pola jak na powyższym zrzucie z Postman. We wcześniejszych wersjach .NET Core (2.1 i starszych) domyślnym typem odpowiedzi jest obiekt SerializableError.
Własny obiekt odpowiedzi
Załóżmy, że nie podoba nam się format obiektu odpowiedzi ValidationProblemDetails, albo dodatkowo chcemy zalogować informacje o nieprawidłowej walidacji. Zauważcie, że w ciele akcji POST nie następuje sprawdzenie ModelState. Gdy stosujemy atrybut APIController, ASP.NET Core robi to z automatu za nas. Jak zatem obsłużyć automatyczną odpowiedź na błąd wynikający z przesłania nieprawidłowego obiektu.
Pierwszą opcją jest cofnięcie się do starego rozwiązania i wyłącznie automatycznej obsługi błędu 400. Możemy to zrobić poprzez usunięcie atrybutu APIController z kontrolera, albo dokonać tego poprzez ustawienie właściwości SuppressModelStateInvalidFilter na wartość true w metodzie ConfigureServices w klasie Startup.
services .AddControllers() .ConfigureApiBehaviorOptions(options => { options.SuppressModelStateInvalidFilter = true; });
Sprawdźmy poprawność powyższej konfiguracji. Dorzuciłem sprawdzenie ModelState w ciele akcji POST i wysłałem takie jak wcześniej żądanie POST z narzędzia Postman. Jak widać na poniższym zrzucie, nie dostaliśmy automatycznej odpowiedzi, tylko program zatrzymał się na breakpoint postawionym w miejscu obsługi błędu.
Wadą powyższego rozwiązania jest powielenie kodu sprawdzającego stan walidacji modelu w pozostałych akcjach kontrolerów. Zastanówmy się jak to zrobić globalnie.
Drugą opcją to pójście dalej z duchem zmian wprowadzonych w kolejny wersjach .NET Core. Czyli zostawiamy mechanizm automatycznej odpowiedzi. W celu podmiany domyślnego typu odpowiedzi na obiekt ErrorDetails wykorzystujemy InvalidModelStateResponseFactory. Zdefiniujmy na początek nasz model ErrorDetails.
using System.Collections.Generic; using System.Text.Json; namespace API.Models { public class ErrorDetails { public int StatusCode { get; set; } public List<string> ErrorMessages { get; set; } public ErrorDetails(int statusCode, List<string> errorMessages) { StatusCode = statusCode; ErrorMessages = errorMessages; } public override string ToString() => JsonSerializer.Serialize(this); } }
W celu wyciągnice kolekcji komunikatów błędów walidacji dodałem extension method (GetErrorMessages) dla klasy ModelStateDictionary.
using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace API.Extensions { public static class ModelStateDictionaryExtension { public static List<string> GetErrorMessages(this ModelStateDictionary modelState) => modelState.Keys.SelectMany(key => modelState[key].Errors.Select(x => $"{key} : [{x.ErrorMessage}]")).ToList(); } }
Została jeszcze konfiguracja zwracania obiektu ErrorDetails zamiast domyślnego ValidationProblemDetails. Zmieniamy aktualną konfigurację na poniższą w metodzie ConfigureServices w klasie Startup. InvalidModelStateResponseFactory to delegat wywołany na akcje z ApiControllerAttribute, konwertuje nieprawidłowy ModelStateDictionary na IActionResult.
services.AddControllers() .AddFluentValidation(fv => { fv.RegisterValidatorsFromAssemblyContaining<Startup>(); fv.LocalizationEnabled = false; }) .ConfigureApiBehaviorOptions(options => { options.InvalidModelStateResponseFactory = context => { var errorMessages = context.ModelState.GetErrorMessages(); var errorDetails = new ErrorDetails((int) HttpStatusCode.BadRequest, errorMessages); var result = new BadRequestObjectResult(errorDetails) {ContentTypes = {MediaTypeNames.Application.Json, MediaTypeNames.Application.Xml}}; return result; }; });
Wykonałem jeszcze raz to samo żądanie, ale tym razem już nie dostałem domyślnego obiektu, tylko nasz ErrorDetails. Misja powiodła się, udało zmienić się domyślny obiekt odpowiedzi na własną implementację. Powodzenia i zapraszam do lektury starszych wpisów, oraz do oczekiwania na kolejne 🙂