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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,33 @@ All packages in the repository are versioned together.
— 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.
- **`RXH024` catches a type missing from your `JsonSerializerContext`**, closing the last sharp edge
in the AOT story. The generator already knows every type your contracts send and receive, so you
no longer have to keep the context in step by hand: a missing registration is a build warning
naming the type, the method, the exact `[JsonSerializable]` line and the context to add it to.
Previously it surfaced as a resolver error on the first call that needed it, in a deployed
trimmed application, a long way from the registration nobody updated.
- Covers `[Body]` parameters that reach the serializer, `Task<T>`, `Task<ApiResponse<T>>` and
every `[Response]` type — including the *collection* type for `Task<IReadOnlyList<T>>`, since
registering `T` does not cover `IReadOnlyList<T>`. That is the easiest gap to miss by hand.
- Stays quiet when the compilation has no context at all, because then you are on the
reflection-based serializer, which is a supported choice that already carries its own warning.
- Two deliberate blind spots, both to avoid warning when nothing is wrong: bare primitives and
`string` are never reported, since they resolve for free whenever any registered type has a
property of that type; and a type registered by *any* context counts, since nothing at compile
time can tell which context is wired to which client.
- A warning rather than an error, because a mixed setup — one client on a source-generated
context, another deliberately on the reflection-based serializer — is legitimate and would
otherwise fail a build with no recourse but suppression. Under warnings-as-errors it fails the
build anyway.
- **The context itself cannot be generated**, and the reason is recorded in `docs/aot.md` so
nobody tries again. Roslyn output registered with `RegisterPostInitializationOutput` joins the
compilation every generator sees, so a `JsonSerializerContext` emitted that way *is* populated
by System.Text.Json's generator — verified. Output registered with `RegisterSourceOutput` is
not: the other generator never sees it and the partial fails with `CS0534`. The type set can
only come from analysing the contracts, which means `RegisterSourceOutput`, so emission is
impossible rather than merely awkward.

- **`[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
28 changes: 26 additions & 2 deletions docs/aot.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,32 @@ services.AddRunaxClient<IRepositoryClient>();
```

Every type a client sends or receives needs a `[JsonSerializable]` entry — including the types
behind each `[Response]`. A missing one fails with a message naming the type and saying the context
needs extending, rather than a generic resolver error.
behind each `[Response]`, and the *collection* type for a method returning
`Task<IReadOnlyList<T>>`, since registering `T` does not cover `IReadOnlyList<T>`.

**You will not be the one tracking that.** The generator knows the complete set, so a type missing
from your context is [`RXH024`](diagnostics.md#rxh024) at build time — naming the type, the method,
the exact attribute line, and the context to add it to. Previously a forgotten entry surfaced as a
resolver error on the first call that needed it, in a deployed application, a long way from the
registration nobody updated.

It only fires when the compilation has a `JsonSerializerContext` at all, so staying on the
reflection-based serializer does not produce a stream of advice you did not ask for.

### Why the context is not generated for you

The obvious next step would be for the generator to *write* the context, since it knows the types.
It cannot, and the reason is worth recording so nobody tries again.

Output registered with Roslyn's `RegisterPostInitializationOutput` joins the compilation every
generator sees, so a `JsonSerializerContext` emitted that way **is** picked up and populated by
System.Text.Json's generator. Output registered with `RegisterSourceOutput` is not — the other
generator never sees it, and the emitted partial fails with `CS0534` for the abstract members
nobody implemented.

The type set can only come from analysing your contracts, which means `RegisterSourceOutput`. So
emission is not awkward, it is impossible; the diagnostic is the reachable 80%, and it closes the
part that actually hurt.

## How it is verified

Expand Down
40 changes: 40 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ removal added `RXH019` rather than repurposing the number.
| [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 |
| [RXH024](#rxh024) | **Warning** | A serialized type missing from every `JsonSerializerContext` |

---

Expand Down Expand Up @@ -348,6 +349,45 @@ flattening would have to guess, which is the thing [`RXH022`](#rxh022) exists to
**Fix:** flatten the type, pass the inner object as its own `[Query]` parameter, or drop the
property.

### RXH024

**This type is not in any `JsonSerializerContext`.** *(warning)*

```csharp
[JsonSerializable(typeof(Repository))]
internal sealed partial class ApiJsonContext : JsonSerializerContext;

[Get("/repos/{owner}/{name}/issues")]
Task<IReadOnlyList<Issue>> ListIssuesAsync(string owner, string name); // ⚠ IReadOnlyList<Issue>
```

Under trimming or native AOT a type the context does not register fails at run time, on the first
call that needs it, with a resolver error — in a deployed application, a long way from the
registration someone forgot to update. The generator already knows every type your contracts send
and receive, so it can tell you now instead.

The message names the type, the method, the exact attribute line to add, and which context to add
it to.

Note the example: registering `Repository` does **not** cover `IReadOnlyList<Repository>`.
Collections need their own registration, and that is the easiest coverage gap to miss by hand.

**Only fires when the compilation has at least one `JsonSerializerContext`.** With none, you are on
the reflection-based serializer — a supported choice that carries its own trimming warning — and
this stays quiet.

Two deliberate blind spots, both to avoid warning when nothing is wrong:

- **Bare primitives and `string`** are never reported. They resolve for free whenever any
registered type has a property of that type, which in a real context is close enough to always.
- **Any context counts.** Nothing at compile time can tell which context is wired to which client,
and several may be, so a type registered anywhere is treated as covered.

A warning rather than an error, because a mixed setup — one client on a source-generated context,
another deliberately on the reflection-based serializer — is legitimate and would otherwise fail a
build with no recourse but suppression. In this repository, and anywhere else with
warnings-as-errors, it fails the build anyway.

## Suppressing

The normal mechanisms apply — `#pragma warning disable RXH010`, a `<NoWarn>` entry, or an
Expand Down
5 changes: 5 additions & 0 deletions docs/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ AOT-correct path is the one without it.
A client with no serializer registered fails when it is first resolved, with a message naming both
options.

If you take the source-generated path, you do not have to keep the context in step by hand: a type
your contracts serialize that the context does not register is [`RXH024`](diagnostics.md#rxh024) at
build time. See [Native AOT & trimming](aot.md) for what it covers and the two cases it stays quiet
about.

## System.Text.Json

`SystemTextJsonContentSerializer` serializes through `JsonTypeInfo` rather than the reflection-based
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ 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
RXH024 | Runax.Http | Warning | This type is not in any JsonSerializerContext
48 changes: 46 additions & 2 deletions src/Runax.Http.SourceGenerator/ClientGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,32 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
if (result.Model is not null)
ctx.AddSource(result.HintName!, Emitter.Emit(result.Model));
});

// JSON context coverage is a second, compilation-wide pass: it needs every contract's
// serialized types joined against every JsonSerializerContext, which no per-interface
// analysis can see. See JsonContextCoverage for why this is a diagnostic and not an
// emitted context.
var contexts = context.SyntaxProvider
.CreateSyntaxProvider(
static (node, _) => JsonContextCoverage.CouldBeJsonContext(node),
static (ctx, token) => JsonContextCoverage.ReadContext(ctx, token))
.Where(static found => found is not null);

context.RegisterSourceOutput(
candidates.Collect().Combine(contexts.Collect()),
static (ctx, pair) =>
{
var serialized = pair.Left
.SelectMany(static result => result!.SerializedTypes)
.ToImmutableArray();

var declared = pair.Right
.Select(static found => found!)
.ToImmutableArray();

foreach (var diagnostic in JsonContextCoverage.Check(serialized, declared))
ctx.ReportDiagnostic(diagnostic);
});
}

private static AnalysisResult? Analyze(GeneratorSyntaxContext context, CancellationToken token)
Expand Down Expand Up @@ -151,7 +177,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
declaredTransports.ToImmutable(),
methods.ToImmutable());

return new AnalysisResult(model, $"{contract.Name}.RunaxClient.g.cs", diagnostics.ToImmutable());
return new AnalysisResult(
model,
$"{contract.Name}.RunaxClient.g.cs",
diagnostics.ToImmutable(),
JsonContextCoverage.Collect(model, Location(contract)));
}

private static MethodModel? AnalyzeMethod(
Expand Down Expand Up @@ -1201,17 +1231,31 @@ private static Location Location(ISymbol symbol)
/// <summary>What one interface produced: a model to emit, diagnostics, or both.</summary>
private sealed class AnalysisResult
{
internal AnalysisResult(ClientModel? model, string? hintName, ImmutableArray<Diagnostic> diagnostics)
internal AnalysisResult(
ClientModel? model,
string? hintName,
ImmutableArray<Diagnostic> diagnostics,
ImmutableArray<SerializedTypeRef> serializedTypes = default)
{
Model = model;
HintName = hintName;
Diagnostics = diagnostics;
SerializedTypes = serializedTypes.IsDefault
? ImmutableArray<SerializedTypeRef>.Empty
: serializedTypes;
}

internal ClientModel? Model { get; }

internal string? HintName { get; }

internal ImmutableArray<Diagnostic> Diagnostics { get; }

/// <summary>
/// What this contract hands to the serializer, for the compilation-wide JSON context
/// check. Empty for a result that produced no model, so the early returns above need no
/// change.
/// </summary>
internal ImmutableArray<SerializedTypeRef> SerializedTypes { get; }
}
}
7 changes: 7 additions & 0 deletions src/Runax.Http.SourceGenerator/Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ internal static class Diagnostics
"'{0}' on '{1}' is a query object, but its property '{2}' is typed '{3}', which cannot be "
+ "sent in a query string. {4}");

internal static readonly DiagnosticDescriptor UncoveredSerializedType = Warning(
"RXH024",
"This type is not in any JsonSerializerContext",
"'{0}' sends or receives '{1}', which no JsonSerializerContext in this project registers. "
+ "Under trimming or native AOT that fails at run time on the first call that needs it. Add "
+ "{2} to {3}.");

private static DiagnosticDescriptor Error(string id, string title, string format)
=> new(id, title, format, Category, DiagnosticSeverity.Error, isEnabledByDefault: true);

Expand Down
Loading
Loading