Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 10 de julio de 2018
ASP.NET Core MVCLos que lleváis tiempo programando APIs HTTP, ya sea con ASP.NET Core o con otras tecnologías, seguro que en muchas ocasiones habéis tenido que decidir cómo retornar al cliente, de forma más o menos normalizada, los errores producidos en el lado servidor.

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 campo type.
     
  • 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.
Aparte de estos campos, el estándar permite el uso de extensiones, es decir, de campos personalizados que aporten aún más información al cliente. El siguiente bloque muestra, por ejemplo, el resultado de una petición con valores incorrectos en sus parámetros:
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:
public class ProblemDetails
{
    public string Type { get; set; }
    public string Title { get; set; }
    public int? Status { get; set; }
    public string Detail { get; set; }
    public string 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)
{
    const int 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
        };
        return new 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.1 403 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);
        return new 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."
        };
        return new 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.

8 Comentarios:

avechuche dijo...

Como es que no encontré este sitio antes, muy buena forma de expicar y en español! Gran trabajo! Saludos!

José María Aguilar dijo...

Muchas gracias!!

Daniel Aguilar dijo...

Un placer señor José.

Tengo exactamente este problema ya descrito por usted. El detalle es que estoy utilizando [Authorization] para todas las acciones a través de tokens. Mediante éste filtro establezco medidas de Políticas con Claims, Roles y Handlers que validan si el usuario tiene los permisos suficientes para realizar esta acción. Se me hace muy útil esta manera de hacerlo pero está el problema presentado de que NO tengo manera de especificarle al cliente el ¿Porque? del 403 o 401 (primordialmente el 403), ya sea por que no cumple un requisito u otro, por ende me toca 'adivinar'. Conoce algún mientras tanto para esto? Muchas gracias, Feliz Día

José María Aguilar dijo...

Hola, Daniel!

Supongo que podrías utilizar los campos de esta estructura "problem details" para ampliar algo de información, aunque probablemente se quede corto si lo que pretendes es describir más de un origen del problema en la misma respuesta.

Por ejemplo, si hay un único motivo por el que el usuario no pueda usar la acción, probablemente la URL en "type" podría serte útil para indicar de forma inequívoca el problema. Pero si hay más de un motivo y quieres indicarlos todos, al final necesitarás añadir un array (al estilo del "invalid-params" mostrado en el post) para incluir mayor detalle en la respuesta.

Saludos!

Saludos!

Juan Luis dijo...

Hola, ¿Existe algun standard de respuesta equivalente al problem details pero cuando no hay errores que tenga una estructura parecida?

José María Aguilar dijo...

Hola!

No conozco ninguno, pero tampoco sé si tendría mucho sentido... los distintos tipos de retorno HTTP 2xx y su combinación con otros encabezados (como location o content-type) suele ser suficiente para indicar el éxito de la petición y añadir información de contexto, quedando el cuerpo de la respuesta queda libre para introducir los datos que queramos enviar al cliente.

Pero vaya, si tienes requisitos al respecto, siempre podrías definir tu propia convención y aplicarla en todas tus respuestas 2xx :)

Saludos!

Antonio dijo...

¡Hola José!

¿Qué tal? muy interesante todo el tema de ProblemDetails. Lo tengo implementado para errores de validación (http 40x). ¿Qué opinas de este sistema para tratar errores 500?

Un saludo!

José María Aguilar dijo...

Hola Antonio! Me alegra verte por aquí :)

La verdad es que este estándar suele verse aplicado a errores tipo 4xx. Incluso en la propia RFC, los ejemplos sólo se refieren a estos errores, y tiene sentido, pues son errores que de alguna forma el cliente puede solucionar corrigiendo datos, o autenticando previamente, o cosas así, algo que no ocurre con los 5xx (server error).

Pero en cualquier caso, si tus clientes van a ser capaces de entenderlos y procesarlos (por ejemplo, mostrar un error, o no reintentar peticiones porque el servidor está saturado), no veo ningún mal en aprovechar para los 5xx la infraestructura que ya tendrás montada para los 4xx.

Saludos!