Creare un'API REST con routing degli attributi in API Web ASP.NET 2

L'API Web 2 supporta un nuovo tipo di routing, denominato routing degli attributi. Per una panoramica generale del routing degli attributi, vedere Routing degli attributi nell'API Web 2. In questa esercitazione si userà il routing degli attributi per creare un'API REST per una raccolta di libri. L'API supporterà le azioni seguenti:

Azione URI di esempio
Ottenere un elenco di tutti i libri. /api/books
Ottenere un libro per ID. /api/books/1
Ottenere i dettagli di un libro. /api/books/1/details
Ottenere un elenco di libri per genere. /api/books/fantasy
Ottenere un elenco di libri in base alla data di pubblicazione. /api/books/date/2013-02-16 /api/books/date/2013/02/16 (modulo alternativo)
Ottenere un elenco di libri da parte di un determinato autore. /api/authors/1/books

Tutti i metodi sono di sola lettura (richieste HTTP GET).

Per il livello di dati si userà Entity Framework. I record di libro avranno i campi seguenti:

  • ID
  • Titolo
  • Genre
  • Data di pubblicazione
  • Prezzo
  • Descrizione
  • AuthorID (chiave esterna a una tabella Autori)

Per la maggior parte delle richieste, tuttavia, l'API restituirà un subset di questi dati (titolo, autore e genere). Per ottenere il record completo, il client richiede /api/books/{id}/details.

Prerequisiti

Visual Studio 2017 Community, Professional o Enterprise edition.

Creare il progetto di Visual Studio

Per iniziare, esegui Visual Studio. Scegliere Nuovo dal menu File e quindi selezionare Progetto.

Espandere la categoriaVisual C#installata>. In Visual C#selezionare Web. Nell'elenco dei modelli di progetto selezionare ASP.NET applicazione Web (.NET Framework). Assegnare un nome al progetto "BooksAPI".

Immagine della finestra di dialogo nuovo progetto

Nella finestra di dialogo Nuova applicazione Web ASP.NET selezionare il modello Vuoto . In "Aggiungi cartelle e riferimenti di base per", selezionare la casella di controllo API Web . Fare clic su OK.

Immagine della nuova finestra di dialogo Applicazione Web A P dot Net

In questo modo viene creato un progetto di scheletro configurato per la funzionalità api Web.

Modelli di dominio

Aggiungere quindi classi per i modelli di dominio. In Esplora soluzioni fare clic con il pulsante destro del mouse sulla cartella Modelli. Selezionare Aggiungi e quindi Classe. Denominare la classe Author.

Immagine della creazione di una nuova classe

Sostituire il codice in Author.cs con quanto segue:

using System.ComponentModel.DataAnnotations;

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

Aggiungere ora un'altra classe denominata 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; }
    }
}

Aggiungere un controller API Web

In questo passaggio verrà aggiunto un controller API Web che usa Entity Framework come livello dati.

Premere CTRL+MAIUSC+B per compilare il progetto. Entity Framework usa la reflection per individuare le proprietà dei modelli, pertanto è necessario un assembly compilato per creare lo schema del database.

In Esplora soluzioni fare clic sulla cartella Controller. Selezionare Aggiungi e quindi Controller.

Immagine del controller di aggiunta

Nella finestra di dialogo Aggiungi Scaffold selezionare Controller API Web 2 con azioni usando Entity Framework.

Immagine dell'aggiunta di scaffold

Nella finestra di dialogo Aggiungi controller immettere "BooksController". Selezionare la casella di controllo "Usa azioni controller asincrone". Per Classe Model selezionare "Book". Se non viene visualizzata la Book classe elencata nell'elenco a discesa, assicurarsi di aver compilato il progetto. Fare quindi clic sul pulsante "+".

Immagine della finestra di dialogo Aggiungi controller

Fare clic su Aggiungi nella finestra di dialogo Nuovo contesto dati .

Immagine della finestra di dialogo nuovo contesto dati

Fare clic su Aggiungi nella finestra di dialogo Aggiungi controller . Il scaffolding aggiunge una classe denominata BooksController che definisce il controller API. Aggiunge inoltre una classe denominata BooksAPIContext nella cartella Models, che definisce il contesto dei dati per Entity Framework.

Immagine delle nuove classi

Specificare il valore di inizializzazione del database

Dal menu Strumenti selezionare Gestione pacchetti NuGet e quindi console di Gestione pacchetti.

Nella finestra Console di gestione pacchetti immettere il comando seguente:

Add-Migration

Questo comando crea una cartella Migrations e aggiunge un nuovo file di codice denominato Configuration.cs. Aprire questo file e aggiungere il codice seguente al Configuration.Seed metodo.

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

Nella finestra Console di Gestione pacchetti digitare i comandi seguenti.

add-migration Initial

update-database

Questi comandi creano un database locale e richiamano il metodo Seed per popolare il database.

Immagine della console di Gestione pacchetti

Aggiungere classi DTO

Se si esegue l'applicazione ora e si invia una richiesta GET a /api/books/1, la risposta è simile alla seguente. Aggiunta del rientro per la leggibilità.

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

Voglio invece che questa richiesta restituisca un subset dei campi. Inoltre, voglio che restituisca il nome dell'autore, anziché l'ID autore. A questo scopo, verranno modificati i metodi del controller per restituire un oggetto DTO ( Data Transfer Object ) anziché il modello EF. Un DTO è un oggetto progettato solo per trasportare dati.

In Esplora soluzioni fare clic con il pulsante destro del mouse sul progetto e scegliere Aggiungi | nuova cartella. Assegnare un nome alla cartella "DTOs". Aggiungere una classe denominata BookDto alla cartella DTOs, con la definizione seguente:

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

Aggiungere un'altra classe denominata 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; }
    }
}

Aggiornare quindi la BooksController classe per restituire BookDto le istanze. Verrà usato il metodo Queryable.Select per proiettare Book le istanze alle BookDto istanze. Ecco il codice aggiornato per la classe controller.

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

Nota

Sono stati eliminati i PutBookmetodi , PostBooke DeleteBook perché non sono necessari per questa esercitazione.

Ora, se si esegue l'applicazione e si richiede /api/books/1, il corpo della risposta dovrebbe essere simile al seguente:

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

Aggiungere attributi di route

Verrà quindi convertito il controller in modo da usare il routing degli attributi. Aggiungere prima di tutto un attributo RoutePrefix al controller. Questo attributo definisce i segmenti URI iniziali per tutti i metodi in questo controller.

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

Aggiungere quindi gli attributi [Route] alle azioni del controller, come indicato di seguito:

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

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

Il modello di route per ogni metodo controller è il prefisso più la stringa specificata nell'attributo Route . Per il GetBook metodo, il modello di route include la stringa con parametri "{id:int}", che corrisponde se il segmento URI contiene un valore intero.

Metodo Modello di route URI di esempio
GetBooks "api/books" http://localhost/api/books
GetBook "api/books/{id:int}" http://localhost/api/books/5

Ottenere i dettagli del libro

Per ottenere i dettagli del libro, il client invierà una richiesta GET a /api/books/{id}/details, dove {id} è l'ID del libro.

Aggiungi alla classe BooksController il metodo seguente.

[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 si richiede /api/books/1/details, la risposta è simile alla seguente:

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

Ottenere libri per genere

Per ottenere un elenco di libri in un genere specifico, il client invierà una richiesta GET a /api/books/genre, dove genere è il nome del genere. ad esempio /api/books/fantasy.

Aggiungere il metodo seguente 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);
}

Di seguito viene definita una route contenente un parametro {genere} nel modello URI. Si noti che l'API Web è in grado di distinguere questi due URI e instradarli a metodi diversi:

/api/books/1

/api/books/fantasy

Questo perché il metodo include un vincolo che il GetBook segmento "id" deve essere un valore integer:

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

Se si richiede /api/books/fantasy, la risposta è simile alla seguente:

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

Ottenere libri per autore

Per ottenere un elenco di libri per un determinato autore, il client invierà una richiesta GET a /api/authors/id/books, dove ID è l'ID dell'autore.

Aggiungere il metodo seguente 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);
}

Questo esempio è interessante perché "libri" viene trattata una risorsa figlio di "autori". Questo modello è abbastanza comune nelle API RESTful.

Il tilde (~) nel modello di route esegue l'override del prefisso di route nell'attributo RoutePrefix .

Ottenere libri in base alla data di pubblicazione

Per ottenere un elenco di libri in base alla data di pubblicazione, il client invierà una richiesta GET a /api/books/date/yyyy-mm-dd, dove a-mm-dd è la data.

Ecco un modo per eseguire questa operazione:

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

Il {pubdate:datetime} parametro è vincolato alla corrispondenza di un valore DateTime . Questo funziona, ma è in realtà più permissivo di quanto ci piacerebbe. Ad esempio, questi URI corrispondono anche alla route:

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

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

Non c'è nulla di sbagliato per consentire questi URI. È tuttavia possibile limitare la route a un formato specifico aggiungendo un vincolo di espressione regolare al modello di route:

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

Ora solo le date nel formato "aa-mm-dd" corrisponderanno. Si noti che non si usa la regex per verificare che sia stata ottenuta una data reale. Viene gestito quando l'API Web tenta di convertire il segmento URI in un'istanza di DateTime . Una data non valida, ad esempio '2012-47-99' non verrà convertita e il client riceverà un errore 404.

È anche possibile supportare un separatore di barre (/api/books/date/yyyy/mm/dd) aggiungendo un altro attributo [Route] con una regex diversa.

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

C'è un sottile ma importante dettaglio qui. Il secondo modello di route ha un carattere jolly (*) all'inizio del parametro {pubdate} :

{*pubdate: ... }

Questo indica al motore di routing che {pubdate} deve corrispondere al resto dell'URI. Per impostazione predefinita, un parametro modello corrisponde a un singolo segmento URI. In questo caso, si vuole che {pubdate} si estende su diversi segmenti di URI:

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

Codice controller

Ecco il codice completo per la 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);
        }
    }
}

Riepilogo

Il routing degli attributi offre maggiore controllo e maggiore flessibilità durante la progettazione degli URI per l'API.