From 69398e495be6a9b77c636fa5b61d7c76755001de Mon Sep 17 00:00:00 2001 From: Brett Samblanet Date: Wed, 14 Jun 2023 06:58:59 -0700 Subject: [PATCH] mapping endpoints on worker for ASP.NET proxy integration (#1596) --- .../AspNetMiddleware/FunctionHttpBinding.cs | 19 +++ .../FunctionsEndpointDataSource.cs | 137 ++++++++++++++++++ .../FunctionsHttpContextExtensions.cs | 20 +++ .../src/AspNetMiddleware/HostJsonModel.cs | 20 +++ .../InvokeFunctionMiddleware.cs | 9 +- ...s.cs => FunctionsHostBuilderExtensions.cs} | 17 ++- .../FunctionsEndpointDataSourceTests.cs | 136 +++++++++++++++++ 7 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/FunctionHttpBinding.cs create mode 100644 extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/FunctionsEndpointDataSource.cs create mode 100644 extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/FunctionsHttpContextExtensions.cs create mode 100644 extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/HostJsonModel.cs rename extensions/Worker.Extensions.Http.AspNetCore/src/{HostBuilderExtensions.cs => FunctionsHostBuilderExtensions.cs} (79%) create mode 100644 test/DotNetWorkerTests/AspNetCore/FunctionsEndpointDataSourceTests.cs diff --git a/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/FunctionHttpBinding.cs b/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/FunctionHttpBinding.cs new file mode 100644 index 000000000..78720e1b1 --- /dev/null +++ b/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/FunctionHttpBinding.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore +{ + /// + /// Represents an HttpTrigger binding. Internal class for deserializing raw binding info. + /// + internal class FunctionHttpBinding + { + public string Name { get; set; } = default!; + + public string Type { get; set; } = default!; + + public string Route { get; set; } = default!; + + public string[] Methods { get; set; } = default!; + } +} diff --git a/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/FunctionsEndpointDataSource.cs b/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/FunctionsEndpointDataSource.cs new file mode 100644 index 000000000..cd89450b6 --- /dev/null +++ b/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/FunctionsEndpointDataSource.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.AspNetMiddleware +{ + internal class FunctionsEndpointDataSource : EndpointDataSource + { + private const string FunctionsApplicationDirectoryKey = "FUNCTIONS_APPLICATION_DIRECTORY"; + private const string HostJsonFileName = "host.json"; + private const string DefaultRoutePrefix = "api"; + + private readonly IFunctionMetadataProvider _functionMetadataProvider; + private readonly object _lock = new(); + + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + + private List? _endpoints; + + public FunctionsEndpointDataSource(IFunctionMetadataProvider functionMetadataProvider) + { + _functionMetadataProvider = functionMetadataProvider ?? throw new ArgumentNullException(nameof(functionMetadataProvider)); + } + + public override IReadOnlyList Endpoints + { + get + { + if (_endpoints is null) + { + lock (_lock) + { + _endpoints ??= BuildEndpoints(); + } + } + + return _endpoints; + } + } + + private List BuildEndpoints() + { + List endpoints = new List(); + + string scriptRoot = Environment.GetEnvironmentVariable(FunctionsApplicationDirectoryKey) ?? + throw new InvalidOperationException("Cannot determine script root directory."); + + var metadata = _functionMetadataProvider.GetFunctionMetadataAsync(scriptRoot).GetAwaiter().GetResult(); + + string routePrefix = GetRoutePrefixFromHostJson(scriptRoot) ?? DefaultRoutePrefix; + + foreach (var functionMetadata in metadata) + { + var endpoint = MapHttpFunction(functionMetadata, routePrefix); + + if (endpoint is not null) + { + endpoints.Add(endpoint); + } + } + + return endpoints; + } + + public override IChangeToken GetChangeToken() => NullChangeToken.Singleton; + + internal static Endpoint? MapHttpFunction(IFunctionMetadata functionMetadata, string routePrefix) + { + if (functionMetadata.RawBindings is null) + { + return null; + } + + var functionName = functionMetadata.Name ?? string.Empty; + + int order = 0; + foreach (var binding in functionMetadata.RawBindings) + { + var functionBinding = JsonSerializer.Deserialize(binding, _jsonSerializerOptions); + + if (functionBinding is null) + { + continue; + } + + if (functionBinding.Type.Equals("httpTrigger", StringComparison.OrdinalIgnoreCase)) + { + string routeSuffix = functionBinding.Route ?? functionName; + string route = $"{routePrefix}/{routeSuffix}"; + + var pattern = RoutePatternFactory.Parse(route); + + var endpointBuilder = new RouteEndpointBuilder(FunctionsHttpContextExtensions.InvokeFunctionAsync, pattern, order++) + { + DisplayName = functionName + }; + endpointBuilder.Metadata.Add(new HttpMethodMetadata(functionBinding.Methods)); + + // no need to look at other bindings for this function + return endpointBuilder.Build(); + } + } + + return null; + } + + private static string? GetRoutePrefixFromHostJson(string scriptRoot) + { + string hostJsonPath = Path.Combine(scriptRoot, HostJsonFileName); + + if (!File.Exists(hostJsonPath)) + { + return null; + } + + string hostJsonString = File.ReadAllText(hostJsonPath); + return GetRoutePrefix(hostJsonString); + } + + internal static string? GetRoutePrefix(string hostJsonString) + { + var hostJson = JsonSerializer.Deserialize(hostJsonString, _jsonSerializerOptions); + return hostJson?.Extensions?.Http?.RoutePrefix; + } + } +} diff --git a/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/FunctionsHttpContextExtensions.cs b/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/FunctionsHttpContextExtensions.cs new file mode 100644 index 000000000..a1b0bd2a2 --- /dev/null +++ b/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/FunctionsHttpContextExtensions.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Http +{ + internal static class FunctionsHttpContextExtensions + { + internal static Task InvokeFunctionAsync(this HttpContext context) + { + var coordinator = context.RequestServices.GetRequiredService(); + context.Request.Headers.TryGetValue(Constants.CorrelationHeader, out StringValues invocationId); + return coordinator.RunFunctionInvocationAsync(invocationId); + } + } +} diff --git a/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/HostJsonModel.cs b/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/HostJsonModel.cs new file mode 100644 index 000000000..9d22d764e --- /dev/null +++ b/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/HostJsonModel.cs @@ -0,0 +1,20 @@ +namespace Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore +{ + /// + /// Represents host.json. Internal class for deserializing. + /// + internal class HostJsonModel + { + public HostJsonExtensionModel Extensions { get; set; } = default!; + } + + internal class HostJsonExtensionModel + { + public HostJsonExtensionHttpModel Http { get; set; } = default!; + } + + internal class HostJsonExtensionHttpModel + { + public string RoutePrefix { get; set; } = default!; + } +} diff --git a/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/InvokeFunctionMiddleware.cs b/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/InvokeFunctionMiddleware.cs index df5ef48eb..a34aff6de 100644 --- a/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/InvokeFunctionMiddleware.cs +++ b/extensions/Worker.Extensions.Http.AspNetCore/src/AspNetMiddleware/InvokeFunctionMiddleware.cs @@ -3,23 +3,18 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Primitives; namespace Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore { internal class InvokeFunctionMiddleware { - private readonly IHttpCoordinator _coordinator; - - public InvokeFunctionMiddleware(RequestDelegate next, IHttpCoordinator httpCoordinator) + public InvokeFunctionMiddleware(RequestDelegate next) { - _coordinator = httpCoordinator; } public Task Invoke(HttpContext context) { - context.Request.Headers.TryGetValue(Constants.CorrelationHeader, out StringValues invocationId); - return _coordinator.RunFunctionInvocationAsync(invocationId); + return context.InvokeFunctionAsync(); } } } diff --git a/extensions/Worker.Extensions.Http.AspNetCore/src/HostBuilderExtensions.cs b/extensions/Worker.Extensions.Http.AspNetCore/src/FunctionsHostBuilderExtensions.cs similarity index 79% rename from extensions/Worker.Extensions.Http.AspNetCore/src/HostBuilderExtensions.cs rename to extensions/Worker.Extensions.Http.AspNetCore/src/FunctionsHostBuilderExtensions.cs index fa8a43d5b..5df1b4fad 100644 --- a/extensions/Worker.Extensions.Http.AspNetCore/src/HostBuilderExtensions.cs +++ b/extensions/Worker.Extensions.Http.AspNetCore/src/FunctionsHostBuilderExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore; +using Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.AspNetMiddleware; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Extensions.Hosting @@ -13,7 +14,7 @@ namespace Microsoft.Extensions.Hosting /// /// Provides extension methods to work with a . /// - public static class HostBuilderExtensions + public static class FunctionsHostBuilderExtensions { /// /// Configures the worker to use the ASP.NET Core integration, enabling advanced HTTP features. @@ -46,14 +47,24 @@ public static IHostBuilder ConfigureFunctionsWebApplication(this IHostBuilder bu internal static IHostBuilder ConfigureAspNetCoreIntegration(this IHostBuilder builder) { + builder.ConfigureServices(services => + { + services.AddSingleton(); + }); + builder.ConfigureWebHostDefaults(webBuilder => { webBuilder.UseUrls(HttpUriProvider.HttpUriString); webBuilder.Configure(b => { + b.UseRouting(); b.UseMiddleware(); - // TODO: provide a way for customers to configure their middleware pipeline here - b.UseMiddleware(); + // TODO: provide a way for customers to configure their middleware pipeline here + b.UseEndpoints(endpoints => + { + var dataSource = endpoints.ServiceProvider.GetRequiredService(); + endpoints.DataSources.Add(dataSource); + }); }); }); diff --git a/test/DotNetWorkerTests/AspNetCore/FunctionsEndpointDataSourceTests.cs b/test/DotNetWorkerTests/AspNetCore/FunctionsEndpointDataSourceTests.cs new file mode 100644 index 000000000..d66f3ebf5 --- /dev/null +++ b/test/DotNetWorkerTests/AspNetCore/FunctionsEndpointDataSourceTests.cs @@ -0,0 +1,136 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Routing; +using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata; +using Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.AspNetMiddleware; +using Xunit; + +namespace Microsoft.Azure.Functions.Worker.Tests.AspNetCore +{ + public class FunctionsEndpointDataSourceTests + { + [Theory] + [InlineData("api")] + [InlineData("customRoutePrefix")] + public void MapHttpFunction(string routePrefix) + { + string rawBinding = """ + { + "name": "req", + "direction": "In", + "Type": "httpTrigger", + "authLevel": "Anonymous", + "methods": ["get", "post"], + "properties": { } + } + """; + + var metadata = new DefaultFunctionMetadata + { + Name = "TestFunction", + RawBindings = new List { rawBinding }, + }; + + RouteEndpoint endpoint = FunctionsEndpointDataSource.MapHttpFunction(metadata, routePrefix) as RouteEndpoint; + + Assert.Equal("TestFunction", endpoint.DisplayName); + Assert.Equal($"{routePrefix}/TestFunction", endpoint.RoutePattern.RawText); + var endpointMetadata = endpoint.Metadata.Single() as HttpMethodMetadata; + Assert.Equal(new[] { "GET", "POST" }, endpointMetadata.HttpMethods); + } + + [Fact] + public void MapHttpFunction_CustomRoute() + { + string rawBinding = """ + { + "name": "req", + "direction": "In", + "type": "httpTrigger", + "route": "customRoute/function", + "authLevel": "Anonymous", + "methods": ["get", "post"], + "properties": { } + } + """; + + var metadata = new DefaultFunctionMetadata + { + Name = "TestFunction", + RawBindings = new List { rawBinding }, + }; + + RouteEndpoint endpoint = FunctionsEndpointDataSource.MapHttpFunction(metadata, "api") as RouteEndpoint; + + Assert.Equal("TestFunction", endpoint.DisplayName); + Assert.Equal($"api/customRoute/function", endpoint.RoutePattern.RawText); + var endpointMetadata = endpoint.Metadata.Single() as HttpMethodMetadata; + Assert.Equal(new[] { "GET", "POST" }, endpointMetadata.HttpMethods); + } + + [Fact] + public void MapHttpFunction_CustomRoute_CaseInsensitive() + { + string rawBinding = """ + { + "name": "req", + "direction": "In", + "tyPe": "httpTrigger", + "rOute": "customRoute/function", + "authLevel": "Anonymous", + "metHOds": ["get", "post"], + "properties": { } + } + """; + + var metadata = new DefaultFunctionMetadata + { + Name = "TestFunction", + RawBindings = new List { rawBinding }, + }; + + RouteEndpoint endpoint = FunctionsEndpointDataSource.MapHttpFunction(metadata, "api") as RouteEndpoint; + + Assert.Equal("TestFunction", endpoint.DisplayName); + Assert.Equal($"api/customRoute/function", endpoint.RoutePattern.RawText); + var endpointMetadata = endpoint.Metadata.Single() as HttpMethodMetadata; + Assert.Equal(new[] { "GET", "POST" }, endpointMetadata.HttpMethods); + } + + [Fact] + public void GetRoutePrefix() + { + string hostJson = """ + { + "version": "2.0", + "extensions": { + "http": { + "routePrefix": "custom" + } + } + } + """; + + string prefix = FunctionsEndpointDataSource.GetRoutePrefix(hostJson); + Assert.Equal("custom", prefix); + } + + [Fact] + public void GetRoutePrefix_CaseInsensitive() + { + string hostJson = """ + { + "version": "2.0", + "ExTEnsions": { + "hTtp": { + "rOUtepREfix": "custom" + } + } + } + """; + + string prefix = FunctionsEndpointDataSource.GetRoutePrefix(hostJson); + Assert.Equal("custom", prefix); + } + } +}