Search code examples
c#asp.net-coreasync-awaitblazorblazor-client-side

Passing two Action<T> parameters and executing one of them based on the JSON result of an async API call


In my Blazor WASM application, I have written a (client-side) service class with a method to make an API call to the web API. The server will return either the expected result of IEnumerable<WeatherForecast> or a Microsoft.AspNetCore.Mvc.ProblemDetails object explaining what went wrong.

When calling the method, the UI (FetchData.razor) passes an Action<IEnumerable<WeatherForecast>> and an Action<ProblemDetails>. Only one of these actions should ever be executed, depending on what is returned by the server. This allows the service class to choose what to do based on the deserialized JSON result of the API call.

Usage (in FetchData.razor):

@page "/fetchdata"
@using BlazorApp1.Shared
@inject HttpClient Http
@inject WeatherForecastsService Service

<h1>Weather forecast</h1>

<p>This component demonstrates fetching data from the server.</p>

@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr>
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td>@forecast.TemperatureC</td>
                    <td>@forecast.TemperatureF</td>
                    <td>@forecast.Summary</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private IEnumerable<WeatherForecast> forecasts;

    protected override async Task OnInitializedAsync()
    {
        await Service.GetAllAsync(
            success => forecasts = success,
            problem => Console.WriteLine("Handle this problem: " + problem.Detail));
    }

}

My attempt at implementation, below, does not work. I am sure that the API call is reaching the correct API endpoint and getting JSON back, but my razor page is not getting populated with the WeatherForecasts and it is not writing the problem detail to the console either. Debugging in Blazor WASM (though much improved) is still quite difficult.

I have been fiddling with this code for days but have failed. Can anybody help me see what I am doing wrong please?

    public class WeatherForecastsService : ServiceBase
    {
        public WeatherForecastsService(
            HttpClient client) : base(client)
        {

        }

        public async Task GetAllAsync(
            Action<IEnumerable<WeatherForecast>> actionOnSuccess,
            Action<ProblemDetails> actionOnFailure,
            CancellationToken cancellationToken = default)
        {
            await GetManyAsync("weatherforecast",
                actionOnSuccess,
                actionOnFailure,
                cancellationToken);
        }
    }

   public abstract class ServiceBase
    {
        public ServiceBase(HttpClient client)
        {
            Client = client;
        }

        protected HttpClient Client
        {
            get;
        }


        protected virtual async Task GetManyAsync<TExpected>(
            string path,
            Action<IEnumerable<TExpected>> actionOnSuccess,
            Action<ProblemDetails> actionOnProblem,
            CancellationToken cancellationToken = default)
            where TExpected : class
        {
            string json = await GetJsonAsync(path, cancellationToken);
            ProblemDetails? problem = Deserialize<ProblemDetails>(json);

            if (problem is { })
            {
                var taskOnProblem = TaskFromAction(actionOnProblem, problem);
                await taskOnProblem;
            }
            else
            {
                IEnumerable<TExpected>? expected = Deserialize<IEnumerable<TExpected>>(json);
                expected = EnsureNotNull(expected);

                var taskOnSuccess = TaskFromAction(actionOnSuccess, expected);
                await taskOnSuccess;
            }
        }

        private Task TaskFromAction<T>(Action<T> action, T state)
        {
            return new Task(ActionOfObjectFromActionOfT(action), state);
        }

        private Action<object> ActionOfObjectFromActionOfT<T>(Action<T> actionOfT)
        {
            return new Action<object>(o => actionOfT((T)o));
        }

        private IEnumerable<T> EnsureNotNull<T>(IEnumerable<T>? enumerable)
        {
            if (enumerable is null)
            {
                enumerable = new List<T>();
            }

            return enumerable;
        }

        private async Task<string> GetJsonAsync(string path, CancellationToken cancellationToken = default)
        {
            var response = await Client.GetAsync(path, cancellationToken);
            return await response.Content.ReadAsStringAsync();
        }


        private T? Deserialize<T>(string json)
            where T : class
        {
            try
            {
                return JsonSerializer.Deserialize<T>(json, null);
            }
            catch (JsonException)
            {
                return default;
            }
        }
    }


A minimal reproducible example of my failed attempt at this problem can be found here: https://github.com/BenjaminCharlton/AsyncBlazorRepro

Thank you!


Solution

  • Fixed it!

    This problem had nothing to do with async-await problems. It was all to do with deserialization problems.

    Looking at the ASP .NET Core source code here:

    https://github.com/dotnet/aspnetcore/blob/master/src/Components/Blazor/Http/src/HttpClientJsonExtensions.cs

    You'll notice that the methods in Microsoft.AspNetCore.Components.HttpClientJsonExtensions all pass a JsonSerializerOptions to the Deserialize method, but in my code I was just passing null because I didn't think it was important. The JsonSerializer was ignoring every single property because of case-sensitivity!

    I changed my Deserialize method as below:

           private T? Deserialize<T>(string json)
                where T : class
            {
                var jsonOptions = new JsonSerializerOptions()
                {
                    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
                    PropertyNameCaseInsensitive = true
                };
    
                try
                {
                    return JsonSerializer.Deserialize<T>(json, jsonOptions);
                }
                catch (JsonException)
                {
                    return default;
                }
            }
    

    As Henk pointed out in the comments, I had also written in some unnecessary complexity. I didn't need to turn the Actions into Tasks using my pointless TaskFromAction method. You can just leave them as Actions. You can also create an overload that takes Func<TExpected, Task> if you want to give callers an asynchronous option too.

    I have updated the repro project on GitHub with working code in case anybody else wishes to encapsulate their Blazor API calls this way.

    https://github.com/BenjaminCharlton/AsyncBlazorRepro