Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
Felk opened this issue Dec 5, 2022 · 5 comments
Labels
bug Something isn't working

Comments

@Felk
Copy link

Felk commented Dec 5, 2022

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/**
@bnasslahsen
Copy link
Contributor

@Felk,

Thank you for your analysis.
Are you willing to propose a PR ?

@bnasslahsen bnasslahsen added the bug Something isn't working label Dec 5, 2022
@Felk
Copy link
Author

Felk commented Dec 5, 2022

I may be able to try soon-ish, but I am not sure how to best fix the problem, since I don't understand the bigger picture. Do you think it would be enough to simply skip duplicate headers? That would be easy enough I suppose

@bnasslahsen
Copy link
Contributor

bnasslahsen commented Dec 5, 2022

@Felk,

This is a good start!
Can you describe in OpenAPI 3 what are you really expecting in terms of result for your headers ?

@bnasslahsen
Copy link
Contributor

Without any response. i have added a workaround.
Please note there are no plans to support GraphQL.

bnasslahsen added a commit that referenced this issue Dec 6, 2022
@Felk
Copy link
Author

Felk commented Dec 7, 2022

looks good, thanks!

bnasslahsen added a commit that referenced this issue Dec 12, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

2 participants