How to customise configuration binding in ASP.NET Core
Asked Answered
S

3

8

I have a multi-tenant ASP.NET Core web application. The current tenancy model is every tenant has a separate web app and SQL database. I'm trying to rearchitect it so that multiple tenants will be served by a single web app (but maintaining a separate database per tenant). I've been following this series of blog posts but I've hit a bit of a roadblock with configuration.

The app makes heavy use of the ASP.NET Core configuration system, and has a custom EF Core provider that fetches config values from the database. I'd like to preserve this if possible, it would be an awful lot of work to rip out and replace with something else (dozens of config settings used in hundreds of places).

The existing code is very standard:

public class MyAppSettings
{
    public string FavouriteColour { get; set; }
    public int LuckyNumber { get; set; }
}

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddOptions();
        services.Configure<MyAppSettings>(Configuration.GetSection("MyAppSettings"));
        // etc....
    }
}

// custom EF Core config provider wired up in Program.Main, but that doesn't actually seem relevant

I've already updated our custom provider so that it fetches all configuration values from all known tenant databases, and adds them all to the configuration system, prefixed with a tenant identifier, so the list of all config values fetched from the n different databases might look something like this:

Key                                       Value
===============================================
TenantABC:MyAppSettings:FavouriteColour   Green
TenantABC:MyAppSettings:LuckyNumber       42
TenantDEF:MyAppsettings:FavouriteColour   Blue
TenantDEF:MyAppSettings:LuckyNumber       37
...
TenantXYZ:MyAppSettings:FavouriteColour   Yellow
TenantXYZ:MyAppSettings:LuckyNumber       88

What I'd like to be able to do is somehow customise the way that the configuration is bound so that it resolves the tenant for the current request, and then uses the appropriate values, e.g. a request on abc.myapp.com would observe config values "Green" and "42", etc, without having to change all the dependent places that inject IOptionsMonitor<AppSettings> (or IOptionsSnapshot, etc). The linked blog series has a post about configuration that covers some gotchas that I expect I'll eventually run into around caching etc, but it doesn't seem to cater for this scenario of using completely different settings for different tenants. Conceptually it seems simple enough, but I haven't been able to find the correct place to hook in. Please help!

Success answered 6/7, 2020 at 17:35 Comment(1)
Try using this github.com/aspnetboilerplate/aspnetboilerplate The template is already built on the architecture you need.Mintz
H
5

Here is an idea (not tested yet, however). You can save the default IConfiguration instance passed to the constructor of your Startup class and then register in DI your own implementation of IConfiguration that will use that default one and HttpContextAccessor (to get the current tenant).

So the code will look something like:


public class Startup 
{
    private IConfiguration _defaultConfig;

    public Startup(IConfiguration configuration, IWebHostEnvironment env)
    {
        _defaultConfig = configuration;
    }

    public void ConfigureServices(IServiceCollection services)
    {
        .   .   .   .
        services.AddScoped<IConfiguration>(serviceProvider => {
            var httpContextAccessor = 
                  serviceProvider.GetService<IHttpContextAccessor>();
            return new MyConfig(_defaultConfig, httpContextAccessor);
        });
    }

    .   .   .   .
}

public class MyConfig : IConfiguration
{
    private readonly IConfiguration _defaultConfig;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public MyConfig(IConfiguration defaultConfig, IHttpContextAccessor httpContextAccessor) 
    {
        _defaultConfig = defaultConfig;
        _httpContextAccessor = httpContextAccessor;
    }

    public string this[string key] {
        get {
            var tenantId = GetTenantId();
            return _defaultConfig[tenantId + ":" + key];
        }
        set {
            var tenantId = GetTenantId();
            _defaultConfig[tenantId + ":" + key] = value;
        }
    }

    protected virtual string GetTenantId()
    { 
        //this is just an example that supposes that you have "TenantId" claim associated with each user
        return _httpContextAccessor.HttpContext.User.FindFirst("TenantId").Value; ;
    }

    public IEnumerable<IConfigurationSection> GetChildren()
    {
        return _defaultConfig.GetChildren();
    }

    public IChangeToken GetReloadToken()
    {
        return _defaultConfig.GetReloadToken();
    }

    public IConfigurationSection GetSection(string key)
    {
        var tenantId = GetTenantId();
        return _defaultConfig.GetSection(tenantId + ":" + key);
    }
}
Heron answered 13/7, 2020 at 16:19 Comment(0)
N
1

Here are 3 solutions that may be helpful. I don't recommend you the IOptionsMonitor<T> because the tenant value is extracted from HttpContext, makes no sense to use the IOptionsMonitor.

Shared code:

public static class Extensions
{
    public static string GetTenantName(this HttpContext context)
    {
        switch (context.Request.Host.Host)
        {
            case "abc.localhost.com":
                return "TenantABC";
            case "def.localhost.com":
                return "TenantDEF";
            default:
                throw new IndexOutOfRangeException("Invalid host requested");
        }
    }

    public static MyAppSettings GetAppSettingsByTenant(this IConfiguration config, string tenant)
    {
        return new MyAppSettings
        {
            LuckyNumber = int.Parse(config[$"{tenant}:MyAppSettings:LuckyNumber"]),
            FavouriteColour = config[$"{tenant}:MyAppSettings:FavouriteColour"]
        };
    }
}

Solution 1: Scoped MyAppSettings object.

Registration (Startup->ConfigureServices(IServiceCollection)`

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
    services.AddScoped(sp =>
    {
        var contextAccessor = sp.GetService<IHttpContextAccessor>();
        var config = sp.GetService<IConfiguration>();
        return config.GetAppSettingsByTenant(contextAccessor.HttpContext.GetTenantName());
    });
...

Usage:

public class TestController : Controller
{
    private readonly MyAppSettings _settings;

    public TestController(MyAppSettings settings)
    {
        _settings = settings;
    }

    [HttpGet]
    public IActionResult Index()
    {
        return Json(_settings);
    }
}

Solution 2: IOptions<MyAppSettings

Registration (Startup->ConfigureServices(IServiceCollection)`

public class MyAppSettingsOptions : IOptions<MyAppSettings>
{
    public MyAppSettingsOptions(IConfiguration configuration, IHttpContextAccessor contextAccessor)
    {
        var tenant = contextAccessor.HttpContext.GetTenantName();
        Value = configuration.GetAppSettingsByTenant(tenant);
    }

    public MyAppSettings Value { get; }
}

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
    services.AddScoped<IOptions<MyAppSettings>, MyAppSettingsOptions>();
...

Usage

public class TestController : Controller
{
    private readonly IOptions<MyAppSettings> _options;
    public TestController(IOptions<MyAppSettings> options)
    {
        _options = options;
    }

    [HttpGet]
    public IActionResult Index()
    {
        return Json(_options.Value);
    }
}

Solution 3: IOptionsMonitor<MyAppSettings

Registration (Startup->ConfigureServices(IServiceCollection)`

public class MyAppSettingsOptionsMonitor : IOptionsMonitor<MyAppSettings>
{
    public MyAppSettingsOptionsMonitor(IConfiguration configuration, IHttpContextAccessor contextAccessor)
    {
        var tenant = contextAccessor.HttpContext.GetTenantName();
        CurrentValue = configuration.GetAppSettingsByTenant(tenant);
    }

    public MyAppSettings Get(string name)
    {
        throw new NotSupportedException();
    }

    public IDisposable OnChange(Action<MyAppSettings, string> listener)
    {
        return null;
    }

    public MyAppSettings CurrentValue { get; }
}

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
    services.AddScoped<IOptionsMonitor<MyAppSettings>, MyAppSettingsOptionsMonitor>();
...

Usage

public class TestController : Controller
{
    private readonly IOptionsMonitor<MyAppSettings> _options;
    public TestController(IOptionsMonitor<MyAppSettings> options)
    {
        _options = options;
    }

    [HttpGet]
    public IActionResult Index()
    {
        return Json(_options.CurrentValue);
    }
}
New answered 15/7, 2020 at 2:11 Comment(0)
C
0

You can use DI services to config options

Sample code of your option class

public class MyAppSettings
{
    public string FavouriteColor { get; set; }
    public int LuckNumber { get; set; }
}

public interface IMySettingServices
{
    string GetFavouriteColor();
    int GetLuckNumber();
}

public class MySettingServices : IMySettingServices
{
    private IHttpContextAccessor httpContextAccessor;
    public MySettingServices(IHttpContextAccessor httpContextAccessor)
    {
        this.httpContextAccessor = httpContextAccessor;
    }
    public string GetFavouriteColor()
    {
        var headers = this.httpContextAccessor.HttpContext.Request.Headers;
        //Write your logic with httpContextAccessor by extract tenant here, then return actual config by tenant name
        if(this.httpContextAccessor.HttpContext.Request.Host.Host=="abc.test.com")
        {
            //Get color setting for abc.test.com
        }
        return "Green";
    }

    public int GetLuckNumber()
    {
        var headers = this.httpContextAccessor.HttpContext.Request.Headers;
        //Write your logic with httpContextAccessor by extract tenant here, then return actual config by tenant name
        if (this.httpContextAccessor.HttpContext.Request.Host.Host == "abc.test.com")
        {
            //Get luck number setting for abc.test.com
        }

        return 1;
    }
}

Sample code of your ConfigureService

public void ConfigureServices(IServiceCollection services)
    {
        services.AddHttpContextAccessor();
        services.AddControllersWithViews();
        services.AddSingleton<IMySettingServices, MySettingServices>();
        services.AddOptions<MyAppSettings>().Configure<IMySettingServices>((setting, settingServices) => {
            setting.FavouriteColor = settingServices.GetFavouriteColor();
            setting.LuckNumber = settingServices.GetLuckNumber();
        });//This is the key point of this answer, you are delegating your setting assignment to a services, so you can do whatever you want in your services, in your word, customise configuration binding
    }

Sample code of use your configuration in controller

public class HomeController : Controller
{
    private readonly ILogger<HomeController> _logger;

    private readonly IOptions<MyAppSettings> settings;

    public HomeController(ILogger<HomeController> logger, IOptions<MyAppSettings> settings)
    {
        _logger = logger;
        this.settings = settings;
    }

    public IActionResult Index()
    {
        var favColor = settings.Value.FavouriteColor;
        return View();
    }
 }

please be aware that when you want to access httpcontext, do not directly add services.AddScoped/AddSingleton/AddTransit<IHttpContextAccessor,HttpContextAccessor>(), this will result to DI system unable to resolve IHttpContextAccessor during ConfigureServices phases. Use services.AddHttpContextAccessor(); is the best way to do that

Companionable answered 11/8, 2020 at 9:57 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.