0
votes

EDIT: here's a repo which simulates the error.

I'm building an API using ASP.NET Core together with ApiVersioning. My controllers are annotated with [Route("api/v{version:apiVersion/[controller] and on my POST actions I'm returning the location of the created resource:

return CreatedAtAction(nameof(Get), new { id = entity.Id }, entity);

When this line is ran I get an InvalidOperationException which the message:

Message: Internal Server Error System.InvalidOperationException: No route matches the supplied values. at Microsoft.AspNetCore.Mvc.CreatedAtActionResult.OnFormatting(ActionContext context) at Microsoft.AspNetCore.Mvc.Infrastructure.ObjectResultExecutor.ExecuteAsyncCore(ActionContext context, ObjectResult result, Type objectType, Object value) at Microsoft.AspNetCore.Mvc.Infrastructure.ObjectResultExecutor.ExecuteAsync(ActionContext context, ObjectResult result) at Microsoft.AspNetCore.Mvc.ObjectResult.ExecuteResultAsync(ActionContext context) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.InvokeResultAsync(IActionResult result) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.ResultNext[TFilter,TFilterAsync](State& next, Scope& scope, Object& state, Boolean& isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.InvokeNextResultFilterAsyncTFilter,TFilterAsync --- End of stack trace from previous location where exception was thrown --- at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResultExecutedContextSealed context) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.ResultNext[TFilter,TFilterAsync](State& next, Scope& scope, Object& state, Boolean& isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.InvokeResultFilters() --- End of stack trace from previous location where exception was thrown --- at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|19_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope) at Microsoft.AspNetCore.Routing.EndpointMiddleware.g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger) at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context) at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware.Invoke(HttpContext context)

If I remove the version tag from the URL pattern (thus becoming api/v1/[controller]) the error goes away. What am I missing?

Oh, here's how I'm bootstrapping my application:

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

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddApiVersioning(o =>
        {
            o.ReportApiVersions = true;
        });

        // ... removed for clarity
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseHttpsRedirection();

        app.UseRouting();

        app.UseAuthorization();

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

2 Answers

0
votes

This scenario has been brought up a couple of times. Action parameters have a BindingSource. The ApiVersion is considered BindingSource.Special because it can come from multiple places. This behavior is similar to how the CancellationToken action parameter works. If memory serves me correctly, an action parameter must have BindingSource.Path to be considered in route URL generation, which makes sense.

The name of the action parameter that you add should not matter, but it should match what you've defined in your route template to be sensical. The member you add that's passed to CreatedAtAction or CreatedAtRoute must be the same as that defined your route template. The routing system wants to serialize the ApiVersion type into the route parameter, which is incorrect. To address that, you simply call .ToString().

Putting that all together, this will work every time:

public class Model
{
    public int Id { get; set; }
}

[ApiVersion("1.0")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class ValuesController : ControllerBase
{
    [HttpGet("{id}"]
    public IActionResult Get(int id) => Ok(new Model(){ Id = id });

    [HttpPost]
    public IActionResult Post(Model model, ApiVersion version)
    {
        model.Id = 42;
        var routeValues = new {id = model.id, version = version.ToString()};
        return CreatedAtAction(nameof(Get), routeValues, model);
    }
}

The other way you can get the requested API version is to call HttpContext.GetRequestedApiVersion(). This method can return null, but in this context it will never be null by the time things have been routed to your action. This is ultimately just a shortcut extension method for:

var feature = HttpContext.Features.Get<IApiVersioningFeature>();
var raw = feature.RawRequestedApiVersion; // unparsed; e.g. exactly as requested
var version = feature.RequestedApiVersion; // parsed and validated
0
votes

It's solved, I'm leaving it here for future generations.

When calling CreatedAtAction, you must inform the version of the action you want to call. While this may be obvious, there's an exact way to do so:

Either capture the current version and pass it back:

        [HttpPost]
        public ActionResult Post([FromBody] Pet pet, ApiVersion version)
        {
            if (pet.Id is null)
            {
                lock (_petStorage)
                {
                    if (_petStorage.Count == 0)
                    {
                        pet.Id = 1;
                    }
                    else
                    {
                        pet.Id = _petStorage.Keys.Max() + 1;
                    }
                }
            }

            _petStorage.Add(pet.Id.Value, pet);

            return CreatedAtAction(nameof(GetById), new { pet.Id, version = version.ToString() }, pet);
        }

Or define the desired version by creating an instance of ApiVersion:


        [HttpPost]
        public ActionResult Post([FromBody] Pet pet)
        {
            if (pet.Id is null)
            {
                lock (_petStorage)
                {
                    if (_petStorage.Count == 0)
                    {
                        pet.Id = 1;
                    }
                    else
                    {
                        pet.Id = _petStorage.Keys.Max() + 1;
                    }
                }
            }

            _petStorage.Add(pet.Id.Value, pet);

            return CreatedAtAction(nameof(Get), new { pet.Id, version = new ApiVersion(1, 0).ToString() }, pet);
        }