Using URL path for localization in Razor + Blazor components - asp.net-core

I want to build an ASP.NET Razor app with razor pages and some Blazor components, with site content being localized based on the language in the URL.
For example, /en/home and /fr/home would have one backing page that renders content based on the language.
What's a method to accomplish this?

AspNetCore.Mvc.Localization has what we need.
Inside _ViewImports.cshtml, we can inject an IViewLocalizer which will grab .resx files for the corresponding pages.
#using Microsoft.AspNetCore.Mvc.Localization
#inject IViewLocalizer Localizer
Now the Localizer is available inside all our pages.
For example, Index.cshtml
#page
#model IndexModel
#{
ViewData["Title"] = #Localizer["Title"];
}
<h1>#Localizer["Header"]</h1>
<section>
<p>#Localizer["Welcome", User.Identity.Name]</p>
#Localizer["Learn"]
<a asp-page="Page1">#Localizer["SomePage"]</a>
<a asp-page="Dogs/Index">#Localizer["LinkDogs"]</a>
</section>
Now the page title, header, and content is localized once the resx files are created.
Resources/Pages/Index.resx and Resources/Pages/Index.fr.resx needs to be created. There is a VSCode extension available for this since these files are just ugly XML.
Strings can be parameterized. In the Index.cshtml example, "Welcome"="Howdy {0}" gets referenced by #Localizer["Welcome", User.Identity.Name] and the username will be substituted in for {0}.
Inside Startup.cs, we also need to add some setup.
services.AddLocalization(options =>
{
options.ResourcesPath = "Resources";
}); // new
services.AddRazorPages()
.AddRazorRuntimeCompilation()
.AddViewLocalization(); // new
services.AddServerSideBlazor();
But this only gives access to the Localizer inside our .cshtml files. Our pages still look like /home instead of /en/home.
To fix this, we will add an IPageRouteModelConvention to modify our page templates, prepending {culture} to all our pages.
Inside Startup.cs, we need to add the convention during razor config.
services.AddRazorPages(options =>
{
options.Conventions.Add(new CultureTemplatePageRouteModelConvention());
})
I created the CultureTemplatePageRouteModelConvention.cs under a Middleware/ folder, but you can put it wherever (not sure if it's "technically" middleware?).
using System;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.Extensions.Logging;
namespace app.Middleware
{
public class CultureTemplatePageRouteModelConvention : IPageRouteModelConvention
{
public void Apply(PageRouteModel model)
{
// For each page Razor has detected
foreach (var selector in model.Selectors)
{
// Grab the template string
var template = selector.AttributeRouteModel.Template;
// Skip the MicrosoftIdentity pages
if (template.StartsWith("MicrosoftIdentity")) continue;
// Prepend the /{culture?}/ route value to allow for route-based localization
selector.AttributeRouteModel.Template = AttributeRouteModel.CombineTemplates("{culture?}", template);
}
}
}
}
Now going to /en/home should resolve, and /home should not.
But if you go to /fr/home you will notice that it's still using the English resx file. This is because the culture is not being updated based on the URL.
To fix this, more modifications to Startup.cs are necessary.
In the Configure method, we will add
app.UseRequestLocalization();
Under ConfigureServices, we will configure the request localization options.
This will include adding a RequestCultureProvider which is used to determine the Culture for each request.
services.Configure<RequestLocalizationOptions>(options =>
{
options.SetDefaultCulture("en");
options.AddSupportedCultures("en", "fr");
options.AddSupportedUICultures("en", "fr");
options.FallBackToParentCultures = true;
options.RequestCultureProviders.Remove(typeof(AcceptLanguageHeaderRequestCultureProvider));
options.RequestCultureProviders.Insert(0, new Middleware.RouteDataRequestCultureProvider() { Options = options });
});
This uses an extension method to remove the default accept-language header culture provider
using System;
using System.Collections.Generic;
using System.Linq;
namespace app.Extensions
{
public static class ListExtensions {
public static void Remove<T>(this IList<T> list, Type type)
{
var items = list.Where(x => x.GetType() == type).ToList();
items.ForEach(x => list.Remove(x));
}
}
}
More importantly, we need to create the RouteDataRequestCultureProvider we just added to the list.
Middleware/RouteDataRequestCultureProvider.cs
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Localization;
namespace app.Middleware
{
public class RouteDataRequestCultureProvider : RequestCultureProvider
{
public override Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext)
{
string routeCulture = (string)httpContext.Request.RouteValues["culture"];
string urlCulture = httpContext.Request.Path.Value.Split('/')[1];
// Culture provided in route values
if (IsSupportedCulture(routeCulture))
{
return Task.FromResult(new ProviderCultureResult(routeCulture));
}
// Culture provided in URL
else if (IsSupportedCulture(urlCulture))
{
return Task.FromResult(new ProviderCultureResult(urlCulture));
}
else
// Use default culture
{
return Task.FromResult(new ProviderCultureResult(DefaultCulture));
}
}
/**
* Culture must be in the list of supported cultures
*/
private bool IsSupportedCulture(string lang) =>
!string.IsNullOrEmpty(lang)
&& Options.SupportedCultures.Any(x =>
x.TwoLetterISOLanguageName.Equals(
lang,
StringComparison.InvariantCultureIgnoreCase
)
);
private string DefaultCulture => Options.DefaultRequestCulture.Culture.TwoLetterISOLanguageName;
}
}
Note we check for RouteValues["culture"] in this provider, when that value isn't actually present yet. This is because we need another piece of middleware for Blazor to work properly. But for now, at least our pages will have the correct culture applied from the URL, which will allow /fr/ to use the correct Index.fr.resx instead of Index.resx.
Another issue is that the asp-page tag helper doesn't work unless you also specify asp-route-culture with the user's current culture. This sucks, so we will override the tag helper with one that just copies the culture every time.
Inside _ViewImports.cshtml
#* Override anchor tag helpers with our own to ensure URL culture is persisted *#
#addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
#removeTagHelper Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper, Microsoft.AspNetCore.Mvc.TagHelpers
#addTagHelper *, app
and under TagHelpders/CultureAnchorTagHelper.cs we will add
using System;
using app.Extensions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.TagHelpers;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
// https://stackoverflow.com/a/59283426/11141271
// https://stackoverflow.com/questions/60397920/razorpages-anchortaghelper-does-not-remove-index-from-href
// https://talagozis.com/en/asp-net-core/razor-pages-localisation-seo-friendly-urls
namespace app.TagHelpers
{
[HtmlTargetElement("a", Attributes = ActionAttributeName)]
[HtmlTargetElement("a", Attributes = ControllerAttributeName)]
[HtmlTargetElement("a", Attributes = AreaAttributeName)]
[HtmlTargetElement("a", Attributes = PageAttributeName)]
[HtmlTargetElement("a", Attributes = PageHandlerAttributeName)]
[HtmlTargetElement("a", Attributes = FragmentAttributeName)]
[HtmlTargetElement("a", Attributes = HostAttributeName)]
[HtmlTargetElement("a", Attributes = ProtocolAttributeName)]
[HtmlTargetElement("a", Attributes = RouteAttributeName)]
[HtmlTargetElement("a", Attributes = RouteValuesDictionaryName)]
[HtmlTargetElement("a", Attributes = RouteValuesPrefix + "*")]
public class CultureAnchorTagHelper : AnchorTagHelper
{
private const string ActionAttributeName = "asp-action";
private const string ControllerAttributeName = "asp-controller";
private const string AreaAttributeName = "asp-area";
private const string PageAttributeName = "asp-page";
private const string PageHandlerAttributeName = "asp-page-handler";
private const string FragmentAttributeName = "asp-fragment";
private const string HostAttributeName = "asp-host";
private const string ProtocolAttributeName = "asp-protocol";
private const string RouteAttributeName = "asp-route";
private const string RouteValuesDictionaryName = "asp-all-route-data";
private const string RouteValuesPrefix = "asp-route-";
private readonly IHttpContextAccessor _contextAccessor;
public CultureAnchorTagHelper(IHttpContextAccessor contextAccessor, IHtmlGenerator generator) :
base(generator)
{
this._contextAccessor = contextAccessor;
}
public override void Process(TagHelperContext context, TagHelperOutput output)
{
var culture = _contextAccessor.HttpContext.Request.GetCulture();
RouteValues["culture"] = culture;
base.Process(context, output);
}
}
}
This uses an extension method to get the current culture from an HttpRequest
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Localization;
namespace app.Extensions
{
public static class HttpRequestExtensions
{
public static string GetCulture(this HttpRequest request)
{
return request.HttpContext.Features.Get<IRequestCultureFeature>()
.RequestCulture.Culture.TwoLetterISOLanguageName;
}
}
}
To make sure the dependency injection for the current context works, we need to modify Startup.cs
// Used by the culture anchor tag helper
services.AddHttpContextAccessor();
Now we can use the tag helper without things breaking.
Example:
<a asp-page="Page1">#Localizer["SomePage"]</a>
With normal pages working, now we can work on getting Blazor components translated.
Inside _Imports.razor, we will add
#using Microsoft.Extensions.Localization
Inside our myComponent.razor, we will add
#inject IStringLocalizer<myComponent> Localizer
Now we can use <h1>#Localizer["Header"]</h1> just like in our normal pages. But now there's another issue: our Blazor components aren't getting their Culture set correctly. The components see /_blazor as their URL instead of the page's URL. Comment out the <base href="~/"> in your <head> element in _Layout.cshtml to make Blazor try hitting /en/_blazor instead of /_blazor. This will get a 404, but we will fix that.
Inside Startup.cs, we will register another middleware.
app.Use(new BlazorCultureExtractor().Handle);
This call should be before the app.UseEndpoints and app.UseRequestLocalization() call.
Middleware/BlazorCultureExtractor.cs
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using app.Extensions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Localization;
namespace app.Middleware
{
public class BlazorCultureExtractor
{
private readonly Regex BlazorRequestPattern = new Regex("^/(.*?)(/_blazor.*)$");
public async Task Handle(HttpContext context, Func<Task> next)
{
var match = BlazorRequestPattern.Match(context.Request.Path.Value);
// If it's a request for a blazor endpoint
if (match.Success)
{
// Grab the culture from the URL and store it in RouteValues
// This allows IStringLocalizers to use the correct culture in Blazor components
context.Request.RouteValues["culture"] = match.Groups[1].Value;
// Remove the /culture/ from the URL so that Blazor works properly
context.Request.Path = match.Groups[2].Value;
}
await next();
}
}
}
The middleware will check if the route is trying to hit /en/_blazor, will set the RouteValues["culture"] value to en, and will rewrite the path to /_blazor before further processing. This puts the lang in the route values for our RequestCultureProvider to use, while also fixing the 404 from blazor trying to hit our localized routes.
Inside _Layout.cshtml I also use
<script src="~/_framework/blazor.server.js"></script>"
to ensure that the request for the blazor script hits the proper path instead of /en/_framework/.... Note the preceeding ~/ on the src attribute.
Closing remarks
If you want pure URL-based localization instead of the weird cookie stuff MS promotes, then it's a lot of work.
I haven't bothered looking into doing this with Blazor pages, I'm just sticking with components for now.
e.g.,
<component>
#(await Html.RenderComponentAsync<MyCounterComponent>(RenderMode.Server))
</component>

Related

How return a yaml file as result of an asp.net core ViewComponent

I want to create an asp.net core ViewComponent that dynamically return a yaml file based on some criteria:
For example
namespace MyNameSpace {
[ViewComponent(Name = nameof(MyViewComponent))]
public class MyViewComponent : ViewComponent
{
public async Task<IViewComponentResult> InvokeAsync(object input)
{
string yamlDocument = GetYamlDocumentByInput(input);
//how to proceed here so that my yamlDocument is returned with the right content type?
return View(..., yamlDocument);
}
}}
you could search the view component class,and there‘s no method can return a file as result.
you'd better add an action in your controller to download file,and you could send a request to this action after your view has been rendered mannully or automaticlly.
and there's the codes in the action:
public FileResult DownLoad(Person person)
{
var serializer = new SerializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
var yaml = serializer.Serialize(person);
byte[] yamlArray = System.Text.Encoding.UTF8.GetBytes(yaml);
return File(yamlArray, "application/x-yml");
}
Result:

How to implement api versioning and swagger document dynamically

I am working in dotnet core api. I have to implement versioning on api. and swagger document should be categorized by api version.
In .NetCore api versioning can be implement by adding below reference from nuget
Microsoft.AspNetCore.Mvc.Versioning
Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer
After adding reference do following in startup file of your project. Add below line before AddMvc line. I will use Header-api versioning. It means client will mention the version in header. Header name is customizable.
services.AddApiVersioning(this.Configuration);
Definition of AddApiVersioning would be like as (In different extension class):
public static void AddApiVersioning(this IServiceCollection services, IConfiguration configuration)
{
services.AddApiVersioning(apiVersioningOptions =>
{
apiVersioningOptions.ApiVersionReader = new HeaderApiVersionReader(new string[] { "api-version" }); // It means version will be define in header.and header name would be "api-version".
apiVersioningOptions.AssumeDefaultVersionWhenUnspecified = true;
var apiVersion = new Version(Convert.ToString(configuration["DefaultApiVersion"]));
apiVersioningOptions.DefaultApiVersion = new ApiVersion(apiVersion.Major, apiVersion.Minor);
apiVersioningOptions.ReportApiVersions = true;
apiVersioningOptions.UseApiBehavior = true; // It means include only api controller not mvc controller.
apiVersioningOptions.Conventions.Controller<AppController>().HasApiVersion(apiVersioningOptions.DefaultApiVersion);
apiVersioningOptions.Conventions.Controller<UserController>().HasApiVersion(apiVersioningOptions.DefaultApiVersion);
apiVersioningOptions.ApiVersionSelector = new CurrentImplementationApiVersionSelector(apiVersioningOptions);
});
services.AddVersionedApiExplorer(); // It will be used to explorer api versioning and add custom text box in swagger to take version number.
}
Here configuration["DefaultApiVersion"] is a key in appsetting having value 1.0
As in above code we have used Convention to define api version for each controller. It is useful when there is one api version and you don't want to label each controller with [ApiVersion] attribute.
If you don't want to use the Convention menthod to define version of controller. use attribute label to define version. like as below:
[Route("[controller]")]
[ApiController]
[ApiVersion("1.0")]
public class TenantController : ConfigController
Once this done go to StartUp file and add below code.
app.UseApiVersioning(); //Here app is IApplicationBuilder
That is complete solution for api versioning.
For swagger We have to add nuget package as defined below:
Swashbuckle.AspNetCore
Swashbuckle.AspNetCore.SwaggerGen
Swashbuckle.AspNetCore.SwaggerUI
After adding reference do below: Add below line after Services.UseApiVersioning()
services.AddSwaggerGenerationUI();
The definition of AddSwaggerGenerationUI is below in extens :
public static void AddSwaggerGenerationUI(this IServiceCollection services)
{
var provider = services.BuildServiceProvider()
.GetRequiredService<IApiVersionDescriptionProvider>();
services.AddSwaggerGen(action =>
{
action.OrderActionsBy(orderBy => orderBy.HttpMethod);
action.UseReferencedDefinitionsForEnums();
foreach (var item in provider.ApiVersionDescriptions)
{
action.SwaggerDoc(item.GroupName, new Swashbuckle.AspNetCore.Swagger.Info
{
Title = "Version-" + item.GroupName,
Version = item.ApiVersion.MajorVersion.ToString() + "." + item.ApiVersion.MinorVersion
});
}
});
}
This code will add swagger in pipeline. Now we have to use swagger. do below code in startup file.:
app.UseSwaggerGenerationUI(this.Configuration)
Definition of UseSwaggerGenerationUI would be like as :
public static void UseSwaggerGenerationUI(this IApplicationBuilder applicationBuilder, IApiVersionDescriptionProvider apiVersionDescriptionProvider, IConfiguration configuration)
{
applicationBuilder.UseSwagger(c =>
{
c.RouteTemplate = "/api/help/versions/{documentname}/document.json";
c.PreSerializeFilters.Add((swaggerDoc, httpReq) => swaggerDoc.BasePath = "/api");
});
applicationBuilder.UseSwaggerUI(c =>
{
c.RoutePrefix = "api/help";
c.DocumentTitle = "Api Help";
foreach (var item in apiVersionDescriptionProvider.ApiVersionDescriptions)
{
c.SwaggerEndpoint($"/api/help/versions/{item.GroupName}/document.json", item.GroupName);
}
});
}

Get RedirectToPage Generated URL ASP.NET Core

You are able to redirect to other actions using built-in methods like:
RedirectToPage("./Index", new { StatusMessage = "Everything was processed successfully"" });
This will redirect to another PageModel that's in same directory and it's Index action. Is it possible to "extract" this functionality so that you get redirect location by just passing ./Index to some util function/method?
public class IndexModel : PageModel
{
public void OnGet()
{
string privacyPageUrl = Url.Page("./Privacy");
}
}

Localized page names with ASP.NET Core 2.1

When create a Razor page, e.g. "Events.cshtml", one get its model name set to
#page
#model EventsModel
where the page's name in this case is "Events", and the URL would look like
http://example.com/Events
To be able to use page name's in Norwegian I added the following to the "Startup.cs"
services.AddMvc()
.SetCompatibilityVersion(CompatibilityVersion.Version_2_1)
.AddRazorPagesOptions(options => {
options.Conventions.AddPageRoute("/Events", "/hvaskjer");
options.Conventions.AddPageRoute("/Companies", "/bedrifter");
options.Conventions.AddPageRoute("/Contact", "/kontakt");
});
With this I can also use an URL like this and still serve the "Events" page
http://example.com/hvaskjer
I'm planning to support many more languages and wonder, is this the recommended way to setup localized page name's/route's?, or is there a more proper, correct way to accomplish the same.
I mean, with the above sample, and having 15 pages in 10 languages it gets/feels messy using options.Conventions.AddPageRoute("/Page", "/side"); 150 times.
You can do this with the IPageRouteModelConvention interface. It provides access to the PageRouteModel where you can effectively add more templates for routes to match against for a particular page.
Here's a very simple proof of concept based on the following service and model:
public interface ILocalizationService
{
List<LocalRoute> LocalRoutes();
}
public class LocalizationService : ILocalizationService
{
public List<LocalRoute> LocalRoutes()
{
var routes = new List<LocalRoute>
{
new LocalRoute{Page = "/Pages/Contact.cshtml", Versions = new List<string>{"kontakt", "contacto", "contatto" } }
};
return routes;
}
}
public class LocalRoute
{
public string Page { get; set; }
public List<string> Versions { get; set; }
}
All it does is provide the list of options for a particular page. The IPageRouteModelConvention implementation looks like this:
public class LocalizedPageRouteModelConvention : IPageRouteModelConvention
{
private ILocalizationService _localizationService;
public LocalizedPageRouteModelConvention(ILocalizationService localizationService)
{
_localizationService = localizationService;
}
public void Apply(PageRouteModel model)
{
var route = _localizationService.LocalRoutes().FirstOrDefault(p => p.Page == model.RelativePath);
if (route != null)
{
foreach (var option in route.Versions)
{
model.Selectors.Add(new SelectorModel()
{
AttributeRouteModel = new AttributeRouteModel
{
Template = option
}
});
}
}
}
}
At Startup, Razor Pages build the routes for the application. The Apply method is executed for every navigable page that the framework finds. If the relative path of the current page matches one in your data, an additional template is added for each option.
You register the new convention in ConfigureServices:
services.AddMvc().AddRazorPagesOptions(options =>
{
options.Conventions.Add(new LocalizedPageRouteModelConvention(new LocalizationService()));
}).SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

How to get all keys in ResourceManager

Recently I've created a new ASP.NET Core Web Application and one of my requirements is to expose an endpoint for clients to get all the translation keys and values stored in .resx files.
Before ASP.NET Core I was able to do something like this. But the command ResourceManager.GetResourceSet() is not recognized anymore:
public IActionResult Get(string lang)
{
var resourceObject = new JObject();
var resourceSet = Resources.Strings.ResourceManager.GetResourceSet(new CultureInfo(lang), true, true);
IDictionaryEnumerator enumerator = resourceSet.GetEnumerator();
while (enumerator.MoveNext())
{
resourceObject.Add(enumerator.Key.ToString(), enumerator.Value.ToString());
}
return Ok(resourceObject);
}
Are there any new ways to get all keys and values of a resource in ASP.NET Core Project?
If you will look at the documentation, you will see that ASP.NET Core Team has introduced IStringLocalizer and IStringLocalizer<T>. Under the cover, IStringLocalizer use ResourceManager and ResourceReader. Basic usage from the documentation:
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
namespace Localization.StarterWeb.Controllers
{
[Route("api/[controller]")]
public class AboutController : Controller
{
private readonly IStringLocalizer<AboutController> _localizer;
public AboutController(IStringLocalizer<AboutController> localizer)
{
_localizer = localizer;
}
[HttpGet]
public string Get()
{
return _localizer["About Title"];
}
}
}
For getting all keys you can do that:
var resourceSet = _localizer.GetAllStrings().Select(x => x.Name);
But for getting all keys by language, you need to use WithCulture method:
var resourceSet = _localizer.WithCulture(new CultureInfo(lang))
.GetAllStrings().Select(x => x.Name);
So when you inject IStringLocalizer into your controller it will be instantiated as an instance of ResourceManagerStringLocalizer class with default CultureInfo for your application, and for getting resources specific for your lang variable, you need to use WithCulture method, because it creates new ResourceManagerStringLocalizer class for a specific CultureInfo.