Feature request: declarative attribute for endpoints introduced in a specific API version
@commonsensesoftware is already working on this.
Since Aug 6, 2026.
Assessment
This issue has not been assessed yet.
Description
Feature request: declarative attribute for endpoints introduced in a specific API version
Summary
Add a first-class attribute to declare that an endpoint was introduced in a specific API version, so requests under prior versions return a clear 404 (or other configured status) without per-endpoint stub actions or global side-effects.
Working name suggestion: [IntroducedInApiVersion("...")]. Naming open to discussion.
Motivation
Evolving an API across multiple versions is a common scenario: ship 2026-11-12 with N endpoints, then ship 2026-12-01 with the same N endpoints plus a new one — say POST /api/orders/{id}/return — that should only be reachable under v2.
The behaviour the framework exposes today is:
GET /api/orders/{id}/return?api-version=2026-12-01→ routes to the v2 action ✅GET /api/orders/{id}/return?api-version=2026-11-12→ returns400 Unsupported API version⚠️
The 400 is technically correct (the combination of route + version is unsupported), but in practice clients on v1 are calling a route that doesn't exist for them, which is a 404-shaped concern. RFC 9110 §15.5.5 maps "the origin server did not find a current representation for the target resource" to 404; "version doesn't exist" maps to 400. Today the framework cannot distinguish.
Distinguishing matters in practice because:
- Clients monitor 4xx rates by status code; 404s trigger different alerts than 400s.
- v1 clients should be able to safely probe routes (e.g., a feature-detection script) and infer "the operation is not available on my version" from the status without parsing a 400 body.
- For routes that truly don't exist on any version (typo'd path) the same 400 fires today, conflating two real client bugs.
Current workarounds
There are two ways to solve this today, and both have downsides.
A. Global UnsupportedApiVersionStatusCode = 404
services.AddApiVersioning(options =>
{
options.UnsupportedApiVersionStatusCode = StatusCodes.Status404NotFound;
});
This makes the 400 in the example above a 404 — but it also turns every unsupported-version response into a 404, including a request like ?api-version=2025-99-99 against a route that exists in current versions. That request is "I sent a version you don't recognise", which is more naturally a 400. The global flag conflates the two semantics.
B. Per-endpoint stub action returning 404
Add an action with the same path on the v1 controller that explicitly returns 404:
namespace MyService.v2026_11_12.Controllers;
[ApiController]
[Route("api/[controller]")]
public sealed class OrdersController : ControllerBase
{
/// <summary>
/// Stub for the v2-only POST /api/orders/{id}/return endpoint. v1 clients hitting
/// this path get a clean 404 instead of the framework default 400. The Consumes list
/// enumerates content types because [Consumes("application/json")] at the controller
/// level would otherwise produce 415 (Unsupported Media Type) before the action runs
/// for any client that sends a non-JSON content type or no body.
/// </summary>
[HttpPost("{id}/return")]
[Consumes("application/json", "application/*+json", "text/json", "text/plain", "application/octet-stream")]
[ApiExplorerSettings(IgnoreApi = true)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult ReturnNotFoundOnV1(string id) => NotFound();
// ... v1's actual 11 endpoints below ...
}
This works, but the ceremony is significant per v2-only endpoint:
- Hand-write a stub action that does nothing useful.
- Enumerate plausible content types in
[Consumes]to prevent the framework's[Consumes("application/json")]-driven 415 short-circuit before the action runs (e.g., when a probing client sendsContent-Type: application/octet-stream, noContent-Typeat all, ortext/plain). - Add
[ApiExplorerSettings(IgnoreApi = true)]to keep the stub out of the v1 OpenAPI / Swagger surface. - Repeat for every v2-only endpoint.
- Keep the stubs in sync with the v2 controller as endpoints are added or removed — no compile-time guarantee.
For a service with three v2-only endpoints, this is ~50 lines of boilerplate that only converts a 400 into a 404. The boilerplate has to be maintained alongside the v2 controller indefinitely.
Proposed solution
A new attribute that declares an endpoint as "introduced in version X":
namespace MyService.v2026_12_01.Controllers;
[ApiController]
[Route("api/[controller]")]
public sealed class OrdersController : ControllerBase
{
[HttpPost("{id}/return"), IntroducedInApiVersion("2026-12-01")]
public Task<ActionResult<OrderResponse>> Return(string id, [FromBody] ReturnRequest request)
{
// Real implementation. No stub on the v1 controller required.
}
}
The framework then handles three concerns automatically:
- Routing-time gate. Requests to this endpoint under api-versions earlier than the declared
SinceVersionreturn the configured status code (default404). - API Explorer filtering. The action is hidden from the API Explorer (and therefore the OpenAPI / Swagger doc) for prior api-versions. Clients reading v1's swagger don't see the endpoint at all.
Allowheader on405is unaffected. When a route is shared between a v1 stub and a v2 real action via path, the existing 405-vs-404 behaviour stays consistent.
Proposed signature
[AttributeUsage(
AttributeTargets.Method | AttributeTargets.Class,
AllowMultiple = false,
Inherited = false)]
public sealed class IntroducedInApiVersionAttribute : Attribute
{
public IntroducedInApiVersionAttribute(string sinceVersion);
public IntroducedInApiVersionAttribute(int major, int minor);
public IntroducedInApiVersionAttribute(int year, int month, int day);
public ApiVersion SinceVersion { get; }
/// <summary>
/// Status code returned for requests under api-versions earlier than
/// <see cref="SinceVersion"/>. Defaults to 404 (the endpoint did not
/// exist in the requested version). 410 (Gone) is a reasonable
/// alternative for endpoints that explicitly want to signal removal.
/// </summary>
public int LegacyResponseStatusCode { get; init; } = StatusCodes.Status404NotFound;
}
Usage examples
// Default: 404 for prior versions
[HttpPost("{id}/return"), IntroducedInApiVersion("2026-12-01")]
public Task<ActionResult<OrderResponse>> Return(...) => ...;
// Custom: 410 Gone (paired with a Sunset header in the legacy version)
[HttpPost("legacy-feature"), IntroducedInApiVersion("2.0", LegacyResponseStatusCode = StatusCodes.Status410Gone)]
public Task<ActionResult> LegacyFeature(...) => ...;
// At controller level — every action inherits unless overridden
[ApiController, IntroducedInApiVersion("2026-12-01"), Route("api/[controller]")]
public sealed class WidgetsController : ControllerBase
{
// every action in this controller is v2+ only
}
Interaction with existing primitives
| Existing | Interaction |
|---|---|
[ApiVersion("X")] |
Orthogonal. [ApiVersion] declares which versions an endpoint supports; [IntroducedInApiVersion] declares the minimum. A v2+ endpoint that supports v2, v3, v4 still uses [ApiVersion] × 3 for the supported set. |
[MapToApiVersion("Y")] |
Unchanged. The new attribute is a routing-time gate, not an action-version mapping. |
[ApiVersionNeutral] |
Incompatible. A neutral endpoint has no version concept. Should fail at startup with a clear MisconfiguredApiVersionException if both are present. |
VersionByNamespaceConvention |
Composes cleanly. The convention determines which versions an action supports; [IntroducedInApiVersion] filters by minimum. |
URL-segment versioning (/v{version:apiVersion}/...) |
Same routing-time gate. No special-casing needed. |
UnsupportedApiVersionStatusCode global |
Becomes per-endpoint-overridable. The global remains the default for "version doesn't exist at all"; the attribute overrides for "endpoint doesn't exist in this version". |
Backward compatibility
The attribute is additive. Existing applications that don't use it are unaffected. Applications using the per-endpoint stub workaround can migrate one endpoint at a time — replacing the ReturnNotFoundOnV1 stub with [IntroducedInApiVersion] on the v2 action, then deleting the v1 stub.
Open design questions
-
Naming.
[IntroducedInApiVersion]reads naturally next to[ApiVersion]and[MapToApiVersion]. Alternatives:[VersionedSince("X")],[ApiVersionAddedIn("X")],[FromApiVersion("X")]. Maintainer preference very welcome. -
Default status code.
404matches the natural "endpoint doesn't exist yet" semantics.410 Gonewould be misleading as a default. Keeping404as the default and allowing override seems right. -
AllowMultiple = falsevstrue. My instinct isfalse— anything more nuanced ("introduced, then deprecated, then re-introduced") should compose with the existing[ApiVersion]/Deprecated = truemachinery, not stack[IntroducedInApiVersion]× N. -
Sunset / Deprecation header integration. Out of scope for this proposal, but a follow-up could emit
Sunset/Deprecationheaders on requests to a deprecated version. Mentioning here so the design doesn't accidentally close that door. -
Class-level inheritance. Should
[IntroducedInApiVersion]at the controller level apply to every action, or only those without an action-level override? Mirroring[ApiVersion]'sInherited = falsesemantics seems consistent.
Why this belongs upstream
The behaviour is a pure routing-time check based on RequestedApiVersion against a declared minimum, plus an API Explorer filter. Both mechanisms already exist in Asp.Versioning; the missing piece is the declarative attribute that ties them together.
A downstream library cannot implement this cleanly because the routing-time dispatch and API Explorer filtering are framework-internal concerns. The current workarounds rely on the consumer reaching past the framework boundary (writing stub actions, hand-managing [ApiExplorerSettings], enumerating [Consumes] content types). Promoting it to a first-class attribute would prevent every consumer who ships an additive v2 from re-discovering and re-implementing the same workaround.
Implementation sketch
I'd be happy to contribute the implementation if there's interest. Rough plan:
- Add
IntroducedInApiVersionAttributetoAsp.Versioning.Abstractions(alongsideApiVersionAttribute,MapToApiVersionAttribute). - Hook into
ApiVersionMatcherPolicy(or equivalent endpoint-selector pipeline) to short-circuit with the configured status code whenRequestedApiVersion < SinceVersion. - Hook into
ApiVersionDescriptionProviderto filterApiDescriptionentries for the prior-versionApiVersionDescriptiongroup. - Tests: routing dispatch matrix (404 / 410 / 400), API Explorer surface check across two versions, interaction with
[ApiVersion]+[MapToApiVersion]+ namespace convention.
Want to align on the attribute name and signature first before I open a PR — happy to iterate based on maintainer guidance.
Thanks!
- Dominant language
- C#
- Stars
- 3.2k
- Forks
- 721
- PR merge metrics
- No merged PRs in 30d
Getting set up
Starts the project's dev container in your browser, under your own GitHub account.
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from dotnet/aspnet-api-versioning
-
Stay backwards compatible within major versionPossibly taken @commonsensesoftware claimed this 3 days ago. Openbug triage
dotnet/aspnet-api-versioning#1230 · 1 comment · 1 assignee ·
-
VersionedOpenApiOptionsFactory race: concurrent document requests cause NullReferenceException or unconfigured documentPossibly taken @commonsensesoftware claimed this 11 days ago. Openbug triage
dotnet/aspnet-api-versioning#1228 · 1 assignee ·
-
InvalidOperationException when create schema on transformerPossibly taken @commonsensesoftware claimed this 11 days ago. Openasp.net core triage
dotnet/aspnet-api-versioning#1227 · 1 assignee ·
-
.NET 11 RC - AOT Mode - System.NotSupportedException: ElementType is not initialized when `Microsoft.AspNetCore.Mvc.ApiExplorer.IsEnhancedModelMetadataSupported` is false.Possibly taken @commonsensesoftware claimed this 11 days ago. Openbug triage
dotnet/aspnet-api-versioning#1226 · 1 comment · 1 assignee ·
-
xmldoc file detection by assembly from a referenced projectPossibly taken @commonsensesoftware claimed this 26 days ago. Openasp.net core enhancement
dotnet/aspnet-api-versioning#1224 · 3 comments · 1 assignee ·
All issues in dotnet/aspnet-api-versioning
Similar issues
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
ClickHouse/clickhouse-cs#639 ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 92/100
ifpebj-ti/controle-acesso-veiculos#377 ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
-
Money ExploitsOpenS: Untriaged
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
project-wayfarer/wayfarer-14#1628 ·
Maintainers usually reply within 3 days
-
:watch: Not Triaged dotnet-target-version
Difficulty 1/5 Under an hour Newbie friendliness 85/100
Maintainers usually reply within 1 day