DEV Community

FaithInErrorsZORO
FaithInErrorsZORO

Posted on

ASP.net Resting!

In this post we're cooking up two apps that work together:

  1. FoodMenuApi, a REST API over a small restaurant database of dishes, their cuisines and categories, and which restaurants have them on the menu.
  2. FoodMenuClient, an https://www.google.com/url?q=http://ASP.NET&source=gmail&ust=1793897479914000&sa=E Core MVC website that calls the API with HttpClient, so users can browse, search, add, edit and remove dishes through normal web forms.

Along the way we'll cover EF Core scaffolding, keeping controllers skinny with a repository, DTOs, CORS, proper HTTP status codes (including a 409 Conflict when you try to delete a dish that's still on a restaurant's menu), and a tiny JavaScript page that calls the API directly.

Here's the API we're building:

Method URL What it does
GET /api/dishes All dishes, sorted by name
GET /api/dishes/5 One dish
GET /api/dishes?name=pad Dishes whose name starts with "pad", any case
POST /api/dishes Add a dish (data in the body)
PUT /api/dishes/5 Update a dish (data in the body)
DELETE /api/dishes/5 Delete a dish, or 409 if it's on a menu

The API runs on http://localhost:5050 and the MVC site on http://localhost:5060.


The finished project

Files marked ★ are ones we write or edit. Files marked ⚙ are generated by EF Core scaffolding.

FoodMenuSolution/
├── FoodMenu.sql ★ (database script)
│
├── FoodMenuApi/ ← Part 1: the REST API
│ ├── Controllers/
│ │ └── DishesController.cs ★
│ ├── DTOs/
│ │ ├── DishDTO.cs ★
│ │ └── DishInputDTO.cs ★
│ ├── Models/
│ │ ├── Category.cs ⚙
│ │ ├── Cuisine.cs ⚙
│ │ ├── Dish.cs ⚙
│ │ ├── FoodMenuContext.cs ⚙
│ │ ├── MenuItem.cs ⚙
│ │ └── Restaurant.cs ⚙
│ ├── Repositories/
│ │ ├── DishRepository.cs ★
│ │ └── IDishRepository.cs ★
│ ├── Properties/
│ │ └── launchSettings.json ★ (fixed port 5050)
│ ├── wwwroot/
│ │ └── index.html ★ (JavaScript page)
│ ├── appsettings.json ★
│ └── Program.cs ★
│
└── FoodMenuClient/ ← Part 2: the MVC website
├── Controllers/
│ ├── DishesController.cs ★
│ └── HomeController.cs (template)
├── Models/
│ ├── ApiResult.cs ★
│ ├── Dish.cs ★ ([DataContract] class)
│ └── ErrorViewModel.cs (template)
├── Services/
│ └── DishApiClient.cs ★ (all the HttpClient code)
├── Views/
│ ├── Dishes/
│ │ ├── Create.cshtml ★
│ │ ├── Delete.cshtml ★
│ │ ├── Details.cshtml ★
│ │ ├── Edit.cshtml ★
│ │ └── Index.cshtml ★
│ └── Shared/
│ └── _Layout.cshtml ★ (one nav link)
├── Properties/
│ └── launchSettings.json ★ (fixed port 5060)
├── appsettings.json ★
└── Program.cs ★
Enter fullscreen mode Exit fullscreen mode

Part 1: The REST API

Step 1: Create the database

Our database has five tables. menu_items is the link table that says which restaurant serves which dish, and it's the reason some deletes will be refused later.

cuisines (cuisine_id PK, cuisine_name)
categories (category_id PK, category_name)
dishes (dish_id PK, dish_name, cuisine_id FK, category_id FK, calories, price)
restaurants (restaurant_id PK, restaurant_name, phone, city)
menu_items (restaurant_id PK/FK, dish_id PK/FK, daily_portions) ← links dishes to restaurants
Enter fullscreen mode Exit fullscreen mode

Run this script in SQL Server (SSMS, or View → SQL Server Object Explorer in Visual Studio):

CREATE DATABASE FoodMenu;
GO
USE FoodMenu;
GO

CREATE TABLE cuisines (
cuisine_id INT IDENTITY(1,1) PRIMARY KEY,
cuisine_name VARCHAR(100) NOT NULL
);

CREATE TABLE categories (
category_id INT IDENTITY(1,1) PRIMARY KEY,
category_name VARCHAR(100) NOT NULL
);

CREATE TABLE dishes (
dish_id INT IDENTITY(1,1) PRIMARY KEY,
dish_name VARCHAR(255) NOT NULL,
cuisine_id INT NOT NULL REFERENCES cuisines(cuisine_id),
category_id INT NOT NULL REFERENCES categories(category_id),
calories INT NOT NULL,
price DECIMAL(10,2) NOT NULL
);

CREATE TABLE restaurants (
restaurant_id INT IDENTITY(1,1) PRIMARY KEY,
restaurant_name VARCHAR(255) NOT NULL,
phone VARCHAR(25) NULL,
city VARCHAR(100) NULL
);

CREATE TABLE menu_items (
restaurant_id INT NOT NULL REFERENCES restaurants(restaurant_id),
dish_id INT NOT NULL REFERENCES dishes(dish_id),
daily_portions INT NOT NULL,
PRIMARY KEY (restaurant_id, dish_id)
);

INSERT INTO cuisines (cuisine_name) VALUES
('Italian'), ('Japanese'), ('Mexican'), ('Thai'), ('Canadian'), ('Indian');

INSERT INTO categories (category_name) VALUES
('Appetizer'), ('Main'), ('Dessert'), ('Soup');

INSERT INTO dishes (dish_name, cuisine_id, category_id, calories, price) VALUES
('Margherita Pizza', 1, 2, 850, 16.50),
('Tiramisu', 1, 3, 450, 8.00),
('Salmon Sushi Roll', 2, 2, 400, 14.00),
('Miso Soup', 2, 4, 80, 4.50),
('Tacos al Pastor', 3, 2, 650, 13.00),
('Churros', 3, 3, 500, 6.50),
('Pad Thai', 4, 2, 700, 15.00),
('Tom Yum Soup', 4, 4, 200, 9.00),
('Poutine', 5, 2, 950, 11.00),
('Butter Tarts', 5, 3, 420, 5.00),
('Butter Chicken', 6, 2, 780, 17.00),
('Samosas', 6, 1, 300, 7.00),
('Pakoras', 6, 1, 350, 6.50);

INSERT INTO restaurants (restaurant_name, phone, city) VALUES
('Chez Gatineau', '819-555-0101', 'Gatineau'),
('Ottawa Eats', '613-555-0202', 'Ottawa');

-- Dishes 1, 3, 7 and 9 are on a menu, so deleting them gives 409 Conflict
INSERT INTO menu_items (restaurant_id, dish_id, daily_portions) VALUES
(1, 1, 40), (1, 9, 60), (2, 3, 30), (2, 7, 50);
Enter fullscreen mode Exit fullscreen mode

Step 2: Create the Web API project

In Visual Studio: Create a new project → https://www.google.com/url?q=http://ASP.NET&source=gmail&ust=1793897479915000&sa=E Core Web API. Name the project FoodMenuApi and the solution FoodMenuSolution, and keep Use controllers checked.

Then install these four NuGet packages (right-click the project → Manage NuGet Packages), all published by Microsoft:

  • Microsoft.EntityFrameworkCore
  • Microsoft.EntityFrameworkCore.Design
  • Microsoft.EntityFrameworkCore.SqlServer
  • Microsoft.EntityFrameworkCore.Tools

Delete the template's WeatherForecast.cs and Controllers/WeatherForecastController.cs, since we won't need them.


Step 3: Add the connection string

appsettings.json:

{
"ConnectionStrings": {
"FoodMenu": "Server=(localdb)\\MSSQLLocalDB;Database=FoodMenu;Trusted_Connection=True;TrustServerCertificate=True"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Enter fullscreen mode Exit fullscreen mode

Using a different SQL Server? Swap in its connection string, for example Server=myserver, 1433;Database=FoodMenu;Authentication=Active Directory Default;TrustServerCertificate=True.


Step 4: Let EF Core write the models for us

Instead of typing out entity classes by hand, EF Core can scaffold them from the database. Open Tools → NuGet Package Manager → Package Manager Console, make sure the default project is FoodMenuApi, and run:

Scaffold-DbContext "Name=ConnectionStrings:FoodMenu" Microsoft.EntityFrameworkCore.SqlServer -OutputDir Models -Context FoodMenuContext -Force
Enter fullscreen mode Exit fullscreen mode
  • Name=ConnectionStrings:FoodMenu reads the connection string from appsettings.json, so it doesn't end up in your code.
  • -OutputDir Models puts the classes in a Models folder.
  • -Context FoodMenuContext names the DbContext.
  • -Force lets you re-run it later.

You'll get Dish, Cuisine, Category, Restaurant, MenuItem and FoodMenuContext. Snake_case columns turn into PascalCase properties. Here's the generated Models/Dish.cs (you don't type this):

namespace FoodMenuApi.Models;

public partial class Dish
{
public int DishId { get; set; }
public string DishName { get; set; } = null!;
public int CuisineId { get; set; }
public int CategoryId { get; set; }
public int Calories { get; set; }
public decimal Price { get; set; }

public virtual Category Category { get; set; } = null!;
public virtual Cuisine Cuisine { get; set; } = null!;
public virtual ICollection<MenuItem> MenuItems { get; set; } = new List<MenuItem>();
}
Enter fullscreen mode Exit fullscreen mode

The context exposes Dishes, Cuisines, Categories, Restaurants and MenuItems as DbSets.


Step 5: Pin the port

By default the port is random, which is annoying when another app needs to find your API. Fix it in Properties/launchSettings.json:

{
"$schema": "https://www.google.com/url?q=https://json.schemastore.org/launchsettings.json&source=gmail&ust=1793897479915000&sa=E",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "api/dishes",
"applicationUrl": "https://www.google.com/url?q=http://localhost:5050&source=gmail&ust=1793897479915000&sa=E",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
Enter fullscreen mode Exit fullscreen mode

Pick the http profile from the dropdown next to the green Run button.


Step 6: DTOs, the public face of a dish

It's tempting to return the Dish entity straight from the API, but that causes problems:

  • It exposes database details like navigation properties and foreign keys the client doesn't care about.
  • Navigation properties can create loops when serialized.
  • If you accept Dish in a POST, [ApiController] validation complains that "The Cuisine field is required", because the navigation properties are non-nullable.

The fix is DTOs (Data Transfer Objects): small classes shaped for the outside world. Create a DTOs folder with two of them.

DTOs/DishDTO.cs is what the API sends out. It swaps unhelpful ids for readable names:

namespace FoodMenuApi.DTOs
{
public class DishDTO
{
public int DishId { get; set; }
public string DishName { get; set; } = string.Empty;
public int CuisineId { get; set; }
public string? CuisineName { get; set; }
public int CategoryId { get; set; }
public string? CategoryName { get; set; }
public int Calories { get; set; }
public decimal Price { get; set; }
}
}
Enter fullscreen mode Exit fullscreen mode

DTOs/DishInputDTO.cs is what the API accepts for POST and PUT. There's no id because the database generates it:

using System.ComponentModel.DataAnnotations;

namespace FoodMenuApi.DTOs
{
public class DishInputDTO
{
[Required, StringLength(255)]
public string DishName { get; set; } = string.Empty;

[Range(1, int.MaxValue)]
public int CuisineId { get; set; }

[Range(1, int.MaxValue)]
public int CategoryId { get; set; }

[Range(0, 5000)]
public int Calories { get; set; }

[Range(0, 9999)]
public decimal Price { get; set; }
}
}
Enter fullscreen mode Exit fullscreen mode

Step 7: A repository to keep the controller skinny

If you scaffold an API controller, Visual Studio stuffs all the EF Core code right inside it, which makes a "fat" controller. A cleaner approach is a repository: one class that owns all the data access, so the controller only deals with HTTP.

Create a Repositories folder. First the interface, Repositories/IDishRepository.cs:

using FoodMenuApi.DTOs;

namespace FoodMenuApi.Repositories
{
public enum DeleteResult { Deleted, NotFound, Conflict }

public interface IDishRepository
{
Task<List<DishDTO>> GetAllAsync(string? name);
Task<DishDTO?> GetByIdAsync(int id);
Task<DishDTO> AddAsync(DishInputDTO input);
Task<bool> UpdateAsync(int id, DishInputDTO input); // false = not found
Task<DeleteResult> DeleteAsync(int id);
}
}
Enter fullscreen mode Exit fullscreen mode

Then the implementation, Repositories/DishRepository.cs:

using FoodMenuApi.DTOs;
using FoodMenuApi.Models;
using Microsoft.EntityFrameworkCore;

namespace FoodMenuApi.Repositories
{
public class DishRepository : IDishRepository
{
private readonly FoodMenuContext _context;

public DishRepository(FoodMenuContext context)
{
_context = context;
}

// Entity -> DTO (manual mapping)
private static DishDTO ToDto(Dish d) => new DishDTO
{
DishId = d.DishId,
DishName = d.DishName,
CuisineId = d.CuisineId,
CuisineName = d.Cuisine?.CuisineName,
CategoryId = d.CategoryId,
CategoryName = d.Category?.CategoryName,
Calories = d.Calories,
Price = d.Price
};

// All dishes, or only those whose name starts with `name` (any case), sorted by name
public async Task<List<DishDTO>> GetAllAsync(string? name)
{
IQueryable<Dish> query = _context.Dishes
.Include(d => d.Cuisine)
.Include(d => d.Category);

if (!string.IsNullOrWhiteSpace(name))
{
string lower = name.Trim().ToLower();
query = query.Where(d => d.DishName.ToLower().StartsWith(lower));
}

List<Dish> dishes = await query
.OrderBy(d => d.DishName)
.ToListAsync();

return dishes.Select(ToDto).ToList();
}

public async Task<DishDTO?> GetByIdAsync(int id)
{
Dish? dish = await _context.Dishes
.Include(d => d.Cuisine)
.Include(d => d.Category)
.FirstOrDefaultAsync(d => d.DishId == id);

return dish == null ? null : ToDto(dish);
}

public async Task<DishDTO> AddAsync(DishInputDTO input)
{
var dish = new Dish
{
DishName = input.DishName,
CuisineId = input.CuisineId,
CategoryId = input.CategoryId,
Calories = input.Calories,
Price = input.Price
};

_context.Dishes.Add(dish);
await _context.SaveChangesAsync(); // the database generates DishId here

return (await GetByIdAsync(dish.DishId))!;
}

public async Task<bool> UpdateAsync(int id, DishInputDTO input)
{
Dish? dish = await _context.Dishes.FindAsync(id); // find it first
if (dish == null) return false;

dish.DishName = input.DishName;
dish.CuisineId = input.CuisineId;
dish.CategoryId = input.CategoryId;
dish.Calories = input.Calories;
dish.Price = input.Price;

await _context.SaveChangesAsync();
return true;
}

public async Task<DeleteResult> DeleteAsync(int id)
{
Dish? dish = await _context.Dishes.FindAsync(id);
if (dish == null) return DeleteResult.NotFound;

// Referential integrity: a dish on a restaurant's menu can't be deleted
bool onAMenu = await _context.MenuItems.AnyAsync(m => m.DishId == id);
if (onAMenu) return DeleteResult.Conflict;

_context.Dishes.Remove(dish);
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateException)
{
return DeleteResult.Conflict; // any other foreign key problem
}
return DeleteResult.Deleted;
}
}
}
Enter fullscreen mode Exit fullscreen mode

The search uses ToLower() on both sides plus StartsWith, so pad, PAD and pAd all find "Pad Thai".


Step 8: Wire it all up in Program.cs

Now we register the DbContext and the repository, and turn on CORS. Browsers block JavaScript from calling an API on a different origin (a different port counts) unless the API explicitly allows it. CORS is that permission.

using FoodMenuApi.Models;
using FoodMenuApi.Repositories;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Database
builder.Services.AddDbContext<FoodMenuContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("FoodMenu")));

// Repository
builder.Services.AddScoped<IDishRepository, DishRepository>();

// CORS: allow JavaScript from other origins to call this API
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod());
});

builder.Services.AddControllers();
builder.Services.AddOpenApi(); // .NET 9+ template default; older templates use AddSwaggerGen()

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}

// app.UseHttpsRedirection(); // off: we're using plain https://www.google.com/url?q=http://localhost:5050&source=gmail&ust=1793897479915000&sa=E

app.UseDefaultFiles(); // "/" serves wwwroot/index.html
app.UseStaticFiles(); // serves files from wwwroot

app.UseCors("AllowAll"); // must come BEFORE MapControllers

app.UseAuthorization();

app.MapControllers();

app.Run();
Enter fullscreen mode Exit fullscreen mode

Step 9: The (skinny) API controller

Right-click Controllers → Add → Controller → API → API Controller with actions, using Entity Framework. Pick model Dish, context FoodMenuContext, and name it DishesController (plural, which makes the route api/dishes). Then replace the generated code with this version, which only handles HTTP and leaves the data work to the repository:

using FoodMenuApi.DTOs;
using FoodMenuApi.Repositories;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace FoodMenuApi.Controllers
{
[Route("api/[controller]")] // → api/dishes
[ApiController]
public class DishesController : ControllerBase
{
private readonly IDishRepository _repo;

public DishesController(IDishRepository repo)
{
_repo = repo;
}

// GET api/dishes → all dishes, sorted
// GET api/dishes?name=pad → dishes starting with "pad"
[HttpGet]
public async Task<ActionResult<IEnumerable<DishDTO>>> GetDishes([FromQuery] string? name)
{
List<DishDTO> dishes = await _repo.GetAllAsync(name);

if (!string.IsNullOrWhiteSpace(name) && dishes.Count == 0)
return NotFound($"No dishes start with '{name}'.");

return Ok(dishes);
}

// GET api/dishes/5
[HttpGet("{id}")]
public async Task<ActionResult<DishDTO>> GetDish(int id)
{
DishDTO? dish = await _repo.GetByIdAsync(id);
if (dish == null)
return NotFound($"Dish {id} not found.");

return Ok(dish);
}

// POST api/dishes (dish in the body)
[HttpPost]
public async Task<ActionResult<DishDTO>> PostDish(DishInputDTO input)
{
try
{
DishDTO created = await _repo.AddAsync(input);
// 201 Created, with a Location header pointing at GetDish
return CreatedAtAction(nameof(GetDish), new { id = created.DishId }, created);
}
catch (DbUpdateException)
{
return BadRequest("Could not add the dish. Check that CuisineId and CategoryId exist.");
}
}

// PUT api/dishes/5 (dish in the body)
[HttpPut("{id}")]
public async Task<IActionResult> PutDish(int id, DishInputDTO input)
{
try
{
bool updated = await _repo.UpdateAsync(id, input);
if (!updated)
return NotFound($"Dish {id} not found.");

return NoContent(); // 204
}
catch (DbUpdateException)
{
return BadRequest("Could not update the dish. Check that CuisineId and CategoryId exist.");
}
}

// DELETE api/dishes/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteDish(int id)
{
DeleteResult result = await _repo.DeleteAsync(id);

return result switch
{
DeleteResult.NotFound => NotFound($"Dish {id} not found."),
DeleteResult.Conflict => Conflict($"Dish {id} is on a restaurant's menu and can't be deleted."), // 409
_ => NoContent() // 204
};
}
}
}
Enter fullscreen mode Exit fullscreen mode

A quick cheat sheet of the status codes we're returning:

Helper Code When
Ok(x) 200 GET worked
CreatedAtAction(...) 201 POST worked
NoContent() 204 PUT or DELETE worked
BadRequest(msg) 400 Bad data or a missing foreign key
NotFound(msg) 404 No such dish
Conflict(msg) 409 Dish is still on a menu

Step 10: A tiny JavaScript front end

To show CORS and fetch in action, add wwwroot/index.html (create the wwwroot folder if it isn't there). It opens at `https://www.google.com/url?q=http://localhost:5050/%60&source=gmail&ust=1793897479916000&sa=E.

`html
<!DOCTYPE html>



Dish Finder body { font-family: sans-serif; margin: 20px; } table { border-collapse: collapse; margin-top: 10px; } th, td { border: 1px solid #ccc; padding: 4px 8px; } #msg { color: darkred; }

🍜 Dish Finder


Search
Show all

Id Dish Cuisine Category Calories Price

const apiUrl = "https://www.google.com/url?q=http://localhost:5050/api/dishes&amp;source=gmail&amp;ust=1793897479916000&amp;sa=E";

async function load(name) {
const url = name ? ${apiUrl}?name=${encodeURIComponent(name)} : apiUrl;
document.getElementById("msg").textContent = "";
document.getElementById("rows").innerHTML = "";

try {
const response = await fetch(url);

if (!response.ok) {
document.getElementById("msg").textContent =
Error ${response.status}: ${await response.text()};
return;
}

const dishes = await response.json(); // JSON keys are camelCase

document.getElementById("rows").innerHTML = dishes.map(d =>
&lt;tr&gt;
&lt;td&gt;${d.dishId}&lt;/td&gt;
&lt;td&gt;${d.dishName}&lt;/td&gt;
&lt;td&gt;${d.cuisineName ?? ""}&lt;/td&gt;
&lt;td&gt;${d.categoryName ?? ""}&lt;/td&gt;
&lt;td&gt;${d.calories}&lt;/td&gt;
&lt;td&gt;$${d.price.toFixed(2)}&lt;/td&gt;
&lt;/tr&gt;
).join("");
} catch (err) {
document.getElementById("msg").textContent = "Could not reach the API: " + err;
}
}

document.getElementById("btnAll").addEventListener("click", () => load(""));
document.getElementById("btnSearch").addEventListener("click",
() => load(document.getElementById("name").value));



`

Notice the JSON keys are camelCase (dishName, not DishName). https://www.google.com/url?q=http://ASP.NET&source=gmail&ust=1793897479916000&sa=E Core does that by default.


Step 11: Test the API with Postman

Before writing a client, make sure the API behaves. In Postman:

For POST and PUT, choose Body → raw → JSON:

json
{
"dishName": "Maple Pancakes",
"cuisineId": 5,
"categoryId": 3,
"calories": 600,
"price": 9.50
}


Part 2: Consuming the API from MVC

Our API works. Now let's give it a proper website.

Step 12: Add an MVC project to the same solution

Right-click the solution → Add → New Project → https://www.google.com/url?q=http://ASP.NET&source=gmail&ust=1793897479916000&sa=E Core Web App (Model-View-Controller) → name it FoodMenuClient.

To run both at once, right-click the solution → Configure Startup Projects → Multiple startup projects, and set both to Start.

The 12-step recipe for calling a REST API

Every call in the client follows the same recipe. Here's where each step shows up in the code below:

# Step Where
1 A controller action, with an optional id DishesController (client)
2 Create an HttpClient DishApiClient.CreateClient()
3 Clear the default headers Accept.Clear()
4 Accept application/json Accept.Add(...)
5 Build the URI string _baseUrl + id or ?name=
6 Read the response as a stream ReadAsStreamAsync()
7 A class whose properties match the JSON Models/Dish.cs
8 Mark it with [DataContract] / [DataMember] Models/Dish.cs
9 Give it constructors Models/Dish.cs
10 Deserialize with DataContractJsonSerializer DishApiClient.Read<T>()
11 Wait for ReadObject to finish DishApiClient.Read<T>()
12 Pass the result to the view DishesController

Step 13: Pin the client's port and store the API URL

Properties/launchSettings.json:

json
{
"$schema": "https://www.google.com/url?q=https://json.schemastore.org/launchsettings.json&source=gmail&ust=1793897479916000&sa=E",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://www.google.com/url?q=http://localhost:5060&source=gmail&ust=1793897479916000&sa=E",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

appsettings.json:

json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"DishesApi": {
"BaseUrl": "https://www.google.com/url?q=http://localhost:5050/api/dishes&source=gmail&ust=1793897479916000&sa=E"
}
}


Step 14: A model that matches the JSON

The client needs its own Dish class shaped like the API's JSON. Each [DataMember(Name = "...")] must match a JSON key exactly, camelCase included.

Models/Dish.cs:

`csharp
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;

namespace FoodMenuClient.Models
{
[DataContract]
public class Dish
{
[DataMember(Name = "dishId")]
[Display(Name = "Id")]
public int DishId { get; set; }

[DataMember(Name = "dishName")]
[Display(Name = "Dish")]
[Required]
public string DishName { get; set; } = string.Empty;

[DataMember(Name = "cuisineId")]
[Display(Name = "Cuisine Id")]
public int CuisineId { get; set; }

[DataMember(Name = "cuisineName")]
[Display(Name = "Cuisine")]
public string? CuisineName { get; set; }

[DataMember(Name = "categoryId")]
[Display(Name = "Category Id")]
public int CategoryId { get; set; }

[DataMember(Name = "categoryName")]
[Display(Name = "Category")]
public string? CategoryName { get; set; }

[DataMember(Name = "calories")]
[Display(Name = "Calories")]
public int Calories { get; set; }

[DataMember(Name = "price")]
[Display(Name = "Price")]
public decimal Price { get; set; }

public Dish() { }

public Dish(int dishId, string dishName, int cuisineId, int categoryId, int calories, decimal price)
{
DishId = dishId;
DishName = dishName;
CuisineId = cuisineId;
CategoryId = categoryId;
Calories = calories;
Price = price;
}
}
}
`

We'll also want the status code along with the data, so the UI can say things like "404, not found". Models/ApiResult.cs is a small wrapper for that:

csharp
namespace FoodMenuClient.Models
{
public class ApiResult<T>
{
public T? Data { get; set; }
public int StatusCode { get; set; }
public bool Success { get; set; }
public string? Message { get; set; }
}
}


Step 15: One class for all the HTTP calls

Just like the repository on the API side, we'll keep HttpClient code out of the controller. Create a Services folder and add DishApiClient.cs:

`csharp
using System.Net.Http.Headers;
using System.Runtime.Serialization.Json;
using System.Text;
using FoodMenuClient.Models;

namespace FoodMenuClient.Services
{
public class DishApiClient
{
private readonly string _baseUrl;

public DishApiClient(IConfiguration config)
{
_baseUrl = config["DishesApi:BaseUrl"]
?? throw new InvalidOperationException("DishesApi:BaseUrl is missing from appsettings.json");
}

// Steps 2-4: create the client, clear headers, accept JSON
private static HttpClient CreateClient()
{
var client = new HttpClient();
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return client;
}

// Steps 6, 10 and 11: stream → DataContractJsonSerializer → object
private static async Task Read(HttpResponseMessage response)
{
using Stream stream = await response.Content.ReadAsStreamAsync();
var serializer = new DataContractJsonSerializer(typeof(T));
return (T)serializer.ReadObject(stream)!;
}

// Object → JSON request body (for POST and PUT)
private static StringContent ToJson(Dish dish)
{
var serializer = new DataContractJsonSerializer(typeof(Dish));
using var ms = new MemoryStream();
serializer.WriteObject(ms, dish);
string json = Encoding.UTF8.GetString(ms.ToArray());
return new StringContent(json, Encoding.UTF8, "application/json");
}

private static ApiResult Fail(HttpResponseMessage response, string body) => new ApiResult
{
StatusCode = (int)response.StatusCode,
Success = false,
Message = string.IsNullOrWhiteSpace(body) ? response.ReasonPhrase : body
};

private static ApiResult Unreachable(Exception ex) => new ApiResult
{
StatusCode = 0,
Success = false,
Message = "Could not reach the API. Is FoodMenuApi running? " + ex.Message
};

// GET api/dishes or api/dishes?name=xxx
public async Task>> GetAllAsync(string? name)
{
using HttpClient client = CreateClient();
string uri = string.IsNullOrWhiteSpace(name)
? _baseUrl
: $"{_baseUrl}?name={Uri.EscapeDataString(name)}"; // step 5

try
{
HttpResponseMessage response = await client.GetAsync(uri);
if (!response.IsSuccessStatusCode)
return Fail>(response, await response.Content.ReadAsStringAsync());

return new ApiResult>
{
Data = await Read>(response),
StatusCode = (int)response.StatusCode,
Success = true
};
}
catch (HttpRequestException ex) { return Unreachable>(ex); }
}

// GET api/dishes/5
public async Task> GetByIdAsync(int id)
{
using HttpClient client = CreateClient();
try
{
HttpResponseMessage response = await client.GetAsync($"{_baseUrl}/{id}");
if (!response.IsSuccessStatusCode)
return Fail(response, await response.Content.ReadAsStringAsync());

return new ApiResult
{
Data = await Read(response),
StatusCode = (int)response.StatusCode,
Success = true
};
}
catch (HttpRequestException ex) { return Unreachable(ex); }
}

// POST api/dishes
public async Task> CreateAsync(Dish dish)
{
using HttpClient client = CreateClient();
try
{
HttpResponseMessage response = await client.PostAsync(_baseUrl, ToJson(dish));
if (!response.IsSuccessStatusCode)
return Fail(response, await response.Content.ReadAsStringAsync());

return new ApiResult
{
Data = await Read(response), // the API sends back the new dish
StatusCode = (int)response.StatusCode,
Success = true,
Message = "Dish added."
};
}
catch (HttpRequestException ex) { return Unreachable(ex); }
}

// PUT api/dishes/5
public async Task> UpdateAsync(int id, Dish dish)
{
using HttpClient client = CreateClient();
try
{
HttpResponseMessage response = await client.PutAsync($"{_baseUrl}/{id}", ToJson(dish));
if (!response.IsSuccessStatusCode)
return Fail(response, await response.Content.ReadAsStringAsync());

return new ApiResult
{
Data = true,
StatusCode = (int)response.StatusCode,
Success = true,
Message = $"Dish {id} updated."
};
}
catch (HttpRequestException ex) { return Unreachable(ex); }
}

// DELETE api/dishes/5
public async Task> DeleteAsync(int id)
{
using HttpClient client = CreateClient();
try
{
HttpResponseMessage response = await client.DeleteAsync($"{_baseUrl}/{id}");
if (!response.IsSuccessStatusCode)
return Fail(response, await response.Content.ReadAsStringAsync());

return new ApiResult
{
Data = true,
StatusCode = (int)response.StatusCode,
Success = true,
Message = $"Dish {id} deleted."
};
}
catch (HttpRequestException ex) { return Unreachable(ex); }
}
}
}
`

Two things worth knowing about DataContractJsonSerializer:

  • A single object deserializes as Read<Dish>, and an array as Read<List<Dish>>.
  • It can't parse ISO date strings like "2026-11-04T12:00:00" into DateTime. If an API you're calling returns dates, make those properties string.

Step 16: Register the API client

The client's Program.cs is the MVC template with one line added:

`csharp
using FoodMenuClient.Services;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();
builder.Services.AddScoped(); // ★ added

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();
`


Step 17: The MVC controller

Right-click Controllers → Add → Controller → MVC Controller - Empty → DishesController. Each action calls the API client and decides which view or message to show:

`csharp
using FoodMenuClient.Models;
using FoodMenuClient.Services;
using Microsoft.AspNetCore.Mvc;

namespace FoodMenuClient.Controllers
{
public class DishesController : Controller
{
private readonly DishApiClient _api;

public DishesController(DishApiClient api)
{
_api = api;
}

// GET /Dishes or /Dishes?name=pad
public async Task Index(string? name)
{
ApiResult> result = await _api.GetAllAsync(name);

ViewBag.Search = name;
ViewBag.Status = result.StatusCode;
ViewBag.Error = result.Success ? null : result.Message;

return View(result.Data ?? new List()); // step 12
}

// GET /Dishes/Details/5
public async Task Details(int id)
{
ApiResult result = await _api.GetByIdAsync(id);
if (!result.Success)
{
TempData["Error"] = $"({result.StatusCode}) {result.Message}";
return RedirectToAction(nameof(Index));
}
ViewBag.Status = result.StatusCode;
return View(result.Data);
}

// GET /Dishes/Create
[HttpGet]
public IActionResult Create()
{
return View(new Dish());
}

// POST /Dishes/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task Create(Dish dish)
{
if (!ModelState.IsValid) return View(dish);

ApiResult result = await _api.CreateAsync(dish);
if (!result.Success)
{
ViewBag.Error = $"({result.StatusCode}) {result.Message}";
return View(dish);
}

TempData["Message"] = $"({result.StatusCode}) Dish added with id {result.Data!.DishId}.";
return RedirectToAction(nameof(Details), new { id = result.Data.DishId });
}

// GET /Dishes/Edit/5 → form pre-filled with the current values
[HttpGet]
public async Task Edit(int id)
{
ApiResult result = await _api.GetByIdAsync(id);
if (!result.Success)
{
TempData["Error"] = $"({result.StatusCode}) {result.Message}";
return RedirectToAction(nameof(Index));
}
return View(result.Data);
}

// POST /Dishes/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task Edit(int id, Dish dish)
{
if (!ModelState.IsValid) return View(dish);

ApiResult result = await _api.UpdateAsync(id, dish);
if (!result.Success)
{
ViewBag.Error = $"({result.StatusCode}) {result.Message}";
return View(dish);
}

TempData["Message"] = $"({result.StatusCode}) {result.Message}";
return RedirectToAction(nameof(Details), new { id });
}

// GET /Dishes/Delete/5 → "are you sure?" page
[HttpGet]
public async Task Delete(int id)
{
ApiResult result = await _api.GetByIdAsync(id);
if (!result.Success)
{
TempData["Error"] = $"({result.StatusCode}) {result.Message}";
return RedirectToAction(nameof(Index));
}
return View(result.Data);
}

// POST /Dishes/Delete/5
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task DeleteConfirmed(int id)
{
ApiResult result = await _api.DeleteAsync(id);

if (result.Success)
TempData["Message"] = $"({result.StatusCode}) {result.Message}";
else if (result.StatusCode == 409)
TempData["Error"] = $"(409) Dish {id} is on a restaurant's menu, so it can't be deleted.";
else
TempData["Error"] = $"({result.StatusCode}) {result.Message}";

return RedirectToAction(nameof(Index));
}
}
}
`


Step 18: The views

Create the folder Views/Dishes and add five views.

A note on the Razor style: these views use explicit expressions like @(Model.DishName) and wrap if/foreach inside @{ ... } blocks. That's standard Razor and behaves exactly like the shorter form without parentheses that you may be used to. Either style works in Visual Studio.

Index.cshtml: the menu, with search

`html
@model List
@{
ViewData["Title"] = "Dishes";
}

🍽️ Dishes

@{
if (TempData["Message"] != null)
{

@(TempData["Message"])

}
if (TempData["Error"] != null)
{

@(TempData["Error"])

}
if (ViewBag.Error != null)
{

(@(ViewBag.Status)) @(ViewBag.Error)

}
}

Search Show all Add a dish Find by id

Status returned: @(ViewBag.Status) | @(Model.Count) dish(es)

@{ foreach (var d in Model) { } }
Id Dish Cuisine Category Calories Price
@(d.DishId) @(d.DishName) @(d.CuisineName) @(d.CategoryName) @(d.Calories) @(d.Price.ToString("C")) Details | Edit | Delete

`

Details.cshtml

`html
@model FoodMenuClient.Models.Dish
@{
ViewData["Title"] = "Dish Details";
}

@(Model.DishName)

@{
if (TempData["Message"] != null)
{

@(TempData["Message"])

}
}

Status returned: @(ViewBag.Status)

Id
@(Model.DishId)
Cuisine
@(Model.CuisineName) (id @(Model.CuisineId))
Category
@(Model.CategoryName) (id @(Model.CategoryId))
Calories
@(Model.Calories) kcal
Price
@(Model.Price.ToString("C"))

Edit
Delete
Back to dishes
`

Create.cshtml

The asp-for tag helpers build each label and input from the model, and asp-validation-for shows any validation error for that field.

`html
@model FoodMenuClient.Models.Dish
@{
ViewData["Title"] = "Add a Dish";
}

Add a Dish

@{
if (ViewBag.Error != null)
{

@(ViewBag.Error)

}
}

Add
Cancel

`

Edit.cshtml

`html
@model FoodMenuClient.Models.Dish
@{
ViewData["Title"] = "Edit Dish";
}

Edit: @(Model.DishName)

@{
if (ViewBag.Error != null)
{

@(ViewBag.Error)

}
}

Save
Cancel

`

Delete.cshtml

`html
@model FoodMenuClient.Models.Dish
@{
ViewData["Title"] = "Delete Dish";
}

Delete this dish?

Id
@(Model.DishId)
Dish
@(Model.DishName)
Cuisine
@(Model.CuisineName)
Price
@(Model.Price.ToString("C"))

Delete
Cancel

`

Add a nav link in Views/Shared/_Layout.cshtml

html
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Dishes" asp-action="Index">Dishes</a>
</li>


Step 19: Run the whole thing

Hit Start and both apps launch: the API on 5050 and the website on 5060. Head to `https://www.google.com/url?q=http://localhost:5060/Dishes%60&source=gmail&ust=1793897479917000&sa=E and try it out:

  • The dish list comes back sorted alphabetically.
  • Searching pAD finds Pad Thai, and searching zzz shows a friendly 404 message.
  • Find by id with 999 explains that the dish doesn't exist instead of crashing.
  • Add a dish creates "Maple Pancakes" and takes you to its details page with its new id.
  • Edit updates it.
  • Delete works on your new dish (204), but trying to delete Margherita Pizza (id 1) is refused with a 409, because it's on Chez Gatineau's menu.

And `https://www.google.com/url?q=http://localhost:5050/%60&source=gmail&ust=1793897479917000&sa=E still serves the little JavaScript Dish Finder.

Here's what happens on a single page load:

text
Browser ──► FoodMenuClient (MVC, :5060)
DishesController.Index()
└─► DishApiClient.GetAllAsync()
HttpClient GET https://www.google.com/url?q=http://localhost:5050/api/dishes&source=gmail&ust=1793897479917000&sa=E
│
▼
FoodMenuApi (Web API, :5050)
DishesController.GetDishes() (skinny)
└─► DishRepository.GetAllAsync() (LINQ + EF Core)
└─► SQL Server (FoodMenu database)
│
JSON ◄──────────────┘ [ { "dishId": 7, "dishName": "Pad Thai", ... } ]
│
DataContractJsonSerializer → List<Dish>
│
View(List<Dish>) → HTML table


Troubleshooting

  • "The Cuisine field is required" on POST. The API is accepting the Dish entity instead of DishInputDTO.
  • "blocked by CORS policy" in the browser console. AddCors/UseCors is missing, or UseCors comes after MapControllers.
  • "Could not reach the API" in the website. The API isn't running, or the port in the client's appsettings.json doesn't match the API's launchSettings.json.
  • Blank values in the website. A [DataMember(Name = "...")] doesn't match the JSON key exactly. Compare against the JSON in Postman.
  • Search is case sensitive. You forgot ToLower() on one side of the comparison.

Reusing this for other APIs

  • Calling someone else's API: open it in Postman, build a [DataContract] class that matches its JSON, change BaseUrl, and copy DishApiClient with your class swapped in.
  • Building an API over a different database: scaffold it, write DTOs, add a repository and its interface, register them in Program.cs, and keep the controller focused on HTTP.

Wrapping up

We built a REST API with EF Core, kept the controller lean with a repository, shaped responses with DTOs, returned meaningful status codes, opened it to JavaScript with CORS, and then built an MVC site that calls it through HttpClient. The recipe is the same whatever's on the menu. 🍕

Top comments (0)