Search code examples
c#asp.net-coreasp.net-core-5.0html-sanitizing

AddScoped: How to call the right constructor function?


I am looking for the correct C# code to inject this service in ASP.NET Core 5 MVC in a way the defaults for the class apply.

If I add the scoped service below, the instance field values are empty. If I do var a = new HtmlSanitizer();, instance fields are populated with non-null defaults like "a long string of values".

services.AddScoped<IHtmlSanitizer, HtmlSanitizer>();

If I rewrite the injection as below, instance fields are populated. Does this code match the intended effect as above? Of course, why the difference in the resulting object?

services.AddScoped<IHtmlSanitizer, HtmlSanitizer>(
    _ => { return new HtmlSanitizer(); }
    // Why the difference?
    // Is this how one passes constant parameter values?
);

I used HtmlSanitizer and ASP.NET Core 5 in this example but I doubt it matters.


Solution

  • This actually has less to do with HtmlSanitizer specifically and more to do with how .NET Core Constructor Dependency Injection works.

    Per the documentation:

    Services can be resolved by using:

    • IServiceProvider
    • ActivatorUtilities:
      • Creates objects that aren't registered in the container.
      • Used with some framework features.

    Constructors can accept arguments that aren't provided by dependency injection, but the arguments must assign default values.

    When services are resolved by IServiceProvider or ActivatorUtilities, constructor injection requires a public constructor.

    When services are resolved by ActivatorUtilities, constructor injection requires that only one applicable constructor exists. Constructor overloads are supported, but only one overload can exist whose arguments can all be fulfilled by dependency injection.

    In this context, you are using the IServiceProvider and the framework can "visit" arguments that are specifically of type IEnumerable<T>, which is what is required by the HtmlSanitizer constructor:

    public HtmlSanitizer(IEnumerable<string>? allowedTags = null, IEnumerable<string>? allowedSchemes = null,
        IEnumerable<string>? allowedAttributes = null, IEnumerable<string>? uriAttributes = null, IEnumerable<string>? allowedCssProperties = null)
    {
        AllowedTags = new HashSet<string>(allowedTags ?? DefaultAllowedTags, StringComparer.OrdinalIgnoreCase);
        AllowedSchemes = new HashSet<string>(allowedSchemes ?? DefaultAllowedSchemes, StringComparer.OrdinalIgnoreCase);
        AllowedAttributes = new HashSet<string>(allowedAttributes ?? DefaultAllowedAttributes, StringComparer.OrdinalIgnoreCase);
        UriAttributes = new HashSet<string>(uriAttributes ?? DefaultUriAttributes, StringComparer.OrdinalIgnoreCase);
        AllowedCssProperties = new HashSet<string>(allowedCssProperties ?? DefaultAllowedCssProperties, StringComparer.OrdinalIgnoreCase);
        AllowedAtRules = new HashSet<CssRuleType>(DefaultAllowedAtRules);
        AllowedClasses = new HashSet<string>(DefaultAllowedClasses, StringComparer.OrdinalIgnoreCase);
    }
    

    When the service resolver sees a constructor with arguments, it will attempt to visit each argument. In the case of IEnumerable<T>, the arguments are specifically handled and default arrays will be created per the source:

    protected override object VisitIEnumerable(IEnumerableCallSite enumerableCallSite, RuntimeResolverContext context)
    {
        var array = Array.CreateInstance(
            enumerableCallSite.ItemType,
            enumerableCallSite.ServiceCallSites.Length);
    
        for (int index = 0; index < enumerableCallSite.ServiceCallSites.Length; index++)
        {
            object value = VisitCallSite(enumerableCallSite.ServiceCallSites[index], context);
            array.SetValue(value, index);
        }
        return array;
    }
    

    You can prove this out with a very simple test harness:

    public class Test : ITest
    {
        private ISet<string> _defaults = new HashSet<string> { "one", "two", "three" };
        private ISet<string> _filters;
    
        public Test(List<string> filters = null)
        {
            _filters = new HashSet<string>(filters.ToHashSet() ?? _defaults);
        }
    }
    
    public interface ITest { }
    

    In this case, the parameter filters will be null and the defaults will be used instead when resolving provider.GetService(typeof(ITest));. However, if I require an IEnumerable instead:

    public class Test : ITest
    {
        private ISet<string> _defaults = new HashSet<string> { "one", "two", "three" };
        private ISet<string> _filters;
    
        public Test(IEnumerable<string> filters = null)
        {
            _filters = new HashSet<string>(filters.ToHashSet() ?? _defaults);
        }
    }
    
    public interface ITest { }
    

    you will find that a default array is passed, causing the default filters to NOT be used.

    By using the factory instantiation where you return new HtmlSanitizer(), you bypass this implementation behavior and pass null for each parameter, allowing the defaults to be used.

    This is very surprising behavior and I was unable to find any documentation that describes this as expected behavior. I believe this may simply be an oversight by the .NET Core DI team since typically dependencies are intended to be non-IEnumerable types. It is also worth noting that this behavior does NOT apply to parameters of IList<T> or ISet<T> type.