Paginación, búsqueda y ordenación en un Web API de ASP.NET

En entornos empresariales, trabajar con Web APIs que manejan grandes volúmenes de datos es algo muy habitual. 

En este escenario de trabajo, realizar consultas sobre una base de datos que devuelvan una gran cantidad de registros, puede causar graves problemas de rendimiento en los servidores, a la hora de tratar estos datos para ser enviados a los clientes finales.

Afortunadamente este problema se puede solucionar añadiendo un sistema de paginación de registros a las consultas realizadas por nuestros Web APIs sobre la base de datos.

A continuación veremos cómo implementar un sistema de paginación, búsqueda y ordenación de registros en un Web API REST de ASP.NET Core 2.2, con Entity Framework (ORM) como medio de acceso al origen de datos.

Nota: Aunque en esta ocasión hemos utilizado ASP.NET Core como plataforma de destino, el sistema de paginación, búsqueda y ordenación que implementaremos, es perfectamente válido para Web APIs 2 REST de ASP.NET Framework 4.6.x.

 

El Modelo de datos

Para realizar este ejemplo, hemos utilizado el modelo de datos (tabla) Customers de la base de datos de pruebas Northwind de Microsoft.

Nota: Los scripts (Sql) para al creación de la tabla Customers, están disponibles para descargar al final de este Post.

    public class Customer
    {
        [Key]
        [StringLength(5)]
        public string CustomerID { get; set; }

        [Required]
        [StringLength(40)]
        public string CompanyName { get; set; }

        [Required]
        [StringLength(30)]
        public string ContactName { get; set; }

        [StringLength(30)]
        public string ContactTitle { get; set; }

        [StringLength(100)]
        public string Address { get; set; }

        [StringLength(15)]
        public string City { get; set; }

        [StringLength(15)]
        public string Region { get; set; }

        [StringLength(10)]
        public string PostalCode { get; set; }

        [Required]
        [StringLength(15)]
        public string Country { get; set; }

        [StringLength(24)]
        public string Phone { get; set; }

        [StringLength(24)]
        public string Fax { get; set; }

        [Required]
        [StringLength(100)]
        [DataType(DataType.EmailAddress)]
        public string Email { get; set; }
    }

Como no es el objetivo de este artículo explicar como enlazar Entity Framework a una base de datos, supondremos que ya hemos definido el DbContext de la aplicación con el correspondiente DbSet del modelo de datos Customers.

    public class AppDbContext : DbContext
    {
        public AppDbContext (DbContextOptions<AppDbContext> options)
            : base(options)
        {
        }

        public DbSet<Customer> Customer { get; set; }
    }

 

La Clase de paginación

Para poder mantener entre otras cosas el estado de la paginación durante las peticiones de consulta al Web API, necesitamos implementar una clase auxiliar que nos permita almacenar, tanto los parámetros de paginación, búsqueda y ordenación cómo los resultados devueltos por la búsqueda. 

Esta clase auxiliar de paginación PaginadorGenerico.cs, la definiremos como Clase genérica para que pueda ser utilizada para paginar cualquier tipo de modelo de datos:

    public class PaginadorGenerico<T> where T : class
    {
        /// <summary>
        /// Página devuelta por la consulta actual.
        /// </summary>
        public int PaginaActual { get; set; }
        /// <summary>
        /// Número de registros de la página devuelta.
        /// </summary>
        public int RegistrosPorPagina { get; set; }
        /// <summary>
        /// Total de registros de consulta.
        /// </summary>
        public int TotalRegistros { get; set; }
        /// <summary>
        /// Total de páginas de la consulta.
        /// </summary>
        public int TotalPaginas { get; set; }
        /// <summary>
        /// Texto de búsqueda de la consuta actual.
        /// </summary>
        public string BusquedaActual { get; set; }
        /// <summary>
        /// Columna por la que esta ordenada la consulta actual.
        /// </summary>
        public string OrdenActual { get; set; }
        /// <summary>
        /// Tipo de ordenación de la consulta actual: ASC o DESC.
        /// </summary>
        public string TipoOrdenActual { get; set; }
        /// <summary>
        /// Resultado devuelto por la consulta a la tabla Customers
        /// en función de todos los parámetros anteriores.
        /// </summary>
        public IEnumerable<T> Resultado { get; set; }
    }

 

El Controlador de Web API

A continuación crearemos el Controlador de API CustomersController.cs, y nos centraremos en la Acción GET (GetCustomers) que en principio debe devolver todos los registros de la tabla Customers existentes en la base de datos.

    [Route("api/[controller]")]
    [ApiController]
    public class CustomersController : ControllerBase
    {                
        private readonly AppDbContext _context;

        public CustomersController(AppDbContext context)
        {
            _context = context;
        }

        // GET: api/Customers
        [HttpGet]
        public async Task<ActionResult<IEnumerable<Customer>>> GetCustomers()
        {
            return await _context.Customer.ToListAsync();
        }

        // ...

    }

Lo primero que haremos será añadir a la Acción los parámetros de entrada necesarios para obtener los resultados paginados, y a continuación, implementaremos el código necesario para paginar, buscar y ordenar los registros de la tabla Customers.

    [Route("api/[controller]")]
    [ApiController]
    public class CustomersController : ControllerBase
    {                
        private readonly AppDbContext _context;

        public CustomersController(AppDbContext context)
        {
            _context = context;
        }

        // GET: api/Customers
        /// <summary>
        /// Obtiene un resultado paginado de los objetos de la BD.
        /// </summary>
        /// <param name="buscar">Texto de búsqueda.</param>
        /// <param name="orden">Nombre de campo por el cual ordenar (distingue mayúsculas).</param>
        /// <param name="tipo_orden">Tipo de orden: ASC (ascendente) / DESC (descendente).</param>
        /// <param name="pagina">Número de página a obtener.</param>
        /// <param name="registros_por_pagina">Número de registros por página.</param>
        /// <returns></returns>
        [HttpGet]
        public async Task<ActionResult<PaginadorGenerico<Customer>>> GetCustomers(string buscar, 
                                                                                 string orden = "CustomerID",
                                                                                 string tipo_orden = "ASC",
                                                                                 int pagina = 1, 
                                                                                 int registros_por_pagina = 10)
        {            
            List<Customer> _Customers;
            PaginadorGenerico<Customer> _PaginadorCustomers;

            ////////////////////////
            // FILTRO DE BÚSQUEDA //
            ////////////////////////
            // Recuperamos el 'DbSet' completo
            _Customers = _context.Customer.ToList();

            // Filtramos el resultado por el 'texto de búqueda'
            if (!string.IsNullOrEmpty(buscar))
            {
                foreach (var item in buscar.Split(new char[] { ' ' },
                         StringSplitOptions.RemoveEmptyEntries))
                {
                    _Customers = _Customers.Where(x => x.ContactName.Contains(item) ||
                                                  x.CompanyName.Contains(item) ||
                                                  x.Email.Contains(item))
                                                  .ToList();
                }
            }

            /////////////////////////////
            // ORDENACIÓN POR COLUMNAS //
            /////////////////////////////
            switch (orden)
            {
                case "CustomerID":
                    if (tipo_orden.ToLower() == "desc")
                        _Customers = _Customers.OrderByDescending(x => x.CustomerID).ToList();
                    else if (tipo_orden.ToLower() == "asc")
                        _Customers = _Customers.OrderBy(x => x.CustomerID).ToList();
                    break;

                case "CompanyName":
                    if (tipo_orden.ToLower() == "desc")
                        _Customers = _Customers.OrderByDescending(x => x.CompanyName).ToList();
                    else if (tipo_orden.ToLower() == "asc")
                        _Customers = _Customers.OrderBy(x => x.CompanyName).ToList();
                    break;

                case "ContactName":
                    if (tipo_orden.ToLower() == "desc")
                        _Customers = _Customers.OrderByDescending(x => x.CompanyName).ToList();
                    else if (tipo_orden.ToLower() == "asc")
                        _Customers = _Customers.OrderBy(x => x.CompanyName).ToList();
                    break;

                // ...
                // Aquí el resto de los campos de la tabla por los que ordenar.
                // ...

                default:
                    if (tipo_orden.ToLower() == "desc")
                        _Customers = _Customers.OrderByDescending(x => x.CustomerID).ToList();
                    else if (tipo_orden.ToLower() == "asc")
                        _Customers = _Customers.OrderBy(x => x.CustomerID).ToList();
                    break;
            }

            ///////////////////////////
            // SISTEMA DE PAGINACIÓN //
            ///////////////////////////
            int _TotalRegistros = 0;
            int _TotalPaginas = 0;
            // Número total de registros de la tabla Customers
            _TotalRegistros = _Customers.Count();
            // Obtenemos la 'página de registros' de la tabla Customers
            _Customers = _Customers.Skip((pagina - 1) * registros_por_pagina)
                                             .Take(registros_por_pagina)
                                             .ToList();
            // Número total de páginas de la tabla Customers
            _TotalPaginas = (int)Math.Ceiling((double)_TotalRegistros / registros_por_pagina);

            // Instanciamos la 'Clase de paginación' y asignamos los nuevos valores
            _PaginadorCustomers = new PaginadorGenerico<Customer>()
            {
                RegistrosPorPagina = registros_por_pagina,
                TotalRegistros = _TotalRegistros,
                TotalPaginas = _TotalPaginas,
                PaginaActual = pagina,
                BusquedaActual = buscar,
                OrdenActual = orden,
                TipoOrdenActual = tipo_orden,
                Resultado = _Customers
            };

            return _PaginadorCustomers;
        }

        // ...

    }

La paginación

Como podemos ver en el código, la Acción GetCustomers() recibe como parámetros la página a recuperar de la tabla Customers (int pagina = 1), y el número de registros por página que queremos obtener (int registros_por_pagina = 10). 

Por defecto los valores de estos parámetros son 1 para la página, y 10 para el número de registros por página. Esto quiere decir que si no indicamos estos parámetros, se nos devolverá la primera página de la tabla Customers con 10 registros.

La búsqueda

En cuanto a la búsqueda, la Acción GetCustomers() recibe el texto de búsqueda a través del parámetro string buscar. Cabe destacar que si no indicamos el parámetro de búsqueda, la paginación se realizará sobre todo el conjunto de registros de la tabla Customers.

La ordenación

Los parámetros de ordenación de registros que recibirá la Acción GetCustomers() serán el campo de la tabla Customers por el cual ordenar (string orden = "CustomerID"), y el tipo de ordenación a aplicar, o sea, ascendente ASC o descendente DESC (string tipo_orden = "ASC").

Los valores por defecto de estos parámetros los definiremos según nuestro criterio. Para este ejemplo, si no indicamos los parámetros de búsqueda, los resultados vendrán ordenados por el campo CustomerID en orden ascendente ASC.

 

Comprobando la funcionalidad con Swagger

Para finalizar, instalaremos Swagger en nuestro proyecto de Web API para comprobar de una manera mas visual la funcionalidad del sistema de paginación.

Nota: Para saber como instalar y configurar Swagger en un proyecto Web API de ASP.NET Core, pueden consultar el artículo de este Blog Swagger - Cómo documentar servicios Web API de ASP.NET Core. En el caso de de haber realizado el proyecto sobre Web API 2 de ASP.NET Framework, pueden consultar este otro artículo Swagger - Documentando un Web API 2 de ASP.NET Framework.

Accediendo a la interfaz de usuario Swagger, el resultado sería el siguiente:

paginacion-web-api

Como vemos, Swagger nos muestra los parámetros del método GetCustomers() con sus valores por defecto correspondientes. Una petición GET con estos valores por defecto generaría una URL con la siguiente estructura:

https://localhost:5001/api/Customers?orden=CustomerID&tipo_orden=ASC&pagina=1&registros_por_pagina=10

El resultado devuelto por el Web API sería una estructura JSON del tipo PaginadorGenerico<T>, donde vendrían incluidos los resultados de la consulta, además de la información necesaria para la paginación, búsqueda y ordenación.

{
  "paginaActual": 1,
  "registrosPorPagina": 10,
  "totalRegistros": 91,
  "totalPaginas": 10,
  "busquedaActual": null,
  "ordenActual": "CustomerID",
  "tipoOrdenActual": "ASC",
  "resultado": [
    {
      "customerID": "ALFKI",
      "companyName": "Alfreds Futterkiste",
      "contactName": "Maria Anders",
      "contactTitle": "Sales Representative",
      "address": "Obere Str. 57",
      "city": "Berlin ",
      "region": "East",
      "postalCode": "12209",
      "country": "Germany",
      "phone": "030-0074321",
      "fax": "030-0076545",
      "email": "maria.anders@northwind.com"
    },
    {
      "customerID": "ANATR",
      "companyName": "Ana Trujillo Emparedados y helados",
      "contactName": "Ana Trujillo",
      "contactTitle": "Owner",
      "address": "Avda. de la Constitución 2222",
      "city": "México D.F.",
      "region": null,
      "postalCode": "05021",
      "country": "Mexico",
      "phone": "(5) 555-4729",
      "fax": "(5) 555-3745",
      "email": "ana.trujillo@northwind.com"
    },
    {
      "customerID": "ANTON",
      "companyName": "Antonio Moreno Taquería",
      "contactName": "Antonio Moreno",
      "contactTitle": "Owner",
      "address": "Mataderos  2312",
      "city": "México D.F.",
      "region": null,
      "postalCode": "05023",
      "country": "Mexico",
      "phone": "(5) 555-3932",
      "fax": null,
      "email": "antonio.moreno@northwind.com"
    },
    {
      "customerID": "AROUT",
      "companyName": "Around the Horn",
      "contactName": "Thomas Hardy",
      "contactTitle": "Sales Representative",
      "address": "120 Hanover Sq.",
      "city": "London",
      "region": null,
      "postalCode": "WA1 1DP",
      "country": "UK",
      "phone": "(171) 555-7788",
      "fax": "(171) 555-6750",
      "email": "thomas.hardy@northwind.com"
    },
    {
      "customerID": "BERGS",
      "companyName": "Berglunds snabbköp",
      "contactName": "Christina Berglund",
      "contactTitle": "Order Administrator",
      "address": "Berguvsvägen  8",
      "city": "Luleå",
      "region": null,
      "postalCode": "S-958 22",
      "country": "Sweden",
      "phone": "0921-12 34 65",
      "fax": "0921-12 34 67",
      "email": "christina.berglund@northwind.com"
    },
    {
      "customerID": "BLAUS",
      "companyName": "Blauer See Delikatessen",
      "contactName": "Hanna Moos",
      "contactTitle": "Sales Representative",
      "address": "Forsterstr. 57",
      "city": "Mannheim",
      "region": null,
      "postalCode": "68306",
      "country": "Germany",
      "phone": "0621-08460",
      "fax": "0621-08924",
      "email": "hanna.moos@northwind.com"
    },
    {
      "customerID": "BLONP",
      "companyName": "Blondesddsl père et fils",
      "contactName": "Frédérique Citeaux",
      "contactTitle": "Marketing Manager",
      "address": "24, place Kléber",
      "city": "Strasbourg",
      "region": null,
      "postalCode": "67000",
      "country": "France",
      "phone": "88.60.15.31",
      "fax": "88.60.15.32",
      "email": "frédérique.citeaux@northwind.com"
    },
    {
      "customerID": "BOLID",
      "companyName": "Bólido Comidas preparadas",
      "contactName": "Martín Sommer",
      "contactTitle": "Owner",
      "address": "C/ Araquil, 67",
      "city": "Madrid",
      "region": null,
      "postalCode": "28023",
      "country": "Spain",
      "phone": "(91) 555 22 82",
      "fax": "(91) 555 91 99",
      "email": "martín.sommer@northwind.com"
    },
    {
      "customerID": "BONAP",
      "companyName": "Bon app'",
      "contactName": "Laurence Lebihan",
      "contactTitle": "Owner",
      "address": "12, rue des Bouchers",
      "city": "Marseille",
      "region": null,
      "postalCode": "13008",
      "country": "France",
      "phone": "91.24.45.40",
      "fax": "91.24.45.41",
      "email": "laurence.lebihan@northwind.com"
    },
    {
      "customerID": "BOTTM",
      "companyName": "Bottom-Dollar Markets",
      "contactName": "Elizabeth Lincoln",
      "contactTitle": "Accounting Manager",
      "address": "23 Tsawassen Blvd.",
      "city": "Tsawassen",
      "region": "BC",
      "postalCode": "T2F 8M4",
      "country": "Canada",
      "phone": "(604) 555-4729",
      "fax": "(604) 555-3745",
      "email": "elizabeth.lincoln@northwind.com"
    }
  ]
}

Es importante tener en cuenta que la Acción GetCustomers() jamás devolverá todos los registros de la tabla Customers en una petición del tipo https://localhost:5001/api/Customers (o sea, sin parámetros). En este caso, si no indicamos ningún parámetro, el resultado siempre será la primera página de resultados, con 10 resultados ordenados por el campo CustomerID de manera ascendente.

 

Descargas

Script Tabla- dbo.Customers.sql
Datos de prueba - dbo.Customers.data.sql

  Compartir


  Nuevo comentario

El campo Comentario es obligatorio.
El campo Nombre es obligatorio.

  Comentarios

Gabriel Batista Gabriel Batista

Estoy presentando un problema al momento de realizar una paginación, sucede que yo filtro por fecha me trae todos los registros y las paginas necesarias para mostrar dichos elementos, pero sucede que en la primera pagina me muestra la cantidad de registro correspondiente y cuando paso a las siguiente paginas no me los muestra con la cantidad que debería.
Gerson GT Gerson GT

Excelente aporte. ¿Cómo podría integrar esta WebApi a un FrontEnd como Angular?
Jairo M Jairo M

Muy buen tutorial amigo, Sr Rafael Acosta por casualidad no tendra un tutorial, en donde se muestre como relacionar tablas en una web api C#, saludos
Rafael Acosta Administrador Rafael Acosta

@Flavio Cortes:

Gracias por tu comentario. 


Podrías indicar exactamente donde has detectado esa disfuncionalidad con Task y Await?. 


Flavio Cortes Flavio Cortes

excelente articulo y el único comentario que tengo es que tienes una función con el atributo TASK el cual dentro de la función debería existir un AWAIT el cual nunca se utiliza el resto funciona correctamente gracias


Utilizamos cookies propias y de terceros para mejorar nuestros servicios y ofrecerle una mejor experiencia de navegación. Si continúa navegando consideramos que acepta su uso. Más información   Acepto