ASP.NET CORE MVC - REST API
Introducing a REST API for Product Data
Add a JSON API alongside the existing MVC interface using [ApiController], attribute routing, HTTP GET/POST/PUT/DELETE methods, appropriate status codes, and a dedicated API ViewModel.
By the end of this tutorial, the application will support browser-based MVC pages and JSON-based Product API endpoints from the same EF Core database.
Part 16 hardened the MVC application with logging and error handling. Part 17 introduces a separate API controller without replacing the existing MVC controller or Razor views.
- Understand MVC versus API controllers
- Create an API ViewModel
- Create
ProductsApiController - Add GET all
- Add GET by ID
- Add POST
- Add PUT
- Add DELETE
- Understand HTTP status codes
- Test with
curl - Review authentication implications
- Compare full code in the appendix
1. Open and Verify the Part 16 Project
cd ~/aspnet-mvc-tutorial/ProductManagement
pwd
dotnet build
code .
The expected path is:
/home/xubuntu/aspnet-mvc-tutorial/ProductManagement
2. MVC Controller versus API Controller
| MVC | API |
|---|---|
| Usually returns Razor Views | Usually returns JSON |
| Designed for browser UI | Designed for clients/applications |
Controller | ControllerBase |
| Conventional routes commonly used | Attribute routes commonly used |
3. Create ProductApiViewModel.cs
Create:
touch ViewModels/ProductApiViewModel.cs
code ViewModels/ProductApiViewModel.cs
Add:
using System.ComponentModel.DataAnnotations;
namespace ProductManagement.ViewModels;
public class ProductApiViewModel
{
public int Id { get; set; }
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[StringLength(500)]
public string? Description { get; set; }
[Range(0.01, 1000000)]
public decimal Price { get; set; }
[Range(0, 1000000)]
public int Quantity { get; set; }
public int? CategoryId { get; set; }
public string? CategoryName { get; set; }
}
This class defines the JSON-facing shape of Product data and avoids directly binding the complete EF Core entity.
4. Create ProductsApiController.cs
Create:
touch Controllers/ProductsApiController.cs
code Controllers/ProductsApiController.cs
Add the complete controller:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;
using ProductManagement.Models;
using ProductManagement.ViewModels;
namespace ProductManagement.Controllers;
[ApiController]
[Route("api/products")]
public class ProductsApiController : ControllerBase
{
private readonly ApplicationDbContext _context;
public ProductsApiController(
ApplicationDbContext context)
{
_context = context;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<ProductApiViewModel>>> GetAll()
{
var products = await _context.Products
.Include(p => p.Category)
.OrderBy(p => p.Name)
.Select(p => new ProductApiViewModel
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Price = p.Price,
Quantity = p.Quantity,
CategoryId = p.CategoryId,
CategoryName =
p.Category != null
? p.Category.Name
: null
})
.ToListAsync();
return Ok(products);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductApiViewModel>> GetById(int id)
{
var product = await _context.Products
.Include(p => p.Category)
.Where(p => p.Id == id)
.Select(p => new ProductApiViewModel
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Price = p.Price,
Quantity = p.Quantity,
CategoryId = p.CategoryId,
CategoryName =
p.Category != null
? p.Category.Name
: null
})
.FirstOrDefaultAsync();
if (product == null)
{
return NotFound();
}
return Ok(product);
}
[Authorize]
[HttpPost]
public async Task<ActionResult<ProductApiViewModel>> Create(
ProductApiViewModel request)
{
var product = new Product
{
Name = request.Name,
Description = request.Description,
Price = request.Price,
Quantity = request.Quantity,
CategoryId = request.CategoryId
};
_context.Products.Add(product);
await _context.SaveChangesAsync();
var response = new ProductApiViewModel
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
Quantity = product.Quantity,
CategoryId = product.CategoryId
};
return CreatedAtAction(
nameof(GetById),
new { id = product.Id },
response);
}
[Authorize]
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(
int id,
ProductApiViewModel request)
{
var product = await _context.Products
.FindAsync(id);
if (product == null)
{
return NotFound();
}
product.Name = request.Name;
product.Description = request.Description;
product.Price = request.Price;
product.Quantity = request.Quantity;
product.CategoryId = request.CategoryId;
await _context.SaveChangesAsync();
return NoContent();
}
[Authorize]
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var product = await _context.Products
.FindAsync(id);
if (product == null)
{
return NotFound();
}
_context.Products.Remove(product);
await _context.SaveChangesAsync();
return NoContent();
}
}
5. Understand [ApiController]
At the top of the controller:
[ApiController]
This enables API-specific controller behavior, including automatic model validation responses for invalid request models.
6. Understand the API Route
The controller uses:
[Route("api/products")]
Therefore the base endpoint is:
/api/products
7. GET All Products
Find:
[HttpGet]
public async Task<ActionResult<IEnumerable<ProductApiViewModel>>> GetAll()
This handles:
GET /api/products
It returns HTTP 200 with a JSON array.
8. GET One Product
Find:
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductApiViewModel>> GetById(int id)
Example:
GET /api/products/1
If the Product does not exist, the action returns:
return NotFound();
9. POST — Create a Product
The POST action is protected with:
[Authorize]
and handles:
POST /api/products
The action creates a Product and returns:
CreatedAtAction(...)
This corresponds to HTTP 201 Created and includes a location for the newly created resource.
The existing Identity setup uses browser cookie authentication. A command-line API client will not automatically be authenticated just because a browser session exists. For learning HTTP API structure, GET endpoints can be tested immediately. Protected POST/PUT/DELETE require a valid authenticated request context. Token-based API authentication is beyond this introductory Part 17.
10. PUT — Update a Product
The route:
PUT /api/products/1
loads Product 1, updates the allowed fields, saves changes and returns:
return NoContent();
This corresponds to HTTP 204 No Content.
11. DELETE — Remove a Product
The route:
DELETE /api/products/1
returns 404 if the Product does not exist, or 204 after successful deletion.
12. Common HTTP Status Codes
| Status | Meaning | Used here |
|---|---|---|
| 200 | OK | Successful GET |
| 201 | Created | Successful POST |
| 204 | No Content | Successful PUT/DELETE |
| 400 | Bad Request | Invalid API model input |
| 401 | Unauthorized | Authentication required |
| 404 | Not Found | Requested Product missing |
13. Build the Project
Save:
ViewModels/ProductApiViewModel.cs
Controllers/ProductsApiController.cs
Then run:
dotnet build
Continue only when the project builds successfully.
14. Run the Application
dotnet run
Note the HTTPS or HTTP localhost URL shown in the terminal, for example:
https://localhost:7000
Your port may differ.
15. Test GET All with curl
Open another Terminal and run, replacing the port if needed:
curl -k https://localhost:7000/api/products
The response should be JSON similar to:
[
{
"id": 1,
"name": "Gaming Laptop",
"description": "...",
"price": 4500.00,
"quantity": 4,
"categoryId": 1,
"categoryName": "Computers"
}
]
16. Test GET by ID
curl -k https://localhost:7000/api/products/1
Test a missing ID:
curl -k -i https://localhost:7000/api/products/99999
You should see HTTP 404.
17. Pretty-Print JSON
If jq is installed:
curl -ks https://localhost:7000/api/products | jq
If not, install it on Xubuntu with:
sudo apt install jq
18. Understand JSON Request Bodies
A POST or PUT request sends JSON such as:
{
"name": "USB Hub",
"description": "Seven-port USB hub",
"price": 89.90,
"quantity": 12,
"categoryId": 2
}
ASP.NET Core model binding converts the JSON body into:
ProductApiViewModel
19. Example POST Command
Structurally, a POST request looks like:
curl -k -X POST https://localhost:7000/api/products -H "Content-Type: application/json" -d '{
"name": "USB Hub",
"description": "Seven-port USB hub",
"price": 89.90,
"quantity": 12,
"categoryId": 2
}'
Because POST is protected with [Authorize], an unauthenticated command-line request should not be allowed to create data.
20. Example PUT Command
curl -k -X PUT https://localhost:7000/api/products/1 -H "Content-Type: application/json" -d '{
"id": 1,
"name": "Updated Product",
"description": "Updated through API",
"price": 199.90,
"quantity": 5,
"categoryId": 1
}'
21. Example DELETE Command
curl -k -X DELETE https://localhost:7000/api/products/1
22. Why Use a Separate API ViewModel?
The API should not automatically expose every property of the database entity.
For example, OwnerId is not included in the JSON request model.
23. API Validation
The ViewModel uses:
[Required]
[StringLength(100)]
[Range(...)]
With [ApiController], invalid model input can automatically produce an HTTP 400 response rather than requiring the controller to manually check ModelState.IsValid.
24. No Migration Is Required
Part 17 adds an API ViewModel and controller only. It does not change the EF Core entity model or database schema.
No database schema change occurs in this part.
25. Security Note: Ownership Is Not Yet Applied to the API
The MVC Product controller already enforces owner/Admin rules. This introductory API currently demonstrates basic API routing and HTTP methods.
Before using such an API in a real multi-user system, protected write endpoints should also enforce resource-level ownership or Admin authorization, just as the MVC controller does.
[Authorize] proves only that a user is authenticated. It does not prove ownership of a specific Product.
26. Troubleshooting
/api/products returns 404
Verify ProductsApiController.cs contains both [ApiController] and [Route("api/products")], then rebuild.
curl reports a certificate error
For this local development exercise, use -k as shown. Do not disable certificate validation in production clients.
POST returns 401 or redirects/authentication fails
This is expected for an unauthenticated API request because POST is protected. The project currently uses Identity cookie authentication intended primarily for the browser UI.
ProductApiViewModel cannot be found
Verify its namespace is ProductManagement.ViewModels and the API controller imports that namespace.
Invalid JSON produces 400
This is expected when [ApiController] detects model-binding or validation errors.
27. Hands-On Exercise
- Run the application.
- GET all Products with
curl. - GET one valid Product.
- GET a nonexistent Product and inspect the 404 status.
- Pipe the Product list through
jq. - Send an unauthenticated POST and observe the authentication response.
- Compare the MVC route
/Productswith API route/api/products.
28. Knowledge Check
- What is the difference between an MVC controller and an API controller?
- What does
[ApiController]do? - What is attribute routing?
- What HTTP method retrieves data?
- What HTTP method normally creates data?
- What does HTTP 201 mean?
- What does HTTP 204 mean?
- Why use an API ViewModel instead of the entity directly?
- Why does Part 17 not require a migration?
- Why is
[Authorize]alone insufficient for ownership-sensitive writes?
29. Part 17 Summary
- created
ProductApiViewModel; - created
ProductsApiController; - used
[ApiController]; - used attribute routing;
- implemented GET all and GET by ID;
- implemented POST, PUT and DELETE;
- used 200, 201, 204, 400, 401 and 404 concepts;
- tested JSON endpoints with
curl; - kept MVC and API interfaces side by side; and
- identified the authentication/ownership work required for a production-grade API.
Appendix — Full Code for Final Verification
Appendix A — ViewModels/ProductApiViewModel.cs
using System.ComponentModel.DataAnnotations;
namespace ProductManagement.ViewModels;
public class ProductApiViewModel
{
public int Id { get; set; }
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[StringLength(500)]
public string? Description { get; set; }
[Range(0.01, 1000000)]
public decimal Price { get; set; }
[Range(0, 1000000)]
public int Quantity { get; set; }
public int? CategoryId { get; set; }
public string? CategoryName { get; set; }
}
Appendix B — Controllers/ProductsApiController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;
using ProductManagement.Models;
using ProductManagement.ViewModels;
namespace ProductManagement.Controllers;
[ApiController]
[Route("api/products")]
public class ProductsApiController : ControllerBase
{
private readonly ApplicationDbContext _context;
public ProductsApiController(
ApplicationDbContext context)
{
_context = context;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<ProductApiViewModel>>> GetAll()
{
var products = await _context.Products
.Include(p => p.Category)
.OrderBy(p => p.Name)
.Select(p => new ProductApiViewModel
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Price = p.Price,
Quantity = p.Quantity,
CategoryId = p.CategoryId,
CategoryName =
p.Category != null
? p.Category.Name
: null
})
.ToListAsync();
return Ok(products);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductApiViewModel>> GetById(int id)
{
var product = await _context.Products
.Include(p => p.Category)
.Where(p => p.Id == id)
.Select(p => new ProductApiViewModel
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Price = p.Price,
Quantity = p.Quantity,
CategoryId = p.CategoryId,
CategoryName =
p.Category != null
? p.Category.Name
: null
})
.FirstOrDefaultAsync();
if (product == null)
{
return NotFound();
}
return Ok(product);
}
[Authorize]
[HttpPost]
public async Task<ActionResult<ProductApiViewModel>> Create(
ProductApiViewModel request)
{
var product = new Product
{
Name = request.Name,
Description = request.Description,
Price = request.Price,
Quantity = request.Quantity,
CategoryId = request.CategoryId
};
_context.Products.Add(product);
await _context.SaveChangesAsync();
var response = new ProductApiViewModel
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
Quantity = product.Quantity,
CategoryId = product.CategoryId
};
return CreatedAtAction(
nameof(GetById),
new { id = product.Id },
response);
}
[Authorize]
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(
int id,
ProductApiViewModel request)
{
var product = await _context.Products
.FindAsync(id);
if (product == null)
{
return NotFound();
}
product.Name = request.Name;
product.Description = request.Description;
product.Price = request.Price;
product.Quantity = request.Quantity;
product.CategoryId = request.CategoryId;
await _context.SaveChangesAsync();
return NoContent();
}
[Authorize]
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var product = await _context.Products
.FindAsync(id);
if (product == null)
{
return NotFound();
}
_context.Products.Remove(product);
await _context.SaveChangesAsync();
return NoContent();
}
}
Appendix C — Final Verification Commands
cd ~/aspnet-mvc-tutorial/ProductManagement
dotnet build
dotnet run
In another Terminal:
curl -k https://localhost:7000/api/products
curl -k https://localhost:7000/api/products/1
curl -k -i https://localhost:7000/api/products/99999
If the MVC interface still works, /api/products returns JSON, valid Product IDs return 200, missing IDs return 404, and protected write methods require authentication, Part 17 is complete.
Part 18 will introduce xUnit, Arrange-Act-Assert, unit tests for application logic, controller tests and basic integration testing.