diff --git a/README.md b/README.md index 296f926..e1397b8 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,35 @@ The last step is to implement and register an IResourcesProvider. This provider services.AddSingleton(); ``` +The alternative way is to implement both the IStringLocalizerFactory and IStringLocalizer interfaces. +Additionally, we can use resource type marker classes. +This approach overrides all logic related to the usage of the IResourcesProvider interface. +```csharp +namespace Namespace.Namespace1.Namespace2; + +[ResourceTypeAlias("SomePath")] +public class CustomResource +{ +} + +new ServiceCollection() + .AddGraphQLServer() + .SetSchema() + .AddTranslation( + /* add all translatable types explicitely, except String, which is already added implicitely. */ + c => c.AddTranslatableType() + .AddTranslatableType() + ) + .AddStringLocalizerFactory() + .AddStringLocalizer(ServiceLifetime.Singleton, typeof(CustomResource)) + .AddStringLocalizer(ServiceLifetime.Scoped, [ typeof(OtherCustomResource) /* additional resources can share the same localizer logic */ ]); +``` + +If the resource type is not decorated with the ResourceTypeAlias attribute, +the default alias is generated from the namespace and name of the resource class. +For the case described above, it would generate the alias "Namespace::Namespace1::Namespace2::CustomResource". + + With this we have registered the necessary objects to support translation on our fields. Now we can start adding translation support to our fields. ### Translating fields @@ -63,6 +92,11 @@ We can also rewrite our string/enum/other field to make it a `{ key label }` fie This can be useful if we also use Array Translations, which use the same typing (see next chapter). ```csharp +[ResourceTypeAlias("Ref/Aex/Countries"))] +public class CountryResource +{ +} + public class AddressType : ObjectType { protected override void Configure(IObjectTypeDescriptor descriptor) @@ -70,16 +104,31 @@ public class AddressType : ObjectType descriptor .Field(c => c.Country) // The Country property is a Country enum .Translate("Ref/Aex/Countries"); + + // OR + // descriptor + // .Field(c => c.Country) + // .Translate(typeof(CountryResource)); } } ``` Alternatively it is possible to translate the field via an attribute directly on the property: ```csharp +[ResourceTypeAlias("Ref/Aex/Countries"))] +public class CountryResource +{ +} + public class Address { [Translate("Ref/Aex/Countries")] public Country Country { get; } + + + // OR + // [Translate(typeof(CountryResource)] + // public Country Country { get; } } ``` diff --git a/src/HotChocolate.Extensions.Translation.Tests/TestResourceType.cs b/src/HotChocolate.Extensions.Translation.Tests/TestResourceType.cs new file mode 100644 index 0000000..75f09b6 --- /dev/null +++ b/src/HotChocolate.Extensions.Translation.Tests/TestResourceType.cs @@ -0,0 +1,9 @@ +using HotChocolate.Extensions.Translation.Resources; + +namespace HotChocolate.Extensions.Translation.Tests; + +[ResourceTypeAlias(Path)] +public class TestResourceType +{ + public const string Path = "myNodePath"; +} diff --git a/src/HotChocolate.Extensions.Translation.Tests/TestStringLocalizer.cs b/src/HotChocolate.Extensions.Translation.Tests/TestStringLocalizer.cs new file mode 100644 index 0000000..170f0b7 --- /dev/null +++ b/src/HotChocolate.Extensions.Translation.Tests/TestStringLocalizer.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using HotChocolate.Extensions.Translation.Resources; +using HotChocolate.Extensions.Translation.Tests.Mock; +using Microsoft.Extensions.Localization; + +namespace HotChocolate.Extensions.Translation.Tests; + +public class TestStringLocalizer : IStringLocalizer +{ + private readonly DictionaryResourcesProviderAdapter _dictionaryResourcesProviderAdapter; + + public TestStringLocalizer( + Func>> func) + { + _dictionaryResourcesProviderAdapter = + new DictionaryResourcesProviderAdapter(func()); + } + + public LocalizedString this[string name] + { + get + { + CultureInfo culture = Thread.CurrentThread.CurrentCulture; + + string notFound = "*** NOT FOUND ***"; + + var translatedValue = + _dictionaryResourcesProviderAdapter.TryGetTranslationAsStringAsync( + $"{TestResourceType.Path}/{name}", culture, notFound, CancellationToken.None) + .GetAwaiter().GetResult(); + + return new LocalizedString( + name, + translatedValue == notFound ? name : translatedValue, + resourceNotFound: translatedValue == notFound); + } + } + + public LocalizedString this[string name, params object[] arguments] => + new LocalizedString(name, string.Format(this[name].Value, arguments)); + + + public IEnumerable GetAllStrings(bool includeParentCultures) + { + throw new NotImplementedException(); + } +} diff --git a/src/HotChocolate.Extensions.Translation.Tests/TranslateDirectiveTypeTests.cs b/src/HotChocolate.Extensions.Translation.Tests/TranslateDirectiveTypeTests.cs index cdad734..f87c2f1 100644 --- a/src/HotChocolate.Extensions.Translation.Tests/TranslateDirectiveTypeTests.cs +++ b/src/HotChocolate.Extensions.Translation.Tests/TranslateDirectiveTypeTests.cs @@ -1,100 +1,134 @@ +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; using HotChocolate.Extensions.Translation.Resources; using HotChocolate.Extensions.Translation.Tests.Mock; using HotChocolate.Resolvers; +using Microsoft.Extensions.DependencyInjection; using Moq; using Xunit; namespace HotChocolate.Extensions.Translation.Tests { - public class TranslateDirectiveTypeTests + public partial class TranslateDirectiveTypeTests { - [Fact] - public async Task UpdateResult_StringValueAndLanguageSet_ReturnsStringValue() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateResult_StringValueAndLanguageSet_ReturnsStringValue( + bool useStringLocalizer) { //Arrange var directive = new TranslateDirective("myNodePath", false); string value = "myValue"; IMiddlewareContext context = BuildMockContext( + useStringLocalizer, value, new Dictionary { { "myNodePath/myValue", "foo" } }); + Thread.CurrentThread.CurrentCulture = new CultureInfo("de"); + //Act await TranslateDirectiveType.UpdateResultAsync( - context, value, directive, new CultureInfo("de"), default); + context, value, directive, default); //Assert context.Result.Should().Be("foo/De"); } - [Fact] - public async Task UpdateResult_StringValueToCodeLabelItem_ReturnsStringValue() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateResult_StringValueToCodeLabelItem_ReturnsStringValue( + bool useStringLocalizer) { //Arrange var directive = new TranslateDirective("myNodePath", toCodeLabelItem: true); string value = "myValue"; IMiddlewareContext context = BuildMockContext( + useStringLocalizer, value, new Dictionary { { "myNodePath/myValue", "foo" } }); + Thread.CurrentThread.CurrentCulture = new CultureInfo("de"); + //Act await TranslateDirectiveType.UpdateResultAsync( - context, value, directive, new CultureInfo("de"), default); + context, value, directive, default); //Assert context.Result.Should().BeEquivalentTo(new TranslatedResource("myValue", "foo/De")); } - [InlineData((sbyte)1)] - [InlineData((byte)1)] - [InlineData((short)1)] - [InlineData((ushort)1)] - [InlineData((int)1)] - [InlineData((uint)1)] - [InlineData((long)1)] - [InlineData((ulong)1)] + [InlineData((sbyte)1, false)] + [InlineData((byte)1, false)] + [InlineData((short)1, false)] + [InlineData((ushort)1, false)] + [InlineData((int)1, false)] + [InlineData((uint)1, false)] + [InlineData((long)1, false)] + [InlineData((ulong)1, false)] + [InlineData((sbyte)1, true)] + [InlineData((byte)1, true)] + [InlineData((short)1, true)] + [InlineData((ushort)1, true)] + [InlineData((int)1, true)] + [InlineData((uint)1, true)] + [InlineData((long)1, true)] + [InlineData((ulong)1, true)] [Theory] - public async Task UpdateResult_NullableIntValue_ReturnsStringValue(object value) + public async Task UpdateResult_NullableIntValue_ReturnsStringValue(object value, bool useStringLocalizer) { //Arrange var directive = new TranslateDirective("myNodePath", false); IMiddlewareContext context = BuildMockContext( + useStringLocalizer, value, new Dictionary { { "myNodePath/1", "foo" } }); + + Thread.CurrentThread.CurrentCulture = new CultureInfo("fr"); + //Act - await TranslateDirectiveType.UpdateResultAsync(context, value, directive, new CultureInfo("fr"), default); + await TranslateDirectiveType.UpdateResultAsync(context, value, directive, default); //Assert context.Result.Should().Be("foo/Fr"); } - [Fact] - public async Task UpdateResult_StringValueMissingKey_ReturnsKey() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateResult_StringValueMissingKey_ReturnsKey(bool useStringLocalizer) { //Arrange var directive = new TranslateDirective("myNodePath", false); string value = "myValue"; IMiddlewareContext context = BuildMockContext( + useStringLocalizer, value, new Dictionary()); + Thread.CurrentThread.CurrentCulture = new CultureInfo("fr"); + //Act - await TranslateDirectiveType.UpdateResultAsync(context, value, directive, new CultureInfo("fr"), default); + await TranslateDirectiveType.UpdateResultAsync(context, value, directive, default); //Assert context.Result.Should().Be("myValue"); } - [Fact] - public async Task UpdateResult_EnumerableStringValue_ReturnsStringValue() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateResult_EnumerableStringValue_ReturnsStringValue(bool useStringLocalizer) { //Arrange var directive = new TranslateDirective("myNodePath", false); @@ -105,15 +139,18 @@ public async Task UpdateResult_EnumerableStringValue_ReturnsStringValue() }; IMiddlewareContext context = BuildMockContext( + useStringLocalizer, value, new Dictionary { { "myNodePath/myValue1", "foo" }, { "myNodePath/myValue2", "bar" }, }); + Thread.CurrentThread.CurrentCulture = new CultureInfo("de"); + //Act await TranslateDirectiveType.UpdateResultAsync( - context, value, directive, new CultureInfo("de"), default); + context, value, directive, default); //Assert context.Result.Should().BeEquivalentTo( @@ -125,30 +162,37 @@ await TranslateDirectiveType.UpdateResultAsync( ); } - [Fact] - public async Task UpdateResult_ArrayEmpty_ShouldNotThrowException() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateResult_ArrayEmpty_ShouldNotThrowException(bool useStringLocalizer) { //Arrange var directive = new TranslateDirective("myNodePath", false); IEnumerable value = new List(); IMiddlewareContext context = BuildMockContext( + useStringLocalizer, value, new Dictionary { { "mynodepath/myValue1", "foo" }, { "mynodepath/myValue2", "bar" }, }); + Thread.CurrentThread.CurrentCulture = new CultureInfo("de"); + //Act await TranslateDirectiveType.UpdateResultAsync( - context, value, directive, new CultureInfo("de"), default); + context, value, directive, default); //Assert context.Result.Should().BeEquivalentTo(new List()); } - [Fact] - public async Task UpdateResult_EnumerableStringValueWithSomeMissingKeys_ReturnsStringValueAndKeysWhenValueIsMissing() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateResult_EnumerableStringValueWithSomeMissingKeys_ReturnsStringValueAndKeysWhenValueIsMissing(bool useStringLocalizer) { //Arrange var directive = new TranslateDirective("myNodePath", false); @@ -160,15 +204,18 @@ public async Task UpdateResult_EnumerableStringValueWithSomeMissingKeys_ReturnsS }; IMiddlewareContext context = BuildMockContext( + useStringLocalizer, resolverResult: resolverResult, resourceDictionary: new Dictionary { { "myNodePath/myValue1", "foo" }, { "myNodePath/myValue3", "bar" }, }); + Thread.CurrentThread.CurrentCulture = new CultureInfo("de"); + //Act await TranslateDirectiveType.UpdateResultAsync( - context, resolverResult, directive, new CultureInfo("de"), default); + context, resolverResult, directive, default); //Assert context.Result.Should().BeEquivalentTo( @@ -181,20 +228,25 @@ await TranslateDirectiveType.UpdateResultAsync( ); } - [Fact] - public async Task UpdateResult_EnumValue_ReturnsStringValue() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateResult_EnumValue_ReturnsStringValue(bool useStringLocalizer) { //Arrange var directive = new TranslateDirective("myNodePath", false); TestEnum value = TestEnum.SecondEnum; IMiddlewareContext context = BuildMockContext( + useStringLocalizer, value, new Dictionary { { "myNodePath/SecondEnum", "foo" } }); + Thread.CurrentThread.CurrentCulture = new CultureInfo("de"); + //Act await TranslateDirectiveType.UpdateResultAsync( - context, value, directive, new CultureInfo("de"), default); + context, value, directive, default); //Assert context.Result.Should().Be("foo/De"); @@ -207,27 +259,66 @@ public enum TestEnum } private static IMiddlewareContext BuildMockContext( + bool useStringLocalizer, object resolverResult, Dictionary resourceDictionary) { - IDictionary> masterDictionary - = new Dictionary>(); - - masterDictionary.Add(Mock.Language.De, ToResourceDictionary(resourceDictionary, Mock.Language.De)); - masterDictionary.Add(Mock.Language.Fr, ToResourceDictionary(resourceDictionary, Mock.Language.Fr)); - masterDictionary.Add(Mock.Language.It, ToResourceDictionary(resourceDictionary, Mock.Language.It)); - masterDictionary.Add(Mock.Language.En, ToResourceDictionary(resourceDictionary, Mock.Language.En)); + DictionaryResourcesProviderAdapter resourceClientAdapter = + CreateResourceProviderAdapter(resourceDictionary); var contextMock = new Mock(); - var resourceClientAdapterMock = new DictionaryResourcesProviderAdapter(masterDictionary); contextMock.SetupProperty(m => m.Result, resolverResult); - IMiddlewareContext context = contextMock.Object; + + IServiceCollection services = new ServiceCollection(); + + if (useStringLocalizer) + { + services.AddDefaultStringLocalizerFactory(); + services.AddStringLocalizer( + ServiceLifetime.Singleton, typeof(TestResourceType)); + services.AddSingleton>>>( + () => GetMasterDictionary(resourceDictionary)); + } + else + { + contextMock + .Setup(m => m.Service()) + .Returns(resourceClientAdapter); + } contextMock - .Setup(m => m.Service()) - .Returns(resourceClientAdapterMock); - return context; + .SetupGet(m => m.Services) + .Returns(services.BuildServiceProvider()); + + return contextMock.Object; + } + + private static DictionaryResourcesProviderAdapter CreateResourceProviderAdapter( + Dictionary resourceDictionary) + { + IDictionary> masterDictionary = + GetMasterDictionary(resourceDictionary); + + var resourceClientAdapter = new DictionaryResourcesProviderAdapter(masterDictionary); + return resourceClientAdapter; + } + + private static IDictionary> GetMasterDictionary( + Dictionary resourceDictionary) + { + IDictionary> masterDictionary + = new Dictionary>(); + + masterDictionary.Add(Mock.Language.De, + ToResourceDictionary(resourceDictionary, Mock.Language.De)); + masterDictionary.Add(Mock.Language.Fr, + ToResourceDictionary(resourceDictionary, Mock.Language.Fr)); + masterDictionary.Add(Mock.Language.It, + ToResourceDictionary(resourceDictionary, Mock.Language.It)); + masterDictionary.Add(Mock.Language.En, + ToResourceDictionary(resourceDictionary, Mock.Language.En)); + return masterDictionary; } private static Dictionary ToResourceDictionary( diff --git a/src/HotChocolate.Extensions.Translation/Configuration/RequestExecutorBuilderExtensions.cs b/src/HotChocolate.Extensions.Translation/Configuration/RequestExecutorBuilderExtensions.cs index 3bd45db..64dee53 100644 --- a/src/HotChocolate.Extensions.Translation/Configuration/RequestExecutorBuilderExtensions.cs +++ b/src/HotChocolate.Extensions.Translation/Configuration/RequestExecutorBuilderExtensions.cs @@ -1,7 +1,10 @@ using System; +using System.Linq; using HotChocolate.Execution.Configuration; using HotChocolate.Extensions.Translation; using HotChocolate.Extensions.Translation.Resources; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Localization; namespace Microsoft.Extensions.DependencyInjection { @@ -39,5 +42,82 @@ public static IRequestExecutorBuilder AddTranslation( return b; } + + public static IRequestExecutorBuilder AddStringLocalizerFactory( + this IRequestExecutorBuilder builder) + { + builder.Services.AddDefaultStringLocalizerFactory(); + + return builder; + } + + internal static void AddDefaultStringLocalizerFactory( + this IServiceCollection services) + { + services.AddSingleton(); + } + + public static IRequestExecutorBuilder AddStringLocalizer( + this IRequestExecutorBuilder builder, + ServiceLifetime serviceLifetime, + params Type[] resourceTypes) + where T : class, IStringLocalizer + { + builder.Services.AddStringLocalizer(serviceLifetime, resourceTypes); + + return builder; + } + + internal static void AddStringLocalizer( + this IServiceCollection services, + ServiceLifetime serviceLifetime, + params Type[] resourceTypes) + where T : class, IStringLocalizer + { + if (serviceLifetime == ServiceLifetime.Transient) + { + services.TryAddTransient(); + services.TryAddTransient(typeof(IStringLocalizer), typeof(T)); + } + else if (serviceLifetime == ServiceLifetime.Scoped) + { + services.TryAddScoped(); + services.TryAddScoped(typeof(IStringLocalizer), typeof(T)); + } + else + { + services.TryAddSingleton(); + services.TryAddSingleton(typeof(IStringLocalizer), typeof(T)); + } + + services.AddOrUpdateResourceTypeResolver(typeof(T), resourceTypes); + } + + private static void AddOrUpdateResourceTypeResolver( + this IServiceCollection services, Type localizer, params Type[] resourceTypes) + { + ServiceDescriptor? descriptor = services.FirstOrDefault(x => + x.Lifetime == ServiceLifetime.Singleton + && x.ServiceType == typeof(IResourceTypeResolver) + && x.ImplementationType == typeof(DefaultResourceTypeResolver)); + + DefaultResourceTypeResolver typeResolver; + + if (descriptor is null) + { + typeResolver = new(); + + services.AddSingleton(typeResolver); + } + else + { + typeResolver = (DefaultResourceTypeResolver)descriptor.ImplementationInstance!; + } + + foreach (Type resourceType in resourceTypes) + { + typeResolver.RegisterType(resourceType, localizer); + } + } } } diff --git a/src/HotChocolate.Extensions.Translation/HotChocolate.Extensions.Translation.csproj b/src/HotChocolate.Extensions.Translation/HotChocolate.Extensions.Translation.csproj index 0056fe8..7c09385 100644 --- a/src/HotChocolate.Extensions.Translation/HotChocolate.Extensions.Translation.csproj +++ b/src/HotChocolate.Extensions.Translation/HotChocolate.Extensions.Translation.csproj @@ -9,6 +9,7 @@ + diff --git a/src/HotChocolate.Extensions.Translation/ObjectFieldDescriptorExtensions.cs b/src/HotChocolate.Extensions.Translation/ObjectFieldDescriptorExtensions.cs index dc519ea..f140acd 100644 --- a/src/HotChocolate.Extensions.Translation/ObjectFieldDescriptorExtensions.cs +++ b/src/HotChocolate.Extensions.Translation/ObjectFieldDescriptorExtensions.cs @@ -1,5 +1,7 @@ using System; +using HotChocolate.Extensions.Translation.Resources; using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.Extensions.Translation { @@ -49,6 +51,23 @@ public static IObjectFieldDescriptor Translate( .Directive(new TranslateDirective(resourceKeyPrefix, toCodeLabelItem: true)); } + public static IObjectFieldDescriptor Translate( + this IObjectFieldDescriptor fieldDescriptor, Type resourceSource, bool nullable = false) + { + if (!nullable) + { + fieldDescriptor + .Type>>(); + } + else + { + fieldDescriptor + .Type>(); + } + + return AddTranslateDirective(fieldDescriptor, resourceSource); + } + public static IInterfaceFieldDescriptor Translate( this IInterfaceFieldDescriptor fieldDescriptor, bool nullable = false) { @@ -93,6 +112,23 @@ public static IObjectFieldDescriptor TranslateArray( .Directive(new TranslateDirective(resourceKeyPrefix, toCodeLabelItem: true)); } + public static IObjectFieldDescriptor TranslateArray( + this IObjectFieldDescriptor fieldDescriptor, Type resourceSource, bool nullable = false) + { + if (!nullable) + { + fieldDescriptor + .Type>>>>(); + } + else + { + fieldDescriptor + .Type>>>(); + } + + return AddTranslateDirective(fieldDescriptor, resourceSource); + } + public static IInterfaceFieldDescriptor TranslateArray( this IInterfaceFieldDescriptor fieldDescriptor, bool nullable = false) { @@ -115,5 +151,31 @@ public static IObjectFieldDescriptor TranslateArray( { return fieldDescriptor.TranslateArray(keyPrefix, nullable); } + + public static IObjectFieldDescriptor TranslateArray( + this IObjectFieldDescriptor fieldDescriptor, Type resourceSource, bool nullable = false) + { + return fieldDescriptor.TranslateArray(resourceSource, nullable); + } + + private static IObjectFieldDescriptor AddTranslateDirective( + IObjectFieldDescriptor fieldDescriptor, Type resourceSource) + { + if (fieldDescriptor is IHasDescriptorContext contextHolder) + { + IResourceTypeResolver? resourceTypeResolver = + contextHolder.Context.Services.GetService(); + + if (resourceTypeResolver != null) + { + string resourceKeyPrefix = resourceTypeResolver.GetAlias(resourceSource); + + return fieldDescriptor + .Directive(new TranslateDirective(resourceKeyPrefix, toCodeLabelItem: true)); + } + } + + return fieldDescriptor; + } } } diff --git a/src/HotChocolate.Extensions.Translation/Resources/DefaultLocalizerFactory.cs b/src/HotChocolate.Extensions.Translation/Resources/DefaultLocalizerFactory.cs new file mode 100644 index 0000000..2fcc434 --- /dev/null +++ b/src/HotChocolate.Extensions.Translation/Resources/DefaultLocalizerFactory.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace HotChocolate.Extensions.Translation.Resources; + +public class DefaultLocalizerFactory : IStringLocalizerFactory +{ + private readonly IResourceTypeResolver _resourceTypeResolver; + private readonly IServiceProvider _serviceProvider; + + public DefaultLocalizerFactory( + IResourceTypeResolver resourceTypeResolver, + IServiceProvider serviceProvider) + { + _resourceTypeResolver = resourceTypeResolver; + _serviceProvider = serviceProvider; + } + + public IStringLocalizer Create(Type resourceSource) + { + Type localizer = _resourceTypeResolver.LookupLocalizer(resourceSource); + + return (IStringLocalizer)_serviceProvider.GetRequiredService(localizer); + } + + public IStringLocalizer Create(string baseName, string location) + { + Type? resourceSource = _resourceTypeResolver.Resolve(baseName); + + if (resourceSource == null) + { + throw new InvalidOperationException( + $"No resource source type has been registered for {baseName} alias."); + } + + return Create(resourceSource!); + } +} + diff --git a/src/HotChocolate.Extensions.Translation/Resources/DefaultResourceTypeResolver.cs b/src/HotChocolate.Extensions.Translation/Resources/DefaultResourceTypeResolver.cs new file mode 100644 index 0000000..41a64b0 --- /dev/null +++ b/src/HotChocolate.Extensions.Translation/Resources/DefaultResourceTypeResolver.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; + +namespace HotChocolate.Extensions.Translation.Resources; + +public class DefaultResourceTypeResolver : IResourceTypeResolver +{ + private readonly ConcurrentDictionary _aliasToTypeMap = new(); + private readonly ConcurrentDictionary _typeToAliasMap = new(); + private readonly ConcurrentDictionary _typeToLocalizerTypeMap = new(); + + public void RegisterType(Type resourceSource, Type localizer) + { + if (!TryGetAliasFromAttribute(resourceSource, out string? alias)) + { + alias = $"{resourceSource.Namespace?.Replace(".", "::")}::{resourceSource.Name}"; + } + + _aliasToTypeMap[alias!] = resourceSource; + _typeToAliasMap[resourceSource] = alias!; + _typeToLocalizerTypeMap[resourceSource] = localizer; + } + + public Type? Resolve(string alias) + { + return _aliasToTypeMap.TryGetValue(alias, out Type? type) ? type : null; + } + + public string GetAlias(Type type) + { + if (_typeToAliasMap.TryGetValue(type, out var alias)) + { + return alias; + } + + throw new KeyNotFoundException($"No alias is registered for {type.Name} type."); + } + + public Type LookupLocalizer(Type resourceSource) + { + return _typeToLocalizerTypeMap[resourceSource]; + } + + private static bool TryGetAliasFromAttribute(Type type, out string? aliasValue) + { + aliasValue = type.GetCustomAttribute()?.Value; + + return aliasValue is not null; + } +} diff --git a/src/HotChocolate.Extensions.Translation/Resources/IResourceTypeResolver.cs b/src/HotChocolate.Extensions.Translation/Resources/IResourceTypeResolver.cs new file mode 100644 index 0000000..0818450 --- /dev/null +++ b/src/HotChocolate.Extensions.Translation/Resources/IResourceTypeResolver.cs @@ -0,0 +1,10 @@ +using System; + +namespace HotChocolate.Extensions.Translation.Resources; + +public interface IResourceTypeResolver +{ + Type? Resolve(string alias); + string GetAlias(Type resourceSource); + Type LookupLocalizer(Type resourceSource); +} diff --git a/src/HotChocolate.Extensions.Translation/Resources/ResourceTypeAliasAttribute.cs b/src/HotChocolate.Extensions.Translation/Resources/ResourceTypeAliasAttribute.cs new file mode 100644 index 0000000..5e8fcd4 --- /dev/null +++ b/src/HotChocolate.Extensions.Translation/Resources/ResourceTypeAliasAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace HotChocolate.Extensions.Translation.Resources; + +[AttributeUsage( + AttributeTargets.Class, + Inherited = true, + AllowMultiple = false)] +public class ResourceTypeAliasAttribute : Attribute +{ + public ResourceTypeAliasAttribute(string Value) + { + this.Value = Value; + } + + public string Value { get; } +} diff --git a/src/HotChocolate.Extensions.Translation/Resources/ResourcesProviderAdapter.cs b/src/HotChocolate.Extensions.Translation/Resources/ResourcesProviderAdapter.cs index c668264..383ecec 100644 --- a/src/HotChocolate.Extensions.Translation/Resources/ResourcesProviderAdapter.cs +++ b/src/HotChocolate.Extensions.Translation/Resources/ResourcesProviderAdapter.cs @@ -24,7 +24,7 @@ public async Task TryGetTranslationAsStringAsync( string fallbackValue, CancellationToken cancellationToken) { - var res = await _resourcesProvider + Resource? res = await _resourcesProvider .TryGetResourceAsync(key, culture, cancellationToken) .ConfigureAwait(false); @@ -41,4 +41,4 @@ public async Task TryGetTranslationAsStringAsync( return fallbackValue; } } -} \ No newline at end of file +} diff --git a/src/HotChocolate.Extensions.Translation/TranslateArrayAttribute.cs b/src/HotChocolate.Extensions.Translation/TranslateArrayAttribute.cs index 808848c..927fa58 100644 --- a/src/HotChocolate.Extensions.Translation/TranslateArrayAttribute.cs +++ b/src/HotChocolate.Extensions.Translation/TranslateArrayAttribute.cs @@ -1,8 +1,9 @@ using System; using System.Reflection; -using HotChocolate.Extensions.Translation.Exceptions; +using HotChocolate.Extensions.Translation.Resources; using HotChocolate.Types; using HotChocolate.Types.Descriptors; +using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.Extensions.Translation { @@ -18,6 +19,13 @@ public TranslateArrayAttribute( : base(resourceKeyPrefix, nullable) { } + + public TranslateArrayAttribute( + Type resourceSource, + bool nullable = false) + : base(resourceSource, nullable) + { + } } [AttributeUsage( @@ -26,6 +34,8 @@ public TranslateArrayAttribute( AllowMultiple = false)] public class TranslateArrayAttribute: DescriptorAttribute { + private readonly Type? _resourceSource; + public TranslateArrayAttribute( string resourceKeyPrefix, bool nullable = false) @@ -34,6 +44,15 @@ public TranslateArrayAttribute( Nullable = nullable; } + public TranslateArrayAttribute( + Type resourceSource, + bool nullable = false) + { + ResourceKeyPrefix = string.Empty; + _resourceSource = resourceSource; + Nullable = nullable; + } + public string ResourceKeyPrefix { get; set; } public bool Nullable { get; set; } @@ -42,6 +61,14 @@ protected override void TryConfigure( IDescriptor descriptor, ICustomAttributeProvider element) { + if (_resourceSource is not null) + { + IResourceTypeResolver typeResolver = + context.Services.GetRequiredService(); + + ResourceKeyPrefix = typeResolver.GetAlias(_resourceSource); + } + if (descriptor is IObjectFieldDescriptor d) { d.TranslateArray(ResourceKeyPrefix, Nullable); diff --git a/src/HotChocolate.Extensions.Translation/TranslateAttribute.cs b/src/HotChocolate.Extensions.Translation/TranslateAttribute.cs index c5fef53..91ac583 100644 --- a/src/HotChocolate.Extensions.Translation/TranslateAttribute.cs +++ b/src/HotChocolate.Extensions.Translation/TranslateAttribute.cs @@ -1,8 +1,9 @@ using System; using System.Reflection; -using HotChocolate.Extensions.Translation.Exceptions; +using HotChocolate.Extensions.Translation.Resources; using HotChocolate.Types; using HotChocolate.Types.Descriptors; +using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.Extensions.Translation { @@ -18,6 +19,13 @@ public TranslateAttribute( : base(resourceKeyPrefix, nullable) { } + + public TranslateAttribute( + Type resourceSource, + bool nullable = false) + : base(resourceSource, nullable) + { + } } [AttributeUsage( @@ -26,6 +34,8 @@ public TranslateAttribute( AllowMultiple = false)] public class TranslateAttribute: DescriptorAttribute { + private readonly Type? _resourceSource; + public TranslateAttribute( string resourceKeyPrefix, bool nullable = false) @@ -34,6 +44,15 @@ public TranslateAttribute( Nullable = nullable; } + public TranslateAttribute( + Type resourceSource, + bool nullable = false) + { + _resourceSource = resourceSource; + ResourceKeyPrefix = string.Empty; + Nullable = nullable; + } + public string ResourceKeyPrefix { get; set; } public bool Nullable { get; set; } @@ -42,6 +61,14 @@ protected override void TryConfigure( IDescriptor descriptor, ICustomAttributeProvider element) { + if (_resourceSource is not null) + { + IResourceTypeResolver typeResolver = + context.Services.GetRequiredService(); + + ResourceKeyPrefix = typeResolver.GetAlias(_resourceSource); + } + if (descriptor is IObjectFieldDescriptor d) { d.Translate(ResourceKeyPrefix, Nullable); diff --git a/src/HotChocolate.Extensions.Translation/TranslateDirectiveType.cs b/src/HotChocolate.Extensions.Translation/TranslateDirectiveType.cs index ac5a4d2..8f7c610 100644 --- a/src/HotChocolate.Extensions.Translation/TranslateDirectiveType.cs +++ b/src/HotChocolate.Extensions.Translation/TranslateDirectiveType.cs @@ -8,6 +8,8 @@ using HotChocolate.Extensions.Translation.Resources; using HotChocolate.Resolvers; using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; namespace HotChocolate.Extensions.Translation { @@ -52,9 +54,7 @@ private static async ValueTask Translate( try { - CultureInfo culture = Thread.CurrentThread.CurrentCulture; - - await UpdateResultAsync(context, value, directive, culture, context.RequestAborted) + await UpdateResultAsync(context, value, directive, context.RequestAborted) .ConfigureAwait(false); } catch (TranslationException ex) @@ -68,39 +68,113 @@ await UpdateResultAsync(context, value, directive, culture, context.RequestAbort } } - internal static async Task UpdateResultAsync(IMiddlewareContext context, + internal static async Task UpdateResultAsync( + IMiddlewareContext context, object value, TranslateDirective directiveOptions, - CultureInfo culture, CancellationToken cancellationToken) { + if (context.Result is null) + { + return; + } + + CultureInfo culture = Thread.CurrentThread.CurrentCulture; + + IStringLocalizerFactory? localizerFactory = + context.Services.GetService(); + + if (localizerFactory is not null) + { + IStringLocalizer stringLocalizer = + localizerFactory.Create(directiveOptions.ResourceKeyPrefix, string.Empty); + + IEnumerable? observers = + context.Services.GetService>(); + + if (value is IEnumerable values) + { + if (directiveOptions.ToCodeLabelItem) + { + context.Result = values + .Select(item => item is not null + ? new TranslatedResource( + (T)item, GetString(stringLocalizer, item, observers)) + : null) + .ToArray(); + + return; + } + + context.Result = values + .Select(item => item is not null + ? GetString(stringLocalizer, item, observers) + : null) + .ToArray(); + + return; + } + + if (directiveOptions.ToCodeLabelItem && value is T t) + { + context.Result = + new TranslatedResource( + (T)value, GetString(stringLocalizer, value, observers)); + + return; + } + + context.Result = GetString(stringLocalizer, value, observers); + + return; + } + IResourcesProviderAdapter client = context.Service(); if (value is IEnumerable items) { var rItems = items.OfType().ToList(); await TranslateArrayAsync( - context, directiveOptions, culture, client, rItems, cancellationToken) + context, directiveOptions, client, rItems, cancellationToken) .ConfigureAwait(false); } else if (directiveOptions.ToCodeLabelItem && value is T t) { await TranslateToCodeLabelItemAsync( - context, directiveOptions, culture, client, t, cancellationToken) + context, directiveOptions, client, t, cancellationToken) .ConfigureAwait(false); } else { await TranslateFieldToStringAsync( - context, value, directiveOptions, culture, client, cancellationToken) + context, value, directiveOptions, client, cancellationToken) .ConfigureAwait(false); } } + private static string GetString( + IStringLocalizer stringLocalizer, + object value, + IEnumerable? observers) + { + string key = value.ToString()!; + + LocalizedString localizedString = stringLocalizer[key]; + + if (localizedString.ResourceNotFound && observers is not null) + { + foreach (TranslationObserver observer in observers) + { + observer.OnMissingResource(key); + } + } + + return localizedString.Value; + } + private static async Task TranslateFieldToStringAsync(IMiddlewareContext context, object value, TranslateDirective directiveOptions, - CultureInfo culture, IResourcesProviderAdapter client, CancellationToken cancellationToken) { @@ -111,7 +185,6 @@ await TranslateStringAsync( context, value, directiveOptions, - culture, client, s, cancellationToken) @@ -129,7 +202,6 @@ await TranslateStringAsync( context, value, directiveOptions, - culture, client, sValue, cancellationToken) @@ -139,7 +211,7 @@ await TranslateStringAsync( case Enum e: await TranslateEnum( - context, value, directiveOptions, culture, client, e, cancellationToken); + context, value, directiveOptions, client, e, cancellationToken); break; default: @@ -151,7 +223,6 @@ await TranslateEnum( private static async Task TranslateArrayAsync(IMiddlewareContext context, TranslateDirective directiveOptions, - CultureInfo culture, IResourcesProviderAdapter client, IReadOnlyList items, CancellationToken cancellationToken) @@ -159,42 +230,25 @@ private static async Task TranslateArrayAsync(IMiddlewareContext context, if (directiveOptions.ToCodeLabelItem) { await TranslateToCodeLabelArrayAsync( - context, directiveOptions, culture, client, items, cancellationToken) + context, directiveOptions, client, items, cancellationToken) .ConfigureAwait(false); } else { await TranslateToStringArrayAsync( - context, directiveOptions, culture, client, items, cancellationToken) + context, directiveOptions, client, items, cancellationToken) .ConfigureAwait(false); } } - private static async Task UpdateResultAsync( - IMiddlewareContext context, - object value, - TranslateDirective directiveOptions, - CultureInfo culture, - IResourcesProviderAdapter client, - Enum e, - CancellationToken cancellationToken) - { - context.Result = await client - .TryGetTranslationAsStringAsync( - $"{directiveOptions.ResourceKeyPrefix}/{value}", - culture, - e.ToString(), - cancellationToken) - .ConfigureAwait(false); - } - private static async Task TranslateStringAsync(IMiddlewareContext context, object value, TranslateDirective directiveOptions, - CultureInfo culture, IResourcesProviderAdapter client, string s, CancellationToken cancellationToken) { + CultureInfo culture = Thread.CurrentThread.CurrentCulture; + context.Result = await client.TryGetTranslationAsStringAsync( $"{directiveOptions.ResourceKeyPrefix}/{value}", culture, @@ -205,10 +259,11 @@ private static async Task TranslateStringAsync(IMiddlewareContext context, private static async Task TranslateToStringArrayAsync(IMiddlewareContext context, TranslateDirective directiveOptions, - CultureInfo culture, IResourcesProviderAdapter client, IReadOnlyList items, CancellationToken cancellationToken) { + CultureInfo culture = Thread.CurrentThread.CurrentCulture; + var result = await Task.WhenAll(items .Select(async t => await client.TryGetTranslationAsStringAsync( $"{directiveOptions.ResourceKeyPrefix}/{t}", @@ -223,11 +278,12 @@ private static async Task TranslateToStringArrayAsync(IMiddlewareContext context private static async Task TranslateToCodeLabelArrayAsync(IMiddlewareContext context, TranslateDirective directiveOptions, - CultureInfo culture, IResourcesProviderAdapter client, IReadOnlyList items, CancellationToken cancellationToken) { - var result = await Task.WhenAll(items + CultureInfo culture = Thread.CurrentThread.CurrentCulture; + + TranslatedResource[] result = await Task.WhenAll(items .Select(async item => new TranslatedResource( item, await client.TryGetTranslationAsStringAsync( @@ -245,11 +301,12 @@ await client.TryGetTranslationAsStringAsync( private static async Task TranslateToCodeLabelItemAsync(IMiddlewareContext context, TranslateDirective directiveOptions, - CultureInfo culture, IResourcesProviderAdapter client, T item, CancellationToken cancellationToken) { + CultureInfo culture = Thread.CurrentThread.CurrentCulture; + context.Result = item == null ? null : new TranslatedResource( @@ -267,11 +324,12 @@ private static async Task TranslateEnum( IMiddlewareContext context, object value, TranslateDirective directiveOptions, - CultureInfo culture, IResourcesProviderAdapter client, Enum e, CancellationToken cancellationToken) { + CultureInfo culture = Thread.CurrentThread.CurrentCulture; + context.Result = await client.TryGetTranslationAsStringAsync( $"{directiveOptions.ResourceKeyPrefix}/{value}", culture,