Add Trellis.Asp.ApiVersioning package: CreatedAtVersionedRoute helpers + TRLS023 analyzer#480
Conversation
…lpers Under query/header API versioning, today's CreatedAtRoute pattern produces a Location header that silently omits the api-version parameter unless every author remembers to add it to the route-value dictionary on every 201 Created. Forgetting it produces a Location URL that 404s on dereference — invisible without integration tests. Both 2026-05-06 lab models hit this; doc-only guidance has not been enough to shift behaviour. New package Trellis.Asp.ApiVersioning adds three CreatedAtVersionedRoute extension overloads on HttpResponseOptionsBuilder<TDomain> that resolve and inject the api-version route value at request time: - (routeName, routeValues) — multi-key route values. - (routeName, idSelector, idRouteKey = "id") — single-id convenience. - (routeName, routeValues, ApiVersion explicitVersion) — escape hatch for cross-version Location pinning. Per-request resolution order: 1. HttpContext.RequestedApiVersion — primary signal from the configured IApiVersionReader chain. 2. Endpoint metadata's single declared version — fallback when (1) is null. 3. ApiVersioningOptions.DefaultApiVersion — host-level fallback. 4. Otherwise — throw InvalidOperationException. Silent picking would resurrect the original 404 bug. Skips injection on [ApiVersionNeutral] endpoints and URL-segment-versioned routes (those carry the version as a route token; ambient routing handles substitution). Architecture is two-layer: - Trellis.Asp gains a generic primitive: HttpResponseOptionsBuilder<TDomain>.WithRouteValueResolver(string key, Func<HttpContext, string?> resolver) — registers a per-request route-value injector for Location-header generation. No Asp.Versioning dependency. Reusable for tenant id, culture code, or any other cross-cutting per-request concern. TrellisHttpResult.ResolveLocation invokes resolvers after the route-values selector. - Trellis.Asp.ApiVersioning depends on Trellis.Asp + Asp.Versioning.Mvc/.Mvc.ApiExplorer/.Http and provides the api-versioning-specific resolver plus the three CreatedAtVersionedRoute extensions. 6 TestHost integration tests cover the resolution order, multi-version controllers, the explicit-version pin, and the single-id convenience overload. 3 new unit tests on Trellis.Asp side cover the WithRouteValueResolver argument validation and chaining contract. Deferred follow-ups: TRLS_VER001 analyzer (warns on bare CreatedAtRoute calls missing the api-version key, with code-fix to migrate); build-time ApiVersionConstants literal-propagation generator; custom route-value key configuration.
Codecov Report❌ Patch coverage is ❌ Your patch check has failed because the patch coverage (71.54%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #480 +/- ##
==========================================
- Coverage 85.17% 84.88% -0.30%
==========================================
Files 302 305 +3
Lines 11503 11754 +251
Branches 2471 2542 +71
==========================================
+ Hits 9798 9977 +179
- Misses 1167 1205 +38
- Partials 538 572 +34
🚀 New features to boost your workflow:
|
…erator)
Part B — TRLS023 analyzer + code-fix in Trellis.Analyzers package.
Warns when HttpResponseOptionsBuilder<T>.CreatedAtRoute(routeName, c => new RouteValueDictionary { ... }) is invoked on a controller with [ApiVersion(...)] and the dictionary literal does not include an "api-version" key. The code-fix mechanically rewrites to CreatedAtVersionedRoute(...). Bails to false-negative for non-literal route values, [ApiVersionNeutral] controllers, and non-versioned controllers — preventing alarm fatigue.
This closes B37's regression risk: the analyzer catches the bug at compile time even for code that hasn't migrated to Part A's helper. 5 analyzer tests cover the four-quadrant matrix (versioned/non-versioned × literal/non-literal route values) plus the [ApiVersionNeutral] opt-out.
Part C1 — ApiVersionConstantsGenerator source generator bundled in Trellis.Asp.ApiVersioning.nupkg.
Reads the <TrellisApiVersion> MSBuild property and emits ApiVersionConstants.g.cs with public const string Current and CurrentNamespaceSuffix. Const semantics let the values flow into [ApiVersion(...)] attribute arguments — eliminating the literal-propagation drift across controllers when the active API version changes. A build/Trellis.Asp.ApiVersioning.props ships in build/ + buildTransitive/ so consumers automatically get CompilerVisibleProperty without per-project boilerplate. 3 generator tests verify the property-to-constant flow plus const-in-attribute usability.
Verification: 924/924 Trellis.Asp.Tests pass; 211/211 Trellis.Analyzers.Tests pass (was 206 + 5 new); 9/9 Trellis.Asp.ApiVersioning.Tests pass; all 19 other framework test projects pass (4400 tests total); local docfx --warningsAsErrors clean.
Deferred: Part C2 (text-asset substitution for http-client.env.json/.vscode/launch.json/api.http) — needs its own design pass for file-mutation strategy. Custom route-value key configuration — hosts using non-default IApiVersionReader parameter names should bypass CreatedAtVersionedRoute and call WithRouteValueResolver directly for now.
The flat ApiVersionConstants.Current model didn't fit services that support multiple API versions concurrently. Older [ApiVersion("...)] literals stay pinned to specific historical versions when a new version ships (because those endpoints ARE that version), so a single Current constant only covers brand-new endpoints — most references in a long-lived service stay as version-specific literals.
A correct generator would emit a multi-version directory (e.g., scan [ApiVersion] attributes at build time, emit KnownApiVersions.V20261112, V20261201, Current = V20261201) where each version literal lives exactly once. Tracked in BACKLOG.md as a separate design spike.
This PR keeps Parts A (runtime CreatedAtVersionedRoute helpers) and B (TRLS023 analyzer + code-fix) which together close the original "201 Created Location 404s on dereference" failure mode at runtime + compile time. The deferred multi-version directory and text-asset substitution work both go to follow-up PRs.
There was a problem hiding this comment.
Pull request overview
Adds first-class support for API-version-aware 201 Created Location headers in Trellis ASP integration by introducing a generic per-request route-value injection hook, a new Trellis.Asp.ApiVersioning package that builds CreatedAtVersionedRoute(...) on top of that hook, and a new analyzer/code-fix (TRLS023) to prevent regressions when authors use CreatedAtRoute(...) without including "api-version".
Changes:
- Add
HttpResponseOptionsBuilder<TDomain>.WithRouteValueResolver(...)and apply registered resolvers duringCreatedAtRoute/CreatedAtActionlink generation. - Introduce
Trellis.Asp.ApiVersioningwithCreatedAtVersionedRouteoverloads and integration tests. - Add
TRLS023analyzer + code fix and wire it into analyzer release tracking.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| Trellis.slnx | Adds the new Trellis.Asp.ApiVersioning projects to the solution folder structure. |
| Trellis.Asp/tests/HttpResponseOptionsBuilderTests.cs | Adds unit tests for WithRouteValueResolver argument validation + chaining. |
| Trellis.Asp/src/Response/TrellisHttpResult.cs | Applies per-request route-value resolvers when generating Location for route/action modes. |
| Trellis.Asp/src/Response/HttpResponseOptionsBuilder.cs | Adds WithRouteValueResolver API and plumbs resolver dictionary into built options. |
| Trellis.Asp.ApiVersioning/src/Trellis.Asp.ApiVersioning.csproj | Introduces the new package project and its Asp.Versioning dependencies. |
| Trellis.Asp.ApiVersioning/src/HttpResponseOptionsBuilderApiVersioningExtensions.cs | Adds CreatedAtVersionedRoute overloads and per-request version resolution logic. |
| Trellis.Asp.ApiVersioning/tests/Trellis.Asp.ApiVersioning.Tests.csproj | Adds the new test project and references needed for integration testing. |
| Trellis.Asp.ApiVersioning/tests/CreatedAtVersionedRouteTests.cs | Integration tests asserting Location contains (or omits) api-version correctly across configurations. |
| Trellis.Asp.ApiVersioning/NUGET_README.md | Package README content for NuGet. |
| Trellis.Analyzers/src/TrellisDiagnosticIds.cs | Adds TRLS023 diagnostic ID constant. |
| Trellis.Analyzers/src/DiagnosticDescriptors.cs | Adds descriptor text for TRLS023. |
| Trellis.Analyzers/src/CreatedAtRouteMissingApiVersionAnalyzer.cs | Implements TRLS023 detection. |
| Trellis.Analyzers/src/CreatedAtRouteMissingApiVersionCodeFixProvider.cs | Implements code fix rewriting CreatedAtRoute → CreatedAtVersionedRoute. |
| Trellis.Analyzers/tests/CreatedAtRouteMissingApiVersionAnalyzerTests.cs | Adds analyzer tests for warning/no-warning scenarios. |
| Trellis.Analyzers/src/AnalyzerReleases.Unshipped.md | Adds TRLS023 to the unshipped analyzer release log. |
| Directory.Packages.props | Adds central version for Asp.Versioning.Http. |
| CHANGELOG.md | Documents the new package + analyzer and the rationale. |
…nce, add using on fix TRLS023 false-positive: case-sensitive 'api-version' key match. RouteValueDictionary uses case-insensitive comparison at runtime, so [API-VERSION] already supplies the route value. Compare with OrdinalIgnoreCase. TRLS023 false-positive: walked BaseType chain to find [ApiVersion]. The attribute is declared with Inherited=false, so derived controllers without their own [ApiVersion] are not versioned by API Versioning itself. Inspect only GetAttributes() on the immediate type. TRLS023 code fix produced uncompilable code: only renamed CreatedAtRoute to CreatedAtVersionedRoute without adding 'using Trellis.Asp.ApiVersioning;'. Add the using directive when missing, matching existing line-ending style.
- TrellisHttpResult: clone RouteValueDictionary before applying per-request route-value resolvers. Selectors that return cached/shared instances would otherwise mutate cross-request and create thread-safety issues. Applied at both LocationKind.Route and LocationKind.Action sites.
- HttpResponseOptionsBuilderApiVersioningExtensions: drop unused System.Linq and global::Asp.Versioning.ApiExplorer using directives.
- Trellis.Asp.ApiVersioning.csproj: add <TrellisApiRefName>asp-apiversioning</TrellisApiRefName> so Directory.Build.targets auto-packs the API reference markdown into the nupkg, matching the pattern used by every other Trellis package.
- docs/docfx_project/api_reference/trellis-api-asp-apiversioning.md: new LLM-targeted API reference for the package (3 CreatedAtVersionedRoute overloads + behavioural notes covering [ApiVersionNeutral], URL-segment versioning, resolution order, manual api-version key override).
- NUGET_README.md: align with implementation — say HttpContext.RequestedApiVersion (the Asp.Versioning.Http extension property), not the non-existent GetRequestedApiVersion() method.
- CreatedAtRouteMissingApiVersionAnalyzer: clarify that the RouteValueDictionary(new {...}) ctor shape is intentionally a false-negative — supporting it would require an anonymous-object-member visitor.
TRLS023 analyzer: detect anonymous-object ctor-arg shape (new RouteValueDictionary(new { id = ... })) as definitely-missing api-version. C# property names cannot contain hyphens, so an anonymous-object route-values argument can never carry the api-version key. Refactor ExtractRouteValueDictionaryInitializer into ClassifyRouteValuesShape returning a tri-state (Initializer / AnonymousObjectCtorArg / Unrecognized).
TRLS023 code fix: HasUsing now walks namespace-scoped usings (NamespaceDeclarationSyntax.Usings + FileScopedNamespaceDeclarationSyntax.Usings) in addition to top-level CompilationUnitSyntax.Usings. The repo convention is file-scoped namespaces with usings inside the namespace block, so the previous top-level-only check would re-add the using even when already in scope.
Trellis.Asp.ApiVersioning: correct comment in CreatedAtVersionedRoute that incorrectly claimed the route-value key is read from IOptions. The key is the constant DefaultRouteValueKey.
trellis-api-asp-apiversioning.md: explicit-version overload signature corrected from string apiVersion to ApiVersion explicitVersion in Patterns Index, method table, and code example. Common traps corrected: the resolver runs after the user selector and overwrites pre-existing api-version entries; manual entries are silently discarded.
TrellisHttpResult: defer the RouteValueDictionary clone in ApplyRouteValueResolvers until at least one resolver returns a non-null value. When every resolver returns null (e.g., api-version resolver short-circuits for [ApiVersionNeutral] or URL-segment versioning), the original dictionary is now returned unchanged with no allocation. Cross-request-leakage protection is preserved because the clone still happens before any write. TRLS023 analyzer: const-string keys are now resolved via SemanticModel.GetConstantValue. Patterns like [ApiVersionKey] = ... where ApiVersionKey is const string ApiVersionKey = "api-version" no longer produce a false-positive missing-api-version warning. Applies to both ImplicitElementAccessSyntax keys and pre-C#6 collection-initializer-pair keys. TRLS023 code fix: when adding using Trellis.Asp.ApiVersioning the directive is now placed in the same scope as existing usings. If the file uses a file-scoped or block-scoped namespace with usings inside it (the repo convention), the new using is added inside the namespace block rather than above the namespace declaration. Trellis.Asp.ApiVersioning tests: add coverage for [ApiVersionNeutral] short-circuit branch — verifies the Location header omits api-version when the controller is version-neutral.
Trellis.Asp.ApiVersioning.Tests.csproj: add explicit FrameworkReference Microsoft.AspNetCore.App so the project pattern matches Trellis.Asp.Tests and Trellis.Testing.AspNetCore.Tests. Builds were succeeding via transitive metadata from Microsoft.AspNetCore.Mvc.Testing, but the explicit reference is the repo convention for ASP.NET Core test projects. Trellis.Asp.ApiVersioning/README.md: new repo-level README alongside NUGET_README.md, matching the dual-README pattern used by the other Trellis packages. Provides a GitHub-friendly landing page distinct from the NuGet readme. trellis-api-asp.md: documents WithRouteValueResolver as a public Trellis.Asp API surface (the per-request route-value injection hook that Trellis.Asp.ApiVersioning builds on). Updates the existing CreatedAtRoute row to point readers at the CreatedAtVersionedRoute extensions and the TRLS023 analyzer rather than recommending manual ["api-version"] = ApiVersion. trellis-api-analyzers.md: TRLS023 added to the Diagnostics rule table, the analyzer-id table, the descriptors table, the descriptor section (with the recognised dictionary shapes and case-insensitive matching documented), and the code-fix providers table. Trellis.Asp.ApiVersioning.Tests: add UrlSegment_versioned_route_omits_api_version_query_from_Location to lock in the URL-segment skip branch — verifies that when the route template uses :apiVersion the resolver does not also inject a ?api-version= query parameter into the Location. The throw branch (multi-version + no client + no DefaultApiVersion) cannot be reached via integration tests with the standard Asp.Versioning configuration: AssumeDefaultVersionWhenUnspecified=true requires DefaultApiVersion, and without it a request without ?api-version= 400s before the controller runs. Reaching the branch requires a custom IApiVersionSelector and would not exercise the same path real consumers hit; left as a documented invariant in the resolver source.
…ning The DocFX api_reference/ files (LLM-targeted) were updated in earlier review rounds, but the human-readable articles/ tree still pointed at the old "manually add api-version" pattern and there was no TRLS023 article. articles/analyzers/TRLS023.md: new analyzer article matching the existing TRLSnnn template — describes detection, why-it-matters, bad/good examples (with the recommended CreatedAtVersionedRoute migration as the primary fix and manual ["api-version"] = ApiVersion as the no-package alternative), code-fix behaviour, configuration, suppression, and limitations. articles/analyzers/toc.yml: TRLS023 entry added. articles/integration-aspnet.md: existing CreatedAtRoute warning callout and "Practical guidance" bullet now point at CreatedAtVersionedRoute and TRLS023 as the recommended path. New subsection "API-version-aware Location headers" added between Created-responses and WriteOutcome — covers the resolver order (RequestedApiVersion -> declared -> default -> throw), the [ApiVersionNeutral] / URL-segment skip, the three overloads, and the WithRouteValueResolver hook. articles/asp-tohttpresponse.md: warning callout updated similarly. docfx build --warningsAsErrors clean.
Doc-only and comment-clarity fixes; no behavioural change. trellis-api-asp.md, integration-aspnet.md: WithRouteValueResolver signature corrected from Func<HttpContext, object?> to Func<HttpContext, string?> (the actual implemented signature). Reviewer noted the doc/impl mismatch on lines 109 / 338. Keeping the implementation at string? since all current and anticipated uses (api-version, tenant id, request culture, etc.) are string-shaped; widening to object? would expand the public surface without a concrete need. TrellisHttpResult.cs ApplyRouteValueResolvers comment: dropped the misleading "previous unconditional clone" reference. The unconditional clone existed only briefly within this same PR; describing it as a "previous" state is confusing for future readers. The comment now describes the actual protection (lazy per-request clone on first non-null resolver write). asp-tohttpresponse.md: corrected "omits the version segment" wording to "omits the api-version query parameter" — under query-string/header versioning the failure mode is a missing query param, not a missing path segment.
ApiVersionNeutralAttribute is declared with AttributeTargets.Class | Method, so it can be applied either to the controller class or to an individual action. The analyzer previously only checked the containing class, producing false positives on bare CreatedAtRoute calls inside an [ApiVersionNeutral] action of an otherwise-versioned controller. Now also inspect the immediate containing method symbol via HasAttributeOnMethod. Test added: CreatedAtRoute_on_method_level_ApiVersionNeutral_action_produces_no_warning. 220/220 Trellis.Analyzers (+1).
Issue
Under query/header API versioning, today's
CreatedAtRoute(...)pattern produces aLocationheader that silently omits theapi-versionparameter unless every author remembers to add["api-version"] = "<version>"to the route-value dictionary on every 201 Created. Forgetting it produces a Location URL that 404s on dereference — invisible without integration tests.Both 2026-05-06 lab models hit this. The pattern is documented as a Mistake-regression routing hint in the cookbook, but doc-only guidance hasn't been enough to shift behaviour (B37: "Opus regressed on this between 2026-04-29 and 2026-04-30 despite Patterns Index callout").
Fix
New package
Trellis.Asp.ApiVersioningwith two integrated parts that together close the failure mode at runtime + compile time:Part A — runtime helper
Three
CreatedAtVersionedRouteextension overloads onHttpResponseOptionsBuilder<TDomain>resolve and inject theapi-versionroute value at request time:Per-request resolution:
HttpContext.RequestedApiVersion→ endpoint metadata's single declared version →ApiVersioningOptions.DefaultApiVersion→ throwInvalidOperationException. Skips injection on[ApiVersionNeutral]endpoints and URL-segment-versioned routes. Three overloads cover multi-key, single-id-convenience, and explicit-version-pin cases.Part B —
TRLS023analyzer + code-fixWarns when
HttpResponseOptionsBuilder<T>.CreatedAtRoute(routeName, c => new RouteValueDictionary { ... })is invoked on a controller with[ApiVersion(...)]and the dictionary literal does not include an"api-version"key. The code-fix mechanically rewrites toCreatedAtVersionedRoute(...). Bails to a false-negative for non-literal route values,[ApiVersionNeutral]controllers, and non-versioned controllers — preventing alarm fatigue.This closes B37's regression risk: the analyzer catches the bug at compile time even for code that hasn't migrated to the helper.
Architecture (additive)
Trellis.Aspgains one new generic primitive —HttpResponseOptionsBuilder<TDomain>.WithRouteValueResolver(string key, Func<HttpContext, string?> resolver)— that lets any consumer register a per-request route-value injector.Trellis.Asp.ApiVersioningbuildsCreatedAtVersionedRouteon top of this hook. The hook is reusable for tenant id, culture code, or any other cross-cutting per-request concern; the api-versioning package'sAsp.Versioning.*dependency stays contained.Tests
Trellis.Asp.Tests(hook layer)WithRouteValueResolverarg validation + chaining contractTrellis.Asp.ApiVersioning.TestsTrellis.Analyzers.Tests[ApiVersionNeutral]→ no warning; non-literal route values → no warningLocal verification: 924/924 Trellis.Asp; 211/211 Trellis.Analyzers (was 206 + 5 new); 6/6 new package; all 19 other framework test projects pass; local
docfx build --warningsAsErrorsclean.Out of scope (deferred, tracked in BACKLOG)
ApiVersionConstants.Currentsource generator was implemented and reverted in this PR after recognising the design flaw: a singleCurrentconstant doesn't model services that support multiple API versions concurrently — older[ApiVersion(...)]literals stay pinned to historical values when a new version ships, soCurrentonly covers brand-new endpoints. A correct generator would scan[ApiVersion]attributes at build time and emit a multi-version directory (KnownApiVersions.V20261112,V20261201,Current = V20261201). Tracked separately as a design spike.http-client.env.json,.vscode/launch.json,api.http. The MSBuild target that mutates tracked files needs its own design pass (file-mutation vs. template-with-output, IDE behaviour, merge conflicts).IApiVersionReaderparameter name should bypassCreatedAtVersionedRouteand callWithRouteValueResolverdirectly for now.