From d0791a21fd39bc77164838fb6b5925d205ac6941 Mon Sep 17 00:00:00 2001 From: Jakub Florkowski Date: Sun, 1 Mar 2026 03:06:01 +0100 Subject: [PATCH] Preserve IOutboundParameterTransformer on optional route constraints When a route constraint implements IOutboundParameterTransformer, wrapping it in OptionalRouteConstraint loses the transformer capability because OptionalRouteConstraint does not implement the interface. Add OptionalOutboundParameterTransformerRouteConstraint that extends OptionalRouteConstraint and delegates TransformOutbound to the inner constraint. DefaultParameterPolicyFactory.InitializeRouteConstraint now uses this wrapper when the inner constraint is a transformer. Fixes #23063 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...oundParameterTransformerRouteConstraint.cs | 27 +++++++++++++ .../src/DefaultParameterPolicyFactory.cs | 4 +- .../DefaultParameterPolicyFactoryTest.cs | 38 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/Http/Routing/src/Constraints/OptionalOutboundParameterTransformerRouteConstraint.cs diff --git a/src/Http/Routing/src/Constraints/OptionalOutboundParameterTransformerRouteConstraint.cs b/src/Http/Routing/src/Constraints/OptionalOutboundParameterTransformerRouteConstraint.cs new file mode 100644 index 000000000000..797975bc77f6 --- /dev/null +++ b/src/Http/Routing/src/Constraints/OptionalOutboundParameterTransformerRouteConstraint.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Routing.Constraints; + +/// +/// Defines a constraint on an optional parameter whose inner constraint also implements +/// . This preserves the transformer capability +/// that would otherwise be lost when wrapping in . +/// +internal sealed class OptionalOutboundParameterTransformerRouteConstraint : OptionalRouteConstraint, IOutboundParameterTransformer +{ + /// + /// Creates a new instance. + /// + /// The inner constraint that also implements . + public OptionalOutboundParameterTransformerRouteConstraint(IRouteConstraint innerConstraint) + : base(innerConstraint) + { + } + + /// + public string? TransformOutbound(object? value) + { + return ((IOutboundParameterTransformer)InnerConstraint).TransformOutbound(value); + } +} diff --git a/src/Http/Routing/src/DefaultParameterPolicyFactory.cs b/src/Http/Routing/src/DefaultParameterPolicyFactory.cs index 8f7cf63f24a9..99a67409d089 100644 --- a/src/Http/Routing/src/DefaultParameterPolicyFactory.cs +++ b/src/Http/Routing/src/DefaultParameterPolicyFactory.cs @@ -64,7 +64,9 @@ private static IParameterPolicy InitializeRouteConstraint( { if (optional) { - routeConstraint = new OptionalRouteConstraint(routeConstraint); + routeConstraint = routeConstraint is IOutboundParameterTransformer + ? new OptionalOutboundParameterTransformerRouteConstraint(routeConstraint) + : new OptionalRouteConstraint(routeConstraint); } return routeConstraint; diff --git a/src/Http/Routing/test/UnitTests/DefaultParameterPolicyFactoryTest.cs b/src/Http/Routing/test/UnitTests/DefaultParameterPolicyFactoryTest.cs index 4ea9408505bb..5266e60e4592 100644 --- a/src/Http/Routing/test/UnitTests/DefaultParameterPolicyFactoryTest.cs +++ b/src/Http/Routing/test/UnitTests/DefaultParameterPolicyFactoryTest.cs @@ -127,6 +127,26 @@ public void Create_CreatesParameterPolicy_FromRoutePattern_Constraint_Optional() Assert.IsType(optionalConstraint.InnerConstraint); } + [Fact] + public void Create_CreatesParameterPolicy_FromRoutePattern_Constraint_Optional_PreservesOutboundTransformer() + { + // Regression test for https://github.com/dotnet/aspnetcore/issues/23063 + var factory = GetParameterPolicyFactory(); + + var parameter = RoutePatternFactory.ParameterPart( + "id", + @default: null, + parameterKind: RoutePatternParameterKind.Optional, + parameterPolicies: new[] { RoutePatternFactory.ParameterPolicy(new TransformingRouteConstraint()), }); + + var parameterPolicy = factory.Create(parameter, parameter.ParameterPolicies[0]); + + var optionalConstraint = Assert.IsType(parameterPolicy); + Assert.IsType(optionalConstraint.InnerConstraint); + var transformer = Assert.IsAssignableFrom(parameterPolicy); + Assert.Equal("hello", transformer.TransformOutbound("HELLO")); + } + [Fact] public void Create_CreatesParameterPolicy_FromRoutePattern_ParameterPolicy() { @@ -445,6 +465,24 @@ public bool Match( return false; } } + + private class TransformingRouteConstraint : IRouteConstraint, IOutboundParameterTransformer + { + public bool Match( + HttpContext httpContext, + IRouter route, + string routeKey, + RouteValueDictionary values, + RouteDirection routeDirection) + { + return true; + } + + public string TransformOutbound(object value) + { + return value?.ToString()?.ToLowerInvariant(); + } + } } public class CustomParameterPolicy : IParameterPolicy