diff --git a/docs/book/api/translations.rst b/docs/book/api/translations.rst index 1b8e0c14fc..682bf8044d 100644 --- a/docs/book/api/translations.rst +++ b/docs/book/api/translations.rst @@ -2,8 +2,13 @@ Translations ============ In the shop part of our API translatable entities are translated at the server-side and users get an already translated field in the response. -By default it will be provided in default locale configured on the channel, different locale must be configured in the header (for example `accept-language: de_DE`) +By default it will be provided in default locale configured on the channel, different locale must be configured in the header (e.g. `Accept-Language: de-DE`) .. warning:: Remember that the chosen locale has to be enabled in the channel! + +.. note:: + + Though Sylius' locale code format (e.g. `de_DE`) will also be accepted in `Accept-Language` header, it's not valid value according to HTTP specification. + Also, it silently changes CORS behavior - results with CORS restrictions because the "_" character is not considered safe for this header type (https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header). diff --git a/docs/book/orders/cart-flow.rst b/docs/book/orders/cart-flow.rst index 5201b33e52..d2024b9a6f 100644 --- a/docs/book/orders/cart-flow.rst +++ b/docs/book/orders/cart-flow.rst @@ -47,7 +47,7 @@ Third scenario:: Then the cart should be empty .. note:: - The cart mentioned in the last scenario will we available once you log in again. + The cart mentioned in the last scenario will be available once you log in again. Learn more ---------- diff --git a/features/checkout/picking_up_the_cart_with_the_locale_other_than_the_default.feature b/features/checkout/picking_up_the_cart_with_the_locale_other_than_the_default.feature index 860e44badd..a1dbe0b743 100644 --- a/features/checkout/picking_up_the_cart_with_the_locale_other_than_the_default.feature +++ b/features/checkout/picking_up_the_cart_with_the_locale_other_than_the_default.feature @@ -18,7 +18,7 @@ Feature: Picking up the cart with the locale other than the default @api Scenario: Picking up the cart without specified locale - When I pick up cart + When I pick up cart without specifying locale And I check details of my cart Then my cart's locale should be "French (France)" diff --git a/src/Sylius/Behat/Context/Api/Shop/CartContext.php b/src/Sylius/Behat/Context/Api/Shop/CartContext.php index 13037db569..a7ff1cfd7e 100644 --- a/src/Sylius/Behat/Context/Api/Shop/CartContext.php +++ b/src/Sylius/Behat/Context/Api/Shop/CartContext.php @@ -226,6 +226,7 @@ final class CartContext implements Context /** * @When I pick up (my )cart (again) * @When I pick up cart in the :localeCode locale + * @When I pick up cart without specifying locale * @When the visitor picks up the cart */ public function iPickUpMyCart(?string $localeCode = null): void @@ -812,8 +813,9 @@ final class CartContext implements Context $request = $this->requestFactory->custom( sprintf('%s/shop/orders', $this->apiUrlPrefix), HttpRequest::METHOD_POST, - $localeCode ? ['HTTP_ACCEPT_LANGUAGE' => $localeCode] : [], + ['HTTP_ACCEPT_LANGUAGE' => $localeCode ?? ''], ); + $this->shopClient->executeCustomRequest($request); $tokenValue = $this->responseChecker->getValue($this->shopClient->getLastResponse(), 'tokenValue'); diff --git a/src/Sylius/Bundle/LocaleBundle/Context/RequestHeaderBasedLocaleContext.php b/src/Sylius/Bundle/LocaleBundle/Context/RequestHeaderBasedLocaleContext.php index eb7060af57..3b4a7d8803 100644 --- a/src/Sylius/Bundle/LocaleBundle/Context/RequestHeaderBasedLocaleContext.php +++ b/src/Sylius/Bundle/LocaleBundle/Context/RequestHeaderBasedLocaleContext.php @@ -18,8 +18,18 @@ use Sylius\Component\Locale\Context\LocaleNotFoundException; use Sylius\Component\Locale\Provider\LocaleProviderInterface; use Symfony\Component\HttpFoundation\RequestStack; +/** + * Locale context implementation based on Symfony Request's language negotiation (RFC 4647 based). + * + * @see Request::getPreferredLanguage() + */ final class RequestHeaderBasedLocaleContext implements LocaleContextInterface { + private const NO_CODE_VALID_STUB = 'NO_CODE_VALID_STUB'; + + /** @var array $availableLocalesCodes */ + private array $availableLocalesCodes = []; + public function __construct(private RequestStack $requestStack, private LocaleProviderInterface $localeProvider) { } @@ -31,16 +41,25 @@ final class RequestHeaderBasedLocaleContext implements LocaleContextInterface throw new LocaleNotFoundException('No main request available.'); } - $localeCode = $request->headers->get('Accept-Language'); - if (null === $localeCode) { - throw new LocaleNotFoundException('No locale is set on the master request\'s headers.'); + if ([] === $this->availableLocalesCodes) { + $this->availableLocalesCodes = array_unique(array_merge( + [$this->localeProvider->getDefaultLocaleCode()], + $this->localeProvider->getAvailableLocalesCodes() + )); } - $availableLocalesCodes = $this->localeProvider->getAvailableLocalesCodes(); - if (!in_array($localeCode, $availableLocalesCodes, true)) { - throw LocaleNotFoundException::notAvailable($localeCode, $availableLocalesCodes); + // Request::getPreferredLanguage() returns first available locale code if none matches. To allow detection of + // this unwanted behavior, we will prepend special locale code to the list of available locale codes. + $prependedAvailableLocalesCodes = array_merge([self::NO_CODE_VALID_STUB], $this->availableLocalesCodes); + + $bestLocaleCode = $request->getPreferredLanguage($prependedAvailableLocalesCodes); + if (self::NO_CODE_VALID_STUB === $bestLocaleCode) { + throw new LocaleNotFoundException(sprintf( + 'None of the available locales is acceptable: "%s".', + implode('", "', $this->availableLocalesCodes), + )); } - return $localeCode; + return $bestLocaleCode; } } diff --git a/src/Sylius/Bundle/LocaleBundle/spec/Context/RequestHeaderBasedLocaleContextSpec.php b/src/Sylius/Bundle/LocaleBundle/spec/Context/RequestHeaderBasedLocaleContextSpec.php index 85a99401a2..9bfdbf3425 100644 --- a/src/Sylius/Bundle/LocaleBundle/spec/Context/RequestHeaderBasedLocaleContextSpec.php +++ b/src/Sylius/Bundle/LocaleBundle/spec/Context/RequestHeaderBasedLocaleContextSpec.php @@ -17,7 +17,6 @@ use PhpSpec\ObjectBehavior; use Sylius\Component\Locale\Context\LocaleContextInterface; use Sylius\Component\Locale\Context\LocaleNotFoundException; use Sylius\Component\Locale\Provider\LocaleProviderInterface; -use Symfony\Component\HttpFoundation\HeaderBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -40,42 +39,78 @@ final class RequestHeaderBasedLocaleContextSpec extends ObjectBehavior $this->shouldThrow(LocaleNotFoundException::class)->during('getLocaleCode'); } - function it_throws_locale_not_found_exception_if_main_request_does_not_have_accept_language_in_header( - RequestStack $requestStack, - Request $request, - ): void { - $requestStack->getMainRequest()->willReturn($request); - - $request->headers = new HeaderBag(); - - $this->shouldThrow(LocaleNotFoundException::class)->during('getLocaleCode'); - } - - function it_throws_locale_not_found_exception_if_main_request_locale_code_is_not_among_available_ones( + function it_throws_locale_not_found_exception_if_locale_from_main_request_preferred_language_cannot_be_resolved( RequestStack $requestStack, LocaleProviderInterface $localeProvider, - Request $request, ): void { + $request = new Request(); + $request->headers->set('Accept-Language', 'fr_FR'); + $requestStack->getMainRequest()->willReturn($request); - $request->headers = new HeaderBag(['ACCEPT_LANGUAGE' => 'en_US']); - + $localeProvider->getDefaultLocaleCode()->willReturn('pl_PL'); $localeProvider->getAvailableLocalesCodes()->willReturn(['pl_PL', 'de_DE']); $this->shouldThrow(LocaleNotFoundException::class)->during('getLocaleCode'); } - function it_returns_main_request_locale_code( + function it_resolves_locale_from_main_request_preferred_language_in_locale_syntax( RequestStack $requestStack, LocaleProviderInterface $localeProvider, - Request $request, ): void { + $request = new Request(); + $request->headers->set('Accept-Language', 'de_DE'); + $requestStack->getMainRequest()->willReturn($request); - $request->headers = new HeaderBag(['Accept-Language' => 'pl_PL']); - + $localeProvider->getDefaultLocaleCode()->willReturn('pl_PL'); $localeProvider->getAvailableLocalesCodes()->willReturn(['pl_PL', 'de_DE']); - $this->getLocaleCode()->shouldReturn('pl_PL'); + $this->getLocaleCode()->shouldReturn('de_DE'); + } + + function it_resolves_locale_from_main_request_preferred_language_in_mixed_cased_language_syntax( + RequestStack $requestStack, + LocaleProviderInterface $localeProvider, + ): void { + $request = new Request(); + $request->headers->set('Accept-Language', 'dE-De'); + + $requestStack->getMainRequest()->willReturn($request); + + $localeProvider->getDefaultLocaleCode()->willReturn('pl_PL'); + $localeProvider->getAvailableLocalesCodes()->willReturn(['pl_PL', 'de_DE']); + + $this->getLocaleCode()->shouldReturn('de_DE'); + } + + function it_resolves_locale_from_main_request_preferred_language_in_upper_cased_language_syntax( + RequestStack $requestStack, + LocaleProviderInterface $localeProvider, + ): void { + $request = new Request(); + $request->headers->set('Accept-Language', 'DE-DE'); + + $requestStack->getMainRequest()->willReturn($request); + + $localeProvider->getDefaultLocaleCode()->willReturn('pl_PL'); + $localeProvider->getAvailableLocalesCodes()->willReturn(['pl_PL', 'de_DE']); + + $this->getLocaleCode()->shouldReturn('de_DE'); + } + + function it_resolves_locale_from_main_request_preferred_language_in_lower_cased_language_syntax( + RequestStack $requestStack, + LocaleProviderInterface $localeProvider, + ): void { + $request = new Request(); + $request->headers->set('Accept-Language', 'de-de'); + + $requestStack->getMainRequest()->willReturn($request); + + $localeProvider->getDefaultLocaleCode()->willReturn('pl_PL'); + $localeProvider->getAvailableLocalesCodes()->willReturn(['pl_PL', 'de_DE']); + + $this->getLocaleCode()->shouldReturn('de_DE'); } } diff --git a/src/Sylius/Bundle/PayumBundle/Tests/DependencyInjection/SyliusPayumExtensionTest.php b/src/Sylius/Bundle/PayumBundle/Tests/DependencyInjection/SyliusPayumExtensionTest.php index 6ae9198fb1..c83a69d468 100644 --- a/src/Sylius/Bundle/PayumBundle/Tests/DependencyInjection/SyliusPayumExtensionTest.php +++ b/src/Sylius/Bundle/PayumBundle/Tests/DependencyInjection/SyliusPayumExtensionTest.php @@ -48,9 +48,9 @@ final class SyliusPayumExtensionTest extends AbstractExtensionTestCase 'gateway_config' => [ 'validation_groups' => [ 'paypal_express_checkout' => ['sylius', 'paypal'], - 'offline' => ['sylius'] - ] - ] + 'offline' => ['sylius'], + ], + ], ]); $this->assertContainerBuilderHasParameter('sylius.payum.gateway_config.validation_groups', ['paypal_express_checkout' => ['sylius', 'paypal'], 'offline' => ['sylius']]); diff --git a/tests/Api/Shop/ProductsTest.php b/tests/Api/Shop/ProductsTest.php index c6068bdcdf..23f2ac3ad9 100644 --- a/tests/Api/Shop/ProductsTest.php +++ b/tests/Api/Shop/ProductsTest.php @@ -55,8 +55,11 @@ final class ProductsTest extends JsonApiTestCase ); } - /** @test */ - public function it_returns_product_with_translations_in_locale_from_header(): void + /** + * @test + * @dataProvider getGermanLocales + */ + public function it_returns_product_with_translations_in_locale_from_header(string $germanLocale): void { $fixtures = $this->loadFixturesFromFile('product/product_with_many_locales.yaml'); @@ -68,7 +71,7 @@ final class ProductsTest extends JsonApiTestCase server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', - 'HTTP_ACCEPT_LANGUAGE' => 'de_DE', + 'HTTP_ACCEPT_LANGUAGE' => $germanLocale, ], ); @@ -133,15 +136,19 @@ final class ProductsTest extends JsonApiTestCase ); } - /** @test */ - public function it_returns_product_attributes_collection_with_translations_in_locale_from_header(): void - { + /** + * @test + * @dataProvider getPolishLocales + */ + public function it_returns_product_attributes_collection_with_translations_in_locale_from_header( + string $polishLocale, + ): void { $this->loadFixturesFromFiles(['channel.yaml', 'product/product_attribute.yaml']); $this->client->request( method: 'GET', uri: sprintf('/api/v2/shop/products/%s/attributes', 'MUG_SW'), - server: array_merge(self::CONTENT_TYPE_HEADER, ['HTTP_ACCEPT_LANGUAGE' => 'pl_PL']), + server: array_merge(self::CONTENT_TYPE_HEADER, ['HTTP_ACCEPT_LANGUAGE' => $polishLocale]), ); $this->assertResponse( @@ -164,4 +171,20 @@ final class ProductsTest extends JsonApiTestCase $this->assertCount(2, json_decode($this->client->getResponse()->getContent(), true)['hydra:member']); } + + public function getGermanLocales(): iterable + { + yield ['de_DE']; // Locale code syntax + yield ['de-DE']; // RFC 4647 and RFC 3066 + yield ['DE-DE']; // RFC 4647 and RFC 3066 + yield ['de-de']; // RFC 4647 and RFC 3066 + } + + public function getPolishLocales(): iterable + { + yield ['pl_PL']; // Locale code syntax + yield ['pl-PL']; // RFC 4647 and RFC 3066 + yield ['PL-PL']; // RFC 4647 and RFC 3066 + yield ['pl-pl']; // RFC 4647 and RFC 3066 + } }