This is Part 3 of a 4-part series. In Part 2, we built our Pizza model, an in-memory data service, and wired up two GET endpoints. Now it's time to handle the write side of our API.
Where We Left Off
By the end of Part 2, our PizzaController could respond to two GET requests:
-
GET /pizzareturns all pizzas -
GET /pizza/{id}returns a specific pizza or a 404
That covers the Read in CRUD. This part finishes the job with Create, Update, and Delete using POST, PUT, and DELETE respectively.
Here's the full HTTP verb-to-CRUD mapping we're working with:
| HTTP Verb | CRUD Operation | ASP.NET Core Attribute |
|---|---|---|
GET |
Read | [HttpGet] |
POST |
Create | [HttpPost] |
PUT |
Update | [HttpPut] |
DELETE |
Delete | [HttpDelete] |
IActionResult vs ActionResult<T> A Quick Clarification
In Part 2, our GET methods returned ActionResult<T> a typed wrapper that tells ASP.NET Core exactly what shape the response body will have.
For write operations, we'll use IActionResult instead. Why? Because write endpoints often return no body at all just a status code. When you update a pizza successfully, you don't need to send the pizza back; a 204 No Content is enough. Since the return type isn't known until runtime, IActionResult gives us the flexibility to return different result types from the same method.
POST Adding a New Pizza
Replace the // POST action comment in PizzaController.cs with:
[HttpPost]
public IActionResult Create(Pizza pizza)
{
PizzaService.Add(pizza);
return CreatedAtAction(nameof(Get), new { id = pizza.Id }, pizza);
}
Let's unpack what's happening here.
The [HttpPost] attribute maps this method to POST /pizza. When the client sends a request with a JSON body, ASP.NET Core automatically deserializes it into a Pizza object no manual parsing needed. This works because the controller has [ApiController] on it, which tells the framework to look for the object in the request body by default.
The return value, CreatedAtAction, does two things at once:
- Sends back a
201 Createdstatus code - Adds a
Locationheader to the response pointing to the newly created resource (e.g.,/pizza/3)
That nameof(Get) refers to our single-item GET method from Part 2. It's used to build the URL in the Location header without hardcoding any strings.
| Result | HTTP Status | When |
|---|---|---|
CreatedAtAction(...) |
201 Created |
Pizza was added successfully |
Implicit BadRequest
|
400 Bad Request |
Request body failed model validation |
PUT Updating an Existing Pizza
Replace the // PUT action comment with:
[HttpPut("{id}")]
public IActionResult Update(int id, Pizza pizza)
{
if (id != pizza.Id)
return BadRequest();
var existingPizza = PizzaService.Get(id);
if (existingPizza is null)
return NotFound();
PizzaService.Update(pizza);
return NoContent();
}
A few deliberate design decisions here worth understanding.
The route is PUT /pizza/{id}, and the method takes both an id from the URL and a Pizza object from the request body. The first thing we do is check that these two IDs agree if someone sends PUT /pizza/1 with a body that has "id": 5, that's a conflicting request and we return 400 Bad Request immediately.
Next, we check whether the pizza actually exists before trying to update it. If it doesn't, 404 Not Found is the correct response not a silent no-op.
On success, we return 204 No Content. There's no need to send the updated pizza back in the response body; the client already has it since they just sent it to us.
| Result | HTTP Status | When |
|---|---|---|
NoContent() |
204 No Content |
Update was applied successfully |
BadRequest() |
400 Bad Request |
URL id and body id don't match |
NotFound() |
404 Not Found |
No pizza with that ID exists |
DELETE Removing a Pizza
Replace the // DELETE action comment with:
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
var pizza = PizzaService.Get(id);
if (pizza is null)
return NotFound();
PizzaService.Delete(id);
return NoContent();
}
This is the most straightforward of the three. The route is DELETE /pizza/{id} no request body needed, just the ID in the URL.
We verify the pizza exists first. If not, 404. If it does, we delete it and return 204 No Content to confirm the operation succeeded without sending anything back.
| Result | HTTP Status | When |
|---|---|---|
NoContent() |
204 No Content |
Pizza was deleted successfully |
NotFound() |
404 Not Found |
No pizza with that ID exists |
The Complete PizzaController
Here's the full controller at this point all five actions in one place:
using ContosoPizza.Models;
using ContosoPizza.Services;
using Microsoft.AspNetCore.Mvc;
namespace ContosoPizza.Controllers;
[ApiController]
[Route("[controller]")]
public class PizzaController : ControllerBase
{
public PizzaController() { }
[HttpGet]
public ActionResult<List<Pizza>> GetAll() =>
PizzaService.GetAll();
[HttpGet("{id}")]
public ActionResult<Pizza> Get(int id)
{
var pizza = PizzaService.Get(id);
if (pizza == null)
return NotFound();
return pizza;
}
[HttpPost]
public IActionResult Create(Pizza pizza)
{
PizzaService.Add(pizza);
return CreatedAtAction(nameof(Get), new { id = pizza.Id }, pizza);
}
[HttpPut("{id}")]
public IActionResult Update(int id, Pizza pizza)
{
if (id != pizza.Id)
return BadRequest();
var existingPizza = PizzaService.Get(id);
if (existingPizza is null)
return NotFound();
PizzaService.Update(pizza);
return NoContent();
}
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
var pizza = PizzaService.Get(id);
if (pizza is null)
return NotFound();
PizzaService.Delete(id);
return NoContent();
}
}
Clean, readable, and covers all five operations in under 50 lines of code.
Build & Run
Save the file, then verify there are no errors:
dotnet build
Start the server:
dotnet run
Testing the Write Endpoints
Open ContosoPizza.http and add the following test requests:
### Add a new pizza
POST {{ContosoPizza_HostAddress}}/pizza/
Content-Type: application/json
{
"name": "Hawaii",
"isGlutenFree": false
}
###
### Update it (fix the name)
PUT {{ContosoPizza_HostAddress}}/pizza/3
Content-Type: application/json
{
"id": 3,
"name": "Hawaiian",
"isGlutenFree": false
}
###
### Confirm the update
GET {{ContosoPizza_HostAddress}}/pizza/3
Accept: application/json
###
### Delete it
DELETE {{ContosoPizza_HostAddress}}/pizza/3
###
### Confirm it's gone
GET {{ContosoPizza_HostAddress}}/pizza/
Accept: application/json
###
Run them in order and here's what you should see:
-
POST →
201 Createdwith the new pizza in the body and aLocationheader -
PUT →
204 No Content(no body, just confirmation) -
GET
/pizza/3→200 OKshowing the updated name "Hawaiian" -
DELETE →
204 No Content -
GET
/pizza/→200 OKwith only the original two pizzas ID 3 is gone
💡 Note: Since we're using in-memory storage, IDs don't persist across server restarts. Every time you run
dotnet run, the list resets to the two seed pizzas.
What's Coming in Part 4,?
Our API is now fully functional. In the final part, we'll wrap everything up with:
- A recap of how all the pieces fit together
- A look at what you'd change moving from in-memory to a real database
- Next steps for taking your API further (authentication, validation, Swagger UI)
Only one part left follow along so you don't miss the wrap-up. Questions? Drop them in the comments!
Tags: dotnet csharp webapi aspnetcore beginners
Top comments (0)