diff --git a/ApiGateway/ApiGateway.csproj b/ApiGateway/ApiGateway.csproj
new file mode 100644
index 00000000..d3911d2e
--- /dev/null
+++ b/ApiGateway/ApiGateway.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ApiGateway/LoadBalancing/ServicesAreEmptyError.cs b/ApiGateway/LoadBalancing/ServicesAreEmptyError.cs
new file mode 100644
index 00000000..2ce3efb6
--- /dev/null
+++ b/ApiGateway/LoadBalancing/ServicesAreEmptyError.cs
@@ -0,0 +1,6 @@
+using Ocelot.Errors;
+
+namespace ApiGateway.LoadBalancing;
+
+public sealed class ServicesAreEmptyError(string message)
+ : Error(message, OcelotErrorCode.UnableToFindDownstreamRouteError, 503);
diff --git a/ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs
new file mode 100644
index 00000000..b7b97740
--- /dev/null
+++ b/ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs
@@ -0,0 +1,48 @@
+using Ocelot.LoadBalancer.LoadBalancers;
+using Ocelot.Responses;
+using Ocelot.Values;
+
+namespace ApiGateway.LoadBalancing;
+
+///
+/// Взвешенная карусель (Weighted Round Robin).
+/// Каждой реплике присваивается вес — она обслуживает ровно weight запросов подряд,
+/// после чего очередь переходит к следующей реплике.
+/// Веса: R1=3, R2=2, R3=1 → R1,R1,R1,R2,R2,R3,R1,...
+///
+public sealed class WeightedRoundRobinBalancer(Func>> services) : ILoadBalancer
+{
+ private readonly Func>> _services = services;
+ private readonly int[] _weights = [3, 2, 1];
+ private readonly object _lock = new();
+
+ private int _currentIndex = -1;
+ private int _remainingCalls = 0;
+
+ public string Type => nameof(WeightedRoundRobinBalancer);
+
+ public async Task> LeaseAsync(HttpContext httpContext)
+ {
+ var available = await _services.Invoke();
+
+ if (available is null || available.Count == 0)
+ return new ErrorResponse(
+ new ServicesAreEmptyError("No downstream services available"));
+
+ lock (_lock)
+ {
+ if (_currentIndex == -1 || _remainingCalls == 0)
+ {
+ _currentIndex = (_currentIndex + 1) % available.Count;
+ _remainingCalls = _weights[_currentIndex % _weights.Length];
+ }
+
+ var service = available[_currentIndex];
+ _remainingCalls--;
+
+ return new OkResponse(service.HostAndPort);
+ }
+ }
+
+ public void Release(ServiceHostAndPort hostAndPort) { }
+}
diff --git a/ApiGateway/Program.cs b/ApiGateway/Program.cs
new file mode 100644
index 00000000..3ab3f5f7
--- /dev/null
+++ b/ApiGateway/Program.cs
@@ -0,0 +1,38 @@
+using ApiGateway.LoadBalancing;
+using Ocelot.DependencyInjection;
+using Ocelot.Middleware;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true);
+
+for (var i = 0; i < 3; i++)
+{
+ var url = builder.Configuration[$"services:generator-service-{i}:http:0"];
+ if (url is null) break;
+ var uri = new Uri(url);
+ builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Host"] = uri.Host;
+ builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Port"] = uri.Port.ToString();
+}
+
+builder.Services.AddOcelot()
+ .AddCustomLoadBalancer((_, _, provider) => new(provider.GetAsync));
+
+var allowedOrigin = builder.Configuration["Cors:AllowedOrigin"]
+ ?? throw new InvalidOperationException("Cors:AllowedOrigin is not configured");
+
+builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
+{
+ policy.WithOrigins(allowedOrigin);
+ policy.WithMethods("GET");
+ policy.WithHeaders("Content-Type");
+}));
+
+var app = builder.Build();
+
+app.UseCors();
+
+await app.UseOcelot();
+
+app.Run();
diff --git a/ApiGateway/Properties/launchSettings.json b/ApiGateway/Properties/launchSettings.json
new file mode 100644
index 00000000..647a0d20
--- /dev/null
+++ b/ApiGateway/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "ApiGateway": {
+ "commandName": "Project",
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5200",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/ApiGateway/appsettings.json b/ApiGateway/appsettings.json
new file mode 100644
index 00000000..10f68b8c
--- /dev/null
+++ b/ApiGateway/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/ApiGateway/ocelot.json b/ApiGateway/ocelot.json
new file mode 100644
index 00000000..ff4e1ddd
--- /dev/null
+++ b/ApiGateway/ocelot.json
@@ -0,0 +1,18 @@
+{
+ "Routes": [
+ {
+ "UpstreamPathTemplate": "/patient",
+ "UpstreamHttpMethod": [ "GET" ],
+ "DownstreamPathTemplate": "/patient",
+ "DownstreamScheme": "http",
+ "LoadBalancerOptions": {
+ "Type": "WeightedRoundRobinBalancer"
+ },
+ "DownstreamHostAndPorts": [
+ { "Host": "localhost", "Port": 15000 },
+ { "Host": "localhost", "Port": 15001 },
+ { "Host": "localhost", "Port": 15002 }
+ ]
+ }
+ ]
+}
diff --git a/AppHost/AppHost.csproj b/AppHost/AppHost.csproj
new file mode 100644
index 00000000..8954367d
--- /dev/null
+++ b/AppHost/AppHost.csproj
@@ -0,0 +1,25 @@
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AppHost/Program.cs b/AppHost/Program.cs
new file mode 100644
index 00000000..2bfc9725
--- /dev/null
+++ b/AppHost/Program.cs
@@ -0,0 +1,40 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+var redis = builder.AddRedis("redis")
+ .WithRedisInsight();
+
+var localstack = builder.AddContainer("localstack", "localstack/localstack", "3.8.1")
+ .WithEnvironment("SERVICES", "sns,sqs")
+ .WithHttpEndpoint(port: 4566, targetPort: 4566, name: "http");
+
+var minio = builder.AddContainer("minio", "minio/minio")
+ .WithArgs("server", "/data", "--console-address", ":9001")
+ .WithEnvironment("MINIO_ROOT_USER", "minioadmin")
+ .WithEnvironment("MINIO_ROOT_PASSWORD", "minioadmin")
+ .WithHttpEndpoint(port: 9000, targetPort: 9000, name: "api")
+ .WithHttpEndpoint(port: 9001, targetPort: 9001, name: "console");
+
+var client = builder.AddProject("client");
+
+var gateway = builder.AddProject("api-gateway")
+ .WithEnvironment("Cors__AllowedOrigin", client.GetEndpoint("http"));
+
+for (var i = 0; i < 3; i++)
+{
+ var replica = builder.AddProject($"generator-service-{i}", launchProfileName: null)
+ .WithHttpEndpoint(port: 15000 + i)
+ .WithReference(redis)
+ .WaitFor(redis)
+ .WaitFor(localstack)
+ .WithEnvironment("AWS__ServiceURL", localstack.GetEndpoint("http"));
+ gateway.WithReference(replica).WaitFor(replica);
+}
+
+builder.AddProject("file-service")
+ .WithHttpEndpoint(port: 5300, name: "http")
+ .WithEnvironment("AWS__ServiceURL", localstack.GetEndpoint("http"))
+ .WithEnvironment("Minio__ServiceUrl", minio.GetEndpoint("api"))
+ .WaitFor(localstack)
+ .WaitFor(minio);
+
+builder.Build().Run();
diff --git a/AppHost/Properties/launchSettings.json b/AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000..6d91260d
--- /dev/null
+++ b/AppHost/Properties/launchSettings.json
@@ -0,0 +1,16 @@
+{
+ "profiles": {
+ "AppHost": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17193;http://localhost:15237",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21193",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22057"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor
index 661f1181..a96e557a 100644
--- a/Client.Wasm/Components/StudentCard.razor
+++ b/Client.Wasm/Components/StudentCard.razor
@@ -4,10 +4,10 @@
- Номер №X "Название лабораторной"
- Вариант №Х "Название варианта"
- Выполнена Фамилией Именем 65ХХ
- Ссылка на форк
+ Номер № 1
+ Вариант № 18
+ Выполнена Чумаковым Иваном 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..0ed61cc5 100644
--- a/Client.Wasm/wwwroot/appsettings.json
+++ b/Client.Wasm/wwwroot/appsettings.json
@@ -1,10 +1,3 @@
{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
- },
- "AllowedHosts": "*",
- "BaseAddress": ""
+ "BaseAddress": "http://localhost:5200/patient"
}
diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln
index cb48241d..40bcaaca 100644
--- a/CloudDevelopment.sln
+++ b/CloudDevelopment.sln
@@ -5,6 +5,20 @@ 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}") = "AppHost", "AppHost\AppHost.csproj", "{B1C2D3E4-F5A6-7890-ABCD-EF1234567890}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GeneratorService", "GeneratorService\GeneratorService.csproj", "{139BD442-54A6-9109-CF9A-53DA218D46F2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GeneratorService.Tests", "GeneratorService.Tests\GeneratorService.Tests.csproj", "{EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceDefaults", "ServiceDefaults\ServiceDefaults.csproj", "{9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiGateway", "ApiGateway\ApiGateway.csproj", "{C2D3E4F5-A6B7-8901-CDEF-234567890ABC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileService", "FileService\FileService.csproj", "{3A9D7F1C-9A6F-425E-A2F5-8BDFFA064707}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Integration.Tests", "Integration.Tests\Integration.Tests.csproj", "{6BC4650D-FEA0-4BA7-8DA7-1A8B097DAF3C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -15,6 +29,34 @@ 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
+ {B1C2D3E4-F5A6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B1C2D3E4-F5A6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B1C2D3E4-F5A6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B1C2D3E4-F5A6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
+ {139BD442-54A6-9109-CF9A-53DA218D46F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {139BD442-54A6-9109-CF9A-53DA218D46F2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {139BD442-54A6-9109-CF9A-53DA218D46F2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {139BD442-54A6-9109-CF9A-53DA218D46F2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C2D3E4F5-A6B7-8901-CDEF-234567890ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C2D3E4F5-A6B7-8901-CDEF-234567890ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C2D3E4F5-A6B7-8901-CDEF-234567890ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C2D3E4F5-A6B7-8901-CDEF-234567890ABC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3A9D7F1C-9A6F-425E-A2F5-8BDFFA064707}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3A9D7F1C-9A6F-425E-A2F5-8BDFFA064707}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3A9D7F1C-9A6F-425E-A2F5-8BDFFA064707}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3A9D7F1C-9A6F-425E-A2F5-8BDFFA064707}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6BC4650D-FEA0-4BA7-8DA7-1A8B097DAF3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6BC4650D-FEA0-4BA7-8DA7-1A8B097DAF3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6BC4650D-FEA0-4BA7-8DA7-1A8B097DAF3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6BC4650D-FEA0-4BA7-8DA7-1A8B097DAF3C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/FileService/FileService.csproj b/FileService/FileService.csproj
new file mode 100644
index 00000000..87de66f9
--- /dev/null
+++ b/FileService/FileService.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FileService/Program.cs b/FileService/Program.cs
new file mode 100644
index 00000000..b62ff8cb
--- /dev/null
+++ b/FileService/Program.cs
@@ -0,0 +1,17 @@
+using FileService.Services;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+
+app.MapGet("/files", async (MinioStorageService storage, CancellationToken ct) =>
+ Results.Ok(await storage.ListFilesAsync(ct)));
+
+app.Run();
diff --git a/FileService/Services/MinioStorageService.cs b/FileService/Services/MinioStorageService.cs
new file mode 100644
index 00000000..a5decf15
--- /dev/null
+++ b/FileService/Services/MinioStorageService.cs
@@ -0,0 +1,72 @@
+using Amazon.Runtime;
+using Amazon.S3;
+using Amazon.S3.Model;
+
+namespace FileService.Services;
+
+public sealed class MinioStorageService : IDisposable
+{
+ private readonly IAmazonS3 _s3;
+ private readonly string _bucket;
+ private int _bucketEnsured;
+
+ public MinioStorageService(IConfiguration configuration)
+ {
+ var serviceUrl = configuration["Minio:ServiceUrl"] ?? "http://localhost:9000";
+ var accessKey = configuration["Minio:AccessKey"] ?? "minioadmin";
+ var secretKey = configuration["Minio:SecretKey"] ?? "minioadmin";
+ _bucket = configuration["Minio:BucketName"] ?? "patients";
+
+ _s3 = new AmazonS3Client(
+ new BasicAWSCredentials(accessKey, secretKey),
+ new AmazonS3Config
+ {
+ ServiceURL = serviceUrl,
+ ForcePathStyle = true,
+ AuthenticationRegion = "us-east-1"
+ });
+ }
+
+ public async Task EnsureBucketExistsAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ await _s3.PutBucketAsync(_bucket, ct);
+ }
+ catch (AmazonS3Exception e) when (e.ErrorCode is "BucketAlreadyOwnedByYou" or "BucketAlreadyExists")
+ {
+ }
+ }
+
+ public async Task SavePatientAsync(string patientJson, int patientId, CancellationToken ct = default)
+ {
+ if (Interlocked.CompareExchange(ref _bucketEnsured, 1, 0) == 0)
+ await EnsureBucketExistsAsync(ct);
+ var key = $"patient-{patientId}-{DateTime.UtcNow:yyyyMMddHHmmss}.json";
+ await _s3.PutObjectAsync(new PutObjectRequest
+ {
+ BucketName = _bucket,
+ Key = key,
+ ContentBody = patientJson,
+ ContentType = "application/json"
+ }, ct);
+ }
+
+ public async Task> ListFilesAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ var response = await _s3.ListObjectsV2Async(new ListObjectsV2Request
+ {
+ BucketName = _bucket
+ }, ct);
+ return response.S3Objects.Select(o => o.Key).ToList();
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ public void Dispose() => _s3.Dispose();
+}
diff --git a/FileService/Services/SqsPollingService.cs b/FileService/Services/SqsPollingService.cs
new file mode 100644
index 00000000..8b28dd33
--- /dev/null
+++ b/FileService/Services/SqsPollingService.cs
@@ -0,0 +1,126 @@
+using Amazon.Runtime;
+using Amazon.SimpleNotificationService;
+using Amazon.SimpleNotificationService.Model;
+using Amazon.SQS;
+using Amazon.SQS.Model;
+using System.Text.Json;
+
+namespace FileService.Services;
+
+public sealed class SqsPollingService(
+ ILogger logger,
+ MinioStorageService storage,
+ IConfiguration configuration) : BackgroundService
+{
+ private readonly string _topicName = configuration["Sns:TopicName"] ?? "medical-patients";
+ private readonly string _queueName = configuration["Sqs:QueueName"] ?? "medical-patients-queue";
+ private readonly string _awsServiceUrl = configuration["AWS:ServiceURL"] ?? "http://localhost:4566";
+
+ private string? _queueUrl;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var credentials = new BasicAWSCredentials("test", "test");
+ using var sqs = new AmazonSQSClient(credentials, new AmazonSQSConfig { ServiceURL = _awsServiceUrl });
+ using var sns = new AmazonSimpleNotificationServiceClient(credentials,
+ new AmazonSimpleNotificationServiceConfig { ServiceURL = _awsServiceUrl });
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await SetupAsync(sns, sqs, stoppingToken);
+ break;
+ }
+ catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
+ {
+ logger.LogWarning(ex, "Infrastructure setup failed, retrying in 5s");
+ await Task.Delay(5000, stoppingToken);
+ }
+ }
+
+ logger.LogInformation("SQS polling started. Queue={QueueUrl}", _queueUrl);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await PollAsync(sqs, stoppingToken);
+ }
+ catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
+ {
+ logger.LogError(ex, "Polling error");
+ await Task.Delay(2000, stoppingToken);
+ }
+ }
+ }
+
+ private async Task SetupAsync(
+ IAmazonSimpleNotificationService sns,
+ IAmazonSQS sqs,
+ CancellationToken ct)
+ {
+ var topicArn = (await sns.CreateTopicAsync(_topicName, ct)).TopicArn;
+
+ _queueUrl = (await sqs.CreateQueueAsync(_queueName, ct)).QueueUrl;
+
+ var attrs = await sqs.GetQueueAttributesAsync(_queueUrl, ["QueueArn"], ct);
+ var queueArn = attrs.QueueARN;
+
+ var subscriptions = await sns.ListSubscriptionsByTopicAsync(topicArn, ct);
+ if (!subscriptions.Subscriptions.Any(s => s.Endpoint == queueArn))
+ {
+ await sns.SubscribeAsync(topicArn, "sqs", queueArn, ct);
+ await sqs.SetQueueAttributesAsync(_queueUrl, new Dictionary
+ {
+ ["Policy"] = $$$"""
+ {
+ "Version": "2012-10-17",
+ "Statement": [{
+ "Effect": "Allow",
+ "Principal": "*",
+ "Action": "sqs:SendMessage",
+ "Resource": "{{{queueArn}}}",
+ "Condition": {"ArnEquals": {"aws:SourceArn": "{{{topicArn}}}"}}
+ }]
+ }
+ """
+ }, ct);
+ }
+ }
+
+ private async Task PollAsync(IAmazonSQS sqs, CancellationToken ct)
+ {
+ if (_queueUrl is null) return;
+
+ var response = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest
+ {
+ QueueUrl = _queueUrl,
+ MaxNumberOfMessages = 10,
+ WaitTimeSeconds = 5
+ }, ct);
+
+ foreach (var message in response.Messages)
+ {
+ try
+ {
+ using var envelope = JsonDocument.Parse(message.Body);
+ var patientJson = envelope.RootElement.TryGetProperty("Message", out var msg)
+ ? msg.GetString() ?? message.Body
+ : message.Body;
+
+ using var patientDoc = JsonDocument.Parse(patientJson);
+ var patientId = patientDoc.RootElement.GetProperty("Id").GetInt32();
+
+ await storage.SavePatientAsync(patientJson, patientId, ct);
+ await sqs.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, ct);
+
+ logger.LogInformation("Saved patient {PatientId} to MinIO", patientId);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to process SQS message");
+ }
+ }
+ }
+}
diff --git a/FileService/appsettings.json b/FileService/appsettings.json
new file mode 100644
index 00000000..a01f3970
--- /dev/null
+++ b/FileService/appsettings.json
@@ -0,0 +1,25 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "AWS": {
+ "ServiceURL": "http://localhost:4566",
+ "Region": "us-east-1"
+ },
+ "Sns": {
+ "TopicName": "medical-patients"
+ },
+ "Sqs": {
+ "QueueName": "medical-patients-queue"
+ },
+ "Minio": {
+ "ServiceUrl": "http://localhost:9000",
+ "AccessKey": "minioadmin",
+ "SecretKey": "minioadmin",
+ "BucketName": "patients"
+ }
+}
diff --git a/GeneratorService.Tests/GeneratorService.Tests.csproj b/GeneratorService.Tests/GeneratorService.Tests.csproj
new file mode 100644
index 00000000..c9bc33ca
--- /dev/null
+++ b/GeneratorService.Tests/GeneratorService.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/GeneratorService.Tests/MedicalPatientGeneratorTests.cs b/GeneratorService.Tests/MedicalPatientGeneratorTests.cs
new file mode 100644
index 00000000..5daec200
--- /dev/null
+++ b/GeneratorService.Tests/MedicalPatientGeneratorTests.cs
@@ -0,0 +1,62 @@
+using GeneratorService.Generators;
+using Xunit;
+
+namespace GeneratorService.Tests;
+
+public sealed class MedicalPatientGeneratorTests
+{
+ public static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.Today);
+
+ public static IEnumerable