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]