dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.48k stars 10.04k forks source link

Error "InvalidOperationException: Sequence contains more than one matching element" in Swagger when using minimal api with the header versioning #54623

Closed vdevc closed 6 months ago

vdevc commented 8 months ago

Is there an existing issue for this?

Describe the bug

I am trying to use Asp.Versioning package with Header versioning together with minimal apis endpoints. Every endpoint is like this example

routeGroupBuilder.MapGet("/", ([FromHeader(Name = "x-api-version")] string apiVersion, [FromRoute] int customerId,
    [FromServices] LinkGenerator linkGenerator ) => {
    return new List<string> { "Individual1", "Individual2" };
})
.WithName("GetIndividuals")
.WithDescription("Get a list of individuals for a customer.")
.WithSummary("Get a list of individuals for a customer.")
.WithOpenApi()
.Produces<List<string>>(200)
.Produces<List<string>>(404);

Whenever I launch my project I get the following exception

Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware: Error: An unhandled exception has occurred while executing the request.

System.InvalidOperationException: Sequence contains more than one matching element
   at System.Linq.ThrowHelper.ThrowMoreThanOneMatchException()
   at System.Linq.Enumerable.TryGetSingle[TSource](IEnumerable`1 source, Func`2 predicate, Boolean& found)
   at System.Linq.Enumerable.SingleOrDefault[TSource](IEnumerable`1 source, Func`2 predicate)
   at Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenerator.GenerateOpenApiOperationFromMetadata(ApiDescription apiDescription, SchemaRepository schemaRepository)
   at Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenerator.GenerateOperation(ApiDescription apiDescription, SchemaRepository schemaRepository)
   at Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenerator.GenerateOperations(IEnumerable`1 apiDescriptions, SchemaRepository schemaRepository)
   at Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenerator.GeneratePaths(IEnumerable`1 apiDescriptions, SchemaRepository schemaRepository)
   at Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenerator.GetSwaggerDocumentWithoutFilters(String documentName, String host, String basePath)
   at Swashbuckle.AspNetCore.SwaggerGen.SwaggerGenerator.GetSwaggerAsync(String documentName, String host, String basePath)
   at Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware.Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider)
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

Please note: this issue was initially reported in issue #53831 having the same effect within a similar context. It has now been splitted on a request from @captainsafia

Expected Behavior

Swagger should not throw the exception.

Steps To Reproduce

You can find a repro here: https://github.com/vdevc/FromHeaderBindingIssue

Exceptions (if any)

System.InvalidOperationException: Sequence contains more than one matching element

.NET Version

Verified with SDKs 8.0.100, 8.0.101, 8.0.201, 8.0.202 and 8.0.203. The behaviour is consistent between all the versions.

Anything else?

No response

captainsafia commented 6 months ago

I took a look at this and I think the issue is at the intersection of Asp.Versioning and some of the code that is used to merge OpenApiOperations in metadata into the Swashbuckle generated document (see here).

For whatever reason, the Asp.Versioning configuration in the setup populates the x-api-version header argument into the operation twice.

/customers/{customerId}/businesses": {
      "get": {
        "tags": [
          "Businesses"
        ],
        "operationId": "GetBusinesses",
        "parameters": [
          {
            "name": "x-api-version",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "customerId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "x-api-version",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],

This seems buggy to mean and the bug is magnified by the SingleOrDefault check in the code above that assumes there is only one parameter with a given name per operation.

There's two avenues to fix it:

Also, FWIW, this issue only manifests when WithOpenApi is called on an endpoint so a viable workaround is to remove it. Especially, if it is not being used to make any modifications.

cc: @martincostello @commonsensesoftware for any thoughts on this

captainsafia commented 6 months ago

Update: I realized that the problem might be the fact that the x-api-version parameter is being referenced in the method signature of the handler:

.MapGet("/", (
                [FromHeader(Name = "x-api-version")] string apiVersion,
                [FromRoute] int customerId,
                [FromServices] ILoggerFactory loggerFactory,
                [FromServices] LinkGenerator linkGenerator
            ) =>
            {
                return new List<string> { "Business 1", "Business 2" };
            })

I bet this is duplicative with what Asp.Versioning is doing in its ApiExplorer overloads. Removing the apiVersion argument above should resolve the issue as well.

commonsensesoftware commented 6 months ago

@captainsafia is correct.

@vdevc explicitly defining the API version with the x-api-version header in your action is unsupported and unknown to API Versioning. It doesn't do any magic string parsing or matching on the method signature. It's special and analogous to something like CancellationToken in an action. This is one reason the parameter doesn't not have to be in your action for it to work. In addition, you have to consider that API Versioning supports multiple sources of the API version. Your action signature is not required to know which API version was selected if there are multiple nor how it was selected. For example, you might support the x-api-version header and the api-version query string. By the time things get to your action, it doesn't matter which one was specified.

If you want the requested API version passed into your action, you have two options.

Option 1

Enable binging the incoming API version to your endpoints with:

builder.Services.AddApiVersioning().EnableApiVersionBinding();

Since model binders are not supported in Minimal APIs and ApiVersion cannot use the TryBindAsync API, API Versioning does some internal sorcery to make it work. Hopefully, this will improve at a future date 🤞🏽. You can now write your action as:

.MapGet("/", (
                [FromRoute] int customerId,
                [FromServices] ILoggerFactory loggerFactory,
                [FromServices] LinkGenerator linkGenerator,
                ApiVersion apiVersion,
            ) =>
            {
                return new List<string> { "Business 1", "Business 2" };
            })

Today, this works via DI and would be equivalent to [FromServices] ApiVersion apiVersion, but I don't recommend using [FromServices] to be more explicit as that may change in the future and could break things. The order and name of the action parameter is irrelevant and can be anything you want. You can be guaranteed that by the time your action is invoked, the provided ApiVersion will never be null.

Option 2

The other option is to use the HttpContext extension method. This is less ergonomic, but doesn't any special magic. You can use the same approach in other places outside of your action.

.MapGet("/", (
                [FromRoute] int customerId,
                [FromServices] ILoggerFactory loggerFactory,
                [FromServices] LinkGenerator linkGenerator,
                HttpContext context,
            ) =>
            {
                var apiVersion = context.GetRequestedApiVersion()!;
                return new List<string> { "Business 1", "Business 2" };
            })

Note that it is possible for GetRequestedApiVersion() to return null, but that will never happen in the context of your action. If you want or need the unparsed, originally specified string value, you can use HttpContext.GetRawRequestedApiVersion(). That is the only way you can get that value.


Either approach will remove the duplicate x-api-version header. In addition, since HttpContext and ApiVersion are special, they will not be documented from the action signature. The ApiVersion parameter is documented according to your configuration (which you've already seen working).

vdevc commented 6 months ago

@commonsensesoftware @captainsafia Thanks a lot for the detailed explanations. I must say that I completely missed the fact that was unsupported to have the header in the signature. However, I was aware that it was working without having the header as a parameter. At the same time I was'nt aware of the alternatives represented by the two described options. Thanks!

@commonsensesoftware Besides the fact that this solution works and permit the generation of the swagger UI, there's still a problem which I did not have a couple of years ago in the same code but with some differences (Net6, controllers instead of minimal apis, and a few others). The swagger UI does not generate the field for specifying the api version to use. I can't say if this depends on the versioning package or on swashbuckle or anything else, however.