AspNetCoreOData icon indicating copy to clipboard operation
AspNetCoreOData copied to clipboard

IUrlHelper.Link returns null for OData route name

Open ivanenkomaksym opened this issue 3 years ago • 4 comments
trafficstars

Assemblies affected Microsoft.AspNetCore.OData 8.0.10

Describe the bug Failed to generate absolute URL for the specified OData route name.

Reproduce steps

  1. Create Asp.Net Core Web API project
  2. Include Nuget packages:
  • Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer 5.0.0
  • Microsoft.AspNetCore.OData 8.0.10
  • Microsoft.AspNetCore.OData.NewtonsoftJson 8.0.4
  • Swashbuckle.AspNetCore 5.6.3
  • Swashbuckle.AspNetCore.Newtonsoft 6.3.1
  1. Add ResourceData
  2. Define routes
  3. Add two controllers:
  • ResourceManagementController - non-OData controller for managing resources
  • ResourceDataController - OData controller for resource data requests
  1. Start application

Repo: https://github.com/ivanenkomaksym/ODataCoreUrlHelper

Data Model ResourceData type:

    public class ResourceData
    {
        public string Id { get; init; }
    }

Define routes:

    internal static class Routes
    {
        public const string VersionedRoutePrefix = "api/v{version:apiVersion}";

        public const string ResourceDataControllerRoute = "api/v{version:apiVersion}/resources/{token}";

        public const string GetResourceDataRoute = nameof(ResourceDataController) + "_" + nameof(ResourceDataController.GetData);
    }

Non-OData controller for managing resources:

    [ApiController]
    [ApiVersion("1.0")]
    [Route(Routes.VersionedRoutePrefix)]
    public class ResourceManagementController : ControllerBase
    {
        [HttpPost("resources")]
        public IActionResult Create(ApiVersion version)
        {
            var location = Url.Link(Routes.GetResourceDataRoute, new
            {
                token = "id1",
                version = version.ToString()
            });

            return Created(location, "id1");
        }
    }

When creating a resource I want to already include in Location response headers URL link to OData resource data request endpoint.

OData controller for resource data requests:

    [ApiController]
    [ApiVersion("1.0")]
    [Produces(MediaTypeNames.Application.Json)]
    [EnableQuery(AllowedQueryOptions = AllowedQueryOptions.Select | AllowedQueryOptions.Count | AllowedQueryOptions.Skip | AllowedQueryOptions.Top | AllowedQueryOptions.Expand)]
    [Route(Routes.ResourceDataControllerRoute)]
    public class ResourceDataController : ODataController
    {
        [HttpGet("data", Name = Routes.GetResourceDataRoute)]
        [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ResourceData))]
        public IActionResult GetData(string token)
        {
            var resource = new ResourceData
            {
                Id = token
            };

            return Ok(resource);
        }
    }

Configure Startup.cs:

    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers().AddOData(options =>
            {
                options.Select().Count().SkipToken().Expand().SetMaxTop(10);
                options.AddRouteComponents(Routes.ResourceDataControllerRoute, BuildEdmModel());
            })
            .AddODataNewtonsoftJson();

            services.AddApiVersioning(options => options.AssumeDefaultVersionWhenUnspecified = false);
            services.AddVersionedApiExplorer(options => options.SubstituteApiVersionInUrl = true);

            services.AddSwaggerGen(setupAction =>
            {
                var provider = services.BuildServiceProvider().GetRequiredService<IApiVersionDescriptionProvider>();

                foreach (var description in provider.ApiVersionDescriptions)
                {
                    var info = new OpenApiInfo
                    {
                        Title = "ODataCoreUrlHelper",
                        Version = description.ApiVersion.ToString()
                    };
                    setupAction.SwaggerDoc(description.GroupName, info);
                }
            });

            services.AddSwaggerGenNewtonsoftSupport();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IApiVersionDescriptionProvider versionDescription)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseODataRouteDebug();

            app.UseHttpsRedirection();

            app.UseRouting();

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

            app.UseSwagger();
            app.UseSwaggerUI(setupAction =>
            {
                foreach (var description in versionDescription.ApiVersionDescriptions)
                {
                    setupAction.SwaggerEndpoint($"/swagger/{description.GroupName}/swagger.json", $"Test v{description.GroupName}");
                }
            });
        }

        private static IEdmModel BuildEdmModel()
        {
            var builder = new ODataConventionModelBuilder();

            builder.EntitySet<ResourceData>("data");

            return builder.GetEdmModel();
        }

    }

Request/Response

  1. https://localhost:5001/api/v1/resources/1/data - works as expected image
  2. https://localhost:5001/api/v1/resources - fails image

Expected behavior It is expected that

            var location = Url.Link(Routes.GetResourceDataRoute, new
            {
                token = "id1",
                version = version.ToString()
            });

correctly generates URL to OData controller endpoint defined as

[HttpGet("data", Name = Routes.GetResourceDataRoute)]

Additional context If commenting out OData route configuration

            services.AddControllers().AddOData(options =>
            {
                options.Select().Count().SkipToken().Expand().SetMaxTop(10);
                //options.AddRouteComponents(Routes.ResourceDataControllerRoute, BuildEdmModel());
            })
  1. https://localhost:5001/api/v1/resources - works as expected and includes URL location of https://localhost:5001/api/v1/resources/1/data in response headers image
  2. https://localhost:5001/api/v1/resources/1/data - doesn't anymore include OData context image

ivanenkomaksym avatar May 26 '22 08:05 ivanenkomaksym

@ivanenkomaksym I don't know the reason why i used 'IUriHelper' to generate the Uri.

Actually, in the endpoint routing, you should use "LinkGenerator" to generate the needed Uri.

You can inject the "LinkGenerator" from service provider through the constructor then use its extension method, for example: "GetUriByRouteValues" (You can try others)

    public class ResourceManagementController : ControllerBase
    {
        public ResourceManagementController(LinkGenerator linkGenerator)
        {
            _LinkGenerator = linkGenerator;
        }

        public LinkGenerator _LinkGenerator { get; }

        [HttpPost("resources")]
        public IActionResult Create(ApiVersion version)
        {
            string locationUri = _LinkGenerator.GetUriByRouteValues(this.Request.HttpContext, null, new
            {
                token = "id1",
                version = version.ToString()
            });

            return Created(locationUri, "id1");

            /*
            var location = Url.Link(Routes.GetResourceDataRoute, new
            {
                token = "id1",
                version = version.ToString()
            });

            return Created(location, "id1");*/
        }
    }

Here's my test, hope it can help:

image

xuzhg avatar Jun 27 '22 21:06 xuzhg

@xuzhg thanks for your reply. Please note that location is https://localhost:5001/api/v1/resources?token=id1, while I would expect location: https://localhost:5001/api/v1/resources/id1/data

I tried specifying the route name parameter

            //var location = Url.Link(Routes.GetResourceDataRoute, new
            //{
            //    token = "id1",
            //    version = version.ToString()
            //});

            //return Created(location, "id1");
            string locationUri = _LinkGenerator.GetUriByRouteValues(this.Request.HttpContext, Routes.GetResourceDataRoute, new
            {
                token = "id1",
                version = version.ToString()
            });

            return Created(locationUri, "id1");

and still got null in response.

I also tried with other methods but failed again:

            string locationUri = _LinkGenerator.GetPathByName(Routes.GetResourceDataRoute, new
            {
                token = "id1",
                version = version.ToString()
            });

To prove GetPathByName works for non-OData endpoints I commented out this part:

            services.AddControllers().AddOData(options =>
            {
                //options.Select().Count().SkipToken().Expand().SetMaxTop(10);
                //options.AddRouteComponents(Routes.ResourceDataControllerRoute, BuildEdmModel());
            })

Feels like it will always fail for OData-enabled endpoints.

Do you have any suggestions?

Thanks in advance.

ivanenkomaksym avatar Jun 29 '22 14:06 ivanenkomaksym

@xuzhg any update from your side?

Thanks

ivanenkomaksym avatar Aug 12 '22 06:08 ivanenkomaksym

No, i haven't dig more for this.

xuzhg avatar Aug 12 '22 18:08 xuzhg