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:
using System.ComponentModel.DataAnnotations; namespace BooksAPI.Models { public class Author { public int AuthorId { get; set; } [Required] public string Name { get; set; } } } Now add another class named 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; } } }
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 “<New data context...>” 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:
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.
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}, }); }
In the Package Manager Console window, type the following commands.
add-migration Initial
update-database
These commands create a local database and invoke the Seed method to populate the database.
Add DTO (Data Transfer Objects) 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.)
{ "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 }
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:
namespace BooksAPI.DTOs { public class BookDto { public string Title { get; set; } public string Author { get; set; } public string Genre { get; set; } } } Add another class named 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; } } }
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.
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> AsBookDto = x => new BookDto { Title = x.Title, Author = x.Author.Name, Genre = x.Genre }; // GET api/Books public IQueryable GetBooks() { return db.Books.Include(b => b.Author).Select(AsBookDto); } // GET api/Books/5 [ResponseType(typeof(BookDto))] public async Task 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); } } }
Now if you run the application and request /api/books/1, the response body should look like this:
{"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.
[RoutePrefix("api/books")] public class BooksController : ApiController { // ... Then add [Route] attributes to the controller actions, as follows: [Route("")] public IQueryableGetBooks() { // ... } [Route("{id:int}")] [ResponseType(typeof(BookDto))] public async Task GetBook(int id) { // ... }
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 integet 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.
[Route("{id:int}/details")] [ResponseType(typeof(BookDetailDto))] public async TaskGetBookDetail(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); }
If you request /api/books/1/detail, the response looks like this:
{
"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 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.
[Route("{genre}")] public IQueryableGetBooksByGenre(string genre) { return db.Books.Include(b => b.Author) .Where(b => b.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase)) .Select(AsBookDto); }
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:
[Route("{id:int}")] public BookDto GetBook(int id) { // ... } 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.
[Route("~api/authors/{authorId}/books")] public IQueryableGetBooksByAuthor(int authorId) { return db.Books.Include(b => b.Author) .Where(b => b.AuthorId == authorId) .Select(AsBookDto); }
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:
[Route("date/{pubdate:datetime}")] public IQueryableGetBooks(DateTime pubdate) { return db.Books.Include(b => b.Author) .Where(b => DbFunctions.TruncateTime(b.PublishDate) == DbFunctions.TruncateTime(pubdate)) .Select(AsBookDto); }
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:
[Route("api/books/date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")] public IQueryableGetBooks(DateTime pubdate) { // ... }
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.
[Route("date/{pubdate:datetime:regex(\\d{4}-\\d{2}-\\d{2})}")] [Route("date/{*pubdate:datetime:regex(\\d{4}/\\d{2}/\\d{2})}")] // new public IQueryableGetBooks(DateTime pubdate) { // ... }
There is a subtle but important detail here. The second route template has a wildcard character (*) at the start of the {pubdate} parameter:
{*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.
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> AsBookDto = x => new BookDto { Title = x.Title, Author = x.Author.Name, Genre = x.Genre }; // GET api/Books [Route("")] public IQueryable GetBooks() { return db.Books.Include(b => b.Author).Select(AsBookDto); } // GET api/Books/5 [Route("{id:int}")] [ResponseType(typeof(BookDto))] public async Task 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 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 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 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 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); } } }