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

por Mike Wasson

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 de REST para una colección de libros. La API admitirá las siguientes acciones:

Acción URI de ejemplo
Obtiene una lista de todos los libros. /api/books
Obtiene un libro por identificador. /api/books/1
Obtener los detalles de un libro. /api/books/1/details
Obtiene una lista de libros por género. /api/books/fantasy
Obtiene una lista de libros por fecha de publicación. /API/Books/Date/2013-02-16/API/Books/Date/2013/02/16 (forma alternativa)
Obtener una lista de libros por un autor determinado. /api/authors/1/books

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

En el nivel de datos, usaremos Entity Framework. Los registros del libro tendrán los campos siguientes:

  • Id.
  • Título
  • Género
  • fecha de publicación
  • Price
  • Descripción
  • AuthorID (clave externa a 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 Community, Professional o Enterprise Edition.

Crear el proyecto de Visual Studio

En primer lugar, ejecuta Visual Studio. En el menú Archivo, seleccione Nuevo y haga clic en Proyecto.

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

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

Esto crea un proyecto de esqueleto que está configurado para la funcionalidad de la 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 Models. Seleccione Agregary, a continuación, seleccione clase. Asigne Author como nombre de la 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; }
    }
}

Incorporación de un controlador de API Web

En este paso, vamos a agregar un controlador de API Web que usa 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 Agregary, a continuación, seleccione controlador.

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

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

Haga clic en Agregar en el cuadro de diálogo 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 para Entity Framework.

Inicializar la base de datos

En el menú herramientas, seleccione Administrador de paquetes NuGety, a continuación, seleccione consola del administrador de paquetes.

En la ventana Package Manager Console, 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 de la 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 de inicialización para rellenar la base de datos.

Agregar clases DTO

Si ejecuta la aplicación ahora y envía una solicitud GET a/API/Books/1, la respuesta tendrá un aspecto similar al siguiente. (He agregado sangría para mejorar la legibilidad).

{
    "BookId": 1,
    "Title": "Lluvia de la noche",
    "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, deseo que esta solicitud devuelva un subconjunto de los campos. Además, quiero devolver 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 que está diseñado únicamente para transportar datos.

En explorador de soluciones, haga clic con el botón derecho en el proyecto y seleccione Agregar | nueva carpeta. Asigne a la carpeta el nombre " 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 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 BooksController clase para devolver BookDto las instancias. Usaremos el método Queryable. Select para proyectar Book instancias de en BookDto instancias de. 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);
        }
    }
}

Note

Eliminé los PutBook PostBook métodos, 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 ser similar al siguiente:

{
    "Title": "Lluvia de la noche",
    "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 los 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 de 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 parametrizada " {ID: int} " , que coincide si el segmento del URI contiene un valor entero.

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

Obtener detalles del libro

Para obtener 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": "Lluvia de la noche",
    "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 en 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í estamos definiendo una ruta que contiene un parámetro {Genre} en la plantilla URI. Tenga en cuenta que la API Web puede distinguir estos dos URI y enrutarlos a distintos métodos:

/api/books/1

/api/books/fantasy

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

Obtener libros por autor

Para obtener una lista de los 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 los " libros " se tratan como un recurso secundario de los " 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 ningún problema al 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 coincidirán las fechas con el formato " AAAA-MM-DD " . Tenga en cuenta que no usamos el regex para validar que obtuvimos una fecha real. Esto se controla cuando Web API intenta convertir el segmento del URI en una instancia de DateTime . Una fecha no válida, como ' 2012-47-99 ', no se convertirá y el cliente obtendrá un error 404.

También puede admitir un separador de barra diagonal ( /api/books/date/yyyy/mm/dd ) agregando otro atributo [Route] con una regex 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)
{
    // ...
}

Hay un detalle sutil pero importante aquí. 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 a la hora de diseñar los URI para la API.