Skip to content

feat(web): source-generated typed-delegate endpoint binding [L03.01.02.03] - #936

Merged
dotnetcadet merged 5 commits into
mainfrom
feature/L03.01.02.03-source-gen-binding
Jul 20, 2026
Merged

feat(web): source-generated typed-delegate endpoint binding [L03.01.02.03]#936
dotnetcadet merged 5 commits into
mainfrom
feature/L03.01.02.03-source-gen-binding

Conversation

@dotnetcadet

@dotnetcadet dotnetcadet commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Batch 5, stack 4/4 (base: feature/L03.01.02.01.09-route-host-httphost, PR #934) — the Stage-4 finale, built to the middleware-first re-scope (the issue body's IResult/result-union/Web.Functions/IEndpointFilter framing predates the 2026-07-10 direction change; no result types anywhere).

Descope (owner decision, 2026-07-20, commit baa55ae6): the opt-in per-endpoint validation seam was removed from this PR entirely — no IValidator Map overloads, no EndpointValidationMetadata/EndpointValidation, no validator emission in the generator, and Web.Api no longer depends on ObjectValidation. The ObjectValidation AOT hardening is retained (it stands on its own). Validation is now an explicit v1 non-goal in the Web.Api DESIGN; a future integration is an open design question.

  • Typed Map* overloads on the existing Web.Api surface (MapGet("/users/{id}", (int id, IHttpContext ctx) => …)): runtime bodies throw a clear "requires the generator" error; EndpointBindingGenerator (interceptor-style IIncrementalGenerator in analyzers/SourceGeneration.Web, mirroring the live MapperProfileGenerator) intercepts call sites and emits AOT-safe thunks — exact-delegate-type cast + direct invoke, inline IParsable<T>/Enum.TryParse conversions; no reflection, no Expression.Compile. C# 14 extension(...)-member interception proven by the runtime e2e suite.
  • Binding sources: route values (boxed-typed via constrained tokens), query (incl. nullable-optional), header, JSON body via Web.Serialization, form via Http.Forms, plus IHttpContext/CancellationToken/typed-feature injection. Inference (injection → [From*] attribute → route-token match → scalar=query, complex=body) with attributes as compile-time generator input only.
  • Failure matrix (outcomes, imperative problem+json): 400 with an errors extension naming the parameter (unparseable scalars, JsonException) · 415 (no reader/unreadable Content-Type). Handler exceptions propagate untouched to the [L03.01.01.19] Pipeline exception boundary, status-code pages, and 404 terminal over IResult (supersedes #776) #881 boundary.
  • ObjectValidation AOT hardening (retained): the member accessor now walks the expression's already-resolved PropertyInfo/FieldInfo (no runtime codegen, IL2026/IL3050-clean, null-chain semantics preserved); When moved Expression<Func<T,bool>>Func<T,bool>source-compatible for lambdas, binary-breaking for materialized-Expression callers (deliberate, documented in its DESIGN).
  • Delivery: CohesionAnalyzerReference for in-repo consumers; the pre-existing CohesionFrameworkAnalyzer manifest machinery ships the DLL at analyzers/dotnet/cs/ in App.Web.Ref (pack-verified). Consumers allow-list Assimalign.Cohesion.Web.Api.Generated via InterceptorsNamespaces (Sdk auto-set recorded as follow-up).

Tests (post-descope)

Web.Api e2e 13 (every source, inference vs attributes, 400/415 matrix, injections) · generator unit 6 · ObjectValidation 218 (unchanged — hardening regression-proof) · Web.Routing 263 · Web.Serialization 42 · core Http 1231 · Http.Connections 434 — all passing. App.Web Ref + Runtime packs validated.

Follow-up candidates (not filed)

Sdk.Web auto-setting InterceptorsNamespaces · compile-time diagnostics for unmodelable call sites · analyzer unit-test CI home (pre-existing gap) · handler filter chains · UseRouting() auto-wiring ergonomics · the future validation-integration design.

Closes #796

🤖 Generated with Claude Code

dotnetcadet and others added 4 commits July 20, 2026 10:48
…ient sees the response terminate [L01.01.11.44]

An HTTP/3 response body is delimited by the request stream's end (RFC 9114
§4.1), not by Content-Length. The h3 send path wrote HEADERS/DATA, flushed,
and returned without ending the request stream's write side, so a real .NET
HTTP/3 client stayed in ReadResponseContentAsync waiting for a FIN that only
arrived at connection teardown — at which point the control/QPACK critical
streams closing surfaced at the client as H3_CLOSED_CRITICAL_STREAM (0x104).

The buffered SendAsync and the streaming sink's CompleteFramedAsync now end
the request stream's write side (graceful QUIC FIN via the IConnection
half-close contract, Output.Complete()) once the response is fully flushed.
Best-effort: a teardown race that disposed the stream is swallowed.

Both #928 send observations resolve here: a bodyless 200 completes the
client's zero-length drain via the FIN, and the buffered-body content length
already matched the DATA written (ReadBodyAsync reads the whole buffer,
position-independent). Adds platform-guarded real-QUIC round-trip regression
tests (full round-trip, bodyless 200, end-position body, sequential requests
pinning critical-stream lifetime) and flips the Web.Hosting UseHttp3 e2e from
best-effort to a hard client-observed status+body assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…op StaticFiles compensation [L01.01.11.43]

Http1MessageReader now surfaces IHttpRequest.Path from the origin-form
request-target through the same HttpPath.FromUriComponent decode HTTP/2
(Http2Stream) and HTTP/3 (Http3HeaderCodec) run over the :path pseudo-header,
so identical wire bytes yield an identical decoded path on every transport:
%2e%2e decodes to "..", %2F stays encoded (never a separator), ordinary octets
decode, and invalid/overlong escapes are left intact per UrlDecoder. A decoded
octet that is not a legal path character (space, control, ?/#, NUL) makes the
target malformed and is surfaced as the reader's existing wire-level failure.
Only origin-form is decoded; absolute/authority/asterisk keep their handling.
The query is unchanged — already split before the decode and parsed by
HttpQuery.Parse identically on all transports.

Removes the now-double-decoding version-gated compensation in
Web.StaticFiles.StaticFilesMiddleware (Http11 + Uri.UnescapeDataString) in the
same change and re-points its tests: the encoded-traversal/name decode is pinned
at the transport (new Http.Connections parity suite + the E2E traversal/name
round-trips), and a middleware test guards against re-decoding a handed path.

Tests: Http.Connections 434, Web.StaticFiles 85, Web.Routing 250, Http core
1231 — all green. Updates both touched DESIGN.md files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ared HttpHost primitive [L03.01.02.01.09]

Host filtering (#781) added a structural host[:port] split to HttpHost
(TryGetComponents) that deliberately mirrored RouteHostConstraint's own
private TrySplitHostAndPort/TryParsePort (#788). The two copies could drift,
so route host SELECTION and host allowlist VALIDATION might disagree on what a
wire value means.

Extract the structural split as an internal HttpHost.TrySplitHostPort and widen
TryParsePort to internal (granted to Web.Routing via InternalsVisibleTo).
TryGetComponents now layers its stricter port validation on top of that shared
split, byte-for-byte unchanged. RouteHostConstraint.TryParse/IsMatch delegate to
the shared helpers and the duplicated private copies are deleted.

Direct delegation to TryGetComponents was impossible without changing behavior:
it fuses port validation into the split and returns false (no host) for a
present-but-invalid port, which would break the documented #788 leniency where a
port-unconstrained route tolerates junk port text in the request host and matches
on the host part alone. The shared *structural* split preserves that leniency
exactly; new RouteHostConstraintTests cases pin both the leniency and its
structural boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion seam [L03.01.02.03]

Delivers the Stage-4 finale of the HTTP/Web program (#796) as the middleware-first re-scope,
NOT the stale issue body: no IResult, no typed result unions, no Web.Functions/Web.Results
(deleted). Handlers write responses imperatively.

- Web.Api gains typed Delegate Map* overloads (Map/MapGet/MapPost/MapPut/MapPatch/MapDelete plus
  IValidator-carrying variants) as throwing placeholders the generator rewrites, a metadata-carrying
  raw Map overload, the [From*] binding attributes, an EndpointValidationMetadata carrier, and the
  EndpointValidation problem-writer. Homed in Web.Api (no new package/manifest assembly).
- analyzers/Assimalign.Cohesion.SourceGeneration.Web: an interceptor-style IIncrementalGenerator
  (EndpointBindingGenerator) that casts each typed handler back to its delegate type and invokes it
  directly (no reflection, no Expression.Compile), binding route/query/header/body/form plus
  injections (IHttpContext, CancellationToken, IHttpFeature). Failures are outcomes: unparseable or
  missing scalar -> 400 problem+json (errors extension), unsupported/absent body reader -> 415,
  JsonException -> 400; handler-thrown exceptions propagate to the #881 boundary.
- Opt-in per-endpoint validation seam over Assimalign.Cohesion.ObjectValidation (as-is): the
  validator runs against the bound model before the handler; failures short-circuit to 400.
- ObjectValidation NativeAOT hardening: removed all three Expression.Compile sites (compile-free
  reflective member-walk getter; Func-first When on IValidationRuleDescriptor<T> /
  IValidationCondition<T>). Now IL2026/IL3050-clean; full 218-test suite green.
- Delivery: CohesionAnalyzerReference for in-repo/test projects; CohesionFrameworkAnalyzer in
  App.props (App.Web) for Sdk.Web consumers (bundled at analyzers/dotnet/cs in App.Web.Ref);
  consumers allow-list Assimalign.Cohesion.Web.Api.Generated via InterceptorsNamespaces.

Verification: 15 runtime e2e tests through WebApplicationTestFactory + 7 generator unit tests;
regressions green (Web.Routing 263, Web.Serialization 42, Http 1231, Http.Connections 434,
ObjectValidation 218). App.Web.Ref/Runtime packs validated (generator bundled at analyzers/dotnet/cs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Base automatically changed from feature/L03.01.02.01.09-route-host-httphost to main July 20, 2026 18:26
…owner decision

Removes the IValidator-carrying Map* overloads, EndpointValidationMetadata,
EndpointValidation, the metadata-carrying Map overload that existed only for the
carrier, the generator's validator modeling/emission, and the ObjectValidation
dependency from Web.Api (src and tests) and the generator tests. The
ObjectValidation AOT hardening (compile-free member walk; When -> Func<T,bool>)
is deliberately retained - it stands on its own. Descope recorded in the Web.Api
DESIGN (owner decision 2026-07-20) with validation now an explicit v1 non-goal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dotnetcadet dotnetcadet changed the title feat(web): source-generated typed-delegate endpoint binding + validation seam [L03.01.02.03] feat(web): source-generated typed-delegate endpoint binding [L03.01.02.03] Jul 20, 2026
@dotnetcadet
dotnetcadet merged commit 9630f3a into main Jul 20, 2026
82 checks passed
@dotnetcadet
dotnetcadet deleted the feature/L03.01.02.03-source-gen-binding branch July 20, 2026 19:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[L03.01.02.03] Source-generated endpoint parameter binding and validation framework (AOT)

1 participant