dotnet / aspnet-api-versioning

Provides a set of libraries which add service API versioning to ASP.NET Web API, OData with ASP.NET Web API, and ASP.NET Core.
MIT License
3.04k stars 703 forks source link

Multiple endpoint with different versions - Versions are wrongly shared over different endpoints #503

Closed j-dc closed 5 years ago

j-dc commented 5 years ago

In one application I have 2 versioned odata endpoints:

/config/V{version} & /data/V{version}

The /config/vxx is internal, and I have controll over the applications using it.. wich means that the versions can iterate much faster.
/data/vxx is used by 3th parties and should be as stable as possible.

This leading me to this current versions:

/Data/V1 - /Data/V2 - /Data/V3
/Config/V11 - /Config/V12

I have configured this with this code:

    //-----DATA-----
            var modelBuilder = new VersionedODataModelBuilder(configuration) {
                ModelConfigurations = { new DataModelConfiguration() }
            };
            var models = modelBuilder.GetEdmModels();
            configuration.MapVersionedODataRoutes("odata-data", "data/v{apiVersion}", models, ConfigureContainer);
 //-----CONFIG-----
            var configModelBuilder = new VersionedODataModelBuilder(configuration) {
                ModelConfigurations = { new ConfigModelConfiguration() }
            };
            var configModels = configModelBuilder.GetEdmModels();
            configuration.MapVersionedODataRoutes("odata-config", "config/v{apiVersion}", configModels, ConfigureContainer);

This setup seems to work at first sight: correctly working urls: /Data/V1 - /Data/V2 - /Data/V3 /Config/V11 - /Config/V12

Problem: This Url's are giving an empty odata endpoint as result.

/Data/V11 /Data/V12 (only 1,2 & 3 exist) /Config/V1 /Config/V2 /Config/V3 (only 11 & 12 exist)

{
    @odata.context: "http://localhost:9007/data/v11/$metadata",
    value: [ ]
}

Expected behaviour would be to get this exception: The HTTP resource that matches the request URI 'http://localhost:9007/data/V11' does not support the API version '11'.

commonsensesoftware commented 5 years ago

This happens for a couple of reasons. OData doesn't really care much about the prefix. It's completely ignored from a URI parsing perspective. The VersionedODataModelBuilder collects and collates the API versions from all known OData controllers. It also does not bucketize API versions by prefix (which would be quite difficult to do and is only necessary if you version by path). The result is that both your data and config branches see the same set of API versions.

In this particular case, you might be better registering the routes individually by API version using configuration.MapVersionedODataRoute instead. This would be easy to do for you since you know which API versions you have and how they should be split. You can build the EDMs per API version or build them all and extract the desired model by querying the EDM for the ApiVersionAnnotation that contains the version you want to match.

Another alternative would be to extend the VersionedODataModelBuilder to understand how to divide your controllers. You can see how here and here. You should only need to override GetApiVersions and perform the appropriate filtering. There are many ways the filtering could be achieved ranging from by namespace to using your own custom attribute. You might even extend it with a callback that gets invoked when things are built up. With that in place, I would expect your two endpoints to begin behaving they way you expect.

I hope that helps. I'm happy to answer more questions if you get stuck.

j-dc commented 5 years ago

Consider this example:
https://github.com/j-dc/aspnet-api-versioning-samples.git


you might be better registering the routes individually by API version using configuration.MapVersionedODataRoute instead

I was not really sure what you meant by this.. So I tried a few things:

Consider: 2 Endpoints: Data & Config. Each have 2 versions:
Data\V1 Data\v2 Config\V11 Config\V12

1) One call to MapVersionedODataRoute per endpoint (as in my original question described): https://github.com/j-dc/aspnet-api-versioning-samples/blob/d67161ff433b54e755d02107d586427347ad3f99/src/J-DC.Api.Versioning.Samples.MultiEndpoint_01/Startup.cs So I have one call for 'Config' and one call for 'Data'.

configuration.MapVersionedODataRoutes("odata-data", "data/v{apiVersion}", models);
configuration.MapVersionedODataRoutes("odata-config", "config/v{apiVersion}", configModels);

Now everything that should work, works.. But.. As described there are a few empty odata endpoints that shouldn't exist. They even contain a broken metadata link. Ok, this works.. but it is ugly!

Uri Expected Actual status
Data/V1 OK OK
Data/V2 OK OK
Config/V11 OK OK
Config/V12 OK OK
Data/V11 error empty OData wrong/ugly
Data/V12 error empty OData wrong/ugly
Config/V1 error empty OData wrong/ugly
Config/V2 error empty OData wrong/ugly

2) One call to MapVersionedODataRoute per Endpoint per version https://github.com/j-dc/aspnet-api-versioning-samples/blob/d67161ff433b54e755d02107d586427347ad3f99/src/J-DC.Api.Versioning.Samples.MultiEndpoint_02/Startup.cs So I end up with 4 calls to MapVersionedODataRoute.

configuration.MapVersionedODataRoutes("odata-data-v1", "data/v{apiVersion}", models, ConfigureContainer);
configuration.MapVersionedODataRoutes("odata-data-v2", "data/v{apiVersion}", models, ConfigureContainer);
configuration.MapVersionedODataRoutes("odata-config-v11", "config/v{apiVersion}", models, ConfigureContainer);
configuration.MapVersionedODataRoutes("odata-config-v12", "config/v{apiVersion}", models, ConfigureContainer);`
URL Expected Actual status
Data/V1 OK OK
Data/V2 OK empty OData BREAKING
Config/V11 OK OK
Config/V12 OK empty OData BREAKING
Data/V11 error empty OData wrong/ugly
Data/V12 error empty OData wrong/ugly
Config/V1 error empty OData wrong/ugly
Config/V2 error empty OData wrong/ugly

3)same as 2, but I hardcode the versions instead of {apiVersion} configuration.MapVersionedODataRoutes("odata-data-v1", "data/v1", models, ConfigureContainer); Short story: all call's to an odata entity return with an error "An API version is required, but was not specified." https://github.com/j-dc/aspnet-api-versioning-samples/blob/d67161ff433b54e755d02107d586427347ad3f99/src/J-DC.Api.Versioning.Samples.MultiEndpoint_03/Startup.cs


Another alternative would be to extend the VersionedODataModelBuilder

You should only need to override GetApiVersions

A bit more work.. especially to get to know the structure. And to provide a way to filter. So I created a new class-attribute (EndPointAttribute) that can have a list of included or excluded Endpointnames. https://github.com/j-dc/aspnet-api-versioning-samples/blob/d67161ff433b54e755d02107d586427347ad3f99/src/J-DC.Api.Versioning.Samples.CustomBuilder/Custom/EndpointAttribute.cs

I created a new ModelBuilder (MyModelBuilder) . This gets the name of the endpoint you want to build. In this class I override getapiversions and only return the versions of the respective endpoint.

URL Expected Actual status
Data/V1 OK OK
Data/V2 OK OK
Config/V11 OK OK
Config/V12 OK OK
Data/V11 error the V2 version very wrong/ very ugly
Data/V12 error the V2 version very wrong/ very ugly
Config/V1 error the V11 version very wrong/ very ugly
Config/V2 error the V11 version very wrong/ very ugly

So.. there must be yet another place to check for versions then? Could you help pointing out where?


While working on this I also discovered that you always will end up with a V1 version.. This V1 comes from VersionedMetadataControllerType and MetadataControllerType . I had to actually check for this types in my implementation of getapiversions. https://github.com/j-dc/aspnet-api-versioning-samples/blob/d67161ff433b54e755d02107d586427347ad3f99/src/J-DC.Api.Versioning.Samples.CustomBuilder/Custom/TypeExtensions.cs -> IsNotInternal.

When creating a simple example with one endpoint with only a V17 and V18, i saw I always get a V1 for free..
https://github.com/j-dc/aspnet-api-versioning-samples/blob/d67161ff433b54e755d02107d586427347ad3f99/src/J-DC.Api.Versioning.Samples.SingleEndpoint/Startup.cs

commonsensesoftware commented 5 years ago

I see. Let me provide some clarity.

Option 1

Register routes by API version using MapVersionedODataRoute (NOTE: the singular form).

Variant A

Build the EDM just as you would without API versioning and then register the route.

var apiVersion = new ApiVersion(1, 0);
var builder = new ODataConventionModelBuilder();

// TODO: build your model

var model = builder.GetEdmModel();

configuration.MapVersionedODataRoute("odata-v1", "data/v1", model, apiVersion);

// TODO: repeat for other versions

Variant B

Build all the EDMs at once, but register the routes one at a time.

var modelBuilder = new VersionedODataModelBuilder(configuration)
{
    ModelConfigurations = { new DataModelConfiguration() },
};
var models = modelBuilder.GetEdmModels();

foreach (var model in models)
{
    var apiVersion = model.GetAnnotationValue<ApiVersionAnnotation>(model).ApiVersion;
    var routeName = "odata-v" + apiVersion.MajorVersion;
    var prefix = "data/v" + apiVersion.MajorVersion;
    configuration.MapVersionedODataRoute(routeName, prefix, model, apiVersion);
}

Option 2

It looks like your implementation is mostly correct.

If you don't want the default API version included, omit where it gets added here in your code. This behavior is for controllers that have no attribution or conventions applied or are API version-neutral. Another alternative is to set the default API version to your initial (or lowest) API version.

You should also use the logical and operator (&&) instead of the binary and operator (&) here so that you short-circuit the condition as soon as possible.

In terms of routing to controllers, I expect this should work.

Additional Observations

You're effectively trying to create subapplications by splitting behaviors at the route prefix. This is not really the intent or design of OData nor would I say is it an officially supported scenario. Without API versioning, it may work because you can only have a single EDM mapped to each prefix. This results in a hard 1:1 mapping between route registration and EDM.

Once you introduce API versioning, you can have 1:* EDMs per route prefix. This causes complications with the MetadataController (~/$metadata) because the division by prefix does not actually result in two separate applications. There can be only one MetadataController registered within an application. Keep in mind that the routing system ties a route (e.g. URL) to a controller type. It is perfectly legal to have multiple routes map to the same backing controller type. This is important in your extended convention builder because if you don't map all of the versions to the MetadataController, you're going to break the $metadata endpoint.

To handle this situation, API versioning aggregates all known OData API versions and maps it to the $metadata endpoint. The MetadataController is, therefore, not API version-neutral. It only supports the API versions defined by the application. This leads to a complication where you want to split things by prefix. I don't think you can achieve this without further work on your part.

I'm trying to think of how you can even do this off the top of my head. I'm thinking your best bet would be to register a custom OData convention that short-circuits the request pipeline. Unfortunately, I think you'd have to throw an exception (HttpRequestException I believe) because route conventions use a Chain of Responsibility. If your convention doesn't match, the system moves on to the next convention until all are exhausted. Another option would be to subclass the VersionedMetadataController (which already extends MetadataController) and return 404 or something like that if the action shouldn't be matched. At this point in the pipeline, you'd have the full request URL, matching EDM, and matching API version.

These are pretty advanced configurations and I don't have any examples handy. If this something you want to pursue and need help, it will take some time to put something together for you. In the meantime, you may be stuck with the inconvenience of an empty result if you hit the $metadata endpoint with the incorrect prefix and API version combination.

commonsensesoftware commented 5 years ago

Did this help or guide you toward a solution? Were you able to resolve this?

commonsensesoftware commented 5 years ago

It would appear that you found your own solution or were satisfied with the proposed solutions and guidance. If your issue wasn't resolved, feel free to come back or reopen the issue. Thanks.

j-dc commented 5 years ago

Sorry for the very late answer. I've never solved this issue. I just learned to live with it.

commonsensesoftware commented 5 years ago

Sorry to hear that.

A few more possibilities would be to actually host different endpoints. The OData prefix is not an endpoint, it's just a prefix. Separate endpoints or applications would give you the separation you need. Depending on your host, you can give the appearance of a single endpoint with multiple applications. For example, IIS can do this with host header mapping.

You might also be able to use nest ASP.NET Core applications. I don't have much experience using them, but it is possible to map an application to a route prefix with a completely separate configuration. This will have an effect of isolation by route prefix within the same application.

Perhaps one of those will give you more ideas to more easily enable your scenario.