Implementar ProblemDetails para APIs HTTP

Como muchos sabéis soy principalmente desarrollador en backend, y como tal, creo muchas API, participó tanto en el diseño como en la implementación. Algo por lo que siempre he peleado es por la unificación de las respuestas de una api, sobre lo cual cree un post hace unos años. Ha pasado un tiempo desde aquel post y he notado que las empresas con las que trabajo están empezando a implementar el estándar, así que aquí vamos a hablar sobre él. 

Como siempre tienes el código disponible en el enlace a GitHub.

 

 

1 - Estandard Problem Details para APIs HTTP

La idea del estándar RFC-9457 publicado en julio de 2023 es normalizar de una y por todas cómo creamos web API, en concreto cuando tenemos un error. 

Cuando todo va bien, no hay ningún problema, devolvemos un objeto que contiene la información, esto ha estado funcionado así durante años.

 

El problema viene cuando tenemos un error, ¿qué pasa si la validación es errónea? O qué pasa cuando tenemos un error inesperado, cómo devolvemos esa información?

Aquí es donde Problem Details entra en acción, y este estándar es necesario porque cada empresa hacía lo que le daba la gana, y eso es un follón.

 

Unas empresas devuelven únicamente el HTTP Status code, otras devuelven el Status Code con un string de texto, que se puede leer por un humano pero NO por una máquina, bueno si, pero hay que hacer una expresión regular. Otros, como solía ser mi caso, tenían un objeto personalizado que indicaba para su empresa las respuestas de la api. 

 

Así que ha llegado el momento de normalizar y estandarizar todo. 

 

Nota, anteriormente teníamos el estándar RFC-7807 pero personalmente no trabaje con (o en) ninguna empresa que lo implementara.

 

 

2 - Qué es Problem Details?

Al final del día, el estándar, o Problem Details no es más que un json estandarizado que contiene los campos Type, title, status, details, instancia y luego los miembros extensibles.

 

Los miembros en sí son todos opcionales, pero obviamente cuantos más incluyamos mejor, si realizamos APIs públicas o semipúblicas lo normal es rellenarlos todos. Cuando es comunicación B2B, en mi experiencia, lo más normal es tener el tipo con el título y quizá la descripción, además la API responderá con el tipo “application/problem.json”.

 

 

2.1 - Miembros de Problem Details

Para este suceso vamos a imaginarnos que tenemos una api la que nos permite crear items en diferentes cuentas, royo una tienda y eres el supervisor de varias.

 

A - Tipo

El miembro tipo es un string que idealmente contiene una URL la cual redirige a la explicación del error en la documentación. Por ejemplo:

https://api.miweb.com/referencia/WRONG_SHOPID

En la comunicación B2B es muy común devolver únicamente el código en sí, sin la url ya que no siempre la documentación es pública. 

 

B - Title

Este campo es únicamente para un humano, para que cuando lo lea, sea capaz de entender el problema. Por ejemplo:

“Tienda no encontrada”

 

C - Detail 

El campo detail es como ampliación al campo anterior y en lo que se centra es en dar un error más concreto. Por ejemplo

“Has intentado crear un producto para la tienda 1 pero solo tienes acceso a la tienda 2 y 3”

 

D - Instance

Representa un identificador único para la resolución del problema, muchas veces es un UUID otras veces es un ID numérico, se utiliza para hacer un seguimiento del error. En muchas empresas es el correlationId.

 

El valor devuelto suele ser simplemente el ID o una URL completa que indica donde hacer dicho seguimiento, por ejemplo:

https://api.miweb.com/errorcase/11111

 

E - Status

Representa el código de estado de HTTP, normalmente será un código de error, pero también he visto empresas que devuelven 200’s incluso en errores… En mi opinión eso es erróneo, pero es un tema para otro post. 

 

F - Extensions

Básicamente un objeto que contiene detalles adicionales sobre el error. Dentro es tratado como un Diccionario clave-valor-https://www.netmentor.es/entrada/diccionarios-csharp-, lo que quiere decir que las keys serán únicas, pero puedes poner tantos como quieras. 

 

Si vemos el ejemplo de la página web del estándar:

{
 "type": "https://example.com/probs/out-of-credit",
 "title": "You do not have enough credit.",
 "detail": "Your current balance is 30, but that costs 50.",
 "instance": "/account/12345/msgs/abc",
 "balance": 30,
 "accounts": ["/account/12345",
              "/account/67890"]
}

vemos que tienen las propiedades balance y accounts; esto significa que no estamos limitados al estándar, sino que podemos extenderlos tanto como queramos. 

 

 

3 - Problem Details en .NET y C#

Ahora toca mencionar la implementación que tenemos en .NET, ya que por defecto ASP.NET nos viene con dicho sistema, si tu tienes un endpoint el cual espera que le mandes un entero pero le mandas un string, el error que nos devuelve ASP.NET es un ProblemDetails.

 

Por ejemplo, en nuestro código, en el endpoint education:

[HttpGet("{userId}")]
public Task<List<EducationDto>> Get(int userId)
{
 ...   
}

 

Si lo que hacemos es enviar un string, nos va a dar el siguiente error:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "userId": [
      "The value 'test' is not valid."
    ]
  },
  "traceId": "00-3a4cb46525de8b9f4da6c3fe9be36e3f-9d5836f4fd45f23c-00"
}

 

Como vemos, ASP.NET nos está devolviendo un ProblemDetails que en este caso contiene el tipo, el título y el status. Además contiene dos propiedades custom de ASP.NET las cuales son errors, para indicar que hay errores y traceId el cual, en mi opinión debería ser “isntance”.

 

 

3.1 - Implementar ProblemDetails en C#

Lo que vamos a hacer ahora es una tarea muy sencilla, vamos a implementar el funcionamiento de dicho objeto. Ahora mismo en nuestro caso de uso de crear un perfil, no tenemos ninguna validación, por ejemplo, no estamos validando si  el email o el teléfono son válidos, esta llamada funciona perfectamente: 

POST {{endpoint}}/api/PerfilPersonal
Content-Type: application/json

{
  "UserId": null,
  "Id": null,
  "UserName": "test-1",
  "FirstName": "firstName",
  "LastName": "last name",
  "Description": "Description",
  "Phone": "telefono",
  "Email": "email",
  "Website": "web",
  "GitHub": "github",
  "Interests": [],
  "Skills": []
}

Pero obviamente, esto no es lo que queremos, necesitamos que nos salte un error, algo que podemos hacer de forma sencilla por ahora es añadir esa lógica en el controlador.

  • NOTA: Esta lógica debería estar dentro de la lógica de negocio, pero esto es una app para aprender.

 

Así que creamos directamente el objeto ProblemDetails dentro del método post:

[HttpPost]
public async Task<IActionResult> Post(PersonalProfileDto profileDto)
{
    👇
    ProblemDetails problemDetails = new ProblemDetails() 
    {
        Title = "Error en la validación de los datos",
        Detail = "Los parámetros no son correctos",
        Status = 400,
        Type = "https://website.net/code-error-1",
    };
    
    
    return await _postPersonalProfile.Create(profileDto)
        .Bind(x => GetProfile(x.UserName))
        .ToActionResult();
}

Por ahora no está haciendo nada, y lo que vamos a incluir es:

Si el email no es un email válido incluya un error, y lo mismo para el teléfono, que si no es todo numérico nos de un error;

Después, va a devolver el objeto ProblemDetails y como vemos  hemos incluido un parámetro extensible llamado “Errors” el cual contiene ambos errores:

[HttpPost]
public async Task<IActionResult> Post(PersonalProfileDto profileDto)
{
    ProblemDetails problemDetails = new ProblemDetails()
    {
        Title = "Error en la validación de los datos",
        Detail = "Los parámetros no son correctos",
        Status = 400,
        Type = "https://website.net/code-error-1",
    };

    👇
    List<(string, string)> errors = new(); 
    //Validar email
    if (!IsValidEmail(profileDto.Email))
    {
        errors.Add(("Email", "El email no es válido")); 
    }
    
    //validar numero de telefono es un numero
    if (!long.TryParse(profileDto.Phone, out _))
    {
        errors.Add(("Phone", "El número de teléfono no es válido"));
    }

    if (errors.Any())
    {
        problemDetails.Extensions.Add("Errors", errors.ToDictionary()); 
        return new ObjectResult(problemDetails); 
    }
    👆

    return await _postPersonalProfile.Create(profileDto)
        .Bind(x => GetProfile(x.UserName))
        .ToActionResult();
}    

 

Ahora, si ejecutamos el código, vemos que falla, tal y como esperamos. 

{
  "type": "https://website.net/code-error-1",
  "title": "Error en la validación de los datos",
  "status": 400,
  "detail": "Los parámetros no son correctos",
  "Errors": {
    "Email": "El email no es válido",
    "Phone": "El número de teléfono no es válido"
  }
}

 

Antes de terminar, vuelvo a repetir que esta lógica debería estar en la lógica de negocio y no en el controlador, en este caso no lo está porque uso la librería de ROP que cree en su momento y por ahora no soporta el convertir a ProblemDetails, pero la idea es crear una extensión para que sea posible y se haga automáticamente. Puedes aportar el cambio tu mismo aquí.

 

Uso del bloqueador de anuncios adblock

Hola!

Primero de todo bienvenido a la web de NetMentor donde podrás aprender programación en C# y .NET desde un nivel de principiante hasta más avanzado.


Yo entiendo que utilices un bloqueador de anuncios como AdBlock, Ublock o el propio navegador Brave. Pero te tengo que pedir por favor que desactives el bloqueador para esta web.


Intento personalmente no poner mucha publicidad, la justa para pagar el servidor y por supuesto que no sea intrusiva; Si pese a ello piensas que es intrusiva siempre me puedes escribir por privado o por Twitter a @NetMentorTW.


Si ya lo has desactivado, por favor recarga la página.


Un saludo y muchas gracias por tu colaboración

© copyright 2024 NetMentor | Todos los derechos reservados | RSS Feed

Buy me a coffee Invitame a un café