Creación de una API REST con enrutamiento de atributos en ASP.NET Web API 2

Web API 2 admite un nuevo tipo de enrutamiento, denominado enrutamiento de atributos. Para obtener información general sobre el enrutamiento de atributos, consulte Enrutamiento de atributos en Web API 2. En este tutorial, usará el enrutamiento de atributos para crear una API REST para una colección de libros. La API admitirá las siguientes acciones:

Action URI de ejemplo
Obtener una lista de todos los libros. /api/books
Obtener un libro por identificador. /api/books/1
Obtener los detalles de un libro. /api/books/1/details
Obtener una lista de libros por género. /api/books/fantasy
Obtener una lista de libros por fecha de publicación. /api/books/date/2013-02-16 /api/books/date/2013/02/16 (formulario alternativo)
Obtener una lista de libros de un autor determinado. /api/authors/1/books

Todos los métodos son de solo lectura (solicitudes HTTP GET).

Para la capa de datos, se usará Entity Framework. Los registros de libros tendrán los siguientes campos:

  • Id.
  • Título
  • Género
  • Fecha de publicación
  • Precio
  • Descripción
  • AuthorID (clave externa para una tabla de autores)

Sin embargo, para la mayoría de las solicitudes, la API devolverá un subconjunto de estos datos (título, autor y género). Para obtener el registro completo, el cliente solicita /api/books/{id}/details.

Requisitos previos

Visual Studio 2017 (edición Community, Professional o Enterprise).

Creación del proyecto de Visual Studio

Para empezar, ejecute Visual Studio. En el menú Archivo, seleccione Nuevo y haga clic en Proyecto.

Expanda la categoría Instalado>Visual C#. En Visual C#, seleccione Web. En la lista de plantillas de proyecto, seleccione Aplicación web ASP.NET (.NET Framework). Asigne al proyecto el nombre "BooksAPI".

Image of new project dialog box

En el cuadro de diálogo Nueva aplicación web ASP.NET, seleccione la plantilla de proyecto Vacía. En "Agregar carpetas y referencias principales para", active la casilla Web API. Haga clic en OK.

Image of new A S P dot Net web application dialog

Esto crea un proyecto maestro que está configurado para la funcionalidad de Web API.

Modelos de dominio

A continuación, agregue clases para los modelos de dominio. En el Explorador de soluciones, haga clic con el botón derecho en la carpeta Models. Seleccione Agregar y, a continuación, seleccione Clase. Asigne Author como nombre de la clase.

Image of create new class

Reemplace el código de Author.cs por lo siguiente:

using System.ComponentModel.DataAnnotations;

namespace BooksAPI.Models
{
    public class Author
    {
        public int AuthorId { get; set; }
        [Required]
        public string Name { get; set; }
    }
}

Ahora agregue otra clase denominada Book.

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace BooksAPI.Models
{
    public class Book
    {
        public int BookId { get; set; }
        [Required]
        public string Title { get; set; }
        public decimal Price { get; set; }
        public string Genre { get; set; }
        public DateTime PublishDate { get; set; }
        public string Description { get; set; }
        public int AuthorId { get; set; }
        [ForeignKey("AuthorId")]
        public Author Author { get; set; }
    }
}

Adición de un controlador de Web API

En este paso, agregaremos un controlador de Web API que use Entity Framework como capa de datos.

Presione Ctrl+Mayús+B para compilar el proyecto. Entity Framework usa la reflexión para detectar las propiedades de los modelos, por lo que requiere un ensamblado compilado para crear el esquema de la base de datos.

En el Explorador de soluciones, haga clic con el botón derecho en la carpeta Controllers. Seleccione Agregar y, a continuación, seleccione Controlador.

Image of add controller

En el cuadro de diálogo Agregar scaffolding, seleccione Controlador de Web API 2 con acciones que usan Entity Framework.

Image of add scaffold

En el cuadro de diálogo Agregar controlador, en Nombre del controlador, escriba "BooksController". Active la casilla "Usar acciones del controlador asincrónico". En Clase de modelo, seleccione "Book". (Si no ve la clase Book en la lista desplegable, asegúrese de haber creado el proyecto). A continuación, haga clic en el botón "+".

Image of add controller dialog box

Haga clic en Agregar en el cuadro de diálogo Nuevo contexto de datos.

Image of new data context dialog box

Haga clic en Agregar en el cuadro de diálogo Agregar controlador. El scaffolding agrega una clase denominada BooksController que define el controlador de API. También agrega una clase denominada BooksAPIContext en la carpeta Models, que define el contexto de datos de Entity Framework.

Image of new classes

Inicializar la base de datos

En el menú Herramientas, seleccione Administrador de paquetes NuGet y, a continuación, seleccione Consola del administrador de paquetes.

En la ventana Consola del Administrador de paquetas , escriba el siguiente comando:

Add-Migration

Este comando crea una carpeta Migrations y agrega un nuevo archivo de código denominado Configuration.cs. Abra este archivo y agregue el código siguiente al método Configuration.Seed.

protected override void Seed(BooksAPI.Models.BooksAPIContext context)
{
    context.Authors.AddOrUpdate(new Author[] {
        new Author() { AuthorId = 1, Name = "Ralls, Kim" },
        new Author() { AuthorId = 2, Name = "Corets, Eva" },
        new Author() { AuthorId = 3, Name = "Randall, Cynthia" },
        new Author() { AuthorId = 4, Name = "Thurman, Paula" }
        });

    context.Books.AddOrUpdate(new Book[] {
        new Book() { BookId = 1,  Title= "Midnight Rain", Genre = "Fantasy", 
        PublishDate = new DateTime(2000, 12, 16), AuthorId = 1, Description =
        "A former architect battles an evil sorceress.", Price = 14.95M }, 

        new Book() { BookId = 2, Title = "Maeve Ascendant", Genre = "Fantasy", 
            PublishDate = new DateTime(2000, 11, 17), AuthorId = 2, Description =
            "After the collapse of a nanotechnology society, the young" +
            "survivors lay the foundation for a new society.", Price = 12.95M },

        new Book() { BookId = 3, Title = "The Sundered Grail", Genre = "Fantasy", 
            PublishDate = new DateTime(2001, 09, 10), AuthorId = 2, Description =
            "The two daughters of Maeve battle for control of England.", Price = 12.95M },

        new Book() { BookId = 4, Title = "Lover Birds", Genre = "Romance", 
            PublishDate = new DateTime(2000, 09, 02), AuthorId = 3, Description =
            "When Carla meets Paul at an ornithology conference, tempers fly.", Price = 7.99M },

        new Book() { BookId = 5, Title = "Splish Splash", Genre = "Romance", 
            PublishDate = new DateTime(2000, 11, 02), AuthorId = 4, Description =
            "A deep sea diver finds true love 20,000 leagues beneath the sea.", Price = 6.99M},
    });
}

En la ventana de la consola del administrador de paquetes, escriba los comandos siguientes.

add-migration Initial

update-database

Estos comandos crean una base de datos local e invocan el método de inicialización para rellenar la base de datos.

Image of Package Manager Console

Incorporación de clases DTO

Si ejecuta la aplicación ahora y envía una solicitud GET a /api/books/1, la respuesta es similar a la siguiente. (He agregado sangría para mejorar la legibilidad).

{
  "BookId": 1,
  "Title": "Midnight Rain",
  "Genre": "Fantasy",
  "PublishDate": "2000-12-16T00:00:00",
  "Description": "A former architect battles an evil sorceress.",
  "Price": 14.95,
  "AuthorId": 1,
  "Author": null
}

En su lugar, quiero que esta solicitud devuelva un subconjunto de los campos. Además, quiero que devuelva el nombre del autor, en lugar del identificador del autor. Para ello, modificaremos los métodos de controlador para que devuelvan un objeto de transferencia de datos (DTO) en lugar del modelo EF. Un DTO es un objeto diseñado solo para transportar datos.

En el Explorador de soluciones, haga clic con el botón derecho en el proyecto y seleccione Agregar | Nueva carpeta. Asigne a la carpeta el nombre "DTOs". Agregue una clase denominada BookDto a la carpeta DTOs, con la siguiente definición:

namespace BooksAPI.DTOs
{
    public class BookDto
    {
        public string Title { get; set; }
        public string Author { get; set; }
        public string Genre { get; set; }
    }
}

Agregue otra clase llamada BookDetailDto.

using System;

namespace BooksAPI.DTOs
{
    public class BookDetailDto
    {
        public string Title { get; set; }
        public string Genre { get; set; }
        public DateTime PublishDate { get; set; }
        public string Description { get; set; }
        public decimal Price { get; set; }         
        public string Author { get; set; }
    }
}

A continuación, actualice la clase BooksController para devolver instancias de BookDto. Se usará el método Queryable.Select para proyectar instancias de Book en instancias de BookDto. Este es el código actualizado para la clase de controlador.

using BooksAPI.DTOs;
using BooksAPI.Models;
using System;
using System.Data.Entity;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Http.Description;

namespace BooksAPI.Controllers
{
    public class BooksController : ApiController
    {
        private BooksAPIContext db = new BooksAPIContext();

        // Typed lambda expression for Select() method. 
        private static readonly Expression<Func<Book, BookDto>> AsBookDto =
            x => new BookDto
            {
                Title = x.Title,
                Author = x.Author.Name,
                Genre = x.Genre
            };

        // GET api/Books
        public IQueryable<BookDto> GetBooks()
        {
            return db.Books.Include(b => b.Author).Select(AsBookDto);
        }

        // GET api/Books/5
        [ResponseType(typeof(BookDto))]
        public async Task<IHttpActionResult> GetBook(int id)
        {
            BookDto book = await db.Books.Include(b => b.Author)
                .Where(b => b.BookId == id)
                .Select(AsBookDto)
                .FirstOrDefaultAsync();
            if (book == null)
            {
                return NotFound();
            }

            return Ok(book);
        }
        
        protected override void Dispose(bool disposing)
        {
            db.Dispose();
            base.Dispose(disposing);
        }
    }
}

Nota:

He eliminado los métodos PutBook, PostBook y DeleteBook, porque no son necesarios para este tutorial.

Ahora, si ejecuta la aplicación y solicita /api/books/1, el cuerpo de la respuesta debe tener este aspecto:

{"Title":"Midnight Rain","Author":"Ralls, Kim","Genre":"Fantasy"}

Incorporación de atributos de ruta

A continuación, se convertirá el controlador para que use el enrutamiento de atributos. En primer lugar, agregue un atributo RoutePrefix al controlador. Este atributo define los segmentos de URI iniciales para todos los métodos de este controlador.

[RoutePrefix("api/books")]
public class BooksController : ApiController
{
    // ...

A continuación, agregue atributos [Route] a las acciones del controlador, como se indica a continuación:

[Route("")]
public IQueryable<BookDto> GetBooks()
{
    // ...
}

[Route("{id:int}")]
[ResponseType(typeof(BookDto))]
public async Task<IHttpActionResult> GetBook(int id)
{
    // ...
}

La plantilla de ruta para cada método de controlador es el prefijo más la cadena especificada en el atributo Route. Para el método GetBook, la plantilla de ruta incluye la cadena con parámetros "{id:int}", que coincide si el segmento de URI contiene un valor entero.

Método Plantilla de ruta URI de ejemplo
GetBooks "api/books" http://localhost/api/books
GetBook "api/books/{id:int}" http://localhost/api/books/5

Obtención de detalles del libro

Para obtener los detalles del libro, el cliente enviará una solicitud GET a /api/books/{id}/details, donde {id} es el identificador del libro.

Agrega el método siguiente a la clase BooksController:

[Route("{id:int}/details")]
[ResponseType(typeof(BookDetailDto))]
public async Task<IHttpActionResult> GetBookDetail(int id)
{
    var book = await (from b in db.Books.Include(b => b.Author)
                where b.BookId == id
                select new BookDetailDto
                {
                    Title = b.Title,
                    Genre = b.Genre,
                    PublishDate = b.PublishDate,
                    Price = b.Price,
                    Description = b.Description,
                    Author = b.Author.Name
                }).FirstOrDefaultAsync();

    if (book == null)
    {
        return NotFound();
    }
    return Ok(book);
}

Si solicita /api/books/1/details, la respuesta tiene el siguiente aspecto:

{
  "Title": "Midnight Rain",
  "Genre": "Fantasy",
  "PublishDate": "2000-12-16T00:00:00",
  "Description": "A former architect battles an evil sorceress.",
  "Price": 14.95,
  "Author": "Ralls, Kim"
}

Obtención de libros por género

Para obtener una lista de libros de un género específico, el cliente enviará una solicitud GET a /api/books/genre, donde genre es el nombre del género. (Por ejemplo, /api/books/fantasy).

Agregue el método siguiente a BooksController.

[Route("{genre}")]
public IQueryable<BookDto> GetBooksByGenre(string genre)
{
    return db.Books.Include(b => b.Author)
        .Where(b => b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase))
        .Select(AsBookDto);
}

Aquí definimos una ruta que contiene un parámetro {genre} en la plantilla de URI. Tenga en cuenta que Web API puede distinguir estos dos URI y enrutarlos a métodos diferentes:

/api/books/1

/api/books/fantasy

Esto se debe a que el método GetBook incluye una restricción según la cual el segmento "id" debe ser un valor entero:

[Route("{id:int}")] 
public BookDto GetBook(int id)
{
    // ... 
}

Si solicita /api/books/fantasy, la respuesta tiene el siguiente aspecto:

[ { "Title": "Midnight Rain", "Author": "Ralls, Kim", "Genre": "Fantasy" }, { "Title": "Maeve Ascendant", "Author": "Corets, Eva", "Genre": "Fantasy" }, { "Title": "The Sundered Grail", "Author": "Corets, Eva", "Genre": "Fantasy" } ]

Obtención de libros por autor

Para obtener una lista de libros de un autor determinado, el cliente enviará una solicitud GET a /api/authors/id/books, donde id es el identificador del autor.

Agregue el método siguiente a BooksController.

[Route("~/api/authors/{authorId:int}/books")]
public IQueryable<BookDto> GetBooksByAuthor(int authorId)
{
    return db.Books.Include(b => b.Author)
        .Where(b => b.AuthorId == authorId)
        .Select(AsBookDto);
}

Este ejemplo es interesante porque "libros" se trata como un recurso secundario de "autores". Este patrón es bastante común en las API de RESTful.

La tilde (~) de la plantilla de ruta invalida el prefijo de ruta en el atributo RoutePrefix.

Obtención de libros por fecha de publicación

Para obtener una lista de libros por fecha de publicación, el cliente enviará una solicitud GET a /api/books/date/yyyy-mm-dd, donde yyyy-mm-dd es la fecha.

Esta es una manera de hacerlo:

[Route("date/{pubdate:datetime}")]
public IQueryable<BookDto> GetBooks(DateTime pubdate)
{
    return db.Books.Include(b => b.Author)
        .Where(b => DbFunctions.TruncateTime(b.PublishDate)
            == DbFunctions.TruncateTime(pubdate))
        .Select(AsBookDto);
}

El parámetro {pubdate:datetime} está restringido para que coincida con un valor DateTime. Esto funciona, pero en realidad es más permisivo de lo que nos gustaría. Por ejemplo, estos URI también coincidirán con la ruta:

/api/books/date/Thu, 01 May 2008

/api/books/date/2000-12-16T00:00:00

No hay nada malo en permitir estos URI. Sin embargo, puede restringir la ruta a un formato determinado agregando una restricción de expresión regular a la plantilla de ruta:

[Route("date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")]
public IQueryable<BookDto> GetBooks(DateTime pubdate)
{
    // ...
}

Ahora solo las fechas con el formato "yyyy-mm-dd" coincidirán. Observe que no usamos la expresión regular para validar que hemos obtenido una fecha real. Esto se controla cuando Web API intenta convertir el segmento de URI en una instancia de DateTime. No se podrá convertir una fecha no válida como "2012-47-99" y el cliente recibirá un error 404.

También puede admitir un separador de barra diagonal (/api/books/date/yyyy/mm/dd) agregando otro atributo [Route] con una expresión regular diferente.

[Route("date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")]
[Route("date/{*pubdate:datetime:regex(\\d{4}/\\d{2}/\\d{2})}")]  // new
public IQueryable<BookDto> GetBooks(DateTime pubdate)
{
    // ...
}

Aquí hay un detalle sutil pero importante. La segunda plantilla de ruta tiene un carácter comodín (*) al principio del parámetro {pubdate}:

{*pubdate: ... }

Esto indica al motor de enrutamiento que {pubdate} debe coincidir con el resto del URI. De forma predeterminada, un parámetro de plantilla coincide con un único segmento de URI. En este caso, queremos que {pubdate} abarque varios segmentos de URI:

/api/books/date/2013/06/17

Código de controlador

Este es el código completo de la clase BooksController.

using BooksAPI.DTOs;
using BooksAPI.Models;
using System;
using System.Data.Entity;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Http.Description;

namespace BooksAPI.Controllers
{
    [RoutePrefix("api/books")]
    public class BooksController : ApiController
    {
        private BooksAPIContext db = new BooksAPIContext();

        // Typed lambda expression for Select() method. 
        private static readonly Expression<Func<Book, BookDto>> AsBookDto =
            x => new BookDto
            {
                Title = x.Title,
                Author = x.Author.Name,
                Genre = x.Genre
            };

        // GET api/Books
        [Route("")]
        public IQueryable<BookDto> GetBooks()
        {
            return db.Books.Include(b => b.Author).Select(AsBookDto);
        }

        // GET api/Books/5
        [Route("{id:int}")]
        [ResponseType(typeof(BookDto))]
        public async Task<IHttpActionResult> GetBook(int id)
        {
            BookDto book = await db.Books.Include(b => b.Author)
                .Where(b => b.BookId == id)
                .Select(AsBookDto)
                .FirstOrDefaultAsync();
            if (book == null)
            {
                return NotFound();
            }

            return Ok(book);
        }

        [Route("{id:int}/details")]
        [ResponseType(typeof(BookDetailDto))]
        public async Task<IHttpActionResult> GetBookDetail(int id)
        {
            var book = await (from b in db.Books.Include(b => b.Author)
                              where b.AuthorId == id
                              select new BookDetailDto
                              {
                                  Title = b.Title,
                                  Genre = b.Genre,
                                  PublishDate = b.PublishDate,
                                  Price = b.Price,
                                  Description = b.Description,
                                  Author = b.Author.Name
                              }).FirstOrDefaultAsync();

            if (book == null)
            {
                return NotFound();
            }
            return Ok(book);
        }

        [Route("{genre}")]
        public IQueryable<BookDto> GetBooksByGenre(string genre)
        {
            return db.Books.Include(b => b.Author)
                .Where(b => b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase))
                .Select(AsBookDto);
        }

        [Route("~/api/authors/{authorId}/books")]
        public IQueryable<BookDto> GetBooksByAuthor(int authorId)
        {
            return db.Books.Include(b => b.Author)
                .Where(b => b.AuthorId == authorId)
                .Select(AsBookDto);
        }

        [Route("date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")]
        [Route("date/{*pubdate:datetime:regex(\\d{4}/\\d{2}/\\d{2})}")]
        public IQueryable<BookDto> GetBooks(DateTime pubdate)
        {
            return db.Books.Include(b => b.Author)
                .Where(b => DbFunctions.TruncateTime(b.PublishDate)
                    == DbFunctions.TruncateTime(pubdate))
                .Select(AsBookDto);
        }

        protected override void Dispose(bool disposing)
        {
            db.Dispose();
            base.Dispose(disposing);
        }
    }
}

Resumen

El enrutamiento de atributos proporciona más control y mayor flexibilidad al diseñar los URI de la API.