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