diff --git a/.gitignore b/.gitignore
index ce892922..9b135ed1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -415,4 +415,4 @@ FodyWeavers.xsd
*.msi
*.msix
*.msm
-*.msp
+*.msp
\ No newline at end of file
diff --git a/Api.Gateway/Api.Gateway.csproj b/Api.Gateway/Api.Gateway.csproj
new file mode 100644
index 00000000..929948ff
--- /dev/null
+++ b/Api.Gateway/Api.Gateway.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Api.Gateway/Api.Gateway.csproj.user b/Api.Gateway/Api.Gateway.csproj.user
new file mode 100644
index 00000000..ccfffb1f
--- /dev/null
+++ b/Api.Gateway/Api.Gateway.csproj.user
@@ -0,0 +1,6 @@
+
+
+
+ https
+
+
\ No newline at end of file
diff --git a/Api.Gateway/LoadBalancers/QueryBased.cs b/Api.Gateway/LoadBalancers/QueryBased.cs
new file mode 100644
index 00000000..3ca43426
--- /dev/null
+++ b/Api.Gateway/LoadBalancers/QueryBased.cs
@@ -0,0 +1,33 @@
+using Ocelot.LoadBalancer.Interfaces;
+using Ocelot.Responses;
+using Ocelot.Values;
+
+namespace Api.Gateway.LoadBalancers;
+
+///
+/// Балансировщик нагрузки на основе query-параметра id
+/// Определяет реплику по остатку от деления идентификатора на число реплик
+///
+/// Делегат для получения списка доступных реплик
+public class QueryBased(Func>> services) : ILoadBalancer
+{
+ public string Type => nameof(QueryBased);
+
+ public async Task> LeaseAsync(HttpContext httpContext)
+ {
+ var availableServices = await services.Invoke();
+ var replicaCount = availableServices.Count;
+
+ var replicaIndex = 0;
+
+ if (httpContext.Request.Query.TryGetValue("id", out var rawId)
+ && int.TryParse(rawId, out var employeeId))
+ {
+ replicaIndex = Math.Abs(employeeId) % replicaCount;
+ }
+
+ return new OkResponse(availableServices[replicaIndex].HostAndPort);
+ }
+
+ public void Release(ServiceHostAndPort hostAndPort) { }
+}
diff --git a/Api.Gateway/Program.cs b/Api.Gateway/Program.cs
new file mode 100644
index 00000000..e34dc824
--- /dev/null
+++ b/Api.Gateway/Program.cs
@@ -0,0 +1,30 @@
+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((_, _, discoveryProvider) => new(discoveryProvider.GetAsync));
+
+var clientOrigin = builder.Configuration["ClientOrigin"]
+ ?? throw new InvalidOperationException("ClientOrigin is not configured");
+
+builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
+{
+ policy.WithOrigins(clientOrigin);
+ policy.WithMethods("GET");
+ policy.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..6056604e
--- /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:11915",
+ "sslPort": 44365
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5162",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7027;http://localhost:5162",
+ "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..ff66ba6b
--- /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..0a8ef0bd
--- /dev/null
+++ b/Api.Gateway/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "ClientOrigin": "https://localhost:7282"
+}
diff --git a/Api.Gateway/ocelot.json b/Api.Gateway/ocelot.json
new file mode 100644
index 00000000..f61c6ab3
--- /dev/null
+++ b/Api.Gateway/ocelot.json
@@ -0,0 +1,20 @@
+{
+ "Routes": [
+ {
+ "DownstreamPathTemplate": "/api/employees",
+ "DownstreamScheme": "https",
+ "DownstreamHostAndPorts": [
+ { "Host": "localhost", "Port": 5100 },
+ { "Host": "localhost", "Port": 5101 },
+ { "Host": "localhost", "Port": 5102 },
+ { "Host": "localhost", "Port": 5103 },
+ { "Host": "localhost", "Port": 5104 }
+ ],
+ "UpstreamPathTemplate": "/employees",
+ "UpstreamHttpMethod": [ "Get" ],
+ "LoadBalancerOptions": {
+ "Type": "QueryBased"
+ }
+ }
+ ]
+}
diff --git a/Client.Wasm/Client.Wasm.csproj.user b/Client.Wasm/Client.Wasm.csproj.user
new file mode 100644
index 00000000..ccfffb1f
--- /dev/null
+++ b/Client.Wasm/Client.Wasm.csproj.user
@@ -0,0 +1,6 @@
+
+
+
+ https
+
+
\ No newline at end of file
diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor
index 661f1181..8feda453 100644
--- a/Client.Wasm/Components/StudentCard.razor
+++ b/Client.Wasm/Components/StudentCard.razor
@@ -4,10 +4,10 @@
- Номер №X "Название лабораторной"
- Вариант №Х "Название варианта"
- Выполнена Фамилией Именем 65ХХ
- Ссылка на форк
+ Номер №3 "Интеграционное тестирование"
+ Вариант №49 "Сотрудник компании"
+ Выполнена Панюшкиным Андрем 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..9532148b 100644
--- a/Client.Wasm/wwwroot/appsettings.json
+++ b/Client.Wasm/wwwroot/appsettings.json
@@ -6,5 +6,5 @@
}
},
"AllowedHosts": "*",
- "BaseAddress": ""
+ "BaseAddress": "https://localhost:7027/employees"
}
diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln
index cb48241d..63fde662 100644
--- a/CloudDevelopment.sln
+++ b/CloudDevelopment.sln
@@ -1,25 +1,61 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-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
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- 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}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {90FE6B04-8381-437E-893A-FEBA1DA10AEE}
- EndGlobalSection
-EndGlobal
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+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}") = "EmployeeApp.AppHost", "EmployeeApp\EmployeeApp.AppHost\EmployeeApp.AppHost.csproj", "{2EF7E965-1A57-4198-93B5-5E5F63EAA4B2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmployeeApp.ServiceDefaults", "EmployeeApp\EmployeeApp.ServiceDefaults\EmployeeApp.ServiceDefaults.csproj", "{2FA6E085-0B95-101F-0B19-42856D8FFBF9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmployeeApp.Api", "EmployeeApp.Api\EmployeeApp.Api.csproj", "{4FE86887-5370-BEE6-70F2-476C725D7719}"
+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}") = "EmployeeApp.AppHost.Tests", "EmployeeApp\EmployeeApp.AppHost.Tests\EmployeeApp.AppHost.Tests.csproj", "{F48C9AC7-767C-441D-897A-87B061B88A5F}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ 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}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2EF7E965-1A57-4198-93B5-5E5F63EAA4B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2EF7E965-1A57-4198-93B5-5E5F63EAA4B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2EF7E965-1A57-4198-93B5-5E5F63EAA4B2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2EF7E965-1A57-4198-93B5-5E5F63EAA4B2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2FA6E085-0B95-101F-0B19-42856D8FFBF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2FA6E085-0B95-101F-0B19-42856D8FFBF9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2FA6E085-0B95-101F-0B19-42856D8FFBF9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2FA6E085-0B95-101F-0B19-42856D8FFBF9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4FE86887-5370-BEE6-70F2-476C725D7719}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4FE86887-5370-BEE6-70F2-476C725D7719}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4FE86887-5370-BEE6-70F2-476C725D7719}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4FE86887-5370-BEE6-70F2-476C725D7719}.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
+ {F48C9AC7-767C-441D-897A-87B061B88A5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F48C9AC7-767C-441D-897A-87B061B88A5F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F48C9AC7-767C-441D-897A-87B061B88A5F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F48C9AC7-767C-441D-897A-87B061B88A5F}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {90FE6B04-8381-437E-893A-FEBA1DA10AEE}
+ EndGlobalSection
+EndGlobal
diff --git a/EmployeeApp.Api/EmployeeApp.Api.csproj b/EmployeeApp.Api/EmployeeApp.Api.csproj
new file mode 100644
index 00000000..92a8d445
--- /dev/null
+++ b/EmployeeApp.Api/EmployeeApp.Api.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EmployeeApp.Api/EmployeeApp.Api.csproj.user b/EmployeeApp.Api/EmployeeApp.Api.csproj.user
new file mode 100644
index 00000000..ccfffb1f
--- /dev/null
+++ b/EmployeeApp.Api/EmployeeApp.Api.csproj.user
@@ -0,0 +1,6 @@
+
+
+
+ https
+
+
\ No newline at end of file
diff --git a/EmployeeApp.Api/Entities/Employee.cs b/EmployeeApp.Api/Entities/Employee.cs
new file mode 100644
index 00000000..fccbab0a
--- /dev/null
+++ b/EmployeeApp.Api/Entities/Employee.cs
@@ -0,0 +1,57 @@
+namespace EmployeeApp.Api.Entities;
+
+///
+/// Сотрудник компании
+///
+public class Employee
+{
+ ///
+ /// Идентификатор сотрудника в системе
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// ФИО
+ ///
+ public required string FullName { get; set; }
+
+ ///
+ /// Должность
+ ///
+ public required string Position { get; set; }
+
+ ///
+ /// Отдел
+ ///
+ public required string Department { get; set; }
+
+ ///
+ /// Дата приема
+ ///
+ public DateOnly HireDate { get; set; }
+
+ ///
+ /// Оклад
+ ///
+ public decimal Salary { get; set; }
+
+ ///
+ /// Электронная почта
+ ///
+ public required string Email { get; set; }
+
+ ///
+ /// Номер телефона
+ ///
+ public required string PhoneNumber { get; set; }
+
+ ///
+ /// Индикатор увольнения
+ ///
+ public bool IsDismissed { get; set; }
+
+ ///
+ /// Дата увольнения
+ ///
+ public DateOnly? DismissalDate { get; set; }
+}
diff --git a/EmployeeApp.Api/Messaging/IProducerService.cs b/EmployeeApp.Api/Messaging/IProducerService.cs
new file mode 100644
index 00000000..e5b20653
--- /dev/null
+++ b/EmployeeApp.Api/Messaging/IProducerService.cs
@@ -0,0 +1,15 @@
+using EmployeeApp.Api.Entities;
+
+namespace EmployeeApp.Api.Messaging;
+
+///
+/// Интерфейс службы для отправки сгенерированных сотрудников в брокер сообщений
+///
+public interface IProducerService
+{
+ ///
+ /// Отправляет сообщение в брокер
+ ///
+ /// Сотрудник
+ public Task SendMessage(Employee employee);
+}
diff --git a/EmployeeApp.Api/Messaging/SnsPublisherService.cs b/EmployeeApp.Api/Messaging/SnsPublisherService.cs
new file mode 100644
index 00000000..57f00658
--- /dev/null
+++ b/EmployeeApp.Api/Messaging/SnsPublisherService.cs
@@ -0,0 +1,42 @@
+using Amazon.SimpleNotificationService;
+using Amazon.SimpleNotificationService.Model;
+using EmployeeApp.Api.Entities;
+using System.Net;
+using System.Text.Json;
+
+namespace EmployeeApp.Api.Messaging;
+
+///
+/// Служба для отправки сообщений в SNS
+///
+/// Клиент SNS
+/// Конфигурация
+/// Логгер
+public class SnsPublisherService(IAmazonSimpleNotificationService client, IConfiguration configuration, ILogger logger) : IProducerService
+{
+ private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"]
+ ?? throw new KeyNotFoundException("SNS topic link was not found in configuration");
+
+ ///
+ public async Task SendMessage(Employee employee)
+ {
+ try
+ {
+ var json = JsonSerializer.Serialize(employee);
+ var request = new PublishRequest
+ {
+ Message = json,
+ TopicArn = _topicArn
+ };
+ var response = await client.PublishAsync(request);
+ if (response.HttpStatusCode == HttpStatusCode.OK)
+ logger.LogInformation("Employee {id} was sent to sink via SNS", employee.Id);
+ else
+ throw new Exception($"SNS returned {response.HttpStatusCode}");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unable to send employee through SNS topic");
+ }
+ }
+}
diff --git a/EmployeeApp.Api/Program.cs b/EmployeeApp.Api/Program.cs
new file mode 100644
index 00000000..a0d9f3e6
--- /dev/null
+++ b/EmployeeApp.Api/Program.cs
@@ -0,0 +1,23 @@
+using Amazon.SimpleNotificationService;
+using EmployeeApp.Api.Messaging;
+using EmployeeApp.Api.Services;
+using LocalStack.Client.Extensions;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.AddRedisDistributedCache("cache");
+
+builder.Services.AddLocalStack(builder.Configuration);
+builder.Services.AddAwsService();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+
+app.MapGet("/api/employees", async (IEmployeeService service, int id) =>
+ await service.GetEmployeeById(id));
+
+app.Run();
diff --git a/EmployeeApp.Api/Properties/launchSettings.json b/EmployeeApp.Api/Properties/launchSettings.json
new file mode 100644
index 00000000..ace0780f
--- /dev/null
+++ b/EmployeeApp.Api/Properties/launchSettings.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/EmployeeApp.Api/Services/EmployeeGenerator.cs b/EmployeeApp.Api/Services/EmployeeGenerator.cs
new file mode 100644
index 00000000..cd97c279
--- /dev/null
+++ b/EmployeeApp.Api/Services/EmployeeGenerator.cs
@@ -0,0 +1,80 @@
+using Bogus;
+using Bogus.DataSets;
+using EmployeeApp.Api.Entities;
+
+namespace EmployeeApp.Api.Services;
+
+///
+/// Генератор данных сотрудников на основе Bogus
+///
+public static class EmployeeGenerator
+{
+ private static readonly string[] _positions =
+ ["Developer", "Manager", "Analyst", "Designer", "Tester", "DevOps Engineer", "Architect", "Consultant"];
+
+ private static readonly Dictionary _salaryRanges = new()
+ {
+ ["Junior"] = (30_000m, 100_000m),
+ ["Middle"] = (100_000m, 250_000m),
+ ["Senior"] = (250_000m, 300_000m),
+ ["Lead"] = (300_000m, 500_000m),
+ ["Principal"] = (280_000m, 850_000m)
+ };
+
+ ///
+ /// Генерация отчества на основе имени и пола
+ ///
+ private static string GeneratePatronymic(Faker f, Name.Gender gender)
+ {
+ var fatherName = f.Name.FirstName(Name.Gender.Male);
+ return gender == Name.Gender.Male
+ ? fatherName + "ович"
+ : fatherName + "овна";
+ }
+
+ ///
+ /// Генерация фамилии с учётом пола
+ ///
+ private static string GenerateLastName(Faker f, Name.Gender gender)
+ {
+ var lastName = f.Name.LastName(Name.Gender.Male);
+ return gender == Name.Gender.Female
+ ? lastName + "а"
+ : lastName;
+ }
+
+ private static readonly Faker _faker = new Faker("ru")
+ .RuleFor(e => e.FullName, f =>
+ {
+ var gender = f.PickRandom();
+ return $"{GenerateLastName(f, gender)} {f.Name.FirstName(gender)} {GeneratePatronymic(f, gender)}";
+ })
+ .RuleFor(e => e.Position, (f, _) =>
+ $"{f.PickRandom(_salaryRanges.Keys.ToArray())} {f.PickRandom(_positions)}")
+ .RuleFor(e => e.Department, f => f.Commerce.Department())
+ .RuleFor(e => e.HireDate, f =>
+ DateOnly.FromDateTime(f.Date.Past(10)))
+ .RuleFor(e => e.Salary, (f, e) =>
+ {
+ var range = _salaryRanges.GetValueOrDefault(e.Position.Split(' ')[0], (30_000m, 60_000m));
+ return Math.Round(f.Random.Decimal(range.Item1, range.Item2), 2);
+ })
+ .RuleFor(e => e.Email, f => f.Internet.Email())
+ .RuleFor(e => e.PhoneNumber, f =>
+ f.Phone.PhoneNumber("+7(###)###-##-##"))
+ .RuleFor(e => e.IsDismissed, f => f.Random.Bool(0.2f))
+ .RuleFor(e => e.DismissalDate, (f, e) =>
+ e.IsDismissed
+ ? DateOnly.FromDateTime(f.Date.Between(e.HireDate.ToDateTime(TimeOnly.MinValue), DateTime.Now))
+ : null);
+
+ ///
+ /// Генерация сотрудника по идентификатору
+ ///
+ public static Employee Generate(int id)
+ {
+ var employee = _faker.Generate();
+ employee.Id = id;
+ return employee;
+ }
+}
diff --git a/EmployeeApp.Api/Services/EmployeeService.cs b/EmployeeApp.Api/Services/EmployeeService.cs
new file mode 100644
index 00000000..ec8e8f60
--- /dev/null
+++ b/EmployeeApp.Api/Services/EmployeeService.cs
@@ -0,0 +1,79 @@
+using EmployeeApp.Api.Entities;
+using EmployeeApp.Api.Messaging;
+using Microsoft.Extensions.Caching.Distributed;
+using System.Text.Json;
+
+namespace EmployeeApp.Api.Services;
+
+///
+/// Реализация сервиса генерации данных сотрудников с кешированием и публикацией в брокер
+///
+public class EmployeeService(IDistributedCache cache,
+ IProducerService producer,
+ ILogger logger,
+ IConfiguration configuration) : IEmployeeService
+{
+ private readonly DistributedCacheEntryOptions _cacheOptions = new()
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(configuration.GetValue("CacheLifetimeMinutes", 5))
+ };
+
+ ///
+ public async Task GetEmployeeById(int id)
+ {
+ logger.LogInformation("Requesting employee with Id {Id}", id);
+
+ var cached = await GetFromCache(id);
+ if (cached is not null)
+ return cached;
+
+ logger.LogInformation("Cache miss for employee {Id}", id);
+
+ var employee = EmployeeGenerator.Generate(id);
+
+ await producer.SendMessage(employee);
+ await SetToCache(id, employee);
+
+ return employee;
+ }
+
+ ///
+ /// Получение сотрудника из кэша по идентификатору
+ ///
+ private async Task GetFromCache(int id)
+ {
+ try
+ {
+ var cached = await cache.GetStringAsync($"employee:{id}");
+ if (cached is null)
+ return null;
+
+ var employee = JsonSerializer.Deserialize(cached);
+ if (employee is not null)
+ logger.LogInformation("Cache hit for employee {Id}", id);
+
+ return employee;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error reading cache for employee {Id}", id);
+ return null;
+ }
+ }
+
+ ///
+ /// Сохранение сотрудника в кэш
+ ///
+ private async Task SetToCache(int id, Employee employee)
+ {
+ try
+ {
+ await cache.SetStringAsync($"employee:{id}", JsonSerializer.Serialize(employee), _cacheOptions);
+ logger.LogInformation("Generated and cached employee {Id}", id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error writing cache for employee {Id}", id);
+ }
+ }
+}
diff --git a/EmployeeApp.Api/Services/IEmployeeService.cs b/EmployeeApp.Api/Services/IEmployeeService.cs
new file mode 100644
index 00000000..5ce35c7c
--- /dev/null
+++ b/EmployeeApp.Api/Services/IEmployeeService.cs
@@ -0,0 +1,14 @@
+using EmployeeApp.Api.Entities;
+
+namespace EmployeeApp.Api.Services;
+
+///
+/// Сервис генерации данных сотрудников
+///
+public interface IEmployeeService
+{
+ ///
+ /// Получить сотрудника по идентификатору
+ ///
+ public Task GetEmployeeById(int id);
+}
diff --git a/EmployeeApp.Api/appsettings.Development.json b/EmployeeApp.Api/appsettings.Development.json
new file mode 100644
index 00000000..ff66ba6b
--- /dev/null
+++ b/EmployeeApp.Api/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/EmployeeApp.Api/appsettings.json b/EmployeeApp.Api/appsettings.json
new file mode 100644
index 00000000..c7ece18e
--- /dev/null
+++ b/EmployeeApp.Api/appsettings.json
@@ -0,0 +1,25 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "CacheLifetimeMinutes": 5,
+ "LocalStack": {
+ "UseLocalStack": true,
+ "Session": {
+ "AwsAccessKeyId": "test",
+ "AwsAccessKey": "test",
+ "AwsSessionToken": "test",
+ "RegionName": "eu-central-1"
+ },
+ "Config": {
+ "LocalStackHost": "localhost",
+ "UseSsl": false,
+ "UseLegacyPorts": false,
+ "EdgePort": 4566
+ }
+ }
+}
diff --git a/EmployeeApp/EmployeeApp.AppHost.Tests/EmployeeApp.AppHost.Tests.csproj b/EmployeeApp/EmployeeApp.AppHost.Tests/EmployeeApp.AppHost.Tests.csproj
new file mode 100644
index 00000000..bca85530
--- /dev/null
+++ b/EmployeeApp/EmployeeApp.AppHost.Tests/EmployeeApp.AppHost.Tests.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EmployeeApp/EmployeeApp.AppHost.Tests/IntegrationTest.cs b/EmployeeApp/EmployeeApp.AppHost.Tests/IntegrationTest.cs
new file mode 100644
index 00000000..06bd2dc2
--- /dev/null
+++ b/EmployeeApp/EmployeeApp.AppHost.Tests/IntegrationTest.cs
@@ -0,0 +1,176 @@
+using Aspire.Hosting;
+using EmployeeApp.Api.Entities;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+using Xunit.Abstractions;
+
+namespace EmployeeApp.AppHost.Tests;
+
+///
+/// Интеграционные тесты для проверки микросервисного пайплайна
+///
+/// Служба журналирования юнит-тестов
+public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime
+{
+ private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
+
+ private DistributedApplication? _app;
+ private HttpClient? _gatewayClient;
+ private HttpClient? _sinkClient;
+
+ ///
+ 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");
+ _sinkClient = _app.CreateHttpClient("file-service", "http");
+ }
+
+ ///
+ /// Проверяет, что вызов гейтвея:
+ ///
+ /// - В ответ отправляет сгенерированного сотрудника
+ /// - Сериализует сотрудника в S3 хранилище
+ /// - Проверяет, что данные из предыдущих пунктов идентичны
+ ///
+ ///
+ [Fact]
+ public async Task TestPipeline()
+ {
+ var id = new Random().Next(1, 100);
+
+ using var gatewayResponse = await _gatewayClient!.GetAsync($"/employees?id={id}");
+ var apiEmployee = JsonSerializer.Deserialize(await gatewayResponse.Content.ReadAsStringAsync(), _jsonOptions);
+
+ await Task.Delay(5000);
+
+ using var listResponse = await _sinkClient!.GetAsync("/api/s3");
+ var employeeList = JsonSerializer.Deserialize>(await listResponse.Content.ReadAsStringAsync());
+ using var s3Response = await _sinkClient.GetAsync($"/api/s3/employee_{id}.json");
+ var s3Employee = JsonSerializer.Deserialize(await s3Response.Content.ReadAsStringAsync(), _jsonOptions);
+
+ Assert.NotNull(employeeList);
+ Assert.Single(employeeList);
+ Assert.Equal($"employee_{id}.json", employeeList[0]);
+ Assert.NotNull(apiEmployee);
+ Assert.NotNull(s3Employee);
+ Assert.Equal(id, s3Employee.Id);
+ Assert.Equivalent(apiEmployee, s3Employee);
+ }
+
+ ///
+ /// Проверяет, что повторный вызов гейтвея для одного и того же идентификатора:
+ ///
+ /// - Возвращает идентичного сотрудника (сработал Redis-кэш)
+ /// - Не создает дубликат файла в S3 (сообщение в SNS отправляется только при промахе кэша)
+ ///
+ ///
+ [Fact]
+ public async Task TestCacheHitDoesNotDuplicateS3File()
+ {
+ var id = new Random().Next(200, 300);
+
+ using var firstResponse = await _gatewayClient!.GetAsync($"/employees?id={id}");
+ var firstEmployee = JsonSerializer.Deserialize(await firstResponse.Content.ReadAsStringAsync(), _jsonOptions);
+ using var secondResponse = await _gatewayClient.GetAsync($"/employees?id={id}");
+ var secondEmployee = JsonSerializer.Deserialize(await secondResponse.Content.ReadAsStringAsync(), _jsonOptions);
+
+ await Task.Delay(5000);
+
+ using var listResponse = await _sinkClient!.GetAsync("/api/s3");
+ var employeeList = JsonSerializer.Deserialize>(await listResponse.Content.ReadAsStringAsync());
+
+ Assert.NotNull(firstEmployee);
+ Assert.NotNull(secondEmployee);
+ Assert.Equivalent(firstEmployee, secondEmployee);
+ Assert.NotNull(employeeList);
+ Assert.Single(employeeList);
+ Assert.Equal($"employee_{id}.json", employeeList[0]);
+ }
+
+ ///
+ /// Проверяет, что разные идентификаторы порождают независимые объекты в S3:
+ ///
+ /// - Для каждого идентификатора создается отдельный файл employee_{id}.json
+ /// - Содержимое каждого файла соответствует ответу API для этого идентификатора
+ ///
+ ///
+ [Fact]
+ public async Task TestMultipleIdsProduceDistinctS3Objects()
+ {
+ var ids = Enumerable.Range(0, 3).Select(_ => new Random().Next(400, 500)).Distinct().ToArray();
+
+ var apiEmployees = new Dictionary();
+ foreach (var id in ids)
+ {
+ using var response = await _gatewayClient!.GetAsync($"/employees?id={id}");
+ apiEmployees[id] = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(), _jsonOptions);
+ }
+
+ await Task.Delay(5000);
+
+ using var listResponse = await _sinkClient!.GetAsync("/api/s3");
+ var employeeList = JsonSerializer.Deserialize>(await listResponse.Content.ReadAsStringAsync());
+
+ Assert.NotNull(employeeList);
+ Assert.Equal(ids.Length, employeeList.Count);
+
+ foreach (var id in ids)
+ {
+ using var s3Response = await _sinkClient.GetAsync($"/api/s3/employee_{id}.json");
+ var s3Employee = JsonSerializer.Deserialize(await s3Response.Content.ReadAsStringAsync(), _jsonOptions);
+
+ Assert.Contains($"employee_{id}.json", employeeList);
+ Assert.NotNull(s3Employee);
+ Assert.Equal(id, s3Employee.Id);
+ Assert.Equivalent(apiEmployees[id], s3Employee);
+ }
+ }
+
+ ///
+ /// Проверяет, что запрос несуществующего ключа в S3 возвращает некорректный статус, а не падает
+ ///
+ [Fact]
+ public async Task TestMissingS3KeyReturnsError()
+ {
+ using var response = await _sinkClient!.GetAsync("/api/s3/employee_nonexistent.json");
+
+ Assert.NotEqual(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ ///
+ /// Проверяет корректность балансировки: запросы с разными идентификаторами
+ /// маршрутизируются через гейтвей к нужной реплике и обрабатываются успешно
+ ///
+ [Fact]
+ public async Task TestGatewayRoutesRequestForEveryReplica()
+ {
+ foreach (var id in Enumerable.Range(1, 5))
+ {
+ using var response = await _gatewayClient!.GetAsync($"/employees?id={id}");
+ var employee = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(), _jsonOptions);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.NotNull(employee);
+ Assert.Equal(id, employee.Id);
+ }
+ }
+
+ ///
+ public async Task DisposeAsync()
+ {
+ await _app!.StopAsync();
+ await _app.DisposeAsync();
+ }
+}
diff --git a/EmployeeApp/EmployeeApp.AppHost/AppHost.cs b/EmployeeApp/EmployeeApp.AppHost/AppHost.cs
new file mode 100644
index 00000000..43c0decd
--- /dev/null
+++ b/EmployeeApp/EmployeeApp.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();
+
+var apiGateway = builder.AddProject("api-gateway");
+
+var awsConfig = builder.AddAWSSDKConfig()
+ .WithProfile("default")
+ .WithRegion(RegionEndpoint.EUCentral1);
+
+var localstack = builder
+ .AddLocalStack("employee-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/employee-template.yaml", "employee")
+ .WithReference(awsConfig);
+
+for (var i = 0; i < 5; i++)
+{
+ var service = builder.AddProject($"employee-app-{i}", launchProfileName: null)
+ .WithReference(cache)
+ .WithReference(awsResources)
+ .WaitFor(cache)
+ .WaitFor(awsResources)
+ .WithHttpsEndpoint(port: 5100 + i, name: $"employee-https-{i}");
+ apiGateway.WaitFor(service);
+}
+
+builder.AddProject("client-wasm")
+ .WaitFor(apiGateway);
+
+builder.AddProject("file-service")
+ .WithReference(awsResources)
+ .WithEnvironment("AWS__Resources__SNSUrl", "http://host.docker.internal:5280/api/sns")
+ .WaitFor(awsResources);
+
+builder.UseLocalStack(localstack);
+
+builder.Build().Run();
diff --git a/EmployeeApp/EmployeeApp.AppHost/CloudFormation/employee-template.yaml b/EmployeeApp/EmployeeApp.AppHost/CloudFormation/employee-template.yaml
new file mode 100644
index 00000000..331bf6fd
--- /dev/null
+++ b/EmployeeApp/EmployeeApp.AppHost/CloudFormation/employee-template.yaml
@@ -0,0 +1,59 @@
+AWSTemplateFormatVersion: '2010-09-09'
+Description: 'Cloud formation template for employee project'
+
+Parameters:
+ BucketName:
+ Type: String
+ Description: Name for the S3 bucket
+ Default: 'employee-bucket'
+
+ TopicName:
+ Type: String
+ Description: Name for the SNS topic
+ Default: 'employee-topic'
+
+Resources:
+ EmployeeBucket:
+ Type: AWS::S3::Bucket
+ Properties:
+ BucketName: !Ref BucketName
+ VersioningConfiguration:
+ Status: Suspended
+ Tags:
+ - Key: Name
+ Value: !Ref BucketName
+ - Key: Environment
+ Value: Lab
+ PublicAccessBlockConfiguration:
+ BlockPublicAcls: true
+ BlockPublicPolicy: true
+ IgnorePublicAcls: true
+ RestrictPublicBuckets: true
+
+ EmployeeTopic:
+ Type: AWS::SNS::Topic
+ Properties:
+ TopicName: !Ref TopicName
+ DisplayName: !Ref TopicName
+ Tags:
+ - Key: Name
+ Value: !Ref TopicName
+ - Key: Environment
+ Value: Lab
+
+Outputs:
+ S3BucketName:
+ Description: Name of the S3 bucket
+ Value: !Ref EmployeeBucket
+
+ S3BucketArn:
+ Description: ARN of the S3 bucket
+ Value: !GetAtt EmployeeBucket.Arn
+
+ SNSTopicName:
+ Description: Name of the SNS topic
+ Value: !GetAtt EmployeeTopic.TopicName
+
+ SNSTopicArn:
+ Description: ARN of the SNS topic
+ Value: !Ref EmployeeTopic
diff --git a/EmployeeApp/EmployeeApp.AppHost/EmployeeApp.AppHost.csproj b/EmployeeApp/EmployeeApp.AppHost/EmployeeApp.AppHost.csproj
new file mode 100644
index 00000000..114554df
--- /dev/null
+++ b/EmployeeApp/EmployeeApp.AppHost/EmployeeApp.AppHost.csproj
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ 1fd8c9f1-e232-4bc1-b971-029684372939
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/EmployeeApp/EmployeeApp.AppHost/Properties/launchSettings.json b/EmployeeApp/EmployeeApp.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000..50549ca7
--- /dev/null
+++ b/EmployeeApp/EmployeeApp.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:17236;http://localhost:15110",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21007",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22036"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15110",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19065",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20143"
+ }
+ }
+ }
+}
diff --git a/EmployeeApp/EmployeeApp.AppHost/appsettings.Development.json b/EmployeeApp/EmployeeApp.AppHost/appsettings.Development.json
new file mode 100644
index 00000000..ff66ba6b
--- /dev/null
+++ b/EmployeeApp/EmployeeApp.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/EmployeeApp/EmployeeApp.AppHost/appsettings.json b/EmployeeApp/EmployeeApp.AppHost/appsettings.json
new file mode 100644
index 00000000..a6b256bb
--- /dev/null
+++ b/EmployeeApp/EmployeeApp.AppHost/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ },
+ "LocalStack": {
+ "UseLocalStack": true
+ }
+}
diff --git a/EmployeeApp/EmployeeApp.ServiceDefaults/EmployeeApp.ServiceDefaults.csproj b/EmployeeApp/EmployeeApp.ServiceDefaults/EmployeeApp.ServiceDefaults.csproj
new file mode 100644
index 00000000..f40f4e11
--- /dev/null
+++ b/EmployeeApp/EmployeeApp.ServiceDefaults/EmployeeApp.ServiceDefaults.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EmployeeApp/EmployeeApp.ServiceDefaults/Extensions.cs b/EmployeeApp/EmployeeApp.ServiceDefaults/Extensions.cs
new file mode 100644
index 00000000..091bf411
--- /dev/null
+++ b/EmployeeApp/EmployeeApp.ServiceDefaults/Extensions.cs
@@ -0,0 +1,126 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+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/File.Service/Controllers/S3StorageController.cs b/File.Service/Controllers/S3StorageController.cs
new file mode 100644
index 00000000..e1d42681
--- /dev/null
+++ b/File.Service/Controllers/S3StorageController.cs
@@ -0,0 +1,63 @@
+using File.Service.Storage;
+using Microsoft.AspNetCore.Mvc;
+using System.Text;
+using System.Text.Json.Nodes;
+
+namespace File.Service.Controllers;
+
+///
+/// Контроллер для взаимодействия с S3
+///
+/// Служба для работы с S3
+/// Логгер
+[ApiController]
+[Route("api/s3")]
+public class S3StorageController(IS3Service s3Service, ILogger logger) : ControllerBase
+{
+ ///
+ /// Метод для получения списка хранящихся в 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 occurred during {method} of {controller}", nameof(ListFiles), nameof(S3StorageController));
+ return BadRequest(ex);
+ }
+ }
+
+ ///
+ /// Получает строковое представление хранящегося в S3 документа
+ ///
+ /// Ключ файла
+ /// Строковое представление файла
+ [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 occurred during {method} of {controller}", nameof(GetFile), nameof(S3StorageController));
+ return BadRequest(ex);
+ }
+ }
+}
diff --git a/File.Service/Controllers/SnsSubscriberController.cs b/File.Service/Controllers/SnsSubscriberController.cs
new file mode 100644
index 00000000..2d85e80b
--- /dev/null
+++ b/File.Service/Controllers/SnsSubscriberController.cs
@@ -0,0 +1,70 @@
+using Amazon.SimpleNotificationService.Util;
+using File.Service.Storage;
+using Microsoft.AspNetCore.Mvc;
+using System.Text;
+
+namespace File.Service.Controllers;
+
+///
+/// Контроллер для приема сообщений от SNS
+///
+/// Служба для работы с S3
+/// Логгер
+[ApiController]
+[Route("api/sns")]
+public class SnsSubscriberController(IS3Service s3Service, ILogger logger) : ControllerBase
+{
+ ///
+ /// Вебхук, который получает оповещения из SNS топика
+ ///
+ ///
+ /// Используется не только, чтобы получать оповещения,
+ /// но и для того, чтобы подтвердить подписку при
+ /// инициализации информационного обмена.
+ /// В любом случае должен возвращать 200
+ ///
+ [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();
+ }
+}
diff --git a/File.Service/File.Service.csproj b/File.Service/File.Service.csproj
new file mode 100644
index 00000000..d1acbaca
--- /dev/null
+++ b/File.Service/File.Service.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/File.Service/Messaging/SnsSubscriptionService.cs b/File.Service/Messaging/SnsSubscriptionService.cs
new file mode 100644
index 00000000..9db4eaa8
--- /dev/null
+++ b/File.Service/Messaging/SnsSubscriptionService.cs
@@ -0,0 +1,43 @@
+using Amazon.SimpleNotificationService;
+using Amazon.SimpleNotificationService.Model;
+using System.Net;
+
+namespace File.Service.Messaging;
+
+///
+/// Служба для подписки на SNS на старте приложения
+///
+/// Клиент SNS
+/// Конфигурация
+/// Логгер
+public class SnsSubscriptionService(IAmazonSimpleNotificationService snsClient, IConfiguration configuration, ILogger logger)
+{
+ ///
+ /// Уникальный идентификатор топика SNS
+ ///
+ private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"]
+ ?? throw new KeyNotFoundException("SNS topic link was not found in configuration");
+
+ ///
+ /// Делает попытку подписаться на топик SNS
+ ///
+ public async Task SubscribeEndpoint()
+ {
+ logger.LogInformation("Sending subscribe request for {topic}", _topicArn);
+ var endpoint = configuration["AWS:Resources:SNSUrl"]
+ ?? throw new KeyNotFoundException("SNS webhook url was not found in configuration");
+
+ 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);
+ else
+ logger.LogInformation("Subscription request for {topic} is successful, waiting for confirmation", _topicArn);
+ }
+}
diff --git a/File.Service/Program.cs b/File.Service/Program.cs
new file mode 100644
index 00000000..5ea1a363
--- /dev/null
+++ b/File.Service/Program.cs
@@ -0,0 +1,31 @@
+using Amazon.S3;
+using Amazon.SimpleNotificationService;
+using File.Service.Messaging;
+using File.Service.Storage;
+using LocalStack.Client.Extensions;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Services.AddControllers();
+
+builder.Services.AddLocalStack(builder.Configuration);
+builder.Services.AddAwsService();
+builder.Services.AddAwsService();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+var app = builder.Build();
+
+using var scope = app.Services.CreateScope();
+var s3Service = scope.ServiceProvider.GetRequiredService();
+await s3Service.EnsureBucketExists();
+
+var subscriptionService = scope.ServiceProvider.GetRequiredService();
+await subscriptionService.SubscribeEndpoint();
+
+app.MapDefaultEndpoints();
+app.MapControllers();
+
+app.Run();
diff --git a/File.Service/Properties/launchSettings.json b/File.Service/Properties/launchSettings.json
new file mode 100644
index 00000000..2a56650b
--- /dev/null
+++ b/File.Service/Properties/launchSettings.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:35685",
+ "sslPort": 44360
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5280",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7108;http://localhost:5280",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/File.Service/Storage/IS3Service.cs b/File.Service/Storage/IS3Service.cs
new file mode 100644
index 00000000..38ff102e
--- /dev/null
+++ b/File.Service/Storage/IS3Service.cs
@@ -0,0 +1,33 @@
+using System.Text.Json.Nodes;
+
+namespace File.Service.Storage;
+
+///
+/// Интерфейс службы для манипуляции файлами в объектном хранилище
+///
+public interface IS3Service
+{
+ ///
+ /// Отправляет файл в хранилище
+ ///
+ /// Строковая репрезентация сохраняемого файла
+ public Task UploadFile(string fileData);
+
+ ///
+ /// Получает список всех файлов из хранилища
+ ///
+ /// Список путей к файлам
+ public Task> GetFileList();
+
+ ///
+ /// Получает строковую репрезентацию файла из хранилища
+ ///
+ /// Путь к файлу в бакете
+ /// Строковая репрезентация прочтенного файла
+ public Task DownloadFile(string filePath);
+
+ ///
+ /// Создает S3 бакет при необходимости
+ ///
+ public Task EnsureBucketExists();
+}
diff --git a/File.Service/Storage/S3AwsService.cs b/File.Service/Storage/S3AwsService.cs
new file mode 100644
index 00000000..4d3b3595
--- /dev/null
+++ b/File.Service/Storage/S3AwsService.cs
@@ -0,0 +1,121 @@
+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;
+
+///
+/// Служба для манипуляции файлами в объектном хранилище
+///
+/// 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 id = rootNode["Id"]?.GetValue() ?? throw new ArgumentException("Passed JSON has invalid structure");
+
+ using var stream = new MemoryStream();
+ JsonSerializer.Serialize(stream, rootNode);
+ stream.Seek(0, SeekOrigin.Begin);
+
+ logger.LogInformation("Began uploading employee {file} onto {bucket}", id, _bucketName);
+ var request = new PutObjectRequest
+ {
+ BucketName = _bucketName,
+ Key = $"employee_{id}.json",
+ InputStream = stream
+ };
+
+ var response = await client.PutObjectAsync(request);
+
+ if (response.HttpStatusCode != HttpStatusCode.OK)
+ {
+ logger.LogError("Failed to upload employee {file}: {code}", id, response.HttpStatusCode);
+ return false;
+ }
+ logger.LogInformation("Finished uploading employee {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);
+ return JsonNode.Parse(reader.ReadToEnd()) ?? 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.EnsureBucketExistsAsync(_bucketName);
+ logger.LogInformation("{bucket} existence ensured", _bucketName);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unhandled exception occurred during {bucket} check", _bucketName);
+ throw;
+ }
+ }
+}
diff --git a/File.Service/appsettings.Development.json b/File.Service/appsettings.Development.json
new file mode 100644
index 00000000..ff66ba6b
--- /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..e6ee7492
--- /dev/null
+++ b/File.Service/appsettings.json
@@ -0,0 +1,24 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "LocalStack": {
+ "UseLocalStack": true,
+ "Session": {
+ "AwsAccessKeyId": "test",
+ "AwsAccessKey": "test",
+ "AwsSessionToken": "test",
+ "RegionName": "eu-central-1"
+ },
+ "Config": {
+ "LocalStackHost": "localhost",
+ "UseSsl": false,
+ "UseLegacyPorts": false,
+ "EdgePort": 4566
+ }
+ }
+}
diff --git a/README.md b/README.md
index dcaa5eb7..5f3ac6c5 100644
--- a/README.md
+++ b/README.md
@@ -1,128 +1,67 @@
-# Современные технологии разработки программного обеспечения
-[Таблица с успеваемостью](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).
+# Облачная разработка — Сервис генерации данных сотрудников
+## Описание
+
+Микросервисное приложение для генерации данных сотрудников компании с кэшированием и балансировкой нагрузки. Оркестрация осуществляется при помощи .NET Aspire.
+
+## Лабораторная работа №1 — Кэширование
+
+### Что реализовано
+
+- **Сервис генерации сотрудников** (`EmployeeApp.Api`) на основе библиотеки Bogus
+- **Сущность `Employee`** с 10 полями: идентификатор, ФИО, должность, отдел, дата приёма, оклад, электронная почта, номер телефона, индикатор увольнения, дата увольнения
+- **Генерация данных** с учётом пола (ФИО, отчество, фамилия), корреляции оклада с суффиксом должности, формата телефона `+7(***)***-**-**`
+- **Кэширование** ответов через `IDistributedCache` и Redis
+- **Структурное логирование** с помощью `ILogger` (логирование попаданий/промахов кэша, ошибок)
+- **Оркестрация** через .NET Aspire (AppHost поднимает Redis, API и Redis Commander)
+- **CORS** настроен для приёма запросов от клиента
+
+### Технологии
+
+- .NET 8, Minimal API
+- Bogus — генерация данных
+- Redis — распределённый кэш
+- .NET Aspire — оркестрация
+
+## Лабораторная работа №2 — Балансировка нагрузки
+
+### Что реализовано
+
+- **API Gateway** на основе Ocelot с маршрутизацией запросов к бэкенду
+- **5 реплик** сервиса `EmployeeApp.Api`, создаваемых в цикле через AppHost
+- **Кастомный балансировщик нагрузки Query Based** — маршрутизация на основе параметра `id` из query string: `index = id % количество_реплик`
+- **CORS** на Gateway настроен на приём только GET-запросов от клиента
+- Gateway скрывает пути бэкенда через `UpstreamPathTemplate`
+
+### Алгоритм балансировки Query Based
+
+От запрашиваемого идентификатора сотрудника находится остаток от деления по модулю числа реплик. Он определяет индекс реплики, которая обработает запрос.
+
+## Лабораторная работа №3 — Интеграционное тестирование (вариант SNS + Localstack)
+
+### Что реализовано
+
+- **Объектное хранилище** — S3-бакет `employee-bucket`, эмулируемый через Localstack (endpoint `http://localhost:4566`), создаётся CloudFormation-стеком `employee`
+- **Брокер сообщений** — SNS-топик `employee-topic`, эмулируемый через Localstack, создаётся тем же CloudFormation-шаблоном
+- **Сервис генерации (`EmployeeApp.Api`)** после промаха кэша публикует сериализованного `Employee` в SNS-топик (`SnsPublisherService` → `PublishAsync` с `TopicArn` из аутпутов CloudFormation)
+- **Файловый сервис (`File.Service`)**:
+ - на старте вызывает `EnsureBucketExists` и подписывает собственный HTTP-эндпоинт `POST /api/sns` на SNS-топик (`SnsSubscriptionService`)
+ - `SnsSubscriberController` — вебхук, подтверждающий подписку (`SubscriptionConfirmation`) и принимающий `Notification`, при получении уведомления сериализует сообщение в `employee_{id}.json` и загружает в S3 через `S3AwsService`
+ - `S3StorageController` — REST API `GET /api/s3` (список файлов) и `GET /api/s3/{key}` (содержимое файла)
+
+- **Интеграционные тесты (`EmployeeApp.AppHost.Tests`)**:
+ - `TestPipeline` — базовый end-to-end: дёргает Gateway `GET /employees?id=N`, ждёт 5 секунд прохода сообщения через SNS, читает список файлов и `employee_{id}.json` из S3 через `File.Service`; проверяет, что список содержит ровно один элемент `employee_{id}.json`, а содержимое файла эквивалентно ответу API (`Assert.Equivalent`)
+ - `TestCacheHitDoesNotDuplicateS3File` — два последовательных запроса с одинаковым `id` возвращают идентичного сотрудника (Redis-hit) и в S3 создаётся **только один** файл `employee_{id}.json` — подтверждает, что публикация в SNS происходит только при промахе кэша
+ - `TestMultipleIdsProduceDistinctS3Objects` — три разных идентификатора порождают три независимых файла в S3; для каждого `id` содержимое файла сверяется с соответствующим ответом API
+ - `TestMissingS3KeyReturnsError` — запрос `GET /api/s3/{key}` с несуществующим ключом возвращает отличный от `200` статус, а не падает
+ - `TestGatewayRoutesRequestForEveryReplica` — в цикле `id=1..5` дёргает Gateway, проверяет `200 OK` и корректный `Employee.Id` в ответе (подтверждает, что QueryBased балансировка маршрутизирует каждый запрос на нужную реплику)
+ - десериализация JSON-ответов идёт через общий `JsonSerializerOptions { PropertyNameCaseInsensitive = true }` — API/S3 отдают camelCase, а `Employee` имеет PascalCase + `required`-свойства
+ - логи тестов выводятся в xUnit через `MartinCostello.Logging.XUnit`
+
+### Технологии
+
+- `LocalStack.Aspire.Hosting` — контейнер Localstack в Aspire
+- `Aspire.Hosting.AWS` — CloudFormation-шаблон + AWS SDK конфигурация
+- `AWSSDK.S3`, `AWSSDK.SimpleNotificationService` — клиенты S3 и SNS
+- `LocalStack.Client.Extensions` — перенаправление AWS SDK на Localstack
+- `Aspire.Hosting.Testing` + `xUnit` + `MartinCostello.Logging.XUnit` — интеграционные тесты