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, 8 de febrero de 2022
ASP.NET Core MVC

Dada una petición como la siguiente:

POST https://localhost:5001/friends HTTP/1.1
Host: localhost:5001
Content-Type: application/json

{ "name": "Jon", "age": 24 }

Lo habitual es que queramos recibir los datos ya materializados en forma de objeto de .NET. Esto podemos conseguirlo fácilmente desde una acción como la siguiente:

[Route("friends")]
public class FriendsController : Controller
{
    [HttpPost]
    public ActionResult Test([FromBody]Friend friend)
    {
        return Content($"Hello, {friend.Name}, you are {friend.Age} years old");
    }
}

Pero aunque no es algo que ocurra con frecuencia, a veces podríamos necesitar recibir el cuerpo JSON de una petición como string, es decir, en crudo, sin deserializarlo ni procesarlo de ninguna manera para, ya más adelante, hacerlo nosotros manualmente. Y aunque a primera vista pudiera resultar trivial, tiene un poco más de truco de lo que parece...

Bueno, en realidad hablo de JSON porque son los casos que he encontrado en el mundo real, pero podría ser cualquier tipo de contenido, como datos de formulario, XML u otros.

Nuestra primera tentación podría ser intentar obtenerlo directamente como cadena de texto, así:

public ActionResult Test([FromBody]string friend) // No funciona!
{
    return Content(friend);
}

Sin embargo, no funcionará. Dado que el content-type de la petición anuncia que el contenido es un objeto codificado como JSON, el formateador de entrada esperará encontrar en el cuerpo la cadena solicitada en el parámetro de la acción... pero formateada como JSON. Es decir espera encontrar algo como "Hola" (incluyendo las comillas). O en otras palabras, el código anterior sólo funcionará bien si en el cuerpo de la petición suministramos un texto codificado como JSON:

POST https://localhost:5001/friends HTTP/1.1
Host: localhost:5001
Content-Type: application/json

"Hola chavales!"

Pero no es esto lo que queremos, así que vamos a ver un par de formas de solucionarlo:

  • Leyendo directamente desde el cuerpo de la petición
  • Creando un filtro y usando convenciones
  • Usando un binder personalizado

1. Leer directamente del cuerpo de la petición

Esto es una solución algo bestia, es la más simple que existe y funcionará. La cuestión consiste en no especificar ningún parámetro para la acción, y leer manualmente el contenido del stream de entrada.

[HttpPost]
public async Task<ActionResult> Test()
{
    using var reader = new StreamReader(Request.Body, Encoding.UTF8);
    var str = await reader.ReadToEndAsync();
    return Content(str);
}

Fijaos que hemos convertido la acción en asíncrona para que podamos utilizar ReadToEndAsync() para obtener el cuerpo de la petición. También, si váis a utilizar este enfoque, debéis tener en cuenta que el stream de entrada sólo puede ser leído una vez, por lo que deberéis habilitar el buffering antes de la primera lectura con Request.EnableBuffering(), y luego reposicionar el puntero de lectura con Request.Body.Seek(0, SeekOrigin.Begin) si queremos consultarlo de nuevo.

Como inconveniente, quizás que si fuéramos a implementar tests serían un poco complejos, por la necesidad de falsificar el contexto HTTP, la petición y el cuerpo presente en ésta. Pero vaya, nada que no pueda resolverse en unos minutos.

2. Crear un filtro

También podríamos definir una convención simple e implementarla en un filtro. Por ejemplo, podríamos acordar que siempre que el primer parámetro de una acción sea de tipo string y se llame bodyAsString, cargaremos en él el cuerpo de la petición. El filtro, cuyo código vemos a continuación, podría ser aplicado a acciones específicas, controladores completos o de forma global:

public class GetBodyAsStringAttribute : ActionFilterAttribute
{
    public override async Task OnActionExecutionAsync(
            ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var firstParameter = context.ActionDescriptor.Parameters.FirstOrDefault();
        if (firstParameter != null 
            && firstParameter.Name == "bodyAsString" 
            && firstParameter.ParameterType == typeof(string))
        {
            using var reader = new StreamReader(
                                        context.HttpContext.Request.Body, 
                                        Encoding.UTF8);
            context.ActionArguments["bodyAsString"] = await reader.ReadToEndAsync();
        }
        await next();
    }
}

Por ejemplo, podríamos usar este filtro como se observa a continuación:

[Route("friends")]
public class FriendsController : Controller
{
    [HttpPost]
    [GetBodyAsString]
    public ActionResult Test(string bodyAsString)
    {
        return Content(bodyAsString);
    }
}

3. Usando un binder personalizado

Vamos a darle una vuelta de tuerca a esto ;) Lo que vamos a conseguir ahora es introducir un nuevo atributo llamado FromStringBody que, aplicado a un parámetro parámetros de tipo string, lo poblará con el contenido textual del cuerpo de la petición:

[Route("friends")]
public class FriendsController : Controller
{
    [HttpPost]
    public ActionResult Test([FromStringBody]string bodyAsString)
    {
        return Content(bodyAsString);
    }
}

Existen varias formas para implementar eso, pero vamos a usar la que creo que es más sencilla. Dado que la obtención del cuerpo como cadena es una tarea encomendable conceptualmente a un binder, basta con heredar del atributo ModelBinder, forzando el uso de un binder personalizado (al que llamamos BodyAsStringBinder) en el que implementamos la lógica de obtención.

El código sería el siguiente:

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
public class FromStringBodyAttribute: ModelBinderAttribute
{
    public FromStringBodyAttribute()
    {
        this.BinderType = typeof(BodyAsStringBinder);
    }

    class BodyAsStringBinder: IModelBinder
    {
        public async Task BindModelAsync(ModelBindingContext bindingContext)
        {
            if (bindingContext.ModelType == typeof(string))
            {
                using var reader = new StreamReader(
                                            bindingContext.HttpContext.Request.Body, 
                                            Encoding.UTF8);
                var body = await reader.ReadToEndAsync();
                bindingContext.Result = ModelBindingResult.Success(body);
            }
        }
    }
}

Bueno, pues creo que ya está bien por hoy. Como veis, la flexibilidad del framework hace posible usar distintos acercamientos para la resolución del problema que nos planteábamos al principio del post.

Personalmente, me quedo con la última de las opciones ;)

Publicado en Variable not found.

4 Comentarios:

Anónimo dijo...

Que opinás del uso de `JsonElement` para eso? Si el contenido es un JSON ofrece mucha flexibilidad, y no estoy seguro pero creo que también performance.

José María Aguilar dijo...

Hola!

En el escenario que planteamos en el post muy probablemente sería más lento que obtenerlo directamente como string, pues no necesitamos procesarlo de ninguna manera.

Si necesitases "echar un ojo" al contenido o navegar por él sin deserializarlo por completo, entonces los tipos básicos del serializador/deserializador como JsonDocument y otros, sí que podrían ser de utilidad.

Saludos!


Alberto Baigorria dijo...

Agradecerte por compartir este tipo de contenidos José Maria, son de mucha ayuda. Gracias.

Consultarte, en caso de querer validar si el string es un JSON y validar ciertas propiedades como requeridas que soluciones serian viables?

Saludos desde Santa Cruz, Bolivia

José María Aguilar dijo...

Hola, Alberto!

Ante todo, muchas gracias por tus comentarios :)

Para verificar que el string sea un JSON, la forma más sencilla creo que sería intentar deserializarlo con un JsonDocument.Parse(str). De esa forma, si no fuera JSON se lanzaría una excepción y, en caso contrario, podrías comprobar el valor de las propiedades requeridas.

Saludos!