EdFi.Ods.WebApi.Netcore

Summary

This is a new assembly that was created to be the entry point for the the web api using netcore. The application is built on Kestrel. This allows us to run the application from the command line, or from IIS. The application is configured to run asynchronously upon startup.

Challenges

  • Net core by default lazy loads the assemblies. This causes an issue when we want to load all container modules, and for the scanning of controllers. To overcome this issue, we force load all assemblies in the executing folder into the AppDomain. Then we can dynamically load the contianer modules, and install them into the container.
  • Microsoft no longer populates the ClaimsPrincipal.Current. Please refer to Migrate from ClaimsPrincipal.Current for further details.

ApiSettings

ApiSettings is a dto that maps to the appsettings.json ApiSettings config section. It is registered into the container.

{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft": "Warning"
    }
  },
  "ConnectionStrings": {
    "EdFi_Ods_Empty": "Database=EdFi_Ods_Empty_Template; Data Source=(local); Trusted_Connection=True",
    "EdFi_Admin": "Server=(local); Database=EdFi_Admin; Trusted_Connection=True; Application Name=EdFi.Ods.WebApi;",
    "EdFi_Security": "Server=(local); Database=EdFi_Security; Trusted_Connection=True; Persist Security Info=True; Application Name=EdFi.Ods.WebApi;",
    "EdFi_Ods": "Server=(local); Database=EdFi_{0}; Trusted_Connection=True; Application Name=EdFi.Ods.WebApi;"
  },
  "ApiSettings": {
    "Mode": "sharedinstance",
    "Engine": "System.Data.SqlClient",
    "EncryptSecrets": false,
    "DisableSecurity": false,
    "Years": [],
    "Features": [
      {
        "Name": "Extensions",
        "IsEnabled": true
      },
      {
        "Name": "Profiles",
        "IsEnabled": false
      },
      {
        "Name": "ChangeQueries",
        "IsEnabled": false
      },
      {
        "Name": "OpenApiMetadata",
        "IsEnabled": false
      },
      {
        "Name": "Composites",
        "IsEnabled": false
      },
      {
        "Name": "IdentityManagement",
        "IsEnabled": false
      },
      {
        "Name": "OwnershipBasedAuthorization",
        "IsEnabled": false
      },
      {
        "Name": "UniqueIdValidation",
        "IsEnabled": false
      }
    ],
    "ExcludedExtensions": [
    ]
  }
}

Program.cs

Program.cs is the main entry point into the application, and its responsibility is to configure logging and to start the host.

using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using Autofac.Extensions.DependencyInjection;
using log4net;
using log4net.Config;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;

namespace EdFi.Ods.WebApi.NetCore
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            ConfigureLogging();
            var host = Host.CreateDefaultBuilder(args)
                .UseServiceProviderFactory(new AutofacServiceProviderFactory())
                .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); }).Build();

            await host.RunAsync();

            static void ConfigureLogging()
            {
                var assembly = typeof(Program).GetTypeInfo().Assembly;

                string configPath = Path.Combine(Path.GetDirectoryName(assembly.Location), "log4net.config");

                XmlConfigurator.Configure(LogManager.GetRepository(assembly), new FileInfo(configPath));
            }
        }
    }
}

Startup.cs

This class is the main configuration class for the application. Container registration has been abstracted to Autofac Modules that are configuration specific. See IOC Container Decision and Changes for the reasoning for switching to Autofac. A nice feature of the Autofac container, it blends into the services collection nicely, and we can register everything into this container and the application will use the dependencies accordingly. For example, we register the EdFiOAuthenticationHandler only in the Autofac container and it is still injected when the authentication is configured. 

The Microsoft net core has structured the startup class to be flexible. What is very nice they have put in a hook so that we can configure the Autofac container with the ConfigureContainer method. The Configure method is the application pipeline and these steps are run in order. 

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Security.Claims;
using Autofac;
using Autofac.Core;
using Autofac.Extensions.DependencyInjection;
using EdFi.Ods.Api.Common.Caching;
using EdFi.Ods.Api.Common.Configuration;
using EdFi.Ods.Api.Common.Container;
using EdFi.Ods.Api.Common.Dependencies;
using EdFi.Ods.Api.Common.Infrastructure.Extensibility;
using EdFi.Ods.Api.NetCore.InversionOfControl;
using EdFi.Ods.Api.NetCore.MediaTypeFormatters;
using EdFi.Ods.Api.NetCore.Middleware;
using EdFi.Ods.Common.Caching;
using EdFi.Ods.Common.InversionOfControl;
using EdFi.Ods.Common.Models;
using EdFi.Ods.Common.Models.Resource;
using EdFi.Ods.Common.Security.Claims;
using log4net;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;

namespace EdFi.Ods.WebApi.NetCore
{
    public class Startup
    {
        private readonly ILog _logger = LogManager.GetLogger(typeof(Startup));

        public ApiSettings ApiSettings { get; }

        public IConfigurationRoot Configuration { get; }

        public ILifetimeScope Container { get; private set; }

        
        public Startup(IWebHostEnvironment env)
        {
            _logger.Debug("Loading configuration files");

            var builder = new ConfigurationBuilder()
                .SetBasePath(env.ContentRootPath)
                .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
                .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
                .AddEnvironmentVariables();

            Configuration = builder.Build();

            ApiSettings = new ApiSettings();

            Configuration.Bind("ApiSettings", ApiSettings);

            _logger.Debug($"built configuration = {Configuration}");
        }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            _logger.Debug("Building services collection");

            services.AddSingleton(ApiSettings);
            services.AddSingleton(Configuration);

            LoadPlugins();
            LoadAssembliesFromExecutingFolder();

            // this allows the solution to resolve the claims principal. this is not best practice defined by the 
            // netcore team, as the claims principal is on the controllers.
            // c.f. https://docs.microsoft.com/en-us/aspnet/core/migration/claimsprincipal-current?view=aspnetcore-3.1
            services.AddHttpContextAccessor();

            // will apply the MvcConfigurator at runtime.
            services.AddControllers(options => options.OutputFormatters.Add(new GraphMLMediaTypeOutputFormatter()))
                .AddNewtonsoftJson(
                    options =>
                    {
                        options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
                        options.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Utc;
                        options.SerializerSettings.DateParseHandling = DateParseHandling.None;
                        options.SerializerSettings.Formatting = Formatting.Indented;
                        options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
                    })
                .AddControllersAsServices();

            services.AddAuthentication(EdFiAuthenticationTypes.OAuth)
                .AddScheme<AuthenticationSchemeOptions, EdFiOAuthAuthenticationHandler>(EdFiAuthenticationTypes.OAuth, null);
        }

        // ConfigureContainer is where you can register things directly
        // with Autofac. This runs after ConfigureServices so the things
        // here will override registrations made in ConfigureServices.
        // Don't build the container; that gets done for you by the factory.
        public void ConfigureContainer(ContainerBuilder builder)
        {
            _logger.Debug("Building Autofac container");

            // For pipelines we need a service locator. Note this is an anti-pattern
            builder.Register(c => new AutofacServiceLocator(new Lazy<ILifetimeScope>(() => Container)))
                .As<IServiceLocator>()
                .SingleInstance();

            RegisterModulesDynamically();

            _logger.Debug("Container loaded.");

            void RegisterModulesDynamically()
            {
                var assemblies = AppDomain.CurrentDomain.GetAssemblies()
                    .Where(
                        a => a.GetTypes()
                            .Any(
                                t => t.GetInterfaces()
                                    .Contains(typeof(IModule))))
                    .ToList();

                _logger.Debug("Assemblies with modules:");
                assemblies.ForEach(a => _logger.Debug($"{a.GetName().Name}"));

                var types = assemblies
                    .SelectMany(
                        a => a.GetTypes()
                            .Where(
                                t => t.GetInterfaces()
                                         .Contains(typeof(IModule)) && !t.IsAbstract))
                    .ToList();

                _logger.Debug("Installing modules:");

                foreach (var type in types)
                {
                    _logger.Debug($"Module {type.Name}");

                    if (type.IsSubclassOf(typeof(ConditionalModule)))
                    {
                        builder.RegisterModule((IModule) Activator.CreateInstance(type, ApiSettings));
                    }
                    else
                    {
                        builder.RegisterModule((IModule) Activator.CreateInstance(type));
                    }
                }
            }
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory)
        {
            loggerFactory.AddLog4Net();

            Container = app.ApplicationServices.GetAutofacRoot();

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseHttpsRedirection();
            app.UseRouting();
            app.UseStaticFiles();
            app.UseAuthentication();
            app.UseAuthorization();
            app.UseEndpoints(endpoints => { endpoints.MapControllers(); });

            SetStaticResolvers();

            void SetStaticResolvers()
            {
                // Make this dependency available to generated artifacts
                GeneratedArtifactStaticDependencies.Resolvers.Set(() => Container.Resolve<IResourceModelProvider>());
                GeneratedArtifactStaticDependencies.Resolvers.Set(() => Container.Resolve<IAuthorizationContextProvider>());

                // netcore has removed the claims principal from the thread, to be on the controller.
                // as a workaround for our current application we can resolve the IHttpContextAccessor.
                // c.f. https://docs.microsoft.com/en-us/aspnet/core/migration/claimsprincipal-current?view=aspnetcore-3.1
                ClaimsPrincipal.ClaimsPrincipalSelector = () => Container.Resolve<IHttpContextAccessor>()
                    .HttpContext?.User;

                // Provide cache using a closure rather than repeated invocations to the container
                IPersonUniqueIdToUsiCache personUniqueIdToUsiCache = null;

                PersonUniqueIdToUsiCache.GetCache = ()
                    => personUniqueIdToUsiCache ??= Container.Resolve<IPersonUniqueIdToUsiCache>();

                // Provide cache using a closure rather than repeated invocations to the container
                IDescriptorsCache cache = null;
                DescriptorsCache.GetCache = () => cache ??= Container.Resolve<IDescriptorsCache>();

                ResourceModelHelper.ResourceModel =
                    new Lazy<ResourceModel>(
                        () => Container.Resolve<IResourceModelProvider>()
                            .GetResourceModel());

                EntityExtensionsFactory.Instance = Container.Resolve<IEntityExtensionsFactory>();
            }
        }

        private void LoadPlugins() => _logger.Debug($"TODO figure out plugins");

        private void LoadAssembliesFromExecutingFolder(bool includeFramework = false)
        {
            // Storage to ensure not loading the same assembly twice and optimize calls to GetAssemblies()
            IDictionary<string, bool> loaded = new ConcurrentDictionary<string, bool>();

            LoadAssembliesFromExecutingFolder();

            int alreadyLoaded = loaded.Keys.Count;

            var sw = new Stopwatch();
            _logger.Debug($"Already loaded assemblies:");

            foreach (var a in AppDomain.CurrentDomain.GetAssemblies()
                .Where(a => ShouldLoad(a.FullName)))
            {
                loaded.TryAdd(a.FullName, true);
                _logger.Debug($"{a.FullName}");
            }

            // Loop on loaded assemblies to load dependencies (it includes Startup assembly so should load all the dependency tree) 
            foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()
                .Where(a => IsNotNetFramework(a.FullName)))
            {
                LoadReferencedAssembly(assembly);
            }

            _logger.Debug(
                $"Assemblies loaded after scan ({loaded.Keys.Count - alreadyLoaded} assemblies in {sw.ElapsedMilliseconds} ms):");

            // Filter to avoid loading all the .net framework
            bool ShouldLoad(string assemblyName)
            {
                return (includeFramework || IsNotNetFramework(assemblyName))
                       && !loaded.ContainsKey(assemblyName);
            }

            bool IsNotNetFramework(string assemblyName)
            {
                return !assemblyName.StartsWith("Microsoft.", StringComparison.CurrentCultureIgnoreCase)
                       && !assemblyName.StartsWith("System.", StringComparison.CurrentCultureIgnoreCase)
                       && !assemblyName.StartsWith("Newtonsoft.", StringComparison.CurrentCultureIgnoreCase)
                       && assemblyName != "netstandard"
                       && !assemblyName.StartsWith("Autofac", StringComparison.CurrentCultureIgnoreCase);
            }

            void LoadReferencedAssembly(Assembly assembly)
            {
                // Check all referenced assemblies of the specified assembly
                foreach (AssemblyName an in assembly.GetReferencedAssemblies()
                    .Where(a => ShouldLoad(a.FullName)))
                {
                    // Load the assembly and load its dependencies
                    LoadReferencedAssembly(Assembly.Load(an)); // AppDomain.CurrentDomain.Load(name)
                    loaded.TryAdd(an.FullName, true);
                    _logger.Debug($"Referenced assembly => {an.FullName}");
                }
            }

            void LoadAssembliesFromExecutingFolder()
            {
                // Load referenced assemblies into the domain. This is effectively the same as EnsureLoaded in common
                // however the assemblies are linked in the project.
                var directoryInfo = new DirectoryInfo(
                    Path.GetDirectoryName(
                        Assembly.GetExecutingAssembly()
                            .Location));

                _logger.Debug($"Loaded assemblies from executing folder:");

                foreach (FileInfo fileInfo in directoryInfo.GetFiles("*.dll")
                    .Where(fi => ShouldLoad(fi.Name)))
                {
                    _logger.Debug($"{fileInfo.Name}");
                    Assembly.LoadFrom(fileInfo.FullName);
                }
            }
        }
    }
}