diff --git a/release_notes.md b/release_notes.md index 37057e890..1511f44a7 100644 --- a/release_notes.md +++ b/release_notes.md @@ -10,8 +10,8 @@ ### Microsoft.Azure.Functions.Worker.Core -- +- Adding optional parameter support (#1868) ### Microsoft.Azure.Functions.Worker.Grpc -- +- Adding optional parameter support (#1868) diff --git a/src/DotNetWorker.Core/Context/Features/DefaultFunctionInputBindingFeature.cs b/src/DotNetWorker.Core/Context/Features/DefaultFunctionInputBindingFeature.cs index 7a3af4bf1..6d4a297b6 100644 --- a/src/DotNetWorker.Core/Context/Features/DefaultFunctionInputBindingFeature.cs +++ b/src/DotNetWorker.Core/Context/Features/DefaultFunctionInputBindingFeature.cs @@ -97,6 +97,21 @@ public async ValueTask BindFunctionInputAsync(Functi errors.Add( $"Cannot convert input parameter '{param.Name}' to type '{param.Type.FullName}' from type '{source.GetType().FullName}'. Error:{bindingResult.Error}"); } + else if (bindingResult.Status == ConversionStatus.Unhandled) + { + // If still unhandled after going through all converters,check an explicit default value was provided for the parameter. + if (param.HasDefaultValue) + { + parameterValues[i] = param.DefaultValue; + } + else + { + // We could not find a value for this param. should throw. + errors ??= new List(); + errors.Add( + $"Could not populate the value for '{param.Name}' parameter. Consider updating the parameter with a default value."); + } + } } // found errors diff --git a/src/DotNetWorker.Core/Definition/FunctionParameter.cs b/src/DotNetWorker.Core/Definition/FunctionParameter.cs index 62e2e1b64..1b97bb493 100644 --- a/src/DotNetWorker.Core/Definition/FunctionParameter.cs +++ b/src/DotNetWorker.Core/Definition/FunctionParameter.cs @@ -22,6 +22,18 @@ public FunctionParameter(string name, Type type) { } + /// + /// Creates an instance of the class. + /// + /// The parameter name. + /// The of the parameter. + /// Value that indicates whether the parameter has a default value. + /// Default value of the parameter. + public FunctionParameter(string name, Type type, bool hasDefaultValue, object? defaultValue) + : this(name, type, hasDefaultValue, defaultValue, ImmutableDictionary.Empty) + { + } + /// /// Creates an instance of the class. /// @@ -35,6 +47,23 @@ public FunctionParameter(string name, Type type, IReadOnlyDictionary + /// Creates an instance of the class. + /// + /// The parameter name. + /// The of the parameter. + /// Value that indicates whether the parameter has a default value. + /// Default value of the parameter. + /// The properties of the parameter. + public FunctionParameter(string name, Type type, bool hasDefaultValue, object? defaultValue, IReadOnlyDictionary properties) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Type = type ?? throw new ArgumentNullException(nameof(type)); + DefaultValue = defaultValue ?? default; + HasDefaultValue = hasDefaultValue; + Properties = properties ?? throw new ArgumentNullException(nameof(properties)); + } + /// /// Gets the parameter name. /// @@ -45,6 +74,16 @@ public FunctionParameter(string name, Type type, IReadOnlyDictionary public Type Type { get; } + /// + /// Gets a value that indicates whether this parameter has a default value. + /// + public bool HasDefaultValue { get; } + + /// + /// Gets the default value of the parameter if exists, else null. + /// + public object? DefaultValue { get; } + /// /// A dictionary holding properties of this parameter. /// diff --git a/src/DotNetWorker.Grpc/Definition/GrpcFunctionDefinition.cs b/src/DotNetWorker.Grpc/Definition/GrpcFunctionDefinition.cs index 5f88d793b..efc55efa4 100644 --- a/src/DotNetWorker.Grpc/Definition/GrpcFunctionDefinition.cs +++ b/src/DotNetWorker.Grpc/Definition/GrpcFunctionDefinition.cs @@ -56,7 +56,7 @@ public GrpcFunctionDefinition(FunctionLoadRequest loadRequest, IMethodInfoLocato Parameters = methodInfoLocator.GetMethod(PathToAssembly, EntryPoint) .GetParameters() .Where(p => p.Name != null) - .Select(p => new FunctionParameter(p.Name!, p.ParameterType, GetAdditionalPropertiesDictionary(p))) + .Select(p => new FunctionParameter(p.Name!, p.ParameterType, p.HasDefaultValue, p.DefaultValue, GetAdditionalPropertiesDictionary(p))) .ToImmutableArray(); } diff --git a/test/DotNetWorkerTests/DefaultModelBindingFeatureTests.cs b/test/DotNetWorkerTests/DefaultModelBindingFeatureTests.cs index e55c024ec..ade28eb79 100644 --- a/test/DotNetWorkerTests/DefaultModelBindingFeatureTests.cs +++ b/test/DotNetWorkerTests/DefaultModelBindingFeatureTests.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Text.Json; using System.Threading; using Azure.Core.Serialization; using Microsoft.Azure.Functions.Worker.Context.Features; +using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Tests.Features; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -59,6 +61,76 @@ public async void BindFunctionInputAsync_Populates_ParametersUsingConverters() Assert.Equal("0ab4800e-1308-4e9f-be5f-4372717e68eb", guid.ToString()); } + [Fact] + public async void BindFunctionInputAsync_Populates_Parameter_Using_DefaultValue_When_CouldNot_Populate_From_InputData() + { + var features = new InvocationFeatures(Enumerable.Empty()); + var httpFunctionDefinition = new TestFunctionDefinition(parameters: new FunctionParameter[] + { + new("req", typeof(HttpRequestData)), + new("fooId", typeof(int), true, defaultValue: 100), + new("bar", typeof(string), true, defaultValue: null) + }, + inputBindings: new Dictionary + { + { "req", new TestBindingMetadata("req","httpTrigger",BindingDirection.In) } + }); ; + features.Set(httpFunctionDefinition); + + var functionContext = new TestFunctionContext(httpFunctionDefinition, invocation: null, CancellationToken.None, serviceProvider: _serviceProvider, features: features); + var grpcHttpReq = new GrpcHttpRequestData(TestUtility.CreateRpcHttp(), functionContext); + var functionBindings = new TestFunctionBindingsFeature + { + InputData = new ReadOnlyDictionary(new Dictionary { { "req", grpcHttpReq } }) + }; + features.Set(functionBindings); + features.Set(_serviceProvider.GetService()); + + // Act + var bindingResult = await _functionInputBindingFeature.BindFunctionInputAsync(functionContext); + var parameterValuesArray = bindingResult.Values; + + // Assert + var httpReqData = TestUtility.AssertIsTypeAndConvert(parameterValuesArray[0]); + Assert.NotNull(httpReqData); + var fooId = TestUtility.AssertIsTypeAndConvert(parameterValuesArray[1]); + Assert.Equal(100, fooId); + var bar = parameterValuesArray[2]; + Assert.Null(bar); + } + + [Fact] + public async void BindFunctionInputAsync_Throws_When_Explicit_OptionalParametersValueNotPresent() + { + // 'fooId' is a parameter defined for the function without a default value + // and 'InputData' does not have a corresponding entry for that we could use to populate that parameter. + + IInvocationFeatures features = new InvocationFeatures(Enumerable.Empty()); + var httpFunctionDefinition = new TestFunctionDefinition(parameters: new FunctionParameter[] + { + new("req", typeof(HttpRequestData)), + new("fooId", typeof(int)) + }, + inputBindings: new Dictionary + { + { "req", new TestBindingMetadata("req","httpTrigger",BindingDirection.In) } + }); ; + features.Set(httpFunctionDefinition); + + var functionContext = new TestFunctionContext(httpFunctionDefinition, invocation: null, CancellationToken.None, serviceProvider: _serviceProvider, features: features); + var grpcHttpReq = new GrpcHttpRequestData(TestUtility.CreateRpcHttp(), functionContext); + var functionBindings = new TestFunctionBindingsFeature + { + InputData = new ReadOnlyDictionary(new Dictionary { { "req", grpcHttpReq } }) + }; + features.Set(functionBindings); + features.Set(_serviceProvider.GetService()); + + // Assert + var exception = await Assert.ThrowsAsync(async () => await _functionInputBindingFeature.BindFunctionInputAsync(functionContext)); + Assert.Equal("Error converting 1 input parameters for Function 'TestName': Could not populate the value for 'fooId' parameter. Consider updating the parameter with a default value.", exception.Message); + } + [Fact] public async void BindFunctionInputAsync_Returns_Cached_Value_When_Called_SecondTime() { diff --git a/test/DotNetWorkerTests/FunctionContextHttpExtensionTests.cs b/test/DotNetWorkerTests/FunctionContextHttpExtensionTests.cs index 2b80b17c6..2778da067 100644 --- a/test/DotNetWorkerTests/FunctionContextHttpExtensionTests.cs +++ b/test/DotNetWorkerTests/FunctionContextHttpExtensionTests.cs @@ -85,7 +85,7 @@ public async Task GetHttpRequestDataAsync_Works_For_Http_Invocation() _features.Set(httpFunctionDefinition); _defaultFunctionContext = new DefaultFunctionContext(_serviceScopeFactory, _features, CancellationToken.None); - var grpcHttpReq = new GrpcHttpRequestData(CreateRpcHttp(), _defaultFunctionContext); + var grpcHttpReq = new GrpcHttpRequestData(TestUtility.CreateRpcHttp(), _defaultFunctionContext); var functionBindings = new TestFunctionBindingsFeature { InputData = new ReadOnlyDictionary(new Dictionary { { "req", grpcHttpReq } }) @@ -118,7 +118,7 @@ public void GetHttpResponseData_Works_For_Http_Invocation() _features.Set(new TestFunctionDefinition()); _defaultFunctionContext = new DefaultFunctionContext(_serviceScopeFactory, _features, CancellationToken.None); - var grpcHttpReq = new GrpcHttpRequestData(CreateRpcHttp(), _defaultFunctionContext); + var grpcHttpReq = new GrpcHttpRequestData(TestUtility.CreateRpcHttp(), _defaultFunctionContext); var functionBindings = new TestFunctionBindingsFeature { InputData = new ReadOnlyDictionary(new Dictionary { { "req", grpcHttpReq } }), @@ -153,7 +153,7 @@ public void GetHttpResponseData_Works_For_Http_Invocation_POCO_OutputBinding_Pro })); _defaultFunctionContext = new DefaultFunctionContext(_serviceScopeFactory, _features, CancellationToken.None); - var grpcHttpReq = new GrpcHttpRequestData(CreateRpcHttp(), _defaultFunctionContext); + var grpcHttpReq = new GrpcHttpRequestData(TestUtility.CreateRpcHttp(), _defaultFunctionContext); var functionBindings = new TestFunctionBindingsFeature { InputData = new ReadOnlyDictionary(new Dictionary { { "req", grpcHttpReq } }) @@ -179,17 +179,5 @@ public void GetHttpResponseData_Works_For_Http_Invocation_POCO_OutputBinding_Pro HttpResponseData actual2 = _defaultFunctionContext.GetHttpResponseData(); Assert.True(actual2.Headers.First(a => a.Key == "X-Foo-Id").Value.Any()); } - - private RpcHttp CreateRpcHttp() - { - var rpcHttp = new RpcHttp - { - Url = "https://m.sn" - }; - rpcHttp.NullableHeaders["Accept-Encoding"] = new NullableString() { Value = "gzip, deflate" }; - rpcHttp.NullableHeaders["Cookie"] = new NullableString() { Value = "theme=light; x-token=foo" }; - - return rpcHttp; - } } } diff --git a/test/DotNetWorkerTests/TestUtility.cs b/test/DotNetWorkerTests/TestUtility.cs index c34bdb993..a14bfb8a7 100644 --- a/test/DotNetWorkerTests/TestUtility.cs +++ b/test/DotNetWorkerTests/TestUtility.cs @@ -87,5 +87,17 @@ public static InvocationRequest CreateInvocationRequestWithNullRetryContext(stri RetryContext = null }; } + + internal static RpcHttp CreateRpcHttp() + { + var rpcHttp = new RpcHttp + { + Url = "https://m.sn" + }; + rpcHttp.NullableHeaders["Accept-Encoding"] = new NullableString() { Value = "gzip, deflate" }; + rpcHttp.NullableHeaders["Cookie"] = new NullableString() { Value = "theme=light; x-token=foo" }; + + return rpcHttp; + } } }