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]