0

I'm still very new to .NET 7 (coming from .NET Framework) and its out-of-the-box DI system so forgive my ignorance here.

I'm trying to write a custom ModelMetadataDetailsProvider in my ASP.NET project so that I can get some details from the database when models are being bound and cache the data to reduce database calls. I have this in my provider:

public class MyDisplayMetadataProvider : IDisplayMetadataProvider
{
    private readonly MyDBContext _db;
    private readonly IMemoryCache _memoryCache;

    public MyDisplayMetadataProvider(MyDBContext db, IMemoryCache memoryCache)
    {
        _db = db;
        _memoryCache = memoryCache;
    }

    public void CreateDisplayMetadata(DisplayMetadataProviderContext context)
    {
        // etc.

I'm trying to wire up this provider in my Program.cs file but that's where I'm getting stuck. When I was just getting started with this provider, before I added the parameters in the constructor, this worked fine:

builder.Services.AddControllersWithViews(options =>
{
    options.ModelMetadataDetailsProviders.Add(new MyDisplayMetadataProvider());
});
builder.Services.AddDbContext<MyDBContext>(options => options.UseSqlServer("name=ConnectionStrings:DefaultConnection"));

However, now that my provider's constructor requires two parameters, Intellisense understandably points out that I haven't provided the required parameters for the database context and memory cache. I know I'm doing something wrong because I wouldn't expect to be manually instantiating an instance of the class with new when using DI, but I cannot for the life of me figure out what the syntax should be and I've spent literally hours searching SO and Google for clues, with no success.

Copying the syntax I've used successfully elsewhere to register a Filter, I tried this:

builder.Services.AddControllersWithViews(options =>
{
    options.ModelMetadataDetailsProviders.Add<MyDisplayMetadataProvider>();
});
builder.Services.AddDbContext<MyDBContext>(options => options.UseSqlServer("name=ConnectionStrings:DefaultConnection"));

but that causes this error to be displayed:

There is no argument given that corresponds to the required parameter 'configureSource' of 'ConfigurationExtensions.Add(IConfigurationBuilder, Action?)'

which really is confusing to me. I expected that Googling that one would yield lots of useful suggestions from people who'd encountered the same error, but in fact there don't seem to be any exact matches at all.

How can I wire up my custom provider using DI or, if that's not possible (I'm sure it is!), how can I supply the parameters it requires?


Update

Following the answer from Guru Stron I have created an additional method which implement IConfigureOptions. I'm just having a bit of trouble getting the registration part working with the database context. Following the advice from the answer they linked to, I've tried this:

builder.Services.AddControllersWithViews();
builder.Services.AddDbContext<MyDBContext>(options => options.UseSqlServer("name=ConnectionStrings:DefaultConnection"));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService<MyDBContext>();
    builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IConfigureOptions<MvcOptions>, MyDisplayMetadataProviderSetup>());
}

This doesn't work, as I knew it wouldn't, because the service collection is read-only at the point I'm calling TryAddEnumerable, since I've already called builder.Build() at that point.

But if I move the using block to above the var app = builder.Build() line then I can't use app.Services.CreateScope() because the app variable hasn't been declared at that point.

I think I've nearly cracked it, but I just can't quite figure out the registration part with the scoped db context.

Philip Stratford
  • 4,513
  • 4
  • 45
  • 71

2 Answers2

2

I've accepted the answer from Guru Stron because it's correct and ultimately it's what got me over the line, but I wanted to add an answer which encapsulated exactly what my solution looked like in the end, as much for my own future reference as anything.

public class MyDisplayMetadataProvider : IDisplayMetadataProvider
{
    private readonly IServiceScopeFactory _serviceScopeFactory;
    private readonly IMemoryCache _memoryCache;

    public MyDisplayMetadataProvider(IServiceScopeFactory serviceScopeFactory, IMemoryCache memoryCache)
    {
        _serviceScopeFactory = serviceScopeFactory;
        _memoryCache = memoryCache;
    }

    public void CreateDisplayMetadata(DisplayMetadataProviderContext context)
    {
          using (var scope = _serviceScopeFactory.CreateScope())
          {
              var db = scope.ServiceProvider.GetRequiredService<MyDBContext>();
              // Code here using db context
          }
    }
}

class MyDisplayMetadataProviderSetup : IConfigureOptions<MvcOptions>
{
    private readonly IServiceScopeFactory _serviceScopeFactory;
    private readonly IMemoryCache _memoryCache;

    public MyDisplayMetadataProviderSetup(IServiceScopeFactory serviceScopeFactory, IMemoryCache memoryCache)
    {
        _serviceScopeFactory = serviceScopeFactory;
        _memoryCache = memoryCache;
    }
    public void Configure(MvcOptions options)
    {
        options.ModelMetadataDetailsProviders.Add(new MyDisplayMetadataProvider(_serviceScopeFactory, _memoryCache));
    }
}

And the registration in Program.cs:

builder.Services.AddDbContext<MyDBContext>(options => options.UseSqlServer("name=ConnectionStrings:DefaultConnection"));
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IConfigureOptions<MvcOptions>, MyDisplayMetadataProviderSetup>());
builder.Services.AddControllersWithViews();
var app = builder.Build();
Philip Stratford
  • 4,513
  • 4
  • 45
  • 71
1

You might want to follow the pattern used for build-in metadata providers via IConfigure<MvcOptions> (for example as done for DataAnnotationsMetadataProvider via MvcDataAnnotationsMvcOptionsSetup):

public class MyDisplayMetadataProvider : IDisplayMetadataProvider
{
    private readonly IMemoryCache _memoryCache;

    public MyDisplayMetadataProvider(IMemoryCache memoryCache)
    {
        _memoryCache = memoryCache;
    }

    public void CreateDisplayMetadata(DisplayMetadataProviderContext context)
    {
          // ...
    }
}

class MyDisplayMetadataProviderSetup : IConfigureOptions<MvcOptions>
{
    private readonly IMemoryCache _memoryCache;

    public MyDisplayMetadataProviderSetup(IMemoryCache memoryCache)
    {
        _memoryCache = memoryCache;
    }
    public void Configure(MvcOptions options)
    {
        options.ModelMetadataDetailsProviders.Add(new  MyDisplayMetadataProvider(_memoryCache));
    }
}

And registration looking like:

services.TryAddEnumerable(
                ServiceDescriptor.Transient<IConfigureOptions<MvcOptions>, MyDisplayMetadataProviderSetup>());

Note that for database context possibly you will need to inject IServiceScopeFactory and create scope to resolve the context from it on each CreateDisplayMetadata invocation (for example similar to what was done here).

Guru Stron
  • 102,774
  • 10
  • 95
  • 132
  • Thanks very much for the suggestion and the helpful links. I've tried to implement your suggestion and I think I've nearly got it working, but I'm still having trouble with injecting the database context. I've updated my question with information on what I've tried. in case you have time to offer any more help! – Philip Stratford Mar 14 '23 at 10:40
  • 1
    @PhilipStratford the last note specifically targets context injection - you should not inject it, but create a scope and resolve on each invocation of `CreateDisplayMetadata`, the linked answer shows how scope can be created and how context can be resolved from it, you need to do similar in `CreateDisplayMetadata` – Guru Stron Mar 14 '23 at 11:12