
com.commercetools.sync.products.ProductSync Maven / Gradle / Ivy
package com.commercetools.sync.products;
import com.commercetools.sync.commons.BaseSync;
import com.commercetools.sync.products.helpers.ProductReferenceResolver;
import com.commercetools.sync.products.helpers.ProductSyncStatistics;
import com.commercetools.sync.services.CategoryService;
import com.commercetools.sync.services.ChannelService;
import com.commercetools.sync.services.ProductService;
import com.commercetools.sync.services.ProductTypeService;
import com.commercetools.sync.services.TypeService;
import com.commercetools.sync.services.impl.CategoryServiceImpl;
import com.commercetools.sync.services.impl.ChannelServiceImpl;
import com.commercetools.sync.services.impl.ProductServiceImpl;
import com.commercetools.sync.services.impl.ProductTypeServiceImpl;
import com.commercetools.sync.services.impl.TypeServiceImpl;
import io.sphere.sdk.client.ConcurrentModificationException;
import io.sphere.sdk.commands.UpdateAction;
import io.sphere.sdk.products.Product;
import io.sphere.sdk.products.ProductDraft;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.ImmutablePair;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import static com.commercetools.sync.commons.utils.SyncUtils.batchDrafts;
import static com.commercetools.sync.products.utils.ProductSyncUtils.buildActions;
import static java.lang.String.format;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
public class ProductSync extends BaseSync {
private static final String PRODUCT_DRAFT_KEY_NOT_SET = "ProductDraft with name: %s doesn't have a key.";
private static final String PRODUCT_DRAFT_IS_NULL = "ProductDraft is null.";
private static final String UPDATE_FAILED = "Failed to update Product with key: '%s'. Reason: %s";
private static final String UNEXPECTED_DELETE = "Product with key: '%s' was deleted unexpectedly.";
private static final String FAILED_TO_RESOLVE_REFERENCES = "Failed to resolve references on "
+ "ProductDraft with key:'%s'. Reason: %s";
private static final String FAILED_TO_FETCH_PRODUCT_TYPE = "Failed to fetch a productType for the product to "
+ "build the products' attributes metadata.";
private final ProductService productService;
private final ProductTypeService productTypeService;
private final ProductReferenceResolver productReferenceResolver;
private Map productsToSync = new HashMap<>();
private Set draftsToCreate = new HashSet<>();
/**
* Takes a {@link ProductSyncOptions} instance to instantiate a new {@link ProductSync} instance that could be
* used to sync product drafts with the given products in the CTP project specified in the injected
* {@link ProductSyncOptions} instance.
*
* @param productSyncOptions the container of all the options of the sync process including the CTP project client
* and/or configuration and other sync-specific options.
*/
public ProductSync(@Nonnull final ProductSyncOptions productSyncOptions) {
this(productSyncOptions, new ProductServiceImpl(productSyncOptions),
new ProductTypeServiceImpl(productSyncOptions), new CategoryServiceImpl(productSyncOptions),
new TypeServiceImpl(productSyncOptions), new ChannelServiceImpl(productSyncOptions));
}
ProductSync(@Nonnull final ProductSyncOptions productSyncOptions, @Nonnull final ProductService productService,
@Nonnull final ProductTypeService productTypeService, @Nonnull final CategoryService categoryService,
@Nonnull final TypeService typeService, @Nonnull final ChannelService channelService) {
super(new ProductSyncStatistics(), productSyncOptions);
this.productService = productService;
this.productTypeService = productTypeService;
this.productReferenceResolver = new ProductReferenceResolver(productSyncOptions, productTypeService,
categoryService, typeService, channelService);
}
@Override
protected CompletionStage process(@Nonnull final List resourceDrafts) {
final List> batches = batchDrafts(resourceDrafts, syncOptions.getBatchSize());
return syncBatches(batches, CompletableFuture.completedFuture(statistics));
}
@Override
protected CompletionStage syncBatches(@Nonnull final List> batches,
@Nonnull final CompletionStage
result) {
if (batches.isEmpty()) {
return result;
}
final List firstBatch = batches.remove(0);
return syncBatches(batches, result.thenCompose(subResult -> processBatch(firstBatch)));
}
@Override
protected CompletionStage processBatch(@Nonnull final List batch) {
productsToSync = new HashMap<>();
draftsToCreate = new HashSet<>();
return productService.cacheKeysToIds()
.thenCompose(keyToIdCache -> {
final Set productDraftKeys = getProductDraftKeys(batch);
return productService.fetchMatchingProductsByKeys(productDraftKeys)
.thenAccept(matchingProducts ->
processFetchedProducts(matchingProducts, batch))
.thenCompose(result -> createOrUpdateProducts())
.thenApply(result -> {
statistics.incrementProcessed(batch.size());
return statistics;
});
});
}
@Nonnull
private Set getProductDraftKeys(@Nonnull final List productDrafts) {
return productDrafts.stream()
.filter(Objects::nonNull)
.map(ProductDraft::getKey)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toSet());
}
private void processFetchedProducts(@Nonnull final Set matchingProducts,
@Nonnull final List productDrafts) {
for (ProductDraft productDraft : productDrafts) {
if (productDraft != null) {
final String productKey = productDraft.getKey();
if (isNotBlank(productKey)) {
productReferenceResolver.resolveReferences(productDraft)
.thenAccept(referencesResolvedDraft -> {
final Optional existingProduct =
getProductByKeyIfExists(matchingProducts, productKey);
if (existingProduct.isPresent()) {
productsToSync.put(referencesResolvedDraft, existingProduct.get());
} else {
draftsToCreate.add(referencesResolvedDraft);
}
})
.exceptionally(referenceResolutionException -> {
Throwable actualException = referenceResolutionException;
if (referenceResolutionException instanceof CompletionException) {
actualException = referenceResolutionException.getCause();
}
final String errorMessage = format(FAILED_TO_RESOLVE_REFERENCES,
productDraft.getKey(), actualException);
handleError(errorMessage, referenceResolutionException);
return null;
}).toCompletableFuture().join();
} else {
final String errorMessage = format(PRODUCT_DRAFT_KEY_NOT_SET, productDraft.getName());
handleError(errorMessage, null);
}
} else {
handleError(PRODUCT_DRAFT_IS_NULL, null);
}
}
}
@Nonnull
private CompletionStage createOrUpdateProducts() {
return productService.createProducts(draftsToCreate)
.thenAccept(createdProducts ->
processCreatedProducts(createdProducts, draftsToCreate.size()))
.thenCompose(result -> syncProducts(productsToSync));
}
@Nonnull
private static Optional getProductByKeyIfExists(@Nonnull final Set products,
@Nonnull final String key) {
return products.stream()
.filter(product -> Objects.equals(product.getKey(), key))
.findFirst();
}
private void processCreatedProducts(@Nonnull final Set createdProducts,
final int totalNumberOfDraftsToCreate) {
final int numberOfFailedCreations = totalNumberOfDraftsToCreate - createdProducts.size();
statistics.incrementFailed(numberOfFailedCreations);
statistics.incrementCreated(createdProducts.size());
}
@Nonnull
private CompletionStage syncProducts(@Nonnull final Map productsToSync) {
final List>> futureUpdates =
productsToSync.entrySet().stream()
.map(entry -> buildUpdateActionsAndUpdate(entry.getValue(), entry.getKey(), false))
.map(CompletionStage::toCompletableFuture)
.collect(Collectors.toList());
return CompletableFuture.allOf(futureUpdates.toArray(new CompletableFuture[futureUpdates.size()]));
}
/**
* Given an existing {@link Product} and a new {@link ProductDraft}, first resolves all references on the category
* draft, then it calculates all the update actions required to synchronize the existing category to be the
* same as the new one. If there are update actions found, a request is made to CTP to update the
* existing category, otherwise it doesn't issue a request.
*
* @param oldProduct the category which could be updated.
* @param newProduct the category draft where we get the new data.
* @return a future which contains an empty result after execution of the update.
*/
@Nonnull
@SuppressWarnings("ConstantConditions")
private CompletionStage> buildUpdateActionsAndUpdate(@Nonnull final Product oldProduct,
@Nonnull final ProductDraft newProduct,
final boolean retry) {
if (retry) {
final String key = oldProduct.getKey();
return productService.fetchProduct(key)
.thenCompose(productOptional -> {
if (productOptional.isPresent()) {
final Product fetchedProduct = productOptional.get();
return fetchProductAttributesMetadataAndUpdate(fetchedProduct, newProduct);
}
handleError(format(UPDATE_FAILED, key, UNEXPECTED_DELETE), null);
return CompletableFuture.completedFuture(productOptional);
});
} else {
return fetchProductAttributesMetadataAndUpdate(oldProduct, newProduct);
}
}
@Nonnull
private CompletionStage> fetchProductAttributesMetadataAndUpdate(@Nonnull final Product
oldProduct,
@Nonnull final ProductDraft
newProduct) {
return productTypeService.fetchCachedProductAttributeMetaDataMap(oldProduct.getProductType().getId())
.thenCompose(optionalAttributesMetaDataMap -> {
if (!optionalAttributesMetaDataMap.isPresent()) {
final String errorMessage = format(UPDATE_FAILED, oldProduct.getKey(),
FAILED_TO_FETCH_PRODUCT_TYPE);
handleError(errorMessage, null);
return CompletableFuture.completedFuture(Optional.of(oldProduct));
} else {
final Map attributeMetaDataMap =
optionalAttributesMetaDataMap.get();
final List> updateActions =
buildActions(oldProduct, newProduct, syncOptions, attributeMetaDataMap);
if (!updateActions.isEmpty()) {
return updateProduct(oldProduct, newProduct, updateActions);
}
return CompletableFuture.completedFuture(Optional.of(oldProduct));
}
});
}
@Nonnull
private CompletionStage> updateProduct(@Nonnull final Product oldProduct,
@Nonnull final ProductDraft newProduct,
@Nonnull final List> updateActions) {
return productService.updateProduct(oldProduct, updateActions)
.handle(ImmutablePair::new)
.thenCompose(updateResponse -> {
final Product updatedProduct = updateResponse.getKey();
final Throwable sphereException = updateResponse.getValue();
if (sphereException != null) {
return retryRequestIfConcurrentModificationException(
sphereException, oldProduct,
() -> buildUpdateActionsAndUpdate(oldProduct, newProduct,
true), UPDATE_FAILED);
} else {
statistics.incrementUpdated();
return CompletableFuture.completedFuture(Optional.of(updatedProduct));
}
});
}
/**
* This method checks if the {@code sphereException} (thrown when trying to sync the old {@link Product} and the
* new {@link ProductDraft}) is an instance of {@link ConcurrentModificationException}. If it is, then it executes
* the supplied {@code request} to rebuild update actions and reissue the CTP update request. Otherwise, if it is
* not an instance of a {@link ConcurrentModificationException} then it is counted as a failed product to sync.
*
* @param sphereException the sphere exception thrown after issuing an update request.
* @param oldProduct the product to update.
* @param request the request to re execute in case of a {@link ConcurrentModificationException}.
* @return a future which contains an empty result after execution of the update.
*/
@Nonnull
private CompletionStage> retryRequestIfConcurrentModificationException(
@Nonnull final Throwable sphereException, @Nonnull final Product oldProduct,
@Nonnull final Supplier>> request,
@Nonnull final String errorMessage) {
if (sphereException instanceof ConcurrentModificationException) {
return request.get();
} else {
final String productKey = oldProduct.getKey();
handleError(format(errorMessage, productKey, sphereException), sphereException);
return CompletableFuture.completedFuture(Optional.empty());
}
}
/**
* Given a {@link String} {@code errorMessage} and a {@link Throwable} {@code exception}, this method calls the
* optional error callback specified in the {@code syncOptions} and updates the {@code statistics} instance by
* incrementing the total number of failed products to sync.
*
* @param errorMessage The error message describing the reason(s) of failure.
* @param exception The exception that called caused the failure, if any.
*/
private void handleError(@Nonnull final String errorMessage, @Nullable final Throwable exception) {
syncOptions.applyErrorCallback(errorMessage, exception);
statistics.incrementFailed();
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy