From f77d5f291910afbf5c5b96d02cd04c375243a311 Mon Sep 17 00:00:00 2001 From: danyala1 Date: Wed, 11 Mar 2026 19:26:36 +0400 Subject: [PATCH 1/6] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=201-?= =?UTF-8?q?=D1=8E=20=D0=BB=D0=B0=D0=B1=D0=BE=D1=80=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BD=D1=83=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/Components/StudentCard.razor | 8 +- Client.Wasm/Properties/launchSettings.json | 6 +- Client.Wasm/wwwroot/appsettings.json | 2 +- CloudDevelopment.sln | 18 +++ CreditApp.Api/CreditApp.Api.csproj | 23 +++ CreditApp.Api/Models/CreditApplication.cs | 57 +++++++ CreditApp.Api/Program.cs | 25 +++ CreditApp.Api/Properties/launchSettings.json | 38 +++++ .../Services/CreditApplicationGenerator.cs | 49 ++++++ .../Services/CreditApplicationService.cs | 72 +++++++++ .../Services/ICreditApplicationService.cs | 16 ++ CreditApp.Api/appsettings.Development.json | 8 + CreditApp.Api/appsettings.json | 21 +++ CreditApp/CreditApp.AppHost/AppHost.cs | 13 ++ .../CreditApp.AppHost.csproj | 23 +++ .../Properties/launchSettings.json | 29 ++++ .../appsettings.Development.json | 8 + CreditApp/CreditApp.AppHost/appsettings.json | 9 ++ .../CreditApp.ServiceDefaults.csproj | 22 +++ .../CreditApp.ServiceDefaults/Extensions.cs | 127 +++++++++++++++ README.md | 150 ++++-------------- 21 files changed, 600 insertions(+), 124 deletions(-) create mode 100644 CreditApp.Api/CreditApp.Api.csproj create mode 100644 CreditApp.Api/Models/CreditApplication.cs create mode 100644 CreditApp.Api/Program.cs create mode 100644 CreditApp.Api/Properties/launchSettings.json create mode 100644 CreditApp.Api/Services/CreditApplicationGenerator.cs create mode 100644 CreditApp.Api/Services/CreditApplicationService.cs create mode 100644 CreditApp.Api/Services/ICreditApplicationService.cs create mode 100644 CreditApp.Api/appsettings.Development.json create mode 100644 CreditApp.Api/appsettings.json create mode 100644 CreditApp/CreditApp.AppHost/AppHost.cs create mode 100644 CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj create mode 100644 CreditApp/CreditApp.AppHost/Properties/launchSettings.json create mode 100644 CreditApp/CreditApp.AppHost/appsettings.Development.json create mode 100644 CreditApp/CreditApp.AppHost/appsettings.json create mode 100644 CreditApp/CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj create mode 100644 CreditApp/CreditApp.ServiceDefaults/Extensions.cs diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..606de97d 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №1 "Кэширование" + Вариант №11 "Кредитная заявка" + Выполнил Маясов Данила Вячеславович 6511 + Ссылка на форк diff --git a/Client.Wasm/Properties/launchSettings.json b/Client.Wasm/Properties/launchSettings.json index 0d824ea7..60120ec3 100644 --- a/Client.Wasm/Properties/launchSettings.json +++ b/Client.Wasm/Properties/launchSettings.json @@ -12,7 +12,7 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "applicationUrl": "http://localhost:5127", "environmentVariables": { @@ -22,7 +22,7 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "applicationUrl": "https://localhost:7282;http://localhost:5127", "environmentVariables": { @@ -31,7 +31,7 @@ }, "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..b02050aa 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "https://localhost:7250/credit-application" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..b93390ed 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -5,6 +5,12 @@ VisualStudioVersion = 17.14.36811.4 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.AppHost", "CreditApp\CreditApp.AppHost\CreditApp.AppHost.csproj", "{39378C50-BF1E-4D2E-B306-22BCBB212135}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.ServiceDefaults", "CreditApp\CreditApp.ServiceDefaults\CreditApp.ServiceDefaults.csproj", "{A7B14B5D-B738-B77E-404F-CF5182E30A41}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.Api", "CreditApp.Api\CreditApp.Api.csproj", "{E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +21,18 @@ Global {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU + {39378C50-BF1E-4D2E-B306-22BCBB212135}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39378C50-BF1E-4D2E-B306-22BCBB212135}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39378C50-BF1E-4D2E-B306-22BCBB212135}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39378C50-BF1E-4D2E-B306-22BCBB212135}.Release|Any CPU.Build.0 = Release|Any CPU + {A7B14B5D-B738-B77E-404F-CF5182E30A41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7B14B5D-B738-B77E-404F-CF5182E30A41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7B14B5D-B738-B77E-404F-CF5182E30A41}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7B14B5D-B738-B77E-404F-CF5182E30A41}.Release|Any CPU.Build.0 = Release|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CreditApp.Api/CreditApp.Api.csproj b/CreditApp.Api/CreditApp.Api.csproj new file mode 100644 index 00000000..2878e8d6 --- /dev/null +++ b/CreditApp.Api/CreditApp.Api.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/CreditApp.Api/Models/CreditApplication.cs b/CreditApp.Api/Models/CreditApplication.cs new file mode 100644 index 00000000..b99f1339 --- /dev/null +++ b/CreditApp.Api/Models/CreditApplication.cs @@ -0,0 +1,57 @@ +namespace CreditApp.Api.Models; + +/// +/// Кредитная заявка +/// +public class CreditApplication +{ + /// + /// Идентификатор в системе + /// + public int Id { get; set; } + + /// + /// Тип кредита (например, "Потребительский", "Ипотека", "Автокредит") + /// + public required string LoanType { get; set; } + + /// + /// Запрашиваемая сумма, округлённая до двух знаков после запятой + /// + public decimal RequestedAmount { get; set; } + + /// + /// Срок кредита в месяцах + /// + public int TermMonths { get; set; } + + /// + /// Процентная ставка (не менее ставки ЦБ РФ), округлённая до двух знаков после запятой + /// + public double InterestRate { get; set; } + + /// + /// Дата подачи заявки (не более двух лет назад от текущей даты) + /// + public DateOnly ApplicationDate { get; set; } + + /// + /// Необходимость страховки + /// + public bool InsuranceRequired { get; set; } + + /// + /// Статус заявки (например, "Новая", "В обработке", "Одобрена", "Отклонена") + /// + public required string Status { get; set; } + + /// + /// Дата решения. Заполняется только для терминальных статусов ("Одобрена", "Отклонена") + /// + public DateOnly? DecisionDate { get; set; } + + /// + /// Одобренная сумма. Заполняется только при статусе "Одобрена", не превышает + /// + public decimal? ApprovedAmount { get; set; } +} diff --git a/CreditApp.Api/Program.cs b/CreditApp.Api/Program.cs new file mode 100644 index 00000000..2e2fa970 --- /dev/null +++ b/CreditApp.Api/Program.cs @@ -0,0 +1,25 @@ +using CreditApp.Api.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddRedisDistributedCache("redis"); + +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +var allowedOrigins = builder.Configuration.GetSection("CorsSettings:AllowedOrigins").Get() ?? []; +builder.Services.AddCors(options => options.AddPolicy("AllowClient", policy => + policy.WithOrigins(allowedOrigins) + .AllowAnyMethod() + .AllowAnyHeader())); + +var app = builder.Build(); + +app.UseCors("AllowClient"); +app.MapDefaultEndpoints(); + +app.MapGet("/credit-application", async (int id, ICreditApplicationService service) => + await service.GetOrGenerate(id)); + +app.Run(); diff --git a/CreditApp.Api/Properties/launchSettings.json b/CreditApp.Api/Properties/launchSettings.json new file mode 100644 index 00000000..16608398 --- /dev/null +++ b/CreditApp.Api/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:55250", + "sslPort": 44301 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5108", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7250;http://localhost:5108", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CreditApp.Api/Services/CreditApplicationGenerator.cs b/CreditApp.Api/Services/CreditApplicationGenerator.cs new file mode 100644 index 00000000..d6ef0b16 --- /dev/null +++ b/CreditApp.Api/Services/CreditApplicationGenerator.cs @@ -0,0 +1,49 @@ +using Bogus; +using CreditApp.Api.Models; + +namespace CreditApp.Api.Services; + +/// +/// Генератор кредитных заявок +/// +public class CreditApplicationGenerator +{ + private static readonly string[] _loanTypes = + ["Потребительский", "Ипотека", "Автокредит", "Рефинансирование", "Образовательный"]; + + private static readonly string[] _statuses = + ["Новая", "В обработке", "Одобрена", "Отклонена", "Отменена"]; + + private static readonly string[] _terminalStatuses = ["Одобрена", "Отклонена"]; + + private static readonly Faker _faker = new Faker("ru") + .RuleFor(x => x.Id, _ => 0) + .RuleFor(x => x.LoanType, f => f.PickRandom(_loanTypes)) + .RuleFor(x => x.RequestedAmount, f => Math.Round(f.Random.Decimal(50_000m, 10_000_000m), 2)) + .RuleFor(x => x.TermMonths, f => f.Random.Int(6, 360)) + .RuleFor(x => x.InterestRate, f => Math.Round(f.Random.Double(15.5, 21.5), 2)) + .RuleFor(x => x.ApplicationDate, f => + DateOnly.FromDateTime(f.Date.Between(DateTime.Today.AddYears(-2), DateTime.Today))) + .RuleFor(x => x.InsuranceRequired, f => f.Random.Bool()) + .RuleFor(x => x.Status, f => f.PickRandom(_statuses)) + .RuleFor(x => x.DecisionDate, (f, app) => + _terminalStatuses.Contains(app.Status) + ? DateOnly.FromDateTime(f.Date.Between(app.ApplicationDate.ToDateTime(TimeOnly.MinValue), DateTime.Today)) + : null) + .RuleFor(x => x.ApprovedAmount, (f, app) => + app.Status == "Одобрена" + ? Math.Round(f.Random.Decimal(app.RequestedAmount * 0.5m, app.RequestedAmount), 2) + : null); + + /// + /// Генерирует кредитную заявку по указанному идентификатору + /// + /// Идентификатор заявки + /// Сгенерированная кредитная заявка + public CreditApplication Generate(int id) + { + var credit = _faker.Generate(); + credit.Id = id; + return credit; + } +} diff --git a/CreditApp.Api/Services/CreditApplicationService.cs b/CreditApp.Api/Services/CreditApplicationService.cs new file mode 100644 index 00000000..67e98adc --- /dev/null +++ b/CreditApp.Api/Services/CreditApplicationService.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using CreditApp.Api.Models; +using Microsoft.Extensions.Caching.Distributed; + +namespace CreditApp.Api.Services; + +/// +/// Реализация сервиса кредитных заявок с кэшированием в Redis +/// +public class CreditApplicationService( + IDistributedCache cache, + CreditApplicationGenerator generator, + IConfiguration configuration, + ILogger logger) : ICreditApplicationService +{ + private readonly DistributedCacheEntryOptions _cacheOptions = new() + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes( + configuration.GetValue("CacheSettings:ExpirationMinutes")) + }; + + /// + public async Task GetOrGenerate(int id) + { + var key = $"credit-application:{id}"; + + logger.LogInformation("Processing request for credit application {Id}", id); + + try + { + var cached = await cache.GetStringAsync(key); + if (cached is not null) + { + logger.LogInformation("Cache hit for credit application {Id}", id); + return JsonSerializer.Deserialize(cached)!; + } + + logger.LogInformation("Cache miss for credit application {Id}", id); + } + catch (Exception ex) + { + logger.LogError(ex, "Error reading from cache for credit application {Id}", id); + } + + try + { + var application = generator.Generate(id); + + logger.LogInformation( + "Generated credit application {Id}, status: {Status}, amount: {RequestedAmount}", + id, application.Status, application.RequestedAmount); + + try + { + var json = JsonSerializer.Serialize(application); + await cache.SetStringAsync(key, json, _cacheOptions); + logger.LogInformation("Cached credit application {Id}", id); + } + catch (Exception ex) + { + logger.LogError(ex, "Error writing to cache for credit application {Id}", id); + } + + return application; + } + catch (Exception ex) + { + logger.LogError(ex, "Error generating credit application {Id}", id); + throw; + } + } +} diff --git a/CreditApp.Api/Services/ICreditApplicationService.cs b/CreditApp.Api/Services/ICreditApplicationService.cs new file mode 100644 index 00000000..f774547e --- /dev/null +++ b/CreditApp.Api/Services/ICreditApplicationService.cs @@ -0,0 +1,16 @@ +using CreditApp.Api.Models; + +namespace CreditApp.Api.Services; + +/// +/// Сервис получения кредитных заявок с кэшированием +/// +public interface ICreditApplicationService +{ + /// + /// Получает кредитную заявку по идентификатору из кэша или генерирует новую + /// + /// Идентификатор заявки + /// Кредитная заявка + public Task GetOrGenerate(int id); +} diff --git a/CreditApp.Api/appsettings.Development.json b/CreditApp.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/CreditApp.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CreditApp.Api/appsettings.json b/CreditApp.Api/appsettings.json new file mode 100644 index 00000000..25ae0ca4 --- /dev/null +++ b/CreditApp.Api/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "GenerationSettings": { + "MinInterestRate": 21.0 + }, + "CacheSettings": { + "ExpirationMinutes": 5 + }, + "CorsSettings": { + "AllowedOrigins": [ + "http://localhost:5127", + "https://localhost:7282" + ] + } +} diff --git a/CreditApp/CreditApp.AppHost/AppHost.cs b/CreditApp/CreditApp.AppHost/AppHost.cs new file mode 100644 index 00000000..18192b51 --- /dev/null +++ b/CreditApp/CreditApp.AppHost/AppHost.cs @@ -0,0 +1,13 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("redis") + .WithRedisInsight(); + +var api = builder.AddProject("creditapp-api") + .WithReference(redis) + .WaitFor(redis); + +builder.AddProject("client-wasm") + .WaitFor(api); + +builder.Build().Run(); diff --git a/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj b/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj new file mode 100644 index 00000000..d83098a7 --- /dev/null +++ b/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj @@ -0,0 +1,23 @@ + + + + + + Exe + net8.0 + enable + enable + 434e3e23-80e8-47ed-ad4a-a9844c22f686 + + + + + + + + + + + + + diff --git a/CreditApp/CreditApp.AppHost/Properties/launchSettings.json b/CreditApp/CreditApp.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..1bf5853f --- /dev/null +++ b/CreditApp/CreditApp.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17017;http://localhost:15065", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21080", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22070" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15065", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19112", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20055" + } + } + } +} diff --git a/CreditApp/CreditApp.AppHost/appsettings.Development.json b/CreditApp/CreditApp.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/CreditApp/CreditApp.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CreditApp/CreditApp.AppHost/appsettings.json b/CreditApp/CreditApp.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/CreditApp/CreditApp.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/CreditApp/CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj b/CreditApp/CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj new file mode 100644 index 00000000..1b6e209a --- /dev/null +++ b/CreditApp/CreditApp.ServiceDefaults/CreditApp.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/CreditApp/CreditApp.ServiceDefaults/Extensions.cs b/CreditApp/CreditApp.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..b72c8753 --- /dev/null +++ b/CreditApp/CreditApp.ServiceDefaults/Extensions.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/README.md b/README.md index dcaa5eb7..18511017 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,46 @@ -# Современные технологии разработки программного обеспечения -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing) +# Лабораторная работа №1 — «Кэширование» -## Задание -### Цель -Реализация проекта микросервисного бекенда. +## Описание -### Задачи -* Реализация межсервисной коммуникации, -* Изучение работы с брокерами сообщений, -* Изучение архитектурных паттернов, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Интеграционное тестирование. +Микросервисный бэкенд для генерации кредитных заявок с кэшированием ответов в Redis. Оркестрация через .NET Aspire. -### Лабораторные работы -
-1. «Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов -
- -В рамках первой лабораторной работы необходимо: -* Реализовать сервис генерации контрактов на основе Bogus, -* Реализовать кеширование при помощи IDistributedCache и Redis, -* Реализовать структурное логирование сервиса генерации, -* Настроить оркестрацию Aspire. - -
-
-2. «Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы -
- -В рамках второй лабораторной работы необходимо: -* Настроить оркестрацию на запуск нескольких реплик сервиса генерации, -* Реализовать апи гейтвей на основе Ocelot, -* Имплементировать алгоритм балансировки нагрузки согласно варианту. +## Стек технологий -
-
-
-3. «Интеграционное тестирование» - Реализация файлового сервиса и объектного хранилища, интеграционное тестирование бекенда -
+- .NET 8, ASP.NET Core Minimal API +- .NET Aspire (оркестрация, Service Defaults, Dashboard) +- Redis (кэширование через `IDistributedCache`) +- Redis Insight (веб-интерфейс для Redis) +- Bogus (генерация данных) +- Blazor WebAssembly (клиент) -В рамках третьей лабораторной работы необходимо: -* Добавить в оркестрацию объектное хранилище, -* Реализовать файловый сервис, сериализующий сгенерированные данные в файлы и сохраняющий их в объектном хранилище, -* Реализовать отправку генерируемых данных в файловый сервис посредством брокера, -* Реализовать интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе. +## Что реализовано -
-
-
-4. (Опционально) «Переход на облачную инфраструктуру» - Перенос бекенда в Yandex Cloud -
- -В рамках четвертой лабораторной работы необходимо перенестиервисы на облако все ранее разработанные сервисы: -* Клиент - в хостинг через отдельный бакет Object Storage, -* Сервис генерации - в Cloud Function, -* Апи гейтвей - в Serverless Integration как API Gateway, -* Брокер сообщений - в Message Queue, -* Файловый сервис - в Cloud Function, -* Объектное хранилище - в отдельный бакет Object Storage, +### Сервис генерации данных +- Генератор `CreditApplicationGenerator` +- `Faker` с бизнес-правилами: + - Дата решения заполняется только для терминальных статусов + - Одобренная сумма заполняется только при статусе «Одобрена» и не превышает запрашиваемую + - Дата подачи — не более 2 лет назад от текущей даты + - Процентная ставка — от 15.5% -
-
+### Кэширование +- `CreditApplicationService` с `IDistributedCache` (Redis) +- Ключ кэша: `credit-application:{id}` +- Время жизни кэша настраивается через `appsettings.json` (`CacheSettings:ExpirationMinutes`) +- Раздельная обработка ошибок: чтение из кэша, генерация, запись в кэш -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.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 с информацией о задании, скриншоты приложения и прочая информация. +### Оркестрация (.NET Aspire) +- `CreditApp.AppHost` управляет запуском всех компонентов +- Redis с веб-интерфейсом Redis Insight (`.WithRedisInsight()`) +- API ждёт готовности Redis (`.WaitFor(redis)`) +- Клиент ждёт готовности API (`.WaitFor(api)`) +- Aspire Dashboard для мониторинга логов, трейсов и метрик -**Факультативно**: -* Перенос бекенда на облачную инфраструктуру Yandex Cloud +## API -Внимательно прочитайте [дискуссии](https://github.com/itsecd/cloud-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. +``` +GET /credit-application?id={int} +``` -По итогу работы в семестре должна получиться следующая информационная система: -
-C4 диаграмма -Современные_технологии_разработки_ПО_drawio -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](https://docs.google.com/document/d/1WGmLYwffTTaAj4TgFCk5bUyW3XKbFMiBm-DHZrfFWr4/edit?usp=sharing) -[Список предметных областей и алгоритмов балансировки](https://docs.google.com/document/d/1PLn2lKe4swIdJDZhwBYzxqFSu0AbY2MFY1SUPkIKOM4/edit?usp=sharing) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). - -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve - -## Критерии оценивания - -Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания - -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. -Не укладываетесь в дедлайн - получаете минимально возможный балл. - -### Шкала оценивания - -- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл - -## Вопросы и обратная связь по курсу - -Чтобы задать вопрос по лабораторной, воспользуйтесь [соответствующим разделом дискуссий](https://github.com/itsecd/cloud-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/cloud-development/issues/new). -Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/cloud-development/discussions/categories/ideas). +Возвращает JSON с данными кредитной заявки. При повторном запросе с тем же `id` возвращает данные из кэша. From 7f491cda0d75417228c60e47ca93446b3ec899f3 Mon Sep 17 00:00:00 2001 From: danyala1 Date: Mon, 16 Mar 2026 15:11:13 +0400 Subject: [PATCH 2/6] Fix --- .../Services/CreditApplicationGenerator.cs | 5 ++--- .../Services/CreditApplicationService.cs | 21 ++++++++++++------- .../CreditApp.AppHost.csproj | 4 ++-- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/CreditApp.Api/Services/CreditApplicationGenerator.cs b/CreditApp.Api/Services/CreditApplicationGenerator.cs index d6ef0b16..5ce48459 100644 --- a/CreditApp.Api/Services/CreditApplicationGenerator.cs +++ b/CreditApp.Api/Services/CreditApplicationGenerator.cs @@ -22,13 +22,12 @@ public class CreditApplicationGenerator .RuleFor(x => x.RequestedAmount, f => Math.Round(f.Random.Decimal(50_000m, 10_000_000m), 2)) .RuleFor(x => x.TermMonths, f => f.Random.Int(6, 360)) .RuleFor(x => x.InterestRate, f => Math.Round(f.Random.Double(15.5, 21.5), 2)) - .RuleFor(x => x.ApplicationDate, f => - DateOnly.FromDateTime(f.Date.Between(DateTime.Today.AddYears(-2), DateTime.Today))) + .RuleFor(x => x.ApplicationDate, f => f.Date.PastDateOnly(2)) .RuleFor(x => x.InsuranceRequired, f => f.Random.Bool()) .RuleFor(x => x.Status, f => f.PickRandom(_statuses)) .RuleFor(x => x.DecisionDate, (f, app) => _terminalStatuses.Contains(app.Status) - ? DateOnly.FromDateTime(f.Date.Between(app.ApplicationDate.ToDateTime(TimeOnly.MinValue), DateTime.Today)) + ? f.Date.BetweenDateOnly(app.ApplicationDate, DateOnly.FromDateTime(DateTime.Today)) : null) .RuleFor(x => x.ApprovedAmount, (f, app) => app.Status == "Одобрена" diff --git a/CreditApp.Api/Services/CreditApplicationService.cs b/CreditApp.Api/Services/CreditApplicationService.cs index 67e98adc..24e8530e 100644 --- a/CreditApp.Api/Services/CreditApplicationService.cs +++ b/CreditApp.Api/Services/CreditApplicationService.cs @@ -13,11 +13,8 @@ public class CreditApplicationService( IConfiguration configuration, ILogger logger) : ICreditApplicationService { - private readonly DistributedCacheEntryOptions _cacheOptions = new() - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes( - configuration.GetValue("CacheSettings:ExpirationMinutes")) - }; + private readonly int _cacheExpirationMinutes = + configuration.GetValue("CacheSettings:ExpirationMinutes", 5); /// public async Task GetOrGenerate(int id) @@ -31,8 +28,12 @@ public async Task GetOrGenerate(int id) var cached = await cache.GetStringAsync(key); if (cached is not null) { - logger.LogInformation("Cache hit for credit application {Id}", id); - return JsonSerializer.Deserialize(cached)!; + var deserialized = JsonSerializer.Deserialize(cached); + if (deserialized is not null) + { + logger.LogInformation("Cache hit for credit application {Id}", id); + return deserialized; + } } logger.LogInformation("Cache miss for credit application {Id}", id); @@ -53,7 +54,11 @@ public async Task GetOrGenerate(int id) try { var json = JsonSerializer.Serialize(application); - await cache.SetStringAsync(key, json, _cacheOptions); + var cacheOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_cacheExpirationMinutes) + }; + await cache.SetStringAsync(key, json, cacheOptions); logger.LogInformation("Cached credit application {Id}", id); } catch (Exception ex) diff --git a/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj b/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj index d83098a7..456f7a0d 100644 --- a/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj +++ b/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj @@ -1,6 +1,6 @@ - + Exe @@ -11,7 +11,7 @@ - + From 3df4776e32a92af8f75ac1909abf03e5a9e2feba Mon Sep 17 00:00:00 2001 From: danyala1 Date: Tue, 24 Mar 2026 16:53:00 +0400 Subject: [PATCH 3/6] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=202?= =?UTF-8?q?=D1=8E=20=D0=BB=D0=B0=D0=B1=D0=BE=D1=80=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BD=D1=83=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Api.Gateway/Api.Gateway.csproj | 17 +++++++++ .../LoadBalancing/QueryBasedLoadBalancer.cs | 30 +++++++++++++++ Api.Gateway/Program.cs | 26 +++++++++++++ Api.Gateway/Properties/launchSettings.json | 38 +++++++++++++++++++ Api.Gateway/appsettings.Development.json | 8 ++++ Api.Gateway/appsettings.json | 15 ++++++++ Api.Gateway/ocelot.json | 18 +++++++++ Client.Wasm/Components/StudentCard.razor | 4 +- Client.Wasm/wwwroot/appsettings.json | 2 +- CloudDevelopment.sln | 6 +++ CreditApp.Api/Program.cs | 9 +---- CreditApp.Api/appsettings.json | 6 --- CreditApp/CreditApp.AppHost/AppHost.cs | 15 ++++++-- .../CreditApp.AppHost.csproj | 1 + README.md | 24 ++++++++++++ 15 files changed, 198 insertions(+), 21 deletions(-) create mode 100644 Api.Gateway/Api.Gateway.csproj create mode 100644 Api.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs create mode 100644 Api.Gateway/Program.cs create mode 100644 Api.Gateway/Properties/launchSettings.json create mode 100644 Api.Gateway/appsettings.Development.json create mode 100644 Api.Gateway/appsettings.json create mode 100644 Api.Gateway/ocelot.json diff --git a/Api.Gateway/Api.Gateway.csproj b/Api.Gateway/Api.Gateway.csproj new file mode 100644 index 00000000..9833cef4 --- /dev/null +++ b/Api.Gateway/Api.Gateway.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/Api.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs b/Api.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs new file mode 100644 index 00000000..ad46269a --- /dev/null +++ b/Api.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs @@ -0,0 +1,30 @@ +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; + +namespace Api.Gateway.LoadBalancing; + +/// +/// Балансировщик нагрузки на основе параметра запроса. +/// Реплика определяется как остаток от деления id на число реплик: index = id % N. +/// +public class QueryBasedLoadBalancer(Func>> services) : ILoadBalancer +{ + public string Type => nameof(QueryBasedLoadBalancer); + + public async Task> LeaseAsync(HttpContext httpContext) + { + var list = await services(); + var query = httpContext.Request.Query; + + if (!query.ContainsKey("id") || !int.TryParse(query["id"], out var id)) + { + return new OkResponse(list[0].HostAndPort); + } + + var index = Math.Abs(id) % list.Count; + return new OkResponse(list[index].HostAndPort); + } + + public void Release(ServiceHostAndPort hostAndPort) { } +} diff --git a/Api.Gateway/Program.cs b/Api.Gateway/Program.cs new file mode 100644 index 00000000..edace36c --- /dev/null +++ b/Api.Gateway/Program.cs @@ -0,0 +1,26 @@ +using Api.Gateway.LoadBalancing; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.Services.AddServiceDiscovery(); +builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); +builder.Services.AddOcelot() + .AddCustomLoadBalancer((_, _, discoveryProvider) => new(discoveryProvider.GetAsync)); + +var allowedOrigins = builder.Configuration.GetSection("CorsSettings:AllowedOrigins").Get() ?? []; +builder.Services.AddCors(options => options.AddPolicy("AllowClient", policy => + policy.WithOrigins(allowedOrigins) + .AllowAnyMethod() + .AllowAnyHeader())); + +var app = builder.Build(); + +app.UseCors("AllowClient"); +app.MapDefaultEndpoints(); + +await app.UseOcelot(); + +app.Run(); diff --git a/Api.Gateway/Properties/launchSettings.json b/Api.Gateway/Properties/launchSettings.json new file mode 100644 index 00000000..5cf362c3 --- /dev/null +++ b/Api.Gateway/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:63809", + "sslPort": 44394 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5087", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7265;http://localhost:5087", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Api.Gateway/appsettings.Development.json b/Api.Gateway/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Api.Gateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Api.Gateway/appsettings.json b/Api.Gateway/appsettings.json new file mode 100644 index 00000000..6053d188 --- /dev/null +++ b/Api.Gateway/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "CorsSettings": { + "AllowedOrigins": [ + "http://localhost:5127", + "https://localhost:7282" + ] + } +} diff --git a/Api.Gateway/ocelot.json b/Api.Gateway/ocelot.json new file mode 100644 index 00000000..ebb976cd --- /dev/null +++ b/Api.Gateway/ocelot.json @@ -0,0 +1,18 @@ +{ + "Routes": [ + { + "UpstreamPathTemplate": "/credit-application", + "UpstreamHttpMethod": [ "GET" ], + "DownstreamPathTemplate": "/api/credit-application", + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { "Host": "localhost", "Port": 8000 }, + { "Host": "localhost", "Port": 8001 }, + { "Host": "localhost", "Port": 8002 } + ], + "LoadBalancerOptions": { + "Type": "QueryBasedLoadBalancer" + } + } + ] +} diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 606de97d..576cb852 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,8 +4,8 @@ - Номер №1 "Кэширование" - Вариант №11 "Кредитная заявка" + Номер №2 "Балансировка нагрузки" + Вариант №11 "Балансировка нагрузки" Выполнил Маясов Данила Вячеславович 6511 Ссылка на форк diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index b02050aa..65b0a4b3 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "https://localhost:7250/credit-application" + "BaseAddress": "https://localhost:7265/credit-application" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index b93390ed..7a0f12c5 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.ServiceDefaults", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.Api", "CreditApp.Api\CreditApp.Api.csproj", "{E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.Gateway", "Api.Gateway\Api.Gateway.csproj", "{C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Debug|Any CPU.Build.0 = Debug|Any CPU {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Release|Any CPU.ActiveCfg = Release|Any CPU {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Release|Any CPU.Build.0 = Release|Any CPU + {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CreditApp.Api/Program.cs b/CreditApp.Api/Program.cs index 2e2fa970..2f3cba20 100644 --- a/CreditApp.Api/Program.cs +++ b/CreditApp.Api/Program.cs @@ -8,18 +8,11 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); -var allowedOrigins = builder.Configuration.GetSection("CorsSettings:AllowedOrigins").Get() ?? []; -builder.Services.AddCors(options => options.AddPolicy("AllowClient", policy => - policy.WithOrigins(allowedOrigins) - .AllowAnyMethod() - .AllowAnyHeader())); - var app = builder.Build(); -app.UseCors("AllowClient"); app.MapDefaultEndpoints(); -app.MapGet("/credit-application", async (int id, ICreditApplicationService service) => +app.MapGet("/api/credit-application", async (int id, ICreditApplicationService service) => await service.GetOrGenerate(id)); app.Run(); diff --git a/CreditApp.Api/appsettings.json b/CreditApp.Api/appsettings.json index 25ae0ca4..5e9e0343 100644 --- a/CreditApp.Api/appsettings.json +++ b/CreditApp.Api/appsettings.json @@ -11,11 +11,5 @@ }, "CacheSettings": { "ExpirationMinutes": 5 - }, - "CorsSettings": { - "AllowedOrigins": [ - "http://localhost:5127", - "https://localhost:7282" - ] } } diff --git a/CreditApp/CreditApp.AppHost/AppHost.cs b/CreditApp/CreditApp.AppHost/AppHost.cs index 18192b51..31d942ff 100644 --- a/CreditApp/CreditApp.AppHost/AppHost.cs +++ b/CreditApp/CreditApp.AppHost/AppHost.cs @@ -3,11 +3,18 @@ var redis = builder.AddRedis("redis") .WithRedisInsight(); -var api = builder.AddProject("creditapp-api") - .WithReference(redis) - .WaitFor(redis); +var gateway = builder.AddProject("api-gateway"); + +for (var i = 0; i < 3; i++) +{ + var service = builder.AddProject($"credit-app-{i}", launchProfileName: null) + .WithReference(redis) + .WaitFor(redis) + .WithHttpsEndpoint(port: 8000 + i); + gateway.WaitFor(service); +} builder.AddProject("client-wasm") - .WaitFor(api); + .WaitFor(gateway); builder.Build().Run(); diff --git a/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj b/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj index 456f7a0d..e8f1fc56 100644 --- a/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj +++ b/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj @@ -16,6 +16,7 @@ + diff --git a/README.md b/README.md index 18511017..b2aaedee 100644 --- a/README.md +++ b/README.md @@ -44,3 +44,27 @@ GET /credit-application?id={int} Возвращает JSON с данными кредитной заявки. При повторном запросе с тем же `id` возвращает данные из кэша. +--- + +# Лабораторная работа №2 — «Балансировка нагрузки» + +## Описание + +API-шлюз на основе Ocelot с кастомным алгоритмом балансировки нагрузки Query Based. Клиентские запросы распределяются по репликам сервиса генерации на основе параметра `id` из строки запроса. + +## Что реализовано + +### Репликация сервиса генерации +- Aspire AppHost запускает 3 реплики `CreditApp.Api` на портах 8000, 8001, 8002 +- Гейтвей ожидает готовности всех реплик перед стартом (`.WaitFor()`) + +### API Gateway (Ocelot) +- Проект `Api.Gateway` — единая точка входа для клиента +- Маршрутизация настроена через `ocelot.json` +- CORS-политика вынесена в конфигурацию (`CorsSettings:AllowedOrigins`) + +### Кастомный балансировщик — `QueryBasedLoadBalancer` +- Реализует интерфейс `ILoadBalancer` из Ocelot +- Алгоритм: `index = id % N`, где `N` — число реплик +- При отсутствии параметра `id` запрос направляется на первую реплику + From b722183783e5297465e5a32d44883d4ce8ecabb0 Mon Sep 17 00:00:00 2001 From: danyala1 Date: Thu, 26 Mar 2026 14:10:46 +0400 Subject: [PATCH 4/6] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Api.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs | 4 ++++ Api.Gateway/Program.cs | 12 ++++++++++++ CreditApp/CreditApp.AppHost/AppHost.cs | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Api.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs b/Api.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs index ad46269a..f3702b14 100644 --- a/Api.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs +++ b/Api.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs @@ -15,6 +15,10 @@ public class QueryBasedLoadBalancer(Func>> services) : ILoadB public async Task> LeaseAsync(HttpContext httpContext) { var list = await services(); + + if (list.Count == 0) + throw new InvalidOperationException("No available downstream services."); + var query = httpContext.Request.Query; if (!query.ContainsKey("id") || !int.TryParse(query["id"], out var id)) diff --git a/Api.Gateway/Program.cs b/Api.Gateway/Program.cs index edace36c..91818ffb 100644 --- a/Api.Gateway/Program.cs +++ b/Api.Gateway/Program.cs @@ -7,6 +7,18 @@ builder.AddServiceDefaults(); builder.Services.AddServiceDiscovery(); builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); + +var overrides = new Dictionary(); +for (var i = 0; Environment.GetEnvironmentVariable($"services__credit-app-{i}__https__0") is { } url; i++) +{ + var uri = new Uri(url); + overrides[$"Routes:0:DownstreamHostAndPorts:{i}:Host"] = uri.Host; + overrides[$"Routes:0:DownstreamHostAndPorts:{i}:Port"] = uri.Port.ToString(); +} + +if (overrides.Count > 0) + builder.Configuration.AddInMemoryCollection(overrides); + builder.Services.AddOcelot() .AddCustomLoadBalancer((_, _, discoveryProvider) => new(discoveryProvider.GetAsync)); diff --git a/CreditApp/CreditApp.AppHost/AppHost.cs b/CreditApp/CreditApp.AppHost/AppHost.cs index 31d942ff..6465d27e 100644 --- a/CreditApp/CreditApp.AppHost/AppHost.cs +++ b/CreditApp/CreditApp.AppHost/AppHost.cs @@ -11,7 +11,7 @@ .WithReference(redis) .WaitFor(redis) .WithHttpsEndpoint(port: 8000 + i); - gateway.WaitFor(service); + gateway.WithReference(service).WaitFor(service); } builder.AddProject("client-wasm") From 1d840f464e470a305cfe686c49f3fa02ea406f17 Mon Sep 17 00:00:00 2001 From: danyala1 Date: Tue, 14 Apr 2026 16:29:33 +0400 Subject: [PATCH 5/6] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=203?= =?UTF-8?q?=D1=8E=20=D0=BB=D0=B0=D0=B1=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/Components/StudentCard.razor | 4 +- CloudDevelopment.sln | 12 ++ CreditApp.Api/CreditApp.Api.csproj | 8 +- CreditApp.Api/Messaging/IProducerService.cs | 15 ++ .../Messaging/SnsPublisherService.cs | 48 +++++ CreditApp.Api/Program.cs | 7 + .../Services/CreditApplicationService.cs | 4 + CreditApp.Api/appsettings.json | 2 +- .../CreditApp.AppHost.Tests.csproj | 33 ++++ CreditApp.AppHost.Tests/IntegrationTests.cs | 169 ++++++++++++++++++ CreditApp/CreditApp.AppHost/AppHost.cs | 41 +++++ .../creditapp-template-sns.yaml | 29 +++ .../CreditApp.AppHost.csproj | 15 +- CreditApp/CreditApp.AppHost/appsettings.json | 3 + README.md | 47 +++++ .../Controllers/S3StorageController.cs | 59 ++++++ .../Controllers/SnsSubscriberController.cs | 66 +++++++ .../Messaging/SnsSubscriptionService.cs | 46 +++++ Service.FileStorage/Program.cs | 34 ++++ .../Properties/launchSettings.json | 38 ++++ .../Service.FileStorage.csproj | 20 +++ Service.FileStorage/Storage/IS3Service.cs | 34 ++++ Service.FileStorage/Storage/S3MinioService.cs | 101 +++++++++++ .../appsettings.Development.json | 8 + Service.FileStorage/appsettings.json | 9 + 25 files changed, 841 insertions(+), 11 deletions(-) create mode 100644 CreditApp.Api/Messaging/IProducerService.cs create mode 100644 CreditApp.Api/Messaging/SnsPublisherService.cs create mode 100644 CreditApp.AppHost.Tests/CreditApp.AppHost.Tests.csproj create mode 100644 CreditApp.AppHost.Tests/IntegrationTests.cs create mode 100644 CreditApp/CreditApp.AppHost/CloudFormation/creditapp-template-sns.yaml create mode 100644 Service.FileStorage/Controllers/S3StorageController.cs create mode 100644 Service.FileStorage/Controllers/SnsSubscriberController.cs create mode 100644 Service.FileStorage/Messaging/SnsSubscriptionService.cs create mode 100644 Service.FileStorage/Program.cs create mode 100644 Service.FileStorage/Properties/launchSettings.json create mode 100644 Service.FileStorage/Service.FileStorage.csproj create mode 100644 Service.FileStorage/Storage/IS3Service.cs create mode 100644 Service.FileStorage/Storage/S3MinioService.cs create mode 100644 Service.FileStorage/appsettings.Development.json create mode 100644 Service.FileStorage/appsettings.json diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 576cb852..6289cc68 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,8 +4,8 @@ - Номер №2 "Балансировка нагрузки" - Вариант №11 "Балансировка нагрузки" + Номер №3 "Интеграционное тестирование" + Вариант №11 "Кредитная заявка" Выполнил Маясов Данила Вячеславович 6511 Ссылка на форк diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index 7a0f12c5..6e3f286d 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.Api", "CreditApp. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.Gateway", "Api.Gateway\Api.Gateway.csproj", "{C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreditApp.AppHost.Tests", "CreditApp.AppHost.Tests\CreditApp.AppHost.Tests.csproj", "{F1E58572-4194-45C8-BAC1-CE4638047D36}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Service.FileStorage", "Service.FileStorage\Service.FileStorage.csproj", "{395BE70D-7DB4-7F62-3FF9-C8EE68E5C610}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +43,14 @@ Global {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|Any CPU.Build.0 = Debug|Any CPU {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.ActiveCfg = Release|Any CPU {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.Build.0 = Release|Any CPU + {F1E58572-4194-45C8-BAC1-CE4638047D36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F1E58572-4194-45C8-BAC1-CE4638047D36}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F1E58572-4194-45C8-BAC1-CE4638047D36}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F1E58572-4194-45C8-BAC1-CE4638047D36}.Release|Any CPU.Build.0 = Release|Any CPU + {395BE70D-7DB4-7F62-3FF9-C8EE68E5C610}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {395BE70D-7DB4-7F62-3FF9-C8EE68E5C610}.Debug|Any CPU.Build.0 = Debug|Any CPU + {395BE70D-7DB4-7F62-3FF9-C8EE68E5C610}.Release|Any CPU.ActiveCfg = Release|Any CPU + {395BE70D-7DB4-7F62-3FF9-C8EE68E5C610}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CreditApp.Api/CreditApp.Api.csproj b/CreditApp.Api/CreditApp.Api.csproj index 2878e8d6..ac38d28e 100644 --- a/CreditApp.Api/CreditApp.Api.csproj +++ b/CreditApp.Api/CreditApp.Api.csproj @@ -9,15 +9,13 @@ + + + - - - - - diff --git a/CreditApp.Api/Messaging/IProducerService.cs b/CreditApp.Api/Messaging/IProducerService.cs new file mode 100644 index 00000000..e1261094 --- /dev/null +++ b/CreditApp.Api/Messaging/IProducerService.cs @@ -0,0 +1,15 @@ +using CreditApp.Api.Models; + +namespace CreditApp.Api.Messaging; + +/// +/// Интерфейс службы для отправки кредитных заявок в брокер сообщений +/// +public interface IProducerService +{ + /// + /// Отправляет сообщение с кредитной заявкой в брокер + /// + /// Кредитная заявка + public Task SendMessage(CreditApplication application); +} diff --git a/CreditApp.Api/Messaging/SnsPublisherService.cs b/CreditApp.Api/Messaging/SnsPublisherService.cs new file mode 100644 index 00000000..ac18a057 --- /dev/null +++ b/CreditApp.Api/Messaging/SnsPublisherService.cs @@ -0,0 +1,48 @@ +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using CreditApp.Api.Models; +using System.Net; +using System.Text.Json; + +namespace CreditApp.Api.Messaging; + +/// +/// Служба для отправки сообщений в SNS +/// +/// Клиент SNS +/// Конфигурация +/// Логгер +public class SnsPublisherService( + IAmazonSimpleNotificationService client, + IConfiguration configuration, + ILogger logger) : IProducerService +{ + /// + /// Уникальный идентификатор топика SNS + /// + private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"] + ?? throw new KeyNotFoundException("SNS topic ARN was not found in configuration"); + + /// + public async Task SendMessage(CreditApplication application) + { + try + { + var json = JsonSerializer.Serialize(application); + var request = new PublishRequest + { + Message = json, + TopicArn = _topicArn + }; + var response = await client.PublishAsync(request); + if (response.HttpStatusCode == HttpStatusCode.OK) + logger.LogInformation("Credit application {Id} sent to SNS", application.Id); + else + throw new Exception($"SNS returned {response.HttpStatusCode}"); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send credit application {Id} through SNS", application.Id); + } + } +} diff --git a/CreditApp.Api/Program.cs b/CreditApp.Api/Program.cs index 2f3cba20..8b9f6ede 100644 --- a/CreditApp.Api/Program.cs +++ b/CreditApp.Api/Program.cs @@ -1,10 +1,17 @@ +using Amazon.SimpleNotificationService; +using CreditApp.Api.Messaging; using CreditApp.Api.Services; +using LocalStack.Client.Extensions; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); builder.AddRedisDistributedCache("redis"); +builder.Services.AddLocalStack(builder.Configuration); +builder.Services.AddScoped(); +builder.Services.AddAwsService(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/CreditApp.Api/Services/CreditApplicationService.cs b/CreditApp.Api/Services/CreditApplicationService.cs index 24e8530e..49d28785 100644 --- a/CreditApp.Api/Services/CreditApplicationService.cs +++ b/CreditApp.Api/Services/CreditApplicationService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using CreditApp.Api.Messaging; using CreditApp.Api.Models; using Microsoft.Extensions.Caching.Distributed; @@ -10,6 +11,7 @@ namespace CreditApp.Api.Services; public class CreditApplicationService( IDistributedCache cache, CreditApplicationGenerator generator, + IProducerService producer, IConfiguration configuration, ILogger logger) : ICreditApplicationService { @@ -51,6 +53,8 @@ public async Task GetOrGenerate(int id) "Generated credit application {Id}, status: {Status}, amount: {RequestedAmount}", id, application.Status, application.RequestedAmount); + await producer.SendMessage(application); + try { var json = JsonSerializer.Serialize(application); diff --git a/CreditApp.Api/appsettings.json b/CreditApp.Api/appsettings.json index 5e9e0343..6ffa544c 100644 --- a/CreditApp.Api/appsettings.json +++ b/CreditApp.Api/appsettings.json @@ -12,4 +12,4 @@ "CacheSettings": { "ExpirationMinutes": 5 } -} +} \ No newline at end of file diff --git a/CreditApp.AppHost.Tests/CreditApp.AppHost.Tests.csproj b/CreditApp.AppHost.Tests/CreditApp.AppHost.Tests.csproj new file mode 100644 index 00000000..bf271f39 --- /dev/null +++ b/CreditApp.AppHost.Tests/CreditApp.AppHost.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CreditApp.AppHost.Tests/IntegrationTests.cs b/CreditApp.AppHost.Tests/IntegrationTests.cs new file mode 100644 index 00000000..2d376f41 --- /dev/null +++ b/CreditApp.AppHost.Tests/IntegrationTests.cs @@ -0,0 +1,169 @@ +using Microsoft.Extensions.Logging; +using System.Text.Json.Nodes; +using Xunit.Abstractions; + +namespace CreditApp.AppHost.Tests; + +public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime +{ + private IDistributedApplicationTestingBuilder? _builder; + private DistributedApplication? _app; + + public async Task InitializeAsync() + { + _builder = await DistributedApplicationTestingBuilder + .CreateAsync(CancellationToken.None); + _builder.Configuration["DcpPublisher:RandomizePorts"] = "false"; + _builder.Services.AddLogging(logging => + { + logging.AddXUnit(output); + logging.SetMinimumLevel(LogLevel.Debug); + logging.AddFilter("Aspire.Hosting.Dcp", LogLevel.Debug); + logging.AddFilter("Aspire.Hosting", LogLevel.Debug); + }); + + _app = await _builder.BuildAsync(CancellationToken.None); + await _app.StartAsync(CancellationToken.None); + } + + private HttpClient CreateGatewayClient() => _app!.CreateHttpClient("api-gateway", "http"); + private HttpClient CreateFileStorageClient() => _app!.CreateHttpClient("service-filestorage", "http"); + + private static int GetId(JsonNode node) => + (int)(node["id"] ?? node["Id"])!; + + /// + /// Ожидает появления файла в S3 через API файлового сервиса + /// + private static async Task WaitForFileAsync(HttpClient client, string fileName, int maxAttempts = 10) + { + for (var i = 0; i < maxAttempts; i++) + { + await Task.Delay(2000); + using var response = await client.GetAsync("/api/s3"); + if (!response.IsSuccessStatusCode) continue; + + var json = await response.Content.ReadAsStringAsync(); + var list = JsonNode.Parse(json)?.AsArray(); + if (list != null && list.Any(item => item?.ToString() == fileName)) + return fileName; + } + return null; + } + + /// + /// Проверяет, что API Gateway возвращает 200 OK при запросе кредитной заявки + /// + [Fact] + public async Task GatewayReturnsOkForCreditApplication() + { + using var client = CreateGatewayClient(); + using var response = await client.GetAsync("/credit-application?id=1"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Проверяет, что API возвращает корректную кредитную заявку с правильным Id + /// + [Fact] + public async Task ApiReturnsCreditApplicationWithCorrectId() + { + var id = 42; + using var client = CreateGatewayClient(); + using var response = await client.GetAsync($"/credit-application?id={id}"); + var json = await response.Content.ReadAsStringAsync(); + var node = JsonNode.Parse(json); + + Assert.NotNull(node); + Assert.Equal(id, GetId(node)); + } + + /// + /// Проверяет, что повторный запрос возвращает закэшированные данные (идентичный результат) + /// + [Fact] + public async Task RepeatedRequestReturnsCachedData() + { + var id = 55; + using var client = CreateGatewayClient(); + + using var response1 = await client.GetAsync($"/credit-application?id={id}"); + var json1 = await response1.Content.ReadAsStringAsync(); + + using var response2 = await client.GetAsync($"/credit-application?id={id}"); + var json2 = await response2.Content.ReadAsStringAsync(); + + Assert.Equal(json1, json2); + } + + /// + /// Проверяет полный пайплайн: генерация → SNS → FileStorage → Minio. + /// Файл должен появиться в списке S3. + /// + [Fact] + public async Task PipelineSavesFileToMinio() + { + var id = new Random().Next(1, 100); + + using var gatewayClient = CreateGatewayClient(); + using var gatewayResponse = await gatewayClient.GetAsync($"/credit-application?id={id}"); + Assert.Equal(HttpStatusCode.OK, gatewayResponse.StatusCode); + + using var sinkClient = CreateFileStorageClient(); + var found = await WaitForFileAsync(sinkClient, $"creditapp_{id}.json"); + + Assert.NotNull(found); + } + + /// + /// Проверяет, что данные из API и из Minio идентичны + /// + [Fact] + public async Task StoredDataMatchesApiResponse() + { + var id = new Random().Next(100, 200); + + using var gatewayClient = CreateGatewayClient(); + using var gatewayResponse = await gatewayClient.GetAsync($"/credit-application?id={id}"); + var apiJson = await gatewayResponse.Content.ReadAsStringAsync(); + var apiNode = JsonNode.Parse(apiJson); + Assert.NotNull(apiNode); + + using var sinkClient = CreateFileStorageClient(); + var found = await WaitForFileAsync(sinkClient, $"creditapp_{id}.json"); + Assert.NotNull(found); + + using var s3Response = await sinkClient.GetAsync($"/api/s3/creditapp_{id}.json"); + Assert.Equal(HttpStatusCode.OK, s3Response.StatusCode); + var s3Json = await s3Response.Content.ReadAsStringAsync(); + var s3Node = JsonNode.Parse(s3Json); + Assert.NotNull(s3Node); + + Assert.Equal(id, GetId(s3Node)); + Assert.Equal(GetId(apiNode), GetId(s3Node)); + } + + /// + /// Проверяет, что FileStorage health endpoint отвечает 200 OK + /// + [Fact] + public async Task FileStorageHealthEndpointReturnsOk() + { + using var client = CreateFileStorageClient(); + using var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + public async Task DisposeAsync() + { + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + if (_builder is not null) + await _builder.DisposeAsync(); + } +} diff --git a/CreditApp/CreditApp.AppHost/AppHost.cs b/CreditApp/CreditApp.AppHost/AppHost.cs index 6465d27e..2970e327 100644 --- a/CreditApp/CreditApp.AppHost/AppHost.cs +++ b/CreditApp/CreditApp.AppHost/AppHost.cs @@ -1,3 +1,6 @@ +using Amazon; +using Aspire.Hosting.LocalStack.Container; + var builder = DistributedApplication.CreateBuilder(args); var redis = builder.AddRedis("redis") @@ -5,16 +8,54 @@ var gateway = builder.AddProject("api-gateway"); +var awsConfig = builder.AddAWSSDKConfig() + .WithProfile("default") + .WithRegion(RegionEndpoint.EUCentral1); + +var localstack = builder + .AddLocalStack("creditapp-localstack", awsConfig: awsConfig, configureContainer: container => + { + container.Lifetime = ContainerLifetime.Session; + container.DebugLevel = 1; + container.LogLevel = LocalStackLogLevel.Debug; + container.Port = 4566; + container.AdditionalEnvironmentVariables + .Add("DEBUG", "1"); + container.AdditionalEnvironmentVariables + .Add("SNS_CERT_URL_HOST", "sns.eu-central-1.amazonaws.com"); + }); + +var awsResources = builder + .AddAWSCloudFormationTemplate("resources", "CloudFormation/creditapp-template-sns.yaml", "creditapp") + .WithReference(awsConfig); + for (var i = 0; i < 3; i++) { var service = builder.AddProject($"credit-app-{i}", launchProfileName: null) .WithReference(redis) .WaitFor(redis) + .WithReference(awsResources) + .WithEnvironment("Settings__MessageBroker", "SNS") + .WaitFor(awsResources) .WithHttpsEndpoint(port: 8000 + i); + gateway.WithReference(service).WaitFor(service); } builder.AddProject("client-wasm") .WaitFor(gateway); +var minio = builder.AddMinioContainer("creditapp-minio"); + +var sink = builder.AddProject("service-filestorage") + .WithReference(awsResources) + .WithEnvironment("Settings__MessageBroker", "SNS") + .WithEnvironment("AWS__Resources__SNSUrl", "http://host.docker.internal:5122/api/sns") + .WithEnvironment("AWS__Resources__MinioBucketName", "creditapp-bucket") + .WithReference(minio) + .WaitFor(minio) + .WaitFor(awsResources); + +builder.UseLocalStack(localstack); + builder.Build().Run(); diff --git a/CreditApp/CreditApp.AppHost/CloudFormation/creditapp-template-sns.yaml b/CreditApp/CreditApp.AppHost/CloudFormation/creditapp-template-sns.yaml new file mode 100644 index 00000000..e287535d --- /dev/null +++ b/CreditApp/CreditApp.AppHost/CloudFormation/creditapp-template-sns.yaml @@ -0,0 +1,29 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: 'CloudFormation template for credit application project (SNS + Minio)' + +Parameters: + TopicName: + Type: String + Description: Name for the SNS topic + Default: 'creditapp-topic' + +Resources: + CreditAppTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref TopicName + DisplayName: !Ref TopicName + Tags: + - Key: Name + Value: !Ref TopicName + - Key: Environment + Value: Development + +Outputs: + SNSTopicName: + Description: Name of the SNS topic + Value: !GetAtt CreditAppTopic.TopicName + + SNSTopicArn: + Description: ARN of the SNS topic + Value: !Ref CreditAppTopic diff --git a/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj b/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj index e8f1fc56..d09f9a66 100644 --- a/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj +++ b/CreditApp/CreditApp.AppHost/CreditApp.AppHost.csproj @@ -1,6 +1,6 @@ - + Exe @@ -11,14 +11,23 @@ - - + + + + + + + + + + Always + diff --git a/CreditApp/CreditApp.AppHost/appsettings.json b/CreditApp/CreditApp.AppHost/appsettings.json index 31c092aa..a6b256bb 100644 --- a/CreditApp/CreditApp.AppHost/appsettings.json +++ b/CreditApp/CreditApp.AppHost/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning", "Aspire.Hosting.Dcp": "Warning" } + }, + "LocalStack": { + "UseLocalStack": true } } diff --git a/README.md b/README.md index b2aaedee..fc2b2c40 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,50 @@ API-шлюз на основе Ocelot с кастомным алгоритмом - Алгоритм: `index = id % N`, где `N` — число реплик - При отсутствии параметра `id` запрос направляется на первую реплику +--- + +# Лабораторная работа №3 — «Интеграционное тестирование» + +## Описание + +Файловый сервис с объектным хранилищем Minio и брокером сообщений SNS. Интеграционные тесты, проверяющие корректность работы всех сервисов бэкенда вместе. + +## Стек технологий + +- SNS (брокер сообщений, через LocalStack) +- Minio (объектное хранилище, S3-совместимое) +- LocalStack (эмуляция AWS-сервисов локально) +- AWS CloudFormation (провизионирование SNS-топика) +- xUnit + Aspire.Hosting.Testing (интеграционные тесты) + +## Что реализовано + +### Публикация сообщений в SNS (`CreditApp.Api`) +- `IProducerService` — интерфейс службы отправки сообщений в брокер +- `SnsPublisherService` — реализация, сериализует кредитную заявку в JSON и публикует в SNS-топик +- `CreditApplicationService` после генерации новой заявки отправляет её в SNS через `IProducerService` + +### Файловый сервис (`Service.FileStorage`) +- Принимает SNS-нотификации через HTTP-вебхук (`POST /api/sns`) +- `SnsSubscriberController` — обрабатывает подтверждение подписки (`SubscriptionConfirmation`) и нотификации (`Notification`) +- `SnsSubscriptionService` — подписывается на SNS-топик при старте приложения +- `S3MinioService` — загрузка, скачивание и листинг файлов в Minio +- `S3StorageController` — API для получения списка файлов (`GET /api/s3`) и скачивания файла (`GET /api/s3/{key}`) +- Файлы сохраняются в формате `creditapp_{id}.json` + +### Оркестрация (.NET Aspire) +- LocalStack-контейнер с SNS и CloudFormation +- CloudFormation-шаблон `creditapp-template-sns.yaml` создаёт SNS-топик +- Minio-контейнер для объектного хранилища +- `Service.FileStorage` ожидает готовности LocalStack, Minio и CloudFormation-ресурсов +- SNS URL передаётся через переменную окружения для вебхука + +### Интеграционные тесты (`CreditApp.AppHost.Tests`) +- Поднимают всю инфраструктуру через `DistributedApplicationTestingBuilder` +- 6 тестов: + 1. **GatewayReturnsOkForCreditApplication** — API Gateway возвращает 200 OK + 2. **ApiReturnsCreditApplicationWithCorrectId** — API возвращает заявку с правильным Id + 3. **RepeatedRequestReturnsCachedData** — повторный запрос возвращает закэшированные данные + 4. **PipelineSavesFileToMinio** — полный пайплайн: генерация → SNS → FileStorage → Minio + 5. **StoredDataMatchesApiResponse** — данные из API и из Minio идентичны + 6. **FileStorageHealthEndpointReturnsOk** — health endpoint файлового сервиса отвечает 200 OK \ No newline at end of file diff --git a/Service.FileStorage/Controllers/S3StorageController.cs b/Service.FileStorage/Controllers/S3StorageController.cs new file mode 100644 index 00000000..aaa1eba8 --- /dev/null +++ b/Service.FileStorage/Controllers/S3StorageController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Mvc; +using Service.FileStorage.Storage; +using System.Text.Json.Nodes; + +namespace Service.FileStorage.Controllers; + +/// +/// Контроллер для взаимодействия с объектным хранилищем S3 +/// +/// Служба для работы с S3 +/// Логгер +[ApiController] +[Route("api/s3")] +public class S3StorageController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + /// + /// Получает список хранящихся в S3 файлов + /// + /// Список с ключами файлов + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> ListFiles() + { + try + { + var list = await s3Service.GetFileList(); + logger.LogInformation("Listed {Count} files from bucket", list.Count); + return Ok(list); + } + catch (Exception ex) + { + logger.LogError(ex, "Error listing files"); + return BadRequest(ex.Message); + } + } + + /// + /// Получает строковое представление хранящегося в S3 документа + /// + /// Ключ файла + /// JSON-представление файла + [HttpGet("{key}")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task> GetFile(string key) + { + try + { + var node = await s3Service.DownloadFile(key); + return Ok(node); + } + catch (Exception ex) + { + logger.LogError(ex, "Error downloading file {Key}", key); + return BadRequest(ex.Message); + } + } +} diff --git a/Service.FileStorage/Controllers/SnsSubscriberController.cs b/Service.FileStorage/Controllers/SnsSubscriberController.cs new file mode 100644 index 00000000..83316fc9 --- /dev/null +++ b/Service.FileStorage/Controllers/SnsSubscriberController.cs @@ -0,0 +1,66 @@ +using Amazon.SimpleNotificationService.Util; +using Microsoft.AspNetCore.Mvc; +using Service.FileStorage.Storage; +using System.Text; + +namespace Service.FileStorage.Controllers; + +/// +/// Контроллер для приема сообщений от SNS +/// +/// Служба для работы с S3 +/// Логгер +[ApiController] +[Route("api/sns")] +public class SnsSubscriberController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + /// + /// Вебхук, который получает оповещения из SNS топика. + /// Используется для получения нотификаций и подтверждения подписки. + /// + /// 200 OK в любом случае + [HttpPost] + [ProducesResponseType(200)] + public async Task ReceiveMessage() + { + logger.LogInformation("SNS webhook was called"); + try + { + using var reader = new StreamReader(Request.Body, Encoding.UTF8); + var jsonContent = await reader.ReadToEndAsync(); + var snsMessage = Message.ParseMessage(jsonContent); + + if (snsMessage.Type == "SubscriptionConfirmation") + { + logger.LogInformation("SubscriptionConfirmation received"); + using var httpClient = new HttpClient(); + var builder = new UriBuilder(new Uri(snsMessage.SubscribeURL)) + { + Scheme = "http", + Host = "localhost", + Port = 4566 + }; + var response = await httpClient.GetAsync(builder.Uri); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + throw new Exception($"SubscriptionConfirmation returned {response.StatusCode}: {body}"); + } + logger.LogInformation("Subscription confirmed"); + return Ok(); + } + + if (snsMessage.Type == "Notification") + { + await s3Service.UploadFile(snsMessage.MessageText); + logger.LogInformation("Notification processed and saved to S3"); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing SNS notification"); + } + + return Ok(); + } +} diff --git a/Service.FileStorage/Messaging/SnsSubscriptionService.cs b/Service.FileStorage/Messaging/SnsSubscriptionService.cs new file mode 100644 index 00000000..7327bdd7 --- /dev/null +++ b/Service.FileStorage/Messaging/SnsSubscriptionService.cs @@ -0,0 +1,46 @@ +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using System.Net; + +namespace Service.FileStorage.Messaging; + +/// +/// Служба для подписки на SNS топик при старте приложения +/// +/// Клиент SNS +/// Конфигурация +/// Логгер +public class SnsSubscriptionService( + IAmazonSimpleNotificationService snsClient, + IConfiguration configuration, + ILogger logger) +{ + /// + /// Уникальный идентификатор топика SNS + /// + private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"] + ?? throw new KeyNotFoundException("SNS topic ARN was not found in configuration"); + + /// + /// Делает попытку подписаться на топик SNS + /// + public async Task SubscribeEndpoint() + { + var endpoint = configuration["AWS:Resources:SNSUrl"]; + logger.LogInformation("Subscribing to {Topic} with endpoint {Endpoint}", _topicArn, endpoint); + + var request = new SubscribeRequest + { + TopicArn = _topicArn, + Protocol = "http", + Endpoint = endpoint, + ReturnSubscriptionArn = true + }; + + var response = await snsClient.SubscribeAsync(request); + if (response.HttpStatusCode != HttpStatusCode.OK) + logger.LogError("Failed to subscribe to {Topic}", _topicArn); + else + logger.LogInformation("Subscription request for {Topic} sent, waiting for confirmation", _topicArn); + } +} diff --git a/Service.FileStorage/Program.cs b/Service.FileStorage/Program.cs new file mode 100644 index 00000000..1ab073e1 --- /dev/null +++ b/Service.FileStorage/Program.cs @@ -0,0 +1,34 @@ +using Amazon.SimpleNotificationService; +using LocalStack.Client.Extensions; +using Service.FileStorage.Messaging; +using Service.FileStorage.Storage; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); + +builder.Services.AddControllers(); + +builder.Services.AddLocalStack(builder.Configuration); +builder.Services.AddScoped(); +builder.Services.AddAwsService(); + +builder.AddMinioClient("creditapp-minio"); +builder.Services.AddScoped(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var subscriptionService = scope.ServiceProvider.GetRequiredService(); + await subscriptionService.SubscribeEndpoint(); +} + +using (var scope = app.Services.CreateScope()) +{ + var s3Service = scope.ServiceProvider.GetRequiredService(); + await s3Service.EnsureBucketExists(); +} + +app.MapDefaultEndpoints(); +app.MapControllers(); +app.Run(); diff --git a/Service.FileStorage/Properties/launchSettings.json b/Service.FileStorage/Properties/launchSettings.json new file mode 100644 index 00000000..394821bc --- /dev/null +++ b/Service.FileStorage/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:33843", + "sslPort": 44333 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5122", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7040;http://localhost:5122", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Service.FileStorage/Service.FileStorage.csproj b/Service.FileStorage/Service.FileStorage.csproj new file mode 100644 index 00000000..2cdcd7e2 --- /dev/null +++ b/Service.FileStorage/Service.FileStorage.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/Service.FileStorage/Storage/IS3Service.cs b/Service.FileStorage/Storage/IS3Service.cs new file mode 100644 index 00000000..46eb76b0 --- /dev/null +++ b/Service.FileStorage/Storage/IS3Service.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Nodes; + +namespace Service.FileStorage.Storage; + +/// +/// Интерфейс службы для манипуляции файлами в объектном хранилище +/// +public interface IS3Service +{ + /// + /// Отправляет файл в хранилище + /// + /// Строковая репрезентация сохраняемого файла + /// Результат загрузки + public Task UploadFile(string fileData); + + /// + /// Получает список всех файлов из хранилища + /// + /// Список ключей файлов + public Task> GetFileList(); + + /// + /// Получает строковую репрезентацию файла из хранилища + /// + /// Путь к файлу в бакете + /// JSON-представление прочитанного файла + public Task DownloadFile(string filePath); + + /// + /// Создает S3 бакет при необходимости + /// + public Task EnsureBucketExists(); +} diff --git a/Service.FileStorage/Storage/S3MinioService.cs b/Service.FileStorage/Storage/S3MinioService.cs new file mode 100644 index 00000000..63163d19 --- /dev/null +++ b/Service.FileStorage/Storage/S3MinioService.cs @@ -0,0 +1,101 @@ +using Minio; +using Minio.DataModel.Args; +using System.Net; +using System.Text; +using System.Text.Json.Nodes; + +namespace Service.FileStorage.Storage; + +/// +/// Служба для манипуляции файлами в объектном хранилище Minio +/// +/// Клиент Minio +/// Конфигурация +/// Логгер +public class S3MinioService(IMinioClient client, IConfiguration configuration, ILogger logger) : IS3Service +{ + /// + /// Имя бакета в Minio + /// + private readonly string _bucketName = configuration["AWS:Resources:MinioBucketName"] + ?? throw new KeyNotFoundException("Minio bucket name was not found in configuration"); + + /// + public async Task> GetFileList() + { + var list = new List(); + var request = new ListObjectsArgs() + .WithBucket(_bucketName) + .WithPrefix("") + .WithRecursive(true); + + var responseList = client.ListObjectsEnumAsync(request); + await foreach (var response in responseList) + list.Add(response.Key); + + return list; + } + + /// + public async Task UploadFile(string fileData) + { + var rootNode = JsonNode.Parse(fileData) ?? throw new ArgumentException("Passed string is not a valid JSON"); + var id = (rootNode["id"] ?? rootNode["Id"])?.GetValue() ?? throw new ArgumentException("Passed JSON has invalid structure"); + + var bytes = Encoding.UTF8.GetBytes(fileData); + using var stream = new MemoryStream(bytes); + stream.Seek(0, SeekOrigin.Begin); + + logger.LogInformation("Uploading credit application {Id} to {Bucket}", id, _bucketName); + var request = new PutObjectArgs() + .WithBucket(_bucketName) + .WithStreamData(stream) + .WithObjectSize(bytes.Length) + .WithObject($"creditapp_{id}.json"); + + var response = await client.PutObjectAsync(request); + + if (response.ResponseStatusCode != HttpStatusCode.OK) + { + logger.LogError("Failed to upload credit application {Id}: {Code}", id, response.ResponseStatusCode); + return false; + } + + logger.LogInformation("Uploaded credit application {Id} to {Bucket}", id, _bucketName); + return true; + } + + /// + public async Task DownloadFile(string key) + { + logger.LogInformation("Downloading {File} from {Bucket}", key, _bucketName); + var memoryStream = new MemoryStream(); + + var request = new GetObjectArgs() + .WithBucket(_bucketName) + .WithObject(key) + .WithCallbackStream(async (stream, cancellationToken) => + { + await stream.CopyToAsync(memoryStream, cancellationToken); + memoryStream.Seek(0, SeekOrigin.Begin); + }); + + var response = await client.GetObjectAsync(request) + ?? throw new InvalidOperationException($"Error downloading {key} — object is null"); + using var reader = new StreamReader(memoryStream, Encoding.UTF8); + return JsonNode.Parse(reader.ReadToEnd()) + ?? throw new InvalidOperationException("Downloaded document is not a valid JSON"); + } + + /// + public async Task EnsureBucketExists() + { + logger.LogInformation("Checking whether {Bucket} exists", _bucketName); + var exists = await client.BucketExistsAsync(new BucketExistsArgs().WithBucket(_bucketName)); + if (!exists) + { + logger.LogInformation("Creating {Bucket}", _bucketName); + await client.MakeBucketAsync(new MakeBucketArgs().WithBucket(_bucketName)); + } + } +} diff --git a/Service.FileStorage/appsettings.Development.json b/Service.FileStorage/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Service.FileStorage/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Service.FileStorage/appsettings.json b/Service.FileStorage/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/Service.FileStorage/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From 64f268de19c9d8c9935cc21a62c02bb67b7a26e4 Mon Sep 17 00:00:00 2001 From: danyala1 Date: Wed, 15 Apr 2026 22:29:15 +0400 Subject: [PATCH 6/6] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20localstac?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CreditApp/CreditApp.AppHost/AppHost.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CreditApp/CreditApp.AppHost/AppHost.cs b/CreditApp/CreditApp.AppHost/AppHost.cs index 2970e327..2a188210 100644 --- a/CreditApp/CreditApp.AppHost/AppHost.cs +++ b/CreditApp/CreditApp.AppHost/AppHost.cs @@ -27,7 +27,8 @@ var awsResources = builder .AddAWSCloudFormationTemplate("resources", "CloudFormation/creditapp-template-sns.yaml", "creditapp") - .WithReference(awsConfig); + .WithReference(awsConfig) + .WaitFor(localstack!); for (var i = 0; i < 3; i++) {