Skip to content

OData routing causing exception with versioned API endpoints #1122

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
JackDC16 opened this issue Feb 28, 2025 · 7 comments
Closed

OData routing causing exception with versioned API endpoints #1122

JackDC16 opened this issue Feb 28, 2025 · 7 comments

Comments

@JackDC16
Copy link

I have inherited a .NET 6 Web API project utilising OData and the Asp.Versioning packages. The versioning functionality was not 100% correct, and now that the project is required for more regular usage, I want to implement the versioning correctly.

The issue I am hitting is this runtime exception:

Attribute routes with the same name 'odata/v{version:apiVersion}/Parts' must have the same template: Action: '...v2.Controllers.PartsController.Get' - Template: 'odata/v{version:apiVersion}/Parts/odata/v{version:apiVersion}/Parts' Action: '...v2.Controllers.PartsController.Get' - Template: 'odata/v{version:apiVersion}/Parts' Action: '...v1.Controllers.PartsController.Get' - Template: 'odata/v{version:apiVersion}/Parts' Action: '...v1.Controllers.PartsController.Get' - Template: 'odata/v{version:apiVersion}/Parts'

My controllers look like:

namespace ...Api.Versions.v1.Controllers
{
    [ApiExplorerSettings(GroupName = "v1")]
    [ApiVersion("1")]
    [Route("odata/v{version:apiVersion}/[controller]")]
    public class PartsController : ODataController
    {
        [Produces("application/json")]
        [EnableQuery]
        [HttpGet]
        public async Task<IQueryable<Part>> Get()
        {
            ...
        }
    }
}

and

namespace ...Api.Versions.v2.Controllers
{
    [ApiExplorerSettings(GroupName = "v2")]
    [ApiVersion("2")]
    [Route("odata/v{version:apiVersion}/[controller]")]
    public class PartsController : ODataController
    {
        [Produces("application/json")]
        [EnableQuery]
        [HttpGet]
        public async Task<IQueryable<Part>> Get()
        {
            ...
        }
    }
}

My Startup.cs has the following configuration:

public void ConfigureServices(IServiceCollection services)
{
   ...
    services.AddControllers()
    .AddOData(options =>
    {
        options.EnableQueryFeatures(maxTopValue: 8000);
        options.TimeZone = TimeZoneInfo.Utc;
    });

    services.AddApiVersioning(options =>
    {
        options.AssumeDefaultVersionWhenUnspecified = true;
        options.DefaultApiVersion = new ApiVersion(1);
        options.ReportApiVersions = true;
        options.ApiVersionReader = new UrlSegmentApiVersionReader();
    })
    .AddMvc()
    .AddOData(options =>
    {
        options.AddRouteComponents("odata/v{version:apiVersion}");
    })
    .AddODataApiExplorer(options =>
    {
        options.GroupNameFormat = "'v'V";
        options.SubstituteApiVersionInUrl = true;
    });
    ...
}

public void Configure(IApplicationBuilder app)
{
    ...
    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
    ...
}

and my EDM configuration is as follows:

namespace ...Api.Utilities.ModelConfiguration
{
    using Asp.Versioning;
    using Asp.Versioning.OData;
    using Microsoft.OData.ModelBuilder;

    public class PartModelConfiguration : IModelConfiguration
    {
        public void Apply(ODataModelBuilder builder, ApiVersion apiVersion, string routePrefix)
        {
            switch (apiVersion.MajorVersion)
            {
                case 1:
                    ConfigureV1(builder);
                    break;
                case 2:
                    ConfigureV2(builder);
                    break;
                default:
                    ConfigureCurrent(builder);
                    break;
            }
        }

        private void ConfigureV1(ODataModelBuilder builder) =>
            ConfigureCurrent(builder);

        private void ConfigureV2(ODataModelBuilder builder) =>
            ConfigureCurrent(builder);

        private EntityTypeConfiguration<Part> ConfigureCurrent(ODataModelBuilder builder)
        {
            var part = builder.EntitySet<Part>("Parts").EntityType;
            part.HasKey(p => p.IdPart);
            return part;
        }
    }
}

Right now there is no difference between models for v1 and v2, this is purely to test functionality.

I'm trusting that the above configuration is automatically registered for DI as per the last section on this page: https://github.com/dotnet/aspnet-api-versioning/wiki/OData-Model-Configurations

I feel like there is some small piece of config that I've missed, but struggling to find what that might be.

@JackDC16
Copy link
Author

UPDATE:

I've managed to get past the exception by removing the RouteAttribute from my controllers, but my endpoints are being duplicated in the OData route debug page:

Image

@commonsensesoftware
Copy link
Collaborator

If you have the world's simplest repro, that would be really useful in troubleshooting; otherwise, I can try to recreate what you've shown so far.

@JackDC16
Copy link
Author

JackDC16 commented Mar 3, 2025

Sorry, I don't have a repro that I can provide. I'd have to work on putting something together this week when I get some time.

@JackDC16
Copy link
Author

JackDC16 commented Mar 14, 2025

I've managed to put a simple repro together: https://drive.google.com/drive/folders/1Fj3RF18jn8USoJQcQD5BubsthFD-7kix?usp=drive_link

Let me know if you need anything further.

@JackDC16
Copy link
Author

@commonsensesoftware any chance you've been able to look into this?

@commonsensesoftware
Copy link
Collaborator

Thanks for the repro. Sorry about the delay. It's hard for me to say exactly what's going on with the route template generation. Without spending a bunch more time on it, I suspect it's a bug in OData. I've seen it before. The first pass is valid, but the second pass somehow has a dirty state and generates the wrong prefix. Honestly, you must conform to the OData routing convention even if you specify [Route]. In my experience, it's just easier to let OData handle that at the root. All of the scenarios provided in the example projects do this. There's a few edge cases where it seems necessary at the action level. The OpenAPI example for OData tries to provide an example for at least one of every OData construct.

Here's your repro with a few modifications that should have it working. I added a few notes as comments also. I hope that helps unblock you.

Issue1122-Fixed.zip

@JackDC16
Copy link
Author

Really appreciate you getting back to me on this, I've now managed to get everything working as required.

I can see what you mean about conforming to the routing conventions, so now that I know that I can work around it.
I've also noticed that when removing the v2 endpoints, the duplicates from v1 are no longer present. I only threw v2 in for testing purposes, although it will be required in the near future.

Thanks again.

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

No branches or pull requests

2 participants