From 4720f6df7350da62b4c8bf20203d78d436d591e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EA=B4=80=ED=9D=AC?= Date: Sun, 26 Apr 2026 03:07:56 +0900 Subject: [PATCH] Allow request-specific Swagger UI index transformation Swagger UI index.html can be customized through SwaggerIndexTransformer, including CSP nonce injection, but cached resource handlers stored the first transformed index resource and reused it for later requests. Register index.html with the uncached Swagger UI handler patterns, matching swagger-initializer.js behavior, while retaining resource resolution caching. --- .../org/springdoc/core/utils/Constants.java | 5 + .../ui/AbstractSwaggerConfigurer.java | 10 +- .../ui/app42/SpringDocApp42Test.java | 102 ++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 springdoc-openapi-starter-webmvc-ui/src/test/java/test/org/springdoc/ui/app42/SpringDocApp42Test.java diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/Constants.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/Constants.java index c653d407b..3398e5d42 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/Constants.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/Constants.java @@ -201,6 +201,11 @@ public final class Constants { */ public static final String INDEX_PAGE = "/index.html"; + /** + * The constant INDEX_PAGE_PATTERN. + */ + public static final String INDEX_PAGE_PATTERN = "/*index.html"; + /** * The constant SWAGGER_UI_URL. */ diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/ui/AbstractSwaggerConfigurer.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/ui/AbstractSwaggerConfigurer.java index 583dd2ffb..d0c489f0a 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/ui/AbstractSwaggerConfigurer.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/ui/AbstractSwaggerConfigurer.java @@ -9,6 +9,7 @@ import java.util.Arrays; import static org.springdoc.core.utils.Constants.ALL_PATTERN; +import static org.springdoc.core.utils.Constants.INDEX_PAGE_PATTERN; import static org.springdoc.core.utils.Constants.SWAGGER_INITIALIZER_PATTERN; import static org.springdoc.core.utils.Constants.SWAGGER_UI_PREFIX; import static org.springdoc.core.utils.Constants.SWAGGER_UI_WEBJAR_NAME; @@ -54,6 +55,7 @@ protected AbstractSwaggerConfigurer(SwaggerUiConfigProperties swaggerUiConfigPro */ protected SwaggerResourceHandlerConfig[] getSwaggerHandlerConfigs() { String swaggerUiPattern = getUiRootPath() + SWAGGER_UI_PREFIX + ALL_PATTERN; + String swaggerUiIndexPattern = combinePatterns(swaggerUiPattern, INDEX_PAGE_PATTERN); String swaggerUiInitializerPattern = combinePatterns(swaggerUiPattern, SWAGGER_INITIALIZER_PATTERN); String swaggerUiResourceLocation = WEBJARS_RESOURCE_LOCATION + SWAGGER_UI_WEBJAR_NAME + DEFAULT_PATH_SEPARATOR + swaggerUiConfigProperties.getVersion() + DEFAULT_PATH_SEPARATOR; @@ -63,7 +65,7 @@ protected SwaggerResourceHandlerConfig[] getSwaggerHandlerConfigs() { .setPatterns(swaggerUiPattern) .setLocations(swaggerUiResourceLocation), SwaggerResourceHandlerConfig.createUncached() - .setPatterns(swaggerUiInitializerPattern) + .setPatterns(swaggerUiIndexPattern, swaggerUiInitializerPattern) .setLocations(swaggerUiResourceLocation) }; } @@ -77,6 +79,9 @@ protected SwaggerResourceHandlerConfig[] getSwaggerWebjarHandlerConfigs() { if (!springWebProperties.getResources().isAddMappings()) return new SwaggerResourceHandlerConfig[]{}; String swaggerUiWebjarPattern = combinePatterns(getWebjarsPathPattern(), SWAGGER_UI_WEBJAR_NAME_PATTERN) + ALL_PATTERN; + String swaggerUiWebjarIndexPattern = combinePatterns(swaggerUiWebjarPattern, INDEX_PAGE_PATTERN); + String swaggerUiWebjarVersionIndexPattern = combinePatterns(swaggerUiWebjarPattern, + swaggerUiConfigProperties.getVersion() + INDEX_PAGE_PATTERN); String swaggerUiWebjarInitializerPattern = combinePatterns(swaggerUiWebjarPattern, SWAGGER_INITIALIZER_PATTERN); String swaggerUiWebjarVersionInitializerPattern = combinePatterns(swaggerUiWebjarPattern, swaggerUiConfigProperties.getVersion() + SWAGGER_INITIALIZER_PATTERN); @@ -87,7 +92,8 @@ protected SwaggerResourceHandlerConfig[] getSwaggerWebjarHandlerConfigs() { .setPatterns(swaggerUiWebjarPattern) .setLocations(swaggerUiWebjarResourceLocation), SwaggerResourceHandlerConfig.createUncached() - .setPatterns(swaggerUiWebjarInitializerPattern, swaggerUiWebjarVersionInitializerPattern) + .setPatterns(swaggerUiWebjarIndexPattern, swaggerUiWebjarVersionIndexPattern, + swaggerUiWebjarInitializerPattern, swaggerUiWebjarVersionInitializerPattern) .setLocations(swaggerUiWebjarResourceLocation) }; } diff --git a/springdoc-openapi-starter-webmvc-ui/src/test/java/test/org/springdoc/ui/app42/SpringDocApp42Test.java b/springdoc-openapi-starter-webmvc-ui/src/test/java/test/org/springdoc/ui/app42/SpringDocApp42Test.java new file mode 100644 index 000000000..c6d512122 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-ui/src/test/java/test/org/springdoc/ui/app42/SpringDocApp42Test.java @@ -0,0 +1,102 @@ +/* + * + * * Copyright 2019-2026 the original author or authors. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * https://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + * + */ + +package test.org.springdoc.ui.app42; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.springdoc.core.properties.SwaggerUiConfigProperties; +import org.springdoc.core.properties.SwaggerUiOAuthProperties; +import org.springdoc.core.providers.ObjectMapperProvider; +import org.springdoc.webmvc.ui.SwaggerIndexPageTransformer; +import org.springdoc.webmvc.ui.SwaggerIndexTransformer; +import org.springdoc.webmvc.ui.SwaggerWelcomeCommon; +import test.org.springdoc.ui.AbstractSpringDocTest; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.Resource; +import org.springframework.web.servlet.resource.ResourceTransformerChain; +import org.springframework.web.servlet.resource.TransformedResource; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests per-request Swagger UI index transformations. + * + * @author limehee + */ +public class SpringDocApp42Test extends AbstractSpringDocTest { + + @Test + void indexPageTransformerRunsForEveryRequest() throws Exception { + mockMvc.perform(get("/swagger-ui/index.html").requestAttr("cspNonce", "nonce-a")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("nonce=\"nonce-a\""))) + .andExpect(content().string(not(containsString("nonce=\"nonce-b\"")))); + + mockMvc.perform(get("/swagger-ui/index.html").requestAttr("cspNonce", "nonce-b")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("nonce=\"nonce-b\""))) + .andExpect(content().string(not(containsString("nonce=\"nonce-a\"")))); + } + + @SpringBootApplication + static class SpringDocTestApp { + + @Bean + SwaggerIndexTransformer swaggerIndexTransformer(SwaggerUiConfigProperties swaggerUiConfig, + SwaggerUiOAuthProperties swaggerUiOAuthProperties, SwaggerWelcomeCommon swaggerWelcomeCommon, + ObjectMapperProvider objectMapperProvider) { + return new NonceSwaggerIndexTransformer(swaggerUiConfig, swaggerUiOAuthProperties, swaggerWelcomeCommon, + objectMapperProvider); + } + + } + + static class NonceSwaggerIndexTransformer extends SwaggerIndexPageTransformer { + + NonceSwaggerIndexTransformer(SwaggerUiConfigProperties swaggerUiConfig, + SwaggerUiOAuthProperties swaggerUiOAuthProperties, SwaggerWelcomeCommon swaggerWelcomeCommon, + ObjectMapperProvider objectMapperProvider) { + super(swaggerUiConfig, swaggerUiOAuthProperties, swaggerWelcomeCommon, objectMapperProvider); + } + + @Override + public Resource transform(HttpServletRequest request, Resource resource, ResourceTransformerChain transformerChain) + throws IOException { + Resource transformedResource = super.transform(request, resource, transformerChain); + if (request.getRequestURI().endsWith("/index.html")) { + String html = new String(transformedResource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String nonce = request.getAttribute("cspNonce").toString(); + html = html.replace("