Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0e99f00
Add generated request building support for path parameters in the URL.
calebkiage Jun 26, 2026
3566221
Exclude query templates from using generated request builders
calebkiage Jun 26, 2026
9e6fc79
Fix BuildRequestPath bug.
calebkiage Jun 28, 2026
89d876a
Merge branch 'main' into u/calebkiage/aot-for-path-parameters
calebkiage Jun 28, 2026
84aeff5
Merge branch 'main' into u/calebkiage/aot-for-path-parameters
calebkiage Jun 28, 2026
8ed5730
Fix failing RestServiceIntegrationTests.ForGeneratedUsesRegisteredFac…
calebkiage Jun 28, 2026
11acde7
Support UrlParameterFormatter in generated code.
calebkiage Jun 29, 2026
e2b6518
Merge branch 'main' into u/calebkiage/aot-for-path-parameters
glennawatson Jun 30, 2026
4dfe643
Fix ICustomAttributeProvider contract.
calebkiage Jun 30, 2026
043639c
Use ITypeSymbol.SpecialType to filter parameter types supported in so…
calebkiage Jun 30, 2026
1e492cc
Merge remote-tracking branch 'origin/u/calebkiage/aot-for-path-parame…
calebkiage Jun 30, 2026
b0ee2b4
Only emit GeneratedParameterAttributeProvider for path parameters.
calebkiage Jun 30, 2026
cd38f95
Remove dummy QueryAttribute parameter provider.
calebkiage Jun 30, 2026
62f9059
Merge branch 'main' into u/calebkiage/aot-for-path-parameters
glennawatson Jun 30, 2026
5166369
Remove dictionary cache when building the request path.
calebkiage Jul 1, 2026
2b66213
Merge remote-tracking branch 'origin/u/calebkiage/aot-for-path-parame…
calebkiage Jul 1, 2026
32366f8
Fix failing tests.
calebkiage Jul 1, 2026
4e88686
Fix SonarQube issue.
calebkiage Jul 1, 2026
26057e2
Add tests for GeneratedParameterAttributeProvider
calebkiage Jul 1, 2026
2b53bf3
Update request path builder to use precomputed locations instead of s…
calebkiage Jul 1, 2026
6507289
Merge branch 'main' into u/calebkiage/aot-for-path-parameters
glennawatson Jul 1, 2026
8c0267c
Fix test build errors.
calebkiage Jul 1, 2026
6f7295b
Merge branch 'main' into u/calebkiage/aot-for-path-parameters
glennawatson Jul 1, 2026
cade2af
fix: restore generator caching and green the path-parameter tests
glennawatson Jul 1, 2026
1a0b849
Merge remote-tracking branch 'origin/u/calebkiage/aot-for-path-parame…
calebkiage Jul 1, 2026
8d0f05d
Use Range type over a tuple for parameter location.
calebkiage Jul 1, 2026
0e4faf8
test: scrub generator version from snapshots so Verify is version-ind…
glennawatson Jul 1, 2026
3bce9b1
Merge remote-tracking branch 'origin/u/calebkiage/aot-for-path-parame…
calebkiage Jul 1, 2026
d70b3e5
URL escape value substitutions
calebkiage Jul 1, 2026
1ca0d85
In BuildRequestPath, fix an edge case where a URL like `/users/20/foo…
calebkiage Jul 1, 2026
e6b2103
Cleanup code.
calebkiage Jul 1, 2026
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ Refit's generated-request runtime helpers.
This default path reduces runtime reflection, method metadata lookup, object-array argument packing, and delegate
construction for request shapes the generator can safely model. It currently covers common request features including:

* parameters that appear in the path template
* static `[Headers]`
* dynamic `[Header]` parameters
* `[HeaderCollection]` dictionaries
Expand Down
157 changes: 155 additions & 2 deletions src/InterfaceStubGenerator.Shared/Emitter.Inline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// ReactiveUI and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.
using System.Diagnostics.CodeAnalysis;
using System.Text;

namespace Refit.Generator;

Expand Down Expand Up @@ -112,8 +113,28 @@ private static string BuildInlineRefitMethod(
var bodyParameter = FindRequestParameter(request, RequestParameterKind.Body);
var cancellationTokenExpression = BuildCancellationTokenExpression(request);
var bufferBodyExpression = BuildBufferBodyExpression(bodyParameter, settingsLocal);

// Build path
var parameterInfoNames = GetParameterInfoUniqueNames(request, uniqueNames);
var parameters = GetParametersArg(request, parameterInfoNames);
var paramInfoSb = new StringBuilder();

foreach (var parameter in request.Parameters)
{
if (parameter.Kind is not RequestParameterKind.Path)
{
continue;
}

var parameterName = parameterInfoNames[parameter.Name];
BuildParameterInfoField(parameter, methodModel.DeclaredMethod, parameterName, paramInfoSb);
}

var pathExpression = parameters.Length > 0
? $"global::Refit.GeneratedRequestRunner.BuildRequestPath({ToCSharpStringLiteral(request.Path)}, {settingsLocal}.AllowUnmatchedRouteParameters{parameters})"
: ToCSharpStringLiteral(request.Path);
var requestUriExpression =
$"global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, {ToCSharpStringLiteral(request.Path)}, {settingsLocal}.UrlResolution)";
$"global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, {pathExpression}, {settingsLocal}.UrlResolution)";
var (formFieldsSource, formFieldsFieldName) = BuildFormFieldsField(bodyParameter, uniqueNames);
var contentSource = bodyParameter is null ? string.Empty : BuildInlineContent(bodyParameter, requestLocal, settingsLocal, formFieldsFieldName);
var headerSource = BuildInlineHeaders(request, requestLocal);
Expand All @@ -123,7 +144,7 @@ private static string BuildInlineRefitMethod(
var bodyIndent = Indent(MethodBodyIndentation);

return $$"""
{{formFieldsSource}}{{BuildMethodOpening(methodModel, isExplicit, isExplicit, interfaceModel.SupportsNullable)}}{{bodyIndent}}var {{settingsLocal}} = {{settingsFieldName}};
{{paramInfoSb}}{{formFieldsSource}}{{BuildMethodOpening(methodModel, isExplicit, isExplicit, interfaceModel.SupportsNullable)}}{{bodyIndent}}var {{settingsLocal}} = {{settingsFieldName}};
{{bodyIndent}}var {{requestLocal}} = new global::System.Net.Http.HttpRequestMessage({{ToHttpMethodExpression(request.HttpMethod)}}, {{requestUriExpression}});
{{bodyIndent}}#if NET6_0_OR_GREATER
{{bodyIndent}}{{requestLocal}}.Version = {{settingsLocal}}.Version;
Expand All @@ -132,6 +153,138 @@ private static string BuildInlineRefitMethod(
{{contentSource}}{{headerSource}}{{requestPropertySource}}{{returnSource}}{{methodIndent}}}

""";

static string GetParameterInfoFieldName(string parameterName, UniqueNameBuilder uniqueNames) =>
uniqueNames.New($"______{parameterName}AttributeProvider");

static StringBuilder AppendSeparator(int i, StringBuilder sb, string separator = ", ")
{
return i <= 0 ? sb : sb.Append(separator);
}
static StringBuilder AppendJoining(string value, int i, StringBuilder sb, string separator = ", ")
{
return AppendSeparator(i, sb, separator).Append(value);
}

static void AppendAttributeValue(ParameterAttributeModel attribute, StringBuilder sb0)
{
_ = sb0.Append("new ").Append(attribute.TypeExpression).Append('(');
var i = 0;

foreach (var argument in attribute.ConstructorArguments)
{
_ = AppendJoining(argument, i++, sb0);
}

_ = sb0.Append(')');
if (attribute.NamedArguments.Count < 1)
{
return;
}

i = 0;
_ = sb0.Append("{ ");
foreach (var named in attribute.NamedArguments)
{
_ = AppendSeparator(i++, sb0);
_ = sb0.Append(named.Name).Append(" = ").Append(named.ValueExpression);
}

_ = sb0.Append(" }");
}
static void BuildParameterInfoField(RequestParameterModel parameter, string method, string paramInfoFieldName, StringBuilder sb)
{
// Build the initializer.
var memberIndent = Indent(MethodMemberIndentation);
Dictionary<string, List<ParameterAttributeModel>> grouped = new();

foreach (var attribute in parameter.Attributes)
{
var key = $"typeof({attribute.TypeExpression})";
if (grouped.TryGetValue(key, out var groupedAttributes))
{
groupedAttributes.Add(attribute);
}
else
{
grouped.Add(key, [attribute]);
}
}

const string dictType = "global::System.Collections.Generic.Dictionary<global::System.Type, object[]>";
_ = sb.AppendLine().Append(memberIndent).Append("/// <summary>Cached attribute provider for the generated ")
.Append(ToXmlDocumentationText(method)).Append(" method's ").Append(ToXmlDocumentationText(parameter.Name)).AppendLine(" parameter.</summary>")
.Append(memberIndent).Append("private static readonly global::Refit.GeneratedParameterAttributeProvider ").Append(paramInfoFieldName).Append(" = ")
.Append("new global::Refit.GeneratedParameterAttributeProvider(new ").Append(dictType).Append("()");
var i = 0;
if (grouped.Count > 0)
{
_ = sb.Append(" {");
foreach (var kv in grouped)
{
_ = AppendJoining("{ ", i++, sb).Append(kv.Key).Append(", new object[] { ");
foreach (var arg in kv.Value)
{
AppendAttributeValue(arg, sb);
}

_ = sb.Append("} }");
}

_ = sb.Append('}');
}

_ = sb.AppendLine(");");
}

static Dictionary<string, string> GetParameterInfoUniqueNames(
RequestModel request,
UniqueNameBuilder uniqueNames)
{
var dict = new Dictionary<string, string>();
foreach (var parameter in request.Parameters)
{
if (parameter.Kind is not RequestParameterKind.Path)
{
continue;
}

var parameterInfoFieldName = GetParameterInfoFieldName(parameter.Name, uniqueNames);
dict.Add(parameter.Name, parameterInfoFieldName);
}

return dict;
}
static string GetParametersArg(RequestModel request, Dictionary<string, string> uniqueNameLookup)
{
var parametersSb = new StringBuilder();
var pathLength = request.Path.Length;
foreach (var parameter in request.Parameters)
{
if (parameter.Kind is not RequestParameterKind.Path || parameter.Locations is null)
{
continue;
}

foreach (var location in parameter.Locations)
{
var start = location.Start.GetOffset(pathLength);
var end = location.End.GetOffset(pathLength);
_ = parametersSb.Append(", ").Append("((").Append(start).Append(", ").Append(end).Append("), ");
var parameterInfoFieldName = uniqueNameLookup[parameter.Name];
_ = parametersSb.Append("_settings.UrlParameterFormatter.Format(")
.Append(parameter.Name)
.Append(", ")
.Append(parameterInfoFieldName)
.Append(", ")
.Append("typeof(").Append(parameter.Type).Append(')')
.Append(')');
_ = parametersSb.Append(')');
}
}

return parametersSb.ToString();
}
}

/// <summary>Builds request content assignment for an inline generated method.</summary>
Expand Down
10 changes: 10 additions & 0 deletions src/InterfaceStubGenerator.Shared/Models/NamedAttributeArgument.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// 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.Generator;

/// <summary>A named attribute argument assignment already rendered as source expressions.</summary>
/// <param name="Name">The argument name.</param>
/// <param name="ValueExpression">The argument value expression.</param>
internal sealed record NamedAttributeArgument(string Name, string ValueExpression);
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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.Generator;

/// <summary>
/// A precomputed attribute to re-emit onto a generated parameter's attribute provider. The parser flattens the
/// Roslyn <c>AttributeData</c> into value-typed expressions so no symbols are held in the incremental cache.
/// </summary>
/// <param name="TypeExpression">The fully-qualified attribute type expression, e.g. <c>global::Refit.AliasAsAttribute</c>.</param>
/// <param name="ConstructorArguments">The attribute's constructor argument expressions, in order.</param>
/// <param name="NamedArguments">The attribute's named argument assignments.</param>
internal sealed record ParameterAttributeModel(
string TypeExpression,
ImmutableEquatableArray<string> ConstructorArguments,
ImmutableEquatableArray<NamedAttributeArgument> NamedArguments);
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,8 @@ internal enum RequestParameterKind
Property,

/// <summary>The parameter supplies the request cancellation token.</summary>
CancellationToken
CancellationToken,

/// <summary>The parameter supplies a value for a placeholder in the path.</summary>
Path
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// 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.Generator;

/// <summary>Parsed request-binding metadata for one method parameter.</summary>
/// <param name="Name">The parameter metadata name.</param>
/// <param name="Type">The fully-qualified parameter type.</param>
/// <param name="Locations">The parameter's location in the URL template string, when this is a path parameter.</param>
/// <param name="Attributes">The parameter's attributes.</param>
/// <param name="Kind">The generated request binding kind.</param>
/// <param name="CanBeNull">Whether generated code must null-check the parameter before dereferencing.</param>
/// <param name="HeaderName">The request header name, when this is a header parameter.</param>
Expand All @@ -15,6 +18,8 @@ namespace Refit.Generator;
internal sealed record RequestParameterModel(
string Name,
string Type,
ImmutableEquatableArray<Range>? Locations,
ImmutableEquatableArray<ParameterAttributeModel> Attributes,
RequestParameterKind Kind,
bool CanBeNull,
string HeaderName,
Expand Down
50 changes: 43 additions & 7 deletions src/InterfaceStubGenerator.Shared/Parser.Request.Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,49 @@ internal static partial class Parser
/// <summary>Determines whether the initial inline emitter can use the path as a literal URI.</summary>
/// <param name="path">The path to inspect.</param>
/// <returns><see langword="true"/> when the path is supported.</returns>
internal static bool IsConstantPathSupported(string path) =>
(path.Length == 0 || path[0] == '/')
&& path.IndexOf('{') < 0
&& path.IndexOf('}') < 0
&& path.IndexOf('\\') < 0
&& path.IndexOf('\r') < 0
&& path.IndexOf('\n') < 0;
internal static bool IsPathSupported(string path)
{
return (path.Length == 0 || path[0] == '/')
&& IsPathTemplateValid(path)
&& path.IndexOf('\\') < 0
&& path.IndexOf('\r') < 0
&& path.IndexOf('\n') < 0;

static bool IsPathTemplateValid(in ReadOnlySpan<char> path)
{
var openingBraces = 0;
var closingBraces = 0;

foreach (var c in path)
{
switch (c)
{
case '/' when openingBraces != closingBraces:
return false;
case '/':
{
openingBraces = 0;
closingBraces = 0;
break;
}

case '{':
{
++openingBraces;
break;
}

case '}':
{
++closingBraces;
break;
}
}
}

return openingBraces == closingBraces;
}
}

/// <summary>Normalizes constant inline paths to match the reflection request builder URI cleanup.</summary>
/// <param name="path">The source path from the HTTP method attribute.</param>
Expand Down
Loading
Loading