Lesson 3 Guide — Full CRUD¶
Goal: by the end of this lesson your StaffController handles the full
set of CRUD operations — Create, Read, Update, and Delete — not just the GET
reads from the previous lesson. You'll learn the HTTP conventions for each verb,
how model
binding turns a JSON request body into a C# object, and how to hash passwords
with BCrypt so plaintext never touches the database.
The general pattern you're learning: every entity in a Web API exposes the
same five operations in the same shape — GetAll, GetById, Create, Update,
Delete (RCUD). Once you've written them once, every other controller is the
same skeleton with a different model.
1. The CRUD-to-HTTP mapping¶
CRUD is a database idea (Create, Read, Update, Delete). HTTP expresses those four operations with verbs and status codes:
| Operation | HTTP Verb | Route | Success status | Not found | Bad request |
|---|---|---|---|---|---|
| Read all | GET | api/staff |
200 OK | — | — |
| Read one | GET | api/staff/5 |
200 OK | 404 | — |
| Create | POST | api/staff |
201 Created | — | — |
| Update | PUT | api/staff/5 |
200 OK + body | 404 | 400 |
| Delete | DELETE | api/staff/5 |
204 No Content | 404 | — |
Memorize this table — it's the contract every controller in this course honors.
The [ApiController] attribute and ControllerBase helper methods (Ok(),
NotFound(), BadRequest(), CreatedAtAction(), NoContent()) exist to make
returning these exact status codes easy.
2. Model binding — JSON in, C# object out¶
When a POST or PUT request arrives with a JSON body, ASP.NET Core automatically deserializes it into the C# parameter you declare. This is model binding.
The client sends:
ASP.NET matches JSON property names to Staff properties (case-insensitively) and
hands you a fully populated newStaff object. You never parse JSON by hand.
Because StaffController has [ApiController], model binding for a complex type
like Staff reads from the request body by default — you don't need
[FromBody].
3. BCrypt password hashing¶
Staff have passwords. Storing a password as plaintext is the single easiest security mistake to make — anyone who can read the database can read every password. BCrypt solves this by storing a one-way hash instead. You can verify a password against the hash, but you can never turn the hash back into the original text.
Add the NuGet package (Tools → NuGet Package Manager → Manage NuGet Packages):
Two methods matter:
// Turn plaintext into a hash before saving
string hash = BCrypt.Net.BCrypt.HashPassword("test1234");
// Check a plaintext attempt against a stored hash (used at login — Lesson 6)
bool ok = BCrypt.Net.BCrypt.Verify("test1234", hash);
Every seed record in this course uses the same BCrypt hash — the plaintext is
test1234. That's how you'll log in during testing.
4. Create — POST¶
// POST: api/Staff
[HttpPost]
public async Task<ActionResult<Staff>> Create(Staff newStaff) {
newStaff.Password = BCrypt.Net.BCrypt.HashPassword(newStaff.Password);
_db.Staff.Add(newStaff);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = newStaff.Id }, newStaff);
}
A few things to notice:
- Hash first, save second — the plaintext password from the request body is replaced with its BCrypt hash before it's added to the database.
_db.Staff.Add(...)thenSaveChangesAsync()—Addstages the insert in memory;SaveChangesAsyncactually runs the SQLINSERT. After it completes, EF Core has populatednewStaff.Idwith the database-generated key.CreatedAtActionreturns 201 Created, includes the new object in the body, and adds aLocationheader pointing atGetByIdfor the new record.nameof(GetById)avoids hardcoding the method name as a string.
5. Update — PUT¶
// PUT: api/Staff/5
[HttpPut("{id}")]
public async Task<ActionResult<Staff>> Update(int id, Staff updatedStaff) {
if (id != updatedStaff.Id) {
return BadRequest();
}
var currentStaff = await _db.Staff.FindAsync(id);
if (currentStaff == null) {
return NotFound();
}
if (updatedStaff.Password != currentStaff.Password) {
updatedStaff.Password = BCrypt.Net.BCrypt.HashPassword(updatedStaff.Password);
}
_db.Entry(currentStaff).CurrentValues.SetValues(updatedStaff);
await _db.SaveChangesAsync();
return Ok(currentStaff);
}
This is the fetch-then-set update pattern — the one you'll use in every controller. Read it carefully:
idcomes from the URL,updatedStafffrom the body. If they disagree the request is contradictory, so return 400 Bad Request.- Fetch the current row first. If
FindAsyncreturns null, the record doesn't exist → 404 Not Found. - Password guard — if the incoming password differs from the stored hash, the caller is setting a new password, so hash it. If it's unchanged (the front end sent back the existing hash), leave it alone so you don't hash an already-hashed value.
_db.Entry(currentStaff).CurrentValues.SetValues(updatedStaff)copies every property from the incoming object onto the tracked entity. This is the pattern we use in this course — never_db.Entry(x).State = EntityState.Modified.- Return 200 OK with the updated entity in the body.
6. Delete — DELETE¶
// DELETE: api/Staff/5
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id) {
var staff = await _db.Staff.FindAsync(id);
if (staff == null) {
return NotFound();
}
_db.Staff.Remove(staff);
await _db.SaveChangesAsync();
return NoContent();
}
- Fetch first — no row means 404.
Removestages the delete;SaveChangesAsyncruns the SQLDELETE.- 204 No Content — the delete succeeded and there's nothing meaningful to
return, so the body is empty. Notice the return type is
IActionResult, notActionResult<Staff>, because there's no entity to return.
7. The finished StaffController¶
With the previous lesson's GetAll/GetById plus this lesson's three methods, the controller is
complete:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TableServe.Api.Data;
using TableServe.Api.Models;
namespace TableServe.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class StaffController : ControllerBase
{
private readonly TableServeDbContext _db;
public StaffController(TableServeDbContext db)
{
_db = db;
}
// GET: api/staff
[HttpGet]
public async Task<ActionResult<IEnumerable<Staff>>> GetAll()
{
return await _db.Staff.ToListAsync();
}
// GET: api/staff/5
[HttpGet("{id}")]
public async Task<ActionResult<Staff>> GetById(int id)
{
var staff = await _db.Staff.FindAsync(id);
if (staff == null) return NotFound();
return staff;
}
// POST: api/staff
[HttpPost]
public async Task<ActionResult<Staff>> Create(Staff newStaff)
{
newStaff.Password = BCrypt.Net.BCrypt.HashPassword(newStaff.Password);
_db.Staff.Add(newStaff);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = newStaff.Id }, newStaff);
}
// PUT: api/staff/5
[HttpPut("{id}")]
public async Task<ActionResult<Staff>> Update(int id, Staff updatedStaff)
{
if (id != updatedStaff.Id) return BadRequest();
var currentStaff = await _db.Staff.FindAsync(id);
if (currentStaff == null) return NotFound();
if (updatedStaff.Password != currentStaff.Password)
updatedStaff.Password = BCrypt.Net.BCrypt.HashPassword(updatedStaff.Password);
_db.Entry(currentStaff).CurrentValues.SetValues(updatedStaff);
await _db.SaveChangesAsync();
return Ok(currentStaff);
}
// DELETE: api/staff/5
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var staff = await _db.Staff.FindAsync(id);
if (staff == null) return NotFound();
_db.Staff.Remove(staff);
await _db.SaveChangesAsync();
return NoContent();
}
}
Notice the method order: GetAll, GetById, Create, Update, Delete. Keep every controller in this order — it makes them easy to scan and compare.
8. Verifying with Insomnia¶
Insomnia setup (import, baseUrl) was covered in the Lesson 2 guide,
section 12. With the project running (no login required — the endpoints are open),
expand the Staff folder and work through the write operations:
- Create Staff → Send → expect 201 Created with the new staff object
(note the generated
id). - Get All Staff → Send → expect 200 OK; the new record is now in the list.
- Update Staff → Send → expect 200 OK with the updated fields reflected.
- Delete Staff → Send → expect 204 No Content (empty body).
- Run Get Staff by Id against the id you just deleted → expect 404 Not Found.
Each request has after-response tests — check the Tests tab. Green = pass, red = fail. If a test fails, the response panel shows expected vs. received.
Tip on passwords: after Create Staff, look at the
Staffrow in SQL Server Object Explorer — thePasswordcolumn holds a long$2a$11$...BCrypt hash, not the plaintext you sent. That's the whole point.
Troubleshooting¶
- 400 Bad Request on Update — the
idin the URL doesn't match theidin the body. Make them the same. - 415 Unsupported Media Type — the request is missing the
Content-Type: application/jsonheader. The pre-built requests include it; if you craft your own, add it. - Connection error — the project stopped running. Press F5 and confirm the
port matches your
baseUrl.
The General Pattern (what to take away)¶
Every write operation follows the same three-beat rhythm:
- Validate / fetch — check the id, fetch the current row, return
BadRequestorNotFoundif something's wrong. - Mutate —
Add,SetValues, orRemovethe entity. SaveChangesAsync— commit, then return the correct status code (201 / 200 / 204).
When you build the PRS backend in the capstone, UsersController is this exact
controller with Staff swapped for User and IsManager swapped for
IsReviewer. The password hashing carries over verbatim.
Build Steps¶
- Install the BCrypt.Net-Next NuGet package
- Add the
Create(POST) method toStaffController— hash the password, thenAdd+SaveChangesAsync, returnCreatedAtAction - Add the
Update(PUT) method — id-match guard, fetch-then-set, password guard, returnOk - Add the
Delete(DELETE) method — fetch,Remove, returnNoContent - Run the project and verify all five operations in the Staff folder in Insomnia — see section 8
- Confirm in SQL Server Object Explorer that a created staff member's
Passwordcolumn contains a BCrypt hash, not plaintext