ASP.NET CORE MVC - REST API

ASP.NET CORE MVC TUTORIAL SERIES · PART 17

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.

Objective

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.

Starting point

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.

In this tutorial
  1. Understand MVC versus API controllers
  2. Create an API ViewModel
  3. Create ProductsApiController
  4. Add GET all
  5. Add GET by ID
  6. Add POST
  7. Add PUT
  8. Add DELETE
  9. Understand HTTP status codes
  10. Test with curl
  11. Review authentication implications
  12. 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

MVCAPI
Usually returns Razor ViewsUsually returns JSON
Designed for browser UIDesigned for clients/applications
ControllerControllerBase
Conventional routes commonly usedAttribute routes commonly used
Database ↓ EF Core ↓ Controllers ┌───────┴────────┐ MVC API ↓ ↓ Razor Views JSON ↓ ↓ Browser App / curl / client

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.

Important limitation for this introductory API

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

StatusMeaningUsed here
200OKSuccessful GET
201CreatedSuccessful POST
204No ContentSuccessful PUT/DELETE
400Bad RequestInvalid API model input
401UnauthorizedAuthentication required
404Not FoundRequested Product missing

13. Build the Project

Save:

ViewModels/ProductApiViewModel.cs
Controllers/ProductsApiController.cs

Then run:

dotnet build
Checkpoint

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.

Product entity Id Name Description Price Quantity CategoryId Category OwnerId ↓ ProductApiViewModel Id Name Description Price Quantity CategoryId CategoryName

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.

Do not create a migration

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.

Important

[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

  1. Run the application.
  2. GET all Products with curl.
  3. GET one valid Product.
  4. GET a nonexistent Product and inspect the 404 status.
  5. Pipe the Product list through jq.
  6. Send an unauthenticated POST and observe the authentication response.
  7. Compare the MVC route /Products with API route /api/products.

28. Knowledge Check

  1. What is the difference between an MVC controller and an API controller?
  2. What does [ApiController] do?
  3. What is attribute routing?
  4. What HTTP method retrieves data?
  5. What HTTP method normally creates data?
  6. What does HTTP 201 mean?
  7. What does HTTP 204 mean?
  8. Why use an API ViewModel instead of the entity directly?
  9. Why does Part 17 not require a migration?
  10. 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
Final Part 17 checkpoint

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.

Next: Part 18 — Testing the MVC Application

Part 18 will introduce xUnit, Arrange-Act-Assert, unit tests for application logic, controller tests and basic integration testing.