Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
* @author kevinraddatz
* @author hyeonisism
* @author doljae
* @author zdary
*/
public abstract class AbstractOpenApiResource extends SpecFilter {

Expand Down Expand Up @@ -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 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
* The type Open api builder.
*
* @author bnasslahsen
* @author zdary
*/
public class OpenAPIService implements ApplicationContextAware {

Expand Down Expand Up @@ -538,63 +539,72 @@ private Optional<OpenAPIDefinition> getOpenAPIDefinition() {
}


/**
* Get webhooks webhooks [ ].
*
* @return the webhooks [ ]
*/
public Webhooks[] getWebhooks() {
List<Webhooks> allWebhooks = new ArrayList<>();

// First: scan Spring-managed beans
Map<String, Object> 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<Webhooks> 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<Class<?>> allWebhookClassesToScan = new HashSet<>();

// First: scan Spring-managed beans
Map<String, Object> 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<BeanDefinition> 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<BeanDefinition> 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -295,4 +296,4 @@ private static class EmptyPathsOpenApiResource extends AbstractOpenApiResource {
public void getPaths(Map<String, Object> findRestControllers, Locale locale, OpenAPI openAPI) {
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
}


Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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.
}
}
Loading