diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..fe1152bd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..a5e08b1a 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №1 "Кеширование" + Вариант №14 "Медицинский пациeнт" + Выполнена Степановым Дмитрием 6511 + Ссылка на форк diff --git a/Client.Wasm/Properties/launchSettings.json b/Client.Wasm/Properties/launchSettings.json index 0d824ea7..6ad691ad 100644 --- a/Client.Wasm/Properties/launchSettings.json +++ b/Client.Wasm/Properties/launchSettings.json @@ -12,9 +12,9 @@ "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", + "applicationUrl": "http://localhost:5128", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -22,20 +22,20 @@ "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", + "applicationUrl": "https://localhost:7283;http://localhost:5128", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } -} +} \ No newline at end of file diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..4668c12a 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" -} + "BaseAddress": "http://localhost:5000/patient" +} \ No newline at end of file diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..70db344d 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -3,7 +3,19 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.14.36811.4 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PatientApp.Generator", "GeneratorService\PatientApp.Generator.csproj", "{DB627DDE-E411-4B5B-9FE3-15F9A8EC1491}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PatientApp.AppHost", "Patient\Patient.AppHost\PatientApp.AppHost.csproj", "{03605D0F-0E69-461D-9417-32DD06B53E23}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PatientApp.ServiceDefaults", "Patient\Patient.ServiceDefaults\PatientApp.ServiceDefaults.csproj", "{DA24CCA7-7B4A-871C-985C-DBE0FA1F7524}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PatientApp.Gateway", "Gateway\PatientApp.Gateway.csproj", "{AB6410EA-A027-B523-756F-6E77D0CA6F6B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventSink2", "EventSink2\EventSink2.csproj", "{49DE02D1-F38B-4E25-AA05-51120A4D9AEC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{1F9349B8-0B98-4B35-9CFE-39A2D1854DD9}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,6 +27,30 @@ Global {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU + {DB627DDE-E411-4B5B-9FE3-15F9A8EC1491}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB627DDE-E411-4B5B-9FE3-15F9A8EC1491}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB627DDE-E411-4B5B-9FE3-15F9A8EC1491}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB627DDE-E411-4B5B-9FE3-15F9A8EC1491}.Release|Any CPU.Build.0 = Release|Any CPU + {03605D0F-0E69-461D-9417-32DD06B53E23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {03605D0F-0E69-461D-9417-32DD06B53E23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {03605D0F-0E69-461D-9417-32DD06B53E23}.Release|Any CPU.ActiveCfg = Release|Any CPU + {03605D0F-0E69-461D-9417-32DD06B53E23}.Release|Any CPU.Build.0 = Release|Any CPU + {DA24CCA7-7B4A-871C-985C-DBE0FA1F7524}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA24CCA7-7B4A-871C-985C-DBE0FA1F7524}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA24CCA7-7B4A-871C-985C-DBE0FA1F7524}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA24CCA7-7B4A-871C-985C-DBE0FA1F7524}.Release|Any CPU.Build.0 = Release|Any CPU + {AB6410EA-A027-B523-756F-6E77D0CA6F6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB6410EA-A027-B523-756F-6E77D0CA6F6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB6410EA-A027-B523-756F-6E77D0CA6F6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB6410EA-A027-B523-756F-6E77D0CA6F6B}.Release|Any CPU.Build.0 = Release|Any CPU + {49DE02D1-F38B-4E25-AA05-51120A4D9AEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49DE02D1-F38B-4E25-AA05-51120A4D9AEC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49DE02D1-F38B-4E25-AA05-51120A4D9AEC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49DE02D1-F38B-4E25-AA05-51120A4D9AEC}.Release|Any CPU.Build.0 = Release|Any CPU + {1F9349B8-0B98-4B35-9CFE-39A2D1854DD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F9349B8-0B98-4B35-9CFE-39A2D1854DD9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F9349B8-0B98-4B35-9CFE-39A2D1854DD9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F9349B8-0B98-4B35-9CFE-39A2D1854DD9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/EventSink1/Controllers/S3StorageController.cs b/EventSink1/Controllers/S3StorageController.cs new file mode 100644 index 00000000..41caa17b --- /dev/null +++ b/EventSink1/Controllers/S3StorageController.cs @@ -0,0 +1,44 @@ +using EventSink1.Storage; +using Microsoft.AspNetCore.Mvc; +using System.Text; +using System.Text.Json.Nodes; + +namespace EventSink1.Controllers; + +[ApiController] +[Route("api/s3")] +public class S3StorageController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + [HttpGet] + public async Task>> ListFiles() + { + logger.LogInformation("Получен запрос списка файлов"); + try + { + var list = await s3Service.GetFileList(); + logger.LogInformation("Найдено файлов: {count}", list.Count); + return Ok(list); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении списка файлов"); + return BadRequest(ex.Message); + } + } + + [HttpGet("{key}")] + public async Task> GetFile(string key) + { + logger.LogInformation("Запрошен файл: {key}", key); + try + { + var node = await s3Service.DownloadFile(key); + return Ok(node); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при скачивании файла {key}", key); + return BadRequest(ex.Message); + } + } +} \ No newline at end of file diff --git a/EventSink1/Controllers/SnsSubscriberController.cs b/EventSink1/Controllers/SnsSubscriberController.cs new file mode 100644 index 00000000..7e95640e --- /dev/null +++ b/EventSink1/Controllers/SnsSubscriberController.cs @@ -0,0 +1,46 @@ +using Amazon.SimpleNotificationService.Util; +using EventSink1.Storage; +using Microsoft.AspNetCore.Mvc; +using System.Text; + +namespace EventSink1.Controllers; + +[ApiController] +[Route("api/sns")] +public class SnsSubscriberController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + [HttpPost] + public async Task ReceiveMessage() + { + logger.LogInformation("SNS webhook вызван"); + + 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("Получено подтверждение подписки"); + using var httpClient = new HttpClient(); + var response = await httpClient.GetAsync(snsMessage.SubscribeURL); + logger.LogInformation("Подписка подтверждена: {status}", response.StatusCode); + return Ok(); + } + + if (snsMessage.Type == "Notification") + { + await s3Service.UploadFile(snsMessage.MessageText); + logger.LogInformation("Сообщение успешно сохранено в S3"); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при обработке SNS сообщения"); + } + + return Ok(); + } +} \ No newline at end of file diff --git a/EventSink1/EventSink1.csproj b/EventSink1/EventSink1.csproj new file mode 100644 index 00000000..523312d2 --- /dev/null +++ b/EventSink1/EventSink1.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + 29d94b31-07bf-4de9-b545-da17884e78db + Linux + + + + + + + + + + + + + + + + + diff --git a/EventSink1/Messaging/SnsSubscriptionService.cs b/EventSink1/Messaging/SnsSubscriptionService.cs new file mode 100644 index 00000000..5debd64d --- /dev/null +++ b/EventSink1/Messaging/SnsSubscriptionService.cs @@ -0,0 +1,33 @@ +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using System.Net; + +namespace EventSink1.Messaging; + +public class SnsSubscriptionService(IAmazonSimpleNotificationService snsClient, IConfiguration configuration, ILogger logger) +{ + private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"] + ?? throw new KeyNotFoundException("SNSTopicArn not found in configuration"); + + public async Task SubscribeEndpoint() + { + logger.LogInformation("Отправка запроса на подписку к {topic}", _topicArn); + + var endpoint = configuration["AWS:Resources:SNSUrl"] ?? "http://localhost:4566"; + + var request = new SubscribeRequest + { + TopicArn = _topicArn, + Protocol = "http", + Endpoint = $"{endpoint}/api/sns", // вебхук + ReturnSubscriptionArn = true + }; + + var response = await snsClient.SubscribeAsync(request); + + if (response.HttpStatusCode == HttpStatusCode.OK) + logger.LogInformation("Запрос на подписку отправлен успешно. Ожидаем подтверждения."); + else + logger.LogError("Ошибка подписки на SNS"); + } +} \ No newline at end of file diff --git a/EventSink1/Program.cs b/EventSink1/Program.cs new file mode 100644 index 00000000..d20b7324 --- /dev/null +++ b/EventSink1/Program.cs @@ -0,0 +1,29 @@ +using EventSink1; +using LocalStack.Client.Extensions; +using PatientApp.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddLocalStack(builder.Configuration); + +builder.AddConsumer(); +builder.AddS3(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +await app.UseConsumer(); +await app.UseS3(); + +app.MapControllers(); +app.Run(); \ No newline at end of file diff --git a/EventSink1/Properties/launchSettings.json b/EventSink1/Properties/launchSettings.json new file mode 100644 index 00000000..01ea89c1 --- /dev/null +++ b/EventSink1/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5142" + }, + "https": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7160;http://localhost:5142" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:8673", + "sslPort": 44331 + } + } +} \ No newline at end of file diff --git a/EventSink1/Storage/IS3Service.cs b/EventSink1/Storage/IS3Service.cs new file mode 100644 index 00000000..77f02fdc --- /dev/null +++ b/EventSink1/Storage/IS3Service.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Nodes; + +namespace EventSink1.Storage; + +public interface IS3Service +{ + public Task EnsureBucketExists(); + public Task UploadFile(string jsonContent); + public Task> GetFileList(); + public Task DownloadFile(string key); +} \ No newline at end of file diff --git a/EventSink1/Storage/S3AwsService.cs b/EventSink1/Storage/S3AwsService.cs new file mode 100644 index 00000000..2905e12e --- /dev/null +++ b/EventSink1/Storage/S3AwsService.cs @@ -0,0 +1,66 @@ +using Amazon.S3; +using Amazon.S3.Model; +using Amazon.S3.Util; +using System.Text.Json.Nodes; + +namespace EventSink1.Storage; + +public class S3AwsService(IAmazonS3 s3Client, ILogger logger) : IS3Service +{ + private const string BucketName = "landplot-bucket"; + + public async Task EnsureBucketExists() + { + try + { + var exists = await AmazonS3Util.DoesS3BucketExistV2Async(s3Client, BucketName); + if (!exists) + { + await s3Client.PutBucketAsync(BucketName); + logger.LogInformation("Bucket {Bucket} успешно создан", BucketName); + } + else + { + logger.LogInformation("Bucket {Bucket} уже существует", BucketName); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при проверке/создании bucket {Bucket}", BucketName); + } + } + + public async Task UploadFile(string jsonContent) + { + var key = $"landplot_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.json"; + + var request = new PutObjectRequest + { + BucketName = BucketName, + Key = key, + ContentBody = jsonContent, + ContentType = "application/json" + }; + + await s3Client.PutObjectAsync(request); + logger.LogInformation("Файл загружен в S3: {Key}", key); + } + + public async Task> GetFileList() + { + var response = await s3Client.ListObjectsV2Async(new ListObjectsV2Request + { + BucketName = BucketName + }); + + return response.S3Objects.Select(o => o.Key).ToList(); + } + + public async Task DownloadFile(string key) + { + var response = await s3Client.GetObjectAsync(BucketName, key); + using var reader = new StreamReader(response.ResponseStream); + var content = await reader.ReadToEndAsync(); + return JsonNode.Parse(content)!; + } +} \ No newline at end of file diff --git a/EventSink1/WebApplicationBuilderExtensions.cs b/EventSink1/WebApplicationBuilderExtensions.cs new file mode 100644 index 00000000..45c049a3 --- /dev/null +++ b/EventSink1/WebApplicationBuilderExtensions.cs @@ -0,0 +1,31 @@ +using Amazon.S3; +using Amazon.SimpleNotificationService; +using EventSink1.Messaging; +using EventSink1.Storage; +using LocalStack.Client.Enums; +using LocalStack.Client.Extensions; + +namespace EventSink1; + +internal static class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddConsumer(this WebApplicationBuilder builder) + { + builder.Services.AddLocalStack(builder.Configuration); + return builder.AddSnsSubscriber(); + } + + private static WebApplicationBuilder AddSnsSubscriber(this WebApplicationBuilder builder) + { + builder.Services.AddScoped(); + builder.Services.AddAwsService(); + return builder; + } + + public static WebApplicationBuilder AddS3(this WebApplicationBuilder builder) + { + builder.Services.AddAwsService(); + builder.Services.AddScoped(); + return builder; + } +} \ No newline at end of file diff --git a/EventSink1/WebApplicationExtensions.cs b/EventSink1/WebApplicationExtensions.cs new file mode 100644 index 00000000..14f22504 --- /dev/null +++ b/EventSink1/WebApplicationExtensions.cs @@ -0,0 +1,23 @@ +using EventSink1.Messaging; +using EventSink1.Storage; + +namespace EventSink1; + +internal static class WebApplicationExtensions +{ + public static async Task UseConsumer(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var subscriptionService = scope.ServiceProvider.GetRequiredService(); + await subscriptionService.SubscribeEndpoint(); + return app; + } + + public static async Task UseS3(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var s3Service = scope.ServiceProvider.GetRequiredService(); + await s3Service.EnsureBucketExists(); + return app; + } +} \ No newline at end of file diff --git a/EventSink1/appsettings.Development.json b/EventSink1/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/EventSink1/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/EventSink1/appsettings.json b/EventSink1/appsettings.json new file mode 100644 index 00000000..c3323f8a --- /dev/null +++ b/EventSink1/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Settings": { + "MessageBroker": "SNS", + "S3Hosting": "Localstack" + }, + "AWS": { + "Resources": { + "SNSTopicArn": "arn:aws:sns:us-east-1:000000000000:landplot-topic", + "SNSUrl": "http://localhost:4566" + } + } +} \ No newline at end of file diff --git a/EventSink2/Controllers/S3StorageController.cs b/EventSink2/Controllers/S3StorageController.cs new file mode 100644 index 00000000..f6277d3e --- /dev/null +++ b/EventSink2/Controllers/S3StorageController.cs @@ -0,0 +1,44 @@ +using EventSink2.Storage; +using Microsoft.AspNetCore.Mvc; +using System.Text; +using System.Text.Json.Nodes; + +namespace EventSink2.Controllers; + +[ApiController] +[Route("api/s3")] +public class S3StorageController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + [HttpGet] + public async Task>> ListFiles() + { + logger.LogInformation("Получен запрос списка файлов"); + try + { + var list = await s3Service.GetFileList(); + logger.LogInformation("Найдено файлов: {count}", list.Count); + return Ok(list); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении списка файлов"); + return BadRequest(ex.Message); + } + } + + [HttpGet("{key}")] + public async Task> GetFile(string key) + { + logger.LogInformation("Запрошен файл: {key}", key); + try + { + var node = await s3Service.DownloadFile(key); + return Ok(node); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при скачивании файла {key}", key); + return BadRequest(ex.Message); + } + } +} \ No newline at end of file diff --git a/EventSink2/Controllers/SnsSubscriberController.cs b/EventSink2/Controllers/SnsSubscriberController.cs new file mode 100644 index 00000000..b09c4134 --- /dev/null +++ b/EventSink2/Controllers/SnsSubscriberController.cs @@ -0,0 +1,49 @@ +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Util; +using EventSink2.Storage; +using Microsoft.AspNetCore.Mvc; +using System.Text; + +namespace EventSink2.Controllers; + +[ApiController] +[Route("api/sns")] +public class SnsSubscriberController(IS3Service s3Service, ILogger logger, IAmazonSimpleNotificationService snsClient) : ControllerBase +{ + [HttpPost] + public async Task ReceiveMessage() + { + logger.LogInformation("SNS webhook вызван"); + + 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("Получено подтверждение подписки"); + using var httpClient = new HttpClient(); + var response = await snsClient.ConfirmSubscriptionAsync( + snsMessage.TopicArn, + snsMessage.Token); + logger.LogInformation("Подписка подтверждена"); + return Ok(); + } + + if (snsMessage.Type == "Notification") + { + await s3Service.UploadFile(snsMessage.MessageText); + logger.LogInformation("Сообщение успешно сохранено в S3"); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при обработке SNS сообщения"); + } + + return Ok(); + } +} \ No newline at end of file diff --git a/EventSink2/EventSink2.csproj b/EventSink2/EventSink2.csproj new file mode 100644 index 00000000..a1215121 --- /dev/null +++ b/EventSink2/EventSink2.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/EventSink2/Messaging/SnsSubscriptionService.cs b/EventSink2/Messaging/SnsSubscriptionService.cs new file mode 100644 index 00000000..ee010200 --- /dev/null +++ b/EventSink2/Messaging/SnsSubscriptionService.cs @@ -0,0 +1,33 @@ +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using System.Net; + +namespace EventSink2.Messaging; + +public class SnsSubscriptionService(IAmazonSimpleNotificationService snsClient, IConfiguration configuration, ILogger logger) +{ + public async Task SubscribeEndpoint() + { + logger.LogInformation("Создание темы SNS"); + var createResponse = await snsClient.CreateTopicAsync("landplot-topic"); + + logger.LogInformation("Отправка запроса на подписку к {topic}", createResponse.TopicArn); + + var endpoint = configuration["AWS:Resources:SNSUrl"] ?? "http://localhost:4566"; + + var request = new SubscribeRequest + { + TopicArn = createResponse.TopicArn, + Protocol = "http", + Endpoint = $"{endpoint}/api/sns", + ReturnSubscriptionArn = true + }; + + var response = await snsClient.SubscribeAsync(request); + + if (response.HttpStatusCode == HttpStatusCode.OK) + logger.LogInformation("Запрос на подписку отправлен успешно. Ожидаем подтверждения."); + else + logger.LogError("Ошибка подписки на SNS"); + } +} \ No newline at end of file diff --git a/EventSink2/Program.cs b/EventSink2/Program.cs new file mode 100644 index 00000000..c946fc99 --- /dev/null +++ b/EventSink2/Program.cs @@ -0,0 +1,22 @@ +using EventSink2; +using LocalStack.Client.Extensions; +using PatientApp.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddControllers(); + +builder.Services.AddLocalStack(builder.Configuration); + +builder.AddConsumer(); +builder.AddS3(); + +var app = builder.Build(); + +await app.UseConsumer(); +await app.UseS3(); + +app.MapControllers(); +app.Run(); \ No newline at end of file diff --git a/EventSink2/Properties/launchSettings.json b/EventSink2/Properties/launchSettings.json new file mode 100644 index 00000000..3948e421 --- /dev/null +++ b/EventSink2/Properties/launchSettings.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:59879", + "sslPort": 44387 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5261", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7156;http://localhost:5261", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/EventSink2/Storage/IS3Service.cs b/EventSink2/Storage/IS3Service.cs new file mode 100644 index 00000000..bcb76064 --- /dev/null +++ b/EventSink2/Storage/IS3Service.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Nodes; + +namespace EventSink2.Storage; + +public interface IS3Service +{ + public Task EnsureBucketExists(); + public Task UploadFile(string jsonContent); + public Task> GetFileList(); + public Task DownloadFile(string key); +} \ No newline at end of file diff --git a/EventSink2/Storage/S3AwsService.cs b/EventSink2/Storage/S3AwsService.cs new file mode 100644 index 00000000..07b759a4 --- /dev/null +++ b/EventSink2/Storage/S3AwsService.cs @@ -0,0 +1,66 @@ +using Amazon.S3; +using Amazon.S3.Model; +using Amazon.S3.Util; +using System.Text.Json.Nodes; + +namespace EventSink2.Storage; + +public class S3AwsService(IAmazonS3 s3Client, ILogger logger) : IS3Service +{ + private const string BucketName = "landplot-bucket"; + + public async Task EnsureBucketExists() + { + try + { + var exists = await AmazonS3Util.DoesS3BucketExistV2Async(s3Client, BucketName); + if (!exists) + { + await s3Client.PutBucketAsync(BucketName); + logger.LogInformation("Bucket {Bucket} успешно создан", BucketName); + } + else + { + logger.LogInformation("Bucket {Bucket} уже существует", BucketName); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при проверке/создании bucket {Bucket}", BucketName); + } + } + + public async Task UploadFile(string jsonContent) + { + var key = $"landplot_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.json"; + + var request = new PutObjectRequest + { + BucketName = BucketName, + Key = key, + ContentBody = jsonContent, + ContentType = "application/json" + }; + + await s3Client.PutObjectAsync(request); + logger.LogInformation("Файл загружен в S3: {Key}", key); + } + + public async Task> GetFileList() + { + var response = await s3Client.ListObjectsV2Async(new ListObjectsV2Request + { + BucketName = BucketName + }); + + return response.S3Objects.Select(o => o.Key).ToList(); + } + + public async Task DownloadFile(string key) + { + var response = await s3Client.GetObjectAsync(BucketName, key); + using var reader = new StreamReader(response.ResponseStream); + var content = await reader.ReadToEndAsync(); + return JsonNode.Parse(content)!; + } +} \ No newline at end of file diff --git a/EventSink2/WebApplicationBuilderExtensions.cs b/EventSink2/WebApplicationBuilderExtensions.cs new file mode 100644 index 00000000..4731d66a --- /dev/null +++ b/EventSink2/WebApplicationBuilderExtensions.cs @@ -0,0 +1,31 @@ +using Amazon.S3; +using Amazon.SimpleNotificationService; +using EventSink2.Messaging; +using EventSink2.Storage; +using LocalStack.Client.Enums; +using LocalStack.Client.Extensions; + +namespace EventSink2; + +internal static class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddConsumer(this WebApplicationBuilder builder) + { + builder.Services.AddLocalStack(builder.Configuration); + return builder.AddSnsSubscriber(); + } + + private static WebApplicationBuilder AddSnsSubscriber(this WebApplicationBuilder builder) + { + builder.Services.AddScoped(); + builder.Services.AddAwsService(); + return builder; + } + + public static WebApplicationBuilder AddS3(this WebApplicationBuilder builder) + { + builder.Services.AddAwsService(); + builder.Services.AddScoped(); + return builder; + } +} \ No newline at end of file diff --git a/EventSink2/WebApplicationExtensions.cs b/EventSink2/WebApplicationExtensions.cs new file mode 100644 index 00000000..daabb205 --- /dev/null +++ b/EventSink2/WebApplicationExtensions.cs @@ -0,0 +1,23 @@ +using EventSink2.Messaging; +using EventSink2.Storage; + +namespace EventSink2; + +internal static class WebApplicationExtensions +{ + public static async Task UseConsumer(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var subscriptionService = scope.ServiceProvider.GetRequiredService(); + await subscriptionService.SubscribeEndpoint(); + return app; + } + + public static async Task UseS3(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var s3Service = scope.ServiceProvider.GetRequiredService(); + await s3Service.EnsureBucketExists(); + return app; + } +} \ No newline at end of file diff --git a/EventSink2/appsettings.Development.json b/EventSink2/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/EventSink2/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/EventSink2/appsettings.json b/EventSink2/appsettings.json new file mode 100644 index 00000000..23e91584 --- /dev/null +++ b/EventSink2/appsettings.json @@ -0,0 +1,22 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Settings": { + "MessageBroker": "SNS", + "S3Hosting": "Localstack" + }, + "AWS": { + "Resources": { + "SNSTopicArn": "arn:aws:sns:eu-central-1:000000000000:landplot-topic", + "SNSUrl": "http://host.docker.internal:5261" + } + }, + "LocalStack": { + "UseLocalStack": true + } +} \ No newline at end of file diff --git a/Gateway/LoadBalancer/QueryBasedLoadBalancer.cs b/Gateway/LoadBalancer/QueryBasedLoadBalancer.cs new file mode 100644 index 00000000..57d0bdd7 --- /dev/null +++ b/Gateway/LoadBalancer/QueryBasedLoadBalancer.cs @@ -0,0 +1,38 @@ +using Ocelot.LoadBalancer.Errors; +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; +using Ocelot.ServiceDiscovery.Providers; + +namespace PatientApp.Gateway.LoadBalancer; + +public class QueryBasedLoadBalancer(IServiceDiscoveryProvider serviceDiscovery) : ILoadBalancer +{ + private readonly IServiceDiscoveryProvider _serviceDiscovery = serviceDiscovery; + public string Type => "QueryBasedLoadBalancer"; + + public async Task> LeaseAsync(HttpContext httpContext) + { + var services = await _serviceDiscovery.GetAsync(); + + if (services == null || services.Count == 0) + { + return new ErrorResponse( + new ServicesAreNullError("No services available") + ); + } + + var idStr = httpContext.Request.Query["id"].FirstOrDefault(); + + if (!int.TryParse(idStr, out var id)) + { + return new OkResponse(services[0].HostAndPort); + } + + var selected = services[id % services.Count]; + + return new OkResponse(selected.HostAndPort); + } + + public void Release(ServiceHostAndPort hostAndPort) { } +} \ No newline at end of file diff --git a/Gateway/PatientApp.Gateway.csproj b/Gateway/PatientApp.Gateway.csproj new file mode 100644 index 00000000..918f6ac1 --- /dev/null +++ b/Gateway/PatientApp.Gateway.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/Gateway/Program.cs b/Gateway/Program.cs new file mode 100644 index 00000000..b9bf7da5 --- /dev/null +++ b/Gateway/Program.cs @@ -0,0 +1,57 @@ +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using PatientApp.Gateway.LoadBalancer; +using PatientApp.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); + +var generators = builder.Configuration.GetSection("Generators").Get() ?? []; + +var addressOverrides = new List>(); + +for (var i = 0; i < generators.Length; ++i) +{ + var name = generators[i]; + var url = builder.Configuration[$"services:{name}:http:0"]; + + if (!string.IsNullOrEmpty(url) && Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + addressOverrides.Add(new($"Routes:0:DownstreamHostAndPorts:{i}:Host", uri.Host)); + addressOverrides.Add(new($"Routes:0:DownstreamHostAndPorts:{i}:Port", uri.Port.ToString())); + } +} + +if (addressOverrides.Count > 0) + builder.Configuration.AddInMemoryCollection(addressOverrides); + +builder.Services + .AddOcelot(builder.Configuration) + .AddCustomLoadBalancer((route, serviceDiscovery) => + new QueryBasedLoadBalancer(serviceDiscovery)); + +var allowedOrigins = builder.Configuration + .GetSection("Cors:AllowedOrigins") + .Get(); + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowClient", policy => + { + policy + .WithOrigins(allowedOrigins!) + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + +var app = builder.Build(); + +app.UseCors("AllowClient"); + +await app.UseOcelot(); + +await app.RunAsync(); \ No newline at end of file diff --git a/Gateway/Properties/launchSettings.json b/Gateway/Properties/launchSettings.json new file mode 100644 index 00000000..2848ab88 --- /dev/null +++ b/Gateway/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:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Gateway/appsettings.Development.json b/Gateway/appsettings.Development.json new file mode 100644 index 00000000..e499766d --- /dev/null +++ b/Gateway/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Ocelot": "Debug" + } + }, + "GlobalConfiguration": { + "BaseUrl": "http://localhost:5000" + } +} diff --git a/Gateway/appsettings.json b/Gateway/appsettings.json new file mode 100644 index 00000000..076a5564 --- /dev/null +++ b/Gateway/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Cors": { + "AllowedOrigins": [ + "https://localhost:7283", + "http://localhost:5128" + ] + } +} diff --git a/Gateway/ocelot.json b/Gateway/ocelot.json new file mode 100644 index 00000000..4fbac1e4 --- /dev/null +++ b/Gateway/ocelot.json @@ -0,0 +1,28 @@ +{ + "Generators": [ "generator-1", "generator-2", "generator-3" ], + "Routes": [ + { + "DownstreamPathTemplate": "/patient", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 5001 + }, + { + "Host": "localhost", + "Port": 5002 + }, + { + "Host": "localhost", + "Port": 5003 + } + ], + "UpstreamPathTemplate": "/patient", + "UpstreamHttpMethod": [ "GET" ], + "LoadBalancerOptions": { + "Type": "QueryBasedLoadBalancer" + } + } + ] +} \ No newline at end of file diff --git a/GeneratorService/Messaging/SnsPublisherService.cs b/GeneratorService/Messaging/SnsPublisherService.cs new file mode 100644 index 00000000..4a63e7ec --- /dev/null +++ b/GeneratorService/Messaging/SnsPublisherService.cs @@ -0,0 +1,28 @@ +using Amazon.SimpleNotificationService; +using PatientApp.Generator.Models; +using System.Text.Json; + +namespace PatientApp.Generator.Messaging; + +public class SnsPublisherService(IAmazonSimpleNotificationService snsClient, IConfiguration config, ILogger logger) +{ + private readonly string _topicArn = config["AWS:Resources:SNSTopicArn"] + ?? "arn:aws:sns:us-east-1:000000000000:landplot-topic"; + + public async Task PublishPatientAsync(Patient patient) + { + var message = JsonSerializer.Serialize(patient); + + await snsClient.PublishAsync(new Amazon.SimpleNotificationService.Model.PublishRequest + { + TopicArn = _topicArn, + Message = message, + MessageAttributes = new Dictionary + { + ["EventType"] = new() { DataType = "String", StringValue = "PatientGenerated" } + } + }); + + logger.LogInformation("Пациент {id} отправлен в SNS", patient.Id); + } +} \ No newline at end of file diff --git a/GeneratorService/Models/Patient.cs b/GeneratorService/Models/Patient.cs new file mode 100644 index 00000000..b664e151 --- /dev/null +++ b/GeneratorService/Models/Patient.cs @@ -0,0 +1,57 @@ +namespace PatientApp.Generator.Models; + +/// +/// Представляет пациента в системе. +/// +public class Patient +{ + /// + /// Уникальный идентификатор пациента. + /// + public int Id { get; set; } + + /// + /// Полное имя пациента. + /// + public required string FullName { get; set; } + + /// + /// Дата рождения пациента. + /// + public DateOnly Birthday { get; set; } + + /// + /// Адрес проживания пациента. + /// + public string? Address { get; set; } + + /// + /// Рост пациента. + /// + public double Height { get; set; } + + /// + /// Вес пациента. + /// + public double Weight { get; set; } + + /// + /// Группа крови пациента. + /// + public int BloodType { get; set; } + + /// + /// Резус фактор пациента. + /// + public bool Resus { get; set; } + + /// + /// Дата последнего визита. + /// + public DateOnly LastVisit { get; set; } + + /// + /// Есть ли вакцинация. + /// + public bool Vactination { get; set; } +} \ No newline at end of file diff --git a/GeneratorService/PatientApp.Generator.csproj b/GeneratorService/PatientApp.Generator.csproj new file mode 100644 index 00000000..f35dc8b3 --- /dev/null +++ b/GeneratorService/PatientApp.Generator.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/GeneratorService/Program.cs b/GeneratorService/Program.cs new file mode 100644 index 00000000..72be1958 --- /dev/null +++ b/GeneratorService/Program.cs @@ -0,0 +1,82 @@ +using Amazon; +using Amazon.Extensions.NETCore.Setup; +using Amazon.Runtime; +using Amazon.SimpleNotificationService; +using LocalStack.Client.Extensions; +using PatientApp.Generator.Messaging; +using PatientApp.Generator.Services; +using PatientApp.ServiceDefaults; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddAwsService(); //AddAwsService +builder.Services.AddScoped(); + +builder.Services.AddDefaultAWSOptions(new AWSOptions +{ + Credentials = new BasicAWSCredentials("test", "test"), + Region = RegionEndpoint.EUCentral1, + DefaultClientConfig = + { + ServiceURL = "http://localhost:4566" + } +}); +builder.Services.AddLocalStack(builder.Configuration); + +builder.AddRedisDistributedCache("redis"); + +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +var allowedOrigins = builder.Configuration + .GetSection("Cors:AllowedOrigins") + .Get(); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.WithOrigins(allowedOrigins!) + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +var app = builder.Build(); + +app.UseCors(); +app.UseSerilogRequestLogging(); + +app.MapDefaultEndpoints(); + +app.MapGet("/patient", async ( + int id, + PatientService service, + ILogger logger, + CancellationToken cancellationToken) => +{ + logger.LogInformation("Received request for patient with ID: {id}", id); + + if (id <= 0) + { + logger.LogWarning("Received invalid ID: {id}", id); + return Results.BadRequest(new { error = "ID must be a positive number"}); + } + + try + { + var application = await service.GetByIdAsync(id, cancellationToken); + return Results.Ok(application); + } + catch (Exception ex) + { + logger.LogError(ex, "Error while getting patient {id}", id); + return Results.Problem("An error occurred while processing the request"); + } +}) +.WithName("GetPatient"); + +app.Run(); diff --git a/GeneratorService/Properties/launchSettings.json b/GeneratorService/Properties/launchSettings.json new file mode 100644 index 00000000..8c4696c2 --- /dev/null +++ b/GeneratorService/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:5171", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/GeneratorService/Services/Generator.cs b/GeneratorService/Services/Generator.cs new file mode 100644 index 00000000..ff9b4cd0 --- /dev/null +++ b/GeneratorService/Services/Generator.cs @@ -0,0 +1,63 @@ +using Bogus; +using Bogus.DataSets; +using static System.Math; +using PatientApp.Generator.Models; + +namespace PatientApp.Generator.Services; + +public class PatientGenerator(ILogger logger) +{ + private readonly Faker _faker = new Faker("ru") + .RuleFor(x => x.FullName, GeneratePatientFullName) + .RuleFor(x => x.Birthday, f => f.Date.PastDateOnly(100)) + .RuleFor(x => x.Address, f => f.Address.FullAddress()) + .RuleFor(x => x.Weight, GenerateWeight) + .RuleFor(x => x.Height, GenerateHeight) + .RuleFor(x => x.BloodType, f => f.Random.Int(1, 4)) + .RuleFor(x => x.Resus, f => f.Random.Bool()) + .RuleFor(x => x.Vactination, f => f.Random.Bool()) + .RuleFor(x => x.LastVisit, (f, patient) => f.Date.BetweenDateOnly(patient.Birthday, DateOnly.FromDateTime(DateTime.UtcNow)) + ); + + public Patient Generate(int id) + { + logger.LogInformation("Generating Patient with ID: {id}", id); + return _faker.UseSeed(id).RuleFor(x => x.Id, _ => id).Generate(); + } + + private static string GeneratePatientFullName(Faker faker) + { + var gender = faker.Person.Gender; + var firstName = faker.Name.FirstName(gender); + var lastName = faker.Name.LastName(gender); + var patronymic = faker.Name.FirstName(Name.Gender.Male) + (gender == Name.Gender.Male ? "еевич" : "еевна"); + + return string.Join(' ', firstName, lastName, patronymic); + } + + private static double GenerateWeight(Faker faker, Patient patient) + { + var age = DateTime.UtcNow.Year - patient.Birthday.Year; + + return age switch + { + < 3 => Round(faker.Random.Double(3, 15), 2), + < 12 => Round(faker.Random.Double(15, 50), 2), + < 18 => Round(faker.Random.Double(40, 80), 2), + _ => Round(faker.Random.Double(50, 120), 2) + }; + } + + private static double GenerateHeight(Faker faker, Patient patient) + { + var age = DateTime.UtcNow.Year - patient.Birthday.Year; + + return age switch + { + < 3 => Round(faker.Random.Double(40, 100), 2), + < 12 => Round(faker.Random.Double(100, 150), 2), + < 18 => Round(faker.Random.Double(140, 180), 2), + _ => Round(faker.Random.Double(150, 200), 2) + }; + } +} \ No newline at end of file diff --git a/GeneratorService/Services/PatientService.cs b/GeneratorService/Services/PatientService.cs new file mode 100644 index 00000000..5c331fa5 --- /dev/null +++ b/GeneratorService/Services/PatientService.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Caching.Distributed; +using PatientApp.Generator.Messaging; +using PatientApp.Generator.Models; +using PatientApp.Generator.Services; +using System.Text.Json; + +public class PatientService( + PatientGenerator generator, + IDistributedCache cache, + ILogger logger, + IConfiguration config, + SnsPublisherService publisher +) +{ + private readonly TimeSpan _cacheExpiration = TimeSpan.FromMinutes(config.GetSection("CacheSetting").GetValue("CacheExpirationMinutes", 5)); + private const string CacheKeyPrefix = "patient:"; + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + var cacheKey = $"{CacheKeyPrefix}{id}"; + + logger.LogInformation("Patient with Id: {id} was requested", id); + logger.LogInformation("Handled by instance: {instance}", Environment.ProcessId); + + var cachedData = await cache.GetStringAsync(cacheKey, cancellationToken); + + if (!string.IsNullOrEmpty(cachedData)) + { + logger.LogInformation("Patient with {id} was found in cache", id); + var cachedPatient = JsonSerializer.Deserialize(cachedData); + if (cachedPatient != null) return cachedPatient; + } + + logger.LogInformation("Patient with {id} was found in cache, start generating", id); + + var patient = generator.Generate(id); + + await publisher.PublishPatientAsync(patient); + + var serializedData = JsonSerializer.Serialize(patient); + var cacheOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _cacheExpiration + }; + + await cache.SetStringAsync(cacheKey, serializedData, cacheOptions, cancellationToken); + + logger.LogInformation("Patint with Id: {id} was saved to cache with TTL {TtlMinutes} minutes", id, _cacheExpiration.TotalMinutes); + + return patient; + } +} \ No newline at end of file diff --git a/GeneratorService/appsettings.Development.json b/GeneratorService/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/GeneratorService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/GeneratorService/appsettings.json b/GeneratorService/appsettings.json new file mode 100644 index 00000000..5c1e8741 --- /dev/null +++ b/GeneratorService/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Cors": { + "AllowedOrigins": [ + "http://localhost:17170", + "http://localhost:5128", + "http://localhost:7283" + ] + }, + "Serilog": { + "MinimumLevel": "Information" + } +} diff --git a/Patient/Patient.AppHost/AppHost.cs b/Patient/Patient.AppHost/AppHost.cs new file mode 100644 index 00000000..38926bf2 --- /dev/null +++ b/Patient/Patient.AppHost/AppHost.cs @@ -0,0 +1,62 @@ +using Amazon; +using Aspire.Hosting.LocalStack.Container; +using Microsoft.Extensions.Configuration; + +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("redis") + .WithRedisCommander(); + +var gatewayPort = builder.Configuration.GetValue("GatewayPort"); +var gateway = builder + .AddProject("gateway") + .WithExternalHttpEndpoints(); + +var awsConfig = builder.AddAWSSDKConfig() + .WithProfile("default") + .WithRegion(RegionEndpoint.EUCentral1); + +var localStack = builder +.AddLocalStack("landplot-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 eventSink = builder.AddProject("landplot-sink") + .WithHttpEndpoint(port: 5261, name: "sns") + .WithReference(localStack) + .WaitFor(localStack); + +for (var i = 1; i <= 3; ++i) +{ + var currGenerator = builder.AddProject + ($"generator-{i}") + .WithEndpoint("http", endpoint => endpoint.Port = gatewayPort + i) + .WithReference(redis) + .WaitFor(redis) + .WithReference(localStack) + .WaitFor(localStack); + + gateway + .WithReference(currGenerator) + .WaitFor(currGenerator); +} + +gateway + .WithReference(eventSink) + .WaitFor(eventSink); + +builder.AddProject("client") + .WithReference(gateway) + .WaitFor(gateway); + +builder.UseLocalStack(localStack); + +builder.Build().Run(); diff --git a/Patient/Patient.AppHost/PatientApp.AppHost.csproj b/Patient/Patient.AppHost/PatientApp.AppHost.csproj new file mode 100644 index 00000000..6d94f06e --- /dev/null +++ b/Patient/Patient.AppHost/PatientApp.AppHost.csproj @@ -0,0 +1,28 @@ + + + + + + Exe + net8.0 + enable + enable + 9850a275-b1ed-4763-b4e3-9c0fbc45d25f + + + + + + + + + + + + + + + + + + diff --git a/Patient/Patient.AppHost/Properties/launchSettings.json b/Patient/Patient.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..f1080df9 --- /dev/null +++ b/Patient/Patient.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17170;http://localhost:15170", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21170", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22170" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15170", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19170", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20170" + } + } + } +} diff --git a/Patient/Patient.AppHost/appsettings.Development.json b/Patient/Patient.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Patient/Patient.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Patient/Patient.AppHost/appsettings.json b/Patient/Patient.AppHost/appsettings.json new file mode 100644 index 00000000..67f9f17e --- /dev/null +++ b/Patient/Patient.AppHost/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "GatewayPort": 5000, + "LocalStack": { + "UseLocalStack": true + } +} \ No newline at end of file diff --git a/Patient/Patient.ServiceDefaults/Extensions.cs b/Patient/Patient.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..9f24d035 --- /dev/null +++ b/Patient/Patient.ServiceDefaults/Extensions.cs @@ -0,0 +1,128 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using Serilog; + +namespace PatientApp.ServiceDefaults; + +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureSerilog(); + builder.ConfigureOpenTelemetry(); + builder.AddDefaultHealthChecks(); + builder.Services.AddServiceDiscovery(); + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureSerilog(this IHostApplicationBuilder builder) + { + var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; + + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .Enrich.WithEnvironmentName() + .Enrich.WithThreadId() + .Enrich.WithMachineName() + .WriteTo.Console(outputTemplate: + "[{Timestamp:HH:mm:ss} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + + if (!string.IsNullOrWhiteSpace(otlpEndpoint)) + { + Log.Logger = Log.Logger.ForContext("Application", builder.Environment.ApplicationName); + + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .Enrich.WithEnvironmentName() + .Enrich.WithThreadId() + .Enrich.WithMachineName() + .WriteTo.Console() + .WriteTo.OpenTelemetry(options => + { + options.Endpoint = otlpEndpoint; + options.ResourceAttributes = new Dictionary + { + ["service.name"] = builder.Environment.ApplicationName + }; + }) + .CreateLogger(); + } + + builder.Services.AddSerilog(); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + return app; + } +} \ No newline at end of file diff --git a/Patient/Patient.ServiceDefaults/PatientApp.ServiceDefaults.csproj b/Patient/Patient.ServiceDefaults/PatientApp.ServiceDefaults.csproj new file mode 100644 index 00000000..4ad5fd47 --- /dev/null +++ b/Patient/Patient.ServiceDefaults/PatientApp.ServiceDefaults.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index dcaa5eb7..361a0d66 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,313 @@ -# Современные технологии разработки программного обеспечения -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing) - -## Задание -### Цель -Реализация проекта микросервисного бекенда. - -### Задачи -* Реализация межсервисной коммуникации, -* Изучение работы с брокерами сообщений, -* Изучение архитектурных паттернов, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Интеграционное тестирование. - -### Лабораторные работы -
-1. «Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов -
- -В рамках первой лабораторной работы необходимо: -* Реализовать сервис генерации контрактов на основе Bogus, -* Реализовать кеширование при помощи IDistributedCache и Redis, -* Реализовать структурное логирование сервиса генерации, -* Настроить оркестрацию Aspire. - -
-
-2. «Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы -
- -В рамках второй лабораторной работы необходимо: -* Настроить оркестрацию на запуск нескольких реплик сервиса генерации, -* Реализовать апи гейтвей на основе Ocelot, -* Имплементировать алгоритм балансировки нагрузки согласно варианту. - -
-
-
-3. «Интеграционное тестирование» - Реализация файлового сервиса и объектного хранилища, интеграционное тестирование бекенда -
+# Cloud Development (вариант 14 «Медицинский пациент») -В рамках третьей лабораторной работы необходимо: -* Добавить в оркестрацию объектное хранилище, -* Реализовать файловый сервис, сериализующий сгенерированные данные в файлы и сохраняющий их в объектном хранилище, -* Реализовать отправку генерируемых данных в файловый сервис посредством брокера, -* Реализовать интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе. +> Студент: Дмитрий Степанов, группа 6511. -
-
-
-4. (Опционально) «Переход на облачную инфраструктуру» - Перенос бекенда в Yandex Cloud -
- -В рамках четвертой лабораторной работы необходимо перенестиервисы на облако все ранее разработанные сервисы: -* Клиент - в хостинг через отдельный бакет Object Storage, -* Сервис генерации - в Cloud Function, -* Апи гейтвей - в Serverless Integration как API Gateway, -* Брокер сообщений - в Message Queue, -* Файловый сервис - в Cloud Function, -* Объектное хранилище - в отдельный бакет Object Storage, - -
-
+## Описание -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview). -* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview). -* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus). -* Реализация тестов с использованием [xUnit](https://xunit.net/?tabs=cs). -* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация. +Проект представляет собой распределённое ASP.NET Core приложение для генерации и обработки карточек медицинских пациентов. -**Факультативно**: -* Перенос бекенда на облачную инфраструктуру Yandex Cloud +Система построена на основе микросервисной архитектуры с использованием: -Внимательно прочитайте [дискуссии](https://github.com/itsecd/cloud-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. +- **ASP.NET Core** +- **.NET Aspire** +- **Ocelot API Gateway** +- **Redis** +- **LocalStack** +- **AWS SNS + S3** +- **Blazor WebAssembly** -По итогу работы в семестре должна получиться следующая информационная система: -
-C4 диаграмма -Современные_технологии_разработки_ПО_drawio -
+--- + +## Компоненты системы + +### GeneratorService + +ASP.NET Core API, выполняющий: + +- генерацию карточки пациента по идентификатору; +- кеширование результатов в Redis; +- публикацию события в SNS после генерации пациента. + +--- + +### EventSink + +ASP.NET Core сервис-подписчик SNS. + +Функции: + +- получение webhook-уведомлений от SNS; +- подтверждение подписки; +- сохранение JSON-файлов пациентов в S3 bucket. + +--- + +### API Gateway (Ocelot) + +Выполняет: + +- маршрутизацию запросов; +- балансировку нагрузки между репликами GeneratorService. + +--- + +### Client (Blazor WASM) + +Пользовательский интерфейс. + +Позволяет: + +- отправлять запросы на генерацию пациента; +- отображать полученные данные. + +--- + +### Redis + +Используется как распределённый кеш. + +--- + +### LocalStack + +Локальная эмуляция AWS-сервисов: + +- SNS +- S3 + +--- + +### .NET Aspire AppHost + +Используется для: + +- оркестрации сервисов; +- запуска инфраструктуры; +- управления зависимостями; +- health checks. + +--- + +## Архитектура + +```text +Client (Blazor WASM) + ↓ +API Gateway (Ocelot) + ↓ +GeneratorService (3 реплики) + ↓ +Redis Cache + ↓ +SNS Topic (LocalStack) + ↓ +EventSink + ↓ +S3 Bucket (LocalStack) +``` + +--- + +## Event-Driven Pipeline + +После генерации пациента: + +1. GeneratorService публикует сообщение в SNS Topic. +2. EventSink получает webhook-уведомление. +3. JSON пациента сохраняется в S3 bucket. + +--- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. +## REST API -[Список вариантов](https://docs.google.com/document/d/1WGmLYwffTTaAj4TgFCk5bUyW3XKbFMiBm-DHZrfFWr4/edit?usp=sharing) -[Список предметных областей и алгоритмов балансировки](https://docs.google.com/document/d/1PLn2lKe4swIdJDZhwBYzxqFSu0AbY2MFY1SUPkIKOM4/edit?usp=sharing) +### `GET /patient?id={int}` -## Схема сдачи +| Параметр | Тип | Обязателен | Описание | +|----------|-----|------------|----------| +| `id` | int (>0) | да | Одновременно идентификатор пациента и seed для генератора; проверяется на положительность. | -На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). +--- -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve +## Ответ `200 OK` -## Критерии оценивания +Возвращает JSON-объект пациента: -Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания +| Поле | Тип | Пример | Описание | +|------|------|------|------| +| `id` | int | `42` | Идентификатор пациента | +| `fullName` | string | `Ирина Смирнова Сергеевна` | ФИО пациента | +| `birthday` | DateOnly | `1993-07-20` | Дата рождения | +| `address` | string | `г. Самара, ул. Карла Маркса...` | Адрес | +| `height` | double | `167.35` | Рост | +| `weight` | double | `61.12` | Вес | +| `bloodType` | int | `2` | Группа крови | +| `resus` | bool | `true` | Резус-фактор | +| `lastVisit` | DateOnly | `2026-02-17` | Последний визит | +| `vaccination` | bool | `false` | Наличие прививки | -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. -Не укладываетесь в дедлайн - получаете минимально возможный балл. +--- -### Шкала оценивания +## Возможные статусы -- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл - -## Вопросы и обратная связь по курсу +| Статус | Описание | +|--------|----------| +| `200 OK` | Пациент успешно получен | +| `400 BadRequest` | Некорректный id (`id <= 0`) | +| `500 Internal Server Error` | Внутренняя ошибка | -Чтобы задать вопрос по лабораторной, воспользуйтесь [соответствующим разделом дискуссий](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). +--- +## Кеширование + +Используется Redis. + +При повторном запросе: + +- данные возвращаются из кеша; +- уменьшается время ответа; +- в логах появляется сообщение: + +```text +Patient with id: X was found in cache +``` + +--- + +## Балансировка нагрузки + +Реализован кастомный балансировщик: + +```text +instanceIndex = id % N +``` + +где: + +- `id` — идентификатор пациента; +- `N` — количество реплик сервиса. + +--- + +## AWS SNS + S3 + +### SNS + +Используется для публикации событий о создании пациента. + +### EventSink + +Подписывается на SNS Topic и получает уведомления. + +### S3 + +JSON-представления пациентов сохраняются в bucket: + +```text +landplot-bucket +``` + +--- + +## Интеграционные тесты + +Реализованы integration tests с использованием: + +- `xUnit` +- `Aspire.Hosting.Testing` +- `LocalStack` + +Проверяются: + +- доступность API Gateway; +- сохранение файлов в S3; +- корректная работа event-driven pipeline. + +--- + +## Структура репозитория + +```text +cloud-development/ +├─ Client.Wasm/ +│ ├─ Components/ +│ ├─ Layout/ +│ ├─ Pages/ +│ └─ wwwroot/ +│ +├─ EventSink2/ +│ ├─ Controllers/ +│ │ └─ SnsSubscriberController.cs +│ ├─ Storage/ +│ │ ├─ IS3Service.cs +│ │ └─ S3AwsService.cs +│ └─ Program.cs +│ +├─ PatientApp.Gateway/ +│ ├─ LoadBalancer/ +│ │ └─ QueryBasedLoadBalancer.cs +│ ├─ ocelot.json +│ └─ Program.cs +│ +├─ PatientApp.Generator/ +│ ├─ Messaging/ +│ │ └─ SnsPublisherService.cs +│ ├─ Models/ +│ │ └─ Patient.cs +│ ├─ Services/ +│ │ ├─ Generator.cs +│ │ └─ PatientService.cs +│ └─ Program.cs +│ +├─ PatientApp.AppHost/ +├─ PatientApp.ServiceDefaults/ +│ +├─ Tests/ +│ ├─ Fixture.cs +│ └─ IntegrationTests.cs +│ +├─ .github/workflows/ +├─ CloudDevelopment.sln +└─ README.md +``` + +--- + +## Используемые технологии + +- ASP.NET Core +- Blazor WebAssembly +- Ocelot +- Redis +- AWS SNS +- AWS S3 +- LocalStack +- Docker +- .NET Aspire +- xUnit + +--- + +## Скрины работы +
+ Сервисы +image +
+
+ Ответ клиенту +Ответ клиенту + +
+
+ Трассировки + Трассировки +
+
+ Тесты + image + +
+
+ Файл в хранилище + image + +
diff --git a/Tests/Fixture.cs b/Tests/Fixture.cs new file mode 100644 index 00000000..88b735f1 --- /dev/null +++ b/Tests/Fixture.cs @@ -0,0 +1,87 @@ +using Amazon.S3; +using Amazon.S3.Model; +using Aspire.Hosting; +using Aspire.Hosting.Testing; + +namespace Tests; + +public class Fixture : IAsyncLifetime +{ + private const string BucketName = "landplot-bucket"; + + public DistributedApplication App { get; private set; } = null!; + + public IDistributedApplicationTestingBuilder Builder { get; private set; } = null!; + + public AmazonS3Client StorageClient { get; private set; } = null!; + + public async Task InitializeAsync() + { + Builder = await DistributedApplicationTestingBuilder + .CreateAsync(); + + Builder.Configuration["DcpPublisher:RandomizePorts"] = "false"; + + App = await Builder.BuildAsync(); + + await App.StartAsync(); + + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + + await Task.WhenAll( + App.ResourceNotifications.WaitForResourceHealthyAsync("landplot-localstack", cts.Token), + App.ResourceNotifications.WaitForResourceHealthyAsync("gateway", cts.Token), + App.ResourceNotifications.WaitForResourceHealthyAsync("landplot-sink", cts.Token) + ); + + var localStackUrl = App + .GetEndpoint("landplot-localstack", "http") + .ToString() + .TrimEnd('/'); + + StorageClient = new AmazonS3Client( + "test", + "test", + new AmazonS3Config + { + ServiceURL = localStackUrl, + ForcePathStyle = true + }); + } + + public async Task> WaitForObjectAsync( + string prefix, + int maxAttempts = 10) + { + for (var i = 0; i < maxAttempts; i++) + { + await Task.Delay(1000); + + var response = + await StorageClient.ListObjectsV2Async( + new ListObjectsV2Request + { + BucketName = BucketName, + Prefix = prefix + }); + + if (response.S3Objects.Count > 0) + { + return response.S3Objects; + } + } + + return []; + } + + public async Task DisposeAsync() + { + StorageClient.Dispose(); + + await App.StopAsync(); + + await App.DisposeAsync(); + + await Builder.DisposeAsync(); + } +} \ No newline at end of file diff --git a/Tests/IntegrationTests.cs b/Tests/IntegrationTests.cs new file mode 100644 index 00000000..bf5a2bb8 --- /dev/null +++ b/Tests/IntegrationTests.cs @@ -0,0 +1,65 @@ +using Aspire.Hosting.Testing; +using PatientApp.Generator.Models; +using System.Net; +using System.Text.Json; + +namespace Tests; + +public class IntegrationTests(Fixture fixture) + : IClassFixture +{ + private static readonly Random _random = new(); + + [Fact] + public async Task Gateway_Returns_200() + { + var id = _random.Next(1, 100); + + using var client = + fixture.App.CreateHttpClient("gateway", "http"); + + var response = + await client.GetAsync($"/patient?id={id}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Patient_Is_Saved_To_S3() + { + var id = _random.Next(100, 200); + + using var client = + fixture.App.CreateHttpClient("gateway", "http"); + + await client.GetAsync($"/patient?id={id}"); + + var objects = + await fixture.WaitForObjectAsync("landplot_"); + + Assert.NotEmpty(objects); + } + + [Fact] + public async Task Cache_Returns_Same_Response() + { + var id = _random.Next(200, 300); + + using var client = + fixture.App.CreateHttpClient("gateway", "http"); + + var firstResponse = + await client.GetAsync($"/patient?id={id}"); + + var firstJson = + await firstResponse.Content.ReadAsStringAsync(); + + var secondResponse = + await client.GetAsync($"/patient?id={id}"); + + var secondJson = + await secondResponse.Content.ReadAsStringAsync(); + + Assert.Equal(firstJson, secondJson); + } +} \ No newline at end of file diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj new file mode 100644 index 00000000..73bd98c1 --- /dev/null +++ b/Tests/Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + diff --git a/image-1.png b/image-1.png new file mode 100644 index 00000000..9c57ccab Binary files /dev/null and b/image-1.png differ diff --git a/image-2.png b/image-2.png new file mode 100644 index 00000000..1526fc63 Binary files /dev/null and b/image-2.png differ diff --git a/image.png b/image.png new file mode 100644 index 00000000..5f22ea2e Binary files /dev/null and b/image.png differ