In the last article, "Are Your ASP.NET Core Routes and Query Strings Culture-Invariant?", we looked at a globalization issue that came up because ASP.NET Core model binding for route and query string data is culture-invariant. In that article, we solved the issue by making the route data culture-invariant, but we have another option: make the ASP.NET Core controller API culture sensitive.
IValueProviderFactory lets us use culture-sensitive route and query string data.
Note: This methodology applies to controller APIs only. I have not found an equivalent method that works with minimal APIs (but be sure to tell me if you know of one). For minimal APIs, use a culture-invariant URL.
The code for this article is available on GitHub: https://github.com/jeremybytes/aspnetcore-api-globalization.
Globalization Issue
Check out the previous article for a full description of the issue. For this article, we will go back to square one and show the basics of the globalization issue.
With the regional format settings on my machine set to United States English, the application runs fine.
(From Controller Service) Sunset Tomorrow: 12/8/2024 4:26:50 PM
(From Minimal API Service) Sunset Tomorrow: 12/8/2024 4:26:50 PM
But when I change the regional format to Icelandic, we get a runtime error.
If we debug to get the URL and run that manually, we see that the service returns an error:
Here is the URL and the results:
http://localhost:8973/SolarCalculator/45,6382/-122,7013/2024-12-08
{"results":null,"status":"ERROR"}
As a reminder, the 2 numeric route parameters represent latitude and longitude. Since we are using the Icelandic regional format, the comma (",") is used as a decimal separator. This is incorrectly parsed (and ignored) by the API. The result is that the latitude and longitude values are out of range.
If you want the full details, see the previous article.
IValueProviderFactory
A Microsoft Learn article (Globalization behavior of model binding route data and query strings) tells us what the problem is. But in addition to telling us that the model binding in this scenario is culture-invariant, it also tells how to make the model binding culture-sensitive -- by using IValueProviderFactory.
In short, we create custom value provider factories for route and query string data, then replace the default factories in the API startup.
Custom IValueProviderFactory
Here is the code for 2 custom value provider factories (in the GitHub repo CultureFactories.cs file):
public class CultureQueryStringValueProviderFactory : IValueProviderFactory
{
public Task CreateValueProviderAsync(ValueProviderFactoryContext context)
{
ArgumentNullException.ThrowIfNull(context);
var query = context.ActionContext.HttpContext.Request.Query;
if (query?.Count > 0)
{
context.ValueProviders.Add(
new QueryStringValueProvider(
BindingSource.Query,
query,
CultureInfo.CurrentCulture));
}
return Task.CompletedTask;
}
}
public class CultureRouteValueProviderFactory : IValueProviderFactory
{
public Task CreateValueProviderAsync(ValueProviderFactoryContext context)
{
ArgumentNullException.ThrowIfNull(context);
context.ValueProviders.Add(
new RouteValueProvider(
BindingSource.Path,
context.ActionContext.RouteData.Values,
CultureInfo.CurrentCulture));
return Task.CompletedTask;
}
}
These classes create value providers that include culture information (set to CultureInfo.CurrentCulture) for the query string value provider and route value provider, respectively.
Replacing the Default Value Provider Factories
The last step is to replace the default value provider factories with the custom factories. This happens in the Program.cs file of the controller API.
We replace the existing AddControllers:
builder.Services.AddControllers();
With one that takes an options delegate parameter:
builder.Services.AddControllers(options =>
{
var index = options.ValueProviderFactories.IndexOf(
options.ValueProviderFactories
.OfType<QueryStringValueProviderFactory>()
.Single());
options.ValueProviderFactories[index] =
new CultureQueryStringValueProviderFactory();
index = options.ValueProviderFactories.IndexOf(
options.ValueProviderFactories
.OfType<RouteValueProviderFactory>()
.Single());
options.ValueProviderFactories[index] =
new CultureRouteValueProviderFactory();
});
This code finds the index of the existing value provider factory in the options and replaces it with the custom value provider factory (once for the query string and once for the route).
Working API
With the value provider factories in place, we can re-run the API using the same URL and get working results:
Here is the URL and results:
http://localhost:8973/SolarCalculator/45,6382/-122,7013/2024-12-08
{"results":{"sunrise":"07:38:51","sunset":"16:26:50","solar_noon":"12:02:50",
"day_length":"08:47:58.5373487"},"status":"OK"}
The route parameters are still using the Icelandic regional format (45,6382 and -122,7013), but now we have valid results.
The application is using the current culture for the values when creating the route, and the API is using the current culture to read the route data.
And the application is back in a working state as well:
(From Controller Service) Sunset Tomorrow: 8.12.2024 16:26:50
(From Minimal API Service) Sunset Tomorrow: 8.12.2024 16:26:50
Two Approaches
Now that we have 2 approaches to solving the same globalization issue, which should we use? Here are my thoughts. I'd love to hear your thoughts as well.
In General: Culture-Invariant
I understand why route and query string data is culture-invariant by default. As stated in the documentation, this is to try to make URLs as "neutral" as possible to support the largest number of clients.
The implication of this is that now I feel like I need to specifically mark all of my API calls as culture-invariant since its possible that my application code could be running on a machine with different regional format settings.
I have not been doing this -- instead relying on the US-centric default. Going forward, I need to make sure that my API calls are appropriately culture-invariant.
For Specific Cases: Culture-Sensitive
For the specific scenario that brought this issue up, I'm leaning toward making the APIs culture-sensitive.
The scenario is that I have workshop lab files that run on a user's machine. Both the API calling code and the API itself are on the same machine. My inclination is to make sure that both ends use the current culture of the machine. This is what I assumed (very incorrectly) was happening and why the issue caught me off guard.
But I'm not completely set on this yet; it's just my initial inclination.
Wrap Up
For those of us in the US, we often forget about globalization issues. This is because we are the default culture -- even when we specify "neutral" culture (i.e., culture-invariant), it is still our culture.
Now that we are aware that route and query string data for APIs is culture-invariant, we need to take some action: either ensuring that our routes and query strings are culture-invariant or by adjusting the API itself.
Ultimately, it comes down to whether to put the onus on the client (API calling code) or the service (the API). Let me know your thoughts.
Happy Coding!
No comments:
Post a Comment