Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace Refit.Generator;
/// <param name="PreserveAttributeDisplayName">The display name of the generated preserve attribute.</param>
/// <param name="DisposableInterfaceSymbol">The <c>IDisposable</c> symbol, if available.</param>
/// <param name="HttpMethodBaseAttributeSymbol">The Refit HTTP method attribute symbol.</param>
/// <param name="FormattableSymbol">The <c>System.IFormattable</c> symbol used to classify inline-eligible path parameter types, if available.</param>
/// <param name="GeneratedRequestBuilding">Whether generated request construction is enabled.</param>
/// <param name="EmitGeneratedCodeMarkers">Whether generated files include generated-code analyzer skip markers.</param>
/// <param name="SupportsNullable">Whether the compilation supports nullable reference types.</param>
Expand All @@ -18,6 +19,7 @@ internal readonly record struct InterfaceGenerationContext(
string PreserveAttributeDisplayName,
ISymbol? DisposableInterfaceSymbol,
INamedTypeSymbol HttpMethodBaseAttributeSymbol,
INamedTypeSymbol? FormattableSymbol,
bool GeneratedRequestBuilding,
bool EmitGeneratedCodeMarkers,
bool SupportsNullable);
72 changes: 43 additions & 29 deletions src/InterfaceStubGenerator.Shared/Parser.Request.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -258,11 +258,13 @@ private static void AddHeadersAttributeValues(List<HeaderModel> headers, Attribu
/// <summary>Parses request parameter bindings for the conservative initial inline path.</summary>
/// <param name="parameters">The method parameters.</param>
/// <param name="parameterLocations">The placeholder names in the URL with their locations.</param>
/// <param name="formattableSymbol">The resolved <c>System.IFormattable</c> symbol used to classify inline-eligible path parameter types, or null when unavailable.</param>
/// <param name="canGenerateInline">Receives whether every parameter is supported.</param>
/// <returns>The parsed request parameter models.</returns>
private static ImmutableEquatableArray<RequestParameterModel> ParseRequestParameters(
in ImmutableArray<IParameterSymbol> parameters,
in ImmutableDictionary<string, List<Range>> parameterLocations,
INamedTypeSymbol? formattableSymbol,
out bool canGenerateInline)
{
if (parameters.Length == 0)
Expand All @@ -283,7 +285,7 @@ private static ImmutableEquatableArray<RequestParameterModel> 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;
Expand All @@ -302,8 +304,9 @@ private static ImmutableEquatableArray<RequestParameterModel> ParseRequestParame
/// <summary>Parses one request parameter binding.</summary>
/// <param name="parameter">The parameter to parse.</param>
/// <param name="locations">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.</param>
/// <param name="formattableSymbol">The resolved <c>System.IFormattable</c> symbol used to classify inline-eligible path parameter types, or null when unavailable.</param>
/// <returns>The parsed parameter and eligibility counters.</returns>
private static ParsedRequestParameter ParseRequestParameter(IParameterSymbol parameter, ImmutableEquatableArray<Range>? locations)
private static ParsedRequestParameter ParseRequestParameter(IParameterSymbol parameter, ImmutableEquatableArray<Range>? locations, INamedTypeSymbol? formattableSymbol)
{
var parameterType = parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var canBeNull = CanBeNull(parameter.Type, parameter.NullableAnnotation);
Expand Down Expand Up @@ -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;
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/InterfaceStubGenerator.Shared/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Diagnostic>();
if (httpMethodBaseAttributeSymbol is null)
Expand Down Expand Up @@ -112,6 +113,7 @@ public static (
preserveAttributeDisplayName,
disposableInterfaceSymbol,
httpMethodBaseAttributeSymbol,
formattableSymbol,
generatedRequestBuilding,
emitGeneratedCodeMarkers,
supportsNullable);
Expand Down
82 changes: 82 additions & 0 deletions src/tests/Refit.GeneratorTests/PathParameterTypeTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Verifies which path-parameter types generated request building supports inline.</summary>
public sealed class PathParameterTypeTests
{
/// <summary>The generated client hint name.</summary>
private const string GeneratedClientHintName = "IGeneratedClient.g.cs";

/// <summary>The reflection request-builder call emitted when a method falls back.</summary>
private const string ReflectiveRequestBuilderCall = "BuildRestResultFuncForMethod";

/// <summary>Verifies scalar path-parameter types (string/bool plus every IFormattable) generate inline.</summary>
/// <param name="parameterType">The path parameter type expression.</param>
/// <returns>A task representing the asynchronous test.</returns>
[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<string> Get({parameterType} value);");

await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall);
}

/// <summary>Verifies non-scalar path-parameter types fall back to the reflection request builder.</summary>
/// <param name="parameterType">The path parameter type expression.</param>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
[Arguments("byte[]")]
[Arguments("int[]")]
[Arguments("System.Collections.Generic.List<int>")]
public async Task NonScalarPathParameterFallsBack(string parameterType)
{
var generated = Generate($"[Get(\"/items/{{value}}\")] Task<string> Get({parameterType} value);");

await Assert.That(generated).Contains(ReflectiveRequestBuilderCall);
}

/// <summary>Runs the generator over an interface body and returns the generated client source.</summary>
/// <param name="body">The interface member body source.</param>
/// <returns>The generated client source text.</returns>
private static string Generate(string body) =>
Fixture.RunGenerator(BuildSource(body), generatedRequestBuilding: true)
.GeneratedSources[GeneratedClientHintName];

/// <summary>Wraps an interface body in a compilable Refit client source.</summary>
/// <param name="body">The interface member body source.</param>
/// <returns>The full source string.</returns>
private static string BuildSource(string body) =>
$$"""
using System;
using System.Threading.Tasks;
using Refit;

namespace RefitGeneratorTest;

public interface IGeneratedClient
{
{{body}}
}
""";
}
6 changes: 6 additions & 0 deletions src/tests/Refit.Tests/IGeneratedParametersApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,10 @@ public interface IGeneratedParametersApi
/// <returns>The response body.</returns>
[Get("/a/{value}/b")]
Task<string> GetNullableParam(int? value);

/// <summary>Gets a value using a DateOnly path parameter.</summary>
/// <param name="date">The date path parameter.</param>
/// <returns>The response body.</returns>
[Get("/events/{date}")]
Task<string> GetDateOnlyPath(DateOnly date);
}
28 changes: 28 additions & 0 deletions src/tests/Refit.Tests/RestServiceIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,34 @@ public async Task GetWithPathParameterGenerated()
await handler.VerifyAllCalledAsync();
}

/// <summary>Verifies a DateOnly path parameter is formatted and substituted by the generated client.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[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<IGeneratedParametersApi>(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}");
}

/// <summary>Verifies a query parameter is formatted correctly.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
Expand Down
Loading