/* * Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Extensions.Loading * File: RoutingExtensions.cs * * RoutingExtensions.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger * VNLib collection of libraries and utilities. * * VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ using System; using System.Linq; using System.Net; using System.Numerics; using System.Diagnostics; using System.Reflection; using System.Threading.Tasks; using System.Collections.Generic; using System.Runtime.CompilerServices; using VNLib.Net.Http; using VNLib.Utils; using VNLib.Utils.Logging; using VNLib.Plugins.Essentials; using VNLib.Plugins.Essentials.Accounts; using VNLib.Plugins.Essentials.Endpoints; using VNLib.Plugins.Essentials.Sessions; namespace VNLib.Plugins.Extensions.Loading.Routing.Mvc { /// /// Provides extension and helper classes for routing using MVC architecture /// public static class MvcExtensions { /// /// Routes all endpoints for the specified controller /// /// /// The controller instance to route endpoints for /// /// public static T Route(this PluginBase plugin, T? controller) where T : IHttpController { //If a null controller is passed (normal case) then create a new instance controller ??= plugin.CreateService(); IEndpoint[] staticEndpoints = GetStaticEndpointsForController(plugin, controller); Array.ForEach(staticEndpoints, plugin.Route); return controller; } /// /// Routes all endpoints for the specified controller /// /// /// /// public static T Route(this PluginBase plugin) where T : IHttpController => plugin.Route(default(T)); private static IEndpoint[] GetStaticEndpointsForController(PluginBase plugin, T controller) where T : IHttpController { IConfigScope? config = plugin.TryGetConfigForType(); ILogProvider logger = RoutingExtensions.ConfigureLogger(plugin, config); StaticRouteHandler[] staticRoutes = GetStaticRoutes(controller, config); if(plugin.IsDebug()) { (string, string, string)[] eps = staticRoutes .Select(static p => (p.Path, p.Route.Method.ToString(), p.WorkFunc.GetMethodInfo().Name)) .ToArray(); plugin.Log.Verbose("Routing static endpoints: {eps}", eps); } return BuildStaticRoutes(controller, logger, staticRoutes); } private static StaticRouteHandler[] GetStaticRoutes(T controller, IConfigScope? config) where T : IHttpController { List routes = []; foreach (MethodInfo method in typeof(T).GetMethods()) { HttpStaticRouteAttribute? route = method.GetCustomAttribute(); HttpRouteProtectionAttribute? protection = method.GetCustomAttribute(); if (route is null) { continue; } routes.Add(new StaticRouteHandler { Parent = controller, Route = route, Protection = HttpProtectionHandler.Create(protection), Path = RoutingExtensions.SubsituteConfigStringValue(route.Path, config), //Path may have config variables to substitute WorkFunc = CreateWorkFunc(controller, method) //Extract the processor delegate from the method }); } return [.. routes]; static EndpointWorkFunc CreateWorkFunc(T controller, MethodInfo method) { //Create the delegate for the method EndpointWorkFunc? del = method.CreateDelegate(controller); return del ?? throw new InvalidOperationException($"Failed to create delegate for method {method.Name}"); } } private static StaticEndpoint[] BuildStaticRoutes(IHttpController parent, ILogProvider logger, StaticRouteHandler[] routes) { //Group routes with the same path together IEnumerable groups = routes .GroupBy(static p => p.Path) .Select(static p => new RoutesWithSamePathGroup(p.Key, [.. p])); //Get endpoints for all groups that share the same endpoint path return groups .Select(i => new StaticEndpoint(i.Routes, logger, parent, i.Path)) .ToArray(); } /* * A static endpoint maps functions from within http controllres labeled * with the HttpStaticRouteAttribute to the IEndpoint interface that vnlib * needs to process virtual connections. * * This is an abstraction for architecture mapping. This endpoint will serve * a single path, but can server mutliple http methods. */ private sealed class StaticEndpoint(IHttpController parent) : ResourceEndpointBase { /* * This array holds all the processor functions for each http method. * * The array size is fixed for performance reasons, and for future compatibility * between the http library and this one. 32 positions shouldn't be that * much memory to worry about as the handlers are reference types. */ private readonly StaticRouteProcessor[] _processorFunctions = new StaticRouteProcessor[32]; //Cache local copy incase the parent call creates too much overhead private readonly ProtectionSettings _protection = parent.GetProtectionSettings(); /// /// /// protected override ProtectionSettings EndpointProtectionSettings => _protection; internal StaticEndpoint( StaticRouteHandler[] routes, ILogProvider logger, IHttpController parent, string staticRoutePath ) : this(parent) { //Ensure all routes have the same path, this is a developer error foreach (StaticRouteHandler route in routes) { Debug.Assert(string.Equals(route.Path, staticRoutePath, StringComparison.OrdinalIgnoreCase)); } InitPathAndLog(staticRoutePath, logger); InitProcessors(routes, _processorFunctions); } /// protected override ERRNO PreProccess(HttpEntity entity) { return base.PreProccess(entity) && parent.PreProccess(entity); } /// protected override ValueTask OnProcessAsync(HttpEntity entity) { StaticRouteProcessor handler = _processorFunctions[GetArrayOffsetForMethod(entity.Server.Method)]; if (!handler.Protection.CheckProtection(entity)) { //Allow the protection handler to define a custom response code entity.CloseResponse(handler.Protection.ErrorCode); return new(VfReturnType.VirtualSkip); } return handler.WorkFunction(entity); } /* * This function will get an array offset that corresponds * to the bit position of the calling method. This is used to * get the processing function for the desired http method. */ private static int GetArrayOffsetForMethod(HttpMethod method) { return BitOperations.TrailingZeroCount((long)method); } private static void InitProcessors(StaticRouteHandler[] routes, StaticRouteProcessor[] processors) { //Assign the default handler to all positions during initialization Array.Fill(processors, StaticRouteProcessor.DefaultProcessor); //Then assign each route to the correct position based on the method foreach (StaticRouteHandler route in routes) { int offset = GetArrayOffsetForMethod(route.Route.Method); processors[offset] = StaticRouteProcessor.FromRoute(route); } } private sealed class StaticRouteProcessor( EndpointWorkFunc workFunc, HttpProtectionHandler protection ) { public readonly EndpointWorkFunc WorkFunction = workFunc; public readonly HttpProtectionHandler Protection = protection; /// /// Gets the default (not found) processor for static routes /// internal static readonly StaticRouteProcessor DefaultProcessor = new( DefaultHandler, HttpProtectionHandler.Create(null) ); internal static StaticRouteProcessor FromRoute(StaticRouteHandler handler) => new(handler.WorkFunc, handler.Protection); /* * This function acts as the default handler in case a route or * http method is not defined */ private static ValueTask DefaultHandler(HttpEntity _) => new(VfReturnType.NotFound); } } private delegate ValueTask EndpointWorkFunc(HttpEntity entity); private sealed class HttpProtectionHandler { private static readonly HttpProtectionHandler _default = new(); public readonly HttpStatusCode ErrorCode; private readonly bool _enabled; private readonly bool _allowNewSessions; private readonly SessionType _sesType; private readonly AuthorzationCheckLevel _authLevel; public HttpProtectionHandler(HttpRouteProtectionAttribute protectionSettings) { _enabled = true; _allowNewSessions = protectionSettings.AllowNewSession; _sesType = protectionSettings.SessionType; _authLevel = protectionSettings.AuthLevel; ErrorCode = protectionSettings.ErrorCode; } private HttpProtectionHandler() { } [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool CheckProtection(HttpEntity entity) { //If protection is disabled, always return true if (!_enabled) { return true; } return entity.Session.IsSet && _allowNewSessions || !entity.Session.IsNew //May require reused sessions && entity.Session.SessionType == _sesType && entity.IsClientAuthorized(_authLevel); } public static HttpProtectionHandler Create(HttpRouteProtectionAttribute? attr) { return attr is null ? _default : new(attr); } } private sealed class StaticRouteHandler { public required IHttpController Parent; public required string Path; public required HttpStaticRouteAttribute Route; public required EndpointWorkFunc WorkFunc; public required HttpProtectionHandler Protection; } private record RoutesWithSamePathGroup(string Path, StaticRouteHandler[] Routes); } }