CRUD based APIs
In the lesson on API Servers we saw how to create an API server. However, an API server that cannot perform the actions of a CRUD (Create, Read, Update, and Delete) style system isn't as valuable for us. In this lesson, we will build an application to keep track of our game nights!
Game Night
Let us create an API to manage our game nights. When creating a game night, we'd like to track the name of the game we will play, the name of the person hosting the event, their address, the date and time the game will start, the minimum and the maximum number of people the game can support.
API Definition
We are going to make an API that can CRUD
(Create, Read, Update, and Delete)
games. The first thing we should do is design our API to support all of these
and to follow a standard convention.
We are going to treat our games as a resource we can manage. We will follow these guidelines while building our API:
- GameNight is the model we are going to manage.
- If an endpoint uses the GET verb, we expect the endpoint to return the same resource each time and not modify it. NOTE: the data inside may change (e.g. we may update the address or date) but the resource, the GameNight, is still the same. When we say "the same resource" we don't mean the contents, but rather the concept (the GameNight with ID 1).
- If an endpoint uses POST/PUT/DELETE it will modify the resource in some way.
- POST will modify the "list of all game nights" resource by adding a new GameNight.
- PUT will modify a specific game by supplying new values.
- DELETE will modify a specific game night by removing it from the "list of all game nights".
Thus we will end up with an API with these endpoints:
Endpoint | Purpose |
---|---|
GET /GameNights | Gets a list of all games. |
GET /GameNights/{id} | Gets the single specific game given by its id. |
POST /GameNights | Creates a new game, assigning a new ID for the game. The properties of the game are given as JSON in the BODY of the request. |
PUT /GameNights/{id} | Updates the single specific game given by its id. The updated properties of the game are given by JSON in the BODY of the request. |
DELETE /GameNights/{id} | Deletes the specific game given by its id. |
This is a very typical pattern of API for a CRUD-style application. These URL patterns and VERB combinations are enough of a pattern that we can typically make some guesses as to what an API does by only looking at the
URL
+VERB
definition.
Creating Our Application
In this section we will expand on our previous code to build a complete
Database + API application. Quite a lot of the previous code will be used here
since managing a List<>
is very similar to managing a DbSet
from EF Core
thanks to LINQ.
If you have not yet followed the lesson on SQL, lesson on SQL joins, and the lesson on EF Core we suggest you study those lessons.
For this application, we are going to use a new template. This template should have been added to your environment in the lesson on computer setup.
Introducing a new template: sdg-api
To generate an app with API and database support:
dotnet new sdg-api -o GameNightWithFriends
This command will create a folder GameNightWithFriends
with a template of an
application that will connect to a database as well as support API controllers.
Generating an ERD
Our ERD for this application is simple since it is only dealing with a single
entity: a GameNight
+-------------------------+| GameNight |+-------------------------+| Id - SERIAL PRIMARY KEY || Name - string || Host - string || Address - string || When - DateTime || MinimumPlayers - int || MaximumPlayers - int |+-------------------------+
Generating our database and our tables
We can use a feature of Entity Framework
we may not have used yet:
Migrations
For more details on migrations you can see this lesson.
As a quick summary, Migrations
are the ability for EF
to detect changes to
our C#
models and automatically generate the required SQL to change the
definition of the database.
This is the idea of Code First
database modeling. What we had done before,
creating our tables manually in SQL
was considered Database First
.
To use Migrations, the first thing we do is define our model. We will create the
GameNight.cs
file and define all the fields we want. Notice they match the
same definitions from our ERD
above. Now we are going to place our database
model files in their proper directory. Open the Models
folder and add a file
GameNight.cs
, and place the code inside.
public class GameNight{public int Id { get; set; }public string Name { get; set; }public string Host { get; set; }public string Address { get; set; }public DateTime When { get; set; }public int MinimumPlayers { get; set; }public int MaximumPlayers { get; set; }}
Next step, inform our DatabaseContext
of this model
In our lesson on ef core we didn't have
a separate file for our DatabaseContext
however, in most apps, it lives in its
own file, and you will find it in the Models
folder here as well.
After this code:
public partial class DatabaseContext : DbContext{
Add this statement to let the DatabaseContext
know we want to track
GameNight
in a GameNights
table:
public partial class DatabaseContext : DbContext{public DbSet<GameNight> GameNights { get; set; }
Next up: generate a migration
NOTE: Any time we change the properties of a model OR we create a new model we must generate a Database Migration and update our database.
Since we just added a new model, we need to create a migration.
dotnet ef migrations add AddGameNights
NOTE: The name of our migration should attempt to capture the database structure change we are making. In this case we are
Adding the GameNights
table.
Next up: ensure your migration is good
You should have at least two new files in Migrations
, one ending in
_AddGameNights.cs
. Open that file and ensure the Up
method has the expected
results:
protected override void Up(MigrationBuilder migrationBuilder){migrationBuilder.CreateTable(name: "GameNights",columns: table => new{Id = table.Column<int>(nullable: false).Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),Name = table.Column<string>(nullable: true),Host = table.Column<string>(nullable: true),Address = table.Column<string>(nullable: true),When = table.Column<DateTime>(nullable: false),MinimumPlayers = table.Column<int>(nullable: false),MaximumPlayers = table.Column<int>(nullable: false)},constraints: table =>{table.PrimaryKey("PK_GameNights", x => x.Id);});}
NOTE: It is important to inspect your migration files after running
dotnet ef migrations add
because we are about to change the structure of our database. It also helps to ensure we don't collect empty migrations which will happen if we rundotnet ef migrations add
when there is no change, or if there are other errors in our code.
Update the database with this migration change
To run the migration against our database:
dotnet ef database update
Define our API controller
In addition to the magic of Migrations
, we are also going to add a new tool to
our toolkit: aspnet-codegenerator
aspnet-codegenerator
is a tool that can generate a default controller for us
that will have a very standard set of code for:
- Reading a complete list of our models
- Reading a single instance of our model by ID
- Creating a model
- Updating a model
- Deleting a model
In this case, our model is our GameNight
. This code generator produces much of
the boilerplate
code that we normally have to write by hand. Often we will
have to update this code when our particular requirements change.
To run the code generator:
dotnet aspnet-codegenerator controller --model GameNight -name GameNightsController --useAsyncActions -api --dataContext DatabaseContext --relativeFolderPath Controllers
Let's look at the various options:
- The first option
controller
, says we want to make a controller. - The second option,
--model GameNight
indicates which model will be used in this controller. - The third option,
--name GameNightsController
indicates the name of the controller. Notice it is a plural version of the singular model name. - The fourth option
--useAsyncActions
indicates we prefer to code with async style code. - The fifth option,
-api
indicates we are generating API style controllers. There are different styles of controllers we will not cover in this course. - The sixth option
--dataContext DatabaseContext
is the name of our context class. - the seventh option
---relativeFolderPath controllers
is the directory where our controllers will be stored.
With this we generate a file controllers/GameNightsController.cs
which we will
review next.
GameNightsController.cs
Here is the entirety of the GameNightsController.cs
that we will be breaking
down.
using System;using System.Collections.Generic;using System.Linq;using System.Threading.Tasks;using Microsoft.AspNetCore.Http;using Microsoft.AspNetCore.Mvc;using Microsoft.EntityFrameworkCore;using GameNightWithFriends.Models;namespace GameNightWithFriends.Controllers{// All of these routes will be at the base URL: /api/GameNights// That is what "api/[controller]" means below. It uses the name of the controller// in this case GameNightsController to determine the URL[Route("api/[controller]")][ApiController]public class GameNightsController : ControllerBase{// This is the variable you use to have access to your databaseprivate readonly DatabaseContext _context;// Constructor that receives a reference to your database context// and stores it in _context for you to use in your API methodspublic GameNightsController(DatabaseContext context){_context = context;}// GET: api/GameNights//// Returns a list of all your GameNights//[HttpGet]public async Task<ActionResult<IEnumerable<GameNight>>> GetGameNights(){// Uses the database context in `_context` to request all of the GameNights and// return them as a JSON array.return await _context.GameNights.ToListAsync();}// GET: api/GameNights/5//// Fetches and returns a specific game night by finding it by id. The id is specified in the// URL. In the sample URL above it is the `5`. The "{id}" in the [HttpGet("{id}")] is what tells dotnet// to grab the id from the URL. It is then made available to us as the `id` argument to the method.//[HttpGet("{id}")]public async Task<ActionResult<GameNight>> GetGameNight(int id){// Find the game night in the database using `FindAsync` to look it up by idvar gameNight = await _context.GameNights.FindAsync(id);// If we didn't find anything, we receive a `null` in returnif (gameNight == null){// Return a `404` response to the client indicating we could not find a game night with this idreturn NotFound();}// Return the game night as a JSON object.return gameNight;}// PUT: api/GameNights/5//// Update an individual game night with the requested id. The id is specified in the URL// In the sample URL above it is the `5`. The "{id} in the [HttpPut("{id}")] is what tells dotnet// to grab the id from the URL. It is then made available to us as the `id` argument to the method.//// In addition the `body` of the request is parsed and then made available to us as a GameNight// variable named game night. The controller matches the keys of the JSON object the client// supplies to the names of the attributes of our GameNight POCO class. This represents the// new values for the record.//[HttpPut("{id}")]public async Task<IActionResult> PutGameNight(int id, GameNight gameNight){// If the ID in the URL does not match the ID in the supplied request body, return a bad requestif (id != gameNight.Id){return BadRequest();}// Tell the database to consider everything in game night to be _updated_ values. When// the save happens the database will _replace_ the values in the database with the ones from game night_context.Entry(gameNight).State = EntityState.Modified;try{// Try to save these changes.await _context.SaveChangesAsync();}catch (DbUpdateConcurrencyException){// Ooops, looks like there was an error, so check to see if the record we were// updating no longer exists.if (!GameNightExists(id)){// If the record we tried to update was already deleted by someone else,// return a `404` not foundreturn NotFound();}else{// Otherwise throw the error back, which will cause the request to fail// and generate an error to the client.throw;}}// return NoContent to indicate the update was done. Alternatively you can use the// following to send back a copy of the updated data.//// return Ok(gameNight)//return NoContent();}// POST: api/GameNights//// Creates a new game night in the database.//// The `body` of the request is parsed and then made available to us as a GameNight// variable named game night. The controller matches the keys of the JSON object the client// supplies to the names of the attributes of our GameNight POCO class. This represents the// new values for the record.//[HttpPost]public async Task<ActionResult<GameNight>> PostGameNight(GameNight gameNight){// Indicate to the database context we want to add this new record_context.GameNights.Add(gameNight);await _context.SaveChangesAsync();// Return a response that indicates the object was created (status code `201`) and some additional// headers with details of the newly created object.return CreatedAtAction("GetGameNight", new { id = gameNight.Id }, gameNight);}// DELETE: api/GameNights/5//// Deletes an individual game night with the requested id. The id is specified in the URL// In the sample URL above it is the `5`. The "{id} in the [HttpDelete("{id}")] is what tells dotnet// to grab the id from the URL. It is then made available to us as the `id` argument to the method.//[HttpDelete("{id}")]public async Task<IActionResult> DeleteGameNight(int id){// Find this game night by looking for the specific idvar gameNight = await _context.GameNights.FindAsync(id);if (gameNight == null){// There wasn't a game night with that id so return a `404` not foundreturn NotFound();}// Tell the database we want to remove this record_context.GameNights.Remove(gameNight);// Tell the database to perform the deletionawait _context.SaveChangesAsync();// return NoContent to indicate the update was done. Alternatively you can use the// following to send back a copy of the deleted data.//// return Ok(gameNight)//return NoContent();}// Private helper method that looks up an existing game night by the supplied idprivate bool GameNightExists(int id){return _context.GameNights.Any(gameNight => gameNight.Id == id);}}}
Breaking down the controller code
Routes
Starting at the top we see:
// All of these routes will be at the base URL: /api/GameNights// That is what "api/[controller]" means below. It uses the name of the controller// in this case GameNightsController to determine the URL[Route("api/[controller]")][ApiController]public class GameNightsController : ControllerBase
This defines that our API will be contained within the URL api/GameNights
.
Database Context
Next up we see:
// This is the variable you use to have access to your databaseprivate readonly DatabaseContext _context;
This declares a private (can't be seen outside of this class) readonly (can't be
changed) database context named _context
. This property of the controller will
be supplied each time a request is made. The way this is supplied is through
Dependency Injection
with the constructor
.
Constructor
The constructor for our controller is called for each API request. It is also supplied a database context that is setup to access our database. Inside the constructor we simply "save a copy" of the context for our later use.
// Constructor that receives a reference to your database context// and stores it in _context for you to use in your API methodspublic GameNightsController(DatabaseContext context){_context = context;}
GET /api/GameNights -- get all the game nights
This code defines a GET method at the URL /api/GameNights
. The method
indicates that it returns a list of GameNight
through this return type:
Task<ActionResult<IEnumerable<GameNight>>>
The Task<>
indicates that this API request can be handled asynchronously.
The ActionResult<>
indicates that the result will have data, and a status
code, and other API related response data.
Lastly, IEnumerable<GameNight>
is a more generic version of List<GameNight>
meaning we return some kind of collection of GameNight
objects.
The code inside the method simply asks the context
for the set of GameNight
s
and returns them as a List
to be generated via async. (ToListAsync
)
The effect is that if we ask the API for /api/GameNights
we will get a JSON
formatted list of all the GameNight
objects in our database.
// GET: api/GameNights//// Returns a list of all your GameNights//[HttpGet]public async Task<ActionResult<IEnumerable<GameNight>>> GetGameNights(){// Uses the database context in `_context` to request all of the GameNights and// return them as a JSON array.return await _context.GameNights.ToListAsync();}
GET /api/GameNights/42 -- get a specific GameNight
This method uses the same base url of /api/GameNights
but adds on one
parameter of the {id}
of the game night we are looking for.
We then use FindAsync
to find this GameNight
by looking for it by ID.
If the returned value is null
we simply return a NotFound
response.
If we do find a value we return that value as the JSON formatted response.
The return value of the function Task<ActionResult<GameNight>>
indicates that
this is an async function that returns a result of a GameNight
including any
possible response codes, such as returning a NotFound
resulting in a 404 code.
// GET: api/GameNights/5//// Fetches and returns a specific game night by finding it by id. The id is specified in the// URL. In the sample URL above it is the `5`. The "{id}" in the [HttpGet("{id}")] is what tells dotnet// to grab the id from the URL. It is then made available to us as the `id` argument to the method.//[HttpGet("{id}")]public async Task<ActionResult<GameNight>> GetGameNight(int id){// Find the game night in the database using `FindAsync` to look it up by idvar gameNight = await _context.GameNights.FindAsync(id);// If we didn't find anything, we receive a `null` in returnif (gameNight == null){// Return a `404` response to the client indicating we could not find a game night with this idreturn NotFound();}// Return the game night as a JSON object.return gameNight;}
PUT api/GameNights/42 -- Update an existing GameNight found by id
This API endpoint is much like the GET
however with a PUT
we are indicating
we are updating an existing GameNight
by ID.
Notice that the parameters to the method include both the int id
we get from
the [HttpPut("{id})]
but also a GameNight gameNight
. The
GameNight gameNight
argument will be a variable, of type GameNight
that is
parsed from deserializing the body of the request as the JSON representation
of a GameNight
.
The very first thing that happens is we ensure that the id
specified on the
command line matches the id
in the body. If they do not match, we return an
error code that this is a BadRequest()
Next up we take the gameNight
we parsed from the body and tell the context
that this is a modified gameNight. We can do this since we have provided all
the attributes of a GameNight
in the body.
We attempt to SaveChangesAsync
to the database. If for some reason, when we
attempt to save the game night, it does not already exist in the database, we
return a NotFound
message, otherwise we re-throw the error so our client can
see what happened.
Finally, if there is no error, we simply return a NoContent()
(204) successful
response. If the client would like the entire updated object returned to it, we
could return Ok(gameNight)
to return the JSON version of the updated
GameNight
// PUT: api/GameNights/5[HttpPut("{id}")]public async Task<IActionResult> PutGameNight(int id, GameNight gameNight){// If the ID in the URL does not match the ID in the supplied request body, return a bad requestif (id != gameNight.Id){return BadRequest();}// Tell the database to consider everything in game night to be _updated_ values. When// the save happens the database will _replace_ the values in the database with the ones from gameNight_context.Entry(gameNight).State = EntityState.Modified;try{// Try to save these changes.await _context.SaveChangesAsync();}catch (DbUpdateConcurrencyException){// Ooops, looks like there was an error, so check to see if the record we were// updating no longer exists.if (!GameNightExists(id)){// If the record we tried to update was already deleted by someone else,// return a `404` not foundreturn NotFound();}else{// Otherwise throw the error back, which will cause the request to fail// and generate an error to the client.throw;}}// return NoContent to indicate the update was done. Alternatively you can use the// following to send back a copy of the updated data.//// return Ok(gameNight)//return NoContent();}
POST /api/GameNights - Creating a new game
This URL, when called with a POST
will create a new game night.
In the arguments to the function we see that we supply the JSON representation
of a GameNight
in the POST
body. This will be deserialized into a
GameNight
object containing the information we wish to add.
We simply need to add this game night to our context and tell our context to save changes.
We then use
CreatedAtAction("GetGameNight", new {id = gameNight.Id }, gameNight)
to return
a 201
(Created) code with the response body of the newly saved GameNight
.
// POST: api/GameNights[HttpPost]public async Task<ActionResult<GameNight>> PostGameNight(GameNight gameNight){// Indicate to the database context we want to add this new record_context.GameNights.Add(gameNight);await _context.SaveChangesAsync();// Return a response that indicates the object was created (status code `201`) and some additional// headers with details of the newly created object.return CreatedAtAction("GetGameNight", new { id = gameNight.Id }, gameNight);}
DELETE /api/GameNights/42 -- Delete a game night given its ID
First we attempt to find the given game night in the database. If not found we
simply return a 404
(NotFound) message.
If we have found the game night, we call _context.Remove(gameNight)
to tell
the context we are deleting this gameNight.
We follow this with a SaveChanges
to cause the deletion to happen and then we
return a 204
(NoContent) response. Again we could return the JSON content of
the deleted game night by changing the return code to return Ok(gameNight)
// DELETE: api/GameNights/5[HttpDelete("{id}")]public async Task<IActionResult> DeleteGameNight(int id){// Find this game night by looking for the specific idvar gameNight = await _context.GameNights.FindAsync(id);if (gameNight == null){// There wasn't a game night with that id so return a `404` not foundreturn NotFound();}// Tell the database we want to remove this record_context.GameNights.Remove(gameNight);// Tell the database to perform the deletionawait _context.SaveChangesAsync();// return NoContent to indicate the update was done. Alternatively you can use the// following to send back a copy of the deleted data.//// return Ok(gameNight)//return NoContent();}
Checking for valid data
Our Game Nights wouldn't be fun without fellow players. Let's add a validation to ensure at least two players are present at each game night.
To add this validation we'll update the methods that create and update a
GameNight
. We'd like to reject any request that has a MinimumPlayers
less
than 2
.
We'll add the following code after the check of the id
in PutGameNight
AND
to the beginning of the PostGameNight
method.
// Add a check to make sure we have enough players.if (gameNight.MinimumPlayers < 2){return BadRequest(new { Message = "You need at least 2 players!" });}
This will return an error code (and message) indicating that this request was rejected.
Let's also add a requirement that we cannot delete a game night that has
already happened. In this case we'll be updating the DeleteGameNight
method to
add code that compares gameNight.When
to DateTime.Now
if (gameNight.When < DateTime.Now){return BadRequest();}
Working with associated data
Our Game Night app is a success! Now we want to update it to keep track of data of the players that attended each game night.
Adding a new Player Model
We'll start by adding a model to track this information.
namespace GameNightWithFriends.Models{public class Player{public int Id { get; set; }public string Name { get; set; }public int GameNightId { get; set; }public GameNight GameNight { get; set; }}}
And add the information to our Database Context
public DbSet<Player> Players { get; set; }
Creating a whole new controller versus a nested/additional method on a current controller
There are several approaches to allowing our code to track the related players
to a GameNight. The first pass would be to create a new controller that focused
on managing the Player
model. The user would supply, for each created player
the GameNightId
along with the details about the player (their Name
).
We could also simply add a method to the existing GameNightsController
if all
we wanted to do was allow for associating the players. In this case we simply
need a Create
style action.
To add such an action to our existing controller we'll add a method on our own.
// Adding Players to a game night// POST /api/GameNights/5/Players[HttpPost("{id}/Players")]public async Task<ActionResult<Player>> CreatePlayerForGameNight(int id, Player player)// | |// | Player deserialized from JSON from the body// |// GameNight ID comes from the URL
This is a POST
style action which typically indicates the creation of data.
Then we ensure the game night's id
is present in the URL. We then place a
/Players
behind it such that the URL becomes POST 42/Players
to create a
player for the GameNight
with Id
of 42
.
We'll name this method CreatePlayerForGameNight
and see that the arguments to
the method indicate we'll be deserializing a Player
object from the body
which we will name the variable player
. The method also returns the newly
created Player
object.
Here is the implementation of this method:
// Adding Players to a game night// POST /api/GameNights/5/Players[HttpPost("{id}/Players")]public async Task<ActionResult<Player>> CreatePlayerForGameNight(int id, Player player)// | |// | Player deserialized from JSON from the body// |// Game Night ID comes from the URL{// First, let's find the game night (by using the ID)var gameNight = await _context.GameNights.FindAsync(id);// If the game night doesn't exist: return a 404 Not found.if (gameNight == null){// Return a `404` response to the client indicating we could not find a game night with this idreturn NotFound();}// Associate the player to the given game night.player.GameNightId = gameNight.Id;// Add the player to the database_context.Players.Add(player);await _context.SaveChangesAsync();// Return the new player to the response of the APIreturn Ok(player);}