Skip to content

Commit 0de07c4

Browse files
authored
Adding route handling configuration for custom handlers (#11345)
1 parent b40d3b6 commit 0de07c4

12 files changed

+652
-12
lines changed

release_notes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@
2121
- Added support for MCP custom handler. (#11355)
2222
- Update Python Worker Version to [4.40.0](https://github.com/Azure/azure-functions-python-worker/releases/tag/4.40.0)
2323
- RpcException Handling (#11347)
24+
- Adding route handling configuration for custom handlers (#11345)

src/WebJobs.Script/Config/HostConfigurationProfile.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ public sealed class HostConfigurationProfile
3030
ConfigurationSectionNames.CustomHandler, "enableHttpProxyingRequest"), "true"),
3131
KeyValuePair.Create(ConfigurationPath.Combine(
3232
ConfigurationSectionNames.Http, "routePrefix"), string.Empty),
33+
KeyValuePair.Create(ConfigurationPath.Combine(
34+
ConfigurationSectionNames.CustomHandler, "http", "routes", "0", "route"), "{*route}"),
3335
]);
3436

3537
private HostConfigurationProfile(

src/WebJobs.Script/ScriptHostBuilderExtensions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,8 @@ public static IHostBuilder AddScriptHostCore(this IHostBuilder builder, ScriptAp
315315
services.AddSingleton<IFunctionInvocationDispatcherFactory, FunctionInvocationDispatcherFactory>();
316316
services.AddSingleton<IScriptJobHost>(p => p.GetRequiredService<ScriptHost>());
317317
services.AddSingleton<IJobHost>(p => p.GetRequiredService<ScriptHost>());
318-
services.AddSingleton<IFunctionProvider, ProxyFunctionProvider>();
318+
services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionProvider, ProxyFunctionProvider>());
319+
services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionProvider, HttpWorkerFunctionProvider>());
319320
services.AddSingleton<IHostedService, WorkerConcurrencyManager>();
320321

321322
services.AddSingleton<ITypeLocator, ScriptTypeLocator>();

src/WebJobs.Script/Workers/Http/Configuration/HttpWorkerOptions.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License. See License.txt in the project root for license information.
33

44
using System;
@@ -33,6 +33,16 @@ public class HttpWorkerOptions : IOptionsFormatter
3333

3434
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
3535

36+
/// <summary>
37+
/// Gets or sets a value indicating whether custom routes are enabled.
38+
/// </summary>
39+
public bool CustomRoutesEnabled { get; set; }
40+
41+
/// <summary>
42+
/// Gets or sets http configuration.
43+
/// </summary>
44+
public CustomHandlerHttpOptions Http { get; set; }
45+
3646
public string Format()
3747
{
3848
return JsonSerializer.Serialize(this, typeof(HttpWorkerOptions), HttpWorkerOptionsJsonSerializerContext.Default);

src/WebJobs.Script/Workers/Http/Configuration/HttpWorkerOptionsSetup.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License. See License.txt in the project root for license information.
33

4+
using System;
45
using System.Collections.Generic;
56
using System.IO;
7+
using System.Linq;
68
using Microsoft.Azure.WebJobs.Script.Configuration;
79
using Microsoft.Azure.WebJobs.Script.Diagnostics;
10+
using Microsoft.Azure.WebJobs.Script.Workers.Rpc;
811
using Microsoft.Extensions.Configuration;
912
using Microsoft.Extensions.Logging;
1013
using Microsoft.Extensions.Options;
@@ -14,19 +17,22 @@ namespace Microsoft.Azure.WebJobs.Script.Workers.Http
1417
{
1518
internal class HttpWorkerOptionsSetup : IConfigureOptions<HttpWorkerOptions>
1619
{
20+
private readonly IEnvironment _environment;
1721
private IConfiguration _configuration;
1822
private ILogger _logger;
1923
private IMetricsLogger _metricsLogger;
2024
private ScriptJobHostOptions _scriptJobHostOptions;
2125
private string argumentsSectionName = $"{WorkerConstants.WorkerDescription}:arguments";
2226
private string workerArgumentsSectionName = $"{WorkerConstants.WorkerDescription}:workerArguments";
2327

24-
public HttpWorkerOptionsSetup(IOptions<ScriptJobHostOptions> scriptJobHostOptions, IConfiguration configuration, ILoggerFactory loggerFactory, IMetricsLogger metricsLogger)
28+
public HttpWorkerOptionsSetup(IOptions<ScriptJobHostOptions> scriptJobHostOptions, IConfiguration configuration, ILoggerFactory loggerFactory, IMetricsLogger metricsLogger, IEnvironment environment)
2529
{
30+
ArgumentNullException.ThrowIfNull(environment);
2631
_scriptJobHostOptions = scriptJobHostOptions.Value;
2732
_configuration = configuration;
2833
_metricsLogger = metricsLogger;
2934
_logger = loggerFactory.CreateLogger<HttpWorkerOptionsSetup>();
35+
_environment = environment;
3036
}
3137

3238
public void Configure(HttpWorkerOptions options)
@@ -66,6 +72,21 @@ public void Configure(HttpWorkerOptions options)
6672
private void ConfigureWorkerDescription(HttpWorkerOptions options, IConfigurationSection workerSection)
6773
{
6874
workerSection.Bind(options);
75+
76+
var workerRuntime = _environment.GetFunctionsWorkerRuntime();
77+
if (string.Equals(workerRuntime, RpcWorkerConstants.CustomHandlerLanguageWorkerName, StringComparison.OrdinalIgnoreCase))
78+
{
79+
options.CustomRoutesEnabled = true;
80+
}
81+
82+
if (options.Http?.Routes is not null && options.Http.Routes.Any())
83+
{
84+
foreach (var route in options.Http.Routes)
85+
{
86+
route.AuthorizationLevel ??= options.Http.DefaultAuthorizationLevel;
87+
}
88+
}
89+
6990
HttpWorkerDescription httpWorkerDescription = options.Description;
7091

7192
if (httpWorkerDescription == null)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System.Collections;
5+
using System.Collections.Generic;
6+
using Microsoft.Azure.WebJobs.Extensions.Http;
7+
8+
namespace Microsoft.Azure.WebJobs.Script.Workers.Http
9+
{
10+
public sealed class CustomHandlerHttpOptions
11+
{
12+
/// <summary>
13+
/// Gets or sets the default authorization level for custom handler HTTP worker routes.
14+
/// </summary>
15+
public AuthorizationLevel DefaultAuthorizationLevel { get; set; } = AuthorizationLevel.Function;
16+
17+
/// <summary>
18+
/// Gets or sets route mapping for a HTTP worker.
19+
/// </summary>
20+
public IEnumerable<HttpWorkerRoute> Routes { get; set; }
21+
}
22+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Collections.Immutable;
7+
using System.Linq;
8+
using System.Threading.Tasks;
9+
using Microsoft.AspNetCore.Http;
10+
using Microsoft.AspNetCore.Routing.Template;
11+
using Microsoft.Azure.WebJobs.Script.Description;
12+
using Microsoft.Azure.WebJobs.Script.Workers.Rpc;
13+
using Microsoft.Extensions.Logging;
14+
using Microsoft.Extensions.Options;
15+
using Newtonsoft.Json.Linq;
16+
17+
namespace Microsoft.Azure.WebJobs.Script.Workers.Http
18+
{
19+
internal sealed class HttpWorkerFunctionProvider : IFunctionProvider
20+
{
21+
private readonly HttpWorkerOptions _httpWorkerOptions;
22+
private readonly IHostFunctionMetadataProvider _hostFunctionMetadataProvider;
23+
private readonly IOptionsMonitor<LanguageWorkerOptions> _languageWorkerOptions;
24+
private readonly ILogger _logger;
25+
private ImmutableDictionary<string, ImmutableArray<string>> _errors = ImmutableDictionary<string, ImmutableArray<string>>.Empty;
26+
private static readonly JArray AllHttpMethods = BuildAllHttpMethods();
27+
28+
public HttpWorkerFunctionProvider(IOptions<HttpWorkerOptions> httpWorkerOptions, IOptionsMonitor<LanguageWorkerOptions> languageWorkerOptions, IHostFunctionMetadataProvider hostFunctionMetadataProvider, ILogger<HttpWorkerFunctionProvider> logger)
29+
{
30+
ArgumentNullException.ThrowIfNull(logger);
31+
ArgumentNullException.ThrowIfNull(httpWorkerOptions?.Value);
32+
ArgumentNullException.ThrowIfNull(languageWorkerOptions);
33+
ArgumentNullException.ThrowIfNull(hostFunctionMetadataProvider);
34+
_httpWorkerOptions = httpWorkerOptions?.Value;
35+
_hostFunctionMetadataProvider = hostFunctionMetadataProvider;
36+
_languageWorkerOptions = languageWorkerOptions;
37+
_logger = logger;
38+
}
39+
40+
public ImmutableDictionary<string, ImmutableArray<string>> FunctionErrors => _errors;
41+
42+
private static JArray BuildAllHttpMethods()
43+
{
44+
return new JArray(
45+
HttpMethods.Get,
46+
HttpMethods.Post,
47+
HttpMethods.Put,
48+
HttpMethods.Delete,
49+
HttpMethods.Patch,
50+
HttpMethods.Head,
51+
HttpMethods.Options,
52+
HttpMethods.Trace,
53+
HttpMethods.Connect);
54+
}
55+
56+
public async Task<ImmutableArray<FunctionMetadata>> GetFunctionMetadataAsync()
57+
{
58+
var routes = _httpWorkerOptions.Http?.Routes;
59+
60+
if (routes is null || !routes.Any())
61+
{
62+
return [];
63+
}
64+
65+
if (!_httpWorkerOptions.CustomRoutesEnabled)
66+
{
67+
throw new InvalidOperationException($"Routes configuration is only allowed for worker runtime: custom");
68+
}
69+
70+
var hostFunctionMetadata = await _hostFunctionMetadataProvider.GetFunctionMetadataAsync(_languageWorkerOptions.CurrentValue.WorkerConfigs, forceRefresh: false);
71+
72+
// We already know custom handler http routes are configured, if function.json files are also present we cannot proceed.
73+
if (hostFunctionMetadata.Any())
74+
{
75+
throw new InvalidOperationException(
76+
"Detected both function.json files and custom handler HTTP route configuration definition in host.json" +
77+
"Only one configuration source is supported. Remove either the function.json files or the HTTP routes entries in host.json.");
78+
}
79+
80+
return CreateFunctionsFromRoutes(routes);
81+
}
82+
83+
private ImmutableArray<FunctionMetadata> CreateFunctionsFromRoutes(IEnumerable<HttpWorkerRoute> routes)
84+
{
85+
var metadataBuilder = ImmutableArray.CreateBuilder<FunctionMetadata>();
86+
var errorsBuilder = ImmutableDictionary.CreateBuilder<string, ImmutableArray<string>>();
87+
88+
int index = 0;
89+
90+
foreach (var route in routes)
91+
{
92+
var functionName = $"http-handler{++index}";
93+
var routeTemplate = route.Route;
94+
95+
if (string.IsNullOrWhiteSpace(routeTemplate))
96+
{
97+
AddError("Route cannot be null, empty or whitespace.");
98+
continue;
99+
}
100+
101+
if (!TryParseRoute(routeTemplate, out var parseError))
102+
{
103+
AddError(parseError!);
104+
continue;
105+
}
106+
107+
metadataBuilder.Add(CreateHttpFunctionMetadata(route, functionName));
108+
109+
_logger.LogInformation(
110+
"Created function {FunctionName} for route {RouteTemplate} (authLevel={AuthLevel}).",
111+
functionName,
112+
routeTemplate,
113+
route.AuthorizationLevel);
114+
115+
void AddError(string reason)
116+
{
117+
errorsBuilder.Add(functionName, [reason]);
118+
_logger.LogError(
119+
"Unable to create function {FunctionName} for route {Route} due to invalid route: {Reason}",
120+
functionName,
121+
routeTemplate ?? "<null>",
122+
reason);
123+
}
124+
}
125+
126+
_errors = errorsBuilder.ToImmutable();
127+
128+
return metadataBuilder.ToImmutable();
129+
130+
bool TryParseRoute(string template, out string error)
131+
{
132+
try
133+
{
134+
_ = TemplateParser.Parse(template);
135+
error = null;
136+
return true;
137+
}
138+
catch (ArgumentException ex)
139+
{
140+
error = ex.Message;
141+
return false;
142+
}
143+
}
144+
}
145+
146+
private static FunctionMetadata CreateHttpFunctionMetadata(HttpWorkerRoute route, string functionName)
147+
{
148+
var trigger = new JObject
149+
{
150+
["type"] = "httpTrigger",
151+
["authLevel"] = route.AuthorizationLevel.ToString(),
152+
["direction"] = "in",
153+
["name"] = "req",
154+
["methods"] = AllHttpMethods,
155+
["route"] = route.Route
156+
};
157+
158+
var output = new JObject
159+
{
160+
["type"] = "http",
161+
["direction"] = "out",
162+
["name"] = "res"
163+
};
164+
165+
var metadata = new FunctionMetadata
166+
{
167+
Name = functionName
168+
};
169+
170+
metadata.Bindings.Add(BindingMetadata.Create(trigger));
171+
metadata.Bindings.Add(BindingMetadata.Create(output));
172+
173+
return metadata;
174+
}
175+
}
176+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using Microsoft.Azure.WebJobs.Extensions.Http;
5+
6+
namespace Microsoft.Azure.WebJobs.Script.Workers.Http
7+
{
8+
/// <summary>
9+
/// Route mapping for a custom handler HTTP worker.
10+
/// </summary>
11+
public sealed class HttpWorkerRoute
12+
{
13+
/// <summary>
14+
/// Gets or sets route template.
15+
/// </summary>
16+
public string Route { get; set; }
17+
18+
/// <summary>
19+
/// Gets or sets the authorization level.
20+
/// </summary>
21+
public AuthorizationLevel? AuthorizationLevel { get; set; }
22+
}
23+
}

src/WebJobs.Script/Workers/Rpc/RpcWorkerConstants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public static class RpcWorkerConstants
2020
public const string JavaLanguageWorkerName = "java";
2121
public const string PowerShellLanguageWorkerName = "powershell";
2222
public const string PythonLanguageWorkerName = "python";
23+
public const string CustomHandlerLanguageWorkerName = "custom";
2324
public const string WorkerConfigFileName = "worker.config.json";
2425
public const string DefaultWorkersDirectoryName = "workers";
2526

test/WebJobs.Script.Tests/Configuration/HostConfigurationProfileTests.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,15 @@ public void Get_Mcp_ReturnsExpectedProfile(string name)
3535

3636
Dictionary<string, string> configDict = new(profile.Configuration);
3737
profile.Name.Should().Be("mcp-custom-handler");
38-
configDict.Should().HaveCount(3);
38+
configDict.Should().HaveCount(4);
3939
configDict.Should().ContainKey("configurationProfile")
4040
.WhoseValue.Should().Be("mcp-custom-handler");
4141
configDict.Should().ContainKey("customHandler:enableHttpProxyingRequest")
4242
.WhoseValue.Should().Be("true");
4343
configDict.Should().ContainKey("extensions:http:routePrefix")
4444
.WhoseValue.Should().Be(string.Empty);
45+
configDict.Should().ContainKey("customHandler:http:routes:0:route")
46+
.WhoseValue.Should().Be("{*route}");
4547
}
4648

4749
[Fact]

0 commit comments

Comments
 (0)