From b953a6b062b32dc830fd7a490f0cb2eced69e77b Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 2 Sep 2026 08:41:50 +0200 Subject: [PATCH] fix: keep a type on the rate limit headers under OpenAPI 3.1 Upgrading springdoc to 3.1.0 (#2112) also changed the spec version it emits: springdoc 3.x defaults springdoc.api-docs.version to OPENAPI_3_1, where 2.8.13 defaulted to OPENAPI_3_0. That was a side effect of the bump rather than a decision, and it silently dropped a field from the document. new Schema<>().type("integer") sets only the legacy string `type` and leaves the 3.1 `types` set null, and a 3.1 document does not serialize the former. The four rate limit headers have therefore been documented with no type at all since the upgrade. Building them through IntegerSchema populates both fields, so they keep a type whichever version springdoc emits. The dev configuration pins api-docs back to 3.0. Beyond restoring the document as it was, it silences a warning that OpenAPI 3.1 draws out of swagger-core 2.2.52 on every schema springdoc clones: SpringDocUtils : Json Processing Exception occurred: Cannot construct instance of `java.util.HashSet` [...] from String value ('integer') (through reference chain: io.swagger.v3.oas.models.media.JsonSchema["type"]) Its 3.1 mapper writes a single type as a bare string but only reads an array back, so cloneViaJson cannot re-read what it just wrote. It logs and falls back to the original object, so the document still renders. A schema with two types serializes as an array and round-trips fine, which is why only some schemas trip it. Nothing in this repository can fix that asymmetry; only avoiding 3.1 avoids it. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/dev/resources/application.yml | 4 +++ .../openvsx/web/DocumentationConfig.java | 15 +++++++--- .../openvsx/web/DocumentationConfigTest.java | 28 +++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/server/src/dev/resources/application.yml b/server/src/dev/resources/application.yml index 0ba9f1a11..c2913e9b0 100644 --- a/server/src/dev/resources/application.yml +++ b/server/src/dev/resources/application.yml @@ -96,6 +96,10 @@ management: springdoc: model-and-view-allowed: true + api-docs: + # springdoc 3.x defaults to OpenAPI 3.1, whose mapper in swagger-core writes a single schema type as + # a bare string but cannot read one back, so springdoc warns on every schema it tries to clone. + version: openapi_3_0 swagger-ui: path: /swagger-ui docExpansion: list diff --git a/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java b/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java index 22954ea5b..f0dd05a32 100644 --- a/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java @@ -17,6 +17,7 @@ import io.swagger.v3.oas.models.headers.Header; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.media.IntegerSchema; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.responses.ApiResponse; import io.swagger.v3.oas.models.responses.ApiResponses; @@ -135,20 +136,26 @@ public OpenApiCustomizer sortSchemasAlphabetically() { }; } + /** + * Schemas must be built through the typed subclasses rather than {@code new Schema<>().type(...)}. + * The latter only sets the legacy string {@code type}, which an OpenAPI 3.1 document does not + * serialize at all - springdoc 3.x emits 3.1 by default - leaving the header with no type. The + * typed subclasses populate both that field and the 3.1 {@code types} set. + */ @Bean public OpenApiCustomizer addRateLimitResponse() { var limitLimitHeader = new Header() .description("Number of requests that can be made in a given amount of time") - .schema(new Schema<>().type("integer").format("int32")); + .schema(new IntegerSchema().format("int32")); var limitRemainingHeader = new Header() .description("Remaining number of requests left in the current time window") - .schema(new Schema<>().type("integer").format("int32")); + .schema(new IntegerSchema().format("int32")); var limitResetHeader = new Header() .description("Number of seconds until the rate limit tokens will be fully filled to its maximum") - .schema(new Schema<>().type("integer").format("int32")); + .schema(new IntegerSchema().format("int32")); var retryAfterHeader = new Header() .description("Number of seconds to wait after receiving a 429 response") - .schema(new Schema<>().type("integer").format("int32")); + .schema(new IntegerSchema().format("int32")); var response = new ApiResponse() .description("A client has sent too many requests in a given amount of time") diff --git a/server/src/test/java/org/eclipse/openvsx/web/DocumentationConfigTest.java b/server/src/test/java/org/eclipse/openvsx/web/DocumentationConfigTest.java index bd7ff1a16..b0dbead37 100644 --- a/server/src/test/java/org/eclipse/openvsx/web/DocumentationConfigTest.java +++ b/server/src/test/java/org/eclipse/openvsx/web/DocumentationConfigTest.java @@ -14,7 +14,12 @@ import java.lang.reflect.Method; +import io.swagger.v3.core.util.Json; +import io.swagger.v3.core.util.Json31; +import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; import org.junit.jupiter.api.Test; import org.springframework.web.method.HandlerMethod; @@ -59,6 +64,29 @@ void shouldMarkAPreviewOperationOnlyOnce() { assertThat(operation.getDescription()).containsOnlyOnce("**Preview**"); } + // springdoc 3.x emits OpenAPI 3.1 by default, which does not serialize the legacy string `type` a + // bare `new Schema<>().type(...)` sets - the header then documents no type at all. The rate limit + // headers have to survive both spec versions, since which one is emitted is a springdoc default. + @Test + void shouldGiveTheRateLimitHeadersATypeInEitherSpecVersion() throws Exception { + var openApi = new OpenAPI().paths( + new Paths().addPathItem("/api/-/search", new PathItem().get(new Operation()))); + + new DocumentationConfig().addRateLimitResponse().customise(openApi); + + var schema = openApi.getPaths() + .get("/api/-/search") + .getGet() + .getResponses() + .get("429") + .getHeaders() + .get("X-RateLimit-Limit") + .getSchema(); + + assertThat(Json.mapper().writeValueAsString(schema)).contains("\"type\":\"integer\""); + assertThat(Json31.mapper().writeValueAsString(schema)).contains("\"type\":\"integer\""); + } + private void customize(Operation operation, String methodName) { Method method; try {