diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index 7237596aed..0a8ed12d2a 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -52,6 +52,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceGeneratorTests", "tes EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore.Annotations", "src\JsonApiDotNetCore.Annotations\JsonApiDotNetCore.Annotations.csproj", "{83FF097C-C8C6-477B-9FAB-DF99B84978B5}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DatabasePerTenantExample", "src\Examples\DatabasePerTenantExample\DatabasePerTenantExample.csproj", "{60334658-BE51-43B3-9C4D-F2BBF56C89CE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -266,6 +268,18 @@ Global {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x64.Build.0 = Release|Any CPU {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x86.ActiveCfg = Release|Any CPU {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x86.Build.0 = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x64.ActiveCfg = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x64.Build.0 = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x86.ActiveCfg = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x86.Build.0 = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|Any CPU.Build.0 = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x64.ActiveCfg = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x64.Build.0 = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x86.ActiveCfg = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -288,6 +302,7 @@ Global {87D066F9-3540-4AC7-A748-134900969EE5} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {83FF097C-C8C6-477B-9FAB-DF99B84978B5} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} + {60334658-BE51-43B3-9C4D-F2BBF56C89CE} = {026FBC6C-AF76-4568-9B87-EC73457899FD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 92cce56c47..c4f0d3d36d 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -631,6 +631,7 @@ $left$ = $right$; WARNING True True + True True True True diff --git a/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs b/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs new file mode 100644 index 0000000000..5e17afab9b --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Controllers.Annotations; +using Microsoft.AspNetCore.Mvc; + +namespace DatabasePerTenantExample.Controllers; + +// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 +public partial class EmployeesController +{ +} + +[DisableRoutingConvention] +[Route("api/{tenantName}/employees")] +partial class EmployeesController +{ +} diff --git a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs new file mode 100644 index 0000000000..c70fc8655f --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs @@ -0,0 +1,80 @@ +using System.Net; +using DatabasePerTenantExample.Models; +using JetBrains.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; + +namespace DatabasePerTenantExample.Data; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class AppDbContext : DbContext +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IConfiguration _configuration; + private string? _forcedTenantName; + + public DbSet Employees => Set(); + + public AppDbContext(IHttpContextAccessor httpContextAccessor, IConfiguration configuration) + { + _httpContextAccessor = httpContextAccessor; + _configuration = configuration; + } + + public void SetTenantName(string tenantName) + { + _forcedTenantName = tenantName; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + string connectionString = GetConnectionString(); + optionsBuilder.UseNpgsql(connectionString); + } + + private string GetConnectionString() + { + string? tenantName = GetTenantName(); + string connectionString = _configuration[$"Data:{tenantName ?? "Default"}Connection"]; + + if (connectionString == null) + { + throw GetErrorForInvalidTenant(tenantName); + } + + string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; + return connectionString.Replace("###", postgresPassword); + } + + private string? GetTenantName() + { + if (_forcedTenantName != null) + { + return _forcedTenantName; + } + + if (_httpContextAccessor.HttpContext != null) + { + string? tenantName = (string?)_httpContextAccessor.HttpContext.Request.RouteValues["tenantName"]; + + if (tenantName == null) + { + throw GetErrorForInvalidTenant(null); + } + + return tenantName; + } + + return null; + } + + private static JsonApiException GetErrorForInvalidTenant(string? tenantName) + { + return new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "Missing or invalid tenant in URL.", + Detail = $"Tenant '{tenantName}' does not exist." + }); + } +} diff --git a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj new file mode 100644 index 0000000000..b243e99ec2 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj @@ -0,0 +1,16 @@ + + + $(TargetFrameworkName) + + + + + + + + + + + + diff --git a/src/Examples/DatabasePerTenantExample/Models/Employee.cs b/src/Examples/DatabasePerTenantExample/Models/Employee.cs new file mode 100644 index 0000000000..cc79449880 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Models/Employee.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DatabasePerTenantExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Employee : Identifiable +{ + [Attr] + public string FirstName { get; set; } = null!; + + [Attr] + public string LastName { get; set; } = null!; + + [Attr] + public string CompanyName { get; set; } = null!; +} diff --git a/src/Examples/DatabasePerTenantExample/Program.cs b/src/Examples/DatabasePerTenantExample/Program.cs new file mode 100644 index 0000000000..b6f960831d --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Program.cs @@ -0,0 +1,58 @@ +using DatabasePerTenantExample.Data; +using DatabasePerTenantExample.Models; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddSingleton(); +builder.Services.AddDbContext(options => options.UseNpgsql()); + +builder.Services.AddJsonApi(options => +{ + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.SerializerOptions.WriteIndented = true; +}); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +await CreateDatabaseAsync(null, app.Services); +await CreateDatabaseAsync("AdventureWorks", app.Services); +await CreateDatabaseAsync("Contoso", app.Services); + +app.Run(); + +static async Task CreateDatabaseAsync(string? tenantName, IServiceProvider serviceProvider) +{ + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + if (tenantName != null) + { + dbContext.SetTenantName(tenantName); + } + + await dbContext.Database.EnsureDeletedAsync(); + await dbContext.Database.EnsureCreatedAsync(); + + if (tenantName != null) + { + dbContext.Employees.Add(new Employee + { + FirstName = "John", + LastName = "Doe", + CompanyName = tenantName + }); + + await dbContext.SaveChangesAsync(); + } +} diff --git a/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json b/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json new file mode 100644 index 0000000000..1ab75296f7 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14147", + "sslPort": 44340 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "api/AdventureWorks/employees", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Kestrel": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/AdventureWorks/employees", + "applicationUrl": "https://localhost:44347;http://localhost:14147", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json new file mode 100644 index 0000000000..c065f66c64 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -0,0 +1,15 @@ +{ + "Data": { + "DefaultConnection": "Host=localhost;Port=5432;Database=DefaultTenantDb;User ID=postgres;Password=###", + "AdventureWorksConnection": "Host=localhost;Port=5432;Database=AdventureWorks;User ID=postgres;Password=###", + "ContosoConnection": "Host=localhost;Port=5432;Database=Contoso;User ID=postgres;Password=###" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "AllowedHosts": "*" +}