Learn C# - Web API & HTTP Client
Series/Learn C#/Episode 11
Episode 11 of 23

Learn C# - Web API & HTTP Client

This episode builds a Web API with ASP.NET Core: Web API project structure, controllers with routing and model binding, response formatting, using HttpClient and typed clients to consume APIs, and API versioning and standard error responses.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Modern backends are almost always APIs — interfaces serving requests from web applications, mobile apps, or other services. ASP.NET Core is .NET's primary web framework, known for high performance and complete tooling.

But a good API isn't just about returning JSON. It needs consistent routing, safe model binding, structured responses, and support for version changes.

Episode 11 builds a Web API from scratch: project structure, controllers and routing, model binding, HttpClient for consuming other APIs, and API versioning and standard error responses.

ASP.NET Core Web API Basics

Creating a Web API Project

The Web API template provides a complete structure with OpenAPI and minimal setup:

Creating a Web API project
dotnet new webapi -n Toko.Api -o src/Toko.Api
dotnet add src/Toko.Api package Microsoft.EntityFrameworkCore.Sqlite

The dotnet new webapi -n Toko.Api command creates a project with a Program.cs that already contains the minimal pipeline. For new projects, ASP.NET Core recommends the minimal APIs approach, while large projects often use controllers.

Controllers, Routing, and Model Binding

Creating Your First Controller

A controller is a class that handles HTTP requests and returns responses:

Controller with routing
[ApiController]
[Route("api/produk")]
public class ProdukController : ControllerBase
{
    private static readonly List<Produk> Produk = new()
    {
        new Produk { Id = 1, Nama = "Kopi Gayo", Harga = 85_000m }
    };
 
    [HttpGet("{id:int}")]
    public ActionResult<Produk> GetProduk(int id)
    {
        var hasil = Produk.FirstOrDefault(p => p.Id == id);
        if (hasil is null)
        {
            return NotFound();
        }
        return Ok(hasil);
    }
 
    [HttpPost]
    public ActionResult<Produk> BuatProduk([FromBody] Produk produk)
    {
        produk.Id = Produk.Count + 1;
        Produk.Add(produk);
        return CreatedAtAction(nameof(GetProduk), new { id = produk.Id }, produk);
    }
}

[Route("api/produk")] determines the controller's base path. Model binding fills the int id parameter from the route, and [FromBody] Produk maps the JSON in the request body to a C# object. The [ApiController] attribute enables automatic model validation — invalid requests are rejected before reaching the method.

Response Formatting

Built-in content negotiation makes an API return JSON by default. To force a format, use the Accept property in the request, or define the format via Produces:

Setting the response format
[HttpGet("{id:int}")]
[Produces("application/json")]
public ActionResult<Produk> GetProdukJson(int id) => Ok(Produk.First(p => p.Id == id));

Response helpers like Ok, NotFound, and CreatedAtAction produce the correct HTTP status codes along with a JSON body — this is the foundation of consistent RESTful design.

HTTP Client and Typed Clients

Basic HttpClient

Backend applications often call other APIs. HttpClient is .NET's primary HTTP client:

Calling another API
using var client = new HttpClient();
client.BaseAddress = new Uri("https://api.exchangerate.host");
 
var response = await client.GetAsync("/v1/latest?base=USD");
var json = await response.Content.ReadAsStringAsync();
Console.WriteLine(json);

An important warning: never create a new HttpClient for every request — sockets will leak and connections will pile up. The solution is typed clients registered in DI.

Typed Clients with IHttpClientFactory

A typed client abstracts all HTTP details into a single injectable class:

Typed client registered in DI
class ExchangeRateClient
{
    private readonly HttpClient _http;
    public ExchangeRateClient(HttpClient http) => _http = http;
 
    public async Task<string> GetRatesAsync()
    {
        return await _http.GetStringAsync("/v1/latest?base=USD");
    }
}
 
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<ExchangeRateClient>(c =>
    c.BaseAddress = new Uri("https://api.exchangerate.host"));

AddHttpClient<ExchangeRateClient> registers a client with an HttpClient that is managed and pooled by the factory — connection pooling, timeouts, and retries are handled automatically.

API Versioning and Standard Error Responses

Versioning with a Package

As an API evolves, old clients must not break. Versioning allows several endpoint versions to coexist:

Adding the versioning package
dotnet add src/Toko.Api package Asp.Versioning.Mvc
Versioned controller
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/produk")]
public class ProdukController : ControllerBase
{
    [HttpGet]
    public IActionResult Get() => Ok(new[] { "versi 1" });
}

With the pattern above, /api/v1/produk and later versions can coexist without conflicts. Standard error responses follow ASP.NET Core's built-in ProblemDetails format:

ProblemDetails error format
{
  "type": "https://tools.ietf.org/html/rfc9110",
  "title": "Not Found",
  "status": 404,
  "detail": "Produk dengan id tersebut tidak ditemukan."
}

This format is standardized and can be used directly by clients to display error messages.

Closing

Key takeaways:

  • [ApiController] enables automatic model validation.
  • Routing, model binding, and response helpers form a consistent REST design.
  • Always register HttpClient through the factory with typed clients.
  • API versioning keeps old clients working as the API changes.
  • Standard error responses use the ProblemDetails format.

In the next episode 12 we secure the API: security and identity — ASP.NET Core Identity, the authentication pipeline, JWT authentication, authorization policies, OAuth2 and OpenID Connect, and secure headers, CORS, and token management.

Learn C# - Web API & HTTP Client | Learn C#