Udostępnij za pośrednictwem


Tworzenie interfejsu API REST z routingiem atrybutów w ASP.NET web API 2

Internetowy interfejs API 2 obsługuje nowy typ routingu, nazywany routingiem atrybutów. Aby zapoznać się z ogólnym omówieniem routingu atrybutów, zobacz Routing atrybutów w internetowym interfejsie API 2. W tym samouczku użyjesz routingu atrybutów do utworzenia interfejsu API REST dla kolekcji książek. Interfejs API będzie obsługiwać następujące akcje:

Akcja Przykładowy identyfikator URI
Pobierz listę wszystkich książek. /api/books
Pobierz książkę według identyfikatora. /api/books/1
Uzyskaj szczegółowe informacje o książce. /api/books/1/details
Pobierz listę książek według gatunku. /api/books/fantasy
Pobierz listę książek według daty publikacji. /api/books/date/2013-02-16 /api/books/date/2013/02/16 (formularz alternatywny)
Pobierz listę książek według określonego autora. /api/authors/1/books

Wszystkie metody są tylko do odczytu (żądania HTTP GET).

W przypadku warstwy danych użyjemy platformy Entity Framework. Rekordy książek będą miały następujące pola:

  • ID (Identyfikator)
  • Tytuł
  • Gatunek
  • Data publikacji
  • Cena
  • Opis
  • AuthorID (klucz obcy tabeli Autorzy)

Jednak w przypadku większości żądań interfejs API zwróci podzestaw tych danych (tytuł, autor i gatunek). Aby uzyskać pełny rekord, klient żąda /api/books/{id}/details.

Wymagania wstępne

Visual Studio 2017 Community, Professional lub Enterprise Edition.

Tworzenie projektu programu Visual Studio

Rozpocznij od uruchomienia programu Visual Studio. W menu Plik wybierz pozycję Nowy , a następnie wybierz pozycję Projekt.

Rozwiń kategorię Zainstalowane środowisko>Visual C# . W obszarze Visual C# wybierz pozycję Sieć Web. Na liście szablonów projektów wybierz pozycję ASP.NET Aplikacja internetowa (.NET Framework). Nadaj projektowi nazwę "BooksAPI".

Obraz okna dialogowego nowego projektu

W oknie dialogowym Nowa aplikacja internetowa ASP.NET wybierz szablon Pusty . W obszarze "Dodaj foldery i odwołania podstawowe dla" zaznacz pole wyboru Internetowy interfejs API . Kliknij przycisk OK.

Obraz przedstawiający nowe okno dialogowe aplikacji internetowej A S P dot Net

Spowoduje to utworzenie szkieletowego projektu skonfigurowanego pod kątem funkcjonalności internetowego interfejsu API.

Modele domen

Następnie dodaj klasy dla modeli domeny. W Eksplorator rozwiązań kliknij prawym przyciskiem myszy folder Models. Wybierz pozycję Dodaj, a następnie wybierz pozycję Klasa. Nadaj klasie Authornazwę .

Obraz przedstawiający tworzenie nowej klasy

Zastąp kod w pliku Author.cs następującym kodem:

using System.ComponentModel.DataAnnotations;

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

Teraz dodaj kolejną klasę o nazwie 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; }
    }
}

Dodawanie kontrolera internetowego interfejsu API

W tym kroku dodamy kontroler internetowego interfejsu API, który używa platformy Entity Framework jako warstwy danych.

Naciśnij kombinację klawiszy CTRL+SHIFT+B. Projekt zostanie skompilowany. Program Entity Framework używa odbicia w celu odnalezienia właściwości modeli, dlatego wymaga skompilowanego zestawu do utworzenia schematu bazy danych.

W Eksplorator rozwiązań kliknij prawym przyciskiem myszy folder Controllers. Wybierz pozycję Dodaj, a następnie wybierz pozycję Kontroler.

Obraz przedstawiający dodawanie kontrolera

W oknie dialogowym Dodawanie szkieletu wybierz pozycję Kontroler internetowego interfejsu API 2 z akcjami przy użyciu programu Entity Framework.

Obraz przedstawiający dodawanie szkieletu

W oknie dialogowym Dodawanie kontrolera w polu Nazwa kontrolera wprowadź wartość "BooksController". Zaznacz pole wyboru "Użyj akcji kontrolera asynchronicznego". W polu Klasa modelu wybierz pozycję "Book". (Jeśli nie widzisz klasy wymienionej Book na liście rozwijanej, upewnij się, że utworzono projekt). Następnie kliknij przycisk "+".

Obraz okna dialogowego dodawania kontrolera

Kliknij przycisk Dodaj w oknie dialogowym Nowy kontekst danych .

Obraz okna dialogowego nowego kontekstu danych

Kliknij przycisk Dodaj w oknie dialogowym Dodawanie kontrolera . Szkielet dodaje klasę o nazwie BooksController definiującą kontroler interfejsu API. Dodaje również klasę o nazwie BooksAPIContext w folderze Models, która definiuje kontekst danych dla programu Entity Framework.

Obraz przedstawiający nowe klasy

Inicjowanie bazy danych

Z menu Narzędzia wybierz pozycję Menedżer pakietów NuGet, a następnie wybierz pozycję Konsola menedżera pakietów.

W oknie Konsola menedżera pakietów wprowadź następujące polecenie:

Add-Migration

To polecenie tworzy folder Migrations i dodaje nowy plik kodu o nazwie Configuration.cs. Otwórz ten plik i dodaj następujący kod do Configuration.Seed metody .

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

W oknie Konsola menedżera pakietów wpisz następujące polecenia.

add-migration Initial

update-database

Te polecenia tworzą lokalną bazę danych i wywołują metodę Seed w celu wypełnienia bazy danych.

Obraz przedstawiający konsolę menedżera pakietów

Dodawanie klas DTO

Jeśli teraz uruchomisz aplikację i wyślesz żądanie GET do adresu /api/books/1, odpowiedź wygląda podobnie do poniższej. (Dodano wcięcie na potrzeby czytelności).

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

Zamiast tego chcę, aby to żądanie zwróciło podzbiór pól. Ponadto chcę, aby zwracała nazwę autora, a nie identyfikator autora. W tym celu zmodyfikujemy metody kontrolera tak, aby zwracały obiekt transferu danych (DTO) zamiast modelu EF. Obiekt DTO jest obiektem zaprojektowanym tylko do przenoszenia danych.

W Eksplorator rozwiązań kliknij prawym przyciskiem myszy projekt i wybierz polecenie Dodaj | nowy folder. Nadaj folderowi nazwę "DTO". Dodaj klasę o nazwie BookDto do folderu DTOs z następującą definicją:

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

Dodaj kolejną klasę o nazwie 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; }
    }
}

Następnie zaktualizuj klasę BooksController , aby zwracała BookDto wystąpienia. Użyjemy metody Queryable.Select do projekcji Book wystąpień do BookDto wystąpień. Oto zaktualizowany kod dla klasy kontrolera.

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

Uwaga

Usunięto PutBookmetody , PostBooki DeleteBook , ponieważ nie są one potrzebne w tym samouczku.

Teraz, jeśli uruchomisz aplikację i zażądasz polecenia /api/books/1, treść odpowiedzi powinna wyglądać następująco:

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

Dodawanie atrybutów trasy

Następnie przekonwertujemy kontroler, aby używał routingu atrybutów. Najpierw dodaj atrybut RoutePrefix do kontrolera. Ten atrybut definiuje początkowe segmenty identyfikatora URI dla wszystkich metod na tym kontrolerze.

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

Następnie dodaj atrybuty [Route] do akcji kontrolera w następujący sposób:

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

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

Szablon trasy dla każdej metody kontrolera jest prefiksem oraz ciągiem określonym w atrybucie Route . W przypadku GetBook metody szablon trasy zawiera sparametryzowany ciąg "{id:int}", który jest zgodny, jeśli segment identyfikatora URI zawiera wartość całkowitą.

Metoda Szablon trasy Przykładowy identyfikator URI
GetBooks "api/books" http://localhost/api/books
GetBook "api/books/{id:int}" http://localhost/api/books/5

Pobieranie szczegółów książki

Aby uzyskać szczegóły książki, klient wyśle żądanie GET do /api/books/{id}/details, gdzie {id} jest identyfikatorem książki.

Dodaj następującą metodę BooksController do klasy .

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

Jeśli zażądasz /api/books/1/detailsżądania , odpowiedź będzie wyglądać następująco:

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

Pobieranie książek według gatunku

Aby uzyskać listę książek w określonym gatunku, klient wyśle żądanie GET do /api/books/genre, gdzie gatunek jest nazwą gatunku. (Na przykład /api/books/fantasy.)

Dodaj następującą metodę do BooksControllermetody .

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

W tym miejscu definiujemy trasę zawierającą parametr {genre} w szablonie identyfikatora URI. Zwróć uwagę, że internetowy interfejs API może odróżnić te dwa identyfikatory URI i kierować je do różnych metod:

/api/books/1

/api/books/fantasy

Wynika to z GetBook tego, że metoda zawiera ograniczenie, że segment "id" musi być wartością całkowitą:

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

Jeśli zażądasz /api/books/fantasy, odpowiedź będzie wyglądać następująco:

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

Pobieranie książek według autora

Aby uzyskać listę książek dla określonego autora, klient wyśle żądanie GET do /api/authors/id/books, gdzie identyfikator jest identyfikatorem autora.

Dodaj następującą metodę do BooksControllermetody .

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

Ten przykład jest interesujący, ponieważ "książki" są traktowane jako zasób podrzędny "autorów". Ten wzorzec jest dość powszechny w interfejsach API RESTful.

Tylda (~) w szablonie trasy zastępuje prefiks trasy w atrybucie RoutePrefix .

Pobieranie książek według daty publikacji

Aby uzyskać listę książek według daty publikacji, klient wyśle żądanie GET do /api/books/date/yyyy-mm-dd, gdzie rrrr-mm-dd jest datą.

Oto jeden ze sposobów, aby to zrobić:

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

Parametr {pubdate:datetime} jest ograniczony do dopasowania wartości DateTime . To działa, ale to rzeczywiście bardziej permissywne niż chcielibyśmy. Na przykład te identyfikatory URI będą również zgodne z trasą:

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

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

Nie ma nic złego w przypadku zezwalania na te identyfikatory URI. Można jednak ograniczyć trasę do określonego formatu, dodając ograniczenie wyrażenia regularnego do szablonu trasy:

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

Teraz tylko daty w postaci "rrrr-mm-dd" będą zgodne. Zwróć uwagę, że nie używamy wyrażenia regularnego do sprawdzania, czy mamy rzeczywistą datę. Jest to obsługiwane, gdy internetowy interfejs API próbuje przekonwertować segment identyfikatora URI na wystąpienie typu DateTime . Nie można przekonwertować nieprawidłowej daty, takiej jak "2012-47-99", a klient otrzyma błąd 404.

Można również obsługiwać separator ukośnika (/api/books/date/yyyy/mm/dd), dodając inny atrybut [Route] z innym wyrażeniem regularnym.

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

Jest tu subtelny, ale ważny szczegół. Drugi szablon trasy ma symbol wieloznaczny (*) na początku parametru {pubdate}:

{*pubdate: ... }

Informuje to aparat routingu, że {pubdate} powinien być zgodny z resztą identyfikatora URI. Domyślnie parametr szablonu jest zgodny z pojedynczym segmentem identyfikatora URI. W tym przypadku chcemy, aby {pubdate} obejmowało kilka segmentów identyfikatora URI:

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

Kod kontrolera

Oto kompletny kod klasy 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);
        }
    }
}

Podsumowanie

Routing atrybutów zapewnia większą kontrolę i większą elastyczność podczas projektowania identyfikatorów URI dla interfejsu API.