Создание REST API с маршрутизацией атрибутов в веб-API ASP.NET 2

Веб-API 2 поддерживает новый тип маршрутизации, называемый маршрутизацией атрибутов. Общие сведения о маршрутизации атрибутов см. в статье Маршрутизация атрибутов в веб-API 2. В этом руководстве вы будете использовать маршрутизацию атрибутов для создания REST API для коллекции книг. API будет поддерживать следующие действия:

Действие Пример URI
Получение списка всех книг. /api/books
Получение книги по идентификатору. /api/books/1
Получение сведений о книге. /api/books/1/details
Получение списка книг по жанрам. /api/books/fantasy
Получение списка книг по дате публикации. /api/books/date/2013-02-16 /api/books/date/2013/02/16 (альтернативная форма)
Получение списка книг определенного автора. /api/authors/1/books

Все методы доступны только для чтения (HTTP-запросы GET).

Для уровня данных мы будем использовать Entity Framework. Записи книг будут содержать следующие поля:

  • ID
  • Название
  • Genre
  • дата публикации.
  • Цена
  • Описание
  • AuthorID (внешний ключ к таблице Author)

Однако для большинства запросов API возвращает подмножество этих данных (название, автор и жанр). Чтобы получить полную запись, клиент запрашивает /api/books/{id}/details.

Предварительные требования

Visual Studio 2017 Выпуск Community, Professional или Enterprise.

Создание проекта Visual Studio

Сначала запустите Visual Studio. В меню Файл выберите пункт Создать, а затем — Проект.

Разверните категорию Установленные>Visual C# . В разделе Visual C# выберите Интернет. В списке шаблонов проектов выберите ASP.NET Веб-приложение (платформа .NET Framework). Назовите проект "BooksAPI".

Изображение диалогового окна нового проекта

В диалоговом окне Новое веб-приложение ASP.NET выберите пустой шаблон. В разделе "Добавление папок и основных ссылок для" установите флажок Веб-API . Нажмите кнопку ОК.

Изображение диалогового окна нового веб-приложения A P Dot Net

При этом создается проект схемы, настроенный для работы с веб-API.

Модели предметной области

Затем добавьте классы для моделей предметной области. В обозревателе решений щелкните правой кнопкой мыши папку Models. Выберите Добавить, а затем — Класс. Назовите класс Author.

Изображение создания класса

Замените код в файле Author.cs следующим кодом:

using System.ComponentModel.DataAnnotations;

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

Теперь добавьте еще один класс с именем 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; }
    }
}

Добавление контроллера веб-API

На этом шаге мы добавим контроллер веб-API, который использует Entity Framework в качестве уровня данных.

Для сборки проекта нажмите CTRL+SHIFT+B. Entity Framework использует отражение для обнаружения свойств моделей, поэтому для создания схемы базы данных требуется скомпилированная сборка.

В обозревателе решений щелкните правой кнопкой мыши папку Controllers. Выберите Добавить, а затем — Контроллер.

Изображение добавления контроллера

В диалоговом окне Добавление шаблона выберите Контроллер Веб-API 2 с действиями с помощью Entity Framework.

Изображение добавления шаблона

В диалоговом окне Добавление контроллера в поле Имя контроллера введите "BooksController". Установите флажок "Использовать действия асинхронного контроллера". В поле Класс модели выберите "Book". (Если вы не видите класс, указанный Book в раскрывающемся списке, убедитесь, что вы создали проект.) Затем нажмите кнопку "+".

Изображение диалогового окна добавления контроллера

Нажмите кнопку Добавить в диалоговом окне Новый контекст данных .

Изображение диалогового окна нового контекста данных

Нажмите кнопку Добавить в диалоговом окне Добавление контроллера . При формировании шаблонов добавляется класс с именем BooksController , определяющий контроллер API. Он также добавляет класс с именем BooksAPIContext в папку Models, которая определяет контекст данных для Entity Framework.

Изображение новых классов

Заполнение базы данных

В меню Сервис выберите Диспетчер пакетов NuGet, а затем консоль диспетчера пакетов.

В окне "Консоль диспетчера пакетов" введите следующую команду:

Add-Migration

Эта команда создает папку Migrations и добавляет новый файл кода с именем Configuration.cs. Откройте этот файл и добавьте следующий код в 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},
    });
}

В окне Консоль диспетчера пакетов введите следующие команды.

add-migration Initial

update-database

Эти команды создают локальную базу данных и вызывают метод Seed для заполнения базы данных.

Изображение консоли диспетчера пакетов

Добавление классов DTO

Если запустить приложение сейчас и отправить запрос GET в /api/books/1, ответ будет выглядеть примерно так: (Для удобства чтения добавлен отступ.)

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

Вместо этого я хочу, чтобы этот запрос возвращал подмножество полей. Кроме того, я хочу, чтобы он возвращал имя автора, а не идентификатор автора. Для этого мы изменим методы контроллера, чтобы возвращать объект передачи данных (DTO) вместо модели EF. DTO — это объект, предназначенный только для передачи данных.

В Обозреватель решений щелкните проект правой кнопкой мыши и выберите Добавить | новую папку. Назовите папку DTO. Добавьте класс с именем BookDto в папку DTO со следующим определением:

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

Добавьте еще один класс с именем 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; }
    }
}

Затем обновите класс , BooksController чтобы он возвращал BookDto экземпляры. Мы будем использовать метод Queryable.Select для проецирования Book экземпляров на BookDto экземпляры. Ниже приведен обновленный код для класса контроллера.

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

Примечание

Методы PutBook, PostBookи DeleteBook удалены, так как они не нужны для работы с этим руководством.

Теперь при запуске приложения и запросе /api/books/1 текст ответа должен выглядеть следующим образом:

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

Добавление атрибутов маршрута

Далее мы преобразуем контроллер для использования маршрутизации атрибутов. Сначала добавьте атрибут RoutePrefix к контроллеру. Этот атрибут определяет начальные сегменты URI для всех методов на этом контроллере.

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

Затем добавьте атрибуты [Маршрут] к действиям контроллера, как показано ниже.

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

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

Шаблон маршрута для каждого метода контроллера — это префикс и строка, указанная в атрибуте Route . GetBook Для метода шаблон маршрута включает параметризованную строку "{id:int}", которая соответствует, если сегмент URI содержит целочисленное значение.

Метод Шаблон маршрута Пример URI
GetBooks "api/books" http://localhost/api/books
GetBook "api/books/{id:int}" http://localhost/api/books/5

Получение сведений о книге

Чтобы получить сведения о книге, клиент отправляет запрос GET на /api/books/{id}/detailsадрес , где {id} — это идентификатор книги.

Добавьте следующий метод в класс 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);
}

При запросе /api/books/1/detailsответ выглядит следующим образом:

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

Получение книг по жанру

Чтобы получить список книг в определенном жанре, клиент отправляет запрос GET в /api/books/genre, где genre — это имя жанра. (Например, /api/books/fantasy).

Добавьте следующий метод в 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);
}

Здесь мы определяем маршрут, содержащий параметр {genre} в шаблоне URI. Обратите внимание, что веб-API может различать эти два URI и направлять их в разные методы:

/api/books/1

/api/books/fantasy

Это связано с тем, что GetBook метод содержит ограничение на то, что сегмент "id" должен быть целочисленным значением:

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

При запросе /api/books/fantasy ответ выглядит следующим образом:

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

Получить книги по авторам

Чтобы получить список книг для определенного автора, клиент отправляет запрос GET в /api/authors/id/books, где id — это идентификатор автора.

Добавьте следующий метод в 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);
}

Этот пример интересен, так как "книги" обрабатываются как дочерний ресурс "авторы". Этот шаблон довольно распространен в ИНТЕРФЕЙСАх API RESTful.

Тильда (~) в шаблоне маршрута переопределяет префикс маршрута в атрибуте RoutePrefix .

Получение книг по дате публикации

Чтобы получить список книг по дате публикации, клиент отправляет запрос GET на /api/books/date/yyyy-mm-ddадрес , где гггг-мм-дд — это дата.

Вот один из способов сделать это:

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

Параметр {pubdate:datetime} ограничен в соответствии со значением DateTime . Это работает, но на самом деле это более разрешительно, чем нам хотелось бы. Например, эти URI также будут соответствовать маршруту:

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

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

Нет ничего плохого в том, чтобы разрешить эти URI. Однако можно ограничить маршрут определенным форматом, добавив в шаблон маршрута ограничение регулярных выражений:

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

Теперь будут соответствовать только даты в форме "гггг-мм-дд". Обратите внимание, что регулярное выражение не используется для проверки реальной даты. Это обрабатывается, когда веб-API пытается преобразовать сегмент URI в экземпляр DateTime . Не удастся преобразовать недопустимую дату, например 2012-47-99, и клиент получит ошибку 404.

Вы также можете поддерживать разделитель косой черты (/api/books/date/yyyy/mm/dd), добавив еще один атрибут [Route] с другим регулярным выражением.

[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)
{
    // ...
}

Здесь есть тонкие, но важные детали. Второй шаблон маршрута имеет подстановочный знак (*) в начале параметра {pubdate}:

{*pubdate: ... }

Это сообщает подсистеме маршрутизации, что {pubdate} должен соответствовать остальной части URI. По умолчанию параметр шаблона соответствует одному сегменту URI. В этом случае мы хотим, чтобы {pubdate} охватывал несколько сегментов URI:

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

Код контроллера

Ниже приведен полный код для класса 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);
        }
    }
}

Итоги

Маршрутизация атрибутов обеспечивает больший контроль и большую гибкость при проектировании URI для API.