diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..9c1e70d9 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №3 Интеграционное тестирование + Вариант №45 Товар на складе + Выполнена Ле Хань Хоанг 6513 + Ссылка на форк diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..80688c21 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "https://localhost:7000/api/inventory" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..cd28b84a 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,20 +1,116 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36811.4 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11520.95 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.ApiService", "InventoryManager\Inventory.ApiService\Inventory.ApiService.csproj", "{E32F4311-CD79-A2FA-0DBA-55DAD48F6377}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.AppHost", "InventoryManager\Inventory.AppHost\Inventory.AppHost.csproj", "{AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.ServiceDefaults", "InventoryManager\Inventory.ServiceDefaults\Inventory.ServiceDefaults.csproj", "{E302BFA1-84FC-63A7-EA3A-7872A83042B9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.Gateway", "InventoryManager\Inventory.Gateway\Inventory.Gateway.csproj", "{103A57B1-1180-645E-9B47-C67CDA2CD513}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.Tests", "InventoryManager\Inventory.Tests\Inventory.Tests.csproj", "{0F0A6F8E-863E-D920-2530-95CD2F7CD63D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.FileService", "InventoryManager\Inventory.FileService\Inventory.FileService.csproj", "{B30C51CA-2866-57DA-E0D4-D52385B5F3BE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|x64.ActiveCfg = Debug|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|x64.Build.0 = Debug|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|x86.ActiveCfg = Debug|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|x86.Build.0 = Debug|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|x64.ActiveCfg = Release|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|x64.Build.0 = Release|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|x86.ActiveCfg = Release|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|x86.Build.0 = Release|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|x64.ActiveCfg = Debug|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|x64.Build.0 = Debug|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|x86.ActiveCfg = Debug|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|x86.Build.0 = Debug|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|Any CPU.Build.0 = Release|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|x64.ActiveCfg = Release|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|x64.Build.0 = Release|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|x86.ActiveCfg = Release|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|x86.Build.0 = Release|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|x64.ActiveCfg = Debug|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|x64.Build.0 = Debug|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|x86.ActiveCfg = Debug|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|x86.Build.0 = Debug|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|Any CPU.Build.0 = Release|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|x64.ActiveCfg = Release|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|x64.Build.0 = Release|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|x86.ActiveCfg = Release|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|x86.Build.0 = Release|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|x64.Build.0 = Debug|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|x86.Build.0 = Debug|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|Any CPU.Build.0 = Release|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|x64.ActiveCfg = Release|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|x64.Build.0 = Release|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|x86.ActiveCfg = Release|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|x86.Build.0 = Release|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|Any CPU.Build.0 = Debug|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|x64.ActiveCfg = Debug|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|x64.Build.0 = Debug|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|x86.ActiveCfg = Debug|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|x86.Build.0 = Debug|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|Any CPU.ActiveCfg = Release|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|Any CPU.Build.0 = Release|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|x64.ActiveCfg = Release|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|x64.Build.0 = Release|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|x86.ActiveCfg = Release|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|x86.Build.0 = Release|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Debug|x64.ActiveCfg = Debug|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Debug|x64.Build.0 = Debug|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Debug|x86.ActiveCfg = Debug|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Debug|x86.Build.0 = Debug|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Release|Any CPU.Build.0 = Release|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Release|x64.ActiveCfg = Release|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Release|x64.Build.0 = Release|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Release|x86.ActiveCfg = Release|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Release|x86.Build.0 = Release|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Debug|x64.ActiveCfg = Debug|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Debug|x64.Build.0 = Debug|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Debug|x86.ActiveCfg = Debug|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Debug|x86.Build.0 = Debug|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Release|Any CPU.Build.0 = Release|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Release|x64.ActiveCfg = Release|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Release|x64.Build.0 = Release|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Release|x86.ActiveCfg = Release|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/InventoryManager/Inventory.ApiService/Cache/IInventoryCache.cs b/InventoryManager/Inventory.ApiService/Cache/IInventoryCache.cs new file mode 100644 index 00000000..974af8cd --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Cache/IInventoryCache.cs @@ -0,0 +1,17 @@ +using Inventory.ApiService.Entity; + +namespace Inventory.ApiService.Cache; + +/// +/// Интерфейс сервиса для получения продукта с использованием кэширования. +/// +public interface IInventoryCache +{ + /// + /// Возвращает продукт по идентификатору из кэша или генерирует его при отсутствии в кэше. + /// + /// Идентификатор продукта + /// Токен отмены операции + /// Экземпляр продукта + public Task GetAsync(int id, CancellationToken ct); +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs b/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs new file mode 100644 index 00000000..32ecb454 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using Inventory.ApiService.Entity; +using Inventory.ApiService.Generation; +using Microsoft.Extensions.Caching.Distributed; + +namespace Inventory.ApiService.Cache; +/// +/// Реализация сервиса кэширования для получения продукта. +/// Сначала пытается получить данные из кэша, при отсутствии — генерирует продукт и сохраняет его в кэш. +/// +/// Сервис распределённого кэширования +/// Конфигурация приложения +/// Логгер для записи событий +/// Генератор +public class InventoryCache(IDistributedCache cache, IConfiguration configuration, ILogger logger,Generator generator) : IInventoryCache +{ + /// + /// Возвращает продукт по идентификатору. + /// При наличии в кэше возвращает сохранённые данные, иначе генерирует новый объект и сохраняет его в кэш + /// + /// Идентификатор продукта + /// Токен отмены операции + /// + public async Task GetAsync(int id, CancellationToken ct) + { + var cacheKey = $"inventory-{id}"; + logger.LogInformation("Try get product {Id} from cache", id); + + string? cachedData = null; + + try + { + cachedData = await cache.GetStringAsync(cacheKey, ct); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Cache READ failed for {Id}. Continue without cache.", id); + } + + if (!string.IsNullOrEmpty(cachedData)) + { + try + { + var cachedProduct = JsonSerializer.Deserialize(cachedData); + if (cachedProduct is not null) + { + logger.LogInformation("Cache HIT for product {Id}", id); + return cachedProduct; + } + + logger.LogWarning("Cache HIT but deserialize returned null for product {Id}", id); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Deserialize failed for product {Id}. Continue without cache.", id); + } + } + + logger.LogInformation("Cache MISS for product {Id}. Generating.", id); + var product = generator.Generate(id); + + try + { + var expirationMinutes = configuration.GetValue("CacheSettings:ExpirationMinutes", 5); + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(expirationMinutes) + }; + + await cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(product), options, ct); + logger.LogInformation("Product {Id} saved to cache", id); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Cache WRITE failed for {Id}. Continue without cache.", id); + } + + return product; + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs new file mode 100644 index 00000000..8fbede53 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs @@ -0,0 +1,35 @@ +using Inventory.ApiService.Entity; +using Inventory.ApiService.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Inventory.ApiService.Controllers; + +/// +/// Контроллер для работы с инвентарём (товарами). +/// Предоставляет методы получения информации о продуктах по идентификатору. +/// +[ApiController] +[Route("api/[controller]")] +public class InventoryController(ILogger logger, IInventoryService inventoryService) : ControllerBase +{ + /// + /// Получает информацию о продукте из инвентаря по указанному ID. + /// + /// Идентификатор продукта (целое неотрицательное число). + /// Токен отмены операции. + /// Объект продукта с кодом 200 OK или ошибку 400 Bad Request, если ID не указан или неверен. + [HttpGet] + [ProducesResponseType(typeof(Product), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Get([FromQuery] int? id, CancellationToken ct) + { + if (id is null || id < 0) + return BadRequest("id is required and must be >= 0"); + + logger.LogInformation("Processing request for inventory {ResourceId}", id); + + var product = await inventoryService.GetInventory(id.Value, ct); + + return Ok(product); + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Entity/Product.cs b/InventoryManager/Inventory.ApiService/Entity/Product.cs new file mode 100644 index 00000000..e06a44a0 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Entity/Product.cs @@ -0,0 +1,57 @@ +namespace Inventory.ApiService.Entity; + +/// +/// Класс, представляющий товар на складе +/// +public class Product +{ + /// + /// Идентификатор в системе + /// + public int Id { get; set; } + + /// + /// Наименование товара + /// + public string NameProduct { get; set; } = string.Empty; + + /// + /// Категория товара + /// + public string Category { get; set; } = string.Empty; + + /// + /// Количество на складе + /// + public int Quantity { get; set; } + + /// + /// Цена за единицу товара + /// + public decimal Price { get; set; } + + /// + /// Вес единицы товара + /// + public double Weight { get; set; } + + /// + /// Габариты единицы товара + /// + public string Dimension { get; set; } = string.Empty; + + /// + /// Товар хрупкий + /// + public bool IsFragile { get; set; } + + /// + /// Дата последней поставки + /// + public DateOnly LastDeliveryDate { get; set; } + + /// + /// Дата следующей поставки + /// + public DateOnly NextDeliveryDate { get; set; } +} diff --git a/InventoryManager/Inventory.ApiService/Generation/Generator.cs b/InventoryManager/Inventory.ApiService/Generation/Generator.cs new file mode 100644 index 00000000..6d1942d3 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Generation/Generator.cs @@ -0,0 +1,43 @@ +using Bogus; +using Inventory.ApiService.Entity; + +namespace Inventory.ApiService.Generation; +/// +/// Сервис генерации тестовых данных продукта.Использует библиотеку Bogus для создания случайных значений. +/// +public class Generator +{ + private static readonly Faker _faker = new Faker() + .RuleFor(x => x.NameProduct, f => f.Commerce.ProductName()) + .RuleFor(x => x.Category, f => f.Commerce.Categories(1)[0]) + .RuleFor(x => x.Quantity, f => f.Random.Int(0, 1000)) + .RuleFor(x => x.Price, f => Math.Round(f.Random.Decimal(1, 10000), 2)) + .RuleFor(x => x.Weight, f => Math.Round(f.Random.Double(0.1, 100), 2)) + .RuleFor(x => x.Dimension, f => + { + var a = f.Random.Int(1, 200); + var b = f.Random.Int(1, 200); + var c = f.Random.Int(1, 200); + return $"{a}×{b}×{c} cm"; + }) + .RuleFor(x => x.IsFragile, f => f.Random.Bool()) + .RuleFor(x => x.LastDeliveryDate, f => DateOnly.FromDateTime(f.Date.Past(2))) + .RuleFor(x => x.NextDeliveryDate, (f, item) => + { + var lastDate = item.LastDeliveryDate.ToDateTime(TimeOnly.MinValue); + var nextDate = f.Date.Between(lastDate, lastDate.AddMonths(6)); + return DateOnly.FromDateTime(nextDate); + }); + + /// + /// Генерирует продукт по заданному идентификатору. + /// + /// Идентификатор продукта + /// Сгенерированный объект продукта + public Product Generate(int id) + { + var product = _faker.Generate(); + product.Id = id; + return product; + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Inventory.ApiService.csproj b/InventoryManager/Inventory.ApiService/Inventory.ApiService.csproj new file mode 100644 index 00000000..df6057b2 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Inventory.ApiService.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/InventoryManager/Inventory.ApiService/Messaging/IProducerService.cs b/InventoryManager/Inventory.ApiService/Messaging/IProducerService.cs new file mode 100644 index 00000000..91bea1e9 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Messaging/IProducerService.cs @@ -0,0 +1,17 @@ +using Inventory.ApiService.Entity; + +namespace Inventory.ApiService.Messaging; + +/// +/// Определяет контракт для сервиса-производителя сообщений. +/// Отвечает за отправку данных о продукте в систему обмена сообщениями. +/// +public interface IProducerService +{ + /// + /// Асинхронно отправляет сообщение, содержащее информацию о продукте. + /// + /// Объект продукта, который необходимо отправить. + /// Задача, представляющая асинхронную операцию отправки. + public Task SendMessage(Product product); +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Messaging/SnsPublisherService.cs b/InventoryManager/Inventory.ApiService/Messaging/SnsPublisherService.cs new file mode 100644 index 00000000..5c7d53b0 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Messaging/SnsPublisherService.cs @@ -0,0 +1,51 @@ +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using Inventory.ApiService.Entity; +using System.Net; +using System.Text.Json; + +namespace Inventory.ApiService.Messaging; + +/// +/// Реализация для отправки сообщений в Amazon SNS (Simple Notification Service). +/// Сериализует продукт в JSON и публикует его в указанный SNS-топик. +/// +public class SnsPublisherService(IAmazonSimpleNotificationService client, IConfiguration configuration, ILogger logger) : IProducerService +{ + /// + /// ARN (Amazon Resource Name) SNS-топика, полученный из конфигурации приложения. + /// + private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"] + ?? throw new KeyNotFoundException("SNS topic link was not found in configuration"); + + /// + /// Асинхронно отправляет сериализованный в JSON продукт в SNS-топик.В случае успешной отправки (HTTP 200) логирует информацию. + /// При ошибке логирует исключение, но не выбрасывает его повторно. + /// + /// Продукт, который необходимо отправить. + /// Задача, представляющая асинхронную операцию. + public async Task SendMessage(Product product) + { + try + { + var json = JsonSerializer.Serialize(product); + + var request = new PublishRequest + { + Message = json, + TopicArn = _topicArn + }; + + var response = await client.PublishAsync(request); + + if (response.HttpStatusCode == HttpStatusCode.OK) + logger.LogInformation("Inventory {id} was sent to sink via SNS", product.Id); + else + throw new Exception($"SNS returned {response.HttpStatusCode}"); + } + catch (Exception ex) + { + logger.LogError(ex, "Unable to send inventory through SNS topic"); + } + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Program.cs b/InventoryManager/Inventory.ApiService/Program.cs new file mode 100644 index 00000000..d06ed180 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Program.cs @@ -0,0 +1,67 @@ +using Amazon.Runtime; +using Amazon.SimpleNotificationService; +using Inventory.ApiService.Cache; +using Inventory.ApiService.Generation; +using Inventory.ApiService.Messaging; +using Inventory.ApiService.Services; +using Inventory.ServiceDefaults; +using LocalStack.Client.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +// Redis +builder.AddRedisDistributedCache("cache"); + +// OpenAPI / errors +builder.Services.AddProblemDetails(); +builder.Services.AddOpenApi(); + +// LocalStack config +builder.Services.AddLocalStack(builder.Configuration); + +// SNS client +builder.Services.AddSingleton(_ => +{ + var serviceUrl = builder.Configuration["AWS:ServiceURL"] + ?? "http://localhost:4566"; + + var region = builder.Configuration["AWS:Region"] + ?? builder.Configuration["AWS_REGION"] + ?? builder.Configuration["AWS_DEFAULT_REGION"] + ?? "eu-central-1"; + + var config = new AmazonSimpleNotificationServiceConfig + { + ServiceURL = serviceUrl, + AuthenticationRegion = region + }; + + return new AmazonSimpleNotificationServiceClient(new BasicAWSCredentials("test", "test"),config); +}); + +// Controllers +builder.Services.AddControllers(); + +// DI +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +app.Logger.LogInformation("SNS Region: {Region}", builder.Configuration["AWS:Region"]); + +app.UseExceptionHandler(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.MapControllers(); +app.MapDefaultEndpoints(); + +app.Run(); \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Properties/launchSettings.json b/InventoryManager/Inventory.ApiService/Properties/launchSettings.json new file mode 100644 index 00000000..49be98fd --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5339", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7266;http://localhost:5339", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs b/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs new file mode 100644 index 00000000..1708479c --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs @@ -0,0 +1,17 @@ +using Inventory.ApiService.Entity; + +namespace Inventory.ApiService.Services; + +/// +/// Интерфейс сервиса для работы с инвентарём +/// +public interface IInventoryService +{ + /// + /// Получает информацию о продукте по его идентификатору + /// + /// Идентификатор продукта + /// Токен для отмены асинхронной операции + /// Объект продукта + public Task GetInventory(int id, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Services/InventoryService.cs b/InventoryManager/Inventory.ApiService/Services/InventoryService.cs new file mode 100644 index 00000000..f0fca309 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Services/InventoryService.cs @@ -0,0 +1,34 @@ +using Inventory.ApiService.Cache; +using Inventory.ApiService.Entity; +using Inventory.ApiService.Messaging; + +namespace Inventory.ApiService.Services; + +/// +/// Сервис для обработки запросов, связанных с инвентарём +/// +/// Сервис логирования операций инвентаря +/// Сервис кэширования данных о продуктах +/// Сервис для отправки сообщений в брокер сообщений +public class InventoryService( + ILogger logger, + IInventoryCache cache, + IProducerService producerService) : IInventoryService +{ + /// + /// Получает информацию о продукте из кэша, отправляет сообщение о продукте в брокер сообщений и записывает информацию об обработке в лог + /// + /// Идентификатор продукта + /// Токен для отмены асинхронной операции + /// Объект продукта, полученный из кэша + public async Task GetInventory(int id, CancellationToken cancellationToken = default) + { + var product = await cache.GetAsync(id, cancellationToken); + + await producerService.SendMessage(product); + + logger.LogInformation("Inventory {ResourceId} processed", id); + + return product; + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/appsettings.Development.json b/InventoryManager/Inventory.ApiService/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/InventoryManager/Inventory.ApiService/appsettings.json b/InventoryManager/Inventory.ApiService/appsettings.json new file mode 100644 index 00000000..82896777 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "CacheSettings": { + "ExpirationMinutes": 5 + }, + "AllowedHosts": "*" +} diff --git a/InventoryManager/Inventory.AppHost/AppHost.cs b/InventoryManager/Inventory.AppHost/AppHost.cs new file mode 100644 index 00000000..f645f269 --- /dev/null +++ b/InventoryManager/Inventory.AppHost/AppHost.cs @@ -0,0 +1,103 @@ +using Amazon; +using Aspire.Hosting.LocalStack.Container; +using LocalStack.Client.Enums; +using Microsoft.Extensions.Configuration; + +var builder = DistributedApplication.CreateBuilder(args); + +// Read configuration +var apiServiceConfig = builder.Configuration.GetSection("ApiService"); +var ports = apiServiceConfig.GetSection("Ports").Get>() ?? [7001, 7002, 7003, 7004, 7005]; + +var apiGatewayConfig = builder.Configuration.GetSection("ApiGateway"); +var gatewayPort = apiGatewayConfig.GetValue("Port"); + +var localStackPort = builder.Configuration.GetSection("LocalStack").GetValue("Port"); +var cloudFormationTemplate = builder.Configuration.GetSection("LocalStack").GetValue("CloudFormationTemplate") + ?? "CloudFormation/inventory-template-sns-s3.yaml"; + +var snsEndpointUrl = builder.Configuration.GetSection("SNS").GetValue("EndpointURL") + ?? "http://host.docker.internal:5037/api/sns"; + +// Cache Redis +var cache = builder.AddRedis("cache") + .WithRedisCommander(); + +// API Gateway +var gateway = builder.AddProject("apigateway") + .WithHttpsEndpoint(port: gatewayPort, name: "gateway") + .WithExternalHttpEndpoints(); + +// AWS config +var awsConfig = builder.AddAWSSDKConfig() + .WithProfile("default") + .WithRegion(RegionEndpoint.EUCentral1); + +// LocalStack +var localstack = builder.AddLocalStack("inventory-localstack", awsConfig: awsConfig, configureContainer: container => +{ + container.Lifetime = ContainerLifetime.Session; + container.DebugLevel = 1; + container.LogLevel = LocalStackLogLevel.Debug; + container.Port = localStackPort; + + container.EagerLoadedServices = + [ + AwsService.CloudFormation, + AwsService.S3, + AwsService.Sns + ]; + + container.AdditionalEnvironmentVariables.Add("DEBUG", "1"); + container.AdditionalEnvironmentVariables.Add("AWS_REGION", "eu-central-1"); + container.AdditionalEnvironmentVariables.Add("AWS_DEFAULT_REGION", "eu-central-1"); + container.AdditionalEnvironmentVariables.Add("SNS_CERT_URL_HOST", "sns.eu-central-1.amazonaws.com"); +}) ?? throw new InvalidOperationException("LocalStack resource could not be created."); + +// CloudFormation resources: S3 bucket + SNS topic +var awsResources = builder.AddAWSCloudFormationTemplate("resources", cloudFormationTemplate, "inventory") + .WithReference(awsConfig); + +// FileService +var fileService = builder.AddProject("inventory-files") + .WithEnvironment("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", "") + .WithReference(awsResources) + .WithEnvironment("Settings__MessageBroker", "SNS") + .WithEnvironment("SNS__EndpointURL", snsEndpointUrl) + .WithEnvironment("AWS_REGION", "eu-central-1") + .WithEnvironment("AWS_DEFAULT_REGION", "eu-central-1") + .WaitFor(awsResources); + +// API services +var serviceId = 1; + +foreach (var port in ports) +{ + var api = builder.AddProject($"apiservice-{serviceId}", launchProfileName: null) + .WithEnvironment("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", "") + .WithReference(cache) + .WithReference(awsResources) + .WithEnvironment("Settings__MessageBroker", "SNS") + .WithEnvironment("AWS__ServiceURL", "http://localhost:4566") + .WithEnvironment("AWS__Region", "eu-central-1") + .WithEnvironment("AWS_REGION", "eu-central-1") + .WithEnvironment("AWS_DEFAULT_REGION", "eu-central-1") + .WithEnvironment("AWS_ACCESS_KEY_ID", "test") + .WithEnvironment("AWS_SECRET_ACCESS_KEY", "test") + .WaitFor(cache) + .WaitFor(awsResources) + .WaitFor(fileService) + .WithHttpsEndpoint(port: port, name: $"api{serviceId}"); + + gateway.WaitFor(api); + serviceId++; +} + +// Client +builder.AddProject("client-wasm") + .WithExternalHttpEndpoints() + .WaitFor(gateway); + +builder.UseLocalStack(localstack); + +builder.Build().Run(); \ No newline at end of file diff --git a/InventoryManager/Inventory.AppHost/CloudFormation/inventory-template-sns-s3.yaml b/InventoryManager/Inventory.AppHost/CloudFormation/inventory-template-sns-s3.yaml new file mode 100644 index 00000000..e250849d --- /dev/null +++ b/InventoryManager/Inventory.AppHost/CloudFormation/inventory-template-sns-s3.yaml @@ -0,0 +1,41 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: 'Cloud formation template for inventory project' + +Parameters: + BucketName: + Type: String + Description: Name for the S3 bucket + Default: 'inventory-bucket' + + TopicName: + Type: String + Description: Name for the SNS topic + Default: 'inventory-topic' + +Resources: + InventoryBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref BucketName + + InventoryTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref TopicName + +Outputs: + S3BucketName: + Description: Name of the S3 bucket + Value: !Ref InventoryBucket + + S3BucketArn: + Description: ARN of the S3 bucket + Value: !GetAtt InventoryBucket.Arn + + SNSTopicName: + Description: Name of the SNS topic + Value: !GetAtt InventoryTopic.TopicName + + SNSTopicArn: + Description: ARN of the SNS topic + Value: !Ref InventoryTopic \ No newline at end of file diff --git a/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj b/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj new file mode 100644 index 00000000..b239ee8f --- /dev/null +++ b/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj @@ -0,0 +1,30 @@ + + + + Exe + net10.0 + enable + enable + c2ca5822-3bae-42fc-9a94-9efbb7235036 + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/InventoryManager/Inventory.AppHost/Properties/launchSettings.json b/InventoryManager/Inventory.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..a5532076 --- /dev/null +++ b/InventoryManager/Inventory.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17276;http://localhost:15241", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21117", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23036", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22056" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15241", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19037", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18161", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" + } + } + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.AppHost/appsettings.Development.json b/InventoryManager/Inventory.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/InventoryManager/Inventory.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/InventoryManager/Inventory.AppHost/appsettings.json b/InventoryManager/Inventory.AppHost/appsettings.json new file mode 100644 index 00000000..cd3db2a6 --- /dev/null +++ b/InventoryManager/Inventory.AppHost/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "ApiService": { + "Ports": [ 7001, 7002, 7003, 7004, 7005 ] + }, + "ApiGateway": { + "Port": 7000 + }, + "LocalStack": { + "UseLocalStack": true, + "Port": 4566, + "CloudFormationTemplate": "CloudFormation/inventory-template-sns-s3.yaml" + }, + "SNS": { + "EndpointURL": "http://host.docker.internal:5037/api/sns" + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs b/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs new file mode 100644 index 00000000..b0aba4f3 --- /dev/null +++ b/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs @@ -0,0 +1,65 @@ +using Inventory.FileService.Storage; +using Microsoft.AspNetCore.Mvc; +using System.Text; +using System.Text.Json.Nodes; + +namespace Inventory.FileService.Controllers; + +/// +/// Контроллер для работы с файлами, хранящимися в S3-хранилище +/// +/// Сервис для выполнения операций с S3-хранилищем +/// Сервис логирования работы контроллера +[ApiController] +[Route("api/s3")] +public class S3StorageController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + /// + /// Получает список всех файлов из S3-хранилища + /// + /// Список ключей файлов, находящихся в S3-хранилище + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> ListFiles() + { + logger.LogInformation("Method {method} of {controller} was called", nameof(ListFiles), nameof(S3StorageController)); + + try + { + var list = await s3Service.GetFileList(); + logger.LogInformation("Got a list of {count} files from bucket", list.Count); + return Ok(list); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occured during {method} of {controller}", nameof(ListFiles), nameof(S3StorageController)); + return BadRequest(ex); + } + } + + /// + /// Получает содержимое JSON-файла из S3-хранилища по его ключу + /// + /// Ключ файла в S3-хранилище + /// Содержимое файла в формате JSON + [HttpGet("{key}")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task> GetFile(string key) + { + logger.LogInformation("Method {method} of {controller} was called", nameof(GetFile), nameof(S3StorageController)); + + try + { + var node = await s3Service.DownloadFile(key); + logger.LogInformation("Received json of {size} bytes", Encoding.UTF8.GetByteCount(node.ToJsonString())); + return Ok(node); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occured during {method} of {controller}", nameof(GetFile), nameof(S3StorageController)); + return BadRequest(ex); + } + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs b/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs new file mode 100644 index 00000000..b2f2f5f9 --- /dev/null +++ b/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs @@ -0,0 +1,69 @@ +using Amazon.SimpleNotificationService.Util; +using Inventory.FileService.Storage; +using Microsoft.AspNetCore.Mvc; +using System.Text; + +namespace Inventory.FileService.Controllers; + +/// +/// Контроллер для получения и обработки сообщений из SNS +/// +/// Сервис для работы с S3-хранилищем +/// Сервис логирования работы контроллера +[ApiController] +[Route("api/sns")] +public class SnsSubscriberController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + /// + /// Принимает входящее сообщение от SNS, подтверждает подписку или обрабатывает уведомление + /// + /// Результат обработки входящего SNS-сообщения + [HttpPost] + [ProducesResponseType(200)] + public async Task ReceiveMessage() + { + logger.LogInformation("SNS webhook was called"); + + try + { + using var reader = new StreamReader(Request.Body, Encoding.UTF8); + var jsonContent = await reader.ReadToEndAsync(); + var snsMessage = Message.ParseMessage(jsonContent); + + if (snsMessage.Type == "SubscriptionConfirmation") + { + logger.LogInformation("SubscriptionConfirmation was received"); + + using var httpClient = new HttpClient(); + var builder = new UriBuilder(new Uri(snsMessage.SubscribeURL)) + { + Scheme = "http", + Host = "localhost", + Port = 4566 + }; + + var response = await httpClient.GetAsync(builder.Uri); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + throw new Exception($"SubscriptionConfirmation returned {response.StatusCode}: {body}"); + } + + logger.LogInformation("Subscription was successfully confirmed"); + return Ok(); + } + + if (snsMessage.Type == "Notification") + { + await s3Service.UploadFile(snsMessage.MessageText); + logger.LogInformation("Notification was successfully processed"); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occurred while processing SNS notifications"); + } + + return Ok(); + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Inventory.FileService.csproj b/InventoryManager/Inventory.FileService/Inventory.FileService.csproj new file mode 100644 index 00000000..b5b84c6c --- /dev/null +++ b/InventoryManager/Inventory.FileService/Inventory.FileService.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/InventoryManager/Inventory.FileService/Messaging/ISubscriberService.cs b/InventoryManager/Inventory.FileService/Messaging/ISubscriberService.cs new file mode 100644 index 00000000..9d90b990 --- /dev/null +++ b/InventoryManager/Inventory.FileService/Messaging/ISubscriberService.cs @@ -0,0 +1,12 @@ +namespace Inventory.FileService.Messaging; + +/// +/// Интерфейс службы подписки на сообщения +/// +public interface ISubscriberService +{ + /// + /// Выполняет инициализацию подписки при старте приложения + /// + public Task SubscribeEndpoint(); +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs b/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs new file mode 100644 index 00000000..b4ad7349 --- /dev/null +++ b/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs @@ -0,0 +1,57 @@ +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using System.Net; + +namespace Inventory.FileService.Messaging; + +/// +/// Сервис для подписки HTTP-endpoint на SNS-топик +/// +/// Клиент Amazon SNS для отправки запроса на подписку +/// Конфигурация приложения, содержащая ARN SNS-топика и URL endpoint +/// Сервис логирования процесса подписки +public class SnsSubscriberService(IAmazonSimpleNotificationService snsClient, IConfiguration configuration, + ILogger logger) : ISubscriberService +{ + /// + /// ARN SNS-топика, на который должен быть подписан endpoint + /// + private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"] + ?? throw new KeyNotFoundException("SNS topic ARN was not found in configuration"); + + /// + /// URL HTTP-endpoint, который будет получать уведомления от SNS + /// + private readonly string _endpoint = configuration["SNS:EndpointURL"] + ?? throw new KeyNotFoundException("SNS endpoint URL was not found in configuration"); + + /// + /// Отправляет запрос на подписку HTTP-endpoint на указанный SNS-топик + /// + /// Асинхронная операция подписки endpoint на SNS-топик + /// + /// Возникает, если запрос на подписку завершился с неуспешным HTTP-статусом + /// + public async Task SubscribeEndpoint() + { + logger.LogInformation("Sending subscribe request for {topic}", _topicArn); + + var request = new SubscribeRequest + { + TopicArn = _topicArn, + Protocol = "http", + Endpoint = _endpoint, + ReturnSubscriptionArn = true + }; + + var response = await snsClient.SubscribeAsync(request); + + if (response.HttpStatusCode != HttpStatusCode.OK) + { + logger.LogError("Failed to subscribe to {topic}", _topicArn); + throw new InvalidOperationException($"Failed to subscribe to SNS topic {_topicArn}"); + } + + logger.LogInformation("Subscription request for {topic} is successful", _topicArn); + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Program.cs b/InventoryManager/Inventory.FileService/Program.cs new file mode 100644 index 00000000..2bdda453 --- /dev/null +++ b/InventoryManager/Inventory.FileService/Program.cs @@ -0,0 +1,50 @@ +using Amazon.S3; +using Amazon.SimpleNotificationService; +using Inventory.FileService.Messaging; +using Inventory.FileService.Storage; +using Inventory.ServiceDefaults; +using LocalStack.Client.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// LocalStack +builder.Services.AddLocalStack(builder.Configuration); + +// AWS services +builder.Services.AddAwsService(); +builder.Services.AddAwsService(); + +// App services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +app.Logger.LogInformation("AWS Region: {Region}", builder.Configuration["AWS:Region"]); +app.Logger.LogInformation("S3 Bucket: {Bucket}", builder.Configuration["AWS:Resources:S3BucketName"]); + +using (var scope = app.Services.CreateScope()) +{ + var s3Service = scope.ServiceProvider.GetRequiredService(); + await s3Service.EnsureBucketExists(); + + var subscriberService = scope.ServiceProvider.GetRequiredService(); + await subscriberService.SubscribeEndpoint(); +} + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapControllers(); +app.MapDefaultEndpoints(); + +await app.RunAsync(); \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Properties/launchSettings.json b/InventoryManager/Inventory.FileService/Properties/launchSettings.json new file mode 100644 index 00000000..43407bcb --- /dev/null +++ b/InventoryManager/Inventory.FileService/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5037", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7110;http://localhost:5037", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/InventoryManager/Inventory.FileService/Storage/IS3Service.cs b/InventoryManager/Inventory.FileService/Storage/IS3Service.cs new file mode 100644 index 00000000..d51d6ef1 --- /dev/null +++ b/InventoryManager/Inventory.FileService/Storage/IS3Service.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Nodes; + +namespace Inventory.FileService.Storage; + +/// +/// Интерфейс службы для манипуляции файлами в объектном хранилище +/// +public interface IS3Service +{ + /// + /// Отправляет файл в хранилище + /// + /// Строковая репрезентация сохраняемого файла + public Task UploadFile(string fileData); + + /// + /// Получает список всех файлов из хранилища + /// + /// Список путей к файлам + public Task> GetFileList(); + + /// + /// Получает строковую репрезентацию файла из хранилища + /// + /// Путь к файлу в бакете + /// Строковая репрезентация прочтенного файла + public Task DownloadFile(string filePath); + + /// + /// Создает S3 бакет при необходимости + /// + public Task EnsureBucketExists(); +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs b/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs new file mode 100644 index 00000000..db35d4ae --- /dev/null +++ b/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs @@ -0,0 +1,185 @@ +using Amazon.S3; +using Amazon.S3.Model; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Inventory.FileService.Storage; + +/// +/// Cлужба для манипуляции файлами в объектном хранилище +/// +/// S3 клиент +/// Конфигурация +/// Логер +public class S3AwsService(IAmazonS3 client, IConfiguration configuration, ILogger logger) : IS3Service +{ + private readonly string _bucketName = configuration["AWS:Resources:S3BucketName"] + ?? throw new KeyNotFoundException("S3 bucket name was not found in configuration"); + + /// + public async Task> GetFileList() + { + var list = new List(); + var request = new ListObjectsV2Request + { + BucketName = _bucketName, + Prefix = "", + Delimiter = ",", + }; + var paginator = client.Paginators.ListObjectsV2(request); + + logger.LogInformation("Began listing files in {bucket}", _bucketName); + + await foreach (var response in paginator.Responses) + { + if (response != null && response.S3Objects != null) + { + foreach (var obj in response.S3Objects) + { + if (obj != null) + list.Add(obj.Key); + else + logger.LogWarning("Received null object from {bucket}", _bucketName); + } + } + else + { + logger.LogWarning("Received null response from {bucket}", _bucketName); + } + } + + return list; + } + + /// + public async Task UploadFile(string fileData) + { + var rootNode = JsonNode.Parse(fileData) + ?? throw new ArgumentException("Passed string is not a valid JSON"); + + var idNode = rootNode["id"] ?? rootNode["Id"]; + + if (idNode is null) + { + logger.LogError("SNS message JSON has no id/Id field. Payload: {Payload}", fileData); + throw new ArgumentException("Passed JSON has invalid structure"); + } + + var id = idNode.GetValue(); + + using var stream = new MemoryStream(); + JsonSerializer.Serialize(stream, rootNode); + stream.Seek(0, SeekOrigin.Begin); + + logger.LogInformation("Began uploading inventory {file} onto {bucket}", id, _bucketName); + + var request = new PutObjectRequest + { + BucketName = _bucketName, + Key = $"inventory_{id}.json", + InputStream = stream, + ContentType = "application/json" + }; + + var response = await client.PutObjectAsync(request); + + if (response.HttpStatusCode != HttpStatusCode.OK) + { + logger.LogError("Failed to upload inventory {file}: {code}", id, response.HttpStatusCode); + return false; + } + + logger.LogInformation("Finished uploading inventory {file} to {bucket}", id, _bucketName); + return true; + } + + /// + public async Task DownloadFile(string key) + { + logger.LogInformation("Began downloading {file} from {bucket}", key, _bucketName); + + try + { + var request = new GetObjectRequest + { + BucketName = _bucketName, + Key = key + }; + + using var response = await client.GetObjectAsync(request); + + if (response.HttpStatusCode != HttpStatusCode.OK) + { + logger.LogError("Failed to download {file}: {code}", key, response.HttpStatusCode); + throw new InvalidOperationException($"Error occurred downloading {key} - {response.HttpStatusCode}"); + } + + using var reader = new StreamReader(response.ResponseStream, Encoding.UTF8); + var content = await reader.ReadToEndAsync(); + + return JsonNode.Parse(content) + ?? throw new InvalidOperationException("Downloaded document is not a valid JSON"); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occurred during {file} downloading", key); + throw; + } + } + + /// + public async Task EnsureBucketExists() + { + logger.LogInformation("Checking whether {bucket} exists", _bucketName); + + try + { + await client.GetBucketLocationAsync(new GetBucketLocationRequest + { + BucketName = _bucketName + }); + + logger.LogInformation("{bucket} already exists", _bucketName); + return; + } + catch (AmazonS3Exception ex) when ( + ex.StatusCode == HttpStatusCode.NotFound || + ex.ErrorCode == "NoSuchBucket" || + ex.ErrorCode == "NotFound") + { + logger.LogInformation("{bucket} does not exist, creating it", _bucketName); + } + + var region = configuration["AWS:Region"] + ?? configuration["AWS_REGION"] + ?? configuration["AWS_DEFAULT_REGION"] + ?? "eu-central-1"; + + var request = new PutBucketRequest{ + BucketName = _bucketName + }; + + if (!string.Equals(region, "us-east-1", StringComparison.OrdinalIgnoreCase)) + request.BucketRegionName = region; + + try + { + await client.PutBucketAsync(request); + logger.LogInformation("{bucket} created in region {region}", _bucketName, region); + } + catch (AmazonS3Exception ex) when ( + ex.ErrorCode == "BucketAlreadyOwnedByYou" || + ex.ErrorCode == "BucketAlreadyExists" || + ex.StatusCode == HttpStatusCode.Conflict) + { + logger.LogInformation("{bucket} already exists", _bucketName); + } + catch (Exception ex) + { + logger.LogError(ex, "Unhandled exception occurred during {bucket} check", _bucketName); + throw; + } + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/appsettings.Development.json b/InventoryManager/Inventory.FileService/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/InventoryManager/Inventory.FileService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/InventoryManager/Inventory.FileService/appsettings.json b/InventoryManager/Inventory.FileService/appsettings.json new file mode 100644 index 00000000..ec04bc12 --- /dev/null +++ b/InventoryManager/Inventory.FileService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/InventoryManager/Inventory.Gateway/Inventory.Gateway.csproj b/InventoryManager/Inventory.Gateway/Inventory.Gateway.csproj new file mode 100644 index 00000000..37bef241 --- /dev/null +++ b/InventoryManager/Inventory.Gateway/Inventory.Gateway.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs b/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs new file mode 100644 index 00000000..3878fe44 --- /dev/null +++ b/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs @@ -0,0 +1,68 @@ +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; + +namespace Inventory.Gateway.LoadBalancer; + +/// +/// Пользовательский балансировщик нагрузки для Ocelot,реализующий алгоритм взвешенного случайного выбора (Weighted Random). +/// Каждый сервис получает вес, увеличивающийся в зависимости от его позиции в списке, +/// после чего один из сервисов выбирается случайным образом пропорционально своему весу. +/// +/// Логгер для записи информации о выбранном сервисе +/// Список доступных сервисов для балансировки нагрузки +public class WeightedRandom(ILogger logger, List services) : ILoadBalancer +{ + /// + /// Тип используемого балансировщика нагрузки. + /// + public string Type => nameof(WeightedRandom); + + /// + /// Генератор случайных чисел для выбора сервиса. + /// + private readonly Random _rng = new(); + + /// + /// Выбирает один из доступных сервисов на основе алгоритма взвешенного случайного выбора. + /// + /// Контекст HTTP-запроса. + /// Выбранный сервис (Host и Port). + public Task> LeaseAsync(HttpContext httpContext) + { + var totalWeight = 0; + for (var i = 0; i < services.Count; i++) + totalWeight += i + 1; + + var ticket = _rng.Next(totalWeight + 1); + + var cumulative = 0; + for (var i = 0; i < services.Count; i++) + { + var weight = i + 1; + + cumulative += weight; + + if (ticket <= cumulative) + { + var service = services[i]; + + logger.LogInformation("WeightedRandom selected port {port}", service.HostAndPort.DownstreamPort); + + return Task.FromResult>( + new OkResponse(service.HostAndPort)); + } + } + + var fallback = services.Last(); + + return Task.FromResult>( + new OkResponse(fallback.HostAndPort)); + } + + /// + /// Метод освобождения ресурса после использования. + /// + /// + public void Release(ServiceHostAndPort hostAndPort) { } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.Gateway/Program.cs b/InventoryManager/Inventory.Gateway/Program.cs new file mode 100644 index 00000000..683109d2 --- /dev/null +++ b/InventoryManager/Inventory.Gateway/Program.cs @@ -0,0 +1,35 @@ +using Inventory.Gateway.LoadBalancer; +using Inventory.ServiceDefaults; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Configuration + .AddJsonFile("ocelot.json", false, true); + +builder.Services.AddOcelot() + .AddCustomLoadBalancer((sp, route, discovery) => + { + var logger = sp.GetRequiredService>(); + var services = discovery.GetAsync().GetAwaiter().GetResult().ToList(); + + return new WeightedRandom(logger, services); + }); + +builder.Services.AddCors(policy => +{ + policy.AddPolicy("cors", cfg => + { + cfg.AllowAnyOrigin() + .WithMethods("GET") + .WithHeaders("Content-Type"); + }); +}); + +var app = builder.Build(); +app.UseCors("cors"); +await app.UseOcelot(); +app.Run(); \ No newline at end of file diff --git a/InventoryManager/Inventory.Gateway/Properties/launchSettings.json b/InventoryManager/Inventory.Gateway/Properties/launchSettings.json new file mode 100644 index 00000000..f7b5a5b7 --- /dev/null +++ b/InventoryManager/Inventory.Gateway/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5085", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7288;http://localhost:5085", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/InventoryManager/Inventory.Gateway/appsettings.Development.json b/InventoryManager/Inventory.Gateway/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/InventoryManager/Inventory.Gateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/InventoryManager/Inventory.Gateway/appsettings.json b/InventoryManager/Inventory.Gateway/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/InventoryManager/Inventory.Gateway/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/InventoryManager/Inventory.Gateway/ocelot.json b/InventoryManager/Inventory.Gateway/ocelot.json new file mode 100644 index 00000000..d9308e15 --- /dev/null +++ b/InventoryManager/Inventory.Gateway/ocelot.json @@ -0,0 +1,104 @@ +{ + "Routes": [ + { + "UpstreamPathTemplate": "/api/inventory", + "UpstreamHttpMethod": [ "GET", "POST", "PUT", "DELETE" ], + "DownstreamPathTemplate": "/api/inventory", + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 7001 + }, + { + "Host": "localhost", + "Port": 7002 + }, + { + "Host": "localhost", + "Port": 7003 + }, + { + "Host": "localhost", + "Port": 7004 + }, + { + "Host": "localhost", + "Port": 7005 + } + ], + "DangerousAcceptAnyServerCertificateValidator": true, + "LoadBalancerOptions": { + "Type": "WeightedRandom" + } + }, + { + "UpstreamPathTemplate": "/api/inventory/{everything}", + "UpstreamHttpMethod": [ "GET", "POST", "PUT", "DELETE" ], + "DownstreamPathTemplate": "/api/inventory/{everything}", + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 7001 + }, + { + "Host": "localhost", + "Port": 7002 + }, + { + "Host": "localhost", + "Port": 7003 + }, + { + "Host": "localhost", + "Port": 7004 + }, + { + "Host": "localhost", + "Port": 7005 + } + ], + "DangerousAcceptAnyServerCertificateValidator": true, + "LoadBalancerOptions": { + "Type": "WeightedRandom" + } + }, + { + "UpstreamPathTemplate": "/health", + "UpstreamHttpMethod": [ "GET" ], + "DownstreamPathTemplate": "/health", + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 7001 + }, + { + "Host": "localhost", + "Port": 7002 + }, + { + "Host": "localhost", + "Port": 7003 + }, + { + "Host": "localhost", + "Port": 7004 + }, + { + "Host": "localhost", + "Port": 7005 + } + ], + "DangerousAcceptAnyServerCertificateValidator": true, + "LoadBalancerOptions": { + "Type": "WeightedRandom" + } + } + ], + "GlobalConfiguration": { + "BaseUrl": "https://localhost:7000", + "RequestIdKey": "Gateway-Request-Id" + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ServiceDefaults/Extensions.cs b/InventoryManager/Inventory.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..61905e5f --- /dev/null +++ b/InventoryManager/Inventory.ServiceDefaults/Extensions.cs @@ -0,0 +1,128 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Inventory.ServiceDefaults; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/InventoryManager/Inventory.ServiceDefaults/Inventory.ServiceDefaults.csproj b/InventoryManager/Inventory.ServiceDefaults/Inventory.ServiceDefaults.csproj new file mode 100644 index 00000000..eeb71e38 --- /dev/null +++ b/InventoryManager/Inventory.ServiceDefaults/Inventory.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/InventoryManager/Inventory.Tests/IntegrationTests.cs b/InventoryManager/Inventory.Tests/IntegrationTests.cs new file mode 100644 index 00000000..7c7b54a7 --- /dev/null +++ b/InventoryManager/Inventory.Tests/IntegrationTests.cs @@ -0,0 +1,158 @@ +using Aspire.Hosting; +using Aspire.Hosting.Testing; +using Inventory.ApiService.Entity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Net; +using System.Text.Json; +using Xunit.Abstractions; + +namespace Inventory.Tests; + +/// +/// Интеграционные тесты для проверки полного сценария генерации инвентаря, публикации сообщения в SNS и сохранения результата в S3 +/// +/// Объект для вывода логов теста в xUnit +public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime +{ + /// + /// Настройки десериализации JSON с нечувствительностью к регистру имён свойств + /// + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private DistributedApplication? _app; + + /// + /// Инициализирует тестовое распределённое приложение Aspire, настраивает логирование и запускает приложение для выполнения интеграционных тестов + /// + /// Асинхронная операция инициализации тестовой среды + public async Task InitializeAsync() + { + var builder = await DistributedApplicationTestingBuilder.CreateAsync(); + + builder.Configuration["DcpPublisher:RandomizePorts"] = "false"; + + builder.Services.AddLogging(logging => + { + logging.AddXUnit(output); + logging.SetMinimumLevel(LogLevel.Debug); + logging.AddFilter("Aspire.Hosting.Dcp", LogLevel.Debug); + logging.AddFilter("Aspire.Hosting", LogLevel.Debug); + }); + + _app = await builder.BuildAsync(); + await _app.StartAsync(); + } + + /// + /// Проверяет, что запрос генерации инвентаря через API Gateway публикует сообщение в SNS и сохраняет полученный продукт в S3-хранилище + /// + /// Асинхронная операция выполнения интеграционного теста + [Fact] + public async Task GenerateInventory_ThroughGateway_ShouldPublishToSns_AndSaveToS3() + { + var app = _app ?? throw new InvalidOperationException("Test application was not initialized."); + + var id = Random.Shared.Next(1, 10_000); + + using var gatewayClient = app.CreateHttpClient("apigateway", "https"); + using var gatewayResponse = await gatewayClient.GetAsync($"/api/inventory?id={id}"); + + var gatewayContent = await gatewayResponse.Content.ReadAsStringAsync(); + + Assert.True(gatewayResponse.IsSuccessStatusCode, + $"Gateway failed: {(int)gatewayResponse.StatusCode} {gatewayResponse.StatusCode}. Body: {gatewayContent}"); + + var apiProduct = JsonSerializer.Deserialize(gatewayContent, _jsonOptions); + + Assert.NotNull(apiProduct); + Assert.Equal(id, apiProduct.Id); + + using var fileServiceClient = app.CreateHttpClient("inventory-files", "http"); + + var matchingFile = await WaitUntilInventoryFileAppearsAsync(fileServiceClient, id, timeout: TimeSpan.FromSeconds(30)); + + Assert.False(string.IsNullOrWhiteSpace(matchingFile)); + + using var s3Response = await fileServiceClient.GetAsync($"/api/s3/{matchingFile}"); + var s3Content = await s3Response.Content.ReadAsStringAsync(); + + Assert.True(s3Response.IsSuccessStatusCode, + $"S3 read failed: {(int)s3Response.StatusCode} {s3Response.StatusCode}. Body: {s3Content}"); + + var s3Product = JsonSerializer.Deserialize(s3Content, _jsonOptions); + + Assert.NotNull(s3Product); + Assert.Equal(id, s3Product.Id); + Assert.Equivalent(apiProduct, s3Product); + } + + /// + /// Ожидает появления файла инвентаря в S3-хранилище в течение заданного времени + /// + /// HTTP-клиент сервиса файлов для обращения к S3 API + /// Идентификатор продукта, файл которого необходимо найти + /// Максимальное время ожидания появления файла + /// Имя найденного файла инвентаря + /// + /// Возникает, если файл с указанным идентификатором не появился в S3 за отведённое время + /// + private static async Task WaitUntilInventoryFileAppearsAsync(HttpClient fileServiceClient, int id, TimeSpan timeout) + { + var deadline = DateTimeOffset.UtcNow.Add(timeout); + var expectedPart = $"inventory_{id}"; + + Exception? lastException = null; + + while (DateTimeOffset.UtcNow < deadline) + { + try + { + using var listResponse = await fileServiceClient.GetAsync("/api/s3"); + var listContent = await listResponse.Content.ReadAsStringAsync(); + + if (listResponse.StatusCode == HttpStatusCode.NotFound) + { + await Task.Delay(500); + continue; + } + + listResponse.EnsureSuccessStatusCode(); + + var files = JsonSerializer.Deserialize>(listContent, _jsonOptions) ?? []; + + var matchingFile = files.FirstOrDefault(file => file.Contains(expectedPart)); + + if (!string.IsNullOrWhiteSpace(matchingFile)) + { + return matchingFile; + } + } + catch (Exception ex) + { + lastException = ex; + } + + await Task.Delay(500); + } + + throw new TimeoutException( + $"Inventory file '{expectedPart}' was not found in S3 within {timeout.TotalSeconds} seconds.", + lastException); + } + + /// + /// Останавливает и освобождает ресурсы тестового распределённого приложения Aspire + /// + /// Асинхронная операция освобождения ресурсов тестовой среды + public async Task DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.Tests/Inventory.Tests.csproj b/InventoryManager/Inventory.Tests/Inventory.Tests.csproj new file mode 100644 index 00000000..f3c887f0 --- /dev/null +++ b/InventoryManager/Inventory.Tests/Inventory.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index dcaa5eb7..715addd7 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,12 @@ -# Современные технологии разработки программного обеспечения -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing) +## Ле Хань Хоанг 6513 +## Вариант 45 - Товар на складке, алгоритм балансировки - Weighted Random, Брокер - SNS, Хостинг S3 - LocalStack +## Лабораторная работа №3 - Интеграционное тестирование -## Задание -### Цель -Реализация проекта микросервисного бекенда. +image -### Задачи -* Реализация межсервисной коммуникации, -* Изучение работы с брокерами сообщений, -* Изучение архитектурных паттернов, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Интеграционное тестирование. -### Лабораторные работы -
-1. «Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов -
- -В рамках первой лабораторной работы необходимо: -* Реализовать сервис генерации контрактов на основе Bogus, -* Реализовать кеширование при помощи IDistributedCache и Redis, -* Реализовать структурное логирование сервиса генерации, -* Настроить оркестрацию Aspire. - -
-
-2. «Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы -
- -В рамках второй лабораторной работы необходимо: -* Настроить оркестрацию на запуск нескольких реплик сервиса генерации, -* Реализовать апи гейтвей на основе Ocelot, -* Имплементировать алгоритм балансировки нагрузки согласно варианту. +image -
-
-
-3. «Интеграционное тестирование» - Реализация файлового сервиса и объектного хранилища, интеграционное тестирование бекенда -
-В рамках третьей лабораторной работы необходимо: -* Добавить в оркестрацию объектное хранилище, -* Реализовать файловый сервис, сериализующий сгенерированные данные в файлы и сохраняющий их в объектном хранилище, -* Реализовать отправку генерируемых данных в файловый сервис посредством брокера, -* Реализовать интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе. - -
-
-
-4. (Опционально) «Переход на облачную инфраструктуру» - Перенос бекенда в Yandex Cloud -
- -В рамках четвертой лабораторной работы необходимо перенестиервисы на облако все ранее разработанные сервисы: -* Клиент - в хостинг через отдельный бакет Object Storage, -* Сервис генерации - в Cloud Function, -* Апи гейтвей - в Serverless Integration как API Gateway, -* Брокер сообщений - в Message Queue, -* Файловый сервис - в Cloud Function, -* Объектное хранилище - в отдельный бакет Object Storage, - -
-
- -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview). -* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview). -* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus). -* Реализация тестов с использованием [xUnit](https://xunit.net/?tabs=cs). -* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация. - -**Факультативно**: -* Перенос бекенда на облачную инфраструктуру Yandex Cloud - -Внимательно прочитайте [дискуссии](https://github.com/itsecd/cloud-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. - -По итогу работы в семестре должна получиться следующая информационная система: -
-C4 диаграмма -Современные_технологии_разработки_ПО_drawio -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](https://docs.google.com/document/d/1WGmLYwffTTaAj4TgFCk5bUyW3XKbFMiBm-DHZrfFWr4/edit?usp=sharing) -[Список предметных областей и алгоритмов балансировки](https://docs.google.com/document/d/1PLn2lKe4swIdJDZhwBYzxqFSu0AbY2MFY1SUPkIKOM4/edit?usp=sharing) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). - -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve - -## Критерии оценивания - -Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания - -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. -Не укладываетесь в дедлайн - получаете минимально возможный балл. - -### Шкала оценивания - -- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл - -## Вопросы и обратная связь по курсу - -Чтобы задать вопрос по лабораторной, воспользуйтесь [соответствующим разделом дискуссий](https://github.com/itsecd/cloud-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/cloud-development/issues/new). -Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/cloud-development/discussions/categories/ideas). +image