diff --git a/build.gradle b/build.gradle index 38e417271..b6dcf2c0a 100644 --- a/build.gradle +++ b/build.gradle @@ -153,12 +153,12 @@ allprojects { doLast { // ensure that the code is not executed as part of a gradle refresh plugins.forEach { - project - .file('springwolf-examples/' + it + '-example/src/test/resources/asyncapi.actual.json') - .renameTo('springwolf-examples/' + it + '-example/src/test/resources/asyncapi.json') - project - .file('springwolf-examples/' + it + '-example/src/test/resources/asyncapi.actual.yaml') - .renameTo('springwolf-examples/' + it + '-example/src/test/resources/asyncapi.yaml') + project.fileTree(dir: project.projectDir, include: '**/src/test/resources/**/*.actual.json').forEach { file -> + file.renameTo(file.path.replace('.actual.json', '.json')) + } + project.fileTree(dir: project.projectDir, include: '**/src/test/resources/**/*.actual.yaml').forEach { file -> + file.renameTo(file.path.replace('.actual.yaml', '.yaml')) + } } } } diff --git a/springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/ReferenceUtil.java b/springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/ReferenceUtil.java index 2768130ec..e049b467a 100644 --- a/springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/ReferenceUtil.java +++ b/springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/ReferenceUtil.java @@ -7,4 +7,8 @@ public class ReferenceUtil { public static String toValidId(String name) { return name.replaceAll(FORBIDDEN_ID_CHARACTER, "_"); } + + public static String getLastSegment(String ref) { + return ref.substring(ref.lastIndexOf('/') + 1); + } } diff --git a/springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/channel/ChannelObject.java b/springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/channel/ChannelObject.java index d00422c89..918eb304f 100644 --- a/springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/channel/ChannelObject.java +++ b/springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/channel/ChannelObject.java @@ -26,7 +26,7 @@ * @see Channel Object */ @Data -@Builder +@Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode(callSuper = true) diff --git a/springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/operation/Operation.java b/springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/operation/Operation.java index bd1228775..567e9099f 100644 --- a/springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/operation/Operation.java +++ b/springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/operation/Operation.java @@ -25,7 +25,7 @@ * @see Operation */ @Data -@Builder +@Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode(callSuper = true) diff --git a/springwolf-asyncapi/src/test/java/io/github/springwolf/asyncapi/v3/model/ReferenceUtilTest.java b/springwolf-asyncapi/src/test/java/io/github/springwolf/asyncapi/v3/model/ReferenceUtilTest.java index f795f143e..9e6490a9b 100644 --- a/springwolf-asyncapi/src/test/java/io/github/springwolf/asyncapi/v3/model/ReferenceUtilTest.java +++ b/springwolf-asyncapi/src/test/java/io/github/springwolf/asyncapi/v3/model/ReferenceUtilTest.java @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 package io.github.springwolf.asyncapi.v3.model; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -12,4 +13,28 @@ void shouldCorrectIllegalCharacter() { assertThat(ReferenceUtil.toValidId(name)).isEqualTo("users_{userId}"); } + + @Nested + class GetLastSegment { + @Test + void shouldExtractChannelId() { + String name = "users/{userId}"; + + assertThat(ReferenceUtil.getLastSegment(name)).isEqualTo("{userId}"); + } + + @Test + void shouldHandleEmptyString() { + String name = ""; + + assertThat(ReferenceUtil.getLastSegment(name)).isEqualTo(""); + } + + @Test + void shouldReturnOriginalStringWhenAlreadyExtracted() { + String name = "{userId}"; + + assertThat(ReferenceUtil.getLastSegment(name)).isEqualTo(name); + } + } } diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/AsyncApiService.java b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/AsyncApiService.java index e2c0d88bf..4eb1dfee2 100644 --- a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/AsyncApiService.java +++ b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/AsyncApiService.java @@ -3,7 +3,18 @@ import io.github.springwolf.asyncapi.v3.model.AsyncAPI; +import java.util.Optional; + public interface AsyncApiService { AsyncAPI getAsyncAPI(); + + /** + * Default implementation was added to avoid breaking (compiler) change. + * + * Maintainer note: remove default implementation + */ + default Optional getForGroupName(String groupName) { + return Optional.ofNullable(getAsyncAPI()); + } } diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/DefaultAsyncApiService.java b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/DefaultAsyncApiService.java index 713f22117..137b5b189 100644 --- a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/DefaultAsyncApiService.java +++ b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/DefaultAsyncApiService.java @@ -7,14 +7,17 @@ import io.github.springwolf.asyncapi.v3.model.operation.Operation; import io.github.springwolf.core.asyncapi.channels.ChannelsService; import io.github.springwolf.core.asyncapi.components.ComponentsService; +import io.github.springwolf.core.asyncapi.grouping.AsyncApiGroupService; import io.github.springwolf.core.asyncapi.operations.OperationsService; import io.github.springwolf.core.configuration.docket.AsyncApiDocket; import io.github.springwolf.core.configuration.docket.AsyncApiDocketService; +import jakarta.annotation.Nullable; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import java.util.List; import java.util.Map; +import java.util.Optional; @Slf4j @RequiredArgsConstructor @@ -22,17 +25,16 @@ public class DefaultAsyncApiService implements AsyncApiService { /** * Record holding the result of AsyncAPI creation. - * - * @param asyncAPI - * @param exception */ - private record AsyncAPIResult(AsyncAPI asyncAPI, Throwable exception) {} + private record AsyncAPIResult( + @Nullable AsyncAPI asyncAPI, @Nullable Map groupedApi, Throwable exception) {} private final AsyncApiDocketService asyncApiDocketService; private final ChannelsService channelsService; private final OperationsService operationsService; private final ComponentsService componentsService; private final List customizers; + private final AsyncApiGroupService groupService; private volatile AsyncAPIResult asyncAPIResult = null; @@ -45,7 +47,20 @@ public AsyncAPI getAsyncAPI() { if (asyncAPIResult.asyncAPI != null) { return asyncAPIResult.asyncAPI; } else { - throw new RuntimeException("Error occured during creation of AsyncAPI", asyncAPIResult.exception); + throw new RuntimeException("Error occurred during creation of AsyncAPI", asyncAPIResult.exception); + } + } + + @Override + public Optional getForGroupName(String groupName) { + if (isNotInitialized()) { + initAsyncAPI(); + } + + if (asyncAPIResult.groupedApi != null) { + return Optional.ofNullable(asyncAPIResult.groupedApi.get(groupName)); + } else { + throw new RuntimeException("Error occurred during creation of AsyncAPI", asyncAPIResult.exception); } } @@ -89,17 +104,18 @@ protected synchronized void initAsyncAPI() { log.debug("Starting customizer {}", customizer.getClass().getName()); customizer.customize(asyncAPI); } - this.asyncAPIResult = new AsyncAPIResult(asyncAPI, null); + Map groupedApi = groupService.group(asyncAPI); + this.asyncAPIResult = new AsyncAPIResult(asyncAPI, groupedApi, null); log.debug("AsyncAPI document was built"); } catch (Throwable t) { log.debug("Failed to build AsyncAPI document", t); - this.asyncAPIResult = new AsyncAPIResult(null, t); + this.asyncAPIResult = new AsyncAPIResult(null, null, t); } } /** - * checks whether asyncApi has internally allready been initialized or not. + * checks whether asyncApi has internally already been initialized or not. * * @return true if asyncApi has not allready been created and initialized. */ diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/annotations/AsyncListener.java b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/annotations/AsyncListener.java index 3cb6fba28..b8bd569d2 100644 --- a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/annotations/AsyncListener.java +++ b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/annotations/AsyncListener.java @@ -28,7 +28,7 @@ * public void receiveMessage(MonetaryAmount payload) { ... } * * - * Maintainer node: move to io.github.springwolf.core.asyncapi.annotation + * Maintainer note: move to io.github.springwolf.core.asyncapi.annotation */ @Retention(RetentionPolicy.RUNTIME) @Target(value = {ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE}) diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/grouping/AsyncApiGroupService.java b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/grouping/AsyncApiGroupService.java new file mode 100644 index 000000000..856214cba --- /dev/null +++ b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/grouping/AsyncApiGroupService.java @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.springwolf.core.asyncapi.grouping; + +import io.github.springwolf.asyncapi.v3.model.AsyncAPI; +import io.github.springwolf.core.configuration.docket.AsyncApiGroup; +import io.github.springwolf.core.configuration.properties.SpringwolfConfigProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Slf4j +@RequiredArgsConstructor +public class AsyncApiGroupService { + private final SpringwolfConfigProperties springwolfConfigProperties; + private final GroupingService groupingService; + + public Map group(AsyncAPI asyncAPI) { + return getAsyncApiGroups() + .map(group -> Map.entry(group.getGroupName(), groupingService.groupAPI(asyncAPI, group))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + public Stream getAsyncApiGroups() { + return springwolfConfigProperties.getDocket().getGroupConfigs().stream() + .map(AsyncApiGroupService::toGroupConfigAndValidate); + } + + private static AsyncApiGroup toGroupConfigAndValidate(SpringwolfConfigProperties.ConfigDocket.Group group) { + String groupName = group.getGroup(); + List channelNameToMatch = + group.getChannelNameToMatch().stream().map(Pattern::compile).toList(); + List messageNameToMatch = + group.getMessageNameToMatch().stream().map(Pattern::compile).toList(); + + if (!StringUtils.hasText(groupName)) { + throw new IllegalArgumentException("AsyncApiGroup must have a name set in configuration"); + } + + int allItemCount = group.getActionToMatch().size() + + group.getChannelNameToMatch().size() + + group.getMessageNameToMatch().size(); + if (allItemCount != 0 + && group.getActionToMatch().size() != allItemCount + && channelNameToMatch.size() != allItemCount + && messageNameToMatch.size() != allItemCount) { + throw new IllegalArgumentException( + "AsyncApiGroup %s must specify at most one filter criteria".formatted(groupName)); + } + + AsyncApiGroup asyncApiGroup = AsyncApiGroup.builder() + .groupName(groupName) + .operationActionsToKeep(group.getActionToMatch()) + .channelNamesToKeep(channelNameToMatch) + .messageNamesToKeep(messageNameToMatch) + .build(); + log.debug("Loaded AsyncApiGroup from configuration: {}", asyncApiGroup); + return asyncApiGroup; + } +} diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/grouping/GroupingService.java b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/grouping/GroupingService.java new file mode 100644 index 000000000..6e1959ce9 --- /dev/null +++ b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/grouping/GroupingService.java @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.springwolf.core.asyncapi.grouping; + +import io.github.springwolf.asyncapi.v3.model.AsyncAPI; +import io.github.springwolf.asyncapi.v3.model.ReferenceUtil; +import io.github.springwolf.asyncapi.v3.model.channel.ChannelObject; +import io.github.springwolf.asyncapi.v3.model.channel.ChannelReference; +import io.github.springwolf.asyncapi.v3.model.channel.message.Message; +import io.github.springwolf.asyncapi.v3.model.channel.message.MessageHeaders; +import io.github.springwolf.asyncapi.v3.model.channel.message.MessageObject; +import io.github.springwolf.asyncapi.v3.model.channel.message.MessagePayload; +import io.github.springwolf.asyncapi.v3.model.channel.message.MessageReference; +import io.github.springwolf.asyncapi.v3.model.components.ComponentSchema; +import io.github.springwolf.asyncapi.v3.model.components.Components; +import io.github.springwolf.asyncapi.v3.model.operation.Operation; +import io.github.springwolf.asyncapi.v3.model.schema.MultiFormatSchema; +import io.github.springwolf.asyncapi.v3.model.schema.SchemaObject; +import io.github.springwolf.core.configuration.docket.AsyncApiGroup; +import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.tuple.Pair; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@AllArgsConstructor +public class GroupingService { + + public AsyncAPI groupAPI(AsyncAPI fullAsyncApi, AsyncApiGroup asyncApiGroup) { + MarkingContext markingContext = MarkingContext.initFor(asyncApiGroup); + + markOperations(fullAsyncApi, asyncApiGroup, markingContext); + markChannels(fullAsyncApi, asyncApiGroup, markingContext); + markMessages(fullAsyncApi, asyncApiGroup, markingContext); + + markSchemasInMessageIds(fullAsyncApi, markingContext); + + return AsyncAPI.builder() + .info(fullAsyncApi.getInfo()) + .id(fullAsyncApi.getId()) + .defaultContentType(fullAsyncApi.getDefaultContentType()) + .servers(fullAsyncApi.getServers()) + .channels(filterChannels(fullAsyncApi, markingContext)) + .operations(filterOperations(fullAsyncApi, markingContext)) + .components(filterComponents(fullAsyncApi, markingContext)) + .build(); + } + + private void markChannels(AsyncAPI fullAsyncApi, AsyncApiGroup asyncApiGroup, MarkingContext markingContext) { + if (asyncApiGroup.getChannelNamesToKeep().isEmpty()) { + return; + } + + fullAsyncApi.getChannels().values().stream() + .filter(asyncApiGroup::isMatch) + .forEach(channel -> { + markingContext.markedChannelIds.add(channel.getChannelId()); + + markOperationsInChannel(fullAsyncApi, markingContext, channel); + + channel.getMessages().keySet().forEach(markingContext.markedComponentMessageIds::add); + }); + } + + private void markOperationsInChannel(AsyncAPI fullAsyncApi, MarkingContext markingContext, ChannelObject channel) { + fullAsyncApi.getOperations().entrySet().stream() + .filter(operationEntry -> matchesOperationInChannel(channel, operationEntry)) + .forEach(operationEntry -> markingContext.markedOperationIds.add(operationEntry.getKey())); + } + + private boolean matchesOperationInChannel(ChannelObject channel, Map.Entry operationEntry) { + return operationEntry + .getValue() + .getChannel() + .getRef() + .equals(ChannelReference.fromChannel(channel.getChannelId()).getRef()); + } + + private void markOperations(AsyncAPI fullAsyncApi, AsyncApiGroup asyncApiGroup, MarkingContext markingContext) { + if (asyncApiGroup.getOperationActionsToKeep().isEmpty()) { + return; + } + + fullAsyncApi.getOperations().entrySet().stream() + .filter(operationEntry -> asyncApiGroup.isMatch(operationEntry.getValue())) + .forEach(operationEntry -> { + markingContext.markedOperationIds.add(operationEntry.getKey()); + + markChannelsForOperation(fullAsyncApi, markingContext, operationEntry.getValue()); + + operationEntry.getValue().getMessages().stream() + .map(MessageReference::getRef) + .map(ReferenceUtil::getLastSegment) + .forEach(markingContext.markedComponentMessageIds::add); + }); + } + + private static void markChannelsForOperation( + AsyncAPI fullAsyncApi, MarkingContext markingContext, Operation operation) { + String operationChannelId = + ReferenceUtil.getLastSegment(operation.getChannel().getRef()); + fullAsyncApi.getChannels().values().stream() + .map(ChannelObject::getChannelId) + .filter(channelId -> channelId.equals(operationChannelId)) + .forEach(markingContext.markedChannelIds::add); + } + + private void markMessages(AsyncAPI fullAsyncApi, AsyncApiGroup asyncApiGroup, MarkingContext markingContext) { + if (asyncApiGroup.getMessageNamesToKeep().isEmpty()) { + return; + } + + fullAsyncApi.getComponents().getMessages().values().stream() + .map((message) -> (MessageObject) message) + .filter(asyncApiGroup::isMatch) + .forEach(message -> { + markingContext.markedComponentMessageIds.add(message.getMessageId()); + + String messageReference = MessageReference.toComponentMessage(message.getMessageId()) + .getRef(); + fullAsyncApi.getChannels().values().stream() + .filter(channelEntry -> matchesMessageForChannel(channelEntry, messageReference)) + .forEach(channelEntry -> markingContext.markedChannelIds.add(channelEntry.getChannelId())); + + fullAsyncApi.getOperations().entrySet().stream() + .filter(operationEntry -> matchesMessageForOperation(message, operationEntry)) + .forEach(operationEntry -> markingContext.markedOperationIds.add(operationEntry.getKey())); + }); + } + + private boolean matchesMessageForChannel(ChannelObject channel, String messageReference) { + return channel.getMessages().values().stream() + .map((messageRef) -> (MessageReference) messageRef) + .anyMatch(messageRef -> messageRef.getRef().equals(messageReference)); + } + + private boolean matchesMessageForOperation(MessageObject message, Map.Entry operationEntry) { + return operationEntry.getValue().getMessages().stream() + .anyMatch(operationMessage -> operationMessage.getRef().endsWith(message.getMessageId())); + } + + private void markSchemasInMessageIds(AsyncAPI fullAsyncApi, MarkingContext markingContext) { + Set schemaIds = new HashSet<>(); + + List messages = fullAsyncApi.getComponents().getMessages().values().stream() + .map((message) -> (MessageObject) message) + .filter(message -> markingContext.markedComponentMessageIds.contains(message.getMessageId())) + .toList(); + + messages.stream() + .map(MessageObject::getHeaders) + .filter(Objects::nonNull) + .map(MessageHeaders::getReference) + .map(MessageReference::getRef) + .map(ReferenceUtil::getLastSegment) + .forEach(schemaIds::add); + messages.stream() + .map(MessageObject::getPayload) + .map(MessagePayload::getMultiFormatSchema) + .filter(Objects::nonNull) + .map(MultiFormatSchema::getSchema) + .filter(el -> el instanceof MessageReference) // skip inline schema + .map(el -> (MessageReference) el) + .map(MessageReference::getRef) + .map(ReferenceUtil::getLastSegment) + .forEach(schemaIds::add); + + markSchemas(fullAsyncApi, markingContext, schemaIds); + } + + private void markSchemas(AsyncAPI fullAsyncApi, MarkingContext markingContext, Set schemaIds) { + schemaIds.stream() + .map(schemaId -> Pair.of( + schemaId, fullAsyncApi.getComponents().getSchemas().getOrDefault(schemaId, null))) + .filter(entry -> entry.getValue() != null) + .forEach(schemaEntry -> { + markingContext.markedComponentSchemaIds.add(schemaEntry.getKey()); + + if (schemaEntry.getValue().getProperties() != null) { + Set nestedSchemas = schemaEntry.getValue().getProperties().values().stream() + .filter(el -> el instanceof ComponentSchema) + .map(el -> (ComponentSchema) el) + .map(ComponentSchema::getReference) + .filter(Objects::nonNull) + .map(MessageReference::getRef) + .map(ReferenceUtil::getLastSegment) + .filter(schemaId -> !markingContext.markedComponentSchemaIds.contains(schemaId)) + .collect(Collectors.toSet()); + if (!nestedSchemas.isEmpty()) { + markSchemas(fullAsyncApi, markingContext, nestedSchemas); + } + } + }); + } + + private Map filterChannels(AsyncAPI fullAsyncApi, MarkingContext markingContext) { + return fullAsyncApi.getChannels().values().stream() + .filter(channel -> markingContext.isChannelMarked(channel.getChannelId())) + .map(channel -> { + Map filteredMessagesInChannel = channel.getMessages().entrySet().stream() + .filter(messageEntry -> markingContext.isComponentMessageMarked(messageEntry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + return channel.toBuilder() + .messages(filteredMessagesInChannel) + .build(); + }) + .collect(Collectors.toMap(ChannelObject::getChannelId, el -> el)); + } + + private Map filterOperations(AsyncAPI fullAsyncApi, MarkingContext markingContext) { + return fullAsyncApi.getOperations().entrySet().stream() + .filter(entry -> markingContext.isOperationMarked(entry.getKey())) + .map(entry -> { + List filteredMessagesInOperation = entry.getValue().getMessages().stream() + .filter(markingContext::isComponentMessageMarked) + .collect(Collectors.toList()); + + Operation updatedOperation = entry.getValue().toBuilder() + .messages(filteredMessagesInOperation) + .build(); + return Map.entry(entry.getKey(), updatedOperation); + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private Components filterComponents(AsyncAPI fullAsyncApi, MarkingContext markingContext) { + Map messages = fullAsyncApi.getComponents().getMessages().entrySet().stream() + .filter(entry -> markingContext.isComponentMessageMarked(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + Map schemas = fullAsyncApi.getComponents().getSchemas().entrySet().stream() + .filter(entry -> markingContext.isComponentSchemaMarked(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + return Components.builder().messages(messages).schemas(schemas).build(); + } + + @RequiredArgsConstructor + private static class MarkingContext { + private final boolean keepEverything; + + private final Set markedOperationIds = new HashSet<>(); + private final Set markedChannelIds = new HashSet<>(); + private final Set markedComponentMessageIds = new HashSet<>(); + private final Set markedComponentSchemaIds = new HashSet<>(); + + public static MarkingContext initFor(AsyncApiGroup asyncApiGroup) { + boolean keepEverything = asyncApiGroup.getOperationActionsToKeep().isEmpty() + && asyncApiGroup.getChannelNamesToKeep().isEmpty() + && asyncApiGroup.getMessageNamesToKeep().isEmpty(); + return new MarkingContext(keepEverything); + } + + public boolean isChannelMarked(String channelId) { + return keepEverything || markedChannelIds.contains(channelId); + } + + public boolean isOperationMarked(String operationId) { + return keepEverything || markedOperationIds.contains(operationId); + } + + public boolean isComponentMessageMarked(String messageId) { + return keepEverything || markedComponentMessageIds.contains(messageId); + } + + public boolean isComponentMessageMarked(MessageReference messageEntry) { + return keepEverything + || markedComponentMessageIds.contains(ReferenceUtil.getLastSegment(messageEntry.getRef())); + } + + public boolean isComponentSchemaMarked(String schemaId) { + return keepEverything || markedComponentSchemaIds.contains(schemaId); + } + } +} diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/scanners/common/payload/PayloadSchemaObject.java b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/scanners/common/payload/PayloadSchemaObject.java index 1744f7682..d63362a15 100644 --- a/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/scanners/common/payload/PayloadSchemaObject.java +++ b/springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/scanners/common/payload/PayloadSchemaObject.java @@ -11,7 +11,6 @@ * @param name The fully qualified name or the simple name of the schema. * @param simpleSchemaName * @param schema The SchemaObject. - * @param schemaPayload The schema-payload to be inserted in the message, when not null this schema will override the payload of the message. */ public record PayloadSchemaObject(String name, String simpleSchemaName, @Nullable ComponentSchema schema) { public String title() { diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/configuration/SpringwolfAutoConfiguration.java b/springwolf-core/src/main/java/io/github/springwolf/core/configuration/SpringwolfAutoConfiguration.java index 7f8270aef..27436cc72 100644 --- a/springwolf-core/src/main/java/io/github/springwolf/core/configuration/SpringwolfAutoConfiguration.java +++ b/springwolf-core/src/main/java/io/github/springwolf/core/configuration/SpringwolfAutoConfiguration.java @@ -22,6 +22,8 @@ import io.github.springwolf.core.asyncapi.components.postprocessors.AvroSchemaPostProcessor; import io.github.springwolf.core.asyncapi.components.postprocessors.ExampleGeneratorPostProcessor; import io.github.springwolf.core.asyncapi.components.postprocessors.SchemasPostProcessor; +import io.github.springwolf.core.asyncapi.grouping.AsyncApiGroupService; +import io.github.springwolf.core.asyncapi.grouping.GroupingService; import io.github.springwolf.core.asyncapi.operations.DefaultOperationsService; import io.github.springwolf.core.asyncapi.operations.OperationsService; import io.github.springwolf.core.asyncapi.scanners.ChannelsScanner; @@ -75,6 +77,19 @@ public SpringwolfInitApplicationListener springwolfInitApplicationListener( return new SpringwolfInitApplicationListener(asyncApiService, springwolfConfigProperties); } + @Bean + @ConditionalOnMissingBean + public GroupingService groupingService() { + return new GroupingService(); + } + + @Bean + @ConditionalOnMissingBean + public AsyncApiGroupService asyncApiGroupService( + SpringwolfConfigProperties springwolfConfigProperties, GroupingService groupingService) { + return new AsyncApiGroupService(springwolfConfigProperties, groupingService); + } + @Bean @ConditionalOnMissingBean public AsyncApiService asyncApiService( @@ -82,9 +97,15 @@ public AsyncApiService asyncApiService( ChannelsService channelsService, OperationsService operationsService, ComponentsService componentsService, - List customizers) { + List customizers, + AsyncApiGroupService groupService) { return new DefaultAsyncApiService( - asyncApiDocketService, channelsService, operationsService, componentsService, customizers); + asyncApiDocketService, + channelsService, + operationsService, + componentsService, + customizers, + groupService); } @Bean diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/configuration/SpringwolfWebConfiguration.java b/springwolf-core/src/main/java/io/github/springwolf/core/configuration/SpringwolfWebConfiguration.java index 649f0770e..817e2ca85 100644 --- a/springwolf-core/src/main/java/io/github/springwolf/core/configuration/SpringwolfWebConfiguration.java +++ b/springwolf-core/src/main/java/io/github/springwolf/core/configuration/SpringwolfWebConfiguration.java @@ -6,9 +6,11 @@ import io.github.springwolf.asyncapi.v3.jackson.DefaultAsyncApiSerializerService; import io.github.springwolf.core.asyncapi.AsyncApiService; import io.github.springwolf.core.asyncapi.components.ComponentsService; +import io.github.springwolf.core.asyncapi.grouping.AsyncApiGroupService; import io.github.springwolf.core.controller.ActuatorAsyncApiController; import io.github.springwolf.core.controller.AsyncApiController; import io.github.springwolf.core.controller.PublishingPayloadCreator; +import io.github.springwolf.core.controller.UiConfigController; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; @@ -47,6 +49,13 @@ public ActuatorAsyncApiController actuatorAsyncApiController( return new ActuatorAsyncApiController(asyncApiService, asyncApiSerializerService); } + @Bean + @ConditionalOnProperty(name = SPRINGWOLF_ENDPOINT_ACTUATOR_ENABLED, havingValue = "false", matchIfMissing = true) + @ConditionalOnMissingBean + public UiConfigController uiConfigController(AsyncApiGroupService asyncApiGroupService) { + return new UiConfigController(asyncApiGroupService); + } + @Bean @ConditionalOnMissingBean public AsyncApiSerializerService asyncApiSerializerService() { diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/configuration/docket/AsyncApiGroup.java b/springwolf-core/src/main/java/io/github/springwolf/core/configuration/docket/AsyncApiGroup.java new file mode 100644 index 000000000..60b97c28e --- /dev/null +++ b/springwolf-core/src/main/java/io/github/springwolf/core/configuration/docket/AsyncApiGroup.java @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.springwolf.core.configuration.docket; + +import io.github.springwolf.asyncapi.v3.model.channel.ChannelObject; +import io.github.springwolf.asyncapi.v3.model.channel.message.MessageObject; +import io.github.springwolf.asyncapi.v3.model.operation.Operation; +import io.github.springwolf.asyncapi.v3.model.operation.OperationAction; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; + +@AllArgsConstructor +@Getter +@Builder +@EqualsAndHashCode +@ToString +public class AsyncApiGroup { + private final String groupName; + + @Builder.Default + private final List operationActionsToKeep = Collections.emptyList(); + + @Builder.Default + private final List channelNamesToKeep = Collections.emptyList(); + + @Builder.Default + private final List messageNamesToKeep = Collections.emptyList(); + + public boolean isMatch(MessageObject messageObject) { + return getMessageNamesToKeep().stream().anyMatch(pattern -> pattern.matcher(messageObject.getMessageId()) + .matches()); + } + + public boolean isMatch(Operation operation) { + return getOperationActionsToKeep().contains(operation.getAction()); + } + + public boolean isMatch(ChannelObject channelObject) { + return getChannelNamesToKeep().stream() + .anyMatch(pattern -> pattern.matcher(channelObject.getAddress()).matches()); + } +} diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/configuration/properties/SpringwolfConfigProperties.java b/springwolf-core/src/main/java/io/github/springwolf/core/configuration/properties/SpringwolfConfigProperties.java index 5fc4bc202..afbef376e 100644 --- a/springwolf-core/src/main/java/io/github/springwolf/core/configuration/properties/SpringwolfConfigProperties.java +++ b/springwolf-core/src/main/java/io/github/springwolf/core/configuration/properties/SpringwolfConfigProperties.java @@ -4,14 +4,19 @@ import io.github.springwolf.asyncapi.v3.model.AsyncAPI; import io.github.springwolf.asyncapi.v3.model.info.Contact; import io.github.springwolf.asyncapi.v3.model.info.License; +import io.github.springwolf.asyncapi.v3.model.operation.OperationAction; import io.github.springwolf.asyncapi.v3.model.server.Server; import io.github.springwolf.core.configuration.docket.AsyncApiDocket; import jakarta.annotation.Nullable; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; +import lombok.ToString; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.NestedConfigurationProperty; +import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -187,6 +192,34 @@ public static class Info { @Nullable private Map extensionFields = Map.of("x-generator", "springwolf"); } + + private List groupConfigs = List.of(); + + @Getter + @Setter + @EqualsAndHashCode + @ToString + public static class Group { + /** + * The name of the Group + */ + private String group = ""; + + /** + * The action to match for the group + */ + private List actionToMatch = Collections.emptyList(); + + /** + * The channel names to match + */ + private List channelNameToMatch = Collections.emptyList(); + + /** + * The message names to match + */ + private List messageNameToMatch = Collections.emptyList(); + } } @Getter diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/controller/ActuatorAsyncApiController.java b/springwolf-core/src/main/java/io/github/springwolf/core/controller/ActuatorAsyncApiController.java index 7fc090c28..4ad50024b 100644 --- a/springwolf-core/src/main/java/io/github/springwolf/core/controller/ActuatorAsyncApiController.java +++ b/springwolf-core/src/main/java/io/github/springwolf/core/controller/ActuatorAsyncApiController.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import io.github.springwolf.asyncapi.v3.jackson.AsyncApiSerializerService; -import io.github.springwolf.asyncapi.v3.model.AsyncAPI; import io.github.springwolf.core.asyncapi.AsyncApiService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,7 +19,6 @@ public class ActuatorAsyncApiController { @ReadOperation public String asyncApiJson() throws JsonProcessingException { - AsyncAPI asyncAPI = asyncApiService.getAsyncAPI(); - return serializer.toJsonString(asyncAPI); + return serializer.toJsonString(asyncApiService.getAsyncAPI()); } } diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/controller/AsyncApiController.java b/springwolf-core/src/main/java/io/github/springwolf/core/controller/AsyncApiController.java index bbf6f986e..31595d864 100644 --- a/springwolf-core/src/main/java/io/github/springwolf/core/controller/AsyncApiController.java +++ b/springwolf-core/src/main/java/io/github/springwolf/core/controller/AsyncApiController.java @@ -3,13 +3,17 @@ import com.fasterxml.jackson.core.JsonProcessingException; import io.github.springwolf.asyncapi.v3.jackson.AsyncApiSerializerService; +import io.github.springwolf.asyncapi.v3.model.AsyncAPI; import io.github.springwolf.core.asyncapi.AsyncApiService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; +import java.util.Optional; + @Slf4j @RestController @RequiredArgsConstructor @@ -20,29 +24,39 @@ public class AsyncApiController { @GetMapping( path = { - "${springwolf.paths.docs:/springwolf/docs}", - "${springwolf.paths.docs:/springwolf/docs}.json", + "${springwolf.paths.docs:/springwolf/docs}", // maintainer note: remove, path.base instead + "${springwolf.paths.docs:/springwolf/docs}.json", // maintainer note: remove, path.base instead "${springwolf.path.base:/springwolf}${springwolf.path.docs:/docs}", - "${springwolf.path.base:/springwolf}${springwolf.path.docs:/docs}.json" + "${springwolf.path.base:/springwolf}${springwolf.path.docs:/docs}.json", // maintainer note: remove, use + // accept header instead + "${springwolf.path.base:/springwolf}${springwolf.path.docs:/docs}/{group}" }, produces = MediaType.APPLICATION_JSON_VALUE) - public String asyncApiJson() throws JsonProcessingException { - log.debug("Returning AsyncApi.json document"); - - Object asyncAPI = asyncApiService.getAsyncAPI(); - return serializer.toJsonString(asyncAPI); + public String asyncApiJson(@PathVariable(required = false) Optional group) throws JsonProcessingException { + return serializer.toJsonString(getAsyncAPI(group)); } @GetMapping( path = { - "${springwolf.paths.docs:/springwolf/docs}.yaml", - "${springwolf.path.base:/springwolf}${springwolf.path.docs:/docs}.yaml" + "${springwolf.paths.docs:/springwolf/docs}", // maintainer note: remove, path.base instead + "${springwolf.paths.docs:/springwolf/docs}.yaml", // maintainer note: remove, path.base instead + "${springwolf.path.base:/springwolf}${springwolf.path.docs:/docs}", + "${springwolf.path.base:/springwolf}${springwolf.path.docs:/docs}.yaml", // maintainer note: remove, use + // accept header instead + "${springwolf.path.base:/springwolf}${springwolf.path.docs:/docs}/{group}", }, produces = "application/yaml") - public String asyncApiYaml() throws JsonProcessingException { - log.debug("Returning AsyncApi.yaml document"); + public String asyncApiYaml(@PathVariable(required = false) Optional group) throws JsonProcessingException { + return serializer.toYaml(getAsyncAPI(group)); + } + + private AsyncAPI getAsyncAPI(Optional group) { + log.debug("Returning AsyncApi document for group {}", group.orElse("default")); - Object asyncAPI = asyncApiService.getAsyncAPI(); - return serializer.toYaml(asyncAPI); + return group.map(groupName -> asyncApiService + .getForGroupName(groupName) + .orElseThrow(() -> + new IllegalArgumentException("AsyncAPI group %s was not found".formatted(groupName)))) + .orElseGet(asyncApiService::getAsyncAPI); } } diff --git a/springwolf-core/src/main/java/io/github/springwolf/core/controller/UiConfigController.java b/springwolf-core/src/main/java/io/github/springwolf/core/controller/UiConfigController.java new file mode 100644 index 000000000..cb0eda456 --- /dev/null +++ b/springwolf-core/src/main/java/io/github/springwolf/core/controller/UiConfigController.java @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.springwolf.core.controller; + +import io.github.springwolf.core.asyncapi.grouping.AsyncApiGroupService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class UiConfigController { + + private final AsyncApiGroupService asyncApiGroupService; + + @GetMapping( + path = {"${springwolf.path.base:/springwolf}/ui-config"}, + produces = MediaType.APPLICATION_JSON_VALUE) + public UiConfig getUiConfig() { + return new UiConfig(asyncApiGroupService + .getAsyncApiGroups() + .map(el -> new UiConfig.UiConfigGroup(el.getGroupName())) + .toList()); + } + + private record UiConfig(List groups) { + private record UiConfigGroup(String name) {} + } +} diff --git a/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/DefaultAsyncApiServiceIntegrationTest.java b/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/DefaultAsyncApiServiceIntegrationTest.java index cf71be793..e2fc0f2e7 100644 --- a/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/DefaultAsyncApiServiceIntegrationTest.java +++ b/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/DefaultAsyncApiServiceIntegrationTest.java @@ -9,6 +9,7 @@ import io.github.springwolf.asyncapi.v3.model.server.Server; import io.github.springwolf.core.asyncapi.channels.ChannelsService; import io.github.springwolf.core.asyncapi.components.ComponentsService; +import io.github.springwolf.core.asyncapi.grouping.AsyncApiGroupService; import io.github.springwolf.core.asyncapi.operations.OperationsService; import io.github.springwolf.core.configuration.docket.DefaultAsyncApiDocketService; import io.github.springwolf.core.configuration.properties.SpringwolfConfigProperties; @@ -63,6 +64,9 @@ class DefaultAsyncApiServiceIntegrationTest { @MockBean private ComponentsService componentsService; + @MockBean + private AsyncApiGroupService groupService; + @Autowired private AsyncApiService asyncApiService; diff --git a/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/DefaultAsyncApiServiceTest.java b/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/DefaultAsyncApiServiceTest.java index 18835b3f0..ebfcbfffe 100644 --- a/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/DefaultAsyncApiServiceTest.java +++ b/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/DefaultAsyncApiServiceTest.java @@ -4,6 +4,7 @@ import io.github.springwolf.asyncapi.v3.model.AsyncAPI; import io.github.springwolf.core.asyncapi.channels.ChannelsService; import io.github.springwolf.core.asyncapi.components.ComponentsService; +import io.github.springwolf.core.asyncapi.grouping.AsyncApiGroupService; import io.github.springwolf.core.asyncapi.operations.OperationsService; import io.github.springwolf.core.configuration.docket.AsyncApiDocket; import io.github.springwolf.core.configuration.docket.AsyncApiDocketService; @@ -14,9 +15,11 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -31,6 +34,7 @@ class DefaultAsyncApiServiceTest { private OperationsService operationsService; private ComponentsService componentsService; private List customizers = new ArrayList<>(); + private AsyncApiGroupService groupService; @BeforeEach public void setup() { @@ -38,12 +42,18 @@ public void setup() { channelsService = mock(ChannelsService.class); operationsService = mock(OperationsService.class); componentsService = mock(ComponentsService.class); + groupService = mock(AsyncApiGroupService.class); when(channelsService.findChannels()).thenReturn(Map.of()); when(componentsService.getSchemas()).thenReturn(Map.of()); defaultAsyncApiService = new DefaultAsyncApiService( - asyncApiDocketService, channelsService, operationsService, componentsService, customizers); + asyncApiDocketService, + channelsService, + operationsService, + componentsService, + customizers, + groupService); } @Test @@ -58,7 +68,7 @@ void shouldThrowExceptionOnSubsequentGetAsyncApi() { fail("RuntimeException expected"); } catch (RuntimeException exc) { // Then a RuntimeException is thrown. - assertThat(exc.getMessage()).isEqualTo("Error occured during creation of AsyncAPI"); + assertThat(exc.getMessage()).isEqualTo("Error occurred during creation of AsyncAPI"); cause = exc.getCause(); assertThat(cause.getMessage()).isEqualTo("test exception"); } @@ -70,7 +80,7 @@ void shouldThrowExceptionOnSubsequentGetAsyncApi() { } catch (RuntimeException exc) { // Then the same RuntimeException as on first invocatin should be thrown. - assertThat(exc.getMessage()).isEqualTo("Error occured during creation of AsyncAPI"); + assertThat(exc.getMessage()).isEqualTo("Error occurred during creation of AsyncAPI"); assertThat(exc.getCause()).isSameAs(cause); } } @@ -93,4 +103,33 @@ void shouldLazyInitializeOnLoadingModeLazy() { assertThat(asyncAPI).isNotNull(); assertThat(defaultAsyncApiService.isNotInitialized()).isFalse(); } + + @Test + void shouldGetGroupedAsyncApi() { + // given + AsyncApiDocket docket = AsyncApiDocketFixture.createMinimal(); + when(asyncApiDocketService.getAsyncApiDocket()).thenReturn(docket); + AsyncAPI asyncAPI = AsyncAPI.builder().build(); + when(groupService.group(any())).thenReturn(Map.of("group", asyncAPI)); + + // when + Optional result = defaultAsyncApiService.getForGroupName("group"); + + // then + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(asyncAPI); + } + + @Test + void shouldNotThrowWhenAsyncApiGroupDoesNotExist() { + // given + AsyncApiDocket docket = AsyncApiDocketFixture.createMinimal(); + when(asyncApiDocketService.getAsyncApiDocket()).thenReturn(docket); + + // when + Optional result = defaultAsyncApiService.getForGroupName("group"); + + // then + assertThat(result).isEmpty(); + } } diff --git a/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/controller/SpringContextControllerIntegrationTest.java b/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/controller/SpringContextControllerIntegrationTest.java index dd3cf156a..474abb26b 100644 --- a/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/controller/SpringContextControllerIntegrationTest.java +++ b/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/controller/SpringContextControllerIntegrationTest.java @@ -44,7 +44,7 @@ void testContextWithApplicationProperties() throws JsonProcessingException { assertThat(asyncApiController).isPresent(); assertThat(actuatorAsyncApiController).isNotPresent(); - assertThat(asyncApiController.get().asyncApiJson()).isNotNull(); + assertThat(asyncApiController.get().asyncApiJson(Optional.empty())).isNotNull(); } } diff --git a/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/grouping/AsyncApiGroupServiceTest.java b/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/grouping/AsyncApiGroupServiceTest.java new file mode 100644 index 000000000..da2db11c3 --- /dev/null +++ b/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/grouping/AsyncApiGroupServiceTest.java @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.springwolf.core.asyncapi.grouping; + +import io.github.springwolf.asyncapi.v3.model.AsyncAPI; +import io.github.springwolf.asyncapi.v3.model.operation.OperationAction; +import io.github.springwolf.core.configuration.docket.AsyncApiGroup; +import io.github.springwolf.core.configuration.properties.SpringwolfConfigProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.assertj.core.api.Fail.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AsyncApiGroupServiceTest { + + private final SpringwolfConfigProperties.ConfigDocket configDocket = mock(); + private final SpringwolfConfigProperties springwolfConfigProperties = mock(); + private final GroupingService groupingService = mock(); + private final AsyncApiGroupService asyncApiGroupService = + new AsyncApiGroupService(springwolfConfigProperties, groupingService); + + private final AsyncAPI asyncAPI = mock(AsyncAPI.class); + private final AsyncAPI groupedAsyncApi = mock(AsyncAPI.class); + + @BeforeEach + void setUp() { + when(springwolfConfigProperties.getDocket()).thenReturn(configDocket); + when(groupingService.groupAPI(eq(asyncAPI), any())).thenReturn(groupedAsyncApi); + } + + @Test + void shouldReturnEmptyWhenNoGroupDefined() { + // given + when(configDocket.getGroupConfigs()).thenReturn(List.of()); + + // when + Map result = asyncApiGroupService.group(asyncAPI); + + // then + assertThat(result).isEmpty(); + } + + @Test + void shouldCreateGroup() { + // given + SpringwolfConfigProperties.ConfigDocket.Group group = new SpringwolfConfigProperties.ConfigDocket.Group(); + group.setGroup("group1"); + when(configDocket.getGroupConfigs()).thenReturn(List.of(group)); + + // when + Map result = asyncApiGroupService.group(asyncAPI); + + // then + assertThat(result).hasSize(1).containsEntry("group1", groupedAsyncApi); + } + + @Test + void shouldRequireNonBlankGroupName() { + // given + SpringwolfConfigProperties.ConfigDocket.Group group = new SpringwolfConfigProperties.ConfigDocket.Group(); + group.setGroup(""); + when(configDocket.getGroupConfigs()).thenReturn(List.of(group)); + + // when + try { + asyncApiGroupService.group(asyncAPI); + fail(); + } catch (Exception e) { + // then + assertThat(e) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("AsyncApiGroup must have a name set in configuration"); + } + } + + @Test + void shouldOnlyAllowGroupingByOneCriteria() { + // given + SpringwolfConfigProperties.ConfigDocket.Group group = new SpringwolfConfigProperties.ConfigDocket.Group(); + group.setGroup("group1"); + group.setActionToMatch(List.of(OperationAction.RECEIVE)); + group.setChannelNameToMatch(List.of("channel-1")); + when(configDocket.getGroupConfigs()).thenReturn(List.of(group)); + + // when + try { + asyncApiGroupService.group(asyncAPI); + fail(); + } catch (Exception e) { + // then + assertThat(e) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("AsyncApiGroup group1 must specify at most one filter criteria"); + } + } + + @Test + void shouldGroupByAction() { + // given + List actions = List.of(OperationAction.RECEIVE); + + SpringwolfConfigProperties.ConfigDocket.Group group = new SpringwolfConfigProperties.ConfigDocket.Group(); + group.setGroup("group1"); + group.setActionToMatch(actions); + when(configDocket.getGroupConfigs()).thenReturn(List.of(group)); + + // when + asyncApiGroupService.group(asyncAPI); + + // then + verify(groupingService) + .groupAPI( + any(), + eq(AsyncApiGroup.builder() + .groupName("group1") + .operationActionsToKeep(actions) + .build())); + } + + @Test + void shouldGroupByChannels() { + // given + List channels = List.of("channel-1"); + + SpringwolfConfigProperties.ConfigDocket.Group group = new SpringwolfConfigProperties.ConfigDocket.Group(); + group.setGroup("group1"); + group.setChannelNameToMatch(channels); + when(configDocket.getGroupConfigs()).thenReturn(List.of(group)); + + // when + asyncApiGroupService.group(asyncAPI); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(AsyncApiGroup.class); + verify(groupingService).groupAPI(any(), captor.capture()); + + AsyncApiGroup capturedGroup = captor.getValue(); + assertThat(capturedGroup.getGroupName()).isEqualTo("group1"); + + Pattern actualPattern = capturedGroup.getChannelNamesToKeep().get(0); + assertThat(actualPattern.pattern()).isEqualTo(channels.get(0)); + } + + @Test + void shouldGroupByMessage() { + // given + List messages = List.of("message-1"); + + SpringwolfConfigProperties.ConfigDocket.Group group = new SpringwolfConfigProperties.ConfigDocket.Group(); + group.setGroup("group1"); + group.setMessageNameToMatch(messages); + when(configDocket.getGroupConfigs()).thenReturn(List.of(group)); + + // when + asyncApiGroupService.group(asyncAPI); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(AsyncApiGroup.class); + verify(groupingService).groupAPI(any(), captor.capture()); + + AsyncApiGroup capturedGroup = captor.getValue(); + assertThat(capturedGroup.getGroupName()).isEqualTo("group1"); + + Pattern actualPattern = capturedGroup.getMessageNamesToKeep().get(0); + assertThat(actualPattern.pattern()).isEqualTo(messages.get(0)); + } +} diff --git a/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/grouping/GroupingServiceTest.java b/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/grouping/GroupingServiceTest.java new file mode 100644 index 000000000..c1f45eb78 --- /dev/null +++ b/springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/grouping/GroupingServiceTest.java @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.springwolf.core.asyncapi.grouping; + +import io.github.springwolf.asyncapi.v3.model.AsyncAPI; +import io.github.springwolf.asyncapi.v3.model.channel.ChannelObject; +import io.github.springwolf.asyncapi.v3.model.channel.ChannelReference; +import io.github.springwolf.asyncapi.v3.model.channel.message.MessageHeaders; +import io.github.springwolf.asyncapi.v3.model.channel.message.MessageObject; +import io.github.springwolf.asyncapi.v3.model.channel.message.MessagePayload; +import io.github.springwolf.asyncapi.v3.model.channel.message.MessageReference; +import io.github.springwolf.asyncapi.v3.model.components.Components; +import io.github.springwolf.asyncapi.v3.model.info.Info; +import io.github.springwolf.asyncapi.v3.model.operation.Operation; +import io.github.springwolf.asyncapi.v3.model.operation.OperationAction; +import io.github.springwolf.asyncapi.v3.model.schema.MultiFormatSchema; +import io.github.springwolf.asyncapi.v3.model.schema.SchemaObject; +import io.github.springwolf.asyncapi.v3.model.server.Server; +import io.github.springwolf.core.configuration.docket.AsyncApiGroup; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class GroupingServiceTest { + + private final SchemaObject schema1 = SchemaObject.builder().title("Schema1").build(); + private final SchemaObject schema2inlined = + SchemaObject.builder().title("Schema2").build(); + private final SchemaObject schema3 = SchemaObject.builder().title("Schema3").build(); + private final SchemaObject header1Schema = + SchemaObject.builder().title("Schema1Header").build(); + + private final MessageObject message1 = MessageObject.builder() + .messageId("messageId1") + .payload(MessagePayload.of(MultiFormatSchema.builder() + .schema(MessageReference.toSchema(schema1.getTitle())) + .build())) + .headers(MessageHeaders.of(MessageReference.toSchema(header1Schema.getTitle()))) + .build(); + private final MessageObject message2 = MessageObject.builder() + .messageId("messageId2") + .payload(MessagePayload.of(schema2inlined)) + .build(); + private final MessageObject message3 = MessageObject.builder() + .messageId("messageId3") + .payload(MessagePayload.of(MultiFormatSchema.builder() + .schema(MessageReference.toSchema(schema3.getTitle())) + .build())) + .build(); + + private final ChannelObject channel1 = ChannelObject.builder() + .channelId("channelId1") + .address("channelId1-address") + .messages(new HashMap<>(Map.of( + message1.getMessageId(), + MessageReference.toComponentMessage(message1.getMessageId()), + message2.getMessageId(), + MessageReference.toComponentMessage(message2.getMessageId())))) + .build(); + private final ChannelObject channel2 = ChannelObject.builder() + .channelId("channelId2") + .address("channelId2-address") + .messages(new HashMap<>( + Map.of(message3.getMessageId(), MessageReference.toComponentMessage(message3.getMessageId())))) + .build(); + + private final Operation sendOperation = Operation.builder() + .action(OperationAction.SEND) + .channel(ChannelReference.fromChannel(channel1)) + .messages(List.of( + MessageReference.toChannelMessage(channel1.getChannelId(), message1.getMessageId()), + MessageReference.toChannelMessage(channel1.getChannelId(), message2.getMessageId()))) + .build(); + private final Operation receiveOperation = Operation.builder() + .action(OperationAction.RECEIVE) + .channel(ChannelReference.fromChannel(channel2)) + .messages(List.of(MessageReference.toChannelMessage(channel2.getChannelId(), message3.getMessageId()))) + .build(); + + private final AsyncAPI simpleApi = AsyncAPI.builder() + .channels(Map.of(channel2.getChannelId(), channel2)) + .operations(Map.of("receive", receiveOperation)) + .components(Components.builder() + .messages(Map.of(message3.getMessageId(), message3)) + .schemas(Map.of(schema3.getTitle(), schema3)) + .build()) + .build(); + private final AsyncAPI fullApi = AsyncAPI.builder() + .channels(Map.of(channel1.getChannelId(), channel1, channel2.getChannelId(), channel2)) + .operations(Map.of("send", sendOperation, "receive", receiveOperation)) + .components(Components.builder() + .messages(Map.of( + message1.getMessageId(), + message1, + message2.getMessageId(), + message2, + message3.getMessageId(), + message3)) + .schemas(Map.of( + schema1.getTitle(), schema1, + header1Schema.getTitle(), header1Schema, + schema3.getTitle(), schema3)) + .build()) + .build(); + + private final AsyncApiGroup noFilterGroup = AsyncApiGroup.builder() + .operationActionsToKeep(List.of()) + .channelNamesToKeep(List.of()) + .messageNamesToKeep(List.of()) + .build(); + + private final GroupingService groupingService = new GroupingService(); + + @Test + void shouldCreateNewAsyncApi() { + // given + AsyncAPI full = AsyncAPI.builder() + .channels(simpleApi.getChannels()) + .operations(simpleApi.getOperations()) + .components(simpleApi.getComponents()) + .build(); + + // when + AsyncAPI grouped = groupingService.groupAPI(full, noFilterGroup); + + // then + assertThat(grouped).isNotSameAs(full); + } + + @Test + void shouldUseIdenticalInfo() { + // given + Info info = Info.builder().title("title").build(); + AsyncAPI full = AsyncAPI.builder() + .info(info) + .channels(simpleApi.getChannels()) + .operations(simpleApi.getOperations()) + .components(simpleApi.getComponents()) + .build(); + + // when + AsyncAPI grouped = groupingService.groupAPI(full, noFilterGroup); + + // then + assertThat(grouped.getInfo()).isSameAs(info); + } + + @Test + void shouldUseIdenticalId() { + // given + AsyncAPI full = AsyncAPI.builder() + .id("id") + .channels(simpleApi.getChannels()) + .operations(simpleApi.getOperations()) + .components(simpleApi.getComponents()) + .build(); + + // when + AsyncAPI grouped = groupingService.groupAPI(full, noFilterGroup); + + // then + assertThat(grouped.getId()).isEqualTo("id"); + } + + @Test + void shouldUseIdenticalDefaultContentType() { + // given + AsyncAPI full = AsyncAPI.builder() + .defaultContentType("application/json") + .channels(simpleApi.getChannels()) + .operations(simpleApi.getOperations()) + .components(simpleApi.getComponents()) + .build(); + + // when + AsyncAPI grouped = groupingService.groupAPI(full, noFilterGroup); + + // then + assertThat(grouped.getDefaultContentType()).isEqualTo("application/json"); + } + + @Test + void shouldUseIdenticalServers() { + // given + AsyncAPI full = AsyncAPI.builder() + .servers(Map.of("server", Server.builder().build())) + .channels(simpleApi.getChannels()) + .operations(simpleApi.getOperations()) + .components(simpleApi.getComponents()) + .build(); + + // when + AsyncAPI grouped = groupingService.groupAPI(full, noFilterGroup); + + // then + assertThat(grouped.getServers()).isEqualTo(full.getServers()); + } + + @Test + void shouldCopyEverythingForEmptyFilter() { + // when + AsyncAPI grouped = groupingService.groupAPI(fullApi, noFilterGroup); + + // then + assertThat(grouped).isEqualTo(fullApi); + } + + @Nested + class ActionFiltering { + + @Test + void shouldFilterSimple() { + // given + AsyncApiGroup actionFilterGroup = AsyncApiGroup.builder() + .operationActionsToKeep(List.of(OperationAction.RECEIVE)) + .channelNamesToKeep(List.of()) + .messageNamesToKeep(List.of()) + .build(); + // when + AsyncAPI grouped = groupingService.groupAPI(simpleApi, actionFilterGroup); + + // then + assertThat(grouped.getChannels()).isEqualTo(Map.of(channel2.getChannelId(), channel2)); + assertThat(grouped.getOperations()).isEqualTo(Map.of("receive", receiveOperation)); + assertThat(grouped.getComponents().getMessages()).isEqualTo(Map.of(message3.getMessageId(), message3)); + assertThat(grouped.getComponents().getSchemas()).isEqualTo(Map.of(schema3.getTitle(), schema3)); + } + + @Test + void shouldFilterFull() { + // given + AsyncApiGroup actionFilterGroup = AsyncApiGroup.builder() + .operationActionsToKeep(List.of(OperationAction.SEND)) + .channelNamesToKeep(List.of()) + .messageNamesToKeep(List.of()) + .build(); + + // when + AsyncAPI grouped = groupingService.groupAPI(fullApi, actionFilterGroup); + + // then + assertThat(grouped.getChannels()).isEqualTo(Map.of(channel1.getChannelId(), channel1)); + assertThat(grouped.getOperations()).isEqualTo(Map.of("send", sendOperation)); + assertThat(grouped.getComponents().getMessages()) + .isEqualTo(Map.of( + message1.getMessageId(), message1, + message2.getMessageId(), message2)); + assertThat(grouped.getComponents().getSchemas()) + .isEqualTo(Map.of(schema1.getTitle(), schema1, header1Schema.getTitle(), header1Schema)); + } + } + + @Nested + class ChannelFiltering { + + @Test + void shouldFilterEverythingWhenNoMatch() { + // given + AsyncApiGroup channelFilterGroup = AsyncApiGroup.builder() + .operationActionsToKeep(List.of()) + .channelNamesToKeep(List.of(Pattern.compile("this-channel-name-does-not-exist"))) + .messageNamesToKeep(List.of()) + .build(); + + // when + AsyncAPI grouped = groupingService.groupAPI(fullApi, channelFilterGroup); + + // then + assertThat(grouped.getChannels()).isEmpty(); + assertThat(grouped.getOperations()).isEmpty(); + assertThat(grouped.getComponents().getMessages()).isEmpty(); + assertThat(grouped.getComponents().getSchemas()).isEmpty(); + } + + @Test + void shouldFilterSimple() { + // given + AsyncApiGroup channelFilterGroup = AsyncApiGroup.builder() + .operationActionsToKeep(List.of()) + .channelNamesToKeep(List.of(Pattern.compile(channel2.getAddress()))) + .messageNamesToKeep(List.of()) + .build(); + + // when + AsyncAPI grouped = groupingService.groupAPI(simpleApi, channelFilterGroup); + + // then + assertThat(grouped.getChannels()).isEqualTo(Map.of(channel2.getChannelId(), channel2)); + assertThat(grouped.getOperations()).isEqualTo(Map.of("receive", receiveOperation)); + assertThat(grouped.getComponents().getMessages()).isEqualTo(Map.of(message3.getMessageId(), message3)); + assertThat(grouped.getComponents().getSchemas()).isEqualTo(Map.of(schema3.getTitle(), schema3)); + } + + @Test + void shouldFilterFull() { + // given + AsyncApiGroup channelFilterGroup = AsyncApiGroup.builder() + .operationActionsToKeep(List.of()) + .channelNamesToKeep(List.of(Pattern.compile(channel1.getAddress()))) + .messageNamesToKeep(List.of()) + .build(); + + // when + AsyncAPI grouped = groupingService.groupAPI(fullApi, channelFilterGroup); + + // then + assertThat(grouped.getChannels()).isEqualTo(Map.of(channel1.getChannelId(), channel1)); + assertThat(grouped.getOperations()).isEqualTo(Map.of("send", sendOperation)); + assertThat(grouped.getComponents().getMessages()) + .isEqualTo(Map.of( + message1.getMessageId(), message1, + message2.getMessageId(), message2)); + assertThat(grouped.getComponents().getSchemas()) + .isEqualTo(Map.of(schema1.getTitle(), schema1, header1Schema.getTitle(), header1Schema)); + } + } + + @Nested + class MessageFiltering { + @Test + void shouldFilterEverythingWhenNoMatch() { + // given + AsyncApiGroup messageFilterGroup = AsyncApiGroup.builder() + .operationActionsToKeep(List.of()) + .channelNamesToKeep(List.of()) + .messageNamesToKeep(List.of(Pattern.compile("this-channel-name-does-not-exist"))) + .build(); + + // when + AsyncAPI grouped = groupingService.groupAPI(fullApi, messageFilterGroup); + + // then + assertThat(grouped.getChannels()).isEmpty(); + assertThat(grouped.getOperations()).isEmpty(); + assertThat(grouped.getComponents().getMessages()).isEmpty(); + assertThat(grouped.getComponents().getSchemas()).isEmpty(); + } + + @Test + void shouldFilterSimple() { + // given + AsyncApiGroup messageFilterGroup = AsyncApiGroup.builder() + .operationActionsToKeep(List.of()) + .channelNamesToKeep(List.of()) + .messageNamesToKeep(List.of(Pattern.compile(message3.getMessageId()))) + .build(); + + // when + AsyncAPI grouped = groupingService.groupAPI(simpleApi, messageFilterGroup); + + // then + assertThat(grouped.getChannels()).isEqualTo(Map.of(channel2.getChannelId(), channel2)); + assertThat(grouped.getOperations()).isEqualTo(Map.of("receive", receiveOperation)); + assertThat(grouped.getComponents().getMessages()).isEqualTo(Map.of(message3.getMessageId(), message3)); + assertThat(grouped.getComponents().getSchemas()).isEqualTo(Map.of(schema3.getTitle(), schema3)); + } + + @Test + void shouldFilterFull() { + // given + AsyncApiGroup messageFilterGroup = AsyncApiGroup.builder() + .operationActionsToKeep(List.of()) + .channelNamesToKeep(List.of()) + .messageNamesToKeep(List.of(Pattern.compile(message1.getMessageId()))) + .build(); + + // when + AsyncAPI grouped = groupingService.groupAPI(fullApi, messageFilterGroup); + + // then + assertThat(grouped.getOperations().keySet()).isEqualTo(Set.of("send")); + assertThat(grouped.getOperations().get("send")) + .usingRecursiveComparison() + .ignoringFields("messages") + .isEqualTo(sendOperation); + assertThat(grouped.getOperations().get("send").getMessages()) + .hasSize(1) + .isEqualTo(List.of( + MessageReference.toChannelMessage(channel1.getChannelId(), message1.getMessageId()))); + + assertThat(grouped.getChannels().keySet()).isEqualTo(Set.of(channel1.getChannelId())); + assertThat(grouped.getChannels().get(channel1.getChannelId())) + .usingRecursiveComparison() + .ignoringFields("messages") + .isEqualTo(channel1); + assertThat(grouped.getChannels().get(channel1.getChannelId()).getMessages()) + .hasSize(1) + .isEqualTo(Map.of( + message1.getMessageId(), MessageReference.toComponentMessage(message1.getMessageId()))); + + assertThat(grouped.getComponents().getMessages()).isEqualTo(Map.of(message1.getMessageId(), message1)); + + assertThat(grouped.getComponents().getSchemas()) + .isEqualTo(Map.of(schema1.getTitle(), schema1, header1Schema.getTitle(), header1Schema)); + } + } +} diff --git a/springwolf-core/src/test/java/io/github/springwolf/core/configuration/properties/SpringwolfConfigPropertiesIntegrationTest.java b/springwolf-core/src/test/java/io/github/springwolf/core/configuration/properties/SpringwolfConfigPropertiesIntegrationTest.java index a72831c39..c725cd85e 100644 --- a/springwolf-core/src/test/java/io/github/springwolf/core/configuration/properties/SpringwolfConfigPropertiesIntegrationTest.java +++ b/springwolf-core/src/test/java/io/github/springwolf/core/configuration/properties/SpringwolfConfigPropertiesIntegrationTest.java @@ -1,7 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 package io.github.springwolf.core.configuration.properties; +import io.github.springwolf.asyncapi.v3.model.operation.OperationAction; import io.github.springwolf.asyncapi.v3.model.server.Server; +import io.github.springwolf.core.configuration.properties.SpringwolfConfigProperties.ConfigDocket.Group; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -10,6 +12,7 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringExtension; +import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -165,4 +168,43 @@ void payloadDisabledTest() { assertThat(actual).containsEntry("java.util.Optional", 0); } } + + @Nested + @ExtendWith(SpringExtension.class) + @EnableConfigurationProperties(SpringwolfConfigProperties.class) + @TestPropertySource( + properties = { + "springwolf.docket.group-configs[0].group=SEND-GROUP", + "springwolf.docket.group-configs[0].action-to-match=send", + "springwolf.docket.group-configs[0].channel-name-to-match=/*,a*b", + "springwolf.docket.group-configs[0].message-name-to-match=/*", + "springwolf.docket.group-configs[1].group=", + "springwolf.docket.group-configs[1].action-to-match=", + "springwolf.docket.group-configs[1].channel-name-to-match=", + "springwolf.docket.group-configs[1].message-name-to-match=" + }) + class GroupConfigTest { + + @Autowired + private SpringwolfConfigProperties properties; + + @Test + void groupConfigIsMappedCorrectly() { + // given + Group sendGroup = new Group(); + sendGroup.setGroup("SEND-GROUP"); + sendGroup.setActionToMatch(List.of(OperationAction.SEND)); + sendGroup.setChannelNameToMatch(List.of("/*", "a*b")); + sendGroup.setMessageNameToMatch(List.of("/*")); + Group receiveGroup = new Group(); + + // when + List actual = properties.getDocket().getGroupConfigs(); + + // then + assertThat(actual).hasSize(2); + assertThat(actual.get(0)).isEqualTo(sendGroup); + assertThat(actual.get(1)).isEqualTo(receiveGroup); + } + } } diff --git a/springwolf-core/src/test/java/io/github/springwolf/core/integrationtests/AsyncApiDocumentIntegrationTest.java b/springwolf-core/src/test/java/io/github/springwolf/core/integrationtests/AsyncApiDocumentIntegrationTest.java index 826348b33..4e42053d8 100644 --- a/springwolf-core/src/test/java/io/github/springwolf/core/integrationtests/AsyncApiDocumentIntegrationTest.java +++ b/springwolf-core/src/test/java/io/github/springwolf/core/integrationtests/AsyncApiDocumentIntegrationTest.java @@ -255,4 +255,74 @@ void enumAsRefIsHandled() { .isEqualTo("\"DOG\""); } } + + @Nested + @SpringBootTest(classes = ListenerApplication.class) + @MinimalIntegrationTestContextConfiguration + @TestPropertySource( + properties = { + "springwolf.docket.base-package=io.github.springwolf.core.integrationtests.application.listener", + "springwolf.docket.group-configs[0].group=FooMessage", + "springwolf.docket.group-configs[0].action-to-match=", + "springwolf.docket.group-configs[0].channel-name-to-match=", + "springwolf.docket.group-configs[0].message-name-to-match=.*Foo", + "springwolf.docket.group-configs[1].group=all & everything", + "springwolf.docket.group-configs[1].action-to-match=", + "springwolf.docket.group-configs[1].channel-name-to-match=.*", + "springwolf.docket.group-configs[1].message-name-to-match=", + }) + class GroupingTest { + @Autowired + private AsyncApiService asyncApiService; + + @Test + void shouldFindOnlyForGroupFoo() { + AsyncAPI asyncAPI = asyncApiService.getForGroupName("FooMessage").get(); + + assertThat(asyncAPI.getChannels().keySet()).containsExactlyInAnyOrder("listener-channel"); + assertThat(asyncAPI.getChannels().get("listener-channel").getMessages()) + .containsOnlyKeys( + "io.github.springwolf.core.integrationtests.application.listener.ListenerApplication$Foo"); + assertThat(asyncAPI.getOperations()) + .containsOnlyKeys("listener-channel_receive_listen3", "listener-channel_receive_listen4"); + assertThat(asyncAPI.getComponents().getMessages()) + .containsOnlyKeys( + "io.github.springwolf.core.integrationtests.application.listener.ListenerApplication$Foo"); + assertThat(asyncAPI.getComponents().getSchemas()) + .containsOnlyKeys( + "HeadersNotDocumented", + "io.github.springwolf.core.integrationtests.application.listener.ListenerApplication$Bar", + "io.github.springwolf.core.integrationtests.application.listener.ListenerApplication$Foo"); + + MessageObject fooMessage = (MessageObject) asyncAPI.getComponents() + .getMessages() + .get("io.github.springwolf.core.integrationtests.application.listener.ListenerApplication$Foo"); + assertThat(fooMessage.getPayload().getMultiFormatSchema().getSchema()) + .isInstanceOf(MessageReference.class); + MessageReference fooRefMessage = (MessageReference) + fooMessage.getPayload().getMultiFormatSchema().getSchema(); + assertThat(fooRefMessage.getRef()) + .isEqualTo( + "#/components/schemas/io.github.springwolf.core.integrationtests.application.listener.ListenerApplication$Foo"); + } + + @Test + void shouldFindAllForGroupAll() { + // given + AsyncAPI fullApi = asyncApiService.getAsyncAPI(); + + // when + AsyncAPI asyncAPIOpt = + asyncApiService.getForGroupName("all & everything").get(); + + // then + + // String and Integer get filtered. + // Question: Why are they in the fullApi in the first place, if not referenced? (inline schema) + fullApi.getComponents().getSchemas().remove(String.class.getName()); + fullApi.getComponents().getSchemas().remove(Integer.class.getName()); + + assertThat(asyncAPIOpt).isEqualTo(fullApi); + } + } } diff --git a/springwolf-core/src/test/java/io/github/springwolf/core/integrationtests/InitModeIntegrationTest.java b/springwolf-core/src/test/java/io/github/springwolf/core/integrationtests/InitModeIntegrationTest.java index 85a95d13e..41f18b7a2 100644 --- a/springwolf-core/src/test/java/io/github/springwolf/core/integrationtests/InitModeIntegrationTest.java +++ b/springwolf-core/src/test/java/io/github/springwolf/core/integrationtests/InitModeIntegrationTest.java @@ -71,7 +71,7 @@ void applicationShouldNotStart() { SpringApplication.run(TestApplication.class, args); fail("Exception expected, but not raised."); } catch (Exception ex) { - assertThat(ex.getMessage()).contains("Error occured during creation of AsyncAPI"); + assertThat(ex.getMessage()).contains("Error occurred during creation of AsyncAPI"); } } } diff --git a/springwolf-examples/springwolf-kafka-example/src/main/resources/application.properties b/springwolf-examples/springwolf-kafka-example/src/main/resources/application.properties index 215a84849..aec88ed49 100644 --- a/springwolf-examples/springwolf-kafka-example/src/main/resources/application.properties +++ b/springwolf-examples/springwolf-kafka-example/src/main/resources/application.properties @@ -40,6 +40,9 @@ springwolf.docket.info.contact.url=https://github.com/springwolf/springwolf-core springwolf.docket.info.license.name=Apache License 2.0 springwolf.payload.extractable-classes.org.apache.kafka.clients.consumer.ConsumerRecord=1 +springwolf.docket.group-configs[0].group=Only Vehicles +springwolf.docket.group-configs[0].message-name-to-match=.*Vehicle.* + # Springwolf kafka configuration springwolf.docket.servers.kafka-server.protocol=kafka springwolf.docket.servers.kafka-server.host=${spring.kafka.bootstrap-servers} diff --git a/springwolf-examples/springwolf-kafka-example/src/test/java/io/github/springwolf/examples/kafka/ApiIntegrationTest.java b/springwolf-examples/springwolf-kafka-example/src/test/java/io/github/springwolf/examples/kafka/ApiIntegrationTest.java index fd940ba55..20235b427 100644 --- a/springwolf-examples/springwolf-kafka-example/src/test/java/io/github/springwolf/examples/kafka/ApiIntegrationTest.java +++ b/springwolf-examples/springwolf-kafka-example/src/test/java/io/github/springwolf/examples/kafka/ApiIntegrationTest.java @@ -53,4 +53,30 @@ void asyncApiResourceArtifactYamlTest() throws IOException { assertEquals(expected, actualPatched); } + + @Test + void asyncApiResourceForVehicleGroupArtifactTest() throws IOException { + String url = "/springwolf/docs/Only Vehicles"; + String actual = restTemplate.getForObject(url, String.class); + // When running with EmbeddedKafka, the kafka bootstrap server does run on random ports + String actualPatched = actual.replace(bootstrapServers, "kafka:29092").trim(); + Files.writeString(Path.of("src", "test", "resources", "groups", "vehicles.actual.json"), actualPatched); + + InputStream s = this.getClass().getResourceAsStream("/groups/vehicles.json"); + String expected = new String(s.readAllBytes(), StandardCharsets.UTF_8).trim(); + + assertEquals(expected, actualPatched); + } + + @Test + void uiConfigTest() throws IOException { + String url = "/springwolf/ui-config"; + String actual = restTemplate.getForObject(url, String.class); + Files.writeString(Path.of("src", "test", "resources", "ui-config.actual.json"), actual); + + InputStream s = this.getClass().getResourceAsStream("/ui-config.json"); + String expected = new String(s.readAllBytes(), StandardCharsets.UTF_8).trim(); + + assertEquals(expected, actual); + } } diff --git a/springwolf-examples/springwolf-kafka-example/src/test/resources/groups/vehicles.json b/springwolf-examples/springwolf-kafka-example/src/test/resources/groups/vehicles.json new file mode 100644 index 000000000..848db8665 --- /dev/null +++ b/springwolf-examples/springwolf-kafka-example/src/test/resources/groups/vehicles.json @@ -0,0 +1,157 @@ +{ + "asyncapi": "3.0.0", + "info": { + "title": "Springwolf example project - Kafka", + "version": "1.0.0", + "description": "Springwolf example project to demonstrate springwolfs abilities, including **markdown** support for descriptions.", + "contact": { + "name": "springwolf", + "url": "https://github.com/springwolf/springwolf-core", + "email": "example@example.com" + }, + "license": { + "name": "Apache License 2.0" + }, + "x-generator": "springwolf" + }, + "defaultContentType": "application/json", + "servers": { + "kafka-server": { + "host": "kafka:29092", + "protocol": "kafka" + } + }, + "channels": { + "vehicle-topic": { + "address": "vehicle-topic", + "messages": { + "io.github.springwolf.examples.kafka.dtos.discriminator.VehicleBase": { + "$ref": "#/components/messages/io.github.springwolf.examples.kafka.dtos.discriminator.VehicleBase" + } + }, + "bindings": { + "kafka": { + "bindingVersion": "0.5.0" + } + } + } + }, + "components": { + "schemas": { + "SpringKafkaDefaultHeaders-VehicleBase": { + "title": "SpringKafkaDefaultHeaders-VehicleBase", + "type": "object", + "properties": { + "__TypeId__": { + "type": "string", + "description": "Spring Type Id Header", + "enum": [ + "io.github.springwolf.examples.kafka.dtos.discriminator.VehicleBase" + ], + "examples": [ + "io.github.springwolf.examples.kafka.dtos.discriminator.VehicleBase" + ] + } + }, + "examples": [ + { + "__TypeId__": "io.github.springwolf.examples.kafka.dtos.discriminator.VehicleBase" + } + ], + "x-json-schema": { + "$schema": "https://json-schema.org/draft-04/schema#", + "properties": { + "__TypeId__": { + "description": "Spring Type Id Header", + "enum": [ + "io.github.springwolf.examples.kafka.dtos.discriminator.VehicleBase" + ], + "type": "string" + } + }, + "title": "SpringKafkaDefaultHeaders-VehicleBase", + "type": "object" + } + }, + "io.github.springwolf.examples.kafka.dtos.discriminator.VehicleBase": { + "discriminator": "vehicleType", + "title": "VehicleBase", + "type": "object", + "properties": { + "powerSource": { + "type": "string" + }, + "topSpeed": { + "type": "integer", + "format": "int32" + }, + "vehicleType": { + "type": "string" + } + }, + "description": "Demonstrates the use of discriminator for polymorphic deserialization (not publishable)", + "examples": [ + { + "powerSource": "string", + "topSpeed": 0, + "vehicleType": "string" + } + ], + "x-json-schema": { + "$schema": "https://json-schema.org/draft-04/schema#", + "description": "Demonstrates the use of discriminator for polymorphic deserialization (not publishable)", + "properties": { + "powerSource": { + "type": "string" + }, + "topSpeed": { + "format": "int32", + "type": "integer" + }, + "vehicleType": { } + }, + "title": "VehicleBase", + "type": "object" + } + } + }, + "messages": { + "io.github.springwolf.examples.kafka.dtos.discriminator.VehicleBase": { + "headers": { + "$ref": "#/components/schemas/SpringKafkaDefaultHeaders-VehicleBase" + }, + "payload": { + "schemaFormat": "application/vnd.aai.asyncapi+json;version=3.0.0", + "schema": { + "$ref": "#/components/schemas/io.github.springwolf.examples.kafka.dtos.discriminator.VehicleBase" + } + }, + "name": "io.github.springwolf.examples.kafka.dtos.discriminator.VehicleBase", + "title": "VehicleBase", + "bindings": { + "kafka": { + "bindingVersion": "0.5.0" + } + } + } + } + }, + "operations": { + "vehicle-topic_receive_receiveExamplePayload": { + "action": "receive", + "channel": { + "$ref": "#/channels/vehicle-topic" + }, + "bindings": { + "kafka": { + "bindingVersion": "0.5.0" + } + }, + "messages": [ + { + "$ref": "#/channels/vehicle-topic/messages/io.github.springwolf.examples.kafka.dtos.discriminator.VehicleBase" + } + ] + } + } +} \ No newline at end of file diff --git a/springwolf-examples/springwolf-kafka-example/src/test/resources/ui-config.json b/springwolf-examples/springwolf-kafka-example/src/test/resources/ui-config.json new file mode 100644 index 000000000..e44f969cf --- /dev/null +++ b/springwolf-examples/springwolf-kafka-example/src/test/resources/ui-config.json @@ -0,0 +1 @@ +{"groups":[{"name":"Only Vehicles"}]} \ No newline at end of file diff --git a/springwolf-ui/src/app/app.component.ts b/springwolf-ui/src/app/app.component.ts index dfc2c28fe..19663f8dc 100644 --- a/springwolf-ui/src/app/app.component.ts +++ b/springwolf-ui/src/app/app.component.ts @@ -1,6 +1,5 @@ /* SPDX-License-Identifier: Apache-2.0 */ -import { Component, OnInit } from "@angular/core"; -import { UiService } from "./service/ui.service"; +import { Component } from "@angular/core"; @Component({ selector: "app-root", diff --git a/springwolf-ui/src/app/app.module.ts b/springwolf-ui/src/app/app.module.ts index 190022796..7bb7b291a 100644 --- a/springwolf-ui/src/app/app.module.ts +++ b/springwolf-ui/src/app/app.module.ts @@ -23,7 +23,7 @@ import { FormsModule } from "@angular/forms"; import { JsonComponent } from "./components/json/json.component"; import { AsyncApiMapperService } from "./service/asyncapi/asyncapi-mapper.service"; import { MarkdownModule, provideMarkdown } from "ngx-markdown"; -import { UiService } from "./service/ui.service"; +import { IUiService, UiService } from "./service/ui.service"; import { provideAnimationsAsync } from "@angular/platform-browser/animations/async"; import { SidenavComponent } from "./components/sidenav/sidenav.component"; import { NavigationTargetDirective } from "./components/sidenav/navigation.directive"; @@ -80,7 +80,7 @@ export const providers = [ AsyncApiMapperService, { provide: INotificationService, useClass: NotificationService }, PublisherService, - UiService, + { provide: IUiService, useClass: UiService }, ]; export const ngModule = { diff --git a/springwolf-ui/src/app/components/channels/channel-main/channel-operation.component.spec.ts b/springwolf-ui/src/app/components/channels/channel-main/channel-operation.component.spec.ts index 24d1a6266..160fce253 100644 --- a/springwolf-ui/src/app/components/channels/channel-main/channel-operation.component.spec.ts +++ b/springwolf-ui/src/app/components/channels/channel-main/channel-operation.component.spec.ts @@ -9,11 +9,13 @@ import { mockedAsyncApiService, mockedExampleSchemaMapped, } from "../../../service/mock/mock-asyncapi.service"; +import { mockedUiService } from "../../../service/mock/mock-ui.service"; import { MockAppJson, MockAppSchemaNewComponent, MockPrismEditorComponent, } from "../../mock-components.spec"; +import { IUiService } from "../../../service/ui.service"; describe("ChannelOperationComponent", () => { const mockData = mockedExampleSchemaMapped.channelOperations @@ -31,6 +33,7 @@ describe("ChannelOperationComponent", () => { ], imports: [MaterialModule, MarkdownModule.forRoot()], providers: [ + { provide: IUiService, useValue: mockedUiService }, { provide: AsyncApiService, useValue: mockedAsyncApiService }, { provide: PublisherService, useValue: {} }, ], diff --git a/springwolf-ui/src/app/components/channels/channel-main/channel-operation.component.ts b/springwolf-ui/src/app/components/channels/channel-main/channel-operation.component.ts index d20193e6d..8a94fb6a1 100644 --- a/springwolf-ui/src/app/components/channels/channel-main/channel-operation.component.ts +++ b/springwolf-ui/src/app/components/channels/channel-main/channel-operation.component.ts @@ -14,7 +14,7 @@ import { initSchema, noExample, } from "../../../service/mock/init-values"; -import { UiService } from "../../../service/ui.service"; +import { IUiService } from "../../../service/ui.service"; @Component({ selector: "app-channel-operation", @@ -39,14 +39,14 @@ export class ChannelOperationComponent implements OnInit { operationBindingExampleString?: string; messageBindingExampleString?: string; - isShowBindings: boolean = UiService.DEFAULT_SHOW_BINDINGS; - isShowHeaders: boolean = UiService.DEFAULT_SHOW_HEADERS; + isShowBindings: boolean = IUiService.DEFAULT_SHOW_BINDINGS; + isShowHeaders: boolean = IUiService.DEFAULT_SHOW_HEADERS; canPublish: boolean = false; constructor( private asyncApiService: AsyncApiService, private publisherService: PublisherService, - private uiService: UiService, + private uiService: IUiService, private snackBar: MatSnackBar ) {} diff --git a/springwolf-ui/src/app/components/channels/channels.component.spec.ts b/springwolf-ui/src/app/components/channels/channels.component.spec.ts index 6e2a8343f..a9e4999c5 100644 --- a/springwolf-ui/src/app/components/channels/channels.component.spec.ts +++ b/springwolf-ui/src/app/components/channels/channels.component.spec.ts @@ -6,8 +6,13 @@ import { mockedAsyncApiService, mockedExampleSchemaMapped, } from "../../service/mock/mock-asyncapi.service"; +import { mockedUiService } from "../../service/mock/mock-ui.service"; import { MaterialModule } from "../../material.module"; -import { MockChannelOperationComponent } from "../mock-components.spec"; +import { + MockChannelOperationComponent, + MockPrismEditorComponent, +} from "../mock-components.spec"; +import { IUiService } from "../../service/ui.service"; describe("ChannelsNewComponent", () => { beforeEach(async () => { @@ -15,8 +20,9 @@ describe("ChannelsNewComponent", () => { await render(ChannelsComponent, { imports: [MaterialModule], - declarations: [MockChannelOperationComponent], + declarations: [MockChannelOperationComponent, MockPrismEditorComponent], providers: [ + { provide: IUiService, useValue: mockedUiService }, { provide: AsyncApiService, useValue: mockedAsyncApiService }, ], }); diff --git a/springwolf-ui/src/app/components/channels/channels.component.ts b/springwolf-ui/src/app/components/channels/channels.component.ts index 4f58368ba..63ba6aaf1 100644 --- a/springwolf-ui/src/app/components/channels/channels.component.ts +++ b/springwolf-ui/src/app/components/channels/channels.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit } from "@angular/core"; import { AsyncApiService } from "../../service/asyncapi/asyncapi.service"; import { Channel } from "../../models/channel.model"; -import { UiService } from "../../service/ui.service"; +import { IUiService } from "../../service/ui.service"; @Component({ selector: "app-channels", @@ -11,12 +11,12 @@ import { UiService } from "../../service/ui.service"; }) export class ChannelsComponent implements OnInit { channels: Channel[] = []; - isShowBindings: boolean = UiService.DEFAULT_SHOW_BINDINGS; + isShowBindings: boolean = IUiService.DEFAULT_SHOW_BINDINGS; JSON = JSON; constructor( private asyncApiService: AsyncApiService, - private uiService: UiService + private uiService: IUiService ) {} ngOnInit(): void { diff --git a/springwolf-ui/src/app/components/header/header.component.html b/springwolf-ui/src/app/components/header/header.component.html index 1b0ecb35d..019f4081d 100644 --- a/springwolf-ui/src/app/components/header/header.component.html +++ b/springwolf-ui/src/app/components/header/header.component.html @@ -18,11 +18,32 @@

{{ title }}

- + + + @for (group of groups; track group) { + + } + +