ASP .NET REST - Walidacja danych wejściowych

Development
  • Marcin Golonka
  • 07-06-2022

Jedną z podstawowych zasad dobrego API REST jest walidacja danych wejściowych i jasna komunikacja serwisowi konsumującemu API błędów walidacyjnych. W ASP do naszej dyspozycji otrzymaliśmy dedykowane atrybuty oraz klasę ProblemDetails zgodną ze standardem RFC 7807.

Standard RFC 7807

Prawie zawsze w API zwracamy błędy, które możemy sygnalizować poprzez kody HTTP, lecz często taka informacja jest tylko podstawowym wyznacznikiem błędu, nie pozwalając określić dokładnej przyczyny. Dlatego w marcu 2016 został określony standard pozwalający zunifikować zwracanie błędów przez interfejsy API.

Definicja zgodna z RFC 7807 przykładowo powinna wyglądać tak:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "00-52f8eaa452b5b1ab506c5abb29e7e6e7-5aa2a02d9a445576-00",
  "errors": {
    "Title": [
      "The Title field is required.",
      "The field Title must be a string with a minimum length of 5 and a maximum length of 15."
    ]
  }
}

Gdzie najbardziej interesującą sekcją jest dla nas tablica Errors. Właściwość Title zgodnie ze specyfikacją powinna być krótką wiadomością, która jest czytelna dla człowieka. Standard również przewiduje możliwość lokalizacji tytułu błędu.

Oczywiście, samo zwrócenie odopowiedniego JSON’a nie załatwia nam sprawy, nadal musimy pamiętać by zwracać odpowiedni kod odpowiedzi HTTP, w powyższym przykładzie 400 - BadRequest, reprezentujemy to również poprzez właściwość Status

Walidacja Atrybutami w ASP

Deweloperzy z Redmond począwszy od .NET Core 2.1 domyślnie dla projektu API zwracają klasę implementującą powyższy standard w przypadku korzystania z Atrybutów walidacyjnych. Dlatego dla większości przypadków, wystarczy że użyjemy dedykowanych atrybutów walidacyjnych w klasie modelowej, którą przyjmujemy w Body metody API.

Przykładowo

Klasa modelowa

public class TodoItem  
{  
 [Required]  
 [StringLength(15, MinimumLength = 5)]  
  public string Title { get; set; }  
 [Required] 
 public DateTime ValidTo { get; set; }  
 [MinLength(5)]
 public string SubTitle { get; set; }  
 [EmailAddress]
 public string NotifyEmail { get; set; }  
}

Kontroler

//...
public class TodoController : ControllerBase  
{  
  //... 
  [HttpPost]  
  public void Post([FromBody] TodoItem value)  
  { }  
 //...
}

Po wywołaniu powyższej metody z nieprawidłowymi danymi wejściowymi, otrzymamy błąd:

Request

{
 "title": "Test",
 "validTo": "2022-06-07T18:45:49.742Z",
 "subTitle": "string",
 "notifyEmail": "userexample"
}

Response

{
 "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
 "title": "One or more validation errors occurred.",
 "status": 400,
 "traceId": "00-a70409b3919da21ece51e7c524db7684-80db0ffd6d864f8e-00",
 "errors": {
   "NotifyEmail": [
     "The NotifyEmail field is not a valid e-mail address."
   ]
 }
}

Atrybuty wbudowane

Przykładowe atrybuty dostępne w przestrzeni nazw System.ComponentModel.DataAnnotations:

  • [Required] - Pole wymagane
  • [EmailAddress] - Pole typu Email
  • [StringLength] - Ograniczenie długości znaków
  • [RegularExpression] - Wyrażenie regularne

Własny atrybut walidacyjny

Nie zawsze wbudowane atrybuty pokrywają nasze przypadki, wtedy na pomoc przychodzi możliwość implementacji własnych atrybutów. Dla atrybutów walidacyjnych musimy zaimplementować klasę abstrakcyjną ValidationAttribute

Przykład

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)]  
public class ContainsA: ValidationAttribute  
{  
	  internal static string ErrorMessageMockup =>  "String don't contain A letter";  
	  
	  public ContainsA() : base(() => ErrorMessageMockup)   { }
	  
	  public override bool IsValid(object? value)  
	  {  
		 if (!(value is string) || value == null)  
			  return false;  
	  
		  return ((string)value).Contains("A");  
	  }
 }

Powyższy atrybut oczywiście dla stringów sprawdzi czy zawierają one choć jeden znak “A”.

Walidacja w kontrolerze

Nie zawsze jesteśmy w stanie zwalidować dane wejściowe Atrybutami. Chociażby chcąc spytać serwis zewnętrzny o poprawność danych, czy bazę danych. Nic nie stoi na przeszkodzie, by zwrócić instancję obiektu ProblemDetails bezpośrednio w kontrolerze. Pamiętać należy wtedy że odpowiedzialność za zwrócenie odpowiedniego kodu błędu, oraz uzupełnienie dokumentacji OPEN API spoczywa na nas.

Przykład

// POST: api/Todo  
[HttpPost]  
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ValidationProblemDetails))]  
public ActionResult Post([FromBody] TodoItem value)  
{  
	  if (!value.Title.Contains("A"))  {
	     ModelState.AddModelError("Title", "String don't contain A letter");  
	     return ValidationProblem();  
	  }  
	  return Ok();  
}

Podsumowanie

Jak widać powyżej w ASP zwracanie błędów zostało uproszczone do minimum. Programista w większości przypadków jedynie musi pamiętać o dodaniu stosownych atrybutów do klas wejściowych. Często jeżeli dziedziczymy klasy modelowe z modelami bazodanowymi z Entity Framework, to większość atrybutów mamy już dodane na etapie projektowania bazy danych.

Sprawa zaczyna się komplikować gdy oczekujemy specyficznej walidacji, lecz i dla niech zostały przygotowane stosowne klasy abstrakcyjne do implementacji własnej logiki