Skip to content

Commit

Permalink
Merge pull request #36 from IgniteUI/DNenchev/35-implement-multi-tena…
Browse files Browse the repository at this point in the history
…nt-db-context

Implement Multi-Tenant DbContext for SQLite With Db Expiration
  • Loading branch information
pmoleri authored Oct 25, 2024
2 parents 2bbeab6 + 7870e41 commit c22519b
Show file tree
Hide file tree
Showing 6 changed files with 203 additions and 32 deletions.
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

0 comments on commit c22519b

Please sign in to comment.