Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / Blazor

Tour of Heroes: Blazor WebAssembly Standalone App

5.00/5 (5 votes)
12 Jun 2024CPOL4 min read 6.7K  
Blazor WebAssembly Standalone App talking to ASP.NET Core Web API with generated client API codes in C#

Background

"Tour of Heroes" is the official tutorial app of Angular 2+. The app contains some functional features and technical features which are common when building a real-world business application talking to Web API:

  1. A few screens presenting tables and nested data
  2. Data binding
  3. Navigation
  4. CRUD operations over a backend, and optionally through a generated client API
  5. Unit testing and integration testing

In this series of articles, I will demonstrate the programmer experiences of various frontend development platforms when building the same functional features: "Tour of Heroes", a fat client talking to a backend.

The frontend apps on Angular, Aurelia, React, Vue, Xamarin and MAUI are talking to the same ASP.NET (Core) backend through generated client APIs. To find the other articles in the same series, please search "Tour of Heroes" in my articles. And at the end of the series, some technical factors of programmer experiences will be discussed:

  1. Computing science
  2. Software engineering
  3. Learning curve
  4. Build size
  5. Runtime performance
  6. Debugging

Choosing a development platform involves a lot of non-technical factors which won't be discussed in this series.

References

Introduction

It is assumed that you have already walked through the first few chapters of ASP.NET Core Blazor documentation, and have played the WeatherForecast app through the scaffolding codes as well as the todolist demo.

This article is focused on Blazor WebAssembly standalone app running in a Web browser, with functionality of "Tour of Heroes", talking to an ASP.NET Core Web API backend.

Demo Repository

Checkout DemoCoreWeb in GitHub, and focus on the following areas:

Core3WebApi

ASP.NET Core Web API csproj provides only Web APIs, including Heroes API.

Blazor Heroes

Blazor Web Assembly standalone app.

Image 1

Remarks

DemoCoreWeb was established for testing NuGet packages of WebApiClientGen, and demonstrating how to use the library in real world projects. And the generated client API codes in C# can be utilized in a Web browser via WebAssembly.

Using the code

Prerequisites

  1. Core3WebApi.csproj has NuGet package Fonlow.WebApiClientGenCore imported.
  2. Add CodeGenController.cs to Core3WebApi.csproj.
  3. Core3WebApi.csproj includes CodeGen.json. This is optional, just for the convenience of running some PowerShell script to generate client APIs.
  4. CreateWebApiClientApi3.ps1. This is optional. This script will launch the Web API on Kestrel and post the data in CodeGen.json.

Generate Client API

Run CreateWebApiClientApi3.ps1, the generated codes will be written to CoreWebApi.ClientApi.

Remarks

Data Models and API Functions

C#
namespace DemoWebApi.Controllers.Client;

public class Hero : object
{
    
    public DemoWebApi.DemoData.Client.Address Address { get; set; }
    
    public System.Nullable<System.DateOnly> Death { get; set; }
    
    public System.DateOnly DOB { get; set; }
    
    public string EmailAddress { get; set; }
    
    public long Id { get; set; }
    
    [System.ComponentModel.DataAnnotations.RequiredAttribute()]
    [System.ComponentModel.DataAnnotations.StringLength(120, MinimumLength=2)]
    public string Name { get; set; }
    
    public System.Collections.Generic.IList<DemoWebApi.DemoData.Client.PhoneNumber> PhoneNumbers { get; set; }
    
    [System.ComponentModel.DataAnnotations.MinLength(6)]
    [System.ComponentModel.DataAnnotations.RegularExpressionAttribute(@"https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)")]
    public string WebAddress { get; set; }
}

Remarks:

  • In the demo app, just only Id and Name are used, while the other properties were for testing some code gen features:
    • Copy some server side attributes to the client side. And for TypeScript codes, the attributes may be transformed to JsDoc for TypeScript.
    • Some validation attributes may be transformed to Angular Reactive Forms.

Heroes Client API:

C#
public partial class Heroes
{
    
    private System.Net.Http.HttpClient client;
    
    private JsonSerializerOptions jsonSerializerSettings;
    
    public Heroes(System.Net.Http.HttpClient client, JsonSerializerOptions jsonSerializerSettings=null)
    {
        if (client == null)
            throw new ArgumentNullException(nameof(client), "Null HttpClient.");

        if (client.BaseAddress == null)
            throw new ArgumentNullException(nameof(client), "HttpClient has no BaseAddress");

        this.client = client;
        this.jsonSerializerSettings = jsonSerializerSettings;
    }
    
    /// <summary>
    /// DELETE api/Heroes/{id}
    /// </summary>
    public async Task DeleteAsync(long id, Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
    {
        var requestUri = "api/Heroes/"+id;
        using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Delete, requestUri);
        handleHeaders?.Invoke(httpRequestMessage.Headers);
        var responseMessage = await client.SendAsync(httpRequestMessage);
        try
        {
            responseMessage.EnsureSuccessStatusCodeEx();
        }
        finally
        {
            responseMessage.Dispose();
        }
    }
    
    /// <summary>
    /// GET api/Heroes/{id}
    /// </summary>
    [return: System.Diagnostics.CodeAnalysis.MaybeNullAttribute()]
    public async Task<DemoWebApi.Controllers.Client.Hero> GetHeroAsync(long id, Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
    {
        var requestUri = "api/Heroes/"+id;
        using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri);
        handleHeaders?.Invoke(httpRequestMessage.Headers);
        var responseMessage = await client.SendAsync(httpRequestMessage);
        try
        {
            responseMessage.EnsureSuccessStatusCodeEx();
            if (responseMessage.StatusCode == System.Net.HttpStatusCode.NoContent) { return null; }
            var contentString = await responseMessage.Content.ReadAsStringAsync();
            return JsonSerializer.Deserialize<DemoWebApi.Controllers.Client.Hero>(contentString, jsonSerializerSettings);
        }
        finally
        {
            responseMessage.Dispose();
        }
    }
        
    /// <summary>
    /// GET api/Heroes
    /// </summary>
    public async Task<DemoWebApi.Controllers.Client.Hero[]> GetHeroesAsync(Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
    {
        var requestUri = "api/Heroes";
        using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri);
        handleHeaders?.Invoke(httpRequestMessage.Headers);
        var responseMessage = await client.SendAsync(httpRequestMessage);
        try
        {
            responseMessage.EnsureSuccessStatusCodeEx();
            if (responseMessage.StatusCode == System.Net.HttpStatusCode.NoContent) { return null; }
            var contentString = await responseMessage.Content.ReadAsStringAsync();
            return JsonSerializer.Deserialize<DemoWebApi.Controllers.Client.Hero[]>(contentString, jsonSerializerSettings);
        }
        finally
        {
            responseMessage.Dispose();
        }
    }
    
    /// <summary>
    /// GET api/Heroes
    /// </summary>
    public DemoWebApi.Controllers.Client.Hero[] GetHeroes(Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
    {
        var requestUri = "api/Heroes";
        using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri);
        handleHeaders?.Invoke(httpRequestMessage.Headers);
        var responseMessage = client.SendAsync(httpRequestMessage).Result;
        try
        {
            responseMessage.EnsureSuccessStatusCodeEx();
            if (responseMessage.StatusCode == System.Net.HttpStatusCode.NoContent) { return null; }
            var contentString = responseMessage.Content.ReadAsStringAsync().Result;
            return JsonSerializer.Deserialize<DemoWebApi.Controllers.Client.Hero[]>(contentString, jsonSerializerSettings);
        }
        finally
        {
            responseMessage.Dispose();
        }
    }
    
...
}

 

Razor Components

Image 2

Dashboard.razor:

ASP
@page "/dashboard"

@inject DemoWebApi.Controllers.Client.Heroes heroesApi

<PageTitle>Dashboard</PageTitle>
<h2>Top Heroes</h2>

@if (heroes == null)
{
    <p><em>Loading Heroes...</em></p>
}
else
{
    <div class="heroes-menu">
        @foreach (var hero in heroes.Take(4))
        {
            string href = $"/detail/{hero.Id}";
            <a href="@href">@hero.Name</a>
        }

    </div>
}

@code {
    private DemoWebApi.Controllers.Client.Hero[]? heroes;
    DemoWebApi.Controllers.Client.Heroes? heroesClient;

    protected override async Task OnInitializedAsync()
    {
        heroes = await heroesApi.GetAsyncHeroesAsync();
    }
}

 

Image 3

Heroes List:

ASP.NET
@page "/heroes"
@inject DemoWebApi.Controllers.Client.Heroes heroesApi

<PageTitle>List</PageTitle>
<h2>My Heroes</h2>

@if (heroes == null)
{
    <p><em>Loading Heroes...</em></p>
}
else
{
    <div>
        <label for="new-hero">Hero name: </label>
        <input id="new-hero" @bind="@newHeroName" />

        <button type="button" class="add-button" @onclick="AddAndClear">
            Add hero
        </button>
    </div>

    <ul class="heroes">
        @foreach (var hero in heroes)
        {
            string href = $"/detail/{hero.Id}";
            <li>
                <a href="@href">
                    <span class="badge">@hero.Id</span> @hero.Name
                </a>
                <button type="button" class="delete" title="delete hero" @onclick="()=>Delete(hero)">x</button>
            </li>
        }

    </ul>
}

@code {
    private List<DemoWebApi.Controllers.Client.Hero> heroes;
    private DemoWebApi.Controllers.Client.Hero? selectedHero;
    string? newHeroName;

    protected override async Task OnInitializedAsync()
    {
        heroes = new List<DemoWebApi.Controllers.Client.Hero>(await heroesApi.GetHeroesAsync());
    }

    async Task Add(string name)
    {
        name = name.Trim();
        if (string.IsNullOrEmpty(name))
        {
            return;
        }

        var newHero = await heroesApi.PostAsync(name);
        this.selectedHero = null;
        heroes.Add(newHero);

    }

    async Task AddAndClear()
    {
        await Add(newHeroName);
        newHeroName = null;

    }

    async Task Delete(DemoWebApi.Controllers.Client.Hero hero)
    {
        await heroesApi.DeleteAsync(hero.Id);
        heroes.Remove(hero);
        if (selectedHero == hero)
        {
            selectedHero = null;
        }
    }
}

Image 4

Dependency Injection of HttpClient

While HttpClient has implemented IDisposable, however often Microsoft would advise not to instantiate it for a HTTP request then dispose, depending on the application host. Instead, use dependency injection of Blazor so the host will manage the lifecycle of a HttpClient instance.

The sample codes with this article follow the advices from "Call a Web API from ASP.NET Core Blazor", utilizing both named HttpClient and typed HttpClient.

Named HttpClient

Program.cs:

builder.Services.AddHttpClient("Core3WebAPI", client =>
{
    client.BaseAddress = new Uri("http://localhost:5000");
});

Weather.razor:

ASP.NET
@page "/weather"
@inject IHttpClientFactory ClientFactory
@inject System.Text.Json.JsonSerializerOptions jsonSerializerOptions

...

@code {
    private WebApplication1.Client.WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        var client = ClientFactory.CreateClient("Core3WebAPI");
        WebApplication1.Controllers.Client.WeatherForecast weatherForecastClient = new WebApplication1.Controllers.Client.WeatherForecast(client, jsonSerializerOptions);
        forecasts = (await weatherForecastClient.GetAsync()).ToArray();
    }
}

 

Typed HttpClient

Program.cs:

C#
builder.Services.AddHttpClient<DemoWebApi.Controllers.Client.Heroes>(heroesApiClient =>
{
    heroesApiClient.BaseAddress = new Uri("http://localhost:5000");
});

Heroes.razor:

ASP.NET
@page "/heroes"
@inject DemoWebApi.Controllers.Client.Heroes heroesApi
...
@code {
    private List<DemoWebApi.Controllers.Client.Hero> heroes;
    private DemoWebApi.Controllers.Client.Hero? selectedHero;
    string? newHeroName;

    protected override async Task OnInitializedAsync()
    {
        heroes = new List<DemoWebApi.Controllers.Client.Hero>(await heroesApi.GetHeroesAsync());
    }

    async Task Add(string name)
    {
        name = name.Trim();
        if (string.IsNullOrEmpty(name))
        {
            return;
        }

        var newHero = await heroesApi.PostAsync(name);
        this.selectedHero = null;
        heroes.Add(newHero);
    }

    async Task AddAndClear()
    {
        await Add(newHeroName);
        newHeroName = null;

    }

    async Task Delete(DemoWebApi.Controllers.Client.Hero hero)
    {
        await heroesApi.DeleteAsync(hero.Id);
        heroes.Remove(hero);
        if (selectedHero == hero)
        {
            selectedHero = null;
        }
    }
}

The generated client API codes support typed HttpClient through DI:

C#
public partial class Heroes
{
    
    private System.Net.Http.HttpClient client;
    
    private JsonSerializerOptions jsonSerializerSettings;
    
    public Heroes(System.Net.Http.HttpClient client, JsonSerializerOptions jsonSerializerSettings=null)
    {
...

.NET runtime can inject a HttpClient instance and a scoped JsonSerializerOptions instance to the container class "Heroes".

Points of Interest

HttpClient in Web Browsers

According to "Call a Web API from ASP.NET Core Blazor":

Quote:

HttpClient is implemented using the browser's Fetch API and is subject to its limitations, including enforcement of the same-origin policy, which is discussed later in this article in the Cross-Origin Resource Sharing (CORS) section.

In addition to the limitations mentioned, the actual operations of "System.Net.Http.HttpClient" in a Web browser are subject to other limitations of JavaScript when handling integral numbers larger than 53-bit, as discussed in these articles:

To support Int64 (long), the Core3WebApi utilizes Int64JsonConverter

C#
builder.Services.AddJsonOptions(options =>
{
    options.JsonSerializerOptions.Converters.Add(new Int64JsonConverter());

The Web API will then respond with a JSON string object for Int64 rather than rather than a JSON number object.

On the client side built on Blazor WebAssembly, the same converter class is needed:

C#
 builder.Services.AddScoped<JsonSerializerOptions>(sp =>
{
    var options = new JsonSerializerOptions
    {
        PropertyNameCaseInsensitive = true,
    };

    options.Converters.Add(new Int64JsonConverter()); //Hero.Id is long, 64-bit, exceeding 53-bit precision of JS number
    return options;
});

Otherwise, there will be runtime error in the browser:

Image 5

After the scoped JsonSerializatioOptions is injected to both named HttpClient and typed HttpClient (Heroes client API) by the WebAssembly runtime, the standalone app can handle Int64 gracefully since you have enjoyed strongly typed coding with Int64.

 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)