Las ventajas que te ofrece Microsoft Azure y el mundo.NET

Cómo utilizar tus modelos con OData en Asp.Net Core

Continuamos con la serie de posts relacionados con Asp.Net Core y OData. El gran Adrián Díaz hace poco escribió el siguiente artículo sobre Cómo beneficiar nuestro perfomance con Entity Framework.

Uno de los problemas que veo cuando empleamos OData es que exponemos en el controlador las entidades de nuestra base de datos…¿qué os parecería si os dijera que podemos definir nuestros modelos y poder filtrar con los mismos en nuestras consultas OData? Suena bien, ¿verdad? Vamos al lío 🙂

Diferencia entre modelo y entidad

Lo primero que vamos a ver es la diferencia que veo entre un modelo o DTO y un entidad de base de datos.

  • Entidad: La entidad representa un concepto del mundo real, la cual posee atributos y se relaciona con otras entidades. Suele tener persistencia en base de datos.
  • Modelo: El modelo es una proyección de una entidad pero que se ajusta a un problema o un espacio de nuestro dominio concreto, por lo que el número de atributos con respecto a la entidad puede variar y puede contener métodos que conforman un comportamiento.

En nuestro ejemplo vamos a definir una entidad en nuestra base de datos,  dicha entidad la llamaremos Tarea.

 
namespace AspNetCoreODataWithModel.Shared.Models.Database
{
    public abstract class BaseEntity
    {
        public int Id { get; set; }
    }
}


using AspNetCoreODataWithModel.Shared.Models.Database;
using System;

namespace AspNetCoreODataWithModel.Data.Entities
{
    public class Tarea : BaseEntity
    {
        public string Nombre { get; set; }
        public string Observaciones { get; set; }
        public DateTime? Fecha{ get; set; }
        public bool Facturable { get; set; }
    }
}

Definiremos nuestro modelo que será el que expongamos en nuestra Api y lo llamaramos TaskModel.

 
using System;

namespace AspNetCoreODataWithModel.Model
{
    public class TaskModel
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Observations { get; set; }
        public DateTime? Date { get; set; }
        public bool Billable { get; set; }
    }
}

Bien, ya tenemos nuestra entidad y nuestro modelo definidos. Como véis, el modelo posee atributos o propiedades con nombres en inglés y la entidad en español. Así reflejaremos mejor el comportamiento del filtrado OData usando propiedades en inglés 🙂

Implementando la magia

Os explicaré el truco… al final lo que hay que hacer es generar un nuevo filtro OData a partir del filtro OData de entrada con nuestro modelo, haciendo un mapeo de las propiedades.

Para ello pondré el código y lo analizaremos juntos, ¿ok?

 
 
[HttpGet("")]
[ProducesResponseType(typeof(IEnumerable<TaskModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(BadRequestObjectResult), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(NotFoundObjectResult), StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[EnableQuery(AllowedQueryOptions = AllowedQueryOptions.All)] 
public IActionResult Get(ODataQueryOptions<TaskModel> filter)
{
	var tasks = repository.ListAll();
	if (!string.IsNullOrWhiteSpace(filter.Filter?.RawValue))
	{
		IEdmModel model = EdmModelHelper.GetEdmModel();
		IEdmType type = model.FindDeclaredType("AspNetCoreODataWithModel.Data.Entities.Tarea");
		IEdmNavigationSource source = model.FindDeclaredEntitySet("Tareas");
		ODataQueryOptionParser parser = new ODataQueryOptionParser(model, type, source, new Dictionary<string, string> { { "$filter", filter.Filter?.RawValue } });
		ODataQueryContext context = new ODataQueryContext(model, typeof(Tarea), filter.Context.Path);
		FilterQueryOption newfilter = new FilterQueryOption(filter.Filter?.RawValue, context, parser);

		tasks = newfilter.ApplyTo(tasks, new ODataQuerySettings()) as IQueryable<Tarea>;
	}

	var results = tasks.Select(p => mapper.Map<TaskMode>(p));
	var page = new PageResult<TaskModel>(mapper.Map<IEnumerable&<TaskModel>>(results.ToList()),
		Request.HttpContext.ODataFeature().NextLink,
		Request.HttpContext.ODataFeature().TotalCount);

	return Ok(page);
}

...........................

public static IEdmModel GetEdmModel()
{
	ODataModelBuilder builder = new ODataConventionModelBuilder().EnableLowerCamelCase(NameResolverOptions.ProcessReflectedPropertyNames | NameResolverOptions.ProcessExplicitPropertyNames);
	return GetEdmModel(builder);
}

private static IEdmModel GetEdmModel(ODataModelBuilder builder)
{
	EntitySetConfiguration<Tarea> tasks = builder.EntitySet<Tarea>("Tareas");
	builder.ContainerName = "DefaultContainer";
	tasks.EntityType.Name = "Tarea";
	tasks.EntityType.Namespace = "AspNetCoreODataWithModel.Data.Entities";

	tasks.EntityType.Property(p => p.Id).Name = "Id";
	tasks.EntityType.Property(p => p.Nombre).Name = "Name";
	tasks.EntityType.Property(p => p.Fecha).Name = "Date";
	tasks.EntityType.Property(p => p.Facturable).Name = "Billable";
	tasks.EntityType.Property(p => p.Observaciones).Name = "Observations";

	tasks.EntityType
					.Filter()
					.Count()
					.Expand()
					.OrderBy()
					.Page()
					.Select();

	return builder.GetEdmModel();
}

En el código del controlador lo primero que observamos es que llamamos al repositorio al método ListAll() que lo único que hace es devolver un IQueryable.

Luego con el método EdmModelHelper.GetEdmModel(); lo que hacemos es establecer el modelo Edm que usa OData para la entidad Tarea, realizando un «mapeo» (estableciendo en su propiedad Name el nombre de la propiedad de nuestro modelo). Esto es muy importante para poder crear el nuevo filtro OData.

El siguiente bloque lo que hace es que, si se recibe un filtro OData desde el modelo de entrada, generamos un nuevo filtro con la entidad y aplicamos sobre el IQueryable inicial los filtros correctos.

Mola mucho, ¿eh?

OData y Swagger

Como regalito de cumpleaños (hoy 28 de julio es el mío jejejeje) os voy a proporcionar una clase para definir los parámetros OData y lo podáis usar con Swashbuckle y Swagger.

 
using Microsoft.AspNet.OData;
using Microsoft.AspNet.OData.Query;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.Linq;

namespace AspNetCoreODataWithModel.Infrastructure.Swagger
{
    public class ODataQueryOptionsFilter : IOperationFilter
    {
        public void Apply(OpenApiOperation operation, OperationFilterContext context)
        {
            var queryAttribute = context.MethodInfo.GetCustomAttributes(true)
                .Union(context.MethodInfo.DeclaringType.GetCustomAttributes(true))
                .OfType<EnableQueryAttribute>().FirstOrDefault();

            if (queryAttribute != null)
            {
                operation.Parameters?.RemoveAt(0);
                if (queryAttribute.AllowedQueryOptions.HasFlag(AllowedQueryOptions.Select))
                {
                    operation.Parameters.Add(new OpenApiParameter
                    {
                        Name = "$select",
                        In = ParameterLocation.Query,
                        Description = "Selects which properties to include in the response.",
                        Schema = new OpenApiSchema { Type = "string" }
                    });
                }

                if (queryAttribute.AllowedQueryOptions.HasFlag(AllowedQueryOptions.Expand))
                {
                    operation.Parameters.Add(new OpenApiParameter
                    {
                        Name = "$expand",
                        In = ParameterLocation.Query,
                        Description = "Expands related entities inline.",                                              
                        Schema = new OpenApiSchema { Type = "string" }
                    });
                }

                // Additional OData query options are available for collections of entities only

                if (queryAttribute.AllowedQueryOptions.HasFlag(AllowedQueryOptions.Filter))
                {
                    operation.Parameters.Add(new OpenApiParameter
                    {
                        Name = "$filter",
                        In = ParameterLocation.Query,
                        Description = "Filters the results, based on a Boolean condition.",
                        Schema = new OpenApiSchema { Type = "string" }
                    });
                }

                if (queryAttribute.AllowedQueryOptions.HasFlag(AllowedQueryOptions.OrderBy))
                {
                    operation.Parameters.Add(new OpenApiParameter
                    {
                        Name = "$orderby",
                        In = ParameterLocation.Query,
                        Description = "Determines what values are used to order a collection of results.",
                        Schema = new OpenApiSchema { Type = "string" }
                    });
                }

                if (queryAttribute.AllowedQueryOptions.HasFlag(AllowedQueryOptions.Top))
                {
                    operation.Parameters.Add(new OpenApiParameter
                    {
                        Name = "$top",
                        In = ParameterLocation.Query,
                        Description = "The max number of results.",
                        Schema = new OpenApiSchema { Type = "string" }
                    });
                }

                if (queryAttribute.AllowedQueryOptions.HasFlag(AllowedQueryOptions.Skip))
                {
                    operation.Parameters.Add(new OpenApiParameter
                    {
                        Name = "$skip",
                        In = ParameterLocation.Query,
                        Description = "The number of results to skip.",
                        Schema = new OpenApiSchema { Type = "string" }
                    });
                }

                if (queryAttribute.AllowedQueryOptions.HasFlag(AllowedQueryOptions.Count))
                {
                    operation.Parameters.Add(new OpenApiParameter
                    {
                        Name = "$count",
                        In = ParameterLocation.Query,
                        Description = "Returns count of results.",
                        Schema = new OpenApiSchema { Type = "string" }
                    });
                }
            }
        }
    }
}

.......

public static IServiceCollection AddSwagger(this IServiceCollection services)
{
	services.AddSwaggerGen(options =>
	{
		options.SwaggerDoc(ApiConstants.Swagger.ApiVersion, new OpenApiInfo
		{
			Version = ApiConstants.Swagger.ApiVersion,
			Title = ApiConstants.Swagger.ApiName,
			Description = ApiConstants.Swagger.ApiName
		});
		options.EnableAnnotations();
		options.IgnoreObsoleteProperties();
		options.IgnoreObsoleteActions();
		options.DescribeAllParametersInCamelCase();
		options.EnableAnnotations();
		options.OperationFilter<ODataQueryOptionsFilter>();   // Aquí nuestro filtro OData
	   
		// Set the comments path for the Swagger JSON and UI.
		var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
		var xmlPath = Path.Combine(System.AppContext.BaseDirectory, xmlFile);
		options.IncludeXmlComments(xmlPath);
	});

	services.AddSwaggerGen();

	return services;
}

Para que veáis cómo queda en Swagger

Probando el desarrollo

Realizaremos una consulta sencilla para probar que todo funciona de lujo. Establecermos un filtro en nuestro Swagger,  como por ejemplo en el campo
$filter ponemos billable eq true

y obtenemos la siguiente salida:

 
  {
    "id": 1,
    "name": "Tarea 1",
    "observations": "Sin observaciones",
    "date": "2020-07-25T10:11:27.7125366+02:00",
    "billable": true
  },
  {
    "id": 3,
    "name": "Tarea 3",
    "observations": "Esto es una prueba de observaciones",
    "date": "2020-07-21T10:11:27.7127657+02:00",
    "billable": true
  }
]

Si establecemos el campo $filter a billable eq false

 
  {
    "id": 2,
    "name": "Tarea 2",
    "observations": "Sin observaciones",
    "date": "2020-07-28T10:11:27.7127637+02:00",
    "billable": false
  }
]

¿Quieres sólo devolver el identificador y el nombre de las tareas?

En el campo $select estableceremos   id, name

 
  {
    "id": 1,
    "name": "Tarea 1"
  },
  {
    "id": 2,
    "name": "Tarea 2"
  },
  {
    "id": 3,
    "name": "Tarea 3"
  }
]

Conclusiones

Hemos visto cómo usar nuestros modelos en los controladores y poder realizar los filtros OData sobre entidades de base de datos sin exponerlas al exterior.

El código podéis descargarlo en Github https://github.com/Encamina/Blogs/tree/master/Desarrolla%20en%20colores/AspNetCoreODataWithModel

Happy coding!!!!

mm

Sobre Sergio Parra Guerra

Sergio Parra es Ingeniero Técnico en Informática de Sistemas por la UPSAM. Tiene a sus espaldas muchísimas certificaciones entre las cuales Microsoft Certified Professional y ex Microsoft MVP Visual Studio and Development Technologies. Actualmente es un magnífico Software & Cloud Architect en ENCAMINA.
Esta entrada ha sido publicada en .NET, Azure App Services. Enlace permanente.
Suscríbete a Piensa en Sofware desarrolla en Colores

Suscríbete a Piensa en Sofware desarrolla en Colores

Recibe todas las actualizaciones semanalmente de nuestro blog

You have Successfully Subscribed!

ENCAMINA, piensa en colores