-
Couldn't load subscription status.
- Fork 716
Would the package support a versioning strategy where endpoints are automagically elevated to the latest version? #900
-
After having setup aspnet-api-versioning for a project, I came up with an approach of which I'm unsure if it would help to use versioning, and if it's an actual feature or usage of the library that I missed, or if it would be better to create something from scratch.
So the approach is that everytime I add a new ApiVersionAttribute with a new version number, all existing endpoints will also be available under that same version number, for which the highest versioned one will be selected.
So let's say I have
[ApiVersion(1.0), Route("persons")]
and I add
[ApiVersion(1.1), Route("jobs")]
Now suddenly /v1.1/persons will be available as well, and point to the highest existing version, which is 1.0
If I add
[ApiVersion(1.2), Route("persons")]
Now suddenly /v1.2/jobs will also be available, and point to v1.1
My thought is that this way, I can just release an entire api version update by creating just a single new endpoint. If this would work with swagger versioning, that would be another big plus (no idea how yet)
downside is that if I create a new version of an existing endpoint, but by mistake the new version number is lower than some existing version, it would actually change a previous version of the api. But I'm sure there's ways to fix it. For example, have the attribute force you to provide a timestamp. then on creating all the aliases, it can check if over time every new version has a higher number than the previous.
So, my questions are:
- does this approach even make sense? :)
- is this approach something that can be achieved with aspnet-api-versioning? (and, if so, where should I start?)
Thanks!
Beta Was this translation helpful? Give feedback.
All reactions
Replies: 2 comments 2 replies
-
This is a good question. You have the right idea. Most people want to implicitly move the version forward for the client, which is just wrong and honestly defeats the purpose of versioning. Unless I've misunderstood, what you are asking to achieve is what I call Symmetrical Versioning. Essentially, you want to provide a consistent, public API version surface for clients; regardless, of the server implementation.
This type of solution is achievable with this library. You still have to be cautious when filling in versions because if People 1.0 is bumped to be symmetrical with Jobs 2.0, but has no initial change and then has changes later, 2.0 cannot be used. It would have to bump to 3.0 to avoid a breaking change.
There are a number of ways this can be implemented. You can apply multiple API version metadata with attributes to a single controller (or endpoint). For example:
[ApiVersion(1.0)] [ApiVersion(1.1)] [ApiVersion(2.0)] [Route("v{version:apiVersion}/[controller"]) public class JobsController : ControllerBase { }
This allows a single implementation to server 1.0, 1.1, and 2.0. I don't generally recommend it (because it can get confusing), but you can interleave versions in a single controller if there are slight, nuanced differences. Version-specific methods/actions are paired using MapToApiVersion. I only recommend doing that for the smallest of changes; otherwise, a controller-per-version is a better approach to manage and rationalize the implementation.
API Versioning doesn't actually care about attributes and that is just one way the metadata can be applied. As you probably have observed (based on your question), this will work, but touching a bunch of controllers to maintain this can be tedious. This is a case where using the Conventions support is a better fit.
Conventions can be explicit:
builder.Services.AddApiVersioning().AddMvc( options => { options.Conventions.Controller<JobsController>().HasApiVersion(1.0).HasApiVersion(1.1).HasApiVersion(2.0); } );
This can still be tedious so you can apply a broader convention. The only built-in convention is the VersionByNamespaceConvention, which implicitly applies an API version to a controller given it's containing .NET namespace. For example:
builder.Services.AddApiVersioning().AddMvc( options => { options.Conventions.Add( new VersionByNamespaceConvention() ); } ); namespace Controllers { namespace V1 { // 1.0 applied by convention [Route("v{version:apiVersion}/[controller]")] public class JobsController : ControllerBase { } } namespace V1_1 { // 1.1 applied by convention [Route("v{version:apiVersion}/[controller]")] public class JobsController : ControllerBase { } } namespace V2 { // 2.0 applied by convention [Route("v{version:apiVersion}/[controller]")] public class JobsController : ControllerBase { } } }
This technique can work really well to keep things clean between versions. Namespaces are usually mapped to source code folders. A new version can be as simple as copying and pasting the baseline version you want, update the namespace, and change any necessary implementation.
You are not restricted to using the built-in conventions. You can roll your own too. You only need to implement IControllerConvention and add it to the MvcApiVersioningOptions.Conventions. Exactly how you decide to apply the convention to a particular controller is up to you. The default behavior treats all API versions as mutually inclusive. This enables you to use combine conventions and attributes together. In your case, you likely want the opposite.
Since you want to fill in the additional versions to the highest implementation for each API, you probably need a custom IApplicationModelProvider. You would need to inject IOptions<MvcApiVersioningOptions> so you can get the conventions, override OnProvidersExecuting, and run before API Versioning (which is the default Order = 0). You'd need two loops. First loop is an aggregation of all possible API versions as well as which ControllerModel has the highest implementation. The second loop goes back through the highest versioned ControllerModel and applies conventions (e.g. HasApiVersion) for each API version higher than it supports (which could be none if it's the current one). When API Versioning runs afterward, it will build ApiVersionMetadata as if you had applied all those attributes by hand. This likely the most flexible way to achieve your goal without having to explicitly configure a bunch of things.
Again, there are multiple solutions. A complete solution is a bit involved, but hopefully that gives you some ideas. Happy to answer any additional questions or give you some pointers.
Beta Was this translation helpful? Give feedback.
All reactions
-
Wow! Thank you so much for your extensive answer! It's going to take me a little while to really understand every detail that you describe :) I will be poking around the next few days to see what I can come up with.
At first, I was really attracted to the namespace convention, combined with a custom IApplicationModelProvider. However, it will be hard to find the latest version of some specific endpoint, because it might just be anywhere. There's likely going to be a lot of versions.
I might keep every endpoint in it's own file (partial controllers) so it's easy to see which versions of each endpoint are available. That should be possible with a custom IApplicationModelProvider, correct?
Aother approach might be to still apply attributes to every single endpoint, but have a test that validates if everything is there. Tedious, yes, but also, simple and safe.
I will try both approaches and see what works best, or, which I can get to work at all :)
Again, thanks, I will share any progress that I make here.
Beta Was this translation helpful? Give feedback.
All reactions
-
This is mean to be the one-stop-shop for API versioning so I do what I can for community building.
Starting in 6.0, the magic that is used to parse API versions from a namespace has been refactored out to something that anyone can use a la the NamespaceParser. It might seem strange that Parse returns a list, but this is to gracefully catch a scenario such as Api.V1.Controllers.V2, which has 2 different API versions in it. The caller can then decide what to do with multiple matches - such as throw an exception.
The only real challenge with this solution is that you need to collate all the possible API versions to fill in the blanks. This results in a 🐔 and 🥚 problem by letting API Versioning do all of the work. You can either let API Versioning calculate the metadata, collate the versions, and update the metadata or you can collate yourself and apply conventions before API Versioning runs. I was a bit curious what this would look like myself. I went with the second option.
There's a few limitations and assumptions in the approach. This assumes you'll be using attributes. The solution simply applies additional API versions to the highest/current controller that isn't already defined elsewhere. This could be made to work with namespaces given a few minor modifications.
using Asp.Versioning; using Asp.Versioning.ApplicationModels; using Asp.Versioning.Conventions; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.Extensions.Options; public sealed class SymmetricalApiVersionProvider : IApplicationModelProvider { private readonly IApiControllerFilter controllerFilter; private readonly IControllerNameConvention namingConvention; private readonly IOptions<MvcApiVersioningOptions> options; public SymmetricalApiVersionProvider( IApiControllerFilter controllerFilter, IControllerNameConvention namingConvention, IOptions<MvcApiVersioningOptions> options ) { this.controllerFilter = controllerFilter; this.namingConvention = namingConvention; this.options = options; } public int Order => -1; // run before API Versioning public void OnProvidersExecuted( ApplicationModelProviderContext context ) { } public void OnProvidersExecuting( ApplicationModelProviderContext context ) { // exclude controller we're not interested in (ex: UI controllers) var controllers = controllerFilter.Apply( context.Result.Controllers ); var supported = new SortedSet<ApiVersion>(); var deprecated = new SortedSet<ApiVersion>(); var apis = new Dictionary<string, Api>( capacity: controllers.Count, StringComparer.OrdinalIgnoreCase ); for ( var i = 0; i < controllers.Count; i++ ) { // this assumes we are using attributes to explicitly declare versions var controller = controllers[i]; var attributes = controller.Attributes; var declared = new HashSet<ApiVersion>(); for ( var j = 0; j < attributes.Count; j++ ) { // any valid attribute is allowed, but will typically be ApiVersionAttribute if ( attributes[j] is not IApiVersionProvider provider ) { continue; } SortedSet<ApiVersion> target; // must be None or Deprecated to be considered a 'declared' (e.g. implemented) version, // which is different from a mapped or 'advertised' version. deprecated versions are // still functional so we want to consider them. if we didn't, then once a version was // deprecated, it would sunset (e.g. 'disappear') and that would be bad. switch ( provider.Options ) { case ApiVersionProviderOptions.None: target = supported; break; case ApiVersionProviderOptions.Deprecated: target = deprecated; break; default: continue; } for ( var k = 0; k < provider.Versions.Count; k++ ) { var version = provider.Versions[k]; declared.Add( version ); target.Add( version ); } } if ( declared.Count == 0 ) { continue; } // name is all we have to collate on so make sure we use the same convention // that will be used elsewhere var name = namingConvention.NormalizeName( controller.ControllerName ); if ( apis.TryGetValue( name, out var api ) ) { // if a version declared by this api is greater than what's already been // seen, then this is now the current api if ( declared.Max() > api.Versions.Max() ) { api.Current = controller; } } else { apis.Add( name, api = new Api( controller ) ); } api.Versions.UnionWith( declared ); } var conventions = options.Value.Conventions; // we'll only consider a version completely deprecated if it doesn't appear // anywhere as supported deprecated.ExceptWith( supported ); // apply a convention to the current controller for each api where a version // from all defined versions is not already applied to another controller foreach ( var api in apis.Values ) { var convention = conventions.Controller( api.Current.ControllerType ); foreach ( var version in supported.Except( api.Versions ) ) { convention.HasApiVersion( version ); } foreach ( var version in deprecated.Except( api.Versions ) ) { convention.HasDeprecatedApiVersion( version ); } } } private sealed class Api { public Api( ControllerModel current ) => Current = current; public SortedSet<ApiVersion> Versions { get; } = new(); public ControllerModel Current { get; set; } } }
You would then register it with:
builder.Services.AddTransient<IApplicationModelProvider, SymmetricalApiVersionProvider>();
You mentioned Swagger (e.g. OpenAPI). I took the OpenApi example and added the following controller:
[ApiVersion( 1.0 )] [Route( "[controller]" )] public class ValuesController : ControllerBase { [HttpGet] public IActionResult Get( ApiVersion version ) => Ok( new[] { version.ToString() } ); }
Even though it's only defined for 1.0, it will now show up for all other versions by convention too. Since all implementations of 0.9 are deprecated, it will appear also appear deprecated in that version.
As previously mentioned, there are some inherent risks that you need to be cautious about, but I leave that in your capable hands.
Beta Was this translation helpful? Give feedback.
All reactions
-
I'm taking my time to step through the solution and get myself familiar with all the moving parts. Your implementation helped me in the right direction a lot, so again, thanks for that! Also, I just love the name you gave this versioning strategy. I will soon get back with my findings, a bit time restricted with work & kids sadly ;)
Beta Was this translation helpful? Give feedback.
All reactions
-
🎉 1