From 1e2153b4a8f35d5b0f71322d08fcdfd2ae9dcf79 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 23 Dec 2025 12:58:22 +0800 Subject: [PATCH 1/8] Replace constant parameters in telemetry route pattern --- src/Http/Routing/src/Patterns/RoutePattern.cs | 58 +++- .../src/Patterns/RoutePatternParameterPart.cs | 17 +- .../UnitTests/Patterns/RoutePatternTest.cs | 277 ++++++++++++++++++ 3 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 src/Http/Routing/test/UnitTests/Patterns/RoutePatternTest.cs diff --git a/src/Http/Routing/src/Patterns/RoutePattern.cs b/src/Http/Routing/src/Patterns/RoutePattern.cs index 035b639a8063..f10697447fdd 100644 --- a/src/Http/Routing/src/Patterns/RoutePattern.cs +++ b/src/Http/Routing/src/Patterns/RoutePattern.cs @@ -160,7 +160,63 @@ internal RoutePattern( // 3. RouteEndpoint display name. internal string DebuggerToString() { - return RawText ?? string.Join(SeparatorString, PathSegments.Select(s => s.DebuggerToString())); + // If there are no required values, use the simple approach + if (RequiredValues.Count == 0) + { + return RawText ?? string.Join(SeparatorString, PathSegments.Select(s => s.DebuggerToString())); + } + + // Build the string replacing parameters with their required values when available + var segments = new List(PathSegments.Count); + foreach (var segment in PathSegments) + { + var segmentString = GetSegmentDebuggerToString(segment); + segments.Add(segmentString); + } + + return string.Join(SeparatorString, segments); + } + + private string GetSegmentDebuggerToString(RoutePatternPathSegment segment) + { + // Simple segment with single parameter that has a required value - just return the required value + if (segment.IsSimple && segment.Parts[0] is RoutePatternParameterPart parameter) + { + if (TryGetRequiredValue(parameter.Name, out var requiredValue)) + { + return requiredValue; + } + } + + // For complex segments, build the string part by part + var parts = new List(segment.Parts.Count); + foreach (var part in segment.Parts) + { + if (part is RoutePatternParameterPart paramPart && TryGetRequiredValue(paramPart.Name, out var value)) + { + parts.Add(value); + } + else + { + parts.Add(part.DebuggerToString()); + } + } + + return string.Join(string.Empty, parts); + } + + private bool TryGetRequiredValue(string parameterName, out string value) + { + if (RequiredValues.TryGetValue(parameterName, out var requiredValue) && + requiredValue is not null && + !IsRequiredValueAny(requiredValue)) + { + value = requiredValue.ToString() ?? string.Empty; + return !string.IsNullOrEmpty(value); + } + + value = string.Empty; + return false; } [DebuggerDisplay("{DebuggerToString(),nq}")] diff --git a/src/Http/Routing/src/Patterns/RoutePatternParameterPart.cs b/src/Http/Routing/src/Patterns/RoutePatternParameterPart.cs index 35abf22f4ab1..f1379d4d57ca 100644 --- a/src/Http/Routing/src/Patterns/RoutePatternParameterPart.cs +++ b/src/Http/Routing/src/Patterns/RoutePatternParameterPart.cs @@ -99,10 +99,23 @@ internal override string DebuggerToString() foreach (var constraint in ParameterPolicies) { builder.Append(':'); - builder.Append(constraint.ParameterPolicy); + if (constraint.Content is not null) + { + builder.Append(constraint.Content); + } + else if (constraint.ParameterPolicy is Constraints.RegexRouteConstraint regexConstraint) + { + builder.Append("regex("); + builder.Append(regexConstraint.Constraint); + builder.Append(')'); + } + else if (constraint.ParameterPolicy is not null) + { + builder.Append(constraint.ParameterPolicy); + } } - if (Default != null) + if (Default is not null) { builder.Append('='); builder.Append(Default); diff --git a/src/Http/Routing/test/UnitTests/Patterns/RoutePatternTest.cs b/src/Http/Routing/test/UnitTests/Patterns/RoutePatternTest.cs new file mode 100644 index 000000000000..2e56913a7e85 --- /dev/null +++ b/src/Http/Routing/test/UnitTests/Patterns/RoutePatternTest.cs @@ -0,0 +1,277 @@ +// 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.Routing.Constraints; + +namespace Microsoft.AspNetCore.Routing.Patterns; + +public class RoutePatternTest +{ + [Fact] + public void DebuggerToString_WithNoRequiredValues_ReturnsRawText() + { + // Arrange + var pattern = RoutePatternFactory.Parse("{controller=Home}/{action=Index}/{id?}"); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("{controller=Home}/{action=Index}/{id?}", result); + } + + [Fact] + public void DebuggerToString_WithRequiredValues_ReplacesMatchingParameters() + { + // Arrange + var pattern = RoutePatternFactory.Parse( + "{controller=Home}/{action=Index}/{id?}", + defaults: null, + parameterPolicies: null, + requiredValues: new { controller = "Store", action = "Index" }); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("Store/Index/{id?}", result); + } + + [Fact] + public void DebuggerToString_WithPartialRequiredValues_ReplacesOnlyMatchingParameters() + { + // Arrange + var pattern = RoutePatternFactory.Parse( + "{controller}/{action}/{id?}", + defaults: null, + parameterPolicies: null, + requiredValues: new { controller = "Products" }); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("Products/{action}/{id?}", result); + } + + [Fact] + public void DebuggerToString_WithRequiredValueAny_DoesNotReplaceParameter() + { + // Arrange + var pattern = RoutePatternFactory.Parse( + "{controller}/{action}/{id?}", + defaults: null, + parameterPolicies: null, + requiredValues: new RouteValueDictionary + { + { "controller", RoutePattern.RequiredValueAny }, + { "action", "Index" } + }); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("{controller}/Index/{id?}", result); + } + + [Fact] + public void DebuggerToString_WithNullRequiredValue_DoesNotReplaceParameter() + { + // Arrange + var pattern = RoutePatternFactory.Parse( + "{controller}/{action}/{id?}", + defaults: null, + parameterPolicies: null, + requiredValues: new RouteValueDictionary + { + { "controller", null }, + { "action", "Index" } + }); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("{controller}/Index/{id?}", result); + } + + [Fact] + public void DebuggerToString_WithEmptyStringRequiredValue_DoesNotReplaceParameter() + { + // Arrange + var pattern = RoutePatternFactory.Parse( + "{controller}/{action}/{id?}", + defaults: null, + parameterPolicies: null, + requiredValues: new RouteValueDictionary + { + { "controller", "" }, + { "action", "Index" } + }); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("{controller}/Index/{id?}", result); + } + + [Fact] + public void DebuggerToString_WithCatchAllParameter_ReplacesWithRequiredValue() + { + // Arrange + var pattern = RoutePatternFactory.Parse( + "{controller}/{*path}", + defaults: null, + parameterPolicies: null, + requiredValues: new { controller = "Files", path = "docs/readme.md" }); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("Files/docs/readme.md", result); + } + + [Fact] + public void DebuggerToString_WithComplexSegment_ReplacesMatchingParameters() + { + // Arrange + var pattern = RoutePatternFactory.Parse( + "{controller}-{action}", + defaults: null, + parameterPolicies: null, + requiredValues: new { controller = "Home", action = "Index" }); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("Home-Index", result); + } + + [Fact] + public void DebuggerToString_WithLiteralSegments_PreservesLiterals() + { + // Arrange + var pattern = RoutePatternFactory.Parse( + "api/{controller}/{id}", + defaults: null, + parameterPolicies: null, + requiredValues: new { controller = "Products" }); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("api/Products/{id}", result); + } + + [Fact] + public void DebuggerToString_WithConstraints_PreservesConstraintsForUnmatchedParameters() + { + // Arrange + var pattern = RoutePatternFactory.Parse( + "{controller}/{action}/{id:int}", + defaults: null, + parameterPolicies: null, + requiredValues: new { controller = "Store" }); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("Store/{action}/{id:int}", result); + } + + [Fact] + public void DebuggerToString_WithMultipleConstraints_PreservesAllConstraints() + { + // Arrange + var template = "{a:int}/{b:regex(^\\d+$)}/{c:int}"; + var defaults = new { a = 0 }; // Required value needs a corresponding parameter or default + var constraints = new { b = "fizz", c = new object[] { new RegexRouteConstraint("foo"), new RegexRouteConstraint("bar"), "baz" } }; + var requiredValues = new { a = "test" }; + + var pattern = RoutePatternFactory.Parse(template, defaults, constraints, requiredValues); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + // Constraints added via parameterPolicies come first, then inline constraints + // RegexRouteConstraint shows as regex(pattern) format + // String constraints like "fizz" are converted to RegexRouteConstraint with pattern "^(fizz)$" + // RegexRouteConstraint("foo") uses raw pattern "foo" + Assert.Equal("test/{b:regex(^(fizz)$):regex(^\\d+$)}/{c:regex(foo):regex(bar):regex(^(baz)$):int}", result); + } + + [Theory] + [InlineData("{controller}/{action}/{id?}", "controller=Home,action=Index", "Home/Index/{id?}")] + [InlineData("{controller=Home}/{action=Index}/{id?}", "controller=Store,action=Index", "Store/Index/{id?}")] + [InlineData("{controller}/{action}", "controller=Products", "Products/{action}")] + [InlineData("{controller}/{id:int}", "controller=Orders", "Orders/{id:int}")] + [InlineData("{controller:alpha}/{action:alpha}", "controller=Home,action=Index", "Home/Index")] + [InlineData("{controller=Home}/{action=Index}", "controller=Blog", "Blog/{action=Index}")] + [InlineData("{controller}-{action}", "controller=Home,action=Index", "Home-Index")] + [InlineData("api-{version}-{controller}", "version=v1,controller=Users", "api-v1-Users")] + [InlineData("{controller}/{*path}", "controller=Files", "Files/{*path}")] + [InlineData("{controller}/{**path}", "controller=Files", "Files/{**path}")] + [InlineData("api/{controller}/{id}", "controller=Products", "api/Products/{id}")] + [InlineData("api/v1/{controller}", "controller=Users", "api/v1/Users")] + [InlineData("{id:int:range(1,100)}", "id=50", "50")] + [InlineData("{a}/{b}/{c}", "a=1,c=3", "1/{b}/3")] + [InlineData("prefix-{param}-suffix", "param=value", "prefix-value-suffix")] + [InlineData("{a:int}/{b:int}/{c:int}", "b=2", "{a:int}/2/{c:int}")] + [InlineData("{a:int}/{b:int}/{c:int}", "b=", "{a:int}/{b:int}/{c:int}")] // Empty string should not replace + public void DebuggerToString_FuzzTest_ProducesExpectedOutput(string template, string requiredValuesText, string expectedOutput) + { + // Arrange + var requiredValues = ParseRequiredValues(requiredValuesText); + var pattern = RoutePatternFactory.Parse(template, defaults: null, parameterPolicies: null, requiredValues: requiredValues); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal(expectedOutput, result); + } + + [Theory] + [InlineData("{controller}/{action}/{id?}", "controller=Home,action=Index")] + [InlineData("{controller=Home}/{action=Index}/{id?}", "controller=Store,action=Index")] + [InlineData("{controller}/{action}", "controller=Products")] + [InlineData("{controller}/{id:int}", "controller=Orders")] + [InlineData("{controller=Home}/{action=Index}", "controller=Blog")] + [InlineData("{controller}-{action}", "controller=Home,action=Index")] + [InlineData("api/{controller}/{id}", "controller=Products")] + [InlineData("api/v1/{controller}", "controller=Users")] + [InlineData("{a}/{b}/{c}", "a=1,c=3")] + public void DebuggerToString_FuzzTest_OutputCanBeParsed(string template, string requiredValuesText) + { + // Arrange + var requiredValues = ParseRequiredValues(requiredValuesText); + var pattern = RoutePatternFactory.Parse(template, defaults: null, parameterPolicies: null, requiredValues: requiredValues); + + // Act + var debugString = pattern.DebuggerToString(); + + // Assert - the output should be parseable + var reparsed = RoutePatternFactory.Parse(debugString); + Assert.NotNull(reparsed); + } + + private static RouteValueDictionary ParseRequiredValues(string requiredValuesText) + { + var requiredValues = new RouteValueDictionary(); + foreach (var pair in requiredValuesText.Split(',')) + { + var eqIndex = pair.IndexOf('='); + var key = pair.Substring(0, eqIndex); + var value = pair.Substring(eqIndex + 1); + requiredValues[key] = value; + } + return requiredValues; + } +} From f1f3274d0c5e42fe7740df3f26fbc2eb6758a01a Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 23 Dec 2025 13:09:22 +0800 Subject: [PATCH 2/8] Clean up --- src/Http/Routing/src/Patterns/RoutePattern.cs | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/Http/Routing/src/Patterns/RoutePattern.cs b/src/Http/Routing/src/Patterns/RoutePattern.cs index f10697447fdd..0ce167a6c90a 100644 --- a/src/Http/Routing/src/Patterns/RoutePattern.cs +++ b/src/Http/Routing/src/Patterns/RoutePattern.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.AspNetCore.Routing.Template; @@ -161,17 +162,18 @@ internal RoutePattern( internal string DebuggerToString() { // If there are no required values, use the simple approach - if (RequiredValues.Count == 0) + if (RequiredValues.Count == 0 && RawText is { } rawText) { - return RawText ?? string.Join(SeparatorString, PathSegments.Select(s => s.DebuggerToString())); + return rawText; } // Build the string replacing parameters with their required values when available - var segments = new List(PathSegments.Count); - foreach (var segment in PathSegments) + var segments = new string[PathSegments.Count]; + for (var i = 0; i < PathSegments.Count; i++) { + var segment = PathSegments[i]; var segmentString = GetSegmentDebuggerToString(segment); - segments.Add(segmentString); + segments[i] = segmentString; } return string.Join(SeparatorString, segments); @@ -189,33 +191,30 @@ private string GetSegmentDebuggerToString(RoutePatternPathSegment segment) } // For complex segments, build the string part by part - var parts = new List(segment.Parts.Count); - foreach (var part in segment.Parts) + var parts = new string[segment.Parts.Count]; + for (var i = 0; i < segment.Parts.Count; i++) { - if (part is RoutePatternParameterPart paramPart && TryGetRequiredValue(paramPart.Name, out var value)) - { - parts.Add(value); - } - else - { - parts.Add(part.DebuggerToString()); - } + var part = segment.Parts[i]; + parts[i] = part is RoutePatternParameterPart paramPart && TryGetRequiredValue(paramPart.Name, out var value) + ? value + : part.DebuggerToString(); } return string.Join(string.Empty, parts); } - private bool TryGetRequiredValue(string parameterName, out string value) + private bool TryGetRequiredValue(string parameterName, [NotNullWhen(true)]out string? value) { if (RequiredValues.TryGetValue(parameterName, out var requiredValue) && requiredValue is not null && - !IsRequiredValueAny(requiredValue)) + !IsRequiredValueAny(requiredValue) && + requiredValue.ToString() is { Length: > 0 } v) { - value = requiredValue.ToString() ?? string.Empty; - return !string.IsNullOrEmpty(value); + value = v; + return true; } - value = string.Empty; + value = null; return false; } From ec8c576591554b760a340b6d923c652e30a89836 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 23 Dec 2025 13:48:42 +0800 Subject: [PATCH 3/8] Clean up --- src/Http/Routing/src/Patterns/RoutePattern.cs | 63 +------------ .../RoutePatternDebugStringFormatter.cs | 88 +++++++++++++++++++ 2 files changed, 89 insertions(+), 62 deletions(-) create mode 100644 src/Http/Routing/src/Patterns/RoutePatternDebugStringFormatter.cs diff --git a/src/Http/Routing/src/Patterns/RoutePattern.cs b/src/Http/Routing/src/Patterns/RoutePattern.cs index 0ce167a6c90a..145346c35124 100644 --- a/src/Http/Routing/src/Patterns/RoutePattern.cs +++ b/src/Http/Routing/src/Patterns/RoutePattern.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; using Microsoft.AspNetCore.Routing.Template; namespace Microsoft.AspNetCore.Routing.Patterns; @@ -36,8 +34,6 @@ internal static bool IsRequiredValueAny(object? value) return object.ReferenceEquals(RequiredValueAny, value); } - private const string SeparatorString = "/"; - internal RoutePattern( string? rawText, IReadOnlyDictionary defaults, @@ -159,64 +155,7 @@ internal RoutePattern( // 1. RoutePattern debug string. // 2. Default IRouteDiagnosticsMetadata value. // 3. RouteEndpoint display name. - internal string DebuggerToString() - { - // If there are no required values, use the simple approach - if (RequiredValues.Count == 0 && RawText is { } rawText) - { - return rawText; - } - - // Build the string replacing parameters with their required values when available - var segments = new string[PathSegments.Count]; - for (var i = 0; i < PathSegments.Count; i++) - { - var segment = PathSegments[i]; - var segmentString = GetSegmentDebuggerToString(segment); - segments[i] = segmentString; - } - - return string.Join(SeparatorString, segments); - } - - private string GetSegmentDebuggerToString(RoutePatternPathSegment segment) - { - // Simple segment with single parameter that has a required value - just return the required value - if (segment.IsSimple && segment.Parts[0] is RoutePatternParameterPart parameter) - { - if (TryGetRequiredValue(parameter.Name, out var requiredValue)) - { - return requiredValue; - } - } - - // For complex segments, build the string part by part - var parts = new string[segment.Parts.Count]; - for (var i = 0; i < segment.Parts.Count; i++) - { - var part = segment.Parts[i]; - parts[i] = part is RoutePatternParameterPart paramPart && TryGetRequiredValue(paramPart.Name, out var value) - ? value - : part.DebuggerToString(); - } - - return string.Join(string.Empty, parts); - } - - private bool TryGetRequiredValue(string parameterName, [NotNullWhen(true)]out string? value) - { - if (RequiredValues.TryGetValue(parameterName, out var requiredValue) && - requiredValue is not null && - !IsRequiredValueAny(requiredValue) && - requiredValue.ToString() is { Length: > 0 } v) - { - value = v; - return true; - } - - value = null; - return false; - } + internal string DebuggerToString() => RoutePatternDebugStringFormatter.Format(this); [DebuggerDisplay("{DebuggerToString(),nq}")] private sealed class RequiredValueAnySentinal diff --git a/src/Http/Routing/src/Patterns/RoutePatternDebugStringFormatter.cs b/src/Http/Routing/src/Patterns/RoutePatternDebugStringFormatter.cs new file mode 100644 index 000000000000..6f8f954afb14 --- /dev/null +++ b/src/Http/Routing/src/Patterns/RoutePatternDebugStringFormatter.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.AspNetCore.Routing.Patterns; + +internal static class RoutePatternDebugStringFormatter +{ + private const string SeparatorString = "/"; + + public static string Format(RoutePattern pattern) + { + // If there are no required values that match parameters, use the simple approach + if (pattern.RawText is { } rawText && !HasMatchingRequiredValues(pattern)) + { + return rawText; + } + + // Build the string replacing parameters with their required values when available + var segments = new string[pattern.PathSegments.Count]; + for (var i = 0; i < pattern.PathSegments.Count; i++) + { + var segment = pattern.PathSegments[i]; + var segmentString = GetSegmentDebuggerToString(pattern, segment); + segments[i] = segmentString; + } + + return string.Join(SeparatorString, segments); + } + + private static bool HasMatchingRequiredValues(RoutePattern pattern) + { + if (pattern.RequiredValues.Count == 0) + { + return false; + } + + for (var i = 0; i < pattern.Parameters.Count; i++) + { + if (TryGetRequiredValue(pattern, pattern.Parameters[i].Name, out _)) + { + return true; + } + } + + return false; + } + + private static string GetSegmentDebuggerToString(RoutePattern pattern, RoutePatternPathSegment segment) + { + // Simple segment with single parameter that has a required value - just return the required value + if (segment.IsSimple && segment.Parts[0] is RoutePatternParameterPart parameter) + { + if (TryGetRequiredValue(pattern, parameter.Name, out var requiredValue)) + { + return requiredValue; + } + } + + // For complex segments, build the string part by part + var parts = new string[segment.Parts.Count]; + for (var i = 0; i < segment.Parts.Count; i++) + { + var part = segment.Parts[i]; + parts[i] = part is RoutePatternParameterPart paramPart && TryGetRequiredValue(pattern, paramPart.Name, out var value) + ? value + : part.DebuggerToString(); + } + + return string.Join(string.Empty, parts); + } + + private static bool TryGetRequiredValue(RoutePattern pattern, string parameterName, [NotNullWhen(true)] out string? value) + { + if (pattern.RequiredValues.TryGetValue(parameterName, out var requiredValue) && + requiredValue is not null && + !RoutePattern.IsRequiredValueAny(requiredValue) && + requiredValue.ToString() is { Length: > 0 } v) + { + value = v; + return true; + } + + value = null; + return false; + } +} From 6fd922c9347828b36c1ff57e2c269a972650a35c Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 23 Dec 2025 18:34:09 +0800 Subject: [PATCH 4/8] Preserve forward slash --- .../RoutePatternDebugStringFormatter.cs | 19 ++++- .../UnitTests/Patterns/RoutePatternTest.cs | 73 +++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/Http/Routing/src/Patterns/RoutePatternDebugStringFormatter.cs b/src/Http/Routing/src/Patterns/RoutePatternDebugStringFormatter.cs index 6f8f954afb14..37560f3b2f57 100644 --- a/src/Http/Routing/src/Patterns/RoutePatternDebugStringFormatter.cs +++ b/src/Http/Routing/src/Patterns/RoutePatternDebugStringFormatter.cs @@ -7,12 +7,13 @@ namespace Microsoft.AspNetCore.Routing.Patterns; internal static class RoutePatternDebugStringFormatter { + private const char Separator = '/'; private const string SeparatorString = "/"; public static string Format(RoutePattern pattern) { // If there are no required values that match parameters, use the simple approach - if (pattern.RawText is { } rawText && !HasMatchingRequiredValues(pattern)) + if (pattern.RawText is { Length: > 0 } rawText && !HasMatchingRequiredValues(pattern)) { return rawText; } @@ -26,7 +27,21 @@ public static string Format(RoutePattern pattern) segments[i] = segmentString; } - return string.Join(SeparatorString, segments); + var result = string.Join(Separator, segments); + + // Preserve leading slash from raw text + if (pattern.RawText is { Length: > 0 } rt && rt[0] == Separator) + { + result = Separator + result; + } + + // Return "/" for empty results + if (result.Length == 0) + { + return SeparatorString; + } + + return result; } private static bool HasMatchingRequiredValues(RoutePattern pattern) diff --git a/src/Http/Routing/test/UnitTests/Patterns/RoutePatternTest.cs b/src/Http/Routing/test/UnitTests/Patterns/RoutePatternTest.cs index 2e56913a7e85..9de31f5ad3ce 100644 --- a/src/Http/Routing/test/UnitTests/Patterns/RoutePatternTest.cs +++ b/src/Http/Routing/test/UnitTests/Patterns/RoutePatternTest.cs @@ -262,6 +262,79 @@ public void DebuggerToString_FuzzTest_OutputCanBeParsed(string template, string Assert.NotNull(reparsed); } + [Fact] + public void DebuggerToString_WithLeadingSlash_PreservesLeadingSlash() + { + // Arrange + var pattern = RoutePatternFactory.Parse( + "/{controller}/{action}", + defaults: null, + parameterPolicies: null, + requiredValues: new { controller = "Home", action = "Index" }); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("/Home/Index", result); + } + + [Fact] + public void DebuggerToString_WithLeadingSlashAndPartialRequiredValues_PreservesLeadingSlash() + { + // Arrange + var pattern = RoutePatternFactory.Parse( + "/{controller}/{action}/{id?}", + defaults: null, + parameterPolicies: null, + requiredValues: new { controller = "Store" }); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("/Store/{action}/{id?}", result); + } + + [Fact] + public void DebuggerToString_WithLeadingSlashNoRequiredValues_PreservesRawText() + { + // Arrange + var pattern = RoutePatternFactory.Parse("/{controller}/{action}"); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("/{controller}/{action}", result); + } + + [Fact] + public void DebuggerToString_EmptyPattern_ReturnsSlash() + { + // Arrange + var pattern = RoutePatternFactory.Parse(""); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("/", result); + } + + [Fact] + public void DebuggerToString_SlashOnlyPattern_ReturnsSlash() + { + // Arrange + var pattern = RoutePatternFactory.Parse("/"); + + // Act + var result = pattern.DebuggerToString(); + + // Assert + Assert.Equal("/", result); + } + private static RouteValueDictionary ParseRequiredValues(string requiredValuesText) { var requiredValues = new RouteValueDictionary(); From f6fbcf8b2e2494b389fb390b7d054420ff763d9e Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 23 Dec 2025 18:37:17 +0800 Subject: [PATCH 5/8] Clean up --- ...tePatternTest.cs => RoutePatternDebugStringFormatterTest.cs} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/Http/Routing/test/UnitTests/Patterns/{RoutePatternTest.cs => RoutePatternDebugStringFormatterTest.cs} (99%) diff --git a/src/Http/Routing/test/UnitTests/Patterns/RoutePatternTest.cs b/src/Http/Routing/test/UnitTests/Patterns/RoutePatternDebugStringFormatterTest.cs similarity index 99% rename from src/Http/Routing/test/UnitTests/Patterns/RoutePatternTest.cs rename to src/Http/Routing/test/UnitTests/Patterns/RoutePatternDebugStringFormatterTest.cs index 9de31f5ad3ce..297f4d7713fe 100644 --- a/src/Http/Routing/test/UnitTests/Patterns/RoutePatternTest.cs +++ b/src/Http/Routing/test/UnitTests/Patterns/RoutePatternDebugStringFormatterTest.cs @@ -5,7 +5,7 @@ namespace Microsoft.AspNetCore.Routing.Patterns; -public class RoutePatternTest +public class RoutePatternDebugStringFormatterTest { [Fact] public void DebuggerToString_WithNoRequiredValues_ReturnsRawText() From 89ee7c94806aada813f6efa1756cf9c5da0c6bfe Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 26 Dec 2025 06:20:21 +0800 Subject: [PATCH 6/8] Reduce test duplication --- .../RoutePatternDebugStringFormatterTest.cs | 123 ++---------------- 1 file changed, 8 insertions(+), 115 deletions(-) diff --git a/src/Http/Routing/test/UnitTests/Patterns/RoutePatternDebugStringFormatterTest.cs b/src/Http/Routing/test/UnitTests/Patterns/RoutePatternDebugStringFormatterTest.cs index 297f4d7713fe..96770e23db98 100644 --- a/src/Http/Routing/test/UnitTests/Patterns/RoutePatternDebugStringFormatterTest.cs +++ b/src/Http/Routing/test/UnitTests/Patterns/RoutePatternDebugStringFormatterTest.cs @@ -7,19 +7,6 @@ namespace Microsoft.AspNetCore.Routing.Patterns; public class RoutePatternDebugStringFormatterTest { - [Fact] - public void DebuggerToString_WithNoRequiredValues_ReturnsRawText() - { - // Arrange - var pattern = RoutePatternFactory.Parse("{controller=Home}/{action=Index}/{id?}"); - - // Act - var result = pattern.DebuggerToString(); - - // Assert - Assert.Equal("{controller=Home}/{action=Index}/{id?}", result); - } - [Fact] public void DebuggerToString_WithRequiredValues_ReplacesMatchingParameters() { @@ -208,27 +195,30 @@ public void DebuggerToString_WithMultipleConstraints_PreservesAllConstraints() } [Theory] + [InlineData("{controller=Home}/{action=Index}/{id?}", "", "{controller=Home}/{action=Index}/{id?}")] [InlineData("{controller}/{action}/{id?}", "controller=Home,action=Index", "Home/Index/{id?}")] - [InlineData("{controller=Home}/{action=Index}/{id?}", "controller=Store,action=Index", "Store/Index/{id?}")] [InlineData("{controller}/{action}", "controller=Products", "Products/{action}")] [InlineData("{controller}/{id:int}", "controller=Orders", "Orders/{id:int}")] [InlineData("{controller:alpha}/{action:alpha}", "controller=Home,action=Index", "Home/Index")] [InlineData("{controller=Home}/{action=Index}", "controller=Blog", "Blog/{action=Index}")] - [InlineData("{controller}-{action}", "controller=Home,action=Index", "Home-Index")] [InlineData("api-{version}-{controller}", "version=v1,controller=Users", "api-v1-Users")] [InlineData("{controller}/{*path}", "controller=Files", "Files/{*path}")] [InlineData("{controller}/{**path}", "controller=Files", "Files/{**path}")] - [InlineData("api/{controller}/{id}", "controller=Products", "api/Products/{id}")] [InlineData("api/v1/{controller}", "controller=Users", "api/v1/Users")] [InlineData("{id:int:range(1,100)}", "id=50", "50")] [InlineData("{a}/{b}/{c}", "a=1,c=3", "1/{b}/3")] [InlineData("prefix-{param}-suffix", "param=value", "prefix-value-suffix")] [InlineData("{a:int}/{b:int}/{c:int}", "b=2", "{a:int}/2/{c:int}")] [InlineData("{a:int}/{b:int}/{c:int}", "b=", "{a:int}/{b:int}/{c:int}")] // Empty string should not replace - public void DebuggerToString_FuzzTest_ProducesExpectedOutput(string template, string requiredValuesText, string expectedOutput) + [InlineData("/{controller}/{action}", "controller=Home,action=Index", "/Home/Index")] + [InlineData("/{controller}/{action}/{id?}", "controller=Store", "/Store/{action}/{id?}")] + [InlineData("/{controller}/{action}", "", "/{controller}/{action}")] + [InlineData("", "", "/")] + [InlineData("/", "", "/")] + public void DebuggerToString_ProducesExpectedOutput(string template, string requiredValuesText, string expectedOutput) { // Arrange - var requiredValues = ParseRequiredValues(requiredValuesText); + var requiredValues = string.IsNullOrEmpty(requiredValuesText) ? null : ParseRequiredValues(requiredValuesText); var pattern = RoutePatternFactory.Parse(template, defaults: null, parameterPolicies: null, requiredValues: requiredValues); // Act @@ -238,103 +228,6 @@ public void DebuggerToString_FuzzTest_ProducesExpectedOutput(string template, st Assert.Equal(expectedOutput, result); } - [Theory] - [InlineData("{controller}/{action}/{id?}", "controller=Home,action=Index")] - [InlineData("{controller=Home}/{action=Index}/{id?}", "controller=Store,action=Index")] - [InlineData("{controller}/{action}", "controller=Products")] - [InlineData("{controller}/{id:int}", "controller=Orders")] - [InlineData("{controller=Home}/{action=Index}", "controller=Blog")] - [InlineData("{controller}-{action}", "controller=Home,action=Index")] - [InlineData("api/{controller}/{id}", "controller=Products")] - [InlineData("api/v1/{controller}", "controller=Users")] - [InlineData("{a}/{b}/{c}", "a=1,c=3")] - public void DebuggerToString_FuzzTest_OutputCanBeParsed(string template, string requiredValuesText) - { - // Arrange - var requiredValues = ParseRequiredValues(requiredValuesText); - var pattern = RoutePatternFactory.Parse(template, defaults: null, parameterPolicies: null, requiredValues: requiredValues); - - // Act - var debugString = pattern.DebuggerToString(); - - // Assert - the output should be parseable - var reparsed = RoutePatternFactory.Parse(debugString); - Assert.NotNull(reparsed); - } - - [Fact] - public void DebuggerToString_WithLeadingSlash_PreservesLeadingSlash() - { - // Arrange - var pattern = RoutePatternFactory.Parse( - "/{controller}/{action}", - defaults: null, - parameterPolicies: null, - requiredValues: new { controller = "Home", action = "Index" }); - - // Act - var result = pattern.DebuggerToString(); - - // Assert - Assert.Equal("/Home/Index", result); - } - - [Fact] - public void DebuggerToString_WithLeadingSlashAndPartialRequiredValues_PreservesLeadingSlash() - { - // Arrange - var pattern = RoutePatternFactory.Parse( - "/{controller}/{action}/{id?}", - defaults: null, - parameterPolicies: null, - requiredValues: new { controller = "Store" }); - - // Act - var result = pattern.DebuggerToString(); - - // Assert - Assert.Equal("/Store/{action}/{id?}", result); - } - - [Fact] - public void DebuggerToString_WithLeadingSlashNoRequiredValues_PreservesRawText() - { - // Arrange - var pattern = RoutePatternFactory.Parse("/{controller}/{action}"); - - // Act - var result = pattern.DebuggerToString(); - - // Assert - Assert.Equal("/{controller}/{action}", result); - } - - [Fact] - public void DebuggerToString_EmptyPattern_ReturnsSlash() - { - // Arrange - var pattern = RoutePatternFactory.Parse(""); - - // Act - var result = pattern.DebuggerToString(); - - // Assert - Assert.Equal("/", result); - } - - [Fact] - public void DebuggerToString_SlashOnlyPattern_ReturnsSlash() - { - // Arrange - var pattern = RoutePatternFactory.Parse("/"); - - // Act - var result = pattern.DebuggerToString(); - - // Assert - Assert.Equal("/", result); - } - private static RouteValueDictionary ParseRequiredValues(string requiredValuesText) { var requiredValues = new RouteValueDictionary(); From bc941088f6a10796bc65e92a3641b142b58d6ef7 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sat, 27 Dec 2025 08:34:12 +0800 Subject: [PATCH 7/8] Fix test --- .../test/ApplicationModels/EndpointMetadataProviderTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataProviderTest.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataProviderTest.cs index 535b3e4b5020..03d5a4348f3b 100644 --- a/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataProviderTest.cs +++ b/src/Mvc/Mvc.Core/test/ApplicationModels/EndpointMetadataProviderTest.cs @@ -126,7 +126,7 @@ public void DiscoversMetadata_CorrectOrder() m => Assert.True(m is RouteNameMetadata), m => Assert.True(m is SuppressLinkGenerationMetadata), m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Finally }), - m => Assert.True(m is IRouteDiagnosticsMetadata { Route: "/{controller}/{action}/{id?}" })); + m => Assert.True(m is IRouteDiagnosticsMetadata { Route: "/Test/ActionWithParameterMetadata/{id?}" })); } [Theory] From 4404a33d5dd37598b24341b64d7b2ea37b2704ec Mon Sep 17 00:00:00 2001 From: tomerqodo Date: Tue, 13 Jan 2026 21:23:36 +0200 Subject: [PATCH 8/8] Apply changes for benchmark PR --- .../src/Patterns/RoutePatternDebugStringFormatter.cs | 6 +++--- src/Http/Routing/src/Patterns/RoutePatternParameterPart.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Http/Routing/src/Patterns/RoutePatternDebugStringFormatter.cs b/src/Http/Routing/src/Patterns/RoutePatternDebugStringFormatter.cs index 37560f3b2f57..8930f1206345 100644 --- a/src/Http/Routing/src/Patterns/RoutePatternDebugStringFormatter.cs +++ b/src/Http/Routing/src/Patterns/RoutePatternDebugStringFormatter.cs @@ -27,7 +27,7 @@ public static string Format(RoutePattern pattern) segments[i] = segmentString; } - var result = string.Join(Separator, segments); + var result = string.Join(SeparatorString, segments); // Preserve leading slash from raw text if (pattern.RawText is { Length: > 0 } rt && rt[0] == Separator) @@ -71,6 +71,7 @@ private static string GetSegmentDebuggerToString(RoutePattern pattern, RoutePatt { return requiredValue; } + return parameter.DebuggerToString(); } // For complex segments, build the string part by part @@ -89,9 +90,8 @@ private static string GetSegmentDebuggerToString(RoutePattern pattern, RoutePatt private static bool TryGetRequiredValue(RoutePattern pattern, string parameterName, [NotNullWhen(true)] out string? value) { if (pattern.RequiredValues.TryGetValue(parameterName, out var requiredValue) && - requiredValue is not null && !RoutePattern.IsRequiredValueAny(requiredValue) && - requiredValue.ToString() is { Length: > 0 } v) + requiredValue?.ToString() is { Length: > 0 } v) { value = v; return true; diff --git a/src/Http/Routing/src/Patterns/RoutePatternParameterPart.cs b/src/Http/Routing/src/Patterns/RoutePatternParameterPart.cs index f1379d4d57ca..38c1b2e1d45c 100644 --- a/src/Http/Routing/src/Patterns/RoutePatternParameterPart.cs +++ b/src/Http/Routing/src/Patterns/RoutePatternParameterPart.cs @@ -106,7 +106,7 @@ internal override string DebuggerToString() else if (constraint.ParameterPolicy is Constraints.RegexRouteConstraint regexConstraint) { builder.Append("regex("); - builder.Append(regexConstraint.Constraint); + builder.Append(regexConstraint.Constraint.ToString()); builder.Append(')'); } else if (constraint.ParameterPolicy is not null)