diff --git a/app/code/Magento/Review/Model/Review/Config.php b/app/code/Magento/Review/Model/Review/Config.php
new file mode 100644
index 0000000000000..a3082503b1391
--- /dev/null
+++ b/app/code/Magento/Review/Model/Review/Config.php
@@ -0,0 +1,46 @@
+scopeConfig = $scopeConfig;
+ }
+
+ /**
+ * Check whether the reviews are enabled or not
+ *
+ * @return bool
+ */
+ public function isEnabled(): bool
+ {
+ return $this->scopeConfig->isSetFlag(
+ self::XML_PATH_REVIEW_ACTIVE,
+ ScopeInterface::SCOPE_STORES
+ );
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Mapper/ReviewDataMapper.php b/app/code/Magento/ReviewGraphQl/Mapper/ReviewDataMapper.php
new file mode 100644
index 0000000000000..6a06fbfc4102c
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Mapper/ReviewDataMapper.php
@@ -0,0 +1,36 @@
+ $review->getData('title'),
+ 'text' => $review->getData('detail'),
+ 'nickname' => $review->getData('nickname'),
+ 'created_at' => $review->getData('created_at'),
+ 'sku' => $review->getSku(),
+ 'model' => $review
+ ];
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/DataProvider/AggregatedReviewsDataProvider.php b/app/code/Magento/ReviewGraphQl/Model/DataProvider/AggregatedReviewsDataProvider.php
new file mode 100644
index 0000000000000..5412c670b4800
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/DataProvider/AggregatedReviewsDataProvider.php
@@ -0,0 +1,75 @@
+reviewDataMapper = $reviewDataMapper;
+ }
+
+ /**
+ * Get reviews result
+ *
+ * @param ProductCollection|ReviewCollection $reviewsCollection
+ *
+ * @return array
+ */
+ public function getData($reviewsCollection): array
+ {
+ if ($reviewsCollection->getPageSize()) {
+ $maxPages = ceil($reviewsCollection->getSize() / $reviewsCollection->getPageSize());
+ } else {
+ $maxPages = 0;
+ }
+
+ $currentPage = $reviewsCollection->getCurPage();
+ if ($reviewsCollection->getCurPage() > $maxPages && $reviewsCollection->getSize() > 0) {
+ $currentPage = new GraphQlInputException(
+ __(
+ 'currentPage value %1 specified is greater than the number of pages available.',
+ [$maxPages]
+ )
+ );
+ }
+
+ $items = [];
+ foreach ($reviewsCollection->getItems() as $item) {
+ $items[] = $this->reviewDataMapper->map($item);
+ }
+
+ return [
+ 'total_count' => $reviewsCollection->getSize(),
+ 'items' => $items,
+ 'page_info' => [
+ 'page_size' => $reviewsCollection->getPageSize(),
+ 'current_page' => $currentPage,
+ 'total_pages' => $maxPages
+ ]
+ ];
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/DataProvider/CustomerReviewsDataProvider.php b/app/code/Magento/ReviewGraphQl/Model/DataProvider/CustomerReviewsDataProvider.php
new file mode 100644
index 0000000000000..42adc8009c010
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/DataProvider/CustomerReviewsDataProvider.php
@@ -0,0 +1,60 @@
+collectionFactory = $collectionFactory;
+ }
+
+ /**
+ * Get customer reviews
+ *
+ * @param int $customerId
+ * @param int $currentPage
+ * @param int $pageSize
+ *
+ * @return ReviewsCollection
+ */
+ public function getData(int $customerId, int $currentPage, int $pageSize): ReviewsCollection
+ {
+ /** @var ReviewsCollection $reviewsCollection */
+ $reviewsCollection = $this->collectionFactory->create();
+ $reviewsCollection
+ ->addCustomerFilter($customerId)
+ ->setPageSize($pageSize)
+ ->setCurPage($currentPage)
+ ->setDateOrder();
+ $reviewsCollection->getSelect()->join(
+ ['cpe' => $reviewsCollection->getTable('catalog_product_entity')],
+ 'cpe.entity_id = main_table.entity_pk_value',
+ ['sku']
+ );
+ $reviewsCollection->addRateVotes();
+
+ return $reviewsCollection;
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/DataProvider/ProductReviewsDataProvider.php b/app/code/Magento/ReviewGraphQl/Model/DataProvider/ProductReviewsDataProvider.php
new file mode 100644
index 0000000000000..635605f9091ed
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/DataProvider/ProductReviewsDataProvider.php
@@ -0,0 +1,60 @@
+collectionFactory = $collectionFactory;
+ }
+
+ /**
+ * Get product reviews
+ *
+ * @param int $productId
+ * @param int $currentPage
+ * @param int $pageSize
+ *
+ * @return Collection
+ */
+ public function getData(int $productId, int $currentPage, int $pageSize): Collection
+ {
+ /** @var Collection $reviewsCollection */
+ $reviewsCollection = $this->collectionFactory->create()
+ ->addStatusFilter(Review::STATUS_APPROVED)
+ ->addEntityFilter(Review::ENTITY_PRODUCT_CODE, $productId)
+ ->setPageSize($pageSize)
+ ->setCurPage($currentPage)
+ ->setDateOrder();
+ $reviewsCollection->getSelect()->join(
+ ['cpe' => $reviewsCollection->getTable('catalog_product_entity')],
+ 'cpe.entity_id = main_table.entity_pk_value',
+ ['sku']
+ );
+ $reviewsCollection->addRateVotes();
+
+ return $reviewsCollection;
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/DataProvider/ReviewRatingsDataProvider.php b/app/code/Magento/ReviewGraphQl/Model/DataProvider/ReviewRatingsDataProvider.php
new file mode 100644
index 0000000000000..82e0f73b1c774
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/DataProvider/ReviewRatingsDataProvider.php
@@ -0,0 +1,56 @@
+voteCollectionFactory = $voteCollectionFactory;
+ }
+
+ /**
+ * Providing rating votes
+ *
+ * @param int $reviewId
+ *
+ * @return array
+ */
+ public function getData(int $reviewId): array
+ {
+ /** @var VoteCollection $ratingVotes */
+ $ratingVotes = $this->voteCollectionFactory->create();
+ $ratingVotes->setReviewFilter($reviewId);
+ $ratingVotes->addRatingInfo();
+
+ $data = [];
+
+ foreach ($ratingVotes->getItems() as $ratingVote) {
+ $data[] = [
+ 'name' => $ratingVote->getData('rating_code'),
+ 'value' => $ratingVote->getData('value')
+ ];
+ }
+
+ return $data;
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/CreateProductReview.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/CreateProductReview.php
new file mode 100644
index 0000000000000..9b0171c3b700a
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/CreateProductReview.php
@@ -0,0 +1,118 @@
+addReviewToProduct = $addReviewToProduct;
+ $this->reviewDataMapper = $reviewDataMapper;
+ $this->reviewHelper = $reviewHelper;
+ $this->reviewsConfig = $reviewsConfig;
+ }
+
+ /**
+ * Resolve product review ratings
+ *
+ * @param Field $field
+ * @param ContextInterface $context
+ * @param ResolveInfo $info
+ * @param array|null $value
+ * @param array|null $args
+ *
+ * @return array[]|Value|mixed
+ *
+ * @throws GraphQlAuthorizationException
+ * @throws GraphQlNoSuchEntityException
+ *
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function resolve(
+ Field $field,
+ $context,
+ ResolveInfo $info,
+ array $value = null,
+ array $args = null
+ ) {
+ if (false === $this->reviewsConfig->isEnabled()) {
+ throw new GraphQlAuthorizationException(__('Creating product reviews are not currently available.'));
+ }
+
+ $input = $args['input'];
+ $customerId = null;
+
+ if (false !== $context->getExtensionAttributes()->getIsCustomer()) {
+ $customerId = (int) $context->getUserId();
+ }
+
+ if (!$customerId && !$this->reviewHelper->getIsGuestAllowToWrite()) {
+ throw new GraphQlAuthorizationException(__('Guest customers aren\'t allowed to add product reviews.'));
+ }
+
+ $sku = $input['sku'];
+ $ratings = $input['ratings'];
+ $data = [
+ 'nickname' => $input['nickname'],
+ 'title' => $input['summary'],
+ 'detail' => $input['text'],
+ ];
+ /** @var StoreInterface $store */
+ $store = $context->getExtensionAttributes()->getStore();
+ $review = $this->addReviewToProduct->execute($data, $ratings, $sku, $customerId, (int) $store->getId());
+
+ return ['review' => $this->reviewDataMapper->map($review)];
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/Customer/Reviews.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/Customer/Reviews.php
new file mode 100644
index 0000000000000..8c0bca63f8efc
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/Customer/Reviews.php
@@ -0,0 +1,90 @@
+customerReviewsDataProvider = $customerReviewsDataProvider;
+ $this->aggregatedReviewsDataProvider = $aggregatedReviewsDataProvider;
+ }
+
+ /**
+ * Resolves the customer reviews
+ *
+ * @param Field $field
+ * @param ContextInterface $context
+ * @param ResolveInfo $info
+ * @param array|null $value
+ * @param array|null $args
+ *
+ * @return array|Value|mixed
+ *
+ * @throws GraphQlInputException
+ * @throws GraphQlAuthorizationException
+ *
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function resolve(
+ Field $field,
+ $context,
+ ResolveInfo $info,
+ array $value = null,
+ array $args = null
+ ) {
+ if (false === $context->getExtensionAttributes()->getIsCustomer()) {
+ throw new GraphQlAuthorizationException(__('The current customer isn\'t authorized.'));
+ }
+
+ if ($args['currentPage'] < 1) {
+ throw new GraphQlInputException(__('currentPage value must be greater than 0.'));
+ }
+
+ if ($args['pageSize'] < 1) {
+ throw new GraphQlInputException(__('pageSize value must be greater than 0.'));
+ }
+
+ $reviewsCollection = $this->customerReviewsDataProvider->getData(
+ (int) $context->getUserId(),
+ $args['currentPage'],
+ $args['pageSize']
+ );
+
+ return $this->aggregatedReviewsDataProvider->getData($reviewsCollection);
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/RatingSummary.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/RatingSummary.php
new file mode 100644
index 0000000000000..eed5034c59daa
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/RatingSummary.php
@@ -0,0 +1,92 @@
+summaryFactory = $summaryFactory;
+ $this->reviewsConfig = $reviewsConfig;
+ }
+
+ /**
+ * Resolves the product rating summary
+ *
+ * @param Field $field
+ * @param ContextInterface $context
+ * @param ResolveInfo $info
+ * @param array|null $value
+ * @param array|null $args
+ *
+ * @return float
+ *
+ * @throws GraphQlInputException
+ *
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function resolve(
+ Field $field,
+ $context,
+ ResolveInfo $info,
+ array $value = null,
+ array $args = null
+ ): float {
+ if (false === $this->reviewsConfig->isEnabled()) {
+ return 0;
+ }
+
+ if (!isset($value['model'])) {
+ throw new GraphQlInputException(__('Value must contain "model" property.'));
+ }
+
+ /** @var StoreInterface $store */
+ $store = $context->getExtensionAttributes()->getStore();
+
+ /** @var Product $product */
+ $product = $value['model'];
+
+ try {
+ $summary = $this->summaryFactory->create()->setStoreId($store->getId())->load($product->getId());
+
+ return floatval($summary->getData('rating_summary'));
+ } catch (Exception $e) {
+ throw new GraphQlInputException(__('Couldn\'t get the product rating summary.'));
+ }
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Review/AverageRating.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Review/AverageRating.php
new file mode 100644
index 0000000000000..2e0d428b47873
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Review/AverageRating.php
@@ -0,0 +1,78 @@
+ratingFactory = $ratingFactory;
+ }
+
+ /**
+ * Resolves review average rating
+ *
+ * @param Field $field
+ * @param ContextInterface $context
+ * @param ResolveInfo $info
+ * @param array|null $value
+ * @param array|null $args
+ *
+ * @return float|Value|mixed
+ *
+ * @throws GraphQlInputException
+ *
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function resolve(
+ Field $field,
+ $context,
+ ResolveInfo $info,
+ array $value = null,
+ array $args = null
+ ) {
+ if (!isset($value['model'])) {
+ throw new GraphQlInputException(__('Value must contain "model" property.'));
+ }
+
+ /** @var Review $review */
+ $review = $value['model'];
+ $summary = $this->ratingFactory->create()->getReviewSummary($review->getId());
+ $averageRating = $summary->getSum() ?: 0;
+
+ if ($averageRating > 0) {
+ $averageRating = (float) number_format(
+ (int) $summary->getSum() / (int) $summary->getCount(),
+ 2
+ );
+ }
+
+ return $averageRating;
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Review/RatingBreakdown.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Review/RatingBreakdown.php
new file mode 100644
index 0000000000000..a51bd0420dda9
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Review/RatingBreakdown.php
@@ -0,0 +1,69 @@
+reviewRatingsDataProvider = $reviewRatingsDataProvider;
+ }
+
+ /**
+ * Resolves the rating breakdown
+ *
+ * @param Field $field
+ * @param ContextInterface $context
+ * @param ResolveInfo $info
+ * @param array|null $value
+ * @param array|null $args
+ *
+ * @return array|Value|mixed
+ *
+ * @throws GraphQlInputException
+ *
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function resolve(
+ Field $field,
+ $context,
+ ResolveInfo $info,
+ array $value = null,
+ array $args = null
+ ) {
+ if (!isset($value['model'])) {
+ throw new GraphQlInputException(__('Value must contain "model" property.'));
+ }
+
+ /** @var Review $review */
+ $review = $value['model'];
+
+ return $this->reviewRatingsDataProvider->getData((int) $review->getId());
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/ReviewCount.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/ReviewCount.php
new file mode 100644
index 0000000000000..dfa62adf0266e
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/ReviewCount.php
@@ -0,0 +1,80 @@
+review = $review;
+ $this->reviewsConfig = $reviewsConfig;
+ }
+
+ /**
+ * Resolves the product total reviews
+ *
+ * @param Field $field
+ * @param ContextInterface $context
+ * @param ResolveInfo $info
+ * @param array|null $value
+ * @param array|null $args
+ *
+ * @return int|Value|mixed
+ *
+ * @throws GraphQlInputException
+ *
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function resolve(
+ Field $field,
+ $context,
+ ResolveInfo $info,
+ array $value = null,
+ array $args = null
+ ) {
+ if (false === $this->reviewsConfig->isEnabled()) {
+ return 0;
+ }
+
+ if (!isset($value['model'])) {
+ throw new GraphQlInputException(__('Value must contain "model" property.'));
+ }
+
+ /** @var Product $product */
+ $product = $value['model'];
+
+ return (int) $this->review->getTotalReviews($product->getId(), true);
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Reviews.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Reviews.php
new file mode 100644
index 0000000000000..72eea5e6b3bd2
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/Product/Reviews.php
@@ -0,0 +1,104 @@
+productReviewsDataProvider = $productReviewsDataProvider;
+ $this->aggregatedReviewsDataProvider = $aggregatedReviewsDataProvider;
+ $this->reviewsConfig = $reviewsConfig;
+ }
+
+ /**
+ * Resolves the product reviews
+ *
+ * @param Field $field
+ * @param ContextInterface $context
+ * @param ResolveInfo $info
+ * @param array|null $value
+ * @param array|null $args
+ *
+ * @return array|Value|mixed
+ *
+ * @throws GraphQlInputException
+ *
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function resolve(
+ Field $field,
+ $context,
+ ResolveInfo $info,
+ array $value = null,
+ array $args = null
+ ) {
+ if (false === $this->reviewsConfig->isEnabled()) {
+ return ['items' => []];
+ }
+
+ if (!isset($value['model'])) {
+ throw new GraphQlInputException(__('Value must contain "model" property.'));
+ }
+
+ if ($args['currentPage'] < 1) {
+ throw new GraphQlInputException(__('currentPage value must be greater than 0.'));
+ }
+
+ if ($args['pageSize'] < 1) {
+ throw new GraphQlInputException(__('pageSize value must be greater than 0.'));
+ }
+
+ /** @var Product $product */
+ $product = $value['model'];
+ $reviewsCollection = $this->productReviewsDataProvider->getData(
+ (int) $product->getId(),
+ $args['currentPage'],
+ $args['pageSize']
+ );
+
+ return $this->aggregatedReviewsDataProvider->getData($reviewsCollection);
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/ProductReviewRatingValueMetadata.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/ProductReviewRatingValueMetadata.php
new file mode 100644
index 0000000000000..e7e6574e7e7ae
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/ProductReviewRatingValueMetadata.php
@@ -0,0 +1,56 @@
+ $item->getData('value'), 'value_id' => base64_encode($item->getData('option_id'))];
+ }
+
+ return $data;
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/Resolver/ProductReviewRatingsMetadata.php b/app/code/Magento/ReviewGraphQl/Model/Resolver/ProductReviewRatingsMetadata.php
new file mode 100644
index 0000000000000..2cf536255baf7
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/Resolver/ProductReviewRatingsMetadata.php
@@ -0,0 +1,92 @@
+ratingCollectionFactory = $ratingCollectionFactory;
+ $this->reviewsConfig = $reviewsConfig;
+ }
+
+ /**
+ * Resolve product review ratings
+ *
+ * @param Field $field
+ * @param ContextInterface $context
+ * @param ResolveInfo $info
+ * @param array|null $value
+ * @param array|null $args
+ *
+ * @return array[]|Value|mixed
+ *
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function resolve(
+ Field $field,
+ $context,
+ ResolveInfo $info,
+ array $value = null,
+ array $args = null
+ ) {
+ if (false === $this->reviewsConfig->isEnabled()) {
+ return ['items' => []];
+ }
+
+ $items = [];
+ /** @var StoreInterface $store */
+ $store = $context->getExtensionAttributes()->getStore();
+
+ /** @var RatingCollection $ratingCollection */
+ $ratingCollection = $this->ratingCollectionFactory->create();
+ $ratingCollection->addEntityFilter(Review::ENTITY_PRODUCT_CODE)
+ ->setStoreFilter($store->getId())
+ ->setActiveFilter(true)
+ ->setPositionOrder()
+ ->addOptionToItems();
+
+ foreach ($ratingCollection->getItems() as $item) {
+ $items[] = [
+ 'id' => base64_encode($item->getData('rating_id')),
+ 'name' => $item->getData('rating_code'),
+ 'values' => $item->getData('options')
+ ];
+ }
+
+ return ['items' => $items];
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/Model/Review/AddReviewToProduct.php b/app/code/Magento/ReviewGraphQl/Model/Review/AddReviewToProduct.php
new file mode 100644
index 0000000000000..1b744e717a782
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/Model/Review/AddReviewToProduct.php
@@ -0,0 +1,159 @@
+productRepository = $productRepository;
+ $this->reviewFactory = $reviewFactory;
+ $this->ratingFactory = $ratingFactory;
+ $this->ratingOptionCollectionFactory = $ratingOptionCollectionFactory;
+ }
+
+ /**
+ * Add review to product
+ *
+ * @param array $data
+ * @param array $ratings
+ * @param string $sku
+ * @param int|null $customerId
+ * @param int $storeId
+ *
+ * @return Review
+ *
+ * @throws GraphQlNoSuchEntityException
+ */
+ public function execute(array $data, array $ratings, string $sku, ?int $customerId, int $storeId): Review
+ {
+ $review = $this->reviewFactory->create()->setData($data);
+ $review->unsetData('review_id');
+ $productId = $this->getProductIdBySku($sku);
+ $review->setEntityId($review->getEntityIdByCode(Review::ENTITY_PRODUCT_CODE))
+ ->setEntityPkValue($productId)
+ ->setStatusId(Review::STATUS_PENDING)
+ ->setCustomerId($customerId)
+ ->setStoreId($storeId)
+ ->setStores([$storeId])
+ ->save();
+ $this->addReviewRatingVotes($ratings, (int) $review->getId(), $customerId, $productId);
+ $review->aggregate();
+ $votesCollection = $this->getReviewRatingVotes((int) $review->getId(), $storeId);
+ $review->setData('rating_votes', $votesCollection);
+ $review->setData('sku', $sku);
+
+ return $review;
+ }
+
+ /**
+ * Get Product ID
+ *
+ * @param string $sku
+ *
+ * @return int|null
+ *
+ * @throws GraphQlNoSuchEntityException
+ */
+ private function getProductIdBySku(string $sku): ?int
+ {
+ try {
+ $product = $this->productRepository->get($sku, false, null, true);
+
+ return (int) $product->getId();
+ } catch (NoSuchEntityException $e) {
+ throw new GraphQlNoSuchEntityException(__('Could not find a product with SKU "%sku"', ['sku' => $sku]));
+ }
+ }
+
+ /**
+ * Add review rating votes
+ *
+ * @param array $ratings
+ * @param int $reviewId
+ * @param int|null $customerId
+ * @param int $productId
+ *
+ * @return void
+ *
+ * @phpcs:disable Magento2.Functions.DiscouragedFunction
+ */
+ private function addReviewRatingVotes(array $ratings, int $reviewId, ?int $customerId, int $productId): void
+ {
+ foreach ($ratings as $option) {
+ $ratingId = $option['id'];
+ $optionId = $option['value_id'];
+ /** @var Rating $ratingModel */
+ $ratingModel = $this->ratingFactory->create();
+ $ratingModel->setRatingId(base64_decode($ratingId))
+ ->setReviewId($reviewId)
+ ->setCustomerId($customerId)
+ ->addOptionVote(base64_decode($optionId), $productId);
+ }
+ }
+
+ /**
+ * Get review rating votes
+ *
+ * @param int $reviewId
+ * @param int $storeId
+ *
+ * @return OptionVoteCollection
+ */
+ private function getReviewRatingVotes(int $reviewId, int $storeId): OptionVoteCollection
+ {
+ /** @var OptionVoteCollection $votesCollection */
+ $votesCollection = $this->ratingOptionCollectionFactory->create();
+ $votesCollection->setReviewFilter($reviewId)->setStoreFilter($storeId)->addRatingInfo($storeId);
+
+ return $votesCollection;
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/README.md b/app/code/Magento/ReviewGraphQl/README.md
new file mode 100644
index 0000000000000..bf9563b87c9b2
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/README.md
@@ -0,0 +1,3 @@
+# ReviewGraphQl
+
+**ReviewGraphQl** provides endpoints for getting and creating the Product reviews by guest and logged in customers.
diff --git a/app/code/Magento/ReviewGraphQl/composer.json b/app/code/Magento/ReviewGraphQl/composer.json
new file mode 100644
index 0000000000000..819ddefd76213
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/composer.json
@@ -0,0 +1,28 @@
+{
+ "name": "magento/module-review-graph-ql",
+ "description": "N/A",
+ "type": "magento2-module",
+ "require": {
+ "php": "~7.3.0||~7.4.0",
+ "magento/module-catalog": "*",
+ "magento/module-review": "*",
+ "magento/module-store": "*",
+ "magento/framework": "*"
+ },
+ "suggest": {
+ "magento/module-graph-ql": "*",
+ "magento/module-graph-ql-cache": "*"
+ },
+ "license": [
+ "OSL-3.0",
+ "AFL-3.0"
+ ],
+ "autoload": {
+ "files": [
+ "registration.php"
+ ],
+ "psr-4": {
+ "Magento\\ReviewGraphQl\\": ""
+ }
+ }
+}
diff --git a/app/code/Magento/ReviewGraphQl/etc/module.xml b/app/code/Magento/ReviewGraphQl/etc/module.xml
new file mode 100644
index 0000000000000..c098ee5094760
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/etc/module.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/ReviewGraphQl/etc/schema.graphqls b/app/code/Magento/ReviewGraphQl/etc/schema.graphqls
new file mode 100644
index 0000000000000..14b4fc60e8b09
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/etc/schema.graphqls
@@ -0,0 +1,78 @@
+# Copyright © Magento, Inc. All rights reserved.
+# See COPYING.txt for license details.
+
+interface ProductInterface {
+ rating_summary: Float! @doc(description: "The average of all the ratings given to the product.") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\Product\\RatingSummary")
+ review_count: Int! @doc(description: "The total count of all the reviews given to the product.") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\Product\\ReviewCount")
+ reviews(
+ pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once."),
+ currentPage: Int = 1 @doc(description: "Specifies which page of results to return."),
+ ): ProductReviews! @doc(description: "The list of products reviews.") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\Product\\Reviews")
+}
+
+type ProductReviews {
+ items: [ProductReview]! @doc(description: "An array of product reviews.")
+ page_info: SearchResultPageInfo! @doc(description: "Metadata for pagination rendering.")
+}
+
+type ProductReview @doc(description: "Details of a product review") {
+ product: ProductInterface! @doc(description: "Contains details about the reviewed product") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product")
+ summary: String! @doc(description: "The summary (title) of the review")
+ text: String! @doc(description: "The review text.")
+ nickname: String! @doc(description: "The customer's nickname. Defaults to the customer name, if logged in")
+ created_at: String! @doc(description: "Date indicating when the review was created.")
+ average_rating: Float! @doc(description: "The average rating for product review.") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\Product\\Review\\AverageRating")
+ ratings_breakdown: [ProductReviewRating!]! @doc(description: "An array of ratings by rating category, such as quality, price, and value") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\Product\\Review\\RatingBreakdown")
+}
+
+type ProductReviewRating {
+ name: String! @doc(description: "The label assigned to an aspect of a product that is being rated, such as quality or price")
+ value: String! @doc(description: "The rating value given by customer. By default, possible values range from 1 to 5.")
+}
+
+type Query {
+ productReviewRatingsMetadata: ProductReviewRatingsMetadata! @doc(description: "Retrieves metadata required by clients to render the Reviews section.") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\ProductReviewRatingsMetadata")
+}
+
+type ProductReviewRatingsMetadata {
+ items: [ProductReviewRatingMetadata!]! @doc(description: "List of product reviews sorted by position")
+}
+
+type ProductReviewRatingMetadata {
+ id: String! @doc(description: "Base64 encoded rating ID.")
+ name: String! @doc(description: "The label assigned to an aspect of a product that is being rated, such as quality or price")
+ values: [ProductReviewRatingValueMetadata!]! @doc(description: "List of product review ratings sorted by position.") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\ProductReviewRatingValueMetadata")
+}
+
+type ProductReviewRatingValueMetadata {
+ value_id: String! @doc(description: "Base 64 encoded rating value id.")
+ value: String! @doc(description: "e.g Good, Perfect, 3, 4, 5")
+}
+
+type Customer {
+ reviews(
+ pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once."),
+ currentPage: Int = 1 @doc(description: "Specifies which page of results to return."),
+ ): ProductReviews! @doc(description: "Contains the customer's product reviews") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\Customer\\Reviews")
+}
+
+type Mutation {
+ createProductReview(input: CreateProductReviewInput!): CreateProductReviewOutput! @doc(description: "Creates a product review for the specified SKU") @resolver(class: "Magento\\ReviewGraphQl\\Model\\Resolver\\CreateProductReview")
+}
+
+type CreateProductReviewOutput {
+ review: ProductReview! @doc(description: "Contains the completed product review")
+}
+
+input CreateProductReviewInput {
+ sku: String! @doc(description: "The SKU of the reviewed product")
+ nickname: String! @doc(description: "The customer's nickname. Defaults to the customer name, if logged in")
+ summary: String! @doc(description: "The summary (title) of the review")
+ text: String! @doc(description: "The review text.")
+ ratings: [ProductReviewRatingInput!]! @doc(description: "Ratings details by category. e.g price: 5, quality: 4 etc")
+}
+
+input ProductReviewRatingInput {
+ id: String! @doc(description: "Base64 encoded rating ID.")
+ value_id: String! @doc(description: "Base 64 encoded rating value id.")
+}
diff --git a/app/code/Magento/ReviewGraphQl/registration.php b/app/code/Magento/ReviewGraphQl/registration.php
new file mode 100644
index 0000000000000..8fb6535902edf
--- /dev/null
+++ b/app/code/Magento/ReviewGraphQl/registration.php
@@ -0,0 +1,9 @@
+customerTokenService = $objectManager->get(CustomerTokenServiceInterface::class);
+ $this->customerRepository = $objectManager->get(CustomerRepositoryInterface::class);
+ $this->reviewCollectionFactory = $objectManager->get(ReviewCollectionFactory::class);
+ $this->registry = $objectManager->get(Registry::class);
+ }
+
+ /**
+ * Test adding a product review as guest and logged in customer
+ *
+ * @param string $customerName
+ * @param bool $isGuest
+ *
+ * @magentoApiDataFixture Magento/Review/_files/set_position_and_add_store_to_all_ratings.php
+ * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php
+ * @magentoApiDataFixture Magento/Customer/_files/customer.php
+ *
+ * @dataProvider customerDataProvider
+ */
+ public function testCustomerAddProductReviews(string $customerName, bool $isGuest)
+ {
+ $productSku = 'simple_product';
+ $query = $this->getQuery($productSku, $customerName);
+ $headers = [];
+
+ if (!$isGuest) {
+ $headers = $this->getHeaderMap();
+ }
+
+ $response = $this->graphQlMutation($query, [], '', $headers);
+
+ $expectedResult = [
+ 'nickname' => $customerName,
+ 'summary' => 'Summary Test',
+ 'text' => 'Text Test',
+ 'average_rating' => 66.67,
+ 'ratings_breakdown' => [
+ [
+ 'name' => 'Price',
+ 'value' => 3
+ ], [
+ 'name' => 'Quality',
+ 'value' => 2
+ ], [
+ 'name' => 'Value',
+ 'value' => 5
+ ]
+ ]
+ ];
+ self::assertArrayHasKey('createProductReview', $response);
+ self::assertArrayHasKey('review', $response['createProductReview']);
+ self::assertEquals($expectedResult, $response['createProductReview']['review']);
+ }
+
+ /**
+ * @magentoConfigFixture default_store catalog/review/allow_guest 0
+ */
+ public function testAddProductReviewGuestIsNotAllowed()
+ {
+ $productSku = 'simple_product';
+ $customerName = 'John Doe';
+ $query = $this->getQuery($productSku, $customerName);
+ self::expectExceptionMessage('Guest customers aren\'t allowed to add product reviews.');
+ $this->graphQlMutation($query);
+ }
+
+ /**
+ * Removing the recently added product reviews
+ */
+ public function tearDown(): void
+ {
+ $this->registry->unregister('isSecureArea');
+ $this->registry->register('isSecureArea', true);
+ $productId = 1;
+ /** @var Collection $reviewsCollection */
+ $reviewsCollection = $this->reviewCollectionFactory->create();
+ $reviewsCollection->addEntityFilter(Review::ENTITY_PRODUCT_CODE, $productId);
+ /** @var Review $review */
+ foreach ($reviewsCollection as $review) {
+ $review->delete();
+ }
+ $this->registry->unregister('isSecureArea');
+ $this->registry->register('isSecureArea', false);
+
+ parent::tearDown();
+ }
+
+ /**
+ * @return array
+ */
+ public function customerDataProvider(): array
+ {
+ return [
+ 'Guest Customer' => ['John Doe', true],
+ 'Logged In Customer' => ['John', false],
+ ];
+ }
+
+ /**
+ * @param string $username
+ * @param string $password
+ *
+ * @return array
+ *
+ * @throws AuthenticationException
+ */
+ private function getHeaderMap(string $username = 'customer@example.com', string $password = 'password'): array
+ {
+ $customerToken = $this->customerTokenService->createCustomerAccessToken($username, $password);
+
+ return ['Authorization' => 'Bearer ' . $customerToken];
+ }
+
+ /**
+ * Get mutation query
+ *
+ * @param string $sku
+ * @param string $customerName
+ *
+ * @return string
+ */
+ private function getQuery(string $sku, string $customerName): string
+ {
+ return <<customerTokenService = $objectManager->get(CustomerTokenServiceInterface::class);
+ $this->reviewCollectionFactory = $objectManager->get(ReviewCollectionFactory::class);
+ $this->registry = $objectManager->get(Registry::class);
+ }
+
+ /**
+ * @magentoApiDataFixture Magento/Review/_files/set_position_and_add_store_to_all_ratings.php
+ */
+ public function testProductReviewRatingsMetadata()
+ {
+ $query
+ = << 'Mw==',
+ 'name' => 'Price',
+ 'values' => [
+ [
+ 'value_id' => 'MTE=',
+ 'value' => "1"
+ ],[
+ 'value_id' => 'MTI=',
+ 'value' => "2"
+ ],[
+ 'value_id' => 'MTM=',
+ 'value' => "3"
+ ],[
+ 'value_id' => 'MTQ=',
+ 'value' => "4"
+ ],[
+ 'value_id' => 'MTU=',
+ 'value' => "5"
+ ]
+ ]
+ ], [
+ 'id' => 'MQ==',
+ 'name' => 'Quality',
+ 'values' => [
+ [
+ 'value_id' => 'MQ==',
+ 'value' => "1"
+ ],[
+ 'value_id' => 'Mg==',
+ 'value' => "2"
+ ],[
+ 'value_id' => 'Mw==',
+ 'value' => "3"
+ ],[
+ 'value_id' => 'NA==',
+ 'value' => "4"
+ ],[
+ 'value_id' => 'NQ==',
+ 'value' => "5"
+ ]
+ ]
+ ], [
+ 'id' => 'Mg==',
+ 'name' => 'Value',
+ 'values' => [
+ [
+ 'value_id' => 'Ng==',
+ 'value' => "1"
+ ],[
+ 'value_id' => 'Nw==',
+ 'value' => "2"
+ ],[
+ 'value_id' => 'OA==',
+ 'value' => "3"
+ ],[
+ 'value_id' => 'OQ==',
+ 'value' => "4"
+ ],[
+ 'value_id' => 'MTA=',
+ 'value' => "5"
+ ]
+ ]
+ ]
+ ];
+ $response = $this->graphQlQuery($query);
+ self::assertArrayHasKey('productReviewRatingsMetadata', $response);
+ self::assertArrayHasKey('items', $response['productReviewRatingsMetadata']);
+ self::assertNotEmpty($response['productReviewRatingsMetadata']['items']);
+ self::assertEquals($expectedRatingItems, $response['productReviewRatingsMetadata']['items']);
+ }
+
+ /**
+ * @magentoApiDataFixture Magento/Review/_files/different_reviews.php
+ */
+ public function testProductReviewRatings()
+ {
+ $productSku = 'simple';
+ /** @var ProductRepositoryInterface $productRepository */
+ $productRepository = ObjectManager::getInstance()->get(ProductRepositoryInterface::class);
+ $product = $productRepository->get($productSku, false, null, true);
+ $summaryFactory = ObjectManager::getInstance()->get(SummaryFactory::class);
+ $storeId = ObjectManager::getInstance()->get(StoreManagerInterface::class)->getStore()->getId();
+ $summary = $summaryFactory->create()->setStoreId($storeId)->load($product->getId());
+ $query
+ = <<graphQlQuery($query);
+ self::assertArrayHasKey('products', $response);
+ self::assertArrayHasKey('items', $response['products']);
+ self::assertNotEmpty($response['products']['items']);
+
+ $items = $response['products']['items'];
+ self::assertEquals($summary->getData('rating_summary'), $items[0]['rating_summary']);
+ self::assertEquals($summary->getData('reviews_count'), $items[0]['review_count']);
+ self::assertArrayHasKey('items', $items[0]['reviews']);
+ self::assertNotEmpty($items[0]['reviews']['items']);
+ }
+
+ /**
+ * @magentoApiDataFixture Magento/Review/_files/customer_review_with_rating.php
+ */
+ public function testCustomerReviewsAddedToProduct()
+ {
+ $query = << 'Nickname',
+ 'summary' => 'Review Summary',
+ 'text' => 'Review text',
+ 'average_rating' => 40,
+ 'ratings_breakdown' => [
+ [
+ 'name' => 'Quality',
+ 'value' => 2
+ ],[
+ 'name' => 'Value',
+ 'value' => 2
+ ]
+ ]
+ ];
+ $response = $this->graphQlQuery($query, [], '', $this->getHeaderMap());
+
+ self::assertArrayHasKey('customer', $response);
+ self::assertArrayHasKey('reviews', $response['customer']);
+ self::assertArrayHasKey('items', $response['customer']['reviews']);
+ self::assertNotEmpty($response['customer']['reviews']['items']);
+ self::assertEquals($expectedFirstItem, $response['customer']['reviews']['items'][0]);
+ }
+
+ /**
+ * Removing the recently added product reviews
+ */
+ public function tearDown(): void
+ {
+ $this->registry->unregister('isSecureArea');
+ $this->registry->register('isSecureArea', true);
+ $productId = 1;
+ /** @var Collection $reviewsCollection */
+ $reviewsCollection = $this->reviewCollectionFactory->create();
+ $reviewsCollection->addEntityFilter(Review::ENTITY_PRODUCT_CODE, $productId);
+ /** @var Review $review */
+ foreach ($reviewsCollection as $review) {
+ $review->delete();
+ }
+ $this->registry->unregister('isSecureArea');
+ $this->registry->register('isSecureArea', false);
+
+ parent::tearDown();
+ }
+
+ /**
+ * @param string $username
+ * @param string $password
+ *
+ * @return array
+ *
+ * @throws AuthenticationException
+ */
+ private function getHeaderMap(string $username = 'customer@example.com', string $password = 'password'): array
+ {
+ $customerToken = $this->customerTokenService->createCustomerAccessToken($username, $password);
+
+ return ['Authorization' => 'Bearer ' . $customerToken];
+ }
+}
diff --git a/dev/tests/integration/testsuite/Magento/Review/_files/customer_review_with_rating_rollback.php b/dev/tests/integration/testsuite/Magento/Review/_files/customer_review_with_rating_rollback.php
new file mode 100644
index 0000000000000..0931d881a6fdc
--- /dev/null
+++ b/dev/tests/integration/testsuite/Magento/Review/_files/customer_review_with_rating_rollback.php
@@ -0,0 +1,11 @@
+requireDataFixture('Magento/Customer/_files/customer_rollback.php');
+Resolver::getInstance()->requireDataFixture('Magento/Catalog/_files/product_simple_rollback.php');
diff --git a/dev/tests/integration/testsuite/Magento/Review/_files/different_reviews_rollback.php b/dev/tests/integration/testsuite/Magento/Review/_files/different_reviews_rollback.php
new file mode 100644
index 0000000000000..328c1e229da5c
--- /dev/null
+++ b/dev/tests/integration/testsuite/Magento/Review/_files/different_reviews_rollback.php
@@ -0,0 +1,10 @@
+requireDataFixture('Magento/Catalog/_files/product_simple_rollback.php');
diff --git a/dev/tests/integration/testsuite/Magento/Review/_files/set_position_and_add_store_to_all_ratings.php b/dev/tests/integration/testsuite/Magento/Review/_files/set_position_and_add_store_to_all_ratings.php
new file mode 100644
index 0000000000000..0c097f62101f8
--- /dev/null
+++ b/dev/tests/integration/testsuite/Magento/Review/_files/set_position_and_add_store_to_all_ratings.php
@@ -0,0 +1,31 @@
+loadArea(FrontNameResolver::AREA_CODE);
+
+$objectManager = Bootstrap::getObjectManager();
+
+$storeId = $objectManager->get(StoreManagerInterface::class)->getStore()->getId();
+
+/** @var RatingResourceModel $ratingResourceModel */
+$ratingResourceModel = $objectManager->create(RatingResourceModel::class);
+
+/** @var RatingCollection $ratingCollection */
+$ratingCollection = $objectManager->create(RatingCollection::class)->setOrder('rating_code', 'ASC');
+$position = 0;
+
+foreach ($ratingCollection as $rating) {
+ $rating->setStores([$storeId])->setPosition($position++);
+ $ratingResourceModel->save($rating);
+}
diff --git a/dev/tests/integration/testsuite/Magento/Review/_files/set_position_and_add_store_to_all_ratings_rollback.php b/dev/tests/integration/testsuite/Magento/Review/_files/set_position_and_add_store_to_all_ratings_rollback.php
new file mode 100644
index 0000000000000..3a96a1be17a8b
--- /dev/null
+++ b/dev/tests/integration/testsuite/Magento/Review/_files/set_position_and_add_store_to_all_ratings_rollback.php
@@ -0,0 +1,29 @@
+loadArea(FrontNameResolver::AREA_CODE);
+$objectManager = Bootstrap::getObjectManager();
+
+$storeId = Bootstrap::getObjectManager()->get(StoreManagerInterface::class)->getStore()->getId();
+
+/** @var RatingResourceModel $ratingResourceModel */
+$ratingResourceModel = $objectManager->create(RatingResourceModel::class);
+
+/** @var RatingCollection $ratingCollection */
+$ratingCollection = Bootstrap::getObjectManager()->create(RatingCollection::class);
+
+foreach ($ratingCollection as $rating) {
+ $rating->setStores([])->setPosition(0);
+ $ratingResourceModel->save($rating);
+}