Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Multi-Tenant DbContext for SQLite With Db Expiration #36

Merged
merged 12 commits into from
Oct 25, 2024
Merged
55 changes: 55 additions & 0 deletions NorthwindCRUD/.ebextensions/cloudwatch.config
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions NorthwindCRUD/Middlewares/TenantHeaderValidationMiddleware.cs
Original file line number Diff line number Diff line change
@@ -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}$");
}
}
}
5 changes: 5 additions & 0 deletions NorthwindCRUD/NorthwindCRUD.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,9 @@
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0" />
</ItemGroup>

<ItemGroup>
<None Include=".ebextensions/**/*">
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
</None>
</ItemGroup>
</Project>
40 changes: 9 additions & 31 deletions NorthwindCRUD/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<DataContext>(options =>
builder.Services.AddDbContext<DataContext>((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<DbContextConfigurationProvider>();
configurationProvider.ConfigureOptions(options);
});

var config = new MapperConfiguration(cfg =>
Expand Down Expand Up @@ -135,8 +111,10 @@ public static void Main(string[] args)
});

builder.Services.AddAuthorization();

builder.Services.AddHttpContextAccessor();
builder.Services.AddMemoryCache();
builder.Services.AddScoped<DBSeeder>();
builder.Services.AddScoped<DbContextConfigurationProvider>();
builder.Services.AddTransient<CategoryService>();
builder.Services.AddTransient<CustomerService>();
builder.Services.AddTransient<EmployeeTerritoryService>();
Expand All @@ -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<TenantHeaderValidationMiddleware>();
app.UseHttpsRedirection();
app.UseDefaultFiles();
app.UseStaticFiles();
Expand Down
97 changes: 97 additions & 0 deletions NorthwindCRUD/Providers/DbContextConfigurationProvider.cs
Original file line number Diff line number Diff line change
@@ -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<int>("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;
}
}
}
3 changes: 2 additions & 1 deletion NorthwindCRUD/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading