Search code examples
c#.netlocalizationresources

.Net SDK 6 (C#): String resources in a separate project not working


I've read many SO threads before posting to no avail, and I'm stuck.

My solution consists in different projects under an hexagonal arquitecture, and I need to share string resources (translations) among more than one project.

For that I created a "Class Library" project and moved all resx files (and the corresponding dummy c# file) there, and in the projects where resources are needed, like "Web", I reference that new project called "SharedResources".

Program.cs

var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
logger.Debug("init main");
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString, op =>
    {
        op.CommandTimeout(1000);
        op.EnableRetryOnFailure();
    }));
builder.Services.AddHttpContextAccessor();
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.BuildGestionTasasServices(builder.Configuration);
builder.Configuration.AddEnvironmentVariables();
builder.Logging.ClearProviders();
builder.Host.UseNLog();
builder.Services.Configure<ConfigMail>(builder.Configuration.GetSection("ConfigMail"), options =>
{
    options.BindNonPublicProperties = true;
});
builder.Services.Configure<IdentityOptions>(options =>
{
    // Default Lockout settings.
    options.Lockout.MaxFailedAccessAttempts = 5;
    options.Lockout.AllowedForNewUsers = true;
    options.Password.RequireNonAlphanumeric = false;
    options.Password.RequiredLength = 8;
    options.SignIn.RequireConfirmedAccount = false;
    options.SignIn.RequireConfirmedEmail = false;
    options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider;
    options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider;
});
builder.Services.AddIdentityCore<ApplicationUser>().AddDefaultTokenProviders();
builder.Services.AddSession(options =>
{
    options.Cookie.Name = "UserSession";
    options.Cookie.IsEssential = false;
});
builder.Services.AddControllersWithViews().AddRazorRuntimeCompilation();
builder.Services.AddRazorPages().AddMvcOptions(options =>
{
    options.ModelBindingMessageProvider.SetAttemptedValueIsInvalidAccessor((x, y)
        => @" " + x + " es un valor incorrecto para el campo " + y + ".");
});
builder.Services.AddAuthentication();
builder.Services.ConfigureApplicationCookie(options =>
{
    // Cookie settings
    // Expulsamos al user en 5 minutos.
    options.ExpireTimeSpan = TimeSpan.FromMinutes(60);

    options.Cookie.HttpOnly = true;
    options.LoginPath = "/Login";
    options.AccessDeniedPath = "/AccessDenied";
    options.SlidingExpiration = true;
});
JsonConvert.DefaultSettings = () => new JsonSerializerSettings
{
    ReferenceLoopHandling = ReferenceLoopHandling.Ignore
};
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminAccess", policy => policy.RequireRole("Admin"));
    options.AddPolicy("ModificationAccess", policy => policy.RequireRole("Admin", "Modification"));
    options.AddPolicy("ParamAccess", policy => policy.RequireRole("Admin", "ParamRole"));
});
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequestLogger<,>));
//builder.Services.AddMediatR(AppDomain.CurrentDomain.GetAssemblies());
builder.Services.Configure<JsonOptions>(options =>
{
    options.SerializerOptions.PropertyNameCaseInsensitive = false;
    options.SerializerOptions.PropertyNamingPolicy = null;
    options.SerializerOptions.WriteIndented = true;
    options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
});

//Folder de recursos
//builder.Services.AddLocalization(opt => { opt.ResourcesPath = "Resources"; });

builder.Services.AddLocalization();

builder.Services.Configure<RequestLocalizationOptions>(
    opts =>
    {
        /* your configurations*/
        var supportedCultures = new List<CultureInfo>
        {
            new CultureInfo("es"),
            new CultureInfo("ca"),
            new CultureInfo("fi")
        };

        opts.DefaultRequestCulture = new RequestCulture("es", "es");
        // Formatting numbers, dates, etc.
        opts.SupportedCultures = supportedCultures;
        // UI strings that we have localized.
        opts.SupportedUICultures = supportedCultures;
    });

//agrego el servicio de localización para las data annotations
builder.Services.AddMvc()
        .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
        .AddDataAnnotationsLocalization();

//configuro los idiomas
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
    //obtengo todas las culturas soportadas
    var allSuportedCultures = SupportedCultures.GetCultures();

    //elimino los idiomas de test si no estoy en desarrollo
    if (!builder.Environment.IsDevelopment())
    {
        SupportedCultures.RemoveTestLanguages();
    }

    //creo la lista de las culturas soportadas
    var supportedCultures = new List<CultureInfo>();
    foreach (var culture in allSuportedCultures)
    {
        supportedCultures.Add(new CultureInfo(culture.CultureName));
    }

    options.DefaultRequestCulture = new RequestCulture(supportedCultures.First(cul => cul.Name == allSuportedCultures.First(r => r.IsDefaultLanguage).CultureName));
    options.SupportedCultures = supportedCultures;
    options.SupportedUICultures = supportedCultures;
});

var app = builder.Build();

app.UseDeveloperExceptionPage();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

// agrego el servicio de localización
var options = app.Services.GetService<IOptions<RequestLocalizationOptions>>();
app.UseRequestLocalization(options!.Value);

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Home/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.MapControllerRoute(
    name: "MyArea",
    pattern: "{area:exists}/{controller=Management}/{action=Index}/{id?}");
app.MapControllerRoute(
    name: "areaRoute",
    pattern: "{area}/{controller}/{did?}/{action=Index}/{id?}"
);
app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapRazorPages();

// migrate any database changes on startup (includes initial db creation)
using (var scope = app.Services.CreateScope())
{
    var dataContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
    dataContext.Database.Migrate();
    await scope.ServiceProvider.GetRequiredService<SeedDataBase>().Seed();
}

app.Run();

Example controller:

[Area(areaName: "Agrupaciones")]
public class ManagementController : BaseController
{
    /// <summary>
    /// Constructor
    /// </summary>
    /// <param name="med"></param>
    /// <param name="userManagerq"></param>
    public ManagementController(IMediator med, UserManager<ApplicationUser> userManagerq, IStringLocalizer<SharedResources> stringLocalizer) : base(med, userManagerq, stringLocalizer)
    {
    }
    ...
}

A view example (where I need translations):

@inject IStringLocalizer<SharedResources> ViewLocalizer
@using DevExtreme.AspNet.Mvc;
@using GestionTasas.Web;
@using Microsoft.Extensions.Localization;
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, AuthoringTagHelpers
@model GestionTasas.Domain.ViewModels.AgrupacionConceptosViewModel
@{
    Layout = "_Layout";
    ViewData["Title"] = ViewLocalizer["CreateAgrupacionConcepto.Title"];
}
<script src="~/js/CustomTreeView.js"></script>
<div class="ibox">
    <div class="form-horizontal">
        <div class="ibox-title">
            <ol class="breadcrumb">
                <li class="breadcrumb-item">
                    @Html.ActionLink(ViewLocalizer["CreateAgrupacionConcepto.AgrupacionesConcepto"], "AgrupacionesConcepto", "Management")
                </li>
                <li class="active breadcrumb-item">
                    <strong style="">@ViewLocalizer["CreateAgrupacionConcepto.Title"]</strong>
                </li>
            </ol>
        </div>
        <div class="ibox-content">
            <div class="row form-group d-flex">
                <div class="d-flex">
                    @Html.ValidationSummary(true, "", new { @class = "text-danger" })
                </div>
                <div class="d-flex">
                    @(Html.DevExtreme().ValidationSummary()
                        .ID("summary")
                        )
                </div>
                @using (Html.BeginForm("CreateAgrupacionConcepto", "Management", FormMethod.Post, new { @class = "row ms-auto g-2 mt-2 form-floating form-horizontal", model = Model }))
                {
                    @Html.AntiForgeryToken()
                    @Html.HiddenFor(model => model.Id)
                    @Html.HiddenFor(model => model.Conceptos)
                    <input type="hidden" id="selectedNodeIds" name="NodosSeleccionados" />

                    <div class="form-group d-flex">
                        <div class="col-2 col-md-auto">
                            @(
                            Html.DevExtreme().TextBoxFor(model => model.Nombre)
                                    .Width(800d)
                                    .Height(40d)
                                    .Label(ViewLocalizer[@Html.DisplayNameFor(m => m.Nombre)])
                                    .Value(Model.Nombre ?? string.Empty)
                                    .OnValueChanged("trimInput")
                        )
                        </div>
                    </div>
                    <hr />
                    <div class="form-group d-flex">
                        <div class="col-2 col-md-auto">
                            @(Html.DevExtreme().TreeView()
                                .ID("conceptos")
                                .SearchEnabled(true)
                                .SelectByClick(false)
                                .SelectNodesRecursive(false)
                                .DataStructure(TreeViewDataStructure.Tree)
                                .SelectionMode(NavSelectionMode.Multiple)
                                .ShowCheckBoxesMode(TreeViewCheckBoxMode.SelectAll)
                                .DataSource(Model.Conceptos)
                                .ItemsExpr("Items")
                                .DisplayExpr("Descripcion")
                                .SelectedExpr("Seleccionado")
                                .ExpandedExpr("Expandido")
                                .OnItemClick("selectAllChildren")
                                .OnItemSelectionChanged("customRecursiveBehaviour")
                                .OnSelectionChanged("syncSelection")
                                .OnContentReady("syncTreeView")
                                .Width(800)
                                .Height(500)
                                )
                        </div>
                    </div>
                    <div class="form-group d-flex">
                        <div class="col-2 col-md-auto">
                            @(Html.DevExtreme().Button()
                                .Text("Guardar")
                                .Width(100d)
                                .Height(40d)
                                .Type(ButtonType.Success)
                                .UseSubmitBehavior(true)
                                .ID("submit-button"))
                        </div>
                    </div>
                }
            </div>
        </div>
    </div>
</div>

As you see I'm injecting IStringLocalizer in the views and using ViewLocalizer to get the translations, which was working fine when the resources where on the web project, but stopped working after moved them to a separate project.

Dummy c# file under DummyClass folder in "SharedResources" project (the resx files are one level up this folder):

namespace GestionTasas.Web
{
    /// <summary>
    /// Dummy class used to collect shared resource strings for this application
    /// </summary>
    /// <remarks>
    /// This class is mostly used with IStringLocalizer and IHtmlLocalizer interfaces.
    /// </remarks>
    public class SharedResources
    {
    }
}

The resources are marked as public under the resx editor.

Everything seems to be fine, but undoubtly I'm missing something, because I'm seeing the keys instead of the translations in the views.


Solution

  • I'm answering myself once more.

    My problem was the string resources files (.resx) were not in the same folder as the dummy class.

    Putting all together solved my issues and now texts are correctly translated and shown.