Intermediate 50 min.NET 10

Build a focused JSON API

Expose useful data through controller endpoints with explicit response shapes and authorization.

BY THE END OF THIS TUTORIAL

Here's what you'll be able to do.

  • Use ApiController and attribute routing
  • Return DTO-shaped responses instead of database entities
  • Test status codes and authentication behavior
Before you begin

Complete routing, EF queries, and Identity. Sign in to TaskBoard before opening its private API.

Understand the idea

An API is another presentation of your application’s capabilities. It receives an HTTP request and returns a machine-readable representation. The same authorization and data-boundary rules apply even though no Razor view is involved.

TaskBoard includes a deliberately read-only API. It lets you inspect your tasks and a summary without introducing an unnecessary second write workflow. The endpoints return explicit fields, not the entire EF entity graph.

Choose your environment

Open TaskBoard.csproj from the example download. Install the ASP.NET and web development workload and a Visual Studio release supporting .NET 10. Select the TaskBoard launch profile and press F5. Use Solution Explorer to find the files named below.

Download the complete companion project
STEP 01

Define the endpoint surface

ApiController enables API-specific conventions such as automatic validation-error responses for invalid bound models. Route supplies a shared prefix, while HttpGet supplies the individual path. ControllerBase is enough because these endpoints do not render views.

The id route uses an integer constraint, and summary has a literal route. Keep resource names and response shapes stable; clients depend on them just as users depend on visible navigation.

Controllers/BoardApiController.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TaskBoard.Data;
using TaskBoard.Services;

namespace TaskBoard.Controllers;

[ApiController, Authorize, Route("api/tasks")]
public class BoardApiController(BoardDb db, IBoardSummary summary) : ControllerBase
{
    private string OwnerId => User.FindFirstValue(ClaimTypes.NameIdentifier)!;

    [HttpGet]
    public async Task<IActionResult> List(CancellationToken ct) => Ok(await db.Tasks.AsNoTracking()
        .Where(t => t.OwnerId == OwnerId).OrderBy(t => t.Id).Take(100)
        .Select(t => new { t.Id, t.Title, t.IsComplete }).ToListAsync(ct));

    [HttpGet("{id:int}")]
    public async Task<IActionResult> Get(int id, CancellationToken ct)
    {
        var task = await db.Tasks.AsNoTracking().Where(t => t.Id == id && t.OwnerId == OwnerId)
            .Select(t => new { t.Id, t.Title, t.IsComplete }).SingleOrDefaultAsync(ct);
        return task is null ? NotFound() : Ok(task);
    }

    [HttpGet("summary")]
    public async Task<Summary> Summary(CancellationToken ct) => await summary.GetAsync(OwnerId, ct);
}
STEP 02

Project a deliberate response

The list returns id, title, and isComplete. It does not expose OwnerId, Identity navigation properties, or full notes. This avoids coupling clients to persistence details and prevents accidental disclosure when an entity gains a new property.

A production API with many consumers benefits from named DTO records and explicit versioning decisions. For this small example, the projection demonstrates the boundary without adding another layer of files. The cap of 100 records is intentional and should be documented to clients.

JSON response example
[
  { "id": 1, "title": "Learn MVC", "isComplete": false },
  { "id": 2, "title": "Run the tests", "isComplete": true }
]
STEP 03

Use the API from the same origin

After signing in, the browser sends the same-origin authentication cookie with fetch. Always check response.ok before treating the body as successful data. A non-success body may have a different shape or be empty.

Do not paste a private access token into client code. The sample uses the existing browser session. If you later support third-party clients, design a proper token-based authentication flow instead of copying browser cookies between systems.

JavaScript example
const response = await fetch("/api/tasks/summary", {
    headers: { "Accept": "application/json" }
});
if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
}
const summary = await response.json();
console.log(`${summary.completed} of ${summary.total} tasks complete`);
STEP 04

Keep errors and writes deliberate

A missing or unowned id returns 404. An anonymous request to a known API endpoint under .NET 10 cookie behavior receives an authentication failure rather than a successful HTML login response. Verify actual HTTP behavior with an integration test.

Adding writes is a separate design step. A cookie-authenticated JSON mutation needs CSRF protection, validation, ownership checks, and a consistent error contract. Do not assume JSON automatically prevents cross-site request forgery. TaskBoard’s API stays read-only while its existing MVC forms provide protected writes.

HTTP checks
GET /api/tasks          → 200 + an array for a signed-in user
GET /api/tasks/summary  → 200 + total/completed counts
GET /api/tasks/999999   → 404 if no owned row exists
GET /api/tasks without authentication → 401
YOUR TURN

Make it your own.

Call /api/tasks/summary before and after completing one of your tasks. Verify that total stays the same and completed increases by one.

See the solution & reasoning

The service counts all owned tasks and the owned subset with IsComplete. Toggling one incomplete task should change only the second count. If another user’s actions affect your summary, the ownership filter is missing or an incorrectly shared cache has been introduced.

When something doesn't work

Calling response.json on an HTML error page produces a misleading JSON parse error. Inspect status and content type first.

CORS is not authentication and does not grant permission to access private records.

Returning EF entities directly can expose navigation cycles or fields added later. Keep the serialized contract explicit.

CHECK YOUR UNDERSTANDING

Why project API data instead of returning the entire entity?

Why project API data instead of returning the entire entity?

ONE STEP FURTHER

That's another skill in your toolkit.

Your progress is saved in this browser. Sign in to save completion to your account.