Creación de una API REST con enrutamiento de atributos en ASP.NET API web 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:

Acción URI de ejemplo
Obtener una lista de todos los libros. /api/books
Obtener un libro por ID. /api/books/1
Consigue los detalles de un libro. /api/libros/1/detalles
Obtener una lista de libros por género. /api/books/fantasía
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, usaremos Entity Framework. Los registros de los libros tendrán los siguientes campos:

  • ID
  • Título
  • Género
  • Fecha de publicación
  • Precio
  • Descripción
  • AuthorID (clave externa para una tabla 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.

Prerrequisitos

Visual Studio 2017 Community, Professional o Enterprise Edition.

Creación del proyecto de Visual Studio

Empiece por ejecutar Visual Studio. En el menú Archivo, seleccione Nuevo y haga clic en Proyecto.

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

Imagen del cuadro de diálogo de nuevo proyecto

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

Imagen del cuadro de diálogo de una nueva aplicación web de ASP.NET

Esto crea un proyecto esqueleto que está configurado para la funcionalidad de api web.

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 Modelos. Seleccione Agregar y, a continuación, seleccione Clase. Asigne un nombre a la clase Author.

Imagen de la creación de una nueva clase

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 API web

En este paso, agregaremos un controlador de API web 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 Controladores. Seleccione Agregar y, a continuación, seleccione Controlador.

Imagen de agregar controlador

En el cuadro de diálogo Agregar esqueleto, seleccione Controlador de API Web 2 con acciones mediante Entity Framework.

Imagen de agregar estructura

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

Imagen del cuadro de diálogo Agregar controlador

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

Imagen del cuadro de diálogo del nuevo contexto de datos

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.

Imagen de nuevas clases

Inicialización de 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 paquetes, 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 Configuration.Seed método .

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 Consola del Administrador de paquetes, escriba los siguientes comandos.

add-migration Initial

update-database

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

Imagen de la consola del Administrador de paquetes

Agregar 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 devolver 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 un nombre a la carpeta "DTO". Agregue una clase denominada BookDto a la carpeta DTO, 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 denominada 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 BooksController clase para devolver BookDto instancias. Usaremos el método Queryable.Select para proyectar instancias de Book a 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 PutBookmétodos , PostBooky 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"}

Agregar atributos de ruta

A continuación, convertiremos el controlador para usar 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 GetBook método , 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

Obtener 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.

Agregue el siguiente método 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"
}

Obtener 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 género 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 la API web puede distinguir estos dos URI y enrutarlos a métodos diferentes:

/api/books/1

/api/books/fantasy

Esto se debe a que el GetBook método incluye una restricción que 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" } ]

Conseguir libros de un autor

Para obtener una lista de libros para 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 consideran un recurso dependiente 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 .

Obtener 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 aaaa-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 {pubdate:datetime} parámetro 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 al permitir estos URI. Sin embargo, puede restringir la ruta a un formato determinado agregando una restricción regular-expression 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 "aaaa-mm-dd" coincidirán. Observe que no usamos la expresión regular para validar que tenemos una verdadera fecha. Esto se controla cuando la API web 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 barras diagonales (/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 del 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 para la API.