diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java index d1577e18b..eb8658110 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java @@ -140,6 +140,7 @@ * @author kevinraddatz * @author hyeonisism * @author doljae + * @author zdary */ public abstract class AbstractOpenApiResource extends SpecFilter { @@ -523,8 +524,12 @@ private void trimIndentOperation(Operation operation) { * @param locale the locale */ protected void calculateWebhooks(OpenAPI calculatedOpenAPI, Locale locale) { - Webhooks[] webhooksAttr = openAPIService.getWebhooks(); - if (ArrayUtils.isEmpty(webhooksAttr)) + Class[] classes = openAPIService.getWebhooksClasses(); + Class[] refinedClasses = Arrays.stream(classes) + .filter(clazz -> isPackageToScan(clazz.getPackage())) + .toArray(Class[]::new); + Webhooks[] webhooksAttr = openAPIService.getWebhooks(refinedClasses); + if (ArrayUtils.isEmpty(webhooksAttr)) return; var webhooks = Arrays.stream(webhooksAttr).map(Webhooks::value).flatMap(Arrays::stream).toArray(Webhook[]::new); Arrays.stream(webhooks).forEach(webhook -> { diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/OpenAPIService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/OpenAPIService.java index 96b2f7244..00f50b164 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/OpenAPIService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/OpenAPIService.java @@ -104,6 +104,7 @@ * The type Open api builder. * * @author bnasslahsen + * @author zdary */ public class OpenAPIService implements ApplicationContextAware { @@ -538,63 +539,72 @@ private Optional getOpenAPIDefinition() { } - /** - * Get webhooks webhooks [ ]. - * - * @return the webhooks [ ] - */ - public Webhooks[] getWebhooks() { - List allWebhooks = new ArrayList<>(); - - // First: scan Spring-managed beans - Map beans = context.getBeansWithAnnotation(Webhooks.class); - - for (Object bean : beans.values()) { - Class beanClass = bean.getClass(); - - // Collect @Webhooks or @Webhook on class level - collectWebhooksFromElement(beanClass, allWebhooks); + /** + * Gets webhooks from given classes. + * + * @param classes Array of classes to scan for webhooks. + * @return An array of {@link Webhooks} annotations found in the given classes. + */ + public Webhooks[] getWebhooks(Class[] classes) { + List allWebhooks = new ArrayList<>(); + + for (Class clazz : classes) { + // Class-level annotations + collectWebhooksFromElement(clazz, allWebhooks); + + // Method-level annotations + for (Method method : clazz.getDeclaredMethods()) { + collectWebhooksFromElement(method, allWebhooks); + } + } + + return allWebhooks.toArray(new Webhooks[0]); + } + + + /** + * Retrieves all classes related to webhooks. + * This method scans for classes annotated with {@link Webhooks} or {@link Webhook}, + * first checking Spring-managed beans and then falling back to classpath scanning + * if no annotated beans are found. + * + * @return An array of classes related to webhooks. + */ + public Class[] getWebhooksClasses() { + Set> allWebhookClassesToScan = new HashSet<>(); + + // First: scan Spring-managed beans + Map beans = context.getBeansWithAnnotation(Webhooks.class); + + for (Object bean : beans.values()) { + Class beanClass = bean.getClass(); + allWebhookClassesToScan.add(beanClass); + } + + // Fallback: classpath scanning + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(Webhooks.class)); + scanner.addIncludeFilter(new AnnotationTypeFilter(Webhook.class)); + + if (AutoConfigurationPackages.has(context)) { + for (String basePackage : AutoConfigurationPackages.get(context)) { + Set candidates = scanner.findCandidateComponents(basePackage); + for (BeanDefinition bd : candidates) { + try { + Class clazz = Class.forName(bd.getBeanClassName()); + allWebhookClassesToScan.add(clazz); + } + catch (ClassNotFoundException e) { + LOGGER.error("Class not found in classpath: {}", e.getMessage()); + } + } + } + } + + return allWebhookClassesToScan.toArray(new Class[0]); + } - // Collect from methods - for (Method method : beanClass.getDeclaredMethods()) { - collectWebhooksFromElement(method, allWebhooks); - } - } - - // Fallback: classpath scanning if nothing found - if (allWebhooks.isEmpty()) { - ClassPathScanningCandidateComponentProvider scanner = - new ClassPathScanningCandidateComponentProvider(false); - scanner.addIncludeFilter(new AnnotationTypeFilter(Webhooks.class)); - scanner.addIncludeFilter(new AnnotationTypeFilter(Webhook.class)); - - if (AutoConfigurationPackages.has(context)) { - for (String basePackage : AutoConfigurationPackages.get(context)) { - Set candidates = scanner.findCandidateComponents(basePackage); - - for (BeanDefinition bd : candidates) { - try { - Class clazz = Class.forName(bd.getBeanClassName()); - - // Class-level annotations - collectWebhooksFromElement(clazz, allWebhooks); - - // Method-level annotations - for (Method method : clazz.getDeclaredMethods()) { - collectWebhooksFromElement(method, allWebhooks); - } - - } - catch (ClassNotFoundException e) { - LOGGER.error("Class not found in classpath: {}", e.getMessage()); - } - } - } - } - } - - return allWebhooks.toArray(new Webhooks[0]); - } /** * Collect webhooks from element. diff --git a/springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java b/springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java index 76499ec70..a82543624 100644 --- a/springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java +++ b/springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java @@ -125,7 +125,8 @@ public void setUp() { when(openAPIService.build(any())).thenReturn(openAPI); when(openAPIService.getContext()).thenReturn(context); - doAnswer(new CallsRealMethods()).when(openAPIService).setServersPresent(false); + when(openAPIService.getWebhooksClasses()).thenReturn(new Class[0]); + doAnswer(new CallsRealMethods()).when(openAPIService).setServersPresent(false); when(openAPIBuilderObjectFactory.getObject()).thenReturn(openAPIService); when(springDocProviders.jsonMapper()).thenReturn(Json.mapper()); @@ -295,4 +296,4 @@ private static class EmptyPathsOpenApiResource extends AbstractOpenApiResource { public void getPaths(Map findRestControllers, Locale locale, OpenAPI openAPI) { } } -} \ No newline at end of file +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/AbstractSpringDocV31Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/AbstractSpringDocV31Test.java new file mode 100644 index 000000000..33595ce7c --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/AbstractSpringDocV31Test.java @@ -0,0 +1,62 @@ +/* + * + * * + * * * + * * * * + * * * * * Copyright 2019-2024 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.v31; + +import org.junit.jupiter.api.Test; +import org.springdoc.core.utils.Constants; +import test.org.springdoc.api.AbstractCommonTest; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MvcResult; + +import static org.hamcrest.Matchers.is; +import static org.skyscreamer.jsonassert.JSONAssert.assertEquals; +import static org.springdoc.core.utils.Constants.SPRINGDOC_CACHE_DISABLED; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * A common base for OpenAPI 3.1 tests which provides the necessary foundation for OpenAPI 3.1 tests, + * making the test setup cleaner and more consistent. + * + * @author zdary + */ +@SpringBootTest +@TestPropertySource(properties = { SPRINGDOC_CACHE_DISABLED + "=true", "springdoc.api-docs.version=OPENAPI_3_1" }) +public abstract class AbstractSpringDocV31Test extends AbstractCommonTest { + + @Test + protected void testApp() throws Exception { + String 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.1.0"))).andReturn(); + String result = mockMvcResult.getResponse().getContentAsString(); + String expected = getContent("results/3.1.0/app" + testNumber + ".json"); + assertEquals(expected, result, true); + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app246/IncludedWebHookResource.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app246/IncludedWebHookResource.java new file mode 100644 index 000000000..c5df8a3ca --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app246/IncludedWebHookResource.java @@ -0,0 +1,84 @@ +package test.org.springdoc.api.v31.app246; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Webhook; +import io.swagger.v3.oas.annotations.Webhooks; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; + +import org.springframework.stereotype.Component; + +@Webhooks({ + @Webhook( + name = "includedPet", + operation = @Operation( + operationId = "includedPet", + requestBody = @RequestBody( + description = "Information about a new pet in the system", + content = { + @Content( + mediaType = "application/json", + schema = @Schema( + description = "Webhook Pet", + implementation = IncludedWebHookResource.RequestDto.class + ) + ) + } + ), + method = "post", + responses = @ApiResponse( + responseCode = "200", + description = "Return a 200 status to indicate that the data was received successfully" + ) + ) + ) +}) +@Component +public class IncludedWebHookResource { + + @Webhook( + name = "includedNewPet", + operation = @Operation( + operationId = "includedNewPet", + requestBody = @RequestBody( + description = "Information about a new pet in the system", + content = { + @Content( + mediaType = "application/json", + schema = @Schema( + description = "Webhook Pet", + implementation = RequestDto.class + ) + ) + } + ), + method = "post", + responses = @ApiResponse( + responseCode = "200", + description = "Return a 200 status to indicate that the data was received successfully" + ) + ) + ) + public void includedNewPet(RequestDto requestDto) { + // This method is intentionally left empty. + // The actual processing of the webhook data would be implemented here. + System.out.println("Received new pet with personal number: " + requestDto.getPersonalNumber()); + } + + public static class RequestDto { + + private String personalNumber; + + public String getPersonalNumber() { + return personalNumber; + } + + public void setPersonalNumber(String personalNumber) { + this.personalNumber = personalNumber; + } + } +} + + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app246/SpringDocApp246Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app246/SpringDocApp246Test.java new file mode 100644 index 000000000..239353d2a --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app246/SpringDocApp246Test.java @@ -0,0 +1,42 @@ +package test.org.springdoc.api.v31.app246; + +import org.junit.jupiter.api.Test; +import org.springdoc.core.utils.Constants; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import test.org.springdoc.api.v31.AbstractSpringDocV31Test; + +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +/** + * This test class verifies the webhook filtering functionality based on package scanning. + * It ensures that only webhooks from the packages defined in {@code springdoc.packages-to-scan} + * are included in the OpenAPI specification, and webhooks from packages in + * {@code springdoc.packages-to-exclude} are correctly omitted. + */ +@SpringBootTest(classes = SpringDocApp246Test.SpringDocApp246.class) +@TestPropertySource(properties = { + "springdoc.packages-to-scan=test.org.springdoc.api.v31.app246", + "springdoc.packages-to-exclude=test.org.springdoc.api.v31.app246.excluded", + "springdoc.api-docs.version=OPENAPI_3_1" +}) +public class SpringDocApp246Test extends AbstractSpringDocV31Test { + + @SpringBootApplication + static class SpringDocApp246 { + } + + @Test + public void testApp2() throws Exception { + mockMvc.perform(get(Constants.DEFAULT_API_DOCS_URL)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.webhooks.includedPet.post.requestBody.description", is("Information about a new pet in the system"))) + .andExpect(jsonPath("$.webhooks.includedNewPet.post.requestBody.description", is("Information about a new pet in the system"))) + .andExpect(jsonPath("$.webhooks.excludedNewPet").doesNotExist()); + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app246/excluded/ExcludedWebHookResource.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app246/excluded/ExcludedWebHookResource.java new file mode 100644 index 000000000..b1b06d9b0 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app246/excluded/ExcludedWebHookResource.java @@ -0,0 +1,25 @@ +package test.org.springdoc.api.v31.app246.excluded; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Webhook; + +import io.swagger.v3.oas.annotations.Webhooks; +import org.springframework.stereotype.Component; +import test.org.springdoc.api.v31.app246.IncludedWebHookResource; + +@Component +@Webhooks({ + @Webhook( + name = "excludedNewPet", + operation = @Operation( + operationId = "excludedNewPet", + method = "post", + summary = "This webhook should be ignored" + ) + ) +}) +public class ExcludedWebHookResource { + public void excludedNewPet(IncludedWebHookResource.RequestDto requestDto) { + // This method is intentionally left empty. + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app246.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app246.json new file mode 100644 index 000000000..fbe41dd73 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app246.json @@ -0,0 +1,70 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": {}, + "webhooks": { + "includedPet": { + "post": { + "operationId": "includedPet", + "requestBody": { + "description": "Information about a new pet in the system", + "content": { + "application/json": { + "schema": { + "description": "Webhook Pet", + "$ref": "#/components/schemas/RequestDto" + } + } + } + }, + "responses": { + "200": { + "description": "Return a 200 status to indicate that the data was received successfully" + } + } + } + }, + "includedNewPet": { + "post": { + "operationId": "includedNewPet", + "requestBody": { + "description": "Information about a new pet in the system", + "content": { + "application/json": { + "schema": { + "description": "Webhook Pet", + "$ref": "#/components/schemas/RequestDto" + } + } + } + }, + "responses": { + "200": { + "description": "Return a 200 status to indicate that the data was received successfully" + } + } + } + } + }, + "components": { + "schemas": { + "RequestDto": { + "type": "object", + "properties": { + "personalNumber": { + "type": "string" + } + } + } + } + } +}