Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9c19167
fix: read problem details without the configured serializer
glennawatson Nov 3, 2026
411d592
refactor: move reflection entry points into the reflection packages
glennawatson Nov 3, 2026
d10ecb5
refactor: drop reflection attributes from the request builder contracts
glennawatson Nov 3, 2026
900d22d
feat: generate serializer-specific form field names
glennawatson Nov 3, 2026
6cc219b
refactor: move reflection form flattening into Refit.Reflection
glennawatson Nov 3, 2026
e7f2330
refactor: move serializer field-name lookup into Refit.Reflection
glennawatson Nov 3, 2026
20c8d34
refactor: move enum member lookup into Refit.Reflection
glennawatson Nov 3, 2026
de27bf5
refactor: move the reflection method metadata types into Refit.Reflec…
glennawatson Nov 3, 2026
34a29d5
refactor: move the System.Text.Json reflection fallbacks into Refit.R…
glennawatson Nov 3, 2026
cd31cc7
test: cover the reflection support and drop the last converter read
glennawatson Nov 3, 2026
e2305c5
refactor: mark the Newtonsoft and XML serializers as not trim or AOT …
glennawatson Nov 3, 2026
de40136
test: cover the enum lookup without a registered resolver
glennawatson Nov 3, 2026
41e749a
refactor!: replace ICustomAttributeProvider with IParameterAttributeP…
glennawatson Nov 3, 2026
d9818ab
refactor: remove the DynamicDependency attribute from generated regis…
glennawatson Nov 3, 2026
ea0270e
docs: ban LINQ in production code
glennawatson Nov 3, 2026
9c06fac
refactor: keep DefaultJsonTypeInfoResolver out of core Refit
glennawatson Nov 3, 2026
7ad79a6
refactor: drop redundant null and range checks
glennawatson Nov 3, 2026
fe7ba8e
test: cover a null global Newtonsoft settings factory
glennawatson Nov 3, 2026
df1638b
test: fix repeated literal in the Newtonsoft settings test
glennawatson Nov 3, 2026
1b309b5
test: cover body model equality and interpolated brace escaping
glennawatson Nov 3, 2026
8c91224
test: cover more async disposal, route binding and query parsing bran…
glennawatson Nov 3, 2026
96e0c53
test: reach full line and branch coverage
glennawatson Nov 3, 2026
7a531f4
fix: repair CI for .NET Framework, Native AOT and shared serializers
glennawatson Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 2 additions & 2 deletions .github/workflows/native-aot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
with:
projectFile: src/examples/Documentation/Aot/NativeClient/NativeClient.csproj

# Packs Refit, Refit.HttpClientFactory and Refit.Testing once for the package consumer below.
# Packs Refit, Refit.Reflection, Refit.HttpClientFactory and Refit.Testing once for the package consumer below.
pack-for-package-consumer:
runs-on: ubuntu-latest
env:
Expand All @@ -46,7 +46,7 @@ jobs:
shell: bash
working-directory: src
run: |
for project in Refit Refit.HttpClientFactory Refit.Testing; do
for project in Refit Refit.Reflection Refit.HttpClientFactory Refit.Testing; do
dotnet pack "$project/$project.csproj" --configuration Release --output "$GITHUB_WORKSPACE/aot-feed"
done

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ dotnet run --project "tests/Refit.GeneratorTests/Refit.GeneratorTests.csproj" -f
- Shared target frameworks and package versions are centralized in `src/Directory.Build.props` and `src/Directory.Packages.props`.
- The repository uses `LangVersion=latest`; use modern C# features where they improve clarity or generated-code quality.
- AOT and trim analyzer behavior is enabled for compatible TFMs; prefer APIs that avoid reflection, dynamic code, and runtime type lookup.
- **No LINQ in production code.** Never use `System.Linq` (`Select`, `Where`, `OfType`, `FirstOrDefault`, `ToArray` and so on) in any shipping project, including the generator, analyzers and examples. Use plain loops. LINQ is allowed in test projects only.
- Keep changes scoped. Do not rewrite unrelated files while touching generator/runtime paths.
- Prefer focused tests that compile or execute the real generated output when changing source generator behavior.

Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
<PropertyGroup>
<RefitGeneratorTargets>netstandard2.0</RefitGeneratorTargets>
<RefitFrameworkTargets>net462;net470;net471;net472;net48;net481</RefitFrameworkTargets>
<RefitModernTargets>net8.0;net9.0;net10.0;net11.0</RefitModernTargets>
<RefitModernTargets>net10.0;net11.0</RefitModernTargets>
<RefitShippingTargets>$(RefitFrameworkTargets);$(RefitModernTargets)</RefitShippingTargets>
<RefitTestTargets>$(RefitModernTargets)</RefitTestTargets>
<RefitBenchmarkTargets>$(RefitModernTargets)</RefitBenchmarkTargets>
Expand Down
32 changes: 28 additions & 4 deletions src/InterfaceStubGenerator.Shared/BodyContentEmission.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,9 @@ internal static string BuildInlineFormUnroll(
}

// CanUnrollForm rejects the null, HttpContent, Stream, string, and dictionary bodies the reflection path
// special-cases; a non-System.Text.Json serializer resolves field names differently, so it falls back too.
// special-cases; a serializer that does not report its name style resolves field names differently, so it falls back too.
return $$"""
{{bodyIndent}}if ({{settingsLocal}}.ContentSerializer is global::Refit.SystemTextJsonContentSerializer
{{bodyIndent}}if ({{settingsLocal}}.ContentSerializer is global::Refit.IContentSerializerNameStyle
{{inner}} && global::Refit.GeneratedRequestRunner.CanUnrollForm({{bodyExpr}}))
{{bodyIndent}}{
{{inner}}var {{entriesLocal}} = new global::System.Collections.Generic.List<{{kvpType}}>({{fields.Length}});
Expand Down Expand Up @@ -206,8 +206,7 @@ internal static void AppendFormFieldUnroll(
var propertyNameLiteral = Emitter.ToCSharpStringLiteral(field.PropertyName);
var explicitNameLiteral = LiteralEmissionHelpers.ToNullableCSharpStringLiteral(field.ExplicitName);
var prefixSegmentLiteral = LiteralEmissionHelpers.ToNullableCSharpStringLiteral(field.PrefixSegment);
var keyExpr =
$"global::Refit.GeneratedRequestRunner.BuildQueryKey({emission.SettingsLocal}, {propertyNameLiteral}, {explicitNameLiteral}, {prefixSegmentLiteral})";
var keyExpr = BuildFormKeyExpression(field, emission.SettingsLocal, propertyNameLiteral, explicitNameLiteral, prefixSegmentLiteral);

_ = sb.Append(indent).Append("var ").Append(valueLocal).Append(" = ").Append(site.BodyExpr).Append(".@").Append(field.PropertyName).AppendLine(";");

Expand Down Expand Up @@ -239,6 +238,31 @@ internal static void AppendFormFieldUnroll(
_ = sb.Append(indent).AppendLine("}");
}

/// <summary>Builds the key expression for one unrolled form field, picking the serializer-aware helper when the field has serializer names.</summary>
/// <param name="field">The form field descriptor.</param>
/// <param name="settingsLocal">The settings local name.</param>
/// <param name="propertyNameLiteral">The CLR property name literal.</param>
/// <param name="explicitNameLiteral">The explicit name literal, or <c>null</c>.</param>
/// <param name="prefixSegmentLiteral">The prefix segment literal, or <c>null</c>.</param>
/// <returns>The key expression source.</returns>
internal static string BuildFormKeyExpression(
FormFieldModel field,
string settingsLocal,
string propertyNameLiteral,
string explicitNameLiteral,
string prefixSegmentLiteral)
{
if (field.JsonName is null && field.NewtonsoftName is null && field.XmlName is null)
{
return $"global::Refit.GeneratedRequestRunner.BuildQueryKey({settingsLocal}, {propertyNameLiteral}, {explicitNameLiteral}, {prefixSegmentLiteral})";
}

var json = LiteralEmissionHelpers.ToNullableCSharpStringLiteral(field.JsonName);
var newtonsoft = LiteralEmissionHelpers.ToNullableCSharpStringLiteral(field.NewtonsoftName);
var xml = LiteralEmissionHelpers.ToNullableCSharpStringLiteral(field.XmlName);
return $"global::Refit.GeneratedRequestRunner.BuildFormKey({settingsLocal}, {propertyNameLiteral}, {explicitNameLiteral}, {json}, {newtonsoft}, {xml}, {prefixSegmentLiteral})";
}

/// <summary>Appends one entry-list <c>Add</c> using an explicit <c>KeyValuePair</c> construction (no C# 9 target-typed new).</summary>
/// <param name="sb">The statement builder.</param>
/// <param name="site">The shared locals and rendered fragments for the enclosing body.</param>
Expand Down
6 changes: 4 additions & 2 deletions src/InterfaceStubGenerator.Shared/Emitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,11 @@ internal static class Emitter
/// <summary>Emits the shared preserve attribute and factory registration code.</summary>
/// <param name="model">The context generation model describing the interfaces.</param>
/// <param name="addSource">Callback used to add generated source files.</param>
/// <param name="support">How the registration is hooked to assembly load in the consumer compilation.</param>
internal static void EmitSharedCode(
ContextGenerationModel model,
Action<string, SourceText> addSource)
Action<string, SourceText> addSource,
ModuleInitializerSupport support = ModuleInitializerSupport.Guarded)
{
if (model.Interfaces.Length == 0)
{
Expand All @@ -86,7 +88,7 @@ internal static void EmitSharedCode(

var generatedFileHeader = BuildSharedGeneratedFileHeader(model.Interfaces, model.EmitGeneratedCodeMarkers);
SharedInfrastructureEmission.EmitPreserveAttribute(model, generatedFileHeader, addSource);
SharedInfrastructureEmission.EmitGeneratedFactoryModule(model, generatedFileHeader, addSource);
SharedInfrastructureEmission.EmitGeneratedFactoryModule(model, generatedFileHeader, addSource, support);
}

/// <summary>Emits the generated implementation source for a single interface.</summary>
Expand Down
22 changes: 22 additions & 0 deletions src/InterfaceStubGenerator.Shared/FormFieldEmission.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ namespace Refit.Generator;
/// <summary>Emits the cached HTTP method field and form-field descriptor array for the inline path.</summary>
internal static class FormFieldEmission
{
/// <summary>The opening of the grouped compile-time field names argument.</summary>
private const string FormFieldNamesNew = "new global::Refit.FormFieldNames(";

/// <summary>The closing of the grouped compile-time field names argument.</summary>
private const string FormFieldNamesClose = ")";

/// <summary>Resolves the HTTP method expression, caching a custom verb in a static field.</summary>
/// <param name="request">The parsed request model.</param>
/// <param name="uniqueNames">The interface-scope unique name builder.</param>
Expand Down Expand Up @@ -117,8 +123,16 @@ internal static string BuildFormFieldElements(
SpanBufferEmission.AppendText(destination, InlineRequestEmission.FormFieldNameOpen, ref position);
SpanBufferEmission.AppendText(destination, field.PropertyName, ref position);
SpanBufferEmission.AppendText(destination, InlineRequestEmission.FormFieldNameClose, ref position);
SpanBufferEmission.AppendText(destination, FormFieldNamesNew, ref position);
SpanBufferEmission.AppendLiteralOrNull(destination, field.ExplicitName, ref position);
SpanBufferEmission.AppendText(destination, InlineRequestEmission.ArgumentSeparator, ref position);
SpanBufferEmission.AppendLiteralOrNull(destination, field.JsonName, ref position);
SpanBufferEmission.AppendText(destination, InlineRequestEmission.ArgumentSeparator, ref position);
SpanBufferEmission.AppendLiteralOrNull(destination, field.NewtonsoftName, ref position);
SpanBufferEmission.AppendText(destination, InlineRequestEmission.ArgumentSeparator, ref position);
SpanBufferEmission.AppendLiteralOrNull(destination, field.XmlName, ref position);
SpanBufferEmission.AppendText(destination, FormFieldNamesClose, ref position);
SpanBufferEmission.AppendText(destination, InlineRequestEmission.ArgumentSeparator, ref position);
SpanBufferEmission.AppendLiteralOrNull(destination, field.PrefixSegment, ref position);
SpanBufferEmission.AppendText(destination, InlineRequestEmission.ArgumentSeparator, ref position);
SpanBufferEmission.AppendLiteralOrNull(destination, field.Format, ref position);
Expand Down Expand Up @@ -155,8 +169,16 @@ internal static int MeasureFormFieldElement(FormFieldModel field, string bodyTyp
+ InlineRequestEmission.FormFieldNameOpen.Length
+ field.PropertyName.Length
+ InlineRequestEmission.FormFieldNameClose.Length
+ FormFieldNamesNew.Length
+ LiteralEmissionHelpers.LiteralOrNullLength(field.ExplicitName)
+ InlineRequestEmission.ArgumentSeparator.Length
+ LiteralEmissionHelpers.LiteralOrNullLength(field.JsonName)
+ InlineRequestEmission.ArgumentSeparator.Length
+ LiteralEmissionHelpers.LiteralOrNullLength(field.NewtonsoftName)
+ InlineRequestEmission.ArgumentSeparator.Length
+ LiteralEmissionHelpers.LiteralOrNullLength(field.XmlName)
+ FormFieldNamesClose.Length
+ InlineRequestEmission.ArgumentSeparator.Length
+ LiteralEmissionHelpers.LiteralOrNullLength(field.PrefixSegment)
+ InlineRequestEmission.ArgumentSeparator.Length
+ LiteralEmissionHelpers.LiteralOrNullLength(field.Format)
Expand Down
16 changes: 16 additions & 0 deletions src/InterfaceStubGenerator.Shared/InlineRequestEmission.cs
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,22 @@ internal static string BuildRelativeUriExpression(
return null;
}

/// <summary>Determines whether the method's <c>[Url]</c> parameter accepts relative URI references.</summary>
/// <param name = "request">The request model to inspect.</param>
/// <returns><see langword="true"/> when a <c>[Url]</c> parameter exists and allows relative URIs.</returns>
internal static bool AllowsRelativeUrl(in RequestModel request)
{
foreach (var parameter in request.Bindings.Parameters)
{
if (parameter.Kind == RequestParameterKind.Url)
{
return parameter.AllowRelativeUrl;
}
}

return false;
}

/// <summary>Builds the cancellation token expression for an inline generated method.</summary>
/// <param name = "request">The request model to inspect.</param>
/// <returns>The cancellation token expression.</returns>
Expand Down
40 changes: 32 additions & 8 deletions src/InterfaceStubGenerator.Shared/InterfaceMemberParsing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,19 +248,43 @@ internal static MethodModel ParseNonRefitMethod(
/// <c>Name`1</c> corresponds to <c>(Name, 1)</c>, so this classifies identically.</remarks>
internal static ReturnTypeInfo GetReturnTypeInfo(IMethodSymbol methodSymbol) =>
methodSymbol.ReturnType is INamedTypeSymbol named
? (named.Name, named.Arity) switch
? named.Arity switch
{
("Task", 0) => ReturnTypeInfo.AsyncVoid,
("Task", 1) when InlineRequestClassification.IsHttpRequestMessageType(named.TypeArguments[0]) => ReturnTypeInfo.RequestMessage,
("Task", 1) or ("ValueTask", 1) => ReturnTypeInfo.AsyncResult,
("IAsyncEnumerable", 1) => ReturnTypeInfo.AsyncEnumerable,
("IObservable", 1) => ReturnTypeInfo.Observable,
(PagedEnumerableName, PagedEnumerableArity) when RequestParameterParsingHelpers.IsInNamespace(named, "Refit") => ReturnTypeInfo.Paged,
("Void", 0) => ReturnTypeInfo.SyncVoid,
0 => ClassifyNonGenericReturn(named.Name),
1 => ClassifySingleArgumentReturn(named),
PagedEnumerableArity => named.Name == PagedEnumerableName && RequestParameterParsingHelpers.IsInNamespace(named, "Refit")
? ReturnTypeInfo.Paged
: ReturnTypeInfo.Return,
_ => ReturnTypeInfo.Return
}
: ReturnTypeInfo.Return;

/// <summary>Classifies a return type that has no type arguments.</summary>
/// <param name="name">The type's name.</param>
/// <returns>The classified return type information.</returns>
internal static ReturnTypeInfo ClassifyNonGenericReturn(string name) =>
name switch
{
"Task" => ReturnTypeInfo.AsyncVoid,
"Void" => ReturnTypeInfo.SyncVoid,
_ => ReturnTypeInfo.Return
};

/// <summary>Classifies a return type that has one type argument.</summary>
/// <param name="named">The return type.</param>
/// <returns>The classified return type information.</returns>
internal static ReturnTypeInfo ClassifySingleArgumentReturn(INamedTypeSymbol named) =>
named.Name switch
{
"Task" => InlineRequestClassification.IsHttpRequestMessageType(named.TypeArguments[0])
? ReturnTypeInfo.RequestMessage
: ReturnTypeInfo.AsyncResult,
"ValueTask" => ReturnTypeInfo.AsyncResult,
"IAsyncEnumerable" => ReturnTypeInfo.AsyncEnumerable,
"IObservable" => ReturnTypeInfo.Observable,
_ => ReturnTypeInfo.Return
};

/// <summary>Collects the distinct member names declared on the interface, preserving order.</summary>
/// <param name="members">The directly declared members of the interface.</param>
/// <returns>The distinct member names.</returns>
Expand Down
11 changes: 7 additions & 4 deletions src/InterfaceStubGenerator.Shared/InterfaceSemanticParsing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ internal static InterfaceModel[] ParseMethodInterface(
MethodDeclarationSyntax method,
CancellationToken cancellationToken)
{
var symbol = semanticModel.GetDeclaredSymbol(method, cancellationToken);
// A method declaration in the model's own syntax tree always has a declared symbol.
var symbol = semanticModel.GetDeclaredSymbol(method, cancellationToken)!;
var attribute = semanticModel.Compilation.GetTypeByMetadataName("Refit.HttpMethodAttribute");
if (symbol is null || attribute is null || !Parser.IsRefitMethod(symbol, attribute))
if (attribute is null || !Parser.IsRefitMethod(symbol, attribute))
{
return [];
}
Expand Down Expand Up @@ -289,9 +290,11 @@ private static InterfaceModel ParseInterface(
}

methods.Add(method);
if (!compilationNullableEnabled && method.DeclaringSyntaxReferences is { IsEmpty: false } references)

// A method declared in source always has at least one declaring syntax reference.
if (!compilationNullableEnabled)
{
nullableEnabled = IsNullableEnabled(references[0], semanticModel);
nullableEnabled = IsNullableEnabled(method.DeclaringSyntaxReferences[0], semanticModel);
}
}

Expand Down
24 changes: 22 additions & 2 deletions src/InterfaceStubGenerator.Shared/InterfaceStubGeneratorV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

Expand Down Expand Up @@ -109,9 +110,28 @@ public void Initialize(
.WithTrackingName(RefitGeneratorStepName.BuildRefit);
context.EmitSource(interfaceModels);

var moduleInitializerSupport = context.CompilationProvider.Select(static (compilation, _) =>
DetectModuleInitializerSupport(compilation));

context.RegisterImplementationSourceOutput(
contextModel,
static (spc, model) => Emitter.EmitSharedCode(model, spc.AddSource));
contextModel.Combine(moduleInitializerSupport),
static (spc, combined) => Emitter.EmitSharedCode(combined.Left, spc.AddSource, combined.Right));
}

/// <summary>Decides how the generated factory registration can be hooked to assembly load in a compilation.</summary>
/// <param name="compilation">The consumer compilation.</param>
/// <returns>The module initializer support the compilation offers.</returns>
internal static ModuleInitializerSupport DetectModuleInitializerSupport(Compilation compilation)
{
// The compiler only recognises [ModuleInitializer] from C# 9. The generator only runs on C# compilations.
if (((CSharpCompilation)compilation).LanguageVersion < LanguageVersion.CSharp9)
{
return ModuleInitializerSupport.Guarded;
}

return compilation.GetTypeByMetadataName("System.Runtime.CompilerServices.ModuleInitializerAttribute") is null
? ModuleInitializerSupport.UnguardedWithPolyfill
: ModuleInitializerSupport.Unguarded;
}

/// <summary>Combines built-in and potential custom HTTP method candidates.</summary>
Expand Down
8 changes: 2 additions & 6 deletions src/InterfaceStubGenerator.Shared/MethodSignatureEmission.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ internal static InlineMethodFragments BuildInlineMethodFragments(

// A [Url] method dispatches to an absolute URI (the validated [Url] value with any query appended), bypassing
// the base-address merge; every other method builds a relative URI merged onto the base address.
var urlKind = InlineRequestEmission.FindRequestParameter(request, RequestParameterKind.Url)
?.AllowRelativeUrl is true
var urlKind = InlineRequestEmission.AllowsRelativeUrl(request)
? "RelativeOrAbsolute"
: "Absolute";
var requestUriExpression = InlineRequestEmission.HasUrlParameter(request)
Expand Down Expand Up @@ -110,10 +109,7 @@ internal static void AppendInlineStandardRefitMethod(

// A [Url] method dispatches to an absolute URI (the validated [Url] value with any query appended), bypassing
// the base-address merge; every other method builds a relative URI merged onto the base address.
var urlKind = InlineRequestEmission.FindRequestParameter(
request,
RequestParameterKind.Url)
?.AllowRelativeUrl is true
var urlKind = InlineRequestEmission.AllowsRelativeUrl(request)
? "RelativeOrAbsolute"
: "Absolute";
var requestUriExpression = InlineRequestEmission.HasUrlParameter(request)
Expand Down
Loading
Loading