From 71cf8d63c9e623bf8c94a726d5f02daf59a5f4f2 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Sun, 15 Mar 2026 19:23:06 +0400 Subject: [PATCH 01/23] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D1=81=D1=81=20=D0=BF=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=BC=D0=B5=D1=82=D0=BD=D0=BE=D0=B9=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=82=D0=B8,=20=D0=BD=D0=B0=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/Properties/launchSettings.json | 2 +- Client.Wasm/wwwroot/appsettings.json | 2 +- src/AppHost/AppHost.csproj | 21 +++++ src/AppHost/Program.cs | 11 +++ src/AppHost/Properties/launchSettings.json | 17 ++++ src/ServiceDefaults/Extensions.cs | 81 +++++++++++++++++ src/ServiceDefaults/ServiceDefaults.csproj | 20 +++++ src/VehicleApi/Models/Vehicle.cs | 15 ++++ src/VehicleApi/Program.cs | 67 ++++++++++++++ src/VehicleApi/Properties/launchSettings.json | 23 +++++ src/VehicleApi/Services/VehicleGenerator.cs | 27 ++++++ src/VehicleApi/VehicleApi.csproj | 18 ++++ .../VehicleApi.Tests/VehicleApi.Tests.csproj | 24 +++++ .../VehicleApi.Tests/VehicleGeneratorTests.cs | 88 +++++++++++++++++++ 14 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 src/AppHost/AppHost.csproj create mode 100644 src/AppHost/Program.cs create mode 100644 src/AppHost/Properties/launchSettings.json create mode 100644 src/ServiceDefaults/Extensions.cs create mode 100644 src/ServiceDefaults/ServiceDefaults.csproj create mode 100644 src/VehicleApi/Models/Vehicle.cs create mode 100644 src/VehicleApi/Program.cs create mode 100644 src/VehicleApi/Properties/launchSettings.json create mode 100644 src/VehicleApi/Services/VehicleGenerator.cs create mode 100644 src/VehicleApi/VehicleApi.csproj create mode 100644 tests/VehicleApi.Tests/VehicleApi.Tests.csproj create mode 100644 tests/VehicleApi.Tests/VehicleGeneratorTests.cs diff --git a/Client.Wasm/Properties/launchSettings.json b/Client.Wasm/Properties/launchSettings.json index 0d824ea7..8141fbab 100644 --- a/Client.Wasm/Properties/launchSettings.json +++ b/Client.Wasm/Properties/launchSettings.json @@ -38,4 +38,4 @@ } } } -} +} \ No newline at end of file diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 4dda7c04..527aa767 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "https://localhost:7170/land-plot" + "BaseAddress": "https://localhost:5001/api/vehicles" } \ No newline at end of file diff --git a/src/AppHost/AppHost.csproj b/src/AppHost/AppHost.csproj new file mode 100644 index 00000000..20004976 --- /dev/null +++ b/src/AppHost/AppHost.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + enable + enable + true + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/AppHost/Program.cs b/src/AppHost/Program.cs new file mode 100644 index 00000000..b5d037cc --- /dev/null +++ b/src/AppHost/Program.cs @@ -0,0 +1,11 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var cache = builder.AddRedis("cache"); + +var api = builder.AddProject("vehicleapi") + .WithReference(cache); + +var client = builder.AddProject("client") + .WithReference(api); + +builder.Build().Run(); diff --git a/src/AppHost/Properties/launchSettings.json b/src/AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..a37f7f18 --- /dev/null +++ b/src/AppHost/Properties/launchSettings.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17057;http://localhost:15057", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21057", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22057" + } + } + } +} diff --git a/src/ServiceDefaults/Extensions.cs b/src/ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..f66d336a --- /dev/null +++ b/src/ServiceDefaults/Extensions.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +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(); + + 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() + .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(); + } + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + app.MapHealthChecks(HealthEndpointPath); + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + return app; + } +} \ No newline at end of file diff --git a/src/ServiceDefaults/ServiceDefaults.csproj b/src/ServiceDefaults/ServiceDefaults.csproj new file mode 100644 index 00000000..66030857 --- /dev/null +++ b/src/ServiceDefaults/ServiceDefaults.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/VehicleApi/Models/Vehicle.cs b/src/VehicleApi/Models/Vehicle.cs new file mode 100644 index 00000000..ef65cdd1 --- /dev/null +++ b/src/VehicleApi/Models/Vehicle.cs @@ -0,0 +1,15 @@ +namespace VehicleApi.Models; + +public record Vehicle +{ + public int Id { get; init; } + public string Vin { get; init; } = string.Empty; + public string Manufacturer { get; init; } = string.Empty; + public string Model { get; init; } = string.Empty; + public int Year { get; init; } + public string BodyType { get; init; } = string.Empty; + public string FuelType { get; init; } = string.Empty; + public string Color { get; init; } = string.Empty; + public double Mileage { get; init; } + public DateOnly LastServiceDate { get; init; } +} diff --git a/src/VehicleApi/Program.cs b/src/VehicleApi/Program.cs new file mode 100644 index 00000000..19fc7f24 --- /dev/null +++ b/src/VehicleApi/Program.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; +using VehicleApi.Models; +using VehicleApi.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Add ServiceDefaults (OpenTelemetry, health checks, service discovery) +builder.AddServiceDefaults(); + +// Add Redis distributed caching +builder.AddRedisDistributedCache("cache"); + +// Add CORS for Blazor client +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +var app = builder.Build(); + +// Enable CORS +app.UseCors(); + +// Map health checks +app.MapDefaultEndpoints(); + +// API endpoint for vehicle data +app.MapGet("/api/vehicles", async (int id, IDistributedCache cache, ILogger logger) => +{ + if (id <= 0) + { + logger.LogWarning("Invalid vehicle ID {Id} requested", id); + return Results.BadRequest("ID must be greater than 0"); + } + + var cacheKey = $"vehicle:{id}"; + var cachedData = await cache.GetAsync(cacheKey); + + if (cachedData != null) + { + logger.LogInformation("Cache hit for vehicle ID {Id}", id); + var vehicle = JsonSerializer.Deserialize(cachedData); + logger.LogInformation("Returning cached vehicle: {@Vehicle}", vehicle); + return Results.Ok(vehicle); + } + + logger.LogInformation("Cache miss for vehicle ID {Id}", id); + var generated = VehicleGenerator.Generate(id); + logger.LogInformation("Generated new vehicle: {@Vehicle}", generated); + + var serialized = JsonSerializer.SerializeToUtf8Bytes(generated); + await cache.SetAsync(cacheKey, serialized, new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) + }); + logger.LogInformation("Vehicle {Id} cached for 10 minutes", id); + + return Results.Ok(generated); +}); + +app.Run(); diff --git a/src/VehicleApi/Properties/launchSettings.json b/src/VehicleApi/Properties/launchSettings.json new file mode 100644 index 00000000..900ff92f --- /dev/null +++ b/src/VehicleApi/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/VehicleApi/Services/VehicleGenerator.cs b/src/VehicleApi/Services/VehicleGenerator.cs new file mode 100644 index 00000000..af3bd41c --- /dev/null +++ b/src/VehicleApi/Services/VehicleGenerator.cs @@ -0,0 +1,27 @@ +using Bogus; +using VehicleApi.Models; + +namespace VehicleApi.Services; + +public static class VehicleGenerator +{ + public static Vehicle Generate(int id) + { + // Seed per-instance (NOT global Randomizer.Seed) + var faker = new Faker() + .UseSeed(id) + .RuleFor(v => v.Id, id) + .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.Random.Int(1990, DateTime.Now.Year)) + .RuleFor(v => v.BodyType, f => f.Vehicle.Type()) + .RuleFor(v => v.FuelType, f => f.Vehicle.Fuel()) + .RuleFor(v => v.Color, f => f.Commerce.Color()) + .RuleFor(v => v.Mileage, f => f.Random.Double(0, 500000)) + .RuleFor(v => v.LastServiceDate, (f, v) => + DateOnly.FromDateTime(f.Date.Between(new DateTime(v.Year, 1, 1), DateTime.Now))); + + return faker.Generate(); + } +} diff --git a/src/VehicleApi/VehicleApi.csproj b/src/VehicleApi/VehicleApi.csproj new file mode 100644 index 00000000..09abff65 --- /dev/null +++ b/src/VehicleApi/VehicleApi.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/VehicleApi.Tests/VehicleApi.Tests.csproj b/tests/VehicleApi.Tests/VehicleApi.Tests.csproj new file mode 100644 index 00000000..43d11c14 --- /dev/null +++ b/tests/VehicleApi.Tests/VehicleApi.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/VehicleApi.Tests/VehicleGeneratorTests.cs b/tests/VehicleApi.Tests/VehicleGeneratorTests.cs new file mode 100644 index 00000000..b52531da --- /dev/null +++ b/tests/VehicleApi.Tests/VehicleGeneratorTests.cs @@ -0,0 +1,88 @@ +using VehicleApi.Models; +using VehicleApi.Services; +using Xunit; +using VehicleApi.Models; +using VehicleApi.Services; + +namespace VehicleApi.Tests; + +public class VehicleGeneratorTests +{ + [Fact] + public void Generate_SameId_ReturnsSameData() + { + // Arrange & Act + var vehicle1 = VehicleGenerator.Generate(42); + var vehicle2 = VehicleGenerator.Generate(42); + + // Assert + Assert.Equal(vehicle1.Id, vehicle2.Id); + Assert.Equal(vehicle1.Vin, vehicle2.Vin); + Assert.Equal(vehicle1.Manufacturer, vehicle2.Manufacturer); + Assert.Equal(vehicle1.Model, vehicle2.Model); + Assert.Equal(vehicle1.Year, vehicle2.Year); + Assert.Equal(vehicle1.BodyType, vehicle2.BodyType); + Assert.Equal(vehicle1.FuelType, vehicle2.FuelType); + Assert.Equal(vehicle1.Color, vehicle2.Color); + Assert.Equal(vehicle1.Mileage, vehicle2.Mileage); + Assert.Equal(vehicle1.LastServiceDate, vehicle2.LastServiceDate); + } + + [Theory] + [InlineData(1, 2)] + [InlineData(10, 20)] + [InlineData(42, 100)] + public void Generate_DifferentIds_ReturnsDifferentData(int id1, int id2) + { + // Arrange & Act + var vehicle1 = VehicleGenerator.Generate(id1); + var vehicle2 = VehicleGenerator.Generate(id2); + + // Assert + Assert.NotEqual(vehicle1.Vin, vehicle2.Vin); + } + + [Fact] + public void Generate_YearConstraint_IsValid() + { + // Arrange & Act + for (int i = 1; i <= 100; i++) + { + var vehicle = VehicleGenerator.Generate(i); + + // Assert + Assert.True(vehicle.Year >= 1990, $"Vehicle {i} has Year {vehicle.Year} which is less than 1990"); + Assert.True(vehicle.Year <= DateTime.Now.Year, $"Vehicle {i} has Year {vehicle.Year} which exceeds current year {DateTime.Now.Year}"); + } + } + + [Fact] + public void Generate_MileageConstraint_IsValid() + { + // Arrange & Act + for (int i = 1; i <= 100; i++) + { + var vehicle = VehicleGenerator.Generate(i); + + // Assert + Assert.True(vehicle.Mileage >= 0, $"Vehicle {i} has Mileage {vehicle.Mileage} which is less than 0"); + Assert.True(vehicle.Mileage <= 500000, $"Vehicle {i} has Mileage {vehicle.Mileage} which exceeds 500,000"); + } + } + + [Fact] + public void Generate_LastServiceDateConstraint_IsValid() + { + // Arrange & Act + for (int i = 1; i <= 100; i++) + { + var vehicle = VehicleGenerator.Generate(i); + + // Assert + Assert.True(vehicle.LastServiceDate.Year >= vehicle.Year, + $"Vehicle {i} has LastServiceDate year {vehicle.LastServiceDate.Year} which is before the vehicle's manufacturing year {vehicle.Year}"); + Assert.True(vehicle.LastServiceDate <= DateOnly.FromDateTime(DateTime.Now), + $"Vehicle {i} has LastServiceDate {vehicle.LastServiceDate} which is in the future"); + } + } +} From 1f95905345ac2dbcc2885599c35284ed33bd885d Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Mon, 16 Mar 2026 17:46:51 +0400 Subject: [PATCH 02/23] clear trash --- src/ServiceDefaults/Extensions.cs | 2 +- src/VehicleApi/Program.cs | 6 ------ src/VehicleApi/Services/VehicleGenerator.cs | 1 - tests/VehicleApi.Tests/VehicleGeneratorTests.cs | 10 ---------- 4 files changed, 1 insertion(+), 18 deletions(-) diff --git a/src/ServiceDefaults/Extensions.cs b/src/ServiceDefaults/Extensions.cs index f66d336a..4baacde9 100644 --- a/src/ServiceDefaults/Extensions.cs +++ b/src/ServiceDefaults/Extensions.cs @@ -78,4 +78,4 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) return app; } -} \ No newline at end of file +} diff --git a/src/VehicleApi/Program.cs b/src/VehicleApi/Program.cs index 19fc7f24..6ab80001 100644 --- a/src/VehicleApi/Program.cs +++ b/src/VehicleApi/Program.cs @@ -5,13 +5,10 @@ var builder = WebApplication.CreateBuilder(args); -// Add ServiceDefaults (OpenTelemetry, health checks, service discovery) builder.AddServiceDefaults(); -// Add Redis distributed caching builder.AddRedisDistributedCache("cache"); -// Add CORS for Blazor client builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => @@ -24,13 +21,10 @@ var app = builder.Build(); -// Enable CORS app.UseCors(); -// Map health checks app.MapDefaultEndpoints(); -// API endpoint for vehicle data app.MapGet("/api/vehicles", async (int id, IDistributedCache cache, ILogger logger) => { if (id <= 0) diff --git a/src/VehicleApi/Services/VehicleGenerator.cs b/src/VehicleApi/Services/VehicleGenerator.cs index af3bd41c..1847691c 100644 --- a/src/VehicleApi/Services/VehicleGenerator.cs +++ b/src/VehicleApi/Services/VehicleGenerator.cs @@ -7,7 +7,6 @@ public static class VehicleGenerator { public static Vehicle Generate(int id) { - // Seed per-instance (NOT global Randomizer.Seed) var faker = new Faker() .UseSeed(id) .RuleFor(v => v.Id, id) diff --git a/tests/VehicleApi.Tests/VehicleGeneratorTests.cs b/tests/VehicleApi.Tests/VehicleGeneratorTests.cs index b52531da..7e664bc0 100644 --- a/tests/VehicleApi.Tests/VehicleGeneratorTests.cs +++ b/tests/VehicleApi.Tests/VehicleGeneratorTests.cs @@ -11,11 +11,9 @@ public class VehicleGeneratorTests [Fact] public void Generate_SameId_ReturnsSameData() { - // Arrange & Act var vehicle1 = VehicleGenerator.Generate(42); var vehicle2 = VehicleGenerator.Generate(42); - // Assert Assert.Equal(vehicle1.Id, vehicle2.Id); Assert.Equal(vehicle1.Vin, vehicle2.Vin); Assert.Equal(vehicle1.Manufacturer, vehicle2.Manufacturer); @@ -34,23 +32,19 @@ public void Generate_SameId_ReturnsSameData() [InlineData(42, 100)] public void Generate_DifferentIds_ReturnsDifferentData(int id1, int id2) { - // Arrange & Act var vehicle1 = VehicleGenerator.Generate(id1); var vehicle2 = VehicleGenerator.Generate(id2); - // Assert Assert.NotEqual(vehicle1.Vin, vehicle2.Vin); } [Fact] public void Generate_YearConstraint_IsValid() { - // Arrange & Act for (int i = 1; i <= 100; i++) { var vehicle = VehicleGenerator.Generate(i); - // Assert Assert.True(vehicle.Year >= 1990, $"Vehicle {i} has Year {vehicle.Year} which is less than 1990"); Assert.True(vehicle.Year <= DateTime.Now.Year, $"Vehicle {i} has Year {vehicle.Year} which exceeds current year {DateTime.Now.Year}"); } @@ -59,12 +53,10 @@ public void Generate_YearConstraint_IsValid() [Fact] public void Generate_MileageConstraint_IsValid() { - // Arrange & Act for (int i = 1; i <= 100; i++) { var vehicle = VehicleGenerator.Generate(i); - // Assert Assert.True(vehicle.Mileage >= 0, $"Vehicle {i} has Mileage {vehicle.Mileage} which is less than 0"); Assert.True(vehicle.Mileage <= 500000, $"Vehicle {i} has Mileage {vehicle.Mileage} which exceeds 500,000"); } @@ -73,12 +65,10 @@ public void Generate_MileageConstraint_IsValid() [Fact] public void Generate_LastServiceDateConstraint_IsValid() { - // Arrange & Act for (int i = 1; i <= 100; i++) { var vehicle = VehicleGenerator.Generate(i); - // Assert Assert.True(vehicle.LastServiceDate.Year >= vehicle.Year, $"Vehicle {i} has LastServiceDate year {vehicle.LastServiceDate.Year} which is before the vehicle's manufacturing year {vehicle.Year}"); Assert.True(vehicle.LastServiceDate <= DateOnly.FromDateTime(DateTime.Now), From 96ebef3c09b38750f1f4f38ddccbb13415a4e97f Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Tue, 17 Mar 2026 17:47:36 +0400 Subject: [PATCH 03/23] studentcard --- Client.Wasm/Components/StudentCard.razor | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..2c0c813c 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №1 "Кэширование" + Вариант №17 "Транспортное средство" + Выполнена Чукарев Михаил 6511 + Ссылка на форк From c963c5867cdf53663d564cd1b73e3f1f16b12616 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Tue, 17 Mar 2026 17:51:08 +0400 Subject: [PATCH 04/23] add summary --- src/VehicleApi/Models/Vehicle.cs | 36 +++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/VehicleApi/Models/Vehicle.cs b/src/VehicleApi/Models/Vehicle.cs index ef65cdd1..5b9602c7 100644 --- a/src/VehicleApi/Models/Vehicle.cs +++ b/src/VehicleApi/Models/Vehicle.cs @@ -1,15 +1,37 @@ namespace VehicleApi.Models; +/// +/// Представляет сущность транспортного средства. +/// public record Vehicle { + /// Уникальный идентификатор транспортного средства. public int Id { get; init; } - public string Vin { get; init; } = string.Empty; - public string Manufacturer { get; init; } = string.Empty; - public string Model { get; init; } = string.Empty; + + /// Идентификационный номер транспортного средства (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 string BodyType { get; init; } = string.Empty; - public string FuelType { get; init; } = string.Empty; - public string Color { get; init; } = string.Empty; + + /// Тип кузова транспортного средства. + public required string BodyType { get; init; } + + /// Тип топлива транспортного средства. + public required string FuelType { get; init; } + + /// Цвет транспортного средства. + public required string Color { get; init; } + + /// Пробег транспортного средства. public double Mileage { get; init; } + + /// Дата последнего технического обслуживания. public DateOnly LastServiceDate { get; init; } -} +} \ No newline at end of file From 52acc9a1a7cad3ce44db12c6cf1cfd9545b3f9e9 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Tue, 17 Mar 2026 17:59:53 +0400 Subject: [PATCH 05/23] fix --- src/VehicleApi/Program.cs | 6 +++- src/VehicleApi/Services/VehicleGenerator.cs | 32 ++++++++++----------- src/VehicleApi/appsettings.json | 7 +++++ 3 files changed, 28 insertions(+), 17 deletions(-) create mode 100644 src/VehicleApi/appsettings.json diff --git a/src/VehicleApi/Program.cs b/src/VehicleApi/Program.cs index 6ab80001..ed28fa26 100644 --- a/src/VehicleApi/Program.cs +++ b/src/VehicleApi/Program.cs @@ -13,7 +13,11 @@ { options.AddDefaultPolicy(policy => { - policy.AllowAnyOrigin() + var allowedOrigins = builder.Configuration + .GetSection("Cors:AllowedOrigins") + .Get() ?? []; + + policy.WithOrigins(allowedOrigins) .AllowAnyMethod() .AllowAnyHeader(); }); diff --git a/src/VehicleApi/Services/VehicleGenerator.cs b/src/VehicleApi/Services/VehicleGenerator.cs index 1847691c..2f7145fb 100644 --- a/src/VehicleApi/Services/VehicleGenerator.cs +++ b/src/VehicleApi/Services/VehicleGenerator.cs @@ -5,22 +5,22 @@ namespace VehicleApi.Services; public static class VehicleGenerator { + private static readonly Faker _faker = new Faker() + .RuleFor(v => v.Id, f => f.IndexFaker) + .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.Random.Int(1990, DateTime.Now.Year)) + .RuleFor(v => v.BodyType, f => f.Vehicle.Type()) + .RuleFor(v => v.FuelType, f => f.Vehicle.Fuel()) + .RuleFor(v => v.Color, f => f.Commerce.Color()) + .RuleFor(v => v.Mileage, f => f.Random.Double(0, 500000)) + .RuleFor(v => v.LastServiceDate, (f, v) => + DateOnly.FromDateTime(f.Date.Between(new DateTime(v.Year, 1, 1), DateTime.Now))); + public static Vehicle Generate(int id) { - var faker = new Faker() - .UseSeed(id) - .RuleFor(v => v.Id, id) - .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.Random.Int(1990, DateTime.Now.Year)) - .RuleFor(v => v.BodyType, f => f.Vehicle.Type()) - .RuleFor(v => v.FuelType, f => f.Vehicle.Fuel()) - .RuleFor(v => v.Color, f => f.Commerce.Color()) - .RuleFor(v => v.Mileage, f => f.Random.Double(0, 500000)) - .RuleFor(v => v.LastServiceDate, (f, v) => - DateOnly.FromDateTime(f.Date.Between(new DateTime(v.Year, 1, 1), DateTime.Now))); - - return faker.Generate(); + _faker.UseSeed(id); + return _faker.Generate(); } -} +} \ No newline at end of file diff --git a/src/VehicleApi/appsettings.json b/src/VehicleApi/appsettings.json new file mode 100644 index 00000000..d8a8c9a5 --- /dev/null +++ b/src/VehicleApi/appsettings.json @@ -0,0 +1,7 @@ +{ + "Cors": { + "AllowedOrigins": [ + "http://localhost:5000" + ] + } +} \ No newline at end of file From 695e95386e5e18c036dc5e17f93988dd947e9454 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Tue, 17 Mar 2026 18:10:23 +0400 Subject: [PATCH 06/23] mega fix --- src/AppHost/AppHost.csproj | 8 +++--- src/AppHost/Program.cs | 9 ++++-- src/VehicleApi/Program.cs | 27 ++---------------- src/VehicleApi/Services/VehicleService.cs | 34 +++++++++++++++++++++++ 4 files changed, 47 insertions(+), 31 deletions(-) create mode 100644 src/VehicleApi/Services/VehicleService.cs diff --git a/src/AppHost/AppHost.csproj b/src/AppHost/AppHost.csproj index 20004976..71667657 100644 --- a/src/AppHost/AppHost.csproj +++ b/src/AppHost/AppHost.csproj @@ -1,16 +1,16 @@ - + Exe - net9.0 + net8.0 enable enable true - - + + diff --git a/src/AppHost/Program.cs b/src/AppHost/Program.cs index b5d037cc..1bf22dcd 100644 --- a/src/AppHost/Program.cs +++ b/src/AppHost/Program.cs @@ -1,11 +1,14 @@ var builder = DistributedApplication.CreateBuilder(args); -var cache = builder.AddRedis("cache"); +var cache = builder.AddRedis("cache") + .WithRedisInsight(); var api = builder.AddProject("vehicleapi") - .WithReference(cache); + .WithReference(cache) + .WaitFor(cache); var client = builder.AddProject("client") - .WithReference(api); + .WithReference(api) + .WaitFor(api); builder.Build().Run(); diff --git a/src/VehicleApi/Program.cs b/src/VehicleApi/Program.cs index ed28fa26..ce9f22f6 100644 --- a/src/VehicleApi/Program.cs +++ b/src/VehicleApi/Program.cs @@ -29,7 +29,7 @@ app.MapDefaultEndpoints(); -app.MapGet("/api/vehicles", async (int id, IDistributedCache cache, ILogger logger) => +app.MapGet("/api/vehicles", async (int id, VehicleService vehicleService, ILogger logger) => { if (id <= 0) { @@ -37,29 +37,8 @@ return Results.BadRequest("ID must be greater than 0"); } - var cacheKey = $"vehicle:{id}"; - var cachedData = await cache.GetAsync(cacheKey); - - if (cachedData != null) - { - logger.LogInformation("Cache hit for vehicle ID {Id}", id); - var vehicle = JsonSerializer.Deserialize(cachedData); - logger.LogInformation("Returning cached vehicle: {@Vehicle}", vehicle); - return Results.Ok(vehicle); - } - - logger.LogInformation("Cache miss for vehicle ID {Id}", id); - var generated = VehicleGenerator.Generate(id); - logger.LogInformation("Generated new vehicle: {@Vehicle}", generated); - - var serialized = JsonSerializer.SerializeToUtf8Bytes(generated); - await cache.SetAsync(cacheKey, serialized, new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) - }); - logger.LogInformation("Vehicle {Id} cached for 10 minutes", id); - - return Results.Ok(generated); + var vehicle = await vehicleService.GetByIdAsync(id); + return Results.Ok(vehicle); }); app.Run(); diff --git a/src/VehicleApi/Services/VehicleService.cs b/src/VehicleApi/Services/VehicleService.cs new file mode 100644 index 00000000..adada996 --- /dev/null +++ b/src/VehicleApi/Services/VehicleService.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; +using VehicleApi.Models; + +namespace VehicleApi.Services; + +public class VehicleService(IDistributedCache cache, ILogger logger) +{ + private static readonly DistributedCacheEntryOptions CacheOptions = new() + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) + }; + + public async Task GetByIdAsync(int id) + { + var cacheKey = $"vehicle:{id}"; + var cachedData = await cache.GetAsync(cacheKey); + + if (cachedData != null) + { + logger.LogInformation("Cache hit for vehicle ID {Id}", id); + return JsonSerializer.Deserialize(cachedData)!; + } + + logger.LogInformation("Cache miss for vehicle ID {Id}", id); + var vehicle = VehicleGenerator.Generate(id); + + var serialized = JsonSerializer.SerializeToUtf8Bytes(vehicle); + await cache.SetAsync(cacheKey, serialized, CacheOptions); + logger.LogInformation("Vehicle {Id} cached for 10 minutes", id); + + return vehicle; + } +} From 0615dd3000c250ada050d18be6d114008c57317a Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Tue, 17 Mar 2026 18:18:30 +0400 Subject: [PATCH 07/23] add readmy --- CloudDevelopment.sln | 63 +++++++++ README.md | 159 ++++++++-------------- src/VehicleApi/Services/VehicleService.cs | 4 +- 3 files changed, 123 insertions(+), 103 deletions(-) diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..66242c3e 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -5,20 +5,83 @@ 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("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceDefaults", "src\ServiceDefaults\ServiceDefaults.csproj", "{207C1A03-203E-4804-854F-0B70D3B0226F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VehicleApi", "src\VehicleApi\VehicleApi.csproj", "{8C4738CB-0C2F-4664-9F35-62F27C0E5877}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VehicleApi.Tests", "tests\VehicleApi.Tests\VehicleApi.Tests.csproj", "{E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}" +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 + {207C1A03-203E-4804-854F-0B70D3B0226F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {207C1A03-203E-4804-854F-0B70D3B0226F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {207C1A03-203E-4804-854F-0B70D3B0226F}.Debug|x64.ActiveCfg = Debug|Any CPU + {207C1A03-203E-4804-854F-0B70D3B0226F}.Debug|x64.Build.0 = Debug|Any CPU + {207C1A03-203E-4804-854F-0B70D3B0226F}.Debug|x86.ActiveCfg = Debug|Any CPU + {207C1A03-203E-4804-854F-0B70D3B0226F}.Debug|x86.Build.0 = Debug|Any CPU + {207C1A03-203E-4804-854F-0B70D3B0226F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {207C1A03-203E-4804-854F-0B70D3B0226F}.Release|Any CPU.Build.0 = Release|Any CPU + {207C1A03-203E-4804-854F-0B70D3B0226F}.Release|x64.ActiveCfg = Release|Any CPU + {207C1A03-203E-4804-854F-0B70D3B0226F}.Release|x64.Build.0 = Release|Any CPU + {207C1A03-203E-4804-854F-0B70D3B0226F}.Release|x86.ActiveCfg = Release|Any CPU + {207C1A03-203E-4804-854F-0B70D3B0226F}.Release|x86.Build.0 = Release|Any CPU + {8C4738CB-0C2F-4664-9F35-62F27C0E5877}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C4738CB-0C2F-4664-9F35-62F27C0E5877}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C4738CB-0C2F-4664-9F35-62F27C0E5877}.Debug|x64.ActiveCfg = Debug|Any CPU + {8C4738CB-0C2F-4664-9F35-62F27C0E5877}.Debug|x64.Build.0 = Debug|Any CPU + {8C4738CB-0C2F-4664-9F35-62F27C0E5877}.Debug|x86.ActiveCfg = Debug|Any CPU + {8C4738CB-0C2F-4664-9F35-62F27C0E5877}.Debug|x86.Build.0 = Debug|Any CPU + {8C4738CB-0C2F-4664-9F35-62F27C0E5877}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C4738CB-0C2F-4664-9F35-62F27C0E5877}.Release|Any CPU.Build.0 = Release|Any CPU + {8C4738CB-0C2F-4664-9F35-62F27C0E5877}.Release|x64.ActiveCfg = Release|Any CPU + {8C4738CB-0C2F-4664-9F35-62F27C0E5877}.Release|x64.Build.0 = Release|Any CPU + {8C4738CB-0C2F-4664-9F35-62F27C0E5877}.Release|x86.ActiveCfg = Release|Any CPU + {8C4738CB-0C2F-4664-9F35-62F27C0E5877}.Release|x86.Build.0 = Release|Any CPU + {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Debug|x64.ActiveCfg = Debug|Any CPU + {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Debug|x64.Build.0 = Debug|Any CPU + {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Debug|x86.ActiveCfg = Debug|Any CPU + {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Debug|x86.Build.0 = Debug|Any CPU + {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Release|Any CPU.Build.0 = Release|Any CPU + {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Release|x64.ActiveCfg = Release|Any CPU + {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Release|x64.Build.0 = Release|Any CPU + {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Release|x86.ActiveCfg = Release|Any CPU + {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {207C1A03-203E-4804-854F-0B70D3B0226F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {8C4738CB-0C2F-4664-9F35-62F27C0E5877} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {90FE6B04-8381-437E-893A-FEBA1DA10AEE} EndGlobalSection diff --git a/README.md b/README.md index dcaa5eb7..4cf16231 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,85 @@ # Современные технологии разработки программного обеспечения -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing) -## Задание -### Цель -Реализация проекта микросервисного бекенда. -### Задачи -* Реализация межсервисной коммуникации, -* Изучение работы с брокерами сообщений, -* Изучение архитектурных паттернов, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Интеграционное тестирование. +## Лабораторная работа №1 — «Кэширование» + +**Вариант №17 — «Транспортное средство»** +**Выполнил:** Чукарев Михаил, группа 6511 + +### Описание + +Реализован сервис генерации данных о транспортных средствах с кэшированием ответов в Redis и оркестрацией через .NET Aspire. + +### Что реализовано -### Лабораторные работы -
-1. «Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов -
- -В рамках первой лабораторной работы необходимо: -* Реализовать сервис генерации контрактов на основе Bogus, -* Реализовать кеширование при помощи IDistributedCache и Redis, -* Реализовать структурное логирование сервиса генерации, -* Настроить оркестрацию Aspire. - -
-
-2. «Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы -
- -В рамках второй лабораторной работы необходимо: -* Настроить оркестрацию на запуск нескольких реплик сервиса генерации, -* Реализовать апи гейтвей на основе Ocelot, -* Имплементировать алгоритм балансировки нагрузки согласно варианту. - -
-
-3. «Интеграционное тестирование» - Реализация файлового сервиса и объектного хранилища, интеграционное тестирование бекенда -
+Генерация данных (Bogus) +
-В рамках третьей лабораторной работы необходимо: -* Добавить в оркестрацию объектное хранилище, -* Реализовать файловый сервис, сериализующий сгенерированные данные в файлы и сохраняющий их в объектном хранилище, -* Реализовать отправку генерируемых данных в файловый сервис посредством брокера, -* Реализовать интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе. +- Класс `VehicleGenerator` с `RuleFor` для каждого поля +- Поля: VIN, производитель, модель, год выпуска, тип кузова, тип топлива, цвет, пробег, дата последнего ТО -
+
+
-4. (Опционально) «Переход на облачную инфраструктуру» - Перенос бекенда в Yandex Cloud -
- -В рамках четвертой лабораторной работы необходимо перенестиервисы на облако все ранее разработанные сервисы: -* Клиент - в хостинг через отдельный бакет Object Storage, -* Сервис генерации - в Cloud Function, -* Апи гейтвей - в Serverless Integration как API Gateway, -* Брокер сообщений - в Message Queue, -* Файловый сервис - в Cloud Function, -* Объектное хранилище - в отдельный бакет Object Storage, - -
+Кэширование (Redis + IDistributedCache) +
+ +- Сервис `VehicleService` инкапсулирует логику работы с кэшем + +
-## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.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 +- Логирование через `ILogger` -Внимательно прочитайте [дискуссии](https://github.com/itsecd/cloud-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. +
+
-По итогу работы в семестре должна получиться следующая информационная система:
-C4 диаграмма -Современные_технологии_разработки_ПО_drawio -
+CORS +
-## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. +- Доверенные origins вынесены в `appsettings.json` (`Cors:AllowedOrigins`) +- `AllowAnyMethod`, `AllowAnyHeader` -[Список вариантов](https://docs.google.com/document/d/1WGmLYwffTTaAj4TgFCk5bUyW3XKbFMiBm-DHZrfFWr4/edit?usp=sharing) -[Список предметных областей и алгоритмов балансировки](https://docs.google.com/document/d/1PLn2lKe4swIdJDZhwBYzxqFSu0AbY2MFY1SUPkIKOM4/edit?usp=sharing) +
+ -## Схема сдачи +
+Оркестрация (.NET Aspire) +
-На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). +- Redis с RedisInsight +- API сервис ждёт Redis (`WaitFor(cache)`) +- Клиент WASM ждёт API (`WaitFor(api)`) -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve +
+
-## Критерии оценивания +
+API +
-Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания +- Единственный эндпоинт: `GET /api/vehicles?id={id}` -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. -Не укладываетесь в дедлайн - получаете минимально возможный балл. +
+
-### Шкала оценивания +
+Тесты (xUnit) +
-- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл - -## Вопросы и обратная связь по курсу +- `Generate_SameId_ReturnsSameData` — детерминированность генерации +- `Generate_DifferentIds_ReturnsDifferentData` — уникальность по `id` +- `Generate_YearConstraint_IsValid` — год в диапазоне [1990, текущий] +- `Generate_MileageConstraint_IsValid` — пробег в диапазоне [0, 500 000] +- `Generate_LastServiceDateConstraint_IsValid` — дата ТО не раньше года выпуска и не в будущем -Чтобы задать вопрос по лабораторной, воспользуйтесь [соответствующим разделом дискуссий](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). +
+
diff --git a/src/VehicleApi/Services/VehicleService.cs b/src/VehicleApi/Services/VehicleService.cs index adada996..1af3f18e 100644 --- a/src/VehicleApi/Services/VehicleService.cs +++ b/src/VehicleApi/Services/VehicleService.cs @@ -6,7 +6,7 @@ namespace VehicleApi.Services; public class VehicleService(IDistributedCache cache, ILogger logger) { - private static readonly DistributedCacheEntryOptions CacheOptions = new() + private static readonly DistributedCacheEntryOptions _cacheOptions = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }; @@ -26,7 +26,7 @@ public async Task GetByIdAsync(int id) var vehicle = VehicleGenerator.Generate(id); var serialized = JsonSerializer.SerializeToUtf8Bytes(vehicle); - await cache.SetAsync(cacheKey, serialized, CacheOptions); + await cache.SetAsync(cacheKey, serialized, _cacheOptions); logger.LogInformation("Vehicle {Id} cached for 10 minutes", id); return vehicle; From 38fcfcc9b93cf6ef9152c904fa19ebb64876d2da Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Tue, 17 Mar 2026 18:54:20 +0400 Subject: [PATCH 08/23] super mega fix --- global.json | 5 +++++ src/AppHost/AppHost.csproj | 6 +++--- src/VehicleApi/Program.cs | 5 ++++- src/VehicleApi/VehicleApi.csproj | 2 +- src/VehicleApi/appsettings.json | 3 ++- 5 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 global.json diff --git a/global.json b/global.json new file mode 100644 index 00000000..bfca90d3 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "sdk": { + "version": "8.0.400" + } +} \ No newline at end of file diff --git a/src/AppHost/AppHost.csproj b/src/AppHost/AppHost.csproj index 71667657..32cb9e10 100644 --- a/src/AppHost/AppHost.csproj +++ b/src/AppHost/AppHost.csproj @@ -1,5 +1,5 @@ - + Exe net8.0 @@ -9,8 +9,8 @@ - - + + diff --git a/src/VehicleApi/Program.cs b/src/VehicleApi/Program.cs index ce9f22f6..12553fcb 100644 --- a/src/VehicleApi/Program.cs +++ b/src/VehicleApi/Program.cs @@ -2,11 +2,14 @@ using Microsoft.Extensions.Caching.Distributed; using VehicleApi.Models; using VehicleApi.Services; +using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); +builder.Services.AddScoped(); + builder.AddRedisDistributedCache("cache"); builder.Services.AddCors(options => @@ -29,7 +32,7 @@ app.MapDefaultEndpoints(); -app.MapGet("/api/vehicles", async (int id, VehicleService vehicleService, ILogger logger) => +app.MapGet("/api/vehicles", async (int id, [FromServices] VehicleService vehicleService, ILogger logger) => { if (id <= 0) { diff --git a/src/VehicleApi/VehicleApi.csproj b/src/VehicleApi/VehicleApi.csproj index 09abff65..b1afe2f4 100644 --- a/src/VehicleApi/VehicleApi.csproj +++ b/src/VehicleApi/VehicleApi.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/VehicleApi/appsettings.json b/src/VehicleApi/appsettings.json index d8a8c9a5..b58f62a1 100644 --- a/src/VehicleApi/appsettings.json +++ b/src/VehicleApi/appsettings.json @@ -1,7 +1,8 @@ { "Cors": { "AllowedOrigins": [ - "http://localhost:5000" + "https://localhost:7282", + "http://localhost:5127" ] } } \ No newline at end of file From d57dbafdaa5fe75b7b5ca2914ed8c2b6694407f4 Mon Sep 17 00:00:00 2001 From: Mishachuu <113331162+Mishachuu@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:55:25 +0400 Subject: [PATCH 09/23] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4cf16231..d4cd8209 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,9 @@ Оркестрация (.NET Aspire)
-- Redis с RedisInsight -- API сервис ждёт Redis (`WaitFor(cache)`) -- Клиент WASM ждёт API (`WaitFor(api)`) +- Redis +- API сервис +- Клиент WASM
From 25fd6537dcc8940d25b3837fda853ce434b3eb39 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Wed, 18 Mar 2026 19:44:58 +0400 Subject: [PATCH 10/23] lab2 --- Client.Wasm/wwwroot/appsettings.json | 2 +- CloudDevelopment.sln | 15 +++++ src/ApiGateway/ApiGateway.csproj | 23 +++++++ .../WeightedRandomLoadBalancer.cs | 55 +++++++++++++++++ src/ApiGateway/Program.cs | 61 +++++++++++++++++++ src/ApiGateway/Properties/launchSettings.json | 11 ++++ src/ApiGateway/appsettings.json | 21 +++++++ src/ApiGateway/ocelot.json | 22 +++++++ src/AppHost/AppHost.csproj | 1 + src/AppHost/Program.cs | 42 +++++++++++-- src/VehicleApi/Properties/launchSettings.json | 25 +++++--- 11 files changed, 264 insertions(+), 14 deletions(-) create mode 100644 src/ApiGateway/ApiGateway.csproj create mode 100644 src/ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs create mode 100644 src/ApiGateway/Program.cs create mode 100644 src/ApiGateway/Properties/launchSettings.json create mode 100644 src/ApiGateway/appsettings.json create mode 100644 src/ApiGateway/ocelot.json diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 87e4c8ee..6a94713f 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,6 +6,6 @@ } }, "AllowedHosts": "*", - "BaseAddress": "https://localhost:5001/api/vehicles" + "BaseAddress": "http://localhost:5200/api/vehicles" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index 66242c3e..7d21cf0a 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VehicleApi.Tests", "tests\VehicleApi.Tests\VehicleApi.Tests.csproj", "{E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiGateway", "src\ApiGateway\ApiGateway.csproj", "{42CE0FED-8889-49C7-988A-E8E12D59DCA2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -73,6 +75,18 @@ Global {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Release|x64.Build.0 = Release|Any CPU {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Release|x86.ActiveCfg = Release|Any CPU {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Release|x86.Build.0 = Release|Any CPU + {42CE0FED-8889-49C7-988A-E8E12D59DCA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {42CE0FED-8889-49C7-988A-E8E12D59DCA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42CE0FED-8889-49C7-988A-E8E12D59DCA2}.Debug|x64.ActiveCfg = Debug|Any CPU + {42CE0FED-8889-49C7-988A-E8E12D59DCA2}.Debug|x64.Build.0 = Debug|Any CPU + {42CE0FED-8889-49C7-988A-E8E12D59DCA2}.Debug|x86.ActiveCfg = Debug|Any CPU + {42CE0FED-8889-49C7-988A-E8E12D59DCA2}.Debug|x86.Build.0 = Debug|Any CPU + {42CE0FED-8889-49C7-988A-E8E12D59DCA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {42CE0FED-8889-49C7-988A-E8E12D59DCA2}.Release|Any CPU.Build.0 = Release|Any CPU + {42CE0FED-8889-49C7-988A-E8E12D59DCA2}.Release|x64.ActiveCfg = Release|Any CPU + {42CE0FED-8889-49C7-988A-E8E12D59DCA2}.Release|x64.Build.0 = Release|Any CPU + {42CE0FED-8889-49C7-988A-E8E12D59DCA2}.Release|x86.ActiveCfg = Release|Any CPU + {42CE0FED-8889-49C7-988A-E8E12D59DCA2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -81,6 +95,7 @@ Global {207C1A03-203E-4804-854F-0B70D3B0226F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {8C4738CB-0C2F-4664-9F35-62F27C0E5877} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {42CE0FED-8889-49C7-988A-E8E12D59DCA2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {90FE6B04-8381-437E-893A-FEBA1DA10AEE} diff --git a/src/ApiGateway/ApiGateway.csproj b/src/ApiGateway/ApiGateway.csproj new file mode 100644 index 00000000..0378f393 --- /dev/null +++ b/src/ApiGateway/ApiGateway.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + Always + + + + diff --git a/src/ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs b/src/ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs new file mode 100644 index 00000000..9a2d3d1a --- /dev/null +++ b/src/ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs @@ -0,0 +1,55 @@ +using Ocelot.LoadBalancer.LoadBalancers; +using Ocelot.Responses; +using Ocelot.Values; + +namespace ApiGateway.LoadBalancing; + +public sealed class WeightedRandomLoadBalancer : ILoadBalancer +{ + public string Type => "WeightedRandom"; + + private readonly IReadOnlyList<(ServiceHostAndPort Host, double CumulativeWeight)> _entries; + + public WeightedRandomLoadBalancer( + IReadOnlyList hosts, + IReadOnlyList weights) + { + if (hosts.Count == 0) + throw new ArgumentException("Список хостов не может быть пустым.", nameof(hosts)); + + if (hosts.Count != weights.Count) + throw new ArgumentException( + $"Число хостов ({hosts.Count}) должно совпадать с числом весов ({weights.Count})."); + + var sum = weights.Sum(); + if (Math.Abs(sum - 1.0) > 1e-9) + throw new ArgumentException( + $"Сумма весов должна равняться 1. Текущая сумма: {sum:F6}"); + + var cumulative = 0.0; + var entries = new List<(ServiceHostAndPort, double)>(hosts.Count); + for (var i = 0; i < hosts.Count; i++) + { + cumulative += weights[i]; + entries.Add((hosts[i], cumulative)); + } + _entries = entries; + } + + public Task> LeaseAsync(HttpContext httpContext) + { + var roll = Random.Shared.NextDouble(); + + foreach (var (host, cumulativeWeight) in _entries) + { + if (roll < cumulativeWeight) + return Task.FromResult>( + new OkResponse(host)); + } + + return Task.FromResult>( + new OkResponse(_entries[^1].Host)); + } + + public void Release(ServiceHostAndPort hostAndPort) { } +} \ No newline at end of file diff --git a/src/ApiGateway/Program.cs b/src/ApiGateway/Program.cs new file mode 100644 index 00000000..e64dc59c --- /dev/null +++ b/src/ApiGateway/Program.cs @@ -0,0 +1,61 @@ +using ApiGateway.LoadBalancing; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using Ocelot.Values; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + var origins = builder.Configuration + .GetSection("Cors:AllowedOrigins") + .Get() ?? []; + + policy.WithOrigins(origins) + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +var weights = new[] { 0.5, 0.3, 0.2 }; + +var replicaKeys = new[] +{ + "services__vehicleapi-1__http__0", + "services__vehicleapi-2__http__0", + "services__vehicleapi-3__http__0", +}; + +var replicaHosts = replicaKeys + .Select(k => builder.Configuration[k]) + .Where(v => v != null) + .Select(v => new Uri(v!)) + .Select(u => new ServiceHostAndPort(u.Host, u.Port)) + .ToList(); + +foreach (var (h, i) in replicaHosts.Select((h, i) => (h, i))) + Console.WriteLine($"[Gateway] Replica {i + 1}: {h.DownstreamHost}:{h.DownstreamPort}"); + +builder.Services + .AddOcelot(builder.Configuration) + .AddCustomLoadBalancer((route, discoveryProvider) => + new WeightedRandomLoadBalancer( + replicaHosts.Count > 0 ? replicaHosts : route.DownstreamAddresses + .Select(a => new ServiceHostAndPort(a.Host, a.Port)) + .ToList(), + weights)); + +var app = builder.Build(); + +app.UseCors(); +app.MapDefaultEndpoints(); + +await app.UseOcelot(); + +app.Run(); \ No newline at end of file diff --git a/src/ApiGateway/Properties/launchSettings.json b/src/ApiGateway/Properties/launchSettings.json new file mode 100644 index 00000000..649f66de --- /dev/null +++ b/src/ApiGateway/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "ApiGateway": { + "commandName": "Project", + "applicationUrl": "http://localhost:5200", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/ApiGateway/appsettings.json b/src/ApiGateway/appsettings.json new file mode 100644 index 00000000..190e4acd --- /dev/null +++ b/src/ApiGateway/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Ocelot": "Information" + } + }, + "AllowedHosts": "*", + "Cors": { + "AllowedOrigins": [ + "https://localhost:7282", + "http://localhost:5127" + ] + }, + "WeightedRandom": { + "vehicles": { + "Weights": [ 0.5, 0.3, 0.2 ] + } + } +} diff --git a/src/ApiGateway/ocelot.json b/src/ApiGateway/ocelot.json new file mode 100644 index 00000000..d7f668cb --- /dev/null +++ b/src/ApiGateway/ocelot.json @@ -0,0 +1,22 @@ +{ + "Routes": [ + { + "UpstreamPathTemplate": "/api/vehicles", + "UpstreamHttpMethod": [ "GET" ], + "DownstreamPathTemplate": "/api/vehicles", + "DownstreamScheme": "http", + "DownstreamHostsAndPorts": [ + { "Host": "localhost", "Port": 5101 }, + { "Host": "localhost", "Port": 5102 }, + { "Host": "localhost", "Port": 5103 } + ], + "LoadBalancerOptions": { + "Type": "WeightedRandom", + "Key": "vehicles" + } + } + ], + "GlobalConfiguration": { + "BaseUrl": "http://localhost:5200" + } +} \ No newline at end of file diff --git a/src/AppHost/AppHost.csproj b/src/AppHost/AppHost.csproj index 32cb9e10..2ed1625e 100644 --- a/src/AppHost/AppHost.csproj +++ b/src/AppHost/AppHost.csproj @@ -15,6 +15,7 @@ + diff --git a/src/AppHost/Program.cs b/src/AppHost/Program.cs index 1bf22dcd..a81a5ff2 100644 --- a/src/AppHost/Program.cs +++ b/src/AppHost/Program.cs @@ -1,14 +1,46 @@ var builder = DistributedApplication.CreateBuilder(args); +var weights = builder.Configuration + .GetSection("ReplicaWeights") + .GetChildren() + .Select(x => double.Parse(x.Value!, System.Globalization.CultureInfo.InvariantCulture)) + .ToArray(); + +if (weights.Length == 0) + weights = [0.5, 0.3, 0.2]; + var cache = builder.AddRedis("cache") .WithRedisInsight(); -var api = builder.AddProject("vehicleapi") +var replica1 = builder.AddProject("vehicleapi-1") + .WithReference(cache) + .WaitFor(cache) + .WithEnvironment("ASPNETCORE_URLS", "http://localhost:5101"); + +var replica2 = builder.AddProject("vehicleapi-2") .WithReference(cache) - .WaitFor(cache); + .WaitFor(cache) + .WithEnvironment("ASPNETCORE_URLS", "http://localhost:5102"); + +var replica3 = builder.AddProject("vehicleapi-3") + .WithReference(cache) + .WaitFor(cache) + .WithEnvironment("ASPNETCORE_URLS", "http://localhost:5103"); + +var gateway = builder.AddProject("apigateway") + .WithReference(replica1) + .WithReference(replica2) + .WithReference(replica3) + .WaitFor(replica1) + .WaitFor(replica2) + .WaitFor(replica3) + .WithEnvironment("ASPNETCORE_URLS", "http://localhost:5200") + .WithEnvironment("WeightedRandom__vehicles__Weights__0", weights[0].ToString("F2", System.Globalization.CultureInfo.InvariantCulture)) + .WithEnvironment("WeightedRandom__vehicles__Weights__1", weights[1].ToString("F2", System.Globalization.CultureInfo.InvariantCulture)) + .WithEnvironment("WeightedRandom__vehicles__Weights__2", weights[2].ToString("F2", System.Globalization.CultureInfo.InvariantCulture)); var client = builder.AddProject("client") - .WithReference(api) - .WaitFor(api); + .WithReference(gateway) + .WaitFor(gateway); -builder.Build().Run(); +builder.Build().Run(); \ No newline at end of file diff --git a/src/VehicleApi/Properties/launchSettings.json b/src/VehicleApi/Properties/launchSettings.json index 900ff92f..36b29597 100644 --- a/src/VehicleApi/Properties/launchSettings.json +++ b/src/VehicleApi/Properties/launchSettings.json @@ -1,23 +1,32 @@ { - "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "http": { "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, "applicationUrl": "http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, - "https": { + "replica1": { "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:5001;http://localhost:5000", + "applicationUrl": "http://localhost:5101", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "replica2": { + "commandName": "Project", + "applicationUrl": "http://localhost:5102", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "replica3": { + "commandName": "Project", + "applicationUrl": "http://localhost:5103", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } -} +} \ No newline at end of file From 25edda69a79e7b155a0d5d451240f76e750c0928 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Wed, 18 Mar 2026 20:01:14 +0400 Subject: [PATCH 11/23] fix lab2 --- .../WeightedRandomLoadBalancer.cs | 42 +++------------ src/ApiGateway/Program.cs | 44 ++++------------ src/ApiGateway/Properties/launchSettings.json | 3 +- src/ApiGateway/ocelot.json | 4 +- src/AppHost/Program.cs | 52 ++++++++----------- src/AppHost/appsettings.json | 15 ++++++ 6 files changed, 58 insertions(+), 102 deletions(-) create mode 100644 src/AppHost/appsettings.json diff --git a/src/ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs b/src/ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs index 9a2d3d1a..ad757268 100644 --- a/src/ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs +++ b/src/ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs @@ -4,51 +4,25 @@ namespace ApiGateway.LoadBalancing; -public sealed class WeightedRandomLoadBalancer : ILoadBalancer +public class WeightedRandomLoadBalancer(List services, double[] weights) : ILoadBalancer { - public string Type => "WeightedRandom"; - - private readonly IReadOnlyList<(ServiceHostAndPort Host, double CumulativeWeight)> _entries; - - public WeightedRandomLoadBalancer( - IReadOnlyList hosts, - IReadOnlyList weights) - { - if (hosts.Count == 0) - throw new ArgumentException("Список хостов не может быть пустым.", nameof(hosts)); - - if (hosts.Count != weights.Count) - throw new ArgumentException( - $"Число хостов ({hosts.Count}) должно совпадать с числом весов ({weights.Count})."); - - var sum = weights.Sum(); - if (Math.Abs(sum - 1.0) > 1e-9) - throw new ArgumentException( - $"Сумма весов должна равняться 1. Текущая сумма: {sum:F6}"); - - var cumulative = 0.0; - var entries = new List<(ServiceHostAndPort, double)>(hosts.Count); - for (var i = 0; i < hosts.Count; i++) - { - cumulative += weights[i]; - entries.Add((hosts[i], cumulative)); - } - _entries = entries; - } + public string Type => nameof(WeightedRandomLoadBalancer); public Task> LeaseAsync(HttpContext httpContext) { + var cumulative = 0.0; var roll = Random.Shared.NextDouble(); - foreach (var (host, cumulativeWeight) in _entries) + for (var i = 0; i < services.Count; i++) { - if (roll < cumulativeWeight) + cumulative += weights[i]; + if (roll < cumulative) return Task.FromResult>( - new OkResponse(host)); + new OkResponse(services[i].HostAndPort)); } return Task.FromResult>( - new OkResponse(_entries[^1].Host)); + new OkResponse(services[^1].HostAndPort)); } public void Release(ServiceHostAndPort hostAndPort) { } diff --git a/src/ApiGateway/Program.cs b/src/ApiGateway/Program.cs index e64dc59c..acdb16fd 100644 --- a/src/ApiGateway/Program.cs +++ b/src/ApiGateway/Program.cs @@ -1,7 +1,6 @@ using ApiGateway.LoadBalancing; using Ocelot.DependencyInjection; using Ocelot.Middleware; -using Ocelot.Values; var builder = WebApplication.CreateBuilder(args); @@ -13,48 +12,27 @@ { options.AddDefaultPolicy(policy => { - var origins = builder.Configuration - .GetSection("Cors:AllowedOrigins") - .Get() ?? []; - - policy.WithOrigins(origins) + policy.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader(); }); }); -var weights = new[] { 0.5, 0.3, 0.2 }; +var weights = builder.Configuration + .GetSection("WeightedRandom:Weights") + .Get() ?? [0.5, 0.3, 0.2]; -var replicaKeys = new[] -{ - "services__vehicleapi-1__http__0", - "services__vehicleapi-2__http__0", - "services__vehicleapi-3__http__0", -}; - -var replicaHosts = replicaKeys - .Select(k => builder.Configuration[k]) - .Where(v => v != null) - .Select(v => new Uri(v!)) - .Select(u => new ServiceHostAndPort(u.Host, u.Port)) - .ToList(); - -foreach (var (h, i) in replicaHosts.Select((h, i) => (h, i))) - Console.WriteLine($"[Gateway] Replica {i + 1}: {h.DownstreamHost}:{h.DownstreamPort}"); - -builder.Services - .AddOcelot(builder.Configuration) - .AddCustomLoadBalancer((route, discoveryProvider) => - new WeightedRandomLoadBalancer( - replicaHosts.Count > 0 ? replicaHosts : route.DownstreamAddresses - .Select(a => new ServiceHostAndPort(a.Host, a.Port)) - .ToList(), - weights)); +builder.Services.AddOcelot(builder.Configuration) + .AddCustomLoadBalancer((serviceProvider, route, serviceDiscoveryProvider) => + { + var services = serviceDiscoveryProvider.GetAsync().GetAwaiter().GetResult().ToList(); + return new WeightedRandomLoadBalancer(services, weights); + }); var app = builder.Build(); app.UseCors(); -app.MapDefaultEndpoints(); +app.MapDefaultEndpoints(); await app.UseOcelot(); diff --git a/src/ApiGateway/Properties/launchSettings.json b/src/ApiGateway/Properties/launchSettings.json index 649f66de..c94776a4 100644 --- a/src/ApiGateway/Properties/launchSettings.json +++ b/src/ApiGateway/Properties/launchSettings.json @@ -2,10 +2,9 @@ "profiles": { "ApiGateway": { "commandName": "Project", - "applicationUrl": "http://localhost:5200", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } -} +} \ No newline at end of file diff --git a/src/ApiGateway/ocelot.json b/src/ApiGateway/ocelot.json index d7f668cb..419c673d 100644 --- a/src/ApiGateway/ocelot.json +++ b/src/ApiGateway/ocelot.json @@ -5,13 +5,13 @@ "UpstreamHttpMethod": [ "GET" ], "DownstreamPathTemplate": "/api/vehicles", "DownstreamScheme": "http", - "DownstreamHostsAndPorts": [ + "DownstreamHostAndPorts": [ { "Host": "localhost", "Port": 5101 }, { "Host": "localhost", "Port": 5102 }, { "Host": "localhost", "Port": 5103 } ], "LoadBalancerOptions": { - "Type": "WeightedRandom", + "Type": "WeightedRandomLoadBalancer", "Key": "vehicles" } } diff --git a/src/AppHost/Program.cs b/src/AppHost/Program.cs index a81a5ff2..74b34475 100644 --- a/src/AppHost/Program.cs +++ b/src/AppHost/Program.cs @@ -1,45 +1,35 @@ var builder = DistributedApplication.CreateBuilder(args); -var weights = builder.Configuration - .GetSection("ReplicaWeights") +var ports = builder.Configuration + .GetSection("ApiService:Ports") .GetChildren() - .Select(x => double.Parse(x.Value!, System.Globalization.CultureInfo.InvariantCulture)) - .ToArray(); + .Select(x => int.Parse(x.Value!)) + .ToList(); -if (weights.Length == 0) - weights = [0.5, 0.3, 0.2]; +if (ports.Count == 0) + ports = [5101, 5102, 5103]; + +var gatewayPort = int.TryParse(builder.Configuration["ApiGateway:Port"], out var p) ? p : 5200; var cache = builder.AddRedis("cache") .WithRedisInsight(); -var replica1 = builder.AddProject("vehicleapi-1") - .WithReference(cache) - .WaitFor(cache) - .WithEnvironment("ASPNETCORE_URLS", "http://localhost:5101"); +var gateway = builder.AddProject("apigateway") + .WithHttpEndpoint(port: gatewayPort, name: "gateway-endpoint", isProxied: false) + .WithExternalHttpEndpoints(); -var replica2 = builder.AddProject("vehicleapi-2") - .WithReference(cache) - .WaitFor(cache) - .WithEnvironment("ASPNETCORE_URLS", "http://localhost:5102"); +var serviceId = 1; +foreach (var port in ports) +{ + var replica = builder.AddProject($"vehicleapi-{serviceId++}") + .WithReference(cache) + .WithHttpEndpoint(port: port, name: "api-endpoint", isProxied: false) + .WithExternalHttpEndpoints(); -var replica3 = builder.AddProject("vehicleapi-3") - .WithReference(cache) - .WaitFor(cache) - .WithEnvironment("ASPNETCORE_URLS", "http://localhost:5103"); + gateway.WaitFor(replica); +} -var gateway = builder.AddProject("apigateway") - .WithReference(replica1) - .WithReference(replica2) - .WithReference(replica3) - .WaitFor(replica1) - .WaitFor(replica2) - .WaitFor(replica3) - .WithEnvironment("ASPNETCORE_URLS", "http://localhost:5200") - .WithEnvironment("WeightedRandom__vehicles__Weights__0", weights[0].ToString("F2", System.Globalization.CultureInfo.InvariantCulture)) - .WithEnvironment("WeightedRandom__vehicles__Weights__1", weights[1].ToString("F2", System.Globalization.CultureInfo.InvariantCulture)) - .WithEnvironment("WeightedRandom__vehicles__Weights__2", weights[2].ToString("F2", System.Globalization.CultureInfo.InvariantCulture)); - -var client = builder.AddProject("client") +builder.AddProject("client") .WithReference(gateway) .WaitFor(gateway); diff --git a/src/AppHost/appsettings.json b/src/AppHost/appsettings.json new file mode 100644 index 00000000..2b0c2486 --- /dev/null +++ b/src/AppHost/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ReplicaWeights": [ 0.5, 0.3, 0.2 ], + "ApiService": { + "Ports": [ 5101, 5102, 5103 ] + }, + "ApiGateway": { + "Port": 5200 + } +} \ No newline at end of file From 32ad6e299eb689fb95a995dddd630d7c20d2e876 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Thu, 19 Mar 2026 16:09:37 +0400 Subject: [PATCH 12/23] super mega last fix --- CloudDevelopment.sln | 15 +++++++++++++ src/VehicleApi/Services/VehicleService.cs | 22 ++++++++++++------- src/VehicleApi/appsettings.json | 6 ++++- .../VehicleApi.Tests/VehicleGeneratorTests.cs | 8 +++---- 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index 66242c3e..f1238eee 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VehicleApi.Tests", "tests\VehicleApi.Tests\VehicleApi.Tests.csproj", "{E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppHost", "src\AppHost\AppHost.csproj", "{BD8774D8-8997-4902-B545-B6EEB3304A63}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -73,6 +75,18 @@ Global {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Release|x64.Build.0 = Release|Any CPU {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Release|x86.ActiveCfg = Release|Any CPU {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}.Release|x86.Build.0 = Release|Any CPU + {BD8774D8-8997-4902-B545-B6EEB3304A63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BD8774D8-8997-4902-B545-B6EEB3304A63}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BD8774D8-8997-4902-B545-B6EEB3304A63}.Debug|x64.ActiveCfg = Debug|Any CPU + {BD8774D8-8997-4902-B545-B6EEB3304A63}.Debug|x64.Build.0 = Debug|Any CPU + {BD8774D8-8997-4902-B545-B6EEB3304A63}.Debug|x86.ActiveCfg = Debug|Any CPU + {BD8774D8-8997-4902-B545-B6EEB3304A63}.Debug|x86.Build.0 = Debug|Any CPU + {BD8774D8-8997-4902-B545-B6EEB3304A63}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BD8774D8-8997-4902-B545-B6EEB3304A63}.Release|Any CPU.Build.0 = Release|Any CPU + {BD8774D8-8997-4902-B545-B6EEB3304A63}.Release|x64.ActiveCfg = Release|Any CPU + {BD8774D8-8997-4902-B545-B6EEB3304A63}.Release|x64.Build.0 = Release|Any CPU + {BD8774D8-8997-4902-B545-B6EEB3304A63}.Release|x86.ActiveCfg = Release|Any CPU + {BD8774D8-8997-4902-B545-B6EEB3304A63}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -81,6 +95,7 @@ Global {207C1A03-203E-4804-854F-0B70D3B0226F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {8C4738CB-0C2F-4664-9F35-62F27C0E5877} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {BD8774D8-8997-4902-B545-B6EEB3304A63} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {90FE6B04-8381-437E-893A-FEBA1DA10AEE} diff --git a/src/VehicleApi/Services/VehicleService.cs b/src/VehicleApi/Services/VehicleService.cs index 1af3f18e..399d2c57 100644 --- a/src/VehicleApi/Services/VehicleService.cs +++ b/src/VehicleApi/Services/VehicleService.cs @@ -4,12 +4,16 @@ namespace VehicleApi.Services; -public class VehicleService(IDistributedCache cache, ILogger logger) +public class VehicleService(IDistributedCache cache, ILogger logger, IConfiguration config) { - private static readonly DistributedCacheEntryOptions _cacheOptions = new() + private DistributedCacheEntryOptions GetCacheOptions() { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) - }; + var minutes = config.GetValue("Cache:AbsoluteExpirationMinutes", 10); + return new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(minutes) + }; + } public async Task GetByIdAsync(int id) { @@ -19,16 +23,18 @@ public async Task GetByIdAsync(int id) if (cachedData != null) { logger.LogInformation("Cache hit for vehicle ID {Id}", id); - return JsonSerializer.Deserialize(cachedData)!; + var cached = JsonSerializer.Deserialize(cachedData); + if (cached != null) + return cached; } logger.LogInformation("Cache miss for vehicle ID {Id}", id); var vehicle = VehicleGenerator.Generate(id); var serialized = JsonSerializer.SerializeToUtf8Bytes(vehicle); - await cache.SetAsync(cacheKey, serialized, _cacheOptions); - logger.LogInformation("Vehicle {Id} cached for 10 minutes", id); + await cache.SetAsync(cacheKey, serialized, GetCacheOptions()); + logger.LogInformation("Vehicle {Id} cached", id); return vehicle; } -} +} \ No newline at end of file diff --git a/src/VehicleApi/appsettings.json b/src/VehicleApi/appsettings.json index b58f62a1..0fa53018 100644 --- a/src/VehicleApi/appsettings.json +++ b/src/VehicleApi/appsettings.json @@ -4,5 +4,9 @@ "https://localhost:7282", "http://localhost:5127" ] - } + }, + "Cache": { + "AbsoluteExpirationMinutes": 10 + }, + "AllowedHosts": "*" } \ No newline at end of file diff --git a/tests/VehicleApi.Tests/VehicleGeneratorTests.cs b/tests/VehicleApi.Tests/VehicleGeneratorTests.cs index 7e664bc0..ae3697e4 100644 --- a/tests/VehicleApi.Tests/VehicleGeneratorTests.cs +++ b/tests/VehicleApi.Tests/VehicleGeneratorTests.cs @@ -14,7 +14,6 @@ public void Generate_SameId_ReturnsSameData() var vehicle1 = VehicleGenerator.Generate(42); var vehicle2 = VehicleGenerator.Generate(42); - Assert.Equal(vehicle1.Id, vehicle2.Id); Assert.Equal(vehicle1.Vin, vehicle2.Vin); Assert.Equal(vehicle1.Manufacturer, vehicle2.Manufacturer); Assert.Equal(vehicle1.Model, vehicle2.Model); @@ -23,7 +22,6 @@ public void Generate_SameId_ReturnsSameData() Assert.Equal(vehicle1.FuelType, vehicle2.FuelType); Assert.Equal(vehicle1.Color, vehicle2.Color); Assert.Equal(vehicle1.Mileage, vehicle2.Mileage); - Assert.Equal(vehicle1.LastServiceDate, vehicle2.LastServiceDate); } [Theory] @@ -41,7 +39,7 @@ public void Generate_DifferentIds_ReturnsDifferentData(int id1, int id2) [Fact] public void Generate_YearConstraint_IsValid() { - for (int i = 1; i <= 100; i++) + for (var i = 1; i <= 100; i++) { var vehicle = VehicleGenerator.Generate(i); @@ -53,7 +51,7 @@ public void Generate_YearConstraint_IsValid() [Fact] public void Generate_MileageConstraint_IsValid() { - for (int i = 1; i <= 100; i++) + for (var i = 1; i <= 100; i++) { var vehicle = VehicleGenerator.Generate(i); @@ -65,7 +63,7 @@ public void Generate_MileageConstraint_IsValid() [Fact] public void Generate_LastServiceDateConstraint_IsValid() { - for (int i = 1; i <= 100; i++) + for (var i = 1; i <= 100; i++) { var vehicle = VehicleGenerator.Generate(i); From 518cfec711409bd46deb78713c651e126f62a0d6 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Thu, 19 Mar 2026 20:32:35 +0400 Subject: [PATCH 13/23] fix --- global.json | 5 ----- src/ServiceDefaults/Extensions.cs | 2 -- src/VehicleApi/Program.cs | 3 --- tests/VehicleApi.Tests/VehicleGeneratorTests.cs | 3 --- 4 files changed, 13 deletions(-) delete mode 100644 global.json diff --git a/global.json b/global.json deleted file mode 100644 index bfca90d3..00000000 --- a/global.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "sdk": { - "version": "8.0.400" - } -} \ No newline at end of file diff --git a/src/ServiceDefaults/Extensions.cs b/src/ServiceDefaults/Extensions.cs index 4baacde9..62670bc2 100644 --- a/src/ServiceDefaults/Extensions.cs +++ b/src/ServiceDefaults/Extensions.cs @@ -2,9 +2,7 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.ServiceDiscovery; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; diff --git a/src/VehicleApi/Program.cs b/src/VehicleApi/Program.cs index 12553fcb..d372b002 100644 --- a/src/VehicleApi/Program.cs +++ b/src/VehicleApi/Program.cs @@ -1,6 +1,3 @@ -using System.Text.Json; -using Microsoft.Extensions.Caching.Distributed; -using VehicleApi.Models; using VehicleApi.Services; using Microsoft.AspNetCore.Mvc; diff --git a/tests/VehicleApi.Tests/VehicleGeneratorTests.cs b/tests/VehicleApi.Tests/VehicleGeneratorTests.cs index ae3697e4..b7d0257f 100644 --- a/tests/VehicleApi.Tests/VehicleGeneratorTests.cs +++ b/tests/VehicleApi.Tests/VehicleGeneratorTests.cs @@ -1,8 +1,5 @@ -using VehicleApi.Models; using VehicleApi.Services; using Xunit; -using VehicleApi.Models; -using VehicleApi.Services; namespace VehicleApi.Tests; From 0f1b20fee5cca737f56036bfd9aeae8e595ac109 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Wed, 1 Apr 2026 11:29:29 +0400 Subject: [PATCH 14/23] fix launchBrowser --- Client.Wasm/Properties/launchSettings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Client.Wasm/Properties/launchSettings.json b/Client.Wasm/Properties/launchSettings.json index 8141fbab..f08b1cea 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" From 874b4020c734da9934710e0491d79a44c9c1883e Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Wed, 1 Apr 2026 15:47:26 +0400 Subject: [PATCH 15/23] lab3 --- CloudDevelopment.sln | 31 ++++ src/AppHost/AppHost.csproj | 1 + src/AppHost/Program.cs | 37 +++- src/FileService/FileService.csproj | 18 ++ src/FileService/Program.cs | 41 +++++ .../Properties/launchSettings.json | 14 ++ .../Services/MinioStorageService.cs | 52 ++++++ .../Services/SqsConsumerService.cs | 108 ++++++++++++ src/FileService/appsettings.json | 19 +++ src/VehicleApi/Program.cs | 16 +- .../Services/SqsPublisherService.cs | 34 ++++ src/VehicleApi/Services/VehicleService.cs | 8 +- src/VehicleApi/VehicleApi.csproj | 1 + src/VehicleApi/appsettings.json | 6 + tests/Integration.Tests/EndToEndTests.cs | 160 ++++++++++++++++++ tests/Integration.Tests/FileServiceFactory.cs | 62 +++++++ tests/Integration.Tests/FileServiceTests.cs | 156 +++++++++++++++++ .../Integration.Tests.csproj | 31 ++++ tests/Integration.Tests/IntegrationFixture.cs | 101 +++++++++++ tests/Integration.Tests/VehicleApiFactory.cs | 57 +++++++ tests/Integration.Tests/VehicleApiTests.cs | 157 +++++++++++++++++ 21 files changed, 1104 insertions(+), 6 deletions(-) create mode 100644 src/FileService/FileService.csproj create mode 100644 src/FileService/Program.cs create mode 100644 src/FileService/Properties/launchSettings.json create mode 100644 src/FileService/Services/MinioStorageService.cs create mode 100644 src/FileService/Services/SqsConsumerService.cs create mode 100644 src/FileService/appsettings.json create mode 100644 src/VehicleApi/Services/SqsPublisherService.cs create mode 100644 tests/Integration.Tests/EndToEndTests.cs create mode 100644 tests/Integration.Tests/FileServiceFactory.cs create mode 100644 tests/Integration.Tests/FileServiceTests.cs create mode 100644 tests/Integration.Tests/Integration.Tests.csproj create mode 100644 tests/Integration.Tests/IntegrationFixture.cs create mode 100644 tests/Integration.Tests/VehicleApiFactory.cs create mode 100644 tests/Integration.Tests/VehicleApiTests.cs diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index b90c61cc..516335e2 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -16,8 +16,13 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VehicleApi.Tests", "tests\VehicleApi.Tests\VehicleApi.Tests.csproj", "{E6CB2EEC-E2A0-449F-89DE-11AE5579B37B}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiGateway", "src\ApiGateway\ApiGateway.csproj", "{42CE0FED-8889-49C7-988A-E8E12D59DCA2}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppHost", "src\AppHost\AppHost.csproj", "{BD8774D8-8997-4902-B545-B6EEB3304A63}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileService", "src\FileService\FileService.csproj", "{E0EC95F2-7EB9-4055-886A-E96E2297F109}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Integration.Tests", "tests\Integration.Tests\Integration.Tests.csproj", "{AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -100,6 +105,30 @@ Global {BD8774D8-8997-4902-B545-B6EEB3304A63}.Release|x64.Build.0 = Release|Any CPU {BD8774D8-8997-4902-B545-B6EEB3304A63}.Release|x86.ActiveCfg = Release|Any CPU {BD8774D8-8997-4902-B545-B6EEB3304A63}.Release|x86.Build.0 = Release|Any CPU + {E0EC95F2-7EB9-4055-886A-E96E2297F109}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0EC95F2-7EB9-4055-886A-E96E2297F109}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0EC95F2-7EB9-4055-886A-E96E2297F109}.Debug|x64.ActiveCfg = Debug|Any CPU + {E0EC95F2-7EB9-4055-886A-E96E2297F109}.Debug|x64.Build.0 = Debug|Any CPU + {E0EC95F2-7EB9-4055-886A-E96E2297F109}.Debug|x86.ActiveCfg = Debug|Any CPU + {E0EC95F2-7EB9-4055-886A-E96E2297F109}.Debug|x86.Build.0 = Debug|Any CPU + {E0EC95F2-7EB9-4055-886A-E96E2297F109}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0EC95F2-7EB9-4055-886A-E96E2297F109}.Release|Any CPU.Build.0 = Release|Any CPU + {E0EC95F2-7EB9-4055-886A-E96E2297F109}.Release|x64.ActiveCfg = Release|Any CPU + {E0EC95F2-7EB9-4055-886A-E96E2297F109}.Release|x64.Build.0 = Release|Any CPU + {E0EC95F2-7EB9-4055-886A-E96E2297F109}.Release|x86.ActiveCfg = Release|Any CPU + {E0EC95F2-7EB9-4055-886A-E96E2297F109}.Release|x86.Build.0 = Release|Any CPU + {AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA}.Debug|x64.ActiveCfg = Debug|Any CPU + {AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA}.Debug|x64.Build.0 = Debug|Any CPU + {AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA}.Debug|x86.ActiveCfg = Debug|Any CPU + {AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA}.Debug|x86.Build.0 = Debug|Any CPU + {AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA}.Release|Any CPU.Build.0 = Release|Any CPU + {AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA}.Release|x64.ActiveCfg = Release|Any CPU + {AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA}.Release|x64.Build.0 = Release|Any CPU + {AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA}.Release|x86.ActiveCfg = Release|Any CPU + {AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -110,6 +139,8 @@ Global {E6CB2EEC-E2A0-449F-89DE-11AE5579B37B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {42CE0FED-8889-49C7-988A-E8E12D59DCA2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {BD8774D8-8997-4902-B545-B6EEB3304A63} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E0EC95F2-7EB9-4055-886A-E96E2297F109} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {AA5B48F3-EF9D-405A-AE6C-F86D4BE9B2FA} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {90FE6B04-8381-437E-893A-FEBA1DA10AEE} diff --git a/src/AppHost/AppHost.csproj b/src/AppHost/AppHost.csproj index 2ed1625e..93490118 100644 --- a/src/AppHost/AppHost.csproj +++ b/src/AppHost/AppHost.csproj @@ -17,6 +17,7 @@ +
\ No newline at end of file diff --git a/src/AppHost/Program.cs b/src/AppHost/Program.cs index 74b34475..44378899 100644 --- a/src/AppHost/Program.cs +++ b/src/AppHost/Program.cs @@ -10,10 +10,34 @@ ports = [5101, 5102, 5103]; var gatewayPort = int.TryParse(builder.Configuration["ApiGateway:Port"], out var p) ? p : 5200; +var fileServicePort = int.TryParse(builder.Configuration["FileService:Port"], out var fp) ? fp : 5300; var cache = builder.AddRedis("cache") .WithRedisInsight(); +var minio = builder.AddContainer("minio", "minio/minio") + .WithArgs("server", "/data", "--console-address", ":9001") + .WithEnvironment("MINIO_ROOT_USER", "minioadmin") + .WithEnvironment("MINIO_ROOT_PASSWORD", "minioadmin") + .WithHttpEndpoint(port: 9000, targetPort: 9000, name: "api") + .WithHttpEndpoint(port: 9001, targetPort: 9001, name: "console"); + +var localstack = builder.AddContainer("localstack", "localstack/localstack", "3.0.0") + .WithEnvironment("SERVICES", "sqs") + .WithEnvironment("DEFAULT_REGION", "us-east-1") + .WithEnvironment("AWS_DEFAULT_REGION", "us-east-1") + .WithEnvironment("AWS_ACCESS_KEY_ID", "test") + .WithEnvironment("AWS_SECRET_ACCESS_KEY", "test") + .WithEnvironment("LOCALSTACK_ACKNOWLEDGE_ACCOUNT_REQUIREMENT", "1") + .WithHttpEndpoint(port: 4566, targetPort: 4566, name: "api"); + +var fileService = builder.AddProject("fileservice") + .WithEnvironment("Sqs__ServiceUrl", "http://localhost:4566") + .WithEnvironment("Sqs__QueueUrl", "http://localhost:4566/000000000000/vehicles") + .WithEnvironment("Minio__Endpoint", "localhost:9000") + .WaitFor(minio) + .WaitFor(localstack); + var gateway = builder.AddProject("apigateway") .WithHttpEndpoint(port: gatewayPort, name: "gateway-endpoint", isProxied: false) .WithExternalHttpEndpoints(); @@ -22,15 +46,20 @@ foreach (var port in ports) { var replica = builder.AddProject($"vehicleapi-{serviceId++}") - .WithReference(cache) - .WithHttpEndpoint(port: port, name: "api-endpoint", isProxied: false) - .WithExternalHttpEndpoints(); + .WithReference(cache) + .WithEnvironment("Sqs__ServiceUrl", "http://localhost:4566") + .WithEnvironment("Sqs__QueueUrl", "http://localhost:4566/000000000000/vehicles") + .WithHttpEndpoint(port: port, name: "api-endpoint", isProxied: false) + .WithExternalHttpEndpoints() + .WaitFor(cache) + .WaitFor(localstack); gateway.WaitFor(replica); + fileService.WaitFor(replica); } builder.AddProject("client") .WithReference(gateway) .WaitFor(gateway); -builder.Build().Run(); \ No newline at end of file +builder.Build().Run(); diff --git a/src/FileService/FileService.csproj b/src/FileService/FileService.csproj new file mode 100644 index 00000000..3daf8a18 --- /dev/null +++ b/src/FileService/FileService.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/src/FileService/Program.cs b/src/FileService/Program.cs new file mode 100644 index 00000000..dbddb68d --- /dev/null +++ b/src/FileService/Program.cs @@ -0,0 +1,41 @@ +using Amazon.SQS; +using FileService.Services; +using Minio; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +// MinIO +var minioEndpoint = builder.Configuration["Minio:Endpoint"] ?? "localhost:9000"; +var minioAccessKey = builder.Configuration["Minio:AccessKey"] ?? "minioadmin"; +var minioSecretKey = builder.Configuration["Minio:SecretKey"] ?? "minioadmin"; +var minioUseSSL = builder.Configuration.GetValue("Minio:UseSSL", false); + +builder.Services.AddSingleton(_ => + new MinioClient() + .WithEndpoint(minioEndpoint) + .WithCredentials(minioAccessKey, minioSecretKey) + .WithSSL(minioUseSSL) + .Build()); + +// AWS SQS +var sqsConfig = new AmazonSQSConfig +{ + ServiceURL = builder.Configuration["Sqs:ServiceUrl"] ?? "http://localhost:4566" +}; + +var sqsCredentials = new Amazon.Runtime.BasicAWSCredentials( + builder.Configuration["Sqs:AccessKey"] ?? "test", + builder.Configuration["Sqs:SecretKey"] ?? "test"); + +builder.Services.AddSingleton(_ => new AmazonSQSClient(sqsCredentials, sqsConfig)); + +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +app.Run(); diff --git a/src/FileService/Properties/launchSettings.json b/src/FileService/Properties/launchSettings.json new file mode 100644 index 00000000..1e0274d8 --- /dev/null +++ b/src/FileService/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5300", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/FileService/Services/MinioStorageService.cs b/src/FileService/Services/MinioStorageService.cs new file mode 100644 index 00000000..271110f4 --- /dev/null +++ b/src/FileService/Services/MinioStorageService.cs @@ -0,0 +1,52 @@ +using Minio; +using Minio.DataModel.Args; + +namespace FileService.Services; + +/// +/// Сервис для сохранения файлов в объектном хранилище MinIO. +/// +public class MinioStorageService(IMinioClient minio, IConfiguration config, ILogger logger) +{ + private readonly string _bucketName = config["Minio:BucketName"] ?? "vehicles"; + + /// + /// Инициализирует bucket при старте сервиса, создавая его если не существует. + /// + public async Task EnsureBucketExistsAsync() + { + var exists = await minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(_bucketName)); + + if (!exists) + { + await minio.MakeBucketAsync(new MakeBucketArgs().WithBucket(_bucketName)); + logger.LogInformation("Bucket '{Bucket}' created", _bucketName); + } + else + { + logger.LogInformation("Bucket '{Bucket}' already exists", _bucketName); + } + } + + /// + /// Сохраняет JSON-содержимое как файл в объектном хранилище. + /// + /// Имя объекта (файла) в bucket. + /// JSON-содержимое для сохранения. + public async Task SaveAsync(string objectName, string jsonContent) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(jsonContent); + using var stream = new MemoryStream(bytes); + + var args = new PutObjectArgs() + .WithBucket(_bucketName) + .WithObject(objectName) + .WithStreamData(stream) + .WithObjectSize(bytes.Length) + .WithContentType("application/json"); + + await minio.PutObjectAsync(args); + + logger.LogInformation("Saved object '{Object}' to bucket '{Bucket}'", objectName, _bucketName); + } +} diff --git a/src/FileService/Services/SqsConsumerService.cs b/src/FileService/Services/SqsConsumerService.cs new file mode 100644 index 00000000..8229efd2 --- /dev/null +++ b/src/FileService/Services/SqsConsumerService.cs @@ -0,0 +1,108 @@ +using Amazon.SQS; +using Amazon.SQS.Model; + +namespace FileService.Services; + +/// +/// Фоновый сервис, непрерывно опрашивающий очередь SQS и сохраняющий +/// полученные данные транспортных средств в объектное хранилище. +/// +public class SqsConsumerService( + IAmazonSQS sqs, + MinioStorageService storage, + IConfiguration config, + ILogger logger) : BackgroundService +{ + private readonly string _queueUrl = config["Sqs:QueueUrl"] + ?? throw new InvalidOperationException("Sqs:QueueUrl is not configured"); + + private readonly int _pollingIntervalMs = config.GetValue("Sqs:PollingIntervalMs", 1000); + private readonly int _maxMessages = config.GetValue("Sqs:MaxMessages", 10); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("SQS consumer started. Queue: {QueueUrl}", _queueUrl); + + await EnsureQueueExistsAsync(); + + await storage.EnsureBucketExistsAsync(); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await PollAsync(stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Error while polling SQS queue"); + await Task.Delay(_pollingIntervalMs, stoppingToken); + } + } + + logger.LogInformation("SQS consumer stopped"); + } + + private async Task PollAsync(CancellationToken ct) + { + var request = new ReceiveMessageRequest + { + QueueUrl = _queueUrl, + MaxNumberOfMessages = _maxMessages, + WaitTimeSeconds = 5 + }; + + var response = await sqs.ReceiveMessageAsync(request, ct); + + if (response.Messages.Count == 0) + { + await Task.Delay(_pollingIntervalMs, ct); + return; + } + + logger.LogInformation("Received {Count} messages from SQS", response.Messages.Count); + + foreach (var message in response.Messages) + { + await ProcessMessageAsync(message, ct); + } + } + + private async Task ProcessMessageAsync(Message message, CancellationToken ct) + { + try + { + var objectName = $"vehicle-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss-fff}-{message.MessageId[..8]}.json"; + + await storage.SaveAsync(objectName, message.Body); + + await sqs.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, ct); + + logger.LogInformation("Processed message {MessageId} → saved as '{ObjectName}'", + message.MessageId, objectName); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process message {MessageId}", message.MessageId); + } + } + private async Task EnsureQueueExistsAsync() +{ + try + { + await sqs.CreateQueueAsync(new Amazon.SQS.Model.CreateQueueRequest + { + QueueName = _queueUrl.Split('/').Last() + }); + logger.LogInformation("SQS queue ensured"); + } + catch (Exception ex) + { + logger.LogWarning("Could not create queue: {Message}", ex.Message); + } +} +} diff --git a/src/FileService/appsettings.json b/src/FileService/appsettings.json new file mode 100644 index 00000000..13067f46 --- /dev/null +++ b/src/FileService/appsettings.json @@ -0,0 +1,19 @@ +{ + "Sqs": { + "QueueUrl": "http://localhost:4566/000000000000/vehicles", + "ServiceUrl": "http://localhost:4566", + "Region": "us-east-1", + "AccessKey": "test", + "SecretKey": "test", + "PollingIntervalMs": 1000, + "MaxMessages": 10 + }, + "Minio": { + "Endpoint": "localhost:9000", + "AccessKey": "minioadmin", + "SecretKey": "minioadmin", + "BucketName": "vehicles", + "UseSSL": false + }, + "AllowedHosts": "*" +} diff --git a/src/VehicleApi/Program.cs b/src/VehicleApi/Program.cs index d372b002..81806d45 100644 --- a/src/VehicleApi/Program.cs +++ b/src/VehicleApi/Program.cs @@ -1,10 +1,24 @@ -using VehicleApi.Services; +using Amazon.SQS; using Microsoft.AspNetCore.Mvc; +using VehicleApi.Services; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); +// AWS SQS +var sqsConfig = new AmazonSQSConfig +{ + ServiceURL = builder.Configuration["Sqs:ServiceUrl"] ?? "http://localhost:4566" +}; + +var sqsCredentials = new Amazon.Runtime.BasicAWSCredentials( + builder.Configuration["Sqs:AccessKey"] ?? "test", + builder.Configuration["Sqs:SecretKey"] ?? "test"); + +builder.Services.AddSingleton(_ => new AmazonSQSClient(sqsCredentials, sqsConfig)); +builder.Services.AddSingleton(); + builder.Services.AddScoped(); builder.AddRedisDistributedCache("cache"); diff --git a/src/VehicleApi/Services/SqsPublisherService.cs b/src/VehicleApi/Services/SqsPublisherService.cs new file mode 100644 index 00000000..50fbad19 --- /dev/null +++ b/src/VehicleApi/Services/SqsPublisherService.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using Amazon.SQS; +using Amazon.SQS.Model; +using VehicleApi.Models; + +namespace VehicleApi.Services; + +/// +/// Сервис публикации данных транспортного средства в очередь SQS. +/// +public class SqsPublisherService(IAmazonSQS sqs, IConfiguration config, ILogger logger) +{ + private readonly string _queueUrl = config["Sqs:QueueUrl"] + ?? throw new InvalidOperationException("Sqs:QueueUrl is not configured"); + + /// + /// Публикует данные транспортного средства в SQS-очередь. + /// + public async Task PublishAsync(Vehicle vehicle) + { + var body = JsonSerializer.Serialize(vehicle); + + var request = new SendMessageRequest + { + QueueUrl = _queueUrl, + MessageBody = body + }; + + var response = await sqs.SendMessageAsync(request); + + logger.LogInformation("Published vehicle {Id} to SQS, MessageId: {MessageId}", + vehicle.Id, response.MessageId); + } +} diff --git a/src/VehicleApi/Services/VehicleService.cs b/src/VehicleApi/Services/VehicleService.cs index 399d2c57..dcd5f036 100644 --- a/src/VehicleApi/Services/VehicleService.cs +++ b/src/VehicleApi/Services/VehicleService.cs @@ -4,7 +4,11 @@ namespace VehicleApi.Services; -public class VehicleService(IDistributedCache cache, ILogger logger, IConfiguration config) +public class VehicleService( + IDistributedCache cache, + SqsPublisherService publisher, + ILogger logger, + IConfiguration config) { private DistributedCacheEntryOptions GetCacheOptions() { @@ -35,6 +39,8 @@ public async Task GetByIdAsync(int id) await cache.SetAsync(cacheKey, serialized, GetCacheOptions()); logger.LogInformation("Vehicle {Id} cached", id); + await publisher.PublishAsync(vehicle); + return vehicle; } } \ No newline at end of file diff --git a/src/VehicleApi/VehicleApi.csproj b/src/VehicleApi/VehicleApi.csproj index b1afe2f4..3ca90c0e 100644 --- a/src/VehicleApi/VehicleApi.csproj +++ b/src/VehicleApi/VehicleApi.csproj @@ -8,6 +8,7 @@ + diff --git a/src/VehicleApi/appsettings.json b/src/VehicleApi/appsettings.json index 0fa53018..d38c8352 100644 --- a/src/VehicleApi/appsettings.json +++ b/src/VehicleApi/appsettings.json @@ -8,5 +8,11 @@ "Cache": { "AbsoluteExpirationMinutes": 10 }, + "Sqs": { + "QueueUrl": "http://localhost:4566/000000000000/vehicles", + "ServiceUrl": "http://localhost:4566", + "AccessKey": "test", + "SecretKey": "test" + }, "AllowedHosts": "*" } \ No newline at end of file diff --git a/tests/Integration.Tests/EndToEndTests.cs b/tests/Integration.Tests/EndToEndTests.cs new file mode 100644 index 00000000..77f75fc9 --- /dev/null +++ b/tests/Integration.Tests/EndToEndTests.cs @@ -0,0 +1,160 @@ +using System.Net; +using System.Text.Json; +using Minio.DataModel.Args; +using Xunit; + +namespace Integration.Tests; + +/// +/// Сквозные (end-to-end) тесты всего бэкенда: +/// HTTP-запрос к VehicleApi → публикация в SQS → потребление FileService → сохранение в MinIO. +/// +[Collection(nameof(IntegrationCollection))] +public class EndToEndTests( + VehicleApiFactory vehicleApiFactory, + FileServiceFactory fileServiceFactory, + IntegrationFixture fixture) + : IClassFixture, IClassFixture +{ + private readonly HttpClient _vehicleClient = vehicleApiFactory.CreateClient(); + + [Fact] + public async Task RequestVehicle_FullPipeline_FileAppearsInMinio() + { + var vehicleId = Random.Shared.Next(300_000, 399_999); + + var response = await _vehicleClient.GetAsync($"/api/vehicles?id={vehicleId}"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + var vehicle = JsonDocument.Parse(json).RootElement; + var returnedVin = vehicle.GetProperty("vin").GetString(); + + List objects = []; + var deadline = DateTime.UtcNow.AddSeconds(15); + + while (DateTime.UtcNow < deadline) + { + objects = await ListObjectsAsync(IntegrationFixture.BucketName); + var found = await FindVehicleInMinioAsync(objects, vehicleId); + if (found) break; + await Task.Delay(500); + } + + var matchingFile = await FindVehicleFileAsync(objects, vehicleId); + Assert.NotNull(matchingFile); + + var fileContent = await GetObjectContentAsync(IntegrationFixture.BucketName, matchingFile); + var savedDoc = JsonDocument.Parse(fileContent).RootElement; + + Assert.Equal(vehicleId, savedDoc.GetProperty("id").GetInt32()); + Assert.Equal(returnedVin, savedDoc.GetProperty("vin").GetString()); + } + + [Fact] + public async Task RequestVehicle_SecondRequest_OnlySingleFileInMinio() + { + var vehicleId = Random.Shared.Next(400_000, 499_999); + + await _vehicleClient.GetAsync($"/api/vehicles?id={vehicleId}"); + await Task.Delay(3000); + + var objectsAfterFirst = await ListObjectsAsync(IntegrationFixture.BucketName); + var firstCount = objectsAfterFirst.Count(o => o.Contains(vehicleId.ToString()) == false + ); + + var totalAfterFirst = objectsAfterFirst.Count; + + await _vehicleClient.GetAsync($"/api/vehicles?id={vehicleId}"); + await Task.Delay(2000); + + var objectsAfterSecond = await ListObjectsAsync(IntegrationFixture.BucketName); + + Assert.Equal(totalAfterFirst, objectsAfterSecond.Count); + } + + [Fact] + public async Task MultipleVehicles_AllFilesAppearInMinio() + { + var ids = Enumerable.Range(500_000, 3).ToList(); + var initialCount = (await ListObjectsAsync(IntegrationFixture.BucketName)).Count; + + foreach (var id in ids) + await _vehicleClient.GetAsync($"/api/vehicles?id={id}"); + + List objects = []; + var deadline = DateTime.UtcNow.AddSeconds(15); + + while (DateTime.UtcNow < deadline) + { + objects = await ListObjectsAsync(IntegrationFixture.BucketName); + if (objects.Count >= initialCount + ids.Count) break; + await Task.Delay(500); + } + + Assert.True(objects.Count >= initialCount + ids.Count, + $"Expected at least {initialCount + ids.Count} objects in MinIO, found {objects.Count}"); + } + + private async Task> ListObjectsAsync(string bucketName) + { + var items = new List(); + var tcs = new TaskCompletionSource>(); + + var args = new ListObjectsArgs().WithBucket(bucketName).WithRecursive(true); + var observable = fixture.MinioClient.ListObjectsAsync(args); + + var sub = observable.Subscribe( + item => items.Add(item.Key), + ex => tcs.TrySetException(ex), + () => tcs.TrySetResult(items)); + + using (sub) + return await tcs.Task.WaitAsync(TimeSpan.FromSeconds(10)); + } + + private async Task FindVehicleInMinioAsync(List objectNames, int vehicleId) + { + foreach (var name in objectNames) + { + var content = await GetObjectContentAsync(IntegrationFixture.BucketName, name); + try + { + var doc = JsonDocument.Parse(content).RootElement; + if (doc.TryGetProperty("id", out var idProp) && idProp.GetInt32() == vehicleId) + return true; + } + catch {} + } + return false; + } + + private async Task FindVehicleFileAsync(List objectNames, int vehicleId) + { + foreach (var name in objectNames) + { + var content = await GetObjectContentAsync(IntegrationFixture.BucketName, name); + try + { + var doc = JsonDocument.Parse(content).RootElement; + if (doc.TryGetProperty("id", out var idProp) && idProp.GetInt32() == vehicleId) + return name; + } + catch {} + } + return null; + } + + private async Task GetObjectContentAsync(string bucketName, string objectName) + { + using var ms = new MemoryStream(); + await fixture.MinioClient.GetObjectAsync( + new GetObjectArgs() + .WithBucket(bucketName) + .WithObject(objectName) + .WithCallbackStream(stream => stream.CopyTo(ms))); + + ms.Position = 0; + return System.Text.Encoding.UTF8.GetString(ms.ToArray()); + } +} diff --git a/tests/Integration.Tests/FileServiceFactory.cs b/tests/Integration.Tests/FileServiceFactory.cs new file mode 100644 index 00000000..f5607231 --- /dev/null +++ b/tests/Integration.Tests/FileServiceFactory.cs @@ -0,0 +1,62 @@ +using Amazon.SQS; +using FileService.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Minio; + +namespace Integration.Tests; + +/// +/// Фабрика для поднятия FileService в тестовом окружении. +/// Подменяет MinIO и SQS на тестовые контейнеры. +/// +public class FileServiceFactory(IntegrationFixture fixture) + : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + + var sqsCreds = new Amazon.Runtime.BasicAWSCredentials("test", "test"); + var sqsConfig = new AmazonSQSConfig { ServiceURL = fixture.SqsServiceUrl }; + services.AddSingleton(_ => new AmazonSQSClient(sqsCreds, sqsConfig)); + + services.AddSingleton(_ => + new MinioClient() + .WithEndpoint(fixture.MinioEndpoint) + .WithCredentials(fixture.MinioAccessKey, fixture.MinioSecretKey) + .WithSSL(false) + .Build()); + + services.AddSingleton(); + services.AddHostedService(); + }); + + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["Sqs:QueueUrl"] = fixture.SqsQueueUrl, + ["Sqs:ServiceUrl"] = fixture.SqsServiceUrl, + ["Sqs:AccessKey"] = "test", + ["Sqs:SecretKey"] = "test", + ["Sqs:PollingIntervalMs"] = "200", + ["Minio:Endpoint"] = fixture.MinioEndpoint, + ["Minio:AccessKey"] = "minioadmin", + ["Minio:SecretKey"] = "minioadmin", + ["Minio:BucketName"] = IntegrationFixture.BucketName, + ["Minio:UseSSL"] = "false" + }); + }); + } +} diff --git a/tests/Integration.Tests/FileServiceTests.cs b/tests/Integration.Tests/FileServiceTests.cs new file mode 100644 index 00000000..9d379423 --- /dev/null +++ b/tests/Integration.Tests/FileServiceTests.cs @@ -0,0 +1,156 @@ +using System.Text.Json; +using Amazon.SQS.Model; +using Minio.DataModel.Args; +using Xunit; + +namespace Integration.Tests; + +/// +/// Интеграционные тесты FileService: потребление из SQS и сохранение в MinIO. +/// +[Collection(nameof(IntegrationCollection))] +public class FileServiceTests : IClassFixture +{ + private readonly IntegrationFixture _fixture; + private readonly HttpClient _fileServiceClient; + + public FileServiceTests(FileServiceFactory factory, IntegrationFixture fixture) + { + _fixture = fixture; + _fileServiceClient = factory.CreateClient(); + } + + [Fact] + public async Task FileService_ConsumesMessage_SavesFileToMinio() + { + var vehicle = new + { + id = 777, + vin = "1HGBH41JXMN109186", + manufacturer = "Honda", + model = "Civic", + year = 2015, + bodyType = "Sedan", + fuelType = "Gasoline", + color = "White", + mileage = 75000.0, + lastServiceDate = "2023-06-01" + }; + + var messageBody = JsonSerializer.Serialize(vehicle); + + await _fixture.SqsClient.SendMessageAsync(new SendMessageRequest + { + QueueUrl = _fixture.SqsQueueUrl, + MessageBody = messageBody + }); + + await Task.Delay(3000); + + var objectsInBucket = await ListObjectsAsync(IntegrationFixture.BucketName); + + Assert.NotEmpty(objectsInBucket); + + var savedFile = objectsInBucket.First(); + var content = await GetObjectContentAsync(IntegrationFixture.BucketName, savedFile); + var savedDoc = JsonDocument.Parse(content); + + Assert.Equal(777, savedDoc.RootElement.GetProperty("id").GetInt32()); + Assert.Equal("1HGBH41JXMN109186", savedDoc.RootElement.GetProperty("vin").GetString()); + } + + [Fact] + public async Task FileService_ConsumesMultipleMessages_SavesAllFilesToMinio() + { + var vehicleIds = new[] { 801, 802, 803 }; + + foreach (var id in vehicleIds) + { + var vehicle = new { id, vin = $"VIN{id:D10}", manufacturer = "Toyota", model = "Corolla" }; + await _fixture.SqsClient.SendMessageAsync(new SendMessageRequest + { + QueueUrl = _fixture.SqsQueueUrl, + MessageBody = JsonSerializer.Serialize(vehicle) + }); + } + + await Task.Delay(4000); + + var objects = await ListObjectsAsync(IntegrationFixture.BucketName); + + Assert.True(objects.Count >= vehicleIds.Length, + $"Expected at least {vehicleIds.Length} objects in MinIO, found {objects.Count}"); + } + + [Fact] + public async Task FileService_DeletesMessageAfterProcessing() + { + var vehicle = new { id = 900, vin = "DELETEME123456789", manufacturer = "Ford" }; + + await _fixture.SqsClient.SendMessageAsync(new SendMessageRequest + { + QueueUrl = _fixture.SqsQueueUrl, + MessageBody = JsonSerializer.Serialize(vehicle) + }); + + await Task.Delay(3000); + + var remaining = await _fixture.SqsClient.ReceiveMessageAsync(new ReceiveMessageRequest + { + QueueUrl = _fixture.SqsQueueUrl, + MaxNumberOfMessages = 10, + WaitTimeSeconds = 1 + }); + + var ourMessage = remaining.Messages.FirstOrDefault(m => m.Body.Contains("DELETEME123456789")); + + Assert.Null(ourMessage); + } + + [Fact] + public async Task FileService_HealthCheck_ReturnsHealthy() + { + var response = await _fileServiceClient.GetAsync("/health"); + + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + private async Task> ListObjectsAsync(string bucketName) + { + var result = new List(); + + var args = new ListObjectsArgs() + .WithBucket(bucketName) + .WithRecursive(true); + + var tcs = new TaskCompletionSource>(); + var items = new List(); + + var observable = _fixture.MinioClient.ListObjectsAsync(args); + var subscription = observable.Subscribe( + item => items.Add(item.Key), + ex => tcs.TrySetException(ex), + () => tcs.TrySetResult(items)); + + using (subscription) + { + result = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(10)); + } + + return result; + } + + private async Task GetObjectContentAsync(string bucketName, string objectName) + { + using var ms = new MemoryStream(); + + await _fixture.MinioClient.GetObjectAsync( + new GetObjectArgs() + .WithBucket(bucketName) + .WithObject(objectName) + .WithCallbackStream(stream => stream.CopyTo(ms))); + + ms.Position = 0; + return System.Text.Encoding.UTF8.GetString(ms.ToArray()); + } +} diff --git a/tests/Integration.Tests/Integration.Tests.csproj b/tests/Integration.Tests/Integration.Tests.csproj new file mode 100644 index 00000000..4c577994 --- /dev/null +++ b/tests/Integration.Tests/Integration.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + diff --git a/tests/Integration.Tests/IntegrationFixture.cs b/tests/Integration.Tests/IntegrationFixture.cs new file mode 100644 index 00000000..013e7685 --- /dev/null +++ b/tests/Integration.Tests/IntegrationFixture.cs @@ -0,0 +1,101 @@ +using Amazon.SQS; +using Amazon.SQS.Model; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using Minio; +using Minio.DataModel.Args; +using Testcontainers.Minio; +using Testcontainers.Redis; +using Xunit; + +namespace Integration.Tests; + +/// +/// Общая тестовая фикстура: поднимает Redis, LocalStack (SQS) и MinIO +/// один раз для всей коллекции интеграционных тестов. +/// +public class IntegrationFixture : IAsyncLifetime +{ + public const string QueueName = "vehicles"; + public const string BucketName = "vehicles"; + + public string MinioAccessKey { get; private set; } = null!; + public string MinioSecretKey { get; private set; } = null!; + + private readonly RedisContainer _redis = new RedisBuilder() + .WithImage("redis:7-alpine") + .Build(); + + private readonly IContainer _localstack = new ContainerBuilder() + .WithImage("localstack/localstack:latest") + .WithEnvironment("SERVICES", "sqs") + .WithEnvironment("DEFAULT_REGION", "us-east-1") + .WithEnvironment("AWS_DEFAULT_REGION", "us-east-1") + .WithEnvironment("AWS_ACCESS_KEY_ID", "test") + .WithEnvironment("AWS_SECRET_ACCESS_KEY", "test") + .WithPortBinding(0, 4566) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => + r.ForPort(4566).ForPath("/_localstack/health"))) + .Build(); + + private readonly MinioContainer _minio = new MinioBuilder() + .WithImage("minio/minio:latest") + .Build(); + + public IAmazonSQS SqsClient { get; private set; } = null!; + public IMinioClient MinioClient { get; private set; } = null!; + public string RedisConnectionString { get; private set; } = null!; + public string SqsServiceUrl { get; private set; } = null!; + public string SqsQueueUrl { get; private set; } = null!; + public string MinioEndpoint { get; private set; } = null!; + + public async Task InitializeAsync() + { + await Task.WhenAll( + _redis.StartAsync(), + _localstack.StartAsync(), + _minio.StartAsync()); + + RedisConnectionString = _redis.GetConnectionString(); + + SqsServiceUrl = $"http://localhost:{_localstack.GetMappedPublicPort(4566)}"; + + var sqsCreds = new Amazon.Runtime.BasicAWSCredentials("test", "test"); + var sqsConfig = new AmazonSQSConfig { ServiceURL = SqsServiceUrl }; + SqsClient = new AmazonSQSClient(sqsCreds, sqsConfig); + + var createQueueResponse = await SqsClient.CreateQueueAsync(new CreateQueueRequest + { + QueueName = QueueName + }); + SqsQueueUrl = createQueueResponse.QueueUrl; + + MinioEndpoint = $"{_minio.Hostname}:{_minio.GetMappedPublicPort(9000)}"; + MinioAccessKey = _minio.GetAccessKey(); + MinioSecretKey = _minio.GetSecretKey(); + + MinioClient = new MinioClient() + .WithEndpoint(MinioEndpoint) + .WithCredentials(MinioAccessKey, MinioSecretKey) + .WithSSL(false) + .Build(); + + await MinioClient.MakeBucketAsync(new MakeBucketArgs().WithBucket(BucketName)); + } + + public async Task DisposeAsync() + { + SqsClient.Dispose(); + + await Task.WhenAll( + _redis.StopAsync(), + _localstack.StopAsync(), + _minio.StopAsync()); + } +} + +/// +/// Коллекция, позволяющая переиспользовать одну фикстуру между тест-классами. +/// +[CollectionDefinition(nameof(IntegrationCollection))] +public class IntegrationCollection : ICollectionFixture; diff --git a/tests/Integration.Tests/VehicleApiFactory.cs b/tests/Integration.Tests/VehicleApiFactory.cs new file mode 100644 index 00000000..a9be06f0 --- /dev/null +++ b/tests/Integration.Tests/VehicleApiFactory.cs @@ -0,0 +1,57 @@ +using Amazon.SQS; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using StackExchange.Redis; +using VehicleApi.Services; + +namespace Integration.Tests; + +/// +/// Фабрика для поднятия VehicleApi в тестовом окружении. +/// Подменяет Redis, SQS и ServiceDiscovery на тестовые контейнеры. +/// +public class VehicleApiFactory(IntegrationFixture fixture) + : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + + services.AddSingleton(_ => + ConnectionMultiplexer.Connect(fixture.RedisConnectionString)); + + services.AddStackExchangeRedisCache(opts => + opts.Configuration = fixture.RedisConnectionString); + + var sqsCreds = new Amazon.Runtime.BasicAWSCredentials("test", "test"); + var sqsConfig = new AmazonSQSConfig { ServiceURL = fixture.SqsServiceUrl }; + var sqsClient = new AmazonSQSClient(sqsCreds, sqsConfig); + + services.AddSingleton(_ => sqsClient); + services.AddSingleton(); + }); + + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["Sqs:QueueUrl"] = fixture.SqsQueueUrl, + ["Sqs:ServiceUrl"] = fixture.SqsServiceUrl, + ["Sqs:AccessKey"] = "test", + ["Sqs:SecretKey"] = "test", + ["Cors:AllowedOrigins:0"] = "http://localhost" + }); + }); + } +} diff --git a/tests/Integration.Tests/VehicleApiTests.cs b/tests/Integration.Tests/VehicleApiTests.cs new file mode 100644 index 00000000..d1e0fd7c --- /dev/null +++ b/tests/Integration.Tests/VehicleApiTests.cs @@ -0,0 +1,157 @@ +using System.Net; +using System.Text.Json; +using Amazon.SQS.Model; +using Xunit; + +namespace Integration.Tests; + +/// +/// Интеграционные тесты VehicleApi: HTTP-эндпоинт, кэш Redis и публикация в SQS. +/// +[Collection(nameof(IntegrationCollection))] +public class VehicleApiTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly IntegrationFixture _fixture; + + public VehicleApiTests(VehicleApiFactory factory, IntegrationFixture fixture) + { + _client = factory.CreateClient(); + _fixture = fixture; + } + + [Fact] + public async Task GetVehicle_ValidId_ReturnsOk() + { + var response = await _client.GetAsync("/api/vehicles?id=1"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetVehicle_ValidId_ReturnsExpectedFields() + { + var response = await _client.GetAsync("/api/vehicles?id=5"); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("id", out _), "Missing 'id' field"); + Assert.True(root.TryGetProperty("vin", out _), "Missing 'vin' field"); + Assert.True(root.TryGetProperty("manufacturer", out _), "Missing 'manufacturer' field"); + Assert.True(root.TryGetProperty("model", out _), "Missing 'model' field"); + Assert.True(root.TryGetProperty("year", out _), "Missing 'year' field"); + Assert.True(root.TryGetProperty("bodyType", out _), "Missing 'bodyType' field"); + Assert.True(root.TryGetProperty("fuelType", out _), "Missing 'fuelType' field"); + Assert.True(root.TryGetProperty("color", out _), "Missing 'color' field"); + Assert.True(root.TryGetProperty("mileage", out _), "Missing 'mileage' field"); + Assert.True(root.TryGetProperty("lastServiceDate", out _), "Missing 'lastServiceDate' field"); + } + + [Fact] + public async Task GetVehicle_SameId_ReturnsSameData() + { + var r1 = await _client.GetAsync("/api/vehicles?id=42"); + var r2 = await _client.GetAsync("/api/vehicles?id=42"); + + var json1 = await r1.Content.ReadAsStringAsync(); + var json2 = await r2.Content.ReadAsStringAsync(); + + Assert.Equal(json1, json2); + } + + [Fact] + public async Task GetVehicle_DifferentIds_ReturnsDifferentData() + { + var r1 = await _client.GetAsync("/api/vehicles?id=10"); + var r2 = await _client.GetAsync("/api/vehicles?id=20"); + + var json1 = await r1.Content.ReadAsStringAsync(); + var json2 = await r2.Content.ReadAsStringAsync(); + + Assert.NotEqual(json1, json2); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public async Task GetVehicle_InvalidId_ReturnsBadRequest(int id) + { + var response = await _client.GetAsync($"/api/vehicles?id={id}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetVehicle_OnCacheMiss_PublishesMessageToSqs() + { + var uniqueId = Random.Shared.Next(100_000, 999_999); + + await _client.GetAsync($"/api/vehicles?id={uniqueId}"); + + await Task.Delay(300); + + var messages = await _fixture.SqsClient.ReceiveMessageAsync(new ReceiveMessageRequest + { + QueueUrl = _fixture.SqsQueueUrl, + MaxNumberOfMessages = 10, + WaitTimeSeconds = 2 + }); + + var vehicleMessage = messages.Messages.FirstOrDefault(m => + { + var doc = JsonDocument.Parse(m.Body); + return doc.RootElement.TryGetProperty("id", out var idProp) + && idProp.GetInt32() == uniqueId; + }); + + Assert.NotNull(vehicleMessage); + } + + [Fact] + public async Task GetVehicle_OnCacheHit_DoesNotPublishDuplicateToSqs() + { + var uniqueId = Random.Shared.Next(200_000, 299_999); + + await _client.GetAsync($"/api/vehicles?id={uniqueId}"); + await Task.Delay(300); + + var firstBatch = await _fixture.SqsClient.ReceiveMessageAsync(new ReceiveMessageRequest + { + QueueUrl = _fixture.SqsQueueUrl, + MaxNumberOfMessages = 10, + WaitTimeSeconds = 2 + }); + + foreach (var msg in firstBatch.Messages) + await _fixture.SqsClient.DeleteMessageAsync(_fixture.SqsQueueUrl, msg.ReceiptHandle); + + await _client.GetAsync($"/api/vehicles?id={uniqueId}"); + await Task.Delay(300); + + var secondBatch = await _fixture.SqsClient.ReceiveMessageAsync(new ReceiveMessageRequest + { + QueueUrl = _fixture.SqsQueueUrl, + MaxNumberOfMessages = 10, + WaitTimeSeconds = 1 + }); + + var duplicateMessage = secondBatch.Messages.FirstOrDefault(m => + { + var doc = JsonDocument.Parse(m.Body); + return doc.RootElement.TryGetProperty("id", out var idProp) + && idProp.GetInt32() == uniqueId; + }); + + Assert.Null(duplicateMessage); + } + + [Fact] + public async Task HealthCheck_ReturnsHealthy() + { + var response = await _client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} From 2e2d9b68b0ec91546da321eb11d5956cc028c036 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Tue, 14 Apr 2026 23:36:12 +0400 Subject: [PATCH 16/23] fix --- Client.Wasm/Components/StudentCard.razor | 4 +- README.md | 43 +++++++++++++++++++ .../WeightedRandomLoadBalancer.cs | 2 +- src/ApiGateway/appsettings.json | 10 +---- src/AppHost/Program.cs | 4 +- src/AppHost/appsettings.json | 1 - src/VehicleApi/Program.cs | 16 ------- src/VehicleApi/appsettings.json | 6 --- 8 files changed, 50 insertions(+), 36 deletions(-) diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 2c0c813c..9f649832 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,9 +4,9 @@ - Номер №1 "Кэширование" + Номер №2 "Балансировка нагрузки" Вариант №17 "Транспортное средство" - Выполнена Чукарев Михаил 6511 + Выполнена Чукаревым Михаилом 6511 Ссылка на форк diff --git a/README.md b/README.md index d4cd8209..496ca3e3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,48 @@ # Современные технологии разработки программного обеспечения +**Вариант №17 — «Транспортное средство»** +**Выполнил:** Чукарев Михаил, группа 6511 + +## Лабораторная работа №2 — «Балансировка нагрузки» + +### Описание + +Реализован ApiGateway с балансировкой нагрузки между тремя репликами сервиса генерации данных о транспортных средствах с оркестрацией через .NET Aspire. + +### Что реализовано + +
+Балансировка нагрузки (Ocelot + WeightedRandom) +
+ +- ApiGateway на базе Ocelot +- Кастомный балансировщик `WeightedRandomLoadBalancer` +- Веса реплик настраиваются через `appsettings.json` (`WeightedRandom:Weights`) +- Три реплики сервиса генерации + +
+
+ +
+CORS +
+ +- Настроен на уровне ApiGateway (`AllowAnyOrigin`, `AllowAnyMethod`, `AllowAnyHeader`) + +
+
+ +
+Оркестрация (.NET Aspire) +
+ +- Redis +- Три реплики VehicleApi +- ApiGateway +- Клиент + +
+
## Лабораторная работа №1 — «Кэширование» diff --git a/src/ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs b/src/ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs index ad757268..75e9782e 100644 --- a/src/ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs +++ b/src/ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs @@ -22,7 +22,7 @@ public Task> LeaseAsync(HttpContext httpContext) } return Task.FromResult>( - new OkResponse(services[^1].HostAndPort)); + new OkResponse(services[Random.Shared.Next(services.Count)].HostAndPort)); } public void Release(ServiceHostAndPort hostAndPort) { } diff --git a/src/ApiGateway/appsettings.json b/src/ApiGateway/appsettings.json index 190e4acd..1aaf9471 100644 --- a/src/ApiGateway/appsettings.json +++ b/src/ApiGateway/appsettings.json @@ -7,15 +7,7 @@ } }, "AllowedHosts": "*", - "Cors": { - "AllowedOrigins": [ - "https://localhost:7282", - "http://localhost:5127" - ] - }, "WeightedRandom": { - "vehicles": { - "Weights": [ 0.5, 0.3, 0.2 ] - } + "Weights": [ 0.5, 0.3, 0.2 ] } } diff --git a/src/AppHost/Program.cs b/src/AppHost/Program.cs index 74b34475..9ef10c94 100644 --- a/src/AppHost/Program.cs +++ b/src/AppHost/Program.cs @@ -23,10 +23,12 @@ { var replica = builder.AddProject($"vehicleapi-{serviceId++}") .WithReference(cache) + .WaitFor(cache) .WithHttpEndpoint(port: port, name: "api-endpoint", isProxied: false) .WithExternalHttpEndpoints(); - gateway.WaitFor(replica); + gateway.WithReference(replica) + .WaitFor(replica); } builder.AddProject("client") diff --git a/src/AppHost/appsettings.json b/src/AppHost/appsettings.json index 2b0c2486..0dbac826 100644 --- a/src/AppHost/appsettings.json +++ b/src/AppHost/appsettings.json @@ -5,7 +5,6 @@ "Microsoft.AspNetCore": "Warning" } }, - "ReplicaWeights": [ 0.5, 0.3, 0.2 ], "ApiService": { "Ports": [ 5101, 5102, 5103 ] }, diff --git a/src/VehicleApi/Program.cs b/src/VehicleApi/Program.cs index d372b002..db45ea6f 100644 --- a/src/VehicleApi/Program.cs +++ b/src/VehicleApi/Program.cs @@ -9,24 +9,8 @@ builder.AddRedisDistributedCache("cache"); -builder.Services.AddCors(options => -{ - options.AddDefaultPolicy(policy => - { - var allowedOrigins = builder.Configuration - .GetSection("Cors:AllowedOrigins") - .Get() ?? []; - - policy.WithOrigins(allowedOrigins) - .AllowAnyMethod() - .AllowAnyHeader(); - }); -}); - var app = builder.Build(); -app.UseCors(); - app.MapDefaultEndpoints(); app.MapGet("/api/vehicles", async (int id, [FromServices] VehicleService vehicleService, ILogger logger) => diff --git a/src/VehicleApi/appsettings.json b/src/VehicleApi/appsettings.json index 0fa53018..416dc860 100644 --- a/src/VehicleApi/appsettings.json +++ b/src/VehicleApi/appsettings.json @@ -1,10 +1,4 @@ { - "Cors": { - "AllowedOrigins": [ - "https://localhost:7282", - "http://localhost:5127" - ] - }, "Cache": { "AbsoluteExpirationMinutes": 10 }, From c02c0227f9851542f2f7872b7f21650df830a2a4 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Thu, 16 Apr 2026 11:28:09 +0400 Subject: [PATCH 17/23] fix --- src/ApiGateway/Program.cs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/ApiGateway/Program.cs b/src/ApiGateway/Program.cs index acdb16fd..4b776543 100644 --- a/src/ApiGateway/Program.cs +++ b/src/ApiGateway/Program.cs @@ -8,6 +8,29 @@ builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); +var ocelotConfig = builder.Configuration.GetSection("Routes:0"); +var hosts = new List(); + +for (var i = 1; ; i++) +{ + var url = builder.Configuration[$"services__vehicleapi-{i}__http__0"]; + if (url == null) break; + + var uri = new Uri(url); + hosts.Add(new { Host = uri.Host, Port = uri.Port }); +} + +if (hosts.Count > 0) +{ + builder.Configuration["Routes:0:DownstreamHostAndPorts"] = null; + for (var i = 0; i < hosts.Count; i++) + { + var h = (dynamic)hosts[i]; + builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Host"] = h.Host; + builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Port"] = h.Port.ToString(); + } +} + builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => From 41eaef425342f1e6dac75653dc43f131cb2b7429 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Thu, 16 Apr 2026 11:30:01 +0400 Subject: [PATCH 18/23] fixx --- src/ApiGateway/Program.cs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/ApiGateway/Program.cs b/src/ApiGateway/Program.cs index 4b776543..23d1872e 100644 --- a/src/ApiGateway/Program.cs +++ b/src/ApiGateway/Program.cs @@ -8,26 +8,21 @@ builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); -var ocelotConfig = builder.Configuration.GetSection("Routes:0"); -var hosts = new List(); +var urls = new List(); for (var i = 1; ; i++) { var url = builder.Configuration[$"services__vehicleapi-{i}__http__0"]; if (url == null) break; - - var uri = new Uri(url); - hosts.Add(new { Host = uri.Host, Port = uri.Port }); + urls.Add(new Uri(url)); } -if (hosts.Count > 0) +if (urls.Count > 0) { - builder.Configuration["Routes:0:DownstreamHostAndPorts"] = null; - for (var i = 0; i < hosts.Count; i++) + for (var i = 0; i < urls.Count; i++) { - var h = (dynamic)hosts[i]; - builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Host"] = h.Host; - builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Port"] = h.Port.ToString(); + builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Host"] = urls[i].Host; + builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Port"] = urls[i].Port.ToString(); } } From d27982f649a8916bdbb03a6a43cd44699d3f3eac Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Tue, 21 Apr 2026 00:10:56 +0400 Subject: [PATCH 19/23] fix --- src/ApiGateway/Program.cs | 8 ++++++-- src/ApiGateway/appsettings.json | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/ApiGateway/Program.cs b/src/ApiGateway/Program.cs index 23d1872e..a521c4e6 100644 --- a/src/ApiGateway/Program.cs +++ b/src/ApiGateway/Program.cs @@ -12,7 +12,7 @@ for (var i = 1; ; i++) { - var url = builder.Configuration[$"services__vehicleapi-{i}__http__0"]; + var url = builder.Configuration[$"services:vehicleapi-{i}:api-endpoint:0"]; if (url == null) break; urls.Add(new Uri(url)); } @@ -30,7 +30,11 @@ { options.AddDefaultPolicy(policy => { - policy.AllowAnyOrigin() + var allowedOrigins = builder.Configuration + .GetSection("Cors:AllowedOrigins") + .Get() ?? []; + + policy.WithOrigins(allowedOrigins) .AllowAnyMethod() .AllowAnyHeader(); }); diff --git a/src/ApiGateway/appsettings.json b/src/ApiGateway/appsettings.json index 1aaf9471..27019413 100644 --- a/src/ApiGateway/appsettings.json +++ b/src/ApiGateway/appsettings.json @@ -8,6 +8,12 @@ }, "AllowedHosts": "*", "WeightedRandom": { - "Weights": [ 0.5, 0.3, 0.2 ] + "Weights": [ 0.5, 0.3, 0.2 ] + }, + "Cors": { + "AllowedOrigins": [ + "https://localhost:7282", + "http://localhost:5127" + ] } -} +} \ No newline at end of file From e8eae783b2ff5106e4d911d6661e8475748bfa4c Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Wed, 6 May 2026 20:28:44 +0400 Subject: [PATCH 20/23] laba3fix --- src/FileService/Program.cs | 6 + .../Services/MinioStorageService.cs | 18 +- src/VehicleApi/Program.cs | 4 + tests/Integration.Tests/AppHostFixture.cs | 51 ++++++ .../BackendIntegrationTests.cs | 128 ++++++++++++++ tests/Integration.Tests/EndToEndTests.cs | 160 ------------------ tests/Integration.Tests/FileServiceFactory.cs | 62 ------- tests/Integration.Tests/FileServiceTests.cs | 156 ----------------- .../Integration.Tests.csproj | 12 +- tests/Integration.Tests/IntegrationFixture.cs | 101 ----------- tests/Integration.Tests/VehicleApiFactory.cs | 57 ------- tests/Integration.Tests/VehicleApiTests.cs | 157 ----------------- 12 files changed, 209 insertions(+), 703 deletions(-) create mode 100644 tests/Integration.Tests/AppHostFixture.cs create mode 100644 tests/Integration.Tests/BackendIntegrationTests.cs delete mode 100644 tests/Integration.Tests/EndToEndTests.cs delete mode 100644 tests/Integration.Tests/FileServiceFactory.cs delete mode 100644 tests/Integration.Tests/FileServiceTests.cs delete mode 100644 tests/Integration.Tests/IntegrationFixture.cs delete mode 100644 tests/Integration.Tests/VehicleApiFactory.cs delete mode 100644 tests/Integration.Tests/VehicleApiTests.cs diff --git a/src/FileService/Program.cs b/src/FileService/Program.cs index dbddb68d..41cfa543 100644 --- a/src/FileService/Program.cs +++ b/src/FileService/Program.cs @@ -38,4 +38,10 @@ app.MapDefaultEndpoints(); +app.MapGet("/files", async (MinioStorageService storage) => +{ + var files = await storage.ListFilesAsync(); + return Results.Ok(files); +}); + app.Run(); diff --git a/src/FileService/Services/MinioStorageService.cs b/src/FileService/Services/MinioStorageService.cs index 271110f4..aa95d964 100644 --- a/src/FileService/Services/MinioStorageService.cs +++ b/src/FileService/Services/MinioStorageService.cs @@ -49,4 +49,20 @@ public async Task SaveAsync(string objectName, string jsonContent) logger.LogInformation("Saved object '{Object}' to bucket '{Bucket}'", objectName, _bucketName); } -} + + /// + /// Возвращает список имён всех файлов в bucket. + /// + public async Task> ListFilesAsync() + { + var items = new List(); + var args = new ListObjectsArgs() + .WithBucket(_bucketName) + .WithRecursive(true); + + await foreach (var item in minio.ListObjectsEnumAsync(args)) + items.Add(item.Key); + + return items; + } +} \ No newline at end of file diff --git a/src/VehicleApi/Program.cs b/src/VehicleApi/Program.cs index 3d25d6c6..45c2a8e2 100644 --- a/src/VehicleApi/Program.cs +++ b/src/VehicleApi/Program.cs @@ -40,3 +40,7 @@ }); app.Run(); + + + +namespace VehicleApi { public partial class Program { } } \ No newline at end of file diff --git a/tests/Integration.Tests/AppHostFixture.cs b/tests/Integration.Tests/AppHostFixture.cs new file mode 100644 index 00000000..c5c84672 --- /dev/null +++ b/tests/Integration.Tests/AppHostFixture.cs @@ -0,0 +1,51 @@ +using Aspire.Hosting; +using Aspire.Hosting.Testing; +using Xunit; + +namespace Integration.Tests; + +/// +/// Поднимает весь Aspire AppHost один раз для всей коллекции тестов. +/// Все контейнеры (Redis, LocalStack, MinIO) запускаются через AppHost — как в продакшне. +/// +public sealed class AppHostFixture : IAsyncLifetime +{ + public HttpClient GatewayClient { get; private set; } = null!; + public HttpClient FileServiceClient { get; private set; } = null!; + + private DistributedApplication _app = null!; + + public async Task InitializeAsync() + { + var builder = await DistributedApplicationTestingBuilder.CreateAsync(); + _app = await builder.BuildAsync(); + await _app.StartAsync(); + + GatewayClient = _app.CreateHttpClient("apigateway"); + FileServiceClient = _app.CreateHttpClient("fileservice"); + + // Ждём, пока gateway реально ответит (контейнеры стартуют не мгновенно) + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + while (true) + { + try + { + var response = await GatewayClient.GetAsync("/api/vehicles?id=1", cts.Token); + if ((int)response.StatusCode < 500) + break; + } + catch + { + // ещё не готов + } + await Task.Delay(2000, cts.Token); + } + } + + public async Task DisposeAsync() + { + GatewayClient?.Dispose(); + FileServiceClient?.Dispose(); + await _app.DisposeAsync(); + } +} diff --git a/tests/Integration.Tests/BackendIntegrationTests.cs b/tests/Integration.Tests/BackendIntegrationTests.cs new file mode 100644 index 00000000..4985ebf9 --- /dev/null +++ b/tests/Integration.Tests/BackendIntegrationTests.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Text.Json; +using Xunit; + +namespace Integration.Tests; + +/// +/// Интеграционные тесты всего бэкенда через Aspire AppHost. +/// Проверяют: gateway, кэш Redis, SQS-пайплайн и сохранение файлов в MinIO. +/// +public sealed class BackendIntegrationTests(AppHostFixture fixture) : IClassFixture +{ + private readonly HttpClient _gatewayClient = fixture.GatewayClient; + private readonly HttpClient _fileServiceClient = fixture.FileServiceClient; + + [Fact] + public async Task GetVehicle_ValidId_ReturnsOk() + { + var response = await _gatewayClient.GetAsync("/api/vehicles?id=1"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetVehicle_ValidId_ReturnsExpectedFields() + { + var response = await _gatewayClient.GetAsync("/api/vehicles?id=5"); + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("id", out _), "Missing 'id' field"); + Assert.True(root.TryGetProperty("vin", out _), "Missing 'vin' field"); + Assert.True(root.TryGetProperty("manufacturer", out _), "Missing 'manufacturer' field"); + Assert.True(root.TryGetProperty("model", out _), "Missing 'model' field"); + Assert.True(root.TryGetProperty("year", out _), "Missing 'year' field"); + } + + [Fact] + public async Task GetVehicle_InvalidId_ReturnsBadRequest() + { + var response = await _gatewayClient.GetAsync("/api/vehicles?id=0"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory] + [InlineData(-1)] + [InlineData(-100)] + public async Task GetVehicle_NegativeId_ReturnsBadRequest(int id) + { + var response = await _gatewayClient.GetAsync($"/api/vehicles?id={id}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetVehicle_SameId_ReturnsCachedData() + { + var r1 = await _gatewayClient.GetAsync("/api/vehicles?id=42"); + var r2 = await _gatewayClient.GetAsync("/api/vehicles?id=42"); + + r1.EnsureSuccessStatusCode(); + r2.EnsureSuccessStatusCode(); + + var json1 = await r1.Content.ReadAsStringAsync(); + var json2 = await r2.Content.ReadAsStringAsync(); + + Assert.Equal(json1, json2); + } + + [Fact] + public async Task GetVehicle_DifferentIds_ReturnDifferentData() + { + var r1 = await _gatewayClient.GetAsync("/api/vehicles?id=10"); + var r2 = await _gatewayClient.GetAsync("/api/vehicles?id=20"); + + r1.EnsureSuccessStatusCode(); + r2.EnsureSuccessStatusCode(); + + using var doc1 = JsonDocument.Parse(await r1.Content.ReadAsStringAsync()); + using var doc2 = JsonDocument.Parse(await r2.Content.ReadAsStringAsync()); + + Assert.Equal(10, doc1.RootElement.GetProperty("id").GetInt32()); + Assert.Equal(20, doc2.RootElement.GetProperty("id").GetInt32()); + } + + [Fact] + public async Task GetVehicle_FileAppearsInMinio() + { + var id = Random.Shared.Next(50_000, 99_999); + var deadline = DateTime.UtcNow.AddSeconds(30); + + await _gatewayClient.GetAsync($"/api/vehicles?id={id}"); + + while (DateTime.UtcNow < deadline) + { + await Task.Delay(2000); + + var filesResponse = await _fileServiceClient.GetAsync("/files"); + if (!filesResponse.IsSuccessStatusCode) continue; + + var files = JsonSerializer.Deserialize>( + await filesResponse.Content.ReadAsStringAsync()) ?? []; + + if (files.Any(f => f.Contains(id.ToString()))) + return; + } + + Assert.Fail($"Файл для vehicle id={id} не появился в MinIO за 30 секунд"); + } + + [Fact] + public async Task FileService_HealthCheck_ReturnsOk() + { + var response = await _fileServiceClient.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Gateway_HealthCheck_ReturnsOk() + { + var response = await _gatewayClient.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/tests/Integration.Tests/EndToEndTests.cs b/tests/Integration.Tests/EndToEndTests.cs deleted file mode 100644 index 77f75fc9..00000000 --- a/tests/Integration.Tests/EndToEndTests.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System.Net; -using System.Text.Json; -using Minio.DataModel.Args; -using Xunit; - -namespace Integration.Tests; - -/// -/// Сквозные (end-to-end) тесты всего бэкенда: -/// HTTP-запрос к VehicleApi → публикация в SQS → потребление FileService → сохранение в MinIO. -/// -[Collection(nameof(IntegrationCollection))] -public class EndToEndTests( - VehicleApiFactory vehicleApiFactory, - FileServiceFactory fileServiceFactory, - IntegrationFixture fixture) - : IClassFixture, IClassFixture -{ - private readonly HttpClient _vehicleClient = vehicleApiFactory.CreateClient(); - - [Fact] - public async Task RequestVehicle_FullPipeline_FileAppearsInMinio() - { - var vehicleId = Random.Shared.Next(300_000, 399_999); - - var response = await _vehicleClient.GetAsync($"/api/vehicles?id={vehicleId}"); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var json = await response.Content.ReadAsStringAsync(); - var vehicle = JsonDocument.Parse(json).RootElement; - var returnedVin = vehicle.GetProperty("vin").GetString(); - - List objects = []; - var deadline = DateTime.UtcNow.AddSeconds(15); - - while (DateTime.UtcNow < deadline) - { - objects = await ListObjectsAsync(IntegrationFixture.BucketName); - var found = await FindVehicleInMinioAsync(objects, vehicleId); - if (found) break; - await Task.Delay(500); - } - - var matchingFile = await FindVehicleFileAsync(objects, vehicleId); - Assert.NotNull(matchingFile); - - var fileContent = await GetObjectContentAsync(IntegrationFixture.BucketName, matchingFile); - var savedDoc = JsonDocument.Parse(fileContent).RootElement; - - Assert.Equal(vehicleId, savedDoc.GetProperty("id").GetInt32()); - Assert.Equal(returnedVin, savedDoc.GetProperty("vin").GetString()); - } - - [Fact] - public async Task RequestVehicle_SecondRequest_OnlySingleFileInMinio() - { - var vehicleId = Random.Shared.Next(400_000, 499_999); - - await _vehicleClient.GetAsync($"/api/vehicles?id={vehicleId}"); - await Task.Delay(3000); - - var objectsAfterFirst = await ListObjectsAsync(IntegrationFixture.BucketName); - var firstCount = objectsAfterFirst.Count(o => o.Contains(vehicleId.ToString()) == false - ); - - var totalAfterFirst = objectsAfterFirst.Count; - - await _vehicleClient.GetAsync($"/api/vehicles?id={vehicleId}"); - await Task.Delay(2000); - - var objectsAfterSecond = await ListObjectsAsync(IntegrationFixture.BucketName); - - Assert.Equal(totalAfterFirst, objectsAfterSecond.Count); - } - - [Fact] - public async Task MultipleVehicles_AllFilesAppearInMinio() - { - var ids = Enumerable.Range(500_000, 3).ToList(); - var initialCount = (await ListObjectsAsync(IntegrationFixture.BucketName)).Count; - - foreach (var id in ids) - await _vehicleClient.GetAsync($"/api/vehicles?id={id}"); - - List objects = []; - var deadline = DateTime.UtcNow.AddSeconds(15); - - while (DateTime.UtcNow < deadline) - { - objects = await ListObjectsAsync(IntegrationFixture.BucketName); - if (objects.Count >= initialCount + ids.Count) break; - await Task.Delay(500); - } - - Assert.True(objects.Count >= initialCount + ids.Count, - $"Expected at least {initialCount + ids.Count} objects in MinIO, found {objects.Count}"); - } - - private async Task> ListObjectsAsync(string bucketName) - { - var items = new List(); - var tcs = new TaskCompletionSource>(); - - var args = new ListObjectsArgs().WithBucket(bucketName).WithRecursive(true); - var observable = fixture.MinioClient.ListObjectsAsync(args); - - var sub = observable.Subscribe( - item => items.Add(item.Key), - ex => tcs.TrySetException(ex), - () => tcs.TrySetResult(items)); - - using (sub) - return await tcs.Task.WaitAsync(TimeSpan.FromSeconds(10)); - } - - private async Task FindVehicleInMinioAsync(List objectNames, int vehicleId) - { - foreach (var name in objectNames) - { - var content = await GetObjectContentAsync(IntegrationFixture.BucketName, name); - try - { - var doc = JsonDocument.Parse(content).RootElement; - if (doc.TryGetProperty("id", out var idProp) && idProp.GetInt32() == vehicleId) - return true; - } - catch {} - } - return false; - } - - private async Task FindVehicleFileAsync(List objectNames, int vehicleId) - { - foreach (var name in objectNames) - { - var content = await GetObjectContentAsync(IntegrationFixture.BucketName, name); - try - { - var doc = JsonDocument.Parse(content).RootElement; - if (doc.TryGetProperty("id", out var idProp) && idProp.GetInt32() == vehicleId) - return name; - } - catch {} - } - return null; - } - - private async Task GetObjectContentAsync(string bucketName, string objectName) - { - using var ms = new MemoryStream(); - await fixture.MinioClient.GetObjectAsync( - new GetObjectArgs() - .WithBucket(bucketName) - .WithObject(objectName) - .WithCallbackStream(stream => stream.CopyTo(ms))); - - ms.Position = 0; - return System.Text.Encoding.UTF8.GetString(ms.ToArray()); - } -} diff --git a/tests/Integration.Tests/FileServiceFactory.cs b/tests/Integration.Tests/FileServiceFactory.cs deleted file mode 100644 index f5607231..00000000 --- a/tests/Integration.Tests/FileServiceFactory.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Amazon.SQS; -using FileService.Services; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Minio; - -namespace Integration.Tests; - -/// -/// Фабрика для поднятия FileService в тестовом окружении. -/// Подменяет MinIO и SQS на тестовые контейнеры. -/// -public class FileServiceFactory(IntegrationFixture fixture) - : WebApplicationFactory -{ - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseEnvironment("Testing"); - - builder.ConfigureServices(services => - { - services.RemoveAll(); - services.RemoveAll(); - services.RemoveAll(); - services.RemoveAll(); - - var sqsCreds = new Amazon.Runtime.BasicAWSCredentials("test", "test"); - var sqsConfig = new AmazonSQSConfig { ServiceURL = fixture.SqsServiceUrl }; - services.AddSingleton(_ => new AmazonSQSClient(sqsCreds, sqsConfig)); - - services.AddSingleton(_ => - new MinioClient() - .WithEndpoint(fixture.MinioEndpoint) - .WithCredentials(fixture.MinioAccessKey, fixture.MinioSecretKey) - .WithSSL(false) - .Build()); - - services.AddSingleton(); - services.AddHostedService(); - }); - - builder.ConfigureAppConfiguration((_, config) => - { - config.AddInMemoryCollection(new Dictionary - { - ["Sqs:QueueUrl"] = fixture.SqsQueueUrl, - ["Sqs:ServiceUrl"] = fixture.SqsServiceUrl, - ["Sqs:AccessKey"] = "test", - ["Sqs:SecretKey"] = "test", - ["Sqs:PollingIntervalMs"] = "200", - ["Minio:Endpoint"] = fixture.MinioEndpoint, - ["Minio:AccessKey"] = "minioadmin", - ["Minio:SecretKey"] = "minioadmin", - ["Minio:BucketName"] = IntegrationFixture.BucketName, - ["Minio:UseSSL"] = "false" - }); - }); - } -} diff --git a/tests/Integration.Tests/FileServiceTests.cs b/tests/Integration.Tests/FileServiceTests.cs deleted file mode 100644 index 9d379423..00000000 --- a/tests/Integration.Tests/FileServiceTests.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Text.Json; -using Amazon.SQS.Model; -using Minio.DataModel.Args; -using Xunit; - -namespace Integration.Tests; - -/// -/// Интеграционные тесты FileService: потребление из SQS и сохранение в MinIO. -/// -[Collection(nameof(IntegrationCollection))] -public class FileServiceTests : IClassFixture -{ - private readonly IntegrationFixture _fixture; - private readonly HttpClient _fileServiceClient; - - public FileServiceTests(FileServiceFactory factory, IntegrationFixture fixture) - { - _fixture = fixture; - _fileServiceClient = factory.CreateClient(); - } - - [Fact] - public async Task FileService_ConsumesMessage_SavesFileToMinio() - { - var vehicle = new - { - id = 777, - vin = "1HGBH41JXMN109186", - manufacturer = "Honda", - model = "Civic", - year = 2015, - bodyType = "Sedan", - fuelType = "Gasoline", - color = "White", - mileage = 75000.0, - lastServiceDate = "2023-06-01" - }; - - var messageBody = JsonSerializer.Serialize(vehicle); - - await _fixture.SqsClient.SendMessageAsync(new SendMessageRequest - { - QueueUrl = _fixture.SqsQueueUrl, - MessageBody = messageBody - }); - - await Task.Delay(3000); - - var objectsInBucket = await ListObjectsAsync(IntegrationFixture.BucketName); - - Assert.NotEmpty(objectsInBucket); - - var savedFile = objectsInBucket.First(); - var content = await GetObjectContentAsync(IntegrationFixture.BucketName, savedFile); - var savedDoc = JsonDocument.Parse(content); - - Assert.Equal(777, savedDoc.RootElement.GetProperty("id").GetInt32()); - Assert.Equal("1HGBH41JXMN109186", savedDoc.RootElement.GetProperty("vin").GetString()); - } - - [Fact] - public async Task FileService_ConsumesMultipleMessages_SavesAllFilesToMinio() - { - var vehicleIds = new[] { 801, 802, 803 }; - - foreach (var id in vehicleIds) - { - var vehicle = new { id, vin = $"VIN{id:D10}", manufacturer = "Toyota", model = "Corolla" }; - await _fixture.SqsClient.SendMessageAsync(new SendMessageRequest - { - QueueUrl = _fixture.SqsQueueUrl, - MessageBody = JsonSerializer.Serialize(vehicle) - }); - } - - await Task.Delay(4000); - - var objects = await ListObjectsAsync(IntegrationFixture.BucketName); - - Assert.True(objects.Count >= vehicleIds.Length, - $"Expected at least {vehicleIds.Length} objects in MinIO, found {objects.Count}"); - } - - [Fact] - public async Task FileService_DeletesMessageAfterProcessing() - { - var vehicle = new { id = 900, vin = "DELETEME123456789", manufacturer = "Ford" }; - - await _fixture.SqsClient.SendMessageAsync(new SendMessageRequest - { - QueueUrl = _fixture.SqsQueueUrl, - MessageBody = JsonSerializer.Serialize(vehicle) - }); - - await Task.Delay(3000); - - var remaining = await _fixture.SqsClient.ReceiveMessageAsync(new ReceiveMessageRequest - { - QueueUrl = _fixture.SqsQueueUrl, - MaxNumberOfMessages = 10, - WaitTimeSeconds = 1 - }); - - var ourMessage = remaining.Messages.FirstOrDefault(m => m.Body.Contains("DELETEME123456789")); - - Assert.Null(ourMessage); - } - - [Fact] - public async Task FileService_HealthCheck_ReturnsHealthy() - { - var response = await _fileServiceClient.GetAsync("/health"); - - Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); - } - - private async Task> ListObjectsAsync(string bucketName) - { - var result = new List(); - - var args = new ListObjectsArgs() - .WithBucket(bucketName) - .WithRecursive(true); - - var tcs = new TaskCompletionSource>(); - var items = new List(); - - var observable = _fixture.MinioClient.ListObjectsAsync(args); - var subscription = observable.Subscribe( - item => items.Add(item.Key), - ex => tcs.TrySetException(ex), - () => tcs.TrySetResult(items)); - - using (subscription) - { - result = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(10)); - } - - return result; - } - - private async Task GetObjectContentAsync(string bucketName, string objectName) - { - using var ms = new MemoryStream(); - - await _fixture.MinioClient.GetObjectAsync( - new GetObjectArgs() - .WithBucket(bucketName) - .WithObject(objectName) - .WithCallbackStream(stream => stream.CopyTo(ms))); - - ms.Position = 0; - return System.Text.Encoding.UTF8.GetString(ms.ToArray()); - } -} diff --git a/tests/Integration.Tests/Integration.Tests.csproj b/tests/Integration.Tests/Integration.Tests.csproj index 4c577994..4a3375e8 100644 --- a/tests/Integration.Tests/Integration.Tests.csproj +++ b/tests/Integration.Tests/Integration.Tests.csproj @@ -15,17 +15,11 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - - + - - + - + \ No newline at end of file diff --git a/tests/Integration.Tests/IntegrationFixture.cs b/tests/Integration.Tests/IntegrationFixture.cs deleted file mode 100644 index 013e7685..00000000 --- a/tests/Integration.Tests/IntegrationFixture.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Amazon.SQS; -using Amazon.SQS.Model; -using DotNet.Testcontainers.Builders; -using DotNet.Testcontainers.Containers; -using Minio; -using Minio.DataModel.Args; -using Testcontainers.Minio; -using Testcontainers.Redis; -using Xunit; - -namespace Integration.Tests; - -/// -/// Общая тестовая фикстура: поднимает Redis, LocalStack (SQS) и MinIO -/// один раз для всей коллекции интеграционных тестов. -/// -public class IntegrationFixture : IAsyncLifetime -{ - public const string QueueName = "vehicles"; - public const string BucketName = "vehicles"; - - public string MinioAccessKey { get; private set; } = null!; - public string MinioSecretKey { get; private set; } = null!; - - private readonly RedisContainer _redis = new RedisBuilder() - .WithImage("redis:7-alpine") - .Build(); - - private readonly IContainer _localstack = new ContainerBuilder() - .WithImage("localstack/localstack:latest") - .WithEnvironment("SERVICES", "sqs") - .WithEnvironment("DEFAULT_REGION", "us-east-1") - .WithEnvironment("AWS_DEFAULT_REGION", "us-east-1") - .WithEnvironment("AWS_ACCESS_KEY_ID", "test") - .WithEnvironment("AWS_SECRET_ACCESS_KEY", "test") - .WithPortBinding(0, 4566) - .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => - r.ForPort(4566).ForPath("/_localstack/health"))) - .Build(); - - private readonly MinioContainer _minio = new MinioBuilder() - .WithImage("minio/minio:latest") - .Build(); - - public IAmazonSQS SqsClient { get; private set; } = null!; - public IMinioClient MinioClient { get; private set; } = null!; - public string RedisConnectionString { get; private set; } = null!; - public string SqsServiceUrl { get; private set; } = null!; - public string SqsQueueUrl { get; private set; } = null!; - public string MinioEndpoint { get; private set; } = null!; - - public async Task InitializeAsync() - { - await Task.WhenAll( - _redis.StartAsync(), - _localstack.StartAsync(), - _minio.StartAsync()); - - RedisConnectionString = _redis.GetConnectionString(); - - SqsServiceUrl = $"http://localhost:{_localstack.GetMappedPublicPort(4566)}"; - - var sqsCreds = new Amazon.Runtime.BasicAWSCredentials("test", "test"); - var sqsConfig = new AmazonSQSConfig { ServiceURL = SqsServiceUrl }; - SqsClient = new AmazonSQSClient(sqsCreds, sqsConfig); - - var createQueueResponse = await SqsClient.CreateQueueAsync(new CreateQueueRequest - { - QueueName = QueueName - }); - SqsQueueUrl = createQueueResponse.QueueUrl; - - MinioEndpoint = $"{_minio.Hostname}:{_minio.GetMappedPublicPort(9000)}"; - MinioAccessKey = _minio.GetAccessKey(); - MinioSecretKey = _minio.GetSecretKey(); - - MinioClient = new MinioClient() - .WithEndpoint(MinioEndpoint) - .WithCredentials(MinioAccessKey, MinioSecretKey) - .WithSSL(false) - .Build(); - - await MinioClient.MakeBucketAsync(new MakeBucketArgs().WithBucket(BucketName)); - } - - public async Task DisposeAsync() - { - SqsClient.Dispose(); - - await Task.WhenAll( - _redis.StopAsync(), - _localstack.StopAsync(), - _minio.StopAsync()); - } -} - -/// -/// Коллекция, позволяющая переиспользовать одну фикстуру между тест-классами. -/// -[CollectionDefinition(nameof(IntegrationCollection))] -public class IntegrationCollection : ICollectionFixture; diff --git a/tests/Integration.Tests/VehicleApiFactory.cs b/tests/Integration.Tests/VehicleApiFactory.cs deleted file mode 100644 index a9be06f0..00000000 --- a/tests/Integration.Tests/VehicleApiFactory.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Amazon.SQS; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using StackExchange.Redis; -using VehicleApi.Services; - -namespace Integration.Tests; - -/// -/// Фабрика для поднятия VehicleApi в тестовом окружении. -/// Подменяет Redis, SQS и ServiceDiscovery на тестовые контейнеры. -/// -public class VehicleApiFactory(IntegrationFixture fixture) - : WebApplicationFactory -{ - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseEnvironment("Testing"); - - builder.ConfigureServices(services => - { - services.RemoveAll(); - services.RemoveAll(); - services.RemoveAll(); - services.RemoveAll(); - - services.AddSingleton(_ => - ConnectionMultiplexer.Connect(fixture.RedisConnectionString)); - - services.AddStackExchangeRedisCache(opts => - opts.Configuration = fixture.RedisConnectionString); - - var sqsCreds = new Amazon.Runtime.BasicAWSCredentials("test", "test"); - var sqsConfig = new AmazonSQSConfig { ServiceURL = fixture.SqsServiceUrl }; - var sqsClient = new AmazonSQSClient(sqsCreds, sqsConfig); - - services.AddSingleton(_ => sqsClient); - services.AddSingleton(); - }); - - builder.ConfigureAppConfiguration((_, config) => - { - config.AddInMemoryCollection(new Dictionary - { - ["Sqs:QueueUrl"] = fixture.SqsQueueUrl, - ["Sqs:ServiceUrl"] = fixture.SqsServiceUrl, - ["Sqs:AccessKey"] = "test", - ["Sqs:SecretKey"] = "test", - ["Cors:AllowedOrigins:0"] = "http://localhost" - }); - }); - } -} diff --git a/tests/Integration.Tests/VehicleApiTests.cs b/tests/Integration.Tests/VehicleApiTests.cs deleted file mode 100644 index d1e0fd7c..00000000 --- a/tests/Integration.Tests/VehicleApiTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Net; -using System.Text.Json; -using Amazon.SQS.Model; -using Xunit; - -namespace Integration.Tests; - -/// -/// Интеграционные тесты VehicleApi: HTTP-эндпоинт, кэш Redis и публикация в SQS. -/// -[Collection(nameof(IntegrationCollection))] -public class VehicleApiTests : IClassFixture -{ - private readonly HttpClient _client; - private readonly IntegrationFixture _fixture; - - public VehicleApiTests(VehicleApiFactory factory, IntegrationFixture fixture) - { - _client = factory.CreateClient(); - _fixture = fixture; - } - - [Fact] - public async Task GetVehicle_ValidId_ReturnsOk() - { - var response = await _client.GetAsync("/api/vehicles?id=1"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task GetVehicle_ValidId_ReturnsExpectedFields() - { - var response = await _client.GetAsync("/api/vehicles?id=5"); - var json = await response.Content.ReadAsStringAsync(); - var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.True(root.TryGetProperty("id", out _), "Missing 'id' field"); - Assert.True(root.TryGetProperty("vin", out _), "Missing 'vin' field"); - Assert.True(root.TryGetProperty("manufacturer", out _), "Missing 'manufacturer' field"); - Assert.True(root.TryGetProperty("model", out _), "Missing 'model' field"); - Assert.True(root.TryGetProperty("year", out _), "Missing 'year' field"); - Assert.True(root.TryGetProperty("bodyType", out _), "Missing 'bodyType' field"); - Assert.True(root.TryGetProperty("fuelType", out _), "Missing 'fuelType' field"); - Assert.True(root.TryGetProperty("color", out _), "Missing 'color' field"); - Assert.True(root.TryGetProperty("mileage", out _), "Missing 'mileage' field"); - Assert.True(root.TryGetProperty("lastServiceDate", out _), "Missing 'lastServiceDate' field"); - } - - [Fact] - public async Task GetVehicle_SameId_ReturnsSameData() - { - var r1 = await _client.GetAsync("/api/vehicles?id=42"); - var r2 = await _client.GetAsync("/api/vehicles?id=42"); - - var json1 = await r1.Content.ReadAsStringAsync(); - var json2 = await r2.Content.ReadAsStringAsync(); - - Assert.Equal(json1, json2); - } - - [Fact] - public async Task GetVehicle_DifferentIds_ReturnsDifferentData() - { - var r1 = await _client.GetAsync("/api/vehicles?id=10"); - var r2 = await _client.GetAsync("/api/vehicles?id=20"); - - var json1 = await r1.Content.ReadAsStringAsync(); - var json2 = await r2.Content.ReadAsStringAsync(); - - Assert.NotEqual(json1, json2); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(-100)] - public async Task GetVehicle_InvalidId_ReturnsBadRequest(int id) - { - var response = await _client.GetAsync($"/api/vehicles?id={id}"); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task GetVehicle_OnCacheMiss_PublishesMessageToSqs() - { - var uniqueId = Random.Shared.Next(100_000, 999_999); - - await _client.GetAsync($"/api/vehicles?id={uniqueId}"); - - await Task.Delay(300); - - var messages = await _fixture.SqsClient.ReceiveMessageAsync(new ReceiveMessageRequest - { - QueueUrl = _fixture.SqsQueueUrl, - MaxNumberOfMessages = 10, - WaitTimeSeconds = 2 - }); - - var vehicleMessage = messages.Messages.FirstOrDefault(m => - { - var doc = JsonDocument.Parse(m.Body); - return doc.RootElement.TryGetProperty("id", out var idProp) - && idProp.GetInt32() == uniqueId; - }); - - Assert.NotNull(vehicleMessage); - } - - [Fact] - public async Task GetVehicle_OnCacheHit_DoesNotPublishDuplicateToSqs() - { - var uniqueId = Random.Shared.Next(200_000, 299_999); - - await _client.GetAsync($"/api/vehicles?id={uniqueId}"); - await Task.Delay(300); - - var firstBatch = await _fixture.SqsClient.ReceiveMessageAsync(new ReceiveMessageRequest - { - QueueUrl = _fixture.SqsQueueUrl, - MaxNumberOfMessages = 10, - WaitTimeSeconds = 2 - }); - - foreach (var msg in firstBatch.Messages) - await _fixture.SqsClient.DeleteMessageAsync(_fixture.SqsQueueUrl, msg.ReceiptHandle); - - await _client.GetAsync($"/api/vehicles?id={uniqueId}"); - await Task.Delay(300); - - var secondBatch = await _fixture.SqsClient.ReceiveMessageAsync(new ReceiveMessageRequest - { - QueueUrl = _fixture.SqsQueueUrl, - MaxNumberOfMessages = 10, - WaitTimeSeconds = 1 - }); - - var duplicateMessage = secondBatch.Messages.FirstOrDefault(m => - { - var doc = JsonDocument.Parse(m.Body); - return doc.RootElement.TryGetProperty("id", out var idProp) - && idProp.GetInt32() == uniqueId; - }); - - Assert.Null(duplicateMessage); - } - - [Fact] - public async Task HealthCheck_ReturnsHealthy() - { - var response = await _client.GetAsync("/health"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } -} From e3e0a0282e2defb58afc93fcc073601c99a1662b Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Wed, 6 May 2026 21:41:53 +0400 Subject: [PATCH 21/23] fixxx --- Client.Wasm/Components/StudentCard.razor | 2 +- src/ApiGateway/Program.cs | 11 +++++- src/AppHost/Program.cs | 30 +++++++++------- .../Services/SqsConsumerService.cs | 35 +++++++++++-------- src/VehicleApi/Program.cs | 3 ++ .../Services/SqsPublisherService.cs | 15 ++++++++ src/VehicleApi/Services/VehicleService.cs | 2 +- tests/Integration.Tests/AppHostFixture.cs | 4 +-- 8 files changed, 69 insertions(+), 33 deletions(-) diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 9f649832..e272049c 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,7 +4,7 @@ - Номер №2 "Балансировка нагрузки" + Номер №3 "Интеграционное тестирование" Вариант №17 "Транспортное средство" Выполнена Чукаревым Михаилом 6511 Ссылка на форк diff --git a/src/ApiGateway/Program.cs b/src/ApiGateway/Program.cs index a521c4e6..91a54015 100644 --- a/src/ApiGateway/Program.cs +++ b/src/ApiGateway/Program.cs @@ -1,6 +1,7 @@ using ApiGateway.LoadBalancing; using Ocelot.DependencyInjection; using Ocelot.Middleware; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; var builder = WebApplication.CreateBuilder(args); @@ -54,7 +55,15 @@ var app = builder.Build(); app.UseCors(); -app.MapDefaultEndpoints(); +app.UseRouting(); +app.UseEndpoints(endpoints => +{ + endpoints.MapHealthChecks("/health"); + endpoints.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); +}); await app.UseOcelot(); diff --git a/src/AppHost/Program.cs b/src/AppHost/Program.cs index 30bcb9a1..4307b487 100644 --- a/src/AppHost/Program.cs +++ b/src/AppHost/Program.cs @@ -31,10 +31,16 @@ .WithEnvironment("LOCALSTACK_ACKNOWLEDGE_ACCOUNT_REQUIREMENT", "1") .WithHttpEndpoint(port: 4566, targetPort: 4566, name: "api"); +var localstackEndpoint = localstack.GetEndpoint("api"); +var minioEndpoint = minio.GetEndpoint("api"); + +var sqsServiceUrl = ReferenceExpression.Create($"http://{localstackEndpoint.Property(EndpointProperty.Host)}:{localstackEndpoint.Property(EndpointProperty.Port)}"); +var sqsQueueUrl = ReferenceExpression.Create($"http://{localstackEndpoint.Property(EndpointProperty.Host)}:{localstackEndpoint.Property(EndpointProperty.Port)}/000000000000/vehicles"); + var fileService = builder.AddProject("fileservice") - .WithEnvironment("Sqs__ServiceUrl", "http://localhost:4566") - .WithEnvironment("Sqs__QueueUrl", "http://localhost:4566/000000000000/vehicles") - .WithEnvironment("Minio__Endpoint", "localhost:9000") + .WithEnvironment("Sqs__ServiceUrl", sqsServiceUrl) + .WithEnvironment("Sqs__QueueUrl", sqsQueueUrl) + .WithEnvironment("Minio__Endpoint", ReferenceExpression.Create($"{minioEndpoint.Property(EndpointProperty.Host)}:{minioEndpoint.Property(EndpointProperty.Port)}")) .WaitFor(minio) .WaitFor(localstack); @@ -46,16 +52,16 @@ foreach (var port in ports) { var replica = builder.AddProject($"vehicleapi-{serviceId++}") - .WithReference(cache) - .WithEnvironment("Sqs__ServiceUrl", "http://localhost:4566") - .WithEnvironment("Sqs__QueueUrl", "http://localhost:4566/000000000000/vehicles") - .WithHttpEndpoint(port: port, name: "api-endpoint", isProxied: false) - .WithExternalHttpEndpoints() - .WaitFor(cache) - .WaitFor(localstack); + .WithReference(cache) + .WithEnvironment("Sqs__ServiceUrl", sqsServiceUrl) + .WithEnvironment("Sqs__QueueUrl", sqsQueueUrl) + .WithHttpEndpoint(port: port, name: "api-endpoint", isProxied: false) + .WithExternalHttpEndpoints() + .WaitFor(cache) + .WaitFor(localstack); gateway.WithReference(replica) - .WaitFor(replica); + .WaitFor(replica); fileService.WaitFor(replica); } @@ -63,4 +69,4 @@ .WithReference(gateway) .WaitFor(gateway); -builder.Build().Run(); +builder.Build().Run(); \ No newline at end of file diff --git a/src/FileService/Services/SqsConsumerService.cs b/src/FileService/Services/SqsConsumerService.cs index 8229efd2..003422d4 100644 --- a/src/FileService/Services/SqsConsumerService.cs +++ b/src/FileService/Services/SqsConsumerService.cs @@ -73,23 +73,28 @@ private async Task PollAsync(CancellationToken ct) } private async Task ProcessMessageAsync(Message message, CancellationToken ct) +{ + try { - try - { - var objectName = $"vehicle-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss-fff}-{message.MessageId[..8]}.json"; - - await storage.SaveAsync(objectName, message.Body); - - await sqs.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, ct); - - logger.LogInformation("Processed message {MessageId} → saved as '{ObjectName}'", - message.MessageId, objectName); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to process message {MessageId}", message.MessageId); - } + using var doc = System.Text.Json.JsonDocument.Parse(message.Body); + var root = doc.RootElement; + + var id = 0; + if (root.TryGetProperty("Id", out var idProp) || root.TryGetProperty("id", out idProp)) + id = idProp.GetInt32(); + var objectName = $"vehicle-{id}-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss-fff}-{message.MessageId[..8]}.json"; + + await storage.SaveAsync(objectName, message.Body); + await sqs.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, ct); + + logger.LogInformation("Processed message {MessageId} → saved as '{ObjectName}'", + message.MessageId, objectName); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process message {MessageId}", message.MessageId); } +} private async Task EnsureQueueExistsAsync() { try diff --git a/src/VehicleApi/Program.cs b/src/VehicleApi/Program.cs index 45c2a8e2..efb045fc 100644 --- a/src/VehicleApi/Program.cs +++ b/src/VehicleApi/Program.cs @@ -25,6 +25,9 @@ var app = builder.Build(); +var publisher = app.Services.GetRequiredService(); +await publisher.EnsureQueueExistsAsync(); + app.MapDefaultEndpoints(); app.MapGet("/api/vehicles", async (int id, [FromServices] VehicleService vehicleService, ILogger logger) => diff --git a/src/VehicleApi/Services/SqsPublisherService.cs b/src/VehicleApi/Services/SqsPublisherService.cs index 50fbad19..cfb29e21 100644 --- a/src/VehicleApi/Services/SqsPublisherService.cs +++ b/src/VehicleApi/Services/SqsPublisherService.cs @@ -31,4 +31,19 @@ public async Task PublishAsync(Vehicle vehicle) logger.LogInformation("Published vehicle {Id} to SQS, MessageId: {MessageId}", vehicle.Id, response.MessageId); } + public async Task EnsureQueueExistsAsync() +{ + try + { + var queueName = _queueUrl.Split('/').Last(); + await sqs.CreateQueueAsync(new Amazon.SQS.Model.CreateQueueRequest { QueueName = queueName }); + logger.LogInformation("SQS queue ensured by VehicleApi"); + } + catch (Exception ex) + { + logger.LogWarning("Could not create queue: {Message}", ex.Message); + } +} } + + diff --git a/src/VehicleApi/Services/VehicleService.cs b/src/VehicleApi/Services/VehicleService.cs index dcd5f036..36c72237 100644 --- a/src/VehicleApi/Services/VehicleService.cs +++ b/src/VehicleApi/Services/VehicleService.cs @@ -33,7 +33,7 @@ public async Task GetByIdAsync(int id) } logger.LogInformation("Cache miss for vehicle ID {Id}", id); - var vehicle = VehicleGenerator.Generate(id); + var vehicle = VehicleGenerator.Generate(id) with { Id = id }; var serialized = JsonSerializer.SerializeToUtf8Bytes(vehicle); await cache.SetAsync(cacheKey, serialized, GetCacheOptions()); diff --git a/tests/Integration.Tests/AppHostFixture.cs b/tests/Integration.Tests/AppHostFixture.cs index c5c84672..887205a3 100644 --- a/tests/Integration.Tests/AppHostFixture.cs +++ b/tests/Integration.Tests/AppHostFixture.cs @@ -6,7 +6,6 @@ namespace Integration.Tests; /// /// Поднимает весь Aspire AppHost один раз для всей коллекции тестов. -/// Все контейнеры (Redis, LocalStack, MinIO) запускаются через AppHost — как в продакшне. /// public sealed class AppHostFixture : IAsyncLifetime { @@ -21,10 +20,9 @@ public async Task InitializeAsync() _app = await builder.BuildAsync(); await _app.StartAsync(); - GatewayClient = _app.CreateHttpClient("apigateway"); + GatewayClient = _app.CreateHttpClient("apigateway", "gateway-endpoint"); FileServiceClient = _app.CreateHttpClient("fileservice"); - // Ждём, пока gateway реально ответит (контейнеры стартуют не мгновенно) using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); while (true) { From cd447e21357c3f333f3b412f0479f2bd883a65b8 Mon Sep 17 00:00:00 2001 From: Mishachuu <113331162+Mishachuu@users.noreply.github.com> Date: Wed, 6 May 2026 23:53:10 +0400 Subject: [PATCH 22/23] Update README with Lab Work 3 details Added details about the third laboratory work on integration testing, including descriptions of the file service, message broker, object storage, and integration tests. --- README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/README.md b/README.md index 496ca3e3..189f3edf 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,56 @@ **Вариант №17 — «Транспортное средство»** **Выполнил:** Чукарев Михаил, группа 6511 +## Лабораторная работа №3 — «Интеграционное тестирование» + +### Описание + +Реализован файловый сервис, сохраняющий данные о транспортных средствах в объектное хранилище через брокер сообщений, а также интеграционные тесты, проверяющие корректность работы всего бэкенда. + +### Что реализовано + +
+Брокер сообщений (Amazon SQS + LocalStack) +
+ +
+Файловый сервис (FileService) +
+ +- `MinioStorageService` — сохраняет JSON каждого сообщения как отдельный файл в MinIO +- Эндпоинт `GET /files` — список всех файлов в bucket + +
+
+ +
+Объектное хранилище (MinIO) +
+ +- Bucket `vehicles` создаётся автоматически при старте FileService +- Хранение JSON-файлов с данными транспортных средств + +
+
+ +
+Интеграционные тесты (xUnit + Aspire Testing) +
+ +- Поднимают весь AppHost целиком (Redis, LocalStack, MinIO, все сервисы) +- `GetVehicle_ValidId_ReturnsOk` — gateway возвращает 200 +- `GetVehicle_ValidId_ReturnsExpectedFields` — ответ содержит все поля +- `GetVehicle_InvalidId_ReturnsBadRequest` — валидация id=0 +- `GetVehicle_NegativeId_ReturnsBadRequest` — валидация отрицательных id +- `GetVehicle_SameId_ReturnsCachedData` — одинаковый id возвращает одинаковые данные +- `GetVehicle_DifferentIds_ReturnDifferentData` — разные id возвращают разные данные +- `GetVehicle_FileAppearsInMinio` — файл появляется в MinIO после запроса (30 сек таймаут) +- `FileService_HealthCheck_ReturnsOk` — health check FileService +- `Gateway_HealthCheck_ReturnsOk` — health check ApiGateway + +
+
+ ## Лабораторная работа №2 — «Балансировка нагрузки» ### Описание From 04ea527289cfa4699b200faae7c1e58ba9d0b291 Mon Sep 17 00:00:00 2001 From: Mishachuu Date: Wed, 6 May 2026 23:57:17 +0400 Subject: [PATCH 23/23] fix --- src/FileService/Services/MinioStorageService.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/FileService/Services/MinioStorageService.cs b/src/FileService/Services/MinioStorageService.cs index aa95d964..851464d8 100644 --- a/src/FileService/Services/MinioStorageService.cs +++ b/src/FileService/Services/MinioStorageService.cs @@ -10,9 +10,6 @@ public class MinioStorageService(IMinioClient minio, IConfiguration config, ILog { private readonly string _bucketName = config["Minio:BucketName"] ?? "vehicles"; - /// - /// Инициализирует bucket при старте сервиса, создавая его если не существует. - /// public async Task EnsureBucketExistsAsync() { var exists = await minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(_bucketName)); @@ -28,11 +25,6 @@ public async Task EnsureBucketExistsAsync() } } - /// - /// Сохраняет JSON-содержимое как файл в объектном хранилище. - /// - /// Имя объекта (файла) в bucket. - /// JSON-содержимое для сохранения. public async Task SaveAsync(string objectName, string jsonContent) { var bytes = System.Text.Encoding.UTF8.GetBytes(jsonContent); @@ -50,9 +42,6 @@ public async Task SaveAsync(string objectName, string jsonContent) logger.LogInformation("Saved object '{Object}' to bucket '{Bucket}'", objectName, _bucketName); } - /// - /// Возвращает список имён всех файлов в bucket. - /// public async Task> ListFilesAsync() { var items = new List();