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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,25 @@ All packages in the repository are versioned together.

### Changed

- **Query keys are camelCased from their identifiers by default**, and a `[QueryNaming]` policy
covers the API that follows another convention — `SnakeCaseLower` for a Python or Ruby backend,
plus the upper and kebab variants and `AsDeclared` for verbatim.
- camelCase because the body serializer already does it to the same type: a filter sent as
`[Body]` serializes to `{"perPage":…}`, so sending `?PerPage=` for the identical type would
have one library spelling one type two ways — and since most APIs want the camelCase one, the
alternative cost an attribute on nearly every property of every filter object.
- Applies to parameter names as well as object properties. A policy that transformed fields but
left parameters alone would leave half a query string in the other convention, which is worse
than either consistent choice. In practice the default changes nothing for parameters, since
C# parameter names are already camelCase; a non-camelCase one now camelCases, and
`[Query(Naming = QueryNaming.AsDeclared)]` keeps the 1.0 behaviour.
- An acronym run stays one word and a digit run is its own, so `HTTPReferer` is `http_referer`
rather than `h_t_t_p_referer`, and `Top10Items` is `top_10_items`.
- Applied by the generator, so every key is a literal in the emitted code and nothing is
transformed per request. Dictionary keys are left alone — those are data, not identifiers.
- Precedence is explicit `[Query("name")]`, then `[Query(Naming = …)]` on the parameter, then
`[QueryNaming]` on the interface, then camelCase.

- **`RXH014` is retired and `RXH019` replaces it.** `RXH014` refused the two formats outright;
there is nothing left for it to refuse. `RXH019` covers the real mistake instead: a body
parameter whose type cannot be sent in the format it declared, including `Raw` with something
Expand Down
46 changes: 46 additions & 0 deletions docs/defining-a-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,52 @@ Rename with the attribute when the wire name is not a legal or idiomatic C# name
[Path("name")] string repository
```

### Query key naming

A query key is **camelCased** from its identifier by default — parameter names and the property
names of an expanded query object alike. That matches what the body serializer does to the same
type: a filter sent as `[Body]` serializes to `{"perPage":…}`, so sending `?PerPage=` for the
identical type would have one library spelling one type two ways.

For an API that follows a different convention throughout — a Python or Ruby backend spelling
everything `snake_case` is the usual reason — set it once on the interface:

```csharp
[BaseAddress("https://api.example.com")]
[QueryNaming(QueryNaming.SnakeCaseLower)]
public interface IReportClient
{
[Get("/reports")]
Task<Report[]> ListAsync([Query] int perPage, [Query] ReportFilter filter, CancellationToken ct = default);
// ?per_page=50&state=open&date_from=…
}
```

| Policy | `PerPage` | `HTTPReferer` | `Top10Items` |
| --- | --- | --- | --- |
| `CamelCase` *(default)* | `perPage` | `httpReferer` | `top10Items` |
| `SnakeCaseLower` | `per_page` | `http_referer` | `top_10_items` |
| `SnakeCaseUpper` | `PER_PAGE` | `HTTP_REFERER` | `TOP_10_ITEMS` |
| `KebabCaseLower` | `per-page` | `http-referer` | `top-10-items` |
| `KebabCaseUpper` | `PER-PAGE` | `HTTP-REFERER` | `TOP-10-ITEMS` |
| `AsDeclared` | `PerPage` | `HTTPReferer` | `Top10Items` |

An acronym run stays one word, and a digit run is its own — `h_t_t_p_referer` is what a naive
"uppercase starts a word" split produces and nobody spells an API that way.

Most specific wins, as elsewhere: an explicit `[Query("name")]` on a parameter or property beats
everything, then `[Query(Naming = …)]` on the parameter, then `[QueryNaming]` on the interface.

The policy applies to parameters **and** object properties. A policy that transformed object
fields but left parameter names alone would leave half the query string in the other convention,
which is worse than either consistent choice.

Dictionary keys are never transformed — those are data you chose, not an identifier the generator
derived.

The transformation happens at compile time, so every key in the generated code is a literal and
nothing is transformed per request. `AsDeclared` is not a performance choice.

### Collections and nulls

A `[Query]` parameter that is a collection expands to one repeated key per item:
Expand Down
20 changes: 15 additions & 5 deletions src/Runax.Http.Abstractions/Attributes/QueryAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ namespace Runax.Http;
/// expanded to repeated keys.
/// </para>
/// <para>
/// On a property: renames that field when its declaring type is used as a query object. A query
/// object's fields are otherwise named after their properties exactly as written, because nothing
/// in this library silently transforms a name — so this is how a C# <c>PerPage</c> reaches an API
/// that spells it <c>per_page</c>.
/// On a property: renames that field when its declaring type is used as a query object, and wins
/// over any <see cref="QueryNaming"/> policy. Fields are otherwise named by the policy in force,
/// which is <see cref="QueryNaming.CamelCase"/> unless the parameter or the interface says
/// otherwise.
/// </para>
/// </remarks>
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]
Expand All @@ -25,6 +25,16 @@ public QueryAttribute() { }
/// <param name="name">The query key to use instead of the parameter name.</param>
public QueryAttribute(string name) => Name = name;

/// <summary>The query key, or <see langword="null"/> to use the parameter name.</summary>
/// <summary>The query key, or <see langword="null"/> to derive it from the identifier.</summary>
public string? Name { get; }

/// <summary>
/// How this parameter's key — and the keys of an expanded query object's fields — are derived
/// from their identifiers. Overrides <see cref="QueryNamingAttribute"/> on the interface.
/// </summary>
/// <remarks>
/// For the one endpoint whose query convention differs from the rest of its client. An explicit
/// <see cref="Name"/>, here or on a property, still wins over this.
/// </remarks>
public QueryNaming Naming { get; set; } = QueryNaming.CamelCase;
}
35 changes: 35 additions & 0 deletions src/Runax.Http.Abstractions/Attributes/QueryNamingAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace Runax.Http;

/// <summary>
/// Sets how query keys are derived from identifiers for every request on a client interface.
/// </summary>
/// <remarks>
/// <para>
/// For the API whose every key follows one convention — a Python backend spelling everything
/// <c>snake_case</c> being the usual reason. Set it once on the interface instead of renaming each
/// parameter and property by hand:
/// </para>
/// <code>
/// [BaseAddress("https://api.example.com")]
/// [QueryNaming(QueryNaming.SnakeCaseLower)]
/// public interface IReportClient
/// {
/// [Get("/reports")]
/// Task&lt;Report[]&gt; ListAsync([Query] int perPage, [Query] ReportFilter filter); // ?per_page=…&amp;date_from=…
/// }
/// </code>
/// <para>
/// Overridden per parameter by <see cref="QueryAttribute.Naming"/>, and per field by an explicit
/// <c>[Query("name")]</c> — most specific wins, as elsewhere in this library.
/// </para>
/// </remarks>
[AttributeUsage(AttributeTargets.Interface)]
public sealed class QueryNamingAttribute : Attribute
{
/// <summary>Initializes a new instance of the <see cref="QueryNamingAttribute"/> class.</summary>
/// <param name="naming">The convention every query key on this client follows.</param>
public QueryNamingAttribute(QueryNaming naming) => Naming = naming;

/// <summary>The convention every query key on this client follows.</summary>
public QueryNaming Naming { get; }
}
46 changes: 46 additions & 0 deletions src/Runax.Http.Abstractions/QueryNaming.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace Runax.Http;

/// <summary>
/// How a query key is derived from a C# identifier — a parameter name, or a property name of an
/// expanded query object.
/// </summary>
/// <remarks>
/// <para>
/// Applied by the generator at compile time, so the key reaching the wire is a literal in the
/// emitted code. Nothing is transformed at run time.
/// </para>
/// <para>
/// An explicit <c>[Query("name")]</c> always wins over any policy. The policy exists for the case
/// where every key follows one convention — a Python or Ruby backend spelling everything
/// <c>snake_case</c>, say — so you set it once rather than renaming every field by hand.
/// </para>
/// <para>
/// Member names mirror <c>System.Text.Json</c>'s <c>JsonKnownNamingPolicy</c>, so the vocabulary
/// is one you already know from configuring body serialization.
/// </para>
/// </remarks>
public enum QueryNaming
{
/// <summary>
/// <c>perPage</c>, <c>state</c>. The default, because it matches both C# parameter naming and
/// what the body serializer does to the same type.
/// </summary>
CamelCase = 0,

/// <summary>
/// The identifier exactly as written: a <c>PerPage</c> property sends <c>PerPage</c>.
/// </summary>
AsDeclared,

/// <summary><c>per_page</c>, <c>state</c>.</summary>
SnakeCaseLower,

/// <summary><c>PER_PAGE</c>, <c>STATE</c>.</summary>
SnakeCaseUpper,

/// <summary><c>per-page</c>, <c>state</c>.</summary>
KebabCaseLower,

/// <summary><c>PER-PAGE</c>, <c>STATE</c>.</summary>
KebabCaseUpper
}
72 changes: 66 additions & 6 deletions src/Runax.Http.SourceGenerator/ClientGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
return new AnalysisResult(null, null, diagnostics.ToImmutable());
}

var queryNaming = ReadQueryNaming(contract);
var baseAddress = ReadBaseAddress(contract, diagnostics);
var transportName = ReadTransportName(contract);
var defaultHeaders = ReadConstantHeaders(contract);
Expand All @@ -128,7 +129,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
{
case IMethodSymbol { MethodKind: MethodKind.Ordinary } method:
{
var methodModel = AnalyzeMethod(method, diagnostics);
var methodModel = AnalyzeMethod(method, queryNaming, diagnostics);
if (methodModel is not null)
methods.Add(methodModel);

Expand Down Expand Up @@ -186,6 +187,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)

private static MethodModel? AnalyzeMethod(
IMethodSymbol method,
string queryNaming,
ImmutableArray<Diagnostic>.Builder diagnostics)
{
var requestAttribute = FindRequestAttribute(method);
Expand Down Expand Up @@ -243,7 +245,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
return null;
}

var parameters = AnalyzeParameters(method, template, diagnostics, out var bodyFormat);
var parameters = AnalyzeParameters(method, queryNaming, template, diagnostics, out var bodyFormat);

ValidatePlaceholders(method, template, parameters, diagnostics);

Expand Down Expand Up @@ -273,6 +275,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)

private static ImmutableArray<ParameterModel> AnalyzeParameters(
IMethodSymbol method,
string queryNaming,
string template,
ImmutableArray<Diagnostic>.Builder diagnostics,
out string? bodyFormat)
Expand Down Expand Up @@ -358,7 +361,16 @@ private static ImmutableArray<ParameterModel> AnalyzeParameters(
}
}

var wireName = ReadWireName(attribute) ?? parameter.Name;
// A policy applies to the derived name only; an explicit [Query("name")] is the
// author stating the key outright and is never transformed.
var effectiveNaming = binding == ParameterBinding.Query
? ReadParameterQueryNaming(attribute) ?? queryNaming
: QueryNameFormatter.AsDeclared;

var wireName = ReadWireName(attribute)
?? (binding == ParameterBinding.Query
? QueryNameFormatter.Apply(parameter.Name, effectiveNaming)
: parameter.Name);

if (binding == ParameterBinding.Query)
{
Expand Down Expand Up @@ -396,7 +408,7 @@ private static ImmutableArray<ParameterModel> AnalyzeParameters(
binding,
typeName,
shape,
BuildQueryFields(parameter, fields)));
BuildQueryFields(parameter, fields, effectiveNaming)));

continue;
}
Expand Down Expand Up @@ -988,7 +1000,8 @@ private static ITypeSymbol StripNullable(ITypeSymbol type)
/// </remarks>
private static ImmutableArray<QueryFieldModel> BuildQueryFields(
IParameterSymbol parameter,
ImmutableArray<IPropertySymbol> properties)
ImmutableArray<IPropertySymbol> properties,
string queryNaming)
{
if (properties.IsDefaultOrEmpty)
return ImmutableArray<QueryFieldModel>.Empty;
Expand All @@ -1005,13 +1018,60 @@ private static ImmutableArray<QueryFieldModel> BuildQueryFields(
foreach (var property in properties)
{
fields.Add(new QueryFieldModel(
ReadPropertyWireName(property) ?? property.Name,
ReadPropertyWireName(property)
?? QueryNameFormatter.Apply(property.Name, queryNaming),
$"{parameter.Name}{accessor}{property.Name}"));
}

return fields.ToImmutable();
}

/// <summary>
/// The naming convention an interface declares with <c>[QueryNaming]</c>, or camelCase.
/// </summary>
private static string ReadQueryNaming(ISymbol symbol)
{
foreach (var attribute in symbol.GetAttributes())
{
if (attribute.AttributeClass?.ToDisplayString() == $"{AttributesNamespace}.QueryNamingAttribute"
&& attribute.ConstructorArguments.Length == 1)
{
return NamingMemberName(attribute.ConstructorArguments[0].Value);
}
}

return QueryNameFormatter.CamelCase;
}

/// <summary>
/// The convention a <c>[Query(Naming = …)]</c> names, or <see langword="null"/> when it does
/// not name one and the interface's policy should apply.
/// </summary>
private static string? ReadParameterQueryNaming(AttributeData attribute)
{
foreach (var named in attribute.NamedArguments)
{
if (named.Key == "Naming")
return NamingMemberName(named.Value.Value);
}

return null;
}

/// <summary>
/// The <c>QueryNaming</c> member name for an attribute argument, which arrives as its
/// underlying integer.
/// </summary>
private static string NamingMemberName(object? value) => value switch
{
1 => QueryNameFormatter.AsDeclared,
2 => QueryNameFormatter.SnakeCaseLower,
3 => QueryNameFormatter.SnakeCaseUpper,
4 => QueryNameFormatter.KebabCaseLower,
5 => QueryNameFormatter.KebabCaseUpper,
_ => QueryNameFormatter.CamelCase
};

/// <summary>A property's <c>[Query("name")]</c> rename, if it carries one.</summary>
private static string? ReadPropertyWireName(IPropertySymbol property)
{
Expand Down
Loading
Loading