Playing with the .NET 8 Web API template

Playing with the .NET 8 Web API template

Enhanced weather forecast API

In this article, we will explore the latest C# 12 and .NET 8 features by applying them to the basic dotnet Web API template.

Getting started with the ASP.NET Core Web API template

First, let's install the latest .NET 8 SDK:

winget install --id Microsoft.DotNet.SDK.8

We can list the available templates:

List of the available dotnet templates

Let's go for the basic ASP.NET Core Web API template but with the controllers:

dotnet new webapi --use-controllers -n WeatherApi
💡
Minimal APIs are great too but having controllers is more suited to what I want to show in this article.

Screenshot of the generated project in Rider

We can run the API and test the GET /weatherforecast endpoint using the generated request file:

@WeatherApi_HostAddress = http://localhost:5103

GET {{WeatherApi_HostAddress}}/weatherforecast/
Accept: application/json

This is included in the dotnet webapi template and is supported by Visual Studio, Rider, and vscode (using the REST Client extension)

💡
Read my article about choosing an API Client and why I prefer versioned HTTP files rather than GUI tools like Postman.

If we put a breakpoint in the controller we can see one small ASP.NET 8 improvement concerning the debugging experience: better debug summaries are displayed for types like HttpContext .

Debugging display of the HTTPContext class

Enhancing the Weather Forecast API

Currently, the template randomly generates weather forecasts in the controller. It would be nice to retrieve real weather data from a weather API.

To do that we can:

  • introduce an IWeatherService interface that contains a method to retrieve weather forecasts

  • extract the current logic that generates the random weather forecasts in a RandomWeatherService.cs that implements this interface

  • creates a new implementation OpenWeatherService of this interface that retrieves the weather data from the Open Weather Map API

A diagram of the ASP.NETCore Weather API

The WeatherForecastController becomes:

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private readonly IWeatherService _weatherService;

    public WeatherForecastController(IWeatherService weatherService)
    {
        _weatherService = weatherService;
    }

    [HttpGet(Name = "GetWeatherForecast")]
    [ProducesResponseType(typeof(WeatherForecast), StatusCodes.Status200OK)]
    public Task<WeatherForecast[]> Get()
    {
        return _weatherService.GetWeatherForecasts();
    }
}

We can get rid of the typeof because there are now generic attributes for some common ASP.NET Core attributes like ProducesResponseType.

    [HttpGet(Name = "GetWeatherForecast")]
    [ProducesResponseType<WeatherForecast>(StatusCodes.Status200OK)]
    public Task<WeatherForecast[]> Get()
    {
        return _weatherService.GetWeatherForecasts();
    }

There are now 2 implementations of the IWeatherService interface:

  • RandomWeatherService that contains the code that previously was in the controller

  • OpenWeatherService that makes a call to the Open Weather Map API to retrieve the weather forecasts and then maps the obtained data to a list of WeatherForecast

public class OpenWeatherService : IWeatherService
{
    private readonly IOpenWeatherMapApi _openWeatherMapApi;
    private static readonly (double Latitude, double Longitude) BordeauxCoordinates = (44.837789, -0.57918);

    public OpenWeatherService(IOpenWeatherMapApi openWeatherMapApi)
    {
        _openWeatherMapApi = openWeatherMapApi;
    }

    public async Task<WeatherForecast[]> GetWeatherForecasts()
    {
        var weatherApiResponse = await _openWeatherMapApi.GetWeatherForecast(BordeauxCoordinates.Latitude, BordeauxCoordinates.Longitude);

        var computeWeatherSummary = (double temperature) =>
            temperature switch
            {
                < 0 => "Freezing",
                >= 0 and < 5 => "Bracing",
                >= 5 and < 12 => "Chilly",
                >= 12 and < 18 => "Cool",
                >= 18 and < 24 => "Mild",
                >= 24 and < 30 => "Warm",
                >= 30 and < 35 => "Balmy",
                >= 35 and < 40 => "Hot",
                >= 40 and < 45 => "Sweltering",
                >= 45 => "Scorching",
                _ => "Warm"
            };
        return weatherApiResponse.List
            .Select(x =>
                new WeatherForecast
                {
                    Date = DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeSeconds(x.Dt).Date),
                    TemperatureC = Convert.ToInt32(x.Main.Temp),
                    Summary = computeWeatherSummary(x.Main.Temp)
                })
            .ToArray();
    }
}

The weather forecasts of a specific geolocation are retrieved. Indeed coordinates (corresponding to Bordeaux in France) are passed to the Open Weather Map API call. In C# 12, we can alias any type so we can introduce an alias "Coordinates" for the coordinates tuple:

using Coordinates = (double Latitude, double Longitude);

public class OpenWeatherService : IWeatherService
{
    private readonly IOpenWeatherMapApi _openWeatherMapApi;
    private static readonly Coordinates BordeauxCoordinates = (44.837789, -0.57918

Once this call is done, results are mapped to the expected model WeatherForecast. A lambda expression is used to get the "weather summary" from a temperature. If we want to have a default summary, that's something we can do thanks to the support of default lambda parameters in C#12.

var computeWeatherSummary = (double temperature, string defaultSummary = "Warm") =>
    temperature switch
    {
        < 0 => "Freezing",
        >= 0 and < 5 => "Bracing",
        >= 5 and < 12 => "Chilly",
        >= 12 and < 18 => "Cool",
        >= 18 and < 24 => "Mild",
        >= 24 and < 30 => "Warm",
        >= 30 and < 35 => "Balmy",
        >= 35 and < 40 => "Hot",
        >= 40 and < 45 => "Sweltering",
        >= 45 => "Scorching",
        _ => defaultSummary
    };

RandomWeatherService does not have this logic because Summaries are randomly selected from an array containing possible summaries.

private static readonly string[] Summaries = new [] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };

With collection expressions, this array can be defined directly with square brackets.

private static readonly string[] Summaries = [ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"];

It would work with other types of collections as well. If we needed to have another list containing only cold summaries and avoid duplication between the two lists, we could also define the two lists and use the spread operator.

 private static readonly IList<string> ColdAdjectives = ["Freezing", "Bracing", "Chilly", "Cool"];
 private static readonly string[] Summaries = [ ..ColdAdjectives, "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"];

The last C# 12 thing we could do in this example is to take advantage of the new class (and structs) primary constructors that were previously limited to records.

The WeatherForecast class could become the following:

namespace WeatherApi;

public class WeatherForecast(DateOnly date, int temperatureC, string? summary)
{
    public int TemperatureC { get; } = temperatureC;

    public DateOnly Date { get; } = date;

    public string? Summary { get; } = summary;

    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
💬
I'm not sure this is completely relevant here, a record would probably be better but you get the idea.

You can use primary constructors in any class, it will work as well with dependency injection. However, be aware that the services you used to assign to a private read-only field of your class won't be read-only anymore like weatherService in this example:

public class WeatherForecastController(IWeatherService weatherService, ILogger<WeatherForecastController> logger) : ControllerBase
{
    [HttpGet(Name = "GetWeatherForecast")]
    [ProducesResponseType(typeof(WeatherForecast), StatusCodes.Status200OK)]
    public Task<WeatherForecast[]> Get()
    {
        return weatherService.GetWeatherForecasts();
    }
}

Having 2 different implementations of the IWeatherService is great, but what if you need one of them in some part of your code? The one you will have injected in your class is the last one registered in the DI container, but that may not be the one you want. You could get all of them by injecting IEnumerable<IWeatherService> and selecting the one you need. You could also create a sort of factory to retrieve the correct instance. Yet in .NET 8, you don't need to worry about all that because you have the keyed DI Services.

Specifying a key (that can be anything, not necessarily a string) is done when registering the services in the DI container.

builder.Services.AddKeyedTransient<IWeatherService, RandomWeatherService>("random");
builder.Services.AddKeyedTransient<IWeatherService, OpenWeatherService>("api");

With this key, retrieving a specific implementation becomes easy.

    public WeatherForecastController([FromKeyedServices("random")] IWeatherService weatherService, ILogger<WeatherForecastController> logger)
    {
        _logger = logger;
        _weatherService = weatherService;
    }

I did not discuss the code that requests the Open Weather Map API. It's quite simple thanks to the uses of Refit.

using Refit;

namespace WeatherApi.Services.OpenWeatherMap;

public interface IOpenWeatherMapApi
{
    [Get("/forecast?lat={latitude}&lon={longitude}&units=metric")]
    Task<WeatherMapResponse> GetWeatherForecast(double latitude, double longitude);
}

public record WeatherMapResponse(IList<WeatherMapForecast> List);

public record WeatherMapForecast(int Dt, WeatherMapMain Main);

public record WeatherMapMain(double Temp);

I created an HTTP Message Handler to take care of adding the Open Weather Map API key to the requests. This API key and the URL to the API come from the configuration and are mapped to a configuration object WeatherMapConfiguration.

In .NET 8, we can use data validation attributes for data like configuration options. There is also a source code generator that can implement the validation logic:

namespace WeatherApi.Services.OpenWeatherMap;

public class WeatherMapConfiguration
{
    [Required]
    public required string ApiKey { get; init; }

    [Required]
    [Url]
    public required string Uri { get; init; }

}

[OptionsValidator]
public partial class WeatherMapConfigurationValidator : IValidateOptions<WeatherMapConfiguration>
{
}

This way we can make sure that the configuration contains the API Key and the URI that has the Url format. The configuration in the Program.cs looks like that:

builder.Services.Configure<WeatherMapConfiguration>(builder.Configuration.GetSection("WeatherMap"));
builder.Services.AddSingleton<IValidateOptions<WeatherMapConfiguration>, WeatherMapConfigurationValidator>();

builder.Services.AddTransient<ApiKeyHandler>();
builder.Services.AddRefitClient<IOpenWeatherMapApi>()
    .ConfigureHttpClient((provider, client) =>
    {
        var configuration = provider.GetRequiredService<IOptions<WeatherMapConfiguration>>().Value;
        client.BaseAddress = new Uri(configuration.Uri);
    })
    .AddHttpMessageHandler<ApiKeyHandler>();

A few closing words

Here is the recap of what we talked about:

There are many more interesting features in C# 12, .NET 8, or ASP.NET Core 8. Yet, the ones I introduced in this article are the ones I will probably use the most.

You can find the complete code sample here. The repository also contains a folder infra to set up the Azure infrastructure to host this API. 2 IaC solutions that use .NET are shown: one using Azure SDK and one using Pulumi.

This article was published as part of the C# Advent 2023 which is a nice initiative. Make sure to check the other blog articles on the advent calendar.