diff --git a/Api.Gateway/Api.Gateway.csproj b/Api.Gateway/Api.Gateway.csproj
new file mode 100644
index 00000000..f2623169
--- /dev/null
+++ b/Api.Gateway/Api.Gateway.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/Api.Gateway/Program.cs b/Api.Gateway/Program.cs
new file mode 100644
index 00000000..870b2ca1
--- /dev/null
+++ b/Api.Gateway/Program.cs
@@ -0,0 +1,47 @@
+using Api.Gateway;
+using Ocelot.DependencyInjection;
+using Ocelot.Middleware;
+using VehicleApp.ServiceDefaults;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.Services.AddServiceDiscovery();
+builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true);
+
+var overrides = new Dictionary();
+var discovered = new List();
+for (var i = 0; Environment.GetEnvironmentVariable($"services__vehicleapp-api-{i}__https__0") is { } url; i++)
+{
+ var uri = new Uri(url);
+ overrides[$"Routes:0:DownstreamHostAndPorts:{i}:Host"] = uri.Host;
+ overrides[$"Routes:0:DownstreamHostAndPorts:{i}:Port"] = uri.Port.ToString();
+ discovered.Add($"{uri.Host}:{uri.Port}");
+}
+
+if (overrides.Count > 0)
+ builder.Configuration.AddInMemoryCollection(overrides);
+
+builder.Services.AddOcelot()
+ .AddCustomLoadBalancer((sp, _, provider) =>
+ new WeightedRoundRobinLoadBalancer(provider.GetAsync, sp.GetRequiredService()));
+
+var allowedOrigins = builder.Configuration
+ .GetSection("Cors:AllowedOrigins")
+ .Get() ?? [];
+
+builder.Services.AddCors(options =>
+ options.AddDefaultPolicy(policy =>
+ policy.WithOrigins(allowedOrigins)
+ .WithMethods("GET")
+ .WithHeaders("Content-Type")));
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+
+app.UseCors();
+
+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..142c4eab
--- /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:3226",
+ "sslPort": 44349
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5233",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7215;http://localhost:5233",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/Api.Gateway/WeightedRoundRobinLoadBalancer.cs b/Api.Gateway/WeightedRoundRobinLoadBalancer.cs
new file mode 100644
index 00000000..fab4ec5f
--- /dev/null
+++ b/Api.Gateway/WeightedRoundRobinLoadBalancer.cs
@@ -0,0 +1,60 @@
+using Ocelot.LoadBalancer.Interfaces;
+using Ocelot.Responses;
+using Ocelot.Values;
+
+namespace Api.Gateway;
+
+///
+/// Балансировщик нагрузки Weighted Round Robin (взвешенная карусель).
+/// Каждой реплике сервиса присваивается вес, в зависимости от которого она используется чаще/реже других.
+/// Реплики перебираются циклически: для весов [3, 2, 1] последовательность будет R1, R1, R1, R2, R2, R3, R1, R1, R1, ...
+///
+/// Делегат получения актуального списка downstream-реплик от Ocelot.
+///
+/// Конфигурация приложения. Веса считываются из секции LoadBalancer:Weights как массив
+/// в порядке реплик. Если для реплики вес отсутствует или некорректен (≤ 0), используется значение 1.
+///
+public class WeightedRoundRobinLoadBalancer(
+ Func>> services,
+ IConfiguration configuration) : ILoadBalancer
+{
+ private readonly int[] _weights = configuration
+ .GetSection("LoadBalancer:Weights").Get() ?? [];
+
+ private long _counter = -1;
+
+ public string Type => nameof(WeightedRoundRobinLoadBalancer);
+
+ public async Task> LeaseAsync(HttpContext httpContext)
+ {
+ var list = await services();
+
+ if (list.Count == 0)
+ throw new InvalidOperationException("No available downstream services.");
+
+ var weights = NormalizeWeights(list.Count);
+ var total = weights.Sum();
+
+ var pick = (int)(Interlocked.Increment(ref _counter) % total);
+ if (pick < 0) pick += total;
+
+ for (var i = 0; i < list.Count; i++)
+ {
+ pick -= weights[i];
+ if (pick < 0)
+ return new OkResponse(list[i].HostAndPort);
+ }
+
+ return new OkResponse(list[^1].HostAndPort);
+ }
+
+ public void Release(ServiceHostAndPort hostAndPort) { }
+
+ private int[] NormalizeWeights(int count)
+ {
+ var result = new int[count];
+ for (var i = 0; i < count; i++)
+ result[i] = i < _weights.Length && _weights[i] > 0 ? _weights[i] : 1;
+ return result;
+ }
+}
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..c79fd8ea
--- /dev/null
+++ b/Api.Gateway/appsettings.json
@@ -0,0 +1,18 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Cors": {
+ "AllowedOrigins": [
+ "http://localhost:5127",
+ "https://localhost:7282"
+ ]
+ },
+ "LoadBalancer": {
+ "Weights": [ 5, 4, 3, 2, 1 ]
+ }
+}
diff --git a/Api.Gateway/ocelot.json b/Api.Gateway/ocelot.json
new file mode 100644
index 00000000..74d62d6c
--- /dev/null
+++ b/Api.Gateway/ocelot.json
@@ -0,0 +1,35 @@
+{
+ "Routes": [
+ {
+ "UpstreamPathTemplate": "/vehicle",
+ "UpstreamHttpMethod": [ "GET" ],
+ "DownstreamPathTemplate": "/api/vehicle",
+ "DownstreamScheme": "https",
+ "DownstreamHostAndPorts": [
+ {
+ "Host": "localhost",
+ "Port": 0
+ },
+ {
+ "Host": "localhost",
+ "Port": 0
+ },
+ {
+ "Host": "localhost",
+ "Port": 0
+ },
+ {
+ "Host": "localhost",
+ "Port": 0
+ },
+ {
+ "Host": "localhost",
+ "Port": 0
+ }
+ ],
+ "LoadBalancerOptions": {
+ "Type": "WeightedRoundRobinLoadBalancer"
+ }
+ }
+ ]
+}
diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor
index 661f1181..9ff77c6a 100644
--- a/Client.Wasm/Components/StudentCard.razor
+++ b/Client.Wasm/Components/StudentCard.razor
@@ -4,10 +4,10 @@
- Номер №X "Название лабораторной"
- Вариант №Х "Название варианта"
- Выполнена Фамилией Именем 65ХХ
- Ссылка на форк
+ Номер №3 "Интеграционное тестирование"
+ Вариант №1 "Транспортное средство"
+ Выполнил Балдин Никита 6511
+ Ссылка на форк
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..d4fa3015 100644
--- a/Client.Wasm/wwwroot/appsettings.json
+++ b/Client.Wasm/wwwroot/appsettings.json
@@ -6,5 +6,5 @@
}
},
"AllowedHosts": "*",
- "BaseAddress": ""
+ "BaseAddress": "https://localhost:7215/vehicle"
}
diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln
index cb48241d..5beafefe 100644
--- a/CloudDevelopment.sln
+++ b/CloudDevelopment.sln
@@ -5,16 +5,112 @@ 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}") = "VehicleApp.AppHost", "VehicleApp\VehicleApp.AppHost\VehicleApp.AppHost.csproj", "{EF467773-3428-4934-B614-77783EAE4FA3}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VehicleApp.ServiceDefaults", "VehicleApp\VehicleApp.ServiceDefaults\VehicleApp.ServiceDefaults.csproj", "{97B1F7F0-C53D-2D4D-3803-B1150F873870}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VehicleApp.Api", "VehicleApp.Api\VehicleApp.Api.csproj", "{54C02CF3-616B-EC3E-6F35-EAF0ED92B6B0}"
+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}") = "VehicleApp.AppHost.Tests", "VehicleApp\VehicleApp.AppHost.Tests\VehicleApp.AppHost.Tests.csproj", "{AA138374-7B28-4097-B3A2-1BBF1446CCF1}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|x64.Build.0 = Debug|Any CPU
+ {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|x86.Build.0 = Debug|Any CPU
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|x64.ActiveCfg = Release|Any CPU
+ {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|x64.Build.0 = Release|Any CPU
+ {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|x86.ActiveCfg = Release|Any CPU
+ {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|x86.Build.0 = Release|Any CPU
+ {EF467773-3428-4934-B614-77783EAE4FA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EF467773-3428-4934-B614-77783EAE4FA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EF467773-3428-4934-B614-77783EAE4FA3}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EF467773-3428-4934-B614-77783EAE4FA3}.Debug|x64.Build.0 = Debug|Any CPU
+ {EF467773-3428-4934-B614-77783EAE4FA3}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EF467773-3428-4934-B614-77783EAE4FA3}.Debug|x86.Build.0 = Debug|Any CPU
+ {EF467773-3428-4934-B614-77783EAE4FA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EF467773-3428-4934-B614-77783EAE4FA3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EF467773-3428-4934-B614-77783EAE4FA3}.Release|x64.ActiveCfg = Release|Any CPU
+ {EF467773-3428-4934-B614-77783EAE4FA3}.Release|x64.Build.0 = Release|Any CPU
+ {EF467773-3428-4934-B614-77783EAE4FA3}.Release|x86.ActiveCfg = Release|Any CPU
+ {EF467773-3428-4934-B614-77783EAE4FA3}.Release|x86.Build.0 = Release|Any CPU
+ {97B1F7F0-C53D-2D4D-3803-B1150F873870}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {97B1F7F0-C53D-2D4D-3803-B1150F873870}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {97B1F7F0-C53D-2D4D-3803-B1150F873870}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {97B1F7F0-C53D-2D4D-3803-B1150F873870}.Debug|x64.Build.0 = Debug|Any CPU
+ {97B1F7F0-C53D-2D4D-3803-B1150F873870}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {97B1F7F0-C53D-2D4D-3803-B1150F873870}.Debug|x86.Build.0 = Debug|Any CPU
+ {97B1F7F0-C53D-2D4D-3803-B1150F873870}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {97B1F7F0-C53D-2D4D-3803-B1150F873870}.Release|Any CPU.Build.0 = Release|Any CPU
+ {97B1F7F0-C53D-2D4D-3803-B1150F873870}.Release|x64.ActiveCfg = Release|Any CPU
+ {97B1F7F0-C53D-2D4D-3803-B1150F873870}.Release|x64.Build.0 = Release|Any CPU
+ {97B1F7F0-C53D-2D4D-3803-B1150F873870}.Release|x86.ActiveCfg = Release|Any CPU
+ {97B1F7F0-C53D-2D4D-3803-B1150F873870}.Release|x86.Build.0 = Release|Any CPU
+ {54C02CF3-616B-EC3E-6F35-EAF0ED92B6B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {54C02CF3-616B-EC3E-6F35-EAF0ED92B6B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {54C02CF3-616B-EC3E-6F35-EAF0ED92B6B0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {54C02CF3-616B-EC3E-6F35-EAF0ED92B6B0}.Debug|x64.Build.0 = Debug|Any CPU
+ {54C02CF3-616B-EC3E-6F35-EAF0ED92B6B0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {54C02CF3-616B-EC3E-6F35-EAF0ED92B6B0}.Debug|x86.Build.0 = Debug|Any CPU
+ {54C02CF3-616B-EC3E-6F35-EAF0ED92B6B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {54C02CF3-616B-EC3E-6F35-EAF0ED92B6B0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {54C02CF3-616B-EC3E-6F35-EAF0ED92B6B0}.Release|x64.ActiveCfg = Release|Any CPU
+ {54C02CF3-616B-EC3E-6F35-EAF0ED92B6B0}.Release|x64.Build.0 = Release|Any CPU
+ {54C02CF3-616B-EC3E-6F35-EAF0ED92B6B0}.Release|x86.ActiveCfg = Release|Any CPU
+ {54C02CF3-616B-EC3E-6F35-EAF0ED92B6B0}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|x64.Build.0 = Debug|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|x86.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
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|x64.ActiveCfg = Release|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|x64.Build.0 = Release|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|x86.ActiveCfg = Release|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Debug|x64.Build.0 = Debug|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Debug|x86.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
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Release|x64.ActiveCfg = Release|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Release|x64.Build.0 = Release|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Release|x86.ActiveCfg = Release|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Release|x86.Build.0 = Release|Any CPU
+ {AA138374-7B28-4097-B3A2-1BBF1446CCF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AA138374-7B28-4097-B3A2-1BBF1446CCF1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AA138374-7B28-4097-B3A2-1BBF1446CCF1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AA138374-7B28-4097-B3A2-1BBF1446CCF1}.Debug|x64.Build.0 = Debug|Any CPU
+ {AA138374-7B28-4097-B3A2-1BBF1446CCF1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AA138374-7B28-4097-B3A2-1BBF1446CCF1}.Debug|x86.Build.0 = Debug|Any CPU
+ {AA138374-7B28-4097-B3A2-1BBF1446CCF1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AA138374-7B28-4097-B3A2-1BBF1446CCF1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AA138374-7B28-4097-B3A2-1BBF1446CCF1}.Release|x64.ActiveCfg = Release|Any CPU
+ {AA138374-7B28-4097-B3A2-1BBF1446CCF1}.Release|x64.Build.0 = Release|Any CPU
+ {AA138374-7B28-4097-B3A2-1BBF1446CCF1}.Release|x86.ActiveCfg = Release|Any CPU
+ {AA138374-7B28-4097-B3A2-1BBF1446CCF1}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/File.Service/Controllers/StorageController.cs b/File.Service/Controllers/StorageController.cs
new file mode 100644
index 00000000..84409732
--- /dev/null
+++ b/File.Service/Controllers/StorageController.cs
@@ -0,0 +1,38 @@
+using System.Text.Json.Nodes;
+using File.Service.Storage;
+using Microsoft.AspNetCore.Mvc;
+
+namespace File.Service.Controllers;
+
+///
+/// Контроллер для просмотра содержимого объектного хранилища
+///
+/// Файловое хранилище
+/// Логгер
+[ApiController]
+[Route("api/s3")]
+public sealed class StorageController(IFileStorage storage, ILogger logger) : ControllerBase
+{
+ ///
+ /// Получить список всех ключей в бакете
+ ///
+ [HttpGet]
+ public async Task>> ListFiles()
+ {
+ logger.LogInformation("Listing files in storage");
+ var list = await storage.ListAsync();
+ return Ok(list);
+ }
+
+ ///
+ /// Скачать содержимое файла по ключу
+ ///
+ /// Ключ файла
+ [HttpGet("{key}")]
+ public async Task> GetFile(string key)
+ {
+ logger.LogInformation("Downloading file {key}", key);
+ var node = await storage.DownloadAsync(key);
+ return Ok(node);
+ }
+}
diff --git a/File.Service/File.Service.csproj b/File.Service/File.Service.csproj
new file mode 100644
index 00000000..eb66f616
--- /dev/null
+++ b/File.Service/File.Service.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/File.Service/Messaging/SqsConsumerService.cs b/File.Service/Messaging/SqsConsumerService.cs
new file mode 100644
index 00000000..73b78997
--- /dev/null
+++ b/File.Service/Messaging/SqsConsumerService.cs
@@ -0,0 +1,58 @@
+using Amazon.SQS;
+using Amazon.SQS.Model;
+using File.Service.Storage;
+
+namespace File.Service.Messaging;
+
+///
+/// Фоновый сервис, потребляющий сообщения из SQS и сохраняющий их в файловое хранилище
+///
+/// Клиент SQS
+/// Фабрика DI-областей
+/// Конфигурация
+/// Логгер
+public sealed class SqsConsumerService(
+ IAmazonSQS sqsClient,
+ IServiceScopeFactory scopeFactory,
+ IConfiguration configuration,
+ ILogger logger) : BackgroundService
+{
+ private readonly string _queueName = configuration["AWS:Resources:SQSQueueName"]
+ ?? throw new KeyNotFoundException("SQS queue name was not found in configuration");
+
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ logger.LogInformation("SQS consumer service started for queue {queue}", _queueName);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ var response = await sqsClient.ReceiveMessageAsync(new ReceiveMessageRequest
+ {
+ QueueUrl = _queueName,
+ MaxNumberOfMessages = 10,
+ WaitTimeSeconds = 5
+ }, stoppingToken);
+
+ if (response?.Messages is null || response.Messages.Count == 0)
+ continue;
+
+ logger.LogInformation("Received {count} messages from {queue}", response.Messages.Count, _queueName);
+
+ foreach (var message in response.Messages)
+ {
+ try
+ {
+ using var scope = scopeFactory.CreateScope();
+ var storage = scope.ServiceProvider.GetRequiredService();
+ await storage.UploadAsync(message.Body);
+ await sqsClient.DeleteMessageAsync(_queueName, message.ReceiptHandle, stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error processing message {id}", message.MessageId);
+ }
+ }
+ }
+ }
+}
diff --git a/File.Service/Program.cs b/File.Service/Program.cs
new file mode 100644
index 00000000..f18eac51
--- /dev/null
+++ b/File.Service/Program.cs
@@ -0,0 +1,29 @@
+using Amazon.SQS;
+using File.Service.Messaging;
+using File.Service.Storage;
+using LocalStack.Client.Extensions;
+using VehicleApp.ServiceDefaults;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.Services.AddControllers();
+
+builder.Services.AddLocalStack(builder.Configuration);
+builder.Services.AddAwsService();
+builder.Services.AddHostedService();
+
+builder.AddMinioClient("vehicle-minio");
+builder.Services.AddScoped();
+
+var app = builder.Build();
+
+using var scope = app.Services.CreateScope();
+
+var storage = scope.ServiceProvider.GetRequiredService();
+await storage.EnsureBucketExistsAsync();
+
+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..98c5fe4d
--- /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:55162",
+ "sslPort": 44338
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5276",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7298;http://localhost:5276",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/File.Service/Storage/IFileStorage.cs b/File.Service/Storage/IFileStorage.cs
new file mode 100644
index 00000000..d7d8aaa0
--- /dev/null
+++ b/File.Service/Storage/IFileStorage.cs
@@ -0,0 +1,31 @@
+using System.Text.Json.Nodes;
+
+namespace File.Service.Storage;
+
+///
+/// Файловое хранилище для сериализованных транспортных средств
+///
+public interface IFileStorage
+{
+ ///
+ /// Загружает строковое представление файла в S3 хранилище
+ ///
+ /// JSON-строка с данными транспортного средства
+ public Task UploadAsync(string payload);
+
+ ///
+ /// Получить список ключей всех файлов в бакете
+ ///
+ public Task> ListAsync();
+
+ ///
+ /// Получить содержимое файла из бакета
+ ///
+ /// Ключ файла
+ public Task DownloadAsync(string key);
+
+ ///
+ /// Создать бакет, если он отсутствует
+ ///
+ public Task EnsureBucketExistsAsync();
+}
diff --git a/File.Service/Storage/MinioFileStorage.cs b/File.Service/Storage/MinioFileStorage.cs
new file mode 100644
index 00000000..77731034
--- /dev/null
+++ b/File.Service/Storage/MinioFileStorage.cs
@@ -0,0 +1,97 @@
+using System.Net;
+using System.Text;
+using System.Text.Json.Nodes;
+using Minio;
+using Minio.DataModel.Args;
+
+namespace File.Service.Storage;
+
+///
+/// Реализация файлового хранилища
+///
+/// MinIO-клиент
+/// Конфигурация
+/// Логгер
+public sealed class MinioFileStorage(IMinioClient client, IConfiguration configuration, ILogger logger) : IFileStorage
+{
+ private readonly string _bucketName = configuration["AWS:Resources:MinioBucketName"]
+ ?? throw new KeyNotFoundException("Minio bucket name was not found in configuration");
+
+ ///
+ public async Task UploadAsync(string payload)
+ {
+ var rootNode = JsonNode.Parse(payload) ?? throw new ArgumentException("Passed string is not a valid JSON");
+ var id = rootNode["id"]?.GetValue() ?? throw new ArgumentException("Passed JSON has invalid structure");
+
+ var bytes = Encoding.UTF8.GetBytes(payload);
+ using var stream = new MemoryStream(bytes);
+ stream.Seek(0, SeekOrigin.Begin);
+
+ logger.LogInformation("Uploading vehicle {id} to bucket {bucket}", id, _bucketName);
+ var request = new PutObjectArgs()
+ .WithBucket(_bucketName)
+ .WithStreamData(stream)
+ .WithObjectSize(bytes.Length)
+ .WithObject($"vehicle_{id}.json");
+
+ var response = await client.PutObjectAsync(request);
+ if (response.ResponseStatusCode != HttpStatusCode.OK)
+ {
+ logger.LogError("Failed to upload vehicle {id}: {code}", id, response.ResponseStatusCode);
+ return false;
+ }
+ logger.LogInformation("Uploaded vehicle {id} to {bucket}", id, _bucketName);
+ return true;
+ }
+
+ ///
+ public async Task> ListAsync()
+ {
+ logger.LogInformation("Listing files in {bucket}", _bucketName);
+ var request = new ListObjectsArgs()
+ .WithBucket(_bucketName)
+ .WithPrefix("")
+ .WithRecursive(true);
+
+ var result = new List();
+ await foreach (var item in client.ListObjectsEnumAsync(request))
+ result.Add(item.Key);
+ return result;
+ }
+
+ ///
+ public async Task DownloadAsync(string key)
+ {
+ logger.LogInformation("Downloading {file} from {bucket}", key, _bucketName);
+ var memoryStream = new MemoryStream();
+ var request = new GetObjectArgs()
+ .WithBucket(_bucketName)
+ .WithObject(key)
+ .WithCallbackStream(async (stream, cancellationToken) =>
+ {
+ await stream.CopyToAsync(memoryStream, cancellationToken);
+ memoryStream.Seek(0, SeekOrigin.Begin);
+ });
+
+ var response = await client.GetObjectAsync(request) ?? throw new InvalidOperationException($"Failed to download {key}");
+ using var reader = new StreamReader(memoryStream, Encoding.UTF8);
+ var content = await reader.ReadToEndAsync();
+ return JsonNode.Parse(content) ?? throw new InvalidOperationException("Downloaded document is not a valid JSON");
+ }
+
+ ///
+ public async Task EnsureBucketExistsAsync()
+ {
+ logger.LogInformation("Checking whether {bucket} exists", _bucketName);
+ var existsRequest = new BucketExistsArgs().WithBucket(_bucketName);
+ var exists = await client.BucketExistsAsync(existsRequest);
+ if (exists)
+ {
+ logger.LogInformation("Bucket {bucket} already exists", _bucketName);
+ return;
+ }
+ var makeRequest = new MakeBucketArgs().WithBucket(_bucketName);
+ await client.MakeBucketAsync(makeRequest);
+ logger.LogInformation("Created bucket {bucket}", _bucketName);
+ }
+}
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..c14c9a47 100644
--- a/README.md
+++ b/README.md
@@ -1,128 +1,125 @@
-# Современные технологии разработки программного обеспечения
-[Таблица с успеваемостью](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 — «Кэширование»
+
+**Вариант:** №1 — Транспортное средство
+
+---
+
+## Цель работы
+
+Реализовать сервис генерации данных о транспортных средствах с кэшированием ответов при помощи Redis и структурным логированием.
+
+---
+
+## Стек технологий
+
+| Компонент | Технология |
+|---|---|
+| Серверная часть | ASP.NET Core Web API (.NET 8) |
+| Генерация данных | Bogus 35.6.5 |
+| Кэширование | IDistributedCache + Redis (Aspire) |
+| Логирование | Microsoft.Extensions.Logging (структурное) |
+| Оркестрация | .NET Aspire 9.5.2 |
+| Клиент | Blazor WebAssembly (.NET 8) |
+
+---
+
+## Модель данных — Транспортное средство
+
+| # | Название | Тип | Источник (Bogus) | Ограничение |
+|---|---|---|---|---|
+| 1 | Идентификатор в системе | `int` | параметр запроса | — |
+| 2 | VIN-номер | `string` | `Vehicle.Vin()` | — |
+| 3 | Производитель | `string` | `Vehicle.Manufacturer()` | — |
+| 4 | Модель | `string` | `Vehicle.Model()` | — |
+| 5 | Год выпуска | `int` | `Date.Past(30).Year` | ≤ текущий год |
+| 6 | Тип корпуса | `string` | `Vehicle.Type()` | — |
+| 7 | Тип топлива | `string` | `Vehicle.Fuel()` | — |
+| 8 | Цвет корпуса | `string` | `Commerce.Color()` | — |
+| 9 | Пробег | `double` | `Random.Double(0, 500_000)` | ≥ 0 |
+| 10 | Дата последнего техобслуживания | `DateOnly` | `Date.Between(...)` | ≥ год выпуска |
+
+---
+
+## API
+
+### `GET /api/vehicle?id={id}`
+
+Возвращает транспортное средство по идентификатору.
+При первом запросе — генерирует и сохраняет в кэш.
+При повторном запросе — возвращает из кэша (TTL 15 минут).
+
+---
+
+# Лабораторная работа №2 — «Балансировка нагрузки»
+
+**Вариант алгоритма:** Weighted Round Robin
+
+---
+
+## Цель работы
+
+Настроить оркестрацию на запуск нескольких реплик сервиса генерации, реализовать API Gateway на основе Ocelot и имплементировать алгоритм балансировки нагрузки Weighted Round Robin.
+
+## Что было сделано
+
+### 1. Несколько реплик сервиса генерации
+
+В [VehicleApp.AppHost/AppHost.cs](VehicleApp/VehicleApp.AppHost/AppHost.cs) поднимается **5 реплик** сервиса `vehicleapp-api-{0..4}` на портах 5250–5254. Каждая реплика подключена к общему Redis-кэшу. Gateway получает ссылки на все реплики через `.WithReference(api)` — Aspire прокидывает их адреса в gateway через переменные окружения `services__vehicleapp-api-{i}__https__0`.
+
+### 2. API Gateway на Ocelot
+
+Проект [Api.Gateway](Api.Gateway/) — единая точка входа для клиента. Маршрут описан в [ocelot.json](Api.Gateway/ocelot.json):
+
+- **Upstream:** `GET /vehicle` → **Downstream:** `/api/vehicle` на одну из реплик
+- `LoadBalancerOptions.Type` = `WeightedRoundRobinLoadBalancer`
+- `DangerousAcceptAnyServerCertificateValidator: true` — принимаем dev-сертификаты реплик
+
+---
+
+# Лабораторная работа №3 — «Интеграционное тестирование»
+
+**Вариант:** SQS + MinIO
+
+---
+
+## Цель работы
+
+Добавить в оркестрацию объектное хранилище, реализовать файловый сервис, который сериализует сгенерированные данные в файлы и сохраняет их в объектном хранилище, наладить отправку данных в файловый сервис через брокер сообщений и покрыть бекенд интеграционными тестами.
+
+---
+
+## Что было сделано
+
+### 1. Объектное хранилище и брокер в оркестрации
+
+В [VehicleApp.AppHost/AppHost.cs](VehicleApp/VehicleApp.AppHost/AppHost.cs) добавлены:
+
+- контейнер **LocalStack** (`vehicle-localstack`, порт 4566) для эмуляции SQS;
+- контейнер **MinIO** (`vehicle-minio`) — объектное хранилище;
+- `AddAWSCloudFormationTemplate("resources", "CloudFormation/vehicle-template-sqs.yaml", ...)`
+Каждая реплика `vehicleapp-api-{i}` и `file-service` получают ссылку на CloudFormation-стек через `.WithReference(awsResources)`, а `file-service` — дополнительно ссылку на MinIO и переменную `AWS__Resources__MinioBucketName=vehicle-bucket`.
+
+### 2. Файловый сервис ([File.Service](File.Service/))
+
+- [Storage/IFileStorage.cs](File.Service/Storage/IFileStorage.cs), [Storage/MinioFileStorage.cs](File.Service/Storage/MinioFileStorage.cs) — интерфейс файлового хранилища и его реализация поверх MinIO. Сериализованный JSON загружается в бакет под ключом `vehicle_{id}.json`.
+- [Messaging/SqsConsumerService.cs](File.Service/Messaging/SqsConsumerService.cs) — фоновый `BackgroundService`, который батчами читает сообщения из SQS (`MaxNumberOfMessages=10`, `WaitTimeSeconds=5`), отдаёт payload в `IFileStorage` и удаляет сообщение из очереди.
+- [Controllers/StorageController.cs](File.Service/Controllers/StorageController.cs) — REST-эндпойнты `GET /api/s3` (список ключей) и `GET /api/s3/{key}` (содержимое файла) — используются интеграционными тестами для проверки результата.
+- [Program.cs](File.Service/Program.cs) — регистрация AWS SDK через `AddLocalStack`, MinIO-клиент через `AddMinioClient("vehicle-minio")` и автоматическое создание бакета при старте (`EnsureBucketExistsAsync`).
+
+### 3. Отправка генерируемых данных через брокер
+
+В [VehicleApp.Api](VehicleApp.Api/):
+
+- [Services/IVehicleProducer.cs](VehicleApp.Api/Services/IVehicleProducer.cs), [Services/SqsVehicleProducer.cs](VehicleApp.Api/Services/SqsVehicleProducer.cs) — продюсер, отправляющий сериализованное `Vehicle` в очередь по имени из `AWS:Resources:SQSQueueName`.
+- [Services/VehicleService.cs](VehicleApp.Api/Services/VehicleService.cs) — после генерации нового транспортного средства (cache miss) вызывает `producer.SendAsync(vehicle)` перед записью в кэш. На cache hit отправка не происходит.
+- [Program.cs](VehicleApp.Api/Program.cs) — регистрация LocalStack/SQS-клиента и продюсера в DI.
+
+### 4. Интеграционные тесты
+
+[VehicleApp/VehicleApp.AppHost.Tests/IntegrationTest1.cs](VehicleApp/VehicleApp.AppHost.Tests/IntegrationTest1.cs) — два `[Fact]`-теста, поднимающие весь AppHost через `DistributedApplicationTestingBuilder`:
+
+- **`GatewayResponse_IsPersistedToObjectStorage`** — выполняет `GET /vehicle?id=...` через шлюз, после паузы запрашивает `GET /api/s3/vehicle_{id}.json` у файлового сервиса и сравнивает оба объекта через `Assert.Equivalent`.
+- **`ObjectStorageList_ContainsGeneratedVehicle`** — после запроса транспортного средства убеждается, что список `GET /api/s3` содержит ключ `vehicle_{id}.json`.
+
+
diff --git a/VehicleApp.Api/Generators/VehicleGenerator.cs b/VehicleApp.Api/Generators/VehicleGenerator.cs
new file mode 100644
index 00000000..17a4ac71
--- /dev/null
+++ b/VehicleApp.Api/Generators/VehicleGenerator.cs
@@ -0,0 +1,34 @@
+using Bogus;
+using VehicleApp.Api.Models;
+
+namespace VehicleApp.Api.Generators;
+
+///
+/// Генератор транспортных средств
+///
+public static class VehicleGenerator
+{
+ private static readonly Faker _faker = new Faker()
+ .RuleFor(v => v.Vin, f => f.Vehicle.Vin())
+ .RuleFor(v => v.Manufacturer, f => f.Vehicle.Manufacturer())
+ .RuleFor(v => v.Model, f => f.Vehicle.Model())
+ .RuleFor(v => v.Year, f => f.Date.Past(30).Year)
+ .RuleFor(v => v.BodyType, f => f.Vehicle.Type())
+ .RuleFor(v => v.FuelType, f => f.Vehicle.Fuel())
+ .RuleFor(v => v.BodyColor, f => f.Commerce.Color())
+ .RuleFor(v => v.Mileage, f => Math.Round(f.Random.Double(0, 500_000), 1))
+ .RuleFor(v => v.LastMaintenanceDate, (f, v) =>
+ DateOnly.FromDateTime(f.Date.Between(new DateTime(v.Year, 1, 1), DateTime.UtcNow)));
+
+ ///
+ /// Сгенерировать транспортное средство по идентификатору
+ ///
+ /// Идентификатор транспортного средства
+ /// Сгенерированное транспортное средство
+ public static Vehicle Generate(int id)
+ {
+ var vehicle = _faker.Generate();
+ vehicle.Id = id;
+ return vehicle;
+ }
+}
diff --git a/VehicleApp.Api/Models/Vehicle.cs b/VehicleApp.Api/Models/Vehicle.cs
new file mode 100644
index 00000000..667d0e2c
--- /dev/null
+++ b/VehicleApp.Api/Models/Vehicle.cs
@@ -0,0 +1,57 @@
+namespace VehicleApp.Api.Models;
+
+///
+/// Транспортное средство
+///
+public sealed class Vehicle
+{
+ ///
+ /// Идентификатор в системе
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// VIN-номер
+ ///
+ public required string Vin { get; init; }
+
+ ///
+ /// Производитель
+ ///
+ public required string Manufacturer { get; init; }
+
+ ///
+ /// Модель
+ ///
+ public required string Model { get; init; }
+
+ ///
+ /// Год выпуска
+ ///
+ public int Year { get; init; }
+
+ ///
+ /// Тип корпуса
+ ///
+ public required string BodyType { get; init; }
+
+ ///
+ /// Тип топлива
+ ///
+ public required string FuelType { get; init; }
+
+ ///
+ /// Цвет корпуса
+ ///
+ public required string BodyColor { get; init; }
+
+ ///
+ /// Пробег
+ ///
+ public double Mileage { get; init; }
+
+ ///
+ /// Дата последнего техобслуживания
+ ///
+ public DateOnly LastMaintenanceDate { get; init; }
+}
diff --git a/VehicleApp.Api/Program.cs b/VehicleApp.Api/Program.cs
new file mode 100644
index 00000000..d87b1dec
--- /dev/null
+++ b/VehicleApp.Api/Program.cs
@@ -0,0 +1,23 @@
+using Amazon.SQS;
+using LocalStack.Client.Extensions;
+using VehicleApp.Api.Services;
+using VehicleApp.ServiceDefaults;
+
+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/vehicle", async (int id, IVehicleService service) =>
+ Results.Ok(await service.GetOrGenerateAsync(id)));
+
+app.Run();
diff --git a/VehicleApp.Api/Properties/launchSettings.json b/VehicleApp.Api/Properties/launchSettings.json
new file mode 100644
index 00000000..c89f8958
--- /dev/null
+++ b/VehicleApp.Api/Properties/launchSettings.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:63479",
+ "sslPort": 44339
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5266",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7208;http://localhost:5266",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/VehicleApp.Api/Services/IVehicleProducer.cs b/VehicleApp.Api/Services/IVehicleProducer.cs
new file mode 100644
index 00000000..7fea05a6
--- /dev/null
+++ b/VehicleApp.Api/Services/IVehicleProducer.cs
@@ -0,0 +1,15 @@
+using VehicleApp.Api.Models;
+
+namespace VehicleApp.Api.Services;
+
+///
+/// Сервис отправки сгенерированных транспортных средств в брокер сообщений
+///
+public interface IVehicleProducer
+{
+ ///
+ /// Отправить транспортное средство в очередь для последующей сериализации в файл
+ ///
+ /// Транспортное средство
+ public Task SendAsync(Vehicle vehicle);
+}
diff --git a/VehicleApp.Api/Services/IVehicleService.cs b/VehicleApp.Api/Services/IVehicleService.cs
new file mode 100644
index 00000000..2faad8c4
--- /dev/null
+++ b/VehicleApp.Api/Services/IVehicleService.cs
@@ -0,0 +1,16 @@
+using VehicleApp.Api.Models;
+
+namespace VehicleApp.Api.Services;
+
+///
+/// Сервис получения транспортных средств с кэшированием
+///
+public interface IVehicleService
+{
+ ///
+ /// Получить транспортное средство из кэша или сгенерировать новое
+ ///
+ /// Идентификатор транспортного средства
+ /// Сгенерированное транспортное средство
+ public Task GetOrGenerateAsync(int id);
+}
diff --git a/VehicleApp.Api/Services/SqsVehicleProducer.cs b/VehicleApp.Api/Services/SqsVehicleProducer.cs
new file mode 100644
index 00000000..e0475944
--- /dev/null
+++ b/VehicleApp.Api/Services/SqsVehicleProducer.cs
@@ -0,0 +1,41 @@
+using System.Net;
+using System.Text.Json;
+using Amazon.SQS;
+using VehicleApp.Api.Models;
+
+namespace VehicleApp.Api.Services;
+
+///
+/// Отправляет сериализованное транспортное средство в очередь SQS
+///
+/// Клиент SQS
+/// Конфигурация
+/// Логгер
+public sealed class SqsVehicleProducer(IAmazonSQS client, IConfiguration configuration, ILogger logger) : IVehicleProducer
+{
+ private static readonly JsonSerializerOptions _serializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ private readonly string _queueName = configuration["AWS:Resources:SQSQueueName"]
+ ?? throw new KeyNotFoundException("SQS queue name was not found in configuration");
+
+ ///
+ public async Task SendAsync(Vehicle vehicle)
+ {
+ try
+ {
+ var json = JsonSerializer.Serialize(vehicle, _serializerOptions);
+ var response = await client.SendMessageAsync(_queueName, json);
+ if (response.HttpStatusCode == HttpStatusCode.OK)
+ logger.LogInformation("Vehicle {id} was sent to SQS queue {queue}", vehicle.Id, _queueName);
+ else
+ throw new Exception($"SQS returned {response.HttpStatusCode}");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to send vehicle {id} to SQS queue", vehicle.Id);
+ }
+ }
+}
diff --git a/VehicleApp.Api/Services/VehicleService.cs b/VehicleApp.Api/Services/VehicleService.cs
new file mode 100644
index 00000000..f003c68b
--- /dev/null
+++ b/VehicleApp.Api/Services/VehicleService.cs
@@ -0,0 +1,88 @@
+using System.Text.Json;
+using Microsoft.Extensions.Caching.Distributed;
+using VehicleApp.Api.Generators;
+using VehicleApp.Api.Models;
+
+namespace VehicleApp.Api.Services;
+
+///
+/// Сервис получения транспортных средств с кэшированием
+///
+/// Распределённый кэш
+/// Сервис отправки в брокер сообщений
+/// Логгер
+/// Конфигурация приложения
+public sealed class VehicleService(IDistributedCache cache, IVehicleProducer producer, ILogger logger, IConfiguration configuration) : IVehicleService
+{
+ private const string KeyPrefix = "vehicle:";
+
+ private readonly TimeSpan _entryLifetime = TimeSpan.FromMinutes(
+ configuration.GetValue("CacheExpirationMinutes", 15));
+
+ ///
+ public async Task GetOrGenerateAsync(int id)
+ {
+ var key = $"{KeyPrefix}{id}";
+
+ var cached = await TryGetFromCacheAsync(key);
+ if (cached is not null)
+ return cached;
+
+ var vehicle = VehicleGenerator.Generate(id);
+ logger.LogInformation("Vehicle generated. Id: {VehicleId}", id);
+
+ await producer.SendAsync(vehicle);
+ await SetToCacheAsync(key, vehicle);
+ return vehicle;
+ }
+
+ ///
+ /// Попытаться получить транспортное средство из кэша
+ ///
+ /// Ключ кэша
+ /// Транспортное средство или при промахе или ошибке кэша
+ private async Task TryGetFromCacheAsync(string key)
+ {
+ logger.LogInformation("Getting vehicle from cache. Key: {CacheKey}", key);
+ try
+ {
+ var json = await cache.GetStringAsync(key);
+ if (json is null)
+ {
+ logger.LogInformation("Cache miss. Key: {CacheKey}", key);
+ return null;
+ }
+
+ logger.LogInformation("Cache hit. Key: {CacheKey}", key);
+ return JsonSerializer.Deserialize(json);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to get vehicle from cache. Key: {CacheKey}", key);
+ return null;
+ }
+ }
+
+ ///
+ /// Сохранить транспортное средство в кэш
+ ///
+ /// Ключ кэша
+ /// Транспортное средство для сохранения
+ private async Task SetToCacheAsync(string key, Vehicle vehicle)
+ {
+ logger.LogInformation("Saving vehicle to cache. Key: {CacheKey}", key);
+ try
+ {
+ var json = JsonSerializer.Serialize(vehicle);
+ var options = new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = _entryLifetime
+ };
+ await cache.SetStringAsync(key, json, options);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to save vehicle to cache. Key: {CacheKey}", key);
+ }
+ }
+}
diff --git a/VehicleApp.Api/VehicleApp.Api.csproj b/VehicleApp.Api/VehicleApp.Api.csproj
new file mode 100644
index 00000000..22e062f4
--- /dev/null
+++ b/VehicleApp.Api/VehicleApp.Api.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VehicleApp.Api/appsettings.Development.json b/VehicleApp.Api/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/VehicleApp.Api/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/VehicleApp.Api/appsettings.json b/VehicleApp.Api/appsettings.json
new file mode 100644
index 00000000..0f908f6f
--- /dev/null
+++ b/VehicleApp.Api/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "CacheExpirationMinutes": 15
+}
diff --git a/VehicleApp/VehicleApp.AppHost.Tests/IntegrationTest.cs b/VehicleApp/VehicleApp.AppHost.Tests/IntegrationTest.cs
new file mode 100644
index 00000000..fdabbf79
--- /dev/null
+++ b/VehicleApp/VehicleApp.AppHost.Tests/IntegrationTest.cs
@@ -0,0 +1,103 @@
+using System.Text.Json;
+using Aspire.Hosting;
+using Microsoft.Extensions.Logging;
+using VehicleApp.Api.Models;
+using Xunit.Abstractions;
+
+namespace VehicleApp.AppHost.Tests;
+
+///
+/// Интеграционные тесты
+///
+/// xUnit-вывод
+public class IntegrationTest(ITestOutputHelper output) : IAsyncLifetime
+{
+ private static readonly JsonSerializerOptions _serializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true
+ };
+
+ private DistributedApplication? _app;
+ private HttpClient? _gatewayClient;
+ private HttpClient? _fileServiceClient;
+
+ ///
+ public async Task InitializeAsync()
+ {
+ var cancellationToken = CancellationToken.None;
+ IDistributedApplicationTestingBuilder 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");
+ }
+
+ ///
+ /// Запрос через шлюз возвращает транспортное средство и сериализует его в файловое хранилище.
+ /// Проверяется идентичность данных, полученных из API и из S3.
+ ///
+ [Fact]
+ public async Task GatewayResponse_IsPersistedToObjectStorage()
+ {
+ var id = Random.Shared.Next(1, 100);
+
+ using var gatewayResponse = await _gatewayClient!.GetAsync($"/vehicle?id={id}");
+ gatewayResponse.EnsureSuccessStatusCode();
+ var apiVehicle = JsonSerializer.Deserialize(
+ await gatewayResponse.Content.ReadAsStringAsync(), _serializerOptions);
+
+ await Task.Delay(TimeSpan.FromSeconds(5));
+
+ using var s3Response = await _fileServiceClient!.GetAsync($"/api/s3/vehicle_{id}.json");
+ s3Response.EnsureSuccessStatusCode();
+ var storedVehicle = JsonSerializer.Deserialize(
+ await s3Response.Content.ReadAsStringAsync(), _serializerOptions);
+
+ Assert.NotNull(apiVehicle);
+ Assert.NotNull(storedVehicle);
+ Assert.Equal(id, storedVehicle!.Id);
+ Assert.Equivalent(apiVehicle, storedVehicle);
+ }
+
+ ///
+ /// После запроса транспортного средства в бакете присутствует соответствующий файл.
+ ///
+ [Fact]
+ public async Task ObjectStorageList_ContainsGeneratedVehicle()
+ {
+ var id = Random.Shared.Next(101, 200);
+
+ using var gatewayResponse = await _gatewayClient!.GetAsync($"/vehicle?id={id}");
+ gatewayResponse.EnsureSuccessStatusCode();
+
+ await Task.Delay(TimeSpan.FromSeconds(5));
+
+ using var listResponse = await _fileServiceClient!.GetAsync("/api/s3");
+ listResponse.EnsureSuccessStatusCode();
+ var keys = JsonSerializer.Deserialize>(
+ await listResponse.Content.ReadAsStringAsync(), _serializerOptions);
+
+ Assert.NotNull(keys);
+ Assert.Contains($"vehicle_{id}.json", keys!);
+ }
+
+ ///
+ public async Task DisposeAsync()
+ {
+ if (_app is not null)
+ {
+ await _app.StopAsync();
+ await _app.DisposeAsync();
+ }
+ }
+}
diff --git a/VehicleApp/VehicleApp.AppHost.Tests/VehicleApp.AppHost.Tests.csproj b/VehicleApp/VehicleApp.AppHost.Tests/VehicleApp.AppHost.Tests.csproj
new file mode 100644
index 00000000..8899355f
--- /dev/null
+++ b/VehicleApp/VehicleApp.AppHost.Tests/VehicleApp.AppHost.Tests.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VehicleApp/VehicleApp.AppHost/AppHost.cs b/VehicleApp/VehicleApp.AppHost/AppHost.cs
new file mode 100644
index 00000000..512dd801
--- /dev/null
+++ b/VehicleApp/VehicleApp.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 gateway = builder.AddProject("api-gateway");
+
+var awsConfig = builder.AddAWSSDKConfig()
+ .WithProfile("default")
+ .WithRegion(RegionEndpoint.EUCentral1);
+
+var localstack = builder.AddLocalStack("vehicle-localstack", awsConfig: awsConfig, configureContainer: container =>
+{
+ container.Lifetime = ContainerLifetime.Session;
+ container.DebugLevel = 1;
+ container.LogLevel = LocalStackLogLevel.Debug;
+ container.Port = 4566;
+ container.AdditionalEnvironmentVariables.Add("DEBUG", "1");
+});
+
+var awsResources = builder.AddAWSCloudFormationTemplate("resources", "CloudFormation/vehicle-template-sqs.yaml", "vehicle")
+ .WithReference(awsConfig);
+
+var minio = builder.AddMinioContainer("vehicle-minio");
+
+for (var i = 0; i < 5; i++)
+{
+ var api = builder.AddProject($"vehicleapp-api-{i}", launchProfileName: null)
+ .WithHttpsEndpoint(5250 + i)
+ .WithReference(cache)
+ .WithReference(awsResources)
+ .WaitFor(cache)
+ .WaitFor(awsResources);
+ gateway.WithReference(api).WaitFor(api);
+}
+
+builder.AddProject("client-wasm")
+ .WaitFor(gateway);
+
+builder.AddProject("file-service")
+ .WithReference(awsResources)
+ .WithReference(minio)
+ .WithEnvironment("AWS__Resources__MinioBucketName", "vehicle-bucket")
+ .WaitFor(awsResources)
+ .WaitFor(minio);
+
+builder.UseLocalStack(localstack);
+
+builder.Build().Run();
diff --git a/VehicleApp/VehicleApp.AppHost/CloudFormation/vehicle-template-sqs.yaml b/VehicleApp/VehicleApp.AppHost/CloudFormation/vehicle-template-sqs.yaml
new file mode 100644
index 00000000..0424f2dd
--- /dev/null
+++ b/VehicleApp/VehicleApp.AppHost/CloudFormation/vehicle-template-sqs.yaml
@@ -0,0 +1,32 @@
+AWSTemplateFormatVersion: '2010-09-09'
+Description: 'Cloud formation template for vehicle project (SQS + Minio variant)'
+
+Parameters:
+ QueueName:
+ Type: String
+ Description: Name for the SQS queue
+ Default: 'vehicle-queue'
+
+Resources:
+ VehicleQueue:
+ Type: AWS::SQS::Queue
+ Properties:
+ QueueName: !Ref QueueName
+ VisibilityTimeout: 30
+ MessageRetentionPeriod: 345600
+ DelaySeconds: 0
+ ReceiveMessageWaitTimeSeconds: 0
+ Tags:
+ - Key: Name
+ Value: !Ref QueueName
+ - Key: Environment
+ Value: Vehicle
+
+Outputs:
+ SQSQueueName:
+ Description: Name of the SQS queue
+ Value: !GetAtt VehicleQueue.QueueName
+
+ SQSQueueArn:
+ Description: ARN of the SQS queue
+ Value: !GetAtt VehicleQueue.Arn
diff --git a/VehicleApp/VehicleApp.AppHost/Properties/launchSettings.json b/VehicleApp/VehicleApp.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000..0cb67d26
--- /dev/null
+++ b/VehicleApp/VehicleApp.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:17131;http://localhost:15261",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21112",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22286"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15261",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19152",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20165"
+ }
+ }
+ }
+}
diff --git a/VehicleApp/VehicleApp.AppHost/VehicleApp.AppHost.csproj b/VehicleApp/VehicleApp.AppHost/VehicleApp.AppHost.csproj
new file mode 100644
index 00000000..86e0a756
--- /dev/null
+++ b/VehicleApp/VehicleApp.AppHost/VehicleApp.AppHost.csproj
@@ -0,0 +1,33 @@
+
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ 0be31d7b-448e-4621-bd8c-aa00ccb229d1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/VehicleApp/VehicleApp.AppHost/appsettings.Development.json b/VehicleApp/VehicleApp.AppHost/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/VehicleApp/VehicleApp.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/VehicleApp/VehicleApp.AppHost/appsettings.json b/VehicleApp/VehicleApp.AppHost/appsettings.json
new file mode 100644
index 00000000..a6b256bb
--- /dev/null
+++ b/VehicleApp/VehicleApp.AppHost/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ },
+ "LocalStack": {
+ "UseLocalStack": true
+ }
+}
diff --git a/VehicleApp/VehicleApp.ServiceDefaults/Extensions.cs b/VehicleApp/VehicleApp.ServiceDefaults/Extensions.cs
new file mode 100644
index 00000000..8cbc2e34
--- /dev/null
+++ b/VehicleApp/VehicleApp.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.Hosting;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace VehicleApp.ServiceDefaults;
+
+// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
+// This project should be referenced by each service project in your solution.
+// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
+public static class Extensions
+{
+ private const string HealthEndpointPath = "/health";
+ private const string AlivenessEndpointPath = "/alive";
+
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+
+ builder.AddDefaultHealthChecks();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ // Turn on resilience by default
+ http.AddStandardResilienceHandler();
+
+ // Turn on service discovery by default
+ http.AddServiceDiscovery();
+ });
+
+ // Uncomment the following to restrict the allowed schemes for service discovery.
+ // builder.Services.Configure(options =>
+ // {
+ // options.AllowedSchemes = ["https"];
+ // });
+
+ return builder;
+ }
+
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation(tracing =>
+ // Exclude health check requests from tracing
+ tracing.Filter = context =>
+ !context.Request.Path.StartsWithSegments(HealthEndpointPath)
+ && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
+ )
+ // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
+ //.AddGrpcClientInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
+ //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
+ //{
+ // builder.Services.AddOpenTelemetry()
+ // .UseAzureMonitor();
+ //}
+
+ return builder;
+ }
+
+ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ // Add a default liveness check to ensure app is responsive
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ // Adding health checks endpoints to applications in non-development environments has security implications.
+ // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
+ if (app.Environment.IsDevelopment())
+ {
+ // All health checks must pass for app to be considered ready to accept traffic after starting
+ app.MapHealthChecks(HealthEndpointPath);
+
+ // Only health checks tagged with the "live" tag must pass for app to be considered alive
+ app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+}
diff --git a/VehicleApp/VehicleApp.ServiceDefaults/VehicleApp.ServiceDefaults.csproj b/VehicleApp/VehicleApp.ServiceDefaults/VehicleApp.ServiceDefaults.csproj
new file mode 100644
index 00000000..1b6e209a
--- /dev/null
+++ b/VehicleApp/VehicleApp.ServiceDefaults/VehicleApp.ServiceDefaults.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+