diff --git a/src/InterfaceStubGenerator.Shared/Models/InterfaceGenerationContext.cs b/src/InterfaceStubGenerator.Shared/Models/InterfaceGenerationContext.cs
index 4ad7f4705..b57605785 100644
--- a/src/InterfaceStubGenerator.Shared/Models/InterfaceGenerationContext.cs
+++ b/src/InterfaceStubGenerator.Shared/Models/InterfaceGenerationContext.cs
@@ -10,6 +10,7 @@ namespace Refit.Generator;
/// The display name of the generated preserve attribute.
/// The IDisposable symbol, if available.
/// The Refit HTTP method attribute symbol.
+/// The System.IFormattable symbol used to classify inline-eligible path parameter types, if available.
/// Whether generated request construction is enabled.
/// Whether generated files include generated-code analyzer skip markers.
/// Whether the compilation supports nullable reference types.
@@ -18,6 +19,7 @@ internal readonly record struct InterfaceGenerationContext(
string PreserveAttributeDisplayName,
ISymbol? DisposableInterfaceSymbol,
INamedTypeSymbol HttpMethodBaseAttributeSymbol,
+ INamedTypeSymbol? FormattableSymbol,
bool GeneratedRequestBuilding,
bool EmitGeneratedCodeMarkers,
bool SupportsNullable);
diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.cs
index 9bad517ba..2ee10737e 100644
--- a/src/InterfaceStubGenerator.Shared/Parser.Request.cs
+++ b/src/InterfaceStubGenerator.Shared/Parser.Request.cs
@@ -37,7 +37,7 @@ private static RequestModel ParseRequest(
var normalizedPath = NormalizeConstantPathForInline(path);
var pathParameters = ExtractPathParameterPlaceholderNames(normalizedPath);
var returnTypes = GetRequestReturnTypes(methodSymbol);
- var parameters = ParseRequestParameters(methodSymbol.Parameters, pathParameters.ToImmutableDictionary(pathParameters.Comparer), out var parameterEligibility);
+ var parameters = ParseRequestParameters(methodSymbol.Parameters, pathParameters.ToImmutableDictionary(pathParameters.Comparer), context.FormattableSymbol, out var parameterEligibility);
var staticHeaders = ParseStaticHeaders(methodSymbol);
var canGenerateInline =
@@ -258,11 +258,13 @@ private static void AddHeadersAttributeValues(List headers, Attribu
/// Parses request parameter bindings for the conservative initial inline path.
/// The method parameters.
/// The placeholder names in the URL with their locations.
+ /// The resolved System.IFormattable symbol used to classify inline-eligible path parameter types, or null when unavailable.
/// Receives whether every parameter is supported.
/// The parsed request parameter models.
private static ImmutableEquatableArray ParseRequestParameters(
in ImmutableArray parameters,
in ImmutableDictionary> parameterLocations,
+ INamedTypeSymbol? formattableSymbol,
out bool canGenerateInline)
{
if (parameters.Length == 0)
@@ -283,7 +285,7 @@ private static ImmutableEquatableArray ParseRequestParame
var aliasAttr = parameter.GetAttributes().FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == "Refit.AliasAsAttribute");
var name = aliasAttr is not null ? GetFirstStringArgument(aliasAttr) ?? parameter.Name : parameter.Name;
_ = parameterLocations.TryGetValue(name, out var location);
- var parsedParameter = ParseRequestParameter(parameter, location?.ToImmutableEquatableArray());
+ var parsedParameter = ParseRequestParameter(parameter, location?.ToImmutableEquatableArray(), formattableSymbol);
requestParameters[i] = parsedParameter.Parameter;
bodyCount += parsedParameter.BodyCount;
cancellationTokenCount += parsedParameter.CancellationTokenCount;
@@ -302,8 +304,9 @@ private static ImmutableEquatableArray ParseRequestParame
/// Parses one request parameter binding.
/// The parameter to parse.
/// The parameter's locations in the URL template string. This is null if the parameter has no placeholder in the URL i.e. not a path parameter.
+ /// The resolved System.IFormattable symbol used to classify inline-eligible path parameter types, or null when unavailable.
/// The parsed parameter and eligibility counters.
- private static ParsedRequestParameter ParseRequestParameter(IParameterSymbol parameter, ImmutableEquatableArray? locations)
+ private static ParsedRequestParameter ParseRequestParameter(IParameterSymbol parameter, ImmutableEquatableArray? locations, INamedTypeSymbol? formattableSymbol)
{
var parameterType = parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var canBeNull = CanBeNull(parameter.Type, parameter.NullableAnnotation);
@@ -352,41 +355,52 @@ private static ParsedRequestParameter ParseRequestParameter(IParameterSymbol par
return new(propertyParameter, true, 0, 0, 0);
}
- return locations is {} l && IsSimpleType(parameter.Type)
+ return locations is {} l && IsSimpleType(parameter.Type, formattableSymbol)
? new(PathRequestParameter(parameter, parameterType, l), true, 0, 0, 0)
: new(UnsupportedRequestParameter(parameter, parameterType), false, 0, 0, 0);
- [SuppressMessage(
- "Maintainability",
- "SST1442: Functions should keep branching complexity low",
- Justification = "There are a lot of simple types supported and matching on them is a simple pattern match")]
- static bool IsSimpleType(ITypeSymbol type)
+ // A path value is emitted as UrlParameterFormatter.Format(value, provider, typeof(T)) - the same call the
+ // reflection path uses - so any type the formatter can render round-trips identically. That is exactly the
+ // set of IFormattable types (which is also what makes [Query(Format = ...)] and invariant culture work),
+ // plus string and bool, which are scalars but not IFormattable. Collections, arrays and DTOs are excluded
+ // and fall back to reflection. Matching on the resolved IFormattable symbol avoids per-parameter name-string
+ // allocations and automatically covers future BCL scalars.
+ static bool IsSimpleType(ITypeSymbol type, INamedTypeSymbol? formattableSymbol)
{
var underlyingType = GetUnderlyingType(type);
- return IsGuid(underlyingType) || IsDateTimeOffset(underlyingType) ||
- underlyingType.SpecialType is SpecialType.System_String or SpecialType.System_DateTime
- or SpecialType.System_Int32
- or SpecialType.System_Int64
- or SpecialType.System_Int16
- or SpecialType.System_Enum
- or SpecialType.System_UInt32 or SpecialType.System_UInt64
- or SpecialType.System_UInt16 or SpecialType.System_Char or SpecialType.System_Boolean
- or SpecialType.System_Byte or SpecialType.System_Decimal or SpecialType.System_Double
- or SpecialType.System_Single;
-
- static ITypeSymbol GetUnderlyingType(ITypeSymbol type)
- {
- return type is INamedTypeSymbol
- {
- OriginalDefinition.SpecialType: SpecialType.System_Nullable_T
- } nullable
+ // Built-in scalars resolve from SpecialType alone (a jump table, no interface walk); everything else that
+ // renders to a URL scalar - enums, Guid, DateTimeOffset, DateOnly, TimeOnly, TimeSpan, BigInteger,
+ // Int128/UInt128, Half - implements IFormattable.
+ return IsScalarSpecialType(underlyingType.SpecialType)
+ || ImplementsInterface(underlyingType, formattableSymbol);
+
+ static ITypeSymbol GetUnderlyingType(ITypeSymbol type) =>
+ type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable
? nullable.TypeArguments[0]
: type;
- }
- static bool IsGuid(ITypeSymbol type) => type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) is "System.Guid";
- static bool IsDateTimeOffset(ITypeSymbol type) => type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) is "System.DateTimeOffset";
+ // The built-in value-type scalars occupy a contiguous SpecialType block - System_Boolean (bool, char,
+ // every integer width, decimal, float, double) through System_Double - so a range check covers them all
+ // in one comparison. string and DateTime sit just outside that block.
+ static bool IsScalarSpecialType(SpecialType specialType) =>
+ specialType is (>= SpecialType.System_Boolean and <= SpecialType.System_Double)
+ or SpecialType.System_String
+ or SpecialType.System_DateTime;
+
+ // A null interfaceSymbol (System.IFormattable unresolved) simply matches nothing and falls back.
+ static bool ImplementsInterface(ITypeSymbol type, INamedTypeSymbol? interfaceSymbol)
+ {
+ foreach (var implemented in type.AllInterfaces)
+ {
+ if (SymbolEqualityComparer.Default.Equals(implemented, interfaceSymbol))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
}
}
diff --git a/src/InterfaceStubGenerator.Shared/Parser.cs b/src/InterfaceStubGenerator.Shared/Parser.cs
index 562fe69f5..980257235 100644
--- a/src/InterfaceStubGenerator.Shared/Parser.cs
+++ b/src/InterfaceStubGenerator.Shared/Parser.cs
@@ -56,6 +56,7 @@ public static (
var disposableInterfaceSymbol = compilation.GetTypeByMetadataName("System.IDisposable");
var httpMethodBaseAttributeSymbol = compilation.GetTypeByMetadataName(
"Refit.HttpMethodAttribute");
+ var formattableSymbol = compilation.GetTypeByMetadataName("System.IFormattable");
var diagnostics = new List();
if (httpMethodBaseAttributeSymbol is null)
@@ -112,6 +113,7 @@ public static (
preserveAttributeDisplayName,
disposableInterfaceSymbol,
httpMethodBaseAttributeSymbol,
+ formattableSymbol,
generatedRequestBuilding,
emitGeneratedCodeMarkers,
supportsNullable);
diff --git a/src/tests/Refit.GeneratorTests/PathParameterTypeTests.cs b/src/tests/Refit.GeneratorTests/PathParameterTypeTests.cs
new file mode 100644
index 000000000..04b551098
--- /dev/null
+++ b/src/tests/Refit.GeneratorTests/PathParameterTypeTests.cs
@@ -0,0 +1,82 @@
+// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
+// ReactiveUI and Contributors licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+namespace Refit.GeneratorTests;
+
+/// Verifies which path-parameter types generated request building supports inline.
+public sealed class PathParameterTypeTests
+{
+ /// The generated client hint name.
+ private const string GeneratedClientHintName = "IGeneratedClient.g.cs";
+
+ /// The reflection request-builder call emitted when a method falls back.
+ private const string ReflectiveRequestBuilderCall = "BuildRestResultFuncForMethod";
+
+ /// Verifies scalar path-parameter types (string/bool plus every IFormattable) generate inline.
+ /// The path parameter type expression.
+ /// A task representing the asynchronous test.
+ [Test]
+ [Arguments("string")]
+ [Arguments("bool")]
+ [Arguments("char")]
+ [Arguments("sbyte")]
+ [Arguments("byte")]
+ [Arguments("int")]
+ [Arguments("long")]
+ [Arguments("double")]
+ [Arguments("decimal")]
+ [Arguments("System.Guid")]
+ [Arguments("System.DateTime")]
+ [Arguments("System.DateTimeOffset")]
+ [Arguments("System.DateOnly")]
+ [Arguments("System.DateOnly?")]
+ [Arguments("System.TimeOnly")]
+ [Arguments("System.TimeSpan")]
+ [Arguments("System.Int128")]
+ [Arguments("System.Half")]
+ [Arguments("System.DayOfWeek")]
+ public async Task ScalarPathParameterGeneratesInline(string parameterType)
+ {
+ var generated = Generate($"[Get(\"/items/{{value}}\")] Task Get({parameterType} value);");
+
+ await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall);
+ }
+
+ /// Verifies non-scalar path-parameter types fall back to the reflection request builder.
+ /// The path parameter type expression.
+ /// A task representing the asynchronous test.
+ [Test]
+ [Arguments("byte[]")]
+ [Arguments("int[]")]
+ [Arguments("System.Collections.Generic.List")]
+ public async Task NonScalarPathParameterFallsBack(string parameterType)
+ {
+ var generated = Generate($"[Get(\"/items/{{value}}\")] Task Get({parameterType} value);");
+
+ await Assert.That(generated).Contains(ReflectiveRequestBuilderCall);
+ }
+
+ /// Runs the generator over an interface body and returns the generated client source.
+ /// The interface member body source.
+ /// The generated client source text.
+ private static string Generate(string body) =>
+ Fixture.RunGenerator(BuildSource(body), generatedRequestBuilding: true)
+ .GeneratedSources[GeneratedClientHintName];
+
+ /// Wraps an interface body in a compilable Refit client source.
+ /// The interface member body source.
+ /// The full source string.
+ private static string BuildSource(string body) =>
+ $$"""
+ using System;
+ using System.Threading.Tasks;
+ using Refit;
+
+ namespace RefitGeneratorTest;
+
+ public interface IGeneratedClient
+ {
+ {{body}}
+ }
+ """;
+}
diff --git a/src/tests/Refit.Tests/IGeneratedParametersApi.cs b/src/tests/Refit.Tests/IGeneratedParametersApi.cs
index b2e60470a..e9692456d 100644
--- a/src/tests/Refit.Tests/IGeneratedParametersApi.cs
+++ b/src/tests/Refit.Tests/IGeneratedParametersApi.cs
@@ -45,4 +45,10 @@ public interface IGeneratedParametersApi
/// The response body.
[Get("/a/{value}/b")]
Task GetNullableParam(int? value);
+
+ /// Gets a value using a DateOnly path parameter.
+ /// The date path parameter.
+ /// The response body.
+ [Get("/events/{date}")]
+ Task GetDateOnlyPath(DateOnly date);
}
diff --git a/src/tests/Refit.Tests/RestServiceIntegrationTests.cs b/src/tests/Refit.Tests/RestServiceIntegrationTests.cs
index 2baf447ff..68865fbbd 100644
--- a/src/tests/Refit.Tests/RestServiceIntegrationTests.cs
+++ b/src/tests/Refit.Tests/RestServiceIntegrationTests.cs
@@ -1036,6 +1036,34 @@ public async Task GetWithPathParameterGenerated()
await handler.VerifyAllCalledAsync();
}
+ /// Verifies a DateOnly path parameter is formatted and substituted by the generated client.
+ /// A task representing the asynchronous test.
+ [Test]
+ public async Task GetWithDateOnlyPathParameterGenerated()
+ {
+ Uri? captured = null;
+ var handler = new StubHttp
+ {
+ {
+ new RouteMatcher { Template = "*", Reusable = true },
+ Reply.From(request =>
+ {
+ captured = request.RequestUri;
+ return new(HttpStatusCode.OK) { Content = new StringContent("Ok") };
+ })
+ },
+ };
+ var fixture = handler.CreateGeneratedClient(BaseUrl);
+
+ var date = new DateOnly(2024, 1, 2);
+ _ = await fixture.GetDateOnlyPath(date);
+
+ var expected = ((IFormattable)date).ToString(null, System.Globalization.CultureInfo.InvariantCulture);
+ await Assert.That(captured).IsNotNull();
+ await Assert.That(Uri.UnescapeDataString(captured!.ToString()))
+ .IsEqualTo($"http://foo/events/{expected}");
+ }
+
/// Verifies a query parameter is formatted correctly.
/// A task representing the asynchronous test.
[Test]