Criar uma API REST com Roteamento de Atributo no ASP.NET Web API 2

A API Web 2 dá suporte a um novo tipo de roteamento, chamado roteamento de atributo. Para obter uma visão geral do roteamento de atributos, consulte Roteamento de atributo na API Web 2. Neste tutorial, você usará o roteamento de atributos para criar uma API REST para uma coleção de livros. A API dará suporte às seguintes ações:

Ação URI de exemplo
Obtenha uma lista de todos os livros. /api/books
Obtenha um livro por ID. /api/books/1
Obtenha os detalhes de um livro. /api/books/1/details
Obtenha uma lista de livros por gênero. /api/books/fantasy
Obtenha uma lista de livros por data de publicação. /api/books/date/2013-02-16 /api/books/date/2013/02/16 (formulário alternativo)
Obtenha uma lista de livros de um autor específico. /api/authors/1/books

Todos os métodos são somente leitura (solicitações HTTP GET).

Para a camada de dados, usaremos o Entity Framework. Os registros do livro terão os seguintes campos:

  • ID
  • Título
  • Gênero
  • Data da publicação
  • Preço
  • Descrição
  • AuthorID (chave estrangeira para uma tabela Authors)

Para a maioria das solicitações, no entanto, a API retornará um subconjunto desses dados (título, autor e gênero). Para obter o registro completo, o cliente solicita /api/books/{id}/details.

Pré-requisitos

Visual Studio 2017 Community, Professional ou Enterprise Edition.

Criar o projeto do Visual Studio

Comece executando o Visual Studio. No menu Arquivo, selecione Novo e Projeto.

Expanda a categoriaVisual C#Instalado>. Em Visual C#, selecione Web. Na lista de modelos de projeto, selecione ASP.NET Aplicativo Web (.NET Framework). Nomeie o projeto como "BooksAPI".

Imagem da caixa de diálogo novo projeto

Na caixa de diálogo Novo aplicativo Web ASP.NET , selecione o modelo Vazio . Em "Adicionar pastas e referências principais para", marque a caixa de seleção API Web . Clique em OK.

Imagem da nova caixa de diálogo do aplicativo Web A SP dot Net

Isso cria um projeto esqueleto configurado para a funcionalidade da API Web.

Modelos de domínio

Em seguida, adicione classes para modelos de domínio. No Gerenciador de Soluções, clique com o botão direito do mouse na pasta Modelos. Selecione Adicionar e , em seguida, classe. Nome da classe Author.

Imagem da criação de uma nova classe

Substitua o código em Author.cs pelo seguinte:

using System.ComponentModel.DataAnnotations;

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

Agora, adicione outra classe chamada 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; }
    }
}

Adicionar um controlador de API Web

Nesta etapa, adicionaremos um controlador de API Web que usa o Entity Framework como a camada de dados.

Pressione CTRL+SHIFT+B para criar o projeto. O Entity Framework usa reflexão para descobrir as propriedades dos modelos, portanto, requer um assembly compilado para criar o esquema de banco de dados.

No Gerenciador de Soluções, clique com o botão direito do mouse na pasta Controladores. Selecione Adicionare controlador.

Imagem de adicionar controlador

Na caixa de diálogo Adicionar Scaffold , selecione Controlador da API Web 2 com ações, usando o Entity Framework.

Imagem de adicionar scaffold

Na caixa de diálogo Adicionar Controlador , em Nome do controlador, insira "BooksController". Marque a caixa de seleção "Usar ações do controlador assíncrono". Para Classe de modelo, selecione "Livro". (Se você não vir a Book classe listada na lista suspensa, certifique-se de ter criado o projeto.) Em seguida, clique no botão "+".

Imagem da caixa de diálogo Adicionar controlador

Clique em Adicionar na caixa de diálogo Novo Contexto de Dados .

Imagem da nova caixa de diálogo de contexto de dados

Clique em Adicionar na caixa de diálogo Adicionar Controlador . O scaffolding adiciona uma classe chamada BooksController que define o controlador de API. Ele também adiciona uma classe chamada BooksAPIContext na pasta Models, que define o contexto de dados para o Entity Framework.

Imagem de novas classes

Propagar o banco de dados

No menu Ferramentas, selecione Gerenciador de Pacotes NuGet e, em seguida, selecione Console do Gerenciador de Pacotes.

Na janela Console do Gerenciador de Pacotes, digite o seguinte comando:

Add-Migration

Esse comando cria uma pasta Migrações e adiciona um novo arquivo de código chamado Configuration.cs. Abra esse arquivo e adicione o código a Configuration.Seed seguir ao 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},
    });
}

Na janela Console do Gerenciador de Pacotes, digite os comandos a seguir.

add-migration Initial

update-database

Esses comandos criam um banco de dados local e invocam o método Seed para popular o banco de dados.

Imagem do Console do Gerenciador de Pacotes

Adicionar classes DTO

Se você executar o aplicativo agora e enviar uma solicitação GET para /api/books/1, a resposta será semelhante à seguinte. (Adição de recuo para legibilidade.)

{
  "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
}

Em vez disso, quero que essa solicitação retorne um subconjunto dos campos. Além disso, quero que ele retorne o nome do autor, em vez da ID do autor. Para fazer isso, modificaremos os métodos do controlador para retornar um DTO (objeto de transferência de dados ) em vez do modelo EF. Um DTO é um objeto projetado apenas para transportar dados.

Em Gerenciador de Soluções, clique com o botão direito do mouse no projeto e selecione Adicionar | Nova Pasta. Nomeie a pasta como "DTOs". Adicione uma classe chamada BookDto à pasta DTOs, com a seguinte definição:

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

Adicione outra classe chamada 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; }
    }
}

Em seguida, atualize a BooksController classe para retornar BookDto instâncias. Usaremos o método Queryable.Select para projetar Book instâncias em BookDto instâncias. Aqui está o código atualizado para a classe do 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);
        }
    }
}

Observação

Exclui os PutBookmétodos , PostBooke DeleteBook , porque eles não são necessários para este tutorial.

Agora, se você executar o aplicativo e solicitar /api/books/1, o corpo da resposta deverá ter esta aparência:

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

Adicionar atributos de rota

Em seguida, converteremos o controlador para usar o roteamento de atributo. Primeiro, adicione um atributo RoutePrefix ao controlador. Esse atributo define os segmentos de URI iniciais para todos os métodos nesse controlador.

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

Em seguida, adicione atributos [Route] às ações do controlador, da seguinte maneira:

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

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

O modelo de rota para cada método de controlador é o prefixo mais a cadeia de caracteres especificada no atributo Route . Para o GetBook método , o modelo de rota inclui a cadeia de caracteres parametrizada "{id:int}", que corresponde se o segmento de URI contiver um valor inteiro.

Método Modelo de rota Exemplo de URI
GetBooks "api/books" http://localhost/api/books
GetBook "api/books/{id:int}" http://localhost/api/books/5

Obter detalhes do livro

Para obter detalhes do livro, o cliente enviará uma solicitação GET para /api/books/{id}/details, em que {id} é a ID do livro.

Adicione o método a seguir à classe 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);
}

Se você solicitar /api/books/1/details, a resposta terá esta aparência:

{
  "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"
}

Obter livros por gênero

Para obter uma lista de livros em um gênero específico, o cliente enviará uma solicitação GET para /api/books/genre, em que gênero é o nome do gênero. (Por exemplo, /api/books/fantasy.)

Adicione o método a seguir 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);
}

Aqui, estamos definindo uma rota que contém um parâmetro {genre} no modelo de URI. Observe que a API Web é capaz de distinguir esses dois URIs e encaminhá-los para métodos diferentes:

/api/books/1

/api/books/fantasy

Isso ocorre porque o GetBook método inclui uma restrição de que o segmento "id" deve ser um valor inteiro:

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

Se você solicitar /api/books/fantasy, a resposta terá esta aparência:

[ { "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" } ]

Obter livros por autor

Para obter uma lista de livros para um autor específico, o cliente enviará uma solicitação GET para /api/authors/id/books, em que id é a ID do autor.

Adicione o método a seguir 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 exemplo é interessante porque "livros" é tratado como um recurso filho de "autores". Esse padrão é bastante comum em APIs RESTful.

O bloco (~) no modelo de rota substitui o prefixo de rota no atributo RoutePrefix .

Obter livros por data de publicação

Para obter uma lista de livros por data de publicação, o cliente enviará uma solicitação GET para /api/books/date/yyyy-mm-dd, em que yyyy-mm-dd é a data.

Aqui está uma maneira de fazer isso:

[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);
}

O {pubdate:datetime} parâmetro é restrito para corresponder a um valor DateTime . Isso funciona, mas na verdade é mais permissivo do que gostaríamos. Por exemplo, esses URIs também corresponderão à rota:

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

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

Não há nada de errado em permitir esses URIs. No entanto, você pode restringir a rota a um formato específico adicionando uma restrição de expressão regular ao modelo de rota:

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

Agora, somente as datas no formato "yyyy-mm-dd" corresponderão. Observe que não usamos o regex para validar se temos uma data real. Isso é tratado quando a API Web tenta converter o segmento de URI em uma instância datetime . Uma data inválida, como '2012-47-99', não será convertida e o cliente receberá um erro 404.

Você também pode dar suporte a um separador de barras (/api/books/date/yyyy/mm/dd) adicionando outro atributo [Route] com um 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)
{
    // ...
}

Há um detalhe sutil, mas importante aqui. O segundo modelo de rota tem um caractere curinga (*) no início do parâmetro {pubdate}:

{*pubdate: ... }

Isso informa ao mecanismo de roteamento que {pubdate} deve corresponder ao restante do URI. Por padrão, um parâmetro de modelo corresponde a um único segmento de URI. Nesse caso, queremos que {pubdate} abrange vários segmentos de URI:

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

Código do controlador

Aqui está o código completo para a classe 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);
        }
    }
}

Resumo

O roteamento de atributo oferece mais controle e maior flexibilidade ao projetar os URIs para sua API.