diff --git a/Api.Gateway/Api.Gateway.csproj b/Api.Gateway/Api.Gateway.csproj
new file mode 100644
index 00000000..6ffae862
--- /dev/null
+++ b/Api.Gateway/Api.Gateway.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Api.Gateway/LoadBalancers/WeightedRandomLoadBalancer.cs b/Api.Gateway/LoadBalancers/WeightedRandomLoadBalancer.cs
new file mode 100644
index 00000000..fb39f531
--- /dev/null
+++ b/Api.Gateway/LoadBalancers/WeightedRandomLoadBalancer.cs
@@ -0,0 +1,54 @@
+using Ocelot.LoadBalancer.Interfaces;
+using Ocelot.Responses;
+using Ocelot.Values;
+
+namespace Api.Gateway.LoadBalancers;
+
+///
+/// Балансировщик нагрузки на основе взвешенного случайного выбора (Weighted Random).
+/// Каждой реплике назначается вероятность выбора. При поступлении запроса
+/// реплика выбирается случайно с учётом заданных вероятностей.
+/// Веса задаются в appsettings.json в секции "WeightedRandomWeights".
+///
+/// Фабрика для получения списка доступных сервисов.
+/// Конфигурация приложения для чтения весов из секции "WeightedRandomWeights".
+public class WeightedRandomLoadBalancer(Func>> services, IConfiguration configuration) : ILoadBalancer
+{
+ private readonly double[] _cumulativeWeights = BuildCumulativeWeights(
+ configuration.GetSection("WeightedRandomWeights").Get() ?? [0.4, 0.3, 0.15, 0.1, 0.05]);
+
+ public string Type => nameof(WeightedRandomLoadBalancer);
+
+ public async Task> LeaseAsync(HttpContext httpContext)
+ {
+ var availableServices = await services();
+
+ if (availableServices.Count == 0)
+ throw new InvalidOperationException("No available downstream services");
+
+ var index = Array.BinarySearch(_cumulativeWeights, Random.Shared.NextDouble());
+ if (index < 0) index = ~index;
+
+ return new OkResponse(
+ availableServices[Math.Min(index, availableServices.Count - 1)].HostAndPort);
+ }
+
+ public void Release(ServiceHostAndPort hostAndPort) { }
+
+ ///
+ /// Строит массив кумулятивных весов на основе входных весов.
+ /// Каждый элемент результирующего массива равен сумме всех предыдущих весов включительно.
+ /// Используется для выбора реплики методом бинарного поиска по случайному числу.
+ ///
+ /// Массив весов для каждой реплики.
+ /// Массив кумулятивных весов.
+ private static double[] BuildCumulativeWeights(double[] weights)
+ {
+ var total = weights.Sum();
+ var cumulative = new double[weights.Length];
+ cumulative[0] = weights[0] / total;
+ for (var i = 1; i < weights.Length; i++)
+ cumulative[i] = cumulative[i - 1] + weights[i] / total;
+ return cumulative;
+ }
+}
diff --git a/Api.Gateway/Program.cs b/Api.Gateway/Program.cs
new file mode 100644
index 00000000..0997cb78
--- /dev/null
+++ b/Api.Gateway/Program.cs
@@ -0,0 +1,32 @@
+using Api.Gateway.LoadBalancers;
+using Ocelot.DependencyInjection;
+using Ocelot.Middleware;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.Services.AddServiceDiscovery();
+builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true);
+builder.Services.AddOcelot()
+ .AddCustomLoadBalancer((sp, _, provider) =>
+ new WeightedRandomLoadBalancer(provider.GetAsync, sp.GetRequiredService()));
+
+var trustedOrigins = builder.Configuration
+ .GetSection("TrustedOrigins")
+ .Get() ?? [];
+
+builder.Services.AddCors(options =>
+{
+ options.AddDefaultPolicy(policy =>
+ {
+ policy.WithOrigins(trustedOrigins)
+ .WithMethods("GET")
+ .AllowAnyHeader();
+ });
+});
+
+var app = builder.Build();
+app.UseCors();
+app.MapDefaultEndpoints();
+await app.UseOcelot();
+app.Run();
diff --git a/Api.Gateway/Properties/launchSettings.json b/Api.Gateway/Properties/launchSettings.json
new file mode 100644
index 00000000..2f53e6a4
--- /dev/null
+++ b/Api.Gateway/Properties/launchSettings.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:64729",
+ "sslPort": 44359
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5025",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7081;http://localhost:5025",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/Api.Gateway/appsettings.Development.json b/Api.Gateway/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/Api.Gateway/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/Api.Gateway/appsettings.json b/Api.Gateway/appsettings.json
new file mode 100644
index 00000000..93d01ee7
--- /dev/null
+++ b/Api.Gateway/appsettings.json
@@ -0,0 +1,14 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "TrustedOrigins": [
+ "http://localhost:5127",
+ "https://localhost:7282"
+ ],
+ "WeightedRandomWeights": [ 0.30, 0.25, 0.20, 0.15, 0.10 ]
+}
diff --git a/Api.Gateway/ocelot.json b/Api.Gateway/ocelot.json
new file mode 100644
index 00000000..d2e65724
--- /dev/null
+++ b/Api.Gateway/ocelot.json
@@ -0,0 +1,20 @@
+{
+ "Routes": [
+ {
+ "UpstreamPathTemplate": "/software-projects",
+ "UpstreamHttpMethod": [ "GET" ],
+ "DownstreamPathTemplate": "/api/software-projects",
+ "DownstreamScheme": "https",
+ "DownstreamHostAndPorts": [
+ { "Host": "localhost", "Port": 5200 },
+ { "Host": "localhost", "Port": 5201 },
+ { "Host": "localhost", "Port": 5202 },
+ { "Host": "localhost", "Port": 5203 },
+ { "Host": "localhost", "Port": 5204 }
+ ],
+ "LoadBalancerOptions": {
+ "Type": "WeightedRandomLoadBalancer"
+ }
+ }
+ ]
+}
diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor
index 661f1181..a68453c9 100644
--- a/Client.Wasm/Components/StudentCard.razor
+++ b/Client.Wasm/Components/StudentCard.razor
@@ -4,10 +4,10 @@
- Номер №X "Название лабораторной"
- Вариант №Х "Название варианта"
- Выполнена Фамилией Именем 65ХХ
- Ссылка на форк
+ Номер №3 "Интеграционное тестирование"
+ Вариант №39 "Программный проект"
+ Выполнил Жидяев Дмитрий 6513
+ Ссылка на форк
diff --git a/Client.Wasm/Properties/launchSettings.json b/Client.Wasm/Properties/launchSettings.json
index 0d824ea7..60120ec3 100644
--- a/Client.Wasm/Properties/launchSettings.json
+++ b/Client.Wasm/Properties/launchSettings.json
@@ -12,7 +12,7 @@
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
- "launchBrowser": true,
+ "launchBrowser": false,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5127",
"environmentVariables": {
@@ -22,7 +22,7 @@
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
- "launchBrowser": true,
+ "launchBrowser": false,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7282;http://localhost:5127",
"environmentVariables": {
@@ -31,7 +31,7 @@
},
"IIS Express": {
"commandName": "IISExpress",
- "launchBrowser": true,
+ "launchBrowser": false,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json
index d1fe7ab3..ad3d834b 100644
--- a/Client.Wasm/wwwroot/appsettings.json
+++ b/Client.Wasm/wwwroot/appsettings.json
@@ -6,5 +6,5 @@
}
},
"AllowedHosts": "*",
- "BaseAddress": ""
+ "BaseAddress": "https://localhost:7081/software-projects"
}
diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln
index cb48241d..2bcdcf65 100644
--- a/CloudDevelopment.sln
+++ b/CloudDevelopment.sln
@@ -5,6 +5,18 @@ VisualStudioVersion = 17.14.36811.4
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}") = "SoftwareProjects.AppHost", "SoftwareProjects\SoftwareProjects.AppHost\SoftwareProjects.AppHost.csproj", "{EC51E772-B7D1-4185-86B3-9E0ACF79C521}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SoftwareProjects.ServiceDefaults", "SoftwareProjects\SoftwareProjects.ServiceDefaults\SoftwareProjects.ServiceDefaults.csproj", "{D568B20D-5F65-3DB5-3289-0CA101BC09FE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SoftwareProjects.Api", "SoftwareProjects.Api\SoftwareProjects.Api.csproj", "{D333DF66-6F24-76D8-CFA9-228C3368FF0D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.Gateway", "Api.Gateway\Api.Gateway.csproj", "{C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "File.Service", "File.Service\File.Service.csproj", "{DF944664-05C9-E01F-B262-C8B381C1EFE1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SoftwareProjects.AppHost.Tests", "SoftwareProjects\SoftwareProjects.AppHost.Tests\SoftwareProjects.AppHost.Tests.csproj", "{7B199D9A-546C-4B96-9B5C-E0B9F16B3E62}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -15,6 +27,30 @@ Global
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.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
+ {EC51E772-B7D1-4185-86B3-9E0ACF79C521}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EC51E772-B7D1-4185-86B3-9E0ACF79C521}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EC51E772-B7D1-4185-86B3-9E0ACF79C521}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EC51E772-B7D1-4185-86B3-9E0ACF79C521}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D568B20D-5F65-3DB5-3289-0CA101BC09FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D568B20D-5F65-3DB5-3289-0CA101BC09FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D568B20D-5F65-3DB5-3289-0CA101BC09FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D568B20D-5F65-3DB5-3289-0CA101BC09FE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D333DF66-6F24-76D8-CFA9-228C3368FF0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D333DF66-6F24-76D8-CFA9-228C3368FF0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D333DF66-6F24-76D8-CFA9-228C3368FF0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D333DF66-6F24-76D8-CFA9-228C3368FF0D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7B199D9A-546C-4B96-9B5C-E0B9F16B3E62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7B199D9A-546C-4B96-9B5C-E0B9F16B3E62}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7B199D9A-546C-4B96-9B5C-E0B9F16B3E62}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7B199D9A-546C-4B96-9B5C-E0B9F16B3E62}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/File.Service/Controllers/SnsWebhookController.cs b/File.Service/Controllers/SnsWebhookController.cs
new file mode 100644
index 00000000..b8c20ff5
--- /dev/null
+++ b/File.Service/Controllers/SnsWebhookController.cs
@@ -0,0 +1,68 @@
+using Amazon.SimpleNotificationService.Util;
+using File.Service.Storage;
+using Microsoft.AspNetCore.Mvc;
+using System.Text;
+
+namespace File.Service.Controllers;
+
+///
+/// Контроллер вебхука, принимающий уведомления от SNS-топика
+///
+/// Служба объектного хранилища, в которое сохраняется тело уведомления
+/// Структурный логгер
+[ApiController]
+[Route("api/sns")]
+public class SnsWebhookController(IObjectStorage storage, ILogger logger) : ControllerBase
+{
+ ///
+ /// Принимает HTTP-запросы от SNS. Подтверждает подписку, если получено сообщение типа
+ /// SubscriptionConfirmation, иначе сохраняет полезную нагрузку в объектное хранилище
+ ///
+ /// 200 OK в любом случае — иначе SNS будет повторять отправку
+ [HttpPost]
+ [ProducesResponseType(200)]
+ public async Task Receive()
+ {
+ logger.LogInformation("SNS webhook invoked");
+ 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 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("SNS subscription confirmed");
+ return Ok();
+ }
+
+ if (snsMessage.Type == "Notification")
+ {
+ await storage.UploadProject(snsMessage.MessageText);
+ logger.LogInformation("SNS notification successfully processed");
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Exception while processing SNS notification");
+ }
+
+ return Ok();
+ }
+}
diff --git a/File.Service/Controllers/StorageController.cs b/File.Service/Controllers/StorageController.cs
new file mode 100644
index 00000000..1169409a
--- /dev/null
+++ b/File.Service/Controllers/StorageController.cs
@@ -0,0 +1,61 @@
+using File.Service.Storage;
+using Microsoft.AspNetCore.Mvc;
+using System.Text.Json.Nodes;
+
+namespace File.Service.Controllers;
+
+///
+/// HTTP-контроллер для просмотра содержимого S3-бакета
+///
+/// Служба объектного хранилища
+/// Структурный логгер
+[ApiController]
+[Route("api/s3")]
+public class StorageController(IObjectStorage storage, ILogger logger) : ControllerBase
+{
+ ///
+ /// Возвращает список ключей всех файлов, лежащих в бакете
+ ///
+ /// Коллекция строковых ключей объектов в бакете
+ [HttpGet]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task>> List()
+ {
+ logger.LogInformation("Listing files in object storage");
+ try
+ {
+ var keys = await storage.ListProjects();
+ logger.LogInformation("Got {Count} keys from bucket", keys.Count);
+ return Ok(keys);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to list files");
+ return StatusCode(500, ex.Message);
+ }
+ }
+
+ ///
+ /// Возвращает JSON-содержимое файла из бакета по его ключу
+ ///
+ /// Ключ файла внутри бакета (например, software-project-42.json)
+ /// Десериализованный JSON-документ
+ [HttpGet("{key}")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task> Get(string key)
+ {
+ logger.LogInformation("Downloading file {Key} from object storage", key);
+ try
+ {
+ var node = await storage.DownloadProject(key);
+ return Ok(node);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to download file {Key}", key);
+ return StatusCode(500, ex.Message);
+ }
+ }
+}
diff --git a/File.Service/File.Service.csproj b/File.Service/File.Service.csproj
new file mode 100644
index 00000000..a475f300
--- /dev/null
+++ b/File.Service/File.Service.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/File.Service/Messaging/SnsSubscriptionService.cs b/File.Service/Messaging/SnsSubscriptionService.cs
new file mode 100644
index 00000000..e9858c85
--- /dev/null
+++ b/File.Service/Messaging/SnsSubscriptionService.cs
@@ -0,0 +1,52 @@
+using Amazon.SimpleNotificationService;
+using Amazon.SimpleNotificationService.Model;
+using System.Net;
+
+namespace File.Service.Messaging;
+
+///
+/// Служба, выполняющая HTTP-подписку File.Service на SNS-топик при старте приложения
+///
+/// Клиент Amazon SNS, разрешённый из DI
+/// Конфигурация приложения; содержит ARN топика и URL вебхука
+/// Структурный логгер
+public class SnsSubscriptionService(
+ IAmazonSimpleNotificationService snsClient,
+ IConfiguration configuration,
+ ILogger logger)
+{
+ ///
+ /// ARN SNS-топика, на который подписывается сервис. Берётся из CloudFormation outputs (AWS:Resources:SNSTopicArn)
+ ///
+ private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"]
+ ?? throw new KeyNotFoundException("SNS topic ARN was not found in configuration");
+
+ ///
+ /// Публичный URL HTTP-эндпоинта, на который SNS будет присылать уведомления (контроллер SnsWebhookController)
+ ///
+ private readonly string _endpoint = configuration["AWS:Resources:SNSUrl"]
+ ?? throw new KeyNotFoundException("SNS subscriber URL was not found in configuration");
+
+ ///
+ /// Регистрирует HTTP-подписку на SNS-топик. Само подтверждение подписки выполняется
+ /// контроллером вебхука при получении сообщения типа SubscriptionConfirmation
+ ///
+ /// Задача, завершающаяся после получения ответа от SNS
+ public async Task SubscribeEndpoint()
+ {
+ logger.LogInformation("Subscribing endpoint {Endpoint} to SNS topic {Topic}", _endpoint, _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 SNS topic {Topic}: {Code}", _topicArn, response.HttpStatusCode);
+ else
+ logger.LogInformation("Subscription request for SNS topic {Topic} sent, awaiting confirmation", _topicArn);
+ }
+}
diff --git a/File.Service/Program.cs b/File.Service/Program.cs
new file mode 100644
index 00000000..ca607bd1
--- /dev/null
+++ b/File.Service/Program.cs
@@ -0,0 +1,49 @@
+using Amazon.S3;
+using Amazon.SimpleNotificationService;
+using File.Service.Messaging;
+using File.Service.Storage;
+using LocalStack.Client.Extensions;
+using System.Reflection;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(options =>
+{
+ var assembly = Assembly.GetExecutingAssembly();
+ var xmlPath = Path.Combine(AppContext.BaseDirectory, $"{assembly.GetName().Name}.xml");
+ if (System.IO.File.Exists(xmlPath))
+ options.IncludeXmlComments(xmlPath);
+});
+
+builder.Services.AddLocalStack(builder.Configuration);
+builder.Services.AddAwsService();
+builder.Services.AddAwsService();
+
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+
+using var scope = app.Services.CreateScope();
+
+var storage = scope.ServiceProvider.GetRequiredService();
+await storage.EnsureBucketExists();
+
+var subscription = scope.ServiceProvider.GetRequiredService();
+await subscription.SubscribeEndpoint();
+
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+app.MapControllers();
+
+app.Run();
diff --git a/File.Service/Properties/launchSettings.json b/File.Service/Properties/launchSettings.json
new file mode 100644
index 00000000..29d026c4
--- /dev/null
+++ b/File.Service/Properties/launchSettings.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5300",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/File.Service/Storage/IObjectStorage.cs b/File.Service/Storage/IObjectStorage.cs
new file mode 100644
index 00000000..bfe2f5ff
--- /dev/null
+++ b/File.Service/Storage/IObjectStorage.cs
@@ -0,0 +1,35 @@
+using System.Text.Json.Nodes;
+
+namespace File.Service.Storage;
+
+///
+/// Контракт службы для работы с объектным хранилищем (S3-совместимым)
+///
+public interface IObjectStorage
+{
+ ///
+ /// Загружает JSON-документ в объектное хранилище. Имя файла формируется на основе поля id JSON-документа
+ ///
+ /// JSON-строка с данными программного проекта
+ /// true, если документ успешно загружен; иначе false
+ public Task UploadProject(string payload);
+
+ ///
+ /// Получает список ключей всех файлов, сохранённых в бакете
+ ///
+ /// Коллекция строковых ключей объектов
+ public Task> ListProjects();
+
+ ///
+ /// Скачивает указанный файл из бакета и десериализует его как JSON
+ ///
+ /// Ключ файла внутри бакета
+ /// Десериализованный с содержимым файла
+ public Task DownloadProject(string key);
+
+ ///
+ /// Создаёт целевой бакет в хранилище, если его ещё нет
+ ///
+ /// Задача, завершающаяся после проверки/создания бакета
+ public Task EnsureBucketExists();
+}
diff --git a/File.Service/Storage/S3ObjectStorage.cs b/File.Service/Storage/S3ObjectStorage.cs
new file mode 100644
index 00000000..ed979be7
--- /dev/null
+++ b/File.Service/Storage/S3ObjectStorage.cs
@@ -0,0 +1,126 @@
+using Amazon.S3;
+using Amazon.S3.Model;
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace File.Service.Storage;
+
+///
+/// Реализация поверх AWS-совместимого S3 (LocalStack)
+///
+/// Клиент AWS SDK для S3, разрешённый из DI
+/// Конфигурация приложения; используется для получения имени бакета
+/// Структурный логгер
+public class S3ObjectStorage(
+ IAmazonS3 client,
+ IConfiguration configuration,
+ ILogger logger) : IObjectStorage
+{
+ ///
+ /// Имя S3-бакета. Берётся из CloudFormation outputs (AWS:Resources:S3BucketName)
+ ///
+ private readonly string _bucketName = configuration["AWS:Resources:S3BucketName"]
+ ?? throw new KeyNotFoundException("S3 bucket name was not found in configuration");
+
+ ///
+ public async Task UploadProject(string payload)
+ {
+ var rootNode = JsonNode.Parse(payload) ?? throw new ArgumentException("Payload is not a valid JSON");
+ var id = rootNode["Id"]?.GetValue()
+ ?? rootNode["id"]?.GetValue()
+ ?? throw new ArgumentException("Payload JSON does not contain 'Id' field");
+
+ using var stream = new MemoryStream();
+ JsonSerializer.Serialize(stream, rootNode);
+ stream.Seek(0, SeekOrigin.Begin);
+
+ logger.LogInformation("Uploading software project {ProjectId} to bucket {Bucket}", id, _bucketName);
+ var request = new PutObjectRequest
+ {
+ BucketName = _bucketName,
+ Key = $"software-project-{id}.json",
+ InputStream = stream,
+ ContentType = "application/json"
+ };
+
+ var response = await client.PutObjectAsync(request);
+ if (response.HttpStatusCode != HttpStatusCode.OK)
+ {
+ logger.LogError("Failed to upload software project {ProjectId}: {Code}", id, response.HttpStatusCode);
+ return false;
+ }
+
+ logger.LogInformation("Software project {ProjectId} successfully uploaded to bucket {Bucket}", id, _bucketName);
+ return true;
+ }
+
+ ///
+ public async Task> ListProjects()
+ {
+ var keys = new List();
+ var request = new ListObjectsV2Request
+ {
+ BucketName = _bucketName,
+ Prefix = string.Empty
+ };
+
+ logger.LogInformation("Listing objects in bucket {Bucket}", _bucketName);
+ var paginator = client.Paginators.ListObjectsV2(request);
+ await foreach (var response in paginator.Responses)
+ {
+ if (response?.S3Objects is null)
+ {
+ logger.LogWarning("Received empty response page from bucket {Bucket}", _bucketName);
+ continue;
+ }
+
+ foreach (var obj in response.S3Objects)
+ if (obj is not null)
+ keys.Add(obj.Key);
+ }
+
+ return keys;
+ }
+
+ ///
+ public async Task DownloadProject(string key)
+ {
+ logger.LogInformation("Downloading object {Key} from bucket {Bucket}", key, _bucketName);
+
+ 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 {Key} from bucket {Bucket}: {Code}", key, _bucketName, 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($"Object {key} contents are not a valid JSON");
+ }
+
+ ///
+ public async Task EnsureBucketExists()
+ {
+ logger.LogInformation("Ensuring bucket {Bucket} exists", _bucketName);
+ try
+ {
+ await client.EnsureBucketExistsAsync(_bucketName);
+ logger.LogInformation("Bucket {Bucket} is ready", _bucketName);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unhandled exception while ensuring bucket {Bucket}", _bucketName);
+ throw;
+ }
+ }
+}
diff --git a/File.Service/appsettings.Development.json b/File.Service/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/File.Service/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/File.Service/appsettings.json b/File.Service/appsettings.json
new file mode 100644
index 00000000..10f68b8c
--- /dev/null
+++ b/File.Service/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/README.md b/README.md
index dcaa5eb7..f5a6d6be 100644
--- a/README.md
+++ b/README.md
@@ -1,128 +1,105 @@
-# Современные технологии разработки программного обеспечения
-[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing)
-
-## Задание
-### Цель
-Реализация проекта микросервисного бекенда.
-
-### Задачи
-* Реализация межсервисной коммуникации,
-* Изучение работы с брокерами сообщений,
-* Изучение архитектурных паттернов,
-* Изучение работы со средствами оркестрации на примере .NET Aspire,
-* Повторение основ работы с системами контроля версий,
-* Интеграционное тестирование.
-
-### Лабораторные работы
-
-1. «Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов
-
-
-В рамках первой лабораторной работы необходимо:
-* Реализовать сервис генерации контрактов на основе Bogus,
-* Реализовать кеширование при помощи IDistributedCache и Redis,
-* Реализовать структурное логирование сервиса генерации,
-* Настроить оркестрацию Aspire.
-
-
-
-2. «Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы
-
-
-В рамках второй лабораторной работы необходимо:
-* Настроить оркестрацию на запуск нескольких реплик сервиса генерации,
-* Реализовать апи гейтвей на основе Ocelot,
-* Имплементировать алгоритм балансировки нагрузки согласно варианту.
-
-
-
-
-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 диаграмма
-
-
-
-## Варианты заданий
-Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи.
-
-[Список вариантов](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).
+# Лабораторная работа №1 «Кэширование»
+## Вариант №39 — «Программный проект»
+
+## Описание
+
+Реализован сервис генерации данных о программных проектах с кэшированием ответов в Redis и оркестрацией через .NET Aspire
+
+## Что реализовано
+
+### Генерация данных (Bogus)
+- Класс `SoftwareProjectFaker` с `RuleFor` для каждого поля
+- Генерация ФИО менеджера с отчеством, образованным от мужского имени с учётом пола (окончания «ович/овна», «евич/евна»)
+- Локаль `ru` для русскоязычных имён
+
+### Кэширование (Redis + IDistributedCache)
+- Сервис `SoftwareProjectService` с интерфейсом `ISoftwareProjectService`
+
+### Структурное логирование
+- Логирование на английском языке через `ILogger`
+- Структурные параметры `{ProjectId}` для корреляции
+- Отдельные уровни: `Information` для успешных операций, `Warning` для ошибок кэша, `Error` для ошибок генерации
+
+### CORS
+- Разрешён только `GET`-запрос
+- Доверенные URL вынесены в `appsettings.json` (`TrustedOrigins`)
+
+### Оркестрация (.NET Aspire)
+- Redis с RedisInsight
+- API сервис ждёт Redis (`WaitFor(cache)`)
+- Клиент WASM ждёт API сервис (`WaitFor(softwareProjectsApi)`)
+
+### API
+- Единственный эндпоинт: `GET /api/software-projects?id={id}`
+- Minimal API
+
+---
+
+# Лабораторная работа №2 «Балансировка нагрузки»
+
+## Описание
+
+Реализован API Gateway на основе Ocelot с кастомным алгоритмом балансировки нагрузки Weighted Random
+
+## Что реализовано
+
+### Несколько реплик сервиса генерации
+- 5 реплик `SoftwareProjects.Api` на портах 5200–5204
+- Оркестрация через .NET Aspire (`AppHost.cs`)
+
+### API Gateway (Ocelot)
+- Маршрутизация запросов `/software-projects` → `/api/software-projects`
+- Конфигурация в `ocelot.json`
+
+### Балансировщик Weighted Random
+- Каждой реплике назначается вероятность выбора
+- При поступлении запроса реплика выбирается случайно с учётом заданных вероятностей
+
+---
+
+# Лабораторная работа №3 «Интеграционное тестирование»
+
+## Вариант: SNS + LocalStack
+
+## Описание
+
+В оркестрацию добавлены брокер сообщений **SNS** и объектное хранилище **S3**, которые
+поднимаются в эмуляторе **LocalStack**. Реализован файловый сервис, принимающий уведомления
+из SNS-топика и сохраняющий полезную нагрузку в S3-бакет в виде JSON-файлов. Реализованы
+интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе.
+
+## Что реализовано
+
+### Объектное хранилище и брокер (LocalStack)
+- В `SoftwareProjects.AppHost` добавлен контейнер `softwareprojects-localstack` через
+ `LocalStack.Aspire.Hosting`
+- Ресурсы (S3-бакет `softwareprojects-bucket` и SNS-топик `softwareprojects-topic`)
+ поднимаются в LocalStack из CloudFormation-шаблона
+ [`CloudFormation/softwareprojects-template.yaml`](SoftwareProjects/SoftwareProjects.AppHost/CloudFormation/softwareprojects-template.yaml)
+- Имена и ARN ресурсов автоматически прокидываются в сервисы через `WithReference(awsResources)`
+
+### Публикация в SNS из API сервиса (`SoftwareProjects.Api`)
+- Интерфейс `IProjectPublisher` с реализацией `SnsProjectPublisher`
+- После генерации проекта `SoftwareProjectService` сериализует его в JSON и публикует в
+ SNS-топик через `IAmazonSimpleNotificationService`
+- LocalStack-клиент подключается через `LocalStack.Client.Extensions`
+
+### Файловый сервис (`File.Service`)
+- HTTP-подписчик SNS: контроллер `SnsWebhookController` принимает
+ `SubscriptionConfirmation` и `Notification` от SNS-топика, подтверждает подписку и
+ передаёт полезную нагрузку в объектное хранилище
+- Реализация объектного хранилища `S3ObjectStorage` поверх AWS SDK (LocalStack):
+ загрузка, листинг и скачивание файлов
+- Контроллер `StorageController` (`/api/s3`) для просмотра содержимого бакета
+- Подписка на SNS и `EnsureBucketExists` выполняются на старте приложения
+
+### Интеграционные тесты (`SoftwareProjects.AppHost.Tests`)
+Поднимают всю распределённую систему через `DistributedApplicationTestingBuilder` и проверяют:
+1. **`Pipeline_GatewayRequest_PersistsGeneratedProjectToS3`** — запрос через гейтвей доходит
+ до API, сгенерированный проект публикуется в SNS, файловый сервис сохраняет его в S3,
+ содержимое файла совпадает с ответом API
+2. **`Pipeline_MultipleRequests_AreAllListedInBucket`** — после нескольких запросов с
+ разными идентификаторами в бакете присутствуют все ожидаемые ключи
+3. **`Pipeline_RepeatedRequests_DoNotProduceDuplicateFiles`** — повторные запросы с тем же
+ идентификатором обслуживаются из Redis-кэша и не создают дубликатов файлов в S3
diff --git a/SoftwareProjects.Api/Entities/SoftwareProject.cs b/SoftwareProjects.Api/Entities/SoftwareProject.cs
new file mode 100644
index 00000000..3c044b94
--- /dev/null
+++ b/SoftwareProjects.Api/Entities/SoftwareProject.cs
@@ -0,0 +1,57 @@
+namespace SoftwareProjects.Api.Entities;
+
+///
+/// Программный проект с информацией о бюджете, сроках и прогрессе
+///
+public class SoftwareProject
+{
+ ///
+ /// Идентификатор в системе
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Название проекта
+ ///
+ public required string ProjectName { get; set; }
+
+ ///
+ /// Заказчик проекта
+ ///
+ public required string CustomerCompany { get; set; }
+
+ ///
+ /// Менеджер проекта (Фамилия Имя Отчество)
+ ///
+ public required string ProjectManager { get; set; }
+
+ ///
+ /// Дата начала
+ ///
+ public DateOnly StartDate { get; set; }
+
+ ///
+ /// Плановая дата завершения
+ ///
+ public DateOnly PlannedEndDate { get; set; }
+
+ ///
+ /// Фактическая дата завершения
+ ///
+ public DateOnly? ActualEndDate { get; set; }
+
+ ///
+ /// Бюджет
+ ///
+ public decimal Budget { get; set; }
+
+ ///
+ /// Фактические затраты
+ ///
+ public decimal ActualCosts { get; set; }
+
+ ///
+ /// Процент выполнения (0-100)
+ ///
+ public int CompletionPercentage { get; set; }
+}
diff --git a/SoftwareProjects.Api/Messaging/IProjectPublisher.cs b/SoftwareProjects.Api/Messaging/IProjectPublisher.cs
new file mode 100644
index 00000000..c757b65d
--- /dev/null
+++ b/SoftwareProjects.Api/Messaging/IProjectPublisher.cs
@@ -0,0 +1,16 @@
+using SoftwareProjects.Api.Entities;
+
+namespace SoftwareProjects.Api.Messaging;
+
+///
+/// Интерфейс службы публикации сгенерированных программных проектов в брокер сообщений
+///
+public interface IProjectPublisher
+{
+ ///
+ /// Публикует сериализованное представление программного проекта в брокер
+ ///
+ /// Программный проект, который должен быть передан в файловый сервис
+ /// Задача, завершающаяся после публикации
+ public Task Publish(SoftwareProject project);
+}
diff --git a/SoftwareProjects.Api/Messaging/SnsProjectPublisher.cs b/SoftwareProjects.Api/Messaging/SnsProjectPublisher.cs
new file mode 100644
index 00000000..21384ef7
--- /dev/null
+++ b/SoftwareProjects.Api/Messaging/SnsProjectPublisher.cs
@@ -0,0 +1,52 @@
+using Amazon.SimpleNotificationService;
+using Amazon.SimpleNotificationService.Model;
+using SoftwareProjects.Api.Entities;
+using System.Net;
+using System.Text.Json;
+
+namespace SoftwareProjects.Api.Messaging;
+
+///
+/// Реализация , публикующая сообщение в SNS-топик AWS/LocalStack
+///
+/// Клиент Amazon SNS, разрешённый из DI
+/// Конфигурация приложения; используется для получения ARN целевого топика
+/// Структурный логгер
+public class SnsProjectPublisher(
+ IAmazonSimpleNotificationService snsClient,
+ IConfiguration configuration,
+ ILogger logger) : IProjectPublisher
+{
+ ///
+ /// ARN SNS-топика, в который публикуются сообщения. Берётся из CloudFormation outputs (AWS:Resources:SNSTopicArn)
+ ///
+ private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"]
+ ?? throw new KeyNotFoundException("SNS topic ARN was not found in configuration");
+
+ ///
+ /// Сериализует переданный программный проект в JSON и отправляет его в SNS-топик
+ ///
+ /// Программный проект для публикации
+ /// Задача, завершающаяся после получения ответа от SNS
+ public async Task Publish(SoftwareProject project)
+ {
+ try
+ {
+ var json = JsonSerializer.Serialize(project);
+ var request = new PublishRequest
+ {
+ Message = json,
+ TopicArn = _topicArn
+ };
+ var response = await snsClient.PublishAsync(request);
+ if (response.HttpStatusCode == HttpStatusCode.OK)
+ logger.LogInformation("Software project {ProjectId} was published to SNS topic", project.Id);
+ else
+ throw new Exception($"SNS returned {response.HttpStatusCode}");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to publish software project {ProjectId} to SNS", project.Id);
+ }
+ }
+}
diff --git a/SoftwareProjects.Api/Program.cs b/SoftwareProjects.Api/Program.cs
new file mode 100644
index 00000000..c44f7cf0
--- /dev/null
+++ b/SoftwareProjects.Api/Program.cs
@@ -0,0 +1,25 @@
+using Amazon.SimpleNotificationService;
+using LocalStack.Client.Extensions;
+using SoftwareProjects.Api.Messaging;
+using SoftwareProjects.Api.Services;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.AddRedisDistributedCache("cache");
+
+builder.Services.AddLocalStack(builder.Configuration);
+builder.Services.AddAwsService();
+
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+
+app.MapGet("/api/software-projects", async (int id, ISoftwareProjectService service) =>
+ Results.Ok(await service.GetById(id)));
+
+app.Run();
diff --git a/SoftwareProjects.Api/Properties/launchSettings.json b/SoftwareProjects.Api/Properties/launchSettings.json
new file mode 100644
index 00000000..600c5c75
--- /dev/null
+++ b/SoftwareProjects.Api/Properties/launchSettings.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:20046",
+ "sslPort": 44320
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5299",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7166;http://localhost:5299",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/SoftwareProjects.Api/Services/ISoftwareProjectCacheService.cs b/SoftwareProjects.Api/Services/ISoftwareProjectCacheService.cs
new file mode 100644
index 00000000..7488b1e3
--- /dev/null
+++ b/SoftwareProjects.Api/Services/ISoftwareProjectCacheService.cs
@@ -0,0 +1,19 @@
+using SoftwareProjects.Api.Entities;
+
+namespace SoftwareProjects.Api.Services;
+
+///
+/// Служба кэширования программных проектов
+///
+public interface ISoftwareProjectCacheService
+{
+ ///
+ /// Получает программный проект из кэша по идентификатору
+ ///
+ public Task GetFromCache(int id);
+
+ ///
+ /// Сохраняет программный проект в кэш
+ ///
+ public Task SetToCache(int id, SoftwareProject project);
+}
diff --git a/SoftwareProjects.Api/Services/ISoftwareProjectService.cs b/SoftwareProjects.Api/Services/ISoftwareProjectService.cs
new file mode 100644
index 00000000..d3c3b338
--- /dev/null
+++ b/SoftwareProjects.Api/Services/ISoftwareProjectService.cs
@@ -0,0 +1,14 @@
+using SoftwareProjects.Api.Entities;
+
+namespace SoftwareProjects.Api.Services;
+
+///
+/// Сервис получения программного проекта с поддержкой кэширования
+///
+public interface ISoftwareProjectService
+{
+ ///
+ /// Получает программный проект по идентификатору из кэша или генерирует новый
+ ///
+ public Task GetById(int id);
+}
diff --git a/SoftwareProjects.Api/Services/SoftwareProjectCacheService.cs b/SoftwareProjects.Api/Services/SoftwareProjectCacheService.cs
new file mode 100644
index 00000000..e8716aaa
--- /dev/null
+++ b/SoftwareProjects.Api/Services/SoftwareProjectCacheService.cs
@@ -0,0 +1,64 @@
+using Microsoft.Extensions.Caching.Distributed;
+using SoftwareProjects.Api.Entities;
+using System.Text.Json;
+
+namespace SoftwareProjects.Api.Services;
+
+///
+/// Реализация службы кэширования программных проектов через Redis
+///
+public class SoftwareProjectCacheService(
+ IDistributedCache cache,
+ IConfiguration configuration,
+ ILogger logger) : ISoftwareProjectCacheService
+{
+ private readonly int _cacheExpirationMinutes = configuration.GetValue("CacheExpirationMinutes", 5);
+
+ ///
+ /// Получает программный проект из кэша по идентификатору
+ ///
+ public async Task GetFromCache(int id)
+ {
+ try
+ {
+ var cached = await cache.GetStringAsync($"software-project-{id}");
+
+ if (!string.IsNullOrEmpty(cached))
+ {
+ var deserialized = JsonSerializer.Deserialize(cached);
+
+ if (deserialized is not null)
+ {
+ logger.LogInformation("Project {ProjectId} retrieved from cache", id);
+ return deserialized;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to read project {ProjectId} from cache", id);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Сохраняет программный проект в кэш
+ ///
+ public async Task SetToCache(int id, SoftwareProject project)
+ {
+ try
+ {
+ var json = JsonSerializer.Serialize(project);
+ await cache.SetStringAsync($"software-project-{id}", json, new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_cacheExpirationMinutes)
+ });
+ logger.LogInformation("Project {ProjectId} saved to cache", id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to write project {ProjectId} to cache", id);
+ }
+ }
+}
diff --git a/SoftwareProjects.Api/Services/SoftwareProjectFaker.cs b/SoftwareProjects.Api/Services/SoftwareProjectFaker.cs
new file mode 100644
index 00000000..f60cab24
--- /dev/null
+++ b/SoftwareProjects.Api/Services/SoftwareProjectFaker.cs
@@ -0,0 +1,65 @@
+using Bogus;
+using Bogus.DataSets;
+using SoftwareProjects.Api.Entities;
+
+namespace SoftwareProjects.Api.Services;
+
+///
+/// Статический генератор данных для программного проекта
+///
+public static class SoftwareProjectFaker
+{
+ private static readonly Faker _faker = new Faker("ru")
+ .RuleFor(p => p.Id, _ => 0)
+ .RuleFor(p => p.ProjectName, f =>
+ $"{f.Commerce.ProductName()} {f.Hacker.Abbreviation()}")
+ .RuleFor(p => p.CustomerCompany, f =>
+ f.Commerce.Department())
+ .RuleFor(p => p.ProjectManager, f =>
+ {
+ var gender = f.PickRandom();
+ return $"{f.Name.LastName(gender)} {f.Name.FirstName(gender)} {GeneratePatronymic(f, gender)}";
+ })
+ .RuleFor(p => p.StartDate, f =>
+ DateOnly.FromDateTime(f.Date.Past(2)))
+ .RuleFor(p => p.PlannedEndDate, (f, p) =>
+ p.StartDate.AddDays(f.Random.Int(30, 365)))
+ .RuleFor(p => p.ActualEndDate, (f, p) =>
+ f.Random.Bool(0.4f)
+ ? p.StartDate.AddDays(f.Random.Int(30, 400))
+ : null)
+ .RuleFor(p => p.Budget, f =>
+ Math.Round(f.Finance.Amount(100_000, 10_000_000), 2))
+ .RuleFor(p => p.ActualCosts, (f, p) =>
+ Math.Round(p.Budget * f.Random.Decimal(0.3m, 1.2m), 2))
+ .RuleFor(p => p.CompletionPercentage, (f, p) =>
+ p.ActualEndDate.HasValue ? 100 : f.Random.Int(0, 99));
+
+ ///
+ /// Генерирует программный проект с указанным идентификатором
+ ///
+ public static SoftwareProject Generate(int id)
+ {
+ var project = _faker.Generate();
+ project.Id = id;
+ return project;
+ }
+
+ ///
+ /// Генерирует отчество на основе мужского имени с учётом пола менеджера
+ ///
+ private static string GeneratePatronymic(Faker faker, Name.Gender gender)
+ {
+ var maleName = faker.Name.FirstName(Name.Gender.Male);
+ var suffix = gender == Name.Gender.Male ? "ович" : "овна";
+ var softSuffix = gender == Name.Gender.Male ? "евич" : "евна";
+
+ if (maleName.EndsWith('а') || maleName.EndsWith('я'))
+ return maleName[..^1] + suffix;
+
+ if (maleName.EndsWith('ь'))
+ return maleName[..^1] + softSuffix;
+
+ return maleName + suffix;
+ }
+}
diff --git a/SoftwareProjects.Api/Services/SoftwareProjectService.cs b/SoftwareProjects.Api/Services/SoftwareProjectService.cs
new file mode 100644
index 00000000..d52506f1
--- /dev/null
+++ b/SoftwareProjects.Api/Services/SoftwareProjectService.cs
@@ -0,0 +1,48 @@
+using SoftwareProjects.Api.Entities;
+using SoftwareProjects.Api.Messaging;
+
+namespace SoftwareProjects.Api.Services;
+
+///
+/// Реализация сервиса программных проектов с кэшированием и публикацией событий о новых проектах в брокер
+///
+/// Служба кэша Redis, через которую проверяется наличие проекта и сохраняется результат
+/// Служба публикации события о новом проекте в брокер сообщений (SNS)
+/// Структурный логгер
+public class SoftwareProjectService(
+ ISoftwareProjectCacheService cacheService,
+ IProjectPublisher publisher,
+ ILogger logger) : ISoftwareProjectService
+{
+ ///
+ /// Получает программный проект по идентификатору. При промахе кэша генерирует новый,
+ /// публикует его в брокер для последующей сериализации в объектное хранилище и сохраняет в кэш
+ ///
+ /// Идентификатор программного проекта
+ /// Готовый к выдаче программный проект
+ public async Task GetById(int id)
+ {
+ var cached = await cacheService.GetFromCache(id);
+
+ if (cached is not null)
+ return cached;
+
+ SoftwareProject project;
+
+ try
+ {
+ project = SoftwareProjectFaker.Generate(id);
+ logger.LogInformation("Project {ProjectId} generated successfully", id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to generate project {ProjectId}", id);
+ throw;
+ }
+
+ await publisher.Publish(project);
+ await cacheService.SetToCache(id, project);
+
+ return project;
+ }
+}
diff --git a/SoftwareProjects.Api/SoftwareProjects.Api.csproj b/SoftwareProjects.Api/SoftwareProjects.Api.csproj
new file mode 100644
index 00000000..22bfb219
--- /dev/null
+++ b/SoftwareProjects.Api/SoftwareProjects.Api.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SoftwareProjects.Api/appsettings.Development.json b/SoftwareProjects.Api/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/SoftwareProjects.Api/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/SoftwareProjects.Api/appsettings.json b/SoftwareProjects.Api/appsettings.json
new file mode 100644
index 00000000..0ef4c3c3
--- /dev/null
+++ b/SoftwareProjects.Api/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "CacheExpirationMinutes": 5
+}
diff --git a/SoftwareProjects/SoftwareProjects.AppHost.Tests/IntegrationTests.cs b/SoftwareProjects/SoftwareProjects.AppHost.Tests/IntegrationTests.cs
new file mode 100644
index 00000000..88b7d649
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost.Tests/IntegrationTests.cs
@@ -0,0 +1,160 @@
+using Aspire.Hosting;
+using Microsoft.Extensions.Logging;
+using SoftwareProjects.Api.Entities;
+using System.Text.Json;
+using Xunit.Abstractions;
+
+namespace SoftwareProjects.AppHost.Tests;
+
+///
+/// Интеграционные тесты, проверяющие совместную работу
+/// API-сервиса, кэша Redis, брокера SNS и файлового сервиса с S3-хранилищем (LocalStack)
+///
+/// Журнал юнит-тестов; пробрасывается в логирование Aspire-хоста
+public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime
+{
+ ///
+ /// Время, которое тесты ждут после HTTP-запроса к API,
+ /// чтобы сообщение успело пройти SNS → File.Service → S3
+ ///
+ private static readonly TimeSpan _propagationDelay = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Опции десериализации, совпадающие с тем, как ASP.NET сериализует ответы Minimal API
+ /// (camelCase свойства)
+ ///
+ private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
+
+ private DistributedApplication? _app;
+ private HttpClient? _gatewayClient;
+ private HttpClient? _fileServiceClient;
+
+
+ ///
+ public async Task InitializeAsync()
+ {
+ var cancellationToken = CancellationToken.None;
+ var builder = await DistributedApplicationTestingBuilder
+ .CreateAsync(cancellationToken);
+ 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(cancellationToken);
+ await _app.StartAsync(cancellationToken);
+ _gatewayClient = _app!.CreateHttpClient("api-gateway", "http");
+ _fileServiceClient = _app!.CreateHttpClient("file-service", "http");
+ }
+
+ ///
+ /// Сценарий «End-to-end»: запрос на гейтвей → API генерирует и публикует проект в SNS →
+ /// File.Service сохраняет JSON в S3 → проверяем, что файл по ключу совпадает с ответом API
+ ///
+ [Fact]
+ public async Task Pipeline_GatewayRequest_PersistsGeneratedProjectToS3()
+ {
+ var id = Random.Shared.Next(1_000, 9_999);
+
+ using var gatewayResponse = await _gatewayClient!.GetAsync($"/software-projects?id={id}");
+ gatewayResponse.EnsureSuccessStatusCode();
+ var apiProject = JsonSerializer.Deserialize(
+ await gatewayResponse.Content.ReadAsStringAsync(), _jsonOptions);
+
+ await Task.Delay(_propagationDelay);
+
+ using var s3Response = await _fileServiceClient!.GetAsync($"/api/s3/software-project-{id}.json");
+ s3Response.EnsureSuccessStatusCode();
+ var s3Project = JsonSerializer.Deserialize(
+ await s3Response.Content.ReadAsStringAsync(), _jsonOptions);
+
+ Assert.NotNull(apiProject);
+ Assert.NotNull(s3Project);
+ Assert.Equal(id, s3Project.Id);
+ Assert.Equivalent(apiProject, s3Project);
+ }
+
+ ///
+ /// Сценарий со списком: после нескольких запросов с разными id в бакете
+ /// должно появиться столько же файлов с ожидаемыми ключами
+ ///
+ [Fact]
+ public async Task Pipeline_MultipleRequests_AreAllListedInBucket()
+ {
+ var ids = new[]
+ {
+ Random.Shared.Next(10_000, 19_999),
+ Random.Shared.Next(20_000, 29_999),
+ Random.Shared.Next(30_000, 39_999)
+ };
+
+ foreach (var id in ids)
+ {
+ using var response = await _gatewayClient!.GetAsync($"/software-projects?id={id}");
+ response.EnsureSuccessStatusCode();
+ }
+
+ await Task.Delay(_propagationDelay);
+
+ using var listResponse = await _fileServiceClient!.GetAsync("/api/s3");
+ listResponse.EnsureSuccessStatusCode();
+ var keys = JsonSerializer.Deserialize>(
+ await listResponse.Content.ReadAsStringAsync(), _jsonOptions);
+
+ Assert.NotNull(keys);
+ foreach (var id in ids)
+ Assert.Contains($"software-project-{id}.json", keys);
+ }
+
+ ///
+ /// Сценарий с кэшем: повторный запрос того же id должен попасть в Redis-кэш и
+ /// не приводить к повторной публикации в SNS, поэтому в S3 остаётся ровно одна версия файла
+ ///
+ [Fact]
+ public async Task Pipeline_RepeatedRequests_DoNotProduceDuplicateFiles()
+ {
+ var id = Random.Shared.Next(40_000, 49_999);
+
+ using var firstResponse = await _gatewayClient!.GetAsync($"/software-projects?id={id}");
+ firstResponse.EnsureSuccessStatusCode();
+ var firstPayload = await firstResponse.Content.ReadAsStringAsync();
+ var firstProject = JsonSerializer.Deserialize(firstPayload, _jsonOptions);
+
+ await Task.Delay(_propagationDelay);
+
+ for (var i = 0; i < 3; i++)
+ {
+ using var repeated = await _gatewayClient!.GetAsync($"/software-projects?id={id}");
+ repeated.EnsureSuccessStatusCode();
+ var repeatedProject = JsonSerializer.Deserialize(
+ await repeated.Content.ReadAsStringAsync(), _jsonOptions);
+ Assert.NotNull(repeatedProject);
+ Assert.Equivalent(firstProject, repeatedProject);
+ }
+
+ await Task.Delay(_propagationDelay);
+
+ using var listResponse = await _fileServiceClient!.GetAsync("/api/s3");
+ listResponse.EnsureSuccessStatusCode();
+ var keys = JsonSerializer.Deserialize>(
+ await listResponse.Content.ReadAsStringAsync(), _jsonOptions);
+
+ Assert.NotNull(keys);
+ var duplicates = keys.Count(k => k == $"software-project-{id}.json");
+ Assert.Equal(1, duplicates);
+ }
+
+ ///
+ public async Task DisposeAsync()
+ {
+ if (_app is not null)
+ {
+ await _app.StopAsync();
+ await _app.DisposeAsync();
+ }
+ }
+}
diff --git a/SoftwareProjects/SoftwareProjects.AppHost.Tests/SoftwareProjects.AppHost.Tests.csproj b/SoftwareProjects/SoftwareProjects.AppHost.Tests/SoftwareProjects.AppHost.Tests.csproj
new file mode 100644
index 00000000..50f084a8
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost.Tests/SoftwareProjects.AppHost.Tests.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs b/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
new file mode 100644
index 00000000..d97ebb78
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
@@ -0,0 +1,52 @@
+using Amazon;
+using Aspire.Hosting.LocalStack.Container;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+var cache = builder.AddRedis("cache")
+ .WithRedisInsight(containerName: "softwareprojects-redis-insight");
+
+var gateway = builder.AddProject("api-gateway");
+
+var awsConfig = builder.AddAWSSDKConfig()
+ .WithProfile("default")
+ .WithRegion(RegionEndpoint.EUCentral1);
+
+var localstack = builder
+ .AddLocalStack("softwareprojects-localstack", awsConfig: awsConfig, configureContainer: container =>
+ {
+ container.Lifetime = ContainerLifetime.Session;
+ container.DebugLevel = 1;
+ container.LogLevel = LocalStackLogLevel.Debug;
+ container.Port = 4566;
+ container.AdditionalEnvironmentVariables.Add("DEBUG", "1");
+ container.AdditionalEnvironmentVariables
+ .Add("SNS_CERT_URL_HOST", "sns.eu-central-1.amazonaws.com");
+ });
+
+var awsResources = builder
+ .AddAWSCloudFormationTemplate("resources", "CloudFormation/softwareprojects-template.yaml", "softwareprojects")
+ .WithReference(awsConfig);
+
+var fileService = builder.AddProject("file-service")
+ .WithReference(awsResources)
+ .WithEnvironment("AWS__Resources__SNSUrl", "http://host.docker.internal:5300/api/sns")
+ .WaitFor(awsResources);
+
+for (var i = 0; i < 5; i++)
+{
+ var softwareProjectsApi = builder.AddProject($"softwareprojects-api-{i}", launchProfileName: null)
+ .WithHttpsEndpoint(5200 + i)
+ .WithReference(cache)
+ .WithReference(awsResources)
+ .WaitFor(cache)
+ .WaitFor(awsResources);
+ gateway.WaitFor(softwareProjectsApi);
+}
+
+builder.AddProject("client-wasm")
+ .WaitFor(gateway);
+
+builder.UseLocalStack(localstack);
+
+builder.Build().Run();
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/CloudFormation/softwareprojects-template.yaml b/SoftwareProjects/SoftwareProjects.AppHost/CloudFormation/softwareprojects-template.yaml
new file mode 100644
index 00000000..d5487c24
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost/CloudFormation/softwareprojects-template.yaml
@@ -0,0 +1,59 @@
+AWSTemplateFormatVersion: '2010-09-09'
+Description: 'Cloud formation template for software projects (SNS + LocalStack S3)'
+
+Parameters:
+ BucketName:
+ Type: String
+ Description: Name for the S3 bucket
+ Default: 'softwareprojects-bucket'
+
+ TopicName:
+ Type: String
+ Description: Name for the SNS topic
+ Default: 'softwareprojects-topic'
+
+Resources:
+ SoftwareProjectsBucket:
+ Type: AWS::S3::Bucket
+ Properties:
+ BucketName: !Ref BucketName
+ VersioningConfiguration:
+ Status: Suspended
+ Tags:
+ - Key: Name
+ Value: !Ref BucketName
+ - Key: Environment
+ Value: Lab3
+ PublicAccessBlockConfiguration:
+ BlockPublicAcls: true
+ BlockPublicPolicy: true
+ IgnorePublicAcls: true
+ RestrictPublicBuckets: true
+
+ SoftwareProjectsTopic:
+ Type: AWS::SNS::Topic
+ Properties:
+ TopicName: !Ref TopicName
+ DisplayName: !Ref TopicName
+ Tags:
+ - Key: Name
+ Value: !Ref TopicName
+ - Key: Environment
+ Value: Lab3
+
+Outputs:
+ S3BucketName:
+ Description: Name of the S3 bucket
+ Value: !Ref SoftwareProjectsBucket
+
+ S3BucketArn:
+ Description: ARN of the S3 bucket
+ Value: !GetAtt SoftwareProjectsBucket.Arn
+
+ SNSTopicName:
+ Description: Name of the SNS topic
+ Value: !GetAtt SoftwareProjectsTopic.TopicName
+
+ SNSTopicArn:
+ Description: ARN of the SNS topic
+ Value: !Ref SoftwareProjectsTopic
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/Properties/launchSettings.json b/SoftwareProjects/SoftwareProjects.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000..f966aa44
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost/Properties/launchSettings.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17036;http://localhost:15252",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21213",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22154"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15252",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19181",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20164"
+ }
+ }
+ }
+}
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj b/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj
new file mode 100644
index 00000000..028e6a82
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ 8fca868f-2ed6-4417-82a1-5fb1ad8ee5f0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/appsettings.Development.json b/SoftwareProjects/SoftwareProjects.AppHost/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/appsettings.json b/SoftwareProjects/SoftwareProjects.AppHost/appsettings.json
new file mode 100644
index 00000000..a6b256bb
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ },
+ "LocalStack": {
+ "UseLocalStack": true
+ }
+}
diff --git a/SoftwareProjects/SoftwareProjects.ServiceDefaults/Extensions.cs b/SoftwareProjects/SoftwareProjects.ServiceDefaults/Extensions.cs
new file mode 100644
index 00000000..b72c8753
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.ServiceDefaults/Extensions.cs
@@ -0,0 +1,127 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.ServiceDiscovery;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace Microsoft.Extensions.Hosting;
+
+// 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/SoftwareProjects/SoftwareProjects.ServiceDefaults/SoftwareProjects.ServiceDefaults.csproj b/SoftwareProjects/SoftwareProjects.ServiceDefaults/SoftwareProjects.ServiceDefaults.csproj
new file mode 100644
index 00000000..1b6e209a
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.ServiceDefaults/SoftwareProjects.ServiceDefaults.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+