diff --git a/Covid19Api.sln b/Covid19Api.sln index e4afdc6..834360f 100644 --- a/Covid19Api.sln +++ b/Covid19Api.sln @@ -53,6 +53,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{EBCCA0C8-4 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Covid19Api.Tests", "test\Covid19Api.Tests\Covid19Api.Tests.csproj", "{663655F5-2BFE-4154-AF3F-BB8BF36C2165}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Covid19Api.Mongo.Migrator", "src\Covid19Api.Mongo.Migrator\Covid19Api.Mongo.Migrator.csproj", "{9C4F9DEA-77E9-42E7-8991-C5227AAC0119}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Covid19Api.AutoMapper", "src\Covid19Api.AutoMapper\Covid19Api.AutoMapper.csproj", "{2DEC9058-1FD3-457F-A0B2-DF053371DF34}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -258,6 +262,30 @@ Global {663655F5-2BFE-4154-AF3F-BB8BF36C2165}.Release|x64.Build.0 = Release|Any CPU {663655F5-2BFE-4154-AF3F-BB8BF36C2165}.Release|x86.ActiveCfg = Release|Any CPU {663655F5-2BFE-4154-AF3F-BB8BF36C2165}.Release|x86.Build.0 = Release|Any CPU + {9C4F9DEA-77E9-42E7-8991-C5227AAC0119}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C4F9DEA-77E9-42E7-8991-C5227AAC0119}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C4F9DEA-77E9-42E7-8991-C5227AAC0119}.Debug|x64.ActiveCfg = Debug|Any CPU + {9C4F9DEA-77E9-42E7-8991-C5227AAC0119}.Debug|x64.Build.0 = Debug|Any CPU + {9C4F9DEA-77E9-42E7-8991-C5227AAC0119}.Debug|x86.ActiveCfg = Debug|Any CPU + {9C4F9DEA-77E9-42E7-8991-C5227AAC0119}.Debug|x86.Build.0 = Debug|Any CPU + {9C4F9DEA-77E9-42E7-8991-C5227AAC0119}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C4F9DEA-77E9-42E7-8991-C5227AAC0119}.Release|Any CPU.Build.0 = Release|Any CPU + {9C4F9DEA-77E9-42E7-8991-C5227AAC0119}.Release|x64.ActiveCfg = Release|Any CPU + {9C4F9DEA-77E9-42E7-8991-C5227AAC0119}.Release|x64.Build.0 = Release|Any CPU + {9C4F9DEA-77E9-42E7-8991-C5227AAC0119}.Release|x86.ActiveCfg = Release|Any CPU + {9C4F9DEA-77E9-42E7-8991-C5227AAC0119}.Release|x86.Build.0 = Release|Any CPU + {2DEC9058-1FD3-457F-A0B2-DF053371DF34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2DEC9058-1FD3-457F-A0B2-DF053371DF34}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2DEC9058-1FD3-457F-A0B2-DF053371DF34}.Debug|x64.ActiveCfg = Debug|Any CPU + {2DEC9058-1FD3-457F-A0B2-DF053371DF34}.Debug|x64.Build.0 = Debug|Any CPU + {2DEC9058-1FD3-457F-A0B2-DF053371DF34}.Debug|x86.ActiveCfg = Debug|Any CPU + {2DEC9058-1FD3-457F-A0B2-DF053371DF34}.Debug|x86.Build.0 = Debug|Any CPU + {2DEC9058-1FD3-457F-A0B2-DF053371DF34}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2DEC9058-1FD3-457F-A0B2-DF053371DF34}.Release|Any CPU.Build.0 = Release|Any CPU + {2DEC9058-1FD3-457F-A0B2-DF053371DF34}.Release|x64.ActiveCfg = Release|Any CPU + {2DEC9058-1FD3-457F-A0B2-DF053371DF34}.Release|x64.Build.0 = Release|Any CPU + {2DEC9058-1FD3-457F-A0B2-DF053371DF34}.Release|x86.ActiveCfg = Release|Any CPU + {2DEC9058-1FD3-457F-A0B2-DF053371DF34}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {1A670B81-08F0-4F15-81AF-4DDD12E765D6} = {2A35F3E7-12D1-4F74-8867-F5882FB47009} @@ -276,5 +304,7 @@ Global {3FAF620F-B417-43EF-8540-B0DDBA932DC8} = {2A35F3E7-12D1-4F74-8867-F5882FB47009} {CC6C9309-C452-4C12-9783-35B5298781AF} = {2A35F3E7-12D1-4F74-8867-F5882FB47009} {663655F5-2BFE-4154-AF3F-BB8BF36C2165} = {EBCCA0C8-4218-490F-A131-E8DF265DC415} + {9C4F9DEA-77E9-42E7-8991-C5227AAC0119} = {2A35F3E7-12D1-4F74-8867-F5882FB47009} + {2DEC9058-1FD3-457F-A0B2-DF053371DF34} = {2A35F3E7-12D1-4F74-8867-F5882FB47009} EndGlobalSection EndGlobal diff --git a/src/Covid19Api/AutoMapper/CountryStatsProfile.cs b/src/Covid19Api.AutoMapper/CountryStatsProfile.cs similarity index 100% rename from src/Covid19Api/AutoMapper/CountryStatsProfile.cs rename to src/Covid19Api.AutoMapper/CountryStatsProfile.cs diff --git a/src/Covid19Api.AutoMapper/Covid19Api.AutoMapper.csproj b/src/Covid19Api.AutoMapper/Covid19Api.AutoMapper.csproj new file mode 100644 index 0000000..ae57a89 --- /dev/null +++ b/src/Covid19Api.AutoMapper/Covid19Api.AutoMapper.csproj @@ -0,0 +1,16 @@ + + + + net5.0 + + + + + + + + + + + + diff --git a/src/Covid19Api.AutoMapper/GlobalStatisticsAggregateProfile.cs b/src/Covid19Api.AutoMapper/GlobalStatisticsAggregateProfile.cs new file mode 100644 index 0000000..1ae4f597 --- /dev/null +++ b/src/Covid19Api.AutoMapper/GlobalStatisticsAggregateProfile.cs @@ -0,0 +1,16 @@ +using AutoMapper; +using Covid19Api.Domain; +using Covid19Api.Presentation.Response; + +namespace Covid19Api.AutoMapper +{ + public class GlobalStatisticsAggregateProfile : Profile + { + public GlobalStatisticsAggregateProfile() + { + this.CreateMap() + .ConstructUsing(source => new GlobalStatisticsAggregateDto(source.Id, source.Total, source.Recovered, + source.Deaths, source.Month, source.Year)); + } + } +} \ No newline at end of file diff --git a/src/Covid19Api/AutoMapper/GlobalStatsProfile.cs b/src/Covid19Api.AutoMapper/GlobalStatsProfile.cs similarity index 100% rename from src/Covid19Api/AutoMapper/GlobalStatsProfile.cs rename to src/Covid19Api.AutoMapper/GlobalStatsProfile.cs diff --git a/src/Covid19Api.Domain/CountryStatistics.cs b/src/Covid19Api.Domain/CountryStatistics.cs index 9f107e1..dfcd679 100644 --- a/src/Covid19Api.Domain/CountryStatistics.cs +++ b/src/Covid19Api.Domain/CountryStatistics.cs @@ -32,7 +32,6 @@ public class CountryStatistics public DateTime FetchedAt { get; private set; } - public CountryStatistics(string country, string? countryCode, int totalCases, int newCases, int totalDeaths, int newDeaths, int recoveredCases, int activeCases, int seriousCases, DateTime fetchedAt) diff --git a/src/Covid19Api.Domain/Enums/AggregateType.cs b/src/Covid19Api.Domain/Enums/AggregateType.cs new file mode 100644 index 0000000..6dc378f --- /dev/null +++ b/src/Covid19Api.Domain/Enums/AggregateType.cs @@ -0,0 +1,8 @@ +namespace Covid19Api.Domain.Enums +{ + public enum AggregateType + { + Month = 0, + Year = 1, + } +} \ No newline at end of file diff --git a/src/Covid19Api.Domain/GlobalStatisticsAggregate.cs b/src/Covid19Api.Domain/GlobalStatisticsAggregate.cs new file mode 100644 index 0000000..01f1073 --- /dev/null +++ b/src/Covid19Api.Domain/GlobalStatisticsAggregate.cs @@ -0,0 +1,39 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Covid19Api.Domain +{ + // ReSharper disable AutoPropertyCanBeMadeGetOnly.Local + // ReSharper disable UnusedAutoPropertyAccessor.Global + public class GlobalStatisticsAggregate + { + public GlobalStatisticsAggregate(int total, int recovered, int deaths, int month, int year) + { + this.Total = total; + this.Recovered = recovered; + this.Deaths = deaths; + this.Month = month; + this.Year = year; + this.Id = this.Generate(); + } + + public Guid Id { get; private set; } + public int Total { get; private set; } + public int Recovered { get; private set; } + public int Deaths { get; private set; } + public int Month { get; private set; } + public int Year { get; private set; } + + private Guid Generate() + { + using var hasher = MD5.Create(); + + var unhashed = $"{nameof(GlobalStatisticsAggregate)}_{this.Month}.{this.Year}"; + + var hashed = hasher.ComputeHash(Encoding.UTF8.GetBytes(unhashed)); + + return new Guid(hashed); + } + } +} \ No newline at end of file diff --git a/src/Covid19Api.IoC/Extensions/ContainerBuilderExtensions.cs b/src/Covid19Api.IoC/Extensions/ContainerBuilderExtensions.cs index 174cb82..3c3e0f6 100644 --- a/src/Covid19Api.IoC/Extensions/ContainerBuilderExtensions.cs +++ b/src/Covid19Api.IoC/Extensions/ContainerBuilderExtensions.cs @@ -25,6 +25,10 @@ public static ContainerBuilder RegisterRepositories(this ContainerBuilder builde .As() .InstancePerLifetimeScope(); + builder.RegisterType() + .As() + .InstancePerLifetimeScope(); + builder.RegisterType() .As() .InstancePerLifetimeScope(); @@ -39,7 +43,7 @@ public static ContainerBuilder RegisterServices(this ContainerBuilder builder) builder.RegisterType() .As() .SingleInstance(); - + builder.RegisterType() .As() .SingleInstance(); @@ -74,6 +78,10 @@ public static ContainerBuilder RegisterWorker(this ContainerBuilder builder) .As() .InstancePerDependency(); + builder.RegisterType() + .As() + .InstancePerDependency(); + return builder; } } diff --git a/src/Covid19Api.Mongo.Migrator/Abstractions/DatabaseMigration.cs b/src/Covid19Api.Mongo.Migrator/Abstractions/DatabaseMigration.cs new file mode 100644 index 0000000..b20f528 --- /dev/null +++ b/src/Covid19Api.Mongo.Migrator/Abstractions/DatabaseMigration.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Covid19Api.Mongo.Migrator.Abstractions +{ + public abstract class DatabaseMigration + { + private readonly ILogger logger; + + protected DatabaseMigration(ILogger logger) + { + this.logger = logger; + } + public abstract int Number { get; } + + protected abstract string Name { get; } + + public async Task ExecuteUpdateAsync() + { + this.logger.LogInformation("Executing migration {number}-{migration}", this.Number, this.Name); + await ExecuteAsync(); + this.logger.LogInformation("Executed migration {number}-{migration}", this.Number, this.Name); + } + + protected abstract Task ExecuteAsync(); + } +} \ No newline at end of file diff --git a/src/Covid19Api.Mongo.Migrator/Configuration/GlobalAggregatesStartConfiguration.cs b/src/Covid19Api.Mongo.Migrator/Configuration/GlobalAggregatesStartConfiguration.cs new file mode 100644 index 0000000..793e9f9 --- /dev/null +++ b/src/Covid19Api.Mongo.Migrator/Configuration/GlobalAggregatesStartConfiguration.cs @@ -0,0 +1,9 @@ +namespace Covid19Api.Mongo.Migrator.Configuration +{ + public class GlobalAggregatesStartConfiguration + { + public int Month { get; set; } = 9; + + public int Year { get; set; } = 2020; + } +} \ No newline at end of file diff --git a/src/Covid19Api.Mongo.Migrator/Covid19Api.Mongo.Migrator.csproj b/src/Covid19Api.Mongo.Migrator/Covid19Api.Mongo.Migrator.csproj new file mode 100644 index 0000000..1807f0e --- /dev/null +++ b/src/Covid19Api.Mongo.Migrator/Covid19Api.Mongo.Migrator.csproj @@ -0,0 +1,36 @@ + + + + Exe + net5.0 + + + + + + + + + + + + + + + + + + + + true + Always + PreserveNewest + + + true + PreserveNewest + Never + + + + diff --git a/src/Covid19Api.Mongo.Migrator/Migrations/000000_GlobalAggregatesMigration.cs b/src/Covid19Api.Mongo.Migrator/Migrations/000000_GlobalAggregatesMigration.cs new file mode 100644 index 0000000..889907a --- /dev/null +++ b/src/Covid19Api.Mongo.Migrator/Migrations/000000_GlobalAggregatesMigration.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading.Tasks; +using Covid19Api.Mongo.Migrator.Abstractions; +using Covid19Api.Mongo.Migrator.Configuration; +using Covid19Api.UseCases.Abstractions.Commands; +using Covid19Api.UseCases.Abstractions.Queries; +using MediatR; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Covid19Api.Mongo.Migrator.Migrations +{ + public class GlobalAggregatesMigration : DatabaseMigration + { + private readonly GlobalAggregatesStartConfiguration options; + private readonly IMediator mediator; + + // ReSharper disable once SuggestBaseTypeForParameter + public GlobalAggregatesMigration(ILogger logger, + IOptions options, IMediator mediator) : base(logger) + { + this.mediator = mediator; + this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + public override int Number => 0; + protected override string Name => nameof(GlobalAggregatesMigration); + + protected override async Task ExecuteAsync() + { + var next = new DateTime(this.options.Year, this.options.Month, 1, 0, 0, 0, DateTimeKind.Utc); + var end = DateTime.UtcNow.Date; + + while (true) + { + if (next.Month > end.Month && next.Year >= end.Year) + break; + + var query = new LoadGlobalStatisticsAggregate(next.Month, next.Year); + var aggregate = await this.mediator.Send(query); + + if (aggregate is {}) + { + next = next.AddMonths(1); + continue; + } + + var command = new AggregateGlobalStatisticsCommand(next.Month, next.Year); + await this.mediator.Send(command); + + next = next.AddMonths(1); + } + } + } +} \ No newline at end of file diff --git a/src/Covid19Api.Mongo.Migrator/Program.cs b/src/Covid19Api.Mongo.Migrator/Program.cs new file mode 100644 index 0000000..a6d9cc7 --- /dev/null +++ b/src/Covid19Api.Mongo.Migrator/Program.cs @@ -0,0 +1,70 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Autofac; +using Autofac.Extensions.DependencyInjection; +using AutoMapper.Contrib.Autofac.DependencyInjection; +using Covid19Api.AutoMapper; +using Covid19Api.IoC.Extensions; +using Covid19Api.Mongo.Migrator.Abstractions; +using Covid19Api.Mongo.Migrator.Configuration; +using Covid19Api.UseCases.Commands; +using MediatR.Extensions.Autofac.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace Covid19Api.Mongo.Migrator +{ + public static class Program + { + public static async Task Main(string[] args) + { + using var host = CreateHost(args); + + await host.StartAsync(); + + var migrations = host.Services.GetServices(); + + foreach (var databaseMigration in migrations.OrderBy(migration => migration.Number)) + await databaseMigration.ExecuteUpdateAsync(); + + await host.StopAsync(); + } + + private static IHost CreateHost(string[] args) + => Host.CreateDefaultBuilder(args) + .UseContentRoot(AppContext.BaseDirectory) + .UseServiceProviderFactory(new AutofacServiceProviderFactory()) + .UseSerilog(ConfigureLogger) + .ConfigureServices(ConfigureServices) + .ConfigureContainer(ConfigureContainer) + .Build(); + + private static void ConfigureServices(HostBuilderContext hostBuilderContext, IServiceCollection services) + { + services.AddOptions(); + services.Configure(options => + hostBuilderContext.Configuration.GetSection(nameof(GlobalAggregatesStartConfiguration)).Bind(options)); + } + + private static void ConfigureLogger(HostBuilderContext context, LoggerConfiguration loggerConfiguration) + { + loggerConfiguration.ReadFrom.Configuration(context.Configuration); + } + + private static void ConfigureContainer(HostBuilderContext context, ContainerBuilder builder) + { + builder.RegisterAssemblyTypes(typeof(Program).Assembly) + .AssignableTo(typeof(DatabaseMigration)) + .As() + .InstancePerLifetimeScope(); + + builder.RegisterRepositories(context.HostingEnvironment, context.Configuration) + .RegisterServices() + .RegisterMediatR(typeof(RefreshGlobalStatisticsCommandHandler).Assembly) + .RegisterAutoMapper(typeof(CountryStatsProfile).Assembly); + } + } +} \ No newline at end of file diff --git a/src/Covid19Api.Mongo.Migrator/Properties/launchSettings.json b/src/Covid19Api.Mongo.Migrator/Properties/launchSettings.json new file mode 100644 index 0000000..6888314 --- /dev/null +++ b/src/Covid19Api.Mongo.Migrator/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Covid19Api.Mongo.Migrator": { + "commandName": "Project", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Covid19Api.Mongo.Migrator/appsettings.json b/src/Covid19Api.Mongo.Migrator/appsettings.json new file mode 100644 index 0000000..2dd93e3 --- /dev/null +++ b/src/Covid19Api.Mongo.Migrator/appsettings.json @@ -0,0 +1,21 @@ +{ + "Serilog": { + "MinimumLevel": "Information", + "WriteTo": [ + { + "Name": "Console", + "Args": { + "minimumLogEventLevel": "Information", + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}" + } + } + ] + }, + "ConnectionStrings": { + "MongoDb": "mongodb://root:root@localhost:27017/Covid19Api?authSource=admin" + }, + "GlobalAggregatesStartConfiguration": { + "Month": 9, + "Year": 2020 + } +} diff --git a/src/Covid19Api.Mongo.Scaffolder/Covid19Api.Mongo.Scaffolder.csproj b/src/Covid19Api.Mongo.Scaffolder/Covid19Api.Mongo.Scaffolder.csproj index 7e0ad41..2fd7c50 100644 --- a/src/Covid19Api.Mongo.Scaffolder/Covid19Api.Mongo.Scaffolder.csproj +++ b/src/Covid19Api.Mongo.Scaffolder/Covid19Api.Mongo.Scaffolder.csproj @@ -24,7 +24,6 @@ - diff --git a/src/Covid19Api.Mongo.Scaffolder/Program.cs b/src/Covid19Api.Mongo.Scaffolder/Program.cs index 58d7ea3..9ee58dd 100644 --- a/src/Covid19Api.Mongo.Scaffolder/Program.cs +++ b/src/Covid19Api.Mongo.Scaffolder/Program.cs @@ -45,7 +45,7 @@ private static void ConfigureContainer(HostBuilderContext context, ContainerBuil builder.RegisterAssemblyTypes(typeof(Program).Assembly) .AssignableTo(typeof(DatabaseUpdateDefinition)) .As() - .InstancePerDependency(); + .InstancePerLifetimeScope(); builder.RegisterRepositories(context.HostingEnvironment, context.Configuration); } diff --git a/src/Covid19Api.Mongo.Scaffolder/Properties/launchSettings.json b/src/Covid19Api.Mongo.Scaffolder/Properties/launchSettings.json index 6fafebb..e48a114 100644 --- a/src/Covid19Api.Mongo.Scaffolder/Properties/launchSettings.json +++ b/src/Covid19Api.Mongo.Scaffolder/Properties/launchSettings.json @@ -4,7 +4,7 @@ "Covid19Api.Mongo.Scaffolder": { "commandName": "Project", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "DOTNET_ENVIRONMENT": "Development" } } } diff --git a/src/Covid19Api.Mongo.Scaffolder/Updates/000002_GlobalStatisticAggregateUpdateDefinition.cs b/src/Covid19Api.Mongo.Scaffolder/Updates/000002_GlobalStatisticAggregateUpdateDefinition.cs new file mode 100644 index 0000000..b6a07b9 --- /dev/null +++ b/src/Covid19Api.Mongo.Scaffolder/Updates/000002_GlobalStatisticAggregateUpdateDefinition.cs @@ -0,0 +1,70 @@ +using System.Threading.Tasks; +using Covid19Api.Domain; +using Covid19Api.Mongo.Scaffolder.Abstractions; +using Covid19Api.Mongo.Scaffolder.Extensions; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Covid19Api.Mongo.Scaffolder.Updates +{ + // ReSharper disable once UnusedType.Global + public class GlobalStatisticAggregateUpdateDefinition : DatabaseUpdateDefinition + { + private readonly Covid19ApiDbContext databaseContext; + + // ReSharper disable once SuggestBaseTypeForParameter + public GlobalStatisticAggregateUpdateDefinition(ILogger logger, + Covid19ApiDbContext databaseContext) : base(logger) + { + this.databaseContext = databaseContext; + } + + public override int Version => 2; + + protected override async Task ExecuteAsync() + { + await this.databaseContext.Database.CreateCollectionIfNotExistsAsync(CollectionNames + .GlobalStatisticsAggregate); + + var monthIndex = Builders + .IndexKeys + .Descending(statistics => statistics.Month); + + var monthIndexModel = new CreateIndexModel(monthIndex, new CreateIndexOptions + { + Name = $"{CollectionNames.GlobalStatisticsAggregate}_month_descending" + }); + + var yearIndex = Builders + .IndexKeys + .Descending(statistics => statistics.Year); + + var yearIndexModel = new CreateIndexModel(yearIndex, new CreateIndexOptions + { + Name = $"{CollectionNames.GlobalStatisticsAggregate}_year_descending" + }); + + var yearMonthIndex = Builders + .IndexKeys + .Combine(yearIndex, monthIndex); + + var yearMonthIndexModel = new CreateIndexModel(yearMonthIndex, + new CreateIndexOptions + { + Name = $"{CollectionNames.GlobalStatisticsAggregate}_year_month", + Unique = true + }); + + var collection = + this.databaseContext.Database.GetCollection(CollectionNames + .GlobalStatisticsAggregate); + + await collection.Indexes.CreateManyAsync(new[] + { + monthIndexModel, + yearIndexModel, + yearMonthIndexModel + }); + } + } +} \ No newline at end of file diff --git a/src/Covid19Api.Mongo.Scaffolder/appsettings.json b/src/Covid19Api.Mongo.Scaffolder/appsettings.json index 8379731..a56fe89 100644 --- a/src/Covid19Api.Mongo.Scaffolder/appsettings.json +++ b/src/Covid19Api.Mongo.Scaffolder/appsettings.json @@ -1,11 +1,4 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, "Serilog": { "MinimumLevel": "Information", "WriteTo": [ diff --git a/src/Covid19Api.Mongo/CollectionNames.cs b/src/Covid19Api.Mongo/CollectionNames.cs index abec83f..a129575 100644 --- a/src/Covid19Api.Mongo/CollectionNames.cs +++ b/src/Covid19Api.Mongo/CollectionNames.cs @@ -3,6 +3,8 @@ namespace Covid19Api.Mongo public static class CollectionNames { public const string GlobalStatistics = "globalStatistics"; + public const string GlobalStatisticsAggregate = "globalStatisticsAggregate"; + public const string CountryStatistics = "countryStatistics"; } } \ No newline at end of file diff --git a/src/Covid19Api.Presentation/Response/GlobalStatisticsAggregateDto.cs b/src/Covid19Api.Presentation/Response/GlobalStatisticsAggregateDto.cs new file mode 100644 index 0000000..1702686 --- /dev/null +++ b/src/Covid19Api.Presentation/Response/GlobalStatisticsAggregateDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Covid19Api.Presentation.Response +{ + public class GlobalStatisticsAggregateDto + { + public GlobalStatisticsAggregateDto(Guid id, int total, int recovered, int deaths, int month, int year) + { + this.Id = id; + this.Total = total; + this.Recovered = recovered; + this.Deaths = deaths; + this.Month = month; + this.Year = year; + } + + public Guid Id { get; set; } + public int Total { get; set; } + public int Recovered { get; set; } + public int Deaths { get; set; } + public int Month { get; set; } + public int Year { get; set; } + } +} \ No newline at end of file diff --git a/src/Covid19Api.Repositories.Abstractions/IGlobalStatisticsAggregatesRepository.cs b/src/Covid19Api.Repositories.Abstractions/IGlobalStatisticsAggregatesRepository.cs new file mode 100644 index 0000000..0b15d9a --- /dev/null +++ b/src/Covid19Api.Repositories.Abstractions/IGlobalStatisticsAggregatesRepository.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using Covid19Api.Domain; + +namespace Covid19Api.Repositories.Abstractions +{ + public interface IGlobalStatisticsAggregatesRepository + { + Task StoreAsync(GlobalStatisticsAggregate globalStatisticsAggregate); + + Task FindAsync(int month, int year); + } +} \ No newline at end of file diff --git a/src/Covid19Api.Repositories.Abstractions/IGlobalStatisticsRepository.cs b/src/Covid19Api.Repositories.Abstractions/IGlobalStatisticsRepository.cs index f6dc0bd..088b421 100644 --- a/src/Covid19Api.Repositories.Abstractions/IGlobalStatisticsRepository.cs +++ b/src/Covid19Api.Repositories.Abstractions/IGlobalStatisticsRepository.cs @@ -10,5 +10,6 @@ public interface IGlobalStatisticsRepository Task StoreAsync(GlobalStatistics globalStatistics); Task> HistoricalAsync(DateTime minFetchedAt); Task> HistoricalForDayAsync(DateTime minFetchedAt); + Task> HistoricalInRange(DateTime inclusiveStart, DateTime inclusiveEnd); } } \ No newline at end of file diff --git a/src/Covid19Api.Repositories/GlobalStatisticsAggregatesRepository.cs b/src/Covid19Api.Repositories/GlobalStatisticsAggregatesRepository.cs new file mode 100644 index 0000000..e9b63d8 --- /dev/null +++ b/src/Covid19Api.Repositories/GlobalStatisticsAggregatesRepository.cs @@ -0,0 +1,45 @@ +using System.Threading.Tasks; +using Covid19Api.Domain; +using Covid19Api.Mongo; +using Covid19Api.Repositories.Abstractions; +using MongoDB.Driver; + +namespace Covid19Api.Repositories +{ + public class GlobalStatisticsAggregatesRepository : IGlobalStatisticsAggregatesRepository + { + private readonly Covid19ApiDbContext context; + + public GlobalStatisticsAggregatesRepository(Covid19ApiDbContext context) + { + this.context = context; + } + + public Task StoreAsync(GlobalStatisticsAggregate globalStatisticsAggregate) + { + var collection = this.GetCollection(); + + var filter = Builders + .Filter.Where(aggregate => aggregate.Id == globalStatisticsAggregate.Id); + + return collection.ReplaceOneAsync(filter, + globalStatisticsAggregate, new ReplaceOptions + { + IsUpsert = true + }); + } + + public async Task FindAsync(int month, int year) + { + var collection = this.GetCollection(); + + var cursor = await collection.FindAsync(aggregate => aggregate.Month == month && aggregate.Year == year); + + return await cursor.SingleOrDefaultAsync(); + } + + private IMongoCollection GetCollection() + => this.context.Database.GetCollection(CollectionNames + .GlobalStatisticsAggregate); + } +} \ No newline at end of file diff --git a/src/Covid19Api.Repositories/GlobalStatisticsRepository.cs b/src/Covid19Api.Repositories/GlobalStatisticsRepository.cs index 65ee6b2..cb062f7 100644 --- a/src/Covid19Api.Repositories/GlobalStatisticsRepository.cs +++ b/src/Covid19Api.Repositories/GlobalStatisticsRepository.cs @@ -59,6 +59,18 @@ public async Task> HistoricalForDayAsync(DateTime return all.OrderBy(entry => entry.FetchedAt); } + public async Task> HistoricalInRange(DateTime inclusiveStart, DateTime inclusiveEnd) + { + var collection = this.GetCollection(); + + var leftFilter = Builders.Filter.Where(global => global.FetchedAt >= inclusiveStart); + var rightFilter = Builders.Filter.Where(global => global.FetchedAt <= inclusiveEnd); + var combinedFilter = leftFilter & rightFilter; + + var cursor = await collection.FindAsync(combinedFilter); + return await cursor.ToListAsync(); + } + private IMongoCollection GetCollection() => this.context.Database.GetCollection(CollectionNames.GlobalStatistics); } diff --git a/src/Covid19Api.Services.Abstractions/Models/CountryMetaData.cs b/src/Covid19Api.Services.Abstractions/Models/CountryMetaData.cs index c3b7fb2..0ad99c0 100644 --- a/src/Covid19Api.Services.Abstractions/Models/CountryMetaData.cs +++ b/src/Covid19Api.Services.Abstractions/Models/CountryMetaData.cs @@ -2,6 +2,7 @@ // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global // ReSharper disable UnusedMember.Local // ReSharper disable ClassNeverInstantiated.Global + namespace Covid19Api.Services.Abstractions.Models { public class CountryMetaData @@ -12,8 +13,10 @@ public CountryMetaData(string name, string alpha2Code, string[] altSpellings) this.Alpha2Code = alpha2Code; this.AltSpellings = altSpellings; } - - private CountryMetaData () {} + + private CountryMetaData() + { + } public string Name { get; set; } = null!; diff --git a/src/Covid19Api.Services/Loader/CountryStatisticsLoader.cs b/src/Covid19Api.Services/Loader/CountryStatisticsLoader.cs index a5fbf4a..a331b9d 100644 --- a/src/Covid19Api.Services/Loader/CountryStatisticsLoader.cs +++ b/src/Covid19Api.Services/Loader/CountryStatisticsLoader.cs @@ -53,7 +53,7 @@ public CountryStatisticsLoader(ICountryMetaDataLoader countryMetaDataLoader, var serious = ParseIntegerValue(tableDataNodes[9]); if (string.IsNullOrWhiteSpace(country)) return null; - + var countryCode = GetCountryCode(countryMetaData, country); return new CountryStatistics(country, countryCode, totalCases, newCases, totalDeaths, newDeaths, recovered, @@ -75,7 +75,7 @@ public CountryStatisticsLoader(ICountryMetaDataLoader countryMetaDataLoader, , StringComparison.InvariantCultureIgnoreCase) || metaData.AltSpellings.Contains(country, StringComparer.InvariantCultureIgnoreCase))?.Alpha2Code; - + return countryCode; } diff --git a/src/Covid19Api.UseCases.Abstractions/Commands/AggregateGlobalStatisticsCommand.cs b/src/Covid19Api.UseCases.Abstractions/Commands/AggregateGlobalStatisticsCommand.cs new file mode 100644 index 0000000..da18ebb --- /dev/null +++ b/src/Covid19Api.UseCases.Abstractions/Commands/AggregateGlobalStatisticsCommand.cs @@ -0,0 +1,17 @@ +using MediatR; + +namespace Covid19Api.UseCases.Abstractions.Commands +{ + public class AggregateGlobalStatisticsCommand : IRequest + { + public AggregateGlobalStatisticsCommand(int month, int year) + { + this.Month = month; + this.Year = year; + } + + public int Month { get; } + + public int Year { get; } + } +} \ No newline at end of file diff --git a/src/Covid19Api.UseCases.Abstractions/Models/CacheConfiguration.cs b/src/Covid19Api.UseCases.Abstractions/Models/CacheConfiguration.cs index b13b114..53b08d2 100644 --- a/src/Covid19Api.UseCases.Abstractions/Models/CacheConfiguration.cs +++ b/src/Covid19Api.UseCases.Abstractions/Models/CacheConfiguration.cs @@ -9,7 +9,7 @@ public CacheConfiguration(string key, TimeSpan duration) this.Key = !string.IsNullOrWhiteSpace(key) ? key : throw new ArgumentNullException(nameof(key)); this.Duration = duration; } - + public string Key { get; } public TimeSpan Duration { get; } diff --git a/src/Covid19Api.UseCases.Abstractions/Queries/LoadGlobalStatisticsAggregate.cs b/src/Covid19Api.UseCases.Abstractions/Queries/LoadGlobalStatisticsAggregate.cs new file mode 100644 index 0000000..c9fad59 --- /dev/null +++ b/src/Covid19Api.UseCases.Abstractions/Queries/LoadGlobalStatisticsAggregate.cs @@ -0,0 +1,18 @@ +using Covid19Api.Presentation.Response; +using MediatR; + +namespace Covid19Api.UseCases.Abstractions.Queries +{ + public class LoadGlobalStatisticsAggregate : IRequest + { + public LoadGlobalStatisticsAggregate(int month, int year) + { + this.Month = month; + this.Year = year; + } + + public int Month { get; } + + public int Year { get; } + } +} \ No newline at end of file diff --git a/src/Covid19Api.UseCases.Abstractions/Queries/LoadHistoricalGlobalStatisticsQuery.cs b/src/Covid19Api.UseCases.Abstractions/Queries/LoadHistoricalGlobalStatisticsQuery.cs index 61ba69a..9cd70f0 100644 --- a/src/Covid19Api.UseCases.Abstractions/Queries/LoadHistoricalGlobalStatisticsQuery.cs +++ b/src/Covid19Api.UseCases.Abstractions/Queries/LoadHistoricalGlobalStatisticsQuery.cs @@ -15,6 +15,8 @@ public LoadHistoricalGlobalStatisticsQuery(DateTime minFetchedAt) } public DateTime MinFetchedAt { get; } - public CacheConfiguration GetCacheConfiguration() => new CacheConfiguration(nameof(LoadHistoricalGlobalStatisticsQuery), TimeSpan.FromMinutes(30)); + + public CacheConfiguration GetCacheConfiguration() => + new CacheConfiguration(nameof(LoadHistoricalGlobalStatisticsQuery), TimeSpan.FromMinutes(30)); } } \ No newline at end of file diff --git a/src/Covid19Api.UseCases/Behaviors/CachingBehavior.cs b/src/Covid19Api.UseCases/Behaviors/CachingBehavior.cs index 985aa82..dfaa548 100644 --- a/src/Covid19Api.UseCases/Behaviors/CachingBehavior.cs +++ b/src/Covid19Api.UseCases/Behaviors/CachingBehavior.cs @@ -13,7 +13,8 @@ namespace Covid19Api.UseCases.Behaviors { - public class CachingBehavior : IPipelineBehavior where TRequest : notnull where TResponse : class + public class CachingBehavior : IPipelineBehavior + where TRequest : notnull where TResponse : class { private readonly JsonSerializerOptions jsonSerializerOptions = new JsonSerializerOptions { @@ -25,7 +26,7 @@ public class CachingBehavior : IPipelineBehavior Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate next) + public async Task Handle(TRequest request, CancellationToken cancellationToken, + RequestHandlerDelegate next) { if (!(request is ICacheableRequest cacheableRequest)) { diff --git a/src/Covid19Api.UseCases/Commands/AggregateGlobalStatisticsCommandHandler.cs b/src/Covid19Api.UseCases/Commands/AggregateGlobalStatisticsCommandHandler.cs new file mode 100644 index 0000000..44ce2c5 --- /dev/null +++ b/src/Covid19Api.UseCases/Commands/AggregateGlobalStatisticsCommandHandler.cs @@ -0,0 +1,61 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Covid19Api.Domain; +using Covid19Api.Repositories.Abstractions; +using Covid19Api.UseCases.Abstractions.Commands; +using Covid19Api.UseCases.Extensions; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Covid19Api.UseCases.Commands +{ + public class AggregateGlobalStatisticsCommandHandler : IRequestHandler + { + private readonly ILogger logger; + private readonly IGlobalStatisticsRepository globalStatisticsRepository; + private readonly IGlobalStatisticsAggregatesRepository globalStatisticsAggregatesRepository; + + + public AggregateGlobalStatisticsCommandHandler(ILogger logger, + IGlobalStatisticsRepository globalStatisticsRepository, + IGlobalStatisticsAggregatesRepository globalStatisticsAggregatesRepository) + { + this.logger = logger; + this.globalStatisticsRepository = globalStatisticsRepository; + this.globalStatisticsAggregatesRepository = globalStatisticsAggregatesRepository; + } + + public async Task Handle(AggregateGlobalStatisticsCommand request, CancellationToken cancellationToken) + { + var start = new DateTime(request.Year, request.Month, 1, 0, 0, 0, DateTimeKind.Utc); + var end = start.MonthsEnd(); + + this.logger.LogInformation("Aggregating {entity} from {from} to {to}", + nameof(GlobalStatistics), + start.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture), + end.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)); + + var globalStatisticsInRange = await this.globalStatisticsRepository.HistoricalInRange(start, end); + + if (!globalStatisticsInRange.Any()) return Unit.Value; + + var aggregate = new GlobalStatisticsAggregate( + globalStatisticsInRange.Sum(statistics => statistics.Total), + globalStatisticsInRange.Sum(statistics => statistics.Recovered), + globalStatisticsInRange.Sum(statistics => statistics.Deaths), + request.Month, request.Year); + + await this.globalStatisticsAggregatesRepository.StoreAsync(aggregate); + + this.logger.LogInformation("Done aggregating {entity} from {from} to {to}", + nameof(GlobalStatistics), + start.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture), + end.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/src/Covid19Api.UseCases/Extensions/DateTimeExtensions.cs b/src/Covid19Api.UseCases/Extensions/DateTimeExtensions.cs new file mode 100644 index 0000000..a111431 --- /dev/null +++ b/src/Covid19Api.UseCases/Extensions/DateTimeExtensions.cs @@ -0,0 +1,15 @@ +using System; + +namespace Covid19Api.UseCases.Extensions +{ + internal static class DateTimeExtensions + { + /// + /// Adds one month and subtracts 1 second from the given . + /// + /// The for the calculation. + /// + public static DateTime MonthsEnd(this DateTime dateTime) + => dateTime.AddMonths(1).AddSeconds(-1); + } +} \ No newline at end of file diff --git a/src/Covid19Api.UseCases/Queries/LoadGlobalStatisticsAggregateQueryHandler.cs b/src/Covid19Api.UseCases/Queries/LoadGlobalStatisticsAggregateQueryHandler.cs new file mode 100644 index 0000000..17ed4f9 --- /dev/null +++ b/src/Covid19Api.UseCases/Queries/LoadGlobalStatisticsAggregateQueryHandler.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using Covid19Api.Presentation.Response; +using Covid19Api.Repositories.Abstractions; +using Covid19Api.UseCases.Abstractions.Queries; +using MediatR; + +namespace Covid19Api.UseCases.Queries +{ + public class + LoadGlobalStatisticsAggregateQueryHandler : IRequestHandler + { + private readonly IMapper mapper; + private readonly IGlobalStatisticsAggregatesRepository globalStatisticsAggregatesRepository; + + public LoadGlobalStatisticsAggregateQueryHandler(IMapper mapper, + IGlobalStatisticsAggregatesRepository globalStatisticsAggregatesRepository) + { + this.mapper = mapper; + this.globalStatisticsAggregatesRepository = globalStatisticsAggregatesRepository; + } + + public async Task Handle(LoadGlobalStatisticsAggregate request, + CancellationToken cancellationToken) + { + var aggregate = await this.globalStatisticsAggregatesRepository.FindAsync(request.Month, request.Year); + + return aggregate is null + ? null + : this.mapper.Map(aggregate); + } + } +} \ No newline at end of file diff --git a/src/Covid19Api.Worker/GlobalStatisticsAggregationWorker.cs b/src/Covid19Api.Worker/GlobalStatisticsAggregationWorker.cs new file mode 100644 index 0000000..a90ee90 --- /dev/null +++ b/src/Covid19Api.Worker/GlobalStatisticsAggregationWorker.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Covid19Api.UseCases.Abstractions.Commands; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Covid19Api.Worker +{ + public class GlobalStatisticsAggregationWorker : BackgroundService + { + private readonly ILogger logger; + private readonly IServiceProvider serviceProvider; + + public GlobalStatisticsAggregationWorker(ILogger logger, + IServiceProvider serviceProvider) + { + this.logger = logger; + this.serviceProvider = serviceProvider; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var nextRun = DateTime.UtcNow; + while (!stoppingToken.IsCancellationRequested) + { + if (nextRun <= DateTime.UtcNow) + { + await ExecuteAggregationAsync(nextRun, stoppingToken); + nextRun = nextRun.AddHours(12); + } + + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + } + + private async Task ExecuteAggregationAsync(DateTime nextRun, CancellationToken stoppingToken) + { + try + { + using var scope = this.serviceProvider.CreateScope(); + var command = new AggregateGlobalStatisticsCommand(nextRun.Month, nextRun.Year); + var mediator = scope.ServiceProvider.GetRequiredService(); + await mediator.Send(command, stoppingToken); + } + catch (Exception e) + { + this.logger.LogCritical(e, "Error while aggregating global-statistics"); + } + } + } +} \ No newline at end of file diff --git a/src/Covid19Api/Covid19Api.csproj b/src/Covid19Api/Covid19Api.csproj index aaf6790..e946207 100644 --- a/src/Covid19Api/Covid19Api.csproj +++ b/src/Covid19Api/Covid19Api.csproj @@ -20,6 +20,7 @@ + diff --git a/src/Covid19Api/Startup.cs b/src/Covid19Api/Startup.cs index 78499f2..fbf2cc0 100644 --- a/src/Covid19Api/Startup.cs +++ b/src/Covid19Api/Startup.cs @@ -1,6 +1,9 @@ using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; using Autofac; using AutoMapper.Contrib.Autofac.DependencyInjection; +using Covid19Api.AutoMapper; using Covid19Api.ExceptionFilter; using Covid19Api.IoC.Extensions; using Covid19Api.Middleware; @@ -42,6 +45,11 @@ public void ConfigureServices(IServiceCollection services) { options.Filters.Add(); options.Filters.Add(); + }).AddJsonOptions(options => + { + options.JsonSerializerOptions.IgnoreNullValues = true; + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); }); services.AddSwaggerGen(options => options.SwaggerDoc(ApiVersion, new OpenApiInfo @@ -70,7 +78,7 @@ public void ConfigureServices(IServiceCollection services) public void ConfigureContainer(ContainerBuilder containerBuilder) { containerBuilder - .RegisterAutoMapper(typeof(Startup).Assembly) + .RegisterAutoMapper(typeof(CountryStatsProfile).Assembly) .RegisterMediatR(typeof(LoadLatestGlobalStatisticsQueryHandler).Assembly, typeof(CachingBehavior<,>)) .RegisterWorker() .RegisterServices()