Sitecore Experience Commerce – Product images

One of the first things you’ll notice when looking at the catalog features in Sitecore Experience Commerce (XC) 9, that the product images will have to be uploaded in Sitecore Experience Platform (XP) and Sitecore XC only has to know the Sitecore Item IDs of these images. In itself a good architectural decision in my opinion, but once you start developing a product import of some sorts you’ll notice that there is no out-of-the-box solution for actually uploading images or other assets from XC into XP!

Lucky for us, Sitecore XC had to get Storefront settings from XP. These Storefront settings are settings about the shop you are running and contain settings like which fulfillment or payment options can be used. Sitecore used the OData Item Service for this, and the implementation they made can be easily reused. So lets create a Sitecore XC Plugin which utilizes these OData Services to upload images when importing your Catalog products.

Sitecore XP implementation

The first thing we will need to do, is create a custom OData Service. For this we have to create a return model and repository:

public class MediaItemDataModel : EntityIdentity
{
}
 
public class MediaItemRepository : IReadOnlyEntityRepository
{
    public Task GetById(string id)
    {
        return Task.FromResult(new MediaItemDataModel());
    }
 
    public Task<IQueryable<MediaItemDataModel>> GetData()
    {
        var list = new List<MediaItemDataModel>();
        return Task.FromResult<IQueryable<MediaItemDataModel>>(list.AsQueryable());
    }
}

This MediaItemDataModel inherits the EntityIdentity as used in the default Item Service. We don’t need anything else, because all we need to know in XC is the Sitecore Item ID. It is a bit weird that we have to create a IReadOnlyEntityRepository as we don’t actually do anything with it, but this is only because the OData controller we are creating needs a IReadOnlyEntityRepository.

[AnonymousUserFilter (AllowAnonymous = AllowAnonymousOptions.Disallow)]
public class MediaItemController : ServiceBaseODataController<MediaItemDataModel> {
    private readonly IReadOnlyEntityRepository<MediaItemDataModel> _repository;
 
    public MediaItemController () : base (ServiceLocator.ServiceProvider.GetRequiredService<IReadOnlyEntityRepository<MediaItemDataModel>> ()) {
        _repository = ServiceLocator.ServiceProvider.GetRequiredService<IReadOnlyEntityRepository<MediaItemDataModel>> ();
    }
 
    public IHttpActionResult Get ([FromODataUri] string imageUrl, [FromODataUri] string sitecorePath) {
        if (string.IsNullOrEmpty (imageUrl))
            return BadRequest ("Specify the imageUrl property.");
        if (string.IsNullOrEmpty (sitecorePath))
            return BadRequest ("Specify the sitecorePath property.");
 
        if (!sitecorePath.StartsWith ("/sitecore/media library/"))
            sitecorePath = sitecorePath.StartsWith ("/") ? $"/sitecore/media library{sitecorePath}" : $"/sitecore/media library/{sitecorePath}";
 
        var fileNameInclExtension = imageUrl.Substring (imageUrl.LastIndexOf ("/", StringComparison.InvariantCultureIgnoreCase) + 1,
            (imageUrl.Length - imageUrl.LastIndexOf ("/", StringComparison.InvariantCultureIgnoreCase)) - 1).Split ('?').FirstOrDefault ();
 
        if (string.IsNullOrEmpty (fileNameInclExtension))
            return BadRequest ("No fileName could be extracted from the imageUrl.");
 
        var fileName = fileNameInclExtension.Split ('.').FirstOrDefault () ?? fileNameInclExtension;
 
        var newItem = CreateMediaItem (imageUrl, sitecorePath, fileName, fileNameInclExtension);
        PublishItem (newItem);
 
        return Ok (new MediaItemDataModel () {
            Id = newItem.ID.ToString ()
        });
    }
 
    private Item CreateMediaItem (string imageUrl, string sitecorePath, string mediaItemName, string fullMediaItemName) {
        var destination = sitecorePath.EndsWith ("/") ?
            $"{sitecorePath}{mediaItemName}" :
            $"{sitecorePath}/{mediaItemName}";
 
        var options = new Sitecore.Resources.Media.MediaCreatorOptions () {
            FileBased = false,
            IncludeExtensionInItemName = false,
            OverwriteExisting = true,
            Versioned = false,
            Destination = destination,
            Database = Sitecore.Configuration.Factory.GetDatabase ("master")
        };
 
        using (var wc = new System.Net.WebClient ()) {
            var daa = wc.DownloadData (imageUrl);
            using (var memoryStream = new MemoryStream (daa)) {
                using (new SecurityDisabler ()) {
                    var mediaItem = Sitecore.Resources.Media.MediaManager.Creator.CreateFromStream (memoryStream, fullMediaItemName, options);
                    memoryStream.Dispose ();
                    return mediaItem;
                }
            }
        }
    }
 
    private void PublishItem (Item item) {
        try {
            PublishOptions po = new PublishOptions (Sitecore.Configuration.Factory.GetDatabase ("master"), Sitecore.Configuration.Factory.GetDatabase ("web"), PublishMode.SingleItem, Sitecore.Context.Language, DateTime.Now);
            po.RootItem = item;
            po.Deep = true; // Publishing subitems
 
            (new Publisher (po)).Publish ();
        } catch (Exception ex) {
            Sitecore.Diagnostics.Log.Error ("Exception publishing items from custom pipeline! : " + ex, this);
        }
    }
}

In this example implementation, we get a URL to the image we want to upload and a Sitecore Path which will be the parent of the new Sitecore Item. Using these two variables we download the image, create a Media Item for it and eventually publish the new Item.

Note the attribute we use on this Controller. If you don’t specifically set any authentication/authorization on these services they will default to be available to anybody, and that’s something we don’t want of course. The manager we will use on Sitecore XC side already sends authorization properties out-of-the-box, so we don’t actually have to do anything for that matter.

Next we need to register the repository:

public class CustomServiceConfigurator : IServicesConfigurator
{
    public void Configure(IServiceCollection serviceCollection)
    {
        var assemblies = new[] { GetType().Assembly };
 
        serviceCollection.AddWebApiControllers(assemblies);
        serviceCollection.AddSingleton<IReadOnlyEntityRepository<MediaItemDataModel>, MediaItemRepository>();
    }
}
<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
    <sitecore>
        <services>
            <configurator type="Sitecore.Foundation.CustomSitecoreServices.DependencyInjection.CustomServiceConfigurator, Sitecore.Foundation.CustomSitecoreServices"/>
        </services>
    </sitecore>
</configuration>

And last but not least we have to actually register the OData Service:

public class MediaItemServiceDescriptor : AggregateDescriptor
{
    public MediaItemServiceDescriptor()
        : base(
            "media", //route name
            "media",  //route prefix
            new DefaultEdmModelBuilder(new []
            {
                new EntitySetDefinition(typeof(MediaItemDataModel), "MediaItem")
            }))
    {
    }
}

Sitecore XC implementation

Now that we have done all of that we are done on the Sitecore XP part and we can start using it in our XC implementation.

Sitecore XC has a SitecoreConnectionManager which they use to get Item data from Sitecore XP. We can use this same manager to call our custom OData Service.
All we have to provide the manager with is a CommerceContext, OData service URL, a method and a ItemModel. This ItemModel input is something Sitecore uses specifically for calling the Item Service. Instead of using that, we can also add our properties to the action input.
Eventually the implementation will look something like this:

/// <summary>
/// Use the OData Sitecore Service to upload the asset to the Sitecore Media Library and return the Item ID.
/// </summary>
/// <param name="context">The Commerce Context.</param>
/// <param name="url">The asset url.</param>
/// <param name="sitecorePath">The Sitecore path in which the asset should be uploaded.</param>
/// <returns>The uploaded Item ID.</returns>
private async Task<string> UploadAsset(CommerceContext context, string url, string sitecorePath)
{
    try
    {
        var action = $"sitecore/api/ssc/aggregate/media/MediaItem?imageUrl={HttpUtility.UrlEncode(url)}&amp;sitecorePath={sitecorePath}";
        var httpResponseMessage = await _sitecoreConnectionManager.ProcessRequestAsync(context, action, "GET", null);
        if (httpResponseMessage == null || !httpResponseMessage.IsSuccessStatusCode)
            return "";
 
        return JsonConvert.DeserializeObject<MediaItemModel>(await httpResponseMessage.Content.ReadAsStringAsync())?.Id ?? "";
    }
    catch (Exception ex)
    {
        context.LogException(nameof(SynchronizeSellableItemDigitalAssetsBlock), ex);
        return "";
    }
}

And with that, you are done! We now have all the building blocks to automatically import Media Items from inside a Sitecore Commerce process instead of somehow uploading them separately.