Skip to content

Feature request: declarative attribute for endpoints introduced in a specific API version #1183

@xavierjohn

Description

@xavierjohn

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

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:

  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":

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:

  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

[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

  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!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions