.Net Core Api: obtener datos en una solicitud de diferentes fuentes

.Net Core tiene un mecanismo de enlace de modelo incorporado, que permite no solo aceptar parámetros de entrada en los controladores, sino también recibir inmediatamente objetos con campos rellenos. Esto le permite incrustar todas las comprobaciones necesarias en dicho objeto utilizando Validación de modelo.

Estos son solo los datos necesarios para que la API funcione, no solo de Query o Body. Es necesario recibir algunos datos de los encabezados (en mi caso había json en base64), algunos datos deberían ser de servicios externos o de ActionRoute si usa REST. Puede usar su enlace para obtener datos desde allí. Es cierto que hay un problema: si decides no romper la encapsulación e inicializar el modelo a través del constructor, tendrás que chamán.

Para mí y para las generaciones futuras, decidí escribir algo así como instrucciones para usar Binding y el chamanismo con él.

Problema


Un controlador típico se parece a esto:

[HttpGet]
public async Task<IActionResult> GetSomeData([FromQuery[IncomeData someData)
{
    var moreData = GetFromHeaderAndDecode("X-Property");
    if (moreData.Id == 0)
    {
        return StatusCode(400, "Nginx doesnt know your id");
    }
    var externalData = GetFromExternalService("http://myservice.com/MoreData");
    if (externalData == null)
    {
        return StatusCode(500, "Cant connect to external service");
    }
    var finalData = new FinalData(someData, moreData, externalData);
    return _myService.Handle(finalData);
}

Como resultado, tenemos los siguientes problemas:

  1. La lógica de validación está manchada por el objeto de solicitud, el método de solicitud del encabezado, el método de solicitud del servicio y el método del controlador. Para asegurarse de que la verificación necesaria esté definitivamente allí, ¡debe realizar una investigación completa!
  2. El método del controlador adyacente tendrá exactamente el mismo código. Programación de copiar y pegar en ataque.
  3. Por lo general, hay muchas más comprobaciones que en el ejemplo, y como resultado, la única línea significativa, la llamada al método de procesamiento de lógica de negocios, está oculta en un montón de código. Verlo y comprender lo que está sucediendo aquí en general requiere un poco de esfuerzo.

Encuadernación personalizada (Modo fácil)


Parte del problema se puede resolver implementando su controlador en la canalización de procesamiento de solicitudes. Para hacer esto, primero corrija nuestro controlador pasando el objeto final al método inmediatamente. Se ve mucho mejor, ¿verdad?

[HttpGet]
public async Task<IActionResult> GetSomeData([FromQuery]FinalData finalData)
{
    return _myService.Handle(finalData);
}

A continuación, cree su carpeta para el tipo MoreData.

public class MoreDataBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var moreData = GetFromHeaderAndDecode(bindingContext.HttpContext.Request.Headers);
        if (moreData != null)
        {
            bindingContext.Result = ModelBindingResult.Success(moreData);
        }
        return Task.CompletedTask;
    }
    private MoreData GetFromHeaderAndDecode(IHeaderDictionary headers) { ... }
}

Finalmente, arreglaremos el modelo FinalData agregando el enlace de carpeta a la propiedad allí:

public class FinalData
{
    public int SomeDataNumber { get; set; }

    public string SomeDataText { get; set; }

    [ModelBinder(BinderType = typeof(MoreDataBinder))]
    public MoreData MoreData { get; set; }
}

Ya es mejor, pero las hemorroides han aumentado: ahora necesita saber que tenemos un controlador especial e indicarlo en todos los modelos. Pero es solucionable.

Crea tu BinderProvider:

public class MoreDataBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        var modelType = context.Metadata.UnderlyingOrModelType;
        if (modelType == typeof(MoreData))
        {
            return new BinderTypeModelBinder(typeof(MoreDataBinder));
        }
        return null;
    }
}

Y regístralo en Inicio:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddMvc(options =>
    {
        options.ModelBinderProviders.Insert(0, new MoreDataBinderProvider());
     });
}

Se llama al proveedor para cada objeto modelo en el orden de la cola. Si nuestro proveedor cumple con el tipo deseado, devolverá la carpeta deseada. Y si no, entonces la carpeta predeterminada funcionará. Entonces, cada vez que especificamos el tipo de MoreData, se tomará y decodificará desde Encabezado y no es necesario especificar atributos especiales en los modelos.

Encuadernación personalizada (modo difícil)


Todo esto es genial, pero hay una cosa: para que la magia funcione, nuestro modelo debe tener propiedades públicas con set. ¿Pero qué hay de la encapsulación? ¿Qué sucede si deseo transferir los datos de la solicitud a varios lugares en la nube y saber que no se cambiarán allí?

El problema es que el archivador predeterminado no funciona para modelos que no tienen un constructor predeterminado. Pero, ¿qué nos impide escribir el nuestro?
El servicio para el que escribí este código no usa REST, los parámetros se transmiten solo a través de Consulta y Cuerpo, y solo se usan dos tipos de solicitudes: Obtener
y Publicar. En consecuencia, en el caso de la API REST, la lógica de procesamiento será ligeramente diferente.
En general, el código permanecerá sin cambios, solo nuestro aglutinante necesita refinamiento para que cree un objeto y llene sus campos privados. Además, daré fragmentos de código con comentarios, a quienes no les interese: al final del artículo debajo del gato está la lista completa de la clase.

Primero, determinemos si MoreData es la única propiedad de la clase. En caso afirmativo, debe crear el objeto usted mismo (hola, activador), y si no, entonces JsonConvert hará el trabajo perfectamente, y simplemente deslizamos los datos necesarios en la propiedad.

private static bool NeedActivator(IReflect modelType)
{
    var propFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
    var properties = modelType.GetProperties(propFlags);

    return properties.Select(p => p.Name).Distinct().Count() == 1;
}

Crear un objeto a través de JsonConvert es simple, para consultas con Body:

private static object? GetModelFromBody(ModelBindingContext bindingContext, Type modelType)
{
    using var reader = new StreamReader(bindingContext.HttpContext.Request.Body);
    var jsonString = reader.ReadToEnd();
    var data = JsonConvert.DeserializeObject(jsonString, modelType);
    return data;
}

Pero con Query tuve que acostarme. Me alegraría si alguien puede sugerir una solución más bella.

Al pasar una matriz, se obtienen varios parámetros con el mismo nombre. La conversión a un tipo plano ayuda, pero la serialización pone comillas adicionales en la matriz [], que debe eliminarse manualmente.

private static object? GetModelFromQuery(ModelBindingContext bindingContext, Type modelType)
{
    var valuesDictionary = QueryHelpers.ParseQuery(bindingContext.HttpContext.Request.QueryString.Value);
    var jsonDictionary = valuesDictionary.ToDictionary(pair => pair.Key, pair => pair.Value.Count < 2 ? pair.Value.ToString() : $"[{pair.Value}]");

    var jsonStr = JsonConvert.SerializeObject(jsonDictionary).Replace("\"[", "[").Replace("]\"", "]");
    var data = JsonConvert.DeserializeObject(jsonStr, modelType);
    return data;
}

Finalmente, al crear el objeto, es necesario escribir nuestros datos en su propiedad privada. Sobre este chamanismo hablé al principio del artículo. Encontré esta solución aquí , por lo que muchas gracias al autor.

private void ForceSetValue(PropertyInfo propertyInfo, object obj, object value)
{
    var propName = $"<{propertyInfo.Name}>k__BackingField";
    var propFlags = BindingFlags.Instance | BindingFlags.NonPublic;
            
    obj.GetType().GetField(propName, propFlags)?.SetValue(obj, value);
}

Bueno, combinamos estos métodos en una sola llamada:

public Task BindModelAsync(ModelBindingContext bindingContext)
{
    var moreData = GetFromHeaderAndDecode(bindingContext.HttpContext.Request.Headers);
    if (moreData == null)
    {
        return Task.CompletedTask;
    }

    var modelType = bindingContext.ModelMetadata.UnderlyingOrModelType;
    if (NeedActivator(modelType))
    {
        var data = Activator.CreateInstance(modelType, moreData);
        bindingContext.Result = ModelBindingResult.Success(data);

        return Task.CompletedTask;
    }

    var model = bindingContext.HttpContext.Request.Method == "GET"
                            ? GetModelFromQuery(bindingContext, modelType)
                            : GetModelFromBody(bindingContext, modelType);

    if (model is null)
    {
        throw new Exception("  ");
    }

    var ignoreCase = StringComparison.InvariantCultureIgnoreCase;
    var dataProperty = modelType.GetProperties()
                            .FirstOrDefault(p => p.Name.Equals(typeof(T).Name, ignoreCase));

    if (dataProperty != null)
    {
        ForceSetValue(dataProperty, model, moreData);
    }

    bindingContext.Result = ModelBindingResult.Success(model);
    return Task.CompletedTask;
}

Queda por arreglar BinderProvider para que responda a cualquier clase con la propiedad deseada:

public class MoreDataBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
       var modelType = context.Metadata.UnderlyingOrModelType;
       if (HasDataProperty(modelType))
       {
           return new BinderTypeModelBinder(typeof(PrivateDataBinder<MoreData>));
       }
       return null;
    }
    private bool HasDataProperty(IReflect modelType)
    {
        var propFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
        var properties = modelType.GetProperties(propFlags);

        return properties.Select(p => p.Name) .Contains(nameof(MoreData));
    }
}


Eso es todo. Binder resultó ser un poco más complicado que en el Modo fácil, pero ahora podemos vincular propiedades "externas" en todos los métodos de todos los controladores sin esfuerzos adicionales. De las desventajas:

  1. El constructor de objetos con campos privados debe especificar el atributo [JsonConstrustor]. Pero esto encaja completamente en la lógica del modelo y no interfiere con su percepción.
  2. En algún lugar, es posible que necesite obtener MoreData no desde el encabezado. Pero esto se trata creando una clase separada.
  3. El resto del equipo debe ser consciente de la presencia de magia. Pero la documentación salvará a la humanidad.

Una lista completa del Binder resultante está aquí:

PrivateMoreDataBinder.cs
public class PrivateDataBinder<T> : IModelBinder
    {
        /// <summary></summary>
        /// <param name="bindingContext"></param>
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            var moreData = GetFromHeaderAndDecode(bindingContext.HttpContext.Request.Headers);
            if (moreData == null)
            {
                return Task.CompletedTask;
            }

            var modelType = bindingContext.ModelMetadata.UnderlyingOrModelType;
            if (NeedActivator(modelType))
            {
                var data = Activator.CreateInstance(modelType, moreData);
                bindingContext.Result = ModelBindingResult.Success(data);

                return Task.CompletedTask;
            }

            var model = bindingContext.HttpContext.Request.Method == "GET"
                            ? GetModelFromQuery(bindingContext, modelType)
                            : GetModelFromBody(bindingContext, modelType);

            if (model is null)
            {
                throw new Exception("  ");
            }

            var ignoreCase = StringComparison.InvariantCultureIgnoreCase;
            var dataProperty = modelType.GetProperties()
                                        .FirstOrDefault(p => p.Name.Equals(typeof(T).Name, ignoreCase));

            if (dataProperty != null)
            {
                ForceSetValue(dataProperty, model, moreData);
            }

            bindingContext.Result = ModelBindingResult.Success(model);

            return Task.CompletedTask;
        }

        private static object? GetModelFromQuery(ModelBindingContext bindingContext,
                                                 Type modelType)
        {
            var valuesDictionary = QueryHelpers.ParseQuery(bindingContext.HttpContext.Request.QueryString.Value);

            var jsonDictionary = valuesDictionary.ToDictionary(pair => pair.Key, pair => pair.Value.Count < 2 ? pair.Value.ToString() : $"[{pair.Value}]");

            var jsonStr = JsonConvert.SerializeObject(jsonDictionary)
                                     .Replace("\"[", "[")
                                     .Replace("]\"", "]");

            var data = JsonConvert.DeserializeObject(jsonStr, modelType);

            return data;
        }

        private static object? GetModelFromBody(ModelBindingContext bindingContext,
                                                Type modelType)
        {
            using var reader = new StreamReader(bindingContext.HttpContext.Request.Body);
            var jsonString = reader.ReadToEnd();
            var data = JsonConvert.DeserializeObject(jsonString, modelType);

            return data;
        }

        private static bool NeedActivator(IReflect modelType)
        {
            var propFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
            var properties = modelType.GetProperties(propFlags);

            return properties.Select(p => p.Name).Distinct().Count() == 1;
        }

        private void ForceSetValue(PropertyInfo propertyInfo, object obj, object value)
        {
            var propName = $"<{propertyInfo.Name}>k__BackingField";
            var propFlags = BindingFlags.Instance | BindingFlags.NonPublic;
            
            obj.GetType().GetField(propName, propFlags)?.SetValue(obj, value);
        }

        private T GetFromHeaderAndDecode(IHeaderDictionary headers) { return (T)Activator.CreateInstance(typeof(T), new object[] { "ok" }); }
    }


All Articles