Create a REST API with Attribute Routing in ASP.NET Web API 2
by Mike Wasson
Web API 2 supports a new type of routing, called attribute routing. For a general overview of attribute routing, see Attribute Routing in Web API 2. In this tutorial, you will use attribute routing to create a REST API for a collection of books. The API will support the following actions:
Action | Example URI |
---|---|
Get a list of all books. | /api/books |
Get a book by ID. | /api/books/1 |
Get the details of a book. | /api/books/1/details |
Get a list of books by genre. | /api/books/fantasy |
Get a list of books by publication date. | /api/books/date/2013-02-16 /api/books/date/2013/02/16 (alternate form) |
Get a list of books by a particular author. | /api/authors/1/books |
All methods are read-only (HTTP GET requests).
For the data layer, we’ll use Entity Framework. Book records will have the following fields:
- ID
- Title
- Genre
- Publication date
- Price
- Description
- AuthorID (foreign key to an Authors table)
For most requests, however, the API will return a subset of this data (title, author, and genre). To get the complete record, the client requests /api/books/{id}/details
.
Prerequisites
Visual Studio 2017 Community, Professional or Enterprise edition.
Create the Visual Studio Project
Start by running Visual Studio. From the File menu, select New and then select Project.
In the Templates pane, select Installed Templates and expand the Visual C# node. Under Visual C#, select Web. In the list of project templates, select ASP.NET MVC 4 Web Application. Name the project “BooksAPI”.
In the New ASP.NET Project dialog, select the Empty template. Under “Add folders and core references for”, select the Web API checkbox. Click Create Project.
This creates a skeleton project that is configured for Web API functionality.
Domain Models
Next, add classes for domain models. In Solution Explorer, right-click the Models folder. Select Add, then select Class. Name the class Author
.
Replace the code in Author.cs with the following:
[!code-csharpMain]
1: using System.ComponentModel.DataAnnotations;
2:
3: namespace BooksAPI.Models
4: {
5: public class Author
6: {
7: public int AuthorId { get; set; }
8: [Required]
9: public string Name { get; set; }
10: }
11: }
Now add another class named Book
.
[!code-csharpMain]
1: using System;
2: using System.ComponentModel.DataAnnotations;
3: using System.ComponentModel.DataAnnotations.Schema;
4:
5: namespace BooksAPI.Models
6: {
7: public class Book
8: {
9: public int BookId { get; set; }
10: [Required]
11: public string Title { get; set; }
12: public decimal Price { get; set; }
13: public string Genre { get; set; }
14: public DateTime PublishDate { get; set; }
15: public string Description { get; set; }
16: public int AuthorId { get; set; }
17: [ForeignKey("AuthorId")]
18: public Author Author { get; set; }
19: }
20: }
Add a Web API Controller
In this step, we’ll add a Web API controller that uses Entity Framework as the data layer.
Press CTRL+SHIFT+B to build the project. Entity Framework uses reflection to discover the properties of the models, so it requires a compiled assembly to create the database schema.
In Solution Explorer, right-click the Controllers folder. Select Add, then select Controller.
In the Add Scaffold dialog, select “Web API 2 Controller with read/write actions, using Entity Framework.”
In the Add Controller dialog, for Controller name, enter “BooksController”. Select the “Use async controller actions” checkbox. For Model class, select “Book”. (If you don’t see the Book
class listed in the dropdown, make sure that you built the project.) Then click the “+” button.
Click Add in the New Data Context dialog.
Click Add in the Add Controller dialog. The scaffolding adds a class named BooksController
that defines the API controller. It also adds a class named BooksAPIContext
in the Models folder, which defines the data context for Entity Framework.
Seed the Database
From the Tools menu, select Library Package Manager, and then select Package Manager Console.
In the Package Manager Console window, enter the following command:
[!code-powershellMain]
1: enable-migrations
This command creates a Migrations folder and adds a new code file named Configuration.cs. Open this file and add the following code to the Configuration.Seed
method.
[!code-csharpMain]
1: protected override void Seed(BooksAPI.Models.BooksAPIContext context)
2: {
3: context.Authors.AddOrUpdate(new Author[] {
4: new Author() { AuthorId = 1, Name = "Ralls, Kim" },
5: new Author() { AuthorId = 2, Name = "Corets, Eva" },
6: new Author() { AuthorId = 3, Name = "Randall, Cynthia" },
7: new Author() { AuthorId = 4, Name = "Thurman, Paula" }
8: });
9:
10: context.Books.AddOrUpdate(new Book[] {
11: new Book() { BookId = 1, Title= "Midnight Rain", Genre = "Fantasy",
12: PublishDate = new DateTime(2000, 12, 16), AuthorId = 1, Description =
13: "A former architect battles an evil sorceress.", Price = 14.95M },
14:
15: new Book() { BookId = 2, Title = "Maeve Ascendant", Genre = "Fantasy",
16: PublishDate = new DateTime(2000, 11, 17), AuthorId = 2, Description =
17: "After the collapse of a nanotechnology society, the young" +
18: "survivors lay the foundation for a new society.", Price = 12.95M },
19:
20: new Book() { BookId = 3, Title = "The Sundered Grail", Genre = "Fantasy",
21: PublishDate = new DateTime(2001, 09, 10), AuthorId = 2, Description =
22: "The two daughters of Maeve battle for control of England.", Price = 12.95M },
23:
24: new Book() { BookId = 4, Title = "Lover Birds", Genre = "Romance",
25: PublishDate = new DateTime(2000, 09, 02), AuthorId = 3, Description =
26: "When Carla meets Paul at an ornithology conference, tempers fly.", Price = 7.99M },
27:
28: new Book() { BookId = 5, Title = "Splish Splash", Genre = "Romance",
29: PublishDate = new DateTime(2000, 11, 02), AuthorId = 4, Description =
30: "A deep sea diver finds true love 20,000 leagues beneath the sea.", Price = 6.99M},
31: });
32: }
In the Package Manager Console window, type the following commands.
[!code-powershellMain]
1: add-migration Initial
2:
3: update-database
These commands create a local database and invoke the Seed method to populate the database.
Add DTO Classes
If you run the application now and send a GET request to /api/books/1, the response looks similar to the following. (I added indentation for readability.)
[!code-jsonMain]
1: {
2: "BookId": 1,
3: "Title": "Midnight Rain",
4: "Genre": "Fantasy",
5: "PublishDate": "2000-12-16T00:00:00",
6: "Description": "A former architect battles an evil sorceress.",
7: "Price": 14.95,
8: "AuthorId": 1,
9: "Author": null
10: }
Instead, I want this request to return a subset of the fields. Also, I want it to return the author’s name, rather than the author ID. To accomplish this, we’ll modify the controller methods to return a data transfer object (DTO) instead of the EF model. A DTO is an object that is designed only to carry data.
In Solution Explorer, right-click the project and select Add | New Folder. Name the folder “DTOs”. Add a class named BookDto
to the DTOs folder, with the following definition:
[!code-csharpMain]
1: namespace BooksAPI.DTOs
2: {
3: public class BookDto
4: {
5: public string Title { get; set; }
6: public string Author { get; set; }
7: public string Genre { get; set; }
8: }
9: }
Add another class named BookDetailDto
.
[!code-csharpMain]
1: using System;
2:
3: namespace BooksAPI.DTOs
4: {
5: public class BookDetailDto
6: {
7: public string Title { get; set; }
8: public string Genre { get; set; }
9: public DateTime PublishDate { get; set; }
10: public string Description { get; set; }
11: public decimal Price { get; set; }
12: public string Author { get; set; }
13: }
14: }
Next, update the BooksController
class to return BookDto
instances. We’ll use the Queryable.Select method to project Book
instances to BookDto
instances. Here is the updated code for the controller class.
[!code-csharpMain]
1: using BooksAPI.DTOs;
2: using BooksAPI.Models;
3: using System;
4: using System.Data.Entity;
5: using System.Linq;
6: using System.Linq.Expressions;
7: using System.Threading.Tasks;
8: using System.Web.Http;
9: using System.Web.Http.Description;
10:
11: namespace BooksAPI.Controllers
12: {
13: public class BooksController : ApiController
14: {
15: private BooksAPIContext db = new BooksAPIContext();
16:
17: // Typed lambda expression for Select() method.
18: private static readonly Expression<Func<Book, BookDto>> AsBookDto =
19: x => new BookDto
20: {
21: Title = x.Title,
22: Author = x.Author.Name,
23: Genre = x.Genre
24: };
25:
26: // GET api/Books
27: public IQueryable<BookDto> GetBooks()
28: {
29: return db.Books.Include(b => b.Author).Select(AsBookDto);
30: }
31:
32: // GET api/Books/5
33: [ResponseType(typeof(BookDto))]
34: public async Task<IHttpActionResult> GetBook(int id)
35: {
36: BookDto book = await db.Books.Include(b => b.Author)
37: .Where(b => b.BookId == id)
38: .Select(AsBookDto)
39: .FirstOrDefaultAsync();
40: if (book == null)
41: {
42: return NotFound();
43: }
44:
45: return Ok(book);
46: }
47:
48: protected override void Dispose(bool disposing)
49: {
50: db.Dispose();
51: base.Dispose(disposing);
52: }
53: }
54: }
[!NOTE] I deleted the
PutBook
,PostBook
, andDeleteBook
methods, because they aren’t needed for this tutorial.
Now if you run the application and request /api/books/1, the response body should look like this:
[!code-jsonMain]
1: {"Title":"Midnight Rain","Author":"Ralls, Kim","Genre":"Fantasy"}
Add Route Attributes
Next, we’ll convert the controller to use attribute routing. First, add a RoutePrefix attribute to the controller. This attribute defines the initial URI segments for all methods on this controller.
[!code-csharpMain]
1: [RoutePrefix("api/books")]
2: public class BooksController : ApiController
3: {
4: // ...
Then add [Route] attributes to the controller actions, as follows:
[!code-csharpMain]
1: [Route("")]
2: public IQueryable<BookDto> GetBooks()
3: {
4: // ...
5: }
6:
7: [Route("{id:int}")]
8: [ResponseType(typeof(BookDto))]
9: public async Task<IHttpActionResult> GetBook(int id)
10: {
11: // ...
12: }
The route template for each controller method is the prefix plus the string specified in the Route attribute. For the GetBook
method, the route template includes the parameterized string “{id:int}”, which matches if the URI segment contains an integer value.
Method | Route Template | Example URI |
---|---|---|
GetBooks |
“api/books” | http://localhost/api/books |
GetBook |
“api/books/{id:int}” | http://localhost/api/books/5 |
Get Book Details
To get book details, the client will send a GET request to /api/books/{id}/details
, where {id} is the ID of the book.
Add the following method to the BooksController
class.
[!code-csharpMain]
1: [Route("{id:int}/details")]
2: [ResponseType(typeof(BookDetailDto))]
3: public async Task<IHttpActionResult> GetBookDetail(int id)
4: {
5: var book = await (from b in db.Books.Include(b => b.Author)
6: where b.AuthorId == id
7: select new BookDetailDto
8: {
9: Title = b.Title,
10: Genre = b.Genre,
11: PublishDate = b.PublishDate,
12: Price = b.Price,
13: Description = b.Description,
14: Author = b.Author.Name
15: }).FirstOrDefaultAsync();
16:
17: if (book == null)
18: {
19: return NotFound();
20: }
21: return Ok(book);
22: }
If you request /api/books/1/details
, the response looks like this:
[!code-jsonMain]
1: {
2: "Title": "Midnight Rain",
3: "Genre": "Fantasy",
4: "PublishDate": "2000-12-16T00:00:00",
5: "Description": "A former architect battles an evil sorceress.",
6: "Price": 14.95,
7: "Author": "Ralls, Kim"
8: }
Get Books By Genre
To get a list of books in a specific genre, the client will send a GET request to /api/books/genre
, where genre is the name of the genre. (For example, /get/books/fantasy
.)
Add the following method to BooksController
.
[!code-csharpMain]
1: [Route("{genre}")]
2: public IQueryable<BookDto> GetBooksByGenre(string genre)
3: {
4: return db.Books.Include(b => b.Author)
5: .Where(b => b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase))
6: .Select(AsBookDto);
7: }
Here we are defining a route that contains a {genre} parameter in the URI template. Notice that Web API is able to distinguish these two URIs and route them to different methods:
/api/books/1
/api/books/fantasy
That’s because the GetBook
method includes a constraint that the “id” segment must be an integer value:
[!code-csharpMain]
1: [Route("{id:int}")]
2: public BookDto GetBook(int id)
3: {
4: // ...
5: }
If you request /api/books/fantasy, the response looks like this:
[ { "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 Books By Author
To get a list of a books for a particular author, the client will send a GET request to /api/authors/id/books
, where id is the ID of the author.
Add the following method to BooksController
.
[!code-csharpMain]
1: [Route("~/api/authors/{authorId:int}/books")]
2: public IQueryable<BookDto> GetBooksByAuthor(int authorId)
3: {
4: return db.Books.Include(b => b.Author)
5: .Where(b => b.AuthorId == authorId)
6: .Select(AsBookDto);
7: }
This example is interesting because “books” is treated a child resource of “authors”. This pattern is quite common in RESTful APIs.
The tilde (~) in the route template overrides the route prefix in the RoutePrefix attribute.
Get Books By Publication Date
To get a list of books by publication date, the client will send a GET request to /api/books/date/yyyy-mm-dd
, where yyyy-mm-dd is the date.
Here is one way to do this:
[!code-csharpMain]
1: [Route("date/{pubdate:datetime}")]
2: public IQueryable<BookDto> GetBooks(DateTime pubdate)
3: {
4: return db.Books.Include(b => b.Author)
5: .Where(b => DbFunctions.TruncateTime(b.PublishDate)
6: == DbFunctions.TruncateTime(pubdate))
7: .Select(AsBookDto);
8: }
The {pubdate:datetime}
parameter is constrained to match a DateTime value. This works, but it’s actually more permissive than we’d like. For example, these URIs will also match the route:
/api/books/date/Thu, 01 May 2008
/api/books/date/2000-12-16T00:00:00
There’s nothing wrong with allowing these URIs. However, you can restrict the route to a particular format by adding a regular-expression constraint to the route template:
[!code-csharpMain]
1: [Route("api/books/date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")]
2: public IQueryable<BookDto> GetBooks(DateTime pubdate)
3: {
4: // ...
5: }
Now only dates in the form “yyyy-mm-dd” will match. Notice that we don’t use the regex to validate that we got a real date. That is handled when Web API tries to convert the URI segment into a DateTime instance. An invalid date such as ‘2012-47-99’ will fail to be converted, and the client will get a 404 error.
You can also support a slash separator (/api/books/date/yyyy/mm/dd
) by adding another [Route] attribute with a different regex.
[!code-htmlMain]
1: [Route("date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")]
2: [Route("date/{*pubdate:datetime:regex(\\d{4}/\\d{2}/\\d{2})}")] // new
3: public IQueryable<BookDto> GetBooks(DateTime pubdate)
4: {
5: // ...
6: }
There is a subtle but important detail here. The second route template has a wildcard character (*) at the start of the {pubdate} parameter:
[!code-jsonMain]
1: {*pubdate: ... }
This tells the routing engine that {pubdate} should match the rest of the URI. By default, a template parameter matches a single URI segment. In this case, we want {pubdate} to span several URI segments:
/api/books/date/2013/06/17
Controller Code
Here is the complete code for the BooksController class.
[!code-csharpMain]
1: using BooksAPI.DTOs;
2: using BooksAPI.Models;
3: using System;
4: using System.Data.Entity;
5: using System.Linq;
6: using System.Linq.Expressions;
7: using System.Threading.Tasks;
8: using System.Web.Http;
9: using System.Web.Http.Description;
10:
11: namespace BooksAPI.Controllers
12: {
13: [RoutePrefix("api/books")]
14: public class BooksController : ApiController
15: {
16: private BooksAPIContext db = new BooksAPIContext();
17:
18: // Typed lambda expression for Select() method.
19: private static readonly Expression<Func<Book, BookDto>> AsBookDto =
20: x => new BookDto
21: {
22: Title = x.Title,
23: Author = x.Author.Name,
24: Genre = x.Genre
25: };
26:
27: // GET api/Books
28: [Route("")]
29: public IQueryable<BookDto> GetBooks()
30: {
31: return db.Books.Include(b => b.Author).Select(AsBookDto);
32: }
33:
34: // GET api/Books/5
35: [Route("{id:int}")]
36: [ResponseType(typeof(BookDto))]
37: public async Task<IHttpActionResult> GetBook(int id)
38: {
39: BookDto book = await db.Books.Include(b => b.Author)
40: .Where(b => b.BookId == id)
41: .Select(AsBookDto)
42: .FirstOrDefaultAsync();
43: if (book == null)
44: {
45: return NotFound();
46: }
47:
48: return Ok(book);
49: }
50:
51: [Route("{id:int}/details")]
52: [ResponseType(typeof(BookDetailDto))]
53: public async Task<IHttpActionResult> GetBookDetail(int id)
54: {
55: var book = await (from b in db.Books.Include(b => b.Author)
56: where b.AuthorId == id
57: select new BookDetailDto
58: {
59: Title = b.Title,
60: Genre = b.Genre,
61: PublishDate = b.PublishDate,
62: Price = b.Price,
63: Description = b.Description,
64: Author = b.Author.Name
65: }).FirstOrDefaultAsync();
66:
67: if (book == null)
68: {
69: return NotFound();
70: }
71: return Ok(book);
72: }
73:
74: [Route("{genre}")]
75: public IQueryable<BookDto> GetBooksByGenre(string genre)
76: {
77: return db.Books.Include(b => b.Author)
78: .Where(b => b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase))
79: .Select(AsBookDto);
80: }
81:
82: [Route("~api/authors/{authorId}/books")]
83: public IQueryable<BookDto> GetBooksByAuthor(int authorId)
84: {
85: return db.Books.Include(b => b.Author)
86: .Where(b => b.AuthorId == authorId)
87: .Select(AsBookDto);
88: }
89:
90: [Route("date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")]
91: [Route("date/{*pubdate:datetime:regex(\\d{4}/\\d{2}/\\d{2})}")]
92: public IQueryable<BookDto> GetBooks(DateTime pubdate)
93: {
94: return db.Books.Include(b => b.Author)
95: .Where(b => DbFunctions.TruncateTime(b.PublishDate)
96: == DbFunctions.TruncateTime(pubdate))
97: .Select(AsBookDto);
98: }
99:
100: protected override void Dispose(bool disposing)
101: {
102: db.Dispose();
103: base.Dispose(disposing);
104: }
105: }
106: }
Summary
Attribute routing gives you more control and greater flexibility when designing the URIs for your API.
|