diff --git a/features/product/viewing_products/accessing_product_page_via_permalink.feature b/features/product/viewing_products/accessing_product_page_via_permalink.feature
index eb79f47283d..e3b75c61d63 100644
--- a/features/product/viewing_products/accessing_product_page_via_permalink.feature
+++ b/features/product/viewing_products/accessing_product_page_via_permalink.feature
@@ -6,10 +6,15 @@ Feature: Viewing a product details using permalink
Background:
Given the store operates on a single channel in "United States"
+ And the store has a product "T-shirt banana"
- @ui
+ @ui @no-api
Scenario: Accessing a detailed product page using permalink
- Given the store has a product "T-shirt banana"
When I open page "en_US/products/t-shirt-banana"
Then I should be on "T-shirt banana" product detailed page
And I should see the product name "T-shirt banana"
+
+ @api
+ Scenario: Viewing a detailed page with product's slug
+ When I view product "T-shirt banana" using slug
+ Then I should be redirected to "T-shirt banana" product
diff --git a/src/Sylius/Behat/Context/Api/Shop/ProductContext.php b/src/Sylius/Behat/Context/Api/Shop/ProductContext.php
index 2ff710bb97e..6568201faa9 100644
--- a/src/Sylius/Behat/Context/Api/Shop/ProductContext.php
+++ b/src/Sylius/Behat/Context/Api/Shop/ProductContext.php
@@ -76,6 +76,26 @@ public function iOpenProductPage(ProductInterface $product): void
$this->sharedStorage->set('product_variant', $productVariant);
}
+ /**
+ * @When I view product :product using slug
+ */
+ public function iViewProductUsingSlug(ProductInterface $product): void
+ {
+ $this->client->showByIri('/api/v2/shop/products-by-slug/'.$product->getSlug());
+
+ $this->sharedStorage->set('product', $product);
+ }
+
+ /**
+ * @Then I should be redirected to :product product
+ */
+ public function iShouldBeRedirectedToProduct(ProductInterface $product): void
+ {
+ $response = $this->client->getLastResponse();
+
+ Assert::eq($response->headers->get('Location'), '/api/v2/shop/products/'.$product->getCode());
+ }
+
/**
* @When I browse products from taxon :taxon
*/
diff --git a/src/Sylius/Bundle/ApiBundle/Controller/GetProductBySlugAction.php b/src/Sylius/Bundle/ApiBundle/Controller/GetProductBySlugAction.php
new file mode 100644
index 00000000000..54e0207f6b5
--- /dev/null
+++ b/src/Sylius/Bundle/ApiBundle/Controller/GetProductBySlugAction.php
@@ -0,0 +1,69 @@
+channelContext = $channelContext;
+ $this->localeContext = $localeContext;
+ $this->productRepository = $productRepository;
+ $this->iriConverter = $iriConverter;
+ $this->requestStack = $requestStack;
+ }
+
+ public function __invoke(string $slug): RedirectResponse
+ {
+ $channel = $this->channelContext->getChannel();
+ $locale = $this->localeContext->getLocaleCode();
+
+ $product = $this->productRepository->findOneByChannelAndSlug($channel, $locale, $slug);
+
+ if (null === $product) {
+ throw new NotFoundHttpException('Not Found');
+ }
+
+ $iri = $this->iriConverter->getIriFromItem($product);
+
+ $request = $this->requestStack->getCurrentRequest();
+
+ $requestQuery = $request->getQueryString();
+ if (null !== $requestQuery) {
+ $iri .= sprintf('?%s', $requestQuery);
+ }
+
+ return new RedirectResponse($iri, Response::HTTP_MOVED_PERMANENTLY);
+ }
+}
diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Product.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Product.xml
index 920f18fcbd7..bfba4c9bfa0 100644
--- a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Product.xml
+++ b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Product.xml
@@ -84,6 +84,29 @@
+
+ GET
+ /shop/products-by-slug/{slug}
+ Sylius\Bundle\ApiBundle\Controller\GetProductBySlugAction
+ false
+
+ Use slug to retrieve a product resource.
+
+
+ slug
+ path
+ true
+
+ string
+
+
+
+
+
+ shop:product:read
+
+
+
PUT
/admin/products/{code}
diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/services.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/services.xml
index 3e607913f28..79df47a2297 100644
--- a/src/Sylius/Bundle/ApiBundle/Resources/config/services.xml
+++ b/src/Sylius/Bundle/ApiBundle/Resources/config/services.xml
@@ -31,6 +31,14 @@
+
+
+
+
+
+
+
+
diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/services/swagger.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/services/swagger.xml
index ca1f865c2c9..238cc0ca09d 100644
--- a/src/Sylius/Bundle/ApiBundle/Resources/config/services/swagger.xml
+++ b/src/Sylius/Bundle/ApiBundle/Resources/config/services/swagger.xml
@@ -57,6 +57,15 @@
+
+
+
+
decoratedNormalizer = $decoratedNormalizer;
+ }
+
+ public function supportsNormalization($data, $format = null): bool
+ {
+ return $this->decoratedNormalizer->supportsNormalization($data, $format);
+ }
+
+ public function normalize($object, $format = null, array $context = [])
+ {
+ $docs = $this->decoratedNormalizer->normalize($object, $format, $context);
+
+ $params = $docs['paths'][self::PRODUCT_SLUG_PATH]['get']['parameters'];
+
+ foreach ($params as $index => $param) {
+ if ($param['name'] === 'code') {
+ unset($docs['paths'][self::PRODUCT_SLUG_PATH]['get']['parameters'][$index]);
+ }
+ }
+
+ return $docs;
+ }
+}
diff --git a/tests/Api/Shop/ProductsTest.php b/tests/Api/Shop/ProductsTest.php
new file mode 100644
index 00000000000..bab5cf325e7
--- /dev/null
+++ b/tests/Api/Shop/ProductsTest.php
@@ -0,0 +1,32 @@
+loadFixturesFromFile('product_variant_with_original_price.yaml');
+
+ $this->client->request('GET', '/api/v2/shop/products-by-slug/mug?paramName=paramValue', [], [], self::CONTENT_TYPE_HEADER);
+ $response = $this->client->getResponse();
+
+ $this->assertEquals('/api/v2/shop/products/MUG?paramName=paramValue', $response->headers->get(('Location')));
+ $this->assertResponseCode($response, Response::HTTP_MOVED_PERMANENTLY);
+ }
+}