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.

ValidationProblemDetails HTTP 400 response

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;
		};
	});
ErrorDetails HTTP 400 Response

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 🙂