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
6 changes: 4 additions & 2 deletions src/InterfaceStubGenerator.Shared/Parser.Request.Body.cs
Original file line number Diff line number Diff line change
Expand Up @@ -300,14 +300,16 @@ internal readonly record struct BodyAttributeInfo(
BodyBufferMode BufferMode);

/// <summary>Form-relevant data parsed from a <c>[Query]</c> attribute on a parameter or body property.</summary>
/// <param name="Delimiter">The delimiter combined with the prefix.</param>
/// <param name="Delimiter">The delimiter combined with the prefix, or <see langword="null"/> when no
/// <c>[Query]</c> attribute was present. An explicitly supplied empty delimiter is preserved as
/// <see cref="string.Empty"/> and must not be confused with the absent case.</param>
/// <param name="Prefix">The field name prefix, if any.</param>
/// <param name="Format">The value format, if any.</param>
/// <param name="CollectionFormatValue">The explicit collection format value, if any.</param>
/// <param name="SerializeNull">Whether null values are serialized as empty fields.</param>
/// <param name="TreatAsString">Whether the raw value is stringified via <c>ToString()</c> before formatting.</param>
internal readonly record struct QueryFormData(
string Delimiter,
string? Delimiter,
string? Prefix,
string? Format,
int? CollectionFormatValue,
Expand Down
2 changes: 1 addition & 1 deletion src/InterfaceStubGenerator.Shared/Parser.Request.Path.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ internal static bool TryBuildPathResidualQuery(
ElementCanBeNull: false,
BuildValueFormat(flattenType, null, formattableSymbol, context),
residual.ToImmutableEquatableArray(),
NestingDelimiter: string.IsNullOrEmpty(data.Delimiter) ? "." : data.Delimiter);
NestingDelimiter: data.Delimiter ?? DefaultNestingDelimiter);
return true;
}

Expand Down
62 changes: 32 additions & 30 deletions src/InterfaceStubGenerator.Shared/Parser.Request.Query.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ internal static partial class Parser
/// <summary>The maximum nested-object depth flattened inline before the whole parameter falls back to reflection.</summary>
private const int MaxNestingDepth = 32;

/// <summary>The delimiter joining nested query keys when no <c>[Query]</c> attribute supplies one.</summary>
/// <remarks>
/// This is only the fallback for the absent-attribute case. A <c>[Query]</c> attribute always carries a delimiter -
/// <c>QueryAttribute.Delimiter</c> itself defaults to <c>"."</c> - so an explicitly supplied empty delimiter
/// must be honoured rather than replaced with this value.
/// </remarks>
private const string DefaultNestingDelimiter = ".";

/// <summary>The metadata name of <c>Refit.CollectionFormat</c>.</summary>
private const string CollectionFormatTypeName = "Refit.CollectionFormat";

Expand Down Expand Up @@ -244,21 +252,18 @@ internal static bool TryBuildQueryModel(
? nullableObject.TypeArguments[0]
: parameter.Type;

if (TryBuildQueryObjectProperties(objectType, parameterPrefixSegment, formattableSymbol, context) is not { } properties)
{
return null;
}

return new(
urlName,
QueryParameterShape.Object,
TreatAsString: false,
preEncoded,
data.CollectionFormatValue,
ElementCanBeNull: false,
BuildValueFormat(parameter.Type, format, formattableSymbol, context),
properties,
NestingDelimiter: string.IsNullOrEmpty(data.Delimiter) ? "." : data.Delimiter);
return TryBuildQueryObjectProperties(objectType, parameterPrefixSegment, formattableSymbol, context) is not { } properties
? null
: new(
urlName,
QueryParameterShape.Object,
TreatAsString: false,
preEncoded,
data.CollectionFormatValue,
ElementCanBeNull: false,
BuildValueFormat(parameter.Type, format, formattableSymbol, context),
properties,
NestingDelimiter: data.Delimiter ?? DefaultNestingDelimiter);
}

/// <summary>Builds the query model for a collection-of-simple-elements parameter, or null for any other shape.</summary>
Expand Down Expand Up @@ -344,21 +349,18 @@ internal static bool TryBuildQueryModel(
return null;
}

if (TryBuildQueryObjectProperties(elementType!, null, formattableSymbol, context) is not { } elementProperties)
{
return null;
}

return new(
urlName,
QueryParameterShape.IndexedCollection,
TreatAsString: false,
preEncoded,
data.CollectionFormatValue,
CanElementBeNull(elementType!),
BuildValueFormat(elementType!, format, formattableSymbol, context),
elementProperties,
NestingDelimiter: string.IsNullOrEmpty(data.Delimiter) ? "." : data.Delimiter);
return TryBuildQueryObjectProperties(elementType!, null, formattableSymbol, context) is not { } elementProperties
? null
: new(
urlName,
QueryParameterShape.IndexedCollection,
TreatAsString: false,
preEncoded,
data.CollectionFormatValue,
CanElementBeNull(elementType!),
BuildValueFormat(elementType!, format, formattableSymbol, context),
elementProperties,
NestingDelimiter: data.Delimiter ?? DefaultNestingDelimiter);
}

/// <summary>Builds the query model for a <c>[QueryConverter]</c> parameter, or null when the type is unresolved.</summary>
Expand Down
32 changes: 27 additions & 5 deletions src/tests/Refit.GeneratorTests/IndexedCollectionGenerationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ public interface IGeneratedClient
}
""";

/// <summary>The expected query key prefix for an Indexed collection parameter, which is <c>items[{</c> for the test sources above.</summary>
private const string ExpectedItemsQueryKeyPrefix = "items[{";

/// <summary>Source where the element type has a nested object property - should fall back to reflection
/// because the nested type is complex and the reflection builder walks runtime types.</summary>
private const string IndexedWithSimpleScalarCollectionSource =
Expand Down Expand Up @@ -150,15 +153,34 @@ public async Task NonNullableIndexedCollectionGeneratesInline()
await Assert.That(result.GeneratedSources[Hint]).DoesNotContain(ReflectiveFallback);
}

/// <summary>Verifies an empty Indexed nesting delimiter falls back to the standard dot delimiter.</summary>
/// <summary>
/// Verifies an explicitly empty Indexed nesting delimiter joins the indexed key to the element's property name
/// with no separator at all, rather than falling back to the dot. The reflection builder composes this key as
/// <c>indexedKey + attr.Delimiter + propertyKey</c> with the delimiter used verbatim, so substituting the dot
/// for an explicitly empty delimiter would make the generated client disagree with it.
/// </summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
public async Task EmptyIndexedDelimiterUsesDotFallback()
public async Task EmptyIndexedDelimiterConcatenatesWithoutSeparator()
{
var result = Fixture.RunGenerator(EmptyDelimiterIndexedSource, generatedRequestBuilding: true);

await Assert.That(result.CompilesWithoutErrors).IsTrue();
await Assert.That(result.GeneratedSources[Hint]).Contains("items[{");
await Assert.That(result.GeneratedSources[Hint]).Contains(ExpectedItemsQueryKeyPrefix);
await Assert.That(result.GeneratedSources[Hint]).Contains("}Id");
await Assert.That(result.GeneratedSources[Hint]).DoesNotContain("}.Id");
}

/// <summary>Verifies an Indexed parameter that supplies no delimiter still composes its keys under the dot,
/// which is the delimiter <c>[Query]</c> itself defaults to.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
public async Task UnspecifiedIndexedDelimiterUsesDot()
{
var result = Fixture.RunGenerator(NullableIndexedSource, generatedRequestBuilding: true);

await Assert.That(result.CompilesWithoutErrors).IsTrue();
await Assert.That(result.GeneratedSources[Hint]).Contains(ExpectedItemsQueryKeyPrefix);
await Assert.That(result.GeneratedSources[Hint]).Contains("}.Id");
}

Expand Down Expand Up @@ -195,7 +217,7 @@ public async Task GeneratedSourceContainsIndexedKeyExpression()

await Assert.That(result.CompilesWithoutErrors).IsTrue();

await Assert.That(generated).Contains("items[{");
await Assert.That(generated).Contains(ExpectedItemsQueryKeyPrefix);
}

/// <summary>Verifies a Indexed parameter whose element type is a scalar (not complex) generates inline
Expand Down Expand Up @@ -229,7 +251,7 @@ public interface IGeneratedClient
}

/// <summary>Verifies a Indexed parameter whose element type is a collection with a complex element type falls back to reflective generation.</summary>
/// <returns>A task representing the asynchronous test.</returns>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
public async Task IndexedWithComplexIndexElementFallsBackToReflective()
{
Expand Down
13 changes: 13 additions & 0 deletions src/tests/Refit.Tests/IQueryObjectApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ public interface IQueryObjectApi
[Get("/nested")]
Task<string> FlattenNested([Query] NestedQueryObject query);

/// <summary>Flattens a query object with a nested object property under an explicitly empty delimiter, so the
/// nested keys are concatenated with no separator at all.</summary>
/// <param name="query">The query object.</param>
/// <returns>The response body.</returns>
[Get("/nested/empty")]
Task<string> FlattenNestedWithEmptyDelimiter([Query(delimiter: "")] NestedQueryObject query);

/// <summary>Flattens a query object with a nested object property under a custom non-dotted delimiter.</summary>
/// <param name="query">The query object.</param>
/// <returns>The response body.</returns>
[Get("/nested/colon")]
Task<string> FlattenNestedWithCustomDelimiter([Query(delimiter: ":")] NestedQueryObject query);

/// <summary>Flattens a query object with a nullable nested value-type property under a dotted key.</summary>
/// <param name="query">The query object.</param>
/// <returns>The response body.</returns>
Expand Down
33 changes: 33 additions & 0 deletions src/tests/Refit.Tests/QueryObjectFlatteningTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,39 @@ await AssertParityAsync(
query);
}

/// <summary>
/// Verifies an explicitly empty <c>[Query]</c> delimiter joins nested keys with nothing at all, rather than
/// falling back to the default dot. An absent attribute and an explicitly empty delimiter are different things.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Test]
public async Task EmptyDelimiterJoinsNestedKeysWithoutSeparator()
{
var query = new NestedQueryObject { Name = "ada", Address = new AddressQuery { City = "wien", Zip = "1010" } };

await AssertParityAsync(
"/nested/empty?Name=ada&AddressCity=wien&Addressz=1010",
api => api.FlattenNestedWithEmptyDelimiter(query),
new RequestBuilderImplementation<IQueryObjectApi>()
.BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenNestedWithEmptyDelimiter)),
query);
}

/// <summary>Verifies a custom non-dotted <c>[Query]</c> delimiter joins nested keys, matching the reflection builder.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Test]
public async Task CustomDelimiterJoinsNestedKeys()
{
var query = new NestedQueryObject { Name = "ada", Address = new AddressQuery { City = "wien", Zip = "1010" } };

await AssertParityAsync(
"/nested/colon?Name=ada&Address%3ACity=wien&Address%3Az=1010",
api => api.FlattenNestedWithCustomDelimiter(query),
new RequestBuilderImplementation<IQueryObjectApi>()
.BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenNestedWithCustomDelimiter)),
query);
}

/// <summary>Verifies a custom key formatter is applied to every nested key segment, matching the reflection builder.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Test]
Expand Down
Loading