Skip to content

Commit 18568ae

Browse files
committed
feat(webhooks): Implement package-based filtering for webhooks
This commit introduces support for filtering webhooks based on the `springdoc.packages-to-scan` and `springdoc.packages-to-exclude` properties. This provides users with more granular control over which webhooks are included in the generated OpenAPI specification, aligning their behavior with that of controllers. To verify and illustrate this new functionality, a new application test (`SpringDocApp246Test`) has been added. This test: •Defines webhook components in two separate packages. •Configures one package to be included and the other to be excluded. •Asserts that the final OpenAPI document correctly contains only the webhooks from the scanned package. Additionally, a new abstract base class, `AbstractSpringDocV31Test`, has been created to streamline the setup for OpenAPI 3.1 integration tests. Fixes #3056
1 parent a297959 commit 18568ae

File tree

8 files changed

+359
-60
lines changed

8 files changed

+359
-60
lines changed

springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
* @author kevinraddatz
141141
* @author hyeonisism
142142
* @author doljae
143+
* @author zdary
143144
*/
144145
public abstract class AbstractOpenApiResource extends SpecFilter {
145146

@@ -523,8 +524,12 @@ private void trimIndentOperation(Operation operation) {
523524
* @param locale the locale
524525
*/
525526
protected void calculateWebhooks(OpenAPI calculatedOpenAPI, Locale locale) {
526-
Webhooks[] webhooksAttr = openAPIService.getWebhooks();
527-
if (ArrayUtils.isEmpty(webhooksAttr))
527+
Class<?>[] classes = openAPIService.getWebhooksClasses();
528+
Class<?>[] refinedClasses = Arrays.stream(classes)
529+
.filter(clazz -> isPackageToScan(clazz.getPackage()))
530+
.toArray(Class<?>[]::new);
531+
Webhooks[] webhooksAttr = openAPIService.getWebhooks(refinedClasses);
532+
if (ArrayUtils.isEmpty(webhooksAttr))
528533
return;
529534
var webhooks = Arrays.stream(webhooksAttr).map(Webhooks::value).flatMap(Arrays::stream).toArray(Webhook[]::new);
530535
Arrays.stream(webhooks).forEach(webhook -> {

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/OpenAPIService.java

Lines changed: 66 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
* The type Open api builder.
105105
*
106106
* @author bnasslahsen
107+
* @author zdary
107108
*/
108109
public class OpenAPIService implements ApplicationContextAware {
109110

@@ -538,63 +539,72 @@ private Optional<OpenAPIDefinition> getOpenAPIDefinition() {
538539
}
539540

540541

541-
/**
542-
* Get webhooks webhooks [ ].
543-
*
544-
* @return the webhooks [ ]
545-
*/
546-
public Webhooks[] getWebhooks() {
547-
List<Webhooks> allWebhooks = new ArrayList<>();
548-
549-
// First: scan Spring-managed beans
550-
Map<String, Object> beans = context.getBeansWithAnnotation(Webhooks.class);
551-
552-
for (Object bean : beans.values()) {
553-
Class<?> beanClass = bean.getClass();
554-
555-
// Collect @Webhooks or @Webhook on class level
556-
collectWebhooksFromElement(beanClass, allWebhooks);
542+
/**
543+
* Gets webhooks from given classes.
544+
*
545+
* @param classes Array of classes to scan for webhooks.
546+
* @return An array of {@link Webhooks} annotations found in the given classes.
547+
*/
548+
public Webhooks[] getWebhooks(Class<?>[] classes) {
549+
List<Webhooks> allWebhooks = new ArrayList<>();
550+
551+
for (Class<?> clazz : classes) {
552+
// Class-level annotations
553+
collectWebhooksFromElement(clazz, allWebhooks);
554+
555+
// Method-level annotations
556+
for (Method method : clazz.getDeclaredMethods()) {
557+
collectWebhooksFromElement(method, allWebhooks);
558+
}
559+
}
560+
561+
return allWebhooks.toArray(new Webhooks[0]);
562+
}
563+
564+
565+
/**
566+
* Retrieves all classes related to webhooks.
567+
* This method scans for classes annotated with {@link Webhooks} or {@link Webhook},
568+
* first checking Spring-managed beans and then falling back to classpath scanning
569+
* if no annotated beans are found.
570+
*
571+
* @return An array of classes related to webhooks.
572+
*/
573+
public Class<?>[] getWebhooksClasses() {
574+
Set<Class<?>> allWebhookClassesToScan = new HashSet<>();
575+
576+
// First: scan Spring-managed beans
577+
Map<String, Object> beans = context.getBeansWithAnnotation(Webhooks.class);
578+
579+
for (Object bean : beans.values()) {
580+
Class<?> beanClass = bean.getClass();
581+
allWebhookClassesToScan.add(beanClass);
582+
}
583+
584+
// Fallback: classpath scanning
585+
ClassPathScanningCandidateComponentProvider scanner =
586+
new ClassPathScanningCandidateComponentProvider(false);
587+
scanner.addIncludeFilter(new AnnotationTypeFilter(Webhooks.class));
588+
scanner.addIncludeFilter(new AnnotationTypeFilter(Webhook.class));
589+
590+
if (AutoConfigurationPackages.has(context)) {
591+
for (String basePackage : AutoConfigurationPackages.get(context)) {
592+
Set<BeanDefinition> candidates = scanner.findCandidateComponents(basePackage);
593+
for (BeanDefinition bd : candidates) {
594+
try {
595+
Class<?> clazz = Class.forName(bd.getBeanClassName());
596+
allWebhookClassesToScan.add(clazz);
597+
}
598+
catch (ClassNotFoundException e) {
599+
LOGGER.error("Class not found in classpath: {}", e.getMessage());
600+
}
601+
}
602+
}
603+
}
604+
605+
return allWebhookClassesToScan.toArray(new Class<?>[0]);
606+
}
557607

558-
// Collect from methods
559-
for (Method method : beanClass.getDeclaredMethods()) {
560-
collectWebhooksFromElement(method, allWebhooks);
561-
}
562-
}
563-
564-
// Fallback: classpath scanning if nothing found
565-
if (allWebhooks.isEmpty()) {
566-
ClassPathScanningCandidateComponentProvider scanner =
567-
new ClassPathScanningCandidateComponentProvider(false);
568-
scanner.addIncludeFilter(new AnnotationTypeFilter(Webhooks.class));
569-
scanner.addIncludeFilter(new AnnotationTypeFilter(Webhook.class));
570-
571-
if (AutoConfigurationPackages.has(context)) {
572-
for (String basePackage : AutoConfigurationPackages.get(context)) {
573-
Set<BeanDefinition> candidates = scanner.findCandidateComponents(basePackage);
574-
575-
for (BeanDefinition bd : candidates) {
576-
try {
577-
Class<?> clazz = Class.forName(bd.getBeanClassName());
578-
579-
// Class-level annotations
580-
collectWebhooksFromElement(clazz, allWebhooks);
581-
582-
// Method-level annotations
583-
for (Method method : clazz.getDeclaredMethods()) {
584-
collectWebhooksFromElement(method, allWebhooks);
585-
}
586-
587-
}
588-
catch (ClassNotFoundException e) {
589-
LOGGER.error("Class not found in classpath: {}", e.getMessage());
590-
}
591-
}
592-
}
593-
}
594-
}
595-
596-
return allWebhooks.toArray(new Webhooks[0]);
597-
}
598608

599609
/**
600610
* Collect webhooks from element.

springdoc-openapi-starter-common/src/test/java/org/springdoc/api/AbstractOpenApiResourceTest.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ public void setUp() {
125125

126126
when(openAPIService.build(any())).thenReturn(openAPI);
127127
when(openAPIService.getContext()).thenReturn(context);
128-
doAnswer(new CallsRealMethods()).when(openAPIService).setServersPresent(false);
128+
when(openAPIService.getWebhooksClasses()).thenReturn(new Class<?>[0]);
129+
doAnswer(new CallsRealMethods()).when(openAPIService).setServersPresent(false);
129130

130131
when(openAPIBuilderObjectFactory.getObject()).thenReturn(openAPIService);
131132
when(springDocProviders.jsonMapper()).thenReturn(Json.mapper());
@@ -295,4 +296,4 @@ private static class EmptyPathsOpenApiResource extends AbstractOpenApiResource {
295296
public void getPaths(Map<String, Object> findRestControllers, Locale locale, OpenAPI openAPI) {
296297
}
297298
}
298-
}
299+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * * Copyright 2019-2024 the original author or authors.
7+
* * * * *
8+
* * * * * Licensed under the Apache License, Version 2.0 (the "License");
9+
* * * * * you may not use this file except in compliance with the License.
10+
* * * * * You may obtain a copy of the License at
11+
* * * * *
12+
* * * * * https://www.apache.org/licenses/LICENSE-2.0
13+
* * * * *
14+
* * * * * Unless required by applicable law or agreed to in writing, software
15+
* * * * * distributed under the License is distributed on an "AS IS" BASIS,
16+
* * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
* * * * * See the License for the specific language governing permissions and
18+
* * * * * limitations under the License.
19+
* * * *
20+
* * *
21+
* *
22+
*
23+
*/
24+
25+
package test.org.springdoc.api.v31;
26+
27+
import org.junit.jupiter.api.Test;
28+
import org.springdoc.core.utils.Constants;
29+
import test.org.springdoc.api.AbstractCommonTest;
30+
31+
import org.springframework.boot.test.context.SpringBootTest;
32+
import org.springframework.test.context.TestPropertySource;
33+
import org.springframework.test.web.servlet.MvcResult;
34+
35+
import static org.hamcrest.Matchers.is;
36+
import static org.skyscreamer.jsonassert.JSONAssert.assertEquals;
37+
import static org.springdoc.core.utils.Constants.SPRINGDOC_CACHE_DISABLED;
38+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
39+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
40+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
41+
42+
/**
43+
* A common base for OpenAPI 3.1 tests which provides the necessary foundation for OpenAPI 3.1 tests,
44+
* making the test setup cleaner and more consistent.
45+
*
46+
* @author zdary
47+
*/
48+
@SpringBootTest
49+
@TestPropertySource(properties = { SPRINGDOC_CACHE_DISABLED + "=true", "springdoc.api-docs.version=OPENAPI_3_1" })
50+
public abstract class AbstractSpringDocV31Test extends AbstractCommonTest {
51+
52+
@Test
53+
protected void testApp() throws Exception {
54+
String className = getClass().getSimpleName();
55+
String testNumber = className.replaceAll("[^0-9]", "");
56+
MvcResult mockMvcResult = mockMvc.perform(get(Constants.DEFAULT_API_DOCS_URL)).andExpect(status().isOk())
57+
.andExpect(jsonPath("$.openapi", is("3.1.0"))).andReturn();
58+
String result = mockMvcResult.getResponse().getContentAsString();
59+
String expected = getContent("results/3.1.0/app" + testNumber + ".json");
60+
assertEquals(expected, result, true);
61+
}
62+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package test.org.springdoc.api.v31.app246;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.Webhook;
5+
import io.swagger.v3.oas.annotations.Webhooks;
6+
import io.swagger.v3.oas.annotations.media.Content;
7+
import io.swagger.v3.oas.annotations.media.Schema;
8+
import io.swagger.v3.oas.annotations.parameters.RequestBody;
9+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
10+
11+
import org.springframework.stereotype.Component;
12+
13+
@Webhooks({
14+
@Webhook(
15+
name = "includedPet",
16+
operation = @Operation(
17+
operationId = "includedPet",
18+
requestBody = @RequestBody(
19+
description = "Information about a new pet in the system",
20+
content = {
21+
@Content(
22+
mediaType = "application/json",
23+
schema = @Schema(
24+
description = "Webhook Pet",
25+
implementation = IncludedWebHookResource.RequestDto.class
26+
)
27+
)
28+
}
29+
),
30+
method = "post",
31+
responses = @ApiResponse(
32+
responseCode = "200",
33+
description = "Return a 200 status to indicate that the data was received successfully"
34+
)
35+
)
36+
)
37+
})
38+
@Component
39+
public class IncludedWebHookResource {
40+
41+
@Webhook(
42+
name = "includedNewPet",
43+
operation = @Operation(
44+
operationId = "includedNewPet",
45+
requestBody = @RequestBody(
46+
description = "Information about a new pet in the system",
47+
content = {
48+
@Content(
49+
mediaType = "application/json",
50+
schema = @Schema(
51+
description = "Webhook Pet",
52+
implementation = RequestDto.class
53+
)
54+
)
55+
}
56+
),
57+
method = "post",
58+
responses = @ApiResponse(
59+
responseCode = "200",
60+
description = "Return a 200 status to indicate that the data was received successfully"
61+
)
62+
)
63+
)
64+
public void includedNewPet(RequestDto requestDto) {
65+
// This method is intentionally left empty.
66+
// The actual processing of the webhook data would be implemented here.
67+
System.out.println("Received new pet with personal number: " + requestDto.getPersonalNumber());
68+
}
69+
70+
public static class RequestDto {
71+
72+
private String personalNumber;
73+
74+
public String getPersonalNumber() {
75+
return personalNumber;
76+
}
77+
78+
public void setPersonalNumber(String personalNumber) {
79+
this.personalNumber = personalNumber;
80+
}
81+
}
82+
}
83+
84+
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package test.org.springdoc.api.v31.app246;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.springdoc.core.utils.Constants;
5+
import org.springframework.boot.autoconfigure.SpringBootApplication;
6+
import org.springframework.boot.test.context.SpringBootTest;
7+
import org.springframework.test.context.TestPropertySource;
8+
import test.org.springdoc.api.v31.AbstractSpringDocV31Test;
9+
10+
import static org.hamcrest.Matchers.is;
11+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
12+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
13+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
14+
15+
16+
/**
17+
* This test class verifies the webhook filtering functionality based on package scanning.
18+
* It ensures that only webhooks from the packages defined in {@code springdoc.packages-to-scan}
19+
* are included in the OpenAPI specification, and webhooks from packages in
20+
* {@code springdoc.packages-to-exclude} are correctly omitted.
21+
*/
22+
@SpringBootTest(classes = SpringDocApp246Test.SpringDocApp246.class)
23+
@TestPropertySource(properties = {
24+
"springdoc.packages-to-scan=test.org.springdoc.api.v31.app246",
25+
"springdoc.packages-to-exclude=test.org.springdoc.api.v31.app246.excluded",
26+
"springdoc.api-docs.version=OPENAPI_3_1"
27+
})
28+
public class SpringDocApp246Test extends AbstractSpringDocV31Test {
29+
30+
@SpringBootApplication
31+
static class SpringDocApp246 {
32+
}
33+
34+
@Test
35+
public void testApp2() throws Exception {
36+
mockMvc.perform(get(Constants.DEFAULT_API_DOCS_URL))
37+
.andExpect(status().isOk())
38+
.andExpect(jsonPath("$.webhooks.includedPet.post.requestBody.description", is("Information about a new pet in the system")))
39+
.andExpect(jsonPath("$.webhooks.includedNewPet.post.requestBody.description", is("Information about a new pet in the system")))
40+
.andExpect(jsonPath("$.webhooks.excludedNewPet").doesNotExist());
41+
}
42+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package test.org.springdoc.api.v31.app246.excluded;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.Webhook;
5+
6+
import io.swagger.v3.oas.annotations.Webhooks;
7+
import org.springframework.stereotype.Component;
8+
import test.org.springdoc.api.v31.app246.IncludedWebHookResource;
9+
10+
@Component
11+
@Webhooks({
12+
@Webhook(
13+
name = "excludedNewPet",
14+
operation = @Operation(
15+
operationId = "excludedNewPet",
16+
method = "post",
17+
summary = "This webhook should be ignored"
18+
)
19+
)
20+
})
21+
public class ExcludedWebHookResource {
22+
public void excludedNewPet(IncludedWebHookResource.RequestDto requestDto) {
23+
// This method is intentionally left empty.
24+
}
25+
}

0 commit comments

Comments
 (0)