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 → returns 400 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 sends Content-Type: application/octet-stream, no Content-Type at all, or text/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
SinceVersion return the configured status code (default 404).
- 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.
Allow header on 405 is 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. 404 matches the natural "endpoint doesn't exist yet" semantics. 410 Gone would be misleading as a default. Keeping 404 as the default and allowing override seems right.
-
AllowMultiple = false vs true. My instinct is false — anything more nuanced ("introduced, then deprecated, then re-introduced") should compose with the existing [ApiVersion] / Deprecated = true machinery, not stack [IntroducedInApiVersion] × N.
-
Sunset / Deprecation header integration. Out of scope for this proposal, but a follow-up could emit Sunset / Deprecation headers 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]'s Inherited = false semantics 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
IntroducedInApiVersionAttribute to Asp.Versioning.Abstractions (alongside ApiVersionAttribute, MapToApiVersionAttribute).
- Hook into
ApiVersionMatcherPolicy (or equivalent endpoint-selector pipeline) to short-circuit with the configured status code when RequestedApiVersion < SinceVersion.
- Hook into
ApiVersionDescriptionProvider to filter ApiDescription entries for the prior-version ApiVersionDescription group.
- 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!
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-12with N endpoints, then ship2026-12-01with the same N endpoints plus a new one — sayPOST /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 versionThe 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:
Current workarounds
There are two ways to solve this today, and both have downsides.
A. Global
UnsupportedApiVersionStatusCode = 404This 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-99against 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:This works, but the ceremony is significant per v2-only endpoint:
[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).[ApiExplorerSettings(IgnoreApi = true)]to keep the stub out of the v1 OpenAPI / Swagger surface.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":
The framework then handles three concerns automatically:
SinceVersionreturn the configured status code (default404).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
Usage examples
Interaction with existing primitives
[ApiVersion("X")][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")][ApiVersionNeutral]MisconfiguredApiVersionExceptionif both are present.VersionByNamespaceConvention[IntroducedInApiVersion]filters by minimum./v{version:apiVersion}/...)UnsupportedApiVersionStatusCodeglobalBackward 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
ReturnNotFoundOnV1stub 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
RequestedApiVersionagainst a declared minimum, plus an API Explorer filter. Both mechanisms already exist inAsp.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:
IntroducedInApiVersionAttributetoAsp.Versioning.Abstractions(alongsideApiVersionAttribute,MapToApiVersionAttribute).ApiVersionMatcherPolicy(or equivalent endpoint-selector pipeline) to short-circuit with the configured status code whenRequestedApiVersion < SinceVersion.ApiVersionDescriptionProviderto filterApiDescriptionentries for the prior-versionApiVersionDescriptiongroup.[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!