Playing with the .NET 8 Web API template
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:
Let's go for the basic ASP.NET Core Web API template but with the controllers:
dotnet new webapi --use-controllers -n WeatherApi
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)
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
.
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
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 controllerOpenWeatherService
that makes a call to the Open Weather Map API to retrieve the weather forecasts and then maps the obtained data to a list ofWeatherForecast
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);
}
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:
Feature | Area |
---|---|
Support for generic attributes | .NET 8 |
Primary constructors | C# 12 |
Collection expressions | C# 12 |
Optional parameters in lambda expressions | C# 12 |
Alias any type | C# 12 |
Debug customization attributes on ASP.NET Core types | ASP.NET Core 8 |
Options validation | .NET 8 |
Keyed DI Services | .NET 8 |
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.
Another year of sharing and learning - Dev Retro 2023
Last year, I wrote my first annual retrospective. It was an interesting exercise that I intend to do every year. So for 2023, here is my year in review.
Effortlessly Configure GitHub Repositories for Azure Deployment via OIDC
What if we could script the creation and configuration of a GitHub Repository so that it is ready to provision or deploy Azure resources from a GitHub Actions pipeline? We will do that in this article using the Azure CLI and GitHub CLI.