Reputation: 2397
I am building a .NET 8.0 Razor Page site where localization/internationalization is made with URLs like:
example.org/us // United States
example.org/fr // France
example.org/pt // Portugal
example.org/se // Sweden
...
example.org/us/contact // contact page for USA (text is displayed in English)
example.org/fr/contact // contact page for France (text is displayed in French)
i.e. URLs contain ISO 3166-1 alpha-2 codes for countries (e.g. us, fr, pt, se etc). Text is coming from standard Resource files (resx).
Most of my implementation works but I have slight problem where non-existing pages return a StatusCode 200
instead of 404
. I have Googled, looked at other examples but they are either dated/no longer working, or only work for MVC. This question is for Razor Pages using @page "/..."
directive.
I need help and inputs on the implementation.
All my Razor Pages have {lang}
in their page route. The Contact page route will be /{lang}/contact
. {lang}
refers to the ISO 3166-1 alpha-2 code.
The following code prepends the {lang}
-parameter to all pages in the web application:
public class CultureTemplatePageRouteModelConvention: IPageRouteModelConvention
{
public void Apply(PageRouteModel model)
{
foreach (var selector in model.Selectors)
{
var template = selector.AttributeRouteModel.Template;
if (template.StartsWith("MicrosoftIdentity")) continue; // Skip MicrosoftIdentity pages
// Prepend {lang}/ to the page routes allow for route-based localization
selector.AttributeRouteModel.Order = -1;
selector.AttributeRouteModel.Template = AttributeRouteModel.CombineTemplates("{lang}", template);
}
}
}
...instantiated in program.cs
with:
builder.Services.AddRazorPages(options =>
{
// decorate all page routes with {lang} e.g. @page "/{lang}..."
options.Conventions.Add(new CultureTemplatePageRouteModelConvention());
});
To set the actual Culture for the page requested, I have implemented a custom RequestCultureProvider
with the following:
public class CustomRouteDataRequestCultureProvider : RequestCultureProvider
{
public SupportedAppLanguages SupportedAppLanguages;
public override Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext)
{
var lang = (string)httpContext.GetRouteValue("lang");
var urlCulture = httpContext.Request.Path.Value.Split('/')[1];
string[] container = [lang, urlCulture];
var culture = SupportedAppLanguages.Dict.Values.SingleOrDefault(langInApp => container.Contains(langInApp.Icc) );
if (culture != null)
{
return Task.FromResult(new ProviderCultureResult(culture.Culture));
}
// if no match, return 404
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
return Task.FromResult(new ProviderCultureResult(Options.DefaultRequestCulture.Culture.TwoLetterISOLanguageName));
}
}
...instantiated via program.cs
:
// get all languages supported by app via `appsettings.json`:
var supportedAppLanguages = builder.Configuration.GetSection("SupportedAppLanguages").Get<SupportedAppLanguages>();
var supportedCultures = supportedAppLanguages.Dict.Values.Select(langInApp => new CultureInfo(langInApp.Culture)).ToList();
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
options.DefaultRequestCulture = new RequestCulture(culture: "en-us", uiCulture: "en-us");
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
options.FallBackToParentCultures = true;
options.RequestCultureProviders.Clear();
options.RequestCultureProviders.Insert(0, new CustomRouteDataRequestCultureProvider() { Options = options, SupportedAppLanguages = supportedAppLanguages });
});
To prevent users from entering weird locale/country codes (and confusing search engines/preventing bad SEO), I created a MiddlewareFilter to deal with such cases:
public class RouteConstraintMiddleware(RequestDelegate next, SupportedAppLanguages supportedAppLanguages)
{
public async Task Invoke(HttpContext context)
{
// The context.Response.StatusCode for pages like example.org/us/non-existing is 200 and not the correct 404.
// How can I implement a proper check?
if (context.Response.StatusCode == StatusCodes.Status404NotFound) return;
if (string.IsNullOrEmpty(context?.GetRouteValue("lang")?.ToString())) return;
// check for a match
var lang = context.GetRouteValue("lang").ToString();
var supported = supportedAppLanguages.Dict.Values.Any(langInApp => lang == langInApp.Icc);
if (!supported)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
await next(context);
}
}
The current state of the app:
example.org/us // 200. (correct)
example.org/fr // 200. (correct)
example.org/nonsense/contact // 404. (correct)
example.org/nonsense // 404. (correct)
example.org/us/non-existing // 200. (wrong, must return 404)
How can I fix this? Is this a good implementation as well (seems kind of clunky to me)? Can it be solved in a better way? A simplified Github repro is available here: https://github.com/shapeh/TestLocalization
Hoping for some pointers.
Upvotes: 0
Views: 550
Reputation: 11546
The issue is caused by your RouteConstraintMiddleware
this line would shortcut your middleware pineline
if (string.IsNullOrEmpty(context?.GetRouteValue("lang")?.ToString())) return;
without await next(context);
it won't get into next middleware,the codes that return 404 response won't be executed
In my opinion,you may try add a RouteConstraint to your {lang}
section,the default Route middleware would handle it for you,so that you don't need to add another middleware
A minimal example:
public class CultureConstraint : IRouteConstraint
{
public bool Match(
HttpContext? httpContext, IRouter? route, string routeKey,
RouteValueDictionary values, RouteDirection routeDirection)
{
if (!values.TryGetValue(routeKey, out var routeValue))
{
return false;
}
var supportedAppLanguages = httpContext.RequestServices.GetService<IConfiguration>().GetSection("SupportedAppLanguages").Get<SupportedAppLanguages>();
var routeValueString = Convert.ToString(routeValue, CultureInfo.InvariantCulture);
return supportedAppLanguages.Dict.Values.Select(x=>x.Icc).Contains(routeValueString);
}
}
Register the constraint:
builder.Services.AddRouting(options =>
{
options.LowercaseUrls = true;
options.AppendTrailingSlash = false;
options.ConstraintMap.Add("cultureconstraint", typeof(CultureConstraint));
});
Remove the middleware:
app.UseMiddleware<RouteConstraintMiddleware>(supportedAppLanguages);
modify CultureTemplatePageRouteModelConvention
:
public class CultureTemplatePageRouteModelConvention: IPageRouteModelConvention
{
public void Apply(PageRouteModel model)
{
foreach (var selector in model.Selectors)
{
var template = selector.AttributeRouteModel.Template;
if (template.StartsWith("MicrosoftIdentity")) continue; // Skip MicrosoftIdentity pages
// Prepend {lang}/ to the page routes allow for route-based localization
selector.AttributeRouteModel.Order = -1;
selector.AttributeRouteModel.Template = AttributeRouteModel.CombineTemplates("{lang:cultureconstraint}", template);
}
}
}
The result:
Upvotes: 1