diff --git a/src/My.Extensions.Localization.Json/Internal/JsonStringLocalizerLoggerExtensions.cs b/src/My.Extensions.Localization.Json/Internal/JsonStringLocalizerLoggerExtensions.cs index 74d170b..d9e9780 100644 --- a/src/My.Extensions.Localization.Json/Internal/JsonStringLocalizerLoggerExtensions.cs +++ b/src/My.Extensions.Localization.Json/Internal/JsonStringLocalizerLoggerExtensions.cs @@ -7,6 +7,7 @@ namespace My.Extensions.Localization.Json.Internal; internal static class JsonStringLocalizerLoggerExtensions { private static readonly Action _searchedLocation; + private static readonly Action _missingLocalization; static JsonStringLocalizerLoggerExtensions() { @@ -14,10 +15,20 @@ static JsonStringLocalizerLoggerExtensions() LogLevel.Debug, 1, $"{nameof(JsonStringLocalizer)} searched for '{{Key}}' in '{{LocationSearched}}' with culture '{{Culture}}'."); + + _missingLocalization = LoggerMessage.Define( + LogLevel.Warning, + 2, + $"{nameof(JsonStringLocalizer)} could not find localization for '{{Key}}' in '{{LocationSearched}}' with culture '{{Culture}}'."); } public static void SearchedLocation(this ILogger logger, string key, string searchedLocation, CultureInfo culture) { _searchedLocation(logger, key, searchedLocation, culture, null); } + + public static void MissingLocalization(this ILogger logger, string key, string searchedLocation, CultureInfo culture) + { + _missingLocalization(logger, key, searchedLocation, culture, null); + } } diff --git a/src/My.Extensions.Localization.Json/JsonLocalizationOptions.cs b/src/My.Extensions.Localization.Json/JsonLocalizationOptions.cs index 4eb1cae..934e51f 100644 --- a/src/My.Extensions.Localization.Json/JsonLocalizationOptions.cs +++ b/src/My.Extensions.Localization.Json/JsonLocalizationOptions.cs @@ -5,4 +5,10 @@ namespace My.Extensions.Localization.Json; public class JsonLocalizationOptions : LocalizationOptions { public ResourcesType ResourcesType { get; set; } = ResourcesType.TypeBased; + + /// + /// Gets or sets the behavior when a localization resource is not found. + /// The default is . + /// + public MissingLocalizationBehavior MissingLocalizationBehavior { get; set; } = MissingLocalizationBehavior.Ignore; } \ No newline at end of file diff --git a/src/My.Extensions.Localization.Json/JsonStringLocalizer.cs b/src/My.Extensions.Localization.Json/JsonStringLocalizer.cs index 2abc9e6..7d72ad6 100644 --- a/src/My.Extensions.Localization.Json/JsonStringLocalizer.cs +++ b/src/My.Extensions.Localization.Json/JsonStringLocalizer.cs @@ -18,6 +18,7 @@ public class JsonStringLocalizer : IStringLocalizer private readonly JsonResourceManager _jsonResourceManager; private readonly IResourceStringProvider _resourceStringProvider; private readonly ILogger _logger; + private readonly MissingLocalizationBehavior _missingLocalizationBehavior; private string _searchedLocation = string.Empty; @@ -27,7 +28,20 @@ public JsonStringLocalizer( ILogger logger) : this(jsonResourceManager, new JsonStringProvider(resourceNamesCache, jsonResourceManager), - logger) + logger, + MissingLocalizationBehavior.Ignore) + { + } + + public JsonStringLocalizer( + JsonResourceManager jsonResourceManager, + IResourceNamesCache resourceNamesCache, + ILogger logger, + MissingLocalizationBehavior missingLocalizationBehavior) + : this(jsonResourceManager, + new JsonStringProvider(resourceNamesCache, jsonResourceManager), + logger, + missingLocalizationBehavior) { } @@ -35,13 +49,23 @@ public JsonStringLocalizer( JsonResourceManager jsonResourceManager, IResourceStringProvider resourceStringProvider, ILogger logger) + : this(jsonResourceManager, resourceStringProvider, logger, MissingLocalizationBehavior.Ignore) + { + } + + public JsonStringLocalizer( + JsonResourceManager jsonResourceManager, + IResourceStringProvider resourceStringProvider, + ILogger logger, + MissingLocalizationBehavior missingLocalizationBehavior) { _jsonResourceManager = jsonResourceManager ?? throw new ArgumentNullException(nameof(jsonResourceManager)); _resourceStringProvider = resourceStringProvider ?? throw new ArgumentNullException(nameof(resourceStringProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _missingLocalizationBehavior = missingLocalizationBehavior; } - [Obsolete("This constructor has been deprected and will be removed in the upcoming major release.")] + [Obsolete("This constructor has been deprecated and will be removed in the upcoming major release.")] public JsonStringLocalizer( JsonResourceManager jsonResourceManager, IResourceStringProvider resourceStringProvider, @@ -51,6 +75,7 @@ public JsonStringLocalizer( _jsonResourceManager = jsonResourceManager ?? throw new ArgumentNullException(nameof(jsonResourceManager)); _resourceStringProvider = resourceStringProvider ?? throw new ArgumentNullException(nameof(resourceStringProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _missingLocalizationBehavior = MissingLocalizationBehavior.Ignore; } public LocalizedString this[string name] @@ -107,23 +132,48 @@ protected string GetStringSafely(string name, CultureInfo culture) if (_missingManifestCache.ContainsKey(cacheKey)) { + HandleMissingLocalization(name, keyCulture); return null; } try { - return culture == null + var value = culture == null ? _jsonResourceManager.GetString(name) : _jsonResourceManager.GetString(name, culture); + + if (value == null) + { + _missingManifestCache.TryAdd(cacheKey, null); + HandleMissingLocalization(name, keyCulture); + } + + return value; } catch (MissingManifestResourceException) { _missingManifestCache.TryAdd(cacheKey, null); + HandleMissingLocalization(name, keyCulture); return null; } } + private void HandleMissingLocalization(string name, CultureInfo culture) + { + switch (_missingLocalizationBehavior) + { + case MissingLocalizationBehavior.LogWarning: + _logger.MissingLocalization(name, _jsonResourceManager.ResourcesFilePath, culture); + break; + case MissingLocalizationBehavior.ThrowException: + throw new MissingLocalizationException(name, culture.Name, _jsonResourceManager.ResourcesFilePath); + case MissingLocalizationBehavior.Ignore: + default: + break; + } + } + private HashSet GetResourceNamesFromCultureHierarchy(CultureInfo startingCulture) { var currentCulture = startingCulture; diff --git a/src/My.Extensions.Localization.Json/JsonStringLocalizerFactory.cs b/src/My.Extensions.Localization.Json/JsonStringLocalizerFactory.cs index a45a9ea..d67e880 100644 --- a/src/My.Extensions.Localization.Json/JsonStringLocalizerFactory.cs +++ b/src/My.Extensions.Localization.Json/JsonStringLocalizerFactory.cs @@ -18,6 +18,7 @@ public class JsonStringLocalizerFactory : IStringLocalizerFactory private readonly ConcurrentDictionary _localizerCache = new(); private readonly string _resourcesRelativePath; private readonly ResourcesType _resourcesType = ResourcesType.TypeBased; + private readonly MissingLocalizationBehavior _missingLocalizationBehavior = MissingLocalizationBehavior.Ignore; private readonly ILoggerFactory _loggerFactory; public JsonStringLocalizerFactory( @@ -28,6 +29,7 @@ public JsonStringLocalizerFactory( _resourcesRelativePath = localizationOptions.Value.ResourcesPath ?? string.Empty; _resourcesType = localizationOptions.Value.ResourcesType; + _missingLocalizationBehavior = localizationOptions.Value.MissingLocalizationBehavior; _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); } @@ -95,7 +97,7 @@ protected virtual JsonStringLocalizer CreateJsonStringLocalizer( : new JsonResourceManager(resourcesPath); var logger = _loggerFactory.CreateLogger(); - return new JsonStringLocalizer(resourceManager, _resourceNamesCache, logger); + return new JsonStringLocalizer(resourceManager, _resourceNamesCache, logger, _missingLocalizationBehavior); } private string GetResourcePath(Assembly assembly) diff --git a/src/My.Extensions.Localization.Json/MissingLocalizationBehavior.cs b/src/My.Extensions.Localization.Json/MissingLocalizationBehavior.cs new file mode 100644 index 0000000..e59566d --- /dev/null +++ b/src/My.Extensions.Localization.Json/MissingLocalizationBehavior.cs @@ -0,0 +1,23 @@ +namespace My.Extensions.Localization.Json; + +/// +/// Specifies the behavior when a localization resource is not found. +/// +public enum MissingLocalizationBehavior +{ + /// + /// Ignores the missing localization and uses the key as the value. + /// This is the default behavior. + /// + Ignore, + + /// + /// Logs a warning when a localization is not found. + /// + LogWarning, + + /// + /// Throws a when a localization is not found. + /// + ThrowException +} diff --git a/src/My.Extensions.Localization.Json/MissingLocalizationException.cs b/src/My.Extensions.Localization.Json/MissingLocalizationException.cs new file mode 100644 index 0000000..6be83dc --- /dev/null +++ b/src/My.Extensions.Localization.Json/MissingLocalizationException.cs @@ -0,0 +1,66 @@ +using System; + +namespace My.Extensions.Localization.Json; + +/// +/// The exception that is thrown when a localization resource is not found +/// and the behavior is configured. +/// +public class MissingLocalizationException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public MissingLocalizationException() + { + } + + /// + /// Initializes a new instance of the class + /// with a specified error message. + /// + /// The error message that explains the reason for the exception. + public MissingLocalizationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class + /// with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. + public MissingLocalizationException(string message, Exception innerException) : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class + /// with details about the missing localization. + /// + /// The localization key that was not found. + /// The culture for which the localization was not found. + /// The location where the localization was searched. + public MissingLocalizationException(string key, string culture, string searchedLocation) + : base($"Localization for key '{key}' was not found for culture '{culture}' in '{searchedLocation}'.") + { + Key = key; + Culture = culture; + SearchedLocation = searchedLocation; + } + + /// + /// Gets the localization key that was not found. + /// + public string Key { get; } + + /// + /// Gets the culture for which the localization was not found. + /// + public string Culture { get; } + + /// + /// Gets the location where the localization was searched. + /// + public string SearchedLocation { get; } +} diff --git a/test/My.Extensions.Localization.Json.Tests/MissingLocalizationBehaviorTests.cs b/test/My.Extensions.Localization.Json.Tests/MissingLocalizationBehaviorTests.cs new file mode 100644 index 0000000..d6923b1 --- /dev/null +++ b/test/My.Extensions.Localization.Json.Tests/MissingLocalizationBehaviorTests.cs @@ -0,0 +1,179 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using My.Extensions.Localization.Json.Tests.Common; +using Xunit; + +namespace My.Extensions.Localization.Json.Tests; + +public class MissingLocalizationBehaviorTests +{ + [Fact] + public void MissingLocalization_WithIgnoreBehavior_ReturnsKey() + { + // Arrange + var localizationOptions = new Mock>(); + localizationOptions.Setup(o => o.Value) + .Returns(() => new JsonLocalizationOptions + { + ResourcesPath = "Resources", + MissingLocalizationBehavior = MissingLocalizationBehavior.Ignore + }); + var localizerFactory = new JsonStringLocalizerFactory(localizationOptions.Object, NullLoggerFactory.Instance); + var location = "My.Extensions.Localization.Json.Tests"; + var basename = $"{location}.Common.{nameof(Test)}"; + var localizer = localizerFactory.Create(basename, location); + + LocalizationHelper.SetCurrentCulture("fr-FR"); + + // Act + var result = localizer["NonExistentKey"]; + + // Assert + Assert.Equal("NonExistentKey", result.Value); + Assert.True(result.ResourceNotFound); + } + + [Fact] + public void MissingLocalization_WithThrowExceptionBehavior_ThrowsMissingLocalizationException() + { + // Arrange + var localizationOptions = new Mock>(); + localizationOptions.Setup(o => o.Value) + .Returns(() => new JsonLocalizationOptions + { + ResourcesPath = "Resources", + MissingLocalizationBehavior = MissingLocalizationBehavior.ThrowException + }); + var localizerFactory = new JsonStringLocalizerFactory(localizationOptions.Object, NullLoggerFactory.Instance); + var location = "My.Extensions.Localization.Json.Tests"; + var basename = $"{location}.Common.{nameof(Test)}"; + var localizer = localizerFactory.Create(basename, location); + + LocalizationHelper.SetCurrentCulture("fr-FR"); + + // Act & Assert + var exception = Assert.Throws(() => localizer["NonExistentKey"]); + Assert.Equal("NonExistentKey", exception.Key); + Assert.Equal("fr-FR", exception.Culture); + } + + [Fact] + public void MissingLocalization_WithLogWarningBehavior_LogsWarning() + { + // Arrange + var loggerFactory = new Mock(); + var logger = new Mock>(); + + // Enable logging for all levels + logger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + var localizationOptions = new Mock>(); + localizationOptions.Setup(o => o.Value) + .Returns(() => new JsonLocalizationOptions + { + ResourcesPath = "Resources", + MissingLocalizationBehavior = MissingLocalizationBehavior.LogWarning + }); + var localizerFactory = new JsonStringLocalizerFactory(localizationOptions.Object, loggerFactory.Object); + var location = "My.Extensions.Localization.Json.Tests"; + var basename = $"{location}.Common.{nameof(Test)}"; + var localizer = localizerFactory.Create(basename, location); + + LocalizationHelper.SetCurrentCulture("fr-FR"); + + // Act + var result = localizer["NonExistentKey"]; + + // Assert + Assert.Equal("NonExistentKey", result.Value); + Assert.True(result.ResourceNotFound); + + // Verify logging was called - the warning should be logged + logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => true), + It.IsAny(), + It.Is>((v, t) => true)), + Times.Once); + } + + [Fact] + public void ExistingLocalization_DoesNotTriggerMissingBehavior() + { + // Arrange + var localizationOptions = new Mock>(); + localizationOptions.Setup(o => o.Value) + .Returns(() => new JsonLocalizationOptions + { + ResourcesPath = "Resources", + MissingLocalizationBehavior = MissingLocalizationBehavior.ThrowException + }); + var localizerFactory = new JsonStringLocalizerFactory(localizationOptions.Object, NullLoggerFactory.Instance); + var location = "My.Extensions.Localization.Json.Tests"; + var basename = $"{location}.Common.{nameof(Test)}"; + var localizer = localizerFactory.Create(basename, location); + + LocalizationHelper.SetCurrentCulture("fr-FR"); + + // Act & Assert - Should not throw because the key exists + var result = localizer["Hello"]; + Assert.Equal("Bonjour", result.Value); + Assert.False(result.ResourceNotFound); + } + + [Fact] + public void MissingLocalizationWithArguments_WithThrowExceptionBehavior_ThrowsMissingLocalizationException() + { + // Arrange + var localizationOptions = new Mock>(); + localizationOptions.Setup(o => o.Value) + .Returns(() => new JsonLocalizationOptions + { + ResourcesPath = "Resources", + MissingLocalizationBehavior = MissingLocalizationBehavior.ThrowException + }); + var localizerFactory = new JsonStringLocalizerFactory(localizationOptions.Object, NullLoggerFactory.Instance); + var location = "My.Extensions.Localization.Json.Tests"; + var basename = $"{location}.Common.{nameof(Test)}"; + var localizer = localizerFactory.Create(basename, location); + + LocalizationHelper.SetCurrentCulture("fr-FR"); + + // Act & Assert + var exception = Assert.Throws(() => localizer["NonExistentKey {0}", "arg1"]); + Assert.Equal("NonExistentKey {0}", exception.Key); + } + + [Fact] + public void DefaultBehavior_IsIgnore() + { + // Arrange + var options = new JsonLocalizationOptions(); + + // Assert + Assert.Equal(MissingLocalizationBehavior.Ignore, options.MissingLocalizationBehavior); + } + + [Fact] + public void MissingLocalizationException_ContainsCorrectProperties() + { + // Arrange & Act + var exception = new MissingLocalizationException("TestKey", "en-US", "/path/to/resources"); + + // Assert + Assert.Equal("TestKey", exception.Key); + Assert.Equal("en-US", exception.Culture); + Assert.Equal("/path/to/resources", exception.SearchedLocation); + Assert.Contains("TestKey", exception.Message); + Assert.Contains("en-US", exception.Message); + Assert.Contains("/path/to/resources", exception.Message); + } +}