diff --git a/OutOfSchool/OutOfSchool.Common/OutOfSchool.Common.csproj b/OutOfSchool/OutOfSchool.Common/OutOfSchool.Common.csproj index 1e36afcdae..30eba535d2 100644 --- a/OutOfSchool/OutOfSchool.Common/OutOfSchool.Common.csproj +++ b/OutOfSchool/OutOfSchool.Common/OutOfSchool.Common.csproj @@ -16,7 +16,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/OutOfSchool/OutOfSchool.DataAccess/Common/Exceptions/ImageStorageException.cs b/OutOfSchool/OutOfSchool.DataAccess/Common/Exceptions/FileStorageException.cs similarity index 60% rename from OutOfSchool/OutOfSchool.DataAccess/Common/Exceptions/ImageStorageException.cs rename to OutOfSchool/OutOfSchool.DataAccess/Common/Exceptions/FileStorageException.cs index ad91f7eb83..09fa2373a8 100644 --- a/OutOfSchool/OutOfSchool.DataAccess/Common/Exceptions/ImageStorageException.cs +++ b/OutOfSchool/OutOfSchool.DataAccess/Common/Exceptions/FileStorageException.cs @@ -8,29 +8,28 @@ namespace OutOfSchool.Services.Common.Exceptions /// to work with image storage. /// [Serializable] - public class ImageStorageException : Exception + public class FileStorageException : Exception { - - public ImageStorageException() + public FileStorageException() { } - public ImageStorageException(Exception ex) + public FileStorageException(Exception ex) : this("Unhandled exception", ex) { } - public ImageStorageException(string message) + public FileStorageException(string message) : base(message) { } - public ImageStorageException(string message, Exception innerException) + public FileStorageException(string message, Exception innerException) : base(message, innerException) { } - protected ImageStorageException(SerializationInfo info, StreamingContext context) + protected FileStorageException(SerializationInfo info, StreamingContext context) : base(info, context) { } diff --git a/OutOfSchool/OutOfSchool.DataAccess/Contexts/Configuration/GcpStorageImagesSourceConfig.cs b/OutOfSchool/OutOfSchool.DataAccess/Contexts/Configuration/GcpStorageImagesSourceConfig.cs new file mode 100644 index 0000000000..3d9eaa49ea --- /dev/null +++ b/OutOfSchool/OutOfSchool.DataAccess/Contexts/Configuration/GcpStorageImagesSourceConfig.cs @@ -0,0 +1,9 @@ +namespace OutOfSchool.Services.Contexts.Configuration +{ + /// + /// Contains a configuration that is essential for operations with images in Google Cloud Storage. + /// + public class GcpStorageImagesSourceConfig : GcpStorageSourceConfig + { + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.DataAccess/Contexts/Configuration/GcpStorageSourceConfig.cs b/OutOfSchool/OutOfSchool.DataAccess/Contexts/Configuration/GcpStorageSourceConfig.cs new file mode 100644 index 0000000000..64a27db37d --- /dev/null +++ b/OutOfSchool/OutOfSchool.DataAccess/Contexts/Configuration/GcpStorageSourceConfig.cs @@ -0,0 +1,18 @@ +namespace OutOfSchool.Services.Contexts.Configuration +{ + /// + /// Contains a configuration that is essential for Google Cloud Storage. + /// + public abstract class GcpStorageSourceConfig + { + /// + /// Gets or sets a file of Google credential. + /// + public string CredentialFilePath { get; set; } // Should be set in environment variables + + /// + /// Gets or sets a bucket name of Google Storage. + /// + public string BucketName { get; set; } + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.DataAccess/Contexts/GcpStorageContext.cs b/OutOfSchool/OutOfSchool.DataAccess/Contexts/GcpStorageContext.cs new file mode 100644 index 0000000000..221db949d1 --- /dev/null +++ b/OutOfSchool/OutOfSchool.DataAccess/Contexts/GcpStorageContext.cs @@ -0,0 +1,18 @@ +using System; +using Google.Cloud.Storage.V1; + +namespace OutOfSchool.Services.Contexts +{ + public class GcpStorageContext : IGcpStorageContext + { + public GcpStorageContext(StorageClient client, string bucketName) + { + StorageClient = client ?? throw new ArgumentNullException(nameof(client)); + BucketName = bucketName ?? throw new ArgumentNullException(nameof(bucketName)); + } + + public StorageClient StorageClient { get; } + + public string BucketName { get; } + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.DataAccess/Contexts/IGcpStorageContext.cs b/OutOfSchool/OutOfSchool.DataAccess/Contexts/IGcpStorageContext.cs new file mode 100644 index 0000000000..fda76ce33d --- /dev/null +++ b/OutOfSchool/OutOfSchool.DataAccess/Contexts/IGcpStorageContext.cs @@ -0,0 +1,11 @@ +using Google.Cloud.Storage.V1; + +namespace OutOfSchool.Services.Contexts +{ + public interface IGcpStorageContext + { + StorageClient StorageClient { get; } + + string BucketName { get; } + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.DataAccess/Extensions/GcpExtension.cs b/OutOfSchool/OutOfSchool.DataAccess/Extensions/GcpExtension.cs new file mode 100644 index 0000000000..80f54ff6c6 --- /dev/null +++ b/OutOfSchool/OutOfSchool.DataAccess/Extensions/GcpExtension.cs @@ -0,0 +1,18 @@ +using Google.Apis.Auth.OAuth2; +using OutOfSchool.Services.Contexts.Configuration; + +namespace OutOfSchool.Services.Extensions +{ + public static class GcpExtension + { + public static GoogleCredential RetrieveGoogleCredential(this GcpStorageSourceConfig config) + { + if (string.IsNullOrEmpty(config.CredentialFilePath)) + { + return GoogleCredential.GetApplicationDefault(); + } + + return GoogleCredential.FromFile(config.CredentialFilePath); + } + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.DataAccess/Models/FileModel.cs b/OutOfSchool/OutOfSchool.DataAccess/Models/FileModel.cs new file mode 100644 index 0000000000..1481e89aaa --- /dev/null +++ b/OutOfSchool/OutOfSchool.DataAccess/Models/FileModel.cs @@ -0,0 +1,11 @@ +using System.IO; + +namespace OutOfSchool.Services.Models +{ + public class FileModel + { + public Stream ContentStream { get; set; } + + public string ContentType { get; set; } + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.DataAccess/Models/Images/ExternalImageModel.cs b/OutOfSchool/OutOfSchool.DataAccess/Models/Images/ImageFileModel.cs similarity index 52% rename from OutOfSchool/OutOfSchool.DataAccess/Models/Images/ExternalImageModel.cs rename to OutOfSchool/OutOfSchool.DataAccess/Models/Images/ImageFileModel.cs index 6a525e7786..5e4044628b 100644 --- a/OutOfSchool/OutOfSchool.DataAccess/Models/Images/ExternalImageModel.cs +++ b/OutOfSchool/OutOfSchool.DataAccess/Models/Images/ImageFileModel.cs @@ -5,10 +5,7 @@ namespace OutOfSchool.Services.Models.Images { - public class ExternalImageModel + public class ImageFileModel : FileModel { - public Stream ContentStream { get; set; } - - public string ContentType { get; set; } } } diff --git a/OutOfSchool/OutOfSchool.DataAccess/OutOfSchool.DataAccess.csproj b/OutOfSchool/OutOfSchool.DataAccess/OutOfSchool.DataAccess.csproj index 9074c73e43..821b86060f 100644 --- a/OutOfSchool/OutOfSchool.DataAccess/OutOfSchool.DataAccess.csproj +++ b/OutOfSchool/OutOfSchool.DataAccess/OutOfSchool.DataAccess.csproj @@ -23,6 +23,7 @@ + @@ -32,7 +33,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all diff --git a/OutOfSchool/OutOfSchool.DataAccess/Repository/ExternalImageStorage.cs b/OutOfSchool/OutOfSchool.DataAccess/Repository/ExternalImageStorage.cs index 82d0d75e5f..267686e83f 100644 --- a/OutOfSchool/OutOfSchool.DataAccess/Repository/ExternalImageStorage.cs +++ b/OutOfSchool/OutOfSchool.DataAccess/Repository/ExternalImageStorage.cs @@ -27,7 +27,7 @@ public ExternalImageStorage(MongoDb db) gridFsBucket = db.GetContext(); } - public async Task GetByIdAsync(string imageId) + public async Task GetByIdAsync(string imageId) { _ = imageId ?? throw new ArgumentNullException(nameof(imageId)); try @@ -35,31 +35,31 @@ public async Task GetByIdAsync(string imageId) var result = await gridFsBucket.OpenDownloadStreamAsync(new ObjectId(imageId)) ?? throw new InvalidOperationException($"Unreal to get non-nullable {nameof(GridFSDownloadStream)} instance."); // think about searching by file name var contentType = result.FileInfo.Metadata[ContentType].AsString; - return new ExternalImageModel { ContentStream = result, ContentType = contentType }; + return new ImageFileModel { ContentStream = result, ContentType = contentType }; } catch (Exception ex) { - throw new ImageStorageException(ex); + throw new FileStorageException(ex); } } - public async Task UploadImageAsync(ExternalImageModel imageModel, CancellationToken cancellationToken = default) + public async Task UploadImageAsync(ImageFileModel imageFileModel, CancellationToken cancellationToken = default) { - _ = imageModel ?? throw new ArgumentNullException(nameof(imageModel)); + _ = imageFileModel ?? throw new ArgumentNullException(nameof(imageFileModel)); try { - imageModel.ContentStream.Position = uint.MinValue; + imageFileModel.ContentStream.Position = uint.MinValue; var options = new GridFSUploadOptions { - Metadata = new BsonDocument(ContentType, imageModel.ContentType), + Metadata = new BsonDocument(ContentType, imageFileModel.ContentType), }; - var objectId = await gridFsBucket.UploadFromStreamAsync(Guid.NewGuid().ToString(), imageModel.ContentStream, options, cancellationToken: cancellationToken).ConfigureAwait(false); + var objectId = await gridFsBucket.UploadFromStreamAsync(Guid.NewGuid().ToString(), imageFileModel.ContentStream, options, cancellationToken: cancellationToken).ConfigureAwait(false); return objectId.ToString(); } catch (Exception ex) { - throw new ImageStorageException(ex); + throw new FileStorageException(ex); } } @@ -72,7 +72,7 @@ public async Task DeleteImageAsync(string imageId, CancellationToken cancellatio } catch (Exception ex) { - throw new ImageStorageException(ex); + throw new FileStorageException(ex); } } } diff --git a/OutOfSchool/OutOfSchool.DataAccess/Repository/Files/GcpFilesStorageBase.cs b/OutOfSchool/OutOfSchool.DataAccess/Repository/Files/GcpFilesStorageBase.cs new file mode 100644 index 0000000000..511a10b0eb --- /dev/null +++ b/OutOfSchool/OutOfSchool.DataAccess/Repository/Files/GcpFilesStorageBase.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Google.Apis.Auth.OAuth2; +using Google.Cloud.Storage.V1; +using OutOfSchool.Services.Common.Exceptions; +using OutOfSchool.Services.Contexts; +using OutOfSchool.Services.Contexts.Configuration; +using OutOfSchool.Services.Extensions; +using OutOfSchool.Services.Models; + +namespace OutOfSchool.Services.Repository.Files +{ + public abstract class GcpFilesStorageBase : IFilesStorage + where TFile : FileModel, new() + { + protected GcpFilesStorageBase(IGcpStorageContext storageContext) + { + StorageClient = storageContext.StorageClient; + BucketName = storageContext.BucketName; + } + + private protected StorageClient StorageClient { get; } + + private protected string BucketName { get; } + + /// + public virtual async Task GetByIdAsync(string fileId, CancellationToken cancellationToken = default) + { + _ = fileId ?? throw new ArgumentNullException(nameof(fileId)); + try + { + var fileObject = await StorageClient.GetObjectAsync( + BucketName, + fileId, + cancellationToken: cancellationToken); + + var fileStream = new MemoryStream(); + await StorageClient.DownloadObjectAsync( + fileObject, + fileStream, + cancellationToken: cancellationToken); + + fileStream.Position = 0; + return new TFile { ContentStream = fileStream, ContentType = fileObject.ContentType }; + } + catch (Exception ex) + { + throw new FileStorageException(ex); + } + } + + /// + public virtual async Task UploadAsync(TFile file, CancellationToken cancellationToken = default) + { + _ = file ?? throw new ArgumentNullException(nameof(file)); + + try + { + var fileId = GenerateFileId(); + var dataObject = await StorageClient.UploadObjectAsync( + BucketName, + fileId, + file.ContentType, + file.ContentStream, + cancellationToken: cancellationToken); + return dataObject.Name; + } + catch (Exception ex) + { + throw new FileStorageException(ex); + } + } + + /// + public virtual async Task DeleteAsync(string fileId, CancellationToken cancellationToken = default) + { + _ = fileId ?? throw new ArgumentNullException(nameof(fileId)); + try + { + await StorageClient.DeleteObjectAsync(BucketName, fileId, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + throw new FileStorageException(ex); + } + } + + /// + /// This method generates a unique value that is used as file identifier. + /// + /// + /// The result contains a string value of the file if it's uploaded. + /// + protected virtual string GenerateFileId() + { + return Guid.NewGuid().ToString(); + } + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.DataAccess/Repository/Files/GcpImagesStorage.cs b/OutOfSchool/OutOfSchool.DataAccess/Repository/Files/GcpImagesStorage.cs new file mode 100644 index 0000000000..4bcebddac8 --- /dev/null +++ b/OutOfSchool/OutOfSchool.DataAccess/Repository/Files/GcpImagesStorage.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using OutOfSchool.Services.Contexts; +using OutOfSchool.Services.Contexts.Configuration; +using OutOfSchool.Services.Models.Images; + +namespace OutOfSchool.Services.Repository.Files +{ + public class GcpImagesStorage : GcpFilesStorageBase, IImageFilesStorage + { + public GcpImagesStorage(IGcpStorageContext storageContext) + : base(storageContext) + { + } + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.DataAccess/Repository/Files/IFilesStorage.cs b/OutOfSchool/OutOfSchool.DataAccess/Repository/Files/IFilesStorage.cs new file mode 100644 index 0000000000..bd1cca110d --- /dev/null +++ b/OutOfSchool/OutOfSchool.DataAccess/Repository/Files/IFilesStorage.cs @@ -0,0 +1,39 @@ +using System.Threading; +using System.Threading.Tasks; +using OutOfSchool.Services.Models; +using OutOfSchool.Services.Models.Images; + +namespace OutOfSchool.Services.Repository.Files +{ + public interface IFilesStorage + where TFile : FileModel + { + /// + /// Asynchronously gets a file by its id. + /// + /// File id. + /// CancellationToken. + /// A representing the result of the asynchronous operation. + /// The task result contains a file of type . + /// + Task GetByIdAsync(TIdentifier fileId, CancellationToken cancellationToken = default); + + /// + /// Asynchronously uploads a file into the storage. + /// + /// File. + /// CancellationToken. + /// A representing the result of the asynchronous operation. + /// The task result contains an identifier of the file if it's uploaded. + /// + Task UploadAsync(TFile file, CancellationToken cancellationToken = default); + + /// + /// Asynchronously deletes a file from the storage. + /// + /// File id. + /// CancellationToken. + /// A representing the result of the asynchronous operation. + Task DeleteAsync(TIdentifier fileId, CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.DataAccess/Repository/Files/IImageFilesStorage.cs b/OutOfSchool/OutOfSchool.DataAccess/Repository/Files/IImageFilesStorage.cs new file mode 100644 index 0000000000..9db56f1daf --- /dev/null +++ b/OutOfSchool/OutOfSchool.DataAccess/Repository/Files/IImageFilesStorage.cs @@ -0,0 +1,8 @@ +using OutOfSchool.Services.Models.Images; + +namespace OutOfSchool.Services.Repository.Files +{ + public interface IImageFilesStorage : IFilesStorage + { + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.DataAccess/Repository/IExternalImageStorage.cs b/OutOfSchool/OutOfSchool.DataAccess/Repository/IExternalImageStorage.cs index 63d4413fdf..6f60e57f68 100644 --- a/OutOfSchool/OutOfSchool.DataAccess/Repository/IExternalImageStorage.cs +++ b/OutOfSchool/OutOfSchool.DataAccess/Repository/IExternalImageStorage.cs @@ -11,9 +11,9 @@ namespace OutOfSchool.Services.Repository { public interface IExternalImageStorage { - Task GetByIdAsync(string imageId); + Task GetByIdAsync(string imageId); - Task UploadImageAsync(ExternalImageModel imageModel, CancellationToken cancellationToken = default); + Task UploadImageAsync(ImageFileModel imageFileModel, CancellationToken cancellationToken = default); Task DeleteImageAsync(string imageId, CancellationToken cancellationToken = default); } diff --git a/OutOfSchool/OutOfSchool.IdentityServer/OutOfSchool.IdentityServer.csproj b/OutOfSchool/OutOfSchool.IdentityServer/OutOfSchool.IdentityServer.csproj index c17ac7716c..389e951a75 100644 --- a/OutOfSchool/OutOfSchool.IdentityServer/OutOfSchool.IdentityServer.csproj +++ b/OutOfSchool/OutOfSchool.IdentityServer/OutOfSchool.IdentityServer.csproj @@ -27,7 +27,7 @@ - + diff --git a/OutOfSchool/OutOfSchool.Redis/OutOfSchool.Redis.csproj b/OutOfSchool/OutOfSchool.Redis/OutOfSchool.Redis.csproj index 1f45f0b194..69ecb69c0a 100644 --- a/OutOfSchool/OutOfSchool.Redis/OutOfSchool.Redis.csproj +++ b/OutOfSchool/OutOfSchool.Redis/OutOfSchool.Redis.csproj @@ -6,7 +6,7 @@ - + diff --git a/OutOfSchool/OutOfSchool.WebApi.Tests/OutOfSchool.WebApi.Tests.csproj b/OutOfSchool/OutOfSchool.WebApi.Tests/OutOfSchool.WebApi.Tests.csproj index 895fe3febc..b43f495c04 100644 --- a/OutOfSchool/OutOfSchool.WebApi.Tests/OutOfSchool.WebApi.Tests.csproj +++ b/OutOfSchool/OutOfSchool.WebApi.Tests/OutOfSchool.WebApi.Tests.csproj @@ -30,7 +30,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/OutOfSchool/OutOfSchool.WebApi.Tests/Services/Images/ImageServiceTests.cs b/OutOfSchool/OutOfSchool.WebApi.Tests/Services/Images/ImageServiceTests.cs index bcff87c114..f5208923f2 100644 --- a/OutOfSchool/OutOfSchool.WebApi.Tests/Services/Images/ImageServiceTests.cs +++ b/OutOfSchool/OutOfSchool.WebApi.Tests/Services/Images/ImageServiceTests.cs @@ -17,6 +17,7 @@ using OutOfSchool.Services.Common.Exceptions; using OutOfSchool.Services.Models.Images; using OutOfSchool.Services.Repository; +using OutOfSchool.Services.Repository.Files; using OutOfSchool.WebApi.Common; using OutOfSchool.WebApi.Common.Resources.Codes; using OutOfSchool.WebApi.Services.Images; @@ -28,12 +29,12 @@ internal class ImageServiceTests { #region TestData - private static readonly IReadOnlyList> ExternalImageModelsWithMockedStreamTestDataSource; + private static readonly IReadOnlyList> ExternalImageModelsWithMockedStreamTestDataSource; private static readonly IReadOnlyList> ImageIdsTestDataSource; #endregion - private Mock externalStorageMock; + private Mock externalStorageMock; private Mock serviceProviderMock; private Mock> loggerMock; private IImageService imageService; @@ -47,7 +48,7 @@ static ImageServiceTests() [SetUp] public void SetUp() { - externalStorageMock = new Mock(); + externalStorageMock = new Mock(); serviceProviderMock = new Mock(); loggerMock = new Mock>(); imageService = new ImageService( @@ -64,7 +65,7 @@ public async Task GetById_WhenImageWithThisIdExists_ShouldReturnSuccessfulResult // Arrange var imageId = TakeFirstFromTestData(ImageIdsTestDataSource); var externalImageModel = TakeFirstFromTestData(ExternalImageModelsWithMockedStreamTestDataSource); - externalStorageMock.Setup(x => x.GetByIdAsync(imageId)). + externalStorageMock.Setup(x => x.GetByIdAsync(imageId, CancellationToken.None)). ReturnsAsync(externalImageModel); // Act @@ -81,7 +82,7 @@ public async Task GetById_WhenImageWithThisIdNotExists_ShouldReturnFailedResultO { // Arrange var imageId = TakeFirstFromTestData(ImageIdsTestDataSource); - externalStorageMock.Setup(x => x.GetByIdAsync(It.IsAny())).ThrowsAsync(new ImageStorageException()); + externalStorageMock.Setup(x => x.GetByIdAsync(It.IsAny(), CancellationToken.None)).ThrowsAsync(new FileStorageException()); // Act var result = await imageService.GetByIdAsync(imageId); @@ -115,7 +116,7 @@ public async Task SetUpValidatorWithOperationResult(true); var queue = new Queue(imageIds); externalStorageMock - .Setup(x => x.UploadImageAsync(It.IsAny(), It.IsAny())) + .Setup(x => x.UploadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(queue.Dequeue); // Act @@ -159,7 +160,7 @@ public void { // Arrange var images = CreateMockedFormFiles(2); - serviceProviderMock.Setup(x => x.GetService(typeof(IImageValidatorService))).Returns(null); + serviceProviderMock.Setup(x => x.GetService(typeof(IImageValidator))).Returns(null); // Act & Assert imageService.Invoking(x => x.UploadManyImagesAsync(images)).Should() @@ -174,16 +175,16 @@ public async Task const byte countOfImages = 4, countOfValidImages = 2; var images = CreateMockedFormFiles(countOfImages); var imageIds = TakeFromTestData(ImageIdsTestDataSource, countOfValidImages); - var validator = new Mock>(); + var validator = new Mock(); validator.SetupSequence(x => x.Validate(It.IsAny())) .Returns(OperationResult.Success) .Returns(OperationResult.Failed()) .Returns(OperationResult.Failed()) .Returns(OperationResult.Success); - serviceProviderMock.Setup(x => x.GetService(typeof(IImageValidatorService))).Returns(validator.Object); + serviceProviderMock.Setup(x => x.GetService(typeof(IImageValidator))).Returns(validator.Object); var queue = new Queue(imageIds); externalStorageMock - .Setup(x => x.UploadImageAsync(It.IsAny(), It.IsAny())) + .Setup(x => x.UploadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(queue.Dequeue); // Act @@ -205,10 +206,10 @@ public async Task var imageIds = TakeFromTestData(ImageIdsTestDataSource, countOfUploadedImages); SetUpValidatorWithOperationResult(true); externalStorageMock - .SetupSequence(x => x.UploadImageAsync(It.IsAny(), It.IsAny())) + .SetupSequence(x => x.UploadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(imageIds[0]) - .ThrowsAsync(new ImageStorageException()) - .ThrowsAsync(new ImageStorageException()) + .ThrowsAsync(new FileStorageException()) + .ThrowsAsync(new FileStorageException()) .ReturnsAsync(imageIds[1]); // Act @@ -229,7 +230,7 @@ public async Task UploadImage_WhenImageIsValid_ShouldReturnSuccessfulResultWithS var imageId = TakeFirstFromTestData(ImageIdsTestDataSource); SetUpValidatorWithOperationResult(true); externalStorageMock - .Setup(x => x.UploadImageAsync(It.IsAny(), It.IsAny())) + .Setup(x => x.UploadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(imageId); // Act @@ -275,8 +276,8 @@ public async Task var imageId = TakeFirstFromTestData(ImageIdsTestDataSource); SetUpValidatorWithOperationResult(true); externalStorageMock - .Setup(x => x.UploadImageAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new ImageStorageException()); + .Setup(x => x.UploadAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new FileStorageException()); // Act var result = await imageService.UploadImageAsync(file); @@ -291,7 +292,7 @@ public void { // Arrange var image = new Mock().Object; - serviceProviderMock.Setup(x => x.GetService(typeof(IImageValidatorService))).Returns(null); + serviceProviderMock.Setup(x => x.GetService(typeof(IImageValidator))).Returns(null); // Act & Assert imageService.Invoking(x => x.UploadImageAsync(image)).Should() @@ -308,7 +309,7 @@ public async Task { // Arrange externalStorageMock - .Setup(x => x.DeleteImageAsync(It.IsAny(), It.IsAny())) + .Setup(x => x.DeleteAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); // Act @@ -354,10 +355,10 @@ public async Task const byte countOfImageIds = 3, countOfDeleted = 1; var imageIds = TakeFromTestData(ImageIdsTestDataSource, countOfImageIds); externalStorageMock - .SetupSequence(x => x.DeleteImageAsync(It.IsAny(), It.IsAny())) + .SetupSequence(x => x.DeleteAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask) - .Throws() - .Throws(); + .Throws() + .Throws(); // Act var result = await imageService.RemoveManyImagesAsync(imageIds); @@ -375,7 +376,7 @@ public async Task RemoveImage_WhenImageIdIsRight_ShouldReturnSuccessfulOperation // Arrange var imageId = TakeFirstFromTestData(ImageIdsTestDataSource); externalStorageMock - .Setup(x => x.DeleteImageAsync(It.IsAny(), It.IsAny())) + .Setup(x => x.DeleteAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); // Act @@ -414,8 +415,8 @@ public async Task RemoveImage_WhenImageIdWasNotRemoved_BecauseItIsIncorrectOrNot // Arrange var imageId = TakeFirstFromTestData(ImageIdsTestDataSource); externalStorageMock - .Setup(x => x.DeleteImageAsync(It.IsAny(), It.IsAny())) - .Throws(); + .Setup(x => x.DeleteAsync(It.IsAny(), It.IsAny())) + .Throws(); // Act var result = await imageService.RemoveImageAsync(imageId); @@ -494,16 +495,16 @@ private static T TakeFirstFromTestData(IReadOnlyList> testDataSource) #region Initializators // Shouldn't make these collections smaller - private static IReadOnlyList> InitializeExternalImageModelsWithMockedStreamsTestDataSource() + private static IReadOnlyList> InitializeExternalImageModelsWithMockedStreamsTestDataSource() { - return new List> + return new List> { - () => new ExternalImageModel + () => new ImageFileModel { ContentStream = new Mock().Object, ContentType = "image/jpeg", }, - () => new ExternalImageModel + () => new ImageFileModel { ContentStream = new Mock().Object, ContentType = "image/png", @@ -535,9 +536,9 @@ private void SetUpValidatorWithOperationResult(bool succeeded) result = () => OperationResult.Failed(); } - var validator = new Mock>(); + var validator = new Mock(); validator.Setup(x => x.Validate(It.IsAny())).Returns(result); - serviceProviderMock.Setup(x => x.GetService(typeof(IImageValidatorService))).Returns(validator.Object); + serviceProviderMock.Setup(x => x.GetService(typeof(IImageValidator))).Returns(validator.Object); } } } diff --git a/OutOfSchool/OutOfSchool.WebApi/Config/DataAccess/GcpStorageConfigConstants.cs b/OutOfSchool/OutOfSchool.WebApi/Config/DataAccess/GcpStorageConfigConstants.cs new file mode 100644 index 0000000000..1b1916c588 --- /dev/null +++ b/OutOfSchool/OutOfSchool.WebApi/Config/DataAccess/GcpStorageConfigConstants.cs @@ -0,0 +1,18 @@ +namespace OutOfSchool.WebApi.Config.DataAccess +{ + /// + /// Contains configuration path keys for Google Cloud Storage. + /// + public static class GcpStorageConfigConstants + { + /// + /// Points Google Cloud Storage Section. + /// + public const string GcpStorageBaseSection = "GoogleCloudPlatform:Storage:"; + + /// + /// Points section for images. + /// + public const string GcpStorageImagesConfig = GcpStorageBaseSection + "OosImages"; + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.WebApi/Controllers/V1/PublicImageController.cs b/OutOfSchool/OutOfSchool.WebApi/Controllers/V1/PublicImageController.cs index 4a7343116d..cc9c05e449 100644 --- a/OutOfSchool/OutOfSchool.WebApi/Controllers/V1/PublicImageController.cs +++ b/OutOfSchool/OutOfSchool.WebApi/Controllers/V1/PublicImageController.cs @@ -28,15 +28,15 @@ public PublicImageController(IImageService imageService) /// /// Gets for a given pictureId. /// - /// This is the image id. + /// This is the image id. /// The result of . [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FileStreamResult))] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [HttpGet("{imageMetadataId}")] - public async Task GetByIdAsync(string imageMetadataId) + [HttpGet("{imageId}")] + public async Task GetByIdAsync(string imageId) { - var imageData = await imageService.GetByIdAsync(imageMetadataId).ConfigureAwait(false); + var imageData = await imageService.GetByIdAsync(imageId).ConfigureAwait(false); if (imageData.Succeeded) { diff --git a/OutOfSchool/OutOfSchool.WebApi/Extensions/Startup/FileStorageExtensions.cs b/OutOfSchool/OutOfSchool.WebApi/Extensions/Startup/FileStorageExtensions.cs new file mode 100644 index 0000000000..9c1292309c --- /dev/null +++ b/OutOfSchool/OutOfSchool.WebApi/Extensions/Startup/FileStorageExtensions.cs @@ -0,0 +1,25 @@ +using Google.Cloud.Storage.V1; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OutOfSchool.Services.Contexts; +using OutOfSchool.Services.Contexts.Configuration; +using OutOfSchool.Services.Extensions; +using OutOfSchool.Services.Repository.Files; + +namespace OutOfSchool.WebApi.Extensions.Startup +{ + public static class FileStorageExtensions + { + public static IServiceCollection AddImagesStorage(this IServiceCollection services) + { + return services.AddTransient(provider => + { + var config = provider.GetRequiredService>(); + var googleCredential = config.Value.RetrieveGoogleCredential(); + var storageClient = StorageClient.Create(googleCredential); + var storageContext = new GcpStorageContext(storageClient, config.Value.BucketName); + return new GcpImagesStorage(storageContext); + }); + } + } +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.WebApi/OutOfSchool.WebApi.csproj b/OutOfSchool/OutOfSchool.WebApi/OutOfSchool.WebApi.csproj index 143d55dbb9..141629ace8 100644 --- a/OutOfSchool/OutOfSchool.WebApi/OutOfSchool.WebApi.csproj +++ b/OutOfSchool/OutOfSchool.WebApi/OutOfSchool.WebApi.csproj @@ -37,7 +37,7 @@ - + diff --git a/OutOfSchool/OutOfSchool.WebApi/Services/Images/IImageValidatorService.cs b/OutOfSchool/OutOfSchool.WebApi/Services/IFileValidator.cs similarity index 52% rename from OutOfSchool/OutOfSchool.WebApi/Services/Images/IImageValidatorService.cs rename to OutOfSchool/OutOfSchool.WebApi/Services/IFileValidator.cs index c22a48ea1d..fb59db9734 100644 --- a/OutOfSchool/OutOfSchool.WebApi/Services/Images/IImageValidatorService.cs +++ b/OutOfSchool/OutOfSchool.WebApi/Services/IFileValidator.cs @@ -1,13 +1,9 @@ -using System.IO; +using System.IO; using OutOfSchool.WebApi.Common; -namespace OutOfSchool.WebApi.Services.Images +namespace OutOfSchool.WebApi.Services { - /// - /// Provides APIs for validating images by their options. - /// - /// This type encapsulates data for which should get validating options. - public interface IImageValidatorService + public interface IFileValidator { /// /// Determines if the given context is valid. @@ -21,21 +17,13 @@ public interface IImageValidatorService /// /// Image size. /// The value which shows the validation state. - bool ImageSizeValid(long size); - - /// - /// Determines if the given resolution is valid. - /// - /// Image width. - /// Image height. - /// The value which shows the validation state. - bool ImageResolutionValid(int width, int height); + bool FileSizeValid(long size); /// /// Determines if the given format is valid. /// /// Image format. /// The value which shows the validation state. - bool ImageFormatValid(string format); + bool FileFormatValid(string format); } -} +} \ No newline at end of file diff --git a/OutOfSchool/OutOfSchool.WebApi/Services/Images/IImageValidator.cs b/OutOfSchool/OutOfSchool.WebApi/Services/Images/IImageValidator.cs new file mode 100644 index 0000000000..d77d39b751 --- /dev/null +++ b/OutOfSchool/OutOfSchool.WebApi/Services/Images/IImageValidator.cs @@ -0,0 +1,19 @@ +using System.IO; +using OutOfSchool.WebApi.Common; + +namespace OutOfSchool.WebApi.Services.Images +{ + /// + /// Provides APIs for validating images by their options. + /// + public interface IImageValidator : IFileValidator + { + /// + /// Determines if the given resolution is valid. + /// + /// Image width. + /// Image height. + /// The value which shows the validation state. + bool ImageResolutionValid(int width, int height); + } +} diff --git a/OutOfSchool/OutOfSchool.WebApi/Services/Images/ImageService.cs b/OutOfSchool/OutOfSchool.WebApi/Services/Images/ImageService.cs index b6bb1c4b00..8a96d30697 100644 --- a/OutOfSchool/OutOfSchool.WebApi/Services/Images/ImageService.cs +++ b/OutOfSchool/OutOfSchool.WebApi/Services/Images/ImageService.cs @@ -11,6 +11,7 @@ using OutOfSchool.Services.Models; using OutOfSchool.Services.Models.Images; using OutOfSchool.Services.Repository; +using OutOfSchool.Services.Repository.Files; using OutOfSchool.WebApi.Common; using OutOfSchool.WebApi.Common.Resources; using OutOfSchool.WebApi.Common.Resources.Codes; @@ -25,19 +26,19 @@ namespace OutOfSchool.WebApi.Services.Images /// public class ImageService : IImageService { - private readonly IExternalImageStorage externalStorage; + private readonly IImageFilesStorage imageStorage; private readonly IServiceProvider serviceProvider; private readonly ILogger logger; /// /// Initializes a new instance of the class. /// - /// Storage for images. + /// Storage for images. /// Provides access to the app services. /// Logger. - public ImageService(IExternalImageStorage externalStorage, IServiceProvider serviceProvider, ILogger logger) + public ImageService(IImageFilesStorage imageStorage, IServiceProvider serviceProvider, ILogger logger) { - this.externalStorage = externalStorage; + this.imageStorage = imageStorage; this.serviceProvider = serviceProvider; this.logger = logger; } @@ -54,7 +55,12 @@ public async Task> GetByIdAsync(string imageId) try { - var externalImageModel = await externalStorage.GetByIdAsync(imageId).ConfigureAwait(false); + var externalImageModel = await imageStorage.GetByIdAsync(imageId).ConfigureAwait(false); + + if (externalImageModel == null) + { + return Result.Failed(ImagesOperationErrorCode.ImageNotFoundError.GetOperationError()); + } var imageDto = new ImageDto { @@ -65,7 +71,7 @@ public async Task> GetByIdAsync(string imageId) logger.LogDebug($"Image with id {imageId} was successfully got."); return Result.Success(imageDto); } - catch (ImageStorageException ex) + catch (FileStorageException ex) { logger.LogError(ex, $"Image with id {imageId} wasn't found."); return Result.Failed(ImagesOperationErrorCode.ImageNotFoundError.GetOperationError()); @@ -236,12 +242,12 @@ private async Task> UploadImageProcessAsync(Stream contentStream, { try { - var imageStorageId = await externalStorage.UploadImageAsync(new ExternalImageModel { ContentStream = contentStream, ContentType = contentType }) + var imageStorageId = await imageStorage.UploadAsync(new ImageFileModel { ContentStream = contentStream, ContentType = contentType }) .ConfigureAwait(false); return Result.Success(imageStorageId); } - catch (ImageStorageException ex) + catch (FileStorageException ex) { logger.LogError(ex, $"Unable to upload image into an external storage because of {ex.Message}."); return Result.Failed(ImagesOperationErrorCode.ImageStorageError.GetOperationError()); @@ -252,19 +258,19 @@ private async Task RemovingImageProcessAsync(string imageId) { try { - await externalStorage.DeleteImageAsync(imageId).ConfigureAwait(false); + await imageStorage.DeleteAsync(imageId).ConfigureAwait(false); return OperationResult.Success; } - catch (ImageStorageException ex) + catch (FileStorageException ex) { logger.LogError(ex, $"Unreal to delete image with an external id = {imageId}."); return OperationResult.Failed(ImagesOperationErrorCode.RemovingError.GetOperationError()); } } - private IImageValidatorService GetValidator() + private IImageValidator GetValidator() { - return (IImageValidatorService)serviceProvider.GetService(typeof(IImageValidatorService)) + return (IImageValidator)serviceProvider.GetService(typeof(IImageValidator)) ?? throw new NullReferenceException($"Unable to receive ImageValidatorService of type {nameof(T)}"); } } diff --git a/OutOfSchool/OutOfSchool.WebApi/Services/Images/ImageValidatorService.cs b/OutOfSchool/OutOfSchool.WebApi/Services/Images/ImageValidator.cs similarity index 80% rename from OutOfSchool/OutOfSchool.WebApi/Services/Images/ImageValidatorService.cs rename to OutOfSchool/OutOfSchool.WebApi/Services/Images/ImageValidator.cs index 10410c1606..209ef40ef5 100644 --- a/OutOfSchool/OutOfSchool.WebApi/Services/Images/ImageValidatorService.cs +++ b/OutOfSchool/OutOfSchool.WebApi/Services/Images/ImageValidator.cs @@ -15,18 +15,18 @@ namespace OutOfSchool.WebApi.Services.Images /// Provides APIs for validating images by their options. /// /// This type encapsulates data for which should get validating options. - public class ImageValidatorService : IImageValidatorService + public class ImageValidator : IImageValidator { private readonly ImageOptions options; - private readonly ILogger> logger; + private readonly ILogger> logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Image options. /// Logger. - public ImageValidatorService(IOptions> options, ILogger> logger) + public ImageValidator(IOptions> options, ILogger> logger) { this.options = options.Value; this.logger = logger; @@ -35,15 +35,16 @@ public ImageValidatorService(IOptions> options, ILogger public OperationResult Validate(Stream stream) { - if (!ImageSizeValid(stream.Length)) - { - return OperationResult.Failed(ImagesOperationErrorCode.InvalidSizeError.GetOperationError()); - } - try { + _ = stream ?? throw new ArgumentNullException(nameof(stream)); + if (!FileSizeValid(stream.Length)) + { + return OperationResult.Failed(ImagesOperationErrorCode.InvalidSizeError.GetOperationError()); + } + using var image = Image.FromStream(stream); // check disposing, using memory - if (!ImageFormatValid(image.RawFormat.ToString())) + if (!FileFormatValid(image.RawFormat.ToString())) { return OperationResult.Failed(ImagesOperationErrorCode.InvalidFormatError.GetOperationError()); } @@ -68,7 +69,7 @@ public OperationResult Validate(Stream stream) } /// - public bool ImageSizeValid(long size) + public bool FileSizeValid(long size) { return size <= options.MaxSizeBytes; } @@ -85,7 +86,7 @@ public bool ImageResolutionValid(int width, int height) } /// - public bool ImageFormatValid(string format) + public bool FileFormatValid(string format) { return options.SupportedFormats.Contains(format, StringComparer.OrdinalIgnoreCase); } diff --git a/OutOfSchool/OutOfSchool.WebApi/Startup.cs b/OutOfSchool/OutOfSchool.WebApi/Startup.cs index 01620a9ee0..142fb4c48d 100644 --- a/OutOfSchool/OutOfSchool.WebApi/Startup.cs +++ b/OutOfSchool/OutOfSchool.WebApi/Startup.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Text.Json.Serialization; using AutoMapper; +using Google.Cloud.Storage.V1; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -13,6 +14,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using Microsoft.FeatureManagement; using OutOfSchool.Common; using OutOfSchool.Common.Config; @@ -28,7 +30,9 @@ using OutOfSchool.Services.Models; using OutOfSchool.Services.Models.ChatWorkshop; using OutOfSchool.Services.Repository; +using OutOfSchool.Services.Repository.Files; using OutOfSchool.WebApi.Config; +using OutOfSchool.WebApi.Config.DataAccess; using OutOfSchool.WebApi.Config.Images; using OutOfSchool.WebApi.Extensions; using OutOfSchool.WebApi.Extensions.Startup; @@ -153,6 +157,7 @@ public void ConfigureServices(IServiceCollection services) services.Configure>(Configuration.GetSection($"Images:{nameof(Teacher)}:Limits")); // Image options + services.Configure(Configuration.GetSection(GcpStorageConfigConstants.GcpStorageImagesConfig)); services.Configure(Configuration.GetSection(ExternalImageSourceConfig.Name)); services.AddSingleton(); services.Configure>(Configuration.GetSection($"Images:{nameof(Workshop)}:Specs")); @@ -222,8 +227,8 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddScoped(); - services.AddScoped, ImageValidatorService>(); - services.AddScoped, ImageValidatorService>(); + services.AddScoped>(); + services.AddScoped>(); services.AddTransient(); services.AddTransient(); services.AddScoped(); @@ -260,7 +265,8 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + //services.AddTransient(); + services.AddImagesStorage(); services.AddTransient(); services.AddTransient(); diff --git a/OutOfSchool/OutOfSchool.WebApi/appsettings.json b/OutOfSchool/OutOfSchool.WebApi/appsettings.json index d42b85a113..81e6b10aa2 100644 --- a/OutOfSchool/OutOfSchool.WebApi/appsettings.json +++ b/OutOfSchool/OutOfSchool.WebApi/appsettings.json @@ -50,6 +50,14 @@ ] } }, + "GoogleCloudPlatform": { + "Storage": { + "OosImages": { + "CredentialFilePath": "", + "BucketName": "" + } + } + }, "Images": { "Workshop": { "Specs": { diff --git a/OutOfSchool/Tests/OutOfSchool.WebApi.IntegrationTests/OutOfSchool.WebApi.IntegrationTests.csproj b/OutOfSchool/Tests/OutOfSchool.WebApi.IntegrationTests/OutOfSchool.WebApi.IntegrationTests.csproj index 8b67f390fa..c45d0ad7f5 100644 --- a/OutOfSchool/Tests/OutOfSchool.WebApi.IntegrationTests/OutOfSchool.WebApi.IntegrationTests.csproj +++ b/OutOfSchool/Tests/OutOfSchool.WebApi.IntegrationTests/OutOfSchool.WebApi.IntegrationTests.csproj @@ -21,7 +21,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive