diff --git a/src/Api/DataProvider/ProductCollectionDataProvider.php b/src/Api/DataProvider/ProductCollectionDataProvider.php
new file mode 100644
index 00000000..7228ab42
--- /dev/null
+++ b/src/Api/DataProvider/ProductCollectionDataProvider.php
@@ -0,0 +1,62 @@
+dataHandler->retrieveData($context);
+ $facets = $this->facetsResolver->resolve($data);
+ $products = $this->shopProductsFinder->find($data);
+
+ return [
+ 'items' => $products->jsonSerialize(),
+ 'facets' => $facets,
+ 'pagination' => [
+ 'current_page' => $products->getCurrentPage(),
+ 'has_previous_page' => $products->hasPreviousPage(),
+ 'has_next_page' => $products->hasNextPage(),
+ 'per_page' => $products->getMaxPerPage(),
+ 'total_items' => $products->getNbResults(),
+ 'total_pages' => $products->getNbPages(),
+ ],
+ ];
+ }
+
+ public function supports(
+ string $resourceClass,
+ string $operationName = null,
+ array $context = []
+ ): bool {
+ return self::SUPPORTED_OPERATION_TYPE === $operationName;
+ }
+}
diff --git a/src/Api/RequestDataHandler/RequestDataHandler.php b/src/Api/RequestDataHandler/RequestDataHandler.php
new file mode 100644
index 00000000..48408f99
--- /dev/null
+++ b/src/Api/RequestDataHandler/RequestDataHandler.php
@@ -0,0 +1,36 @@
+sortDataHandler->retrieveData($requestData),
+ $this->paginationDataHandler->retrieveData($requestData),
+ ['query' => $requestData['query'] ?? ''],
+ ['facets' => $requestData['facets'] ?? []],
+ );
+ }
+}
diff --git a/src/Api/RequestDataHandler/RequestDataHandlerInterface.php b/src/Api/RequestDataHandler/RequestDataHandlerInterface.php
new file mode 100644
index 00000000..84f052bf
--- /dev/null
+++ b/src/Api/RequestDataHandler/RequestDataHandlerInterface.php
@@ -0,0 +1,18 @@
+autoDiscoverRegistry->autoRegister();
+
+ $boolQuery = $this->queryBuilder->buildQuery($data);
+ $query = new Query($boolQuery);
+ $query->setSize(0);
+
+ foreach ($this->facetRegistry->getFacets() as $facetId => $facet) {
+ $query->addAggregation($facet->getAggregation()->setName($facetId));
+ }
+
+ $facets = $this->finder->findPaginated($query);
+ $adapter = $facets->getAdapter();
+ if (!$adapter instanceof FantaPaginatorAdapter) {
+ return [];
+ }
+
+ return $adapter->getAggregations();
+ }
+}
diff --git a/src/Api/Resolver/FacetsResolverInterface.php b/src/Api/Resolver/FacetsResolverInterface.php
new file mode 100644
index 00000000..2460cb2f
--- /dev/null
+++ b/src/Api/Resolver/FacetsResolverInterface.php
@@ -0,0 +1,18 @@
+
+
+
+
+ sylius
+
+
+ ASC
+
+
+
+
+ GET
+ /shop/products/search
+ false
+
+
+ shop:product:index
+ sylius:shop:product:index
+
+
+
+
+
+ query
+ query
+ false
+
+ string
+
+ Search query
+
+
+ limit
+ query
+ false
+
+ integer
+ 10
+
+ Number of items to return per page
+
+
+ page
+ query
+ false
+
+ integer
+ 1
+
+ Page number
+
+
+ order_by
+ query
+ false
+
+ string
+
+ sold_units
+ product_created_at
+ price
+
+
+ Field to order by (sold_units, product_created_at, price)
+
+
+ sort
+ query
+ false
+
+ string
+
+ asc
+ desc
+
+
+ Order direction (asc, desc)
+
+
+ facets
+ query
+ false
+ deepObject
+ true
+ Filter facets with dynamic keys. Example: facets[t_shirt_material][]=100%25_cotton&facets[t_shirt_brand][]=modern_wear
+
+
+
+
+ Successful response
+
+
+
+ object
+
+
+ array
+
+ object
+
+
+ array
+
+ string
+
+
+
+ string
+
+
+ integer
+
+
+ array
+
+ object
+
+
+ integer
+
+
+ string
+
+
+ string
+
+
+
+
+
+ integer
+
+
+ string
+
+
+ array
+
+ string
+
+
+
+ array
+
+ string
+
+
+
+ array
+
+ string
+
+
+
+ string
+ date-time
+
+
+ string
+ date-time
+
+
+ string
+
+
+ array
+
+ array
+
+
+
+ string
+
+
+ string
+
+
+ string
+
+
+
+
+
+ object
+
+ object
+
+
+ integer
+
+
+ integer
+
+
+ array
+
+ object
+
+
+ string
+
+
+ integer
+
+
+
+
+
+
+
+
+ object
+
+
+ integer
+
+
+ boolean
+
+
+ boolean
+
+
+ integer
+
+
+ integer
+
+
+ integer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/api/data_provider.xml b/src/Resources/config/services/api/data_provider.xml
new file mode 100644
index 00000000..66702d1e
--- /dev/null
+++ b/src/Resources/config/services/api/data_provider.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/api/request_data_handler.xml b/src/Resources/config/services/api/request_data_handler.xml
new file mode 100644
index 00000000..a6b35a3a
--- /dev/null
+++ b/src/Resources/config/services/api/request_data_handler.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/api/resolver.xml b/src/Resources/config/services/api/resolver.xml
new file mode 100644
index 00000000..8b977591
--- /dev/null
+++ b/src/Resources/config/services/api/resolver.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Application/config/packages/_sylius.yaml b/tests/Application/config/packages/_sylius.yaml
index 7532b01a..18acdd44 100644
--- a/tests/Application/config/packages/_sylius.yaml
+++ b/tests/Application/config/packages/_sylius.yaml
@@ -13,3 +13,6 @@ parameters:
sylius_shop:
product_grid:
include_all_descendants: true
+
+sylius_api:
+ enabled: true
diff --git a/tests/PHPUnit/Integration/Api/DataFixtures/ORM/test_it_finds_products_by_name.yaml b/tests/PHPUnit/Integration/Api/DataFixtures/ORM/test_it_finds_products_by_name.yaml
new file mode 100644
index 00000000..83f5307e
--- /dev/null
+++ b/tests/PHPUnit/Integration/Api/DataFixtures/ORM/test_it_finds_products_by_name.yaml
@@ -0,0 +1,46 @@
+Sylius\Component\Core\Model\Channel:
+ channel_web:
+ code: 'WEB'
+ name: 'Web Channel'
+ hostname: 'localhost'
+ description: 'Lorem ipsum'
+ baseCurrency: '@currency_usd'
+ defaultLocale: '@locale_en'
+ locales: ['@locale_en']
+ color: 'black'
+ enabled: true
+ taxCalculationStrategy: 'order_items_based'
+
+Sylius\Component\Currency\Model\Currency:
+ currency_usd:
+ code: 'USD'
+
+Sylius\Component\Locale\Model\Locale:
+ locale_en:
+ code: 'en_US'
+
+Sylius\Component\Core\Model\Product:
+ product_mug:
+ code: 'MUG'
+ channels: ['@channel_web']
+ currentLocale: 'en_US'
+ translations:
+ en_US: '@product_translation_mug'
+
+Sylius\Component\Core\Model\ProductTranslation:
+ product_translation_mug:
+ slug: 'mug'
+ locale: 'en_US'
+ name: 'Mug'
+ description: ''
+ translatable: '@product_mug'
+
+Sylius\Component\Core\Model\Taxon:
+ mugs:
+ code: "mugs"
+
+Sylius\Component\Core\Model\ProductTaxon:
+ productTaxon1:
+ product: "@product_mug"
+ taxon: "@mugs"
+ position: 0
diff --git a/tests/PHPUnit/Integration/Api/DataFixtures/ORM/test_it_finds_products_by_name_and_facets.yaml b/tests/PHPUnit/Integration/Api/DataFixtures/ORM/test_it_finds_products_by_name_and_facets.yaml
new file mode 100644
index 00000000..ef5b671d
--- /dev/null
+++ b/tests/PHPUnit/Integration/Api/DataFixtures/ORM/test_it_finds_products_by_name_and_facets.yaml
@@ -0,0 +1,79 @@
+Sylius\Component\Core\Model\Channel:
+ channel_web:
+ code: 'WEB'
+ name: 'Web Channel'
+ hostname: 'localhost'
+ description: 'Lorem ipsum'
+ baseCurrency: '@currency_usd'
+ defaultLocale: '@locale_en'
+ locales: ['@locale_en']
+ color: 'black'
+ enabled: true
+ taxCalculationStrategy: 'order_items_based'
+
+Sylius\Component\Currency\Model\Currency:
+ currency_usd:
+ code: 'USD'
+
+Sylius\Component\Locale\Model\Locale:
+ locale_en:
+ code: 'en_US'
+
+Sylius\Component\Core\Model\Product:
+ product_mug:
+ code: 'MUG'
+ channels: ['@channel_web']
+ currentLocale: 'en_US'
+ translations:
+ en_US: '@product_translation_mug'
+ product_mug2:
+ code: 'MUG2'
+ channels: ['@channel_web']
+ currentLocale: 'en_US'
+ translations:
+ en_US: '@product_translation_mug2'
+
+Sylius\Component\Core\Model\ProductTranslation:
+ product_translation_mug:
+ slug: 'mug'
+ locale: 'en_US'
+ name: 'Mug'
+ description: ''
+ translatable: '@product_mug'
+ product_translation_mug2:
+ slug: 'mug-2'
+ locale: 'en_US'
+ name: 'Mug 2'
+ description: ''
+ translatable: '@product_mug2'
+
+Sylius\Component\Product\Model\ProductAttributeTranslation:
+ attributeTranslation1:
+ locale: en_US
+ name: "Product color"
+ translatable: "@product_attribute_color"
+
+Sylius\Component\Product\Model\ProductAttribute:
+ product_attribute_color:
+ code: 'color'
+ type: 'text'
+ storage_type: 'text'
+ position: 1
+ translatable: 1
+
+Sylius\Component\Product\Model\ProductAttributeValue:
+ product_attribute_value_color:
+ product: '@product_mug'
+ attribute: '@product_attribute_color'
+ localeCode: 'en_US'
+ value: 'red'
+
+Sylius\Component\Core\Model\Taxon:
+ mugs:
+ code: "mugs"
+
+Sylius\Component\Core\Model\ProductTaxon:
+ productTaxon1:
+ product: "@product_mug"
+ taxon: "@mugs"
+ position: 0
diff --git a/tests/PHPUnit/Integration/Api/DataFixtures/ORM/test_it_finds_products_by_name_and_multiple_facets.yaml b/tests/PHPUnit/Integration/Api/DataFixtures/ORM/test_it_finds_products_by_name_and_multiple_facets.yaml
new file mode 100644
index 00000000..4473d549
--- /dev/null
+++ b/tests/PHPUnit/Integration/Api/DataFixtures/ORM/test_it_finds_products_by_name_and_multiple_facets.yaml
@@ -0,0 +1,116 @@
+Sylius\Component\Core\Model\Channel:
+ channel_web:
+ code: 'WEB'
+ name: 'Web Channel'
+ hostname: 'localhost'
+ description: 'Lorem ipsum'
+ baseCurrency: '@currency_usd'
+ defaultLocale: '@locale_en'
+ locales: ['@locale_en']
+ color: 'black'
+ enabled: true
+ taxCalculationStrategy: 'order_items_based'
+
+Sylius\Component\Currency\Model\Currency:
+ currency_usd:
+ code: 'USD'
+
+Sylius\Component\Locale\Model\Locale:
+ locale_en:
+ code: 'en_US'
+
+Sylius\Component\Core\Model\Product:
+ product_mug:
+ code: 'MUG'
+ channels: ['@channel_web']
+ currentLocale: 'en_US'
+ translations:
+ en_US: '@product_translation_mug'
+ product_mug2:
+ code: 'MUG2'
+ channels: ['@channel_web']
+ currentLocale: 'en_US'
+ translations:
+ en_US: '@product_translation_mug2'
+ product_mug3:
+ code: 'MUG3'
+ channels: ['@channel_web']
+ currentLocale: 'en_US'
+ translations:
+ en_US: '@product_translation_mug3'
+
+Sylius\Component\Core\Model\ProductTranslation:
+ product_translation_mug:
+ slug: 'mug'
+ locale: 'en_US'
+ name: 'Mug'
+ description: ''
+ translatable: '@product_mug'
+ product_translation_mug2:
+ slug: 'mug-2'
+ locale: 'en_US'
+ name: 'Mug 2'
+ description: ''
+ translatable: '@product_mug2'
+ product_translation_mug3:
+ slug: 'mug-3'
+ locale: 'en_US'
+ name: 'Mug 3'
+ description: ''
+ translatable: '@product_mug3'
+
+Sylius\Component\Product\Model\ProductAttributeTranslation:
+ attributeTranslation1:
+ locale: en_US
+ name: "Product color"
+ translatable: "@product_attribute_color"
+ attributeTranslation2:
+ locale: en_US
+ name: "Product material"
+ translatable: "@product_attribute_material"
+
+Sylius\Component\Product\Model\ProductAttribute:
+ product_attribute_color:
+ code: 'color'
+ type: 'text'
+ storage_type: 'text'
+ position: 1
+ translatable: 1
+ product_attribute_material:
+ code: 'material'
+ type: 'text'
+ storage_type: 'text'
+ position: 1
+ translatable: 1
+
+Sylius\Component\Product\Model\ProductAttributeValue:
+ product_attribute_value_color_1:
+ product: '@product_mug'
+ attribute: '@product_attribute_color'
+ localeCode: 'en_US'
+ value: 'red'
+ product_attribute_value_color_2:
+ product: '@product_mug2'
+ attribute: '@product_attribute_color'
+ localeCode: 'en_US'
+ value: 'red'
+ product_attribute_value_material_1:
+ product: '@product_mug'
+ attribute: '@product_attribute_material'
+ localeCode: 'en_US'
+ value: 'ceramic'
+ product_attribute_value_material_2:
+ product: '@product_mug2'
+ attribute: '@product_attribute_material'
+ localeCode: 'en_US'
+ value: 'ceramic'
+
+Sylius\Component\Core\Model\Taxon:
+ mugs:
+ code: "mugs"
+
+Sylius\Component\Core\Model\ProductTaxon:
+ productTaxon1:
+ product: "@product_mug"
+ taxon: "@mugs"
+ position: 0
diff --git a/tests/PHPUnit/Integration/Api/ProductListingTest.php b/tests/PHPUnit/Integration/Api/ProductListingTest.php
new file mode 100644
index 00000000..77ecebf0
--- /dev/null
+++ b/tests/PHPUnit/Integration/Api/ProductListingTest.php
@@ -0,0 +1,83 @@
+dataFixturesPath = __DIR__ . \DIRECTORY_SEPARATOR . 'DataFixtures' . \DIRECTORY_SEPARATOR . 'ORM';
+ $this->expectedResponsesPath = __DIR__ . \DIRECTORY_SEPARATOR . 'Responses' . \DIRECTORY_SEPARATOR . 'Expected';
+ }
+
+ public function test_it_finds_products_by_name(): void
+ {
+ $this->loadFixturesFromFiles(['test_it_finds_products_by_name.yaml']);
+ $this->populateElasticsearch();
+
+ $this->client->request(
+ 'GET',
+ '/api/v2/shop/products/search?query=mug'
+ );
+
+ $response = $this->client->getResponse();
+ $this->assertResponse($response, 'test_it_finds_products_by_name', Response::HTTP_OK);
+ }
+
+ public function test_it_finds_products_by_name_and_facets(): void
+ {
+ $this->loadFixturesFromFiles(['test_it_finds_products_by_name_and_facets.yaml']);
+ $this->populateElasticsearch();
+
+ $this->client->request(
+ 'GET',
+ '/api/v2/shop/products/search?query=mug&facets[color][]=red'
+ );
+
+ $response = $this->client->getResponse();
+ $this->assertResponse($response, 'test_it_finds_products_by_name_and_facets', Response::HTTP_OK);
+ }
+
+ public function test_it_finds_products_by_name_and_multiple_facets(): void
+ {
+ $this->loadFixturesFromFiles(['test_it_finds_products_by_name_and_multiple_facets.yaml']);
+ $this->populateElasticsearch();
+
+ $this->client->request(
+ 'GET',
+ '/api/v2/shop/products/search?query=mug&facets[color][]=red&facets[material][]=ceramic'
+ );
+
+ $response = $this->client->getResponse();
+ $this->assertResponse($response, 'test_it_finds_products_by_name_and_multiple_facets', Response::HTTP_OK);
+ }
+
+ private function populateElasticsearch(): void
+ {
+ $process = new Process(['tests/Application/bin/console', 'fos:elastica:populate']);
+ $process->run();
+
+ if (!$process->isSuccessful()) {
+ throw new \Exception('Failed to populate Elasticsearch: ' . $process->getErrorOutput());
+ }
+ }
+}
diff --git a/tests/PHPUnit/Integration/Api/Responses/Expected/test_it_finds_products_by_name.json b/tests/PHPUnit/Integration/Api/Responses/Expected/test_it_finds_products_by_name.json
new file mode 100644
index 00000000..6e35a3c7
--- /dev/null
+++ b/tests/PHPUnit/Integration/Api/Responses/Expected/test_it_finds_products_by_name.json
@@ -0,0 +1,47 @@
+{
+ "items": [
+ {
+ "productTaxons": [
+ "/api/v2/shop/product-taxons/@string@"
+ ],
+ "mainTaxon": null,
+ "averageRating": 0,
+ "images": [],
+ "id": "@integer@",
+ "code": "MUG",
+ "variants": [],
+ "options": [],
+ "associations": [],
+ "createdAt": "@string@",
+ "updatedAt": "@string@",
+ "shortDescription": null,
+ "reviews": [],
+ "name": "Mug",
+ "description": "@string@",
+ "slug": "mug"
+ }
+ ],
+ "facets": {
+ "price": {
+ "buckets": []
+ },
+ "taxon": {
+ "doc_count_error_upper_bound": 0,
+ "sum_other_doc_count": 0,
+ "buckets": [
+ {
+ "key": "mugs",
+ "doc_count": 1
+ }
+ ]
+ }
+ },
+ "pagination": {
+ "current_page": 1,
+ "has_previous_page": false,
+ "has_next_page": false,
+ "per_page": 9,
+ "total_items": 1,
+ "total_pages": 1
+ }
+}
diff --git a/tests/PHPUnit/Integration/Api/Responses/Expected/test_it_finds_products_by_name_and_facets.json b/tests/PHPUnit/Integration/Api/Responses/Expected/test_it_finds_products_by_name_and_facets.json
new file mode 100644
index 00000000..dcf7c6f6
--- /dev/null
+++ b/tests/PHPUnit/Integration/Api/Responses/Expected/test_it_finds_products_by_name_and_facets.json
@@ -0,0 +1,57 @@
+{
+ "items": [
+ {
+ "productTaxons": [
+ "/api/v2/shop/product-taxons/@string@"
+ ],
+ "mainTaxon": null,
+ "averageRating": 0,
+ "images": [],
+ "id": "@integer@",
+ "code": "MUG",
+ "variants": [],
+ "options": [],
+ "associations": [],
+ "createdAt": "@string@",
+ "updatedAt": "@string@",
+ "shortDescription": null,
+ "reviews": [],
+ "name": "Mug",
+ "description": "@string@",
+ "slug": "mug"
+ }
+ ],
+ "facets": {
+ "color": {
+ "doc_count_error_upper_bound": 0,
+ "sum_other_doc_count": 0,
+ "buckets": [
+ {
+ "key": "red",
+ "doc_count": 1
+ }
+ ]
+ },
+ "price": {
+ "buckets": []
+ },
+ "taxon": {
+ "doc_count_error_upper_bound": 0,
+ "sum_other_doc_count": 0,
+ "buckets": [
+ {
+ "key": "mugs",
+ "doc_count": 1
+ }
+ ]
+ }
+ },
+ "pagination": {
+ "current_page": 1,
+ "has_previous_page": false,
+ "has_next_page": false,
+ "per_page": 9,
+ "total_items": 1,
+ "total_pages": 1
+ }
+}
diff --git a/tests/PHPUnit/Integration/Api/Responses/Expected/test_it_finds_products_by_name_and_multiple_facets.json b/tests/PHPUnit/Integration/Api/Responses/Expected/test_it_finds_products_by_name_and_multiple_facets.json
new file mode 100644
index 00000000..6cf156a0
--- /dev/null
+++ b/tests/PHPUnit/Integration/Api/Responses/Expected/test_it_finds_products_by_name_and_multiple_facets.json
@@ -0,0 +1,85 @@
+{
+ "items": [
+ {
+ "productTaxons": [
+ "/api/v2/shop/product-taxons/@string@"
+ ],
+ "mainTaxon": null,
+ "averageRating": 0,
+ "images": [],
+ "id": "@integer@",
+ "code": "MUG",
+ "variants": [],
+ "options": [],
+ "associations": [],
+ "createdAt": "@string@",
+ "updatedAt": "@string@",
+ "shortDescription": null,
+ "reviews": [],
+ "name": "Mug",
+ "description": "@string@",
+ "slug": "mug"
+ },
+ {
+ "productTaxons": [],
+ "mainTaxon": null,
+ "averageRating": 0,
+ "images": [],
+ "id": "@integer@",
+ "code": "MUG2",
+ "variants": [],
+ "options": [],
+ "associations": [],
+ "createdAt": "@string@",
+ "updatedAt": "@string@",
+ "shortDescription": null,
+ "reviews": [],
+ "name": "Mug 2",
+ "description": "@string@",
+ "slug": "mug-2"
+ }
+ ],
+ "facets": {
+ "material": {
+ "doc_count_error_upper_bound": 0,
+ "sum_other_doc_count": 0,
+ "buckets": [
+ {
+ "key": "ceramic",
+ "doc_count": 2
+ }
+ ]
+ },
+ "color": {
+ "doc_count_error_upper_bound": 0,
+ "sum_other_doc_count": 0,
+ "buckets": [
+ {
+ "key": "red",
+ "doc_count": 2
+ }
+ ]
+ },
+ "price": {
+ "buckets": []
+ },
+ "taxon": {
+ "doc_count_error_upper_bound": 0,
+ "sum_other_doc_count": 0,
+ "buckets": [
+ {
+ "key": "mugs",
+ "doc_count": 1
+ }
+ ]
+ }
+ },
+ "pagination": {
+ "current_page": 1,
+ "has_previous_page": false,
+ "has_next_page": false,
+ "per_page": 9,
+ "total_items": 2,
+ "total_pages": 1
+ }
+}
diff --git a/tests/PHPUnit/Integration/IntegrationTestCase.php b/tests/PHPUnit/Integration/IntegrationTestCase.php
index 192808b1..d13f8b05 100644
--- a/tests/PHPUnit/Integration/IntegrationTestCase.php
+++ b/tests/PHPUnit/Integration/IntegrationTestCase.php
@@ -21,7 +21,8 @@ public function __construct(
) {
parent::__construct($name, $data, $dataName);
- $this->dataFixturesPath = __DIR__ . '/DataFixtures/ORM';
+ $this->dataFixturesPath = __DIR__ . \DIRECTORY_SEPARATOR . 'DataFixtures' . \DIRECTORY_SEPARATOR . 'ORM';
+ $this->expectedResponsesPath = __DIR__ . \DIRECTORY_SEPARATOR . 'Responses' . \DIRECTORY_SEPARATOR . 'Expected';
}
protected function setUp(): void