Repository: springdoc/springdoc-openapi
Pull request #451 (2020-02-19 22:57:11)

Add OpenApiBuilderCustomiser
Add OpenApiBuilderCustomiser to allow users to customize the OpenApiBuilder.
In our case we have our own custom annotation `FacadeRestResource` (much similar to spring-data-rest `RepositoryRestResource`) and we need a way to allow the OpenApiBuilder to scan beans for our annotation.
With the proposed change we introduce a new interface called `OpenApiBuilderCustomiser` and thus we can create our own customizer to provide the additional Map of mappings.


Patch:
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 4275d353b..55dec9c1f 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
@@ -114,11 +114,7 @@ public abstract class AbstractOpenApiResource extends SpecFilter {
 		if (!computeDone || springDocConfigProperties.getCache().isDisabled()) {
 			Instant start = Instant.now();
 			openAPIBuilder.build();
-			Map<String, Object> restControllersMap = openAPIBuilder.getRestControllersMap();
-			Map<String, Object> requestMappingMap = openAPIBuilder.getRequestMappingMap();
-			Map<String, Object> controllerMap = openAPIBuilder.getControllersMap();
-			Map<String, Object> restControllers = Stream.of(restControllersMap, requestMappingMap, controllerMap)
-					.flatMap(mapEl -> mapEl.entrySet().stream())
+			Map<String, Object> mappingsMap = openAPIBuilder.getMappingsMap().entrySet().stream()
 					.filter(controller -> (AnnotationUtils.findAnnotation(controller.getValue().getClass(),
 							Hidden.class) == null))
 					.filter(controller -> !isHiddenRestControllers(controller.getValue().getClass()))
@@ -128,7 +124,7 @@ public abstract class AbstractOpenApiResource extends SpecFilter {
 			// calculate generic responses
 			responseBuilder.buildGenericResponse(openAPIBuilder.getComponents(), findControllerAdvice);
 
-			getPaths(restControllers);
+			getPaths(mappingsMap);
 			openApi = openAPIBuilder.getCalculatedOpenAPI();
 
 			// run the optional customisers
diff --git a/springdoc-openapi-common/src/main/java/org/springdoc/core/OpenAPIBuilder.java b/springdoc-openapi-common/src/main/java/org/springdoc/core/OpenAPIBuilder.java
index 8b36c76c2..13afae9e2 100644
--- a/springdoc-openapi-common/src/main/java/org/springdoc/core/OpenAPIBuilder.java
+++ b/springdoc-openapi-common/src/main/java/org/springdoc/core/OpenAPIBuilder.java
@@ -49,6 +49,7 @@ import org.apache.commons.lang3.StringUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import org.springdoc.core.customizers.OpenApiBuilderCustomiser;
 import org.springframework.beans.factory.config.BeanDefinition;
 import org.springframework.boot.autoconfigure.AutoConfigurationPackages;
 import org.springframework.context.ApplicationContext;
@@ -81,10 +82,14 @@ public class OpenAPIBuilder {
 
 	private final SecurityParser securityParser;
 
+	private final Map<String, Object> mappingsMap = new HashMap<>();
+
 	private final Map<HandlerMethod, io.swagger.v3.oas.models.tags.Tag> springdocTags = new HashMap<>();
 
 	private final Optional<SecurityOAuth2Provider> springSecurityOAuth2Provider;
 
+	private final List<OpenApiBuilderCustomiser> openApiBuilderCustomisers;
+
 	private boolean isServersPresent;
 
 	private String serverBaseUrl;
@@ -92,7 +97,9 @@ public class OpenAPIBuilder {
 	private final SpringDocConfigProperties springDocConfigProperties;
 
 	@SuppressWarnings("WeakerAccess")
-	OpenAPIBuilder(Optional<OpenAPI> openAPI, ApplicationContext context, SecurityParser securityParser, Optional<SecurityOAuth2Provider> springSecurityOAuth2Provider, SpringDocConfigProperties springDocConfigProperties) {
+	OpenAPIBuilder(Optional<OpenAPI> openAPI, ApplicationContext context, SecurityParser securityParser,
+				   Optional<SecurityOAuth2Provider> springSecurityOAuth2Provider, SpringDocConfigProperties springDocConfigProperties,
+				   List<OpenApiBuilderCustomiser> openApiBuilderCustomisers) {
 		if (openAPI.isPresent()) {
 			this.openAPI = openAPI.get();
 			if (this.openAPI.getComponents() == null)
@@ -106,6 +113,7 @@ public class OpenAPIBuilder {
 		this.securityParser = securityParser;
 		this.springSecurityOAuth2Provider = springSecurityOAuth2Provider;
 		this.springDocConfigProperties = springDocConfigProperties;
+		this.openApiBuilderCustomisers = openApiBuilderCustomisers;
 	}
 
 	private static String splitCamelCase(String str) {
@@ -146,12 +154,18 @@ public class OpenAPIBuilder {
 			Info infos = new Info().title(DEFAULT_TITLE).version(DEFAULT_VERSION);
 			calculatedOpenAPI.setInfo(infos);
 		}
+		// Set default mappings
+		this.mappingsMap.putAll(context.getBeansWithAnnotation(RestController.class));
+		this.mappingsMap.putAll(context.getBeansWithAnnotation(RequestMapping.class));
+		this.mappingsMap.putAll(context.getBeansWithAnnotation(Controller.class));
+
 		// default server value
 		if (CollectionUtils.isEmpty(calculatedOpenAPI.getServers()) || !isServersPresent) {
 			this.updateServers(calculatedOpenAPI);
 		}
 		// add security schemes
 		this.calculateSecuritySchemes(calculatedOpenAPI.getComponents());
+		Optional.ofNullable(this.openApiBuilderCustomisers).ifPresent(customisers -> customisers.forEach(customiser -> customiser.customise(this)));
 	}
 
 	public void updateServers(OpenAPI openAPI) {
@@ -397,16 +411,12 @@ public class OpenAPIBuilder {
 		handlerMethods.forEach(handlerMethod -> springdocTags.put(handlerMethod, tag));
 	}
 
-	public Map<String, Object> getRestControllersMap() {
-		return context.getBeansWithAnnotation(RestController.class);
-	}
-
-	public Map<String, Object> getRequestMappingMap() {
-		return context.getBeansWithAnnotation(RequestMapping.class);
+	public Map<String, Object> getMappingsMap() {
+		return this.mappingsMap;
 	}
 
-	public Map<String, Object> getControllersMap() {
-		return context.getBeansWithAnnotation(Controller.class);
+	public void addMappings(Map<String, Object> mappings) {
+		this.mappingsMap.putAll(mappings);
 	}
 
 	public Map<String, Object> getControllerAdviceMap() {
diff --git a/springdoc-openapi-common/src/main/java/org/springdoc/core/SpringDocConfiguration.java b/springdoc-openapi-common/src/main/java/org/springdoc/core/SpringDocConfiguration.java
index 22ff8ea03..ce9563ae4 100644
--- a/springdoc-openapi-common/src/main/java/org/springdoc/core/SpringDocConfiguration.java
+++ b/springdoc-openapi-common/src/main/java/org/springdoc/core/SpringDocConfiguration.java
@@ -30,6 +30,7 @@ import org.springdoc.core.converters.AdditionalModelsConverter;
 import org.springdoc.core.converters.ModelConverterRegistrar;
 import org.springdoc.core.converters.PropertyCustomizingConverter;
 import org.springdoc.core.converters.ResponseSupportConverter;
+import org.springdoc.core.customizers.OpenApiBuilderCustomiser;
 import org.springdoc.core.customizers.PropertyCustomizer;
 
 import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
@@ -82,8 +83,10 @@ public class SpringDocConfiguration {
 	}
 
 	@Bean
-	public OpenAPIBuilder openAPIBuilder(Optional<OpenAPI> openAPI, ApplicationContext context, SecurityParser securityParser, Optional<SecurityOAuth2Provider> springSecurityOAuth2Provider,SpringDocConfigProperties springDocConfigProperties) {
-		return new OpenAPIBuilder(openAPI, context, securityParser, springSecurityOAuth2Provider,springDocConfigProperties);
+	public OpenAPIBuilder openAPIBuilder(Optional<OpenAPI> openAPI, ApplicationContext context, SecurityParser securityParser,
+										 Optional<SecurityOAuth2Provider> springSecurityOAuth2Provider, SpringDocConfigProperties springDocConfigProperties,
+										 List<OpenApiBuilderCustomiser> openApiBuilderCustomisers) {
+		return new OpenAPIBuilder(openAPI, context, securityParser, springSecurityOAuth2Provider,springDocConfigProperties, openApiBuilderCustomisers);
 	}
 
 	@Bean
diff --git a/springdoc-openapi-common/src/main/java/org/springdoc/core/customizers/OpenApiBuilderCustomiser.java b/springdoc-openapi-common/src/main/java/org/springdoc/core/customizers/OpenApiBuilderCustomiser.java
new file mode 100644
index 000000000..c8a664348
--- /dev/null
+++ b/springdoc-openapi-common/src/main/java/org/springdoc/core/customizers/OpenApiBuilderCustomiser.java
@@ -0,0 +1,25 @@
+/*
+ *
+ *  * Copyright 2019-2020 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 org.springdoc.core.customizers;
+
+import org.springdoc.core.OpenAPIBuilder;
+
+public interface OpenApiBuilderCustomiser {
+    void customise(OpenAPIBuilder openApiBuilder);
+}


Test patch:
diff --git a/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/AbstractSpringDocTest.java b/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/AbstractSpringDocTest.java
index 23b3d3441..eaba438b2 100644
--- a/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/AbstractSpringDocTest.java
+++ b/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/AbstractSpringDocTest.java
@@ -47,32 +47,31 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
 @AutoConfigureMockMvc
 public abstract class AbstractSpringDocTest {
 
-	protected static final Logger LOGGER = LoggerFactory.getLogger(AbstractSpringDocTest.class);
+    protected static final Logger LOGGER = LoggerFactory.getLogger(AbstractSpringDocTest.class);
 
-	public static String className;
+    public static String className;
 
-	@Autowired
-	protected MockMvc mockMvc;
+    @Autowired
+    protected MockMvc mockMvc;
 
-	public static String getContent(String fileName) throws Exception {
-		try {
-			Path path = Paths.get(FileUtils.class.getClassLoader().getResource(fileName).toURI());
-			byte[] fileBytes = Files.readAllBytes(path);
-			return new String(fileBytes, StandardCharsets.UTF_8);
-		}
-		catch (Exception e) {
-			throw new RuntimeException("Failed to read file: " + fileName, e);
-		}
-	}
+    public static String getContent(String fileName) throws Exception {
+        try {
+            Path path = Paths.get(FileUtils.class.getClassLoader().getResource(fileName).toURI());
+            byte[] fileBytes = Files.readAllBytes(path);
+            return new String(fileBytes, StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to read file: " + fileName, e);
+        }
+    }
 
-	@Test
-	public void testApp() throws Exception {
-		className = getClass().getSimpleName();
-		String testNumber = className.replaceAll("[^0-9]", "");
-		MvcResult mockMvcResult = mockMvc.perform(get(Constants.DEFAULT_API_DOCS_URL)).andExpect(status().isOk())
-				.andExpect(jsonPath("$.openapi", is("3.0.1"))).andReturn();
-		String result = mockMvcResult.getResponse().getContentAsString();
-		String expected = getContent("results/app" + testNumber + ".json");
-		assertEquals(expected, result, true);
-	}
+    @Test
+    public void testApp() throws Exception {
+        className = getClass().getSimpleName();
+        String testNumber = className.replaceAll("[^0-9]", "");
+        MvcResult mockMvcResult = mockMvc.perform(get(Constants.DEFAULT_API_DOCS_URL)).andExpect(status().isOk()).andExpect(jsonPath("$.openapi", is("3.0.1")))
+                                         .andReturn();
+        String result = mockMvcResult.getResponse().getContentAsString();
+        String expected = getContent("results/app" + testNumber + ".json");
+        assertEquals(expected, result, true);
+    }
 }
diff --git a/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app92/SpringDocApp92Test.java b/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app92/SpringDocApp92Test.java
new file mode 100644
index 000000000..a944f41cd
--- /dev/null
+++ b/springdoc-openapi-webmvc-core/src/test/java/test/org/springdoc/api/app92/SpringDocApp92Test.java
@@ -0,0 +1,123 @@
+/*
+ *
+ *  * Copyright 2019-2020 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.api.app92;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.springdoc.api.ActuatorProvider;
+import org.springdoc.api.OpenApiResource;
+import org.springdoc.core.AbstractRequestBuilder;
+import org.springdoc.core.GenericResponseBuilder;
+import org.springdoc.core.OpenAPIBuilder;
+import org.springdoc.core.OperationBuilder;
+import org.springdoc.core.SpringDocConfigProperties;
+import org.springdoc.core.customizers.OpenApiBuilderCustomiser;
+import org.springdoc.core.customizers.OpenApiCustomiser;
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationContextAware;
+import org.springframework.context.annotation.Bean;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
+import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
+import test.org.springdoc.api.AbstractSpringDocTest;
+import test.org.springdoc.api.app91.Greeting;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import static org.springdoc.core.Constants.DEFAULT_GROUP_NAME;
+import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
+
+@TestPropertySource(properties = "springdoc.default-produces-media-type=application/json")
+public class SpringDocApp92Test extends AbstractSpringDocTest {
+
+    @SpringBootApplication
+    static class SpringDocTestApp implements ApplicationContextAware {
+
+        private ApplicationContext applicationContext;
+
+        @Bean
+        public GreetingController greetingController() {
+            return new GreetingController();
+        }
+
+        @Bean
+        public OpenApiBuilderCustomiser customOpenAPI() {
+            return openApiBuilder -> openApiBuilder.addMappings(Collections.singletonMap("greetingController", new GreetingController()));
+        }
+
+        @Bean
+        public RequestMappingHandlerMapping defaultTestHandlerMapping(GreetingController greetingController) throws NoSuchMethodException {
+            RequestMappingHandlerMapping result = new RequestMappingHandlerMapping();
+            RequestMappingInfo requestMappingInfo =
+                            RequestMappingInfo.paths("/test").methods(RequestMethod.GET).produces(MediaType.APPLICATION_JSON_VALUE).build();
+
+            result.setApplicationContext(this.applicationContext);
+            result.registerMapping(requestMappingInfo, "greetingController", GreetingController.class.getDeclaredMethod("sayHello2"));
+            //result.handlerme
+            return result;
+        }
+
+        @Bean(name = "mvcOpenApiResource")
+        public OpenApiResource openApiResource(OpenAPIBuilder openAPIBuilder, AbstractRequestBuilder requestBuilder, GenericResponseBuilder responseBuilder,
+                                               OperationBuilder operationParser,
+                                               @Qualifier("defaultTestHandlerMapping") RequestMappingHandlerMapping requestMappingHandlerMapping,
+                                               Optional<ActuatorProvider> servletContextProvider, SpringDocConfigProperties springDocConfigProperties,
+                                               Optional<List<OpenApiCustomiser>> openApiCustomisers) {
+            return new OpenApiResource(DEFAULT_GROUP_NAME, openAPIBuilder, requestBuilder, responseBuilder, operationParser, requestMappingHandlerMapping,
+                                       servletContextProvider, openApiCustomisers, springDocConfigProperties);
+        }
+
+        @Override
+        public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
+            this.applicationContext = applicationContext;
+        }
+    }
+
+    @ResponseBody
+    @Tag(name = "Demo", description = "The Demo API")
+    public static class GreetingController {
+
+        @GetMapping(produces = APPLICATION_JSON_VALUE)
+        @Operation(summary = "This API will return a random greeting.")
+        public ResponseEntity<Greeting> sayHello() {
+            return ResponseEntity.ok(new Greeting(RandomStringUtils.randomAlphanumeric(10)));
+        }
+
+        @GetMapping("/test")
+        @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "item created"),
+                        @ApiResponse(responseCode = "400", description = "invalid input, object invalid"),
+                        @ApiResponse(responseCode = "409", description = "an existing item already exists") })
+        public ResponseEntity<Greeting> sayHello2() {
+            return ResponseEntity.ok(new Greeting(RandomStringUtils.randomAlphanumeric(10)));
+        }
+
+    }
+}
diff --git a/springdoc-openapi-webmvc-core/src/test/resources/results/app92.json b/springdoc-openapi-webmvc-core/src/test/resources/results/app92.json
new file mode 100644
index 000000000..140e54673
--- /dev/null
+++ b/springdoc-openapi-webmvc-core/src/test/resources/results/app92.json
@@ -0,0 +1,77 @@
+{
+  "openapi": "3.0.1",
+  "info": {
+    "title": "OpenAPI definition",
+    "version": "v0"
+  },
+  "servers": [
+    {
+      "url": "http://localhost",
+      "description": "Generated server url"
+    }
+  ],
+  "tags": [
+    {
+      "name": "Demo",
+      "description": "The Demo API"
+    }
+  ],
+  "paths": {
+    "/test": {
+      "get": {
+        "tags": [
+          "Demo"
+        ],
+        "operationId": "sayHello2",
+        "responses": {
+          "400": {
+            "description": "invalid input, object invalid",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Greeting"
+                }
+              }
+            }
+          },
+          "201": {
+            "description": "item created",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Greeting"
+                }
+              }
+            }
+          },
+          "409": {
+            "description": "an existing item already exists",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Greeting"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "Greeting": {
+        "title": "Greeting",
+        "type": "object",
+        "properties": {
+          "payload": {
+            "type": "string",
+            "description": "The greeting value",
+            "example": "sdfsdfs"
+          }
+        },
+        "description": "An object containing a greeting message"
+      }
+    }
+  }
+}