diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index 49bab261b954..d81c7f4f3558 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -1820,9 +1820,16 @@ private static Expression BindParameterFromValue(ParameterInfo parameter, Expres Expression.Assign(parameter.ParameterType.IsArray ? Expression.ArrayAccess(argument, index) : argument, Expression.Convert(parsedValue, targetParseType)), failBlock)); + // For nullable value types, treat empty strings as null (don't attempt to parse them). + // This matches controller behavior and avoids parse failures on ?param= (empty value). + var isNullableValueType = Nullable.GetUnderlyingType(parameter.ParameterType) != null; + var sourceCheckExpr = (Expression)(isNullableValueType + ? TempSourceStringIsNotNullOrEmptyExpr + : TempSourceStringNotNullExpr); + var ifNotNullTryParse = !parameter.HasDefaultValue - ? Expression.IfThen(TempSourceStringNotNullExpr, tryParseExpression) - : Expression.IfThenElse(TempSourceStringNotNullExpr, tryParseExpression, + ? Expression.IfThen(sourceCheckExpr, tryParseExpression) + : Expression.IfThenElse(sourceCheckExpr, tryParseExpression, Expression.Assign(argument, CreateDefaultValueExpression(parameter.DefaultValue, parameter.ParameterType))); diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index 61e7d2a46965..485b4fae635a 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -263,6 +263,90 @@ public async Task NullRouteParametersPrefersRouteOverQueryString() Assert.Equal(42, httpContext.Items["input"]); } + [Fact] + public async Task EmptyQueryStringValueTreatedAsNullForNullableBool() + { + var httpContext = CreateHttpContext(); + + var factoryResult = RequestDelegateFactory.Create((bool? value, HttpContext httpContext) => + { + httpContext.Items["value"] = value; + }); + + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["value"] = "" // Empty string should be treated as null + }); + + var requestDelegate = factoryResult.RequestDelegate; + await requestDelegate(httpContext); + + Assert.Null(httpContext.Items["value"]); + } + + [Fact] + public async Task EmptyQueryStringValueTreatedAsNullForNullableInt() + { + var httpContext = CreateHttpContext(); + + var factoryResult = RequestDelegateFactory.Create((int? value, HttpContext httpContext) => + { + httpContext.Items["value"] = value; + }); + + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["value"] = "" // Empty string should be treated as null + }); + + var requestDelegate = factoryResult.RequestDelegate; + await requestDelegate(httpContext); + + Assert.Null(httpContext.Items["value"]); + } + + [Fact] + public async Task NonEmptyQueryStringValueParsedCorrectlyForNullableBool() + { + var httpContext = CreateHttpContext(); + + var factoryResult = RequestDelegateFactory.Create((bool? value, HttpContext httpContext) => + { + httpContext.Items["value"] = value; + }); + + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["value"] = "true" + }); + + var requestDelegate = factoryResult.RequestDelegate; + await requestDelegate(httpContext); + + Assert.True((bool?)httpContext.Items["value"]); + } + + [Fact] + public async Task NonEmptyQueryStringValueParsedCorrectlyForNullableInt() + { + var httpContext = CreateHttpContext(); + + var factoryResult = RequestDelegateFactory.Create((int? value, HttpContext httpContext) => + { + httpContext.Items["value"] = value; + }); + + httpContext.Request.Query = new QueryCollection(new Dictionary + { + ["value"] = "42" + }); + + var requestDelegate = factoryResult.RequestDelegate; + await requestDelegate(httpContext); + + Assert.Equal(42, (int?)httpContext.Items["value"]); + } + [Fact] public async Task CreatingDelegateWithInstanceMethodInfoCreatesInstancePerCall() { diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.QueryParameters.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.QueryParameters.cs index 414dee57e5b1..c41b77edad04 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.QueryParameters.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.QueryParameters.cs @@ -226,4 +226,44 @@ public async Task MapAction_ExplicitQueryParam_NameTest(string name, string look await endpoint.RequestDelegate(httpContext); await VerifyResponseBodyAsync(httpContext, "test", 200); } + + [Fact] + public async Task MapAction_NullableBoolParam_WithEmptyQueryStringValue_SetsNull() + { + // Regression test for https://github.com/dotnet/aspnetcore/issues/65754 + // ?myBool= should bind to null for bool? parameter, not throw. + var (results, compilation) = await RunGeneratorAsync(""" +app.MapGet("/test", ([FromQuery] bool? myBool) => myBool.HasValue ? $"Value: {myBool.Value}" : "null"); +"""); + var endpoint = GetEndpointFromCompilation(compilation); + + VerifyStaticEndpointModel(results, endpointModel => + { + Assert.Equal("MapGet", endpointModel.HttpMethod); + var p = Assert.Single(endpointModel.Parameters); + Assert.Equal(EndpointParameterSource.Query, p.Source); + Assert.Equal("myBool", p.SymbolName); + }); + + var httpContext = CreateHttpContext(); + httpContext.Request.QueryString = new QueryString("?myBool="); + + await endpoint.RequestDelegate(httpContext); + await VerifyResponseBodyAsync(httpContext, "null"); + } + + [Fact] + public async Task MapAction_NullableIntParam_WithEmptyQueryStringValue_SetsNull() + { + var (results, compilation) = await RunGeneratorAsync(""" +app.MapGet("/test", ([FromQuery] int? value) => value.HasValue ? $"{value.Value}" : "null"); +"""); + var endpoint = GetEndpointFromCompilation(compilation); + + var httpContext = CreateHttpContext(); + httpContext.Request.QueryString = new QueryString("?value="); + + await endpoint.RequestDelegate(httpContext); + await VerifyResponseBodyAsync(httpContext, "null"); + } }