Search code examples
c#.netasp.net-web-apiapi-versioning

ASP.NET Core API versioning with major.minor but only major in the path


Is there any way to achieve the following:

  • controllers are versioned with ApiVersionAttribute using major.minor pattern, so 1.1, 2.3 and so on
  • the corresponding routes contain only the major part in the path, for example /v1/WeatherForecast

I have tried adding something like below to the default project created for ASP.NET Core Web API (.NET 6).

WeatherForecastController.cs:

[ApiController]
[ApiVersion("1.1")]
[Route("v1/[controller]")]
public class WeatherForecastController : ControllerBase
{
}

Program.cs

builder.Services.AddApiVersioning(
    options =>
    {
        options.ReportApiVersions = true;
        options.AssumeDefaultVersionWhenUnspecified = true;
    });

And now I cannot call the endpoint /v1/WeatherForecast, I get the error:

{
    "error": {
        "code": "UnsupportedApiVersion",
        "message": "The HTTP resource that matches the request URI 'https://localhost:7170/v1/WeatherForecast' is not supported.",
        "innerError": null
    }
}

Thanks


Solution

  • It sounds like you might be conflating API version with some internal, server notion of version. The API version is part of your contract.

    By design, a client cannot ask for 1.0, but get 1.1. This is misleading and confusing to the client. As a server, you cannot pull the carpet out from under the client and expect that it won't break the client (unless you own both sides). Backward-compatible is a fallacy. A server cannot guarantee that adding or removing data attributes doesn't break a client. It might not, but you can't guarantee it.

    The reason nothing is matching above is because API Versioning doesn't make any assumptions or reasoning about your route templates. If you want the version in the URL path (not RESTful), that's your choice, but you must use the route constraint in the template as @kanils_ suggested. It should look like:

    [ApiController]
    [ApiVersion("1.1")]
    [Route("v{version:apiVersion}/[controller]")]
    public class WeatherForecastController : ControllerBase
    {
    }
    

    You're free to call the parameter whatever you want, but version is commonly used. This will now enable https://localhost:7170/v1.1/WeatherForecast. There is no definition for 1.0 so v1 will not resolve.

    options.AssumeDefaultVersionWhenUnspecified = true isn't going to do what you think it will do (in all likelihood). This is a highly abused feature. It is meant to support grandfathering in existing APIs before they were formally given an official version. This behavior exists for exactly one version. If this feature wasn't supported, then all existing clients that don't know to include a version in the request would break. A route template cannot have a default route parameter value unless it's at the end of the template. This means the template will never match unless the API version is specified in the URL. Versioning by URL segment is the only method that suffers from this problem.

    I don't endorse, agree with, or recommend using anything other than explicit API versions, but this can be made to work. You wouldn't return application/xml if a client asks for application/json and expect it to be understood. An API version is no different. Since you're versioning via URL segment, this will require double route registration. You need a neutral route as a catch all and each specific route. This will give clients an explicit URL who want something stable and a floating route for everything else. If a route is a moving target for a client, then it's honestly not any better than no versioning at all IMO.

    Start with:

    namespace V1
    {
      [ApiController]
      [ApiVersion("1.0")]
      [Route("v{version:apiVersion}/[controller]")]
      public class WeatherForecastController : ControllerBase
      {
      }
    }
    
    namespace V1_1
    {
      [ApiController]
      [ApiVersion("1.1")]
      [Route("[controller]")] // ← 2nd 'floating' route template
      [Route("v{version:apiVersion}/[controller]")]
      public class WeatherForecastController : ControllerBase
      {
      }
    }
    

    In the configuration add:

    builder.Services.AddApiVersioning(
        options =>
        {
            options.ReportApiVersions = true;
            options.AssumeDefaultVersionWhenUnspecified = true;
    
            // OPTION 1: explicitly set the default value;
            //           this is the 'assumed' version by default.
            //           the default value is new ApiVersion(1.0)
            //
            // options.DefaultApiVersion = new ApiVersion(1.1);
    
            // OPTION 2: most people using this setup want to 'float'
            //           an API to the most current version available
            //
            options.ApiVersionReader =
                new CurrentImplementationApiVersionSelector(options);
        });
    

    When a client asks for WeatherForecast, the request is directed to the same location as v1.1/WeatherForecast. This is because:

    • No API version is parsed from the template
    • AssumeDefaultVersionWhenUnspecified = true
    • CurrentImplementationApiVersionSelector resolved 1.1 as the current (e.g. highest) available version for that API

    When you eventually add a new version, say 1.2 or 2.0, then you will have to move the floating [Route("[controller]")] route template from the old current controller to the new one. There are probably more convenient ways of managing that, but that approach certainly works.

    Since the literal /v1 is unknown or recognized by API Versioning, you can probably combine it with this approach and it will work, but it's a head-scratcher IMHO.

    In all of these configurations, you can't (and shouldn't) eliminate the explicit routes, but you can achieve your implicit route goals. Absence of documentation can hide them.