
Lo habitual es echar mano de los status code de HTTP para indicar problemas en el proceso de una petición; de hecho, este protocolo dispone de un rico conjunto de códigos que en principio parecen cubrir todas nuestras necesidades.
Pero no siempre es así. Por ejemplo, si tenemos un servicio que permite a los clientes de una empresa formalizar un pedido a través de un API y una llamada a este servicio retorna un error HTTP 403 (forbidden), claramente estamos indicando que el solicitante no tiene permisos para hacer un pedido. Sin embargo, no tenemos una forma clara de indicar cuál es la causa de esta prohibición (¿quizás las credenciales no son correctas? ¿o quizás el cliente no tiene crédito en la empresa? ¿o puede ser que el administrador lo haya denegado expresamente?)
Para aportar más detalles sobre el problema, normalmente necesitaremos retornar en el cuerpo de la respuesta información extra usando estructuras o formatos personalizados, probablemente distintos de una aplicación a otra, y documentarlos apropiadamente para que los clientes puedan entenderlos. Y aquí es donde entra en juego el estándar “Problem details”.
El estándar Problem details (RFC7807)
Consciente de la necesidad de normalizar este tipo de escenarios, la Internet Engineering Task Force (IEFT) propuso hace unos años el estándar Problem Details for HTTP APIs (RFC7807), promoviendo el uso de una estructura JSON normalizada en la que es posible ampliar información sobre un error producido durante el proceso de la petición a la API.Estas respuestas, empaquetadas con un content type“application/problem+json” (aunque también podrían ser “application/problem+xml”), tienen una pinta como la siguiente:
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en
{
"status": 403,
"type": "https://www.b2bcommerce.com/orders/problems/disabled-customer",
"title": "You can't submit orders",
"detail": "You must finish your registration process before submitting orders",
"instance": "/orders/12/123456",
}
En la estructura anterior se observan varias propiedades que forman parte del estándar:status
, si existe, debe contener el código HTTP original del error. Puede ser interesante cuando intermediarios como proxies u otros middlewares pueden alterar el código original.
type
es una URI absoluta o relativa, por defecto “about:blank”, que permite identificar cuál es el problema exacto y su posible solución. Según la especificación, el contenido de esa URI debería ser una descripción del problema legible por personas (por ejemplo, escrita usando HTML).
title
puede contener un texto breve que describa el problema, sobre todo destinados a aquellos consumidores que no sean capaces de interpretar correctamente el significado del campotype
.
- En
detail
podemos especificar información adicional sobre el problema, pero más enfocada a su resolución. Como en el caso anterior, debe ser legible por humanos, y no estructuras de datos que deban ser procesadas por el consumidor.
- Por último,
instance
es una URL, absoluta o relativa, que puede apuntar a la instancia protagonista del problema.
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://mysite.com/validation",
"title": "Invalid request parameters",
"invalid-params":
[
{
"name": "displayName",
"reason": "This field is required"
},
{
"name": "age",
"reason": "Must be a integer between 0 and 120"
},
{
"name": "email",
"reason": "Must be a valid email address"
}
]
}
Es muy recomendable leer la especificación completa en https://tools.ietf.org/html/rfc7807
Problem details en ASP.NET Core 2.1
ASP.NET Core 2.1 incluye un soporte aún muy básico para facilitar la adopción de este estándar en nuestros servicios HTTP, pero al menos nos facilita la tarea en algunos escenarios.En primer lugar, en el espacio de nombres
Microsoft.AspNetCore.Mvc
encontramos la clase ProblemDetails
, que modela la estructura de datos definida por la especificación:publicclass ProblemDetails
{
publicstring Type { get; set; }
publicstring Title { get; set; }
publicint? Status { get; set; }
publicstring Detail { get; set; }
publicstring Instance { get; set; }
}
Esto ya nos da la posibilidad de utilizarla directamente para retornar al cliente problem details, por ejemplo como sigue:[HttpPost]
public ActionResult Submit(Order order)
{
constint maxProducts = 10;
if (order.ProductsCount > maxProducts)
{
var details = new ProblemDetails()
{
Type = "https://www.b2bcommerce.com/orders/too-many-products",
Title = "The order has too many products",
Detail = $"You can't submit orders with more than {maxProducts} products",
Instance = Url.Action("Get", "Orders", new { id = order.Id }),
Status = 403
};
returnnew ObjectResult(details)
{
ContentTypes = {"application/problem+json"},
StatusCode = 403,
};
}
...
}
Una secuencia de petición y respuesta para comprobar el funcionamiento del código anterior podría ser:========================================================================
POST https://localhost:44399/api/orders HTTP/1.1
Host: localhost:44399
Content-Length: 37
Content-type: application/json
{
id: 19,
productsCount: 22
}
========================================================================
HTTP/1.1403 Forbidden
Content-Type: application/problem+json; charset=utf-8
Server: Kestrel
X-Powered-By: ASP.NET
Content-Length: 208
{
"type":"https://www.b2bcommerce.com/orders/too-many-products",
"title":"The order has too many products",
"status":403,
"detail":"You can't submit orders with more than 10 products",
"instance":"/api/Orders/19"
}
Aunque crear el ObjectResult
de forma manual es algo farragoso, sería bastante sencillo crear un helper o action result que nos ayudara a construir y retornar este tipo de objetos.
Tenemos también la clase ValidationProblemDetails
, que, heredando de ProblemDetails
, está diseñada expresamente para retornar errores de validación estructurados. Esta clase añade la propiedad Errors
que es un diccionario en el que las claves son los nombres de las propiedades validadas con error, y el valor contiene un array de strings describiendo los problemas encontrados.De hecho, esta clase está tan enfocada a los errores de validación que incluso podemos instanciarla enviándole un
ModelState
:[HttpPost]
public ActionResult Submit([FromBody]Order order)
{
if (!ModelState.IsValid)
{
var details = new ValidationProblemDetails(ModelState);
returnnew ObjectResult(details)
{
ContentTypes = {"application/problem+json"},
StatusCode = 400,
};
}
return Ok(order);
}
Para simplificar aún más este escenario, también se ha añadido en ControllerBase
el método ValidationProblem()
, que automáticamente retornará un error 400 con la descripción de los errores:[HttpPost]
public ActionResult Submit([FromBody]Order order)
{
if (!ModelState.IsValid)
{
return ValidationProblem();
}
...
}
Misteriosamente, el retornar el resultado de la invocación a ValidationProblem()
no establecerá el content type a "application/problem+json"
, cuando esto tendría bastante sentido. Supongo que será para evitar que exploten clientes que no estén preparados para recibir ese tipo de contenido.En cualquier caso, el resultado devuelto por el servicio anterior podría ser el siguiente:
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Server: Kestrel
X-Powered-By: ASP.NET
Content-Length: 188
{
"errors": {
"ProductsCount": ["The field ProductsCount must be between 1 and 10."]
},
"type":null,
"title":"One or more validation errors occurred.",
"status":null,
"detail":null,
"instance":null
}
¿Y cómo se integra esto con el filtro [ApiController]?
Hace unos días hablábamos del nuevo filtro[ApiController]
, que permite identificar un controlador como endpoint de un API, y aplicarle automáticamente usa serie de convenciones, como el retorno automático de errores 400 cuando se detectan errores de validación.También veíamos que, jugando un poco con su configuración, era posible establecer la factoría de resultados para las respuestas a peticiones con problemas de validación, por lo que resulta sencillo utilizar este punto de extensión para crear y configurar la respuesta bad request alineada con el estándar:
services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var problemDetails = new ValidationProblemDetails(context.ModelState)
{
Instance = context.HttpContext.Request.Path,
Status = 400,
Type = "https://myserver.com/problems/validation",
Detail = "Invalid input data. See additional details in 'errors' property."
};
returnnew BadRequestObjectResult(problemDetails)
{
ContentTypes = { "application/problem+json", "application/problem+xml" }
};
};
});
¡Y eso es todo, al menos de momento! Espero que os haya resultado interesante, y útil para conocer este nuevo estándar y su, aunque aún básica, integración en ASP.NET Core.Publicado en Variable not found.