Создание 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 . Нажмите кнопку ОК.
При этом создается проект схемы, настроенный для работы с веб-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.