Routing

Summary

Current routing for the original WebApi contains different conventions that follow unique patterns that are registered from the container and we utilize an endpoint pattern. However, this is complicated and hard to maintain. Considering we are using a pattern based route, for example (http://localhost/data/v3/ed-fi/schools), we can utilize the ASP.NET Core hooks and attribute routing pattern (c.f. Routing in ASP.NET Core). 

Challenges

The primary challenge we need to sort out is how to apply our custom registration links for our controllers. With the current implementation we also utilize a greedy route to encompass other routes (e.g. composites or metadata). We also have to address when we are using school year specific pattern.

Solution

It is suggested that with net core we favor attribute routing as this is built into the scanning of assemblies. We can hook into this process utilizing Web API Conventions. These conventions a can be registered into the container which then will get applied on startup once. The benefit of this solution, is that we can put a route on all controllers, and eliminate a default (greedy) route. However, there is one drawback, that when on startup, all controllers are loaded into the container utilizing this method

            // 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();

What happens, is that net core scans the application domain, and registers the routes, and controller parameters into the the container. By default ASP.NET Core does not resolve the controller from the container, that is why we add the extension method .AddControllersAsServices() to the service collection definition (c.f. Controllers as Services). Once we have the controllers defined into the container, we then can apply our routing convention on any controller we would like.

Looking at our routes, we have a well defined pattern for the routes. The class RouteContants contains these patterns.

namespace EdFi.Ods.Api.Common.Constants
{
    public static class RouteConstants
    {
        public static string DataManagementRoutePrefix
        {
            get => $"data/v{ApiVersionConstants.Ods}";
        }

        public static string Dependencies
        {
            get => "AggregateDependencies";
        }

        public static string SchoolYearFromRoute
        {
            get => @"{schoolYearFromRoute:regex(^\d{{4}}$)}/";
        }
    }
}

ASP.NET Core has the concept of conventions and we can apply them on Startup using an implementation of IConfigureOptions<MvcOptions>. This allows us to inject and apply our routing rules. The implementation is as follows:

using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;

namespace EdFi.Ods.Api.NetCore.Configuration
{
    public class MvcOptionsConfigurator : IConfigureOptions<MvcOptions>
    {
        private readonly IEnumerable<IApplicationModelConvention> _applicationModelConventions;
        private readonly IEnumerable<IFilterMetadata> _filters;

        public MvcOptionsConfigurator(IEnumerable<IApplicationModelConvention> applicationModelConventions, IEnumerable<IFilterMetadata> filters)
        {
            _applicationModelConventions = applicationModelConventions;
            _filters = filters;
        }

        public void Configure(MvcOptions options)
        {
            foreach (IApplicationModelConvention applicationModelConvention in _applicationModelConventions)
            {
                options.Conventions.Add(applicationModelConvention);
            }

            foreach (var actionFilterAttribute in _filters)
            {
                options.Filters.Add(actionFilterAttribute);
            }
        }
    }
}

We then create a implementation of IApplicationModelConvention, and register that implementation into the container. For example for the main controllers (e.g. ed-fi/Schools) the implementation is as follows:

using EdFi.Ods.Api.Common.Configuration;
using EdFi.Ods.Api.Common.Constants;
using EdFi.Ods.Api.NetCore.Extensions;
using EdFi.Ods.Common.Configuration;
using Microsoft.AspNetCore.Mvc.ApplicationModels;

namespace EdFi.Ods.Api.NetCore.Conventions
{
    public class DataManagementControllerRouteConvention : IApplicationModelConvention
    {
        private readonly ApiSettings _apiSettings;

        public DataManagementControllerRouteConvention(ApiSettings apiSettings)
        {
            _apiSettings = apiSettings;
        }

        public void Apply(ApplicationModel application)
        {
            var routePrefix = new AttributeRouteModel {Template = CreateRouteTemplate()};

            foreach (ControllerModel controller in application.Controllers)
            {
                // apply only to data management controllers
                if (!controller.IsDataManagementController())
                {
                    continue;
                }

                foreach (var selector in controller.Selectors)
                {
                    if (selector.AttributeRouteModel != null)
                    {
                        selector.AttributeRouteModel = AttributeRouteModel.CombineAttributeRouteModel(
                            routePrefix,
                            selector.AttributeRouteModel);
                    }
                }
            }

            string CreateRouteTemplate()
            {
                string template = $"{RouteConstants.DataManagementRoutePrefix}/";

                if (_apiSettings.GetApiMode() == ApiMode.YearSpecific)
                {
                    template += RouteConstants.SchoolYearFromRoute;
                }

                return template;
            }
        }
    }
}

and we register it into the container as follows:

using Autofac;
using EdFi.Ods.Api.NetCore.Configuration;
using EdFi.Ods.Api.NetCore.Conventions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.Extensions.Options;

namespace EdFi.Ods.Api.NetCore.Container.Modules
{
    public class ApplicationModelConventionModule : Module
    {
        protected override void Load(ContainerBuilder builder)
        {
            // api model conventions should be singletons
            builder.RegisterType<MvcOptionsConfigurator>().As<IConfigureOptions<MvcOptions>>().SingleInstance();
            builder.RegisterType<DataManagementControllerRouteConvention>().As<IApplicationModelConvention>().SingleInstance();
        }
    }
}

For OpenApiMetadata routing, the interface of IOpenApiMetadataRouteInformation has been added so that the custom metadata information can be captured, and applied into the OpenApiMetadataCache.

#if NETCOREAPP
using EdFi.Ods.Api.Common.Dtos;

namespace EdFi.Ods.Api.NetCore.Routing
{
    public interface IOpenApiMetadataRouteInformation
    {
        RouteInformation GetRouteInformation();
    }
}
#endif

A sample implementation of this interface is:

#if NETCOREAPP
using EdFi.Ods.Api.Common.Configuration;
using EdFi.Ods.Api.Common.Constants;
using EdFi.Ods.Api.Common.Dtos;
using EdFi.Ods.Api.NetCore.Routing;
using EdFi.Ods.Common.Configuration;
using EdFi.Ods.Common.Extensions;

namespace EdFi.Ods.Features.RouteInformations {
    public abstract class OpenApiMetadataRouteInformationBase : IOpenApiMetadataRouteInformation
    {
        private readonly ApiSettings _apiSettings;
        private readonly string _routeName;
        private readonly string _template;

        public OpenApiMetadataRouteInformationBase(ApiSettings apiSettings, string routeName, string template)
        {
            _apiSettings = apiSettings;
            _routeName = routeName;
            _template = template;
        }

        public RouteInformation GetRouteInformation()
            => new RouteInformation
            {
                Name = _routeName,
                Template = $"{CreateRoute()}/swagger.json"
            };

        string CreateRoute()
        {
            string prefix = $"metadata/{RouteConstants.DataManagementRoutePrefix}/";

            if (_apiSettings.GetApiMode() == ApiMode.YearSpecific)
            {
                prefix += RouteConstants.SchoolYearFromRoute;
            }

            if (!string.IsNullOrEmpty(_template))
            {
                prefix += _template;
            }

            return prefix.TrimSuffix("/");
        }
    }
}
#endif
#if NETCOREAPP
using EdFi.Ods.Api.Common.Configuration;
using EdFi.Ods.Api.Common.Constants;

namespace EdFi.Ods.Features.RouteInformations {
    public class SchemaOpenApiMetadataRouteInformation : OpenApiMetadataRouteInformationBase
    {
        public SchemaOpenApiMetadataRouteInformation(ApiSettings apiSettings)
            : base(apiSettings, MetadataRouteConstants.Schema, "{document}") { }
    }
}
#endif


Another example is for OpenApiMetadata the convention is implemented as:

#if NETCOREAPP
using System.Linq;
using System.Reflection;
using EdFi.Ods.Api.Common.Configuration;
using EdFi.Ods.Api.Common.Constants;
using EdFi.Ods.Common.Configuration;
using EdFi.Ods.Features.Controllers;
using Microsoft.AspNetCore.Mvc.ApplicationModels;

namespace EdFi.Ods.Features.Conventions
{
    public class OpenApiMetadataRouteConvention : IApplicationModelConvention
    {
        private readonly ApiSettings _apiSettings;

        public OpenApiMetadataRouteConvention(ApiSettings apiSettings)
        {
            _apiSettings = apiSettings;
        }

        public void Apply(ApplicationModel application)
        {
            var controller =
                application.Controllers.FirstOrDefault(x => x.ControllerType == typeof(OpenApiMetadataController).GetTypeInfo());

            if (controller != null)
            {
                var routeSuffix = new AttributeRouteModel {Template = CreateRouteTemplate()};

                foreach (var selector in controller.Selectors)
                {
                    if (selector.AttributeRouteModel != null)
                    {
                        selector.AttributeRouteModel = AttributeRouteModel.CombineAttributeRouteModel(
                            selector.AttributeRouteModel,
                            routeSuffix);
                    }
                }
            }

            string CreateRouteTemplate()
            {
                string template = $"{RouteConstants.DataManagementRoutePrefix}/";

                if (_apiSettings.GetApiMode() == ApiMode.YearSpecific)
                {
                    template += RouteConstants.SchoolYearFromRoute;
                }

                return template;
            }
        }
    }
}
#endif

and it is registered as:

#if NETCOREAPP
using Autofac;
using EdFi.Ods.Api.Common.Configuration;
using EdFi.Ods.Api.Common.Constants;
using EdFi.Ods.Api.Common.Container;
using EdFi.Ods.Api.Common.ExternalTasks;
using EdFi.Ods.Api.Common.Providers;
using EdFi.Ods.Api.NetCore.Providers;
using EdFi.Ods.Api.NetCore.Routing;
using EdFi.Ods.Features.Middleware;
using EdFi.Ods.Features.OpenApiMetadata;
using EdFi.Ods.Features.OpenApiMetadata.Providers;
using EdFi.Ods.Features.RouteInformations;
using Microsoft.AspNetCore.Http;

namespace EdFi.Ods.Features.Container.Modules
{
    public class EnabledOpenApiMetadataModule : ConditionalModule
    {
        public EnabledOpenApiMetadataModule(ApiSettings apiSettings)
            : base(apiSettings, nameof(EnabledOpenApiMetadataModule)) { }

        public override bool IsSelected() => IsFeatureEnabled(ApiFeature.OpenApiMetadata);

        public override void ApplyConfigurationSpecificRegistrations(ContainerBuilder builder)
        {
            builder.RegisterType<OpenApiMetadataCacheProvider>()
                .As<IOpenApiMetadataCacheProvider>()
                .SingleInstance();

            builder.RegisterType<InitializeOpenApiMetadataCache>()
                .As<IExternalTask>()
                .SingleInstance();

            builder.RegisterType<AllOpenApiMetadataRouteInformation>()
                .As<IOpenApiMetadataRouteInformation>()
                .SingleInstance();

            builder.RegisterType<ResourceTypeOpenMetadataRouteInformation>()
                .As<IOpenApiMetadataRouteInformation>()
                .SingleInstance();

            // this is required to for ed-fi default
            builder.RegisterType<SchemaOpenApiMetadataRouteInformation>()
                .As<IOpenApiMetadataRouteInformation>()
                .SingleInstance();

            builder.RegisterType<EdFiOpenApiContentProvider>()
                .As<IOpenApiContentProvider>();

            builder.RegisterType<EnabledOpenApiMetadataDocumentProvider>()
                .As<IOpenApiMetadataDocumentProvider>();

            builder.RegisterType<OpenApiMetadataMiddleware>()
                .As<IMiddleware>()
                .AsSelf();
        }
    }
}
#endif