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
22 changes: 20 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,20 @@ All packages in the repository are versioned together.
- Unbounded by default. Adding a budget nobody asked for would change the behaviour of every
existing client on upgrade.
- `Timeout`'s documentation now says "per attempt", which it never did.

- **Query objects.** A whole object as a `[Query]` parameter is expanded to one key per property,
resolved by the generator at compile time — so it needs no reflection and survives trimming,
which is why only a generator can offer it. Field names are the property names exactly as
written, because nothing in this library silently transforms a name; `[Query("per_page")]` on a
property renames it. Inherited properties are included, null properties are omitted, collection
properties repeat their key, and values go through the same formatter as every other query value
so a date spells the same thing either way.
- **Nested objects are refused** rather than flattened. ASP.NET Core spells a nested key
`filter.state` and OpenAPI's `deepObject` spells it `filter[state]`; picking one silently would
be the same guess the new diagnostics exist to prevent.
- **Dictionary query parameters.** A sequence of `KeyValuePair<string, _>` contributes its own keys
— the one query shape whose keys are not known until run time, and so the one resolved there.
- Two new diagnostics. `RXH022` rejects a `[Query]` type the runtime cannot turn into text;
`RXH023` names the property of an otherwise-expandable object that cannot be sent.
- **`[Retry]` on a method**, for the one flaky endpoint on an otherwise healthy API. Surfaces an
attempt count, `DelayMilliseconds` (milliseconds because a `TimeSpan` cannot be an attribute
argument) and `AllowNonIdempotent`. It refines the client's registered settings rather than
Expand Down Expand Up @@ -99,12 +112,17 @@ All packages in the repository are versioned together.

### Fixed

- **A `[Query]` object no longer sends `ToString()` to your server.** `[Query] SearchFilter filter`
compiled clean, raised no diagnostic, and sent
`?filter=SearchFilter%20%7B%20State%20%3D%20open...` — the record's `ToString()`, URL-encoded. A
non-record class sent its type name. `RequestValueFormatter`'s `ToString()` fallback is right for
an unknown `IFormattable` but should never have been reachable by a type the generator could see
was wrong, and a multi-field filter object is the most natural thing to try.
- **A rebuilt request no longer changes underneath a transport.** `TransportRequest` is documented
as immutable and a transport is entitled to hold on to one, but the pipeline handed over its live
header list — so on a retry the next attempt's header changes rewrote a request that had already
been sent. It showed up as the first attempt appearing to carry the second attempt's token, and
was caught by the test written to prove token refresh worked.

- **`BodyFormat.UrlEncoded` and `BodyFormat.Multipart`** are now emitted, completing the body
formats the attribute has always advertised. A form body comes from a sequence of key/value pairs
— a `Dictionary<string, string>`, or `IEnumerable<KeyValuePair<string, object?>>` for values that
Expand Down
52 changes: 52 additions & 0 deletions docs/defining-a-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,58 @@ default header.
A null **path** value is an error at run time. `/repos//issues` addresses something other than what
the caller named, and would most likely 404 a long way from the cause.

### Query objects

A whole object works as a `[Query]` parameter, expanded to one key per property:

```csharp
public sealed record SearchFilter
{
public string? State { get; init; }

[Query("per_page")]
public int PerPage { get; init; } = 30;

public IEnumerable<string>? Labels { get; init; }
}

[Get("/search")]
Task<Results> SearchAsync([Query] SearchFilter filter, CancellationToken ct = default);
```

sends `?State=open&per_page=50&Labels=bug&Labels=docs`.

The generator resolves the properties at compile time, so this stays reflection-free and survives
trimming — which is why only a generator can offer it.

- **Field names are the property names, exactly as written.** Nothing in this library transforms a
name, so `State` sends `State`. `[Query("per_page")]` on the property renames it, the same way it
renames a parameter.
- **Inherited properties are included**, so a filter sharing a pagination base type works.
- **Null properties are omitted**, matching a null parameter. A null object contributes nothing at
all.
- **Collection properties repeat their key**, matching a collection parameter.
- **Values go through the same formatter**, so a `DateTimeOffset` property spells exactly what a
`DateTimeOffset` parameter would.
- **Nested objects are refused** ([`RXH023`](diagnostics.md#rxh023)). There is no single convention
for naming a nested field — ASP.NET Core spells it `filter.state`, OpenAPI's `deepObject` spells
it `filter[state]` — so flattening would have to guess. Flatten the type, or pass the inner
object as its own parameter.

A **dictionary** keyed by string works too, contributing its own keys:

```csharp
[Get("/search")]
Task<Results> SearchAsync([Query] Dictionary<string, string?> filters, CancellationToken ct = default);
```

That is the one query shape whose keys are not known until run time, so it is the one resolved
there rather than by the generator.

A `[Query]` type the runtime cannot turn into text is [`RXH022`](diagnostics.md#rxh022), a build
error. Before that diagnostic existed, such a type fell through to `ToString()` and the server
received `?filter=SearchFilter { State = open, Page = 2 }` with no warning anywhere.

### Value formatting

Every value — path, query or header — is formatted the same way, invariantly:
Expand Down
42 changes: 42 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ removal added `RXH019` rather than repurposing the number.
| [RXH019](#rxh019) | Error | A body parameter typed wrong for its format |
| [RXH020](#rxh020) | **Warning** | A `[Retry]` that will not retry anything |
| [RXH021](#rxh021) | Error | A `[Retry]` with fewer than one attempt |
| [RXH022](#rxh022) | Error | A `[Query]` type that cannot be sent |
| [RXH023](#rxh023) | Error | A query object property that cannot be sent |

---

Expand Down Expand Up @@ -306,6 +308,46 @@ Attempts include the first, so the lowest meaningful value is `1`.
**Fix:** use `[Retry(1)]` if you meant "do not retry this method even though the client is
configured to" — which is a real thing to want. Otherwise give it the attempt count you intended.

### RXH022

**This parameter type cannot be sent as a query value.**

```csharp
[Get("/search")]
Task<Results> SearchAsync([Query] Opaque value); // ✗
```

A query value is formatted, never serialized, so the runtime can only send a type it recognises: a
string, number, bool, enum, date, `Guid` or `Uri`, anything `IFormattable`, a collection of those,
a dictionary keyed by string, or an object whose properties are all of those.

Without this diagnostic such a type fell through to `ToString()` and the server received
`?filter=SearchFilter { State = open, Page = 2 }` — garbage, with no warning. That is exactly the
silent-wrong-request this library exists to prevent.

**Fix:** pass the scalars individually, pass a dictionary, or make the type a flat object whose
properties are all sendable — see [Query objects](defining-a-client.md#query-objects).

### RXH023

**A query object property cannot be sent.**

```csharp
public sealed record Inner(string State);
public sealed record Outer(Inner Nested, int Page);

[Get("/search")]
Task<Results> SearchAsync([Query] Outer filter); // ✗ Nested
```

The parameter is expandable but one of its properties is not sendable. The most common cause is a
**nested object**, which is refused rather than flattened: ASP.NET Core spells a nested key
`filter.state`, OpenAPI's `deepObject` spells it `filter[state]`, and others differ again — so
flattening would have to guess, which is the thing [`RXH022`](#rxh022) exists to stop.

**Fix:** flatten the type, pass the inner object as its own `[Query]` parameter, or drop the
property.

## Suppressing

The normal mechanisms apply — `#pragma warning disable RXH010`, a `<NoWarn>` entry, or an
Expand Down
17 changes: 14 additions & 3 deletions src/Runax.Http.Abstractions/Attributes/QueryAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
namespace Runax.Http;

/// <summary>
/// Binds a parameter to a query string value. The parameter name is used unless
/// <see cref="Name"/> is set. Collections are expanded to repeated keys.
/// Binds a parameter to a query string value, or renames a property of an expanded query object.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter)]
/// <remarks>
/// <para>
/// On a parameter: the parameter name is used unless <see cref="Name"/> is set, and a collection is
/// 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>.
/// </para>
/// </remarks>
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]
public sealed class QueryAttribute : Attribute
{
/// <summary>Initializes a new instance of the <see cref="QueryAttribute"/> class.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ Rule ID | Category | Severity | Notes
RXH019 | Runax.Http | Error | This parameter type cannot be sent in that body format
RXH020 | Runax.Http | Warning | This [Retry] will not retry anything
RXH021 | Runax.Http | Error | [Retry] needs at least one attempt
RXH022 | Runax.Http | Error | This parameter type cannot be sent as a query value
RXH023 | Runax.Http | Error | A query object property cannot be sent
Loading
Loading