jezzipin
jezzipin

Reputation: 4254

Conflicting method/path combination for action - Swagger unable to distinguish alternate version from Route

I have the following controller setup in my solution:

[Route("api/v{VersionId}/[controller]")]
[ApiController]
[Produces("application/json")]
[Consumes("application/json")]
public class MyBaseController : ControllerBase
{
}

[ApiVersion("1.0")]
[ApiVersion("1.1")]
public class AuthenticationController : MyBaseController
{
    private readonly ILoginService _loginService;

    public AuthenticationController(ILoginService loginService)
    {
        _loginService = loginService;
    }

    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status403Forbidden)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    [ProducesResponseType(StatusCodes.Status500InternalServerError)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [HttpPost("login")]
    public ActionResult<v1.JwtTokenResponse> Login([FromBody] v1.LoginRequest loginRequest)
    {
        var loginResult = _loginService.Login(loginRequest.Email, loginRequest.Password);
        if (loginResult.StatusCode != HttpStatusCode.OK)
        {
            return StatusCode((int)loginResult.StatusCode);
        }

        var tokenResponse = new v1.JwtTokenResponse() { Token = loginResult.Token };

        return Ok(tokenResponse);
    }
}  

Between the two versions of my API, nothing has changed for this method and so logically in my documentation I want to display that the method is still supported in the new version. Let's argue that we have a second controller of customer that has had some changed logic and hence is the reason why we have the new version 1.1 as semantic versioning dictates something new has been added but in a backwards compatible manner.

When running this code, naturally everything builds fine. The code is valid and .net core allows this sort of implementation however, when it comes to the swagger gen I am hitting issues with it producing the following error:

NotSupportedException: Conflicting method/path combination "POST api/v{VersionId}/Authentication/login" for actions - Template.Api.Endpoints.Controllers.AuthenticationController.Login (Template.Api.Endpoints),Template.Api.Endpoints.Controllers.AuthenticationController.Login (Template.Api.Endpoints). Actions require a unique method/path combination for Swagger/OpenAPI 3.0. Use ConflictingActionsResolver as a workaround

As you can see above, the path is different because the version parameter passed into the route makes it that way. Furthermore, it does not make sense to create a brand new method purely to represent that the code is available through documentation so, my question is why is swagger ignoring the version differences in the path and suggesting the user of the ConflictingActionsResolver?

Furthermore, after digging into this further and seeing that a lot of other people were having the same issue (with header versioning being a particular bugbear of the community and Swaggers hard-line approach being in conflict with this) the general approach seems to be to using the conflicting actions resolver to only take the first description it comes across which would only expose version 1.0 in the api documentation and leave out the 1.1 version giving the impression in Swagger that there is no 1.1 version of the endpoint available.

Swagger UI Config

app.UseSwaggerUI(setup =>
{
   setup.RoutePrefix = string.Empty;

   foreach (var description in apiVersions.ApiVersionDescriptions)
   {
      setup.SwaggerEndpoint($"/swagger/OpenAPISpecification{description.GroupName}/swagger.json",
                            description.GroupName.ToUpperInvariant());
   }
});

How can we get around this and correctly display available endpoints in Swagger without having to create new methods that effectively result in a duplication of code just to satisfy what seems like an oversight in the Swagger spec? Any help would be greatly appreciated.

N.B. Many may suggest appending action on to the end of the route however we wish to avoid this as it would mean our endpoints are not restful where we want to strive for something like customers/1 with the GET, POST, PUT attributes deriving the CRUD operations without having to append something like customers/add_customer_1 or customers/add_customer_2 reflecting the method name in the URL.

Upvotes: 8

Views: 13744

Answers (2)

Roar S.
Roar S.

Reputation: 11329

This is my Swagger settings when using HeaderApiVersionReader.

public class SwaggerOptions
{
    public string Title { get; set; }
    public string JsonRoute { get; set; }
    public string Description { get; set; }
    public List<Version> Versions { get; set; }

    public class Version
    {
        public string Name { get; set; }
        public string UiEndpoint { get; set; }
    }
}

In Startup#ConfigureServices

services.AddApiVersioning(apiVersioningOptions =>
{
    apiVersioningOptions.AssumeDefaultVersionWhenUnspecified = true;
    apiVersioningOptions.DefaultApiVersion = new ApiVersion(1, 0);
    apiVersioningOptions.ReportApiVersions = true;
    apiVersioningOptions.ApiVersionReader = new HeaderApiVersionReader("api-version");
});

// Register the Swagger generator, defining 1 or more Swagger documents
services.AddSwaggerGen(swaggerGenOptions =>
{
    var swaggerOptions = new SwaggerOptions();
    Configuration.GetSection("Swagger").Bind(swaggerOptions);

    foreach (var currentVersion in swaggerOptions.Versions)
    {
        swaggerGenOptions.SwaggerDoc(currentVersion.Name, new OpenApiInfo
        {
            Title = swaggerOptions.Title,
            Version = currentVersion.Name,
            Description = swaggerOptions.Description
        });
    }

    swaggerGenOptions.DocInclusionPredicate((version, desc) =>
    {
        if (!desc.TryGetMethodInfo(out MethodInfo methodInfo))
        {
            return false;
        }
        var versions = methodInfo.DeclaringType.GetConstructors()
            .SelectMany(constructorInfo => constructorInfo.DeclaringType.CustomAttributes
                .Where(attributeData => attributeData.AttributeType == typeof(ApiVersionAttribute))
                .SelectMany(attributeData => attributeData.ConstructorArguments
                    .Select(attributeTypedArgument => attributeTypedArgument.Value)));

        return versions.Any(v => $"{v}" == version);
    });

    swaggerGenOptions.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"));
    
    //... some filter settings here 
});           

In Startup#Configure

    var swaggerOptions = new SwaggerOptions();
    Configuration.GetSection("Swagger").Bind(swaggerOptions);
    app.UseSwagger(option => option.RouteTemplate = swaggerOptions.JsonRoute);

    app.UseSwaggerUI(option =>
    {
      foreach (var currentVersion in swaggerOptions.Versions)
      {
        option.SwaggerEndpoint(currentVersion.UiEndpoint, $"{swaggerOptions.Title} {currentVersion.Name}");
      }
    });

appsettings.json

{
  "Swagger": {
    "Title": "App title",
    "JsonRoute": "swagger/{documentName}/swagger.json",
    "Description": "Some text",
    "Versions": [
      {
        "Name": "2.0",
          "UiEndpoint": "/swagger/2.0/swagger.json"
      },
      {
        "Name": "1.0",
        "UiEndpoint": "/swagger/1.0/swagger.json"
      }
    ]
  }
}

Upvotes: 3

Chris Martinez
Chris Martinez

Reputation: 4418

There are a couple of problems.

The first issue is that the route template does not contain the route constraint. This is required when versioning by URL segment.

Therefore:

[Route("api/v{VersionId}/[controller]")]

Should be:

[Route("api/v{VersionId:apiVersion}/[controller]")]

Many examples will show using version as the route parameter name, but you can use VersionId or any other name you want.

The second problem is that you are probably creating a single OpenAPI/Swagger document. The document requires that every route template is unique. The default behavior in Swashbuckle is a document per API version. This method will produce unique paths. If you really want a single document, it is possible using URL segment versioning, but you need to expand the route templates so they produce unique paths.

Ensure your API Explorer configuration has:

services.AddVersionedApiExplorer(options => options.SubstituteApiVersionInUrl = true);

This will produce paths that expand api/v{VersionId:apiVersion}/[controller] to api/v1/Authentication and api/v1.1/Authentication respectively.

Upvotes: 0

Related Questions