Skip to content

[Question] [ASP.NET Core Web API] - Multiple Route Prefixes for a single API Version #628

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
davidhjones opened this issue May 1, 2020 · 16 comments

Comments

@davidhjones
Copy link

davidhjones commented May 1, 2020

Framework: netcoreapp3.1

I'm having trouble setting up an API with a unique routing structure. I've intentionally kept the API as simple as possible to explain my problem clearly. If this repo is not the correct place to ask this question, please redirect me to the proper location. I've put together a github sample, which I've been working in to try and get this working.

The API I'm trying to achieve is the follow:

GET ~/api/v1/Contexts({contextId})/Applications
GET ~/api/v1/People

Note that the Contexts({contextId}) segment isn't related to the Application Entity, but rather a logic step in the API to retrieve a group of Applications, segregated by context.

I've tried doing this in two ways:

  1. Register two routes as part of UseMvc. This allows me to specify the two routePrefixes, for api/v1 and api/v1/Contexts({contextId}). The problem with this is that a call to MapVersionedODataRoutes, configures routes for all of the controllers. Thus by making two calls to MapVersionedODataRoutes, I end up with
GET ~/api/v1/Contexts({contextId})/Applications
GET ~/api/v1/Applications
GET ~/api/v1/Contexts({contextId})/People
GET ~/api/v1/People
  1. One call to MapVersionedODataRoutes, but somehow use Attributes to specify the Context({contextId}) prefix for the Controller(s) that need it. This option seemed to be the correct approach, but I hit a snag with the EDM specifications, when added the Contexts({contextId}) prefix. I tried using the ODataRoutePrefix attribute on the controller, like [ODataRoutePrefix("Contexts({contextId})/Applications")], but this produces at ODataUnrecognizedPathException for the segment Contexts, because I haven't registered the Contexts EntitySet (which I don't want to do, because it doesn't represent an Entity).

Any advice to help me achieve this design would be much appreciated!

@davidhjones davidhjones changed the title [Question] [ASP.NET Web API] - Multiple Route Prefix's for a single API Version [Question] [ASP.NET Web API] - Multiple Route Prefixes for a single API Version May 1, 2020
@davidhjones davidhjones changed the title [Question] [ASP.NET Web API] - Multiple Route Prefixes for a single API Version [Question] [ASP.NET Core Web API] - Multiple Route Prefixes for a single API Version May 1, 2020
@commonsensesoftware commonsensesoftware self-assigned this May 1, 2020
@commonsensesoftware
Copy link
Collaborator

First, understand that OData doesn't intrinsically handle duplicate routes and EDM matching/parsing begins after the prefix. I recommend that you do not try to use the prefix as a sub application. You may not be, but I've seen that several times. There may be a way to beat OData into submission, but that was not the intent of the prefix design.

If you're going to version by URL segment, then you need include the route constraint with everything else in the prefix. It should look something like this:

app.UseMvc(
  routes => routes.MapVersionedODataRoutes(
    "odata",
    "api/v{version:apiVersion}/Contexts({contextId})",
    builder.GetEdmModels() ) );

Based on what you have shared so far, I believe this will solve the problem. The template contains route parameters that are not processed by the OData URL parser.

I hope that helps.

@commonsensesoftware
Copy link
Collaborator

Sorry ... I didn't read the question fully.

Why is there?

  • api/v1/People
  • api/v1/Contexts({contextId})/People

These look to be the same resource. If you want permissions, you should do that another way. Alternatively, move contextId to the query string.

In REST, the URL path is the identifier of the resource. Versioning by URL already violates this constraint as api/v1/People/123 and api/v2/People/123 imply completely different people when, in fact, the only difference is likely their representation over the wire. The Representation part in REST is handled by media type negotiation. This issue is further conflated by the use of Context.

API Versioning for OData does not do some type of grouping by prefix as some people might expect. That would the behavior of a sub application. API Versioning sees all of the possible route candidates as a flat list. Grouping is done by route and API version just like all other routes. If it didn't behave this way, it would be incongruent with versioning non-OData controllers and versioning OData controllers by URL segment (despite my reservations) would not work as expected (due the prefix design of OData).

The paths api/v1/People/123 and api/v2/People/123 should be considered duplicate routes by OData that route to different controllers which are only disambiguated by API Version (e.g. the role of API Versioning). If api/v1/People/123 and api/v1/Context(42)/People/123 go to different controllers, that seems odd to me. Even so, it's difficult to make OData do what you want because it only sees People/123 based on the EDM. This combination with API versioning, therefore, makes the two look and behave like they are the same versioned resource, when you actually intent a different behavior.

@davidhjones
Copy link
Author

Ok let me try to clarify. My intention is to have different prefixes, for different controllers, on the same API version.

In this example I have two resources, People and Applications. For the API routes to the PeopleController, I want the route prefixed by api/v{version:apiVersion}, but for the API routes to the ApplicationsController, I want the route prefixed by api/v{version:apiVersion}/Contexts({contextId}). I tried doing so via the two options in the original post, but neither of them worked. The 1st option resulted in two routes to the same resource, as you have mentioned in your last post.

While I assume there are better ways to design the API to fit this scenario (move Contexts and API version to query parameters), unfortunately this is a extremely simplified example of a production application that is already in place, but I'm adding API Versioning to, while migrating to .net core 3.1, and the API is already built using these URI constraints. I'm attempting to find a way to introduce aspnet-api-versioning to our existing system, without requiring a version change.

@commonsensesoftware
Copy link
Collaborator

I think I understand. So you already have some kind of API version in the path? If that's the case, it's unfortunate, but workable. If not, then I would suggest query string or media type negotiation method instead. I can provide other recommendations about how to grandfather in your existing APIs.

You can have multiple prefixes. What is not going to work or, at least, you're going to have problems with is the same resource stripes over multiple prefixes. I've seen this before with people using {tenant} as part of the prefix. Totally reasonable, but a challenge with out OData routing works. To recap:

Setup 1 (Works)

  • api/v1/People
  • api/v1/Context({contextId})/Application

Setup 2 (Has Problems)

  • api/v1/People
  • api/v1/Context({contextId})/People
  • api/v1/Application
  • api/v1/Context({contextId})/Application

This is because OData only sees /People and /Application

If you're within the realm of Setup 1, then the following configuration should work:

app.UseMvc(routes => {
  routes.MapVersionedODataRoutes(
    "odata-default",
    "api/v{version:apiVersion}",
    builder.GetEdmModels() ) ;

  routes.MapVersionedODataRoutes(
    "odata-by-context",
    "api/v{version:apiVersion}/Contexts({contextId})",
    builder.GetEdmModels() );
});

In your particular scenario, you may need to bucketize the EDMs yourself and pass them to the respective MapVersionedODataRoutes. API Versioning will not know how to do this for you. In fact, you may not even be able to use the builder as configured out-of-the-box.

It probably helps to understand what the builder does:

  • Collate all possible API versions
  • Aggregate all configured or discovered IModelConfiguration instances
  • When GetEdmModels is called:
    • For each API version
    • Create a new ODataModelBuilder (you control the factory; defaults to convention-based)
    • For each IModelConfiguration*, call Apply with API version and builder
    • Apply the ApiVersionAnnotation to the EDM
    • Return the list of EDMs

As you can see, there's no discrimination that some EDM should be used for some routes and some are used for others. The flexibility in OData is a pro and con, but there's not really a universal way to figure out someone means in all scenarios.

There are several ways you could address this by building up EDMs yourself, customizing/extending the builder, and so on. If you do build the EDMs yourself, you should always attach the associated API version with the ApiVersionAnnotation. This is how MapVersionedODataRoutes knows which API version corresponds to which EDM. When you use the single form MapVersionedODataRoute, you have to provide the EDM and API version so it's done for you.

Hopefully that helps you get started.

@davidhjones
Copy link
Author

Thanks a lot! I’m exactly targeting Setup 1. I’ll take a stab at your suggestions, and report back.

@davidhjones
Copy link
Author

davidhjones commented May 4, 2020

I've updated my sample following your suggestions, but I'll also provide a synopsis here.

Updated configuration to

app.UseMvc(
    routeBuilder =>
    {
        /*    
        *   Try to Achieve:
        *   GET ~/api/v1/People
        *   GET ~/api/v1/Contexts({contextId})/Applications
        */

        var apiVersion = new ApiVersion( 1, 0 );

        // Add ~/api/v1/People
        routeBuilder.MapVersionedODataRoutes( "odata-default", "api/v{version:apiVersion}", GetPeopleModels( apiVersion ) );

        // Add ~/api/v1/Contexts({contextId})/Applications
        routeBuilder.MapVersionedODataRoutes( "odata-by-context", "api/v{version:apiVersion}/Contexts({contextId})", GetApplicationModels( apiVersion ) );
    } );

private IEnumerable<IEdmModel> GetPeopleModels ( ApiVersion apiVersion )
    {
    var models = new List<IEdmModel>();
    var builder = new ODataConventionModelBuilder().EnableLowerCamelCase();

    var person = builder.EntitySet<Person>( "People" ).EntityType.HasKey( p => p.Id );

    var model = builder.GetEdmModel();
    model.SetAnnotationValue( model, new ApiVersionAnnotation( apiVersion ) );
    models.Add( model );
    return models;
    }

private IEnumerable<IEdmModel> GetApplicationModels ( ApiVersion apiVersion )
    {
    var models = new List<IEdmModel>();
    var builder = new ODataConventionModelBuilder().EnableLowerCamelCase();

    var order = builder.EntitySet<Application>( "Applications" ).EntityType.HasKey( o => o.Id );

    var model = builder.GetEdmModel();
    model.SetAnnotationValue( model, new ApiVersionAnnotation( apiVersion ) );
    models.Add( model );
    return models;
    }

There is an initialization exception happening upon the first execution of routeBuilder.MapVersionedODataRoutes( "odata-default", "api/v{version:apiVersion}", GetPeopleModels( apiVersion ) );, Microsoft.OData.UriParser.ODataUnrecognizedPathException: 'Resource not found for the segment 'Applications'.'. I dug into the OData source, and the failure is because the ApplicationController is being used for the first MapVersionedODataRoutes call, but the EdmModel doesn't exist, and thus the error happens.

Is there a way to specify which controllers should be parsed during route configuration?

@davidhjones
Copy link
Author

davidhjones commented May 4, 2020

Exception details:

Microsoft.OData.UriParser.ODataUnrecognizedPathException
  HResult=0x80131509
  Message=Resource not found for the segment 'Applications'.
  Source=Microsoft.OData.Core
  StackTrace:
   at Microsoft.OData.UriParser.ODataPathParser.CreateDynamicPathSegment(ODataPathSegment previous, String identifier, String parenthesisExpression)
   at Microsoft.OData.UriParser.ODataPathParser.CreateFirstSegment(String segmentText)
   at Microsoft.OData.UriParser.ODataPathParser.ParsePath(ICollection`1 segments)
   at Microsoft.OData.UriParser.ODataPathFactory.BindPath(ICollection`1 segments, ODataUriParserConfiguration configuration)
   at Microsoft.OData.UriParser.ODataUriParser.ParsePathImplementation()
   at Microsoft.OData.UriParser.ODataUriParser.Initialize()
   at Microsoft.OData.UriParser.ODataUriParser.ParsePath()
   at Microsoft.AspNet.OData.Routing.DefaultODataPathHandler.Parse(String serviceRoot, String odataPath, IServiceProvider requestContainer, Boolean template)
   at Microsoft.AspNet.OData.Routing.DefaultODataPathHandler.ParseTemplate(String odataPathTemplate, IServiceProvider requestContainer)
   at Microsoft.AspNet.OData.Routing.ActionParameterContext..ctor(ODataRouteBuilder routeBuilder, ODataRouteBuilderContext routeContext) in C:\dev\misc\aspnet-api-versioning\src\Microsoft.AspNetCore.OData.Versioning\AspNet.OData\Routing\ActionParameterContext.cs:line 12

image

As you can see from the ActionDescriptor, we're performing route initialization for the ApplicationsController.Get action, which started from the first call to routeBuilder.MapVersionedODataRoutes("odata-default", "api/v{version:apiVersion}", GetPeopleModels(apiVersion));. Since this model doesn't exist (isn't included from GetPeopleModels), the exception occurs.

@davidhjones
Copy link
Author

davidhjones commented May 7, 2020

@commonsensesoftware any ideas to workaround this road block? I went down a rabbit hole into the OData route registration code and I couldn't find a way around registering all models during route registration.

@davidhjones
Copy link
Author

davidhjones commented May 20, 2020

@commonsensesoftware If this isn't the right repo to gather more information about this scenario, do you know of another place.

I'm quite aware that you're the only maintainer of this repository, and are spread too thin to answer everyone's questions. This seems to be outside the scope of api-versioning (maybe?). In that case, maybe this questions is better fit for another repo?

@commonsensesoftware
Copy link
Collaborator

Appreciate the understanding. You have a repro. Let me see if I can't get something combination working for you later today.

If you use true subapplications (using RouteBuilder.MapWhen I believe), you should get the isolation you want. Despite how it may appear, that's not how the OData prefix works.

@davidhjones
Copy link
Author

I attempted using .MapWhen to split the request pipeline hoping that it would allow me to segregate the EDM resolution, with two invocations of .UseMvc (one for each .MapWhen), but it doesn't seem to work as expected. A single invocation of .MapVersionedODataRoutes is still trying to resolve all Controllers + EDMs (regardless of the Models passed into the parameter).

@davidhjones
Copy link
Author

Appreciate the understanding. You have a repro. Let me see if I can't get something combination working for you later today.

Were you able to find the time to look into this?

@davidhjones
Copy link
Author

@commonsensesoftware could you spare some time this week and help me resolve this issue?

@ascherer1993
Copy link

Any chance a solution was found to this issue?

@commonsensesoftware
Copy link
Collaborator

A quick follow up this issue. Ultimately, there has not been a way to associate the route prefix to the registered EDM(s). I've come to the conclusion that IModelConfiguration.Apply needs a new string routePrefix. This will pass through and allow you to decide whether to configure a particular EDM based on an API version and route prefix combination. This will bring a new overload to VersionedODataModelBuilder.GetEdmModels(string routePrefix), which will flow the parameter through.

I've started working on the Endpoint Routing support and come up with a break through that is changing all the internal mechanics - in a good and simpler way. I've got the full test suite working in the Web API version and now I'm working updating things in Core.

Once all of these changes are in, then you should be able to achieve the results you want in a pretty straight forward way. Stay tuned. ;)

@commonsensesoftware
Copy link
Collaborator

As previously mentioned, this is now an official supported concept. The routePrefix can be used in IModelConfiguration.Apply to determine whether a configuration applies to a particular API version and route prefix. The prefix is passed through VersionedODataModelBuilder.GetEdmModels . This makes the setup really simple because endpoints.MapVersionedODataRoute("odata", "api", modelBuilder) and endpoints.MapVersionedODataRoute("odata", "other", modelBuilder) run through different paths with the filtering. This should achieve what you are looking for.

This is now available in 5.0.0-RC.1. The official release will likely occur after 2 weeks of burn-in. If this doesn't resolve your issue, feel free to reopen the issue or open a new one. Thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants