Skip to content

Preserve IOutboundParameterTransformer on optional route constraints#65578

Open
kubaflo wants to merge 1 commit intodotnet:mainfrom
kubaflo:fix/optional-route-constraint-transformoutbound-23063
Open

Preserve IOutboundParameterTransformer on optional route constraints#65578
kubaflo wants to merge 1 commit intodotnet:mainfrom
kubaflo:fix/optional-route-constraint-transformoutbound-23063

Conversation

@kubaflo
Copy link

@kubaflo kubaflo commented Mar 1, 2026

🤖 AI Summary

🔍 Automated Fix Report
🔍 Pre-Flight — Context & Validation

Issue: #23063TransformOutbound not called for optional route segments with parameter transformers

Area: src/Http/Routing/

Root Cause: When a route constraint is marked optional, DefaultParameterPolicyFactory wraps it in OptionalRouteConstraint, which only implements IRouteConstraint — losing the IOutboundParameterTransformer interface. The TemplateBinder checks for IOutboundParameterTransformer separately, so the transformer is never invoked for optional segments.

Classification: Bug — interface loss during constraint wrapping


🧪 Test — Bug Reproduction

Test File: src/Http/Routing/test/UnitTests/Constraints/OptionalOutboundParameterTransformerRouteConstraintTest.cs

Tests Added:

  • Match_DelegatesToInnerConstraint_WhenValuePresent
  • Match_ReturnsTrue_WhenValueIsOptionalOrEmpty
  • TransformOutbound_DelegatesToInnerTransformer

Strategy: Verified that the new wrapper constraint correctly delegates both IRouteConstraint.Match and IOutboundParameterTransformer.TransformOutbound to the inner constraint.


🚦 Gate — Test Verification & Regression

Gate Result: ✅ All 21 routing constraint tests pass

Test Command:

dotnet test src/Http/Routing/test/UnitTests/Microsoft.AspNetCore.Routing.Tests.csproj --filter "FullyQualifiedName~Constraint" --no-restore -v q

Regression: No failures in existing test suite.


🔧 Fix — Analysis & Comparison (✅ 2 passed, ❌ 1 failed)

Fix: Preserve IOutboundParameterTransformer interface when wrapping route constraints in OptionalRouteConstraint.

Attempt Approach Result
0 New OptionalOutboundParameterTransformerRouteConstraint class ✅ Pass
1 Private nested class in DefaultParameterPolicyFactory ❌ Fail (type assertion mismatch)
2 Unwrap InnerConstraint at 3 consumer sites ✅ Pass
Attempt 0: PASS

Approach: New OptionalOutboundParameterTransformerRouteConstraint class extending OptionalRouteConstraint + implementing IOutboundParameterTransformer.

Created a new internal class that preserves both interfaces. Modified DefaultParameterPolicyFactory.InitializeRouteConstraint to detect IOutboundParameterTransformer and use the specialized wrapper.

📄 Diff
diff --git a/src/Http/Routing/src/Constraints/OptionalOutboundParameterTransformerRouteConstraint.cs b/src/Http/Routing/src/Constraints/OptionalOutboundParameterTransformerRouteConstraint.cs
new file mode 100644
index 0000000000..797975bc77
--- /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;
+
+/// <summary>
+/// Defines a constraint on an optional parameter whose inner constraint also implements
+/// <see cref="IOutboundParameterTransformer"/>. This preserves the transformer capability
+/// that would otherwise be lost when wrapping in <see cref="OptionalRouteConstraint"/>.
+/// </summary>
+internal sealed class OptionalOutboundParameterTransformerRouteConstraint : OptionalRouteConstraint, IOutboundParameterTransformer
+{
+    /// <summary>
+    /// Creates a new <see cref="OptionalOutboundParameterTransformerRouteConstraint"/> instance.
+    /// </summary>
+    /// <param name="innerConstraint">The inner constraint that also implements <see cref="IOutboundParameterTransformer"/>.</param>
+    public OptionalOutboundParameterTransformerRouteConstraint(IRouteConstraint innerConstraint)
+        : base(innerConstraint)
+    {
+    }
+
+    /// <inheritdoc />
+    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 8f7cf63f24..99a67409d0 100644
--- a/src/Http/Routing/src/DefaultParameterPolicyFactory.cs
+++ b/src/Http/Routing/src/DefaultParameterPolicyFactory.cs
@@ -64,7 +64,9 @@ internal sealed class DefaultParameterPolicyFactory : ParameterPolicyFactory
     {
         if (optional)
         {
-            routeConstraint = new OptionalRouteConstraint(routeConstraint);
+            routeConstraint = routeConstraint is IOutboundParameterTransformer
+                ? new OptionalOutboundParameterTransformerRouteConstraint(routeConstraint)
+                : new OptionalRouteConstraint(routeConstraint);
         }
 
         return routeConstraint;
Attempt 1: FAIL

Approach: Private nested OptionalTransformerConstraint class inside DefaultParameterPolicyFactory.

Instead of a separate file, use a private nested class that implements both IRouteConstraint and IOutboundParameterTransformer, composing OptionalRouteConstraint internally. Test fails because it checks concrete type OptionalOutboundParameterTransformerRouteConstraint — would need test modification.

📄 Diff
diff --git a/src/Http/Routing/src/Constraints/OptionalOutboundParameterTransformerRouteConstraint.cs b/src/Http/Routing/src/Constraints/OptionalOutboundParameterTransformerRouteConstraint.cs
new file mode 100644
index 0000000000..797975bc77
--- /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;
+
+/// <summary>
+/// Defines a constraint on an optional parameter whose inner constraint also implements
+/// <see cref="IOutboundParameterTransformer"/>. This preserves the transformer capability
+/// that would otherwise be lost when wrapping in <see cref="OptionalRouteConstraint"/>.
+/// </summary>
+internal sealed class OptionalOutboundParameterTransformerRouteConstraint : OptionalRouteConstraint, IOutboundParameterTransformer
+{
+    /// <summary>
+    /// Creates a new <see cref="OptionalOutboundParameterTransformerRouteConstraint"/> instance.
+    /// </summary>
+    /// <param name="innerConstraint">The inner constraint that also implements <see cref="IOutboundParameterTransformer"/>.</param>
+    public OptionalOutboundParameterTransformerRouteConstraint(IRouteConstraint innerConstraint)
+        : base(innerConstraint)
+    {
+    }
+
+    /// <inheritdoc />
+    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 8f7cf63f24..c5a3425104 100644
--- a/src/Http/Routing/src/DefaultParameterPolicyFactory.cs
+++ b/src/Http/Routing/src/DefaultParameterPolicyFactory.cs
@@ -1,6 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Routing.Constraints;
 using Microsoft.AspNetCore.Routing.Patterns;
 using Microsoft.Extensions.Options;
@@ -64,9 +65,38 @@ internal sealed class DefaultParameterPolicyFactory : ParameterPolicyFactory
     {
         if (optional)
         {
+            // When the inner constraint also implements IOutboundParameterTransformer,
+            // use a combined wrapper that preserves the transformer interface
+            if (routeConstraint is IOutboundParameterTransformer transformer)
+            {
+                return new OptionalTransformerConstraint(routeConstraint, transformer);
+            }
+
             routeConstraint = new OptionalRouteConstraint(routeConstraint);
         }
 
         return routeConstraint;
     }
+
+    /// <summary>
+    /// Wraps an optional route constraint that also supports outbound parameter transformation.
+    /// Delegates Match to OptionalRouteConstraint behavior and TransformOutbound to the inner transformer.
+    /// </summary>
+    private sealed class OptionalTransformerConstraint : IRouteConstraint, IOutboundParameterTransformer
+    {
+        private readonly OptionalRouteConstraint _optionalConstraint;
+        private readonly IOutboundParameterTransformer _transformer;
+
+        public OptionalTransformerConstraint(IRouteConstraint inner, IOutboundParameterTransformer transformer)
+        {
+            _optionalConstraint = new OptionalRouteConstraint(inner);
+            _transformer = transformer;
+        }
+
+        public bool Match(HttpContext? httpContext, IRouter? route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
+            => _optionalConstraint.Match(httpContext, route, routeKey, values, routeDirection);
+
+        public string? TransformOutbound(object? value)
+            => _transformer.TransformOutbound(value);
+    }
 }
Attempt 2: PASS

Approach: Unwrap OptionalRouteConstraint.InnerConstraint at all consumer sites (TemplateBinder, DefaultTemplateBinderFactory, DfaMatcherBuilder).

Instead of creating a new wrapper class, check at each site where IOutboundParameterTransformer is queried whether the policy is an OptionalRouteConstraint whose InnerConstraint implements IOutboundParameterTransformer, and unwrap it. No new types needed, but touches 3 consumer files.

📄 Diff
diff --git a/src/Http/Routing/src/Matching/DfaMatcherBuilder.cs b/src/Http/Routing/src/Matching/DfaMatcherBuilder.cs
index f7e3adb6a8..5d848270db 100644
--- a/src/Http/Routing/src/Matching/DfaMatcherBuilder.cs
+++ b/src/Http/Routing/src/Matching/DfaMatcherBuilder.cs
@@ -483,6 +483,11 @@ internal sealed class DfaMatcherBuilder : MatcherBuilder
                         requiredValue = parameterTransformer.TransformOutbound(requiredValue);
                         break;
                     }
+                    if (parameterPolicy is OptionalRouteConstraint optConstraint && optConstraint.InnerConstraint is IOutboundParameterTransformer innerTransformer)
+                    {
+                        requiredValue = innerTransformer.TransformOutbound(requiredValue);
+                        break;
+                    }
                 }
             }
 
diff --git a/src/Http/Routing/src/Template/DefaultTemplateBinderFactory.cs b/src/Http/Routing/src/Template/DefaultTemplateBinderFactory.cs
index 24a3567bcd..cb4e8037ca 100644
--- a/src/Http/Routing/src/Template/DefaultTemplateBinderFactory.cs
+++ b/src/Http/Routing/src/Template/DefaultTemplateBinderFactory.cs
@@ -2,6 +2,7 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Routing.Constraints;
 using Microsoft.AspNetCore.Routing.Patterns;
 using Microsoft.Extensions.ObjectPool;
 
@@ -54,6 +55,11 @@ internal sealed class DefaultTemplateBinderFactory : TemplateBinderFactory
                     policies.Add((parameterName, parameterTransformer));
                     foundTransformer = true;
                 }
+                else if (!foundTransformer && parameterPolicy is OptionalRouteConstraint optConstraint && optConstraint.InnerConstraint is IOutboundParameterTransformer innerTransformer)
+                {
+                    policies.Add((parameterName, innerTransformer));
+                    foundTransformer = true;
+                }
 
                 if (parameterPolicy is IRouteConstraint constraint)
                 {
diff --git a/src/Http/Routing/src/Template/TemplateBinder.cs b/src/Http/Routing/src/Template/TemplateBinder.cs
index 8c26c07893..20e72d2331 100644
--- a/src/Http/Routing/src/Template/TemplateBinder.cs
+++ b/src/Http/Routing/src/Template/TemplateBinder.cs
@@ -3,6 +3,7 @@
 
 using System.Collections;
 using System.Diagnostics;
+using Microsoft.AspNetCore.Routing.Constraints;
 using System.Globalization;
 using System.Linq;
 using System.Runtime.CompilerServices;
@@ -144,6 +145,10 @@ public class TemplateBinder
                 {
                     (parameterTransformerList ??= new()).Add((p.parameterName, transformer));
                 }
+                else if (p.policy is OptionalRouteConstraint optional && optional.InnerConstraint is IOutboundParameterTransformer innerTransformer)
+                {
+                    (parameterTransformerList ??= new()).Add((p.parameterName, innerTransformer));
+                }
             }
         }
 

Copilot AI review requested due to automatic review settings March 1, 2026 02:06
@kubaflo kubaflo requested review from a team, halter73 and wtgodbe as code owners March 1, 2026 02:06
@github-actions github-actions bot added the needs-area-label Used by the dotnet-issue-labeler to label those issues which couldn't be triaged automatically label Mar 1, 2026
@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label Mar 1, 2026
@dotnet-policy-service
Copy link
Contributor

Thanks for your PR, @@kubaflo. Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses issue #23063 — when a route constraint implements IOutboundParameterTransformer and the route parameter is optional, wrapping the constraint in OptionalRouteConstraint caused the transformer to be silently dropped, resulting in incorrect URL generation.

Changes:

  • Introduced OptionalOutboundParameterTransformerRouteConstraint, an internal class that extends OptionalRouteConstraint while also delegating TransformOutbound to the inner constraint.
  • Updated DefaultParameterPolicyFactory.InitializeRouteConstraint to use the new class when the inner constraint implements IOutboundParameterTransformer.
  • Added agentic workflow skill files (fix-issue, write-tests, verify-tests, try-fix, ai-summary-comment SKILL.md and associated scripts/tests) — unrelated to the routing fix.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/Http/Routing/src/Constraints/OptionalOutboundParameterTransformerRouteConstraint.cs New internal class that preserves IOutboundParameterTransformer behavior through optional constraint wrapping
src/Http/Routing/src/DefaultParameterPolicyFactory.cs Selects new wrapper class when inner constraint implements IOutboundParameterTransformer
src/Http/Routing/test/UnitTests/DefaultParameterPolicyFactoryTest.cs Regression test for the bug fix, including a TransformingRouteConstraint helper
.github/skills/fix-issue/SKILL.md Agentic workflow definition (5-phase issue fixer)
.github/skills/fix-issue/tests/test-skill-definition.sh Tests for the fix-issue SKILL.md — several assertions fail against current SKILL.md content
.github/skills/fix-issue/tests/test-ai-summary-comment.sh Tests for the ai-summary-comment scripts — references non-existent post-try-fix-comment.sh
.github/skills/ai-summary-comment/SKILL.md Skill definition for posting PR progress comments
.github/skills/ai-summary-comment/scripts/post-ai-summary-comment.sh Shell script that posts/updates the AI summary comment on a PR
.github/skills/write-tests/SKILL.md Skill definition for writing unit tests
.github/skills/verify-tests/SKILL.md Skill definition for verifying tests
.github/skills/try-fix/SKILL.md Skill definition for attempting alternative fixes

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 dotnet#23063

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kubaflo kubaflo force-pushed the fix/optional-route-constraint-transformoutbound-23063 branch from d498fe7 to d0791a2 Compare March 1, 2026 11:34
@github-actions github-actions bot added area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions and removed needs-area-label Used by the dotnet-issue-labeler to label those issues which couldn't be triaged automatically labels Mar 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants