So now that we have our admin role in place, we have added the CRUD funtionality to the products, it is now time to turn our attention to the image upload part of this. If we take look at what we currently have in place we can see that all we currently have in place is a simple string URL for the PictureURL on our product class, and just a bunch of images being served from the Content/images/products folder from the API:

namespace Core.Entities
{
    public class Product : BaseEntity
    {
        public string Name { get; set; }
        public string Description { get; set; }
        public decimal Price { get; set; }
        public string PictureUrl { get; set; }
        public ProductType ProductType { get; set; }
        public int ProductTypeId { get; set; }
        public ProductBrand ProductBrand { get; set; }
        public int ProductBrandId { get; set; }
    }
}

This is clearly not going to cut it for any product upload feature we want to add so brace yourself for some refactoring here. What we are aiming to do in this part is to:

  1. Refactor the product entity so that instead of a single PictureUrl each product has a collection of Photo objects.
  2. For our queries we will refactor the app to return this photo collection and set the PictureUrl in the PhotoToReturnDto to the main photo.
  3. Refactor the Product.cs class to have a collection of Photos and we will also create methods to Add/Remove and Set the main photo in this class.
  4. Refactor the Seed method to accommodate the new Photos collection.
  5. Remove and recreate the migration – since we are using Sqlite we cannot drop columns due to the limitations of this DB.

Ok lets make a start. First of all we need a Photo.cs class so add this in the Entities folder in the Core project:

namespace Core.Entities
{
    public class Photo : BaseEntity
    {
        public string PictureUrl { get; set; }
        public string FileName { get; set; }
        public bool IsMain { get; set; }
        public Product Product { get; set; }
        public int ProductId { get; set; }
    }
}

We are going to “fully define” the relationship between the Photo and the Product here – this will mean that when a product is deleted, then this will cascade down to the Photo as well and also delete this. Then in the Product.cs class lets remove the PictureUrl and add the Photo.cs as a collection. To avoid the possibility of uploading an image to another products photo collection we will make this a readonly list and provide methods in the class to add a new Photo. Here is the new Product.cs class:

using System.Collections.Generic;
using System.Linq;

namespace Core.Entities
{
    public class Product : BaseEntity
    {
        public string Name { get; set; }
        public string Description { get; set; }
        public decimal Price { get; set; }
        public ProductType ProductType { get; set; }
        public int ProductTypeId { get; set; }
        public ProductBrand ProductBrand { get; set; }
        public int ProductBrandId { get; set; }
        private readonly List<Photo> _photos = new List<Photo>();
        public IReadOnlyList<Photo> Photos => _photos.AsReadOnly();

        public void AddPhoto(string pictureUrl, string fileName, bool isMain = false)
        {
            var photo = new Photo
            {
                FileName = fileName,
                PictureUrl = pictureUrl
            };
            
            if (_photos.Count == 0) photo.IsMain = true;
            
            _photos.Add(photo);
        }
    }
}

In the AddPhoto method we are checking to see if the Product already has a photo in the collection – if it does not we know this is the only photo so of course this will be the “main” photo so we will set this accordingly.

Now that we have removed the “PictureUrl” from the Product, a number of elements of the app will be broken so lets go and clean up the errors:

ProductsController – CreateProduct (remove the setting of the picture url, return a ProductToReturnDto):

        [HttpPost]
        [Authorize(Roles = "Admin")]
        public async Task<ActionResult<ProductToReturnDto>> CreateProduct(ProductCreateDto productToCreate)
        {
            var product = _mapper.Map<ProductCreateDto, Product>(productToCreate);

            _unitOfWork.Repository<Product>().Add(product);

            var result = await _unitOfWork.Complete();

            if (result <= 0) return BadRequest(new ApiResponse(400, "Problem creating product"));

            return _mapper.Map<Product, ProductToReturnDto>(product);
        }

ProductController – UpdateProduct (return ProductToReturnDto and remove the setting of the PictureUrl property)

        [HttpPut("{id}")]
        [Authorize(Roles = "Admin")]
        public async Task<ActionResult<ProductToReturnDto>> UpdateProduct(int id, ProductCreateDto productToUpdate)
        {
            var product = await _unitOfWork.Repository<Product>().GetByIdAsync(id);

            _mapper.Map(productToUpdate, product);

            _unitOfWork.Repository<Product>().Update(product);

            var result = await _unitOfWork.Complete();

            if (result <= 0) return BadRequest(new ApiResponse(400, "Problem updating product"));

            return _mapper.Map<Product, ProductToReturnDto>(product);
        }

ProductConfiguration.cs in the Infrastructure/Data/Config folder – Remove the configuration for PictureURL here:

namespace Infrastructure.Data.Config
{
    public class ProductConfiguration : IEntityTypeConfiguration<Product>
    {
        public void Configure(EntityTypeBuilder<Product> builder)
        {
            builder.Property(p => p.Id).IsRequired();
            builder.Property(p => p.Name).IsRequired().HasMaxLength(100);
            builder.Property(p => p.Description).IsRequired();
            builder.Property(p => p.Price).HasColumnType("decimal(18,2)");
            // builder.Property(p => p.PictureUrl).IsRequired();
            builder.HasOne(b => b.ProductBrand).WithMany()
                .HasForeignKey(p => p.ProductBrandId);
            builder.HasOne(t => t.ProductType).WithMany()
                .HasForeignKey(p => p.ProductTypeId);
        }
    }
}

OrderService.cs in the Infrastructure/Services folder – CreateOrderAsync – instead of the itemOrdered being set to the productItem.PicureUrl we will set it to the main photo url:

            // get items from the product repo
            var items = new List<OrderItem>();
            foreach (var item in basket.Items)
            {
                var productItem = await _unitOfWork.Repository<Product>().GetByIdAsync(item.Id);
                var itemOrdered = new ProductItemOrdered(productItem.Id, productItem.Name, productItem.Photos.FirstOrDefault(x => x.IsMain)?.PictureUrl);
                var orderItem = new OrderItem(itemOrdered, productItem.Price, item.Quantity);
                items.Add(orderItem);
            }

ProductUrlResolver.cs – this needs to be updated to accommodate the photos collection:

using System.Linq;
using API.Dtos;
using AutoMapper;
using Core.Entities;
using Microsoft.Extensions.Configuration;

namespace API.Helpers
{
    public class ProductUrlResolver : IValueResolver<Product, ProductToReturnDto, string>
    {
        private readonly IConfiguration _config;
        public ProductUrlResolver(IConfiguration config)
        {
            _config = config;
        }

        public string Resolve(Product source, ProductToReturnDto destination, string destMember, ResolutionContext context)
        {
            var photo = source.Photos.FirstOrDefault(x => x.IsMain);
            
            if(photo != null)
            {
                return _config["ApiUrl"] + photo.PictureUrl;
            }

            return _config["ApiUrl"] + "images/products/placeholder.png";
        }
    }
}

We also need to create a new DTO for the Photo we want to return otherwise we will run into a circular dependancy due to fully defining the relationship and including a property for the Product here. Create a new class in the Dtos folder called PhotoToReturnDto:

namespace API.Dtos
{
    public class PhotoToReturnDto
    {
        public int Id { get; set; }
        public string PictureUrl { get; set; }
        public string FileName { get; set; }
        public bool IsMain { get; set; }
    }
}

We will also need to add an additional mapping for this in the AutoMapperProfile, and in order to set the PictureUrl correctly we will also need another custom URL resolvor for this. Create a new class in the Helpers folder called PhotoUrlResolver.cs:

using API.Dtos;
using AutoMapper;
using Core.Entities;
using Microsoft.Extensions.Configuration;

namespace API.Helpers
{
    public class PhotoUrlResolver : IValueResolver<Photo, PhotoToReturnDto, string>
    {
        private readonly IConfiguration _config;

        public PhotoUrlResolver(IConfiguration config)
        {
            _config = config;
        }

        public string Resolve(Photo source, PhotoToReturnDto destination, string destMember, ResolutionContext context)
        {
            if (!string.IsNullOrEmpty(source.PictureUrl))
            {
                return _config["ApiUrl"] + source.PictureUrl;
            }

            return null;
        }
    }
}

Then update the MappingProfiles.cs:

CreateMap<Photo, PhotoToReturnDto>()
                .ForMember(d => d.PictureUrl, 
                    o => o.MapFrom<PhotoUrlResolver>());

We also want to update the ProductToReturnDto to include the Photos collection for each product when we return a product (we will keep the PictureUrl property here as we have used it extensively in our client app and we set it in AutoMapper to the main photo):

namespace API.Dtos
{
    public class ProductToReturnDto
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public decimal Price { get; set; }
        public string PictureUrl { get; set; }
        public string ProductType { get; set; }
        public string ProductBrand { get; set; }
        public IEnumerable<PhotoToReturnDto> Photos { get; set; }
    }
}

Almost there for the refactoring! We next need to update the StoreContextSeed as we want to add a new Photo for each of the images we have stored in the product image folder. To accommodate this we will need to create a new class that we can deserialise the JSON into as we no longer have the PictureUrl property on the product. In the Data folder in the Infrastructure project create a new class called ProductSeedModel.cs:

namespace Infrastructure.Data
{
    public class ProductSeedModel
    {
        public string Name { get; set; }
        public string Description { get; set; }
        public string PictureUrl { get; set; }
        public decimal Price { get; set; }
        public int ProductTypeId { get; set; }
        public int ProductBrandId { get; set; }   
    }
}

Then in the StoreContextSeed.cs update the seed method where we add the product data:

if (!context.Products.Any())
                {
                    var productsData =
                        File.ReadAllText(path + @"/Data/SeedData/products.json");

                    var products = JsonSerializer.Deserialize<List<ProductSeedModel>>(productsData);

                    foreach (var item in products)
                    {
                        var pictureFileName = item.PictureUrl.Substring(16);
                        var product = new Product
                        {
                            Name = item.Name,
                            Description = item.Description,
                            Price = item.Price,
                            ProductBrandId = item.ProductBrandId,
                            ProductTypeId = item.ProductTypeId
                        };
                        product.AddPhoto(item.PictureUrl, pictureFileName);
                        context.Products.Add(product);
                    }

                    await context.SaveChangesAsync();
                }

Note that we use the AddPhoto method from the Product.cs class to actually add the photo here. Since each product will have no photos in their photos collection this AddPhoto method will also set each photo to be the main photo.

We also need to update the ProductsWithTypesAndBrandsSpecification to include the Photos when we return a product:

    public class ProductsWithTypesAndBrandsSpecification : BaseSpecification<Product>
    {
        public ProductsWithTypesAndBrandsSpecification(ProductSpecParams productParams) 
            : base(x => 
                (string.IsNullOrEmpty(productParams.Search) || x.Name.ToLower().Contains(productParams.Search)) &&
                (!productParams.BrandId.HasValue || x.ProductBrandId == productParams.BrandId) &&
                (!productParams.TypeId.HasValue || x.ProductTypeId == productParams.TypeId)
            )
        {
            AddInclude(x => x.ProductType);
            AddInclude(x => x.ProductBrand);
            AddInclude(x => x.Photos);
            AddOrderBy(x => x.Name);
// omitted

        public ProductsWithTypesAndBrandsSpecification(int id) 
            : base(x => x.Id == id)
        {
            AddInclude(x => x.ProductType);
            AddInclude(x => x.ProductBrand);
            AddInclude(x => x.Photos);
        }

Now all we need to do is drop the Database again, remove the existing migrations, create a new migration then re-run the app and we should be in a good place to actually add the functionality to add photos for the products. Run the following command:

dotnet ef database drop -p Infrastructure -s API -c StoreContext 

Then you will need to delete the migrations folder in the Infrastructure/Data folder. Then run the following command:

dotnet ef migrations add PhotoEntityAdded -p Infrastructure -s API -c StoreContext -o Data/Migrations

Then restart the API server and the database should be seeded with the data once again, but this time we should have a separate table for the Photos.

Before you commit the changes here you will want to double check that you can use all the CRUD operations as we had before for the products – everythign should be almost the same except we are returning an array of Photo objects for each product now instead of just the PictureUrl.

Now that we have the refactoring out of the way, in the next part we will deal with the functionality to allow photo uploads here. The commit for this part of the tutorial can be found here.

Next up we will add the logic so that we can add, remove and set a photo to be the main photo for a product.