dotnet / dotnet/aspnet-api-versioning
Feature request: declarative attribute for endpoints introduced in a specific API version
- Dominant language
- C#
- Stars
- 3.2k
- Forks
- 721
- Avg merge
- 12h 51m
- Merged PRs (30d)
- 2
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` → 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:
1. Clients monitor 4xx rates by status code; 404s trigger different alerts than 400s.
2. 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.
3. 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`
```csharp
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`:
```csharp
namespace MyService.v2026_11_12.Controllers;
[ApiController]
[Route("api/[controller]")]
public sealed class OrdersController : ControllerBase
{
///
/// 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.
///
[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:
1. Hand-write a stub action that does nothing useful.
2. 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`).
3. Add `[ApiExplorerSettings(IgnoreApi = true)]` to keep the stub out of the v1 OpenAPI / Swagger surface.
4. Repeat for every v2-only endpoint.
5. 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":
```csharp
namespace MyService.v2026_12_01.Controllers;
[ApiController]
[Route("api/[controller]")]
public sealed class OrdersController : ControllerBase
{
[HttpPost("{id}/return"), IntroducedInApiVersion("2026-12-01")]
public Task> Return(string id, [FromBody] ReturnRequest request)
{
// Real implementation. No stub on the v1 controller required.
}
}
```
The framework then handles three concerns automatically:
1. **Routing-time gate.** Requests to this endpoint under api-versions earlier than the declared `SinceVersion` return the configured status code (default `404`).
2. **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.
3. **`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
```csharp
[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; }
///
/// Status code returned for requests under api-versions earlier than
/// . 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.
///
public int LegacyResponseStatusCode { get; init; } = StatusCodes.Status404NotFound;
}
```
### Usage examples
```csharp
// Default: 404 for prior versions
[HttpPost("{id}/return"), IntroducedInApiVersion("2026-12-01")]
public Task> Return(...) => ...;
// Custom: 410 Gone (paired with a Sunset header in the legacy version)
[HttpPost("legacy-feature"), IntroducedInApiVersion("2.0", LegacyResponseStatusCode = StatusCodes.Status410Gone)]
public Task 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
1. **Naming.** `[IntroducedInApiVersion]` reads naturally next to `[ApiVersion]` and `[MapToApiVersion]`. Alternatives: `[VersionedSince("X")]`, `[ApiVersionAddedIn("X")]`, `[FromApiVersion("X")]`. Maintainer preference very welcome.
2. **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.
3. **`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.
4. **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.
5. **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!
Contributor guide
Assessment
This issue has not been assessed yet.