Description
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 Parameter
s 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/**