From d8dc2b0eb118c0998b8906a9ce2bf02f9d183e99 Mon Sep 17 00:00:00 2001 From: Maksym Kacharaba Date: Sat, 7 Nov 2026 13:40:25 +0200 Subject: [PATCH] feat: camelCase query keys by default, with a naming policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expansion landed sending property names verbatim, reasoned from [Query] parameters already sending theirs that way. Coherent, but compared against the wrong neighbour: C# parameter names are camelCase by convention, so "verbatim" only looks like camelCase there. The closer neighbour is body serialization, which camelCases — so the same filter type sent as [Body] produced {"state":...} while as [Query] it produced ?State=..., one library spelling one type two ways. Since most APIs want the camelCase one, the old default cost an attribute on nearly every property of every filter object, which is the friction the feature existed to remove. snake_case then makes a hardcoded flip the wrong shape, so this is a policy: [QueryNaming] on the interface for the API that follows one convention throughout — a Python or Ruby backend being the usual reason — overridable per parameter and always beaten by an explicit [Query("name")]. It 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 and is exactly the trap someone setting snake_case would fall into. The default changes nothing for parameters in practice, since C# parameter names are already camelCase; AsDeclared is the one-attribute escape for anyone relying on a non-camelCase one. Word splitting follows what a reader expects rather than "uppercase starts a word": an acronym run is one word and a digit run is its own, so HTTPReferer is http_referer and not h_t_t_p_referer, and Top10Items is top_10_items. The camelCase rule matches JsonNamingPolicy.CamelCase exactly, since matching it is the whole justification for the default. Applied in the generator, so every key is a literal in the emitted code and nothing is transformed per request — AsDeclared is not a performance choice. Dictionary keys are left alone, being data rather than a derived identifier. Three existing tests asserted the old behaviour, including one whose entire purpose was to pin it; they now pin the new default, and AsDeclared has its own coverage. 1500 tests; build, format and native AOT clean. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 19 +++ docs/defining-a-client.md | 46 ++++++ .../Attributes/QueryAttribute.cs | 20 ++- .../Attributes/QueryNamingAttribute.cs | 35 +++++ src/Runax.Http.Abstractions/QueryNaming.cs | 46 ++++++ .../ClientGenerator.cs | 72 ++++++++- .../QueryNameFormatter.cs | 147 ++++++++++++++++++ src/Runax.Http/README.md | 13 ++ .../Contracts/ISearchClient.cs | 47 ++++++ tests/Runax.Http.Tests/QueryNamingTests.cs | 136 ++++++++++++++++ tests/Runax.Http.Tests/QueryObjectTests.cs | 22 +-- 11 files changed, 582 insertions(+), 21 deletions(-) create mode 100644 src/Runax.Http.Abstractions/Attributes/QueryNamingAttribute.cs create mode 100644 src/Runax.Http.Abstractions/QueryNaming.cs create mode 100644 src/Runax.Http.SourceGenerator/QueryNameFormatter.cs create mode 100644 tests/Runax.Http.Tests/QueryNamingTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 70095b7..1e54491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/defining-a-client.md b/docs/defining-a-client.md index 53fbf62..d329165 100644 --- a/docs/defining-a-client.md +++ b/docs/defining-a-client.md @@ -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 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: diff --git a/src/Runax.Http.Abstractions/Attributes/QueryAttribute.cs b/src/Runax.Http.Abstractions/Attributes/QueryAttribute.cs index d8dcd22..a3ebed5 100644 --- a/src/Runax.Http.Abstractions/Attributes/QueryAttribute.cs +++ b/src/Runax.Http.Abstractions/Attributes/QueryAttribute.cs @@ -9,10 +9,10 @@ namespace Runax.Http; /// expanded to repeated keys. /// /// -/// 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# PerPage reaches an API -/// that spells it per_page. +/// On a property: renames that field when its declaring type is used as a query object, and wins +/// over any policy. Fields are otherwise named by the policy in force, +/// which is unless the parameter or the interface says +/// otherwise. /// /// [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] @@ -25,6 +25,16 @@ public QueryAttribute() { } /// The query key to use instead of the parameter name. public QueryAttribute(string name) => Name = name; - /// The query key, or to use the parameter name. + /// The query key, or to derive it from the identifier. public string? Name { get; } + + /// + /// How this parameter's key — and the keys of an expanded query object's fields — are derived + /// from their identifiers. Overrides on the interface. + /// + /// + /// For the one endpoint whose query convention differs from the rest of its client. An explicit + /// , here or on a property, still wins over this. + /// + public QueryNaming Naming { get; set; } = QueryNaming.CamelCase; } diff --git a/src/Runax.Http.Abstractions/Attributes/QueryNamingAttribute.cs b/src/Runax.Http.Abstractions/Attributes/QueryNamingAttribute.cs new file mode 100644 index 0000000..382e9fd --- /dev/null +++ b/src/Runax.Http.Abstractions/Attributes/QueryNamingAttribute.cs @@ -0,0 +1,35 @@ +namespace Runax.Http; + +/// +/// Sets how query keys are derived from identifiers for every request on a client interface. +/// +/// +/// +/// For the API whose every key follows one convention — a Python backend spelling everything +/// snake_case being the usual reason. Set it once on the interface instead of renaming each +/// parameter and property by hand: +/// +/// +/// [BaseAddress("https://api.example.com")] +/// [QueryNaming(QueryNaming.SnakeCaseLower)] +/// public interface IReportClient +/// { +/// [Get("/reports")] +/// Task<Report[]> ListAsync([Query] int perPage, [Query] ReportFilter filter); // ?per_page=…&date_from=… +/// } +/// +/// +/// Overridden per parameter by , and per field by an explicit +/// [Query("name")] — most specific wins, as elsewhere in this library. +/// +/// +[AttributeUsage(AttributeTargets.Interface)] +public sealed class QueryNamingAttribute : Attribute +{ + /// Initializes a new instance of the class. + /// The convention every query key on this client follows. + public QueryNamingAttribute(QueryNaming naming) => Naming = naming; + + /// The convention every query key on this client follows. + public QueryNaming Naming { get; } +} diff --git a/src/Runax.Http.Abstractions/QueryNaming.cs b/src/Runax.Http.Abstractions/QueryNaming.cs new file mode 100644 index 0000000..6ab3ccf --- /dev/null +++ b/src/Runax.Http.Abstractions/QueryNaming.cs @@ -0,0 +1,46 @@ +namespace Runax.Http; + +/// +/// How a query key is derived from a C# identifier — a parameter name, or a property name of an +/// expanded query object. +/// +/// +/// +/// 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. +/// +/// +/// An explicit [Query("name")] always wins over any policy. The policy exists for the case +/// where every key follows one convention — a Python or Ruby backend spelling everything +/// snake_case, say — so you set it once rather than renaming every field by hand. +/// +/// +/// Member names mirror System.Text.Json's JsonKnownNamingPolicy, so the vocabulary +/// is one you already know from configuring body serialization. +/// +/// +public enum QueryNaming +{ + /// + /// perPage, state. The default, because it matches both C# parameter naming and + /// what the body serializer does to the same type. + /// + CamelCase = 0, + + /// + /// The identifier exactly as written: a PerPage property sends PerPage. + /// + AsDeclared, + + /// per_page, state. + SnakeCaseLower, + + /// PER_PAGE, STATE. + SnakeCaseUpper, + + /// per-page, state. + KebabCaseLower, + + /// PER-PAGE, STATE. + KebabCaseUpper +} diff --git a/src/Runax.Http.SourceGenerator/ClientGenerator.cs b/src/Runax.Http.SourceGenerator/ClientGenerator.cs index e8d9205..b3e39c3 100644 --- a/src/Runax.Http.SourceGenerator/ClientGenerator.cs +++ b/src/Runax.Http.SourceGenerator/ClientGenerator.cs @@ -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); @@ -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); @@ -186,6 +187,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) private static MethodModel? AnalyzeMethod( IMethodSymbol method, + string queryNaming, ImmutableArray.Builder diagnostics) { var requestAttribute = FindRequestAttribute(method); @@ -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); @@ -273,6 +275,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) private static ImmutableArray AnalyzeParameters( IMethodSymbol method, + string queryNaming, string template, ImmutableArray.Builder diagnostics, out string? bodyFormat) @@ -358,7 +361,16 @@ private static ImmutableArray 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) { @@ -396,7 +408,7 @@ private static ImmutableArray AnalyzeParameters( binding, typeName, shape, - BuildQueryFields(parameter, fields))); + BuildQueryFields(parameter, fields, effectiveNaming))); continue; } @@ -988,7 +1000,8 @@ private static ITypeSymbol StripNullable(ITypeSymbol type) /// private static ImmutableArray BuildQueryFields( IParameterSymbol parameter, - ImmutableArray properties) + ImmutableArray properties, + string queryNaming) { if (properties.IsDefaultOrEmpty) return ImmutableArray.Empty; @@ -1005,13 +1018,60 @@ private static ImmutableArray 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(); } + /// + /// The naming convention an interface declares with [QueryNaming], or camelCase. + /// + 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; + } + + /// + /// The convention a [Query(Naming = …)] names, or when it does + /// not name one and the interface's policy should apply. + /// + private static string? ReadParameterQueryNaming(AttributeData attribute) + { + foreach (var named in attribute.NamedArguments) + { + if (named.Key == "Naming") + return NamingMemberName(named.Value.Value); + } + + return null; + } + + /// + /// The QueryNaming member name for an attribute argument, which arrives as its + /// underlying integer. + /// + 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 + }; + /// A property's [Query("name")] rename, if it carries one. private static string? ReadPropertyWireName(IPropertySymbol property) { diff --git a/src/Runax.Http.SourceGenerator/QueryNameFormatter.cs b/src/Runax.Http.SourceGenerator/QueryNameFormatter.cs new file mode 100644 index 0000000..ec3f819 --- /dev/null +++ b/src/Runax.Http.SourceGenerator/QueryNameFormatter.cs @@ -0,0 +1,147 @@ +using System.Text; + +namespace Runax.Http.SourceGenerator; + +/// +/// Turns a C# identifier into a query key, following the declared convention. +/// +/// +/// +/// Runs in the generator, so the result is baked into the emitted code as a literal. The runtime +/// never transforms a name — which is why a policy costs nothing per request, and why the key you +/// see in generated output is exactly the key that reaches the wire. +/// +/// +/// Word splitting matches what a reader expects of these conventions rather than a naive +/// "uppercase means new word": an acronym run is one word, so HTTPStatus is +/// http_status and not h_t_t_p_status, and a digit run is its own word, so +/// Top10Items is top_10_items. +/// +/// +internal static class QueryNameFormatter +{ + /// The member names, as the generator sees them. + internal const string CamelCase = "CamelCase"; + internal const string AsDeclared = "AsDeclared"; + internal const string SnakeCaseLower = "SnakeCaseLower"; + internal const string SnakeCaseUpper = "SnakeCaseUpper"; + internal const string KebabCaseLower = "KebabCaseLower"; + internal const string KebabCaseUpper = "KebabCaseUpper"; + + /// Applies a naming convention to an identifier. + /// The parameter or property name as written. + /// The convention, as a member name. + /// The query key. + internal static string Apply(string identifier, string naming) + { + if (string.IsNullOrEmpty(identifier) || naming == AsDeclared) + return identifier; + + return naming switch + { + SnakeCaseLower => Join(identifier, '_', upper: false), + SnakeCaseUpper => Join(identifier, '_', upper: true), + KebabCaseLower => Join(identifier, '-', upper: false), + KebabCaseUpper => Join(identifier, '-', upper: true), + _ => ToCamelCase(identifier) + }; + } + + /// + /// Lowercases the leading uppercase run, leaving the last of it capitalised when a lowercase + /// letter follows — so ID is id and HTTPStatus is httpStatus. + /// + /// + /// Deliberately the same rule as JsonNamingPolicy.CamelCase, so a type sent as a query + /// object and as a JSON body spells its fields identically. That symmetry is the reason + /// camelCase is the default at all. + /// + private static string ToCamelCase(string identifier) + { + if (!char.IsUpper(identifier[0])) + return identifier; + + var buffer = identifier.ToCharArray(); + + for (var i = 0; i < buffer.Length; i++) + { + if (i > 0 && i + 1 < buffer.Length && !char.IsUpper(buffer[i + 1])) + break; + + buffer[i] = char.ToLowerInvariant(buffer[i]); + } + + return new string(buffer); + } + + private static string Join(string identifier, char separator, bool upper) + { + var result = new StringBuilder(identifier.Length + 4); + + foreach (var word in SplitWords(identifier)) + { + if (result.Length > 0) + result.Append(separator); + + result.Append(upper ? word.ToUpperInvariant() : word.ToLowerInvariant()); + } + + return result.Length == 0 ? identifier : result.ToString(); + } + + /// + /// Splits an identifier into words at the boundaries a reader would see. + /// + /// + /// Three boundaries: lower-to-upper (perPage), the end of an acronym run followed by a + /// lowercase letter (HTTPStatus breaks before Status, not after H), and + /// either side of a digit run. Underscores and separators already present are treated as + /// boundaries and dropped, so an identifier that is already snake_case survives the trip. + /// + private static List SplitWords(string identifier) + { + var words = new List(); + var current = new StringBuilder(); + + for (var i = 0; i < identifier.Length; i++) + { + var character = identifier[i]; + + if (character is '_' or '-' or ' ') + { + Flush(); + continue; + } + + if (current.Length > 0) + { + var previous = identifier[i - 1]; + + var startsWord = + (char.IsUpper(character) && !char.IsUpper(previous)) + || (char.IsUpper(character) && char.IsUpper(previous) + && i + 1 < identifier.Length + && char.IsLower(identifier[i + 1])) + || (char.IsDigit(character) != char.IsDigit(previous)); + + if (startsWord) + Flush(); + } + + current.Append(character); + } + + Flush(); + + return words; + + void Flush() + { + if (current.Length == 0) + return; + + words.Add(current.ToString()); + current.Clear(); + } + } +} diff --git a/src/Runax.Http/README.md b/src/Runax.Http/README.md index dcc6f6a..4a0d6cb 100644 --- a/src/Runax.Http/README.md +++ b/src/Runax.Http/README.md @@ -54,6 +54,19 @@ Task SearchAsync([Query] SearchFilter filter, CancellationToken ct = de A dictionary keyed by string works too, contributing its own keys. A type the runtime cannot turn into text is a build error rather than a `ToString()` sent to your server. +### Query key naming + +Keys are camelCased from their identifiers by default, matching what the body serializer does to +the same type. For an API that spells everything another way: + +```csharp +[QueryNaming(QueryNaming.SnakeCaseLower)] // ?per_page=50&http_referer=… +public interface IReportClient { } +``` + +`SnakeCaseLower/Upper`, `KebabCaseLower/Upper`, `AsDeclared`. An explicit `[Query("name")]` always +wins, and the transformation happens at compile time so nothing is renamed per request. + ## Return types | Signature | Behaviour | diff --git a/tests/Runax.Http.Tests/Contracts/ISearchClient.cs b/tests/Runax.Http.Tests/Contracts/ISearchClient.cs index f1e895b..b2e3795 100644 --- a/tests/Runax.Http.Tests/Contracts/ISearchClient.cs +++ b/tests/Runax.Http.Tests/Contracts/ISearchClient.cs @@ -69,3 +69,50 @@ Task SearchMixedAsync( [Query] SearchFilter filter, CancellationToken ct = default); } + +/// A filter whose property names exercise every word boundary the policies split on. +internal sealed class ReportFilter +{ + public string? State { get; set; } + + public int PerPage { get; set; } + + /// An acronym run, which must stay one word rather than becoming h_t_t_p. + public string? HTTPReferer { get; set; } + + /// A digit run, which is its own word. + public int Top10Items { get; set; } + + /// Explicitly named, so no policy may touch it. + [Query("EXACT")] + public string? Exact { get; set; } +} + +/// +/// A client against an API that spells every key snake_case — a Python or Ruby backend, +/// typically. +/// +[BaseAddress("https://api.example.com")] +[QueryNaming(QueryNaming.SnakeCaseLower)] +internal interface ISnakeCaseClient +{ + [Get("/reports")] + Task ListAsync( + [Query] ReportFilter filter, + [Query] int pageNumber, + CancellationToken ct = default); + + /// The one endpoint whose convention differs from its client's. + [Get("/legacy")] + Task LegacyAsync( + [Query(Naming = QueryNaming.AsDeclared)] ReportFilter filter, + CancellationToken ct = default); +} + +/// A client taking the default policy, for contrast. +[BaseAddress("https://api.example.com")] +internal interface IDefaultNamingClient +{ + [Get("/reports")] + Task ListAsync([Query] ReportFilter filter, CancellationToken ct = default); +} diff --git a/tests/Runax.Http.Tests/QueryNamingTests.cs b/tests/Runax.Http.Tests/QueryNamingTests.cs new file mode 100644 index 0000000..9bd5253 --- /dev/null +++ b/tests/Runax.Http.Tests/QueryNamingTests.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.DependencyInjection; +using Runax.Http.DependencyInjection; +using Runax.Http.Tests.Contracts; +using Runax.Http.Tests.Fakes; + +namespace Runax.Http.Tests; + +/// +/// How a query key is derived from an identifier. +/// +/// +/// +/// The policy is applied by the generator, so every key in the emitted code is a literal and +/// nothing is transformed per request. That is why a policy is free at run time, and why +/// AsDeclared is not a performance choice. +/// +/// +/// camelCase is the default because the body serializer already does that 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. +/// +/// +public class QueryNamingTests +{ + private static (TContract Client, RecordingTransport Transport) Build() + where TContract : class + { + var transport = new RecordingTransport(respond: _ => Responses.Json("""{"id":1,"name":"w"}""")); + var services = new ServiceCollection(); + + var shared = services.AddRunaxHttp(); + shared.UseSystemTextJson(); + shared.AddTransport(transport); + shared.UseDefaultTransport(transport.Name); + + services.AddRunaxClient(); + + return (services.BuildServiceProvider().GetRequiredService(), transport); + } + + private static string Query(RecordingTransport transport) + => Uri.UnescapeDataString(transport.Last.RequestUri.Query); + + private static ReportFilter Filter() => new() + { + State = "open", + PerPage = 50, + HTTPReferer = "ref", + Top10Items = 7, + Exact = "kept" + }; + + [Fact] + public async Task The_default_is_camelCase() + { + var (client, transport) = Build(); + + await client.ListAsync(Filter()); + + Query(transport).ShouldBe("?state=open&perPage=50&httpReferer=ref&top10Items=7&EXACT=kept"); + } + + [Fact] + public async Task An_interface_policy_applies_to_properties_and_parameters_alike() + { + // Both, deliberately. 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, and precisely the trap someone setting snake_case would fall + // into. + var (client, transport) = Build(); + + await client.ListAsync(Filter(), 3); + + Query(transport) + .ShouldBe("?state=open&per_page=50&http_referer=ref&top_10_items=7&EXACT=kept&page_number=3"); + } + + [Fact] + public async Task An_acronym_run_stays_one_word() + { + // h_t_t_p_referer is what a naive "uppercase starts a word" split produces, and nobody + // writing an API spells it that way. + var (client, transport) = Build(); + + await client.ListAsync(Filter(), 1); + + Query(transport).ShouldContain("http_referer=ref", Case.Sensitive); + } + + [Fact] + public async Task A_digit_run_is_its_own_word() + { + var (client, transport) = Build(); + + await client.ListAsync(Filter(), 1); + + Query(transport).ShouldContain("top_10_items=7", Case.Sensitive); + } + + [Fact] + public async Task An_explicit_name_survives_every_policy() + { + // [Query("EXACT")] is the author stating the key outright. Transforming it would leave no + // way to say "this one, exactly". + var (snake, snakeTransport) = Build(); + await snake.ListAsync(Filter(), 1); + Query(snakeTransport).ShouldContain("EXACT=kept", Case.Sensitive); + + var (camel, camelTransport) = Build(); + await camel.ListAsync(Filter()); + Query(camelTransport).ShouldContain("EXACT=kept", Case.Sensitive); + } + + [Fact] + public async Task A_parameter_can_override_its_client_policy() + { + // For the one endpoint whose convention differs from the rest of its API. + var (client, transport) = Build(); + + await client.LegacyAsync(Filter()); + + Query(transport).ShouldBe("?State=open&PerPage=50&HTTPReferer=ref&Top10Items=7&EXACT=kept"); + } + + [Fact] + public async Task AsDeclared_is_how_the_previous_behaviour_is_kept() + { + // 1.0 sent parameter names verbatim. Anyone relying on a non-camelCase parameter name has + // this as a one-attribute escape rather than a rename. + var (client, transport) = Build(); + + await client.LegacyAsync(Filter()); + + Query(transport).ShouldContain("HTTPReferer=ref", Case.Sensitive); + } +} diff --git a/tests/Runax.Http.Tests/QueryObjectTests.cs b/tests/Runax.Http.Tests/QueryObjectTests.cs index 09da781..d58659c 100644 --- a/tests/Runax.Http.Tests/QueryObjectTests.cs +++ b/tests/Runax.Http.Tests/QueryObjectTests.cs @@ -46,23 +46,25 @@ public async Task An_object_expands_to_one_key_per_property() // Not "?filter=SearchFilter { ... }". Query(transport).ShouldNotContain("filter="); Query(transport).ShouldContain("State=open"); - Query(transport).ShouldContain("Page=2"); + Query(transport).ShouldContain("page=2", Case.Sensitive); } [Fact] - public async Task A_property_name_is_sent_exactly_as_written() + public async Task A_property_name_is_camelCased_by_default() { - // Nothing in this library transforms a name: a [Query] parameter sends its parameter name - // verbatim, so a property has to behave the same way or the two rules disagree. + // Matching what the body serializer does to the same type. Sending PascalCase here while + // a [Body] of the identical type serializes to camelCase would make one library spell one + // type two ways, and most APIs want the camelCase one — so the default would have cost an + // attribute on nearly every property of every filter. var (client, transport) = Build(); await client.SearchAsync(new SearchFilter { State = "open" }); - Query(transport).ShouldContain("State=open"); + Query(transport).ShouldContain("state=open", Case.Sensitive); - // Case-sensitively: Shouldly compares case-insensitively by default, which would let a - // lowercased key pass the very assertion meant to catch it. - Query(transport).ShouldNotContain("state=open", Case.Sensitive); + // Case-sensitively: Shouldly compares case-insensitively by default, which would let the + // untransformed key pass the very assertion meant to catch it. + Query(transport).ShouldNotContain("State=open", Case.Sensitive); } [Fact] @@ -153,7 +155,7 @@ public async Task A_struct_object_expands_without_null_checks() await client.SearchPointAsync(new PointFilter(3, 4)); - Query(transport).ShouldBe("?X=3&Y=4"); + Query(transport).ShouldBe("?x=3&y=4"); } [Fact] @@ -163,7 +165,7 @@ public async Task A_nullable_struct_object_expands_when_present() await client.SearchNullablePointAsync(new PointFilter(1, 2)); - Query(transport).ShouldBe("?X=1&Y=2"); + Query(transport).ShouldBe("?x=1&y=2"); } [Fact]