Skip to content

API Client

dotnet add package Polly
dotnet add package Microsoft.Extensions.Http.Polly
// ResponseStatusEnum.cs
public enum ResponseStatusEnum { Success, InvalidModelState, NotFound, Exception, Conflict }

// ResponseModel.cs
public class ResponseModel<T>
{
    public string Message { get; set; }
    public ResponseStatusEnum Status { get; set; }
    public T? Data { get; set; }
}

// Paging.cs
public sealed record PagedRequest(
    int Page = 1,
    int PageSize = 20,
    string? SortBy = null,     // e.g. "Name"
    bool Desc = false,
    string? Filter = null      // free-text or DSL, decide the server format
);

public sealed record PagedResult<T>(
    IReadOnlyList<T> Items,
    int Total,
    int Page,
    int PageSize
);

// Example DTOs (repeat per entity or generate)
public sealed class ItemDto { public int Id { get; set; } public string Name { get; set; } public bool IsActive { get; set; } }
public sealed class CustomerDto { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } }
Program.cs
using Polly;
using Polly.Extensions.Http;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();

var apiBase = builder.Configuration["Api:BaseUrl"] 
              ?? throw new InvalidOperationException("Missing Api:BaseUrl");

// shared JSON options
builder.Services.ConfigureHttpJsonOptions(o =>
{
    // Keep property names as-is (match API)
    o.SerializerOptions.PropertyNamingPolicy = null;
});

// Retry 3 times automatically when: The remote API is down for a short moment or get HTTP 500, 502, 503, 504 or get 429 (Too many requests → rate limiting)
// With wait times: 200ms, 400ms, 600ms
// resilience
static IAsyncPolicy<HttpResponseMessage> RetryPolicy() =>
    HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult(r => r.StatusCode == HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(3, retry => TimeSpan.FromMilliseconds(200 * (retry + 1)));


builder.Services.AddHttpClient("Api", client =>
{
    client.BaseAddress = new Uri(apiBase);        // e.g. https://localhost:5001/api/v1/
    client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
    // If you use JWT:
    // client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "<token>");
})
.AddPolicyHandler(RetryPolicy())
.SetHandlerLifetime(TimeSpan.FromMinutes(5));     // Prevents DNS and socket exhaustion. Default lifetime: 2 minutes. This ensures HttpClient reuses connections efficiently.

// register generic CRUD client
builder.Services.AddScoped<IApiClientFactory, ApiClientFactory>();

var app = builder.Build();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.MapRazorPages();
app.Run();
appsettings.json
{
  "Api": { "BaseUrl": "https://localhost:5001/api/v1/" }
}
CrudClient.cs
using System.Net;
using System.Net.Http.Json;
using MyApp.Contracts;

public interface ICrudApi<TDto, TId>
{
    Task<ResponseModel<PagedResult<TDto>>> ListAsync(PagedRequest req, CancellationToken ct = default);
    Task<ResponseModel<TDto>> GetAsync(TId id, CancellationToken ct = default);
    Task<ResponseModel<TDto>> CreateAsync(TDto dto, CancellationToken ct = default);
    Task<ResponseModel<TDto>> UpdateAsync(TId id, TDto dto, CancellationToken ct = default);
    Task<ResponseModel<object>> DeleteAsync(TId id, CancellationToken ct = default);
}

// Builds correctly-typed clients per entity on demand
public interface IApiClientFactory
{
    ICrudApi<TDto, TId> Create<TDto, TId>(string entityRoute);
}

public sealed class ApiClientFactory : IApiClientFactory
{
    private readonly IHttpClientFactory _httpFactory;
    public ApiClientFactory(IHttpClientFactory httpFactory) => _httpFactory = httpFactory;
    public ICrudApi<TDto, TId> Create<TDto, TId>(string entityRoute)
        => new CrudApi<TDto, TId>(_httpFactory.CreateClient("Api"), entityRoute.TrimEnd('/') + "/");
}

internal sealed class CrudApi<TDto, TId> : ICrudApi<TDto, TId>
{
    private readonly HttpClient _http;
    private readonly string _route; // e.g. "items/" (already under /api/v1/)
    public CrudApi(HttpClient http, string route) { _http = http; _route = route; }

    public async Task<ResponseModel<PagedResult<TDto>>> ListAsync(PagedRequest req, CancellationToken ct = default)
    {
        var url = $"{_route}?page={req.Page}&pageSize={req.PageSize}"
                + (string.IsNullOrWhiteSpace(req.SortBy) ? "" : $"&sortBy={Uri.EscapeDataString(req.SortBy)}&desc={req.Desc}")
                + (string.IsNullOrWhiteSpace(req.Filter) ? "" : $"&filter={Uri.EscapeDataString(req.Filter)}");

        return await ReadOrThrow<ResponseModel<PagedResult<TDto>>>(await _http.GetAsync(url, ct), ct);
    }

    public async Task<ResponseModel<TDto>> GetAsync(TId id, CancellationToken ct = default)
        => await ReadOrThrow<ResponseModel<TDto>>(await _http.GetAsync($"{_route}{id}", ct), ct);

    public async Task<ResponseModel<TDto>> CreateAsync(TDto dto, CancellationToken ct = default)
        => await ReadOrThrow<ResponseModel<TDto>>(await _http.PostAsJsonAsync(_route, dto, ct), ct);

    public async Task<ResponseModel<TDto>> UpdateAsync(TId id, TDto dto, CancellationToken ct = default)
        => await ReadOrThrow<ResponseModel<TDto>>(await _http.PutAsJsonAsync($"{_route}{id}", dto, ct), ct);

    public async Task<ResponseModel<object>> DeleteAsync(TId id, CancellationToken ct = default)
        => await ReadOrThrow<ResponseModel<object>>(await _http.DeleteAsync($"{_route}{id}", ct), ct);

    private static async Task<T> ReadOrThrow<T>(HttpResponseMessage res, CancellationToken ct)
    {
        // Prefer server’s envelope
        var contentType = res.Content.Headers.ContentType?.MediaType ?? "";
        try
        {
            var obj = await res.Content.ReadFromJsonAsync<T>(cancellationToken: ct);
            if (obj is not null) return obj;
        }
        catch { /* fall through to ProblemDetails */ }

        // If server returned ProblemDetails (RFC7807)
        if (contentType.Contains("application/problem+json"))
        {
            var problem = await res.Content.ReadFromJsonAsync<Microsoft.AspNetCore.Mvc.ProblemDetails>(cancellationToken: ct);
            throw new ApiProblemException(res.StatusCode, problem?.Title ?? "API error", problem?.Detail);
        }

        var raw = await res.Content.ReadAsStringAsync(ct);
        throw new ApiProblemException(res.StatusCode, $"HTTP {(int)res.StatusCode}", raw);
    }
}

public sealed class ApiProblemException : Exception
{
    public HttpStatusCode StatusCode { get; }
    public ApiProblemException(HttpStatusCode statusCode, string title, string? detail = null)
        : base($"{title}{(string.IsNullOrWhiteSpace(detail) ? "" : $": {detail}")}") => StatusCode = statusCode;
}

Use Create("items") or Create("customers") for each entity. No new code per entity required.

  • Using the generic client in Razor Pages Pages/Items/Index.cshtml.cs
Index.cshtml.cs
using Microsoft.AspNetCore.Mvc.RazorPages;
using MyApp.Contracts;

public class IndexModel : PageModel
{
    private readonly ICrudApi<ItemDto, int> _items;
    public IndexModel(IApiClientFactory factory) => _items = factory.Create<ItemDto, int>("items");

    public PagedResult<ItemDto> Result { get; private set; } = new(new List<ItemDto>(), 0, 1, 20);
    public string? Q { get; private set; }
    public string? SortBy { get; private set; }
    public bool Desc { get; private set; }

    public async Task OnGetAsync(int page = 1, int pageSize = 20, string? q = null, string? sortBy = null, bool desc = false)
    {
        Q = q; SortBy = sortBy; Desc = desc;
        var req = new PagedRequest(page, pageSize, sortBy, desc, q);
        var resp = await _items.ListAsync(req);
        Result = resp.Data ?? new PagedResult<ItemDto>(Array.Empty<ItemDto>(), 0, page, pageSize);
        // You can use resp.Status/Message for banners
    }
}
Index.cshtml
@page
@model IndexModel
@{
    ViewData["Title"] = "Items";
}
<h2>Items</h2>

<form method="get" class="mb-3">
  <input type="text" name="q" value="@Model.Q" placeholder="Search..." />
  <select name="sortBy">
    <option value="">(no sort)</option>
    <option value="Name" selected="@(Model.SortBy=="Name")">Name</option>
    <option value="Id" selected="@(Model.SortBy=="Id")">Id</option>
  </select>
  <label><input type="checkbox" name="desc" value="true" @(Model.Desc ? "checked" : "") /> Desc</label>
  <button type="submit">Apply</button>
</form>

<table class="table">
  <thead><tr><th>Id</th><th>Name</th><th>Active</th><th></th></tr></thead>
  <tbody>
  @foreach (var it in Model.Result.Items)
  {
    <tr>
      <td>@it.Id</td>
      <td>@it.Name</td>
      <td>@it.IsActive</td>
      <td>
        <a asp-page="./Edit" asp-route-id="@it.Id">Edit</a> |
        <a asp-page="./Delete" asp-route-id="@it.Id">Delete</a>
      </td>
    </tr>
  }
  </tbody>
</table>

<div>
  Page @Model.Result.Page of @Math.Ceiling((double)Model.Result.Total/Model.Result.PageSize)
  @if (Model.Result.Page > 1) { <a asp-page="./Index" asp-route-page="@(Model.Result.Page-1)" asp-route-pageSize="@Model.Result.PageSize" asp-route-q="@Model.Q" asp-route-sortBy="@Model.SortBy" asp-route-desc="@Model.Desc">Prev</a> }
  @if (Model.Result.Page * Model.Result.PageSize < Model.Result.Total) { <a asp-page="./Index" asp-route-page="@(Model.Result.Page+1)" asp-route-pageSize="@Model.Result.PageSize" asp-route-q="@Model.Q" asp-route-sortBy="@Model.SortBy" asp-route-desc="@Model.Desc">Next</a> }
</div>
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using MyApp.Contracts;

public class EditModel : PageModel
{
    private readonly ICrudApi<ItemDto, int> _items;
    public EditModel(IApiClientFactory factory) => _items = factory.Create<ItemDto, int>("items");

    [BindProperty] public ItemDto Form { get; set; } = new();
    [TempData] public string? Flash { get; set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        var resp = await _items.GetAsync(id);
        if (resp.Status != ResponseStatusEnum.Success || resp.Data is null)
        {
            Flash = resp.Message ?? "Not found";
            return RedirectToPage("./Index");
        }
        Form = resp.Data;
        return Page();
    }

    public async Task<IActionResult> OnPostAsync()
    {
        if (!ModelState.IsValid) return Page();
        var resp = await _items.UpdateAsync(Form.Id, Form);
        if (resp.Status == ResponseStatusEnum.Success)
        {
            Flash = resp.Message ?? "Updated";
            return RedirectToPage("./Index");
        }
        ModelState.AddModelError(string.Empty, resp.Message ?? "Update failed");
        return Page();
    }
}
  • Simple Razor Page
Components\Pages\Home.razor
@page "/"

@inject IApiClientFactory Factory

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>


@if (_items is not null && _items.Count > 0)
{
    <table>
        @foreach (var item in _items)
        {
            <tr>
                <td>@item.Name</td>
            </tr>
        }
    </table>
}
else
{
    <p>No products found</p>
}


@code {
    private ICrudApi<ProductDto, int>? _products;
    private List<ProductDto>? _items = new();

    private ProductDto item = new();

    protected override async Task OnInitializedAsync()
    {
        try
        {
            _products = Factory.Create<ProductDto, int>("products"); 
            var response = await _products.ListAsync();

            _items = response.Data ?? new List<ProductDto>();

            var product = await _products.ReadAsync(1);
            item = product.Data!;


        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
    }
}