From ef244bc07e9281fc15ef24b803c3bfd1f7d1657b Mon Sep 17 00:00:00 2001 From: Yevhenii Semenov Date: Tue, 11 Nov 2025 18:19:21 +0200 Subject: [PATCH 1/2] Make DocumentationSource configurable Introduces a generic StrategyLookup utility and makes DocumentationSource configurable via the spring.modulith.documentation-source property, following the same pattern as ApplicationModuleDetectionStrategy. Key changes: - Create generic StrategyLookup utility in spring-modulith-core for reusable configuration-based strategy lookups - Refactor ApplicationModuleDetectionStrategyLookup to use the generic StrategyLookup utility, reducing code duplication - Add DocumentationSourceLookup with support for predefined strategies and custom implementations via property configuration - Add NoOpDocumentationSource as fallback implementation - Update Asciidoctor to use DocumentationSourceLookup instead of hardcoded static field - Make DocumentationSource interface public (was package-private) and add getDocumentation(JavaPackage) method for package-level docs - Add spring-configuration-metadata.json for IDE autocomplete support - Add comprehensive test coverage with DocumentationSourceLookupTests The implementation supports: - Predefined strategy: 'spring-modulith' (uses SpringModulithDocumentationSource) - Custom implementations via fully qualified class name - SpringFactoriesLoader fallback with deprecation warning (for compatibility) - IDE autocomplete via configuration metadata Signed-off-by: Yevhenii Semenov --- ...licationModuleDetectionStrategyLookup.java | 80 ++--------- .../modulith/core/config/StrategyLookup.java | 134 ++++++++++++++++++ .../modulith/docs/Asciidoctor.java | 63 +++----- .../modulith/docs/DocumentationSource.java | 2 +- .../docs/DocumentationSourceLookup.java | 85 +++++++++++ .../docs/NoOpDocumentationSource.java | 44 ++++++ .../spring-configuration-metadata.json | 28 ++++ .../modulith/docs/AsciidoctorUnitTests.java | 4 +- .../docs/DocumentationSourceLookupTests.java | 86 +++++++++++ .../custom-type.properties | 1 + .../spring-modulith.properties | 1 + 11 files changed, 417 insertions(+), 111 deletions(-) create mode 100644 spring-modulith-core/src/main/java/org/springframework/modulith/core/config/StrategyLookup.java create mode 100644 spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSourceLookup.java create mode 100644 spring-modulith-docs/src/main/java/org/springframework/modulith/docs/NoOpDocumentationSource.java create mode 100644 spring-modulith-docs/src/main/resources/META-INF/spring-configuration-metadata.json create mode 100644 spring-modulith-docs/src/test/java/org/springframework/modulith/docs/DocumentationSourceLookupTests.java create mode 100644 spring-modulith-docs/src/test/resources/documentation-source/custom-type.properties create mode 100644 spring-modulith-docs/src/test/resources/documentation-source/spring-modulith.properties diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategyLookup.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategyLookup.java index a6fcc7f44..3f7eac09e 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategyLookup.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategyLookup.java @@ -15,18 +15,10 @@ */ package org.springframework.modulith.core; -import java.util.List; -import java.util.function.Supplier; +import org.springframework.modulith.core.config.StrategyLookup; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.BeanUtils; -import org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor; -import org.springframework.core.env.StandardEnvironment; -import org.springframework.core.io.DefaultResourceLoader; -import org.springframework.core.io.support.SpringFactoriesLoader; -import org.springframework.util.ClassUtils; -import org.springframework.util.StringUtils; +import java.util.Map; +import java.util.function.Supplier; /** * A factory for the {@link ApplicationModuleDetectionStrategy} to be used when scanning code for @@ -37,35 +29,6 @@ class ApplicationModuleDetectionStrategyLookup { private static final String DETECTION_STRATEGY_PROPERTY = "spring.modulith.detection-strategy"; - private static final Logger LOG = LoggerFactory.getLogger(ApplicationModuleDetectionStrategyLookup.class); - private static final Supplier FALLBACK_DETECTION_STRATEGY; - - static { - - FALLBACK_DETECTION_STRATEGY = () -> { - - List loadFactories = SpringFactoriesLoader.loadFactories( - ApplicationModuleDetectionStrategy.class, ApplicationModules.class.getClassLoader()); - - var size = loadFactories.size(); - - if (size == 0) { - return ApplicationModuleDetectionStrategy.directSubPackage(); - } - - if (size > 1) { - - throw new IllegalStateException( - "Multiple module detection strategies configured. Only one supported! %s".formatted(loadFactories)); - } - - LOG.warn( - "Configuring the application module detection strategy via spring.factories is deprecated! Please configure {} instead.", - DETECTION_STRATEGY_PROPERTY); - - return loadFactories.get(0); - }; - } /** * Returns the {@link ApplicationModuleDetectionStrategy} to be used to detect {@link ApplicationModule}s. Will use @@ -74,7 +37,7 @@ class ApplicationModuleDetectionStrategyLookup { *
  • Use the prepared strategies if either {@code direct-sub-packages} or {@code explicitly-annotated} is configured * for the {@code spring.modulith.detection-strategy} configuration property.
  • *
  • Interpret the configured value as class if it doesn't match the predefined values just described.
  • - *
  • Use the {@link ApplicationModuleDetectionStrategy} declared in {@code META-INF/spring.properties} + *
  • Use the {@link ApplicationModuleDetectionStrategy} declared in {@code META-INF/spring.factories} * (deprecated)
  • *
  • A final fallback on the {@code direct-sub-packages}.
  • * @@ -83,33 +46,16 @@ class ApplicationModuleDetectionStrategyLookup { */ static ApplicationModuleDetectionStrategy getStrategy() { - var environment = new StandardEnvironment(); - ConfigDataEnvironmentPostProcessor.applyTo(environment, - new DefaultResourceLoader(ApplicationModuleDetectionStrategyLookup.class.getClassLoader()), null); - - var configuredStrategy = environment.getProperty(DETECTION_STRATEGY_PROPERTY, String.class); - - // Nothing configured? Use fallback. - if (!StringUtils.hasText(configuredStrategy)) { - return FALLBACK_DETECTION_STRATEGY.get(); - } - - // Any of the prepared ones? - switch (configuredStrategy) { - case "direct-sub-packages": - return ApplicationModuleDetectionStrategy.directSubPackage(); - case "explicitly-annotated": - return ApplicationModuleDetectionStrategy.explicitlyAnnotated(); - } - - try { + Map> predefinedStrategies = Map.of( + "direct-sub-packages", ApplicationModuleDetectionStrategy::directSubPackage, + "explicitly-annotated", ApplicationModuleDetectionStrategy::explicitlyAnnotated); - // Lookup configured value as class - var strategyType = ClassUtils.forName(configuredStrategy, ApplicationModules.class.getClassLoader()); - return BeanUtils.instantiateClass(strategyType, ApplicationModuleDetectionStrategy.class); + var lookup = new StrategyLookup<>( + DETECTION_STRATEGY_PROPERTY, + ApplicationModuleDetectionStrategy.class, + predefinedStrategies, + ApplicationModuleDetectionStrategy::directSubPackage); - } catch (ClassNotFoundException | LinkageError o_O) { - throw new IllegalStateException(o_O); - } + return lookup.lookup(); } } diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/config/StrategyLookup.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/config/StrategyLookup.java new file mode 100644 index 000000000..4a6cff425 --- /dev/null +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/config/StrategyLookup.java @@ -0,0 +1,134 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.core.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Generic utility for looking up strategy implementations based on configuration. Supports property-based + * configuration, predefined strategies, custom class instantiation, and {@link SpringFactoriesLoader} fallback. + * + * @param the strategy type + * @since 1.4 + */ +public class StrategyLookup { + + private static final Logger LOG = LoggerFactory.getLogger(StrategyLookup.class); + + private final String propertyName; + private final Class strategyType; + private final Map> predefinedStrategies; + private final Supplier fallbackSupplier; + + /** + * Creates a new {@link StrategyLookup} instance. + * + * @param propertyName the configuration property name (e.g., "spring.modulith.detection-strategy") + * @param strategyType the strategy class + * @param predefinedStrategies map of predefined strategy names to their suppliers + * @param fallbackSupplier the fallback strategy supplier + */ + public StrategyLookup(String propertyName, Class strategyType, Map> predefinedStrategies, + Supplier fallbackSupplier) { + + this.propertyName = propertyName; + this.strategyType = strategyType; + this.predefinedStrategies = predefinedStrategies; + this.fallbackSupplier = fallbackSupplier; + } + + /** + * Looks up and returns the strategy implementation using the following algorithm: + *
      + *
    1. Use the predefined strategies if the configured property value matches one of them.
    2. + *
    3. Interpret the configured value as a class name if it doesn't match the predefined values.
    4. + *
    5. Use the {@link SpringFactoriesLoader} if no property is configured (deprecated).
    6. + *
    7. A final fallback on the provided fallback supplier.
    8. + *
    + * + * @return the strategy implementation, never {@literal null} + */ + public T lookup() { + + var environment = new StandardEnvironment(); + ConfigDataEnvironmentPostProcessor.applyTo(environment, + new DefaultResourceLoader(StrategyLookup.class.getClassLoader()), null); + + var configuredStrategy = environment.getProperty(propertyName, String.class); + + // Nothing configured? Use SpringFactoriesLoader or fallback + if (!StringUtils.hasText(configuredStrategy)) { + return lookupViaSpringFactoriesOrFallback(); + } + + // Check predefined strategies + var predefined = predefinedStrategies.get(configuredStrategy); + + if (predefined != null) { + return predefined.get(); + } + + // Try to load configured value as class + try { + + var strategyClass = ClassUtils.forName(configuredStrategy, strategyType.getClassLoader()); + return BeanUtils.instantiateClass(strategyClass, strategyType); + + } catch (ClassNotFoundException | LinkageError o_O) { + throw new IllegalStateException("Unable to load strategy class: " + configuredStrategy, o_O); + } + } + + /** + * Attempts to load strategy via {@link SpringFactoriesLoader} (deprecated), falling back to the fallback supplier + * if none found. + * + * @return the strategy implementation, never {@literal null} + */ + private T lookupViaSpringFactoriesOrFallback() { + + List loadFactories = SpringFactoriesLoader.loadFactories(strategyType, strategyType.getClassLoader()); + + var size = loadFactories.size(); + + if (size == 0) { + return fallbackSupplier.get(); + } + + if (size > 1) { + throw new IllegalStateException( + "Multiple strategies configured via spring.factories. Only one supported! %s".formatted(loadFactories)); + } + + LOG.warn( + "Configuring strategy via spring.factories is deprecated! Please configure {} instead.", + propertyName); + + return loadFactories.get(0); + } +} diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java index 0fc2195a4..43c7309d8 100644 --- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java @@ -15,36 +15,27 @@ */ package org.springframework.modulith.docs; -import static java.util.stream.Collectors.*; -import static org.springframework.util.ClassUtils.*; - -import java.util.Collection; -import java.util.List; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Stream; - +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaModifier; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.modulith.core.ApplicationModule; -import org.springframework.modulith.core.ApplicationModuleDependency; -import org.springframework.modulith.core.ApplicationModules; -import org.springframework.modulith.core.ArchitecturallyEvidentType; +import org.springframework.modulith.core.*; import org.springframework.modulith.core.ArchitecturallyEvidentType.ReferenceMethod; -import org.springframework.modulith.core.DependencyType; -import org.springframework.modulith.core.EventType; -import org.springframework.modulith.core.FormattableType; -import org.springframework.modulith.core.Source; -import org.springframework.modulith.core.SpringBean; import org.springframework.modulith.docs.ConfigurationProperties.ModuleProperty; import org.springframework.modulith.docs.Documenter.CanvasOptions; import org.springframework.util.Assert; import org.springframework.util.StringUtils; -import com.tngtech.archunit.core.domain.JavaClass; -import com.tngtech.archunit.core.domain.JavaModifier; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.joining; +import static org.springframework.util.ClassUtils.convertClassNameToResourcePath; /** * @author Oliver Drotbohm @@ -56,11 +47,9 @@ class Asciidoctor { private static final Pattern LINE_BREAKS = Pattern.compile("\\<\\s*br\\s*\\>"); private static final Logger LOG = LoggerFactory.getLogger(Asciidoctor.class); - private static final Optional DOC_SOURCE = getSpringModulithDocsSource(); - private final ApplicationModules modules; private final String javaDocBase; - private final Optional docSource; + private final DocumentationSource docSource; private Asciidoctor(ApplicationModules modules, String javaDocBase) { @@ -69,13 +58,15 @@ private Asciidoctor(ApplicationModules modules, String javaDocBase) { this.javaDocBase = javaDocBase; this.modules = modules; - this.docSource = DOC_SOURCE.map(it -> new CodeReplacingDocumentationSource(it, this)); + + var rawSource = DocumentationSourceLookup.getDocumentationSource(); + this.docSource = new CodeReplacingDocumentationSource(rawSource, this); } /** * Creates a new {@link Asciidoctor} instance for the given {@link ApplicationModules} and Javadoc base URI. * - * @param modules must not be {@literal null}. + * @param modules must not be {@literal null}. * @param javadocBase can be {@literal null}. * @return will never be {@literal null}. */ @@ -103,7 +94,7 @@ public String toInlineCode(String source) { var parts = source.split("#"); var type = parts[0]; - var methodSignature = parts.length == 2 ? Optional.of(parts[1]) : Optional. empty(); + var methodSignature = parts.length == 2 ? Optional.of(parts[1]) : Optional.empty(); if (type.isBlank()) { return methodSignature.map(Asciidoctor::toCode).orElse(source); @@ -138,7 +129,7 @@ public String toInlineCode(SpringBean bean) { private String withDocumentation(String base, JavaClass type) { - return docSource.flatMap(it -> it.getDocumentation(type)) + return docSource.getDocumentation(type) .map(it -> base + " -- " + it) .orElse(base); } @@ -194,7 +185,7 @@ public String renderPublishedEvents(ApplicationModule module) { continue; } - var documentation = docSource.flatMap(it -> it.getDocumentation(eventType.getType())) + var documentation = docSource.getDocumentation(eventType.getType()) .map(" -- "::concat); builder.append("* ") @@ -333,7 +324,7 @@ private String renderReferenceMethod(ReferenceMethod it, int level) { var isAsync = it.isAsync() ? "(async) " : ""; var indent = "*".repeat(level + 1); - return docSource.flatMap(source -> source.getDocumentation(method)) + return docSource.getDocumentation(method) .map(doc -> "%s %s %s-- %s".formatted(indent, toInlineCode(exposedReferenceTypes), isAsync, doc)) .orElseGet(() -> "%s %s %s".formatted(indent, toInlineCode(exposedReferenceTypes), isAsync)); } @@ -411,7 +402,7 @@ public String renderBeanReferences(ApplicationModule module) { } public String renderModuleDescription(ApplicationModule module) { - return docSource.flatMap(it -> it.getDocumentation(module.getBasePackage())).orElse(""); + return docSource.getDocumentation(module.getBasePackage()).orElse(""); } public String renderHeadline(int i, String modules) { @@ -426,16 +417,6 @@ public String renderGeneralInclude(String componentsFilename) { return "include::" + componentsFilename + "[]" + System.lineSeparator(); } - private static Optional getSpringModulithDocsSource() { - - return SpringModulithDocumentationSource.getInstance() - .map(it -> { - LOG.debug("Using Javadoc extracted by Spring Modulith in {}.", - SpringModulithDocumentationSource.getMetadataLocation()); - return it; - }); - } - private static final String wrap(String source, String chars) { return chars + source + chars; } diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSource.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSource.java index 2b1f799c4..ef61c2843 100644 --- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSource.java +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSource.java @@ -27,7 +27,7 @@ * * @author Oliver Drotbohm */ -interface DocumentationSource { +public interface DocumentationSource { /** * Returns the documentation to be used for the given {@link JavaMethod}. diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSourceLookup.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSourceLookup.java new file mode 100644 index 000000000..10a85a097 --- /dev/null +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSourceLookup.java @@ -0,0 +1,85 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.docs; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.modulith.core.config.StrategyLookup; + +import java.util.Map; +import java.util.function.Supplier; + +/** + * A factory for the {@link DocumentationSource} to be used when generating documentation. + */ +class DocumentationSourceLookup { + + private static final String DOCUMENTATION_SOURCE_PROPERTY = "spring.modulith.documentation-source"; + private static final Logger LOG = LoggerFactory.getLogger(DocumentationSourceLookup.class); + + /** + * Returns the {@link DocumentationSource} to be used for documentation generation. Will use the following + * algorithm: + *
      + *
    1. Use the predefined strategy if {@code spring-modulith} is configured for the + * {@code spring.modulith.documentation-source} configuration property.
    2. + *
    3. Interpret the configured value as class if it doesn't match the predefined value.
    4. + *
    5. Use the {@link DocumentationSource} declared in {@code META-INF/spring.factories} (deprecated)
    6. + *
    7. A final fallback on {@link SpringModulithDocumentationSource} or {@link NoOpDocumentationSource} if the + * metadata file is not available.
    8. + *
    + * + * @return will never be {@literal null}. + */ + static DocumentationSource getDocumentationSource() { + + Map> predefinedStrategies = Map.of( + "spring-modulith", DocumentationSourceLookup::getSpringModulithDocumentationSource); + + var lookup = new StrategyLookup<>( + DOCUMENTATION_SOURCE_PROPERTY, + DocumentationSource.class, + predefinedStrategies, + DocumentationSourceLookup::getDefaultDocumentationSource); + + return lookup.lookup(); + } + + /** + * Returns the Spring Modulith documentation source, or a no-op source if metadata is not available. + * + * @return will never be {@literal null}. + */ + private static DocumentationSource getSpringModulithDocumentationSource() { + + return SpringModulithDocumentationSource.getInstance() + .map(it -> { + LOG.debug("Using Javadoc extracted by Spring Modulith in {}.", + SpringModulithDocumentationSource.getMetadataLocation()); + return it; + }) + .orElseGet(NoOpDocumentationSource::new); + } + + /** + * Returns the default documentation source (Spring Modulith or no-op). + * + * @return will never be {@literal null}. + */ + private static DocumentationSource getDefaultDocumentationSource() { + return getSpringModulithDocumentationSource(); + } +} diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/NoOpDocumentationSource.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/NoOpDocumentationSource.java new file mode 100644 index 000000000..8a290f4d6 --- /dev/null +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/NoOpDocumentationSource.java @@ -0,0 +1,44 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.docs; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaMethod; +import org.springframework.modulith.core.JavaPackage; + +import java.util.Optional; + +/** + * A no-op {@link DocumentationSource} that returns empty {@link Optional}s for all documentation lookups. Used as a + * fallback when no documentation source is available. + */ +class NoOpDocumentationSource implements DocumentationSource { + + @Override + public Optional getDocumentation(JavaMethod method) { + return Optional.empty(); + } + + @Override + public Optional getDocumentation(JavaClass type) { + return Optional.empty(); + } + + @Override + public Optional getDocumentation(JavaPackage pkg) { + return Optional.empty(); + } +} diff --git a/spring-modulith-docs/src/main/resources/META-INF/spring-configuration-metadata.json b/spring-modulith-docs/src/main/resources/META-INF/spring-configuration-metadata.json new file mode 100644 index 000000000..afa34126b --- /dev/null +++ b/spring-modulith-docs/src/main/resources/META-INF/spring-configuration-metadata.json @@ -0,0 +1,28 @@ +{ + "properties": [ + { + "name": "spring.modulith.documentation-source", + "type": "java.lang.String", + "description": "The documentation source to use for extracting Javadoc comments." + } + ], + "hints": [ + { + "name": "spring.modulith.documentation-source", + "values": [ + { + "value": "spring-modulith", + "description": "Uses Javadoc metadata extracted by Spring Modulith APT processor." + } + ], + "providers": [ + { + "name": "class-reference", + "parameters": { + "target": "org.springframework.modulith.docs.DocumentationSource" + } + } + ] + } + ] +} diff --git a/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/AsciidoctorUnitTests.java b/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/AsciidoctorUnitTests.java index 26f2272a2..53207fbc2 100644 --- a/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/AsciidoctorUnitTests.java +++ b/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/AsciidoctorUnitTests.java @@ -48,8 +48,8 @@ void rendersLinkToMethodReference() { @Test void doesNotRenderLinkToMethodReferenceForNonPublicType() { - assertThat(asciidoctor.toInlineCode("DocumentationSource#getDocumentation(JavaMethod)")) - .isEqualTo("`o.s.m.d.DocumentationSource#getDocumentation(JavaMethod)`"); + assertThat(asciidoctor.toInlineCode("ConfigurationProperties#getModuleProperties(ApplicationModule)")) + .isEqualTo("`o.s.m.d.ConfigurationProperties#getModuleProperties(ApplicationModule)`"); } @Test diff --git a/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/DocumentationSourceLookupTests.java b/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/DocumentationSourceLookupTests.java new file mode 100644 index 000000000..d3d44c284 --- /dev/null +++ b/spring-modulith-docs/src/test/java/org/springframework/modulith/docs/DocumentationSourceLookupTests.java @@ -0,0 +1,86 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.docs; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaMethod; +import org.junit.jupiter.api.Test; +import org.springframework.modulith.core.JavaPackage; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link DocumentationSourceLookup}. + */ +class DocumentationSourceLookupTests { + + @Test + void usesSpringModulithSourceIfConfigured() { + + System.setProperty("spring.config.additional-location", "classpath:documentation-source/spring-modulith.properties"); + + var source = DocumentationSourceLookup.getDocumentationSource(); + + // Should return either SpringModulithDocumentationSource or NoOpDocumentationSource + // (depending on whether metadata file exists) + assertThat(source).isNotNull(); + } + + @Test + void usesCustomSourceIfConfigured() { + + System.setProperty("spring.config.additional-location", "classpath:documentation-source/custom-type.properties"); + + var source = DocumentationSourceLookup.getDocumentationSource(); + + assertThat(source).isInstanceOf(TestDocumentationSource.class); + } + + @Test + void usesDefaultSourceWhenNoConfigurationProvided() { + + // Clear any existing configuration + System.clearProperty("spring.config.additional-location"); + + var source = DocumentationSourceLookup.getDocumentationSource(); + + // Should return either SpringModulithDocumentationSource or NoOpDocumentationSource as default + assertThat(source).isNotNull(); + } + + /** + * Test implementation of {@link DocumentationSource} for testing custom type configuration. + */ + public static class TestDocumentationSource implements DocumentationSource { + + @Override + public Optional getDocumentation(JavaMethod method) { + return Optional.of("Test method documentation"); + } + + @Override + public Optional getDocumentation(JavaClass type) { + return Optional.of("Test class documentation"); + } + + @Override + public Optional getDocumentation(JavaPackage pkg) { + return Optional.of("Test package documentation"); + } + } +} diff --git a/spring-modulith-docs/src/test/resources/documentation-source/custom-type.properties b/spring-modulith-docs/src/test/resources/documentation-source/custom-type.properties new file mode 100644 index 000000000..86f22089a --- /dev/null +++ b/spring-modulith-docs/src/test/resources/documentation-source/custom-type.properties @@ -0,0 +1 @@ +spring.modulith.documentation-source=org.springframework.modulith.docs.DocumentationSourceLookupTests.TestDocumentationSource diff --git a/spring-modulith-docs/src/test/resources/documentation-source/spring-modulith.properties b/spring-modulith-docs/src/test/resources/documentation-source/spring-modulith.properties new file mode 100644 index 000000000..26ca878d9 --- /dev/null +++ b/spring-modulith-docs/src/test/resources/documentation-source/spring-modulith.properties @@ -0,0 +1 @@ +spring.modulith.documentation-source=spring-modulith From 5f12dbe99823b6b538cd84a702db6c92be0e7a89 Mon Sep 17 00:00:00 2001 From: Yevhenii Semenov Date: Wed, 3 Dec 2025 19:18:50 +0200 Subject: [PATCH 2/2] Revert generic StrategyLookup and duplicate pattern for docs module Reverts the generic StrategyLookup utility and restores the original ApplicationModuleDetectionStrategyLookup implementation in the core module. The DocumentationSourceLookup now contains an inline implementation of the lookup pattern, removing the dependency on the core module's generic utility. Key changes: - Restore ApplicationModuleDetectionStrategyLookup to original implementation with static initializer, Logger field, and switch statement - Delete generic StrategyLookup utility class from core/config package - Duplicate lookup pattern inline in DocumentationSourceLookup with private helper method for SpringFactoriesLoader fallback This approach favors module independence over code reuse, keeping the core module unchanged while maintaining all documentation source functionality. All tests pass: 84 tests in core module, 15 tests in docs module. Signed-off-by: Yevhenii Semenov --- ...licationModuleDetectionStrategyLookup.java | 78 ++++++++-- .../modulith/core/config/StrategyLookup.java | 134 ------------------ .../docs/DocumentationSourceLookup.java | 74 ++++++++-- 3 files changed, 128 insertions(+), 158 deletions(-) delete mode 100644 spring-modulith-core/src/main/java/org/springframework/modulith/core/config/StrategyLookup.java diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategyLookup.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategyLookup.java index 3f7eac09e..77b30a92d 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategyLookup.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/ApplicationModuleDetectionStrategyLookup.java @@ -15,11 +15,19 @@ */ package org.springframework.modulith.core; -import org.springframework.modulith.core.config.StrategyLookup; - -import java.util.Map; +import java.util.List; import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + /** * A factory for the {@link ApplicationModuleDetectionStrategy} to be used when scanning code for * {@link ApplicationModule}s. @@ -29,6 +37,35 @@ class ApplicationModuleDetectionStrategyLookup { private static final String DETECTION_STRATEGY_PROPERTY = "spring.modulith.detection-strategy"; + private static final Logger LOG = LoggerFactory.getLogger(ApplicationModuleDetectionStrategyLookup.class); + private static final Supplier FALLBACK_DETECTION_STRATEGY; + + static { + + FALLBACK_DETECTION_STRATEGY = () -> { + + List loadFactories = SpringFactoriesLoader.loadFactories( + ApplicationModuleDetectionStrategy.class, ApplicationModules.class.getClassLoader()); + + var size = loadFactories.size(); + + if (size == 0) { + return ApplicationModuleDetectionStrategy.directSubPackage(); + } + + if (size > 1) { + + throw new IllegalStateException( + "Multiple module detection strategies configured. Only one supported! %s".formatted(loadFactories)); + } + + LOG.warn( + "Configuring the application module detection strategy via spring.factories is deprecated! Please configure {} instead.", + DETECTION_STRATEGY_PROPERTY); + + return loadFactories.get(0); + }; + } /** * Returns the {@link ApplicationModuleDetectionStrategy} to be used to detect {@link ApplicationModule}s. Will use @@ -46,16 +83,33 @@ class ApplicationModuleDetectionStrategyLookup { */ static ApplicationModuleDetectionStrategy getStrategy() { - Map> predefinedStrategies = Map.of( - "direct-sub-packages", ApplicationModuleDetectionStrategy::directSubPackage, - "explicitly-annotated", ApplicationModuleDetectionStrategy::explicitlyAnnotated); + var environment = new StandardEnvironment(); + ConfigDataEnvironmentPostProcessor.applyTo(environment, + new DefaultResourceLoader(ApplicationModuleDetectionStrategyLookup.class.getClassLoader()), null); + + var configuredStrategy = environment.getProperty(DETECTION_STRATEGY_PROPERTY, String.class); + + // Nothing configured? Use fallback. + if (!StringUtils.hasText(configuredStrategy)) { + return FALLBACK_DETECTION_STRATEGY.get(); + } + + // Any of the prepared ones? + switch (configuredStrategy) { + case "direct-sub-packages": + return ApplicationModuleDetectionStrategy.directSubPackage(); + case "explicitly-annotated": + return ApplicationModuleDetectionStrategy.explicitlyAnnotated(); + } + + try { - var lookup = new StrategyLookup<>( - DETECTION_STRATEGY_PROPERTY, - ApplicationModuleDetectionStrategy.class, - predefinedStrategies, - ApplicationModuleDetectionStrategy::directSubPackage); + // Lookup configured value as class + var strategyType = ClassUtils.forName(configuredStrategy, ApplicationModules.class.getClassLoader()); + return BeanUtils.instantiateClass(strategyType, ApplicationModuleDetectionStrategy.class); - return lookup.lookup(); + } catch (ClassNotFoundException | LinkageError o_O) { + throw new IllegalStateException(o_O); + } } } diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/config/StrategyLookup.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/config/StrategyLookup.java deleted file mode 100644 index 4a6cff425..000000000 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/config/StrategyLookup.java +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2024-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.modulith.core.config; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.BeanUtils; -import org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor; -import org.springframework.core.env.StandardEnvironment; -import org.springframework.core.io.DefaultResourceLoader; -import org.springframework.core.io.support.SpringFactoriesLoader; -import org.springframework.util.ClassUtils; -import org.springframework.util.StringUtils; - -import java.util.List; -import java.util.Map; -import java.util.function.Supplier; - -/** - * Generic utility for looking up strategy implementations based on configuration. Supports property-based - * configuration, predefined strategies, custom class instantiation, and {@link SpringFactoriesLoader} fallback. - * - * @param the strategy type - * @since 1.4 - */ -public class StrategyLookup { - - private static final Logger LOG = LoggerFactory.getLogger(StrategyLookup.class); - - private final String propertyName; - private final Class strategyType; - private final Map> predefinedStrategies; - private final Supplier fallbackSupplier; - - /** - * Creates a new {@link StrategyLookup} instance. - * - * @param propertyName the configuration property name (e.g., "spring.modulith.detection-strategy") - * @param strategyType the strategy class - * @param predefinedStrategies map of predefined strategy names to their suppliers - * @param fallbackSupplier the fallback strategy supplier - */ - public StrategyLookup(String propertyName, Class strategyType, Map> predefinedStrategies, - Supplier fallbackSupplier) { - - this.propertyName = propertyName; - this.strategyType = strategyType; - this.predefinedStrategies = predefinedStrategies; - this.fallbackSupplier = fallbackSupplier; - } - - /** - * Looks up and returns the strategy implementation using the following algorithm: - *
      - *
    1. Use the predefined strategies if the configured property value matches one of them.
    2. - *
    3. Interpret the configured value as a class name if it doesn't match the predefined values.
    4. - *
    5. Use the {@link SpringFactoriesLoader} if no property is configured (deprecated).
    6. - *
    7. A final fallback on the provided fallback supplier.
    8. - *
    - * - * @return the strategy implementation, never {@literal null} - */ - public T lookup() { - - var environment = new StandardEnvironment(); - ConfigDataEnvironmentPostProcessor.applyTo(environment, - new DefaultResourceLoader(StrategyLookup.class.getClassLoader()), null); - - var configuredStrategy = environment.getProperty(propertyName, String.class); - - // Nothing configured? Use SpringFactoriesLoader or fallback - if (!StringUtils.hasText(configuredStrategy)) { - return lookupViaSpringFactoriesOrFallback(); - } - - // Check predefined strategies - var predefined = predefinedStrategies.get(configuredStrategy); - - if (predefined != null) { - return predefined.get(); - } - - // Try to load configured value as class - try { - - var strategyClass = ClassUtils.forName(configuredStrategy, strategyType.getClassLoader()); - return BeanUtils.instantiateClass(strategyClass, strategyType); - - } catch (ClassNotFoundException | LinkageError o_O) { - throw new IllegalStateException("Unable to load strategy class: " + configuredStrategy, o_O); - } - } - - /** - * Attempts to load strategy via {@link SpringFactoriesLoader} (deprecated), falling back to the fallback supplier - * if none found. - * - * @return the strategy implementation, never {@literal null} - */ - private T lookupViaSpringFactoriesOrFallback() { - - List loadFactories = SpringFactoriesLoader.loadFactories(strategyType, strategyType.getClassLoader()); - - var size = loadFactories.size(); - - if (size == 0) { - return fallbackSupplier.get(); - } - - if (size > 1) { - throw new IllegalStateException( - "Multiple strategies configured via spring.factories. Only one supported! %s".formatted(loadFactories)); - } - - LOG.warn( - "Configuring strategy via spring.factories is deprecated! Please configure {} instead.", - propertyName); - - return loadFactories.get(0); - } -} diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSourceLookup.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSourceLookup.java index 10a85a097..1c466baa8 100644 --- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSourceLookup.java +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSourceLookup.java @@ -15,12 +15,17 @@ */ package org.springframework.modulith.docs; +import java.util.List; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.modulith.core.config.StrategyLookup; - -import java.util.Map; -import java.util.function.Supplier; +import org.springframework.beans.BeanUtils; +import org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; /** * A factory for the {@link DocumentationSource} to be used when generating documentation. @@ -46,16 +51,61 @@ class DocumentationSourceLookup { */ static DocumentationSource getDocumentationSource() { - Map> predefinedStrategies = Map.of( - "spring-modulith", DocumentationSourceLookup::getSpringModulithDocumentationSource); + var environment = new StandardEnvironment(); + ConfigDataEnvironmentPostProcessor.applyTo(environment, + new DefaultResourceLoader(DocumentationSourceLookup.class.getClassLoader()), null); + + var configuredSource = environment.getProperty(DOCUMENTATION_SOURCE_PROPERTY, String.class); + + // Nothing configured? Use SpringFactoriesLoader or fallback + if (!StringUtils.hasText(configuredSource)) { + return lookupViaSpringFactoriesOrFallback(); + } + + // Check predefined strategy + if ("spring-modulith".equals(configuredSource)) { + return getSpringModulithDocumentationSource(); + } + + // Try to load configured value as class + try { + + var sourceClass = ClassUtils.forName(configuredSource, DocumentationSource.class.getClassLoader()); + return BeanUtils.instantiateClass(sourceClass, DocumentationSource.class); + + } catch (ClassNotFoundException | LinkageError o_O) { + throw new IllegalStateException("Unable to load documentation source class: " + configuredSource, o_O); + } + } + + /** + * Attempts to load documentation source via {@link SpringFactoriesLoader} (deprecated), falling back to the default + * source if none found. + * + * @return will never be {@literal null}. + */ + private static DocumentationSource lookupViaSpringFactoriesOrFallback() { + + List loadFactories = SpringFactoriesLoader.loadFactories(DocumentationSource.class, + DocumentationSource.class.getClassLoader()); + + var size = loadFactories.size(); + + if (size == 0) { + return getDefaultDocumentationSource(); + } + + if (size > 1) { + throw new IllegalStateException( + "Multiple documentation sources configured via spring.factories. Only one supported! %s" + .formatted(loadFactories)); + } - var lookup = new StrategyLookup<>( - DOCUMENTATION_SOURCE_PROPERTY, - DocumentationSource.class, - predefinedStrategies, - DocumentationSourceLookup::getDefaultDocumentationSource); + LOG.warn( + "Configuring documentation source via spring.factories is deprecated! Please configure {} instead.", + DOCUMENTATION_SOURCE_PROPERTY); - return lookup.lookup(); + return loadFactories.get(0); } /**