Skip to content

IllegalStateException: Duplicate key when two endpoints at the same URL with same header exist #1985

Closed
@Felk

Description

@Felk

I am using springdoc-openapi-ui 1.6.13 in conjunction with graphql-spqr 0.12.0 and its accompanying graphql-spqr-spring-boot-starter 0.0.6 in a spring-boot application 2.7.6.
When trying to access http://localhost:8080/v3/api-docs to see the generated OpenAPI, I get an error:

java.lang.IllegalStateException: Duplicate key class Parameter {
    name: Connection!
    in: header
    description: null
    required: null
    deprecated: null
    allowEmptyValue: null
    style: null
    explode: null
    allowReserved: null
    schema: class StringSchema {
        class Schema {
            type: string
            format: null
            $ref: null
            description: null
            title: null
            multipleOf: null
            maximum: null
            exclusiveMaximum: null
            minimum: null
            exclusiveMinimum: null
            maxLength: null
            minLength: null
            pattern: null
            maxItems: null
            minItems: null
            uniqueItems: null
            maxProperties: null
            minProperties: null
            required: null
            not: null
            properties: null
            additionalProperties: null
            nullable: null
            readOnly: null
            writeOnly: null
            example: null
            externalDocs: null
            deprecated: null
            discriminator: null
            xml: null
        }
    }
    examples: null
    example: null
    content: null
    $ref: null
}
	at org.springdoc.core.AbstractRequestService.lambda$getParameterLinkedHashMap$4(AbstractRequestService.java:371)
	at java.base/java.util.HashMap.merge(HashMap.java:1391)
	at java.base/java.util.stream.Collectors.lambda$toMap$68(Collectors.java:1673)
	at java.base/java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)
	at java.base/java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1625)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:682)
	at org.springdoc.core.AbstractRequestService.getParameterLinkedHashMap(AbstractRequestService.java:370)
	at org.springdoc.core.AbstractRequestService.build(AbstractRequestService.java:336)
	at org.springdoc.api.AbstractOpenApiResource.calculatePath(AbstractOpenApiResource.java:503)
	at org.springdoc.api.AbstractOpenApiResource.calculatePath(AbstractOpenApiResource.java:665)
	at org.springdoc.webmvc.api.OpenApiResource.lambda$calculatePath$11(OpenApiResource.java:234)
	at java.base/java.util.Optional.ifPresent(Optional.java:178)
	at org.springdoc.webmvc.api.OpenApiResource.calculatePath(OpenApiResource.java:215)
	at org.springdoc.webmvc.api.OpenApiResource.lambda$getPaths$2(OpenApiResource.java:185)
	at java.base/java.util.Optional.ifPresent(Optional.java:178)
	at org.springdoc.webmvc.api.OpenApiResource.getPaths(OpenApiResource.java:164)
	at org.springdoc.api.AbstractOpenApiResource.getOpenApi(AbstractOpenApiResource.java:364)
	at org.springdoc.webmvc.api.OpenApiResource.openapiJson(OpenApiResource.java:139)
	at org.springdoc.webmvc.api.OpenApiWebMvcResource.openapiJson(OpenApiWebMvcResource.java:116)
	<snip>
	at com.example.controller.OpenAPI2MarkupTest.createOpenAPIJson(OpenAPI2MarkupTest.java:43)
	<snip>

I used this test, but just launching the application and hitting http://localhost:8080/v3/api-docs should be equivalent:

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@Transactional
@AutoConfigureMockMvc
class OpenAPI2MarkupTest extends AbstractIntegrationTest {

  @Autowired MockMvc mockMvc;

  @Test
  void createOpenAPIJson() throws Exception {
    MvcResult mvcResult =
        mockMvc
            .perform(get("http://localhost:8080/v3/api-docs").accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andReturn();
  }
}

I was able to narrow the problem down a bit:

graphql-spqr-spring-boot-starter provides a rest controller for a /graphql endpoint, which includes the headers headers = { "Connection!=Upgrade", "Connection!=keep-alive, Upgrade" } for two endpoints as seen here:

@GetMapping(
        value = "${graphql.spqr.http.endpoint:/graphql}",
        produces = MediaType.APPLICATION_JSON_VALUE,
        headers = { "Connection!=Upgrade", "Connection!=keep-alive, Upgrade" }
)
public Object executeGet(GraphQLRequest graphQLRequest, R request) {
    return get(graphQLRequest, request, TransportType.HTTP);
}

@GetMapping(
        value = "${graphql.spqr.http.endpoint:/graphql}",
        produces = MediaType.TEXT_EVENT_STREAM_VALUE,
        headers = { "Connection!=Upgrade", "Connection!=keep-alive, Upgrade" }
)
public Object executeGetEventStream(GraphQLRequest graphQLRequest, R request) {
    return get(graphQLRequest, request, TransportType.HTTP_EVENT_STREAM);
}

Springdoc later parses this information using this code:

String[] headers = requestMappingInfo.getHeadersCondition().getExpressions().stream().map(Object::toString).toArray(String[]::new);

which results in the headers ["Connection!=Upgrade", "Connection!=keep-alive, Upgrade"]. MethodAttributes#setHeaders later interprets those header expressions as plain header, splitting at = and adding a header called Connection! to the headers map.

This in and of itself already looks like a bug, but at this point it's just a precondition for the following:

Because graphql-spqr-spring-boot-starter's rest controller contains two GET endpoints that only differ in their produces mime type, OpenApiResource's calculatePath method is run for both. It constructs a new Operation object the first time but reuses the already defined operation, as seen in AbstractOpenApiResource#calculatePath:

Operation existingOperation = getExistingOperation(operationMap, requestMethod);
// ...
Operation operation = (existingOperation != null) ? existingOperation : new Operation();

A bit further down fillParametersList is called, which derives Parameters from http headers. Here, a second duplicate class Parameter { name: Connection!, ... } gets added using parametersList.addAll(headersMap).

List<Parameter> parametersList = operation.getParameters();
if (parametersList == null)
    parametersList = new ArrayList<>();
Collection<Parameter> headersMap = AbstractRequestService.getHeaders(methodAttributes, new LinkedHashMap<>());
parametersList.addAll(headersMap);

This later on causes a IllegalStateException in AbstractRequestService#getParameterLinkedHashMap:

LinkedHashMap<ParameterId, Parameter> map = operationParameters.stream().collect(Collectors.toMap(ParameterId::new, parameter -> parameter, (u, v) -> {
    throw new IllegalStateException(String.format("Duplicate key %s", u));
}, LinkedHashMap::new));

It would be nice if springdoc could process the graphql endpoint in some meaningful way without crashing.
The easy workaround right now is to exclude the graphql endpoint from springdoc in the application.properties:

springdoc.paths-to-exclude=/graphql/**

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions