In this part we will actually add the functionality to be able to upload photos to our API. So that we do not need to add any additonal services here we will just add the uploaded images to the existing folder that we are using that we are storing the existing images in. Now, in the real world this might not be what you want to do and you do need to be careful allowing file uploads to your own server as this comes with some risk. Specifically:

  1. Adding a file upload system that adds files to your server will obviously consume disk space. You will want to be careful about running out of space as if this is the drive that runs the OS then it will bring your whole server down if it runs out of space.
  2. You need to be careful with file/folder permissions. You need to enable read/write on the folder but make sure that it does not have execute permissions, you do not want any file to be ‘executed’ on the server.

Now the warnings are out the way lets add the code! Now we have already made a start on this by adding the AddPhoto logic to the Product.cs entity class – the reason for this is so that we can only add a photo each products own photos collection. Here is the relevant code from the Product.cs class:

        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);
        }

Lets add 2 additonal methods in here as well. One method to remove a photo from the Photos collection for a product and also a method that will set a photo to be the main photo. Add the following additional methods here:

        public void RemovePhoto(int id)
        {
            var photo = _photos.Find(x => x.Id == id);
            _photos.Remove(photo);
        }

        public void SetMainPhoto(int id)
        {
            var currentMain = _photos.SingleOrDefault(item => item.IsMain);
            foreach (var item in _photos.Where(item => item.IsMain))
            {
                item.IsMain = false;
            }
            
            var photo = _photos.Find(x => x.Id == id);
            if (photo != null)
            {
                photo.IsMain = true;
                if (currentMain != null) currentMain.IsMain = false;
            }
        }

The SetMainPhoto has a bit of additional logic to set the existing main photo to not be the main photo – there can be only one main photo!

We want to be able to save the photos to disk – in order to do this we will create a PhotoService and corresponding interface here so that we do not have the saving to disk logic inside our controllers. Add a new Interface called IPhotoService.cs in the Interfaces folder inside the Core project:

using System.Threading.Tasks;
using Core.Entities;
using Microsoft.AspNetCore.Http;

namespace Core.Interfaces
{
    public interface IPhotoService
    {
        Task<Photo> SaveToDiskAsync(IFormFile photo);
        void DeleteFromDisk(Photo photo);
    }
}

You will get an error here as the IFormFile is not recognised. In order to make use of this class we need to add an additonal nuget package to the Core project so go ahead and use Nuget package manager to add the Microsoft.AspNetCore.Http.Features package or run the following command inside the Core project folder:

dotnet add package Microsoft.AspNetCore.Http.Features --version 3.1.3

Next lets add the implementation class for this. Add a new class called PhotoService.cs in the Services folder in the Infrastructure project:

using System;
using System.IO;
using System.Threading.Tasks;
using Core.Entities;
using Core.Interfaces;
using Microsoft.AspNetCore.Http;

namespace Infrastructure.Services
{
    public class PhotoService : IPhotoService
    {
        public async Task<Photo> SaveToDiskAsync(IFormFile file)
        {
            var photo = new Photo();
            if (file.Length > 0)
            {
                var fileName = Guid.NewGuid() + Path.GetExtension(file.FileName);
                var filePath = Path.Combine("Content/images/products", fileName);
                await using var fileStream = new FileStream(filePath, FileMode.Create);
                await file.CopyToAsync(fileStream);

                photo.FileName = fileName;
                photo.PictureUrl = "images/products/" + fileName;

                return photo;
            }

            return null;
        }

        public void DeleteFromDisk(Photo photo)
        {
            if (File.Exists(Path.Combine("Content/images/products", photo.FileName)))
            {
                File.Delete("Content/images/products/" + photo.FileName);
            }
        }
    }
}

So we are using the CopyToAsync method of the IFormFile class to copy this into the path we specify with the FileStream. This effectively will copy this file into our Content/images/products folder. Note that we are using a Guid for the filename here – since we are just using a single folder we do not want any conflicting names so we are just swapping the existing file name for a new, random name. We then return a Photo object that contains both the FileName and the PictureUrl which we will make use of in the controller.

Next up, lets create a DTO that we will use as our parameter in the AddProductPhoto method. In the Dtos folder create a new class called ProductPhotoDto.cs:

using API.Helpers;
using Microsoft.AspNetCore.Http;

namespace API.Dtos
{
    public class ProductPhotoDto
    {
        public IFormFile Photo { get; set; }
    }
}

We also need to add the new PhotoService to our Startup configuration so that we can inject it where we need to. Add the following to the ApplicationServiceExtensions.cs class in the Extensions folder:

services.AddScoped<IPhotoService, PhotoService>();

We will use the IFormFile class in the DTO which represents a file sent with an HTTP request. We also want to add some validation here to make sure that we only receive files with that are either Jpgs or Pngs and since we don’t want users to be able to upload gigantic files we also want to prevent our server accepting them. There are no default validators for this so we need to make our own. Now, the chances of us being the very first to need to validate file size and type in a .net application is about as likely as me being selected to play for the Arsenal first team so a quick google search for some key words such as “.net core validate file size type” leads to this Stack Overflow post which has just what we need.

So lets create 2 new classes for some custom validation attributes – one for each type of validation so each one just has a single responsibility. First create a new class in the Helpers folder in the API called AllowedExtensionsAttribute.cs

using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using Microsoft.AspNetCore.Http;

namespace API.Helpers
{
    public class AllowedExtensionsAttribute : ValidationAttribute
    {
        private readonly string[] _extensions;
        public AllowedExtensionsAttribute(string[] extensions)
        {
            _extensions = extensions;
        }

        protected override ValidationResult IsValid(
            object value, ValidationContext validationContext)
        {
            var file = value as IFormFile;
            
            if (file != null)
            {
                var extension = Path.GetExtension(file.FileName);
                if (extension != null && !_extensions.Contains(extension.ToLower()))
                {
                    return new ValidationResult(GetErrorMessage());
                }
            }

            return ValidationResult.Success;
        }

        private string GetErrorMessage()
        {
            return $"This file extension is not allowed!";
        }
    }
}

Then create another new class in the same folder called MaxFileSizeAttribute.cs

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http;

namespace API.Helpers
{
    public class MaxFileSizeAttribute : ValidationAttribute
    {
        private readonly int _maxFileSize;
        public MaxFileSizeAttribute(int maxFileSize)
        {
            _maxFileSize = maxFileSize;
        }

        protected override ValidationResult IsValid(
            object value, ValidationContext validationContext)
        {
            var file = value as IFormFile;
            if (file != null)
            {
                if (file.Length > _maxFileSize)
                {
                    return new ValidationResult(GetErrorMessage());
                }
            }

            return ValidationResult.Success;
        }

        private string GetErrorMessage()
        {
            return $"Maximum allowed file size is { _maxFileSize} bytes.";
        }
    }
}

Then in order to use these we can just update the ProductPhotoDto.cs:

using API.Helpers;
using Microsoft.AspNetCore.Http;

namespace API.Dtos
{
    public class ProductPhotoDto
    {
        [MaxFileSize(2 * 1024 * 1024)]
        [AllowedExtensions(new[] {".jpg", ".png", ".jpeg"})]
        public IFormFile Photo { get; set; }
    }
}

Now our API server will only accept files that are less than 2Mb in size and have a file extension of either .jpg, .png or .jpeg.

With this in place we can now add the functionality we need to the ProductsController.cs. First of all lets add the AddProductPhoto method and inject our new IPhotoService into the controller:

[HttpPut("{id}/photo")]
        [Authorize(Roles = "Admin")]
        public async Task<ActionResult<ProductToReturnDto>> AddProductPhoto(int id, [FromForm]ProductPhotoDto photoDto)
        {
            var spec = new ProductsWithTypesAndBrandsSpecification(id);
            var product = await _unitOfWork.Repository<Product>().GetEntityWithSpec(spec);

            if (photoDto.Photo.Length > 0)
            {
                var photo = await _photoService.SaveToDiskAsync(photoDto.Photo);

                if (photo != null)
                {
                    product.AddPhoto(photo.PictureUrl, photo.FileName);

                    _unitOfWork.Repository<Product>().Update(product);
                
                    var result = await _unitOfWork.Complete();
                
                    if (result <= 0) return BadRequest(new ApiResponse(400, "Problem adding photo product"));
                }
                else
                {
                    return BadRequest(new ApiResponse(400, "problem saving photo to disk"));
                }
            }
            
            return _mapper.Map<Product, ProductToReturnDto>(product);
        }

First of all in this method we get the product with spec so we get the photos collection with the product. We want to return the product after creation so we need the includes methods here so that we also return the brand, type and photos for the product. We are using the PhotoService we created earlier so we also need to inject this into the ProductsController:

    public class ProductsController : BaseApiController
    {
        private readonly IMapper _mapper;
        private readonly IUnitOfWork _unitOfWork;
        private readonly IPhotoService _photoService;

        public ProductsController(IMapper mapper, IUnitOfWork unitOfWork, IPhotoService photoService)
        {
            _mapper = mapper;
            _unitOfWork = unitOfWork;
            _photoService = photoService;
        }

Also, the PhotoService will return null if there is an issue saving to disk or will throw an exception so we add a check to see if the photo is null after we call the SaveToDiskAsync method. We should now be able to check to see if our efforts have been successful. We will do our testing on a new product rather than the ones we have been seeding so we can make sure the new photo is set to the main photo.

Then lets test the photo upload function:

When testing make sure that you have selected the form-data in Postman, you have a jpg or png image and its less than 2Mb. You should find this uploads successfully and is given a guid as the filename here. It will also be set as the main photo. If we send again the same photo will be uploaded but given a different name and will not be set as the main image.

If we try and send up a file that is either too large or is not an image file our API will reject it:

Ok good – so we can upload images. Lets now work on removing an image. We will not let the main photo be deleted but all the others will be fair game for deletion. Lets now add the DeleteProductPhoto method:

[HttpDelete("{id}/photo/{photoId}")]
        [Authorize(Roles = "Admin")]
        public async Task<ActionResult> DeleteProductPhoto(int id, int photoId)
        {
            var spec = new ProductsWithTypesAndBrandsSpecification(id);
            var product = await _unitOfWork.Repository<Product>().GetEntityWithSpec(spec);
            
            var photo = product.Photos.SingleOrDefault(x => x.Id == photoId);

            if (photo != null)
            {
                if (photo.IsMain)
                    return BadRequest(new ApiResponse(400,
                        "You cannot delete the main photo"));

                _photoService.DeleteFromDisk(photo);
            }
            else
            {
                return BadRequest(new ApiResponse(400, "Photo does not exist"));
            }

            product.RemovePhoto(photoId);
            
            _unitOfWork.Repository<Product>().Update(product);
            
            var result = await _unitOfWork.Complete();
            
            if (result <= 0) return BadRequest(new ApiResponse(400, "Problem adding photo product"));

            return Ok();
        }

This method is quite long but fairly straight forward. We get the product (with photos) from the repo that matches the route parameter for the photoId, we make sure the photo is not null, we make sure it is not the main photo, then we delete it from the disk and then remove the photo from the product photo collection. Lets now test by attempting to delete (in my case) the photo with Id of 31, from the product with the Id of 24.

You should find that the file is removed from the product and the file is also removed from the images/products folder in the API. One more method to go, the SetMainPhoto method:

[HttpPost("{id}/photo/{photoId}")]
        [Authorize(Roles = "Admin")]
        public async Task<ActionResult<ProductToReturnDto>> SetMainPhoto(int id, int photoId)
        {
            var spec = new ProductsWithTypesAndBrandsSpecification(id);
            var product = await _unitOfWork.Repository<Product>().GetEntityWithSpec(spec);

            if (product.Photos.All(x => x.Id != photoId)) return NotFound();
            
            product.SetMainPhoto(photoId);
            
            _unitOfWork.Repository<Product>().Update(product);
            
            var result = await _unitOfWork.Complete();
            
            if (result <= 0) return BadRequest(new ApiResponse(400, "Problem adding photo product"));

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

We should now be able to set any photo that is not already the main photo for a product to be so:

Phew! Quite a bit of work to add this to the API! Next up we will take a look at the Angular client of course and add this functionality to the client. Since we have the ability to upload mulitple images for a product we will also look at adding a gallery to display them. That is coming up next.

The commit for this part can be found here.