Repository: springdoc/springdoc-openapi
Issue: Wrong server url with when grouped api name contains special charater

When the name of the grouped API contains a character which gets URL encoded the server url is generated wrong. When calculating the server URL the request URL should be decoded.

Changed files:
springdoc-openapi-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java
springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app68/SpringDocApp68Test.java
springdoc-openapi-webmvc-core/src/main/java/org/springdoc/api/OpenApiResource.java
springdoc-openapi-webflux-core/src/main/java/org/springdoc/api/OpenApiResource.java
springdoc-openapi-webmvc-core/src/main/java/org/springdoc/api/MultipleOpenApiResource.java
springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app68/SpringDocTestApp.java

diff --git a/springdoc-openapi-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java b/springdoc-openapi-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java
index 87e61a7b..8df235e3 100644
--- a/springdoc-openapi-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java
+++ b/springdoc-openapi-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java
@@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.Hidden;
 import io.swagger.v3.oas.models.*;
 import io.swagger.v3.oas.models.PathItem.HttpMethod;
 import io.swagger.v3.oas.models.responses.ApiResponses;
+import org.apache.commons.lang3.StringUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springdoc.core.*;
@@ -18,7 +19,12 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestMethod;
 import org.springframework.web.method.HandlerMethod;
 
+import java.io.UnsupportedEncodingException;
 import java.lang.reflect.Method;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 import java.time.Instant;
 import java.util.*;
@@ -290,4 +296,12 @@ public abstract class AbstractOpenApiResource {
     protected boolean isPathToMatch(String operationPath) {
         return CollectionUtils.isEmpty(pathsToMatch) || pathsToMatch.stream().anyMatch(pattern -> antPathMatcher.match(pattern, operationPath));
     }
+
+    protected static String decode(String requestURI) {
+        try {
+            return URLDecoder.decode(requestURI, StandardCharsets.UTF_8.toString());
+        } catch (UnsupportedEncodingException e) {
+            return requestURI;
+        }
+    }
 }
diff --git a/springdoc-openapi-webflux-core/src/main/java/org/springdoc/api/OpenApiResource.java b/springdoc-openapi-webflux-core/src/main/java/org/springdoc/api/OpenApiResource.java
index 253e105a..19843c12 100644
--- a/springdoc-openapi-webflux-core/src/main/java/org/springdoc/api/OpenApiResource.java
+++ b/springdoc-openapi-webflux-core/src/main/java/org/springdoc/api/OpenApiResource.java
@@ -82,7 +82,7 @@ public class OpenApiResource extends AbstractOpenApiResource {
     }
 
     private void calculateServerUrl(ServerHttpRequest serverHttpRequest, String apiDocsUrl) {
-        String requestUrl = serverHttpRequest.getURI().toString();
+        String requestUrl = decode(serverHttpRequest.getURI().toString());
         String serverBaseUrl = requestUrl.substring(0, requestUrl.length() - apiDocsUrl.length());
         openAPIBuilder.setServerBaseUrl(serverBaseUrl);
     }
diff --git a/springdoc-openapi-webmvc-core/src/main/java/org/springdoc/api/MultipleOpenApiResource.java b/springdoc-openapi-webmvc-core/src/main/java/org/springdoc/api/MultipleOpenApiResource.java
index 1f7bc9d6..7707294f 100644
--- a/springdoc-openapi-webmvc-core/src/main/java/org/springdoc/api/MultipleOpenApiResource.java
+++ b/springdoc-openapi-webmvc-core/src/main/java/org/springdoc/api/MultipleOpenApiResource.java
@@ -3,6 +3,7 @@ package org.springdoc.api;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import io.swagger.v3.oas.annotations.Operation;
 import org.springdoc.core.*;
+import org.springframework.beans.factory.InitializingBean;
 import org.springframework.beans.factory.ObjectFactory;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.MediaType;
@@ -21,14 +22,36 @@ import static org.springdoc.core.Constants.*;
 import static org.springframework.util.AntPathMatcher.DEFAULT_PATH_SEPARATOR;
 
 @RestController
-public class MultipleOpenApiResource {
+public class MultipleOpenApiResource implements InitializingBean {
 
-    private final Map<String, OpenApiResource> groupedOpenApiResources;
+    private Map<String, OpenApiResource> groupedOpenApiResources;
+    private final List<GroupedOpenApi> groupedOpenApis;
+    private final ObjectFactory<OpenAPIBuilder> defaultOpenAPIBuilder;
+    private final AbstractRequestBuilder requestBuilder;
+    private final AbstractResponseBuilder responseBuilder;
+    private final OperationBuilder operationParser;
+    private final RequestMappingInfoHandlerMapping requestMappingHandlerMapping;
+    private final Optional<ActuatorProvider> servletContextProvider;
+
+    @Value(SPRINGDOC_SHOW_ACTUATOR_VALUE)
+    private boolean showActuator;
 
     public MultipleOpenApiResource(List<GroupedOpenApi> groupedOpenApis,
                                    ObjectFactory<OpenAPIBuilder> defaultOpenAPIBuilder, AbstractRequestBuilder requestBuilder,
                                    AbstractResponseBuilder responseBuilder, OperationBuilder operationParser,
                                    RequestMappingInfoHandlerMapping requestMappingHandlerMapping, Optional<ActuatorProvider> servletContextProvider) {
+
+        this.groupedOpenApis = groupedOpenApis;
+        this.defaultOpenAPIBuilder = defaultOpenAPIBuilder;
+        this.requestBuilder = requestBuilder;
+        this.responseBuilder = responseBuilder;
+        this.operationParser = operationParser;
+        this.requestMappingHandlerMapping = requestMappingHandlerMapping;
+        this.servletContextProvider = servletContextProvider;
+    }
+
+    @Override
+    public void afterPropertiesSet() throws Exception {
         this.groupedOpenApiResources = groupedOpenApis.stream()
                 .collect(Collectors.toMap(GroupedOpenApi::getGroup, item ->
                         new OpenApiResource(
@@ -38,7 +61,8 @@ public class MultipleOpenApiResource {
                                 operationParser,
                                 requestMappingHandlerMapping,
                                 servletContextProvider,
-                                Optional.of(item.getOpenApiCustomisers()), item.getPathsToMatch(), item.getPackagesToScan()
+                                Optional.of(item.getOpenApiCustomisers()), item.getPathsToMatch(), item.getPackagesToScan(),
+                                showActuator
                         )
                 ));
     }
@@ -67,4 +91,4 @@ public class MultipleOpenApiResource {
         }
         return openApiResource;
     }
-}
\\ No newline at end of file
+}
diff --git a/springdoc-openapi-webmvc-core/src/main/java/org/springdoc/api/OpenApiResource.java b/springdoc-openapi-webmvc-core/src/main/java/org/springdoc/api/OpenApiResource.java
index aeec5584..0fab2447 100644
--- a/springdoc-openapi-webmvc-core/src/main/java/org/springdoc/api/OpenApiResource.java
+++ b/springdoc-openapi-webmvc-core/src/main/java/org/springdoc/api/OpenApiResource.java
@@ -53,12 +53,15 @@ public class OpenApiResource extends AbstractOpenApiResource {
     public OpenApiResource(OpenAPIBuilder openAPIBuilder, AbstractRequestBuilder requestBuilder,
                            AbstractResponseBuilder responseBuilder, OperationBuilder operationParser,
                            RequestMappingInfoHandlerMapping requestMappingHandlerMapping, Optional<ActuatorProvider> servletContextProvider,
-                           Optional<List<OpenApiCustomiser>> openApiCustomisers, List<String> pathsToMatch, List<String> packagesToScan) {
+                           Optional<List<OpenApiCustomiser>> openApiCustomisers, List<String> pathsToMatch, List<String> packagesToScan,
+                           boolean showActuator) {
         super(openAPIBuilder, requestBuilder, responseBuilder, operationParser, openApiCustomisers, pathsToMatch, packagesToScan);
         this.requestMappingHandlerMapping = requestMappingHandlerMapping;
         this.servletContextProvider = servletContextProvider;
+        this.showActuator = showActuator;
     }
 
+
     @Operation(hidden = true)
     @GetMapping(value = API_DOCS_URL, produces = MediaType.APPLICATION_JSON_VALUE)
     public String openapiJson(HttpServletRequest request, @Value(API_DOCS_URL) String apiDocsUrl)
@@ -126,8 +129,8 @@ public class OpenApiResource extends AbstractOpenApiResource {
     }
 
     private void calculateServerUrl(HttpServletRequest request, String apiDocsUrl) {
-        StringBuffer requestUrl = request.getRequestURL();
-        String serverBaseUrl = requestUrl.substring(0, requestUrl.length() - apiDocsUrl.length());
-        openAPIBuilder.setServerBaseUrl(serverBaseUrl);
+        String requestUrl = decode(request.getRequestURL().toString());
+        String calculatedUrl = requestUrl.substring(0, requestUrl.length() - apiDocsUrl.length());
+        openAPIBuilder.setServerBaseUrl(calculatedUrl);
     }
 }
diff --git a/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app68/SpringDocApp68Test.java b/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app68/SpringDocApp68Test.java
index 88d500c4..087e2a64 100644
--- a/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app68/SpringDocApp68Test.java
+++ b/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app68/SpringDocApp68Test.java
@@ -57,7 +57,7 @@ public class SpringDocApp68Test {
 
     @Test
     public void testApp4() throws Exception {
-        mockMvc.perform(get(Constants.DEFAULT_API_DOCS_URL + "/groups"))
+        mockMvc.perform(get(Constants.DEFAULT_API_DOCS_URL + "/groups test"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.openapi", is("3.0.1")))
                 .andExpect(content().json(getContent("results/app684.json"), true));
diff --git a/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app68/SpringDocTestApp.java b/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app68/SpringDocTestApp.java
index 1b245552..81d06752 100644
--- a/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app68/SpringDocTestApp.java
+++ b/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app68/SpringDocTestApp.java
@@ -43,7 +43,7 @@ public class SpringDocTestApp {
     @Bean
     public GroupedOpenApi groupOpenApi() {
         return GroupedOpenApi.builder()
-                .setGroup("groups")
+                .setGroup("groups test")
                 .pathsToMatch("/v1/**")
                 .packagesToScan("test.org.springdoc.api.app68.api.user", "test.org.springdoc.api.app68.api.store")
                 .build();