From 41e0d2d50f4373e02f4ca26e4f59a9bfae7ec40b Mon Sep 17 00:00:00 2001 From: Paula Date: Wed, 8 Jul 2026 12:54:31 +0200 Subject: [PATCH] feat(config): add configuration tree and migrate to AbstractBundle Migrate the bundle to the modern layout: the bundle class extends AbstractBundle, defines the api_platform_translation configuration tree in configure() and imports root-level config/services.php (PHP DSL) from loadExtension(). The legacy ApiPlatformTranslationExtension keeps working standalone but is deprecated for removal in 3.0. New configuration, all defaults preserving current behavior: - enabled_locales: accepted request locales, empty inherits framework.enabled_locales - fallback_locale: locale stamped as fallback on translatables, null inherits kernel.default_locale - locale_resolution: ordered locale sources (query_param, accept_language), each can be removed or reordered Translator resolves the locale by iterating the configured sources. A request without an Accept-Language header now falls through to the next source instead of resolving to the head of the negotiation list, which is what makes non-default orders meaningful; with the default order the observable behavior is unchanged. Dependency hygiene: symfony/config, symfony/http-kernel and symfony/deprecation-contracts are now direct requirements, symfony/yaml is dropped (no YAML is loaded at runtime anymore). --- .php-cs-fixer.dist.php | 2 +- CHANGELOG.md | 23 +++ README.md | 33 ++++- UPGRADE-2.1.md | 27 ++++ composer.json | 6 +- config/services.php | 48 +++++++ phpstan.neon.dist | 1 + src/ApiPlatformTranslationBundle.php | 50 ++++++- .../ApiPlatformTranslationExtension.php | 26 ++-- src/Resources/config/services.yml | 35 ----- src/Translation/Translator.php | 57 ++++++-- tests/ApiPlatformTranslationBundleTest.php | 6 + .../ApiPlatformTranslationExtensionTest.php | 32 +++++ tests/Fixtures/TestKernel.php | 34 ++++- tests/Functional/ConfigurationTest.php | 134 ++++++++++++++++++ tests/Translation/TranslatorTest.php | 70 +++++++++ 16 files changed, 522 insertions(+), 62 deletions(-) create mode 100644 UPGRADE-2.1.md create mode 100644 config/services.php delete mode 100644 src/Resources/config/services.yml create mode 100644 tests/DependencyInjection/ApiPlatformTranslationExtensionTest.php create mode 100644 tests/Functional/ConfigurationTest.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 793816b..3107a87 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,7 +1,7 @@ in([__DIR__.'/src', __DIR__.'/tests']) + ->in([__DIR__.'/config', __DIR__.'/src', __DIR__.'/tests']) ; return (new PhpCsFixer\Config()) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f2d3f9..d79a157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Bundle configuration tree under the `api_platform_translation` alias: + `enabled_locales` (inherits `framework.enabled_locales` when empty), + `fallback_locale` (inherits `framework.default_locale` when null) and + `locale_resolution` (ordered locale sources, `query_param` and + `accept_language`; each can be removed or reordered) (#85) +- FQCN alias for the `Translator` service, so it can be autowired by type (#85) + +### Changed +- Modern bundle layout: the bundle class extends `AbstractBundle` and services + are defined in `config/services.php`; `src/Resources/config/services.yml` is + gone (#85) +- A request without an `Accept-Language` header now falls through to the next + configured locale source instead of being resolved by header negotiation; + with the default resolution order the observable behavior is unchanged (#85) +- Dependencies: `symfony/config`, `symfony/http-kernel` and + `symfony/deprecation-contracts` are direct requirements now that the bundle + uses them directly; `symfony/yaml` is no longer required (#85) + +### Deprecated +- `ApiPlatformTranslationExtension`, to be removed in 3.0; the bundle registers + its extension itself through `AbstractBundle` (#85) + ## [2.0.1] - 2026-07-07 ### Fixed diff --git a/README.md b/README.md index 6c4a10d..4ddb2ae 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,37 @@ Installation: composer require locastic/api-platform-translation-bundle ``` +Configuration: +-------------- +The bundle works without any configuration. All options and their defaults: + +```yaml +# config/packages/api_platform_translation.yaml +api_platform_translation: + # Locales accepted from the ?locale= query parameter and Accept-Language + # negotiation. Empty (the default) inherits framework.enabled_locales; + # when both are empty, any requested locale is accepted. + enabled_locales: [] + + # Locale used when a translation for the current locale does not exist. + # null (the default) inherits framework.default_locale. + fallback_locale: null + + # Ordered sources the request locale is resolved from; the first source + # producing a locale wins. Remove a source to disable it. + locale_resolution: + - query_param + - accept_language +``` + +For example, to resolve the locale from the `Accept-Language` header only and +ignore the `?locale=` query parameter: + +```yaml +api_platform_translation: + locale_resolution: [accept_language] +``` + Implementation: -------------- **Translatable entity:** @@ -192,7 +223,7 @@ Usage: `Accept-Language: de` -**Restricting locales:** if [`framework.enabled_locales`](https://symfony.com/doc/current/reference/configuration/framework.html#enabled-locales) is configured, only those locales are accepted: a `?locale=` value outside the list and non-matching `Accept-Language` headers fall back to the default locale. When `enabled_locales` is not configured (Symfony's default), any requested locale is accepted. +**Restricting locales:** if [`framework.enabled_locales`](https://symfony.com/doc/current/reference/configuration/framework.html#enabled-locales) or the bundle's own `enabled_locales` option (which takes precedence) is configured, only those locales are accepted: a `?locale=` value outside the list and non-matching `Accept-Language` headers fall back to the default locale. When neither is configured (Symfony's default), any requested locale is accepted. **Serialization group for displaying all translations:** diff --git a/UPGRADE-2.1.md b/UPGRADE-2.1.md new file mode 100644 index 0000000..dfd96e6 --- /dev/null +++ b/UPGRADE-2.1.md @@ -0,0 +1,27 @@ +# UPGRADE FROM 2.0 TO 2.1 + +2.1 contains no BC breaks. Typical installs need no changes. + +## Deprecations + +1. `Locastic\ApiPlatformTranslationBundle\DependencyInjection\ApiPlatformTranslationExtension` + is deprecated and will be removed in 3.0. The bundle now extends + `AbstractBundle` and registers its extension itself. The class keeps working + if you instantiate it directly, but stop referencing it: registering the + bundle in `config/bundles.php` is all that is needed. + +## Internal layout changes + +These only affect you if you referenced bundle files by path: + +1. Services are now defined in `config/services.php` at the bundle root; + `src/Resources/config/services.yml` no longer exists. Service IDs and + behavior are unchanged. +2. `symfony/yaml` is no longer a dependency of the bundle. If your application + used it without requiring it, add it to your own `composer.json`. + +## New configuration + +The bundle now has a configuration tree under `api_platform_translation` +(`enabled_locales`, `fallback_locale`, `locale_resolution`). All defaults +preserve 2.0 behavior; see the README "Configuration" section. diff --git a/composer.json b/composer.json index 1d55140..20396cf 100644 --- a/composer.json +++ b/composer.json @@ -14,9 +14,11 @@ "api-platform/symfony": "^3.4 || ^4.0", "doctrine/orm": "^3.0", "doctrine/doctrine-bundle": "^2.13 || ^3.0", - "symfony/translation": "^6.4 || ^7.0 || ^8.0", + "symfony/config": "^6.4 || ^7.0 || ^8.0", "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/translation": "^6.4 || ^7.0 || ^8.0" }, "require-dev": { "phpunit/phpunit": "^10.5", diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..acecee1 --- /dev/null +++ b/config/services.php @@ -0,0 +1,48 @@ +services(); + + $services->set('locastic_api_platform_translation.translation.translator', Translator::class) + ->args([ + service('translator'), + service('request_stack'), + param('kernel.default_locale'), + param('locastic_api_platform_translation.enabled_locales'), + param('locastic_api_platform_translation.locale_resolution'), + ]); + + $services->alias(Translator::class, 'locastic_api_platform_translation.translation.translator'); + + $services->set('locastic_api_platform_translation.listener.assign_locale', AssignLocaleListener::class) + ->args([ + service('locastic_api_platform_translation.translation.translator'), + param('locastic_api_platform_translation.fallback_locale'), + ]) + ->tag('doctrine.event_listener', ['event' => 'postLoad']) + ->tag('doctrine.event_listener', ['event' => 'prePersist']); + + // Serializer: in-place, merge denormalization of nested translations + $services->set('locastic_api_platform_translation.serializer.translatable_denormalizer', TranslatableItemDenormalizer::class) + ->tag('serializer.normalizer', ['priority' => 100]); + + $services->alias(TranslatableItemDenormalizer::class, 'locastic_api_platform_translation.serializer.translatable_denormalizer'); + + // Filters + $services->set('locastic_api_platform_translation.filter.translation_groups') + ->parent('api_platform.serializer.group_filter') + ->args([ + 'groups', + false, + ['translations'], + ]) + ->tag('api_platform.filter', ['id' => 'translation.groups']); +}; diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 58e0e9c..4f09149 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,6 +1,7 @@ parameters: level: 6 paths: + - config - src - tests ignoreErrors: diff --git a/src/ApiPlatformTranslationBundle.php b/src/ApiPlatformTranslationBundle.php index b9351a7..d886dbe 100644 --- a/src/ApiPlatformTranslationBundle.php +++ b/src/ApiPlatformTranslationBundle.php @@ -4,8 +4,54 @@ namespace Locastic\ApiPlatformTranslationBundle; -use Symfony\Component\HttpKernel\Bundle\Bundle; +use Locastic\ApiPlatformTranslationBundle\Translation\Translator; +use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Bundle\AbstractBundle; -class ApiPlatformTranslationBundle extends Bundle +class ApiPlatformTranslationBundle extends AbstractBundle { + protected string $extensionAlias = 'api_platform_translation'; + + public function configure(DefinitionConfigurator $definition): void + { + $definition->rootNode() + ->children() + ->arrayNode('enabled_locales') + ->info('Locales accepted from the ?locale= query parameter and Accept-Language negotiation. Empty inherits framework.enabled_locales; when both are empty, any locale is accepted.') + ->scalarPrototype()->cannotBeEmpty()->end() + ->defaultValue([]) + ->end() + ->scalarNode('fallback_locale') + ->info('Locale stamped as the fallback on loaded and persisted translatables. Defaults to kernel.default_locale.') + ->defaultNull() + ->end() + ->arrayNode('locale_resolution') + ->info('Ordered list of sources the request locale is resolved from; the first source producing a locale wins. Remove a source to disable it.') + ->performNoDeepMerging() + ->enumPrototype() + ->values([Translator::RESOLUTION_QUERY_PARAM, Translator::RESOLUTION_ACCEPT_LANGUAGE]) + ->end() + ->defaultValue([Translator::RESOLUTION_QUERY_PARAM, Translator::RESOLUTION_ACCEPT_LANGUAGE]) + ->validate() + ->ifTrue(static fn (array $sources): bool => \count($sources) !== \count(array_unique($sources))) + ->thenInvalid('Locale resolution sources must not repeat: %s') + ->end() + ->end() + ->end(); + } + + /** + * @param array{enabled_locales: list, fallback_locale: ?string, locale_resolution: list} $config + */ + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $container->parameters() + ->set('locastic_api_platform_translation.enabled_locales', $config['enabled_locales'] ?: '%kernel.enabled_locales%') + ->set('locastic_api_platform_translation.fallback_locale', $config['fallback_locale'] ?? '%kernel.default_locale%') + ->set('locastic_api_platform_translation.locale_resolution', $config['locale_resolution']); + + $container->import('../config/services.php'); + } } diff --git a/src/DependencyInjection/ApiPlatformTranslationExtension.php b/src/DependencyInjection/ApiPlatformTranslationExtension.php index d94e3ae..450da85 100644 --- a/src/DependencyInjection/ApiPlatformTranslationExtension.php +++ b/src/DependencyInjection/ApiPlatformTranslationExtension.php @@ -7,22 +7,32 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; -use Symfony\Component\DependencyInjection\Loader; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; /** - * This is the class that loads and manages your bundle configuration. - * - * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html} + * @deprecated since locastic/api-platform-translation-bundle 2.1, to be removed in 3.0; + * the bundle extends AbstractBundle and registers its extension itself */ class ApiPlatformTranslationExtension extends Extension { + public function __construct() + { + trigger_deprecation( + 'locastic/api-platform-translation-bundle', + '2.1', + 'The "%s" class is deprecated and will be removed in 3.0, the bundle registers its own extension through AbstractBundle.', + self::class, + ); + } + public function load(array $configs, ContainerBuilder $container): void { - $directory = - __DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'Resources'.DIRECTORY_SEPARATOR.'config'; + $container->setParameter('locastic_api_platform_translation.enabled_locales', '%kernel.enabled_locales%'); + $container->setParameter('locastic_api_platform_translation.fallback_locale', '%kernel.default_locale%'); + $container->setParameter('locastic_api_platform_translation.locale_resolution', ['query_param', 'accept_language']); - $loader = new Loader\YamlFileLoader($container, new FileLocator($directory)); - $loader->load('services.yml'); + $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'config')); + $loader->load('services.php'); } public function getAlias(): string diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yml deleted file mode 100644 index d5d4bef..0000000 --- a/src/Resources/config/services.yml +++ /dev/null @@ -1,35 +0,0 @@ -services: - locastic_api_platform_translation.translation.translator: - class: Locastic\ApiPlatformTranslationBundle\Translation\Translator - arguments: - - '@translator' - - '@request_stack' - - '%kernel.default_locale%' - - '%kernel.enabled_locales%' - - locastic_api_platform_translation.listener.assign_locale: - class: Locastic\ApiPlatformTranslationBundle\EventListener\AssignLocaleListener - arguments: - - '@locastic_api_platform_translation.translation.translator' - - '%kernel.default_locale%' - tags: - - { name: doctrine.event_listener, event: postLoad } - - { name: doctrine.event_listener, event: prePersist } - -# Serializer: in-place, merge denormalization of nested translations - locastic_api_platform_translation.serializer.translatable_denormalizer: - class: Locastic\ApiPlatformTranslationBundle\Serializer\TranslatableItemDenormalizer - tags: - - { name: serializer.normalizer, priority: 100 } - - Locastic\ApiPlatformTranslationBundle\Serializer\TranslatableItemDenormalizer: '@locastic_api_platform_translation.serializer.translatable_denormalizer' - -# Filters - locastic_api_platform_translation.filter.translation_groups: - parent: 'api_platform.serializer.group_filter' - arguments: - - 'groups' - - false - - ['translations'] - tags: - - { name: 'api_platform.filter', id: 'translation.groups' } diff --git a/src/Translation/Translator.php b/src/Translation/Translator.php index 2438736..c771a85 100644 --- a/src/Translation/Translator.php +++ b/src/Translation/Translator.php @@ -4,20 +4,26 @@ namespace Locastic\ApiPlatformTranslationBundle\Translation; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\Translation\LocaleAwareInterface; use Symfony\Contracts\Translation\TranslatorInterface; class Translator implements TranslatorInterface, LocaleAwareInterface { + public const RESOLUTION_QUERY_PARAM = 'query_param'; + public const RESOLUTION_ACCEPT_LANGUAGE = 'accept_language'; + /** * @param list $enabledLocales + * @param list $localeResolution ordered self::RESOLUTION_* values */ public function __construct( - private TranslatorInterface&LocaleAwareInterface $translator, - private RequestStack $requestStack, - private string $defaultLocale, - private array $enabledLocales = [], + private readonly TranslatorInterface&LocaleAwareInterface $translator, + private readonly RequestStack $requestStack, + private readonly string $defaultLocale, + private readonly array $enabledLocales = [], + private readonly array $localeResolution = [self::RESOLUTION_QUERY_PARAM, self::RESOLUTION_ACCEPT_LANGUAGE], ) { } @@ -41,14 +47,45 @@ public function loadCurrentLocale(): string return $this->defaultLocale; } - $localeCode = $request->query->get('locale'); + foreach ($this->localeResolution as $source) { + $locale = match ($source) { + self::RESOLUTION_QUERY_PARAM => $this->resolveFromQueryParam($request), + self::RESOLUTION_ACCEPT_LANGUAGE => $this->resolveFromAcceptLanguage($request), + default => throw new \InvalidArgumentException(sprintf('Unknown locale resolution source "%s".', $source)), + }; - if ($localeCode) { - if (!$this->enabledLocales || \in_array($localeCode, $this->enabledLocales, true)) { - return $localeCode; + if (null !== $locale) { + return $locale; } + } - return $this->defaultLocale; + return $this->defaultLocale; + } + + private function resolveFromQueryParam(Request $request): ?string + { + $localeCode = $request->query->get('locale'); + + if (!$localeCode) { + return null; + } + + if (!$this->enabledLocales || \in_array($localeCode, $this->enabledLocales, true)) { + return (string) $localeCode; + } + + // A locale outside the enabled list pins the default instead of falling + // through: an explicit but invalid request must not be re-interpreted + // from a lower-priority source. + return $this->defaultLocale; + } + + private function resolveFromAcceptLanguage(Request $request): ?string + { + // An absent header falls through to the next source; getPreferredLanguage() + // would otherwise resolve it to the head of the locale list. + if (!$request->headers->has('Accept-Language')) { + return null; } // getPreferredLanguage() falls back to the first element of the list when @@ -59,7 +96,7 @@ public function loadCurrentLocale(): string : null ); - return empty($preferredLanguage) ? $this->defaultLocale : $preferredLanguage; + return empty($preferredLanguage) ? null : $preferredLanguage; } /** diff --git a/tests/ApiPlatformTranslationBundleTest.php b/tests/ApiPlatformTranslationBundleTest.php index 5c634ea..cf87229 100644 --- a/tests/ApiPlatformTranslationBundleTest.php +++ b/tests/ApiPlatformTranslationBundleTest.php @@ -22,4 +22,10 @@ public function testExtensionIsLoaded(): void $bundle = new ApiPlatformTranslationBundle(); $this->assertNotNull($bundle->getContainerExtension()); } + + public function testExtensionAliasIsStable(): void + { + $bundle = new ApiPlatformTranslationBundle(); + $this->assertSame('api_platform_translation', $bundle->getContainerExtension()?->getAlias()); + } } diff --git a/tests/DependencyInjection/ApiPlatformTranslationExtensionTest.php b/tests/DependencyInjection/ApiPlatformTranslationExtensionTest.php new file mode 100644 index 0000000..8c2a5f1 --- /dev/null +++ b/tests/DependencyInjection/ApiPlatformTranslationExtensionTest.php @@ -0,0 +1,32 @@ +load([], $container); + + $this->assertTrue($container->hasDefinition('locastic_api_platform_translation.translation.translator')); + $this->assertTrue($container->hasDefinition('locastic_api_platform_translation.listener.assign_locale')); + $this->assertTrue($container->hasDefinition('locastic_api_platform_translation.serializer.translatable_denormalizer')); + $this->assertTrue($container->hasDefinition('locastic_api_platform_translation.filter.translation_groups')); + $this->assertSame('%kernel.enabled_locales%', $container->getParameter('locastic_api_platform_translation.enabled_locales')); + $this->assertSame('api_platform_translation', $extension->getAlias()); + } +} diff --git a/tests/Fixtures/TestKernel.php b/tests/Fixtures/TestKernel.php index b57bdef..837f688 100644 --- a/tests/Fixtures/TestKernel.php +++ b/tests/Fixtures/TestKernel.php @@ -18,6 +18,19 @@ final class TestKernel extends Kernel { use MicroKernelTrait; + /** + * @param array $translationConfig api_platform_translation extension config + * @param array $frameworkConfig extra framework extension config + */ + public function __construct( + string $environment = 'test', + bool $debug = true, + private readonly array $translationConfig = [], + private readonly array $frameworkConfig = [], + ) { + parent::__construct($environment, $debug); + } + public function registerBundles(): iterable { return [ @@ -28,9 +41,20 @@ public function registerBundles(): iterable ]; } + public function getConfigDir(): string + { + // Keep the app-level config dir out of the repository: the project dir + // is the bundle root, and debug kernels dump reference files into it. + return $this->getCacheDir().'/config'; + } + public function getCacheDir(): string { - return sys_get_temp_dir().'/aptb-tests/cache/'.$this->environment; + // The container is cached per directory; configs passed in code are not + // tracked as resources, so each config combination gets its own dir. + $configHash = hash('xxh128', serialize([$this->translationConfig, $this->frameworkConfig])); + + return sys_get_temp_dir().'/aptb-tests/cache/'.$this->environment.'/'.$configHash; } public function getLogDir(): string @@ -56,12 +80,16 @@ public function process(ContainerBuilder $container): void protected function configureContainer(ContainerConfigurator $container): void { - $container->extension('framework', [ + $container->extension('framework', array_merge([ 'secret' => 'test', 'test' => true, 'http_method_override' => false, 'validation' => ['enabled' => true], - ]); + ], $this->frameworkConfig)); + + if ($this->translationConfig) { + $container->extension('api_platform_translation', $this->translationConfig); + } $container->extension('doctrine', [ 'dbal' => ['url' => 'sqlite:///:memory:'], diff --git a/tests/Functional/ConfigurationTest.php b/tests/Functional/ConfigurationTest.php new file mode 100644 index 0000000..257a1e2 --- /dev/null +++ b/tests/Functional/ConfigurationTest.php @@ -0,0 +1,134 @@ + */ + private static array $translationConfig = []; + + /** @var array */ + private static array $frameworkConfig = []; + + protected static function getKernelClass(): string + { + return TestKernel::class; + } + + /** + * @param array $options + */ + protected static function createKernel(array $options = []): KernelInterface + { + return new TestKernel('test', true, self::$translationConfig, self::$frameworkConfig); + } + + protected function tearDown(): void + { + parent::tearDown(); + self::$translationConfig = []; + self::$frameworkConfig = []; + } + + public function testDefaultsWithBareFramework(): void + { + self::bootKernel(); + $container = static::getContainer(); + + $this->assertSame([], $container->getParameter('locastic_api_platform_translation.enabled_locales')); + $this->assertSame('en', $container->getParameter('locastic_api_platform_translation.fallback_locale')); + $this->assertSame( + [Translator::RESOLUTION_QUERY_PARAM, Translator::RESOLUTION_ACCEPT_LANGUAGE], + $container->getParameter('locastic_api_platform_translation.locale_resolution') + ); + } + + public function testDefaultsInheritFrameworkLocales(): void + { + self::$frameworkConfig = [ + 'default_locale' => 'fr', + 'enabled_locales' => ['en', 'fr'], + ]; + self::bootKernel(); + $container = static::getContainer(); + + $this->assertSame(['en', 'fr'], $container->getParameter('locastic_api_platform_translation.enabled_locales')); + $this->assertSame('fr', $container->getParameter('locastic_api_platform_translation.fallback_locale')); + } + + public function testBundleConfigurationOverridesFramework(): void + { + self::$frameworkConfig = [ + 'default_locale' => 'fr', + 'enabled_locales' => ['en', 'fr'], + ]; + self::$translationConfig = [ + 'enabled_locales' => ['de', 'it'], + 'fallback_locale' => 'de', + 'locale_resolution' => [Translator::RESOLUTION_ACCEPT_LANGUAGE], + ]; + self::bootKernel(); + $container = static::getContainer(); + + $this->assertSame(['de', 'it'], $container->getParameter('locastic_api_platform_translation.enabled_locales')); + $this->assertSame('de', $container->getParameter('locastic_api_platform_translation.fallback_locale')); + $this->assertSame( + [Translator::RESOLUTION_ACCEPT_LANGUAGE], + $container->getParameter('locastic_api_platform_translation.locale_resolution') + ); + } + + public function testTranslatorFollowsConfiguredResolutionOrder(): void + { + self::$translationConfig = [ + 'enabled_locales' => ['en', 'de'], + 'locale_resolution' => [Translator::RESOLUTION_ACCEPT_LANGUAGE], + ]; + self::bootKernel(); + $container = static::getContainer(); + + $requestStack = $container->get(RequestStack::class); + $requestStack->push(new Request(['locale' => 'en'], [], [], [], [], ['HTTP_ACCEPT_LANGUAGE' => 'de'])); + + $translator = $container->get('locastic_api_platform_translation.translation.translator'); + + // Accept-Language wins: the query parameter source is not configured. + $this->assertSame('de', $translator->loadCurrentLocale()); + } + + public function testUnknownResolutionSourceIsRejected(): void + { + self::$translationConfig = [ + 'locale_resolution' => ['cookie'], + ]; + + $this->expectException(InvalidConfigurationException::class); + + self::bootKernel(); + } + + public function testDuplicateResolutionSourcesAreRejected(): void + { + self::$translationConfig = [ + 'locale_resolution' => [Translator::RESOLUTION_QUERY_PARAM, Translator::RESOLUTION_QUERY_PARAM], + ]; + + $this->expectException(InvalidConfigurationException::class); + + self::bootKernel(); + } +} diff --git a/tests/Translation/TranslatorTest.php b/tests/Translation/TranslatorTest.php index 7bbd897..524a7a3 100644 --- a/tests/Translation/TranslatorTest.php +++ b/tests/Translation/TranslatorTest.php @@ -161,6 +161,62 @@ public function testAnyLocaleAcceptedWhenEnabledLocalesNotConfigured(): void $this->assertSame('nl', $translator->loadCurrentLocale()); } + /** + * @test loadCurrentLocale + * + * @dataProvider provideResolutionOrders + * + * @param list $localeResolution + */ + public function testConfigurableResolutionOrder( + array $localeResolution, + ?string $requestedLocale, + ?string $acceptedLanguage, + string $expectedLocale, + ): void { + $translator = new Translator( + $this->translator, + $this->requestStack, + $this->defaultLocale, + $this->enabledLocales, + $localeResolution, + ); + + $server = null === $acceptedLanguage ? [] : ['HTTP_ACCEPT_LANGUAGE' => $acceptedLanguage]; + $request = new Request(['locale' => $requestedLocale], [], [], [], [], $server); + + $this->requestStack + ->expects($this->once()) + ->method('getCurrentRequest') + ->willReturn($request); + + $this->assertSame($expectedLocale, $translator->loadCurrentLocale()); + } + + /** + * @test loadCurrentLocale + */ + public function testUnknownResolutionSourceThrows(): void + { + $translator = new Translator( + $this->translator, + $this->requestStack, + $this->defaultLocale, + $this->enabledLocales, + ['cookie'], + ); + + $this->requestStack + ->expects($this->once()) + ->method('getCurrentRequest') + ->willReturn(new Request()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown locale resolution source "cookie".'); + + $translator->loadCurrentLocale(); + } + /** * @test loadCurrentLocale */ @@ -208,6 +264,20 @@ public function provideLocales(): \Generator yield ['nl', 'en']; // Locale not enabled } + public static function provideResolutionOrders(): \Generator + { + $header = Translator::RESOLUTION_ACCEPT_LANGUAGE; + $query = Translator::RESOLUTION_QUERY_PARAM; + + yield 'header-first order prefers the header' => [[$header, $query], 'hr', 'fr', 'fr']; + yield 'header-first order falls through to the query param without a header' => [[$header, $query], 'hr', null, 'hr']; + yield 'header-first order pins the default on a non-enabled header locale' => [[$header, $query], 'hr', 'es', 'en']; + yield 'query-param-only ignores the header' => [[$query], null, 'fr', 'en']; + yield 'query-param-only still resolves the query param' => [[$query], 'hr', 'fr', 'hr']; + yield 'header-only ignores the query param' => [[$header], 'hr', 'fr', 'fr']; + yield 'empty resolution always yields the default locale' => [[], 'hr', 'fr', 'en']; + } + public function provideLocalesWithAcceptLanguage(): \Generator { yield ['en', 'fr', 'en'];