aspnet-api-versioning
aspnet-api-versioning copied to clipboard
Using only major versions in url doesn't fully work.
For a project I am working on we have to follow API design rules from the Dutch government. Unfortunately they insist on using the version in the URL, but only the major version. We use .NET 6, OData and the preview version of the library.
I used the following configuration in order to get started:
builder.Services.AddApiVersioning().AddOData(options =>
{
options.AddRouteComponents("odata/v{version:apiVersion}");
}).AddODataApiExplorer(options=>
{
options.GroupNameFormat = "'v'V";
options.SubstituteApiVersionInUrl = true;
options.SubstitutionFormat = "V";
});
This seems to be working as long as I specify a controller with [ApiVersion(1.0)], but as soon as I specify a minor version [ApiVersion(1.1)] I see the actions on the controller in the OpenAPI documentation, but I get a 404 status code back when calling the API.
This is reproducible in the ODataOpenApi example by changing the configuration to reflect the configuration above and try to use the 0.9 version of the api.
I thought this might be easy to fix, since you can specify the ApiVersionSelector as CurrentImplementationApiVersionSelector, but this selector isn't being called at all, instead the ApiVersionPolicyJumpTable is being called and there doesn't seem to be a way to get the correct destination based on the version I select.
So I guess what I wan't to know is whether or not I am doing something wrong (except for using the version in the URL). And if not, would it be an option to use the ApiVersionSelector inside the ApiVersionPolicyJumpTable?
Sorry to hear you couldn't convince them to not use the URL path. Sounds like you've come over to the light side and understand the problems that can arise.
The issue here appears to rooted in the OpenAPI UI. I presume that you have not tried invoking the API in a unit test or directly via a tool such as Postman. I would expect those methods to work, but not via the UI. Why? I see you've combined two features SubstituteApiVersionInUrl = true which will replace the {version:apiVersion} token with the formatted ApiVersion and you've specified SubstitutionFormat = "V". Per the formatting documentation, this will only format/keep the major version during substitution. This means that 1.1 into odata/v{version:apiVersion} will become odata/v1 when you still should want odata/v1.1. In a similar manner, 0.9 would become odata/v0. Since the value is substituted in the URL, it will look correct and appear in the UI, but when you try to invoke it, the request will go to the wrong place.
The default substitution format is VVV, which will use the major, optional minor, and status - if present. When the minor version is 0 it is not displayed. This is purely for display. The route will match on 1.0 or just 1.
If you can only use a major version, I'm not sure I understand why you are trying to shoehorn in a minor version. Attempting to use a minor version secretly will cause you grief. This will include routing as well as grouping in the documentation. As an example, if 1.0 and 1.1 both end up in the v1 group for documentation, you're likely going to have a duplicate path entry, which OpenAPI does not allow. If you can't use a minor version and the number just keeps increasing, then it would seem to be out of your control. You could, however, highlight how it is and will cause problems going forward with that approach.
API Versioning requires that versions be explicit. The one and only edge case is to allow grandfathering in existing services that didn't formally declare a version before. Such an API still has some version, but it's not named or declared anywhere. This is typically referred to as the DefaultApiVersion. The IApiVersionSelector has a few potential use cases, but its main purpose is to select the approach API version given a HTTP request and the API version of all possible candidate matches. If an API version is explicitly requested, then IApiVersionSelector.SelectApiVersion will never be called. The server cannot chose something different from what the client explicitly asked for. This is by design.
I'd recommend staying away from messing with the jump table. You're, of course, able to, but that's getting way into the weeds and I don't think it's necessary for your goals. Keep in mind that API version != app version. The API version is the version of the public contract or API. You might have implementation versions that are incongruent. That is ok. I wouldn't try to force them to be the same. It's generally a bad idea to have a client ask for a version - say 1.0 or 1.1 - and then match on 1.2. A server cannot match assumptions about what the client wants (unless of course you own and control both sides). It is often assumed that a minor version bump means the API is backward compatible and any client can handle it. That simply is not true or guaranteed. The server cannot be sure that some client will break. Breaking even one client could put you in a bad situation. Clients should always have to ask for a version explicitly and the server should always serve that request or indicate that it's not available.
Thanks for the reply, I kind of suspected that it wasn't supported by this library, but since I really need the api explorer support, I thought I'd give it a try anyways.
The reasoning behind using /v1/orders and than using version 1.1 is to just support a single minor version of the api per major. So we will not have those conflicting api's where you are talking about. They even go as far as using semver for the versioning as well, which I find even stranger. A work around in my case would be to only use major versions and never tell anybody that a minor version has been added. But that doesn't feel right either.
I actually didn't try to convince anybody otherwise, because they keep telling me "this is the government standard, use it", so I think I will go directly to the people who wrote the guidelines and start the discussion there...
The issue here appears to rooted in the OpenAPI UI. I presume that you have not tried invoking the API in a unit test or directly via a tool such as Postman. I would expect those methods to work, but not via the UI.
Actually I did try this via postman as well, but as I said in my previous post, I do need the major version. But I will test some more...
Oh the 😱s of government standards. 😆 Dr Fielding says _"don't do it" so it's kind of hard to argue with that. It's like the university student in class that raises their hand and says "I don't think that's what the author meant.". The professor responds with "Actually, that is what I meant when I wrote the book." 🤣
I'm thinking about other possible solutions. If a client can only ask for /v1, regardless of minor version, then why does the minor version matter at all? Again, it sounds like the minor version is a build or patch revision that only the server knows about. If that's the case, I'm not sure I understand why it needs to be exposed externally.
Is the minor version consistent for all versions in a release? It seems like it might be. It's unclear if you ask for 1 and the minor version is constant, how you can get anything other than that specific version (e.g. 1.1 and 1.2 side-by-side don't make sense because "There can be only one!"). If this is the case, then some new functionality in 6.0 might work for you. There is a new, replaceable IApiVersionParser service that you could swap out. You could change the parsing to always use a specific minor version. This would mean that 1 could parse to 1.1 or whatever you wish. This is only feasible if all minor versions are the same. With a little more information, I might have some other suggestions.
In an other project, we changed the version of one controller from 1.0 to 1.1, but the other controller we didn't change. I guess it wouldn't have been a problem when we would have changed the other version number as well. I don't have any examples with multiple major versions yet.
But I was thinking about another solution.
We basically already split up our controllers into major versions by putting them in separate namespaces. By specifying the major version on the controller itself like:
[ApiVersion(1.0)]
[ODataRouteComponent("odata/v1")]
public class Order : ODataController { ... }
I can use the controllers with the major version in the URL and I can use the api-version query parameter to specify the exact version I want:
GET https://localhost:51187/odata/v1/orders?api-version=1.1
This part is already working and seems to work fine. But now it would be great if I don't have to specify the api-version and default to the latest minor version, but I am not sure (yet) how to do that :)
How I got it working as I would wanted is by creating a custom IApiVersionSelector with the following implementation:
public class CurrentMinorApiVersionSelector : IApiVersionSelector
{
public ApiVersion SelectVersion(HttpRequest request, ApiVersionModel model)
{
if (model == null)
{
throw new ArgumentNullException(nameof(model));
}
var pattern = RoutePatternFactory.Parse("/odata/v{version:apiVersion}/{**catchall}");
if(request.TryGetApiVersionFromPath(new[] { pattern }, "apiVersion", out var apiversion))
{
var parser = request.HttpContext.RequestServices.GetRequiredService<IApiVersionParser>();
var majorversion = parser.Parse(apiversion);
return model.ImplementedApiVersions.Where(x => x.MajorVersion == majorversion.MajorVersion).Where(x => x.Status is null).Max(x => x);
}
return options.DefaultApiVersion;
}
}
So with the following controller:
[ApiVersion(1.0)]
[ApiVersion(1.1)]
[ODataRouteComponent("odata/v1")]
public class OrdersController : ODataController
when I request: /odata/v1/orders I get version 1.1, but I can also use /odata/v1/orders?api-version=1.0 to get 1.0
It does still seem strange to me that if you have a mapping to 1.0 and 1.1, but you always go to the latest minor version, then why is 1.0 still needed? There's no way for a client to route there, right?
There are a couple of ways I can think of that might make this work the way you want.
Option 1
If the API version minor version is consistent, then you can use a custom IApiVersionParser or custom middleware to set IApiVersioningFeature.RequestedApiVersion to an explicit ApiVersion with the desired minor version. If it's not consistent, you might be able to use a simple Dictionary<K,V> as a map to look up the correct minor version.
The biggest issue at this part of the pipeline is that there are no candidates yet. This is why you're not able to use IApiVersionSelector here.
Option 2
I haven't done it, but some people have been brave enough to go into customizing the routing system further. It sounds like you might be one of them. You shouldn't need to dive into the jump table. The ApiVersionMatcherPolicy currently isn't extensible, but I'm not sure it needs to be. You can still create a custom MatcherPolicy for IEndpointSelectorPolicy and make sure it runs before the ApiVersionMatcherPolicy. AppliesToEndpoints should be the same methinks. For ApplyAsync, you'd just need to follow something similar to what TrySelectApiVersion does. This will enable you to use your custom IApiVersionSelector.
The magic part is here is making sure that you explicitly set the ApiVersion you want (e.g. by resolving the minor version) like so:
var minorVersion = selector.SelectApiVersion(request, model);
var feature = httpContext.Features.Get<IApiVersioningFeature>();
feature.RequestedApiVersion = minorVersion;
When the ApiVersionMatcherPolicy runs afterward, it will have no clue as to how the requested API version was set. For all it knows, that's how it came in on the request. At this point, candidate and API version matching should continue to work as expected.
Option 3
I don't really like this one, but it should work. If you use /v1 as suggested instead of the ApiVersionRouteConstraint (e.g. v{version:apiVersion}), then API Versioning will not recognize /v1 as anything special. From API Versioning's perspective, there is no API version. If you then set:
ApiVersioningOptions.AssumeDefaultVersionWhenUnspecified = true- Replace
IApiVersionSelectorwith your custom selector
This will trigger your custom IApiVersionSelector to be used on every path because API Versioning won't see an incoming API version. It will have the appearance that one was not specified. While this should work, it's sleazy and not intention-revealing. This might be a Litmus test to verify some behavior and then choose to use Option 2, which will have similar results.
Additional Notes
There is a built-in convention to derive the API Version from the source code namespace. If you add the VersionByNamespaceConvention, the API version will be applied from that namespace without having to use any attributes. For example:
namespace Controllers
{
// 1.0
namespace V1 { }
// 1.1
namespace V1_1 { }
}
Interleaving multiple versions this way is not supported by namespace alone; however, defining API versions is always mutually inclusive. You can use attributes or other conventions to additional API versions. It's probably easier to rationalize versions by namespace so I wouldn't recommend mixing or interleaving, but that's solely up to you.
Thanks for your detailed comment, I will look further into the options you provide. At the moment I am using Option 3 and that works great because it actually supports the query parameter as well to select the exact version. It is indeed not intention-revealing, but given that we also return the used version in a response header and that we no can select a different one, I think this is a sacrifice I am willing to make. If in time we are allowed to drop the version from the URL, we are at least already using this awesome package :) The biggest drawback is that the documentation only shows the latest minor version, but perhaps that can be solved somehow as well... for now it is good enough for me I think.
Ah ... I forgot to clarify that. Yes, you can make /v1/<resource>?api-version=1.1 work, but that really confusing IMHO. If you can get that past the powers that be, maybe that's the easiest win. Ultimately, /v1 means nothing in this context and everything is really driven by api-version.
If you manage things correctly, then /v1 should show 1.0, 1.1, etc and /v2 can show 2.0, 2.1, etc. I don't see why that wouldn't work. The /v# just becomes a segment in the name that has no real special meaning in the implementation. If what you meant is that you only want to show the latest minor version, then you'll need to address that with a custom IApiDescrptionProvider or a hook into the OpenAPI document generator (e.g. Swashbuckle, NSwag, etc).
I'm cleaning house. From your last comment, I gather that you have a working solution that works and you are satisfied with. I'm going to close this out, but if you have more questions or need additional assistance, I'm happy to continue the discussion or reopen the issue. Thanks.