diff --git a/.github/ISSUE_TEMPLATE/New_plugin.md b/.github/ISSUE_TEMPLATE/New_plugin.md new file mode 100644 index 000000000..22d772607 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/New_plugin.md @@ -0,0 +1,13 @@ +--- +name: Add a new third-party plugin +about: Update OFT documentation to include a new third-party plugin + +--- + +## Plugin Details + +* Name: +* Description: +* Web page: +* Source code repository: +* Plugin type (importer, exporter or reporter): diff --git a/.github/workflows/broken_links_checker.yml b/.github/workflows/broken_links_checker.yml index 52cd23517..1a2666053 100644 --- a/.github/workflows/broken_links_checker.yml +++ b/.github/workflows/broken_links_checker.yml @@ -2,7 +2,7 @@ name: Broken Links Checker on: push: - branches: [ main ] + branches: [main] pull_request: jobs: @@ -18,6 +18,6 @@ jobs: echo '{ "aliveStatusCodes": [429, 200] }' > ./target/broken_links_checker.json - uses: gaurav-nelson/github-action-markdown-link-check@v1 with: - use-quiet-mode: 'yes' - use-verbose-mode: 'yes' - config-file: ./target/broken_links_checker.json \ No newline at end of file + use-quiet-mode: "yes" + use-verbose-mode: "yes" + config-file: ./target/broken_links_checker.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2e380bad..dbaf6598f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,17 +2,16 @@ name: Build on: push: - branches: [ main ] + branches: [main] pull_request: jobs: matrix-build: - permissions: contents: read strategy: - fail-fast: true + fail-fast: false matrix: java: [17] os: [ubuntu-latest, macos-latest, windows-latest] @@ -34,83 +33,81 @@ jobs: DEFAULT_OS: ubuntu-latest steps: - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: | - 17 - 21 - cache: 'maven' - - - name: Cache SonarQube packages - if: ${{ env.DEFAULT_OS == matrix.os && env.DEFAULT_JAVA == matrix.java }} - uses: actions/cache@v4 - with: - path: ~/.sonar/cache - key: ${{ runner.os }}-sonar - restore-keys: ${{ runner.os }}-sonar - - - name: Build with Java ${{ matrix.java }} - run: | - mvn --batch-mode -T 1C clean org.jacoco:jacoco-maven-plugin:prepare-agent install \ - -Djava.version=${{ matrix.java }} - - - name: Sonar analysis - if: ${{ env.DEFAULT_OS == matrix.os && env.DEFAULT_JAVA == matrix.java && env.SONAR_TOKEN != null }} - run: | - mvn --batch-mode org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \ - -Dsonar.token=$SONAR_TOKEN - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - name: Verify reproducible build - # Build fails on Windows with error "Failed to execute goal org.apache.maven.plugins:maven-artifact-plugin:3.5.0:compare (default-cli) on project openfasttrace-reporter-plaintext: Could not copy D:\a\openfasttrace\openfasttrace\reporter\plaintext\target\openfasttrace-reporter-plaintext-3.8.0.buildcompareto D:\a\openfasttrace\openfasttrace\target\openfasttrace-root-0.0.0.buildcompare" - if: ${{ matrix.os != 'windows-latest' }} - run: | - mvn --batch-mode -T 1C clean verify artifact:compare -DskipTests \ - -Djava.version=${{ matrix.java }} - - - name: Archive aggregated reproducible build report - uses: actions/upload-artifact@v4 - if: ${{ matrix.os != 'windows-latest' }} - with: - name: reproducible-build-report-${{ matrix.os }}-java-${{ matrix.java }} - path: | - target/openfasttrace-root-0.0.0.buildcompare - target/openfasttrace-root-0.0.0.buildinfo - if-no-files-found: error - - - name: Archive oft binary - uses: actions/upload-artifact@v4 - if: ${{ env.DEFAULT_OS == matrix.os && env.DEFAULT_JAVA == matrix.java }} - with: - name: openfasttrace-binaries - path: | - product/target/openfasttrace-*.jar - !product/target/openfasttrace-*-javadoc.jar - !product/target/openfasttrace-*-sources.jar - if-no-files-found: error - - - name: Run self-trace - run: ./oft-self-trace.sh - - - name: Upload self-tracing report - uses: actions/upload-artifact@v4 - if: ${{ env.DEFAULT_OS == matrix.os && env.DEFAULT_JAVA == matrix.java }} - with: - name: self-tracing-report - path: target/self-trace-report.html - if-no-files-found: error - - - name: Check shell scripts - if: ${{ env.DEFAULT_OS == matrix.os && env.DEFAULT_JAVA == matrix.java }} - run: .github/workflows/run_shellcheck.sh + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-java@v4 + name: Set up Java ${{ matrix.java }} + with: + distribution: "temurin" + java-version: ${{ matrix.java }} + cache: "maven" + + - name: Cache SonarQube packages + if: ${{ env.DEFAULT_OS == matrix.os && env.DEFAULT_JAVA == matrix.java }} + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Build with Java ${{ matrix.java }} + run: | + mvn --batch-mode -T 1C clean org.jacoco:jacoco-maven-plugin:prepare-agent install \ + -Djava.version=${{ matrix.java }} + + - name: Sonar analysis + if: ${{ env.DEFAULT_OS == matrix.os && env.DEFAULT_JAVA == matrix.java && env.SONAR_TOKEN != null }} + run: | + mvn --batch-mode org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \ + -Dsonar.token=$SONAR_TOKEN + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + - name: Verify reproducible build + # Build fails on Windows with error "Failed to execute goal org.apache.maven.plugins:maven-artifact-plugin:3.5.0:compare (default-cli) on project openfasttrace-reporter-plaintext: Could not copy D:\a\openfasttrace\openfasttrace\reporter\plaintext\target\openfasttrace-reporter-plaintext-3.8.0.buildcompareto D:\a\openfasttrace\openfasttrace\target\openfasttrace-root-0.0.0.buildcompare" + if: ${{ matrix.os != 'windows-latest' }} + run: | + mvn --batch-mode -T 1C clean verify artifact:compare -DskipTests \ + -Djava.version=${{ matrix.java }} + + - name: Archive aggregated reproducible build report + uses: actions/upload-artifact@v4 + if: ${{ matrix.os != 'windows-latest' }} + with: + name: reproducible-build-report-${{ matrix.os }}-java-${{ matrix.java }} + path: | + target/openfasttrace-root-0.0.0.buildcompare + target/openfasttrace-root-0.0.0.buildinfo + if-no-files-found: error + + - name: Archive oft binary + uses: actions/upload-artifact@v4 + if: ${{ env.DEFAULT_OS == matrix.os && env.DEFAULT_JAVA == matrix.java }} + with: + name: openfasttrace-binaries + path: | + product/target/openfasttrace-*.jar + !product/target/openfasttrace-*-javadoc.jar + !product/target/openfasttrace-*-sources.jar + if-no-files-found: error + + - name: Run self-trace + run: ./oft-self-trace.sh + + - name: Upload self-tracing report + uses: actions/upload-artifact@v4 + if: ${{ env.DEFAULT_OS == matrix.os && env.DEFAULT_JAVA == matrix.java }} + with: + name: self-tracing-report + path: target/self-trace-report.html + if-no-files-found: error + + - name: Check shell scripts + if: ${{ env.DEFAULT_OS == matrix.os && env.DEFAULT_JAVA == matrix.java }} + run: .github/workflows/run_shellcheck.sh build: needs: matrix-build diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index aa2db5938..7942d9f94 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,11 +2,11 @@ name: "CodeQL" on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] schedule: - - cron: '0 4 * * 3' + - cron: "0 4 * * 3" jobs: analyze: @@ -21,22 +21,22 @@ jobs: cancel-in-progress: true steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: 17 - cache: 'maven' + - uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: 17 + cache: "maven" - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: java + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: java - - name: Autobuild - uses: github/codeql-action/autobuild@v3 + - name: Autobuild + uses: github/codeql-action/autobuild@v3 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index b41550542..757c4f480 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -4,9 +4,6 @@ on: push: branches: ["main"] workflow_dispatch: - # Temporarily also run on pull requests - pull_request: - branches: ["main"] permissions: contents: read diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..94799b607 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,34 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "Run Self-Trace", + "request": "launch", + "mainClass": "org.itsallcode.openfasttrace.core.cli.CliStarter", + "projectName": "openfasttrace", + "args": [ + "trace", + "--log-level", + "INFO", + "${workspaceFolder}/doc/spec", + "${workspaceFolder}/importer/lightweightmarkup/src", + "${workspaceFolder}/importer/markdown/src", + "${workspaceFolder}/importer/restructuredtext/src", + "${workspaceFolder}/importer/specobject/src", + "${workspaceFolder}/importer/zip/src", + "${workspaceFolder}/importer/tag/src", + "${workspaceFolder}/core/src/main", + "${workspaceFolder}/core/src/test/java", + "${workspaceFolder}/reporter/plaintext/src", + "${workspaceFolder}/reporter/html/src", + "${workspaceFolder}/reporter/aspec/src", + "${workspaceFolder}/product/src/test/java", + "${workspaceFolder}/api/src", + "${workspaceFolder}/exporter/specobject/src", + "${workspaceFolder}/exporter/common/src", + "${workspaceFolder}/testutil/src" + ] + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 1795d5ecc..10af627d8 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Sonarcloud status: **User Guides** * [📖 User Guide](doc/user_guide.md) +* [🔌 Extending OpenFastTrace With Plugins](doc/plugins.md) * [💲 Command Line Usage](core/src/main/resources/usage.txt) **News and Discussions** @@ -47,6 +48,7 @@ Sonarcloud status: * [🎟️ Project Board](https://github.com/orgs/itsallcode/projects/3/views/1) * [🦮 Developer Guide](doc/developer_guide.md) +* [🔌 Plugin Developer Guide](doc/plugin_developer_guide.md) * [🎁 Contributing Guide](CONTRIBUTING.md) * [💡 System Requirements](doc/spec/system_requirements.md) * [👜 Design](doc/spec/design.md) diff --git a/api/src/main/java/org/itsallcode/openfasttrace/api/importer/MultiFileImporter.java b/api/src/main/java/org/itsallcode/openfasttrace/api/importer/MultiFileImporter.java index 1935b7d49..c83399d02 100644 --- a/api/src/main/java/org/itsallcode/openfasttrace/api/importer/MultiFileImporter.java +++ b/api/src/main/java/org/itsallcode/openfasttrace/api/importer/MultiFileImporter.java @@ -26,8 +26,8 @@ public interface MultiFileImporter MultiFileImporter importFile(InputFile file); /** - * Import from the path, independently of whether it is represents a - * directory or a file. + * Import from the path, independently of whether it represents a directory + * or a file. * * @param paths * lists of paths to files or directories diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index 9a9118428..e2a7686cd 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -10,12 +10,13 @@ exports org.itsallcode.openfasttrace.core; exports org.itsallcode.openfasttrace.core.cli; exports org.itsallcode.openfasttrace.core.cli.commands; + exports org.itsallcode.openfasttrace.core.cli.logging; exports org.itsallcode.openfasttrace.core.report; exports org.itsallcode.openfasttrace.core.exporter; exports org.itsallcode.openfasttrace.core.importer; exports org.itsallcode.openfasttrace.core.serviceloader; - requires java.logging; + requires transitive java.logging; requires transitive org.itsallcode.openfasttrace.api; uses org.itsallcode.openfasttrace.api.exporter.ExporterFactory; diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/ServiceFactory.java b/core/src/main/java/org/itsallcode/openfasttrace/core/ServiceFactory.java index 3e1ee95e9..4d0e5d3b1 100644 --- a/core/src/main/java/org/itsallcode/openfasttrace/core/ServiceFactory.java +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/ServiceFactory.java @@ -13,7 +13,6 @@ import org.itsallcode.openfasttrace.core.importer.ImporterServiceImpl; import org.itsallcode.openfasttrace.core.report.ReportService; import org.itsallcode.openfasttrace.core.report.ReporterFactoryLoader; -import org.itsallcode.openfasttrace.core.serviceloader.InitializingServiceLoader; class ServiceFactory { @@ -25,15 +24,12 @@ ExporterService createExporterService() ImporterService createImporterService(final ImportSettings settings) { final ImporterContext context = new ImporterContext(settings); - final InitializingServiceLoader serviceLoader = InitializingServiceLoader - .load(ImporterFactory.class, context); - final ImporterService service = new ImporterServiceImpl( - new ImporterFactoryLoader(serviceLoader), settings); + final ImporterService service = new ImporterServiceImpl(new ImporterFactoryLoader(context), settings); context.setImporterService(service); return service; } - Linker createLinker(List items) + Linker createLinker(final List items) { return new Linker(items); } diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/cli/CliArguments.java b/core/src/main/java/org/itsallcode/openfasttrace/core/cli/CliArguments.java index 37974d577..629ffdfb0 100644 --- a/core/src/main/java/org/itsallcode/openfasttrace/core/cli/CliArguments.java +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/cli/CliArguments.java @@ -12,6 +12,7 @@ import org.itsallcode.openfasttrace.api.report.ReportVerbosity; import org.itsallcode.openfasttrace.core.cli.commands.ConvertCommand; import org.itsallcode.openfasttrace.core.cli.commands.TraceCommand; +import org.itsallcode.openfasttrace.core.cli.logging.LogLevel; import org.itsallcode.openfasttrace.core.exporter.ExporterConstants; /** @@ -42,6 +43,9 @@ public class CliArguments // [impl->dsn~reporting.html.details-display~1] private DetailsSectionDisplay detailsSectionDisplay; + // [impl->dsn~cli.plugins.log~1] + private LogLevel logLevel; + /** * Create new {@link CliArguments}. * @@ -300,18 +304,21 @@ public Set getWantedTags() /** * Get the color scheme. *

- * Defaults to {@link ColorScheme#COLOR}. The switch -f overrides this setting, so that the color - * scheme is always {@link ColorScheme#BLACK_AND_WHITE}. + * Defaults to {@link ColorScheme#COLOR}. The switch -f + * overrides this setting, so that the color scheme is always + * {@link ColorScheme#BLACK_AND_WHITE}. *

* * @return the color scheme */ public ColorScheme getColorScheme() { - if (this.getOutputPath() == null) { + if (this.getOutputPath() == null) + { return (this.colorScheme == null) ? ColorScheme.COLOR : this.colorScheme; } - else { + else + { return ColorScheme.BLACK_AND_WHITE; } } @@ -354,8 +361,7 @@ public void setT(final String tags) /** * Check if origin information should be shown in reports. * - * @return {@code true} if origin information should be shown in - * reports. + * @return {@code true} if origin information should be shown in reports. */ public boolean getShowOrigin() { @@ -366,8 +372,7 @@ public boolean getShowOrigin() * Choose whether to show origin information in reports. * * @param showOrigin - * {@code true} if origin information should be shown in - * reports + * {@code true} if origin information should be shown in reports */ public void setShowOrigin(final boolean showOrigin) { @@ -378,8 +383,7 @@ public void setShowOrigin(final boolean showOrigin) * Choose whether to show origin information in reports. * * @param showOrigin - * {@code true} if origin information should be shown in - * reports + * {@code true} if origin information should be shown in reports */ public void setS(final boolean showOrigin) { @@ -389,7 +393,8 @@ public void setS(final boolean showOrigin) /** * Choose the color scheme. * - * @param colorScheme color scheme to use for console output + * @param colorScheme + * color scheme to use for console output */ public void setColorScheme(final ColorScheme colorScheme) { @@ -399,7 +404,8 @@ public void setColorScheme(final ColorScheme colorScheme) /** * Choose the color scheme. * - * @param colorScheme color scheme to use for console output + * @param colorScheme + * color scheme to use for console output */ public void setC(final ColorScheme colorScheme) { @@ -416,4 +422,36 @@ public void setDetailsSectionDisplay(final DetailsSectionDisplay detailsSectionD { this.detailsSectionDisplay = detailsSectionDisplay; } + + /** + * Get the log level for console logging. + * + * @return log level + */ + public Optional getLogLevel() + { + return Optional.ofNullable(this.logLevel); + } + + /** + * Set the log level for console logging. + * + * @param logLevel + * log level + */ + public void setLogLevel(final LogLevel logLevel) + { + this.logLevel = logLevel; + } + + /** + * Set the log level for console logging. + * + * @param logLevel + * log level + */ + public void setL(final LogLevel logLevel) + { + setLogLevel(logLevel); + } } diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/cli/CliStarter.java b/core/src/main/java/org/itsallcode/openfasttrace/core/cli/CliStarter.java index e540acd19..843897b1d 100644 --- a/core/src/main/java/org/itsallcode/openfasttrace/core/cli/CliStarter.java +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/cli/CliStarter.java @@ -4,6 +4,7 @@ import org.itsallcode.openfasttrace.api.cli.DirectoryService; import org.itsallcode.openfasttrace.core.cli.commands.*; +import org.itsallcode.openfasttrace.core.cli.logging.LoggingConfigurator; /** * The main entry point class for the command line application. @@ -51,6 +52,7 @@ public static void main(final String[] args, final DirectoryService directorySer final ArgumentValidator validator = new ArgumentValidator(arguments); if (validator.isValid()) { + LoggingConfigurator.create(arguments).configureLogging(); new CliStarter(arguments).run(); } else diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/cli/logging/LogLevel.java b/core/src/main/java/org/itsallcode/openfasttrace/core/cli/logging/LogLevel.java new file mode 100644 index 000000000..496b18171 --- /dev/null +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/cli/logging/LogLevel.java @@ -0,0 +1,61 @@ +package org.itsallcode.openfasttrace.core.cli.logging; + +import java.util.logging.Level; + +/** + * Log levels for console logging. + *

+ * We can't use {@code java.util.logging} (JUL) {@link java.util.logging.Level} + * directly for configuration because it is not an enum. + */ +public enum LogLevel +{ + /** + * OFF is a special level that can be used to turn off logging. + */ + OFF(Level.OFF), + /** + * SEVERE is a message level indicating a serious failure. + */ + SEVERE(Level.SEVERE), + /** + * WARNING is a message level indicating a potential problem. + */ + WARNING(Level.WARNING), + /** + * INFO is a message level for informational messages. + */ + INFO(Level.INFO), + /** + * CONFIG is a message level for static configuration messages. + */ + CONFIG(Level.CONFIG), + /** + * FINE is a message level providing tracing information. + */ + FINE(Level.FINE), + /** + * FINER indicates a fairly detailed tracing message. + */ + FINER(Level.FINER), + /** + * FINEST indicates a highly detailed tracing message. + */ + FINEST(Level.FINEST), + /** + * ALL indicates that all messages should be logged. + */ + ALL(Level.ALL); + + private final Level julLevel; + + LogLevel(final Level julLevel) + { + this.julLevel = julLevel; + } + + String getJavaUtilLoggingLogLevel() + { + return julLevel.getName(); + } +} diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/cli/logging/LoggingConfigurator.java b/core/src/main/java/org/itsallcode/openfasttrace/core/cli/logging/LoggingConfigurator.java new file mode 100644 index 000000000..c9e36d38b --- /dev/null +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/cli/logging/LoggingConfigurator.java @@ -0,0 +1,71 @@ +package org.itsallcode.openfasttrace.core.cli.logging; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +import org.itsallcode.openfasttrace.core.cli.CliArguments; + +/** + * Configures console logging for the application. + */ +// [impl->dsn~cli.plugins.log~1] +public final class LoggingConfigurator +{ + private static final LogLevel DEFAULT_LOG_LEVEL = LogLevel.WARNING; + private static final String CONFIG_TEMPLATE = """ + handlers=java.util.logging.ConsoleHandler + .level=$LOG_LEVEL + java.util.logging.ConsoleHandler.level=$LOG_LEVEL + java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter + java.util.logging.SimpleFormatter.format=%1$tF %1$tT.%1$tL [%4$-7s] %5$s %n + org.itsallcode.openfasttrace.level=$LOG_LEVEL + """; + private final LogLevel logLevel; + + private LoggingConfigurator(final LogLevel logLevel) + { + this.logLevel = logLevel; + } + + /** + * Create a new logging configurator. + * + * @param arguments + * command line arguments. + * @return a new logging configurator. + */ + public static LoggingConfigurator create(final CliArguments arguments) + { + return new LoggingConfigurator(arguments.getLogLevel().orElse(DEFAULT_LOG_LEVEL)); + } + + /** + * Configures logging according to the configured log level. + */ + public void configureLogging() + { + final LogManager logManager = LogManager.getLogManager(); + configureLogManager(logManager, getConfigContent()); + final Logger rootLogger = logManager.getLogger(""); + rootLogger.fine(() -> "Logging configured with level " + this.logLevel + "."); + } + + private String getConfigContent() + { + return CONFIG_TEMPLATE.replace("$LOG_LEVEL", this.logLevel.getJavaUtilLoggingLogLevel()); + } + + private static void configureLogManager(final LogManager logManager, final String configContent) + { + try (InputStream config = new ByteArrayInputStream(configContent.getBytes(StandardCharsets.UTF_8))) + { + logManager.readConfiguration(config); + } + catch (SecurityException | IOException exception) + { + throw new IllegalStateException("Failed to configure logging", exception); + } + } +} diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/exporter/ExporterFactoryLoader.java b/core/src/main/java/org/itsallcode/openfasttrace/core/exporter/ExporterFactoryLoader.java index 111ec8c24..d2edb904e 100644 --- a/core/src/main/java/org/itsallcode/openfasttrace/core/exporter/ExporterFactoryLoader.java +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/exporter/ExporterFactoryLoader.java @@ -1,13 +1,11 @@ package org.itsallcode.openfasttrace.core.exporter; -import static java.util.stream.Collectors.toList; - import java.nio.file.Path; import java.util.List; -import java.util.stream.StreamSupport; import org.itsallcode.openfasttrace.api.exporter.*; import org.itsallcode.openfasttrace.core.serviceloader.InitializingServiceLoader; +import org.itsallcode.openfasttrace.core.serviceloader.Loader; /** * This class is responsible for finding the matching {@link ExporterFactory} @@ -15,7 +13,7 @@ */ public class ExporterFactoryLoader { - private final InitializingServiceLoader serviceLoader; + private final Loader serviceLoader; /** * Create a new loader for the given context. @@ -25,11 +23,11 @@ public class ExporterFactoryLoader */ public ExporterFactoryLoader(final ExporterContext context) { + // [impl->dsn~plugins.loading.plugin-types~1] this(InitializingServiceLoader.load(ExporterFactory.class, context)); } - ExporterFactoryLoader( - final InitializingServiceLoader serviceLoader) + ExporterFactoryLoader(final Loader serviceLoader) { this.serviceLoader = serviceLoader; } @@ -64,9 +62,9 @@ public ExporterFactory getExporterFactory(final String outputFormat) private List getMatchingFactories(final String format) { - return StreamSupport.stream(this.serviceLoader.spliterator(), false) // - .filter(f -> f.supportsFormat(format)) // - .collect(toList()); + return this.serviceLoader.load() + .filter(f -> f.supportsFormat(format)) + .toList(); } /** diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/importer/ImporterFactoryLoader.java b/core/src/main/java/org/itsallcode/openfasttrace/core/importer/ImporterFactoryLoader.java index 45adacb32..9fec635dc 100644 --- a/core/src/main/java/org/itsallcode/openfasttrace/core/importer/ImporterFactoryLoader.java +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/importer/ImporterFactoryLoader.java @@ -1,16 +1,14 @@ package org.itsallcode.openfasttrace.core.importer; -import static java.util.stream.Collectors.toList; - import java.nio.file.Path; import java.util.List; import java.util.Optional; import java.util.logging.Logger; -import java.util.stream.StreamSupport; import org.itsallcode.openfasttrace.api.importer.*; import org.itsallcode.openfasttrace.api.importer.input.InputFile; import org.itsallcode.openfasttrace.core.serviceloader.InitializingServiceLoader; +import org.itsallcode.openfasttrace.core.serviceloader.Loader; /** * This class is responsible for finding the matching {@link ImporterFactory} @@ -20,7 +18,7 @@ public class ImporterFactoryLoader { private static final Logger LOG = Logger.getLogger(ImporterFactoryLoader.class.getName()); - private final InitializingServiceLoader serviceLoader; + private final Loader serviceLoader; /** * Creates a new loader. @@ -28,12 +26,23 @@ public class ImporterFactoryLoader * @param serviceLoader * the loader used for locating importers. */ - public ImporterFactoryLoader( - final InitializingServiceLoader serviceLoader) + ImporterFactoryLoader(final Loader serviceLoader) { this.serviceLoader = serviceLoader; } + /** + * Create a new loader for the given context. + * + * @param context + * context for the new loader + */ + public ImporterFactoryLoader(final ImporterContext context) + { + // [impl->dsn~plugins.loading.plugin-types~1] + this(InitializingServiceLoader.load(ImporterFactory.class, context)); + } + /** * Finds a matching {@link ImporterFactory} that can handle the given * {@link Path}. If no or more than one {@link ImporterFactory} is found, @@ -79,8 +88,8 @@ public boolean supportsFile(final InputFile file) private List getMatchingFactories(final InputFile file) { - return StreamSupport.stream(this.serviceLoader.spliterator(), false) // - .filter(f -> f.supportsFile(file)) // - .collect(toList()); + return this.serviceLoader.load() + .filter(f -> f.supportsFile(file)) + .toList(); } } diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/importer/MultiFileImporterImpl.java b/core/src/main/java/org/itsallcode/openfasttrace/core/importer/MultiFileImporterImpl.java index 468b22aa5..6e53a5cca 100644 --- a/core/src/main/java/org/itsallcode/openfasttrace/core/importer/MultiFileImporterImpl.java +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/importer/MultiFileImporterImpl.java @@ -4,9 +4,7 @@ import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.PathMatcher; +import java.nio.file.*; import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; @@ -85,18 +83,21 @@ public MultiFileImporter importRecursiveDir(final Path dir, final String glob) final int itemCountBefore = this.specItemBuilder.getItemCount(); try (Stream fileStream = Files.walk(dir)) { - fileStream.filter(path -> !path.toFile().isDirectory()) // - .filter(matcher::matches) // + fileStream.filter(path -> !path.toFile().isDirectory()) + .filter(matcher::matches) .map(path -> RealFileInput.forPath(path, DEFAULT_CHARSET)) .filter(this.factoryLoader::supportsFile) - .map(file -> createImporterIfPossible(file, this.specItemBuilder)).forEach(importer -> { - importer.ifPresent(Importer::runImport); + .map(file -> createImporterIfPossible(file, this.specItemBuilder)) + .filter(Optional::isPresent) + .map(Optional::get) + .forEach(importer -> { + importer.runImport(); fileCount.incrementAndGet(); }); } - catch (final IOException e) + catch (final IOException exception) { - throw new ImporterException("Error walking directory " + dir, e); + throw new ImporterException("Error walking directory " + dir, exception); } final int itemCountImported = this.specItemBuilder.getItemCount() - itemCountBefore; LOG.fine(() -> "Imported " + fileCount + " files containing " + itemCountImported @@ -112,14 +113,13 @@ public List getImportedItems() private Optional createImporterIfPossible(final InputFile file, final SpecificationListBuilder builder) { - final Optional importerFactory = this.factoryLoader.getImporterFactory(file); - final Optional importer = importerFactory.isPresent() - ? Optional.of(importerFactory.get().createImporter(file, builder)) - : Optional.empty(); + final Optional importer = this.factoryLoader.getImporterFactory(file) + .map(factory -> factory.createImporter(file, builder)); - LOG.fine(() -> (importer.isPresent() ? "Created importer of type '" + importer.getClass().getSimpleName() - : "No import") - + "' for file '" + file + "'"); + LOG.finest( + () -> (importer.isPresent() ? "Created importer of type '" + importer.get().getClass().getSimpleName() + : "No import") + + "' for file '" + file + "'"); return importer; } diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/report/ReporterFactoryLoader.java b/core/src/main/java/org/itsallcode/openfasttrace/core/report/ReporterFactoryLoader.java index 73f57c0c5..ac9d22ccc 100644 --- a/core/src/main/java/org/itsallcode/openfasttrace/core/report/ReporterFactoryLoader.java +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/report/ReporterFactoryLoader.java @@ -1,13 +1,11 @@ package org.itsallcode.openfasttrace.core.report; -import static java.util.stream.Collectors.toList; - import java.util.List; -import java.util.stream.StreamSupport; import org.itsallcode.openfasttrace.api.exporter.ExporterException; import org.itsallcode.openfasttrace.api.report.*; import org.itsallcode.openfasttrace.core.serviceloader.InitializingServiceLoader; +import org.itsallcode.openfasttrace.core.serviceloader.Loader; /** * This class is responsible for finding the matching {@link ReporterFactory} @@ -15,7 +13,7 @@ */ public class ReporterFactoryLoader { - private final InitializingServiceLoader serviceLoader; + private final Loader serviceLoader; /** * Create a new {@link ReporterFactoryLoader}. @@ -26,11 +24,11 @@ public class ReporterFactoryLoader */ public ReporterFactoryLoader(final ReporterContext context) { + // [impl->dsn~plugins.loading.plugin-types~1] this(InitializingServiceLoader.load(ReporterFactory.class, context)); } - private ReporterFactoryLoader( - final InitializingServiceLoader serviceLoader) + private ReporterFactoryLoader(final Loader serviceLoader) { this.serviceLoader = serviceLoader; } @@ -65,9 +63,9 @@ public ReporterFactory getReporterFactory(final String outputFormat) private List getMatchingFactories(final String format) { - return StreamSupport.stream(this.serviceLoader.spliterator(), false) // - .filter(factory -> factory.supportsFormat(format)) // - .collect(toList()); + return this.serviceLoader.load() + .filter(factory -> factory.supportsFormat(format)) + .toList(); } /** diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/ChildFirstClassLoader.java b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/ChildFirstClassLoader.java new file mode 100644 index 000000000..77f82e29f --- /dev/null +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/ChildFirstClassLoader.java @@ -0,0 +1,62 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import java.net.URL; +import java.net.URLClassLoader; + +/** + * This class loader will first try to load the class from the given URLs and + * then from the parent class loader, unlike {@link URLClassLoader} which does + * it the other way around. + *

+ * This allows us to prefer external plugins over plugins on the classpath + * included with OFT. + *

+ * This is based on + * https://medium.com/@isuru89/java-a-child-first-class-loader-cbd9c3d0305 + *

+ */ +class ChildFirstClassLoader extends URLClassLoader +{ + ChildFirstClassLoader(final String name, final URL[] urls, final ClassLoader parent) + { + super(name, urls, parent); + } + + @Override + protected Class loadClass(final String name, final boolean resolve) throws ClassNotFoundException + { + final Class loadedClass = findClass(name, resolve); + if (resolve) + { + resolveClass(loadedClass); + } + return loadedClass; + } + + private Class findClass(final String name, final boolean resolve) throws ClassNotFoundException + { + // Has the class loaded already? + final Class loadedClass = findLoadedClass(name); + if (loadedClass != null) + { + return loadedClass; + } + return loadClassInternally(name, resolve); + } + + private Class loadClassInternally(final String name, final boolean resolve) throws ClassNotFoundException + { + try + { + // Find the class from given jar urls + return findClass(name); + } + catch (final ClassNotFoundException ignore) + { + // Class does not exist in the given URLs. + // Let's try finding it in our parent classloader. + // This will throw ClassNotFoundException on failure. + return super.loadClass(name, resolve); + } + } +} diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/ClassPathServiceLoader.java b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/ClassPathServiceLoader.java new file mode 100644 index 000000000..81b0b42ce --- /dev/null +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/ClassPathServiceLoader.java @@ -0,0 +1,63 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import java.util.ServiceLoader; +import java.util.ServiceLoader.Provider; +import java.util.logging.Logger; +import java.util.stream.Stream; + +/** + * A service loader that loads services from the class path using a given + * {@link ServiceOrigin}. + */ +final class ClassPathServiceLoader implements Loader +{ + private static final Logger LOGGER = Logger.getLogger(ClassPathServiceLoader.class.getName()); + private final ServiceOrigin serviceOrigin; + private final ServiceLoader serviceLoader; + + ClassPathServiceLoader(final ServiceOrigin serviceOrigin, final ServiceLoader serviceLoader) + { + this.serviceOrigin = serviceOrigin; + this.serviceLoader = serviceLoader; + } + + static Loader create(final Class serviceType, final ServiceOrigin serviceOrigin) + { + return new ClassPathServiceLoader<>(serviceOrigin, + ServiceLoader.load(serviceType, serviceOrigin.getClassLoader())); + } + + @Override + @SuppressWarnings("java:S3864") // Using peek() for logging only. + public Stream load() + { + return this.serviceLoader.stream() + .map(Provider::get) + .filter(this::filterOtherClassLoader) + .peek(this::logService); + } + + private boolean filterOtherClassLoader(final T service) + { + final ClassLoader serviceClassLoader = service.getClass().getClassLoader(); + final boolean correctClassLoader = serviceClassLoader == serviceOrigin.getClassLoader(); + if (!correctClassLoader) + { + LOGGER.finest( + () -> "Service " + service + " has unexpected class loader: " + serviceClassLoader + ", expected " + + serviceOrigin.getClassLoader() + ". This service will not be used."); + } + return correctClassLoader; + } + + private void logService(final T service) + { + LOGGER.fine(() -> "Loading service '" + service.getClass().getName() + "' from " + serviceOrigin + "."); + } + + @Override + public void close() + { + this.serviceOrigin.close(); + } +} diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/DelegatingLoader.java b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/DelegatingLoader.java new file mode 100644 index 000000000..b64684b08 --- /dev/null +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/DelegatingLoader.java @@ -0,0 +1,30 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** + * A service loader that delegates to a list of other loaders. + */ +class DelegatingLoader implements Loader +{ + private final List> delegates; + + DelegatingLoader(final List> delegates) + { + this.delegates = new ArrayList<>(delegates); + } + + @Override + public Stream load() + { + return delegates.stream().flatMap(Loader::load); + } + + @Override + public void close() + { + delegates.forEach(Loader::close); + } +} diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/InitializingServiceLoader.java b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/InitializingServiceLoader.java index bf31f01e4..805a3b49f 100644 --- a/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/InitializingServiceLoader.java +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/InitializingServiceLoader.java @@ -1,5 +1,8 @@ package org.itsallcode.openfasttrace.core.serviceloader; -import java.util.*; + +import java.util.List; +import java.util.ServiceLoader; +import java.util.stream.Stream; import org.itsallcode.openfasttrace.api.core.serviceloader.Initializable; @@ -12,15 +15,15 @@ * @param * the context type */ -public class InitializingServiceLoader, C> implements Iterable +public final class InitializingServiceLoader, C> implements Loader { - private final ServiceLoader serviceLoader; + private final Loader delegate; private final C context; private List services; - private InitializingServiceLoader(final ServiceLoader serviceLoader, final C context) + InitializingServiceLoader(final Loader delegate, final C context) { - this.serviceLoader = serviceLoader; + this.delegate = delegate; this.context = context; } @@ -38,30 +41,34 @@ private InitializingServiceLoader(final ServiceLoader serviceLoader, final C * instances. * @return an {@link InitializingServiceLoader} for type T */ - public static , C> InitializingServiceLoader load( + public static , C> Loader load( final Class serviceType, final C context) { - return new InitializingServiceLoader<>(ServiceLoader.load(serviceType), context); + final ServiceLoaderFactory loaderFactory = ServiceLoaderFactory.createDefault(); + return new InitializingServiceLoader<>(loaderFactory.createLoader(serviceType), context); } @Override - public Iterator iterator() + public Stream load() { if (this.services == null) { this.services = loadServices(); } - return this.services.iterator(); + return services.stream(); } private List loadServices() { - final List initializedServices = new ArrayList<>(); - for (final T service : this.serviceLoader) - { + return this.delegate.load().map(service -> { service.init(this.context); - initializedServices.add(service); - } - return initializedServices; + return service; + }).toList(); + } + + @Override + public void close() + { + delegate.close(); } } diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/Loader.java b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/Loader.java new file mode 100644 index 000000000..efdf41472 --- /dev/null +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/Loader.java @@ -0,0 +1,25 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import java.io.Closeable; +import java.util.stream.Stream; + +/** + * A loader for services, similar to Java's {@link java.util.ServiceLoader}. + * + * @param + * service type + */ +public interface Loader extends Closeable +{ + /** + * Load services. + * + * @return a stream of services + */ + Stream load(); + + /** + * Close the loader and potentially the underlying ClassLoader. + */ + void close(); +} diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceLoaderFactory.java b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceLoaderFactory.java new file mode 100644 index 000000000..559a03d1e --- /dev/null +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceLoaderFactory.java @@ -0,0 +1,156 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.logging.Logger; +import java.util.stream.Stream; + +/** + * Factory for creating {@link Loader} instances for services. + */ +class ServiceLoaderFactory +{ + private static final Logger LOGGER = Logger.getLogger(ServiceLoaderFactory.class.getName()); + private final Path pluginsDirectory; + private final boolean searchCurrentClasspath; + + /** + * Create a new factory for service {@link Loader}. + * + * @param pluginsDirectory + * directory to search for plugins + * @param searchCurrentClasspath + * whether to search the current classpath for plugins. This is + * useful for testing to avoid loading plugins twice. + */ + ServiceLoaderFactory(final Path pluginsDirectory, final boolean searchCurrentClasspath) + { + this.pluginsDirectory = pluginsDirectory; + this.searchCurrentClasspath = searchCurrentClasspath; + } + + /** + * Create a default factory that searches for plugins in the user's home. + * + * @return a default factory + */ + static ServiceLoaderFactory createDefault() + { + final Path pluginsDirectory = getHomeDirectory().resolve(".oft").resolve("plugins"); + return new ServiceLoaderFactory(pluginsDirectory, true); + } + + private static Path getHomeDirectory() + { + return Path.of(System.getProperty("user.home")); + } + + /** + * Create a new {@link Loader} for the given service type. + * + * @param + * service type + * @param serviceType + * service type + * @return a new {@link Loader} for the given service type + */ + Loader createLoader(final Class serviceType) + { + return createLoader(serviceType, findServiceOrigins()); + } + + private static Loader createLoader(final Class serviceType, final List origins) + { + final List> loaders = origins.stream() + .map(origin -> ClassPathServiceLoader.create(serviceType, origin)) + .toList(); + return new DelegatingLoader<>(loaders); + } + + /** + * Find all service origins. + *

+ * This method is package-private for testing. + * + * @return a list of service origins + */ + // [impl->dsn~plugins.loading~1] + List findServiceOrigins() + { + final List origins = new ArrayList<>(findPluginOrigins()); + if (searchCurrentClasspath) + { + origins.add(ServiceOrigin.forCurrentClassPath()); + } + LOGGER.finest(() -> "Found " + origins.size() + " service origins: " + origins + "."); + return origins; + } + + private Collection findPluginOrigins() + { + if (!Files.isDirectory(pluginsDirectory)) + { + return Collections.emptyList(); + } + try + { + try (Stream stream = Files.list(pluginsDirectory)) + { + return stream.sorted() + .map(ServiceLoaderFactory::originForPath) + .flatMap(Optional::stream) + .toList(); + } + } + catch (final IOException exception) + { + throw new UncheckedIOException( + "Failed to list plugin directories in '" + this.pluginsDirectory + "': " + exception.getMessage(), + exception); + } + } + + private static Optional originForPath(final Path path) + { + if (isJarFile(path)) + { + LOGGER.fine(() -> "Found single plugin JAR '" + path + "'."); + return Optional.of(ServiceOrigin.forJars(List.of(path))); + } + if (!Files.isDirectory(path)) + { + LOGGER.fine(() -> "Ignoring plugin search path '" + path + "' because it is not a directory."); + return Optional.empty(); + } + final List jars = findJarsInDir(path); + if (jars.isEmpty()) + { + LOGGER.fine(() -> "Ignoring empty plugin directory '" + path + "'."); + return Optional.empty(); + } + LOGGER.fine(() -> "Found " + jars.size() + " JAR files in '" + path + "': " + jars + "."); + return Optional.of(ServiceOrigin.forJars(jars)); + } + + private static List findJarsInDir(final Path path) + { + try (Stream stream = Files.list(path)) + { + return stream.filter(ServiceLoaderFactory::isJarFile).toList(); + } + catch (final IOException exception) + { + throw new UncheckedIOException( + "Failed to list files in plugin directory '" + path + "': " + exception.getMessage(), + exception); + } + } + + private static boolean isJarFile(final Path path) + { + return path.getFileName().toString().toLowerCase(Locale.ENGLISH).endsWith(".jar"); + } +} diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceOrigin.java b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceOrigin.java new file mode 100644 index 000000000..cbe421070 --- /dev/null +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceOrigin.java @@ -0,0 +1,159 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import static java.util.stream.Collectors.joining; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.*; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Origin of a service, either the current class path or a list of JAR files. + */ +// [impl->dsn~plugins.loading~1] +interface ServiceOrigin extends AutoCloseable +{ + /** + * Create a service origin for the current class path. + * + * @return a service origin for the current class path + */ + static ServiceOrigin forCurrentClassPath() + { + return new CurrentClassPathOrigin(getBaseClassLoader()); + } + + private static ClassLoader getBaseClassLoader() + { + return Thread.currentThread().getContextClassLoader(); + } + + /** + * Create a service origin for a single JAR file. + * + * @param jar + * path to the JAR file + * @return a service origin for the JAR file + */ + static ServiceOrigin forJar(final Path jar) + { + return forJars(List.of(jar)); + } + + /** + * Create a service origin for a list of JAR files. + * + * @param jars + * list of paths to JAR files + * @return a service origin for the JAR files + */ + // [impl->dsn~plugins.loading.separate-classloader~1] + static ServiceOrigin forJars(final List jars) + { + return new JarFileOrigin(jars, createClassLoader(jars)); + } + + private static URLClassLoader createClassLoader(final List jars) + { + final URL[] urls = jars.stream().map(ServiceOrigin::toUrl) + .toArray(URL[]::new); + return new ChildFirstClassLoader(getClassLoaderName(jars), urls, getBaseClassLoader()); + } + + private static String getClassLoaderName(final List jars) + { + return "JarClassLoader-" + + jars.stream().map(Path::getFileName).sorted().map(Path::toString).collect(joining(",")); + } + + private static URL toUrl(final Path path) + { + try + { + return path.toUri().toURL(); + } + catch (final MalformedURLException exception) + { + throw new IllegalStateException("Error converting path " + path + " to url", exception); + } + } + + /** + * Get the class loader for this service origin. + * + * @return the class loader for this service origin + */ + ClassLoader getClassLoader(); + + /** + * Close the underlying ClassLoader if appropriate. + */ + void close(); + + final class JarFileOrigin implements ServiceOrigin + { + private final List jars; + private final URLClassLoader classLoader; + + JarFileOrigin(final List jars, final URLClassLoader classLoader) + { + this.jars = new ArrayList<>(jars); + this.classLoader = classLoader; + } + + @Override + public ClassLoader getClassLoader() + { + return classLoader; + } + + @Override + public void close() + { + try + { + classLoader.close(); + } + catch (final IOException exception) + { + throw new UncheckedIOException("Error closing class loader " + classLoader, exception); + } + } + + @Override + public String toString() + { + return "JarFileOrigin [classLoader=" + classLoader.getName() + ", jars=" + jars + "]"; + } + } + + final class CurrentClassPathOrigin implements ServiceOrigin + { + private final ClassLoader classLoader; + + CurrentClassPathOrigin(final ClassLoader classLoader) + { + this.classLoader = classLoader; + } + + @Override + public ClassLoader getClassLoader() + { + return classLoader; + } + + @Override + public void close() + { + // The current class loader cannot be closed. + } + + @Override + public String toString() + { + return "CurrentClassPathOrigin [classLoader=" + classLoader.getName() + "]"; + } + } +} diff --git a/core/src/main/resources/usage.txt b/core/src/main/resources/usage.txt index ae1095886..f16160922 100644 --- a/core/src/main/resources/usage.txt +++ b/core/src/main/resources/usage.txt @@ -36,6 +36,10 @@ Common options: least one tag contained in the comma-separated list. Add a single underscore as first item in the list to also import items without any tags. + -l, --log-level Log level for console logging. One of + "OFF", "SEVERE", "WARNING", "INFO", "CONFIG", + "FINE", "FINER", "FINEST", "ALL". + Defaults to "WARNING". Returns: 0 on success diff --git a/core/src/test/java/org/itsallcode/openfasttrace/core/importer/TestImporterFactoryLoader.java b/core/src/test/java/org/itsallcode/openfasttrace/core/importer/TestImporterFactoryLoader.java index 5979e4d5d..4b2b6e0b0 100644 --- a/core/src/test/java/org/itsallcode/openfasttrace/core/importer/TestImporterFactoryLoader.java +++ b/core/src/test/java/org/itsallcode/openfasttrace/core/importer/TestImporterFactoryLoader.java @@ -1,6 +1,5 @@ package org.itsallcode.openfasttrace.core.importer; -import static java.util.Arrays.asList; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.sameInstance; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -9,12 +8,13 @@ import static org.mockito.Mockito.when; import java.nio.file.Paths; +import java.util.Arrays; import org.itsallcode.openfasttrace.api.importer.ImporterContext; import org.itsallcode.openfasttrace.api.importer.ImporterFactory; import org.itsallcode.openfasttrace.api.importer.input.InputFile; import org.itsallcode.openfasttrace.api.importer.input.RealFileInput; -import org.itsallcode.openfasttrace.core.serviceloader.InitializingServiceLoader; +import org.itsallcode.openfasttrace.core.serviceloader.Loader; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -28,7 +28,7 @@ class TestImporterFactoryLoader { @Mock - private InitializingServiceLoader serviceLoaderMock; + private Loader serviceLoaderMock; @Mock private ImporterFactory supportedFactory1; @Mock @@ -87,6 +87,6 @@ private void assertFactoryFound(final ImporterFactory expectedFactory) private void simulateFactories(final ImporterFactory... factories) { - when(this.serviceLoaderMock.spliterator()).thenReturn(asList(factories).spliterator()); + when(this.serviceLoaderMock.load()).thenReturn(Arrays.stream(factories)); } } diff --git a/core/src/test/java/org/itsallcode/openfasttrace/core/importer/tag/config/DescribedPathMatcherTest.java b/core/src/test/java/org/itsallcode/openfasttrace/core/importer/tag/config/DescribedPathMatcherTest.java index f5548cbf5..682dc092e 100644 --- a/core/src/test/java/org/itsallcode/openfasttrace/core/importer/tag/config/DescribedPathMatcherTest.java +++ b/core/src/test/java/org/itsallcode/openfasttrace/core/importer/tag/config/DescribedPathMatcherTest.java @@ -1,6 +1,5 @@ package org.itsallcode.openfasttrace.core.importer.tag.config; -import static java.util.stream.Collectors.toList; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.notNullValue; @@ -166,7 +165,7 @@ private void createListMatcher(final String... paths) { final List pathList = Arrays.stream(paths) // .map(Paths::get) // - .collect(toList()); + .toList(); this.matcher = DescribedPathMatcher.createPathListMatcher(pathList); } diff --git a/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ChildFirstClassLoaderTest.java b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ChildFirstClassLoaderTest.java new file mode 100644 index 000000000..9cd24e44e --- /dev/null +++ b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ChildFirstClassLoaderTest.java @@ -0,0 +1,74 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.net.URL; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +class ChildFirstClassLoaderTest +{ + @Test + void parentFindsClass() throws ClassNotFoundException, IOException + { + try (final ChildFirstClassLoader testee = new ChildFirstClassLoader("name", new URL[] {}, + new MockClassLoader(String.class))) + { + assertThat(testee.loadClass("ClassName"), sameInstance(String.class)); + } + } + + @Test + void parentDoesNotFindClass() throws IOException + { + try (final ChildFirstClassLoader testee = new ChildFirstClassLoader("name", new URL[] {}, + new MockClassLoader(null))) + { + final ClassNotFoundException exception = assertThrows(ClassNotFoundException.class, + () -> testee.loadClass("ClassName")); + assertThat(exception.getMessage(), equalTo("ClassName")); + } + } + + @Test + void classFoundInJar() throws ClassNotFoundException, IOException + { + final Class classToFind = Matchers.class; + final URL jarForClass = ClassPathHelper.findUrlForClass(classToFind); + try (final ChildFirstClassLoader testee = new ChildFirstClassLoader("name", new URL[] { jarForClass }, + Thread.currentThread().getContextClassLoader())) + { + final Class foundClass = testee.loadClass(classToFind.getName()); + assertThat(foundClass, not(sameInstance(classToFind))); + assertThat(foundClass.toGenericString(), equalTo(classToFind.toGenericString())); + } + } + + /** + * We can't stub the ClassLoader using Mockito because method + * {@link ClassLoader#loadClass(String, boolean)} is protected. + */ + private static class MockClassLoader extends ClassLoader + { + private final Class clazz; + + private MockClassLoader(final Class clazz) + { + this.clazz = clazz; + } + + @Override + protected Class loadClass(final String name, final boolean resolve) throws ClassNotFoundException + { + if (clazz == null) + { + throw new ClassNotFoundException("mock"); + } + return clazz; + } + } +} diff --git a/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ClassPathHelper.java b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ClassPathHelper.java new file mode 100644 index 000000000..14fa39cc8 --- /dev/null +++ b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ClassPathHelper.java @@ -0,0 +1,78 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Enumeration; + +import org.junit.jupiter.api.condition.OS; + +final class ClassPathHelper +{ + static URL findUrlForClass(final Class clazz) + { + return findUrlForClass(clazz.getName()); + } + + static URL findUrlForClass(final String className) + { + try + { + return findJarForClass(className).toUri().toURL(); + } + catch (final MalformedURLException exception) + { + throw new IllegalStateException("Error creating URL for class " + className, exception); + } + } + + static Path findJarForClass(final Class clazz) + { + return findJarForClass(clazz.getName()); + } + + static Path findJarForClass(final String className) + { + final String resourceName = className.replace('.', '/') + ".class"; + final Enumeration urls = getResources(resourceName); + while (urls.hasMoreElements()) + { + final URL url = urls.nextElement(); + if ("jar".equals(url.getProtocol())) + { + final Path path = Path.of(getJarPath(url)); + assertTrue(Files.exists(path)); + return path; + } + } + throw new AssertionError("No jar found containing " + className); + } + + private static String getJarPath(final URL url) + { + final String jarPath = url.getPath().substring(5, url.getPath().indexOf("!")); + if (OS.WINDOWS.isCurrentOs()) + { + // Remove leading slash of "/C:/Users/user/.m2/..." + return jarPath.substring(1); + } + return jarPath; + } + + private static Enumeration getResources(final String resourceName) + { + try + { + return Thread.currentThread().getContextClassLoader().getResources(resourceName); + } + catch (final IOException exception) + { + throw new UncheckedIOException("Error finding resource " + resourceName, exception); + } + } +} diff --git a/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ClassPathServiceLoaderTest.java b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ClassPathServiceLoaderTest.java new file mode 100644 index 000000000..8e1ae46eb --- /dev/null +++ b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ClassPathServiceLoaderTest.java @@ -0,0 +1,74 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.*; +import java.util.ServiceLoader.Provider; +import java.util.stream.Stream; + +import org.itsallcode.openfasttrace.api.report.ReporterFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ClassPathServiceLoaderTest +{ + @Test + void loadingNonAccessibleServiceFails() + { + final ServiceOrigin origin = ServiceOrigin.forCurrentClassPath(); + final ServiceConfigurationError error = assertThrows(ServiceConfigurationError.class, + () -> ClassPathServiceLoader.create(DummyService.class, origin)); + assertThat(error.getMessage(), equalTo( + "org.itsallcode.openfasttrace.core.serviceloader.ClassPathServiceLoaderTest$DummyService: module org.itsallcode.openfasttrace.core does not declare `uses`")); + } + + @Test + void loadingFindsNothing() + { + final List services = ClassPathServiceLoader + .create(ReporterFactory.class, ServiceOrigin.forCurrentClassPath()).load() + .toList(); + assertThat(services, emptyIterable()); + } + + @Test + void loadingReturnsService(@Mock final ServiceLoader serviceLoaderMock, + @Mock final Provider providerMock, @Mock final ServiceOrigin originMock) + { + final DummyServiceImpl service = new DummyServiceImpl(); + when(serviceLoaderMock.stream()).thenReturn(Stream.of(providerMock)); + when(providerMock.get()).thenReturn(service); + when(originMock.getClassLoader()).thenReturn(DummyServiceImpl.class.getClassLoader()); + final List services = new ClassPathServiceLoader(originMock, serviceLoaderMock) + .load().toList(); + assertThat(services, contains(sameInstance(service))); + } + + @Test + void loadingIgnoresServicesFromOtherClassLoaders(@Mock final ServiceLoader serviceLoaderMock, + @Mock final Provider providerMock, @Mock final ServiceOrigin originMock) + { + final DummyServiceImpl service = new DummyServiceImpl(); + when(serviceLoaderMock.stream()).thenReturn(Stream.of(providerMock)); + when(providerMock.get()).thenReturn(service); + when(originMock.getClassLoader()).thenReturn(mock(ClassLoader.class)); + final List services = new ClassPathServiceLoader(originMock, serviceLoaderMock) + .load().toList(); + assertThat(services, empty()); + } + + static interface DummyService + { + } + + static class DummyServiceImpl implements DummyService + { + } +} diff --git a/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/DelegatingLoaderTest.java b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/DelegatingLoaderTest.java new file mode 100644 index 000000000..9a5591c65 --- /dev/null +++ b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/DelegatingLoaderTest.java @@ -0,0 +1,61 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.empty; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DelegatingLoaderTest +{ + @Test + void noDelegates() + { + assertThat(load(List.of()), empty()); + } + + private List load(final List> delegates) + { + try (DelegatingLoader loader = new DelegatingLoader(delegates)) + { + return loader.load().toList(); + } + } + + @Test + void emptyDelegate(@Mock final Loader loaderMock) + { + when(loaderMock.load()).thenReturn(Stream.empty()); + assertThat(load(List.of(loaderMock)), empty()); + } + + @Test + void nonEmptyDelegate(@Mock final Loader loaderMock, @Mock final DummyService serviceMock) + { + when(loaderMock.load()).thenReturn(Stream.of(serviceMock)); + assertThat(load(List.of(loaderMock)), contains(serviceMock)); + } + + @Test + void loadsDelegatesInOrder(@Mock final Loader loaderMock1, + @Mock final Loader loaderMock2, @Mock final DummyService serviceMock1, + @Mock final DummyService serviceMock2) + { + when(loaderMock1.load()).thenReturn(Stream.of(serviceMock1)); + when(loaderMock2.load()).thenReturn(Stream.of(serviceMock2)); + assertThat(load(List.of(loaderMock1, loaderMock2)), + contains(serviceMock1, serviceMock2)); + } + + static interface DummyService + { + } +} diff --git a/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/InitializingServiceLoaderTest.java b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/InitializingServiceLoaderTest.java new file mode 100644 index 000000000..3310a078c --- /dev/null +++ b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/InitializingServiceLoaderTest.java @@ -0,0 +1,47 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.stream.Stream; + +import org.itsallcode.openfasttrace.api.core.serviceloader.Initializable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class InitializingServiceLoaderTest +{ + @Mock + Loader delegateMock; + @Mock + Object contextMock; + @Mock + ServiceMock serviceInstanceMock; + + @Test + void initializesServices() + { + when(delegateMock.load()).thenReturn(Stream.of(serviceInstanceMock)); + try (Loader loader = new InitializingServiceLoader<>(delegateMock, contextMock)) + { + loader.load(); + } + verify(serviceInstanceMock).init(same(contextMock)); + } + + @Test + void closesDelegate() + { + new InitializingServiceLoader<>(delegateMock, contextMock).close(); + verify(delegateMock).close(); + } + + private interface ServiceMock extends Initializable + { + + } +} diff --git a/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceLoaderFactoryTest.java b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceLoaderFactoryTest.java new file mode 100644 index 000000000..2741e34d8 --- /dev/null +++ b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceLoaderFactoryTest.java @@ -0,0 +1,131 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.itsallcode.openfasttrace.api.report.ReporterFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.opentest4j.MultipleFailuresError; + +// [utest->dsn~plugins.loading~1] +class ServiceLoaderFactoryTest +{ + @TempDir + Path tempDir; + + @Test + void noPluginJar() + { + assertThat(factory().createLoader(ReporterFactory.class).load().toList(), empty()); + } + + private ServiceLoaderFactory factory() + { + return new ServiceLoaderFactory(tempDir, true); + } + + @Test + void findServiceSkipCurrentClassLoader() + { + final Path missingDirectory = tempDir.resolve("missing-dir"); + assertThat(new ServiceLoaderFactory(missingDirectory, false).findServiceOrigins(), empty()); + } + + @Test + void findServiceMissingParentDirFindsNoPlugins() throws IOException + { + Files.delete(tempDir); + final List origins = factory().findServiceOrigins(); + assertNoPlugins(origins); + } + + private void assertNoPlugins(final List origins) throws MultipleFailuresError + { + assertThat(origins, contains( + hasProperty("classLoader", + hasProperty("name", equalTo("app"))))); + } + + @Test + void findServiceOriginsPluginDir() + { + final List origins = factory().findServiceOrigins(); + assertNoPlugins(origins); + } + + @Test + void findServiceOriginsEmptyPluginDir() throws IOException + { + final Path pluginDir = tempDir.resolve("plugin1"); + Files.createDirectories(pluginDir); + final List origins = factory().findServiceOrigins(); + assertNoPlugins(origins); + } + + @Test + void findServiceOriginsIgnoresNonJarFiles() throws IOException + { + final Path pluginDir = tempDir.resolve("plugin1"); + Files.createDirectories(pluginDir); + Files.createFile(pluginDir.resolve("plugin1.txt")); + final List origins = factory().findServiceOrigins(); + assertNoPlugins(origins); + } + + @Test + void findServiceOriginsIgnoresDirectories() throws IOException + { + final Path pluginDir = tempDir.resolve("plugin1"); + Files.createDirectories(pluginDir.resolve("ignored-directory")); + final List origins = factory().findServiceOrigins(); + assertNoPlugins(origins); + } + + @Test + void findServiceOriginsSingleJar() throws IOException + { + final Path pluginDir = tempDir.resolve("plugin1"); + Files.createDirectories(pluginDir); + Files.createFile(pluginDir.resolve("plugin1.jar")); + final List origins = factory().findServiceOrigins(); + assertAll(() -> assertThat(origins, hasSize(2)), + () -> assertThat(origins.get(0).getClassLoader().getName(), equalTo("JarClassLoader-plugin1.jar"))); + } + + @Test + void findServiceOriginsMultiJar() throws IOException + { + final Path pluginDir = tempDir.resolve("plugin1"); + Files.createDirectories(pluginDir); + Files.createFile(pluginDir.resolve("plugin1.jar")); + Files.createFile(pluginDir.resolve("plugin2.jar")); + final List origins = factory().findServiceOrigins(); + assertAll(() -> assertThat(origins, hasSize(2)), + () -> assertThat(origins.get(0).getClassLoader().getName(), + equalTo("JarClassLoader-plugin1.jar,plugin2.jar"))); + } + + @Test + void findServiceOriginsMultiPlugins() throws IOException + { + final Path pluginDir1 = tempDir.resolve("plugin1"); + final Path pluginDir2 = tempDir.resolve("plugin2"); + Files.createDirectories(pluginDir1); + Files.createDirectories(pluginDir2); + Files.createFile(pluginDir1.resolve("plugin1.jar")); + Files.createFile(pluginDir2.resolve("plugin2.jar")); + final List origins = factory().findServiceOrigins(); + assertAll(() -> assertThat(origins, hasSize(3)), + () -> assertThat(origins.get(0).getClassLoader().getName(), + equalTo("JarClassLoader-plugin1.jar")), + () -> assertThat(origins.get(1).getClassLoader().getName(), + equalTo("JarClassLoader-plugin2.jar"))); + } +} diff --git a/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceMock.java b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceMock.java new file mode 100644 index 000000000..6f5111816 --- /dev/null +++ b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceMock.java @@ -0,0 +1,6 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +public @interface ServiceMock +{ + // Service mock interface +} diff --git a/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceOriginTest.java b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceOriginTest.java new file mode 100644 index 000000000..a94721234 --- /dev/null +++ b/core/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceOriginTest.java @@ -0,0 +1,74 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import java.net.URLClassLoader; +import java.util.Collections; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +// [utest->dsn~plugins.loading~1] +@ExtendWith(MockitoExtension.class) +class ServiceOriginTest +{ + @Test + void closesUrlClassLoader(@Mock final URLClassLoader classLoaderMock) + throws Exception + { + final ServiceOrigin serviceOrigin = new ServiceOrigin.JarFileOrigin(Collections.emptyList(), classLoaderMock); + serviceOrigin.close(); + verify(((AutoCloseable) classLoaderMock)).close(); + verifyNoMoreInteractions(classLoaderMock); + } + + @Test + void currentClassPathOriginToString() + { + final ServiceOrigin origin = ServiceOrigin.forCurrentClassPath(); + assertThat(origin, hasToString(startsWith("CurrentClassPathOrigin [classLoader="))); + } + + // [utest->dsn~plugins.loading.separate-classloader~1] + @Test + void currentClassPathGetClassLoader() + { + final ServiceOrigin origin = ServiceOrigin.forCurrentClassPath(); + assertThat(origin.getClassLoader(), sameInstance(Thread.currentThread().getContextClassLoader())); + } + + @Test + void currentClassPathClose() + { + final ServiceOrigin origin = ServiceOrigin.forCurrentClassPath(); + assertDoesNotThrow(origin::close); + } + + @Test + void jarFileOriginToString() + { + final ServiceOrigin origin = ServiceOrigin.forJar(ClassPathHelper.findJarForClass(Test.class)); + assertThat(origin, + hasToString(allOf(startsWith("JarFileOrigin [classLoader="), containsString("junit-jupiter-api")))); + } + + @Test + void jarFileOriginGetClassLoader() + { + final ServiceOrigin origin = ServiceOrigin.forJar(ClassPathHelper.findJarForClass(Test.class)); + assertThat(origin.getClassLoader(), instanceOf(URLClassLoader.class)); + } + + @Test + void jarFileOriginClose() + { + final ServiceOrigin origin = ServiceOrigin.forJar(ClassPathHelper.findJarForClass(Test.class)); + assertDoesNotThrow(origin::close); + } +} diff --git a/core/src/test/resources/logging.properties b/core/src/test/resources/logging.properties index b0722c144..edc57772b 100644 --- a/core/src/test/resources/logging.properties +++ b/core/src/test/resources/logging.properties @@ -1,6 +1,6 @@ handlers = java.util.logging.ConsoleHandler org.itsallcode.openfasttrace.testutil.log.NoOpLoggingHandler .level = INFO -java.util.logging.ConsoleHandler.level = INFO +java.util.logging.ConsoleHandler.level = ALL java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter java.util.logging.ConsoleHandler.encoding = UTF-8 java.util.logging.SimpleFormatter.format = %1$tF %1$tT [%4$s] %2$s - %5$s %6$s%n diff --git a/doc/changes/changes_4.1.0.md b/doc/changes/changes_4.1.0.md index fb58dfef7..eee32d73d 100644 --- a/doc/changes/changes_4.1.0.md +++ b/doc/changes/changes_4.1.0.md @@ -1,15 +1,19 @@ -# OpenFastTrace 4.1.0, released 2024-08-?? +# OpenFastTrace 4.1.0, released 2024-08-11 -Code name: ??? +Code name: Third-party plugins ## Summary -This release adds support for using [Gherkin](https://cucumber.io/docs/gherkin/) `feature` files with OFT. +This release adds support for loading third-party plugins from external JAR files. See the documentation for details: -## Features +* [Installation](../plugins.md) +* [Plugin developer guide](../plugin_developer_guide.md) + +The release also adds command line option `--log-level` that allows configuring the log level. Possible values are `OFF`, `SEVERE`, `WARNING`, `INFO`, `CONFIG`, `FINE`, `FINER`, `FINEST`, `ALL`. The default log level is `WARNING`. -* #425: Add support for reading Tags from Gherkin feature files +The release also adds support for using [Gherkin](https://cucumber.io/docs/gherkin/) `feature` files with OFT, thanks to [@sophokles73](https://github.com/sophokles73) for his contribution! -## Bugfixes +## Features -## Refactoring +* #413: Added support for third-party plugins +* #425: Add support for reading Tags from Gherkin feature files ([@sophokles73](https://github.com/sophokles73)) diff --git a/doc/developer_guide.md b/doc/developer_guide.md index fdf2f9a10..ff7812d62 100644 --- a/doc/developer_guide.md +++ b/doc/developer_guide.md @@ -98,19 +98,19 @@ git clone https://github.com/itsallcode/openfasttrace.git Run unit tests: ```sh -mvn test +mvn -T 1C test ``` Run unit and integration tests and additional checks: ```sh -mvn verify +mvn -T 1C verify ``` Build OFT: ```sh -mvn package -DskipTests +mvn -T 1C package -DskipTests ``` This will build the executable JAR including all modules at `product/target/openfasttrace-$VERSION.jar`. @@ -163,13 +163,13 @@ mvn --update-snapshots versions:display-dependency-updates versions:display-plug Automatically upgrade dependencies: ```bash -mvn --update-snapshots versions:use-latest-releases versions:update-properties +mvn -T 1C --update-snapshots versions:use-latest-releases versions:update-properties ``` ## Run local sonar analysis ```bash -mvn clean org.jacoco:jacoco-maven-plugin:prepare-agent package sonar:sonar \ +mvn -T 1C clean org.jacoco:jacoco-maven-plugin:prepare-agent package sonar:sonar \ -Dsonar.token=[token] ``` diff --git a/doc/plugin_developer_guide.md b/doc/plugin_developer_guide.md new file mode 100644 index 000000000..5cd6f2f4c --- /dev/null +++ b/doc/plugin_developer_guide.md @@ -0,0 +1,41 @@ +# Plugin Developer Guide + +This guide describes how to develop [plugins](plugins.md) for OpenFastTrace (OFT). + +## Initial Setup + +1. Create a new Java project and add dependency [`org.itsallcode.openfasttrace:openfasttrace-api`](https://search.maven.org/artifact/org.itsallcode.openfasttrace/openfasttrace-api): + ```xml + + org.itsallcode.openfasttrace + openfasttrace-api + [latest version] + + ``` + +2. Create a new class (e.g. `com.example.oft.import.MyImporter`) implementing one of the following interfaces: + * [`org.itsallcode.openfasttrace.api.report.ReporterFactory`](https://github.com/itsallcode/openfasttrace/blob/main/api/src/main/java/org/itsallcode/openfasttrace/api/report/ReporterFactory.java): Generate tracing report + * [`org.itsallcode.openfasttrace.api.importer.ImporterFactory`](https://github.com/itsallcode/openfasttrace/blob/main/api/src/main/java/org/itsallcode/openfasttrace/api/importer/ImporterFactory.java): Import requirements from a new file format + * [`org.itsallcode.openfasttrace.api.exporter.ExporterFactory`](https://github.com/itsallcode/openfasttrace/blob/main/api/src/main/java/org/itsallcode/openfasttrace/api/exporter/ExporterFactory.java): Export requirements in a new file format + +3. Create a file in `src/main/resources/$INTERFACE_FQN`, using the fully qualified class name of the interface as file name. + +4. Add the fully qualified class name of your new plugin class to the new file, e.g. `com.example.oft.import.MyImporter` + +## Runtime Dependencies + +OpenFastTrace does not use any third-party runtime dependencies by design. You can add any dependencies to your plugin if required (see [note about packaging](#adding-third-party-dependencies)). + +**Warning** The plugin must not use any classes other than those included in the API `openfasttrace-api`. All other classes in OFT are internal and may change in incompatible ways even in patch releases. + +## Packaging + +Build your plugin as a normal Java JAR file, e.g. using `maven-jar-plugin`. The JAR must not contain `openfasttrace-api`. + +### Adding Third-Party Dependencies + +If your plugin uses third party dependencies, you have two options: +* Publish and install the plugin and its dependencies as separate JARs. +* Build a fat JAR, e.g. using `maven-shade-plugin` and include the plugin's dependencies. + + **Important:** do not include `openfasttrace-api` in the fat JAR to avoid having duplicate classes on the classpath at runtime. diff --git a/doc/plugins.md b/doc/plugins.md new file mode 100644 index 000000000..054b3bc19 --- /dev/null +++ b/doc/plugins.md @@ -0,0 +1,15 @@ +# Extending OpenFastTrace With Plugins + +Version 4.1.0 adds support for extending OFT with third-party plugins. + +## Installing Plugins + +You install a plugin by copying its JAR files to `$HOME/.oft/plugins//*.jar`. OFT will automatically load plugins from this location. To check which plugins are available, start OFT with command line argument `--log-level INFO`. This will log all available plugins and their location. + +## Available Plugins + +Currently no third-party plugins are available. If you want to add a new plugin, please create a [GitHub issue](https://github.com/itsallcode/openfasttrace/issues/new?assignees=&labels=&projects=&template=New_plugin.md). + +| Plugin Name | Plugin Type | Description | +|-------------|-------------|-------------| +| N/A | N/A | Currently, no third-party plugins are available for OpenFastTrace. | diff --git a/doc/spec/design.md b/doc/spec/design.md index 81333ade9..47a084b6e 100644 --- a/doc/spec/design.md +++ b/doc/spec/design.md @@ -92,6 +92,9 @@ Since the specification item IDs inherently look similar, load tests need to sho # Building Block View +## Plugin Loader +The plugin loader discovers and loads available plugins. + ## Importers For each specification artifact type OFT uses an importer. The importer uses the specification artifact as data source and reads specification items from it. @@ -122,6 +125,53 @@ API users select exporters via their name as strings. # Runtime View +## Discovering and Loading Plugins +`dsn~plugins.loading~1` + +OFT loads plugins at startup from these locations: +* Plugins included with OFT from the current ClassPath +* Third party plugins from folder `$HOME/.oft/plugins//*.jar` + +Rationale: + +* A plugin may consist of multiple JARs, e.g. the plugin itself and it's dependencies. +* A plugin might need extra resources + +Covers: + +* [`req~plugins.loading~1`](system_requirements.md#loading-plugins) + +Needs: impl, utest, itest + +### Plugin Loader Uses Separate ClassLoaders +`dsn~plugins.loading.separate-classloader~1` + +The Plugin loader uses a separate ClassLoader for each plugin. + +Rationale: + +Separating plugins from each other avoids conflicts between potentially duplicate classes in plugin. + +Covers: + +* [`req~plugins.loading~1`](system_requirements.md#loading-plugins) + +Needs: impl, utest + +### Loader Supports Plugin Types +`dsn~plugins.loading.plugin-types~1` + +The Plugin loader supports loading factories for the following plugin types: + +* Importers: `org.itsallcode.openfasttrace.api.importer.ImporterFactory` +* Exporters: `org.itsallcode.openfasttrace.api.exporter.ExporterFactory` +* Reports: `org.itsallcode.openfasttrace.api.report.ReporterFactory` + +Covers: +* [`req~plugins.types~1`](system_requirements.md#supported-plugin-types) + +Needs: impl, itest + ## Import Depending on the source format a variety of [importers](#importers) takes care of reading the input [specification items](#specification-item). Each importer emits events which an [import event listener](#import-event-listener) consumes. @@ -835,7 +885,7 @@ Needs: impl, utest The CLI expects one of the following commands as first unnamed command line parameter: - command = "trace" / "convert" + command = "trace" / "convert" / "help" Covers: @@ -978,6 +1028,16 @@ Covers: Needs: impl, itest, utest +### Listing Plugins +`dsn~cli.plugins.log~1` + +The CLI logs available plugins in OFT at startup. + +Covers: +- [req~plugins.log~1](system_requirements.md#logging-available-plugins) + +Needs: impl + # Design Decisions ## How do we Implement the Command Line Interpreter diff --git a/doc/spec/system_requirements.md b/doc/spec/system_requirements.md index 118d10eb5..d739ab9f7 100644 --- a/doc/spec/system_requirements.md +++ b/doc/spec/system_requirements.md @@ -175,6 +175,21 @@ Running traces automatically in a scripted environment is the most important use Needs: req +### Third Party Plugins + +`feat~plugins~1` + +Users can extend OFT's features with plugins from third parties. + +Rationale: + +* Some use cases or proprietary file formats might only be relevant for a very small user group. It does not make sense to integrate this into the core OFT product. +* Some importers/exporters require additional dependencies that we don't want to include in the core OFT product. + +* We want to be able to release OFT and the plugins independently. Especially if the plugins have many dependencies, it is expected that they need frequent security updates. By keeping them separate, we make sure that users don't have to update OFT whenever a plugin needs a security update. + +Needs: req + ## Functional Requirements ### Anatomy of Specification Items @@ -794,3 +809,54 @@ Covers: * [feat~plain-text-report](#plain-text-report) Needs: dsn + +### Third Party Plugins + +#### Loading Plugins +`req~plugins.loading~1` + +OFT automatically loads plugins from JAR files located in a predefined location at startup. + +Rationale: + +* Plugins must be only installed once in the correct location. +* This requires no additional configuration by the user. +* OFT adheres to the standard locations for plugin installation depending on the OS. + +Covers: +* [feat~plugins~1](#third-party-plugins) + +Needs: dsn + +#### Supported Plugin Types +`req~plugins.types~1` + +OFT supports the following plugin types: + +* Importers add support for importing requirements from additional file formats. +* Exporters add support for exporting requirements in additional file formats. +* Reports add support for generating reports in additional formats. + +Covers: + +* [feat~plugins~1](#third-party-plugins) + +Needs: dsn + +#### Logging Available Plugins +`req~plugins.log~1` + +OFT logs all currently available plugins at startup including the following information: +* Plugin type (importer, exporter, reporter) +* Location (included with OFT, external JAR) +* Version number + +Rationale: + +This is helpful for debugging in case OFT does not use a plugin as expected. + +Covers: + +* [feat~plugins~1](#third-party-plugins) + +Needs: dsn diff --git a/doc/user_guide.md b/doc/user_guide.md index b0aab1074..8534f2bc0 100644 --- a/doc/user_guide.md +++ b/doc/user_guide.md @@ -566,6 +566,11 @@ The available color schemes are `color` : Color output. Also enables font style on the console. + + -l, --log-level + +Log level for console logging. One of `OFF`, `SEVERE`, `WARNING`, `INFO`, `CONFIG`, `FINE`, `FINER`, `FINEST`, `ALL`. Defaults to `WARNING`. + ### Build Integration In order to integrate requirement tracing with OFT into your CI build, we recommend using the OFT plugins for Maven and Gradle: @@ -962,21 +967,21 @@ or run a report. The following example code use OFT as a converter that scans the current working directory recursively (default import setting) and exports the found artifacts with the standard settings to a ReqM2 file. -```JAVA +```java import org.itsallcode.openfasttrace.Oft; import org.itsallcode.openfasttrace.core.SpecificationItem; ``` Select input paths and import specification items from there: -```JAVA +```java final Oft oft = Oft.create(); final List items = oft.importItems(settings); ``` Export the items: -```JAVA +```java oft.exportToPath(items, Paths.get("/output/path/export.oreqm")); ``` @@ -984,7 +989,7 @@ oft.exportToPath(items, Paths.get("/output/path/export.oreqm")); The example below shows how to use OFT as a reporter. -```JAVA +```java import org.itsallcode.openfasttrace.Oft; import org.itsallcode.openfasttrace.core.LinkedSpecificationItem; import org.itsallcode.openfasttrace.core.SpecificationItem; @@ -993,7 +998,7 @@ import org.itsallcode.openfasttrace.core.Trace; The import is similar to the converter case, except this time we add an input path explicitly for the sake of demonstration: -```JAVA +```java final ImportSettings settings = ImportSettings // .builder() // .addInputs("/input/path") // @@ -1004,25 +1009,25 @@ final List items = oft.importItems(settings); Now link the items together (i.e. make them navigable): -```JAVA +```java final List linkedItems = oft.link(items); ``` Run the tracer on the linked items: -```JAVA +```java final Trace trace = oft.trace(linkedItems); ``` Create a report from the trace: -```JAVA +```java oft.reportToStdOut(trace); ``` You can also use the trace results in your own code: -```JAVA +```java if (trace.hasNoDefects()) { // ... do something @@ -1033,7 +1038,7 @@ if (trace.hasNoDefects()) There are various reporting formats for OFT and one can set it using the ReportSettings object. -```JAVA +```java ReportSettings reportSettings = ReportSettings.builder().outputFormat("html").build(); ``` @@ -1041,13 +1046,13 @@ The `ReportSettings` builder has other functions as well that allow you to set v OFT allows you to report directly to the standard output or to a file -```JAVA -//Reporting to a file +```java +// Reporting to a file oft.reportToPath(trace, reportPath, reportSettings); ``` -```JAVA -//Reporting to stdout +```java +// Reporting to stdout oft.reportToStdOut(trace); ``` @@ -1061,7 +1066,7 @@ Import, export and report each have an overloaded variant that can be configured Each of those classes comes with a builder which is called like this: -```JAVA +```java ReportSettings settings = ReportSettings.builder().newline(Newline.UNIX).build(); ``` @@ -1087,3 +1092,7 @@ The following editors and integrated development environments are well suited fo | [IntelliJ](https://www.jetbrains.com/idea/) | y | y | y | y | | [Vim](https://www.vim.org/) | y | | | | | [Visual Studio Code](https://code.visualstudio.com/) | y | y | y | | + +### Templates for IDEs + +You can create OpenFastTrace artifacts faster with templates for your IDE. See [the list of available IDE Templates](https://github.com/itsallcode/openfasttrace-ide-templates). diff --git a/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/LongTagImportingLineConsumer.java b/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/LongTagImportingLineConsumer.java index d526730b6..17853ce2a 100644 --- a/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/LongTagImportingLineConsumer.java +++ b/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/LongTagImportingLineConsumer.java @@ -1,7 +1,6 @@ package org.itsallcode.openfasttrace.importer.tag; import static java.util.Collections.emptyList; -import static java.util.stream.Collectors.toList; import java.util.*; import java.util.function.Predicate; @@ -75,7 +74,7 @@ private List parseCommaSeparatedList(final String input) return Arrays.stream(input.split(",")) .map(String::trim) .filter(Predicate.not(String::isEmpty)) - .collect(toList()); + .toList(); } private SpecificationItemId createItemId(final Matcher matcher, final int lineNumber, final int lineMatchCount, diff --git a/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/TagImporterFactory.java b/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/TagImporterFactory.java index 55af54888..b099b5cf2 100644 --- a/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/TagImporterFactory.java +++ b/importer/tag/src/main/java/org/itsallcode/openfasttrace/importer/tag/TagImporterFactory.java @@ -1,7 +1,5 @@ package org.itsallcode.openfasttrace.importer.tag; -import static java.util.stream.Collectors.toList; - import java.util.*; import java.util.stream.Stream; @@ -92,7 +90,7 @@ public Importer createImporter(final InputFile path, final ImportEventListener l { throw new ImporterException("File '" + path + "' cannot be imported because it does not match any supported file patterns: " - + DEFAULT_FILE_REGEX + " and " + getPathConfigs().collect(toList())); + + DEFAULT_FILE_REGEX + " and " + getPathConfigs().toList()); } final Optional config = findConfig(path); return TagImporter.create(config, path, listener); diff --git a/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporter.java b/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporter.java index 0454ffc86..f7017f6cb 100644 --- a/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporter.java +++ b/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporter.java @@ -1,7 +1,6 @@ package org.itsallcode.openfasttrace.importer.tag; import static java.util.Collections.emptyList; -import static java.util.stream.Collectors.toList; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasSize; @@ -229,7 +228,7 @@ private static Arguments parsedItems(final String content, final SpecificationIt final List expectedItems = Arrays.stream(itemBuilders) .map(TestTagImporter::setLocation) .map(SpecificationItem.Builder::build) - .collect(toList()); + .toList(); return Arguments.of(content, expectedItems); } diff --git a/oft-self-trace.sh b/oft-self-trace.sh index 86d3c35d5..47b612c0a 100755 --- a/oft-self-trace.sh +++ b/oft-self-trace.sh @@ -12,6 +12,7 @@ report_file=$base_dir/target/self-trace-report.html mkdir -p "$(dirname "$report_file")" if $oft_script trace \ + --log-level INFO \ --output-file "$report_file" \ --output-format html \ "$base_dir/doc/spec" \ diff --git a/parent/pom.xml b/parent/pom.xml index 977bb064e..d9c8a1849 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -13,7 +13,7 @@ 4.1.0 17 5.11.0-M2 - 3.2.5 + 3.3.0 UTF-8 UTF-8 ${git.commit.time} @@ -298,7 +298,7 @@ io.github.git-commit-id git-commit-id-maven-plugin - 8.0.2 + 9.0.0 get-the-git-infos @@ -465,7 +465,7 @@ org.apache.maven.plugins maven-enforcer-plugin - 3.4.1 + 3.5.0 enforce-maven @@ -549,7 +549,7 @@ org.apache.maven.plugins maven-clean-plugin - 3.3.2 + 3.4.0 org.apache.maven.plugins @@ -564,12 +564,12 @@ org.apache.maven.plugins maven-site-plugin - 4.0.0-M13 + 4.0.0-M15 org.apache.maven.plugins maven-shade-plugin - 3.5.3 + 3.6.0 org.apache.maven.plugins @@ -584,7 +584,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.6.3 + 3.7.0 org.codehaus.mojo diff --git a/product/src/test/java/org/itsallcode/openfasttrace/ITestSelfTrace.java b/product/src/test/java/org/itsallcode/openfasttrace/ITestSelfTrace.java index f9a2e705d..a3d3938dd 100644 --- a/product/src/test/java/org/itsallcode/openfasttrace/ITestSelfTrace.java +++ b/product/src/test/java/org/itsallcode/openfasttrace/ITestSelfTrace.java @@ -1,7 +1,5 @@ package org.itsallcode.openfasttrace; -import static java.util.stream.Collectors.toList; - import java.io.*; import java.nio.file.*; import java.util.List; @@ -80,7 +78,7 @@ private List findInputDirectories(final Path rootProjectDir) .filter(path -> Files.isDirectory(path)) // .filter(endsWith(Paths.get("src/main")) // .or(endsWith(Paths.get("src/test/java")))) // - .collect(toList()); + .toList(); } catch (final IOException e) { diff --git a/product/src/test/java/org/itsallcode/openfasttrace/TestAllServicesAvailable.java b/product/src/test/java/org/itsallcode/openfasttrace/TestAllServicesAvailable.java index 716e8ac33..33368f051 100644 --- a/product/src/test/java/org/itsallcode/openfasttrace/TestAllServicesAvailable.java +++ b/product/src/test/java/org/itsallcode/openfasttrace/TestAllServicesAvailable.java @@ -14,10 +14,7 @@ import org.itsallcode.openfasttrace.api.core.Trace; import org.itsallcode.openfasttrace.api.exporter.Exporter; import org.itsallcode.openfasttrace.api.exporter.ExporterContext; -import org.itsallcode.openfasttrace.api.importer.ImportEventListener; -import org.itsallcode.openfasttrace.api.importer.Importer; -import org.itsallcode.openfasttrace.api.importer.ImporterContext; -import org.itsallcode.openfasttrace.api.importer.ImporterFactory; +import org.itsallcode.openfasttrace.api.importer.*; import org.itsallcode.openfasttrace.api.importer.input.InputFile; import org.itsallcode.openfasttrace.api.importer.input.RealFileInput; import org.itsallcode.openfasttrace.api.report.Reportable; @@ -25,7 +22,6 @@ import org.itsallcode.openfasttrace.core.exporter.ExporterFactoryLoader; import org.itsallcode.openfasttrace.core.importer.ImporterFactoryLoader; import org.itsallcode.openfasttrace.core.report.ReporterFactoryLoader; -import org.itsallcode.openfasttrace.core.serviceloader.InitializingServiceLoader; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; @@ -54,7 +50,7 @@ private static ImporterFactoryLoader createImporterLoader() final ImporterContext contextMock = mock(ImporterContext.class, withSettings().defaultAnswer(Answers.RETURNS_DEEP_STUBS)); - return new ImporterFactoryLoader(InitializingServiceLoader.load(ImporterFactory.class, contextMock)); + return new ImporterFactoryLoader(contextMock); } private static ExporterFactoryLoader createExporterLoader() diff --git a/product/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceLoaderFactoryIT.java b/product/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceLoaderFactoryIT.java new file mode 100644 index 000000000..5ab223eba --- /dev/null +++ b/product/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/ServiceLoaderFactoryIT.java @@ -0,0 +1,89 @@ +package org.itsallcode.openfasttrace.core.serviceloader; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +import org.itsallcode.openfasttrace.api.report.ReporterFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.opentest4j.TestAbortedException; + +/** + * Test for {@link ServiceLoaderFactory} from module {@code core}. This test + * must be located in module {@code product} (which includes all plugin modules) + * so that it can access all plugin services. + */ +// [itest->dsn~plugins.loading~1] +class ServiceLoaderFactoryIT +{ + @TempDir + Path tempDir; + + @Test + void loadServiceFromWrongJar() throws IOException + { + preparePlugin(Path.of("../reporter/plaintext/target"), + Pattern.compile("openfasttrace-reporter-plaintext-\\d\\.\\d\\.\\d\\-javadoc.jar")); + try (Loader loader = createLoader()) + { + final List service = loader.load().toList(); + assertThat(service, empty()); + } + } + + private Loader createLoader() + { + return new ServiceLoaderFactory(tempDir, false).createLoader(ReporterFactory.class); + } + + @Test + void loadServiceFromJar() throws IOException + { + preparePlugin(Path.of("../reporter/plaintext/target"), + Pattern.compile("openfasttrace-reporter-plaintext-\\d\\.\\d\\.\\d\\.jar")); + try (Loader loader = createLoader()) + { + final List services = loader.load().toList(); + assertThat(services, hasSize(1)); + final ReporterFactory service = services.get(0); + final ClassLoader pluginClassLoader = service.getClass().getClassLoader(); + assertAll( + () -> assertThat(service.getClass().getName().toString(), + equalTo("org.itsallcode.openfasttrace.report.plaintext.PlaintextReporterFactory")), + () -> assertThat(pluginClassLoader.getName(), + startsWith("JarClassLoader-openfasttrace-reporter-plaintext")), + () -> assertThat(pluginClassLoader, + not(sameInstance(Thread.currentThread().getContextClassLoader())))); + } + } + + private void preparePlugin(final Path targetDir, final Pattern filePattern) throws TestAbortedException, IOException + { + final Path jar = findMatchingFile(targetDir, filePattern) + .orElseThrow(() -> new AssertionError( + "Did not file matching '" + filePattern + "' in '" + targetDir + + "'. Ensure the module was built with 'mvn package'.")); + preparePlugin(jar); + } + + private Optional findMatchingFile(final Path dir, final Pattern filePattern) throws IOException + { + return Files.list(dir).filter(file -> filePattern.matcher(file.getFileName().toString()).matches()) + .findFirst(); + } + + private void preparePlugin(final Path pluginJar) throws IOException + { + final Path pluginDir = tempDir.resolve("plugin"); + Files.createDirectories(pluginDir); + Files.copy(pluginJar, pluginDir.resolve(pluginJar.getFileName())); + } +} diff --git a/product/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/TestInitializingServiceLoader.java b/product/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/TestInitializingServiceLoader.java index 4925686eb..63f898393 100644 --- a/product/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/TestInitializingServiceLoader.java +++ b/product/src/test/java/org/itsallcode/openfasttrace/core/serviceloader/TestInitializingServiceLoader.java @@ -1,27 +1,32 @@ package org.itsallcode.openfasttrace.core.serviceloader; -import static java.util.stream.Collectors.toList; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import java.util.List; -import java.util.stream.StreamSupport; import org.itsallcode.openfasttrace.api.core.serviceloader.Initializable; import org.itsallcode.openfasttrace.api.exporter.ExporterContext; import org.itsallcode.openfasttrace.api.exporter.ExporterFactory; import org.itsallcode.openfasttrace.api.importer.ImporterContext; import org.itsallcode.openfasttrace.api.importer.ImporterFactory; +import org.itsallcode.openfasttrace.api.report.ReporterContext; +import org.itsallcode.openfasttrace.api.report.ReporterFactory; import org.itsallcode.openfasttrace.exporter.specobject.SpecobjectExporterFactory; import org.itsallcode.openfasttrace.importer.markdown.MarkdownImporterFactory; import org.itsallcode.openfasttrace.importer.restructuredtext.RestructuredTextImporterFactory; import org.itsallcode.openfasttrace.importer.specobject.SpecobjectImporterFactory; import org.itsallcode.openfasttrace.importer.tag.TagImporterFactory; import org.itsallcode.openfasttrace.importer.zip.ZipFileImporterFactory; +import org.itsallcode.openfasttrace.report.aspec.ASpecReporterFactory; +import org.itsallcode.openfasttrace.report.html.HtmlReporterFactory; +import org.itsallcode.openfasttrace.report.plaintext.PlaintextReporterFactory; import org.junit.jupiter.api.Test; /** - * Test for {@link InitializingServiceLoader} + * Test for {@link InitializingServiceLoader} from module {@code core}. This + * test must be located in module {@code product} (which includes all plugin + * modules) so that it can access all plugin services. */ class TestInitializingServiceLoader { @@ -29,14 +34,14 @@ class TestInitializingServiceLoader void testNoServicesRegistered() { final Object context = new Object(); - final InitializingServiceLoader voidServiceLoader = InitializingServiceLoader + final Loader voidServiceLoader = InitializingServiceLoader .load(InitializableServiceStub.class, context); - final List services = StreamSupport - .stream(voidServiceLoader.spliterator(), false).collect(toList()); + final List services = voidServiceLoader.load().toList(); assertThat(services, emptyIterable()); - assertThat(voidServiceLoader, emptyIterable()); + assertThat(voidServiceLoader.load().toList(), emptyIterable()); } + // [itest->dsn~plugins.loading.plugin-types~1] @Test void testImporterFactoriesRegistered() { @@ -44,10 +49,11 @@ void testImporterFactoriesRegistered() final List services = getRegisteredServices(ImporterFactory.class, context); assertThat(services, hasSize(5)); - assertThat(services, containsInAnyOrder(instanceOf(MarkdownImporterFactory.class), // - instanceOf(RestructuredTextImporterFactory.class), // - instanceOf(SpecobjectImporterFactory.class), // - instanceOf(TagImporterFactory.class), // + assertThat(services, containsInAnyOrder( + instanceOf(MarkdownImporterFactory.class), + instanceOf(RestructuredTextImporterFactory.class), + instanceOf(SpecobjectImporterFactory.class), + instanceOf(TagImporterFactory.class), instanceOf(ZipFileImporterFactory.class))); for (final ImporterFactory importerFactory : services) { @@ -55,6 +61,7 @@ void testImporterFactoriesRegistered() } } + // [itest->dsn~plugins.loading.plugin-types~1] @Test void testExporterFactoriesRegistered() { @@ -62,19 +69,35 @@ void testExporterFactoriesRegistered() final List services = getRegisteredServices(ExporterFactory.class, context); assertThat(services, hasSize(1)); - assertThat(services, contains(instanceOf(SpecobjectExporterFactory.class))); + assertThat(services, containsInAnyOrder(instanceOf(SpecobjectExporterFactory.class))); for (final ExporterFactory factory : services) { assertThat(factory.getContext(), sameInstance(context)); } } + // [itest->dsn~plugins.loading.plugin-types~1] + @Test + void testReporterFactoriesRegistered() + { + final ReporterContext context = new ReporterContext(null); + final List services = getRegisteredServices(ReporterFactory.class, + context); + assertThat(services, hasSize(3)); + assertThat(services, containsInAnyOrder(instanceOf(PlaintextReporterFactory.class), + instanceOf(ASpecReporterFactory.class), + instanceOf(HtmlReporterFactory.class))); + for (final ReporterFactory factory : services) + { + assertThat(factory.getContext(), sameInstance(context)); + } + } + private , C> List getRegisteredServices(final Class type, final C context) { - final InitializingServiceLoader serviceLoader = InitializingServiceLoader.load(type, - context); - return StreamSupport.stream(serviceLoader.spliterator(), false).collect(toList()); + final Loader serviceLoader = InitializingServiceLoader.load(type, context); + return serviceLoader.load().toList(); } class InitializableServiceStub implements Initializable diff --git a/product/src/test/resources/logging.properties b/product/src/test/resources/logging.properties new file mode 100644 index 000000000..20697e382 --- /dev/null +++ b/product/src/test/resources/logging.properties @@ -0,0 +1,10 @@ +handlers = java.util.logging.ConsoleHandler org.itsallcode.openfasttrace.testutil.log.NoOpLoggingHandler +.level = INFO +java.util.logging.ConsoleHandler.level = ALL +java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter +java.util.logging.ConsoleHandler.encoding = UTF-8 +java.util.logging.SimpleFormatter.format = %1$tF %1$tT [%4$s] %2$s - %5$s %6$s%n + +org.itsallcode.openfasttrace.testutil.log.NoOpLoggingHandler.level = ALL + +org.itsallcode.openfasttrace.level = INFO