Skip to content

Commit 3ba5e5f

Browse files
author
Bart Koelman
committed
Added example that uses a different database per tenant. Note this only works when the database structure is identical for all tenants.
1 parent c2cf072 commit 3ba5e5f

File tree

9 files changed

+245
-0
lines changed

9 files changed

+245
-0
lines changed

JsonApiDotNetCore.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceGeneratorTests", "tes
5252
EndProject
5353
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore.Annotations", "src\JsonApiDotNetCore.Annotations\JsonApiDotNetCore.Annotations.csproj", "{83FF097C-C8C6-477B-9FAB-DF99B84978B5}"
5454
EndProject
55+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DatabasePerTenantExample", "src\Examples\DatabasePerTenantExample\DatabasePerTenantExample.csproj", "{60334658-BE51-43B3-9C4D-F2BBF56C89CE}"
56+
EndProject
5557
Global
5658
GlobalSection(SolutionConfigurationPlatforms) = preSolution
5759
Debug|Any CPU = Debug|Any CPU
@@ -266,6 +268,18 @@ Global
266268
{83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x64.Build.0 = Release|Any CPU
267269
{83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x86.ActiveCfg = Release|Any CPU
268270
{83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x86.Build.0 = Release|Any CPU
271+
{60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
272+
{60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
273+
{60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x64.ActiveCfg = Debug|Any CPU
274+
{60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x64.Build.0 = Debug|Any CPU
275+
{60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x86.ActiveCfg = Debug|Any CPU
276+
{60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x86.Build.0 = Debug|Any CPU
277+
{60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
278+
{60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|Any CPU.Build.0 = Release|Any CPU
279+
{60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x64.ActiveCfg = Release|Any CPU
280+
{60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x64.Build.0 = Release|Any CPU
281+
{60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x86.ActiveCfg = Release|Any CPU
282+
{60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x86.Build.0 = Release|Any CPU
269283
EndGlobalSection
270284
GlobalSection(SolutionProperties) = preSolution
271285
HideSolutionNode = FALSE
@@ -288,6 +302,7 @@ Global
288302
{87D066F9-3540-4AC7-A748-134900969EE5} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F}
289303
{0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F}
290304
{83FF097C-C8C6-477B-9FAB-DF99B84978B5} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF}
305+
{60334658-BE51-43B3-9C4D-F2BBF56C89CE} = {026FBC6C-AF76-4568-9B87-EC73457899FD}
291306
EndGlobalSection
292307
GlobalSection(ExtensibilityGlobals) = postSolution
293308
SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4}

JsonApiDotNetCore.sln.DotSettings

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,7 @@ $left$ = $right$;</s:String>
631631
<s:String x:Key="/Default/PatternsAndTemplates/StructuralSearch/Pattern/=B3D9EE6B4EC62A4F961EB15F9ADEC2C6/Severity/@EntryValue">WARNING</s:String>
632632
<s:Boolean x:Key="/Default/UserDictionary/Words/=appsettings/@EntryIndexedValue">True</s:Boolean>
633633
<s:Boolean x:Key="/Default/UserDictionary/Words/=Assignee/@EntryIndexedValue">True</s:Boolean>
634+
<s:Boolean x:Key="/Default/UserDictionary/Words/=Contoso/@EntryIndexedValue">True</s:Boolean>
634635
<s:Boolean x:Key="/Default/UserDictionary/Words/=Injectables/@EntryIndexedValue">True</s:Boolean>
635636
<s:Boolean x:Key="/Default/UserDictionary/Words/=jsonapi/@EntryIndexedValue">True</s:Boolean>
636637
<s:Boolean x:Key="/Default/UserDictionary/Words/=linebreaks/@EntryIndexedValue">True</s:Boolean>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using JsonApiDotNetCore.Controllers.Annotations;
2+
using Microsoft.AspNetCore.Mvc;
3+
4+
namespace DatabasePerTenantExample.Controllers
5+
{
6+
[DisableRoutingConvention]
7+
[Route("api/{tenantName}/employees")]
8+
partial class EmployeesController
9+
{
10+
}
11+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using System.Net;
2+
using DatabasePerTenantExample.Models;
3+
using JetBrains.Annotations;
4+
using JsonApiDotNetCore.Errors;
5+
using JsonApiDotNetCore.Serialization.Objects;
6+
using Microsoft.EntityFrameworkCore;
7+
8+
namespace DatabasePerTenantExample.Data;
9+
10+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
11+
public sealed class AppDbContext : DbContext
12+
{
13+
private readonly IHttpContextAccessor _httpContextAccessor;
14+
private readonly IConfiguration _configuration;
15+
private string? _forcedTenantName;
16+
17+
public DbSet<Employee> Employees => Set<Employee>();
18+
19+
public AppDbContext(IHttpContextAccessor httpContextAccessor, IConfiguration configuration)
20+
{
21+
_httpContextAccessor = httpContextAccessor;
22+
_configuration = configuration;
23+
}
24+
25+
public void SetTenantName(string tenantName)
26+
{
27+
_forcedTenantName = tenantName;
28+
}
29+
30+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
31+
{
32+
string connectionString = GetConnectionString();
33+
optionsBuilder.UseNpgsql(connectionString);
34+
}
35+
36+
private string GetConnectionString()
37+
{
38+
string? tenantName = GetTenantName();
39+
string connectionString = _configuration[$"Data:{tenantName ?? "Default"}Connection"];
40+
41+
if (connectionString == null)
42+
{
43+
throw GetErrorForInvalidTenant(tenantName);
44+
}
45+
46+
string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres";
47+
return connectionString.Replace("###", postgresPassword);
48+
}
49+
50+
private string? GetTenantName()
51+
{
52+
if (_forcedTenantName != null)
53+
{
54+
return _forcedTenantName;
55+
}
56+
57+
if (_httpContextAccessor.HttpContext != null)
58+
{
59+
string? tenantName = (string?)_httpContextAccessor.HttpContext.Request.RouteValues["tenantName"];
60+
61+
if (tenantName == null)
62+
{
63+
throw GetErrorForInvalidTenant(null);
64+
}
65+
66+
return tenantName;
67+
}
68+
69+
return null;
70+
}
71+
72+
private static JsonApiException GetErrorForInvalidTenant(string? tenantName)
73+
{
74+
return new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest)
75+
{
76+
Title = "Missing or invalid tenant in URL.",
77+
Detail = $"Tenant '{tenantName}' does not exist."
78+
});
79+
}
80+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
<PropertyGroup>
3+
<TargetFramework>$(TargetFrameworkName)</TargetFramework>
4+
</PropertyGroup>
5+
6+
<ItemGroup>
7+
<ProjectReference Include="..\..\JsonApiDotNetCore\JsonApiDotNetCore.csproj" />
8+
<ProjectReference Include="..\..\JsonApiDotNetCore.SourceGenerators\JsonApiDotNetCore.SourceGenerators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
9+
</ItemGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="$(EFCoreVersion)" />
13+
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="$(EFCorePostgresVersion)" />
14+
</ItemGroup>
15+
</Project>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace DatabasePerTenantExample.Models;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource]
9+
public sealed class Employee : Identifiable<Guid>
10+
{
11+
[Attr]
12+
public string FirstName { get; set; } = null!;
13+
14+
[Attr]
15+
public string LastName { get; set; } = null!;
16+
17+
[Attr]
18+
public string CompanyName { get; set; } = null!;
19+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using DatabasePerTenantExample.Data;
2+
using DatabasePerTenantExample.Models;
3+
using JsonApiDotNetCore.Configuration;
4+
using Microsoft.EntityFrameworkCore;
5+
6+
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
7+
8+
// Add services to the container.
9+
10+
builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
11+
builder.Services.AddDbContext<AppDbContext>(options => options.UseNpgsql());
12+
13+
builder.Services.AddJsonApi<AppDbContext>(options =>
14+
{
15+
options.Namespace = "api";
16+
options.UseRelativeLinks = true;
17+
options.SerializerOptions.WriteIndented = true;
18+
});
19+
20+
WebApplication app = builder.Build();
21+
22+
// Configure the HTTP request pipeline.
23+
24+
app.UseRouting();
25+
app.UseJsonApi();
26+
app.MapControllers();
27+
28+
await CreateDatabaseAsync(null, app.Services);
29+
await CreateDatabaseAsync("AdventureWorks", app.Services);
30+
await CreateDatabaseAsync("Contoso", app.Services);
31+
32+
app.Run();
33+
34+
static async Task CreateDatabaseAsync(string? tenantName, IServiceProvider serviceProvider)
35+
{
36+
await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope();
37+
38+
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
39+
40+
if (tenantName != null)
41+
{
42+
dbContext.SetTenantName(tenantName);
43+
}
44+
45+
await dbContext.Database.EnsureDeletedAsync();
46+
await dbContext.Database.EnsureCreatedAsync();
47+
48+
if (tenantName != null)
49+
{
50+
dbContext.Employees.Add(new Employee
51+
{
52+
FirstName = "John",
53+
LastName = "Doe",
54+
CompanyName = tenantName
55+
});
56+
57+
await dbContext.SaveChangesAsync();
58+
}
59+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"$schema": "http://json.schemastore.org/launchsettings.json",
3+
"iisSettings": {
4+
"windowsAuthentication": false,
5+
"anonymousAuthentication": true,
6+
"iisExpress": {
7+
"applicationUrl": "http://localhost:14147",
8+
"sslPort": 44340
9+
}
10+
},
11+
"profiles": {
12+
"IIS Express": {
13+
"commandName": "IISExpress",
14+
"launchBrowser": true,
15+
"launchUrl": "api/AdventureWorks/employees",
16+
"environmentVariables": {
17+
"ASPNETCORE_ENVIRONMENT": "Development"
18+
}
19+
},
20+
"Kestrel": {
21+
"commandName": "Project",
22+
"launchBrowser": true,
23+
"launchUrl": "api/AdventureWorks/employees",
24+
"applicationUrl": "https://localhost:44347;http://localhost:14147",
25+
"environmentVariables": {
26+
"ASPNETCORE_ENVIRONMENT": "Development"
27+
}
28+
}
29+
}
30+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"Data": {
3+
"DefaultConnection": "Host=localhost;Port=5432;Database=DefaultTenantDb;User ID=postgres;Password=###",
4+
"AdventureWorksConnection": "Host=localhost;Port=5432;Database=AdventureWorks;User ID=postgres;Password=###",
5+
"ContosoConnection": "Host=localhost;Port=5432;Database=Contoso;User ID=postgres;Password=###"
6+
},
7+
"Logging": {
8+
"LogLevel": {
9+
"Default": "Warning",
10+
"Microsoft.Hosting.Lifetime": "Information",
11+
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
12+
}
13+
},
14+
"AllowedHosts": "*"
15+
}

0 commit comments

Comments
 (0)