MVC6–Recibir un GUID en el cuerpo de la petición

Hoy me he encontrado un controlador MVC6 con la siguiente acción:

[HttpPut]
[Route("{userid:int}/faceprofile")]
public async Task<IActionResult> SetFaceProfileId(int userid, [FromBody] Guid id)

Claramente su autor esperaba que pudieramos poner un Guid en el cuerpo de la petición y eso funcionaría… Pero, ¿como debe mandarse?

Primeras pruebas – Usando binding “a lo WebApi”

La primera prueba es mandar un Guid directo en el cuerpo: curl -X PUT –header “Content-Type: application/json” -d “9B606B9F-9835-4E97-A5BC-C445831591AB” http://localhost:15858/api/Profiles/1/faceprofile

Tiene cierta lógica que no funcione ya que realmente lo que estamos enviando  no es un JSON, así que raro sería que eso hubiese funcionado.

¿Y si mandamos un JSON Correcto? curl -X PUT –header ‘Content-Type: application/json’ -d ‘{“id”: “9B606B9F-9835-4E97-A5BC-C445831591AB”}’ ‘http://localhost:15858/api/Profiles/1/faceprofile’

Pues tampoco funciona, porque el input formatter no sabe enlazar un Guid a partir de la cadena indicada.

La tercera alternativa plausible, mandar el GUID directamente en el cuerpo pero con content-type plain/text no funciona tampoco porque no tenemos media formatter para text/plain por lo que recibimos un 415.

Alternativa – Usando binding “a lo MVC”

Con el binding a lo WebApi no vamos a poder enlazar un Guid. Los media formatters que vienen por defecto no entienden de Guids, así que poco haremos. Una alternativa es irnos al binding “a lo MVC” a ver si el model binder si que entiende de Guids.

Para ello eliminamos el [FromBody] del parámetro y veamos que ocurre.

Usar el content-type text/plain con el GUID en el cuerpo que parecía una alternativa plausible no funciona tampoco. El problema está en que MVC6 no sabe que esa única cadena sin más info, se corresponde a parámetro de tipo Guid.

¿Y si simulamos el POST de un formulario HTML? Para ello debemos mandar el content-type application/x-www-form-urlencoded y en el cuerpo mandar el nombre del parámetro (id) un igual y el valor:

curl -X PUT –header “Content-Type: application/x-www-form-urlencoded” -d “id=9B606B9F-9835-4E97-A5BC-C445831591AB” “http://localhost:15858/api/Profiles/1/faceprofile”

Ahora si que funciona correctamente. También funciona si usamos query string por supuesto:

curl -X PUT –header “Content-Type: application/x-www-form-urlencoded”  –header “Content-Length: 0” “http://localhost:15858/api/Profiles/1/faceprofile?id=9B606B9F-9835-4E97-A5BC-C445831591AB”

Ojo que, dado que es un PUT, debemos especificar content-length a 0 si no mandamos nada en el cuerpo de la petición.

¿Y todo eso por qué?

El model binding de MVC6 es una mezcla del model binding de MVC5 y WebAPI2. Eso implica que es fácil confundirse si vienes de esos dos frameworks, ya que el uso de algunos atributos funciona distinto y un ejemplo es [FromBody]:

  • En WebApi2 [FromBody] se usa para indicar al framework que un tipo que habitualmente se resuelve via query string o route value (es decir en la URL) debe resolverse con los datos del cuerpo de la petición. En WebApi2 por defecto los tipos simples (value types) se resuelven desde la URL y los tipos complejos (clases) desde el cuerpo de la petición. A diferencia de MVC5 solo un parámetro puede ser resuelto desde el cuerpo. En el caso de tener un parámetro que es una clase este parámetro será el resuelto a través del cuerpo de la petición, aun sin necesidad de usar [FromBody]
  • En MVC6 [FromBody] se usa para habilitar el binding “a lo WebApi”. Por defecto se usa un binding “a lo MVC” donde los datos de la petición son recogidos por los value providers y los parámetros enlazados vía los model binders. Por lo tanto. Hay value providers que leen el cuerpo de la petición (aunque no en todos los content types) y es por esto que un mismo parámetro se puede enlazar vía el cuerpo de la petición o via URL (como en nuestro ejemplo). Pero cuando aparece un solo parámetro con [FromBody] (solo puede aparecer uno) se usa un binding basado en media formatters. Solo un media formatter puede leer el cuerpo (cual, depende del content type) y enlazar un solo parámetro. Así [FromBody] en MVC6 no significa “enlaza esto que habitualmente harías vía URL a través del cuerpo] si no “para este parámetro usa el media formatter correspondiente”. Si hubiera más parámetros esos serían enlazados vía los value providers y el model binder (es decir “a lo MVC”).

Por defecto MVC6 trae value providers que leen el cuerpo para el content-type application/x-www-form-urlencoded” y media formatters para los content-type de JSON y XML. Recuerda que MVC5 traía value providers para content-types de JSON, en MVC6 ya no estan, porque su rol lo juega el media formatter. Así, la idea es:

  1. Si vas a recibir POSTS de formularios HTML no debes decorar nada con [FromBody]. No uses [FromBody] y MVC6 se comportará como lo hace MVC5.
  2. Si vas a recibir datos en JSON o XML debes decorar el parámetro con [FromBody]. Si no lo haces no recibirás los datos (nota que en WebApi2 si que los recibirías). Solo debes decorar un parámetro via [FromBody] (el que se serializa en el cuerpo). Puedes recibir datos adicionales vía URL.

Probablemente el desarrollador de esta acción del controlador venía de WebApi2 y de ahí este uso de [FromBody] sobre un Guid (un tipo simple, que por defecto enlazaríamos a través de la URL).

Vale, pero quiero enlazar mi Guid y no quiero usar application/x-www-formurlencoded

Bueno, pues entonces te toca hacer un media formatter que entienda los Guids. Veamos como. El primer paso es crear un IInputFormatter nuevo. En MVC6 los media formatters se han dividido en dos (los input formatters que entienden datos de entrada y los output formatters que serializan los datos de salida). En este caso queremos entender datos en un Content-Type nuevo, no devolver datos en un Content-Type adicional. De ahí que necesitamos un IInputFormatter adicional.

Lo vamos a asignar a un content-type nuevo: el de text/plain. De esta manera aceptaremos peticiones con text/plain y directamente un valor en el cuerpo. El input formatter será capaz de enlazar parámetros de tipo Guid y, ya puestos, string:

public class TextPlainInputFormatter : IInputFormatter
{
    private const string TextContentType = "text/plain";

    public bool CanRead(InputFormatterContext context)
    {
        var typeValid = context.ModelType == typeof(string)
            || context.ModelType == typeof(Guid);

        var contentTypeValid =
            context.HttpContext.Request.ContentType == TextContentType;

        return typeValid && contentTypeValid;
    }

    public async Task<InputFormatterResult> ReadAsync(InputFormatterContext context)
    {
        var request = context.HttpContext.Request;
        var destIsGuid = context.ModelType == typeof(Guid);
        if (request.ContentLength == 0)
        {
            if (destIsGuid)
            {
                return InputFormatterResult.Success(Guid.Empty);
            }
            else
            {
                return InputFormatterResult.Success(null);
            }
        }

        using (var reader = new StreamReader(request.Body))
        {

            var str = await reader.ReadToEndAsync();
            if (destIsGuid)
            {
                return ParseGuidResult(str);
            }
            else
            {
                return InputFormatterResult.Success(str);
            }
        }


    }

    private InputFormatterResult ParseGuidResult(string str)
    {
        Guid guid;
        var ok = Guid.TryParse(str, out guid);
        if (ok)
        {
            return InputFormatterResult.Success(guid);
        }
        else
        {
            return InputFormatterResult.Failure();
        }
    }
}

El código no es para nada complicado. Simplemente implementamos los dos métodos de la interfaz:

  • CanRead: Que debe devolver true si este media formatter es el que va a procesar el argumento (recuerda, el único argumento [FromBody]). En este caso solo lo procesamos si el tipo es Guid o string y el content-type es text/json.
  • ReadAsync: Método asíncrono que lee el cuerpo de la petición y devuelve un objeto del tipo correcto (Guid o String). Es importante resaltar que, al igual que WebApi 2, no hay model binder de por medio: el formatter es el encargado de devolver el objeto creado para asignar al parámetro correspondiente.

Tan solo nos falta añadir el formatter a MVC6. Para ello, desde la clase Startup:

services.AddMvc()
    .AddMvcOptions(options =>
    {
        options.InputFormatters.Insert(0, new TextPlainInputFormatter());
    });

Usamos el método AddMvcOptions para añadir nuestro formatter a la colección de formatters.

¡Y listos! Como ves… ¡tampoco es tan difícil!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *