diff --git a/Aspire.AppHost.Tests/Aspire.AppHost.Tests.csproj b/Aspire.AppHost.Tests/Aspire.AppHost.Tests.csproj new file mode 100644 index 00000000..954c6f1f --- /dev/null +++ b/Aspire.AppHost.Tests/Aspire.AppHost.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/Aspire.AppHost.Tests/UnitTest1.cs b/Aspire.AppHost.Tests/UnitTest1.cs new file mode 100644 index 00000000..ceea9789 --- /dev/null +++ b/Aspire.AppHost.Tests/UnitTest1.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using Aspire.Hosting; +using Aspire.Hosting.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Service.Api.Entity; +using Xunit.Abstractions; +using System.Net.Http.Json; + +namespace Aspire.AppHost.Tests; + +public class UnitTest1(ITestOutputHelper output) : IAsyncLifetime +{ + private IDistributedApplicationTestingBuilder? _builder; + private DistributedApplication? _app; + + /// + public async Task InitializeAsync() + { + var cancellationToken = CancellationToken.None; + _builder = await DistributedApplicationTestingBuilder.CreateAsync(cancellationToken); + _builder.Configuration["DcpPublisher:RandomizePorts"] = "false"; + _builder.Services.AddLogging(logging => + { + logging.AddXUnit(output); + logging.SetMinimumLevel(LogLevel.Debug); + logging.AddFilter("Aspire.Hosting.Dcp", LogLevel.Debug); + logging.AddFilter("Aspire.Hosting", LogLevel.Debug); + }); + } + + [Theory] + [InlineData("Development")] + public async Task TestPipeline(string envName) + { + var cancellationToken = CancellationToken.None; + _builder!.Environment.EnvironmentName = envName; + _app = await _builder.BuildAsync(cancellationToken); + await _app.StartAsync(cancellationToken); + + var random = new Random(); + var id = random.Next(1, 100); + using var gatewayClient = _app.CreateHttpClient("api-gw", "http"); + using var gatewayResponse = await gatewayClient!.GetAsync($"/api/projects?id={id}"); + var api = await gatewayResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + await Task.Delay(5000); + using var storageClient = _app.CreateHttpClient("programproj-storage", "http"); + using var listResponse = await storageClient!.GetAsync($"/api/s3"); + var ppList = await listResponse.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken); + using var s3Response = await storageClient!.GetAsync($"/api/s3/programproj_{id}.json"); + var s3 = await s3Response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + Assert.NotNull(ppList); + Assert.Single(ppList); + Assert.NotNull(api); + Assert.NotNull(s3); + Assert.Equal(id, s3.Id); + Assert.Equivalent(api, s3); + } + + /// + public async Task DisposeAsync() + { + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + + if (_builder is not null) + { + await _builder.DisposeAsync(); + } + } + +} \ No newline at end of file diff --git a/Aspire/Aspire.AppHost/AppHost.cs b/Aspire/Aspire.AppHost/AppHost.cs new file mode 100644 index 00000000..7e1a184d --- /dev/null +++ b/Aspire/Aspire.AppHost/AppHost.cs @@ -0,0 +1,85 @@ +using Amazon; +using Microsoft.Extensions.Configuration; +using Aspire.Hosting.LocalStack.Container; +using Projects; + +var builder = DistributedApplication.CreateBuilder(args); + +var cache = builder.AddRedis("programproj-cache").WithRedisInsight(containerName: "programproj-insight"); + +var ports = builder.Configuration.GetSection("ApiGateway:Ports").Get() + ?? throw new InvalidOperationException("Configuration section 'ApiGateway:Ports' is missing or empty in appsettings.json file."); + +var apiGW = builder.AddProject("api-gw", project => +{ + project.ExcludeLaunchProfile = true; +}) +.WithHttpEndpoint(port: 5247) +.WithHttpsEndpoint(port: 7198); + +var awsConfig = builder.AddAWSSDKConfig() + .WithProfile("default") + .WithRegion(RegionEndpoint.EUCentral1); + +var localstack = builder.AddLocalStack("programproj-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 templatePath = Path.Combine( + builder.AppHostDirectory, + "Cloudformation", + "programproj-template-sns.yml"); + +var awsResources = builder.AddAWSCloudFormationTemplate( + "resources", + templatePath, + "programproj") + .WithReference(awsConfig); + +var storage = builder.AddProject("programproj-storage", project => +{ + project.ExcludeLaunchProfile = true; +}) +.WithReference(awsResources) +.WithHttpEndpoint(port: 5280) +.WithEnvironment("AWS__Resources__SNSUrl", "http://host.docker.internal:5280/api/sns") +.WaitFor(awsResources); + +var minio = builder.AddMinioContainer("programproj-minio"); + +storage.WithEnvironment("AWS__Resources__MinioBucketName", "programproj-bucket") + .WithReference(minio) + .WaitFor(minio); + +for (var i = 0; i < ports.Length; i++) +{ + var httpsPort = ports[i]; + var httpPort = httpsPort - 1000; + + var service = builder.AddProject($"programproj-api{i + 1}", project => + { + project.ExcludeLaunchProfile = true; + }) + .WithReference(cache, "RedisCache") + .WithHttpEndpoint(port: httpPort) + .WithHttpsEndpoint(port: httpsPort) + .WithReference(awsResources) + .WithHttpHealthCheck("/health", endpointName: "https") + .WaitFor(cache) + .WaitFor(awsResources); + + apiGW.WaitFor(service); +} + +builder.AddProject("programproj-wasm") + .WaitFor(apiGW); + +builder.UseLocalStack(localstack); + +builder.Build().Run(); \ No newline at end of file diff --git a/Aspire/Aspire.AppHost/Aspire.AppHost.csproj b/Aspire/Aspire.AppHost/Aspire.AppHost.csproj new file mode 100644 index 00000000..27b6c4b4 --- /dev/null +++ b/Aspire/Aspire.AppHost/Aspire.AppHost.csproj @@ -0,0 +1,31 @@ + + + + + + Exe + net8.0 + enable + enable + 348a6352-668a-4b0d-833f-bbd1cc51ceb3 + + + + + + + + + + + + + + + + + + + + + diff --git a/Aspire/Aspire.AppHost/Cloudformation/programproj-template-sns.yml b/Aspire/Aspire.AppHost/Cloudformation/programproj-template-sns.yml new file mode 100644 index 00000000..16bea66c --- /dev/null +++ b/Aspire/Aspire.AppHost/Cloudformation/programproj-template-sns.yml @@ -0,0 +1,29 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: 'Cloud formation template for program-project project' + +Parameters: + TopicName: + Type: String + Description: Name for the SNS topic + Default: 'programproj-topic' + +Resources: + ProgramProjTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref TopicName + DisplayName: !Ref TopicName + Tags: + - Key: Name + Value: !Ref TopicName + - Key: Environment + Value: Sample + +Outputs: + SNSTopicName: + Description: Name of the SNS topic + Value: !GetAtt ProgramProjTopic.TopicName + + SNSTopicArn: + Description: ARN of the SNS topic + Value: !Ref ProgramProjTopic \ No newline at end of file diff --git a/Aspire/Aspire.AppHost/Properties/launchSettings.json b/Aspire/Aspire.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..59a529e3 --- /dev/null +++ b/Aspire/Aspire.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:17161;http://localhost:15041", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21204", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22223" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15041", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19104", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20068" + } + } + } +} diff --git a/Aspire/Aspire.AppHost/appsettings.Development.json b/Aspire/Aspire.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Aspire/Aspire.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Aspire/Aspire.AppHost/appsettings.json b/Aspire/Aspire.AppHost/appsettings.json new file mode 100644 index 00000000..5055c494 --- /dev/null +++ b/Aspire/Aspire.AppHost/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "ApiGateway": { + "Ports": [ 4441, 4442, 4443, 4444, 4445 ] + }, + "LocalStack": { + "UseLocalStack": true + } +} diff --git a/Aspire/Aspire.ServiceDefaults/Aspire.ServiceDefaults.csproj b/Aspire/Aspire.ServiceDefaults/Aspire.ServiceDefaults.csproj new file mode 100644 index 00000000..1b6e209a --- /dev/null +++ b/Aspire/Aspire.ServiceDefaults/Aspire.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/Aspire/Aspire.ServiceDefaults/Extensions.cs b/Aspire/Aspire.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..b72c8753 --- /dev/null +++ b/Aspire/Aspire.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/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..fe1204dc 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №3 "брокер+хранилище" + Вариант №42 "Программный проект" + Выполнена Кадников Николай 6513 + Ссылка на форк diff --git a/Client.Wasm/Properties/launchSettings.json b/Client.Wasm/Properties/launchSettings.json index 0d824ea7..7e2ee008 100644 --- a/Client.Wasm/Properties/launchSettings.json +++ b/Client.Wasm/Properties/launchSettings.json @@ -1,41 +1,41 @@ { - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:36545", - "sslPort": 44337 - } - }, "profiles": { "http": { "commandName": "Project", - "dotnetRunMessages": true, "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "http://localhost:5127", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" - } + }, + "dotnetRunMessages": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5127" }, "https": { "commandName": "Project", - "dotnetRunMessages": true, "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "https://localhost:7282;http://localhost:5127", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" - } + }, + "dotnetRunMessages": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7282;http://localhost:5127" }, "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" - } + }, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:58120/", + "sslPort": 44345 } } -} +} \ No newline at end of file diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..1b733ca6 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "https://localhost:7198/api/projects" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..e980f521 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -5,6 +5,18 @@ 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}") = "Service.Api", "Service.Api\Service.Api.csproj", "{5003CEA3-9343-45C1-B1E4-6FE43745A8D5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.AppHost", "Aspire\Aspire.AppHost\Aspire.AppHost.csproj", "{466A15CD-2DE0-4374-9653-08989D32708A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.ServiceDefaults", "Aspire\Aspire.ServiceDefaults\Aspire.ServiceDefaults.csproj", "{AD6B47E3-6C6E-420E-9803-369B1474B73F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Service.Gateway", "Service.Gateway\Service.Gateway.csproj", "{7F9E88E0-BDDC-444F-A20D-B312AD79AD6C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Service.Storage", "Service.Storage\Service.Storage.csproj", "{38FD6B58-76B3-4EEC-BF2C-F37E0B4D0055}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.AppHost.Tests", "Aspire.AppHost.Tests\Aspire.AppHost.Tests.csproj", "{D6D100F4-5267-431E-977D-254192A49518}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -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 + {5003CEA3-9343-45C1-B1E4-6FE43745A8D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5003CEA3-9343-45C1-B1E4-6FE43745A8D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5003CEA3-9343-45C1-B1E4-6FE43745A8D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5003CEA3-9343-45C1-B1E4-6FE43745A8D5}.Release|Any CPU.Build.0 = Release|Any CPU + {466A15CD-2DE0-4374-9653-08989D32708A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {466A15CD-2DE0-4374-9653-08989D32708A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {466A15CD-2DE0-4374-9653-08989D32708A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {466A15CD-2DE0-4374-9653-08989D32708A}.Release|Any CPU.Build.0 = Release|Any CPU + {AD6B47E3-6C6E-420E-9803-369B1474B73F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD6B47E3-6C6E-420E-9803-369B1474B73F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD6B47E3-6C6E-420E-9803-369B1474B73F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD6B47E3-6C6E-420E-9803-369B1474B73F}.Release|Any CPU.Build.0 = Release|Any CPU + {7F9E88E0-BDDC-444F-A20D-B312AD79AD6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F9E88E0-BDDC-444F-A20D-B312AD79AD6C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F9E88E0-BDDC-444F-A20D-B312AD79AD6C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F9E88E0-BDDC-444F-A20D-B312AD79AD6C}.Release|Any CPU.Build.0 = Release|Any CPU + {38FD6B58-76B3-4EEC-BF2C-F37E0B4D0055}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38FD6B58-76B3-4EEC-BF2C-F37E0B4D0055}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38FD6B58-76B3-4EEC-BF2C-F37E0B4D0055}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38FD6B58-76B3-4EEC-BF2C-F37E0B4D0055}.Release|Any CPU.Build.0 = Release|Any CPU + {D6D100F4-5267-431E-977D-254192A49518}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6D100F4-5267-431E-977D-254192A49518}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6D100F4-5267-431E-977D-254192A49518}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6D100F4-5267-431E-977D-254192A49518}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Service.Api/Broker/IProducerService.cs b/Service.Api/Broker/IProducerService.cs new file mode 100644 index 00000000..188d3587 --- /dev/null +++ b/Service.Api/Broker/IProducerService.cs @@ -0,0 +1,8 @@ +using Service.Api.Entity; + +namespace Service.Api.Broker; + +public interface IProducerService +{ + public Task SendMessage(ProgramProject pp); +} diff --git a/Service.Api/Broker/SnsPublisherService.cs b/Service.Api/Broker/SnsPublisherService.cs new file mode 100644 index 00000000..42fe79cc --- /dev/null +++ b/Service.Api/Broker/SnsPublisherService.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using Service.Api.Entity; + +namespace Service.Api.Broker; + +public class SnsPublisherService(IAmazonSimpleNotificationService client, IConfiguration configuration, ILogger logger) : IProducerService +{ + private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"] + ?? throw new KeyNotFoundException("SNS topic link was not found in configuration"); + public async Task SendMessage(ProgramProject pp) + { + try + { + var json = JsonSerializer.Serialize(pp); + var request = new PublishRequest + { + Message = json, + TopicArn = _topicArn + }; + var response = await client.PublishAsync(request); + if (response.HttpStatusCode == System.Net.HttpStatusCode.OK) logger.LogInformation($"Programproj {pp.Id} was sent to storage via SNS"); + else throw new Exception($"SNS returned {response.HttpStatusCode}"); + } + catch (Exception e) + { + logger.LogError(e, "Unable to send programproj through sns topic"); + } + } +} diff --git a/Service.Api/Entity/ProgramProject.cs b/Service.Api/Entity/ProgramProject.cs new file mode 100644 index 00000000..bbd55586 --- /dev/null +++ b/Service.Api/Entity/ProgramProject.cs @@ -0,0 +1,48 @@ +namespace Service.Api.Entity; +/// +/// This class describes program project with customer, manager, +/// dates of start n end, budged and spent money. +/// +public class ProgramProject +{ + /// + /// The project's ID + /// + public int Id { get; set; } + /// + /// The project's name + /// + public string Name { get; set; } + /// + /// The project's customer + /// + public string Customer { get; set; } + /// + /// The project's manager + /// + public string Manager { get; set; } + /// + /// The date of actual start of the project + /// + public DateOnly StartDate { get; set; } + /// + /// The planned date of the end of project + /// + public DateOnly EndDatePlanned { get; set; } + /// + /// The real date of the end of the project. It may be null if the project is still in progress. + /// + public DateOnly? EndDateReal { get; set; } + /// + /// The project's budget + /// + public decimal Budget { get; set; } + /// + /// How much money the project actually spent + /// + public decimal SpentMoney { get; set; } + /// + /// Shows how complete the project is + /// + public int FinishedPerCent { get; set; } +} diff --git a/Service.Api/Generator/ProgramProjectFaker.cs b/Service.Api/Generator/ProgramProjectFaker.cs new file mode 100644 index 00000000..4229e3f9 --- /dev/null +++ b/Service.Api/Generator/ProgramProjectFaker.cs @@ -0,0 +1,35 @@ +using Bogus; +using Service.Api.Entity; + +namespace Service.Api.Generator; +/// +/// Generator of test data of Program Project using Bogus. +/// +public class ProgramProjectFaker : Faker +{ + /// + /// Initializes the ProgramProject field generation rules + /// with the "ru" locale and established dependencies between dates, + /// budget, completion percentage, and actual costs. + /// + public ProgramProjectFaker() : base("ru") + { + RuleFor(o => o.Name, f => f.Commerce.ProductName()); + RuleFor(o => o.Customer, f => f.Company.CompanyName()); + RuleFor(o => o.Manager, f => f.Name.FullName()); + RuleFor(o => o.StartDate, f => DateOnly.FromDateTime(f.Date.Past(2, DateTime.Now))); + RuleFor(o => o.EndDatePlanned, (f, o) => DateOnly.FromDateTime(f.Date.Future(5, o.StartDate.ToDateTime(TimeOnly.MinValue)))); + RuleFor(o => o.EndDateReal, (f, o) => + { + DateTime end = f.Date.Between(o.StartDate.ToDateTime(TimeOnly.MinValue), o.EndDatePlanned.ToDateTime(TimeOnly.MinValue)); + return end > DateTime.Now ? null : DateOnly.FromDateTime(end); + }); + RuleFor(o => o.Budget, f => Math.Round(f.Finance.Amount(100_000, 1_000_000), 2)); + RuleFor(o => o.FinishedPerCent, (f, o) => o.EndDateReal != null ? 100 : f.Random.Number(1, 99)); + RuleFor(o => o.SpentMoney, (f, o) => + { + var spread = Convert.ToInt32(o.Budget) / 15; + return Math.Round((o.Budget - f.Finance.Amount(-spread, spread)) * o.FinishedPerCent / 100, 2); + }); + } +} diff --git a/Service.Api/Program.cs b/Service.Api/Program.cs new file mode 100644 index 00000000..b32df542 --- /dev/null +++ b/Service.Api/Program.cs @@ -0,0 +1,49 @@ +using Bogus; +using Service.Api.Entity; +using Service.Api.Generator; +using Service.Api.Redis; +using Service.Api.Services; +using Amazon.SimpleNotificationService; +using LocalStack.Client.Extensions; +using StackExchange.Redis; +using Service.Api.Broker; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddHealthChecks(); + +builder.Services.AddSingleton, ProgramProjectFaker>(); + +builder.Services.AddSingleton(sp => +{ + var configuration = builder.Configuration.GetConnectionString("RedisCache"); + if (configuration != null) return ConnectionMultiplexer.Connect(configuration); + else throw new InvalidOperationException("u should fix the redis connection"); +}); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddLocalStack(builder.Configuration); +builder.Services.AddScoped(); +builder.Services.AddAwsService(); + +var app = builder.Build(); + +app.MapHealthChecks("health"); + +app.MapDefaultEndpoints(); + +app.UseHttpsRedirection(); + +app.MapGet("/program-proj", async (int id, ProgramProjectCacheService cacheService) => +{ + return Results.Ok(await cacheService.GetOrGenerateAsync(id)); +}) +.WithName("GetProgramProject"); +app.MapGet("/", () => Results.Ok("ok")); +app.Run(); diff --git a/Service.Api/Properties/launchSettings.json b/Service.Api/Properties/launchSettings.json new file mode 100644 index 00000000..f7af466f --- /dev/null +++ b/Service.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:45188", + "sslPort": 44359 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5044", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7262;http://localhost:5044", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Service.Api/Redis/RedisService.cs b/Service.Api/Redis/RedisService.cs new file mode 100644 index 00000000..805db955 --- /dev/null +++ b/Service.Api/Redis/RedisService.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using Service.Api.Entity; +using StackExchange.Redis; + +namespace Service.Api.Redis; +/// +/// Provides Redis caching for storing and retrieving objects. +/// +public class RedisService(IConnectionMultiplexer redis) +{ + private readonly IDatabase _db = redis.GetDatabase(); + /// + /// Stores a value inn Redis. + /// + /// The type of the value to store. + /// Redis key. + /// Object which will be serialized and stored. + /// Expiration timespan, or null for no expiration. + public async Task SetAsync(string key, T value, TimeSpan? expire) + { + var json = JsonSerializer.Serialize(value); + await this._db.StringSetAsync(key, json, expire); + } + /// + /// Retrieves a value from Redis and deserializes it. + /// + /// The expected return typee. + /// Redis key. + /// The deserialized value, or default if the key does'nt exist. + public async Task GetAsync(string key) + { + var value = await this._db.StringGetAsync(key); + if (value.IsNullOrEmpty) return default; + return JsonSerializer.Deserialize(value!); + } +} diff --git a/Service.Api/Service.Api.csproj b/Service.Api/Service.Api.csproj new file mode 100644 index 00000000..0ee66d50 --- /dev/null +++ b/Service.Api/Service.Api.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/Service.Api/Service.Api.http b/Service.Api/Service.Api.http new file mode 100644 index 00000000..2eec3c2d --- /dev/null +++ b/Service.Api/Service.Api.http @@ -0,0 +1,6 @@ +@Service.Api_HostAddress = https://localhost:7262 + +GET {{Service.Api_HostAddress}}/program-proj?id=1 +Accept: application/json + +### diff --git a/Service.Api/Services/ProgramProjectCacheService.cs b/Service.Api/Services/ProgramProjectCacheService.cs new file mode 100644 index 00000000..d42b1679 --- /dev/null +++ b/Service.Api/Services/ProgramProjectCacheService.cs @@ -0,0 +1,47 @@ +using Bogus; +using Service.Api.Broker; +using Service.Api.Entity; +using Service.Api.Generator; +using Service.Api.Redis; +using StackExchange.Redis; + +namespace Service.Api.Services; + +/// +/// Provides cached access to instances. +/// Generates a new project if it is not found in Redis. +/// +public class ProgramProjectCacheService(RedisService cacheService, Faker faker, IProducerService producerService) +{ + /// + /// Returns a cached by ID, + /// or generates and stores a new one if not found. + /// + /// Id of the project. + /// The existing or newly generated . + public async Task GetOrGenerateAsync(int id) + { + var key = $"project:{id}"; + try + { + var programProject = await cacheService.GetAsync(key); + if (programProject != null) return programProject; + } + catch (RedisException ex) + { + Console.WriteLine(ex.Message); + } + var newProject = faker.Generate(); + newProject.Id = id; + await producerService.SendMessage(newProject); + try + { + await cacheService.SetAsync(key, newProject, TimeSpan.FromHours(12)); + } + catch (RedisException ex) + { + Console.WriteLine(ex.Message); + } + return newProject; + } +} diff --git a/Service.Api/appsettings.Development.json b/Service.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Service.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Service.Api/appsettings.json b/Service.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/Service.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Service.Gateway/Balancer/QueryBasedLoadBalancer.cs b/Service.Gateway/Balancer/QueryBasedLoadBalancer.cs new file mode 100644 index 00000000..1b409878 --- /dev/null +++ b/Service.Gateway/Balancer/QueryBasedLoadBalancer.cs @@ -0,0 +1,44 @@ +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Values; +using Ocelot.Responses; +using Ocelot.DownstreamRouteFinder.Finder; +using Ocelot.Errors; + +namespace Service.Gateway.Balancer; +/// +/// Ocelot load balancer that selects a downstream service based on the value of id parametr in query. +/// +public class QueryBasedLoadBalancer(Func>> services) : ILoadBalancer +{ + private readonly Func>> _services = services; + + public string Type => nameof(QueryBasedLoadBalancer); + /// + /// get downstream service selected for the request + /// + /// + /// + /// + public async Task> LeaseAsync(HttpContext httpContext) + { + var services = await _services(); + if (services == null || services.Count == 0) { + throw new InvalidOperationException("QueryBasedLoadBalancer couldnt select an downstream services cuz no downstream services were available"); + } + var strId = httpContext.Request.Query["id"].ToString(); + int id; + if (!int.TryParse(strId, out id)) + { + return new OkResponse(services[0].HostAndPort); + } + var index = Math.Abs(id % services.Count); + return new OkResponse(services[index].HostAndPort); + } + /// + /// releases Lease instance + /// + /// + public void Release(ServiceHostAndPort hostAndPort) + { + } +} diff --git a/Service.Gateway/Program.cs b/Service.Gateway/Program.cs new file mode 100644 index 00000000..ddd419f7 --- /dev/null +++ b/Service.Gateway/Program.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.DependencyInjection; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using Service.Gateway.Balancer; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); +builder.Configuration.AddJsonFile("apiGateway.json", optional: false, reloadOnChange: true); +builder.Services.AddOcelot(builder.Configuration).AddCustomLoadBalancer((serviceProvider, route, discoveryProvider) => +{ + return new QueryBasedLoadBalancer(discoveryProvider.GetAsync); +}); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + policy.SetIsOriginAllowed(origin => + { + try + { + var uri = new Uri(origin); + return uri.Host == "localhost"; + } + catch + { + return false; + } + }) + .WithMethods("GET") + .AllowAnyHeader()); +}); + +var app = builder.Build(); + +app.UseCors(); + +app.UseHttpsRedirection(); +await app.UseOcelot(); +app.Run(); diff --git a/Service.Gateway/Properties/launchSettings.json b/Service.Gateway/Properties/launchSettings.json new file mode 100644 index 00000000..e20b864c --- /dev/null +++ b/Service.Gateway/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:62668", + "sslPort": 44320 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5163", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7107;http://localhost:5163", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Service.Gateway/Service.Gateway.csproj b/Service.Gateway/Service.Gateway.csproj new file mode 100644 index 00000000..f790f47c --- /dev/null +++ b/Service.Gateway/Service.Gateway.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/Service.Gateway/apiGateway.json b/Service.Gateway/apiGateway.json new file mode 100644 index 00000000..3090ca44 --- /dev/null +++ b/Service.Gateway/apiGateway.json @@ -0,0 +1,36 @@ +{ + "Routes": [ + { + "UpstreamPathTemplate": "/api/projects", + "UpstreamHttpMethod": [ "Get" ], + "DownstreamPathTemplate": "/program-proj", + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 4441 + }, + { + "Host": "localhost", + "Port": 4442 + }, + { + "Host": "localhost", + "Port": 4443 + }, + { + "Host": "localhost", + "Port": 4444 + }, + { + "Host": "localhost", + "Port": 4445 + } + ], + "LoadBalancerOptions": { + "Type": "QueryBasedLoadBalancer" + } + + } + ], +} \ No newline at end of file diff --git a/Service.Gateway/appsettings.Development.json b/Service.Gateway/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Service.Gateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Service.Gateway/appsettings.json b/Service.Gateway/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/Service.Gateway/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Service.Storage/Broker/SnsSubscriptionService.cs b/Service.Storage/Broker/SnsSubscriptionService.cs new file mode 100644 index 00000000..6ebf9669 --- /dev/null +++ b/Service.Storage/Broker/SnsSubscriptionService.cs @@ -0,0 +1,36 @@ +using System.Net; +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; + +namespace Service.Storage.Broker; + +public class SnsSubscriptionService(IAmazonSimpleNotificationService snsClient, IConfiguration configuration, ILogger logger) +{ + /// + /// Unique topic sns identifier + /// + private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"] ?? throw new KeyNotFoundException("SNS topic link was not found in configuration"); + + /// + /// tries to subscribe on sns topic + /// + public async Task SubscribeEndpoint() + { + logger.LogInformation("Sending subscride request for {topic}", _topicArn); + var endpoint = configuration["AWS:Resources:SNSUrl"]; + logger.LogInformation($"SNS Endpoint: {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("Subscripltion request for {topic} is seccessfull, waiting for confirmation", _topicArn); + } +} diff --git a/Service.Storage/Controllers/S3StorageController.cs b/Service.Storage/Controllers/S3StorageController.cs new file mode 100644 index 00000000..cacfcc1d --- /dev/null +++ b/Service.Storage/Controllers/S3StorageController.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Nodes; +using System.Text; +using Microsoft.AspNetCore.Mvc; +using Service.Storage.Storage; + +namespace Service.Storage.Controllers; + +/// +/// Controller to deal with S3 +/// +/// Service to deal witg S3 +/// Logger +[ApiController] +[Route("api/s3")] +public class S3StorageController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + /// + /// Getting files from s3 + /// + /// List with files keys + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> ListFiles() + { + logger.LogInformation("Method {method} of {controller} was called", nameof(ListFiles), nameof(S3StorageController)); + try + { + var list = await s3Service.GetFileList(); + logger.LogInformation("Got a list of {count} files from bucket", list.Count); + return Ok(list); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occured during {method} of {controller}", nameof(ListFiles), nameof(S3StorageController)); + return BadRequest(ex); + } + } + + /// + /// Gets files string + /// + /// Key of the file + /// File string representation + [HttpGet("{key}")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task> GetFile(string key) + { + logger.LogInformation("Method {method} of {controller} was called", nameof(GetFile), nameof(S3StorageController)); + try + { + var node = await s3Service.DownloadFile(key); + logger.LogInformation("Received json of {size} bytes", Encoding.UTF8.GetByteCount(node.ToJsonString())); + return Ok(node); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occured during {method} of {controller}", nameof(GetFile), nameof(S3StorageController)); + return BadRequest(ex); + } + } +} diff --git a/Service.Storage/Controllers/SnsSubscriberController.cs b/Service.Storage/Controllers/SnsSubscriberController.cs new file mode 100644 index 00000000..6c7bab25 --- /dev/null +++ b/Service.Storage/Controllers/SnsSubscriberController.cs @@ -0,0 +1,68 @@ +using Amazon.SimpleNotificationService.Util; +using System.Text; +using Microsoft.AspNetCore.Mvc; +using Service.Storage.Storage; + +namespace Service.Storage.Controllers; + +/// +/// Controller for getting messages from sns +/// +/// Service to work with S3 +/// Logger obviously +[ApiController] +[Route("api/sns")] +public class SnsSubscriberController(ILogger logger, IS3Service s3service) : ControllerBase +{ + /// + /// Webhook that gets notifications from sns topic + /// + /// + /// As well confirms subscription at the messaging beginning. Returns cod 200 anyway + /// + [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 was 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 was successfully confirmed"); + return Ok(); + } + + if (snsMessage.Type == "Notification") + { + await s3service.UploadFile(snsMessage.MessageText); + logger.LogInformation("Notification was successfully processed"); + } + + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occurred while processing SNS notifications"); + } + return Ok(); + } +} diff --git a/Service.Storage/Program.cs b/Service.Storage/Program.cs new file mode 100644 index 00000000..125d4f05 --- /dev/null +++ b/Service.Storage/Program.cs @@ -0,0 +1,26 @@ +using Amazon.SimpleNotificationService; +using LocalStack.Client.Extensions; +using Service.Storage.Broker; +using Service.Storage.Storage; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddLocalStack(builder.Configuration); +builder.Services.AddScoped(); +builder.Services.AddAwsService(); +builder.AddMinioClient("programproj-minio"); +builder.Services.AddScoped(); + +var app = builder.Build(); +await app.Services.CreateScope().ServiceProvider.GetRequiredService().SubscribeEndpoint(); +await app.Services.CreateScope().ServiceProvider.GetRequiredService().EnsureBucketExists(); + +app.MapDefaultEndpoints(); + +app.MapControllers(); + +app.Run(); diff --git a/Service.Storage/Properties/launchSettings.json b/Service.Storage/Properties/launchSettings.json new file mode 100644 index 00000000..46ea9703 --- /dev/null +++ b/Service.Storage/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:54503", + "sslPort": 44373 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5202", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7160;http://localhost:5202", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Service.Storage/Service.Storage.csproj b/Service.Storage/Service.Storage.csproj new file mode 100644 index 00000000..7f6965f6 --- /dev/null +++ b/Service.Storage/Service.Storage.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/Service.Storage/Storage/IS3Service.cs b/Service.Storage/Storage/IS3Service.cs new file mode 100644 index 00000000..6cb5bdf3 --- /dev/null +++ b/Service.Storage/Storage/IS3Service.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Nodes; + +namespace Service.Storage.Storage; + +public interface IS3Service +{ + /// + /// sends file in storage + /// + /// saving file string representation + public Task UploadFile(string fileData); + + /// + /// getting all files list from the storage + /// + /// Paths list + public Task> GetFileList(); + + /// + /// Getting string representation of a file from the storage + /// + /// Bucket file path + /// Read file string representation + public Task DownloadFile(string filePath); + + /// + /// Creates s3 bucket if needed + /// + public Task EnsureBucketExists(); +} diff --git a/Service.Storage/Storage/S3MinioService.cs b/Service.Storage/Storage/S3MinioService.cs new file mode 100644 index 00000000..fa80ac40 --- /dev/null +++ b/Service.Storage/Storage/S3MinioService.cs @@ -0,0 +1,124 @@ +using System.Net; +using System.Text.Json.Nodes; +using System.Text; +using Minio; +using Minio.DataModel.Args; + +namespace Service.Storage.Storage; + +public class S3MinioService(IMinioClient client, IConfiguration configuration, ILogger logger) : IS3Service +{ + + private readonly string _bucketName = configuration["AWS:Resources:MinioBucketName"] + ?? throw new KeyNotFoundException("S3 bucket name was not found in configuration"); + + /// + public async Task> GetFileList() + { + var list = new List(); + var request = new ListObjectsArgs() + .WithBucket(_bucketName) + .WithPrefix("") + .WithRecursive(true); + logger.LogInformation("Began listing files in {bucket}", _bucketName); + var responseList = client.ListObjectsEnumAsync(request); + + if (responseList == null) + logger.LogWarning("Received null response from {bucket}", _bucketName); + + 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"]?.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("Began uploading {file} onto {bucket}", id, _bucketName); + var request = new PutObjectArgs() + .WithBucket(_bucketName) + .WithStreamData(stream) + .WithObjectSize(bytes.Length) + .WithObject($"programproj_{id}.json"); + + var response = await client.PutObjectAsync(request); + + if (response.ResponseStatusCode != HttpStatusCode.OK) + { + logger.LogError("Failed to upload {file}: {code}", id, response.ResponseStatusCode); + return false; + } + logger.LogInformation("Finished uploading {file} to {bucket}", id, _bucketName); + return true; + } + + /// + public async Task DownloadFile(string key) + { + logger.LogInformation("Began downloading {file} from {bucket}", key, _bucketName); + + try + { + 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); + + if (response == null) + { + logger.LogError("Failed to download {file}", key); + throw new InvalidOperationException($"Error occurred 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"); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occurred during {file} downloading ", key); + throw; + } + } + + /// + public async Task EnsureBucketExists() + { + logger.LogInformation("Checking whether {bucket} exists", _bucketName); + try + { + var request = new BucketExistsArgs() + .WithBucket(_bucketName); + + var exists = await client.BucketExistsAsync(request); + if (!exists) + { + + logger.LogInformation("Creating {bucket}", _bucketName); + var createRequest = new MakeBucketArgs() + .WithBucket(_bucketName); + await client.MakeBucketAsync(createRequest); + return; + } + logger.LogInformation("{bucket} exists", _bucketName); + } + catch (Exception ex) + { + logger.LogError(ex, "Unhandled exception occurred during {bucket} check", _bucketName); + throw; + } + } +} diff --git a/Service.Storage/appsettings.Development.json b/Service.Storage/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Service.Storage/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Service.Storage/appsettings.json b/Service.Storage/appsettings.json new file mode 100644 index 00000000..d66fe771 --- /dev/null +++ b/Service.Storage/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Settings": { + "MessageBroker": "", + "S3Hosting": "" + } +} diff --git a/StorageService/Program.cs b/StorageService/Program.cs new file mode 100644 index 00000000..ef24d4dc --- /dev/null +++ b/StorageService/Program.cs @@ -0,0 +1,38 @@ +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +// Add services to the container. + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// Configure the HTTP request pipeline. + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => +{ + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +}); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/StorageService/Properties/launchSettings.json b/StorageService/Properties/launchSettings.json new file mode 100644 index 00000000..6280f628 --- /dev/null +++ b/StorageService/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:49047", + "sslPort": 44355 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "http://localhost:5215", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "https://localhost:7009;http://localhost:5215", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "weatherforecast", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/StorageService/Service.Storage.csproj b/StorageService/Service.Storage.csproj new file mode 100644 index 00000000..00f936c9 --- /dev/null +++ b/StorageService/Service.Storage.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/StorageService/StorageService.http b/StorageService/StorageService.http new file mode 100644 index 00000000..660a032c --- /dev/null +++ b/StorageService/StorageService.http @@ -0,0 +1,6 @@ +@StorageService_HostAddress = http://localhost:5215 + +GET {{StorageService_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/StorageService/appsettings.Development.json b/StorageService/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/StorageService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/StorageService/appsettings.json b/StorageService/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/StorageService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}