diff --git a/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs
index ab53cbd77c89..9a27107f22c8 100644
--- a/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs
+++ b/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs
@@ -12,4 +12,23 @@ public interface IFromRouteMetadata
/// The name.
///
string? Name { get; }
+
+ ///
+ /// Gets a value indicating whether the route parameter value should be fully URL-decoded
+ /// using .
+ ///
+ ///
+ ///
+ /// By default, some characters such as / (%2F) are not decoded
+ /// in route values because they are decoded at the server level with special handling.
+ /// Setting this property to true ensures the value is fully percent-decoded
+ /// per RFC 3986.
+ ///
+ ///
+ ///
+ /// app.MapGet("/users/{userId}", ([FromRoute(UrlDecode = true)] string userId) => userId);
+ ///
+ ///
+ ///
+ bool UrlDecode => false;
}
diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
index 7dc5c58110bf..229fa5d8fdb7 100644
--- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
+++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
@@ -1 +1,2 @@
#nullable enable
+Microsoft.AspNetCore.Http.Metadata.IFromRouteMetadata.UrlDecode.get -> bool
diff --git a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs
index 399c61755280..f81226faf9ba 100644
--- a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs
+++ b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs
@@ -204,6 +204,15 @@ internal static void EmitRouteParameterPreparation(this EndpointParameter endpoi
var assigningCode = $"(string?)httpContext.Request.RouteValues[\"{endpointParameter.LookupName}\"]";
codeWriter.WriteLine($"var {endpointParameter.EmitAssigningCodeResult()} = {assigningCode};");
+ // Apply Uri.UnescapeDataString to fully decode percent-encoded route values (e.g. %2F → /)
+ if (endpointParameter.UrlDecode)
+ {
+ codeWriter.WriteLine($"if ({endpointParameter.EmitAssigningCodeResult()} is not null)");
+ codeWriter.StartBlock();
+ codeWriter.WriteLine($"{endpointParameter.EmitAssigningCodeResult()} = Uri.UnescapeDataString({endpointParameter.EmitAssigningCodeResult()});");
+ codeWriter.EndBlock();
+ }
+
if (!endpointParameter.IsOptional)
{
codeWriter.WriteLine($"if ({endpointParameter.EmitAssigningCodeResult()} == null)");
diff --git a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/EndpointParameter.cs b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/EndpointParameter.cs
index 80ee927a64f4..1a1a9fd24d2a 100644
--- a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/EndpointParameter.cs
+++ b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/EndpointParameter.cs
@@ -73,6 +73,7 @@ private void ProcessEndpointParameterSource(Endpoint endpoint, ISymbol symbol, I
LookupName = GetEscapedParameterName(fromRouteAttribute, symbol.Name);
IsParsable = TryGetParsability(Type, wellKnownTypes, out var preferredTryParseInvocation);
PreferredTryParseInvocation = preferredTryParseInvocation;
+ UrlDecode = fromRouteAttribute.TryGetNamedArgumentValue("UrlDecode", out var urlDecode) && urlDecode;
}
else if (attributes.TryGetAttributeImplementingInterface(wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Http_Metadata_IFromQueryMetadata), out var fromQueryAttribute))
{
@@ -297,6 +298,7 @@ private static bool ImplementsIEndpointParameterMetadataProvider(ITypeSymbol typ
public bool IsParsable { get; set; }
public Func? PreferredTryParseInvocation { get; set; }
public bool IsStringValues { get; set; }
+ public bool UrlDecode { get; set; }
public BindabilityMethod? BindMethod { get; set; }
public IMethodSymbol? BindableMethodSymbol { get; set; }
@@ -599,7 +601,8 @@ obj is EndpointParameter other &&
other.Ordinal == Ordinal &&
other.IsOptional == IsOptional &&
SymbolEqualityComparer.IncludeNullability.Equals(other.Type, Type) &&
- other.KeyedServiceKey == KeyedServiceKey;
+ other.KeyedServiceKey == KeyedServiceKey &&
+ other.UrlDecode == UrlDecode;
public bool SignatureEquals(object obj) =>
obj is EndpointParameter other &&
diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs
index c51e7aae5a36..ac4b8de3034a 100644
--- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs
+++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs
@@ -60,6 +60,7 @@ public static partial class RequestDelegateFactory
private static readonly MethodInfo ResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteResultWriteResponse), BindingFlags.NonPublic | BindingFlags.Static)!;
private static readonly MethodInfo StringResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteWriteStringResponseAsync), BindingFlags.NonPublic | BindingFlags.Static)!;
private static readonly MethodInfo StringIsNullOrEmptyMethod = typeof(string).GetMethod(nameof(string.IsNullOrEmpty), BindingFlags.Static | BindingFlags.Public)!;
+ private static readonly MethodInfo UriUnescapeDataStringMethod = typeof(Uri).GetMethod(nameof(Uri.UnescapeDataString), BindingFlags.Static | BindingFlags.Public, new[] { typeof(string) })!;
private static readonly MethodInfo WrapObjectAsValueTaskMethod = typeof(RequestDelegateFactory).GetMethod(nameof(WrapObjectAsValueTask), BindingFlags.NonPublic | BindingFlags.Static)!;
private static readonly MethodInfo TaskOfTToValueTaskOfObjectMethod = typeof(RequestDelegateFactory).GetMethod(nameof(TaskOfTToValueTaskOfObject), BindingFlags.NonPublic | BindingFlags.Static)!;
private static readonly MethodInfo ValueTaskOfTToValueTaskOfObjectMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ValueTaskOfTToValueTaskOfObject), BindingFlags.NonPublic | BindingFlags.Static)!;
@@ -734,7 +735,7 @@ private static Expression CreateArgument(ParameterInfo parameter, RequestDelegat
throw new InvalidOperationException($"'{routeName}' is not a route parameter.");
}
- return BindParameterFromProperty(parameter, RouteValuesExpr, RouteValuesIndexerProperty, routeName, factoryContext, "route");
+ return BindParameterFromProperty(parameter, RouteValuesExpr, RouteValuesIndexerProperty, routeName, factoryContext, "route", urlDecode: routeAttribute.UrlDecode);
}
else if (parameterCustomAttributes.OfType().FirstOrDefault() is { } queryAttribute)
{
@@ -1568,6 +1569,20 @@ private static Expression GetValueFromProperty(MemberExpression sourceExpression
return Expression.Convert(indexExpression, returnType ?? typeof(string));
}
+ // Wraps a string expression with null-safe Uri.UnescapeDataString: value is null ? null : Uri.UnescapeDataString(value)
+ private static Expression ApplyUrlDecode(Expression valueExpression)
+ {
+ var tempVar = Expression.Variable(typeof(string), "routeValue");
+ return Expression.Block(
+ typeof(string),
+ new[] { tempVar },
+ Expression.Assign(tempVar, valueExpression),
+ Expression.Condition(
+ Expression.Equal(tempVar, Expression.Constant(null, typeof(string))),
+ Expression.Constant(null, typeof(string)),
+ Expression.Call(UriUnescapeDataStringMethod, tempVar)));
+ }
+
private static Expression BindParameterFromProperties(ParameterInfo parameter, RequestDelegateFactoryContext factoryContext)
{
var parameterType = parameter.ParameterType;
@@ -1967,12 +1982,18 @@ private static Expression BindParameterFromExpression(
Expression.Convert(CreateDefaultValueExpression(parameter.DefaultValue, parameter.ParameterType), parameter.ParameterType)));
}
- private static Expression BindParameterFromProperty(ParameterInfo parameter, MemberExpression property, PropertyInfo itemProperty, string key, RequestDelegateFactoryContext factoryContext, string source)
+ private static Expression BindParameterFromProperty(ParameterInfo parameter, MemberExpression property, PropertyInfo itemProperty, string key, RequestDelegateFactoryContext factoryContext, string source, bool urlDecode = false)
{
var valueExpression = (source == "header" && parameter.ParameterType.IsArray)
? Expression.Call(GetHeaderSplitMethod, property, Expression.Constant(key))
: GetValueFromProperty(property, itemProperty, key, GetExpressionType(parameter.ParameterType));
+ // Apply Uri.UnescapeDataString to fully decode percent-encoded route values (e.g. %2F → /)
+ if (urlDecode)
+ {
+ valueExpression = ApplyUrlDecode(valueExpression);
+ }
+
return BindParameterFromValue(parameter, valueExpression, factoryContext, source);
}
diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs
index 61e7d2a46965..0545d50a4d68 100644
--- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs
+++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs
@@ -293,6 +293,85 @@ public void SpecifiedEmptyRouteParametersThrowIfRouteParameterDoesNotExist()
Assert.Equal("'id' is not a route parameter.", ex.Message);
}
+ [Fact]
+ public async Task FromRouteWithUrlDecodeTrueDecodesPercentEncodedValues()
+ {
+ var httpContext = CreateHttpContext();
+ var responseBodyStream = new MemoryStream();
+ httpContext.Response.Body = responseBodyStream;
+
+ // %2F is preserved by the server's path decoder, so route values contain literal "%2F"
+ httpContext.Request.RouteValues["userId"] = "domain%2Fuser";
+
+ var factoryResult = RequestDelegateFactory.Create(
+ ([FromRoute(UrlDecode = true)] string userId) => userId,
+ new() { RouteParameterNames = new[] { "userId" } });
+
+ await factoryResult.RequestDelegate(httpContext);
+
+ Assert.Equal(200, httpContext.Response.StatusCode);
+ var body = Encoding.UTF8.GetString(responseBodyStream.ToArray());
+ Assert.Equal("domain/user", body);
+ }
+
+ [Fact]
+ public async Task FromRouteWithUrlDecodeDefaultPreservesEncodedValues()
+ {
+ var httpContext = CreateHttpContext();
+ var responseBodyStream = new MemoryStream();
+ httpContext.Response.Body = responseBodyStream;
+
+ httpContext.Request.RouteValues["userId"] = "domain%2Fuser";
+
+ var factoryResult = RequestDelegateFactory.Create(
+ ([FromRoute] string userId) => userId,
+ new() { RouteParameterNames = new[] { "userId" } });
+
+ await factoryResult.RequestDelegate(httpContext);
+
+ Assert.Equal(200, httpContext.Response.StatusCode);
+ var body = Encoding.UTF8.GetString(responseBodyStream.ToArray());
+ // Default behavior: %2F is NOT decoded
+ Assert.Equal("domain%2Fuser", body);
+ }
+
+ [Fact]
+ public async Task FromRouteWithUrlDecodeHandlesNullValues()
+ {
+ var httpContext = CreateHttpContext();
+ httpContext.Response.Body = new MemoryStream();
+
+ // Route value not set — null case
+ var factoryResult = RequestDelegateFactory.Create(
+ ([FromRoute(UrlDecode = true)] string? userId) => userId ?? string.Empty,
+ new() { RouteParameterNames = new[] { "userId" } });
+
+ await factoryResult.RequestDelegate(httpContext);
+
+ Assert.Equal(200, httpContext.Response.StatusCode);
+ }
+
+ [Fact]
+ public async Task FromRouteWithUrlDecodeDecodesMultipleEncodedCharacters()
+ {
+ var httpContext = CreateHttpContext();
+ var responseBodyStream = new MemoryStream();
+ httpContext.Response.Body = responseBodyStream;
+
+ // Multiple encoded characters: %2F (/) and %2B (+) and %20 (space)
+ httpContext.Request.RouteValues["value"] = "a%2Fb%2Bc%20d";
+
+ var factoryResult = RequestDelegateFactory.Create(
+ ([FromRoute(UrlDecode = true)] string value) => value,
+ new() { RouteParameterNames = new[] { "value" } });
+
+ await factoryResult.RequestDelegate(httpContext);
+
+ Assert.Equal(200, httpContext.Response.StatusCode);
+ var body = Encoding.UTF8.GetString(responseBodyStream.ToArray());
+ Assert.Equal("a/b+c d", body);
+ }
+
public static object?[][] TryParsableArrayParameters
{
get
@@ -3633,6 +3712,7 @@ private record struct TodoStruct(int Id, string? Name, bool IsComplete) : ITodo;
private class FromRouteAttribute : Attribute, IFromRouteMetadata
{
public string? Name { get; set; }
+ public bool UrlDecode { get; set; }
}
private class FromQueryAttribute : Attribute, IFromQueryMetadata
diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.RouteParameter.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.RouteParameter.cs
index 4add5af92414..e2e4f33af79c 100644
--- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.RouteParameter.cs
+++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.RouteParameter.cs
@@ -231,4 +231,49 @@ public async Task RequestDelegatePopulatesFromRouteParameterBasedOnParameterName
Assert.Equal(originalRouteParam, httpContext.Items["input"]);
}
+
+ [Fact]
+ public async Task FromRouteWithUrlDecodeTrueDecodesPercentEncodedValues()
+ {
+ var source = """app.MapGet("/{userId}", ([FromRoute(UrlDecode = true)] string userId) => userId);""";
+ var (_, compilation) = await RunGeneratorAsync(source);
+ var endpoint = GetEndpointFromCompilation(compilation);
+
+ var httpContext = CreateHttpContext();
+ httpContext.Request.RouteValues["userId"] = "domain%2Fuser";
+
+ await endpoint.RequestDelegate(httpContext);
+
+ await VerifyResponseBodyAsync(httpContext, "domain/user");
+ }
+
+ [Fact]
+ public async Task FromRouteWithUrlDecodeDefaultPreservesEncodedValues()
+ {
+ var source = """app.MapGet("/{userId}", ([FromRoute] string userId) => userId);""";
+ var (_, compilation) = await RunGeneratorAsync(source);
+ var endpoint = GetEndpointFromCompilation(compilation);
+
+ var httpContext = CreateHttpContext();
+ httpContext.Request.RouteValues["userId"] = "domain%2Fuser";
+
+ await endpoint.RequestDelegate(httpContext);
+
+ await VerifyResponseBodyAsync(httpContext, "domain%2Fuser");
+ }
+
+ [Fact]
+ public async Task FromRouteWithUrlDecodeDecodesMultipleEncodedCharacters()
+ {
+ var source = """app.MapGet("/{value}", ([FromRoute(UrlDecode = true)] string value) => value);""";
+ var (_, compilation) = await RunGeneratorAsync(source);
+ var endpoint = GetEndpointFromCompilation(compilation);
+
+ var httpContext = CreateHttpContext();
+ httpContext.Request.RouteValues["value"] = "a%2Fb%2Bc%20d";
+
+ await endpoint.RequestDelegate(httpContext);
+
+ await VerifyResponseBodyAsync(httpContext, "a/b+c d");
+ }
}
diff --git a/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs b/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs
index 6524c70af5ab..1a0c6f6124e6 100644
--- a/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs
+++ b/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs
@@ -34,4 +34,19 @@ public class FromRouteAttribute : Attribute, IBindingSourceMetadata, IModelNameP
/// The name.
///
public string? Name { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the route parameter value should be fully URL-decoded
+ /// using .
+ ///
+ ///
+ /// When set to , characters such as %2F (forward slash) that
+ /// are normally preserved in route values will be decoded. Defaults to .
+ ///
+ ///
+ ///
+ /// app.MapGet("/users/{userId}", ([FromRoute(UrlDecode = true)] string userId) => userId);
+ ///
+ ///
+ public bool UrlDecode { get; set; }
}
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs
index 6f96c36dcf4e..275c951acca1 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs
@@ -5,6 +5,7 @@
using System.ComponentModel;
using System.Runtime.ExceptionServices;
+using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
@@ -56,6 +57,12 @@ public Task BindModelAsync(ModelBindingContext bindingContext)
? valueProviderResult.Values.ToString()
: valueProviderResult.FirstValue;
+ // Apply URL decoding when FromRoute(UrlDecode = true) is specified
+ if (value is not null && ShouldUrlDecodeRouteValue(bindingContext))
+ {
+ value = Uri.UnescapeDataString(value);
+ }
+
object? model;
if (bindingContext.ModelType == typeof(string))
{
@@ -104,6 +111,37 @@ public Task BindModelAsync(ModelBindingContext bindingContext)
return Task.CompletedTask;
}
+ // Checks if the current binding context targets a route parameter with UrlDecode = true
+ private static bool ShouldUrlDecodeRouteValue(ModelBindingContext bindingContext)
+ {
+ if (bindingContext.BindingSource != BindingSource.Path)
+ {
+ return false;
+ }
+
+ if (bindingContext.ModelMetadata is not Metadata.DefaultModelMetadata defaultMetadata)
+ {
+ return false;
+ }
+
+ var attributes = defaultMetadata.Attributes.ParameterAttributes
+ ?? defaultMetadata.Attributes.PropertyAttributes;
+ if (attributes is null)
+ {
+ return false;
+ }
+
+ foreach (var attribute in attributes)
+ {
+ if (attribute is IFromRouteMetadata { UrlDecode: true })
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
///
/// If the is , verifies that it is allowed to be ,
/// otherwise notifies the about the invalid .
diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
index 7dc5c58110bf..32b56df28897 100644
--- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
+++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
@@ -1 +1,3 @@
#nullable enable
+Microsoft.AspNetCore.Mvc.FromRouteAttribute.UrlDecode.get -> bool
+Microsoft.AspNetCore.Mvc.FromRouteAttribute.UrlDecode.set -> void
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs
index 291facb06f8d..73adc86ad040 100644
--- a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs
@@ -3,6 +3,7 @@
using System.Globalization;
using Microsoft.AspNetCore.InternalTesting;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
@@ -494,6 +495,87 @@ private static DefaultModelBindingContext GetBindingContext(Type modelType)
};
}
+ private static DefaultModelBindingContext GetBindingContextForParameter(Type controllerType, string methodName, string paramName)
+ {
+ var provider = new Metadata.DefaultModelMetadataProvider(
+ new DefaultCompositeMetadataDetailsProvider(new IMetadataDetailsProvider[]
+ {
+ new DefaultBindingMetadataProvider()
+ }));
+ var method = controllerType.GetMethod(methodName)!;
+ var parameter = method.GetParameters().First(p => p.Name == paramName);
+ var metadata = provider.GetMetadataForParameter(parameter);
+
+ return new DefaultModelBindingContext
+ {
+ ModelMetadata = metadata,
+ ModelName = paramName,
+ ModelState = new ModelStateDictionary(),
+ BindingSource = metadata.BindingSource,
+ ValueProvider = new SimpleValueProvider()
+ };
+ }
+
+ [Fact]
+ public async Task BindModelAsync_UrlDecodesRouteValue_WhenFromRouteUrlDecodeIsTrue()
+ {
+ var bindingContext = GetBindingContextForParameter(
+ typeof(UrlDecodeTestController), nameof(UrlDecodeTestController.WithUrlDecode), "userId");
+ bindingContext.ValueProvider = new SimpleValueProvider
+ {
+ { "userId", "domain%2Fuser" }
+ };
+
+ var binder = new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance);
+
+ await binder.BindModelAsync(bindingContext);
+
+ Assert.True(bindingContext.Result.IsModelSet);
+ Assert.Equal("domain/user", bindingContext.Result.Model);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_PreservesEncodedRouteValue_WhenFromRouteUrlDecodeIsFalse()
+ {
+ var bindingContext = GetBindingContextForParameter(
+ typeof(UrlDecodeTestController), nameof(UrlDecodeTestController.WithoutUrlDecode), "userId");
+ bindingContext.ValueProvider = new SimpleValueProvider
+ {
+ { "userId", "domain%2Fuser" }
+ };
+
+ var binder = new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance);
+
+ await binder.BindModelAsync(bindingContext);
+
+ Assert.True(bindingContext.Result.IsModelSet);
+ Assert.Equal("domain%2Fuser", bindingContext.Result.Model);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_UrlDecodesMultipleEncodedCharacters()
+ {
+ var bindingContext = GetBindingContextForParameter(
+ typeof(UrlDecodeTestController), nameof(UrlDecodeTestController.WithUrlDecode), "userId");
+ bindingContext.ValueProvider = new SimpleValueProvider
+ {
+ { "userId", "a%2Fb%2Bc%20d" }
+ };
+
+ var binder = new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance);
+
+ await binder.BindModelAsync(bindingContext);
+
+ Assert.True(bindingContext.Result.IsModelSet);
+ Assert.Equal("a/b+c d", bindingContext.Result.Model);
+ }
+
+ private class UrlDecodeTestController
+ {
+ public void WithUrlDecode([FromRoute(UrlDecode = true)] string userId) { }
+ public void WithoutUrlDecode([FromRoute] string userId) { }
+ }
+
private sealed class TestClass
{
}