diff --git a/NorthwindCRUD/.ebextensions/cloudwatch.config b/NorthwindCRUD/.ebextensions/cloudwatch.config new file mode 100644 index 0000000..cc8f412 --- /dev/null +++ b/NorthwindCRUD/.ebextensions/cloudwatch.config @@ -0,0 +1,55 @@ +# https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/customize-containers-cw.html + +files: + "/opt/aws/amazon-cloudwatch-agent/bin/config.json": + mode: "000600" + owner: root + group: root + content: | + { + "agent":{ + "metrics_collection_interval":60, + "logfile":"/opt/aws/amazon-cloudwatch-agent/logs/amazon-cloudwatch-agent.log", + "run_as_user":"cwagent" + }, + "metrics":{ + "namespace":"CWAgent/AppBuilderData", + "append_dimensions":{ + "InstanceId":"${aws:InstanceId}", + "InstanceType":"${aws:InstanceType}", + "AutoScalingGroupName":"${aws:AutoScalingGroupName}" + }, + "aggregation_dimensions":[ + [ "AutoScalingGroupName", "InstanceId" ], + [ ] + ], + "metrics_collected":{ + "cpu":{ + "resources":[ + "*" + ], + "measurement":[ + "time_idle", + "time_iowait", + "time_system", + "time_user", + "usage_steal", + "usage_system", + "usage_user", + "usage_iowait" + ] + }, + "mem":{ + "measurement":[ + "used_percent", + "total", + "available_percent" + ] + } + } + } + } + +container_commands: + start_cloudwatch_agent: + command: /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a append-config -m ec2 -s -c file:/opt/aws/amazon-cloudwatch-agent/bin/config.json diff --git a/NorthwindCRUD/Middlewares/TenantHeaderValidationMiddleware.cs b/NorthwindCRUD/Middlewares/TenantHeaderValidationMiddleware.cs new file mode 100644 index 0000000..b8389a1 --- /dev/null +++ b/NorthwindCRUD/Middlewares/TenantHeaderValidationMiddleware.cs @@ -0,0 +1,35 @@ +using System.Text.RegularExpressions; + +namespace NorthwindCRUD.Middlewares +{ + public class TenantHeaderValidationMiddleware + { + private const string TenantHeaderKey = "X-Tenant-ID"; + + private readonly RequestDelegate next; + + public TenantHeaderValidationMiddleware(RequestDelegate next) + { + this.next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + var tenantHeader = context.Request.Headers[TenantHeaderKey].FirstOrDefault(); + + if (tenantHeader != null && !IsTenantValid(tenantHeader)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync($"Invalid format for Header {TenantHeaderKey}"); + return; + } + + await next(context); + } + + private bool IsTenantValid(string tenantId) + { + return Regex.IsMatch(tenantId, "^[A-Za-z0-9-_]{0,40}$"); + } + } +} diff --git a/NorthwindCRUD/NorthwindCRUD.csproj b/NorthwindCRUD/NorthwindCRUD.csproj index faf2e0a..d8276db 100644 --- a/NorthwindCRUD/NorthwindCRUD.csproj +++ b/NorthwindCRUD/NorthwindCRUD.csproj @@ -47,4 +47,9 @@ + + + Always + + diff --git a/NorthwindCRUD/Program.cs b/NorthwindCRUD/Program.cs index 2203523..1b40840 100644 --- a/NorthwindCRUD/Program.cs +++ b/NorthwindCRUD/Program.cs @@ -11,6 +11,8 @@ using Newtonsoft.Json.Converters; using NorthwindCRUD.Filters; using NorthwindCRUD.Helpers; +using NorthwindCRUD.Middlewares; +using NorthwindCRUD.Providers; using NorthwindCRUD.Services; namespace NorthwindCRUD @@ -74,36 +76,10 @@ public static void Main(string[] args) }); }); - var dbProvider = builder.Configuration.GetConnectionString("Provider"); - - if (dbProvider == "SQLite") - { - // For SQLite in memory to be shared across multiple EF calls, we need to maintain a separate open connection. - // see post https://stackoverflow.com/questions/56319638/entityframeworkcore-sqlite-in-memory-db-tables-are-not-created - var keepAliveConnection = new SqliteConnection(builder.Configuration.GetConnectionString("SQLiteConnectionString")); - keepAliveConnection.Open(); - } - - builder.Services.AddDbContext(options => + builder.Services.AddDbContext((serviceProvider, options) => { - if (dbProvider == "SqlServer") - { - options.UseSqlServer(builder.Configuration.GetConnectionString("SqlServerConnectionString")); - } - else if (dbProvider == "InMemory") - { - options.ConfigureWarnings(warnOpts => - { - // InMemory doesn't support transactions and we're ok with it - warnOpts.Ignore(InMemoryEventId.TransactionIgnoredWarning); - }); - - options.UseInMemoryDatabase(databaseName: builder.Configuration.GetConnectionString("InMemoryDBConnectionString")); - } - else if (dbProvider == "SQLite") - { - options.UseSqlite(builder.Configuration.GetConnectionString("SQLiteConnectionString")); - } + var configurationProvider = serviceProvider.GetRequiredService(); + configurationProvider.ConfigureOptions(options); }); var config = new MapperConfiguration(cfg => @@ -135,8 +111,10 @@ public static void Main(string[] args) }); builder.Services.AddAuthorization(); - + builder.Services.AddHttpContextAccessor(); + builder.Services.AddMemoryCache(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); @@ -155,7 +133,7 @@ public static void Main(string[] args) // Necessary to detect if it's behind a load balancer, for example changing protocol, port or hostname app.UseForwardedHeaders(); - + app.UseMiddleware(); app.UseHttpsRedirection(); app.UseDefaultFiles(); app.UseStaticFiles(); diff --git a/NorthwindCRUD/Providers/DbContextConfigurationProvider.cs b/NorthwindCRUD/Providers/DbContextConfigurationProvider.cs new file mode 100644 index 0000000..6df8705 --- /dev/null +++ b/NorthwindCRUD/Providers/DbContextConfigurationProvider.cs @@ -0,0 +1,97 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using NorthwindCRUD.Helpers; + +namespace NorthwindCRUD.Providers +{ + public class DbContextConfigurationProvider + { + private const string DefaultTenantId = "default-tenant"; + private const string TenantHeaderKey = "X-Tenant-ID"; + private const string DatabaseConnectionCacheKey = "Data-Connection-{0}"; + + private readonly IHttpContextAccessor context; + private readonly IMemoryCache memoryCache; + private readonly IConfiguration configuration; + + public DbContextConfigurationProvider(IHttpContextAccessor context, IMemoryCache memoryCache, IConfiguration configuration) + { + this.context = context; + this.memoryCache = memoryCache; + this.configuration = configuration; + } + + public void ConfigureOptions(DbContextOptionsBuilder options) + { + var dbProvider = configuration.GetConnectionString("Provider"); + + if (dbProvider == "SqlServer") + { + options.UseSqlServer(configuration.GetConnectionString("SqlServerConnectionString")); + } + else if (dbProvider == "SQLite") + { + var tenantId = GetTenantId(); + + var cacheKey = string.Format(CultureInfo.InvariantCulture, DatabaseConnectionCacheKey, tenantId); + + if (!memoryCache.TryGetValue(cacheKey, out SqliteConnection connection)) + { + var connectionString = this.GetSqlLiteConnectionString(tenantId); + connection = new SqliteConnection(connectionString); + memoryCache.Set(cacheKey, connection, GetCacheConnectionEntryOptions()); + } + + // For SQLite in memory to be shared across multiple EF calls, we need to maintain a separate open connection. + // see post https://stackoverflow.com/questions/56319638/entityframeworkcore-sqlite-in-memory-db-tables-are-not-created + connection.Open(); + + options.UseSqlite(connection).EnableSensitiveDataLogging(); + + SeedDb(options); + } + } + + private static void SeedDb(DbContextOptionsBuilder optionsBuilder) + { + using var dataContext = new DataContext(optionsBuilder.Options); + DBSeeder.Seed(dataContext); + } + + private static void CloseConnection(object key, object value, EvictionReason reason, object state) + { + //Used to clear datasource from memory. + (value as SqliteConnection)?.Close(); + } + + private MemoryCacheEntryOptions GetCacheConnectionEntryOptions() + { + var defaultAbsoluteCacheExpirationInHours = this.configuration.GetValue("DefaultAbsoluteCacheExpirationInHours"); + var cacheEntryOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(defaultAbsoluteCacheExpirationInHours), + }; + + cacheEntryOptions.RegisterPostEvictionCallback(CloseConnection); + + return cacheEntryOptions; + } + + private string GetSqlLiteConnectionString(string tenantId) + { + var connectionStringTemplate = configuration.GetConnectionString("SQLiteConnectionString"); + var unsanitizedConntectionString = string.Format(CultureInfo.InvariantCulture, connectionStringTemplate, tenantId); + var connectionStringBuilder = new SqliteConnectionStringBuilder(unsanitizedConntectionString); + var sanitizedConntectionString = connectionStringBuilder.ToString(); + + return sanitizedConntectionString; + } + + private string GetTenantId() + { + return context.HttpContext?.Request.Headers[TenantHeaderKey].FirstOrDefault() ?? DefaultTenantId; + } + } +} diff --git a/NorthwindCRUD/appsettings.json b/NorthwindCRUD/appsettings.json index 2fe4d3c..4faeb7c 100644 --- a/NorthwindCRUD/appsettings.json +++ b/NorthwindCRUD/appsettings.json @@ -3,8 +3,9 @@ "Provider": "SQLite", //SqlServer or InMemory or SQLite "SqlServerConnectionString": "Data Source=(localdb)\\MSSQLLocalDB;Database=NorthwindCRUD;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False;MultipleActiveResultSets=True", "InMemoryDBConnectionString": "NorthwindCRUD", - "SQLiteConnectionString": "DataSource=northwind-db;mode=memory;cache=shared" + "SQLiteConnectionString": "DataSource=northwind-db-{0};mode=memory;cache=shared;" }, + "DefaultAbsoluteCacheExpirationInHours": 24, "Logging": { "LogLevel": { "Default": "Information",