From 6c444769d7ba9ee2b2da71d75dd3401cd1c33b3d Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Wed, 6 Jul 2022 12:58:50 +0200 Subject: [PATCH] GH-1 - Initial port of Moduliths project. Basically the state of commit c7cf939 of https://github.com/moduliths/moduliths for further development under the Spring umbrella. --- .github/workflows/build.yaml | 49 ++ .gitignore | 14 + .mvn/wrapper/MavenWrapperDownloader.java | 117 +++ .mvn/wrapper/maven-wrapper.jar | Bin 0 -> 50710 bytes .mvn/wrapper/maven-wrapper.properties | 2 + LICENSE | 201 +++++ application.yml | 2 + etc/ide/README.md | 17 + etc/ide/eclipse-formatting.xml | 291 ++++++ etc/ide/intellij.importorder | 8 + lombok.config | 3 + moduliths-api/pom.xml | 28 + .../src/main/java/org/moduliths/Module.java | 41 + .../src/main/java/org/moduliths/Modulith.java | 85 ++ .../main/java/org/moduliths/Modulithic.java | 66 ++ .../java/org/moduliths/NamedInterface.java | 40 + moduliths-core/pom.xml | 83 ++ .../model/AnnotationModulithMetadata.java | 105 +++ .../model/ArchitecturallyEvidentType.java | 582 ++++++++++++ .../java/org/moduliths/model/Classes.java | 237 +++++ .../model/DefaultModulithMetadata.java | 122 +++ .../java/org/moduliths/model/EventType.java | 75 ++ .../moduliths/model/FormatableJavaClass.java | 123 +++ .../org/moduliths/model/JavaAccessSource.java | 65 ++ .../java/org/moduliths/model/JavaPackage.java | 180 ++++ .../main/java/org/moduliths/model/Module.java | 833 ++++++++++++++++++ .../model/ModuleDetectionStrategies.java | 60 ++ .../model/ModuleDetectionStrategy.java | 57 ++ .../moduliths/model/ModuleInformation.java | 151 ++++ .../java/org/moduliths/model/Modules.java | 451 ++++++++++ .../org/moduliths/model/ModulithMetadata.java | 101 +++ .../org/moduliths/model/NamedInterface.java | 178 ++++ .../org/moduliths/model/NamedInterfaces.java | 144 +++ .../main/java/org/moduliths/model/Source.java | 33 + .../java/org/moduliths/model/SpringBean.java | 68 ++ .../main/java/org/moduliths/model/Types.java | 158 ++++ .../java/org/moduliths/model/Violations.java | 122 +++ .../org/moduliths/model/package-info.java | 2 + .../acme/withatbean/SampleConfiguration.java | 33 + .../java/com/acme/withatbean/TestEvents.java | 47 + .../test/java/jmolecules/package-info.java | 2 + .../AnnotationModulithMetadataUnitTest.java | 59 ++ .../ArchitecturallyEvidentTypeUnitTest.java | 258 ++++++ .../model/ModuleDependencyUnitTest.java | 107 +++ .../ModuleDetectionStrategyUnitTest.java | 59 ++ .../org/moduliths/model/ModuleUnitTest.java | 72 ++ .../model/ModulithMetadataUnitTest.java | 85 ++ .../java/org/moduliths/model/TestUtils.java | 71 ++ .../moduliths/model/ViolationsUnitTests.java | 39 + .../src/test/resources/application.properties | 1 + moduliths-core/src/test/resources/logback.xml | 14 + moduliths-docs/pom.xml | 72 ++ .../java/org/moduliths/docs/Asciidoctor.java | 358 ++++++++ .../CodeReplacingDocumentationSource.java | 47 + .../docs/ConfigurationProperties.java | 174 ++++ .../moduliths/docs/DocumentationSource.java | 37 + .../java/org/moduliths/docs/Documenter.java | 821 +++++++++++++++++ ...SpringAutoRestDocsDocumentationSource.java | 44 + .../java/org/moduliths/docs/package-info.java | 2 + .../moduliths/docs/AsciidoctorUnitTests.java | 71 ++ .../org/moduliths/docs/DocumenterTest.java | 94 ++ .../spring-configuration-metadata.json | 19 + moduliths-docs/src/test/resources/logback.xml | 14 + .../moduliths-events-core/pom.xml | 20 + .../events/CompletableEventPublication.java | 61 ++ .../events/DefaultEventPublication.java | 54 ++ .../moduliths/events/EventPublication.java | 89 ++ .../events/EventPublicationRegistry.java | 65 ++ .../org/moduliths/events/EventSerializer.java | 39 + .../events/PublicationTargetIdentifier.java | 40 + .../config/EnablePersistentDomainEvents.java | 78 ++ .../config/EventPublicationConfiguration.java | 44 + ...ventPublicationConfigurationExtension.java | 21 + ...ntSerializationConfigurationExtension.java | 21 + .../moduliths/events/config/package-info.java | 2 + .../org/moduliths/events/package-info.java | 2 + ...ompletionRegisteringBeanPostProcessor.java | 224 +++++ .../support/MapEventPublicationRegistry.java | 79 ++ ...PersistentApplicationEventMulticaster.java | 247 ++++++ .../events/support/package-info.java | 2 + .../main/resources/META-INF/spring.factories | 2 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../CompletableEventPublicationUnitTest.java | 62 ++ ...nRegisteringBeanPostProcessorUnitTest.java | 105 +++ .../moduliths-events-jackson/pom.xml | 36 + ...acksonEventSerializationConfiguration.java | 49 ++ .../jackson/JacksonEventSerializer.java | 63 ++ .../events/jackson/package-info.java | 2 + .../main/resources/META-INF/spring.factories | 5 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../moduliths-events-jpa-jakarta/pom.xml | 96 ++ .../events/jpa/JpaEventPublication.java | 59 ++ .../JpaEventPublicationAutoConfiguration.java | 26 + .../jpa/JpaEventPublicationConfiguration.java | 44 + .../jpa/JpaEventPublicationRegistry.java | 175 ++++ .../jpa/JpaEventPublicationRepository.java | 86 ++ .../moduliths/events/jpa/package-info.java | 2 + .../META-INF/spring-devtools.properties | 1 + .../main/resources/META-INF/spring.factories | 6 + ...figure.AutoConfiguration.imports.factories | 2 + ...licationConfigurationIntegrationTests.java | 75 ++ ...PublicationRepositoryIntegrationTests.java | 118 +++ .../src/test/resources/logback.xml | 16 + moduliths-events/moduliths-events-jpa/pom.xml | 67 ++ .../events/jpa/JpaEventPublication.java | 60 ++ .../JpaEventPublicationAutoConfiguration.java | 28 + .../jpa/JpaEventPublicationConfiguration.java | 44 + .../jpa/JpaEventPublicationRegistry.java | 182 ++++ .../jpa/JpaEventPublicationRepository.java | 87 ++ .../moduliths/events/jpa/package-info.java | 2 + .../META-INF/spring-devtools.properties | 1 + .../main/resources/META-INF/spring.factories | 6 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + ...licationConfigurationIntegrationTests.java | 76 ++ ...PublicationRepositoryIntegrationTests.java | 119 +++ .../src/test/resources/logback.xml | 16 + .../moduliths-events-starter/pom.xml | 38 + .../DomainEventsAutoConfiguration.java | 51 ++ .../events/starter/package-info.java | 2 + .../main/resources/META-INF/spring.factories | 1 + .../moduliths-events-tests/pom.xml | 48 + .../events/InfrastructureConfiguration.java | 72 ++ .../PersistentDomainEventIntegrationTest.java | 207 +++++ .../src/test/resources/logback.xml | 17 + moduliths-events/pom.xml | 91 ++ moduliths-events/readme.adoc | 30 + moduliths-integration-test/pom.xml | 49 ++ .../moduliths/docs/DocumenterUnitTests.java | 83 ++ .../moduliths/model/JavaPackageUnitTests.java | 47 + .../model/ModulesIntegrationTest.java | 155 ++++ .../model/TestModuleDetectionStrategy.java | 40 + .../test/resources/META-INF/spring.factories | 1 + .../src/test/resources/logback.xml | 16 + moduliths-moments/pom.xml | 44 + moduliths-moments/readme.adoc | 59 ++ .../org/moduliths/moments/DayHasPassed.java | 37 + .../org/moduliths/moments/HourHasPassed.java | 37 + .../org/moduliths/moments/MonthHasPassed.java | 37 + .../java/org/moduliths/moments/Quarter.java | 63 ++ .../moduliths/moments/QuarterHasPassed.java | 79 ++ .../org/moduliths/moments/ShiftedQuarter.java | 143 +++ .../org/moduliths/moments/WeekHasPassed.java | 87 ++ .../org/moduliths/moments/YearHasPassed.java | 67 ++ .../MomentsAutoConfiguration.java | 61 ++ .../org/moduliths/moments/package-info.java | 2 + .../moduliths/moments/support/Moments.java | 159 ++++ .../moments/support/MomentsProperties.java | 137 +++ .../moments/support/TimeMachine.java | 63 ++ .../main/resources/META-INF/spring.factories | 2 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../moments/QuarterHasPassedUnitTests.java | 50 ++ .../moduliths/moments/QuarterUnitTests.java | 48 + .../moments/WeekHasPassedUnitTests.java | 45 + .../moments/YearHasPassedUnitTests.java | 40 + .../MomentsAutoConfigurationTests.java | 97 ++ .../moments/support/MomentsUnitTests.java | 187 ++++ .../src/test/resources/logback.xml | 16 + moduliths-observability/pom.xml | 76 ++ .../observability/ApplicationRuntime.java | 58 ++ .../observability/DefaultObservedModule.java | 141 +++ .../observability/ModuleEntryInterceptor.java | 93 ++ .../observability/ModuleEventListener.java | 73 ++ .../ModuleTracingBeanPostProcessor.java | 109 +++ .../observability/ModuleTracingSupport.java | 87 ++ .../observability/ModulesRuntime.java | 77 ++ .../observability/ObservedModule.java | 61 ++ .../observability/ObservedModuleType.java | 85 ++ ...ataRestModuleTracingBeanPostProcessor.java | 110 +++ .../ModuleObservabilityAutoConfiguration.java | 106 +++ .../SpringBootApplicationRuntime.java | 92 ++ ...tModuleObservabilityAutoConfiguration.java | 38 + .../main/resources/META-INF/spring.factories | 3 + ...figure.AutoConfiguration.imports.factories | 2 + .../test/java/example/ExampleApplication.java | 30 + .../ExampleApplicationIntegrationTests.java | 29 + ...SpringBootApplicationRuntimeUnitTests.java | 49 ++ .../src/test/resources/application.properties | 2 + .../src/test/resources/logback.xml | 16 + moduliths-sample/jqassistant/index.adoc | 79 ++ moduliths-sample/pom.xml | 97 ++ .../java/com/acme/myproject/Application.java | 26 + .../complex/api/ComplexApiComponent.java | 24 + .../myproject/complex/api/package-info.java | 2 + .../internal/ComplextInternalComponent.java | 26 + .../complex/internal/FirstTypeBasedPort.java | 26 + .../complex/internal/SecondTypeBasePort.java | 26 + .../complex/spi/ComplexSpiComponent.java | 24 + .../myproject/complex/spi/package-info.java | 2 + .../com/acme/myproject/cycleA/CycleA.java | 25 + .../com/acme/myproject/cycleB/CycleB.java | 25 + .../fieldinjected/WithFieldInjection.java | 30 + .../myproject/invalid/InvalidComponent.java | 26 + .../invalid2/InvalidModuleDependency.java | 33 + .../acme/myproject/invalid2/package-info.java | 2 + .../myproject/moduleA/ServiceComponentA.java | 35 + .../myproject/moduleA/SomeConfigurationA.java | 33 + .../acme/myproject/moduleA/SomeEventA.java | 29 + .../myproject/moduleB/ServiceComponentB.java | 34 + .../myproject/moduleB/SomeEventListenerB.java | 31 + .../moduleB/internal/InternalComponentB.java | 26 + .../internal/SupportingComponentB.java | 26 + .../myproject/moduleC/ServiceComponentC.java | 31 + .../acme/myproject/moduleC/package-info.java | 2 + .../myproject/stereotypes/Stereotypes.java | 83 ++ .../stereotypes/web/WebRepresentations.java | 26 + .../java/com/acme/myproject/ModulithTest.java | 73 ++ .../myproject/NonVerifyingModuleTest.java | 37 + .../acme/myproject/complex/ComplexTest.java | 44 + .../FieldInjectedIntegrationTest.java | 50 ++ .../acme/myproject/moduleA/ModuleATest.java | 64 ++ .../acme/myproject/moduleB/ModuleBTest.java | 91 ++ .../acme/myproject/moduleC/ModuleCTest.java | 85 ++ .../src/test/resources/application.properties | 1 + .../src/test/resources/logback.xml | 14 + moduliths-starter-jpa-jakarta/pom.xml | 53 ++ moduliths-starter-jpa/pom.xml | 53 ++ moduliths-starter-test/pom.xml | 33 + moduliths-test/pom.xml | 60 ++ .../moduliths/test/AggregateTestUtils.java | 89 ++ .../test/DefaultPublishedEvents.java | 165 ++++ .../test/ModuleContextCustomizerFactory.java | 156 ++++ .../java/org/moduliths/test/ModuleTest.java | 99 +++ .../test/ModuleTestAutoConfiguration.java | 113 +++ .../moduliths/test/ModuleTestExecution.java | 181 ++++ .../test/ModuleTypeExcludeFilter.java | 47 + .../org/moduliths/test/PublishedEvents.java | 99 +++ .../test/PublishedEventsExtension.java | 27 + .../PublishedEventsParameterResolver.java | 137 +++ .../java/org/moduliths/test/TestUtils.java | 73 ++ .../main/resources/META-INF/spring.factories | 1 + ...ishedEventsParameterResolverUnitTests.java | 103 +++ .../test/PublishedEventsUnitTests.java | 38 + moduliths-test/src/test/resources/logback.xml | 16 + mvnw | 310 +++++++ mvnw.cmd | 182 ++++ pom.xml | 298 +++++++ 236 files changed, 17771 insertions(+) create mode 100644 .github/workflows/build.yaml create mode 100644 .gitignore create mode 100644 .mvn/wrapper/MavenWrapperDownloader.java create mode 100644 .mvn/wrapper/maven-wrapper.jar create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 LICENSE create mode 100644 application.yml create mode 100644 etc/ide/README.md create mode 100644 etc/ide/eclipse-formatting.xml create mode 100644 etc/ide/intellij.importorder create mode 100644 lombok.config create mode 100644 moduliths-api/pom.xml create mode 100644 moduliths-api/src/main/java/org/moduliths/Module.java create mode 100644 moduliths-api/src/main/java/org/moduliths/Modulith.java create mode 100644 moduliths-api/src/main/java/org/moduliths/Modulithic.java create mode 100644 moduliths-api/src/main/java/org/moduliths/NamedInterface.java create mode 100644 moduliths-core/pom.xml create mode 100644 moduliths-core/src/main/java/org/moduliths/model/AnnotationModulithMetadata.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/ArchitecturallyEvidentType.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/Classes.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/DefaultModulithMetadata.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/EventType.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/FormatableJavaClass.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/JavaAccessSource.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/JavaPackage.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/Module.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/ModuleDetectionStrategies.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/ModuleDetectionStrategy.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/ModuleInformation.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/Modules.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/ModulithMetadata.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/NamedInterface.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/NamedInterfaces.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/Source.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/SpringBean.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/Types.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/Violations.java create mode 100644 moduliths-core/src/main/java/org/moduliths/model/package-info.java create mode 100644 moduliths-core/src/test/java/com/acme/withatbean/SampleConfiguration.java create mode 100644 moduliths-core/src/test/java/com/acme/withatbean/TestEvents.java create mode 100644 moduliths-core/src/test/java/jmolecules/package-info.java create mode 100644 moduliths-core/src/test/java/org/moduliths/model/AnnotationModulithMetadataUnitTest.java create mode 100644 moduliths-core/src/test/java/org/moduliths/model/ArchitecturallyEvidentTypeUnitTest.java create mode 100644 moduliths-core/src/test/java/org/moduliths/model/ModuleDependencyUnitTest.java create mode 100644 moduliths-core/src/test/java/org/moduliths/model/ModuleDetectionStrategyUnitTest.java create mode 100644 moduliths-core/src/test/java/org/moduliths/model/ModuleUnitTest.java create mode 100644 moduliths-core/src/test/java/org/moduliths/model/ModulithMetadataUnitTest.java create mode 100644 moduliths-core/src/test/java/org/moduliths/model/TestUtils.java create mode 100644 moduliths-core/src/test/java/org/moduliths/model/ViolationsUnitTests.java create mode 100644 moduliths-core/src/test/resources/application.properties create mode 100644 moduliths-core/src/test/resources/logback.xml create mode 100644 moduliths-docs/pom.xml create mode 100644 moduliths-docs/src/main/java/org/moduliths/docs/Asciidoctor.java create mode 100644 moduliths-docs/src/main/java/org/moduliths/docs/CodeReplacingDocumentationSource.java create mode 100644 moduliths-docs/src/main/java/org/moduliths/docs/ConfigurationProperties.java create mode 100644 moduliths-docs/src/main/java/org/moduliths/docs/DocumentationSource.java create mode 100644 moduliths-docs/src/main/java/org/moduliths/docs/Documenter.java create mode 100644 moduliths-docs/src/main/java/org/moduliths/docs/SpringAutoRestDocsDocumentationSource.java create mode 100644 moduliths-docs/src/main/java/org/moduliths/docs/package-info.java create mode 100644 moduliths-docs/src/test/java/org/moduliths/docs/AsciidoctorUnitTests.java create mode 100644 moduliths-docs/src/test/java/org/moduliths/docs/DocumenterTest.java create mode 100644 moduliths-docs/src/test/resources/META-INF/spring-configuration-metadata.json create mode 100644 moduliths-docs/src/test/resources/logback.xml create mode 100644 moduliths-events/moduliths-events-core/pom.xml create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/CompletableEventPublication.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/DefaultEventPublication.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/EventPublication.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/EventPublicationRegistry.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/EventSerializer.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/PublicationTargetIdentifier.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EnablePersistentDomainEvents.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EventPublicationConfiguration.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EventPublicationConfigurationExtension.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EventSerializationConfigurationExtension.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/package-info.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/package-info.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/CompletionRegisteringBeanPostProcessor.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/MapEventPublicationRegistry.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/PersistentApplicationEventMulticaster.java create mode 100644 moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/package-info.java create mode 100644 moduliths-events/moduliths-events-core/src/main/resources/META-INF/spring.factories create mode 100644 moduliths-events/moduliths-events-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 moduliths-events/moduliths-events-core/src/test/java/org/moduliths/events/CompletableEventPublicationUnitTest.java create mode 100644 moduliths-events/moduliths-events-core/src/test/java/org/moduliths/events/support/CompletionRegisteringBeanPostProcessorUnitTest.java create mode 100644 moduliths-events/moduliths-events-jackson/pom.xml create mode 100644 moduliths-events/moduliths-events-jackson/src/main/java/org/moduliths/events/jackson/JacksonEventSerializationConfiguration.java create mode 100644 moduliths-events/moduliths-events-jackson/src/main/java/org/moduliths/events/jackson/JacksonEventSerializer.java create mode 100644 moduliths-events/moduliths-events-jackson/src/main/java/org/moduliths/events/jackson/package-info.java create mode 100644 moduliths-events/moduliths-events-jackson/src/main/resources/META-INF/spring.factories create mode 100644 moduliths-events/moduliths-events-jackson/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 moduliths-events/moduliths-events-jpa-jakarta/pom.xml create mode 100644 moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublication.java create mode 100644 moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationAutoConfiguration.java create mode 100644 moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationConfiguration.java create mode 100644 moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRegistry.java create mode 100644 moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRepository.java create mode 100644 moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/package-info.java create mode 100644 moduliths-events/moduliths-events-jpa-jakarta/src/main/resources/META-INF/spring-devtools.properties create mode 100644 moduliths-events/moduliths-events-jpa-jakarta/src/main/resources/META-INF/spring.factories create mode 100644 moduliths-events/moduliths-events-jpa-jakarta/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.factories create mode 100644 moduliths-events/moduliths-events-jpa-jakarta/src/test/java/org/moduliths/events/jpa/JpaEventPublicationConfigurationIntegrationTests.java create mode 100644 moduliths-events/moduliths-events-jpa-jakarta/src/test/java/org/moduliths/events/jpa/JpaEventPublicationRepositoryIntegrationTests.java create mode 100644 moduliths-events/moduliths-events-jpa-jakarta/src/test/resources/logback.xml create mode 100644 moduliths-events/moduliths-events-jpa/pom.xml create mode 100644 moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublication.java create mode 100644 moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationAutoConfiguration.java create mode 100644 moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationConfiguration.java create mode 100644 moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRegistry.java create mode 100644 moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRepository.java create mode 100644 moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/package-info.java create mode 100644 moduliths-events/moduliths-events-jpa/src/main/resources/META-INF/spring-devtools.properties create mode 100644 moduliths-events/moduliths-events-jpa/src/main/resources/META-INF/spring.factories create mode 100644 moduliths-events/moduliths-events-jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 moduliths-events/moduliths-events-jpa/src/test/java/org/moduliths/events/jpa/JpaEventPublicationConfigurationIntegrationTests.java create mode 100644 moduliths-events/moduliths-events-jpa/src/test/java/org/moduliths/events/jpa/JpaEventPublicationRepositoryIntegrationTests.java create mode 100644 moduliths-events/moduliths-events-jpa/src/test/resources/logback.xml create mode 100644 moduliths-events/moduliths-events-starter/pom.xml create mode 100644 moduliths-events/moduliths-events-starter/src/main/java/org/moduliths/events/starter/DomainEventsAutoConfiguration.java create mode 100644 moduliths-events/moduliths-events-starter/src/main/java/org/moduliths/events/starter/package-info.java create mode 100644 moduliths-events/moduliths-events-starter/src/main/resources/META-INF/spring.factories create mode 100644 moduliths-events/moduliths-events-tests/pom.xml create mode 100644 moduliths-events/moduliths-events-tests/src/test/java/example/events/InfrastructureConfiguration.java create mode 100644 moduliths-events/moduliths-events-tests/src/test/java/example/events/PersistentDomainEventIntegrationTest.java create mode 100644 moduliths-events/moduliths-events-tests/src/test/resources/logback.xml create mode 100644 moduliths-events/pom.xml create mode 100644 moduliths-events/readme.adoc create mode 100644 moduliths-integration-test/pom.xml create mode 100644 moduliths-integration-test/src/test/java/org/moduliths/docs/DocumenterUnitTests.java create mode 100644 moduliths-integration-test/src/test/java/org/moduliths/model/JavaPackageUnitTests.java create mode 100644 moduliths-integration-test/src/test/java/org/moduliths/model/ModulesIntegrationTest.java create mode 100644 moduliths-integration-test/src/test/java/org/moduliths/model/TestModuleDetectionStrategy.java create mode 100644 moduliths-integration-test/src/test/resources/META-INF/spring.factories create mode 100644 moduliths-integration-test/src/test/resources/logback.xml create mode 100644 moduliths-moments/pom.xml create mode 100644 moduliths-moments/readme.adoc create mode 100644 moduliths-moments/src/main/java/org/moduliths/moments/DayHasPassed.java create mode 100644 moduliths-moments/src/main/java/org/moduliths/moments/HourHasPassed.java create mode 100644 moduliths-moments/src/main/java/org/moduliths/moments/MonthHasPassed.java create mode 100644 moduliths-moments/src/main/java/org/moduliths/moments/Quarter.java create mode 100644 moduliths-moments/src/main/java/org/moduliths/moments/QuarterHasPassed.java create mode 100644 moduliths-moments/src/main/java/org/moduliths/moments/ShiftedQuarter.java create mode 100644 moduliths-moments/src/main/java/org/moduliths/moments/WeekHasPassed.java create mode 100644 moduliths-moments/src/main/java/org/moduliths/moments/YearHasPassed.java create mode 100644 moduliths-moments/src/main/java/org/moduliths/moments/autoconfigure/MomentsAutoConfiguration.java create mode 100644 moduliths-moments/src/main/java/org/moduliths/moments/package-info.java create mode 100644 moduliths-moments/src/main/java/org/moduliths/moments/support/Moments.java create mode 100644 moduliths-moments/src/main/java/org/moduliths/moments/support/MomentsProperties.java create mode 100644 moduliths-moments/src/main/java/org/moduliths/moments/support/TimeMachine.java create mode 100644 moduliths-moments/src/main/resources/META-INF/spring.factories create mode 100644 moduliths-moments/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 moduliths-moments/src/test/java/org/moduliths/moments/QuarterHasPassedUnitTests.java create mode 100644 moduliths-moments/src/test/java/org/moduliths/moments/QuarterUnitTests.java create mode 100644 moduliths-moments/src/test/java/org/moduliths/moments/WeekHasPassedUnitTests.java create mode 100644 moduliths-moments/src/test/java/org/moduliths/moments/YearHasPassedUnitTests.java create mode 100644 moduliths-moments/src/test/java/org/moduliths/moments/autoconfigure/MomentsAutoConfigurationTests.java create mode 100644 moduliths-moments/src/test/java/org/moduliths/moments/support/MomentsUnitTests.java create mode 100644 moduliths-moments/src/test/resources/logback.xml create mode 100644 moduliths-observability/pom.xml create mode 100644 moduliths-observability/src/main/java/org/moduliths/observability/ApplicationRuntime.java create mode 100644 moduliths-observability/src/main/java/org/moduliths/observability/DefaultObservedModule.java create mode 100644 moduliths-observability/src/main/java/org/moduliths/observability/ModuleEntryInterceptor.java create mode 100644 moduliths-observability/src/main/java/org/moduliths/observability/ModuleEventListener.java create mode 100644 moduliths-observability/src/main/java/org/moduliths/observability/ModuleTracingBeanPostProcessor.java create mode 100644 moduliths-observability/src/main/java/org/moduliths/observability/ModuleTracingSupport.java create mode 100644 moduliths-observability/src/main/java/org/moduliths/observability/ModulesRuntime.java create mode 100644 moduliths-observability/src/main/java/org/moduliths/observability/ObservedModule.java create mode 100644 moduliths-observability/src/main/java/org/moduliths/observability/ObservedModuleType.java create mode 100644 moduliths-observability/src/main/java/org/moduliths/observability/SpringDataRestModuleTracingBeanPostProcessor.java create mode 100644 moduliths-observability/src/main/java/org/moduliths/observability/autoconfigure/ModuleObservabilityAutoConfiguration.java create mode 100644 moduliths-observability/src/main/java/org/moduliths/observability/autoconfigure/SpringBootApplicationRuntime.java create mode 100644 moduliths-observability/src/main/java/org/moduliths/observability/autoconfigure/SpringDataRestModuleObservabilityAutoConfiguration.java create mode 100644 moduliths-observability/src/main/resources/META-INF/spring.factories create mode 100644 moduliths-observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.factories create mode 100644 moduliths-observability/src/test/java/example/ExampleApplication.java create mode 100644 moduliths-observability/src/test/java/example/ExampleApplicationIntegrationTests.java create mode 100644 moduliths-observability/src/test/java/org/moduliths/observability/autoconfigure/SpringBootApplicationRuntimeUnitTests.java create mode 100644 moduliths-observability/src/test/resources/application.properties create mode 100644 moduliths-observability/src/test/resources/logback.xml create mode 100644 moduliths-sample/jqassistant/index.adoc create mode 100644 moduliths-sample/pom.xml create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/Application.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/complex/api/ComplexApiComponent.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/complex/api/package-info.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/complex/internal/ComplextInternalComponent.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/complex/internal/FirstTypeBasedPort.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/complex/internal/SecondTypeBasePort.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/complex/spi/ComplexSpiComponent.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/complex/spi/package-info.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/cycleA/CycleA.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/cycleB/CycleB.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/fieldinjected/WithFieldInjection.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/invalid/InvalidComponent.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/invalid2/InvalidModuleDependency.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/invalid2/package-info.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/moduleA/ServiceComponentA.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/moduleA/SomeConfigurationA.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/moduleA/SomeEventA.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/moduleB/ServiceComponentB.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/moduleB/SomeEventListenerB.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/moduleB/internal/InternalComponentB.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/moduleB/internal/SupportingComponentB.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/moduleC/ServiceComponentC.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/moduleC/package-info.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/stereotypes/Stereotypes.java create mode 100644 moduliths-sample/src/main/java/com/acme/myproject/stereotypes/web/WebRepresentations.java create mode 100644 moduliths-sample/src/test/java/com/acme/myproject/ModulithTest.java create mode 100644 moduliths-sample/src/test/java/com/acme/myproject/NonVerifyingModuleTest.java create mode 100644 moduliths-sample/src/test/java/com/acme/myproject/complex/ComplexTest.java create mode 100644 moduliths-sample/src/test/java/com/acme/myproject/fieldinjected/FieldInjectedIntegrationTest.java create mode 100644 moduliths-sample/src/test/java/com/acme/myproject/moduleA/ModuleATest.java create mode 100644 moduliths-sample/src/test/java/com/acme/myproject/moduleB/ModuleBTest.java create mode 100644 moduliths-sample/src/test/java/com/acme/myproject/moduleC/ModuleCTest.java create mode 100644 moduliths-sample/src/test/resources/application.properties create mode 100644 moduliths-sample/src/test/resources/logback.xml create mode 100644 moduliths-starter-jpa-jakarta/pom.xml create mode 100644 moduliths-starter-jpa/pom.xml create mode 100644 moduliths-starter-test/pom.xml create mode 100644 moduliths-test/pom.xml create mode 100644 moduliths-test/src/main/java/org/moduliths/test/AggregateTestUtils.java create mode 100644 moduliths-test/src/main/java/org/moduliths/test/DefaultPublishedEvents.java create mode 100644 moduliths-test/src/main/java/org/moduliths/test/ModuleContextCustomizerFactory.java create mode 100644 moduliths-test/src/main/java/org/moduliths/test/ModuleTest.java create mode 100644 moduliths-test/src/main/java/org/moduliths/test/ModuleTestAutoConfiguration.java create mode 100644 moduliths-test/src/main/java/org/moduliths/test/ModuleTestExecution.java create mode 100644 moduliths-test/src/main/java/org/moduliths/test/ModuleTypeExcludeFilter.java create mode 100644 moduliths-test/src/main/java/org/moduliths/test/PublishedEvents.java create mode 100644 moduliths-test/src/main/java/org/moduliths/test/PublishedEventsExtension.java create mode 100644 moduliths-test/src/main/java/org/moduliths/test/PublishedEventsParameterResolver.java create mode 100644 moduliths-test/src/main/java/org/moduliths/test/TestUtils.java create mode 100644 moduliths-test/src/main/resources/META-INF/spring.factories create mode 100644 moduliths-test/src/test/java/org/moduliths/test/PublishedEventsParameterResolverUnitTests.java create mode 100644 moduliths-test/src/test/java/org/moduliths/test/PublishedEventsUnitTests.java create mode 100644 moduliths-test/src/test/resources/logback.xml create mode 100755 mvnw create mode 100644 mvnw.cmd create mode 100644 pom.xml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 00000000..4467254f --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,49 @@ +name: Maven Build + +on: + push: + branches: [ main, 1.3.x ] + pull_request: + branches: [ main ] + +jobs: + build: + name: Build project + runs-on: ubuntu-latest + + steps: + + - name: Check out sources + uses: actions/checkout@v2 + + - name: Set up JDK 17 + uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: 17 + cache: 'maven' + + - name: Build with Maven + run: ./mvnw -B + + integrations: + runs-on: ubuntu-latest + strategy: + matrix: + boot-version: ["2.5.14", "2.6.9", "3.0.0-SNAPSHOT"] + name: Integration test (Boot ${{ matrix.boot-version }}) + needs: build + steps: + + - name: Check out sources + uses: actions/checkout@v2 + + - name: Set up JDK 17 + uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: 17 + cache: 'maven' + + - name: Build with Maven (Boot ${{ matrix.boot-version }}) + run: sed -i -e 's/2.7.1/${{ matrix.boot-version }}/g' ./pom.xml && ./mvnw dependency:list -B && ./mvnw -B diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..adfccd77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +target/ +.idea/ +.flattened-pom.xml +.settings/ +*.iml +.project +.classpath +.springBeans +target/ +.factorypath + +#IntelliJ Stuff +.idea +*.iml diff --git a/.mvn/wrapper/MavenWrapperDownloader.java b/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 00000000..c32394f1 --- /dev/null +++ b/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,117 @@ +/* + * Copyright 2007-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import java.net.*; +import java.io.*; +import java.nio.channels.*; +import java.util.Properties; + +public class MavenWrapperDownloader { + + private static final String WRAPPER_VERSION = "0.5.5"; + /** + * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. + */ + private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" + + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; + + /** + * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to + * use instead of the default one. + */ + private static final String MAVEN_WRAPPER_PROPERTIES_PATH = + ".mvn/wrapper/maven-wrapper.properties"; + + /** + * Path where the maven-wrapper.jar will be saved to. + */ + private static final String MAVEN_WRAPPER_JAR_PATH = + ".mvn/wrapper/maven-wrapper.jar"; + + /** + * Name of the property which should be used to override the default download url for the wrapper. + */ + private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; + + public static void main(String args[]) { + System.out.println("- Downloader started"); + File baseDirectory = new File(args[0]); + System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); + + // If the maven-wrapper.properties exists, read it and check if it contains a custom + // wrapperUrl parameter. + File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); + String url = DEFAULT_DOWNLOAD_URL; + if(mavenWrapperPropertyFile.exists()) { + FileInputStream mavenWrapperPropertyFileInputStream = null; + try { + mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); + Properties mavenWrapperProperties = new Properties(); + mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); + url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); + } catch (IOException e) { + System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); + } finally { + try { + if(mavenWrapperPropertyFileInputStream != null) { + mavenWrapperPropertyFileInputStream.close(); + } + } catch (IOException e) { + // Ignore ... + } + } + } + System.out.println("- Downloading from: " + url); + + File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); + if(!outputFile.getParentFile().exists()) { + if(!outputFile.getParentFile().mkdirs()) { + System.out.println( + "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); + } + } + System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); + try { + downloadFileFromURL(url, outputFile); + System.out.println("Done"); + System.exit(0); + } catch (Throwable e) { + System.out.println("- Error downloading"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void downloadFileFromURL(String urlString, File destination) throws Exception { + if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { + String username = System.getenv("MVNW_USERNAME"); + char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(username, password); + } + }); + } + URL website = new URL(urlString); + ReadableByteChannel rbc; + rbc = Channels.newChannel(website.openStream()); + FileOutputStream fos = new FileOutputStream(destination); + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + fos.close(); + rbc.close(); + } + +} diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..0d5e649888a4843c1520054d9672f80c62ebbb48 GIT binary patch literal 50710 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�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 JS$+5G{{hr`vzY(@ literal 0 HcmV?d00001 diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..fa87ad7d --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.1/apache-maven-3.6.1-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.5/maven-wrapper-0.5.5.jar diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/application.yml b/application.yml new file mode 100644 index 00000000..1ff4782c --- /dev/null +++ b/application.yml @@ -0,0 +1,2 @@ +changelog: + repository: spring-projects-experimental/spring-modulith diff --git a/etc/ide/README.md b/etc/ide/README.md new file mode 100644 index 00000000..1f841dff --- /dev/null +++ b/etc/ide/README.md @@ -0,0 +1,17 @@ +# Spring Data Code Formatting Settings + +This directory contains `eclipse-formatting.xml` and `intellij.importorder` settings files to be used with Eclipse and IntelliJ. + +## Eclipse Setup + +Import both files in Eclipse through the Preferences dialog. + +## IntelliJ Setup + +Use the IntelliJ [Eclipse Code Formatter](https://plugins.jetbrains.com/plugin/6546-eclipse-code-formatter) plugin to configure code formatting and import ordering with the newest Eclipse formatter version. + +Additionally, make sure to configure your import settings in `Editor -> Code Style -> Java` with the following explicit settings: + +* Use tab character indents +* Class count to use import with `*`: 10 +* Names count to use static import with `*`: 1 diff --git a/etc/ide/eclipse-formatting.xml b/etc/ide/eclipse-formatting.xml new file mode 100644 index 00000000..b5515c19 --- /dev/null +++ b/etc/ide/eclipse-formatting.xml @@ -0,0 +1,291 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/etc/ide/intellij.importorder b/etc/ide/intellij.importorder new file mode 100644 index 00000000..a974f58e --- /dev/null +++ b/etc/ide/intellij.importorder @@ -0,0 +1,8 @@ +#Organize Import Order +#Mon Nov 14 09:58:12 CET 2016 +5=com +4=org +3=javax +2=java +1= +0=\# diff --git a/lombok.config b/lombok.config new file mode 100644 index 00000000..25fc177c --- /dev/null +++ b/lombok.config @@ -0,0 +1,3 @@ +lombok.nonNull.exceptionType = IllegalArgumentException +lombok.log.fieldName = LOG +lombok.addLombokGeneratedAnnotation = true diff --git a/moduliths-api/pom.xml b/moduliths-api/pom.xml new file mode 100644 index 00000000..877df18e --- /dev/null +++ b/moduliths-api/pom.xml @@ -0,0 +1,28 @@ + + 4.0.0 + + + org.moduliths + moduliths + 1.4.0-SNAPSHOT + ../pom.xml + + + Moduliths - API + moduliths-api + + + org.moduliths.api + + + + + + org.springframework.boot + spring-boot-autoconfigure + true + + + + + diff --git a/moduliths-api/src/main/java/org/moduliths/Module.java b/moduliths-api/src/main/java/org/moduliths/Module.java new file mode 100644 index 00000000..c3b215c5 --- /dev/null +++ b/moduliths-api/src/main/java/org/moduliths/Module.java @@ -0,0 +1,41 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to customize information of a {@link Modulith} module. + * + * @author Oliver Gierke + */ +@Target({ ElementType.PACKAGE, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface Module { + + String displayName() default ""; + + /** + * List the names of modules that the module is allowed to depend on. Shared modules defined in {@link Modulith} will + * be allowed, too. + * + * @return + */ + String[] allowedDependencies() default {}; +} diff --git a/moduliths-api/src/main/java/org/moduliths/Modulith.java b/moduliths-api/src/main/java/org/moduliths/Modulith.java new file mode 100644 index 00000000..ce6c647b --- /dev/null +++ b/moduliths-api/src/main/java/org/moduliths/Modulith.java @@ -0,0 +1,85 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.TypeExcludeFilter; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.FilterType; +import org.springframework.core.annotation.AliasFor; + +/** + * Defines a Spring Boot application to follow the Modulith structuring conventions. + * + * @author Oliver Gierke + */ +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Modulithic +@SpringBootConfiguration +@EnableAutoConfiguration +@ComponentScan(excludeFilters = { // + @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class), + @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) }) +public @interface Modulith { + + /** + * A logical system name for documentation purposes. + * + * @return + */ + @AliasFor(annotation = Modulithic.class) + String systemName() default ""; + + /** + * Whether to use fully qualified module names by default. If set to {@literal true}, hits will cause the module's + * default names to be their complete package name instead of just the modulith-local one. This might be useful in + * case {@link #additionalPackages()} pulls in packages that would cause module name conflicts, i.e. both root + * packages declare a local sub-package of the same name. + * + * @return + */ + @AliasFor(annotation = Modulithic.class) + boolean useFullyQualifiedModuleNames() default false; + + /** + * The names of modules considered to be shared, i.e. which should always be included in the bootstrap no matter what. + * Useful for code to contain commons Spring configuration and components. + * + * @return + */ + @AliasFor(annotation = Modulithic.class) + String[] sharedModules() default {}; + + /** + * Defines which additional packages shall be considered as modulith base packages in addition to the one of the class + * carrying this annotation. + * + * @return + */ + @AliasFor(annotation = Modulithic.class) + String[] additionalPackages() default {}; +} diff --git a/moduliths-api/src/main/java/org/moduliths/Modulithic.java b/moduliths-api/src/main/java/org/moduliths/Modulithic.java new file mode 100644 index 00000000..8515e624 --- /dev/null +++ b/moduliths-api/src/main/java/org/moduliths/Modulithic.java @@ -0,0 +1,66 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines a Spring Boot application to follow the Modulith structuring conventions. + * + * @author Oliver Drotbohm + */ +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface Modulithic { + + /** + * A logical system name for documentation purposes. + * + * @return + */ + String systemName() default ""; + + /** + * Whether to use fully qualified module names by default. If set to {@literal true}, hits will cause the module's + * default names to be their complete package name instead of just the modulith-local one. This might be useful in + * case {@link #additionalPackages()} pulls in packages that would cause module name conflicts, i.e. both root + * packages declare a local sub-package of the same name. + * + * @return + */ + boolean useFullyQualifiedModuleNames() default false; + + /** + * The names of modules considered to be shared, i.e. which should always be included in the bootstrap no matter what. + * Useful for code to contain commons Spring configuration and components. + * + * @return + */ + String[] sharedModules() default {}; + + /** + * Defines which additional packages shall be considered as modulith base packages in addition to the one of the class + * carrying this annotation. + * + * @return + */ + String[] additionalPackages() default {}; +} diff --git a/moduliths-api/src/main/java/org/moduliths/NamedInterface.java b/moduliths-api/src/main/java/org/moduliths/NamedInterface.java new file mode 100644 index 00000000..32404bc8 --- /dev/null +++ b/moduliths-api/src/main/java/org/moduliths/NamedInterface.java @@ -0,0 +1,40 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to mark a package as named interface of a {@link Module} (either implicit or explicitly annotated). + * + * @author Oliver Gierke + */ +@Documented +@Target({ ElementType.PACKAGE, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface NamedInterface { + + /** + * The name of the interface. + * + * @return + */ + String[] value(); +} diff --git a/moduliths-core/pom.xml b/moduliths-core/pom.xml new file mode 100644 index 00000000..ae177346 --- /dev/null +++ b/moduliths-core/pom.xml @@ -0,0 +1,83 @@ + + 4.0.0 + + + org.moduliths + moduliths + 1.4.0-SNAPSHOT + ../pom.xml + + + Moduliths - Core + moduliths-core + + + org.moduliths.core + + + + + + org.moduliths + moduliths-api + ${project.version} + + + + com.tngtech.archunit + archunit + ${archunit.version} + + + + org.springframework + spring-core + + + + org.springframework.data + spring-data-commons + true + + + + + + org.jmolecules + jmolecules-ddd + true + + + + org.jmolecules + jmolecules-events + test + + + + org.jmolecules.integrations + jmolecules-archunit + true + + + + org.springframework + spring-context + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + jakarta.persistence + jakarta.persistence-api + test + + + + + diff --git a/moduliths-core/src/main/java/org/moduliths/model/AnnotationModulithMetadata.java b/moduliths-core/src/main/java/org/moduliths/model/AnnotationModulithMetadata.java new file mode 100644 index 00000000..82bc8174 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/AnnotationModulithMetadata.java @@ -0,0 +1,105 @@ +/* + * Copyright 2019-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.moduliths.Modulithic; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * {@link ModulithMetadata} backed by a {@link Modulithic} annotated type. + * + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +class AnnotationModulithMetadata implements ModulithMetadata { + + private final Class modulithType; + private final Modulithic annotation; + + /** + * Creates a {@link ModulithMetadata} inspecting {@link Modulithic} annotation or return {@link Optional#empty()} if + * the type given does not carry the annotation. + * + * @param annotated must not be {@literal null}. + * @return + */ + public static Optional of(Class annotated) { + + Assert.notNull(annotated, "Modulith type must not be null!"); + + Modulithic annotation = AnnotatedElementUtils.findMergedAnnotation(annotated, Modulithic.class); + + return Optional.ofNullable(annotation) // + .map(it -> new AnnotationModulithMetadata(annotated, it)); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModulithMetadata#getModulithSource() + */ + @Override + public Object getModulithSource() { + return modulithType; + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModulithMetadata#getAdditionalPackages() + */ + @Override + public List getAdditionalPackages() { + return Arrays.asList(annotation.additionalPackages()); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModulithMetadata#useFullyQualifiedModuleNames() + */ + @Override + public boolean useFullyQualifiedModuleNames() { + return annotation.useFullyQualifiedModuleNames(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModulithMetadata#getSharedModuleNames() + */ + @Override + public Stream getSharedModuleNames() { + return Arrays.stream(annotation.sharedModules()); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModulithMetadata#getSystemName() + */ + @Override + public Optional getSystemName() { + + return Optional.of(annotation.systemName()) // + .filter(StringUtils::hasText); + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/ArchitecturallyEvidentType.java b/moduliths-core/src/main/java/org/moduliths/model/ArchitecturallyEvidentType.java new file mode 100644 index 00000000..79c05fd2 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/ArchitecturallyEvidentType.java @@ -0,0 +1,582 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static org.moduliths.model.Types.JavaXTypes.*; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Value; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.moduliths.model.Types.JMoleculesTypes; +import org.moduliths.model.Types.SpringDataTypes; +import org.moduliths.model.Types.SpringTypes; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.AbstractRepositoryMetadata; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.core.domain.JavaType; +import com.tngtech.archunit.thirdparty.com.google.common.base.Supplier; +import com.tngtech.archunit.thirdparty.com.google.common.base.Suppliers; + +/** + * A type that is architecturally relevant, i.e. it fulfills a significant role within the architecture. + * + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class ArchitecturallyEvidentType { + + private static Map CACHE = new HashMap<>(); + + private final @Getter JavaClass type; + + /** + * Creates a new {@link AbstractArchitecturallyEvidentType} for the given {@link JavaType} and {@link Classes} of + * Spring components. + * + * @param type must not be {@literal null}. + * @param beanTypes must not be {@literal null}. + * @return + */ + public static ArchitecturallyEvidentType of(JavaClass type, Classes beanTypes) { + + return CACHE.computeIfAbsent(Key.of(type, beanTypes), it -> { + + List delegates = new ArrayList<>(); + + if (JMoleculesTypes.isPresent()) { + delegates.add(new JMoleculesArchitecturallyEvidentType(type)); + } + + if (SpringDataTypes.isPresent()) { + delegates.add(new SpringDataAwareArchitecturallyEvidentType(type, beanTypes)); + } + + delegates.add(new SpringAwareArchitecturallyEvidentType(type)); + + return DelegatingType.of(type, delegates); + }); + } + + /** + * Returns the abbreviated (i.e. every package fragment reduced to its first character) full name. + * + * @return will never be {@literal null}. + */ + String getAbbreviatedFullName() { + return FormatableJavaClass.of(getType()).getAbbreviatedFullName(); + } + + /** + * Returns whether the type is an entity in the DDD sense. + * + * @return + */ + boolean isEntity() { + return isJpaEntity().apply(getType()); + } + + /** + * Returns whether the type is considered an aggregate root in the DDD sense. + * + * @return + */ + public abstract boolean isAggregateRoot(); + + /** + * Returns whether the type is considered a repository in the DDD sense. + * + * @return + */ + public abstract boolean isRepository(); + + public boolean isService() { + return false; + } + + public boolean isController() { + return false; + } + + public boolean isEventListener() { + return false; + } + + public boolean isConfigurationProperties() { + return false; + } + + /** + * Returns other types that are interesting in the context of the current {@link ArchitecturallyEvidentType}. For + * example, for an event listener this might be the event types the particular listener is interested in. + * + * @return + */ + public Stream getReferenceTypes() { + return Stream.empty(); + } + + public Stream getReferenceMethods() { + return Stream.empty(); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return type.getFullName(); + } + + private static Stream distinctByName(Stream types) { + + Set names = new HashSet<>(); + + return types.flatMap(it -> { + + if (names.contains(it.getFullName())) { + return Stream.empty(); + } else { + + names.add(it.getFullName()); + + return Stream.of(it); + } + }); + } + + static class SpringAwareArchitecturallyEvidentType extends ArchitecturallyEvidentType { + + /** + * Methods (meta-)annotated with @EventListener. + */ + private static final Predicate IS_ANNOTATED_EVENT_LISTENER = it -> // + Types.isAnnotatedWith(SpringTypes.AT_EVENT_LISTENER).apply(it) // + || Types.isAnnotatedWith(SpringTypes.AT_TX_EVENT_LISTENER).apply(it); + + /** + * {@code ApplicationListener.onApplicationEvent(…)} + */ + private static final Predicate IS_IMPLEMENTING_EVENT_LISTENER = it -> // + it.getOwner().isAssignableTo(SpringTypes.APPLICATION_LISTENER) // + && it.getName().equals("onApplicationEvent") // + && !it.reflect().isSynthetic(); + + private static final Predicate IS_EVENT_LISTENER = IS_ANNOTATED_EVENT_LISTENER + .or(IS_IMPLEMENTING_EVENT_LISTENER); + + public SpringAwareArchitecturallyEvidentType(JavaClass type) { + super(type); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isAggregateRoot() + */ + @Override + public boolean isAggregateRoot() { + return false; + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isRepository() + */ + @Override + public boolean isRepository() { + return Types.isAnnotatedWith(SpringTypes.AT_REPOSITORY).apply(getType()); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isService() + */ + @Override + public boolean isService() { + return Types.isAnnotatedWith(SpringTypes.AT_SERVICE).apply(getType()); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isController() + */ + @Override + public boolean isController() { + return Types.isAnnotatedWith(SpringTypes.AT_CONTROLLER).apply(getType()); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isEventListener() + */ + @Override + public boolean isEventListener() { + return getType().getMethods().stream().anyMatch(IS_EVENT_LISTENER); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isConfigurationProperties() + */ + @Override + public boolean isConfigurationProperties() { + return Types.isAnnotatedWith(SpringTypes.AT_CONFIGURATION_PROPERTIES).apply(getType()); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#getOtherTypeReferences() + */ + @Override + public Stream getReferenceTypes() { + + if (isEventListener()) { + + return distinctByName(getType().getMethods().stream() // + .filter(IS_EVENT_LISTENER) // + .flatMap(it -> it.getRawParameterTypes().stream())) + .sorted(Comparator.comparing(JavaClass::getSimpleName)); + } + + return super.getReferenceTypes(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#getReferenceMethods() + */ + @Override + public Stream getReferenceMethods() { + + if (!isEventListener()) { + return super.getReferenceMethods(); + } + + return getType().getMethods().stream() // + .filter(IS_EVENT_LISTENER) + .sorted(Comparator.comparing(JavaMethod::getName) + .thenComparing(it -> it.getRawParameterTypes().size())) + .map(ReferenceMethod::new); + } + } + + static class SpringDataAwareArchitecturallyEvidentType extends ArchitecturallyEvidentType { + + private final Classes beanTypes; + + SpringDataAwareArchitecturallyEvidentType(JavaClass type, Classes beanTypes) { + super(type); + this.beanTypes = beanTypes; + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isEntity() + */ + @Override + public boolean isEntity() { + + return super.isEntity() // + || getType().isAnnotatedWith("org.springframework.data.mongodb.core.mapping.Document"); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.DefaultArchitectuallyEvidentType#isAggregateRoot(org.moduliths.model.Classes) + */ + @Override + public boolean isAggregateRoot() { + return isEntity() && beanTypes.that(SpringDataTypes.isSpringDataRepository()).stream() // + .map(JavaClass::reflect) // + .map(AbstractRepositoryMetadata::getMetadata) // + .map(RepositoryMetadata::getDomainType) // + .anyMatch(it -> getType().isAssignableTo(it)); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isRepository() + */ + @Override + public boolean isRepository() { + return SpringDataTypes.isSpringDataRepository().apply(getType()); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isController() + */ + @Override + public boolean isController() { + return Types.isAnnotatedWith("org.springframework.data.rest.webmvc.BasePathAwareController").apply(getType()); + } + } + + static class JMoleculesArchitecturallyEvidentType extends ArchitecturallyEvidentType { + + private static final Predicate IS_ANNOTATED_EVENT_LISTENER = Types + .isAnnotatedWith(JMoleculesTypes.AT_DOMAIN_EVENT_HANDLER)::apply; + + JMoleculesArchitecturallyEvidentType(JavaClass type) { + super(type); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isEntity() + */ + @Override + public boolean isEntity() { + + JavaClass type = getType(); + + return Types.isAnnotatedWith(org.jmolecules.ddd.annotation.Entity.class).apply(type) || // + type.isAssignableTo(org.jmolecules.ddd.types.Entity.class); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isAggregateRoot() + */ + @Override + public boolean isAggregateRoot() { + + JavaClass type = getType(); + + return Types.isAnnotatedWith(org.jmolecules.ddd.annotation.AggregateRoot.class).apply(type) // + || type.isAssignableTo(org.jmolecules.ddd.types.AggregateRoot.class); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isRepository() + */ + @Override + public boolean isRepository() { + + JavaClass type = getType(); + + return Types.isAnnotatedWith(org.jmolecules.ddd.annotation.Repository.class).apply(type) + || type.isAssignableTo(org.jmolecules.ddd.types.Repository.class); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isService() + */ + @Override + public boolean isService() { + + JavaClass type = getType(); + + return Types.isAnnotatedWith(org.jmolecules.ddd.annotation.Service.class).apply(type); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isEventListener() + */ + @Override + public boolean isEventListener() { + return getType().getMethods().stream().anyMatch(IS_ANNOTATED_EVENT_LISTENER); + } + } + + static class DelegatingType extends ArchitecturallyEvidentType { + + private final Supplier isAggregateRoot, isRepository, isEntity, isService, isController, isEventListener, + isConfigurationProperties; + private final Supplier> referenceTypes; + private final Supplier> referenceMethods; + + DelegatingType(JavaClass type, Supplier isAggregateRoot, + Supplier isRepository, Supplier isEntity, Supplier isService, + Supplier isController, Supplier isEventListener, Supplier isConfigurationProperties, + Supplier> referenceTypes, Supplier> referenceMethods) { + + super(type); + + this.isAggregateRoot = isAggregateRoot; + this.isRepository = isRepository; + this.isEntity = isEntity; + this.isService = isService; + this.isController = isController; + this.isEventListener = isEventListener; + this.isConfigurationProperties = isConfigurationProperties; + this.referenceTypes = referenceTypes; + this.referenceMethods = referenceMethods; + } + + public static DelegatingType of(JavaClass type, List types) { + + Supplier isAggregateRoot = Suppliers + .memoize(() -> types.stream().anyMatch(ArchitecturallyEvidentType::isAggregateRoot)); + + Supplier isRepository = Suppliers + .memoize(() -> types.stream().anyMatch(ArchitecturallyEvidentType::isRepository)); + + Supplier isEntity = Suppliers + .memoize(() -> types.stream().anyMatch(ArchitecturallyEvidentType::isEntity)); + + Supplier isService = Suppliers + .memoize(() -> types.stream().anyMatch(ArchitecturallyEvidentType::isService)); + + Supplier isController = Suppliers + .memoize(() -> types.stream().anyMatch(ArchitecturallyEvidentType::isController)); + + Supplier isEventListener = Suppliers + .memoize(() -> types.stream().anyMatch(ArchitecturallyEvidentType::isEventListener)); + + Supplier isConfigurationProperties = Suppliers + .memoize(() -> types.stream().anyMatch(ArchitecturallyEvidentType::isConfigurationProperties)); + + Supplier> referenceTypes = Suppliers.memoize(() -> types.stream() // + .flatMap(ArchitecturallyEvidentType::getReferenceTypes) // + .collect(Collectors.toList())); + + Supplier> referenceMethods = Suppliers.memoize(() -> types.stream() // + .flatMap(ArchitecturallyEvidentType::getReferenceMethods) // + .collect(Collectors.toList())); + + return new DelegatingType(type, isAggregateRoot, isRepository, isEntity, isService, isController, + isEventListener, isConfigurationProperties, referenceTypes, referenceMethods); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isAggregateRoot() + */ + // @Override + @Override + public boolean isAggregateRoot() { + return isAggregateRoot.get(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isRepository() + */ + // @Override + @Override + public boolean isRepository() { + return isRepository.get(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isEntity() + */ + @Override + public boolean isEntity() { + return isEntity.get(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isService() + */ + @Override + public boolean isService() { + return isService.get(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isController() + */ + @Override + public boolean isController() { + return isController.get(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isEventListener() + */ + @Override + public boolean isEventListener() { + return isEventListener.get(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#isConfigurationProperties() + */ + @Override + public boolean isConfigurationProperties() { + return isConfigurationProperties.get(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#getOtherTypeReferences() + */ + @Override + public Stream getReferenceTypes() { + return distinctByName(referenceTypes.get().stream()); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ArchitecturallyEvidentType#getReferenceMethods() + */ + @Override + public Stream getReferenceMethods() { + return referenceMethods.get().stream(); + } + } + + @Value(staticConstructor = "of") + private static class Key { + + JavaClass type; + Classes beanTypes; + } + + @Value + public final class ReferenceMethod { + + private final JavaMethod method; + + public boolean isAsync() { + return method.isAnnotatedWith(SpringTypes.AT_ASYNC) || method.isMetaAnnotatedWith(SpringTypes.AT_ASYNC); + } + + public Optional getTransactionPhase() { + + return Optional.ofNullable(method.getAnnotationOfType(SpringTypes.AT_TX_EVENT_LISTENER)) + .map(it -> it.get("phase")) + .map(Object::toString); + } + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/Classes.java b/moduliths-core/src/main/java/org/moduliths/model/Classes.java new file mode 100644 index 00000000..5573930f --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/Classes.java @@ -0,0 +1,237 @@ +/* + * Copyright 2018-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Collector; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import com.tngtech.archunit.base.DescribedIterable; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.core.domain.JavaType; +import com.tngtech.archunit.core.domain.properties.HasName; + +/** + * @author Oliver Gierke + */ +@ToString +@EqualsAndHashCode +public class Classes implements DescribedIterable { + + private final List classes; + + /** + * Creates a new {@link Classes} for the given {@link JavaClass}es. + * + * @param classes must not be {@literal null}. + */ + private Classes(List classes) { + + Assert.notNull(classes, "JavaClasses must not be null!"); + + this.classes = classes.stream() // + .sorted(Comparator.comparing(JavaClass::getName)) // + .collect(Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList)); + } + + /** + * Creates a new {@link Classes} for the given {@link JavaClass}es. + * + * @param classes must not be {@literal null}. + * @return + */ + static Classes of(JavaClasses classes) { + + return new Classes(StreamSupport.stream(classes.spliterator(), false) // + .collect(Collectors.toList())); + } + + /** + * Creates a new {@link Classes} for the given {@link JavaClass}es. + * + * @param classes must not be {@literal null}. + * @return will never be {@literal null}. + */ + static Classes of(List classes) { + return new Classes(classes); + } + + /** + * Returns a {@link Collector} creating a {@link Classes} instance from a {@link Stream} of {@link JavaType}. + * + * @return will never be {@literal null}. + */ + static Collector toClasses() { + return Collectors.collectingAndThen(Collectors.toList(), Classes::of); + } + + /** + * Returns {@link Classes} that match the given {@link DescribedPredicate}. + * + * @param predicate must not be {@literal null}. + * @return + */ + Classes that(DescribedPredicate predicate) { + + Assert.notNull(predicate, "Predicate must not be null!"); + + return classes.stream() // + .filter((Predicate) it -> predicate.apply(it)) // + .collect(Collectors.collectingAndThen(Collectors.toList(), Classes::new)); + } + + Classes and(Classes classes) { + return and(classes.classes); + } + + /** + * Returns a Classes with the current elements and the given other ones combined. + * + * @param others must not be {@literal null}. + * @return + */ + Classes and(Collection others) { + + Assert.notNull(others, "JavaClasses must not be null!"); + + if (others.isEmpty()) { + return this; + } + + List result = new ArrayList<>(classes); + + others.forEach(it -> { + if (!result.contains(it)) { + result.add(it); + } + }); + + return new Classes(result); + } + + public Stream stream() { + return classes.stream(); + } + + boolean isEmpty() { + return !classes.iterator().hasNext(); + } + + Optional toOptional() { + return isEmpty() ? Optional.empty() : Optional.of(classes.iterator().next()); + } + + boolean contains(JavaClass type) { + return !that(new SameClass(type)).isEmpty(); + } + + boolean contains(String className) { + return !that(HasName.Predicates.name(className)).isEmpty(); + } + + JavaClass getRequiredClass(Class type) { + + return classes.stream() // + .filter(it -> it.isEquivalentTo(type)) // + .findFirst() // + .orElseThrow(() -> new IllegalArgumentException(String.format("No JavaClass found for type %s!", type))); + } + + /* + * (non-Javadoc) + * @see com.tngtech.archunit.base.HasDescription#getDescription() + */ + @Override + public String getDescription() { + return ""; + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return classes.iterator(); + } + + String format() { + return classes.stream() // + .map(Classes::format) // + .collect(Collectors.joining("\n")); + } + + String format(String basePackage) { + return classes.stream() // + .map(it -> Classes.format(it, basePackage)) // + .collect(Collectors.joining("\n")); + } + + private static String format(JavaClass type) { + return format(type, ""); + } + + static String format(JavaClass type, String basePackage) { + + Assert.notNull(type, "Type must not be null!"); + Assert.notNull(basePackage, "Base package must not be null!"); + + String prefix = type.getModifiers().contains(JavaModifier.PUBLIC) ? "+" : "o"; + String name = StringUtils.hasText(basePackage) // + ? type.getName().replace(basePackage, "…") // + : type.getName(); + + return String.format(" %s %s", prefix, name); + } + + private static class SameClass extends DescribedPredicate { + + private final JavaClass reference; + + public SameClass(JavaClass reference) { + super(" is the same class as "); + this.reference = reference; + } + + /* + * (non-Javadoc) + * @see com.tngtech.archunit.base.DescribedPredicate#apply(java.lang.Object) + */ + @Override + public boolean apply(@Nullable JavaClass input) { + return input != null && reference.getName().equals(input.getName()); + } + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/DefaultModulithMetadata.java b/moduliths-core/src/main/java/org/moduliths/model/DefaultModulithMetadata.java new file mode 100644 index 00000000..0025d036 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/DefaultModulithMetadata.java @@ -0,0 +1,122 @@ +/* + * Copyright 2019-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.lang.annotation.Annotation; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.moduliths.Modulith; +import org.moduliths.Modulithic; +import org.moduliths.model.Types.SpringTypes; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.Assert; + +/** + * Creates a new {@link ModulithMetadata} representing the defaults of {@link Modulithic} but without the annotation + * present. + * + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +class DefaultModulithMetadata implements ModulithMetadata { + + private static final Class AT_SPRING_BOOT_APPLICATION = Types + .loadIfPresent(SpringTypes.AT_SPRING_BOOT_APPLICATION); + + private final @NonNull Object modulithSource; + + /** + * Creates a new {@link ModulithMetadata} representing the defaults of a class annotated but not customized with + * {@link Modulithic} or {@link Modulith}. + * + * @param annotated must not be {@literal null}. + * @return + */ + public static Optional of(Class annotated) { + + Assert.notNull(annotated, "Annotated type must not be null!"); + + return Optional.ofNullable(AT_SPRING_BOOT_APPLICATION) // + .filter(it -> AnnotatedElementUtils.hasAnnotation(annotated, it)) // + .map(__ -> new DefaultModulithMetadata(annotated)); + } + + /** + * Creates a new {@link ModulithMetadata} from the given package name. + * + * @param javaPackage must not be {@literal null} or empty. + * @return will never be {@literal null}. + * @since 1.1 + */ + public static ModulithMetadata of(String javaPackage) { + + Assert.hasText(javaPackage, "Package name must not be null or empty!"); + + return new DefaultModulithMetadata(javaPackage); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModulithMetadata#getModulithSource() + */ + @Override + public Object getModulithSource() { + return modulithSource; + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModulithMetadata#getAdditionalPackages() + */ + @Override + public List getAdditionalPackages() { + return Collections.emptyList(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModulithMetadata#useFullyQualifiedModuleNames() + */ + @Override + public boolean useFullyQualifiedModuleNames() { + return false; + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModulithMetadata#getSharedModuleNames() + */ + @Override + public Stream getSharedModuleNames() { + return Stream.empty(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModulithMetadata#getSystemName() + */ + @Override + public Optional getSystemName() { + return Optional.empty(); + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/EventType.java b/moduliths-core/src/main/java/org/moduliths/model/EventType.java new file mode 100644 index 00000000..84b24719 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/EventType.java @@ -0,0 +1,75 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import lombok.Value; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.util.Assert; + +import com.tngtech.archunit.core.domain.JavaAccess; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaModifier; + +/** + * A type that represents an event in a system. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +@Value +public class EventType { + + private final JavaClass type; + + /** + * The sources that create that event. Includes static factory methods that return an instance of the event type + * itself as well as constructor invocations, except ones from the factory methods. + */ + private final List sources; + + /** + * Creates a new {@link EventType} for the given {@link JavaClass}. + * + * @param type must not be {@literal null}. + */ + public EventType(JavaClass type) { + + Assert.notNull(type, "Type must not be null!"); + + this.type = type; + + Stream> factoryMethodCalls = type.getMethods().stream() + .filter(method -> method.getModifiers().contains(JavaModifier.STATIC)) + .filter(method -> method.getRawReturnType().equals(type)) + .flatMap(method -> method.getCallsOfSelf().stream()); + + Stream> constructorCalls = type.getConstructors().stream() + .flatMap(constructor -> constructor.getCallsOfSelf().stream()); + + this.sources = Stream.concat(constructorCalls, factoryMethodCalls) + .filter(call -> !call.getOriginOwner().equals(type)) + .map(JavaAccessSource::new) + .collect(Collectors.toList()); + } + + public boolean hasSources() { + return !this.sources.isEmpty(); + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/FormatableJavaClass.java b/moduliths-core/src/main/java/org/moduliths/model/FormatableJavaClass.java new file mode 100644 index 00000000..962d573b --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/FormatableJavaClass.java @@ -0,0 +1,123 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.thirdparty.com.google.common.base.Supplier; +import com.tngtech.archunit.thirdparty.com.google.common.base.Suppliers; + +/** + * Wrapper around {@link JavaClass} that allows creating additional formatted names. + * + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class FormatableJavaClass { + + private static final Map CACHE = new ConcurrentHashMap<>(); + + private final JavaClass type; + private final Supplier abbreviatedName; + + public static FormatableJavaClass of(JavaClass type) { + return CACHE.computeIfAbsent(type, FormatableJavaClass::new); + } + + private FormatableJavaClass(JavaClass type) { + + Assert.notNull(type, "JavaClass must not be null!"); + + this.type = type; + this.abbreviatedName = Suppliers.memoize(() -> { + + String abbreviatedPackage = Stream // + .of(type.getPackageName().split("\\.")) // + .map(it -> it.substring(0, 1)) // + .collect(Collectors.joining(".")); + + return abbreviatedPackage.concat(".") // + .concat(ClassUtils.getShortName(getFullName())); + }); + } + + /** + * Returns the abbreviated (i.e. every package fragment reduced to its first character) full name, e.g. + * {@code com.acme.MyType} will result in {@code c.a.MyType}. + * + * @return will never be {@literal null}. + */ + public String getAbbreviatedFullName() { + return abbreviatedName.get(); + } + + public String getAbbreviatedFullName(@Nullable Module module) { + + if (module == null) { + return getAbbreviatedFullName(); + } + + String basePackageName = module.getBasePackage().getName(); + + if (!StringUtils.hasText(basePackageName)) { + return getAbbreviatedFullName(); + } + + String typePackageName = type.getPackageName(); + + if (basePackageName.equals(typePackageName)) { + return getAbbreviatedFullName(); + } + + if (!typePackageName.startsWith(basePackageName)) { + return getFullName(); + } + + return abbreviate(basePackageName) // + .concat(typePackageName.substring(basePackageName.length())) // + .concat(".") // + .concat(ClassUtils.getShortName(getFullName())); + } + + /** + * Returns the type's full name. + * + * @return will never be {@literal null}. + */ + public String getFullName() { + return type.getName().replace("$", "."); + } + + private static String abbreviate(String source) { + + return Stream // + .of(source.split("\\.")) // + .map(it -> it.substring(0, 1)) // + .collect(Collectors.joining(".")); + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/JavaAccessSource.java b/moduliths-core/src/main/java/org/moduliths/model/JavaAccessSource.java new file mode 100644 index 00000000..a48e77c4 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/JavaAccessSource.java @@ -0,0 +1,65 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.tngtech.archunit.core.domain.JavaAccess; +import com.tngtech.archunit.core.domain.JavaCodeUnit; + +/** + * A {@link Source} backed by an ArchUnit {@link JavaAccess}. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +class JavaAccessSource implements Source { + + private final static Pattern LAMBDA_EXTRACTOR = Pattern.compile("lambda\\$(.*)\\$.*"); + + private final FormatableJavaClass type; + private final JavaCodeUnit method; + private final String name; + + /** + * Creates a new {@link JavaAccessSource} for the given {@link JavaAccess}. + * + * @param access must not be {@literal null}. + */ + public JavaAccessSource(JavaAccess access) { + + this.type = FormatableJavaClass.of(access.getOriginOwner()); + this.method = access.getOrigin(); + + String name = method.getName(); + Matcher matcher = LAMBDA_EXTRACTOR.matcher(name); + + this.name = matcher.matches() ? matcher.group(1) : name; + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.Source#toString(org.moduliths.model.Module) + */ + @Override + public String toString(Module module) { + + boolean noParameters = method.getRawParameterTypes().isEmpty(); + + return String.format("%s.%s(%s)", type.getAbbreviatedFullName(module), name, noParameters ? "" : "…"); + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/JavaPackage.java b/moduliths-core/src/main/java/org/moduliths/model/JavaPackage.java new file mode 100644 index 00000000..6429467d --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/JavaPackage.java @@ -0,0 +1,180 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.*; + +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.lang.annotation.Annotation; +import java.util.Collection; +import java.util.Iterator; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.tngtech.archunit.base.DescribedIterable; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.properties.CanBeAnnotated; +import com.tngtech.archunit.thirdparty.com.google.common.base.Supplier; +import com.tngtech.archunit.thirdparty.com.google.common.base.Suppliers; + +/** + * @author Oliver Gierke + */ +@EqualsAndHashCode +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class JavaPackage implements DescribedIterable { + + private static final String PACKAGE_INFO_NAME = "package-info"; + + private final @Getter String name; + private final Classes classes; + private final Classes packageClasses; + private final Supplier> directSubPackages; + + private JavaPackage(Classes classes, String name, boolean includeSubPackages) { + + this.classes = classes; + this.packageClasses = classes.that(resideInAPackage(includeSubPackages ? name.concat("..") : name)); + this.name = name; + this.directSubPackages = Suppliers.memoize(() -> packageClasses.stream() // + .map(it -> it.getPackageName()) // + .filter(it -> !it.equals(name)) // + .map(it -> extractDirectSubPackage(it)) // + .distinct() // + .map(it -> of(classes, it)) // + .collect(Collectors.toSet())); + } + + public static JavaPackage of(Classes classes, String name) { + return new JavaPackage(classes, name, true); + } + + public static boolean isPackageInfoType(JavaClass type) { + return type.getSimpleName().equals(PACKAGE_INFO_NAME); + } + + public JavaPackage toSingle() { + return new JavaPackage(classes, name, false); + } + + public String getLocalName() { + return name.substring(name.lastIndexOf(".") + 1); + } + + public Collection getDirectSubPackages() { + return directSubPackages.get(); + } + + /** + * Returns all classes residing in the current package and potentially in sub-packages if the current package was + * created to include them. + * + * @return + */ + public Classes getClasses() { + return packageClasses; + } + + /** + * Extract the direct sub-package name of the given candidate. + * + * @param candidate + * @return + */ + private String extractDirectSubPackage(String candidate) { + + if (candidate.length() <= name.length()) { + return candidate; + } + + int subSubPackageIndex = candidate.indexOf('.', name.length() + 1); + int endIndex = subSubPackageIndex == -1 ? candidate.length() : subSubPackageIndex; + + return candidate.substring(0, endIndex); + } + + public Stream getSubPackagesAnnotatedWith(Class annotation) { + + return packageClasses.that(JavaClass.Predicates.simpleName(PACKAGE_INFO_NAME) // + .and(CanBeAnnotated.Predicates.annotatedWith(annotation))).stream() // + .map(JavaClass::getPackageName) // + .distinct() // + .map(it -> of(classes, it)); + } + + public Classes that(DescribedPredicate predicate) { + return packageClasses.that(predicate); + } + + public boolean contains(JavaClass type) { + return packageClasses.contains(type); + } + + public boolean contains(String className) { + return packageClasses.contains(className); + } + + public Stream stream() { + return packageClasses.stream(); + } + + public Optional getAnnotation(Class annotationType) { + + return packageClasses.that(JavaClass.Predicates.simpleName(PACKAGE_INFO_NAME) // + .and(CanBeAnnotated.Predicates.annotatedWith(annotationType))) // + .toOptional() // + .map(it -> it.getAnnotationOfType(annotationType)); + } + + /* + * (non-Javadoc) + * @see com.tngtech.archunit.base.HasDescription#getDescription() + */ + @Override + public String getDescription() { + return classes.getDescription(); + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return classes.iterator(); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + + return new StringBuilder(name) // + .append("\n") // + .append(getClasses().format(name)) // + .append('\n') // + .toString(); + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/Module.java b/moduliths-core/src/main/java/org/moduliths/model/Module.java new file mode 100644 index 00000000..7b1748fb --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/Module.java @@ -0,0 +1,833 @@ +/* + * Copyright 2018-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static com.tngtech.archunit.base.DescribedPredicate.*; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.*; +import static java.lang.System.*; +import static org.moduliths.model.Classes.*; +import static org.moduliths.model.Types.*; +import static org.moduliths.model.Types.JavaXTypes.*; +import static org.moduliths.model.Types.SpringDataTypes.*; +import static org.moduliths.model.Types.SpringTypes.*; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.moduliths.model.Types.JMoleculesTypes; +import org.moduliths.model.Types.SpringTypes; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.Dependency; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaCodeUnit; +import com.tngtech.archunit.core.domain.JavaConstructor; +import com.tngtech.archunit.core.domain.JavaField; +import com.tngtech.archunit.core.domain.JavaMember; +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.core.domain.SourceCodeLocation; +import com.tngtech.archunit.thirdparty.com.google.common.base.Supplier; +import com.tngtech.archunit.thirdparty.com.google.common.base.Suppliers; + +/** + * @author Oliver Gierke + */ +@EqualsAndHashCode(doNotUseGetters = true) +public class Module { + + private final @Getter JavaPackage basePackage; + private final ModuleInformation information; + private final @Getter NamedInterfaces namedInterfaces; + private final boolean useFullyQualifiedModuleNames; + + private final Supplier springBeans; + private final Supplier entities; + private final Supplier> publishedEvents; + + Module(JavaPackage basePackage, boolean useFullyQualifiedModuleNames) { + + this.basePackage = basePackage; + this.information = ModuleInformation.of(basePackage); + this.namedInterfaces = NamedInterfaces.discoverNamedInterfaces(basePackage); + this.useFullyQualifiedModuleNames = useFullyQualifiedModuleNames; + + this.springBeans = Suppliers.memoize(() -> filterSpringBeans(basePackage)); + this.entities = Suppliers.memoize(() -> findEntities(basePackage)); + this.publishedEvents = Suppliers.memoize(() -> findPublishedEvents()); + } + + public String getName() { + return useFullyQualifiedModuleNames ? basePackage.getName() : basePackage.getLocalName(); + } + + public String getDisplayName() { + return information.getDisplayName(); + } + + public List getDependencies(Modules modules, DependencyType... type) { + + return getAllModuleDependencies(modules) // + .filter(it -> type.length == 0 ? true : Arrays.stream(type).anyMatch(it::hasType)) // + .map(it -> modules.getModuleByType(it.target)) // + .distinct() // + .flatMap(it -> it.map(Stream::of).orElseGet(Stream::empty)) // + .collect(Collectors.toList()); + } + + /** + * Returns all event types the current module exposes an event listener for. + * + * @param modules must not be {@literal null}. + * @return + */ + public List getEventsListenedTo(Modules modules) { + + Assert.notNull(modules, "Modules must not be null!"); + + return getAllModuleDependencies(modules) // + .filter(it -> it.type == DependencyType.EVENT_LISTENER) // + .map(ModuleDependency::getTarget) // + .collect(Collectors.toList()); + } + + /** + * Returns all {@link EventType}s published by the module. + * + * @return will never be {@literal null}. + */ + public List getPublishedEvents() { + return publishedEvents.get(); + } + + /** + * Returns all types that are considered aggregate roots. + * + * @return will never be {@literal null}. + */ + public List getAggregateRoots() { + + return entities.get().stream() // + .map(it -> ArchitecturallyEvidentType.of(it, getSpringBeansInternal())) // + .filter(ArchitecturallyEvidentType::isAggregateRoot) // + .map(ArchitecturallyEvidentType::getType) // + .flatMap(this::resolveModuleSuperTypes) // + .distinct() // + .collect(Collectors.toList()); + } + + /** + * Returns all types that are considered aggregate roots. + * + * @param modules must not be {@literal null}. + * @return + * @deprecated since 1.3, use {@link #getAggregateRoots()} instead. + */ + @Deprecated + public List getAggregateRoots(Modules modules) { + + Assert.notNull(modules, "Modules must not be null!"); + + return getAggregateRoots(); + } + + /** + * Returns all modules that contain types which the types of the current module depend on. + * + * @param modules must not be {@literal null}. + * @return + */ + public Stream getBootstrapDependencies(Modules modules) { + + Assert.notNull(modules, "Modules must not be null!"); + + return getBootstrapDependencies(modules, DependencyDepth.IMMEDIATE); + } + + public Stream getBootstrapDependencies(Modules modules, DependencyDepth depth) { + + Assert.notNull(modules, "Modules must not be null!"); + Assert.notNull(depth, "Dependency depth must not be null!"); + + return streamDependencies(modules, depth); + } + + /** + * Returns all {@link JavaPackage} for the current module including the ones by its dependencies. + * + * @param modules must not be {@literal null}. + * @param depth must not be {@literal null}. + * @return + */ + public Stream getBasePackages(Modules modules, DependencyDepth depth) { + + Assert.notNull(modules, "Modules must not be null!"); + Assert.notNull(depth, "Dependency depth must not be null!"); + + Stream dependencies = streamDependencies(modules, depth); + + return Stream.concat(Stream.of(this), dependencies) // + .map(Module::getBasePackage); + } + + public List getSpringBeans() { + return getSpringBeansInternal().stream() // + .map(it -> SpringBean.of(it, this)) // + .collect(Collectors.toList()); + } + + Classes getSpringBeansInternal() { + return springBeans.get(); + } + + public boolean contains(JavaClass type) { + return basePackage.contains(type); + } + + public boolean contains(@Nullable Class type) { + return type != null && getType(type.getName()).isPresent(); + } + + /** + * Returns the {@link JavaClass} for the given candidate simple of fully-qualified type name. + * + * @param candidate must not be {@literal null} or empty. + * @return will never be {@literal null}. + * @since 1.1 + */ + public Optional getType(String candidate) { + + Assert.hasText(candidate, "Candidate must not be null or emtpy!"); + + return basePackage.stream() + .filter(hasSimpleOrFullyQualifiedName(candidate)) + .findFirst(); + } + + /** + * Returns whether the given {@link JavaClass} is exposed by the current module, i.e. whether it's part of any of the + * module's named interfaces. + * + * @param type must not be {@literal null}. + * @return + */ + public boolean isExposed(JavaClass type) { + + Assert.notNull(type, "Type must not be null!"); + + return namedInterfaces.stream().anyMatch(it -> it.contains(type)); + } + + public void verifyDependencies(Modules modules) { + detectDependencies(modules).throwIfPresent(); + } + + public Violations detectDependencies(Modules modules) { + + return getAllModuleDependencies(modules) // + .map(it -> it.isValidDependencyWithin(modules)) // + .reduce(Violations.NONE, Violations::and); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return toString(null); + } + + public String toString(@Nullable Modules modules) { + + StringBuilder builder = new StringBuilder("## ").append(getDisplayName()).append(" ##\n"); + builder.append("> Logical name: ").append(getName()).append('\n'); + builder.append("> Base package: ").append(basePackage.getName()).append('\n'); + + if (namedInterfaces.hasExplicitInterfaces()) { + + builder.append("> Named interfaces:\n"); + + namedInterfaces.forEach(it -> builder.append(" + ") // + .append(it.toString()) // + .append('\n')); + } + + if (modules != null) { + + List dependencies = getBootstrapDependencies(modules).collect(Collectors.toList()); + + builder.append("> Direct module dependencies: "); + builder.append(dependencies.isEmpty() ? "none" + : dependencies.stream().map(Module::getName).collect(Collectors.joining(", "))); + builder.append('\n'); + } + + Classes beans = getSpringBeansInternal(); + + if (beans.isEmpty()) { + + builder.append("> Spring beans: none\n"); + + } else { + + builder.append("> Spring beans:\n"); + beans.forEach(it -> builder.append(" ") // + .append(Classes.format(it, basePackage.getName()))// + .append('\n')); + } + + return builder.toString(); + } + + /** + * Returns all allowed module dependencies, either explicitly declared or defined as shared on the given + * {@link Modules} instance. + * + * @param modules must not be {@literal null}. + * @return + */ + List getAllowedDependencies(Modules modules) { + + Assert.notNull(modules, "Modules must not be null!"); + + List allowedDependencyNames = information.getAllowedDependencies(); + + if (allowedDependencyNames.isEmpty()) { + return Collections.emptyList(); + } + + Stream explicitlyDeclaredModules = allowedDependencyNames.stream() // + .map(modules::getModuleByName) // + .flatMap(it -> it.map(Stream::of).orElse(Stream.empty())); + + return Stream.concat(explicitlyDeclaredModules, modules.getSharedModules().stream()) // + .distinct() // + .collect(Collectors.toList()); + } + + /** + * Returns whether the given module contains a type with the given simple or fully qualified name. + * + * @param candidate must not be {@literal null} or empty. + * @return + * @since 1.1 + */ + boolean contains(String candidate) { + + Assert.hasText(candidate, "Candidate must not be null or empty!"); + + return getType(candidate).isPresent(); + } + + private List findPublishedEvents() { + + DescribedPredicate isEvent = implement(JMoleculesTypes.DOMAIN_EVENT) // + .or(isAnnotatedWith(JMoleculesTypes.AT_DOMAIN_EVENT)); + + return basePackage.that(isEvent).stream() // + .map(EventType::new) + .collect(Collectors.toList()); + } + + /** + * Returns a {@link Stream} of all super types of the given one that are declared in the same module as well as the + * type itself. + * + * @param type must not be {@literal null}. + * @return + */ + private Stream resolveModuleSuperTypes(JavaClass type) { + + Assert.notNull(type, "Type must not be null!"); + + return Stream.concat(// + type.getAllRawSuperclasses().stream().filter(this::contains), // + Stream.of(type)); + } + + private Stream getAllModuleDependencies(Modules modules) { + + return basePackage.stream() // + .flatMap(it -> getModuleDependenciesOf(it, modules)); + } + + private Stream streamDependencies(Modules modules, DependencyDepth depth) { + + switch (depth) { + + case NONE: + return Stream.empty(); + case IMMEDIATE: + return getDirectModuleDependencies(modules); + case ALL: + default: + return getDirectModuleDependencies(modules) // + .flatMap(it -> Stream.concat(Stream.of(it), it.streamDependencies(modules, DependencyDepth.ALL))) // + .distinct(); + } + } + + private Stream getDirectModuleDependencies(Modules modules) { + + return getSpringBeansInternal().stream() // + .flatMap(it -> ModuleDependency.fromType(it)) // + .filter(it -> isDependencyToOtherModule(it.target, modules)) // + .map(it -> modules.getModuleByType(it.target)) // + .distinct() // + .flatMap(it -> it.map(Stream::of).orElseGet(Stream::empty)); + } + + private Stream getModuleDependenciesOf(JavaClass type, Modules modules) { + + Stream injections = ModuleDependency.fromType(type) // + .filter(it -> isDependencyToOtherModule(it.getTarget(), modules)); // + + Stream directDependencies = type.getDirectDependenciesFromSelf().stream() // + .filter(it -> isDependencyToOtherModule(it.getTargetClass(), modules)) // + .map(ModuleDependency::new); + + return Stream.concat(injections, directDependencies).distinct(); + } + + private boolean isDependencyToOtherModule(JavaClass dependency, Modules modules) { + return modules.contains(dependency) && !contains(dependency); + } + + private Classes findEntities(JavaPackage source) { + + return source.stream() // + .map(it -> ArchitecturallyEvidentType.of(it, getSpringBeansInternal())) + .filter(ArchitecturallyEvidentType::isEntity) // + .map(ArchitecturallyEvidentType::getType).collect(toClasses()); + } + + private static Classes filterSpringBeans(JavaPackage source) { + + Map> collect = source.that(isConfiguration()).stream() // + .flatMap(it -> it.getMethods().stream()) // + .filter(SpringTypes::isAtBeanMethod) // + .map(JavaMethod::getRawReturnType) // + .collect(Collectors.groupingBy(it -> source.contains(it))); + + Classes repositories = source.that(isSpringDataRepository()); + Classes coreComponents = source.that(not(INTERFACES).and(isComponent())); + Classes configurationProperties = source.that(isConfigurationProperties()); + + return coreComponents // + .and(repositories) // + .and(configurationProperties) // + .and(collect.getOrDefault(true, Collections.emptyList())) // + .and(collect.getOrDefault(false, Collections.emptyList())); + } + + private static Predicate hasSimpleOrFullyQualifiedName(String candidate) { + return it -> it.getSimpleName().equals(candidate) || it.getFullName().equals(candidate); + } + + public enum DependencyDepth { + + NONE, + + IMMEDIATE, + + ALL; + } + + @EqualsAndHashCode + @RequiredArgsConstructor + static class ModuleDependency { + + private static final List INJECTION_TYPES = Arrays.asList(// + AT_AUTOWIRED, AT_RESOURCE, AT_INJECT); + + private final @NonNull @Getter JavaClass origin, target; + private final @NonNull String description; + private final @NonNull DependencyType type; + + ModuleDependency(Dependency dependency) { + this(dependency.getOriginClass(), // + dependency.getTargetClass(), // + dependency.getDescription(), // + DependencyType.forDependency(dependency)); + } + + boolean hasType(DependencyType type) { + return this.type.equals(type); + } + + Violations isValidDependencyWithin(Modules modules) { + + Module originModule = getExistingModuleOf(origin, modules); + Module targetModule = getExistingModuleOf(target, modules); + + List allowedTargets = originModule.getAllowedDependencies(modules); + Violations violations = Violations.NONE; + + if (!allowedTargets.isEmpty() && !allowedTargets.contains(targetModule)) { + + String allowedTargetsString = allowedTargets.stream() // + .map(Module::getName) // + .collect(Collectors.joining(", ")); + + String message = String.format("Module '%s' depends on module '%s' via %s -> %s. Allowed target modules: %s.", + originModule.getName(), targetModule.getName(), origin.getName(), target.getName(), allowedTargetsString); + + violations = violations.and(new IllegalStateException(message)); + } + + if (!targetModule.isExposed(target)) { + + String violationText = String.format("Module '%s' depends on non-exposed type %s within module '%s'!", + originModule.getName(), target.getName(), targetModule.getName()); + + violations = violations.and(new IllegalStateException(violationText + lineSeparator() + description)); + } + + return violations; + } + + Module getExistingModuleOf(JavaClass javaClass, Modules modules) { + + Optional module = modules.getModuleByType(javaClass); + + return module.orElseThrow(() -> new IllegalStateException( + String.format("Origin/Target of a %s should always be within a module, but %s is not", + getClass().getSimpleName(), javaClass.getName()))); + } + + static ModuleDependency fromCodeUnitParameter(JavaCodeUnit codeUnit, JavaClass parameter) { + + String description = createDescription(codeUnit, parameter, "parameter"); + + DependencyType type = DependencyType.forCodeUnit(codeUnit) // + .or(() -> DependencyType.forParameter(parameter)); + + return new ModuleDependency(codeUnit.getOwner(), parameter, description, type); + } + + static ModuleDependency fromCodeUnitReturnType(JavaCodeUnit codeUnit) { + + String description = createDescription(codeUnit, codeUnit.getRawReturnType(), "return type"); + + return new ModuleDependency(codeUnit.getOwner(), codeUnit.getRawReturnType(), description, + DependencyType.DEFAULT); + } + + static Stream fromType(JavaClass source) { + return Stream.concat(Stream.concat(fromConstructorOf(source), fromMethodsOf(source)), fromFieldsOf(source)); + } + + private static Stream fromConstructorOf(JavaClass source) { + + Set constructors = source.getConstructors(); + + return constructors.stream() // + .filter(it -> constructors.size() == 1 || isInjectionPoint(it)) // + .flatMap(it -> it.getRawParameterTypes().stream() // + .map(parameter -> new InjectionModuleDependency(source, parameter, it))); + } + + private static Stream fromFieldsOf(JavaClass source) { + + Stream fieldInjections = source.getAllFields().stream() // + .filter(ModuleDependency::isInjectionPoint) // + .map(field -> new InjectionModuleDependency(source, field.getRawType(), field)); + + return fieldInjections; + } + + private static Stream fromMethodsOf(JavaClass source) { + + Set methods = source.getAllMethods().stream() // + .filter(it -> !it.getOwner().isEquivalentTo(Object.class)) // + .collect(Collectors.toSet()); + + if (methods.isEmpty()) { + return Stream.empty(); + } + + Stream returnTypes = methods.stream() // + .filter(it -> !it.getRawReturnType().isPrimitive()) // + .filter(it -> !it.getRawReturnType().getPackageName().startsWith("java")) // + .map(it -> fromCodeUnitReturnType(it)); + + Set injectionMethods = methods.stream() // + .filter(ModuleDependency::isInjectionPoint) // + .collect(Collectors.toSet()); + + Stream methodInjections = injectionMethods.stream() // + .flatMap(it -> it.getRawParameterTypes().stream() // + .map(parameter -> new InjectionModuleDependency(source, parameter, it))); + + Stream otherMethods = methods.stream() // + .filter(it -> !injectionMethods.contains(it)) // + .flatMap(it -> it.getRawParameterTypes().stream() // + .map(parameter -> fromCodeUnitParameter(it, parameter))); + + return Stream.concat(Stream.concat(methodInjections, otherMethods), returnTypes); + } + + static Stream allFrom(JavaCodeUnit codeUnit) { + + Stream parameterDependencies = codeUnit.getRawParameterTypes()// + .stream() // + .map(it -> fromCodeUnitParameter(codeUnit, it)); + + Stream returnType = Stream.of(fromCodeUnitReturnType(codeUnit)); + + return Stream.concat(parameterDependencies, returnType); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return type.format(FormatableJavaClass.of(origin), FormatableJavaClass.of(target)); + } + + private static String createDescription(JavaMember codeUnit, JavaClass declaringElement, + String declarationDescription) { + + String type = declaringElement.getSimpleName(); + + String codeUnitDescription = JavaConstructor.class.isInstance(codeUnit) // + ? String.format("%s", declaringElement.getSimpleName()) // + : String.format("%s.%s", declaringElement.getSimpleName(), codeUnit.getName()); + + if (JavaCodeUnit.class.isInstance(codeUnit)) { + codeUnitDescription = String.format("%s(%s)", codeUnitDescription, + JavaCodeUnit.class.cast(codeUnit).getRawParameterTypes().stream() // + .map(JavaClass::getSimpleName) // + .collect(Collectors.joining(", "))); + } + + String annotations = codeUnit.getAnnotations().stream() // + .filter(it -> INJECTION_TYPES.contains(it.getRawType().getName())) // + .map(it -> "@" + it.getRawType().getSimpleName()) // + .collect(Collectors.joining(" ", "", " ")); + + annotations = StringUtils.hasText(annotations) ? annotations : ""; + + String declaration = declarationDescription + " " + annotations + codeUnitDescription; + String location = SourceCodeLocation.of(codeUnit.getOwner(), 0).toString(); + + return String.format("%s declares %s in %s", type, declaration, location); + } + + private static boolean isInjectionPoint(JavaMember unit) { + return INJECTION_TYPES.stream().anyMatch(type -> unit.isAnnotatedWith(type)); + } + } + + private static class InjectionModuleDependency extends ModuleDependency { + + private final JavaMember member; + private final boolean isConfigurationClass; + + /** + * @param origin + * @param target + * @param member + */ + public InjectionModuleDependency(JavaClass origin, JavaClass target, JavaMember member) { + + super(origin, target, ModuleDependency.createDescription(member, origin, getDescriptionFor(member)), + DependencyType.USES_COMPONENT); + + this.member = member; + this.isConfigurationClass = isConfiguration().apply(origin); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.Module.ModuleDependency#isValidDependencyWithin(org.moduliths.model.Modules) + */ + @Override + Violations isValidDependencyWithin(Modules modules) { + + Violations violations = super.isValidDependencyWithin(modules); + + if (JavaField.class.isInstance(member) && !isConfigurationClass) { + + Module module = getExistingModuleOf(member.getOwner(), modules); + + violations = violations.and(new IllegalStateException( + String.format("Module %s uses field injection in %s. Prefer constructor injection instead!", + module.getDisplayName(), member.getFullName()))); + } + + return violations; + } + + private static String getDescriptionFor(JavaMember member) { + + if (JavaConstructor.class.isInstance(member)) { + return "constructor"; + } else if (JavaMethod.class.isInstance(member)) { + return "injection method"; + } else if (JavaField.class.isInstance(member)) { + return "injected field"; + } + + throw new IllegalArgumentException(String.format("Invalid member type %s!", member.toString())); + } + } + + public enum DependencyType { + + /** + * Indicates that the module depends on the other one by a component dependency, i.e. that other module needs to be + * bootstrapped to run the source module. + */ + USES_COMPONENT { + + /* + * (non-Javadoc) + * @see org.moduliths.model.Module.DependencyType#format(org.moduliths.model.FormatableJavaClass, org.moduliths.model.FormatableJavaClass) + */ + @Override + public String format(FormatableJavaClass source, FormatableJavaClass target) { + return String.format("Component %s using %s", source.getAbbreviatedFullName(), target.getAbbreviatedFullName()); + } + }, + + /** + * Indicates that the module refers to an entity of the other. + */ + ENTITY { + + /* + * (non-Javadoc) + * @see org.moduliths.model.Module.DependencyType#format(org.moduliths.model.FormatableJavaClass, org.moduliths.model.FormatableJavaClass) + */ + @Override + public String format(FormatableJavaClass source, FormatableJavaClass target) { + return String.format("Entity %s depending on %s", source.getAbbreviatedFullName(), + target.getAbbreviatedFullName()); + } + }, + + /** + * Indicates that the module depends on the other by declaring an event listener for an event exposed by the other + * module. Thus, the target module does not have to be bootstrapped to run the source one. + */ + EVENT_LISTENER { + + /* + * (non-Javadoc) + * @see org.moduliths.model.Module.DependencyType#format(org.moduliths.model.FormatableJavaClass, org.moduliths.model.FormatableJavaClass) + */ + @Override + public String format(FormatableJavaClass source, FormatableJavaClass target) { + return String.format("%s listening to events of type %s", source.getAbbreviatedFullName(), + target.getAbbreviatedFullName()); + } + }, + + DEFAULT { + + /* + * (non-Javadoc) + * @see org.moduliths.model.Module.DependencyType#or(com.tngtech.archunit.thirdparty.com.google.common.base.Supplier) + */ + @Override + public DependencyType or(Supplier supplier) { + return supplier.get(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.Module.DependencyType#format(org.moduliths.model.FormatableJavaClass, org.moduliths.model.FormatableJavaClass) + */ + @Override + public String format(FormatableJavaClass source, FormatableJavaClass target) { + return String.format("%s depending on %s", source.getAbbreviatedFullName(), target.getAbbreviatedFullName()); + } + }; + + public static DependencyType forParameter(JavaClass type) { + return type.isAnnotatedWith("javax.persistence.Entity") ? ENTITY : DEFAULT; + } + + public static DependencyType forCodeUnit(JavaCodeUnit codeUnit) { + return Types.isAnnotatedWith(SpringTypes.AT_EVENT_LISTENER).apply(codeUnit) // + || Types.isAnnotatedWith(JMoleculesTypes.AT_DOMAIN_EVENT_HANDLER).apply(codeUnit) // + ? EVENT_LISTENER + : DEFAULT; + } + + public static DependencyType forDependency(Dependency dependency) { + return forParameter(dependency.getTargetClass()); + } + + public abstract String format(FormatableJavaClass source, FormatableJavaClass target); + + public DependencyType or(Supplier supplier) { + return this; + } + + /** + * Returns all {@link DependencyType}s except the given ones. + * + * @param types must not be {@literal null}. + * @return + */ + public static Stream allBut(Collection types) { + + Assert.notNull(types, "Types must not be null!"); + + Predicate isIncluded = types::contains; + + return Arrays.stream(values()) // + .filter(isIncluded.negate()); + } + + public static Stream allBut(Stream types) { + return allBut(types.collect(Collectors.toList())); + } + + /** + * Returns all {@link DependencyType}s except the given ones. + * + * @param types must not be {@literal null}. + * @return + */ + public static Stream allBut(DependencyType... types) { + + Assert.notNull(types, "Types must not be null!"); + + return allBut(Arrays.asList(types)); + } + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/ModuleDetectionStrategies.java b/moduliths-core/src/main/java/org/moduliths/model/ModuleDetectionStrategies.java new file mode 100644 index 00000000..855856e6 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/ModuleDetectionStrategies.java @@ -0,0 +1,60 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import java.util.Objects; +import java.util.stream.Stream; + +import org.moduliths.Module; +import org.moduliths.model.Types.JMoleculesTypes; + +/** + * Default implementations of {@link ModuleDetectionStrategy}. + * + * @author Oliver Drotbohm + * @see ModuleDetectionStrategy#directSubPackage() + * @see ModuleDetectionStrategy#explictlyAnnotated() + */ +enum ModuleDetectionStrategies implements ModuleDetectionStrategy { + + DIRECT_SUB_PACKAGES { + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModuleDetection#getModuleBasePackages(org.moduliths.model.JavaPackage) + */ + @Override + public Stream getModuleBasePackages( + JavaPackage basePackage) { + return basePackage.getDirectSubPackages().stream(); + } + }, + + EXPLICITLY_ANNOTATED { + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModuleDetection#getModuleBasePackages(org.moduliths.model.JavaPackage) + */ + @Override + public Stream getModuleBasePackages(JavaPackage basePackage) { + + return Stream.of(Module.class, JMoleculesTypes.getModuleAnnotationTypeIfPresent()) + .filter(Objects::nonNull) + .flatMap(basePackage::getSubPackagesAnnotatedWith); + } + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/ModuleDetectionStrategy.java b/moduliths-core/src/main/java/org/moduliths/model/ModuleDetectionStrategy.java new file mode 100644 index 00000000..3e33ad71 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/ModuleDetectionStrategy.java @@ -0,0 +1,57 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import java.util.stream.Stream; + +import org.moduliths.Module; + +/** + * Strategy interface to customize which packages are considered module base packages. + * + * @author Oliver Drotbohm + */ +public interface ModuleDetectionStrategy { + + /** + * Given the {@link JavaPackage} that Moduliths was initialized with, return the base packages for all modules in the + * system. + * + * @param basePackage will never be {@literal null}. + * @return must not be {@literal null}. + */ + Stream getModuleBasePackages(JavaPackage basePackage); + + /** + * A {@link ModuleDetectionStrategy} that considers all direct sub-packages of the Moduliths base package to be module + * base packages. + * + * @return will never be {@literal null}. + */ + static ModuleDetectionStrategy directSubPackage() { + return ModuleDetectionStrategies.DIRECT_SUB_PACKAGES; + } + + /** + * A {@link ModuleDetectionStrategy} that considers packages explicitly annotated with {@link Module} module base + * packages. + * + * @return will never be {@literal null}. + */ + static ModuleDetectionStrategy explictlyAnnotated() { + return ModuleDetectionStrategies.EXPLICITLY_ANNOTATED; + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/ModuleInformation.java b/moduliths-core/src/main/java/org/moduliths/model/ModuleInformation.java new file mode 100644 index 00000000..3733396b --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/ModuleInformation.java @@ -0,0 +1,151 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.moduliths.Module; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +/** + * Abstraction for low-level module information. Used to support different annotations to configure metadata about a + * module. + * + * @author Oliver Drotbohm + */ +interface ModuleInformation { + + public static ModuleInformation of(JavaPackage javaPackage) { + + if (ClassUtils.isPresent("org.jmolecules.ddd.annotation.Module", ModuleInformation.class.getClassLoader()) + && MoleculesModule.supports(javaPackage)) { + return new MoleculesModule(javaPackage); + } + + return new ModulithsModule(javaPackage); + } + + String getDisplayName(); + + List getAllowedDependencies(); + + @RequiredArgsConstructor(access = AccessLevel.PROTECTED) + static abstract class AbstractModuleInformation implements ModuleInformation { + + private final JavaPackage javaPackage; + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModuleInformation#getName() + */ + @Override + public String getDisplayName() { + return javaPackage.getName(); + } + } + + static class MoleculesModule extends AbstractModuleInformation { + + private final Optional annotation; + + public static boolean supports(JavaPackage javaPackage) { + return javaPackage.getAnnotation(org.jmolecules.ddd.annotation.Module.class).isPresent(); + } + + public MoleculesModule(JavaPackage javaPackage) { + + super(javaPackage); + + this.annotation = javaPackage.getAnnotation(org.jmolecules.ddd.annotation.Module.class); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModuleInformation#getName() + */ + @Override + public String getDisplayName() { + + return annotation // + .map(org.jmolecules.ddd.annotation.Module::name) // + .filter(StringUtils::hasText) + .orElseGet(() -> annotation // + .map(org.jmolecules.ddd.annotation.Module::value) // + .filter(StringUtils::hasText) // + .orElseGet(super::getDisplayName)); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModuleInformation#getAllowedDependencies() + */ + @Override + public List getAllowedDependencies() { + return Collections.emptyList(); + } + } + + static class ModulithsModule extends AbstractModuleInformation { + + private final Optional annotation; + + public static boolean supports(JavaPackage javaPackage) { + return javaPackage.getAnnotation(Module.class).isPresent(); + } + + public ModulithsModule(JavaPackage javaPackage) { + + super(javaPackage); + + this.annotation = javaPackage.getAnnotation(Module.class); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModuleInformation.AbstractModuleInformation#getName() + */ + @Override + public String getDisplayName() { + + return annotation // + .map(Module::displayName) // + .filter(StringUtils::hasText) // + .orElseGet(super::getDisplayName); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModuleInformation#getAllowedDependencies() + */ + @Override + public List getAllowedDependencies() { + + return annotation // + .map(it -> Arrays.stream(it.allowedDependencies())) // + .orElse(Stream.empty()) // + .collect(Collectors.toList()); + } + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/Modules.java b/moduliths-core/src/main/java/org/moduliths/model/Modules.java new file mode 100644 index 00000000..3a7354fb --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/Modules.java @@ -0,0 +1,451 @@ +/* + * Copyright 2018-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static com.tngtech.archunit.base.DescribedPredicate.*; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.*; +import static java.util.stream.Collectors.*; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; +import lombok.With; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.jmolecules.archunit.JMoleculesDddRules; +import org.moduliths.Modulith; +import org.moduliths.Modulithic; +import org.moduliths.model.Types.JMoleculesTypes; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.Assert; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.EvaluationResult; +import com.tngtech.archunit.lang.FailureReport; +import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition; + +/** + * @author Oliver Gierke + * @author Peter Gafert + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Modules implements Iterable { + + private static final Map CACHE = new HashMap<>(); + + private static final ModuleDetectionStrategy DETECTION_STRATEGY; + + static { + + List loadFactories = SpringFactoriesLoader.loadFactories(ModuleDetectionStrategy.class, + Modules.class.getClassLoader()); + + if (loadFactories.size() > 1) { + + throw new IllegalStateException( + String.format("Multiple module detection strategies configured. Only one supported! %s", + loadFactories)); + } + + DETECTION_STRATEGY = loadFactories.isEmpty() ? ModuleDetectionStrategies.DIRECT_SUB_PACKAGES : loadFactories.get(0); + } + + private final ModulithMetadata metadata; + private final Map modules; + private final JavaClasses allClasses; + private final List rootPackages; + private final @With(AccessLevel.PRIVATE) @Getter Set sharedModules; + + private boolean verified; + + private Modules(ModulithMetadata metadata, Collection packages, DescribedPredicate ignored, + boolean useFullyQualifiedModuleNames) { + + this.metadata = metadata; + this.allClasses = new ClassFileImporter() // + .withImportOption(new ImportOption.DoNotIncludeTests()) // + .importPackages(packages) // + .that(not(ignored)); + + Classes classes = Classes.of(allClasses); + + this.modules = packages.stream() // + .map(it -> JavaPackage.of(classes, it)) + .flatMap(DETECTION_STRATEGY::getModuleBasePackages) // + .map(it -> new Module(it, useFullyQualifiedModuleNames)) // + .collect(toMap(Module::getName, Function.identity())); + + this.rootPackages = packages.stream() // + .map(it -> JavaPackage.of(classes, it).toSingle()) // + .collect(Collectors.toList()); + + this.sharedModules = Collections.emptySet(); + } + + /** + * Creates a new {@link Modules} relative to the given modulith type. Will inspect the {@link Modulith} annotation on + * the class given for advanced customizations of the module setup. + * + * @param modulithType must not be {@literal null}. + * @return + */ + public static Modules of(Class modulithType) { + return of(modulithType, alwaysFalse()); + } + + /** + * Creates a new {@link Modules} relative to the given modulith type, a {@link ModuleDetectionStrategy} and a + * {@link DescribedPredicate} which types and packages to ignore. Will inspect the {@link Modulith} and + * {@link Modulithic} annotations on the class given for advanced customizations of the module setup. + * + * @param modulithType must not be {@literal null}. + * @param detection must not be {@literal null}. + * @param ignored must not be {@literal null}. + * @return + */ + public static Modules of(Class modulithType, DescribedPredicate ignored) { + + CacheKey key = TypeKey.of(modulithType, ignored); + + return CACHE.computeIfAbsent(key, it -> { + + Assert.notNull(modulithType, "Modulith root type must not be null!"); + Assert.notNull(ignored, "Predicate to describe ignored types must not be null!"); + + return of(key); + }); + } + + /** + * Creates a new {@link Modules} instance for the given package name. + * + * @param javaPackage must not be {@literal null} or empty. + * @return will never be {@literal null}. + * @since 1.1 + */ + public static Modules of(String javaPackage) { + return of(javaPackage, alwaysFalse()); + } + + /** + * Creates a new {@link Modules} instance for the given package name and ignored classes. + * + * @param javaPackage must not be {@literal null} or empty. + * @param ignored must not be {@literal null}. + * @return will never be {@literal null}. + * @since 1.1 + */ + public static Modules of(String javaPackage, DescribedPredicate ignored) { + + CacheKey key = PackageKey.of(javaPackage, ignored); + + return CACHE.computeIfAbsent(key, it -> { + + Assert.hasText(javaPackage, "Base package must not be null or empty!"); + Assert.notNull(ignored, "Predicate to describe ignored types must not be null!"); + + return of(key); + }); + } + + /** + * Creates a new {@link Modules} instance for the given {@link CacheKey}. + * + * @param key must not be {@literal null}. + * @return will never be {@literal null}. + */ + private static Modules of(CacheKey key) { + + Assert.notNull(key, "Cache key must not be null!"); + + ModulithMetadata metadata = key.getMetadata(); + + Set basePackages = new HashSet<>(); + basePackages.add(key.getBasePackage()); + basePackages.addAll(metadata.getAdditionalPackages()); + + Modules modules = new Modules(metadata, basePackages, key.getIgnored(), + metadata.useFullyQualifiedModuleNames()); + + Set sharedModules = metadata.getSharedModuleNames() // + .map(modules::getRequiredModule) // + .collect(Collectors.toSet()); + + return modules.withSharedModules(sharedModules); + } + + public Object getModulithSource() { + return metadata.getModulithSource(); + } + + /** + * @return + * @deprecated since 1.1, as a {@link Modules} instance doesn't have to be created from a class in the first place. + * For generic use, use {@link #getModulithSource()} instead. + */ + @Deprecated + public Class getModulithType() { + + Object source = getModulithSource(); + + if (!Class.class.isInstance(source)) { + throw new IllegalStateException(String.format("Moduliths not created from a type but %s!", source)); + } + + return (Class) source; + } + + /** + * Returns whether the given {@link JavaClass} is contained within the {@link Modules}. + * + * @param type must not be {@literal null}. + * @return + */ + public boolean contains(JavaClass type) { + + Assert.notNull(type, "Type must not be null!"); + + return modules.values().stream() // + .anyMatch(module -> module.contains(type)); + } + + /** + * Returns whether the given type is contained in one of the root packages (not including sub-packages) of the + * modules. + * + * @param className must not be {@literal null} or empty. + * @return + */ + public boolean withinRootPackages(String className) { + + Assert.hasText(className, "Class name must not be null or empty!"); + + return rootPackages.stream().anyMatch(it -> it.contains(className)); + } + + /** + * Returns the {@link Module} with the given name. + * + * @param name must not be {@literal null} or empty. + * @return + */ + public Optional getModuleByName(String name) { + + Assert.hasText(name, "Module name must not be null or empty!"); + + return Optional.ofNullable(modules.get(name)); + } + + /** + * Returns the module that contains the given {@link JavaClass}. + * + * @param type must not be {@literal null}. + * @return + */ + public Optional getModuleByType(JavaClass type) { + + Assert.notNull(type, "Type must not be null!"); + + return modules.values().stream() // + .filter(it -> it.contains(type)) // + .findFirst(); + } + + /** + * Returns the {@link Module} containing the type with the given simple or fully-qualified name. + * + * @param candidate must not be {@literal null} or empty. + * @return will never be {@literal null}. + * @since 1.1 + */ + public Optional getModuleByType(String candidate) { + + Assert.hasText(candidate, "Candidate must not be null or empty!"); + + return modules.values().stream() // + .filter(it -> it.contains(candidate)) // + .findFirst(); + } + + public Optional getModuleForPackage(String name) { + + return modules.values().stream() // + .filter(it -> name.startsWith(it.getBasePackage().getName())) // + .findFirst(); + } + + public void verify() { + + if (verified) { + return; + } + + Violations violations = detectViolations(); + + this.verified = true; + + violations.throwIfPresent(); + } + + public Violations detectViolations() { + + Violations violations = rootPackages.stream() // + .map(this::assertNoCyclesFor) // + .flatMap(it -> it.getDetails().stream()) // + .map(IllegalStateException::new) // + .collect(Violations.toViolations()); + + if (JMoleculesTypes.areRulesPresent()) { + + EvaluationResult result = JMoleculesDddRules.all().evaluate(allClasses); + + for (String message : result.getFailureReport().getDetails()) { + violations = violations.and(message); + } + } + + return modules.values().stream() // + .map(it -> it.detectDependencies(this)) // + .reduce(violations, Violations::and); + } + + private FailureReport assertNoCyclesFor(JavaPackage rootPackage) { + + EvaluationResult result = SlicesRuleDefinition.slices() // + .matching(rootPackage.getName().concat(".(*)..")) // + .should().beFreeOfCycles() // + .evaluate(allClasses.that(resideInAPackage(rootPackage.getName().concat("..")))); + + return result.getFailureReport(); + } + + /** + * Returns all {@link Module}s. + * + * @return will never be {@literal null}. + */ + public Stream stream() { + return modules.values().stream(); + } + + /** + * Returns the system name if defined. + * + * @return + */ + public Optional getSystemName() { + return metadata.getSystemName(); + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return modules.values().iterator(); + } + + /** + * Returns the module with the given name rejecting invalid module names. + * + * @param moduleName must not be {@literal null}. + * @return + */ + private Module getRequiredModule(String moduleName) { + + Module module = modules.get(moduleName); + + if (module == null) { + throw new IllegalArgumentException(String.format("Module %s does not exist!", moduleName)); + } + + return module; + } + + public static class Filters { + + public static DescribedPredicate withoutModules(String... names) { + + return Arrays.stream(names) // + .map(it -> withoutModule(it)) // + .reduce(DescribedPredicate.alwaysFalse(), DescribedPredicate::or, (__, right) -> right); + } + + public static DescribedPredicate withoutModule(String name) { + return resideInAPackage("..".concat(name).concat("..")); + } + } + + private static interface CacheKey { + + String getBasePackage(); + + DescribedPredicate getIgnored(); + + ModulithMetadata getMetadata(); + } + + @Value(staticConstructor = "of") + private static final class TypeKey implements CacheKey { + + Class type; + DescribedPredicate ignored; + + /* + * (non-Javadoc) + * @see org.moduliths.model.Modules.CacheKey#getBasePackage() + */ + @Override + public String getBasePackage() { + return type.getPackage().getName(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.Modules.CacheKey#getMetadata() + */ + @Override + public ModulithMetadata getMetadata() { + return ModulithMetadata.of(type); + } + } + + @Value(staticConstructor = "of") + private static final class PackageKey implements CacheKey { + + String basePackage; + DescribedPredicate ignored; + + /* + * (non-Javadoc) + * @see org.moduliths.model.Modules.CacheKey#getMetadata() + */ + @Override + public ModulithMetadata getMetadata() { + return ModulithMetadata.of(basePackage); + } + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/ModulithMetadata.java b/moduliths-core/src/main/java/org/moduliths/model/ModulithMetadata.java new file mode 100644 index 00000000..89e91a4a --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/ModulithMetadata.java @@ -0,0 +1,101 @@ +/* + * Copyright 2019-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.moduliths.Modulith; +import org.moduliths.Modulithic; +import org.moduliths.model.Types.SpringTypes; +import org.springframework.util.Assert; + +interface ModulithMetadata { + + static final String ANNOTATION_MISSING = "Modules can only be retrieved from a root type, but %s is not annotated with either @%s, @%s or @%s!"; + + /** + * Creates a new {@link ModulithMetadata} for the given annotated type. Expecteds the type either be annotated with + * {@link Modulith}, {@link Modulithic} or {@link SpringBootApplication}. + * + * @param annotated must not be {@literal null}. + * @return + * @throws IllegalArgumentException in case none of the above mentioned annotations is present on the given type. + */ + public static ModulithMetadata of(Class annotated) { + + Assert.notNull(annotated, "Annotated type must not be null!"); + + Supplier exception = () -> new IllegalArgumentException( + String.format(ANNOTATION_MISSING, annotated.getSimpleName(), Modulith.class.getSimpleName(), + Modulithic.class.getSimpleName(), SpringTypes.AT_SPRING_BOOT_APPLICATION)); + + Supplier withDefaults = () -> DefaultModulithMetadata.of(annotated).orElseThrow(exception); + + return AnnotationModulithMetadata.of(annotated).orElseGet(withDefaults); + } + + /** + * Creates a new {@link ModulithMetadata} instance for the given package. + * + * @param javaPackage must not be {@literal null} or empty. + * @return will never be {@literal null}. + * @since 1.1 + */ + public static ModulithMetadata of(String javaPackage) { + return DefaultModulithMetadata.of(javaPackage); + } + + /** + * Returns the source of the Moduliths setup. Either a type or a package. + * + * @return will never be {@literal null}. + * @since 1.1 + */ + Object getModulithSource(); + + /** + * Returns the names of the packages that are supposed to be considered modulith base packages, i.e. for which to + * consider all direct sub-packages modules by default. + * + * @return will never be {@literal null}. + */ + List getAdditionalPackages(); + + /** + * Whether to use fully-qualified module names, i.e. rather use the fully-qualified package name instead of the local + * one. + * + * @return + */ + boolean useFullyQualifiedModuleNames(); + + /** + * Returns the name of shared modules, i.e. modules that are supposed to always be included in bootstraps. + * + * @return will never be {@literal null}. + */ + Stream getSharedModuleNames(); + + /** + * Returns the name of the system. + * + * @return will never be {@literal null}. + */ + Optional getSystemName(); +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/NamedInterface.java b/moduliths-core/src/main/java/org/moduliths/model/NamedInterface.java new file mode 100644 index 00000000..596eb8c4 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/NamedInterface.java @@ -0,0 +1,178 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.util.Assert; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClass.Predicates; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.core.domain.properties.HasModifiers; + +/** + * @author Oliver Gierke + */ +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class NamedInterface implements Iterable { + + private static final String UNNAMED_NAME = "<>"; + private static final String PACKAGE_INFO_NAME = "package-info"; + + protected final @Getter String name; + + static NamedInterface unnamed(JavaPackage javaPackage) { + return new PackageBasedNamedInterface(UNNAMED_NAME, javaPackage); + } + + public static List of(JavaPackage javaPackage) { + + String[] name = javaPackage.getAnnotation(org.moduliths.NamedInterface.class) // + .map(it -> it.value()) // + .orElseThrow(() -> new IllegalArgumentException( + String.format("Couldn't find NamedInterface annotation on package %s!", javaPackage))); + + return Arrays.stream(name) // + .map(it -> new PackageBasedNamedInterface(it, javaPackage)) // + .collect(Collectors.toList()); + } + + public static TypeBasedNamedInterface of(String name, Classes classes, JavaPackage basePackage) { + return new TypeBasedNamedInterface(name, classes, basePackage); + } + + public boolean isUnnamed() { + return name.equals(UNNAMED_NAME); + } + + public boolean contains(JavaClass type) { + return getClasses().contains(type); + } + + public boolean contains(Class type) { + return !getClasses().that(Predicates.equivalentTo(type)).isEmpty(); + } + + /** + * Returns whether the given {@link NamedInterface} has the same name as the current one. + * + * @param other + * @return + */ + boolean hasSameNameAs(NamedInterface other) { + return this.name.equals(other.name); + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return getClasses().iterator(); + } + + protected abstract Classes getClasses(); + + public abstract NamedInterface merge(TypeBasedNamedInterface other); + + static class PackageBasedNamedInterface extends NamedInterface { + + private final @Getter Classes classes; + private final JavaPackage javaPackage; + + public PackageBasedNamedInterface(String name, JavaPackage pkg) { + + super(name); + + Assert.notNull(pkg, "Package must not be null!"); + Assert.hasText(name, "Package name must not be null or empty!"); + + this.classes = pkg.toSingle().getClasses() // + .that(HasModifiers.Predicates.modifier(JavaModifier.PUBLIC)) // + .that(DescribedPredicate.not(JavaClass.Predicates.simpleName(PACKAGE_INFO_NAME))); + + this.javaPackage = pkg; + } + + private PackageBasedNamedInterface(String name, Classes classes, JavaPackage pkg) { + + super(name); + this.classes = classes; + this.javaPackage = pkg; + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.NamedInterface#merge(org.moduliths.model.NamedInterface.TypeBasedNamedInterface) + */ + @Override + public NamedInterface merge(TypeBasedNamedInterface other) { + return new PackageBasedNamedInterface(name, classes.and(other.classes), javaPackage); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.NamedInterface#toString() + */ + @Override + public String toString() { + return String.format("%s - Public types residing in %s:\n%s\n", name, javaPackage.getName(), + classes.format(javaPackage.getName())); + } + } + + static class TypeBasedNamedInterface extends NamedInterface { + + private final @Getter Classes classes; + private final JavaPackage pkg; + + public TypeBasedNamedInterface(String name, Classes types, JavaPackage pkg) { + super(name); + + this.classes = types; + this.pkg = pkg; + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.NamedInterface#merge(org.moduliths.model.NamedInterface.TypeBasedNamedInterface) + */ + @Override + public NamedInterface merge(TypeBasedNamedInterface other) { + return new TypeBasedNamedInterface(name, classes.and(other.classes), pkg); + } + + /* + * (non-Javadoc) + * @see org.moduliths.model.NamedInterface#toString() + */ + @Override + public String toString() { + return String.format("%s - Types underneath base package %s:\n%s\n", name, pkg.getName(), + classes.format(pkg.getName())); + } + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/NamedInterfaces.java b/moduliths-core/src/main/java/org/moduliths/model/NamedInterfaces.java new file mode 100644 index 00000000..0dbf2262 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/NamedInterfaces.java @@ -0,0 +1,144 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.moduliths.model.NamedInterface.TypeBasedNamedInterface; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import com.tngtech.archunit.core.domain.JavaClass; + +/** + * @author Oliver Gierke + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class NamedInterfaces implements Iterable { + + public static final NamedInterfaces NONE = new NamedInterfaces(Collections.emptyList()); + + private final List namedInterfaces; + + public static NamedInterfaces discoverNamedInterfaces(JavaPackage basePackage) { + + return NamedInterfaces.ofAnnotatedPackages(basePackage) // + .and(NamedInterfaces.ofAnnotatedTypes(basePackage)) // + .orUnnamed(basePackage); + } + + public static NamedInterfaces of(List interfaces) { + return interfaces.isEmpty() ? NONE : new NamedInterfaces(interfaces); + } + + static NamedInterfaces ofAnnotatedPackages(JavaPackage basePackage) { + + return basePackage // + .getSubPackagesAnnotatedWith(org.moduliths.NamedInterface.class) // + .flatMap(it -> NamedInterface.of(it).stream()) // + .collect(Collectors.collectingAndThen(Collectors.toList(), NamedInterfaces::of)); + } + + private static List ofAnnotatedTypes(JavaPackage basePackage) { + + MultiValueMap mappings = new LinkedMultiValueMap<>(); + + basePackage.stream() // + .filter(it -> !JavaPackage.isPackageInfoType(it)) // + .forEach(it -> { + + if (!it.isAnnotatedWith(org.moduliths.NamedInterface.class)) { + return; + } + + org.moduliths.NamedInterface annotation = it + .getAnnotationOfType(org.moduliths.NamedInterface.class); + + for (String name : annotation.value()) { + mappings.add(name, it); + } + }); + + return mappings.entrySet().stream() // + .map(entry -> NamedInterface.of(entry.getKey(), Classes.of(entry.getValue()), basePackage)) // + .collect(Collectors.toList()); + } + + public boolean hasExplicitInterfaces() { + return namedInterfaces.size() > 1 || !namedInterfaces.get(0).isUnnamed(); + } + + public Stream stream() { + return namedInterfaces.stream(); + } + + public NamedInterfaces and(List others) { + + List namedInterfaces = new ArrayList<>(); + List unmergedInterface = this.namedInterfaces; + + for (TypeBasedNamedInterface candidate : others) { + + Optional existing = namedInterfaces.stream() // + .filter(it -> it.hasSameNameAs(candidate)) // + .findFirst(); + + // Merge existing with new and add to result + existing.ifPresent(it -> { + namedInterfaces.add(it.merge(candidate)); + namedInterfaces.add(it); + unmergedInterface.remove(it); + }); + + // Simply add candidate + if (!existing.isPresent()) { + namedInterfaces.add(candidate); + } + } + + namedInterfaces.addAll(unmergedInterface); + + return new NamedInterfaces(namedInterfaces); + } + + public NamedInterfaces orUnnamed(JavaPackage basePackage) { + return namedInterfaces.isEmpty() // + ? of(Collections.singletonList(NamedInterface.unnamed(basePackage))) // + : this; + } + + public Optional getByName(String name) { + return namedInterfaces.stream().filter(it -> it.getName().equals(name)).findFirst(); + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return namedInterfaces.iterator(); + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/Source.java b/moduliths-core/src/main/java/org/moduliths/model/Source.java new file mode 100644 index 00000000..60e07507 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/Source.java @@ -0,0 +1,33 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +/** + * A {@link Source} of some type, bean definition etc. Essentially describes the origin of that bean, event etc. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +public interface Source { + + /** + * Renders the source in human readable way. + * + * @param module must not be {@literal null}. + * @return + */ + String toString(Module module); +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/SpringBean.java b/moduliths-core/src/main/java/org/moduliths/model/SpringBean.java new file mode 100644 index 00000000..87cb0214 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/SpringBean.java @@ -0,0 +1,68 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.stream.Collectors; + +import com.tngtech.archunit.core.domain.JavaClass; + +/** + * A Spring bean type. + * + * @author Oliver Drotbohm + */ +@EqualsAndHashCode +@RequiredArgsConstructor(staticName = "of", access = AccessLevel.PACKAGE) +public class SpringBean { + + private final @Getter JavaClass type; + private final Module module; + + /** + * Returns the fully-qualified name of the Spring bean type. + * + * @return + */ + public String getFullyQualifiedTypeName() { + return type.getFullName(); + } + + /** + * Returns all interfaces implemented by the bean that are part of the same module. + * + * @return + */ + public List getInterfacesWithinModule() { + + return type.getRawInterfaces().stream() // + .filter(module::contains) // + .collect(Collectors.toList()); + } + + public boolean isAnnotatedWith(Class type) { + return Types.isAnnotatedWith(type).apply(this.type); + } + + public ArchitecturallyEvidentType toArchitecturallyEvidentType() { + return ArchitecturallyEvidentType.of(type, module.getSpringBeansInternal()); + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/Types.java b/moduliths-core/src/main/java/org/moduliths/model/Types.java new file mode 100644 index 00000000..bf3fc855 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/Types.java @@ -0,0 +1,158 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.*; + +import lombok.experimental.UtilityClass; + +import java.lang.annotation.Annotation; + +import org.springframework.lang.Nullable; +import org.springframework.util.ClassUtils; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.core.domain.properties.CanBeAnnotated; +import com.tngtech.archunit.core.domain.properties.CanBeAnnotated.Predicates; + +/** + * @author Oliver Drotbohm + */ +@UtilityClass +class Types { + + @Nullable + @SuppressWarnings("unchecked") + Class loadIfPresent(String name) { + + ClassLoader loader = Types.class.getClassLoader(); + + return ClassUtils.isPresent(name, loader) ? (Class) ClassUtils.resolveClassName(name, loader) : null; + } + + static class JMoleculesTypes { + + private static final String BASE_PACKAGE = "org.jmolecules"; + private static final String ANNOTATION_PACKAGE = BASE_PACKAGE + ".ddd.annotation"; + private static final String AT_ENTITY = ANNOTATION_PACKAGE + ".Entity"; + private static final String ARCHUNIT_RULES = BASE_PACKAGE + ".archunit.JMoleculesDddRules"; + private static final String MODULE = ANNOTATION_PACKAGE + ".Module"; + + static final String AT_DOMAIN_EVENT_HANDLER = BASE_PACKAGE + ".event.annotation.DomainEventHandler"; + static final String AT_DOMAIN_EVENT = BASE_PACKAGE + ".event.annotation.DomainEvent"; + static final String DOMAIN_EVENT = BASE_PACKAGE + ".event.types.DomainEvent"; + + public static boolean isPresent() { + return ClassUtils.isPresent(AT_ENTITY, JMoleculesTypes.class.getClassLoader()); + } + + @Nullable + @SuppressWarnings("unchecked") + public static Class getModuleAnnotationTypeIfPresent() { + + try { + return isPresent() + ? (Class) ClassUtils.forName(MODULE, JMoleculesTypes.class.getClassLoader()) + : null; + } catch (Exception o_O) { + return null; + } + } + + public static boolean areRulesPresent() { + return ClassUtils.isPresent(ARCHUNIT_RULES, JMoleculesTypes.class.getClassLoader()); + } + } + + @UtilityClass + static class JavaXTypes { + + private static final String BASE_PACKAGE = "javax"; + + static final String AT_ENTITY = BASE_PACKAGE + ".persistence.Entity"; + static final String AT_INJECT = BASE_PACKAGE + ".inject.Inject"; + static final String AT_RESOURCE = BASE_PACKAGE + ".annotation.Resource"; + + static DescribedPredicate isJpaEntity() { + return isAnnotatedWith(AT_ENTITY); + } + } + + @UtilityClass + static class SpringTypes { + + private static final String BASE_PACKAGE = "org.springframework"; + + static final String APPLICATION_LISTENER = BASE_PACKAGE + ".context.ApplicationListener"; + static final String AT_AUTOWIRED = BASE_PACKAGE + ".beans.factory.annotation.Autowired"; + static final String AT_ASYNC = BASE_PACKAGE + ".scheduling.annotation.Async"; + static final String AT_BEAN = BASE_PACKAGE + ".context.annotation.Bean"; + static final String AT_COMPONENT = BASE_PACKAGE + ".stereotype.Component"; + static final String AT_CONFIGURATION = BASE_PACKAGE + ".context.annotation.Configuration"; + static final String AT_CONTROLLER = BASE_PACKAGE + ".stereotype.Controller"; + static final String AT_EVENT_LISTENER = BASE_PACKAGE + ".context.event.EventListener"; + static final String AT_REPOSITORY = BASE_PACKAGE + ".stereotype.Repository"; + static final String AT_SERVICE = BASE_PACKAGE + ".stereotype.Service"; + static final String AT_SPRING_BOOT_APPLICATION = BASE_PACKAGE + ".boot.autoconfigure.SpringBootApplication"; + static final String AT_TX_EVENT_LISTENER = BASE_PACKAGE + ".transaction.event.TransactionalEventListener"; + static final String AT_CONFIGURATION_PROPERTIES = BASE_PACKAGE + ".boot.context.properties.ConfigurationProperties"; + + static DescribedPredicate isConfiguration() { + return isAnnotatedWith(AT_CONFIGURATION); + } + + static DescribedPredicate isComponent() { + return isAnnotatedWith(AT_COMPONENT); + } + + static DescribedPredicate isConfigurationProperties() { + return isAnnotatedWith(AT_CONFIGURATION_PROPERTIES); + } + + static boolean isAtBeanMethod(JavaMethod method) { + return isAnnotatedWith(SpringTypes.AT_BEAN).apply(method); + } + } + + @UtilityClass + static class SpringDataTypes { + + private static final String BASE_PACKAGE = SpringTypes.BASE_PACKAGE + ".data"; + + static final String REPOSITORY = BASE_PACKAGE + ".repository.Repository"; + static final String AT_REPOSITORY_DEFINITION = BASE_PACKAGE + ".repository.RepositoryDefinition"; + + static boolean isPresent() { + return ClassUtils.isPresent(REPOSITORY, SpringDataTypes.class.getClassLoader()); + } + + static DescribedPredicate isSpringDataRepository() { + return assignableTo(SpringDataTypes.REPOSITORY) // + .or(isAnnotatedWith(SpringDataTypes.AT_REPOSITORY_DEFINITION)); + } + } + + DescribedPredicate isAnnotatedWith(Class type) { + return isAnnotatedWith(type.getName()); + } + + DescribedPredicate isAnnotatedWith(String type) { + return Predicates.annotatedWith(type) // + .or(Predicates.metaAnnotatedWith(type)); + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/Violations.java b/moduliths-core/src/main/java/org/moduliths/model/Violations.java new file mode 100644 index 00000000..55e478f4 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/Violations.java @@ -0,0 +1,122 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collector; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.util.Assert; + +/** + * Value type to gather and report architectural violations. + * + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor(staticName = "of", access = AccessLevel.PRIVATE) +public class Violations extends RuntimeException { + + private static final long serialVersionUID = 6863781504675034691L; + + public static Violations NONE = new Violations(Collections.emptyList()); + + private final List exceptions; + + /** + * A {@link Collector} to turn a {@link Stream} of {@link RuntimeException}s into a {@link Violations} instance. + * + * @return will never be {@literal null}. + */ + static Collector toViolations() { + return Collectors.collectingAndThen(Collectors.toList(), Violations::of); + } + + /* + * (non-Javadoc) + * @see java.lang.Throwable#getMessage() + */ + @Override + public String getMessage() { + + return exceptions.stream() // + .map(RuntimeException::getMessage) // + .collect(Collectors.joining("\n- ", "- ", "")); + } + + /** + * Returns whether there are violations available. + * + * @return + */ + public boolean hasViolations() { + return !exceptions.isEmpty(); + } + + /** + * Throws itself in case it's not an empty instance. + */ + public void throwIfPresent() { + + if (hasViolations()) { + throw this; + } + } + + /** + * Creates a new {@link Violations} with the given {@link RuntimeException} added to the current ones? + * + * @param exception must not be {@literal null}. + * @return + */ + Violations and(RuntimeException exception) { + + Assert.notNull(exception, "Exception must not be null!"); + + List newExceptions = new ArrayList<>(exceptions.size() + 1); + newExceptions.addAll(exceptions); + newExceptions.add(exception); + + return new Violations(newExceptions); + } + + Violations and(Violations other) { + + List newExceptions = new ArrayList<>(exceptions.size() + other.exceptions.size()); + newExceptions.addAll(exceptions); + newExceptions.addAll(other.exceptions); + + return new Violations(newExceptions); + } + + Violations and(String violation) { + return and(new ArchitecturalViolation(violation)); + } + + private static class ArchitecturalViolation extends RuntimeException { + + private static final long serialVersionUID = 3587887036508024142L; + + public ArchitecturalViolation(String message) { + super(message); + } + } +} diff --git a/moduliths-core/src/main/java/org/moduliths/model/package-info.java b/moduliths-core/src/main/java/org/moduliths/model/package-info.java new file mode 100644 index 00000000..3dd041c5 --- /dev/null +++ b/moduliths-core/src/main/java/org/moduliths/model/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package org.moduliths.model; diff --git a/moduliths-core/src/test/java/com/acme/withatbean/SampleConfiguration.java b/moduliths-core/src/test/java/com/acme/withatbean/SampleConfiguration.java new file mode 100644 index 00000000..6c0feb1d --- /dev/null +++ b/moduliths-core/src/test/java/com/acme/withatbean/SampleConfiguration.java @@ -0,0 +1,33 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.withatbean; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Oliver Drotbohm + */ +@Configuration +public class SampleConfiguration { + + @Bean + DataSource dataSource() { + return null; + } +} diff --git a/moduliths-core/src/test/java/com/acme/withatbean/TestEvents.java b/moduliths-core/src/test/java/com/acme/withatbean/TestEvents.java new file mode 100644 index 00000000..8052dcf3 --- /dev/null +++ b/moduliths-core/src/test/java/com/acme/withatbean/TestEvents.java @@ -0,0 +1,47 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.withatbean; + +/** + * @author Oliver Drotbohm + */ +public class TestEvents { + + /** + * Method calling a factory method. + */ + public void method() { + JMoleculesAnnotated.of(); + } + + /** + * Method calling a constructor. + */ + public void constructorCall() { + new JMoleculesAnnotated(); + } + + // jMolecules + + @org.jmolecules.event.annotation.DomainEvent + public static class JMoleculesAnnotated { + public static JMoleculesAnnotated of() { + return null; + } + } + + public static class JMoleculesImplementing implements org.jmolecules.event.types.DomainEvent {} +} diff --git a/moduliths-core/src/test/java/jmolecules/package-info.java b/moduliths-core/src/test/java/jmolecules/package-info.java new file mode 100644 index 00000000..618d6bc6 --- /dev/null +++ b/moduliths-core/src/test/java/jmolecules/package-info.java @@ -0,0 +1,2 @@ +@org.jmolecules.ddd.annotation.Module +package jmolecules; diff --git a/moduliths-core/src/test/java/org/moduliths/model/AnnotationModulithMetadataUnitTest.java b/moduliths-core/src/test/java/org/moduliths/model/AnnotationModulithMetadataUnitTest.java new file mode 100644 index 00000000..4ef9d0fc --- /dev/null +++ b/moduliths-core/src/test/java/org/moduliths/model/AnnotationModulithMetadataUnitTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static org.assertj.core.api.Assertions.*; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import org.junit.jupiter.api.Test; +import org.moduliths.Modulithic; + +/** + * Unit tests for {@link AnnotationModulithMetadata}. + * + * @author Oliver Drotbohm + */ +class AnnotationModulithMetadataUnitTest { + + @Test + void findsCustomizationsOnClass() { + + assertThat(AnnotationModulithMetadata.of(Sample.class)).hasValueSatisfying(it -> { + assertThat(it.useFullyQualifiedModuleNames()).isTrue(); + }); + } + + @Test + void findsCustomizationsOnClassForMetaAnnotationUsage() { + + assertThat(AnnotationModulithMetadata.of(MetaSample.class)).hasValueSatisfying(it -> { + assertThat(it.useFullyQualifiedModuleNames()).isTrue(); + }); + } + + @Modulithic(useFullyQualifiedModuleNames = true) + static class Sample {} + + @Intermediate + static class MetaSample {} + + @Retention(RetentionPolicy.RUNTIME) + @Modulithic(useFullyQualifiedModuleNames = true) + @interface Intermediate { + } +} diff --git a/moduliths-core/src/test/java/org/moduliths/model/ArchitecturallyEvidentTypeUnitTest.java b/moduliths-core/src/test/java/org/moduliths/model/ArchitecturallyEvidentTypeUnitTest.java new file mode 100644 index 00000000..52ba61a0 --- /dev/null +++ b/moduliths-core/src/test/java/org/moduliths/model/ArchitecturallyEvidentTypeUnitTest.java @@ -0,0 +1,258 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static org.assertj.core.api.Assertions.*; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +import javax.persistence.Entity; + +import org.jmolecules.event.annotation.DomainEventHandler; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.moduliths.model.ArchitecturallyEvidentType.SpringAwareArchitecturallyEvidentType; +import org.moduliths.model.ArchitecturallyEvidentType.SpringDataAwareArchitecturallyEvidentType; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.EventListener; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import com.tngtech.archunit.core.domain.JavaClass; + +/** + * Unit tests for {@link ArchitecturallyEvidentType}. + * + * @author Oliver Drotbohm + */ +class ArchitecturallyEvidentTypeUnitTest { + + Classes classes = TestUtils.getClasses(); + JavaClass self = classes.getRequiredClass(ArchitecturallyEvidentTypeUnitTest.class); + + @Test + void abbreviatesFullyQualifiedTypeName() { + + ArchitecturallyEvidentType type = ArchitecturallyEvidentType.of(self, classes); + + assertThat(type.getAbbreviatedFullName()).isEqualTo("o.m.m.ArchitecturallyEvidentTypeUnitTest"); + } + + @Test + void doesNotConsiderArbitraryTypeAStereotype() { + + ArchitecturallyEvidentType type = ArchitecturallyEvidentType.of(self, classes); + + assertThat(type.isEntity()).isFalse(); + assertThat(type.isAggregateRoot()).isFalse(); + assertThat(type.isRepository()).isFalse(); + } + + @Test + void detectsSpringAnnotatedRepositories() { + + ArchitecturallyEvidentType type = new SpringAwareArchitecturallyEvidentType( + classes.getRequiredClass(SpringRepository.class)); + + assertThat(type.isRepository()).isTrue(); + } + + @Test + void doesNotConsiderEntityAggregateRoot() { + + ArchitecturallyEvidentType type = new SpringAwareArchitecturallyEvidentType( + classes.getRequiredClass(SampleEntity.class)); + + assertThat(type.isEntity()).isTrue(); + assertThat(type.isAggregateRoot()).isFalse(); + } + + @Test + void considersEntityAnAggregateRootIfTheresARepositoryForIt() { + + Map, Boolean> parameters = new HashMap, Boolean>(); + parameters.put(SampleEntity.class, true); + parameters.put(OtherEntity.class, false); + parameters.put(NoEntity.class, false); + + parameters.entrySet().stream().forEach(it -> { + + JavaClass entity = classes.getRequiredClass(it.getKey()); + + assertThat(new SpringDataAwareArchitecturallyEvidentType(entity, classes).isAggregateRoot()) + .isEqualTo(it.getValue()); + }); + } + + @TestFactory + Stream considersJMoleculesEntity() { + + return DynamicTest.stream(getTypesFor(JMoleculesAnnotatedEntity.class, JMoleculesImplementingEntity.class), // + it -> String.format("%s is considered an entity", it.getType().getSimpleName()), // + it -> { + assertThat(it.isEntity()).isTrue(); + assertThat(it.isAggregateRoot()).isFalse(); + assertThat(it.isRepository()).isFalse(); + }); + } + + @TestFactory + Stream considersJMoleculesAggregateRoot() { + + return DynamicTest.stream( + getTypesFor(JMoleculesAnnotatedAggregateRoot.class, JMoleculesImplementingAggregateRoot.class), // + it -> String.format("%s is considered an entity, aggregate root but not a repository", + it.getType().getSimpleName()), // + it -> { + assertThat(it.isEntity()).isTrue(); + assertThat(it.isAggregateRoot()).isTrue(); + assertThat(it.isRepository()).isFalse(); + }); + } + + @TestFactory + Stream considersJMoleculesRepository() { + + return DynamicTest.stream(getTypesFor(JMoleculesAnnotatedRepository.class), // + it -> String.format("%s is considered a repository", it.getType().getSimpleName()), // + it -> { + assertThat(it.isEntity()).isFalse(); + assertThat(it.isAggregateRoot()).isFalse(); + assertThat(it.isRepository()).isTrue(); + }); + } + + @Test + void discoversEventsListenedToForEventListener() { + + JavaClass listenerType = classes.getRequiredClass(SomeEventListener.class); + + assertThat(ArchitecturallyEvidentType.of(listenerType, classes).getReferenceTypes()) // + .extracting(JavaClass::getFullName) // + .containsExactly(Object.class.getName(), String.class.getName()); + } + + @Test + void discoversImplementingEventListener() { + + JavaClass listenerType = classes.getRequiredClass(ImplementingEventListener.class); + + assertThat(ArchitecturallyEvidentType.of(listenerType, classes).getReferenceTypes()) // + .extracting(JavaClass::getFullName) // + .containsExactly(ApplicationReadyEvent.class.getName()); + } + + @Test + void discoversJMoleculesEventHandler() { + + JavaClass type = classes.getRequiredClass(JMoleculesEventListener.class); + + assertThat(ArchitecturallyEvidentType.of(type, classes).isEventListener()).isTrue(); + } + + @Test + void discoversJMoleculesRepository() { + + JavaClass type = classes.getRequiredClass(JMoleculesImplementingRepository.class); + + assertThat(ArchitecturallyEvidentType.of(type, classes).isRepository()).isTrue(); + } + + private Iterator getTypesFor(Class... types) { + + return Stream.of(types) // + .map(classes::getRequiredClass) // + .map(it -> ArchitecturallyEvidentType.of(it, classes)) // + .iterator(); + } + + // Spring + + @Repository + interface SpringRepository {} + + @Entity + class SampleEntity {} + + // Spring Data + + interface SampleRepository extends CrudRepository {} + + @Entity + class OtherEntity {} + + class NoEntity {} + + // jMolecules + + @org.jmolecules.ddd.annotation.Entity + class JMoleculesAnnotatedEntity {} + + @org.jmolecules.ddd.annotation.AggregateRoot + class JMoleculesAnnotatedAggregateRoot {} + + class JMoleculesImplementingIdentifier implements org.jmolecules.ddd.types.Identifier {} + + abstract class JMoleculesImplementingEntity + implements + org.jmolecules.ddd.types.Entity {} + + abstract class JMoleculesImplementingAggregateRoot + implements + org.jmolecules.ddd.types.AggregateRoot {} + + @org.jmolecules.ddd.annotation.Repository + class JMoleculesAnnotatedRepository {} + + interface JMoleculesEventListener { + + @DomainEventHandler + void on(Object event); + } + + interface JMoleculesImplementingRepository extends + org.jmolecules.ddd.types.Repository {} + + // Spring + + class SomeEventListener { + + @EventListener + void on(Object event) {} + + @EventListener + void on(String event) {} + + @EventListener + void onOther(Object event) {} + } + + class ImplementingEventListener implements ApplicationListener { + + /* + * (non-Javadoc) + * @see org.springframework.context.ApplicationListener#onApplicationEvent(org.springframework.context.ApplicationEvent) + */ + @Override + public void onApplicationEvent(ApplicationReadyEvent event) {} + } +} diff --git a/moduliths-core/src/test/java/org/moduliths/model/ModuleDependencyUnitTest.java b/moduliths-core/src/test/java/org/moduliths/model/ModuleDependencyUnitTest.java new file mode 100644 index 00000000..6d1df0f2 --- /dev/null +++ b/moduliths-core/src/test/java/org/moduliths/model/ModuleDependencyUnitTest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static org.assertj.core.api.Assertions.*; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.moduliths.model.Module.ModuleDependency; +import org.springframework.beans.factory.annotation.Autowired; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ClassFileImporter; + +/** + * Unit tests for {@link ModuleDependency}. + * + * @author Oliver Drotbohm + */ +class ModuleDependencyUnitTest { + + ClassFileImporter importer = new ClassFileImporter(); + + @Test + public void detectsInjectionDependencies() { + + assertThat(findDependencies(SubType.class)) // + .containsExactlyInAnyOrder(A.class, B.class, C.class, D.class, E.class, F.class); + } + + @Test + public void detectsDependencyFromAnnotatedConstructor() { + + assertThat(findDependencies(MultipleConstructors.class)) // + .containsExactlyInAnyOrder(B.class); + } + + @Test + public void detectsDependencyFromSingleUnannotatedConstructor() { + + assertThat(findDependencies(SingleConstructor.class)) // + .containsExactlyInAnyOrder(B.class); + } + + private Stream> findDependencies(Class type) { + + return ModuleDependency.fromType(importer.importClass(type)) // + .map(ModuleDependency::getTarget) // + .map(JavaClass::reflect); + } + + static class A {} + + static class B {} + + static class C {} + + static class D {} + + static class E {} + + static class F {} + + static class SomeComponent { + + @Autowired A a; + + @Autowired + void setD(D d) {} + } + + static class SubType extends SomeComponent { + + @Autowired E e; + + SubType(B b, C c) {} + + @Autowired + void setF(F f) {} + } + + static class MultipleConstructors { + + MultipleConstructors(A a) {} + + @Autowired + MultipleConstructors(B b) {} + } + + static class SingleConstructor { + SingleConstructor(B b) {} + } +} diff --git a/moduliths-core/src/test/java/org/moduliths/model/ModuleDetectionStrategyUnitTest.java b/moduliths-core/src/test/java/org/moduliths/model/ModuleDetectionStrategyUnitTest.java new file mode 100644 index 00000000..7e36bc35 --- /dev/null +++ b/moduliths-core/src/test/java/org/moduliths/model/ModuleDetectionStrategyUnitTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; + +/** + * Unit tests for {@link ModuleDetectionStrategy}. + * + * @author Oliver Drotbohm + */ +class ModuleDetectionStrategyUnitTest { + + @Test + void usesExplicitlyAnnotatedConstant() { + + assertThat(ModuleDetectionStrategy.explictlyAnnotated()) + .isEqualTo(ModuleDetectionStrategies.EXPLICITLY_ANNOTATED); + } + + @Test + void usesDirectSubPackages() { + + assertThat(ModuleDetectionStrategy.directSubPackage()) + .isEqualTo(ModuleDetectionStrategies.DIRECT_SUB_PACKAGES); + } + + @Test + void detectsJMoleculesAnnotatedModule() { + + JavaClasses classes = new ClassFileImporter() // + .withImportOption(new ImportOption.OnlyIncludeTests()) // + .importPackages("jmolecules"); + + JavaPackage javaPackage = JavaPackage.of(Classes.of(classes), "jmolecules"); + + assertThat(ModuleDetectionStrategy.explictlyAnnotated().getModuleBasePackages(javaPackage)) + .containsExactly(javaPackage); + } +} diff --git a/moduliths-core/src/test/java/org/moduliths/model/ModuleUnitTest.java b/moduliths-core/src/test/java/org/moduliths/model/ModuleUnitTest.java new file mode 100644 index 00000000..c8c2944e --- /dev/null +++ b/moduliths-core/src/test/java/org/moduliths/model/ModuleUnitTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2019-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; + +import com.acme.withatbean.TestEvents.JMoleculesAnnotated; +import com.acme.withatbean.TestEvents.JMoleculesImplementing; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; + +/** + * Unit tests for {@link Module}. + * + * @author Oliver Drotbohm + */ +@TestInstance(Lifecycle.PER_CLASS) +class ModuleUnitTest { + + ClassFileImporter importer = new ClassFileImporter(); + JavaClasses classes = importer.importPackages("com.acme.withatbean"); // + JavaPackage javaPackage = JavaPackage.of(Classes.of(classes), ""); + + Module module = new Module(javaPackage, false); + + @Test + public void considersExternalSpringBeans() { + + assertThat(module.getSpringBeans()) // + .flatExtracting(SpringBean::getFullyQualifiedTypeName) // + .contains(DataSource.class.getName()); + } + + @Test + void discoversPublishedEvents() { + + JavaClass jMoleculesAnnotated = classes.get(JMoleculesAnnotated.class); + JavaClass jMoleculesImplementing = classes.get(JMoleculesImplementing.class); + + List events = module.getPublishedEvents(); + + assertThat(events.stream().map(EventType::getType)) // + .containsExactlyInAnyOrder(jMoleculesAnnotated, jMoleculesImplementing); + assertThat(events.stream().filter(it -> it.getType().equals(jMoleculesAnnotated))) // + .element(0) // + .satisfies(it -> { + assertThat(it.getSources()).isNotEmpty(); + }); + } +} diff --git a/moduliths-core/src/test/java/org/moduliths/model/ModulithMetadataUnitTest.java b/moduliths-core/src/test/java/org/moduliths/model/ModulithMetadataUnitTest.java new file mode 100644 index 00000000..e19c010d --- /dev/null +++ b/moduliths-core/src/test/java/org/moduliths/model/ModulithMetadataUnitTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static org.assertj.core.api.Assertions.*; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.moduliths.Modulith; +import org.moduliths.Modulithic; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Unit tests for {@link ModulithMetadata}. + * + * @author Oliver Drotbohm + */ +class ModulithMetadataUnitTest { + + @Test + public void inspectsModulithAnnotation() throws Exception { + + Stream.of(ModulithAnnotated.class, ModuliticAnnotated.class) // + .map(ModulithMetadata::of) // + .forEach(it -> { + + assertThat(it.getAdditionalPackages()).containsExactly("com.acme.foo"); + assertThat(it.getSharedModuleNames()).containsExactly("shared.module"); + assertThat(it.getSystemName()).hasValue("systemName"); + assertThat(it.useFullyQualifiedModuleNames()).isTrue(); + }); + } + + @Test + public void usesDefaultsIfModulithAnnotationsAreMissing() { + + ModulithMetadata metadata = ModulithMetadata.of(SpringBootApplicationAnnotated.class); + + assertThat(metadata.getAdditionalPackages()).isEmpty(); + assertThat(metadata.getSharedModuleNames()).isEmpty(); + assertThat(metadata.getSystemName()).isEmpty(); + assertThat(metadata.useFullyQualifiedModuleNames()).isFalse(); + } + + @Test + public void rejectsTypeNotAnnotatedWithEitherModulithAnnotationOrSpringBootApplication() { + + assertThatExceptionOfType(IllegalArgumentException.class) // + .isThrownBy(() -> ModulithMetadata.of(Unannotated.class)) // + .withMessageContaining(Modulith.class.getSimpleName()) // + .withMessageContaining(Modulithic.class.getSimpleName()) // + .withMessageContaining(SpringBootApplication.class.getSimpleName()); + } + + @Modulith(additionalPackages = "com.acme.foo", // + sharedModules = "shared.module", // + systemName = "systemName", // + useFullyQualifiedModuleNames = true) + static class ModulithAnnotated {} + + @Modulithic(additionalPackages = "com.acme.foo", // + sharedModules = "shared.module", // + systemName = "systemName", // + useFullyQualifiedModuleNames = true) + static class ModuliticAnnotated {} + + @SpringBootApplication + static class SpringBootApplicationAnnotated {} + + static class Unannotated {} +} diff --git a/moduliths-core/src/test/java/org/moduliths/model/TestUtils.java b/moduliths-core/src/test/java/org/moduliths/model/TestUtils.java new file mode 100644 index 00000000..58542027 --- /dev/null +++ b/moduliths-core/src/test/java/org/moduliths/model/TestUtils.java @@ -0,0 +1,71 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.*; + +import org.jmolecules.ddd.annotation.AggregateRoot; +import org.springframework.data.repository.Repository; +import org.springframework.util.Assert; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.thirdparty.com.google.common.base.Supplier; +import com.tngtech.archunit.thirdparty.com.google.common.base.Suppliers; + +/** + * Utilities for testing. + * + * @author Oliver Drotbohm + */ +class TestUtils { + + private static Supplier imported = Suppliers.memoize(() -> new ClassFileImporter() // + .importPackagesOf(Modules.class, Repository.class, AggregateRoot.class)); + + private static DescribedPredicate IS_MODULE_TYPE = JavaClass.Predicates + .resideInAPackage(Modules.class.getPackage().getName()); + + private static Supplier classes = Suppliers.memoize(() -> Classes.of(imported.get()).that(IS_MODULE_TYPE)); + + /** + * Returns all {@link Classes} of this module. + * + * @return + */ + public static Classes getClasses() { + return classes.get(); + } + + public static JavaClasses getJavaClasses() { + return imported.get().that(IS_MODULE_TYPE); + } + + /** + * Returns all {@link Classes} in the package of the given type. + * + * @param packageType must not be {@literal null}. + * @return + */ + public static Classes getClasses(Class packageType) { + + Assert.notNull(packageType, "Package type must not be null!"); + + return getClasses().that(resideInAPackage(packageType.getPackage().getName())); + } +} diff --git a/moduliths-core/src/test/java/org/moduliths/model/ViolationsUnitTests.java b/moduliths-core/src/test/java/org/moduliths/model/ViolationsUnitTests.java new file mode 100644 index 00000000..294a64b7 --- /dev/null +++ b/moduliths-core/src/test/java/org/moduliths/model/ViolationsUnitTests.java @@ -0,0 +1,39 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link Violations}. + * + * @author Oliver Drotbohm + */ +class ViolationsUnitTests { + + @Test + void combinesExceptionMessages() { + + Violations violations = Violations.NONE // + .and(new IllegalArgumentException("First")) // + .and(new IllegalArgumentException("Second")); + + assertThat(violations.getMessage()) // + .isEqualTo("- First\n- Second"); + } +} diff --git a/moduliths-core/src/test/resources/application.properties b/moduliths-core/src/test/resources/application.properties new file mode 100644 index 00000000..b92adaf4 --- /dev/null +++ b/moduliths-core/src/test/resources/application.properties @@ -0,0 +1 @@ +spring.main.banner-mode=OFF diff --git a/moduliths-core/src/test/resources/logback.xml b/moduliths-core/src/test/resources/logback.xml new file mode 100644 index 00000000..faa852f5 --- /dev/null +++ b/moduliths-core/src/test/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + \ No newline at end of file diff --git a/moduliths-docs/pom.xml b/moduliths-docs/pom.xml new file mode 100644 index 00000000..84132c3e --- /dev/null +++ b/moduliths-docs/pom.xml @@ -0,0 +1,72 @@ + + 4.0.0 + + + org.moduliths + moduliths + 1.4.0-SNAPSHOT + ../pom.xml + + + Moduliths - Docs + moduliths-docs + + + org.moduliths.docs + 1.3.0 + + + + + + ${project.groupId} + moduliths-core + ${project.version} + + + + ${project.groupId} + moduliths-sample + ${project.version} + test + + + + com.structurizr + structurizr-core + 1.9.7 + + + + com.structurizr + structurizr-plantuml + 1.6.3 + + + + com.structurizr + structurizr-export + 1.2.0 + + + + com.jayway.jsonpath + json-path + + + + capital.scalable + spring-auto-restdocs-core + 2.0.8 + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + diff --git a/moduliths-docs/src/main/java/org/moduliths/docs/Asciidoctor.java b/moduliths-docs/src/main/java/org/moduliths/docs/Asciidoctor.java new file mode 100644 index 00000000..92310b24 --- /dev/null +++ b/moduliths-docs/src/main/java/org/moduliths/docs/Asciidoctor.java @@ -0,0 +1,358 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.docs; + +import static org.springframework.util.ClassUtils.*; + +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.moduliths.docs.ConfigurationProperties.ModuleProperty; +import org.moduliths.docs.Documenter.CanvasOptions; +import org.moduliths.docs.Documenter.CanvasOptions.Groupings; +import org.moduliths.model.ArchitecturallyEvidentType; +import org.moduliths.model.EventType; +import org.moduliths.model.FormatableJavaClass; +import org.moduliths.model.Module; +import org.moduliths.model.Modules; +import org.moduliths.model.Source; +import org.moduliths.model.SpringBean; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.core.domain.JavaModifier; + +/** + * @author Oliver Drotbohm + */ +class Asciidoctor { + + private static String PLACEHOLDER = "¯\\_(ツ)_/¯"; + private static final Pattern JAVADOC_CODE = Pattern.compile("\\{\\@(?>link|code|literal)\\s(.*)\\}"); + + private final Modules modules; + private final String javaDocBase; + private final Optional docSource; + + private Asciidoctor(Modules modules, String javaDocBase) { + + Assert.notNull(modules, "Modules must not be null!"); + Assert.hasText(javaDocBase, "Javadoc base must not be null or empty!"); + + this.javaDocBase = javaDocBase; + this.modules = modules; + this.docSource = Optional.of("capital.scalable.restdocs.javadoc.JavadocReaderImpl") + .filter(it -> ClassUtils.isPresent(it, Asciidoctor.class.getClassLoader())) + .map(__ -> new SpringAutoRestDocsDocumentationSource()) + .map(it -> new CodeReplacingDocumentationSource(it, this)); + } + + /** + * Creates a new {@link Asciidoctor} instance for the given {@link Modules} and Javadoc base URI. + * + * @param modules must not be {@literal null}. + * @param javadocBase can be {@literal null}. + * @return will never be {@literal null}. + */ + public static Asciidoctor withJavadocBase(Modules modules, @Nullable String javadocBase) { + return new Asciidoctor(modules, javadocBase == null ? PLACEHOLDER : javadocBase); + } + + /** + * Creates a new {@link Asciidoctor} instance for the given {@link Modules}. + * + * @param modules must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static Asciidoctor withoutJavadocBase(Modules modules) { + return new Asciidoctor(modules, PLACEHOLDER); + } + + /** + * Turns the given source string into inline code. + * + * @param source must not be {@literal null}. + * @return + */ + public String toInlineCode(String source) { + + String[] parts = source.split("#"); + + String type = parts[0]; + Optional methodSignature = parts.length == 2 ? Optional.of(parts[1]) : Optional.empty(); + + return modules.getModuleByType(type) + .flatMap(it -> it.getType(type)) + .map(it -> toOptionalLink(it, methodSignature)) + .orElseGet(() -> String.format("`%s`", type)); + } + + public String toInlineCode(JavaClass type) { + return toOptionalLink(type); + } + + public String toInlineCode(SpringBean bean) { + + String base = toInlineCode(bean.toArchitecturallyEvidentType()); + + List interfaces = bean.getInterfacesWithinModule(); + + if (interfaces.isEmpty()) { + return base; + } + + String interfacesAsString = interfaces.stream() // + .map(this::toInlineCode) // + .collect(Collectors.joining(", ")); + + return String.format("%s implementing %s", base, interfacesAsString); + } + + public String renderSpringBeans(CanvasOptions options, Module module) { + + StringBuilder builder = new StringBuilder(); + Groupings groupings = options.groupBeans(module); + + if (groupings.hasOnlyFallbackGroup()) { + return toBulletPoints(groupings.byGrouping(CanvasOptions.FALLBACK_GROUP)); + } + + groupings.forEach((grouping, beans) -> { + + if (beans.isEmpty()) { + return; + } + + if (builder.length() != 0) { + builder.append("\n\n"); + } + + builder.append("_").append(grouping.getName()).append("_"); + + if (grouping.getDescription() != null) { + builder.append(" -- ").append(grouping.getDescription()); + } + + builder.append("\n\n"); + builder.append(toBulletPoints(beans)); + + }); + + return builder.length() == 0 ? "None" : builder.toString(); + } + + public String renderEvents(Module module) { + + List events = module.getPublishedEvents(); + + if (events.isEmpty()) { + return "none"; + } + + StringBuilder builder = new StringBuilder(); + + for (EventType eventType : events) { + + builder.append("* ") + .append(toInlineCode(eventType.getType())); + + if (!eventType.hasSources()) { + builder.append("\n"); + } else { + builder.append(" created by:\n"); + } + + for (Source source : eventType.getSources()) { + + builder.append("** ") + .append(toInlineCode(source.toString(module))) + .append("\n"); + } + } + + return builder.toString(); + } + + public String renderConfigurationProperties(Module module, List properties) { + + if (properties.isEmpty()) { + return "none"; + } + + Stream stream = properties.stream() + .map(it -> { + + StringBuilder builder = new StringBuilder() + .append(toCode(it.getName())) + .append(" -- ") + .append(toInlineCode(it.getType())); + + String defaultValue = it.getDefaultValue(); + + if (defaultValue != null && StringUtils.hasText(defaultValue)) { + + builder = builder.append(", default ") + .append(toInlineCode(defaultValue)) + .append(""); + } + + String description = it.getDescription(); + + if (description != null && StringUtils.hasText(description)) { + builder = builder.append(". ") + .append(toAsciidoctor(description)); + } + + return builder.toString(); + }); + + return toBulletPoints(stream); + } + + private String toBulletPoints(List beans) { + return toBulletPoints(beans.stream().map(this::toInlineCode)); + } + + public String typesToBulletPoints(List types) { + return toBulletPoints(types.stream() // + .map(this::toOptionalLink)); + } + + private String toBulletPoints(Stream types) { + + return types// + .collect(Collectors.joining("\n* ", "* ", "")); + } + + public String toBulletPoint(String source) { + return String.format("* %s", source); + } + + private String toOptionalLink(JavaClass source) { + return toOptionalLink(source, Optional.empty()); + } + + private String toOptionalLink(JavaClass source, Optional methodSignature) { + + Module module = modules.getModuleByType(source).orElse(null); + String typeAndMethod = toCode( + toTypeAndMethod(FormatableJavaClass.of(source).getAbbreviatedFullName(module), methodSignature)); + + if (module == null + || !source.getModifiers().contains(JavaModifier.PUBLIC) + || !module.contains(source)) { + return typeAndMethod; + } + + String classPath = convertClassNameToResourcePath(source.getFullName()) // + .replace('$', '.'); + + return Optional.ofNullable(javaDocBase == PLACEHOLDER ? null : javaDocBase) // + .map(it -> it.concat("/").concat(classPath).concat(".html")) // + .map(it -> toLink(typeAndMethod, it)) // + .orElseGet(() -> typeAndMethod); + } + + private static String toTypeAndMethod(String type, Optional methodSignature) { + return methodSignature + .map(it -> type.concat("#").concat(it)) + .orElse(type); + } + + private String toInlineCode(ArchitecturallyEvidentType type) { + + if (type.isEventListener()) { + + if (!docSource.isPresent()) { + + Stream referenceTypes = type.getReferenceTypes(); + + return String.format("%s listening to %s", // + toInlineCode(type.getType()), // + toInlineCode(referenceTypes)); + } + + String header = String.format("%s listening to:\n", toInlineCode(type.getType())); + + return header + type.getReferenceMethods().map(it -> { + + JavaMethod method = it.getMethod(); + Assert.isTrue(method.getRawParameterTypes().size() > 0, + () -> String.format("Method %s must have at least one parameter!", method)); + + JavaClass parameterType = it.getMethod().getRawParameterTypes().get(0); + String isAsync = it.isAsync() ? "(async) " : ""; + + return docSource.flatMap(source -> source.getDocumentation(it.getMethod())) + .map(doc -> String.format("** %s %s-- %s", toInlineCode(parameterType), isAsync, doc)) + .orElseGet(() -> String.format("** %s %s", toInlineCode(parameterType), isAsync)); + + }).collect(Collectors.joining("\n")); + } + + return toInlineCode(type.getType()); + } + + private String toInlineCode(Stream types) { + + return types.map(this::toInlineCode) // + .collect(Collectors.joining(", ")); + } + + private static String toLink(String source, String href) { + return String.format("link:%s[%s]", href, source); + } + + private static String toCode(String source) { + return String.format("`%s`", source); + } + + public static String startTable(String tableSpec) { + return String.format("[%s]\n|===\n", tableSpec); + } + + public static String startOrEndTable() { + return "|===\n"; + } + + public static String writeTableRow(String... columns) { + + return Stream.of(columns) // + .collect(Collectors.joining("\n|", "|", "\n")); + } + + public String toAsciidoctor(String source) { + + Matcher matcher = JAVADOC_CODE.matcher(source); + + while (matcher.find()) { + + String type = matcher.group(1); + + source = source.replace(matcher.group(), toInlineCode(type)); + } + + return source; + } +} diff --git a/moduliths-docs/src/main/java/org/moduliths/docs/CodeReplacingDocumentationSource.java b/moduliths-docs/src/main/java/org/moduliths/docs/CodeReplacingDocumentationSource.java new file mode 100644 index 00000000..19749c67 --- /dev/null +++ b/moduliths-docs/src/main/java/org/moduliths/docs/CodeReplacingDocumentationSource.java @@ -0,0 +1,47 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.docs; + +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +import com.tngtech.archunit.core.domain.JavaMethod; + +/** + * A {@link DocumentationSource} that replaces {@literal {@code …}} or {@literal {@link …}} blocks into inline code + * references + * + * @author Oliver Drotbohm + * @since 1.1 + */ +@RequiredArgsConstructor +class CodeReplacingDocumentationSource implements DocumentationSource { + + private final DocumentationSource delegate; + private final Asciidoctor codeSource; + + /* + * (non-Javadoc) + * @see org.moduliths.docs.DocumentationSource#getDocumentation(com.tngtech.archunit.core.domain.JavaMethod) + */ + @Override + public Optional getDocumentation(JavaMethod method) { + + return delegate.getDocumentation(method) + .map(codeSource::toAsciidoctor); + } +} diff --git a/moduliths-docs/src/main/java/org/moduliths/docs/ConfigurationProperties.java b/moduliths-docs/src/main/java/org/moduliths/docs/ConfigurationProperties.java new file mode 100644 index 00000000..097846c7 --- /dev/null +++ b/moduliths-docs/src/main/java/org/moduliths/docs/ConfigurationProperties.java @@ -0,0 +1,174 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.docs; + +import lombok.Value; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.moduliths.docs.ConfigurationProperties.ConfigurationProperty; +import org.moduliths.model.Module; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import com.tngtech.archunit.core.domain.JavaType; + +/** + * Represents all {@link ConfigurationProperty} instances found for the current project. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +class ConfigurationProperties implements Iterable { + + private static final String METADATA_PATH = "classpath:META-INF/spring-configuration-metadata.json"; + private static final JsonPath PATH = JsonPath.compile("$.properties"); + + private final List properties; + + /** + * Creates a new {@link ConfigurationProperties} instance. + */ + ConfigurationProperties() { + + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + + try { + Resource[] resources = resolver.getResources(METADATA_PATH); + + this.properties = Arrays.stream(resources) + .flatMap(ConfigurationProperties::parseProperties) + .collect(Collectors.toList()); + + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns all {@link ModuleProperty} instances for the given {@link Module}. + * + * @param module must not be {@literal null}. + * @return + */ + public List getModuleProperties(Module module) { + + Assert.notNull(module, "Module must not be null!"); + + return properties.stream() + .flatMap(it -> getModuleProperty(module, it)) + .collect(Collectors.toList()); + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return properties.iterator(); + } + + private Stream getModuleProperty(Module module, + ConfigurationProperty property) { + + return module.getType(property.getSourceType()) + .map(it -> new ModuleProperty(property.getName(), property.getDescription(), property.getType(), it, + property.getDefaultValue())) + .map(Stream::of) + .orElseGet(Stream::empty); + } + + @SuppressWarnings("unchecked") + private static Stream parseProperties(Resource source) { + + if (!source.exists()) { + return Stream.empty(); + } + + try (InputStream stream = source.getInputStream()) { + + DocumentContext context = JsonPath.parse(stream); + List read = context.read(PATH, List.class); + + return read.stream() + .map(it -> (Map) it) + .flatMap(ConfigurationProperty::of); + + } catch (Exception o_O) { + return Stream.empty(); + } + } + + @Value + static class ConfigurationProperty { + + String name; + @Nullable String description; + String type, sourceType; + @Nullable String defaultValue; + + @SuppressWarnings("null") + static Stream of(Map source) { + + String sourceType = getAsString(source, "sourceType"); + + if (!StringUtils.hasText(sourceType)) { + return Stream.empty(); + } + + ConfigurationProperty property = new ConfigurationProperty(getAsString(source, "name"), + getAsString(source, "description"), + getAsString(source, "type"), + sourceType, + getAsString(source, "defaultValue")); + + return Stream.of(property); + } + + boolean hasSourceType() { + return StringUtils.hasText(sourceType); + } + + private static @Nullable String getAsString(Map source, String key) { + + Object value = source.get(key); + + return value == null ? null : value.toString(); + } + } + + @Value + static class ModuleProperty { + String name; + @Nullable String description; + String type; + JavaType sourceType; + @Nullable String defaultValue; + } +} diff --git a/moduliths-docs/src/main/java/org/moduliths/docs/DocumentationSource.java b/moduliths-docs/src/main/java/org/moduliths/docs/DocumentationSource.java new file mode 100644 index 00000000..a1bdf04c --- /dev/null +++ b/moduliths-docs/src/main/java/org/moduliths/docs/DocumentationSource.java @@ -0,0 +1,37 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.docs; + +import java.util.Optional; + +import com.tngtech.archunit.core.domain.JavaMethod; + +/** + * Interface to abstract different ways of looking up documentation for code abstractions. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +interface DocumentationSource { + + /** + * Returns the documentation to be used for the given {@link JavaMethod}. + * + * @param method must not be {@literal null}. + * @return will never be {@literal null}. + */ + Optional getDocumentation(JavaMethod method); +} diff --git a/moduliths-docs/src/main/java/org/moduliths/docs/Documenter.java b/moduliths-docs/src/main/java/org/moduliths/docs/Documenter.java new file mode 100644 index 00000000..fe7cd6bd --- /dev/null +++ b/moduliths-docs/src/main/java/org/moduliths/docs/Documenter.java @@ -0,0 +1,821 @@ +/* + * Copyright 2018-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.docs; + +import static org.moduliths.docs.Asciidoctor.*; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Value; +import lombok.With; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.Map.Entry; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.moduliths.model.Module; +import org.moduliths.model.Module.DependencyDepth; +import org.moduliths.model.Module.DependencyType; +import org.moduliths.model.Modules; +import org.moduliths.model.SpringBean; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import com.structurizr.Workspace; +import com.structurizr.io.Diagram; +import com.structurizr.io.plantuml.BasicPlantUMLWriter; +import com.structurizr.io.plantuml.C4PlantUMLExporter; +import com.structurizr.io.plantuml.PlantUMLWriter; +import com.structurizr.model.Component; +import com.structurizr.model.Container; +import com.structurizr.model.Element; +import com.structurizr.model.Model; +import com.structurizr.model.Relationship; +import com.structurizr.model.SoftwareSystem; +import com.structurizr.model.Tags; +import com.structurizr.view.ComponentView; +import com.structurizr.view.RelationshipView; +import com.structurizr.view.Shape; +import com.structurizr.view.Styles; +import com.structurizr.view.View; +import com.tngtech.archunit.core.domain.JavaClass; + +/** + * API to create documentation for {@link Modules}. + * + * @author Oliver Gierke + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Documenter { + + private static final Map DEPENDENCY_DESCRIPTIONS = new LinkedHashMap<>(); + + private static final String INVALID_FILE_NAME_PATTERN = "Configured file name pattern does not include a '%s' placeholder for the module name!"; + + static { + DEPENDENCY_DESCRIPTIONS.put(DependencyType.EVENT_LISTENER, "listens to"); + DEPENDENCY_DESCRIPTIONS.put(DependencyType.DEFAULT, "depends on"); + } + + private final @Getter Modules modules; + private final Workspace workspace; + private final Container container; + private final ConfigurationProperties properties; + private final String outputFolder; + + private Map components; + + /** + * Creates a new {@link Documenter} for the {@link Modules} created for the given modulith type. + * + * @param modulithType must not be {@literal null}. + */ + public Documenter(Class modulithType) { + this(Modules.of(modulithType)); + } + + /** + * Creates a new {@link Documenter} for the given {@link Modules} instance. + * + * @param modules must not be {@literal null}. + */ + public Documenter(Modules modules) { + this(modules, getDefaultOutputDirectory()); + } + + private Documenter(Modules modules, String outputFolder) { + + Assert.notNull(modules, "Modules must not be null!"); + Assert.hasText(outputFolder, "Output folder must not be null or empty!"); + + this.modules = modules; + this.outputFolder = outputFolder; + this.workspace = new Workspace("Modulith", ""); + + workspace.getViews().getConfiguration() + .getStyles() + .addElementStyle(Tags.COMPONENT) + .shape(Shape.Component); + + Model model = workspace.getModel(); + String systemName = modules.getSystemName().orElse("Modulith"); + + SoftwareSystem system = model.addSoftwareSystem(systemName, ""); + + this.container = system.addContainer("Application", "", ""); + this.properties = new ConfigurationProperties(); + } + + private Map getComponents(Options options) { + + if (components == null) { + + this.components = modules.stream() // + .collect(Collectors.toMap(Function.identity(), + it -> container.addComponent(options.getDefaultDisplayName().apply(it), "", "Module"))); + + this.components.forEach((key, value) -> addDependencies(key, value, options)); + } + + return components; + } + + /** + * Customize the output folder to write the generated files to. Defaults to {@value #DEFAULT_LOCATION}. + * + * @param outputFolder must not be {@literal null} or empty. + * @return + * @see #DEFAULT_LOCATION + */ + public Documenter withOutputFolder(String outputFolder) { + return new Documenter(modules, workspace, container, properties, outputFolder, components); + } + + /** + * Writes all available documentation: + *
    + *
  • The entire set of modules as overview component diagram.
  • + *
  • Individual component diagrams per module to include all upstream modules.
  • + *
  • The Module Canvas for each module.
  • + *
+ * + * @param options must not be {@literal null}, use {@link Options#defaults()} for default. + * @param canvasOptions must not be {@literal null}, use {@link CanvasOptions#defaults()} for default. + * @return the current instance, will never be {@literal null}. + * @throws IOException + * @since 1.1 + */ + public Documenter writeDocumentation(Options options, CanvasOptions canvasOptions) throws IOException { + + return writeModulesAsPlantUml(options) + .writeIndividualModulesAsPlantUml(options) // + .writeModuleCanvases(canvasOptions); + } + + /** + * Writes the PlantUML component diagram for all {@link Modules}. + * + * @param options must not be {@literal null}. + * @throws IOException + */ + public Documenter writeModulesAsPlantUml(Options options) throws IOException { + + Assert.notNull(options, "Options must not be null!"); + + Path file = recreateFile(options.getTargetFileName().orElse("components.uml")); + + try (Writer writer = new FileWriter(file.toFile())) { + writer.write(createPlantUml(options)); + } + + return this; + } + + /** + * Writes the component diagrams for all individual modules. + * + * @param options must not be {@literal null}. + * @return the current instance, will never be {@literal null}. + * @since 1.1 + */ + public Documenter writeIndividualModulesAsPlantUml(Options options) { + + modules.forEach(it -> writeModuleAsPlantUml(it, options)); + + return this; + } + + /** + * Writes the PlantUML component diagram for the given {@link Module}. + * + * @param module must not be {@literal null}. + * @return the current instance, will never be {@literal null}. + */ + public Documenter writeModuleAsPlantUml(Module module) { + + Assert.notNull(module, "Module must not be null!"); + + return writeModuleAsPlantUml(module, Options.defaults()); + } + + /** + * Writes the PlantUML component diagram for the given {@link Module} with the given rendering {@link Options}. + * + * @param module must not be {@literal null}. + * @param options must not be {@literal null}. + * @return the current instance, will never be {@literal null}. + */ + public Documenter writeModuleAsPlantUml(Module module, Options options) { + + Assert.notNull(module, "Module must not be null!"); + Assert.notNull(options, "Options must not be null!"); + + ComponentView view = createComponentView(options, module); + view.setTitle(options.getDefaultDisplayName().apply(module)); + + addComponentsToView(module, view, options); + + String fileNamePattern = options.getTargetFileName().orElse("module-%s.uml"); + + Assert.isTrue(fileNamePattern.contains("%s"), () -> String.format(INVALID_FILE_NAME_PATTERN, fileNamePattern)); + + return writeViewAsPlantUml(view, String.format(fileNamePattern, module.getName()), options); + } + + /** + * Writes all module canvases using {@link Options#defaults()}. + * + * @return the current instance, will never be {@literal null}. + */ + public Documenter writeModuleCanvases() { + return writeModuleCanvases(CanvasOptions.defaults()); + } + + public Documenter writeModuleCanvases(CanvasOptions options) { + + modules.forEach(module -> { + + String filename = String.format(options.getTargetFileName().orElse("module-%s.adoc"), module.getName()); + Path file = recreateFile(filename); + + try (FileWriter writer = new FileWriter(file.toFile())) { + + writer.write(toModuleCanvas(module, options)); + + } catch (IOException o_O) { + throw new RuntimeException(o_O); + } + }); + + return this; + } + + /** + * @param javadocBase + * @deprecated since 1.1, use {@link #writeModuleCanvases(CanvasOptions)} instead. + */ + @Deprecated + public Documenter writeModuleCanvases(String javadocBase) { + return writeModuleCanvases(CanvasOptions.defaults().withApiBase(javadocBase)); + } + + public String toModuleCanvas(Module module) { + return toModuleCanvas(module, CanvasOptions.defaults()); + } + + public String toModuleCanvas(Module module, String apiBase) { + return toModuleCanvas(module, CanvasOptions.defaults().withApiBase(apiBase)); + } + + public String toModuleCanvas(Module module, CanvasOptions options) { + + Asciidoctor asciidoctor = Asciidoctor.withJavadocBase(modules, options.getApiBase()); + Function, String> mapper = asciidoctor::typesToBulletPoints; + + StringBuilder builder = new StringBuilder(); + builder.append(startTable("%autowidth.stretch, cols=\"h,a\"")); + builder.append(writeTableRow("Base package", asciidoctor.toInlineCode(module.getBasePackage().getName()))); + builder.append(writeTableRow("Spring components", asciidoctor.renderSpringBeans(options, module))); + builder.append(addTableRow(module.getAggregateRoots(), "Aggregate roots", mapper)); + builder.append(writeTableRow("Published events", asciidoctor.renderEvents(module))); + builder.append(addTableRow(module.getEventsListenedTo(modules), "Events listened to", mapper)); + builder.append(writeTableRow("Properties", + asciidoctor.renderConfigurationProperties(module, properties.getModuleProperties(module)))); + builder.append(startOrEndTable()); + + return builder.toString(); + } + + private String addTableRow(List types, String header, Function, String> mapper) { + return types.isEmpty() ? "" : writeTableRow(header, mapper.apply(types)); + } + + public String toPlantUml() throws IOException { + return createPlantUml(Options.defaults()); + } + + private void addDependencies(Module module, Component component, Options options) { + + DEPENDENCY_DESCRIPTIONS.entrySet().stream().forEach(entry -> { + + module.getDependencies(modules, entry.getKey()).stream() // + .map(it -> getComponents(options).get(it)) // + // .filter(it -> !component.hasEfferentRelationshipWith(it)) // + .forEach(it -> { + + Relationship relationship = component.uses(it, entry.getValue()); + relationship.addTags(entry.getKey().toString()); + }); + }); + + module.getBootstrapDependencies(modules) // + .forEach(it -> { + Relationship relationship = component.uses(getComponents(options).get(it), "uses"); + relationship.addTags(DependencyType.USES_COMPONENT.toString()); + }); + } + + private void addComponentsToView(Module module, ComponentView view, Options options) { + + Supplier> bootstrapDependencies = () -> module.getBootstrapDependencies(modules, + options.getDependencyDepth()); + Supplier> otherDependencies = () -> options.getDependencyTypes() + .flatMap(it -> module.getDependencies(modules, it).stream()); + + Supplier> dependencies = () -> Stream.concat(bootstrapDependencies.get(), otherDependencies.get()); + + addComponentsToView(dependencies, view, options, it -> it.add(getComponents(options).get(module))); + } + + private void addComponentsToView(Supplier> modules, ComponentView view, Options options, + Consumer afterCleanup) { + + Styles styles = view.getViewSet().getConfiguration().getStyles(); + Map components = getComponents(options); + + modules.get() // + .distinct() + .filter(options.getExclusions().negate()) // + .map(it -> applyBackgroundColor(it, components, options, styles)) // + .filter(options.getComponentFilter()) // + .forEach(view::add); + + // view.getViewSet().getConfiguration().getStyles().findElementStyle(element).getBackground() + + // Remove filtered dependency types + DependencyType.allBut(options.getDependencyTypes()) // + .map(Object::toString) // + .forEach(it -> view.removeRelationshipsWithTag(it)); + + afterCleanup.accept(view); + + // Filter outgoing relationships of target-only modules + modules.get().filter(options.getTargetOnly()) // + .forEach(module -> { + + Component component = components.get(module); + + view.getRelationships().stream() // + .map(RelationshipView::getRelationship) // + .filter(it -> it.getSource().equals(component)) // + .forEach(it -> view.remove(it)); + }); + + // … as well as all elements left without a relationship + if (options.hideElementsWithoutRelationships()) { + view.removeElementsWithNoRelationships(); + } + + afterCleanup.accept(view); + + // Remove default relationships if more qualified ones exist + view.getRelationships().stream() // + .map(RelationshipView::getRelationship) // + .collect(Collectors.groupingBy(Connection::of)) // + .values().stream() // + .forEach(it -> potentiallyRemoveDefaultRelationship(view, it)); + } + + private void potentiallyRemoveDefaultRelationship(View view, Collection relationships) { + + if (relationships.size() <= 1) { + return; + } + + relationships.stream().filter(it -> it.getTagsAsSet().contains(DependencyType.DEFAULT.toString())) // + .findFirst().ifPresent(view::remove); + } + + private static Component applyBackgroundColor(Module module, Map components, Options options, + Styles styles) { + + Component component = components.get(module); + Function> selector = options.getColorSelector(); + + // Apply custom color if configured + selector.apply(module).ifPresent(color -> { + + String tag = module.getName() + "-" + color; + component.addTags(tag); + + // Add or update background color + styles.getElements().stream() + .filter(it -> it.getTag().equals(tag)) + .findFirst() + .orElseGet(() -> styles.addElementStyle(tag)) + .background(color); + }); + + return component; + } + + private Documenter writeViewAsPlantUml(ComponentView view, String filename, Options options) { + + Path file = recreateFile(filename); + + try (Writer writer = new FileWriter(file.toFile())) { + + writer.write(render(view, options)); + + return this; + + } catch (IOException o_O) { + throw new RuntimeException(o_O); + } + } + + private String render(ComponentView view, Options options) { + + switch (options.style) { + + case C4: + + C4PlantUMLExporter exporter = new C4PlantUMLExporter(); + Diagram diagram = exporter.export(view); + return diagram.getDefinition(); + + case UML: + default: + + Writer writer = new StringWriter(); + PlantUMLWriter umlWriter = new BasicPlantUMLWriter(); + umlWriter.addSkinParam("componentStyle", "uml1"); + umlWriter.write(view, writer); + + return writer.toString(); + } + } + + private String createPlantUml(Options options) throws IOException { + + ComponentView componentView = createComponentView(options); + componentView.setTitle(modules.getSystemName().orElse("Modules")); + + addComponentsToView(() -> modules.stream(), componentView, options, it -> {}); + + return render(componentView, options); + } + + private ComponentView createComponentView(Options options) { + return createComponentView(options, null); + } + + private ComponentView createComponentView(Options options, @Nullable Module module) { + + String prefix = module == null ? "modules-" : module.getName(); + + return workspace.getViews() // + .createComponentView(container, prefix + options.toString(), ""); + } + + private Path recreateFile(String name) { + + try { + + Files.createDirectories(Paths.get(outputFolder)); + Path filePath = Paths.get(outputFolder, name); + Files.deleteIfExists(filePath); + + return Files.createFile(filePath); + + } catch (IOException o_O) { + throw new RuntimeException(o_O); + } + } + + /** + * Returns the default output directory based on the detected build system. + * + * @return will never be {@literal null}. + */ + private static String getDefaultOutputDirectory() { + return (new File("pom.xml").exists() ? "target" : "build").concat("/moduliths-docs"); + } + + @Value + private static class Connection { + + Element source, target; + + public static Connection of(Relationship relationship) { + return new Connection(relationship.getSource(), relationship.getDestination()); + } + } + + /** + * Options to tweak the rendering of diagrams. + * + * @author Oliver Gierke + */ + @Getter(AccessLevel.PRIVATE) + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + public static class Options { + + private static Set ALL_TYPES = Arrays.stream(DependencyType.values()).collect(Collectors.toSet()); + + private final Set dependencyTypes; + + /** + * The {@link DependencyDepth} to define which other modules to be included in the diagram to be created. + */ + private final @With DependencyDepth dependencyDepth; + + /** + * A {@link Predicate} to define the which modules to exclude from the diagram to be created. + */ + private final @With Predicate exclusions; + + /** + * A {@link Predicate} to define which Structurizr {@link Component}s to be included in the diagram to be created. + */ + private final @With Predicate componentFilter; + + /** + * A {@link Predicate} to define which of the modules shall only be considered targets, i.e. all efferent + * relationships are going to be hidden from the rendered view. Modules that have no incoming relationships will + * entirely be removed from the view. + */ + private final @With Predicate targetOnly; + + /** + * The target file name to be used for the diagram to be created. For individual module diagrams this needs to + * include a {@code %s} placeholder for the module names. + */ + private final @With @Nullable String targetFileName; + + /** + * A callback to return a hex-encoded color per {@link Module}. + */ + private final @With Function> colorSelector; + + /** + * A callback to return a default display names for a given {@link Module}. Default implementation just forwards to + * {@link Module#getDisplayName()}. + */ + private final @With Function defaultDisplayName; + + /** + * Which style to render the diagram in. Defaults to {@value DiagramStyle#UML}. + */ + private final @With DiagramStyle style; + + /** + * Configuration setting to define whether modules that do not have a relationship to any other module shall be + * retained in the diagrams created. The default is {@value ElementsWithoutRelationships#HIDDEN}. See + * {@link Options#withExclusions(Predicate)} for a more fine-grained way of defining which modules to exclude in + * case you flip this to {@link ElementsWithoutRelationships#VISIBLE}. + * + * @see #withExclusions(Predicate) + */ + private final @With ElementsWithoutRelationships elementsWithoutRelationships; + + /** + * Creates a new default {@link Options} instance configured to use all dependency types, list immediate + * dependencies for individual module instances, not applying any kind of {@link Module} or {@link Component} + * filters and default file names. + * + * @return will never be {@literal null}. + */ + public static Options defaults() { + return new Options(ALL_TYPES, DependencyDepth.IMMEDIATE, it -> false, it -> true, it -> false, null, + __ -> Optional.empty(), it -> it.getDisplayName(), DiagramStyle.UML, ElementsWithoutRelationships.HIDDEN); + } + + /** + * Select the dependency types that are supposed to be included in the diagram to be created. + * + * @param types must not be {@literal null}. + * @return + */ + public Options withDependencyTypes(DependencyType... types) { + + Assert.notNull(types, "Dependency types must not be null!"); + + Set dependencyTypes = Arrays.stream(types).collect(Collectors.toSet()); + + return new Options(dependencyTypes, dependencyDepth, exclusions, componentFilter, targetOnly, targetFileName, + colorSelector, defaultDisplayName, style, elementsWithoutRelationships); + } + + private Optional getTargetFileName() { + return Optional.ofNullable(targetFileName); + } + + private Stream getDependencyTypes() { + return dependencyTypes.stream(); + } + + private boolean hideElementsWithoutRelationships() { + return elementsWithoutRelationships.equals(ElementsWithoutRelationships.HIDDEN); + } + + /** + * Different diagram styles. + * + * @author Oliver Drotbohm + */ + public enum DiagramStyle { + + /** + * A plain UML component diagram. + */ + UML, + + /** + * A C4 model component diagram. + * + * @see https://c4model.com/#ComponentDiagram + */ + C4; + } + + /** + * Configuration setting to define whether modules that do not have a relationship to any other module shall be + * retained in the diagrams created. The default is {@value ElementsWithoutRelationships#HIDDEN}. See + * {@link Options#withExclusions(Predicate)} for a more fine-grained way of defining which modules to exclude in + * case you flip this to {@link ElementsWithoutRelationships#VISIBLE}. + * + * @author Oliver Drotbohm + * @see Options#withExclusions(Predicate) + */ + public enum ElementsWithoutRelationships { + HIDDEN, VISIBLE; + } + } + + // Prefix required for javac 🤔 + @lombok.RequiredArgsConstructor(access = AccessLevel.PACKAGE) + public static class CanvasOptions { + + static final Grouping FALLBACK_GROUP = Grouping.of("Others", null, __ -> true); + + private final List groupers; + private final @With @Getter @Nullable String apiBase; + private final @With @Nullable String targetFileName; + + public static CanvasOptions defaults() { + + return withoutDefaultGroupings() + .groupingBy("Controllers", bean -> bean.toArchitecturallyEvidentType().isController()) // + .groupingBy("Services", bean -> bean.toArchitecturallyEvidentType().isService()) // + .groupingBy("Repositories", bean -> bean.toArchitecturallyEvidentType().isRepository()) // + .groupingBy("Event listeners", bean -> bean.toArchitecturallyEvidentType().isEventListener()) // + .groupingBy("Configuration properties", + bean -> bean.toArchitecturallyEvidentType().isConfigurationProperties()); + } + + public static CanvasOptions withoutDefaultGroupings() { + return new CanvasOptions(new ArrayList<>(), null, null); + } + + public CanvasOptions groupingBy(Grouping... groupings) { + + List result = new ArrayList<>(groupers); + result.addAll(Arrays.asList(groupings)); + + return new CanvasOptions(result, apiBase, targetFileName); + } + + public CanvasOptions groupingBy(String name, Predicate filter) { + return groupingBy(Grouping.of(name, null, filter)); + } + + Groupings groupBeans(Module module) { + + List sources = new ArrayList<>(groupers); + sources.add(FALLBACK_GROUP); + + MultiValueMap result = new LinkedMultiValueMap<>(); + List alreadyMapped = new ArrayList<>(); + + sources.forEach(it -> { + + List matchingBeans = getMatchingBeans(module, it, alreadyMapped); + + result.addAll(it, matchingBeans); + alreadyMapped.addAll(matchingBeans); + }); + + // Wipe entries without any beans + new HashSet<>(result.keySet()).forEach(key -> { + if (result.get(key).isEmpty()) { + result.remove(key); + } + }); + + return Groupings.of(result); + } + + private Optional getTargetFileName() { + return Optional.ofNullable(targetFileName); + } + + private static List getMatchingBeans(Module module, Grouping filter, List alreadyMapped) { + + return module.getSpringBeans().stream() + .filter(it -> !alreadyMapped.contains(it)) + .filter(filter::matches) + .collect(Collectors.toList()); + } + + @Value(staticConstructor = "of") + @Getter(AccessLevel.PACKAGE) + public static class Grouping { + + String name; + @Nullable String description; + Predicate predicate; + + public static Grouping of(String name) { + return new Grouping(name, null, __ -> false); + } + + public static Grouping of(String name, Predicate predicate) { + return new Grouping(name, null, predicate); + } + + public boolean matches(SpringBean candidate) { + return predicate.test(candidate); + } + + public static Predicate nameMatching(String pattern) { + return bean -> bean.getFullyQualifiedTypeName().matches(pattern); + } + + public static Predicate implementing(Class type) { + return bean -> bean.getType().isAssignableTo(type); + } + + public static Predicate subtypeOf(Class type) { + return implementing(type) // + .and(bean -> !bean.getType().isEquivalentTo(type)); + } + } + + @RequiredArgsConstructor(access = AccessLevel.PACKAGE, staticName = "of") + static class Groupings { + + private final MultiValueMap groupings; + + Set keySet() { + return groupings.keySet(); + } + + List byGrouping(Grouping grouping) { + return byFilter(grouping::equals); + } + + List byGroupName(String name) { + return byFilter(it -> it.getName().equals(name)); + } + + void forEach(BiConsumer> consumer) { + groupings.forEach(consumer); + } + + private List byFilter(Predicate filter) { + + return groupings.entrySet().stream() + .filter(it -> filter.test(it.getKey())) + .findFirst() + .map(Entry::getValue) + .orElseGet(Collections::emptyList); + } + + boolean hasOnlyFallbackGroup() { + return groupings.size() == 1 && groupings.get(FALLBACK_GROUP) != null; + } + } + } +} diff --git a/moduliths-docs/src/main/java/org/moduliths/docs/SpringAutoRestDocsDocumentationSource.java b/moduliths-docs/src/main/java/org/moduliths/docs/SpringAutoRestDocsDocumentationSource.java new file mode 100644 index 00000000..eedb7eaf --- /dev/null +++ b/moduliths-docs/src/main/java/org/moduliths/docs/SpringAutoRestDocsDocumentationSource.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.docs; + +import capital.scalable.restdocs.javadoc.JavadocReader; +import capital.scalable.restdocs.javadoc.JavadocReaderImpl; + +import java.util.Optional; + +import com.tngtech.archunit.core.domain.JavaMethod; + +/** + * A {@link DocumentationSource} that uses metadata generated by Spring Auto REST Docs' Javadoc Doclet. + * + * @author Oliver Drotbohm + * @since 1.1 + */ +class SpringAutoRestDocsDocumentationSource implements DocumentationSource { + + private final JavadocReader reader = JavadocReaderImpl.createWithSystemProperty(); + + /* + * (non-Javadoc) + * @see org.moduliths.docs.JavadocSource#getDocumentation(com.tngtech.archunit.core.domain.JavaMethod) + */ + @Override + public Optional getDocumentation(JavaMethod method) { + return Optional.of(reader.resolveMethodComment(method.getOwner().reflect(), method.getName())) + .filter(it -> !it.isEmpty()); + } +} diff --git a/moduliths-docs/src/main/java/org/moduliths/docs/package-info.java b/moduliths-docs/src/main/java/org/moduliths/docs/package-info.java new file mode 100644 index 00000000..2cf78dc1 --- /dev/null +++ b/moduliths-docs/src/main/java/org/moduliths/docs/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package org.moduliths.docs; diff --git a/moduliths-docs/src/test/java/org/moduliths/docs/AsciidoctorUnitTests.java b/moduliths-docs/src/test/java/org/moduliths/docs/AsciidoctorUnitTests.java new file mode 100644 index 00000000..e8d21982 --- /dev/null +++ b/moduliths-docs/src/test/java/org/moduliths/docs/AsciidoctorUnitTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.docs; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.moduliths.model.Modules; +import org.springframework.context.ApplicationContext; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ClassFileImporter; + +/** + * @author Oliver Drotbohm + */ +class AsciidoctorUnitTests { + + Asciidoctor asciidoctor = Asciidoctor.withJavadocBase(Modules.of("org.moduliths"), "{javadoc}"); + + @Test + void formatsInlineCode() { + assertThat(asciidoctor.toInlineCode("Foo")).isEqualTo("`Foo`"); + } + + @Test + void rendersLinkToMethodReference() { + + assertThat(asciidoctor.toInlineCode("Documenter#toModuleCanvas(Module, CanvasOptions)")) + .isEqualTo("link:{javadoc}/org/moduliths/docs/Documenter.html" + + "[`o.m.d.Documenter#toModuleCanvas(Module, CanvasOptions)`]"); + } + + @Test + void doesNotRenderLinkToMethodReferenceForNonPublicType() { + + assertThat(asciidoctor.toInlineCode("DocumentationSource#getDocumentation(JavaMethod)")) + .isEqualTo("`o.m.d.DocumentationSource#getDocumentation(JavaMethod)`"); + } + + @Test + void rendersInlineCodeForNonModuleTypeCorrectly() { + + JavaClass type = new ClassFileImporter().importClass(ApplicationContext.class); + + assertThatCode(() -> asciidoctor.toInlineCode(type)).doesNotThrowAnyException(); + } + + @Test + void cleansUpJavadocForConfigurationProperties() { + + ConfigurationProperties metadata = new ConfigurationProperties(); + + assertThat(metadata).containsExactly(new ConfigurationProperties.ConfigurationProperty("org.moduliths.sample.test", + "Some test property of type {@link java.lang.Boolean}.", "java.lang.Boolean", + "com.acme.myproject.stereotypes.Stereotypes$SomeConfigurationProperties", "false")); + } +} diff --git a/moduliths-docs/src/test/java/org/moduliths/docs/DocumenterTest.java b/moduliths-docs/src/test/java/org/moduliths/docs/DocumenterTest.java new file mode 100644 index 00000000..dfdf9062 --- /dev/null +++ b/moduliths-docs/src/test/java/org/moduliths/docs/DocumenterTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.docs; + +import static org.assertj.core.api.Assertions.*; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.moduliths.docs.Documenter.Options; +import org.moduliths.model.Module; +import org.moduliths.model.Module.DependencyType; + +import com.acme.myproject.Application; + +/** + * Unit tests for {@link Documenter}. + * + * @author Oliver Gierke + */ +class DocumenterTest { + + Documenter documenter = new Documenter(Application.class); + + @Test + void writesComponentStructureAsPlantUml() throws IOException { + documenter.toPlantUml(); + } + + @Test + void writesSingleModuleDocumentation() throws IOException { + + Module module = documenter.getModules().getModuleByName("moduleB") // + .orElseThrow(() -> new IllegalArgumentException()); + + documenter.writeModuleAsPlantUml(module, Options.defaults() // + .withColorSelector(it -> Optional.of("#ff0000")) // + .withDefaultDisplayName(it -> it.getDisplayName().toUpperCase())); + + Options options = Options.defaults() // + .withComponentFilter(component -> component.getRelationships().stream() + .anyMatch(it -> it.getTagsAsSet().contains(DependencyType.EVENT_LISTENER.toString()))); + + documenter.writeModulesAsPlantUml(options); + } + + @Test + void testName() { + + documenter.getModules().stream() // + .map(it -> documenter.toModuleCanvas(it)); + } + + @Test + void customizesOutputLocation() throws IOException { + + String customOutputFolder = "build/moduliths"; + Path path = Paths.get(customOutputFolder); + + try { + + documenter.withOutputFolder(customOutputFolder).writeModuleCanvases(); + + assertThat(Files.list(path)).isNotEmpty(); + assertThat(path).exists(); + + } finally { + + Files.walk(path) + .sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } +} diff --git a/moduliths-docs/src/test/resources/META-INF/spring-configuration-metadata.json b/moduliths-docs/src/test/resources/META-INF/spring-configuration-metadata.json new file mode 100644 index 00000000..2497677f --- /dev/null +++ b/moduliths-docs/src/test/resources/META-INF/spring-configuration-metadata.json @@ -0,0 +1,19 @@ +{ + "groups": [ + { + "name": "org.moduliths.sample", + "type": "com.acme.myproject.stereotypes.Stereotypes$SomeConfigurationProperties", + "sourceType": "com.acme.myproject.stereotypes.Stereotypes$SomeConfigurationProperties" + } + ], + "properties": [ + { + "name": "org.moduliths.sample.test", + "type": "java.lang.Boolean", + "description": "Some test property of type {@link java.lang.Boolean}.", + "sourceType": "com.acme.myproject.stereotypes.Stereotypes$SomeConfigurationProperties", + "defaultValue": false + } + ], + "hints": [] +} diff --git a/moduliths-docs/src/test/resources/logback.xml b/moduliths-docs/src/test/resources/logback.xml new file mode 100644 index 00000000..455fd805 --- /dev/null +++ b/moduliths-docs/src/test/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + diff --git a/moduliths-events/moduliths-events-core/pom.xml b/moduliths-events/moduliths-events-core/pom.xml new file mode 100644 index 00000000..7205e60c --- /dev/null +++ b/moduliths-events/moduliths-events-core/pom.xml @@ -0,0 +1,20 @@ + + 4.0.0 + + + org.moduliths + moduliths-events + 1.4.0-SNAPSHOT + ../pom.xml + + + Moduliths - Events - Core + + moduliths-events-core + + + org.moduliths.events.core + + + + \ No newline at end of file diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/CompletableEventPublication.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/CompletableEventPublication.java new file mode 100644 index 00000000..8dd1874a --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/CompletableEventPublication.java @@ -0,0 +1,61 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events; + +import java.time.Instant; +import java.util.Optional; + +/** + * An event publication that can be completed. + * + * @author Oliver Drotbohm + */ +public interface CompletableEventPublication extends EventPublication { + + /** + * Returns the completion date of the publication. + * + * @return will never be {@literal null}. + */ + Optional getCompletionDate(); + + /** + * Returns whether the publication o + * + * @return + */ + default boolean isPublicationCompleted() { + return getCompletionDate().isPresent(); + } + + /** + * Marks the event publication as completed. + * + * @return + */ + CompletableEventPublication markCompleted(); + + /** + * Creates a {@link CompletableEventPublication} for the given event an listener identifier. + * + * @param event must not be {@literal null}. + * @param id must not be {@literal null}. + * @return + */ + static CompletableEventPublication of(Object event, PublicationTargetIdentifier id) { + return DefaultEventPublication.of(event, id); + } +} diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/DefaultEventPublication.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/DefaultEventPublication.java new file mode 100644 index 00000000..0a56e5fa --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/DefaultEventPublication.java @@ -0,0 +1,54 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +import java.time.Instant; +import java.util.Optional; + +/** + * Default {@link CompletableEventPublication} implementation. + * + * @author Oliver Gierke + */ +@Getter +@RequiredArgsConstructor(staticName = "of") +@EqualsAndHashCode +@ToString +class DefaultEventPublication implements CompletableEventPublication { + + private final @NonNull Object event; + private final @NonNull PublicationTargetIdentifier targetIdentifier; + private final Instant publicationDate = Instant.now(); + + private Optional completionDate = Optional.empty(); + + /* + * (non-Javadoc) + * @see de.olivergierke.events.CompletableEventPublication#markCompleted() + */ + @Override + public CompletableEventPublication markCompleted() { + + this.completionDate = Optional.of(Instant.now()); + return this; + } +} diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/EventPublication.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/EventPublication.java new file mode 100644 index 00000000..6ed79700 --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/EventPublication.java @@ -0,0 +1,89 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events; + +import java.time.Instant; + +import org.springframework.context.ApplicationEvent; +import org.springframework.context.PayloadApplicationEvent; +import org.springframework.util.Assert; + +/** + * An event publication. + * + * @author Oliver Drotbohm + * @see CompletableEventPublication#of(Object, PublicationTargetIdentifier) + */ +public interface EventPublication extends Comparable { + + /** + * Returns the event that is published. + * + * @return + */ + Object getEvent(); + + /** + * Returns the event as Spring {@link ApplicationEvent}, effectively wrapping it into a + * {@link PayloadApplicationEvent} in case it's not one already. + * + * @return + */ + default ApplicationEvent getApplicationEvent() { + + Object event = getEvent(); + + return PayloadApplicationEvent.class.isInstance(event) // + ? PayloadApplicationEvent.class.cast(event) + : new PayloadApplicationEvent<>(this, event); + } + + /** + * Returns the time the event is published at. + * + * @return + */ + Instant getPublicationDate(); + + /** + * Returns the identifier of the target that the event is supposed to be published to. + * + * @return + */ + PublicationTargetIdentifier getTargetIdentifier(); + + /** + * Returns whether the publication is identified by the given {@link PublicationTargetIdentifier}. + * + * @param identifier must not be {@literal null}. + * @return + */ + default boolean isIdentifiedBy(PublicationTargetIdentifier identifier) { + + Assert.notNull(identifier, "Identifier must not be null!"); + + return this.getTargetIdentifier().equals(identifier); + } + + /* + * (non-Javadoc) + * @see java.lang.Comparable#compareTo(java.lang.Object) + */ + @Override + public default int compareTo(EventPublication that) { + return this.getPublicationDate().compareTo(that.getPublicationDate()); + } +} diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/EventPublicationRegistry.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/EventPublicationRegistry.java new file mode 100644 index 00000000..e884298c --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/EventPublicationRegistry.java @@ -0,0 +1,65 @@ +/* + * Copyright 2017-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events; + +import java.util.stream.Stream; + +import org.springframework.context.ApplicationListener; +import org.springframework.util.Assert; + +/** + * A registry to capture event publications to {@link ApplicationListener}s. Allows to register those publications, mark + * them as completed and lookup incomplete publications. + * + * @author Oliver Drotbohm + */ +public interface EventPublicationRegistry { + + /** + * Stores {@link EventPublication}s for the given event and {@link ApplicationListener}s. + * + * @param event must not be {@literal null}. + * @param listeners must not be {@literal null}. + */ + void store(Object event, Stream listeners); + + /** + * Marks the publication for the given event and {@link PublicationTargetIdentifier} as completed. + * + * @param event must not be {@literal null}. + * @param listener must not be {@literal null}. + */ + void markCompleted(Object event, PublicationTargetIdentifier listener); + + /** + * Marks the given {@link EventPublication} as completed. + * + * @param publication must not be {@literal null}. + */ + default void markCompleted(EventPublication publication) { + + Assert.notNull(publication, "Publication must not be null!"); + + markCompleted(publication.getEvent(), publication.getTargetIdentifier()); + } + + /** + * Returns all {@link EventPublication}s that have not been completed yet. + * + * @return will never be {@literal null}. + */ + Iterable findIncompletePublications(); +} diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/EventSerializer.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/EventSerializer.java new file mode 100644 index 00000000..669233f3 --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/EventSerializer.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events; + +/** + * @author Oliver Drotbohm + */ +public interface EventSerializer { + + /** + * Serializes the given event into a storable format. + * + * @param event must not be {@literal null}. + * @return + */ + Object serialize(Object event); + + /** + * Deserializes the event from the serialization format into an instance of the given type. + * + * @param serialized must not be {@literal null}. + * @param type must not be {@literal null}. + * @return + */ + Object deserialize(Object serialized, Class type); +} diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/PublicationTargetIdentifier.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/PublicationTargetIdentifier.java new file mode 100644 index 00000000..90a42e5e --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/PublicationTargetIdentifier.java @@ -0,0 +1,40 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events; + +import lombok.RequiredArgsConstructor; +import lombok.Value; + +/** + * Identifier for a publication target. + * + * @author Oliver Drotbohm + */ +@Value +@RequiredArgsConstructor(staticName = "of") +public class PublicationTargetIdentifier { + + String value; + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return value; + } +} diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EnablePersistentDomainEvents.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EnablePersistentDomainEvents.java new file mode 100644 index 00000000..554dc4c8 --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EnablePersistentDomainEvents.java @@ -0,0 +1,78 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.config; + +import static org.springframework.core.io.support.SpringFactoriesLoader.*; + +import lombok.RequiredArgsConstructor; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.ArrayList; +import java.util.List; + +import org.moduliths.events.config.EnablePersistentDomainEvents.PersistentDomainEventsImportSelector; +import org.springframework.context.ResourceLoaderAware; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.ImportSelector; +import org.springframework.core.io.ResourceLoader; +import org.springframework.core.type.AnnotationMetadata; + +/** + * @author Oliver Gierke + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@Import(PersistentDomainEventsImportSelector.class) +public @interface EnablePersistentDomainEvents { + + @RequiredArgsConstructor + static class PersistentDomainEventsImportSelector implements ImportSelector, ResourceLoaderAware { + + private ResourceLoader resourceLoader; + + /* + * (non-Javadoc) + * @see org.springframework.context.ResourceLoaderAware#setResourceLoader(org.springframework.core.io.ResourceLoader) + */ + @Override + public void setResourceLoader(ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } + + /* + * (non-Javadoc) + * @see org.springframework.context.annotation.ImportSelector#selectImports(org.springframework.core.type.AnnotationMetadata) + */ + @Override + public String[] selectImports(AnnotationMetadata importingClassMetadata) { + + List result = new ArrayList<>(); + + result.add(EventPublicationConfiguration.class.getName()); + result.addAll(loadFactoryNames(EventPublicationConfigurationExtension.class, resourceLoader.getClassLoader())); + result.addAll(loadFactoryNames(EventSerializationConfigurationExtension.class, resourceLoader.getClassLoader())); + + return result.toArray(new String[result.size()]); + } + } +} diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EventPublicationConfiguration.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EventPublicationConfiguration.java new file mode 100644 index 00000000..75dd56a4 --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EventPublicationConfiguration.java @@ -0,0 +1,44 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.config; + +import org.moduliths.events.EventPublicationRegistry; +import org.moduliths.events.support.CompletionRegisteringBeanPostProcessor; +import org.moduliths.events.support.MapEventPublicationRegistry; +import org.moduliths.events.support.PersistentApplicationEventMulticaster; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Oliver Drotbohm + */ +@Configuration(proxyBeanMethods = false) +class EventPublicationConfiguration { + + @Bean + PersistentApplicationEventMulticaster applicationEventMulticaster(ObjectProvider registry) { + + return new PersistentApplicationEventMulticaster( + () -> registry.getIfAvailable(() -> new MapEventPublicationRegistry())); + } + + @Bean + static CompletionRegisteringBeanPostProcessor bpp(ObjectFactory store) { + return new CompletionRegisteringBeanPostProcessor(() -> store.getObject()); + } +} diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EventPublicationConfigurationExtension.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EventPublicationConfigurationExtension.java new file mode 100644 index 00000000..e9caf697 --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EventPublicationConfigurationExtension.java @@ -0,0 +1,21 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.config; + +/** + * @author Oliver Gierke + */ +public interface EventPublicationConfigurationExtension {} diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EventSerializationConfigurationExtension.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EventSerializationConfigurationExtension.java new file mode 100644 index 00000000..9307dadb --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/EventSerializationConfigurationExtension.java @@ -0,0 +1,21 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.config; + +/** + * @author Oliver Gierke + */ +public interface EventSerializationConfigurationExtension {} diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/package-info.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/package-info.java new file mode 100644 index 00000000..853ab3f9 --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/config/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package org.moduliths.events.config; diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/package-info.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/package-info.java new file mode 100644 index 00000000..77cda59a --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package org.moduliths.events; diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/CompletionRegisteringBeanPostProcessor.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/CompletionRegisteringBeanPostProcessor.java new file mode 100644 index 00000000..3761705d --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/CompletionRegisteringBeanPostProcessor.java @@ -0,0 +1,224 @@ +/* + * Copyright 2017-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.support; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.Value; +import lombok.extern.slf4j.Slf4j; + +import java.lang.reflect.Method; +import java.util.function.Supplier; + +import org.aopalliance.aop.Advice; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.moduliths.events.EventPublicationRegistry; +import org.moduliths.events.PublicationTargetIdentifier; +import org.springframework.aop.framework.Advised; +import org.springframework.aop.framework.AopProxyUtils; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalApplicationListenerMethodAdapter; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.util.Assert; +import org.springframework.util.ConcurrentLruCache; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.ReflectionUtils.MethodCallback; + +/** + * {@link BeanPostProcessor} that will add a + * {@link org.moduliths.events.support.CompletionRegisteringBeanPostProcessor.ProxyCreatingMethodCallback.CompletionRegisteringMethodInterceptor} + * to the bean in case it carries a {@link TransactionalEventListener} annotation so that the successful invocation of + * those methods mark the event publication to those listeners as completed. + * + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor +public class CompletionRegisteringBeanPostProcessor implements BeanPostProcessor { + + private final Supplier registry; + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.config.BeanPostProcessor#postProcessAfterInitialization(java.lang.Object, java.lang.String) + */ + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + + ProxyCreatingMethodCallback callback = new ProxyCreatingMethodCallback(registry, beanName, bean, false); + + ReflectionUtils.doWithMethods(AopProxyUtils.ultimateTargetClass(bean), callback); + + return callback.methodFound ? callback.getBean() : bean; + + } + + /** + * Method callback to find a {@link TransactionalEventListener} method and creating a proxy including an + * {@link CompletionRegisteringBeanPostProcessor} for it or adding the latter to the already existing advisor chain. + * + * @author Oliver Drotbohm + */ + @AllArgsConstructor + private static class ProxyCreatingMethodCallback implements MethodCallback { + + private @NonNull final Supplier registry; + private @NonNull final String beanName; + private @NonNull @Getter Object bean; + private boolean methodFound; + + /* + * (non-Javadoc) + * @see org.springframework.util.ReflectionUtils.MethodCallback#doWith(java.lang.reflect.Method) + */ + @Override + public void doWith(Method method) throws IllegalArgumentException, IllegalAccessException { + + if (methodFound || !CompletionRegisteringMethodInterceptor.isCompletingMethod(method)) { + return; + } + + this.methodFound = true; + this.bean = createCompletionRegisteringProxy(bean, + new CompletionRegisteringMethodInterceptor(registry, beanName)); + } + + private static Object createCompletionRegisteringProxy(Object bean, Advice interceptor) { + + if (bean instanceof Advised) { + + Advised advised = (Advised) bean; + advised.addAdvice(advised.getAdvisors().length, interceptor); + + return bean; + } + + ProxyFactory factory = new ProxyFactory(bean); + factory.setProxyTargetClass(true); + factory.addAdvice(interceptor); + + return factory.getProxy(); + } + } + + /** + * {@link MethodInterceptor} to trigger the completion of an event publication after a transaction event listener + * method has been completed successfully. + * + * @author Oliver Drotbohm + */ + @Slf4j + @RequiredArgsConstructor + private static class CompletionRegisteringMethodInterceptor implements MethodInterceptor, Ordered { + + private static final ConcurrentLruCache COMPLETING_METHOD = new ConcurrentLruCache<>(100, + CompletionRegisteringMethodInterceptor::calculateIsCompletingMethod); + private static final ConcurrentLruCache ADAPTERS = new ConcurrentLruCache<>( + 100, CompletionRegisteringMethodInterceptor::createAdapter); + + private final @NonNull Supplier registry; + private final @NonNull String beanName; + + /* + * (non-Javadoc) + * @see org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept.MethodInvocation) + */ + @Override + public Object invoke(MethodInvocation invocation) throws Throwable { + + Object result = null; + Method method = invocation.getMethod(); + + try { + result = invocation.proceed(); + } catch (Exception o_O) { + + if (!isCompletingMethod(method)) { + throw o_O; + } + + if (LOG.isDebugEnabled()) { + LOG.debug("Invocation of listener {} failed. Leaving event publication uncompleted.", method, o_O); + } else { + LOG.info("Invocation of listener {} failed with message {}. Leaving event publication uncompleted.", + method, o_O.getMessage()); + } + + return result; + } + + if (!isCompletingMethod(method)) { + return result; + } + + // Mark publication complete if the method is a transactional event listener. + String adapterId = ADAPTERS.get(CacheKey.of(beanName, method)).getListenerId(); + PublicationTargetIdentifier identifier = PublicationTargetIdentifier.of(adapterId); + registry.get().markCompleted(invocation.getArguments()[0], identifier); + + return result; + } + + /* + * (non-Javadoc) + * @see org.springframework.core.Ordered#getOrder() + */ + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE - 10; + } + + /** + * Returns whether the given method is one that requires publication completion. + * + * @param method must not be {@literal null}. + * @return + */ + static boolean isCompletingMethod(Method method) { + + Assert.notNull(method, "Method must not be null!"); + + return COMPLETING_METHOD.get(method); + } + + private static boolean calculateIsCompletingMethod(Method method) { + + TransactionalEventListener annotation = AnnotatedElementUtils.getMergedAnnotation(method, + TransactionalEventListener.class); + + return annotation == null ? false : annotation.phase().equals(TransactionPhase.AFTER_COMMIT); + } + + private static TransactionalApplicationListenerMethodAdapter createAdapter(CacheKey key) { + return new TransactionalApplicationListenerMethodAdapter(key.beanName, key.method.getDeclaringClass(), + key.method); + } + } + + @Value(staticConstructor = "of") + static class CacheKey { + + String beanName; + Method method; + } +} diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/MapEventPublicationRegistry.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/MapEventPublicationRegistry.java new file mode 100644 index 00000000..0a3b1e37 --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/MapEventPublicationRegistry.java @@ -0,0 +1,79 @@ +/* + * Copyright 2017-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.support; + +import lombok.Value; + +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.moduliths.events.CompletableEventPublication; +import org.moduliths.events.EventPublication; +import org.moduliths.events.EventPublicationRegistry; +import org.moduliths.events.PublicationTargetIdentifier; + +/** + * Map based {@link EventPublicationRegistry}, for testing purposes only. + * + * @author Oliver Drotbohm + */ +public class MapEventPublicationRegistry implements EventPublicationRegistry { + + private final Map events = new HashMap<>(); + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublicationRegistry#findIncompletePublications() + */ + @Override + public Iterable findIncompletePublications() { + + return events.entrySet().stream()// + .filter(it -> !it.getValue().isPublicationCompleted())// + .map(it -> it.getValue())// + .collect(Collectors.toList()); + } + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublicationRegistry#store(java.lang.Object, java.util.Collection) + */ + @Override + public void store(Object event, Stream identifiers) { + + identifiers.forEach(id -> { + events.computeIfAbsent(Key.of(event, id), it -> CompletableEventPublication.of(event, id)); + }); + } + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublicationRegistry#markCompleted(java.lang.Object, org.springframework.events.PublicationTargetIdentifier) + */ + @Override + public void markCompleted(Object event, PublicationTargetIdentifier id) { + events.computeIfPresent(Key.of(event, id), (__, value) -> value.markCompleted()); + } + + @Value(staticConstructor = "of") + private static class Key { + + Object event; + PublicationTargetIdentifier identifier; + } +} diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/PersistentApplicationEventMulticaster.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/PersistentApplicationEventMulticaster.java new file mode 100644 index 00000000..4d2135f3 --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/PersistentApplicationEventMulticaster.java @@ -0,0 +1,247 @@ +/* + * Copyright 2017-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.support; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collection; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.moduliths.events.EventPublication; +import org.moduliths.events.EventPublicationRegistry; +import org.moduliths.events.PublicationTargetIdentifier; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.context.PayloadApplicationEvent; +import org.springframework.context.event.AbstractApplicationEventMulticaster; +import org.springframework.context.event.ApplicationEventMulticaster; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalApplicationListener; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.util.Assert; + +/** + * An {@link ApplicationEventMulticaster} to register {@link EventPublication}s in an {@link EventPublicationRegistry} + * so that potentially failing transactional event listeners can get re-invoked upon application restart or via a + * schedule. + *

+ * Republication is handled in {@link #afterSingletonsInstantiated()} inspecting the {@link EventPublicationRegistry} + * for incomplete publications and + * + * @author Oliver Drotbohm + * @see CompletionRegisteringBeanPostProcessor + */ +@Slf4j +@RequiredArgsConstructor +public class PersistentApplicationEventMulticaster extends AbstractApplicationEventMulticaster + implements SmartInitializingSingleton { + + private final @NonNull Supplier registry; + + /* + * (non-Javadoc) + * @see org.springframework.context.event.ApplicationEventMulticaster#multicastEvent(org.springframework.context.ApplicationEvent) + */ + @Override + public void multicastEvent(ApplicationEvent event) { + multicastEvent(event, ResolvableType.forInstance(event)); + } + + /* + * (non-Javadoc) + * @see org.springframework.context.event.ApplicationEventMulticaster#multicastEvent(org.springframework.context.ApplicationEvent, org.springframework.core.ResolvableType) + */ + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void multicastEvent(ApplicationEvent event, ResolvableType eventType) { + + ResolvableType type = eventType == null ? ResolvableType.forInstance(event) : eventType; + Collection> listeners = getApplicationListeners(event, type); + + if (listeners.isEmpty()) { + return; + } + + TransactionalEventListeners txListeners = new TransactionalEventListeners(listeners); + Object eventToPersist = getEventToPersist(event); + registry.get().store(eventToPersist, txListeners.stream() // + .map(TransactionalApplicationListener::getListenerId) // + .map(PublicationTargetIdentifier::of)); + + for (ApplicationListener listener : listeners) { + listener.onApplicationEvent(event); + } + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.SmartInitializingSingleton#afterSingletonsInstantiated() + */ + @Override + public void afterSingletonsInstantiated() { + + for (EventPublication publication : registry.get().findIncompletePublications()) { + invokeTargetListener(publication); + } + } + + private void invokeTargetListener(EventPublication publication) { + + TransactionalEventListeners listeners = new TransactionalEventListeners( + getApplicationListeners()); + + listeners.stream() // + .filter(it -> publication.isIdentifiedBy(PublicationTargetIdentifier.of(it.getListenerId()))) // + .findFirst() // + .map(it -> executeListenerWithCompletion(publication, it)) // + .orElseGet(() -> { + + LOG.debug("Listener {} not found!", publication.getTargetIdentifier()); + return null; + }); + } + + private ApplicationListener executeListenerWithCompletion(EventPublication publication, + TransactionalApplicationListener listener) { + + listener.processEvent(publication.getApplicationEvent()); + + return listener; + } + + private static Object getEventToPersist(ApplicationEvent event) { + + return PayloadApplicationEvent.class.isInstance(event) // + ? ((PayloadApplicationEvent) event).getPayload() // + : event; + } + + /** + * First-class collection to work with transactional event listeners, i.e. {@link ApplicationListener} instances that + * implement {@link TransactionalEventListenerMetadata}. + * + * @author Oliver Drotbohm + * @since 1.1 + * @see TransactionalEventListener + * @see TransactionalEventListenerMetadata + */ + static class TransactionalEventListeners { + + private final List> listeners; + + /** + * Creates a new {@link TransactionalEventListeners} instance by filtering all elements implementing + * {@link TransactionalEventListenerMetadata}. + * + * @param listeners must not be {@literal null}. + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + public TransactionalEventListeners(Collection> listeners) { + + Assert.notNull(listeners, "ApplicationListeners must not be null!"); + + this.listeners = (List) listeners.stream() + .filter(TransactionalApplicationListener.class::isInstance) + .map(TransactionalApplicationListener.class::cast) + .sorted(AnnotationAwareOrderComparator.INSTANCE) + .collect(Collectors.toList()); + } + + private TransactionalEventListeners( + List> listeners) { + this.listeners = listeners; + } + + /** + * Returns all {@link TransactionalEventListeners} for the given {@link TransactionPhase}. + * + * @param phase must not be {@literal null}. + * @return will never be {@literal null}. + */ + public TransactionalEventListeners forPhase(TransactionPhase phase) { + + Assert.notNull(phase, "TransactionPhase must not be null!"); + + List> collect = listeners.stream() + .filter(it -> it.getTransactionPhase().equals(phase)) + .collect(Collectors.toList()); + + return new TransactionalEventListeners(collect); + } + + /** + * Invokes the given {@link Consumer} for all transactional event listeners. + * + * @param callback must not be {@literal null}. + */ + public void forEach(Consumer> callback) { + + Assert.notNull(callback, "Callback must not be null!"); + + listeners.forEach(callback); + } + + /** + * Executes the given consumer only if there are actual listeners available. + * + * @param metadata must not be {@literal null}. + */ + public void ifPresent(Consumer>> metadata) { + + Assert.notNull(metadata, "Callback must not be null!"); + + if (!listeners.isEmpty()) { + metadata.accept(listeners.stream()); + } + } + + /** + * Returns all transactional event listeners. + * + * @return will never be {@literal null}. + */ + public Stream> stream() { + return listeners.stream(); + } + + /** + * Invokes the given {@link Consumer} for the listener with the given identifier. + * + * @param identifier must not be {@literal null} or empty. + * @param callback must not be {@literal null}. + */ + public void doWithListener(String identifier, + Consumer> callback) { + + Assert.hasText(identifier, "Identifier must not be null or empty!"); + Assert.notNull(callback, "Callback must not be null!"); + + listeners.stream() + .filter(it -> it.getListenerId().equals(identifier)) + .findFirst() + .ifPresent(callback); + } + } +} diff --git a/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/package-info.java b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/package-info.java new file mode 100644 index 00000000..8565f24b --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/java/org/moduliths/events/support/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package org.moduliths.events.support; diff --git a/moduliths-events/moduliths-events-core/src/main/resources/META-INF/spring.factories b/moduliths-events/moduliths-events-core/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..911ae0aa --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + org.moduliths.events.config.EventPublicationConfiguration diff --git a/moduliths-events/moduliths-events-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/moduliths-events/moduliths-events-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..3cc2586f --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.moduliths.events.config.EventPublicationConfiguration diff --git a/moduliths-events/moduliths-events-core/src/test/java/org/moduliths/events/CompletableEventPublicationUnitTest.java b/moduliths-events/moduliths-events-core/src/test/java/org/moduliths/events/CompletableEventPublicationUnitTest.java new file mode 100644 index 00000000..dbcd64b8 --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/test/java/org/moduliths/events/CompletableEventPublicationUnitTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2017-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * @author Oliver Gierke + */ +class CompletableEventPublicationUnitTest { + + @Test + void rejectsNullEvent() { + + assertThatExceptionOfType(IllegalArgumentException.class)// + .isThrownBy(() -> CompletableEventPublication.of(null, PublicationTargetIdentifier.of("foo")))// + .withMessageContaining("event"); + } + + @Test + void rejectsNullTargetIdentifier() { + + assertThatExceptionOfType(IllegalArgumentException.class)// + .isThrownBy(() -> CompletableEventPublication.of(new Object(), null))// + .withMessageContaining("targetIdentifier"); + } + + @Test + void publicationIsIncompleteByDefault() { + + CompletableEventPublication publication = CompletableEventPublication.of(new Object(), + PublicationTargetIdentifier.of("foo")); + + assertThat(publication.isPublicationCompleted()).isFalse(); + assertThat(publication.getCompletionDate()).isNotPresent(); + } + + @Test + void completionCapturesDate() { + + CompletableEventPublication publication = CompletableEventPublication + .of(new Object(), PublicationTargetIdentifier.of("foo")).markCompleted(); + + assertThat(publication.isPublicationCompleted()).isTrue(); + assertThat(publication.getCompletionDate()).isPresent(); + } +} diff --git a/moduliths-events/moduliths-events-core/src/test/java/org/moduliths/events/support/CompletionRegisteringBeanPostProcessorUnitTest.java b/moduliths-events/moduliths-events-core/src/test/java/org/moduliths/events/support/CompletionRegisteringBeanPostProcessorUnitTest.java new file mode 100644 index 00000000..1792605f --- /dev/null +++ b/moduliths-events/moduliths-events-core/src/test/java/org/moduliths/events/support/CompletionRegisteringBeanPostProcessorUnitTest.java @@ -0,0 +1,105 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.support; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.function.BiConsumer; + +import org.junit.jupiter.api.Test; +import org.moduliths.events.EventPublicationRegistry; +import org.springframework.aop.framework.Advised; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.context.event.EventListener; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Unit tests for {@link CompletionRegisteringBeanPostProcessor}. + * + * @author Oliver Drotbohm + */ +class CompletionRegisteringBeanPostProcessorUnitTest { + + EventPublicationRegistry registry = mock(EventPublicationRegistry.class); + BeanPostProcessor processor = new CompletionRegisteringBeanPostProcessor(() -> registry); + SomeEventListener bean = new SomeEventListener(); + + @Test + void doesNotProxyNonTransactionalEventListenerClass() { + + NoEventListener bean = new NoEventListener(); + + assertThat(bean).isSameAs(processor.postProcessBeforeInitialization(bean, "bean")); + } + + @Test + void triggersCompletionForAfterCommitEventListener() throws Exception { + assertCompletion(SomeEventListener::onAfterCommit); + } + + @Test + void doesNotTriggerCompletionForNonAfterCommitPhase() throws Exception { + assertNonCompletion(SomeEventListener::onAfterRollback); + } + + @Test + void doesNotTriggerCompletionForPlainEventListener() { + assertNonCompletion(SomeEventListener::simpleEventListener); + } + + @Test + void doesNotTriggerCompletionForNonEventListener() { + assertNonCompletion(SomeEventListener::nonEventListener); + } + + private void assertCompletion(BiConsumer consumer) { + assertCompletion(consumer, true); + } + + private void assertNonCompletion(BiConsumer consumer) { + assertCompletion(consumer, false); + } + + private void assertCompletion(BiConsumer consumer, boolean expected) { + + Object processed = processor.postProcessAfterInitialization(bean, "listener"); + + assertThat(processed).isInstanceOf(Advised.class); + assertThat(processed).isInstanceOfSatisfying(SomeEventListener.class, // + it -> consumer.accept(it, new Object())); + + verify(registry, times(expected ? 1 : 0)).markCompleted(any(), any()); + } + + static class SomeEventListener { + + @TransactionalEventListener + void onAfterCommit(Object event) {} + + @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) + void onAfterRollback(Object object) {} + + @EventListener + void simpleEventListener(Object object) {} + + void nonEventListener(Object object) {} + } + + static class NoEventListener {} +} diff --git a/moduliths-events/moduliths-events-jackson/pom.xml b/moduliths-events/moduliths-events-jackson/pom.xml new file mode 100644 index 00000000..06765e2f --- /dev/null +++ b/moduliths-events/moduliths-events-jackson/pom.xml @@ -0,0 +1,36 @@ + + 4.0.0 + + + org.moduliths + moduliths-events + 1.4.0-SNAPSHOT + ../pom.xml + + + Moduliths - Events - Jackson serializer + moduliths-events-jackson + + + org.moduliths.events.jackson + + + + + + + org.moduliths + moduliths-events-core + ${project.version} + + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + \ No newline at end of file diff --git a/moduliths-events/moduliths-events-jackson/src/main/java/org/moduliths/events/jackson/JacksonEventSerializationConfiguration.java b/moduliths-events/moduliths-events-jackson/src/main/java/org/moduliths/events/jackson/JacksonEventSerializationConfiguration.java new file mode 100644 index 00000000..9f9eaad2 --- /dev/null +++ b/moduliths-events/moduliths-events-jackson/src/main/java/org/moduliths/events/jackson/JacksonEventSerializationConfiguration.java @@ -0,0 +1,49 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jackson; + +import lombok.RequiredArgsConstructor; + +import org.moduliths.events.config.EventSerializationConfigurationExtension; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +/** + * @author Oliver Gierke + */ +@Configuration(proxyBeanMethods = false) +@RequiredArgsConstructor +class JacksonEventSerializationConfiguration implements EventSerializationConfigurationExtension { + + private final ObjectProvider mapper; + + @Bean + public JacksonEventSerializer jacksonEventSerializer() { + return new JacksonEventSerializer(() -> mapper.getIfAvailable(() -> defaultObjectMapper())); + } + + private static ObjectMapper defaultObjectMapper() { + + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); + + return mapper; + } +} diff --git a/moduliths-events/moduliths-events-jackson/src/main/java/org/moduliths/events/jackson/JacksonEventSerializer.java b/moduliths-events/moduliths-events-jackson/src/main/java/org/moduliths/events/jackson/JacksonEventSerializer.java new file mode 100644 index 00000000..6e8d0941 --- /dev/null +++ b/moduliths-events/moduliths-events-jackson/src/main/java/org/moduliths/events/jackson/JacksonEventSerializer.java @@ -0,0 +1,63 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jackson; + +import lombok.RequiredArgsConstructor; + +import java.io.IOException; +import java.util.function.Supplier; + +import org.moduliths.events.EventSerializer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * @author Oliver Gierke + */ +@RequiredArgsConstructor +class JacksonEventSerializer implements EventSerializer { + + private final Supplier mapper; + + /* + * (non-Javadoc) + * @see de.olivergierke.events.EventSerializer#serialize(java.lang.Object) + */ + @Override + public Object serialize(Object event) { + + try { + return mapper.get().writeValueAsString(event); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + /* + * (non-Javadoc) + * @see de.olivergierke.events.EventSerializer#deserialize(java.lang.Object, java.lang.Class) + */ + @Override + public Object deserialize(Object serialized, Class type) { + + try { + return mapper.get().readerFor(type).readValue(serialized.toString()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/moduliths-events/moduliths-events-jackson/src/main/java/org/moduliths/events/jackson/package-info.java b/moduliths-events/moduliths-events-jackson/src/main/java/org/moduliths/events/jackson/package-info.java new file mode 100644 index 00000000..b1dc742f --- /dev/null +++ b/moduliths-events/moduliths-events-jackson/src/main/java/org/moduliths/events/jackson/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package org.moduliths.events.jackson; diff --git a/moduliths-events/moduliths-events-jackson/src/main/resources/META-INF/spring.factories b/moduliths-events/moduliths-events-jackson/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..b5d0cfc8 --- /dev/null +++ b/moduliths-events/moduliths-events-jackson/src/main/resources/META-INF/spring.factories @@ -0,0 +1,5 @@ +org.moduliths.events.config.EventSerializationConfigurationExtension=\ + org.moduliths.events.jackson.JacksonEventSerializationConfiguration + +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + org.moduliths.events.jackson.JacksonEventSerializationConfiguration diff --git a/moduliths-events/moduliths-events-jackson/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/moduliths-events/moduliths-events-jackson/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..52da31f4 --- /dev/null +++ b/moduliths-events/moduliths-events-jackson/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.moduliths.events.jackson.JacksonEventSerializationConfiguration diff --git a/moduliths-events/moduliths-events-jpa-jakarta/pom.xml b/moduliths-events/moduliths-events-jpa-jakarta/pom.xml new file mode 100644 index 00000000..4b5053e2 --- /dev/null +++ b/moduliths-events/moduliths-events-jpa-jakarta/pom.xml @@ -0,0 +1,96 @@ + + 4.0.0 + + + org.moduliths + moduliths-events + 1.4.0-SNAPSHOT + ../pom.xml + + + Moduliths - Events - Jakarta JPA-based registry + moduliths-events-jpa-jakarta + + + 17 + org.moduliths.events.jpa.jakarta + 6.0.0-M1 + + + + + + ${project.groupId} + moduliths-events-core + ${project.version} + + + + + + jakarta.persistence + jakarta.persistence-api + 3.0.0 + + + + org.springframework.boot + spring-boot-autoconfigure + true + + + + + + jakarta.xml.bind + jakarta.xml.bind-api + 3.0.1 + test + + + + + org.hibernate.orm + hibernate-core + 6.0.0.CR2 + test + + + + jakarta.transaction + jakarta.transaction-api + 2.0.1-RC1 + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework + spring-orm + test + + + + org.hsqldb + hsqldb + test + + + + + + + spring-milestone + https://repo.spring.io/milestone + + false + + + + + \ No newline at end of file diff --git a/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublication.java b/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublication.java new file mode 100644 index 00000000..8880bff9 --- /dev/null +++ b/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublication.java @@ -0,0 +1,59 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +/** + * @author Oliver Gierke + */ +@Data +@Entity +@NoArgsConstructor(force = true) +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +class JpaEventPublication { + + private final @Id @Column(length = 16) UUID id; + private final Instant publicationDate; + private final String listenerId; + private final String serializedEvent; + private final Class eventType; + + private Instant completionDate; + + @Builder + static JpaEventPublication of(Instant publicationDate, String listenerId, Object serializedEvent, + Class eventType) { + return new JpaEventPublication(UUID.randomUUID(), publicationDate, listenerId, serializedEvent.toString(), + eventType); + } + + JpaEventPublication markCompleted() { + + this.completionDate = Instant.now(); + return this; + } +} diff --git a/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationAutoConfiguration.java b/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationAutoConfiguration.java new file mode 100644 index 00000000..6da461ea --- /dev/null +++ b/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationAutoConfiguration.java @@ -0,0 +1,26 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.context.annotation.Configuration; + +/** + * @author Oliver Drotbohm + */ +@Configuration(proxyBeanMethods = false) +@AutoConfigurationPackage +class JpaEventPublicationAutoConfiguration {} diff --git a/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationConfiguration.java b/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationConfiguration.java new file mode 100644 index 00000000..132b6456 --- /dev/null +++ b/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationConfiguration.java @@ -0,0 +1,44 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import lombok.RequiredArgsConstructor; + +import jakarta.persistence.EntityManager; + +import org.moduliths.events.EventSerializer; +import org.moduliths.events.config.EventPublicationConfigurationExtension; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Oliver Gierke + */ +@Configuration(proxyBeanMethods = false) +@RequiredArgsConstructor +class JpaEventPublicationConfiguration implements EventPublicationConfigurationExtension { + + @Bean + public JpaEventPublicationRegistry jpaEventPublicationRegistry(JpaEventPublicationRepository repository, + EventSerializer serializer) { + return new JpaEventPublicationRegistry(repository, serializer); + } + + @Bean + public JpaEventPublicationRepository jpaEventPublicationRepository(EntityManager em) { + return new JpaEventPublicationRepository(em); + } +} diff --git a/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRegistry.java b/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRegistry.java new file mode 100644 index 00000000..17966221 --- /dev/null +++ b/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRegistry.java @@ -0,0 +1,175 @@ +/* + * Copyright 2017-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import lombok.EqualsAndHashCode; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.moduliths.events.CompletableEventPublication; +import org.moduliths.events.EventPublication; +import org.moduliths.events.EventPublicationRegistry; +import org.moduliths.events.EventSerializer; +import org.moduliths.events.PublicationTargetIdentifier; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; + +/** + * JPA based {@link EventPublicationRegistry}. + * + * @author Oliver Gierke + */ +@Slf4j +@RequiredArgsConstructor +class JpaEventPublicationRegistry implements EventPublicationRegistry, DisposableBean { + + private final @NonNull JpaEventPublicationRepository events; + private final @NonNull EventSerializer serializer; + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublicationRegistry#store(java.lang.Object, java.util.Collection) + */ + @Override + public void store(Object event, Stream listeners) { + + listeners.map(it -> CompletableEventPublication.of(event, it)) // + .map(this::map) // + .forEach(it -> events.create(it)); + } + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublicationRegistry#findIncompletePublications() + */ + @Override + public Iterable findIncompletePublications() { + + List result = events.findByCompletionDateIsNull().stream() // + .map(it -> JpaEventPublicationAdapter.of(it, serializer)) // + .collect(Collectors.toList()); + + return result; + } + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublicationRegistry#markCompleted(java.lang.Object, org.springframework.events.ListenerId) + */ + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markCompleted(Object event, PublicationTargetIdentifier listener) { + + Assert.notNull(event, "Domain event must not be null!"); + Assert.notNull(listener, "Listener identifier must not be null!"); + + events.findBySerializedEventAndListenerId(serializer.serialize(event), listener.toString()) // + .map(JpaEventPublicationRegistry::LOGCompleted) // + .ifPresent(it -> events.update(it.markCompleted())); + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.DisposableBean#destroy() + */ + @Override + public void destroy() throws Exception { + + List publications = events.findByCompletionDateIsNull(); + + if (publications.isEmpty()) { + + LOG.info("No publications outstanding!"); + return; + } + + LOG.info("Shutting down with the following publications left unfinished:"); + + for (int i = 0; i < publications.size(); i++) { + + String prefix = (i + 1) == publications.size() ? "└─" : "├─"; + JpaEventPublication it = publications.get(i); + + LOG.info("{} {} - {} - {}", prefix, it.getId(), it.getEventType().getName(), it.getListenerId()); + } + } + + private JpaEventPublication map(EventPublication publication) { + + JpaEventPublication result = JpaEventPublication.builder() // + .eventType(publication.getEvent().getClass()) // + .publicationDate(publication.getPublicationDate()) // + .listenerId(publication.getTargetIdentifier().toString()) // + .serializedEvent(serializer.serialize(publication.getEvent()).toString()) // + .build(); + + LOG.debug("Registering publication of {} with id {} for {}.", // + result.getEventType(), result.getId(), result.getListenerId()); + + return result; + } + + private static JpaEventPublication LOGCompleted(JpaEventPublication publication) { + + LOG.debug("Marking publication of event {} with id {} to listener {} completed.", // + publication.getEventType(), publication.getId(), publication.getListenerId()); + + return publication; + } + + @EqualsAndHashCode + @RequiredArgsConstructor(staticName = "of") + static class JpaEventPublicationAdapter implements EventPublication { + + private final JpaEventPublication publication; + private final EventSerializer serializer; + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublication#getEvent() + */ + @Override + public Object getEvent() { + return serializer.deserialize(publication.getSerializedEvent(), publication.getEventType()); + } + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublication#getListenerId() + */ + @Override + public PublicationTargetIdentifier getTargetIdentifier() { + return PublicationTargetIdentifier.of(publication.getListenerId()); + } + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublication#getPublicationDate() + */ + @Override + public Instant getPublicationDate() { + return publication.getPublicationDate(); + } + } +} diff --git a/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRepository.java b/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRepository.java new file mode 100644 index 00000000..d4cd6083 --- /dev/null +++ b/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRepository.java @@ -0,0 +1,86 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Optional; + +import org.springframework.transaction.annotation.Transactional; + +/** + * Repository to store {@link JpaEventPublication}s. + * + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor +public class JpaEventPublicationRepository { + + private final EntityManager entityManager; + + @Transactional + JpaEventPublication create(JpaEventPublication publication) { + + entityManager.persist(publication); + return publication; + } + + @Transactional + JpaEventPublication update(JpaEventPublication publication) { + + entityManager.merge(publication); + entityManager.flush(); + + return publication; + } + + /** + * Returns all {@link JpaEventPublication} that have not been completed yet. + */ + @Transactional(readOnly = true) + List findByCompletionDateIsNull() { + + String query = "select p from JpaEventPublication p where p.completionDate is null"; + + return entityManager.createQuery(query, JpaEventPublication.class).getResultList(); + } + + /** + * Return the {@link JpaEventPublication} for the given serialized event and listener identifier. + * + * @param event must not be {@literal null}. + * @param listenerId must not be {@literal null}. + * @return + */ + @Transactional(readOnly = true) + Optional findBySerializedEventAndListenerId(Object event, String listenerId) { + + String query = "select p from JpaEventPublication p where p.serializedEvent = ?1 and p.listenerId = ?2"; + + TypedQuery typedQuery = entityManager.createQuery(query, JpaEventPublication.class) + .setParameter(1, event) + .setParameter(2, listenerId); + + try { + return Optional.of(typedQuery.getSingleResult()); + } catch (Exception o_O) { + return Optional.empty(); + } + } +} diff --git a/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/package-info.java b/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/package-info.java new file mode 100644 index 00000000..a48c9cea --- /dev/null +++ b/moduliths-events/moduliths-events-jpa-jakarta/src/main/java/org/moduliths/events/jpa/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package org.moduliths.events.jpa; diff --git a/moduliths-events/moduliths-events-jpa-jakarta/src/main/resources/META-INF/spring-devtools.properties b/moduliths-events/moduliths-events-jpa-jakarta/src/main/resources/META-INF/spring-devtools.properties new file mode 100644 index 00000000..aecea6f4 --- /dev/null +++ b/moduliths-events/moduliths-events-jpa-jakarta/src/main/resources/META-INF/spring-devtools.properties @@ -0,0 +1 @@ +restart.include.moduliths-events:/moduliths-events-jpa-[\\w-\\.]+\.jar diff --git a/moduliths-events/moduliths-events-jpa-jakarta/src/main/resources/META-INF/spring.factories b/moduliths-events/moduliths-events-jpa-jakarta/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..118209eb --- /dev/null +++ b/moduliths-events/moduliths-events-jpa-jakarta/src/main/resources/META-INF/spring.factories @@ -0,0 +1,6 @@ +org.moduliths.events.config.EventPublicationConfigurationExtension=\ + org.moduliths.events.jpa.JpaEventPublicationConfiguration + +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + org.moduliths.events.jpa.JpaEventPublicationAutoConfiguration,\ + org.moduliths.events.jpa.JpaEventPublicationConfiguration diff --git a/moduliths-events/moduliths-events-jpa-jakarta/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.factories b/moduliths-events/moduliths-events-jpa-jakarta/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.factories new file mode 100644 index 00000000..3346c654 --- /dev/null +++ b/moduliths-events/moduliths-events-jpa-jakarta/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.factories @@ -0,0 +1,2 @@ +org.moduliths.events.jpa.JpaEventPublicationAutoConfiguration +org.moduliths.events.jpa.JpaEventPublicationConfiguration diff --git a/moduliths-events/moduliths-events-jpa-jakarta/src/test/java/org/moduliths/events/jpa/JpaEventPublicationConfigurationIntegrationTests.java b/moduliths-events/moduliths-events-jpa-jakarta/src/test/java/org/moduliths/events/jpa/JpaEventPublicationConfigurationIntegrationTests.java new file mode 100644 index 00000000..c6141c6b --- /dev/null +++ b/moduliths-events/moduliths-events-jpa-jakarta/src/test/java/org/moduliths/events/jpa/JpaEventPublicationConfigurationIntegrationTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import lombok.RequiredArgsConstructor; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.moduliths.events.EventSerializer; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestConstructor; +import org.springframework.test.context.TestConstructor.AutowireMode; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * @author Oliver Drotbohm + */ +@ExtendWith(SpringExtension.class) +@TestConstructor(autowireMode = AutowireMode.ALL) +@RequiredArgsConstructor +public class JpaEventPublicationConfigurationIntegrationTests { + + private final ApplicationContext context; + + @Configuration + @Import(JpaEventPublicationConfiguration.class) + static class TestConfig { + + @Bean + EventSerializer eventSerializer() { + return mock(EventSerializer.class); + } + + @Bean + EntityManager entityManager() { + + EntityManager em = mock(EntityManager.class); + + // Mock API for query executed at bootstrap time + TypedQuery query = mock(TypedQuery.class); + doReturn(query).when(em).createQuery(any(String.class), any()); + + return em; + } + } + + @Test + void bootstrapsApplicationComponents() { + + assertThat(context.getBean(JpaEventPublicationRegistry.class)).isNotNull(); + assertThat(context.getBean(JpaEventPublicationRepository.class)).isNotNull(); + } +} diff --git a/moduliths-events/moduliths-events-jpa-jakarta/src/test/java/org/moduliths/events/jpa/JpaEventPublicationRepositoryIntegrationTests.java b/moduliths-events/moduliths-events-jpa-jakarta/src/test/java/org/moduliths/events/jpa/JpaEventPublicationRepositoryIntegrationTests.java new file mode 100644 index 00000000..a5e513cc --- /dev/null +++ b/moduliths-events/moduliths-events-jpa-jakarta/src/test/java/org/moduliths/events/jpa/JpaEventPublicationRepositoryIntegrationTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; + +import java.time.Instant; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.moduliths.events.EventSerializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.SharedEntityManagerCreator; +import org.springframework.orm.jpa.vendor.AbstractJpaVendorAdapter; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.test.context.TestConstructor; +import org.springframework.test.context.TestConstructor.AutowireMode; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author Oliver Drotbohm + */ +@ExtendWith(SpringExtension.class) +@TestConstructor(autowireMode = AutowireMode.ALL) +@Transactional +@RequiredArgsConstructor +class JpaEventPublicationRepositoryIntegrationTests { + + @Configuration + @Import(JpaEventPublicationConfiguration.class) + static class TestConfig { + + @Bean + EventSerializer eventSerializer() { + return mock(EventSerializer.class); + } + + // Database + + @Bean + EmbeddedDatabase hsqlDatabase() { + return new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.HSQL).build(); + } + + // JPA + + @Bean + LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) { + + AbstractJpaVendorAdapter vendor = new HibernateJpaVendorAdapter(); + vendor.setGenerateDdl(true); + + LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean(); + factory.setJpaVendorAdapter(vendor); + factory.setDataSource(dataSource); + factory.setPackagesToScan(getClass().getPackage().getName()); + + return factory; + } + + @Bean + EntityManager entityManager(EntityManagerFactory factory) { + return SharedEntityManagerCreator.createSharedEntityManager(factory); + } + + @Bean + JpaTransactionManager transactionManager(EntityManagerFactory factory) { + return new JpaTransactionManager(factory); + } + } + + private final JpaEventPublicationRepository repository; + + @Test + void persistsJpaEventPublication() { + + JpaEventPublication publication = JpaEventPublication.of(Instant.now(), "listener", "", Object.class); + + // Store publication + repository.create(publication); + + assertThat(repository.findByCompletionDateIsNull()).containsExactly(publication); + assertThat(repository.findBySerializedEventAndListenerId("", "listener")).isPresent(); + + // Complete publication + repository.update(publication.markCompleted()); + + assertThat(repository.findByCompletionDateIsNull()).isEmpty(); + } +} diff --git a/moduliths-events/moduliths-events-jpa-jakarta/src/test/resources/logback.xml b/moduliths-events/moduliths-events-jpa-jakarta/src/test/resources/logback.xml new file mode 100644 index 00000000..bd79b4ab --- /dev/null +++ b/moduliths-events/moduliths-events-jpa-jakarta/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + + + diff --git a/moduliths-events/moduliths-events-jpa/pom.xml b/moduliths-events/moduliths-events-jpa/pom.xml new file mode 100644 index 00000000..4f014c49 --- /dev/null +++ b/moduliths-events/moduliths-events-jpa/pom.xml @@ -0,0 +1,67 @@ + + 4.0.0 + + + org.moduliths + moduliths-events + 1.4.0-SNAPSHOT + ../pom.xml + + + Moduliths - Events - JPA-based registry + moduliths-events-jpa + + + org.moduliths.events.jpa + + + + + + ${project.groupId} + moduliths-events-core + ${project.version} + + + + + + javax.persistence + javax.persistence-api + + + + org.springframework.boot + spring-boot-autoconfigure + true + + + + + + org.hibernate + hibernate-core + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework + spring-orm + test + + + + org.hsqldb + hsqldb + test + + + + + \ No newline at end of file diff --git a/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublication.java b/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublication.java new file mode 100644 index 00000000..785dae9b --- /dev/null +++ b/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublication.java @@ -0,0 +1,60 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; + +/** + * @author Oliver Gierke + */ +@Data +@Entity +@NoArgsConstructor(force = true) +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +class JpaEventPublication { + + private final @Id @Column(length = 16) UUID id; + private final Instant publicationDate; + private final String listenerId; + private final String serializedEvent; + private final Class eventType; + + private Instant completionDate; + + @Builder + static JpaEventPublication of(Instant publicationDate, String listenerId, Object serializedEvent, + Class eventType) { + return new JpaEventPublication(UUID.randomUUID(), publicationDate, listenerId, serializedEvent.toString(), + eventType); + } + + JpaEventPublication markCompleted() { + + this.completionDate = Instant.now(); + return this; + } +} diff --git a/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationAutoConfiguration.java b/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationAutoConfiguration.java new file mode 100644 index 00000000..0d871487 --- /dev/null +++ b/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationAutoConfiguration.java @@ -0,0 +1,28 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.context.annotation.Configuration; + +/** + * @author Oliver Drotbohm + */ +@Configuration(proxyBeanMethods = false) +@AutoConfigurationPackage +class JpaEventPublicationAutoConfiguration { + +} diff --git a/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationConfiguration.java b/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationConfiguration.java new file mode 100644 index 00000000..65875ba2 --- /dev/null +++ b/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationConfiguration.java @@ -0,0 +1,44 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import lombok.RequiredArgsConstructor; + +import javax.persistence.EntityManager; + +import org.moduliths.events.EventSerializer; +import org.moduliths.events.config.EventPublicationConfigurationExtension; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Oliver Gierke + */ +@Configuration(proxyBeanMethods = false) +@RequiredArgsConstructor +class JpaEventPublicationConfiguration implements EventPublicationConfigurationExtension { + + @Bean + public JpaEventPublicationRegistry jpaEventPublicationRegistry(JpaEventPublicationRepository repository, + EventSerializer serializer) { + return new JpaEventPublicationRegistry(repository, serializer); + } + + @Bean + public JpaEventPublicationRepository jpaEventPublicationRepository(EntityManager em) { + return new JpaEventPublicationRepository(em); + } +} diff --git a/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRegistry.java b/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRegistry.java new file mode 100644 index 00000000..dee0bceb --- /dev/null +++ b/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRegistry.java @@ -0,0 +1,182 @@ +/* + * Copyright 2017-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import lombok.EqualsAndHashCode; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.moduliths.events.CompletableEventPublication; +import org.moduliths.events.EventPublication; +import org.moduliths.events.EventPublicationRegistry; +import org.moduliths.events.EventSerializer; +import org.moduliths.events.PublicationTargetIdentifier; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; + +/** + * JPA based {@link EventPublicationRegistry}. + * + * @author Oliver Gierke + */ +@Slf4j +@RequiredArgsConstructor +class JpaEventPublicationRegistry implements EventPublicationRegistry, DisposableBean { + + private final @NonNull JpaEventPublicationRepository events; + private final @NonNull EventSerializer serializer; + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublicationRegistry#store(java.lang.Object, java.util.Collection) + */ + @Override + public void store(Object event, Stream listeners) { + + listeners.map(it -> CompletableEventPublication.of(event, it)) // + .map(this::map) // + .forEach(it -> events.create(it)); + } + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublicationRegistry#findIncompletePublications() + */ + @Override + public Iterable findIncompletePublications() { + + List result = events.findByCompletionDateIsNull().stream() // + .map(it -> JpaEventPublicationAdapter.of(it, serializer)) // + .collect(Collectors.toList()); + + return result; + } + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublicationRegistry#markCompleted(java.lang.Object, org.springframework.events.ListenerId) + */ + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markCompleted(Object event, PublicationTargetIdentifier listener) { + + Assert.notNull(event, "Domain event must not be null!"); + Assert.notNull(listener, "Listener identifier must not be null!"); + + events.findBySerializedEventAndListenerId(serializer.serialize(event), listener.toString()) // + .map(JpaEventPublicationRegistry::logCompleted) // + .ifPresent(it -> events.update(it.markCompleted())); + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.DisposableBean#destroy() + */ + @Override + public void destroy() throws Exception { + + List publications = events.findByCompletionDateIsNull(); + + if (publications.isEmpty()) { + + LOG.info("No publications outstanding!"); + return; + } + + LOG.info("Shutting down with the following publications left unfinished:"); + + for (int i = 0; i < publications.size(); i++) { + + String prefix = (i + 1) == publications.size() ? "└─" : "├─"; + JpaEventPublication it = publications.get(i); + + LOG.info("{} {} - {} - {}", prefix, it.getId(), it.getEventType().getName(), it.getListenerId()); + } + } + + private JpaEventPublication map(EventPublication publication) { + + JpaEventPublication result = JpaEventPublication.builder() // + .eventType(publication.getEvent().getClass()) // + .publicationDate(publication.getPublicationDate()) // + .listenerId(publication.getTargetIdentifier().toString()) // + .serializedEvent(serializer.serialize(publication.getEvent()).toString()) // + .build(); + + LOG.debug("Registering publication of {} with id {} for {}.", // + result.getEventType(), result.getId(), result.getListenerId()); + + return result; + } + + private static JpaEventPublication logCompleted(JpaEventPublication publication) { + + LOG.debug("Marking publication of event {} with id {} to listener {} completed.", // + publication.getEventType(), publication.getId(), publication.getListenerId()); + + return publication; + } + + @EqualsAndHashCode + @RequiredArgsConstructor(staticName = "of") + static class JpaEventPublicationAdapter implements EventPublication { + + private final JpaEventPublication publication; + private final EventSerializer serializer; + + private Object deserializedEvent; + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublication#getEvent() + */ + @Override + public Object getEvent() { + + if (deserializedEvent == null) { + this.deserializedEvent = serializer.deserialize(publication.getSerializedEvent(), publication.getEventType()); + } + + return deserializedEvent; + } + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublication#getListenerId() + */ + @Override + public PublicationTargetIdentifier getTargetIdentifier() { + return PublicationTargetIdentifier.of(publication.getListenerId()); + } + + /* + * (non-Javadoc) + * @see org.springframework.events.EventPublication#getPublicationDate() + */ + @Override + public Instant getPublicationDate() { + return publication.getPublicationDate(); + } + } +} diff --git a/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRepository.java b/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRepository.java new file mode 100644 index 00000000..396cc0fa --- /dev/null +++ b/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/JpaEventPublicationRepository.java @@ -0,0 +1,87 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Optional; + +import javax.persistence.EntityManager; +import javax.persistence.TypedQuery; + +import org.springframework.transaction.annotation.Transactional; + +/** + * Repository to store {@link JpaEventPublication}s. + * + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor +public class JpaEventPublicationRepository { + + private final EntityManager entityManager; + + @Transactional + JpaEventPublication create(JpaEventPublication publication) { + + entityManager.persist(publication); + return publication; + } + + @Transactional + JpaEventPublication update(JpaEventPublication publication) { + + entityManager.merge(publication); + entityManager.flush(); + + return publication; + } + + /** + * Returns all {@link JpaEventPublication} that have not been completed yet. + */ + @Transactional(readOnly = true) + List findByCompletionDateIsNull() { + + String query = "select p from JpaEventPublication p where p.completionDate is null"; + + return entityManager.createQuery(query, JpaEventPublication.class).getResultList(); + } + + /** + * Return the {@link JpaEventPublication} for the given serialized event and listener identifier. + * + * @param event must not be {@literal null}. + * @param listenerId must not be {@literal null}. + * @return + */ + @Transactional(readOnly = true) + Optional findBySerializedEventAndListenerId(Object event, String listenerId) { + + String query = "select p from JpaEventPublication p where p.serializedEvent = ?1 and p.listenerId = ?2"; + + TypedQuery typedQuery = entityManager.createQuery(query, JpaEventPublication.class) + .setParameter(1, event) + .setParameter(2, listenerId); + + try { + return Optional.of(typedQuery.getSingleResult()); + } catch (Exception o_O) { + return Optional.empty(); + } + } +} diff --git a/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/package-info.java b/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/package-info.java new file mode 100644 index 00000000..a48c9cea --- /dev/null +++ b/moduliths-events/moduliths-events-jpa/src/main/java/org/moduliths/events/jpa/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package org.moduliths.events.jpa; diff --git a/moduliths-events/moduliths-events-jpa/src/main/resources/META-INF/spring-devtools.properties b/moduliths-events/moduliths-events-jpa/src/main/resources/META-INF/spring-devtools.properties new file mode 100644 index 00000000..aecea6f4 --- /dev/null +++ b/moduliths-events/moduliths-events-jpa/src/main/resources/META-INF/spring-devtools.properties @@ -0,0 +1 @@ +restart.include.moduliths-events:/moduliths-events-jpa-[\\w-\\.]+\.jar diff --git a/moduliths-events/moduliths-events-jpa/src/main/resources/META-INF/spring.factories b/moduliths-events/moduliths-events-jpa/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..118209eb --- /dev/null +++ b/moduliths-events/moduliths-events-jpa/src/main/resources/META-INF/spring.factories @@ -0,0 +1,6 @@ +org.moduliths.events.config.EventPublicationConfigurationExtension=\ + org.moduliths.events.jpa.JpaEventPublicationConfiguration + +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + org.moduliths.events.jpa.JpaEventPublicationAutoConfiguration,\ + org.moduliths.events.jpa.JpaEventPublicationConfiguration diff --git a/moduliths-events/moduliths-events-jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/moduliths-events/moduliths-events-jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..3346c654 --- /dev/null +++ b/moduliths-events/moduliths-events-jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +org.moduliths.events.jpa.JpaEventPublicationAutoConfiguration +org.moduliths.events.jpa.JpaEventPublicationConfiguration diff --git a/moduliths-events/moduliths-events-jpa/src/test/java/org/moduliths/events/jpa/JpaEventPublicationConfigurationIntegrationTests.java b/moduliths-events/moduliths-events-jpa/src/test/java/org/moduliths/events/jpa/JpaEventPublicationConfigurationIntegrationTests.java new file mode 100644 index 00000000..b405461f --- /dev/null +++ b/moduliths-events/moduliths-events-jpa/src/test/java/org/moduliths/events/jpa/JpaEventPublicationConfigurationIntegrationTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import lombok.RequiredArgsConstructor; + +import javax.persistence.EntityManager; +import javax.persistence.TypedQuery; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.moduliths.events.EventSerializer; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestConstructor; +import org.springframework.test.context.TestConstructor.AutowireMode; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * @author Oliver Drotbohm + */ +@ExtendWith(SpringExtension.class) +@TestConstructor(autowireMode = AutowireMode.ALL) +@RequiredArgsConstructor +public class JpaEventPublicationConfigurationIntegrationTests { + + private final ApplicationContext context; + + @Configuration + @Import(JpaEventPublicationConfiguration.class) + static class TestConfig { + + @Bean + EventSerializer eventSerializer() { + return mock(EventSerializer.class); + } + + @Bean + EntityManager entityManager() { + + EntityManager em = mock(EntityManager.class); + + // Mock API for query executed at bootstrap time + TypedQuery query = mock(TypedQuery.class); + doReturn(query).when(em).createQuery(any(String.class), any()); + + return em; + } + } + + @Test + void bootstrapsApplicationComponents() { + + assertThat(context.getBean(JpaEventPublicationRegistry.class)).isNotNull(); + assertThat(context.getBean(JpaEventPublicationRepository.class)).isNotNull(); + } +} diff --git a/moduliths-events/moduliths-events-jpa/src/test/java/org/moduliths/events/jpa/JpaEventPublicationRepositoryIntegrationTests.java b/moduliths-events/moduliths-events-jpa/src/test/java/org/moduliths/events/jpa/JpaEventPublicationRepositoryIntegrationTests.java new file mode 100644 index 00000000..6435276e --- /dev/null +++ b/moduliths-events/moduliths-events-jpa/src/test/java/org/moduliths/events/jpa/JpaEventPublicationRepositoryIntegrationTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.jpa; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import lombok.RequiredArgsConstructor; + +import java.time.Instant; + +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.sql.DataSource; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.moduliths.events.EventSerializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.SharedEntityManagerCreator; +import org.springframework.orm.jpa.vendor.AbstractJpaVendorAdapter; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.test.context.TestConstructor; +import org.springframework.test.context.TestConstructor.AutowireMode; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author Oliver Drotbohm + */ +@ExtendWith(SpringExtension.class) +@TestConstructor(autowireMode = AutowireMode.ALL) +@Transactional +@RequiredArgsConstructor +class JpaEventPublicationRepositoryIntegrationTests { + + @Configuration + @Import(JpaEventPublicationConfiguration.class) + static class TestConfig { + + @Bean + EventSerializer eventSerializer() { + return mock(EventSerializer.class); + } + + // Database + + @Bean + EmbeddedDatabase hsqlDatabase() { + return new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.HSQL).build(); + } + + // JPA + + @Bean + LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) { + + AbstractJpaVendorAdapter vendor = new HibernateJpaVendorAdapter(); + vendor.setGenerateDdl(true); + + LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean(); + factory.setJpaVendorAdapter(vendor); + factory.setDataSource(dataSource); + factory.setPackagesToScan(getClass().getPackage().getName()); + + return factory; + } + + @Bean + EntityManager entityManager(EntityManagerFactory factory) { + return SharedEntityManagerCreator.createSharedEntityManager(factory); + } + + @Bean + JpaTransactionManager transactionManager(EntityManagerFactory factory) { + return new JpaTransactionManager(factory); + } + } + + private final JpaEventPublicationRepository repository; + + @Test + void persistsJpaEventPublication() { + + String listenerId = "listener"; + JpaEventPublication publication = JpaEventPublication.of(Instant.now(), listenerId, "", Object.class); + + // Store publication + repository.create(publication); + + assertThat(repository.findByCompletionDateIsNull()).containsExactly(publication); + assertThat(repository.findBySerializedEventAndListenerId("", listenerId)).isPresent(); + + // Complete publication + repository.update(publication.markCompleted()); + + assertThat(repository.findByCompletionDateIsNull()).isEmpty(); + } +} diff --git a/moduliths-events/moduliths-events-jpa/src/test/resources/logback.xml b/moduliths-events/moduliths-events-jpa/src/test/resources/logback.xml new file mode 100644 index 00000000..bd79b4ab --- /dev/null +++ b/moduliths-events/moduliths-events-jpa/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + + + diff --git a/moduliths-events/moduliths-events-starter/pom.xml b/moduliths-events/moduliths-events-starter/pom.xml new file mode 100644 index 00000000..e01edb49 --- /dev/null +++ b/moduliths-events/moduliths-events-starter/pom.xml @@ -0,0 +1,38 @@ + + 4.0.0 + + + org.moduliths + moduliths-events + 1.4.0-SNAPSHOT + ../pom.xml + + + Moduliths - Events - Spring Boot Starter + moduliths-events-starter + + + org.moduliths.events.starter + + + + + + org.springframework.boot + spring-boot-autoconfigure + + + + ${project.groupId} + moduliths-events-jpa + ${project.version} + + + + ${project.groupId} + moduliths-events-jackson + ${project.version} + + + + \ No newline at end of file diff --git a/moduliths-events/moduliths-events-starter/src/main/java/org/moduliths/events/starter/DomainEventsAutoConfiguration.java b/moduliths-events/moduliths-events-starter/src/main/java/org/moduliths/events/starter/DomainEventsAutoConfiguration.java new file mode 100644 index 00000000..1e69a3c5 --- /dev/null +++ b/moduliths-events/moduliths-events-starter/src/main/java/org/moduliths/events/starter/DomainEventsAutoConfiguration.java @@ -0,0 +1,51 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.events.starter; + +import org.moduliths.events.EventPublication; +import org.moduliths.events.config.EnablePersistentDomainEvents; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.boot.autoconfigure.AutoConfigurationPackages; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.type.AnnotationMetadata; + +/** + * Enables auto-configuration for the events package. + * + * @author Oliver Gierke + */ +@Configuration +@AutoConfigureBefore(HibernateJpaAutoConfiguration.class) +@Import(DomainEventsAutoConfiguration.EnableAutoConfigForEventsPackage.class) +@EnablePersistentDomainEvents +public class DomainEventsAutoConfiguration { + + static class EnableAutoConfigForEventsPackage implements ImportBeanDefinitionRegistrar { + + /* + * (non-Javadoc) + * @see org.springframework.context.annotation.ImportBeanDefinitionRegistrar#registerBeanDefinitions(org.springframework.core.type.AnnotationMetadata, org.springframework.beans.factory.support.BeanDefinitionRegistry) + */ + @Override + public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { + AutoConfigurationPackages.register(registry, EventPublication.class.getPackage().getName()); + } + } +} diff --git a/moduliths-events/moduliths-events-starter/src/main/java/org/moduliths/events/starter/package-info.java b/moduliths-events/moduliths-events-starter/src/main/java/org/moduliths/events/starter/package-info.java new file mode 100644 index 00000000..9d2df0a2 --- /dev/null +++ b/moduliths-events/moduliths-events-starter/src/main/java/org/moduliths/events/starter/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package org.moduliths.events.starter; diff --git a/moduliths-events/moduliths-events-starter/src/main/resources/META-INF/spring.factories b/moduliths-events/moduliths-events-starter/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..32037b4a --- /dev/null +++ b/moduliths-events/moduliths-events-starter/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=org.moduliths.events.starter.DomainEventsAutoConfiguration diff --git a/moduliths-events/moduliths-events-tests/pom.xml b/moduliths-events/moduliths-events-tests/pom.xml new file mode 100644 index 00000000..c8b5896b --- /dev/null +++ b/moduliths-events/moduliths-events-tests/pom.xml @@ -0,0 +1,48 @@ + + 4.0.0 + + org.moduliths + moduliths-events + 1.4.0-SNAPSHOT + + + Moduliths - Events - Integration tests + moduliths-events-tests + + + org.moduliths.events.integrationtests + + + + + + ${project.groupId} + moduliths-events-core + ${project.version} + + + + ${project.groupId} + moduliths-events-jpa + ${project.version} + + + + ${project.groupId} + moduliths-events-jackson + ${project.version} + + + + org.hibernate + hibernate-core + + + + org.springframework + spring-orm + + + + + \ No newline at end of file diff --git a/moduliths-events/moduliths-events-tests/src/test/java/example/events/InfrastructureConfiguration.java b/moduliths-events/moduliths-events-tests/src/test/java/example/events/InfrastructureConfiguration.java new file mode 100644 index 00000000..9d44400e --- /dev/null +++ b/moduliths-events/moduliths-events-tests/src/test/java/example/events/InfrastructureConfiguration.java @@ -0,0 +1,72 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.events; + +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.SharedEntityManagerCreator; +import org.springframework.orm.jpa.vendor.Database; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@Configuration(proxyBeanMethods = false) +@EnableTransactionManagement +class InfrastructureConfiguration { + + @Bean + LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) { + + HibernateJpaVendorAdapter adapter = new HibernateJpaVendorAdapter(); + adapter.setDatabase(Database.HSQL); + adapter.setGenerateDdl(true); + + LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean(); + factoryBean.setDataSource(dataSource); + factoryBean.setJpaVendorAdapter(adapter); + factoryBean.setPackagesToScan("org.moduliths.events.jpa"); + + return factoryBean; + } + + @Bean + DataSource dataSource() { + return new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.HSQL).build(); + } + + @Bean + JpaTransactionManager transactionManager(EntityManagerFactory factory) { + return new JpaTransactionManager(factory); + } + + @Bean + EntityManager entityManager(EntityManagerFactory factory) { + return SharedEntityManagerCreator.createSharedEntityManager(factory); + } + + @Bean + ThreadPoolTaskExecutor taskExecutor() { + return new ThreadPoolTaskExecutor(); + } +} diff --git a/moduliths-events/moduliths-events-tests/src/test/java/example/events/PersistentDomainEventIntegrationTest.java b/moduliths-events/moduliths-events-tests/src/test/java/example/events/PersistentDomainEventIntegrationTest.java new file mode 100644 index 00000000..98b855ba --- /dev/null +++ b/moduliths-events/moduliths-events-tests/src/test/java/example/events/PersistentDomainEventIntegrationTest.java @@ -0,0 +1,207 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.events; + +import static org.assertj.core.api.Assertions.*; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.junit.jupiter.api.Test; +import org.moduliths.events.EventPublication; +import org.moduliths.events.EventPublicationRegistry; +import org.moduliths.events.PublicationTargetIdentifier; +import org.moduliths.events.config.EnablePersistentDomainEvents; +import org.moduliths.events.support.PersistentApplicationEventMulticaster; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * @author Oliver Gierke + */ +class PersistentDomainEventIntegrationTest { + + @Test + void exposesEventPublicationForFailedListener() throws Exception { + + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(ApplicationConfiguration.class, InfrastructureConfiguration.class); + context.refresh(); + + EventPublicationRegistry registry = context.getBean(EventPublicationRegistry.class); + + try { + + context.getBean(Client.class).method(); + + Thread.sleep(200); + + assertThat(context.getBean(NonTxEventListener.class).getInvoked()).isEqualTo(1); + assertThat(context.getBean(FirstTxEventListener.class).getInvoked()).isEqualTo(1); + assertThat(context.getBean(SecondTxEventListener.class).getInvoked()).isEqualTo(1); + assertThat(context.getBean(ThirdTxEventListener.class).getInvoked()).isEqualTo(1); + assertThat(context.getBean(FourthTxEventListener.class).getInvoked()).isEqualTo(1); + + } catch (Throwable e) { + + System.out.println(e); + + } finally { + + assertThat(registry.findIncompletePublications()) // + .extracting(EventPublication::getTargetIdentifier) // + .extracting(PublicationTargetIdentifier::getValue) // + .hasSize(2) // + .allSatisfy(id -> { + assertThat(id) + .matches(it -> // + it.contains(SecondTxEventListener.class.getName()) // + || it.contains(FourthTxEventListener.class.getName())); + }); + + } + + // Simulate application restart with pending publications + PersistentApplicationEventMulticaster multicaster = context.getBean(PersistentApplicationEventMulticaster.class); + multicaster.afterSingletonsInstantiated(); + + Thread.sleep(200); + + assertThat(context.getBean(NonTxEventListener.class).getInvoked()).isEqualTo(1); + assertThat(context.getBean(FirstTxEventListener.class).getInvoked()).isEqualTo(1); + assertThat(context.getBean(SecondTxEventListener.class).getInvoked()).isEqualTo(2); + assertThat(context.getBean(ThirdTxEventListener.class).getInvoked()).isEqualTo(1); + assertThat(context.getBean(FourthTxEventListener.class).getInvoked()).isEqualTo(2); + + // Still 2 uncompleted publications + assertThat(registry.findIncompletePublications()).hasSize(2); + + context.close(); + } + + @Configuration + @EnableAsync + @EnablePersistentDomainEvents + static class ApplicationConfiguration { + + @Bean + NonTxEventListener nonTx() { + return new NonTxEventListener(); + } + + @Bean + FirstTxEventListener first() { + return new FirstTxEventListener(); + } + + @Bean + SecondTxEventListener second() { + return new SecondTxEventListener(); + } + + @Bean + ThirdTxEventListener third() { + return new ThirdTxEventListener(); + } + + @Bean + FourthTxEventListener fourth() { + return new FourthTxEventListener(); + } + + @Bean + Client client(ApplicationEventPublisher publisher) { + return new Client(publisher); + } + } + + @RequiredArgsConstructor + static class Client { + + private final ApplicationEventPublisher publisher; + + @Transactional + public void method() { + publisher.publishEvent(new DomainEvent()); + } + } + + static class DomainEvent {} + + static class NonTxEventListener { + + @Getter int invoked = 0; + + @EventListener + void on(DomainEvent event) { + invoked++; + } + } + + static class FirstTxEventListener { + + @Getter int invoked = 0; + + @TransactionalEventListener + public void on(DomainEvent event) { + invoked++; + } + } + + static class SecondTxEventListener { + + @Getter int invoked = 0; + + @TransactionalEventListener + public void on(DomainEvent event) { + invoked++; + throw new IllegalStateException(); + } + } + + static class ThirdTxEventListener { + + @Getter int invoked = 0; + + @TransactionalEventListener + public void on(DomainEvent event) { + invoked++; + } + } + + static class FourthTxEventListener { + + @Getter int invoked = 0; + + @Async + @TransactionalEventListener + public void on(DomainEvent event) throws InterruptedException { + + invoked++; + + Thread.sleep(100); + + throw new RuntimeException("Error!"); + } + } +} diff --git a/moduliths-events/moduliths-events-tests/src/test/resources/logback.xml b/moduliths-events/moduliths-events-tests/src/test/resources/logback.xml new file mode 100644 index 00000000..08efe7ce --- /dev/null +++ b/moduliths-events/moduliths-events-tests/src/test/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + + + + \ No newline at end of file diff --git a/moduliths-events/pom.xml b/moduliths-events/pom.xml new file mode 100644 index 00000000..215972d5 --- /dev/null +++ b/moduliths-events/pom.xml @@ -0,0 +1,91 @@ + + 4.0.0 + + + org.moduliths + moduliths + 1.4.0-SNAPSHOT + ../pom.xml + + + moduliths-events + pom + + Moduliths - Events + + + moduliths-events-core + moduliths-events-jpa + moduliths-events-jpa-jakarta + moduliths-events-jackson + moduliths-events-tests + moduliths-events-starter + + + + + + org.springframework + spring-context + + + + org.springframework + spring-tx + + + + org.springframework + spring-aop + + + + org.springframework + spring-jdbc + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.mockito + mockito-junit-jupiter + test + + + + org.assertj + assertj-core + test + + + + org.hsqldb + hsqldb + test + + + + + org.slf4j + slf4j-api + + + + org.slf4j + jcl-over-slf4j + runtime + + + + ch.qos.logback + logback-classic + test + + + + + diff --git a/moduliths-events/readme.adoc b/moduliths-events/readme.adoc new file mode 100644 index 00000000..cc30dd07 --- /dev/null +++ b/moduliths-events/readme.adoc @@ -0,0 +1,30 @@ += Spring (reliable) Domain Events + +image:https://travis-ci.org/olivergierke/spring-domain-events.svg?branch=master["Build Status", link="https://travis-ci.org/olivergierke/spring-domain-events"] + +== The Problem + +Spring allows applications to publish events via its `ApplicationEventPublisher` API. +With a transaction in place, these events can either be consumed within the transaction (using `@EventListener`) or in a dedicated transaction completion phase (using `@TransactionalEventListener`, defaulting to after transaction commit). +While an application failure for an in-transaction event listener is not a problem as the transaction has not been committed, a failure during the publication of events to transactional event listeners will mean that the event is lost and the notification of those listeners cannot be guaranteed. + +== The idea + +As we already have a transactional datastore in place, we could also store publication information about all transactional event listeners with the transaction that publishes one or more events. +We can then wrap the transactional event listeners to be able to remove those registrations on successful listener invocation and completion. +This allows us to re-publish the events to transactional listeners that either haven't been notified yet or didn't successfully complete the message handling in case of an application failure (on either a restart or in a scheduled way). + +== Building blocks of the prototype + +* The `EventPublicationRegistry` -- the core interface to register publications and mark them completed. It allows different implementations (JPA, JDBC). +* The `EventSerializer` -- a component to serialize the actual domain event so that it can be kept around in the publication. Again, to allow pluggable implementations (Jackson etc.) +* `PersistentApplicationEventMulticaster` -- a replacement for Spring's default `ApplicationEventMulticaster` that stores publications via the `EventPublicationRegistry`. +* `CompletionRegisteringBeanPostProcessor` -- a `BeanPostProcessor` that wraps `@TransactionalEventListener` instances with an interceptor to mark publications as completed. +* `@EnablePersistentDomainEvents` -- registers the multicaster and includes configuration classes for `EventPublicationConfigurationExtension` (to register the registry) and `EventSerializationConfigurationExtension` (to register an `EventSerializer`) via `spring.factories`. + +=== Implementation modules + +* `core` -- multicaster implementation, general and configuration infrastructure and SPI interfaces. +* `jackson` -- a rudimentary Jackson-based `EventSerializer` implementation. +* `jpa` -- a JPA-based `EventPublicationRegistry`. +* `test` -- a sample integration test featuring two successful and one failing listener to show the registry exposes the publication of the failed listener after the failure. diff --git a/moduliths-integration-test/pom.xml b/moduliths-integration-test/pom.xml new file mode 100644 index 00000000..0113965c --- /dev/null +++ b/moduliths-integration-test/pom.xml @@ -0,0 +1,49 @@ + + 4.0.0 + + + org.moduliths + moduliths + 1.4.0-SNAPSHOT + ../pom.xml + + + Moduliths - Integration Tests + moduliths-integration-test + + + org.moduliths.integrationtests + + + + + + ${project.groupId} + moduliths-test + ${project.version} + test + + + + ${project.groupId} + moduliths-sample + ${project.version} + test + + + + ${project.groupId} + moduliths-docs + ${project.version} + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + diff --git a/moduliths-integration-test/src/test/java/org/moduliths/docs/DocumenterUnitTests.java b/moduliths-integration-test/src/test/java/org/moduliths/docs/DocumenterUnitTests.java new file mode 100644 index 00000000..11bbf9f5 --- /dev/null +++ b/moduliths-integration-test/src/test/java/org/moduliths/docs/DocumenterUnitTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.docs; + +import static org.assertj.core.api.Assertions.*; +import static org.moduliths.docs.Documenter.CanvasOptions.Grouping.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.moduliths.docs.Documenter.CanvasOptions; +import org.moduliths.docs.Documenter.CanvasOptions.Grouping; +import org.moduliths.docs.Documenter.CanvasOptions.Groupings; +import org.moduliths.model.Modules; +import org.moduliths.model.SpringBean; + +import com.acme.myproject.Application; +import com.acme.myproject.stereotypes.Stereotypes; +import com.tngtech.archunit.core.domain.JavaClass; + +/** + * Unit tests for {@link Documenter}. + * + * @author Oliver Drotbohm + */ +class DocumenterUnitTests { + + Modules modules = Modules.of(Application.class); + + @Test + void groupsSpringBeansByArchitecturallyEvidentType() { + + Groupings result = CanvasOptions.defaults() + .groupingBy(of("Representations", nameMatching(".*Representations"))) + .groupingBy(of("Interface implementations", subtypeOf(Stereotypes.SomeAppInterface.class))) + .groupBeans(modules.getModuleByName("stereotypes").orElseThrow(RuntimeException::new)); + + assertThat(result.keySet()) + .extracting(Grouping::getName) + .containsExactlyInAnyOrder("Controllers", "Services", "Repositories", "Event listeners", + "Configuration properties", "Representations", "Interface implementations", "Others"); + + List impls = result.byGroupName("Interface implementations"); + + assertThat(impls).hasSize(1) // + .extracting(it -> it.getType()) // + .extracting(JavaClass::getSimpleName) // + .containsExactly("SomeAppInterfaceImplementation"); + + List listeners = result.byGroupName("Event listeners"); + + assertThat(listeners).hasSize(2) // + .extracting(it -> it.getType()) // + .extracting(JavaClass::getSimpleName) // + .containsOnly("SomeEventListener", "SomeTxEventListener"); + } + + @Test + void playWithOutput() { + + Documenter documenter = new Documenter(modules); + + CanvasOptions options = CanvasOptions.defaults() // + .groupingBy(of("Representations", nameMatching(".*Representations"))); + + assertThatNoException().isThrownBy(() -> { + modules.forEach(it -> documenter.toModuleCanvas(it, options)); + }); + } +} diff --git a/moduliths-integration-test/src/test/java/org/moduliths/model/JavaPackageUnitTests.java b/moduliths-integration-test/src/test/java/org/moduliths/model/JavaPackageUnitTests.java new file mode 100644 index 00000000..57ea593f --- /dev/null +++ b/moduliths-integration-test/src/test/java/org/moduliths/model/JavaPackageUnitTests.java @@ -0,0 +1,47 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.ImportOptions; + +/** + * @author Oliver Drotbohm + */ +class JavaPackageUnitTests { + + static final ImportOptions NO_TESTS = new ImportOptions().with(new ImportOption.DoNotIncludeTests()); + static final JavaClasses ALL_CLASSES = new ClassFileImporter(NO_TESTS) // + .importPackages("com.acme.myproject"); + + @Test + void testName() throws Exception { + + Classes classes = Classes.of(ALL_CLASSES); + JavaPackage pkg = JavaPackage.of(classes, "com.acme.myproject.complex"); + + assertThat(pkg.getLocalName()).isEqualTo("complex"); + assertThat(pkg.getDirectSubPackages()) // + .extracting(JavaPackage::getLocalName) // + .contains("api", "internal", "spi"); + } +} diff --git a/moduliths-integration-test/src/test/java/org/moduliths/model/ModulesIntegrationTest.java b/moduliths-integration-test/src/test/java/org/moduliths/model/ModulesIntegrationTest.java new file mode 100644 index 00000000..27f099ab --- /dev/null +++ b/moduliths-integration-test/src/test/java/org/moduliths/model/ModulesIntegrationTest.java @@ -0,0 +1,155 @@ +/* + * Copyright 2018-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.moduliths.model.Module.DependencyType; + +import com.acme.myproject.Application; +import com.acme.myproject.complex.internal.FirstTypeBasedPort; +import com.acme.myproject.complex.internal.SecondTypeBasePort; +import com.acme.myproject.moduleA.SomeConfigurationA.SomeAtBeanComponentA; + +/** + * @author Oliver Gierke + * @author Peter Gafert + */ +class ModulesIntegrationTest { + + Modules modules = Modules.of(Application.class); + + @Test + void moduleDetectionUsesStrategyDefinedInSpringFactories() { + assertThat(TestModuleDetectionStrategy.used).isTrue(); + } + + @Test + void exposesModulesForPrimaryPackages() { + + Optional module = modules.getModuleByName("moduleB"); + + assertThat(module).hasValueSatisfying(it -> { + assertThat(it.getBootstrapDependencies(modules)).anySatisfy(dep -> { + assertThat(dep.getName()).isEqualTo("moduleA"); + }); + }); + } + + @Test + public void usesExplicitlyAnnotatedDisplayName() { + + Optional module = modules.getModuleByName("moduleC"); + + assertThat(module).hasValueSatisfying(it -> { + assertThat(it.getDisplayName()).isEqualTo("MyModule C"); + }); + } + + @Test + public void rejectsDependencyIntoInternalPackage() { + + Optional module = modules.getModuleByName("invalid"); + + assertThat(module).hasValueSatisfying(it -> { + assertThatExceptionOfType(Violations.class) // + .isThrownBy(() -> it.verifyDependencies(modules)); + }); + } + + @Test + public void complexModuleExposesNamedInterfaces() { + + Optional module = modules.getModuleByName("complex"); + + assertThat(module).hasValueSatisfying(it -> { + + NamedInterfaces interfaces = it.getNamedInterfaces(); + + assertThat(interfaces.stream().map(NamedInterface::getName)) // + .containsExactlyInAnyOrder("API", "SPI", "Port 1", "Port 2", "Port 3"); + + verifyNamedInterfaces(interfaces, "Port 1", FirstTypeBasedPort.class, SecondTypeBasePort.class); + verifyNamedInterfaces(interfaces, "Port 2", FirstTypeBasedPort.class, SecondTypeBasePort.class); + verifyNamedInterfaces(interfaces, "Port 3", FirstTypeBasedPort.class, SecondTypeBasePort.class); + }); + } + + private static void verifyNamedInterfaces(NamedInterfaces interfaces, String name, Class... types) { + + Optional byName = interfaces.getByName(name); + + Stream.of(types).forEach(type -> { + assertThat(byName).hasValueSatisfying(named -> named.contains(type)); + }); + } + + @Test + public void discoversAtBeanComponent() { + + Optional module = modules.getModuleByName("moduleA"); + + assertThat(module).hasValueSatisfying(it -> { + assertThat(it.getSpringBeansInternal().contains(SomeAtBeanComponentA.class.getName())).isTrue(); + }); + } + + @Test + public void moduleBListensToModuleA() { + + Optional module = modules.getModuleByName("moduleB"); + Module moduleA = modules.getModuleByName("moduleA").orElseThrow(IllegalStateException::new); + + assertThat(module).hasValueSatisfying(it -> { + assertThat(it.getDependencies(modules, DependencyType.EVENT_LISTENER)) // + .contains(moduleA); + }); + } + + @Test + public void rejectsNotExplicitlyListedDependency() { + + Optional moduleByName = modules.getModuleByName("invalid2"); + + assertThat(moduleByName).hasValueSatisfying(it -> { + + assertThatExceptionOfType(Violations.class) // + .isThrownBy(() -> it.verifyDependencies(modules)) // + .withMessageContaining(it.getName()); + }); + } + + @Test + void findsModuleBySubPackage() { + + assertThat(modules.getModuleForPackage("com.acme.myproject.moduleA.sub.package")) // + .isEqualTo(modules.getModuleByName("moduleA")); + } + + @Test + void createsModulesFromJavaPackage() { + + Modules fromPackage = Modules.of(Application.class.getPackage().getName()); + + assertThat(fromPackage.stream().map(Module::getName)) // + .containsExactlyInAnyOrderElementsOf(modules.stream().map(Module::getName).collect(Collectors.toList())); + } +} diff --git a/moduliths-integration-test/src/test/java/org/moduliths/model/TestModuleDetectionStrategy.java b/moduliths-integration-test/src/test/java/org/moduliths/model/TestModuleDetectionStrategy.java new file mode 100644 index 00000000..bca85891 --- /dev/null +++ b/moduliths-integration-test/src/test/java/org/moduliths/model/TestModuleDetectionStrategy.java @@ -0,0 +1,40 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.model; + +import java.util.stream.Stream; + +/** + * @author Oliver Drotbohm + */ +class TestModuleDetectionStrategy implements ModuleDetectionStrategy { + + private final ModuleDetectionStrategy delegate = ModuleDetectionStrategy.directSubPackage(); + + static boolean used; + + /* + * (non-Javadoc) + * @see org.moduliths.model.ModuleDetectionStrategy#getModuleBasePackages(org.moduliths.model.JavaPackage) + */ + @Override + public Stream getModuleBasePackages(JavaPackage basePackage) { + + TestModuleDetectionStrategy.used = true; + + return delegate.getModuleBasePackages(basePackage); + } +} diff --git a/moduliths-integration-test/src/test/resources/META-INF/spring.factories b/moduliths-integration-test/src/test/resources/META-INF/spring.factories new file mode 100644 index 00000000..cae44a4f --- /dev/null +++ b/moduliths-integration-test/src/test/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.moduliths.model.ModuleDetectionStrategy=org.moduliths.model.TestModuleDetectionStrategy diff --git a/moduliths-integration-test/src/test/resources/logback.xml b/moduliths-integration-test/src/test/resources/logback.xml new file mode 100644 index 00000000..0cd7abba --- /dev/null +++ b/moduliths-integration-test/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + + + \ No newline at end of file diff --git a/moduliths-moments/pom.xml b/moduliths-moments/pom.xml new file mode 100644 index 00000000..3e0ae2a3 --- /dev/null +++ b/moduliths-moments/pom.xml @@ -0,0 +1,44 @@ + + 4.0.0 + + + org.moduliths + moduliths + 1.4.0-SNAPSHOT + + + Moduliths - Moments + moduliths-moments + + + org.moduliths.moments + + + + + + org.jmolecules + jmolecules-events + + + + org.springframework.boot + spring-boot-autoconfigure + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + \ No newline at end of file diff --git a/moduliths-moments/readme.adoc b/moduliths-moments/readme.adoc new file mode 100644 index 00000000..5af03278 --- /dev/null +++ b/moduliths-moments/readme.adoc @@ -0,0 +1,59 @@ += Moduliths Moments + +Moments is an implementation of the https://verraes.net/2019/05/patterns-for-decoupling-distsys-passage-of-time-event/[Passage of Time Event] idea, originally presented by https://github.com/mathiasverraes[Mathias Verraes]. +It enables Spring Boot applications to consume the following events to attach business logic to them: + +* `DayHasPassed` - signals the end of a day +* `WeekHasPassed` - signals the end of a week +* `MonthHasPassed` - signals the end of a month +* `QuarterHasPassed` - signals the end of a quarter +* `YearHasPassed` - signals the end of a year + +This allows event listeners to be registered that react on the publication of such events. +They allow to decouple from the actual triggering mechanism (e.g. Spring's built-in scheduling) and can thus be tested more easily. + +== How to use Moments? + +Moments will auto-activate in a Spring Boot application if you put it on the classpath of your application. + +[source, xml] +---- + + org.moduliths + moduliths-moments + + +---- + +== What does Moments enable? + +The Moments module enables scheduling for the application it is applied to. +It registers a bean of type `Moments` that takes care of publishing the above mentioned events. +By setting `moduliths.moments.enable-time-machine` you can also rather expose a bean of type `TimeMachine` (which extends `Moments`), which exposes a `….shift(Duration)` method which allows to move what constitutes "now" by the given `Duration`. +Moving time forward will cause all events published that would occur during the delta. + +=== How to disable Moments? + +If you don't control the classpath of the application you run, you can still disable Moments by setting the `moduliths.moments.enabled` property to `false`. + +== Customization options + +Moments works with Java's standard `Clock` abstraction to determine the current time. +By default, `Clock.standardUTC()` is used. +To use a different clock, just register a Spring Bean of type `Clock`. +Moments will pick it up automatically. + +=== Moments configuration properties + +Moments exposes configuration properties to tweak its behavior under the `moduliths.moments` namespace. + +[%header, cols="1,1,2"] +|=== +|Property|Default value|Description +|`enabled`|`true`|Whether to enable Moments in the first place. +|`enable-time-machine`|`false`|Set to true to expose `TimeMachine` instead of `Moments` to publicly expose methods to shift time. +|`granularity`|`hours`|At which granularity to publish events. Switch to `days` if you want to disable the distribution of `HourHasPassed` events. +|`locale`|`Locale.default()`|The `Locale` to determine the start date of the week for `WeekHasPassed` events. +|`quarter-start-month`|January|The month in which the first quarter starts. Customize via the month's name you like to start the quarters at (e.g. `February`). +|`zone-id`|`UTC`|The time zone to determine the dates and times attached to the events published. Use standardized region time zone descriptors (e.g. `Europe/Berlin`) to customize +|=== diff --git a/moduliths-moments/src/main/java/org/moduliths/moments/DayHasPassed.java b/moduliths-moments/src/main/java/org/moduliths/moments/DayHasPassed.java new file mode 100644 index 00000000..8a8a99d9 --- /dev/null +++ b/moduliths-moments/src/main/java/org/moduliths/moments/DayHasPassed.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments; + +import lombok.Value; + +import java.time.LocalDate; + +import org.jmolecules.event.types.DomainEvent; + +/** + * A {@link DomainEvent} published on each day. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +@Value(staticConstructor = "of") +public class DayHasPassed implements DomainEvent { + + /** + * The day that has just passed. + */ + private final LocalDate date; +} diff --git a/moduliths-moments/src/main/java/org/moduliths/moments/HourHasPassed.java b/moduliths-moments/src/main/java/org/moduliths/moments/HourHasPassed.java new file mode 100644 index 00000000..29f8f482 --- /dev/null +++ b/moduliths-moments/src/main/java/org/moduliths/moments/HourHasPassed.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments; + +import lombok.Value; + +import java.time.LocalDateTime; + +import org.jmolecules.event.types.DomainEvent; + +/** + * A {@link DomainEvent} published on each day. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +@Value(staticConstructor = "of") +public class HourHasPassed implements DomainEvent { + + /** + * The hour that has just passed. + */ + private final LocalDateTime time; +} diff --git a/moduliths-moments/src/main/java/org/moduliths/moments/MonthHasPassed.java b/moduliths-moments/src/main/java/org/moduliths/moments/MonthHasPassed.java new file mode 100644 index 00000000..93d41e2f --- /dev/null +++ b/moduliths-moments/src/main/java/org/moduliths/moments/MonthHasPassed.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments; + +import lombok.Value; + +import java.time.YearMonth; + +import org.jmolecules.event.types.DomainEvent; + +/** + * A {@link DomainEvent} published on the last day of the month. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +@Value(staticConstructor = "of") +public class MonthHasPassed implements DomainEvent { + + /** + * The month that has just passed. + */ + private final YearMonth month; +} diff --git a/moduliths-moments/src/main/java/org/moduliths/moments/Quarter.java b/moduliths-moments/src/main/java/org/moduliths/moments/Quarter.java new file mode 100644 index 00000000..a873e71a --- /dev/null +++ b/moduliths-moments/src/main/java/org/moduliths/moments/Quarter.java @@ -0,0 +1,63 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments; + +import static java.time.MonthDay.*; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.time.Month; +import java.time.MonthDay; + +/** + * A logical {@link Quarter} of the year. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum Quarter { + + Q1(of(Month.JANUARY, 1), of(Month.MARCH, 31)), // + Q2(of(Month.APRIL, 1), of(Month.JUNE, 30)), // + Q3(of(Month.JULY, 1), of(Month.SEPTEMBER, 30)), // + Q4(of(Month.OCTOBER, 1), of(Month.DECEMBER, 31)); + + private final @Getter MonthDay start, end; + + /** + * Returns the next logical {@link Quarter}. + * + * @return will never be {@literal null}. + */ + Quarter next() { + + switch (this) { + case Q1: + return Q2; + case Q2: + return Q3; + case Q3: + return Q4; + case Q4: + return Q1; + default: + throw new IllegalStateException("¯\\_(ツ)_/¯"); + } + } +} diff --git a/moduliths-moments/src/main/java/org/moduliths/moments/QuarterHasPassed.java b/moduliths-moments/src/main/java/org/moduliths/moments/QuarterHasPassed.java new file mode 100644 index 00000000..3335933f --- /dev/null +++ b/moduliths-moments/src/main/java/org/moduliths/moments/QuarterHasPassed.java @@ -0,0 +1,79 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments; + +import lombok.NonNull; +import lombok.Value; + +import java.time.LocalDate; +import java.time.Month; +import java.time.Year; + +import org.jmolecules.event.types.DomainEvent; + +/** + * A {@link DomainEvent} published once a quarter has passed. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +@Value(staticConstructor = "of") +public class QuarterHasPassed implements DomainEvent { + + private final @NonNull Year year; + private final @NonNull ShiftedQuarter quarter; + + /** + * Returns a {@link QuarterHasPassed} for the given {@link Year} and logical {@link Quarter}. + * + * @param year must not be {@literal null}. + * @param quarter must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static QuarterHasPassed of(Year year, Quarter quarter) { + return QuarterHasPassed.of(year, ShiftedQuarter.of(quarter)); + } + + /** + * Returns a {@link QuarterHasPassed} for the given {@link Year}, logical {@link Quarter} and start {@link Month}. + * + * @param year must not be {@literal null}. + * @param quarter must not be {@literal null}. + * @param startMonth must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static QuarterHasPassed of(Year year, Quarter quarter, Month startMonth) { + return QuarterHasPassed.of(year, ShiftedQuarter.of(quarter, startMonth)); + } + + /** + * Returns the date of the first day of the quarter that has just passed. + * + * @return will never be {@literal null}. + */ + public LocalDate getStartDate() { + return quarter.getStartDate(year); + } + + /** + * Returns the date of the last day of the quarter that has just passed. + * + * @return will never be {@literal null}. + */ + public LocalDate getEndDate() { + return quarter.getEndDate(year); + } +} diff --git a/moduliths-moments/src/main/java/org/moduliths/moments/ShiftedQuarter.java b/moduliths-moments/src/main/java/org/moduliths/moments/ShiftedQuarter.java new file mode 100644 index 00000000..270422ca --- /dev/null +++ b/moduliths-moments/src/main/java/org/moduliths/moments/ShiftedQuarter.java @@ -0,0 +1,143 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NonNull; +import lombok.Value; + +import java.time.LocalDate; +import java.time.Month; +import java.time.MonthDay; +import java.time.Year; +import java.util.stream.Stream; + +import org.springframework.util.Assert; + +/** + * A quarter that can be shifted to start at a configurable {@link Month}. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +@Value(staticConstructor = "of") +public class ShiftedQuarter { + + private static final MonthDay FIRST_DAY = MonthDay.of(Month.JANUARY, 1); + private static final MonthDay LAST_DAY = MonthDay.of(Month.DECEMBER, 31); + + private final @NonNull Quarter quarter; + private final @NonNull @Getter(AccessLevel.NONE) Month startMonth; + + /*+ + * Creates a new ShiftedQuarter for the given logical {@link Quarter}. + * + * @param quarter must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static ShiftedQuarter of(Quarter quarter) { + return new ShiftedQuarter(quarter, Month.JANUARY); + } + + /** + * Returns the next {@link ShiftedQuarter}. + * + * @return will never be {@literal null}. + */ + public ShiftedQuarter next() { + return new ShiftedQuarter(quarter.next(), startMonth); + } + + /** + * Returns whether the given {@link LocalDate} is contained in the current {@link ShiftedQuarter}. + * + * @param date must not be {@literal null}. + * @return + */ + public boolean contains(LocalDate date) { + + Assert.notNull(date, "Reference date must not be null!"); + + MonthDay shiftedStart = getStart(); + MonthDay shiftedEnd = getEnd(); + MonthDay reference = MonthDay.from(date); + + Stream ranges = shiftedEnd.isAfter(shiftedStart) + ? Stream.of(Range.of(shiftedStart, shiftedEnd)) + : Stream.of(Range.of(shiftedStart, LAST_DAY), Range.of(FIRST_DAY, shiftedEnd)); + + return ranges.anyMatch(it -> it.contains(reference)); + } + + public MonthDay getStart() { + return getShifted(quarter.getStart()); + } + + public MonthDay getEnd() { + return getShifted(quarter.getEnd()); + } + + public boolean isLastDay(LocalDate date) { + return MonthDay.from(date).equals(getEnd()); + } + + /** + * Returns the start date of the {@link ShiftedQuarter} for the given {@link Year}. + * + * @param year must not be {@literal null}. + * @return will never be {@literal null}. + */ + public LocalDate getStartDate(Year year) { + + Assert.notNull(year, "Year must not be null!"); + + return quarter.getStart() + .atYear(year.getValue()) + .plusMonths(startMonth.getValue() - 1); + } + + /** + * Returns the end date of the {@link ShiftedQuarter} for the given {@link Year}. + * + * @param year must not be {@literal null}. + * @return will never be {@literal null}. + */ + public LocalDate getEndDate(Year year) { + + Assert.notNull(year, "Year must not be null!"); + + return getStartDate(year).plusMonths(3).minusDays(1); + } + + private MonthDay getShifted(MonthDay source) { + return source.with(source.getMonth().plus(startMonth.getValue() - 1)); + } + + @Value(staticConstructor = "of") + private static class Range { + + MonthDay start, end; + + public boolean contains(MonthDay day) { + + boolean isAfterStart = start.equals(day) || start.isBefore(day); + boolean isBeforeEnd = end.equals(day) || end.isAfter(day); + + return isAfterStart && isBeforeEnd; + } + } +} diff --git a/moduliths-moments/src/main/java/org/moduliths/moments/WeekHasPassed.java b/moduliths-moments/src/main/java/org/moduliths/moments/WeekHasPassed.java new file mode 100644 index 00000000..12232312 --- /dev/null +++ b/moduliths-moments/src/main/java/org/moduliths/moments/WeekHasPassed.java @@ -0,0 +1,87 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NonNull; +import lombok.Value; + +import java.time.LocalDate; +import java.time.Year; +import java.time.temporal.ChronoField; +import java.time.temporal.WeekFields; +import java.util.Locale; + +import org.jmolecules.event.types.DomainEvent; + +/** + * A {@link DomainEvent} published if a week has passed. The semantics of what constitutes are depended on the + * {@link Locale} provided. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +@Value(staticConstructor = "of") +public class WeekHasPassed implements DomainEvent { + + /** + * The year of the week that has just passed. + */ + private final @NonNull Year year; + + /** + * The week of the {@link Year} that has just passed. + */ + private final int week; + + /** + * The {@link Locale} to be used to calculate the start date of the week. + */ + private final @NonNull @Getter(AccessLevel.NONE) Locale locale; + + /** + * Creates a new {@link WeekHasPassed} for the given {@link Year} and week of the year. + * + * @param year must not be {@literal null}. + * @param week must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static WeekHasPassed of(Year year, int week) { + return WeekHasPassed.of(year, week, Locale.getDefault()); + } + + /** + * Returns the start date of the week that has passed. + * + * @return will never be {@literal null}. + */ + public LocalDate getStartDate() { + + return LocalDate.of(year.getValue(), 1, 1) + .with(WeekFields.of(locale).weekOfYear(), week) + .with(ChronoField.DAY_OF_WEEK, 1); + } + + /** + * Returns the end date of the week that has passed. + * + * @return will never be {@literal null}. + */ + public LocalDate getEndDate() { + return getStartDate().plusDays(6); + } +} diff --git a/moduliths-moments/src/main/java/org/moduliths/moments/YearHasPassed.java b/moduliths-moments/src/main/java/org/moduliths/moments/YearHasPassed.java new file mode 100644 index 00000000..4a34414f --- /dev/null +++ b/moduliths-moments/src/main/java/org/moduliths/moments/YearHasPassed.java @@ -0,0 +1,67 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments; + +import lombok.Value; + +import java.time.LocalDate; +import java.time.Month; +import java.time.Year; + +import org.jmolecules.event.types.DomainEvent; + +/** + * A {@link DomainEvent} published on the last day of the year. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +@Value(staticConstructor = "of") +public class YearHasPassed implements DomainEvent { + + /** + * The month that has just passed. + */ + private final Year year; + + /** + * Creates a new {@link YearHasPassed} event for the given year. + * + * @param year a valid year + * @return will never be {@literal null}. + */ + public static YearHasPassed of(int year) { + return of(Year.of(year)); + } + + /** + * Returns the start date of the year passed. + * + * @return will never be {@literal null}. + */ + LocalDate getStartDate() { + return LocalDate.of(year.getValue(), Month.JANUARY, 1); + } + + /** + * Returns the end date of the year passed. + * + * @return will never be {@literal null}. + */ + LocalDate getEndDate() { + return LocalDate.of(year.getValue(), Month.DECEMBER, 31); + } +} diff --git a/moduliths-moments/src/main/java/org/moduliths/moments/autoconfigure/MomentsAutoConfiguration.java b/moduliths-moments/src/main/java/org/moduliths/moments/autoconfigure/MomentsAutoConfiguration.java new file mode 100644 index 00000000..c45a4394 --- /dev/null +++ b/moduliths-moments/src/main/java/org/moduliths/moments/autoconfigure/MomentsAutoConfiguration.java @@ -0,0 +1,61 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments.autoconfigure; + +import java.time.Clock; + +import org.moduliths.moments.support.Moments; +import org.moduliths.moments.support.MomentsProperties; +import org.moduliths.moments.support.TimeMachine; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * Auto-configuration for {@link Moments}. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +@EnableScheduling +@EnableConfigurationProperties(MomentsProperties.class) +@ConditionalOnProperty(name = "moduliths.moments.enabled", havingValue = "true", matchIfMissing = true) +@Configuration(proxyBeanMethods = false) +class MomentsAutoConfiguration { + + @Bean + @ConditionalOnProperty(name = "moduliths.moments.enable-time-machine", havingValue = "false", matchIfMissing = true) + Moments moments(ObjectProvider clockProvider, ApplicationEventPublisher events, MomentsProperties properties) { + + Clock clock = clockProvider.getIfAvailable(() -> Clock.systemUTC()); + + return new Moments(clock, events, properties); + } + + @Bean + @ConditionalOnProperty(name = "moduliths.moments.enable-time-machine", havingValue = "true", matchIfMissing = false) + TimeMachine timeMachine(ObjectProvider clockProvider, ApplicationEventPublisher events, + MomentsProperties properties) { + + Clock clock = clockProvider.getIfAvailable(() -> Clock.systemUTC()); + + return new TimeMachine(clock, events, properties); + } +} diff --git a/moduliths-moments/src/main/java/org/moduliths/moments/package-info.java b/moduliths-moments/src/main/java/org/moduliths/moments/package-info.java new file mode 100644 index 00000000..03b20952 --- /dev/null +++ b/moduliths-moments/src/main/java/org/moduliths/moments/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package org.moduliths.moments; diff --git a/moduliths-moments/src/main/java/org/moduliths/moments/support/Moments.java b/moduliths-moments/src/main/java/org/moduliths/moments/support/Moments.java new file mode 100644 index 00000000..6de3f9c8 --- /dev/null +++ b/moduliths-moments/src/main/java/org/moduliths/moments/support/Moments.java @@ -0,0 +1,159 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments.support; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Month; +import java.time.MonthDay; +import java.time.Year; +import java.time.YearMonth; +import java.time.temporal.ChronoUnit; +import java.time.temporal.WeekFields; + +import org.moduliths.moments.DayHasPassed; +import org.moduliths.moments.HourHasPassed; +import org.moduliths.moments.MonthHasPassed; +import org.moduliths.moments.QuarterHasPassed; +import org.moduliths.moments.ShiftedQuarter; +import org.moduliths.moments.WeekHasPassed; +import org.moduliths.moments.YearHasPassed; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Scheduled; + +/** + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor +public class Moments { + + private static final MonthDay DEC_31ST = MonthDay.of(Month.DECEMBER, 31); + + private final @NonNull Clock clock; + private final @NonNull ApplicationEventPublisher events; + private final @NonNull MomentsProperties properties; + + private Duration shift = Duration.ZERO; + + /** + * Triggers event publication every hour. + */ + @Scheduled(cron = "@hourly") + void everyHour() { + + if (properties.isHourly()) { + emitEventsFor(now().minusHours(1)); + } + } + + /** + * Triggers event publication every midnight. + */ + @Scheduled(cron = "@daily") + void everyMidnight() { + emitEventsFor(now().toLocalDate().minusDays(1)); + } + + void emitEventsFor(LocalDateTime time) { + events.publishEvent(HourHasPassed.of(time.truncatedTo(ChronoUnit.HOURS))); + } + + void emitEventsFor(LocalDate date) { + + // Day has passed + events.publishEvent(DayHasPassed.of(date)); + + // Week has passed + int week = getWeekOfYear(date); + Year year = Year.from(date); + + if (getWeekOfYear(date.plusDays(1)) > week) { + events.publishEvent(WeekHasPassed.of(year, week, properties.getLocale())); + } + + // Month has passed + if (date.getDayOfMonth() == date.lengthOfMonth()) { + events.publishEvent(MonthHasPassed.of(YearMonth.from(date))); + } + + // Quarter has passed + ShiftedQuarter quarter = properties.getShiftedQuarter(date); + + if (quarter.isLastDay(date)) { + events.publishEvent(QuarterHasPassed.of(year, quarter)); + } + + // Year has passed + if (MonthDay.from(date).equals(DEC_31ST)) { + events.publishEvent(YearHasPassed.of(year)); + } + } + + Moments shiftBy(Duration duration) { + + LocalDateTime before = now(); + LocalDateTime after = before.plus(duration); + + this.shift = shift.plus(duration); + + if (duration.isNegative()) { + return this; + } + + LocalDateTime current = before.truncatedTo(ChronoUnit.HOURS); + boolean hourly = properties.isHourly(); + + while (current.isBefore(after.truncatedTo(ChronoUnit.HOURS))) { + + LocalDateTime next = hourly ? current.plusHours(1) : current.plusDays(1); + + if (hourly) { + emitEventsFor(next); + } + + if (current.toLocalDate().isBefore(next.toLocalDate())) { + emitEventsFor(current.toLocalDate()); + } + + current = next; + } + + return this; + } + + LocalDateTime now() { + + Instant instant = clock.instant().plus(shift); + + return LocalDateTime.ofInstant(instant, properties.getZoneId()); + } + + /** + * Returns the week of the year for the given {@link LocalDate}. + * + * @param date must not be {@literal null}. + * @return + */ + private int getWeekOfYear(LocalDate date) { + return date.get(WeekFields.of(properties.getLocale()).weekOfYear()); + } +} diff --git a/moduliths-moments/src/main/java/org/moduliths/moments/support/MomentsProperties.java b/moduliths-moments/src/main/java/org/moduliths/moments/support/MomentsProperties.java new file mode 100644 index 00000000..781bf596 --- /dev/null +++ b/moduliths-moments/src/main/java/org/moduliths/moments/support/MomentsProperties.java @@ -0,0 +1,137 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments.support; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.With; + +import java.time.LocalDate; +import java.time.Month; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import org.moduliths.moments.Quarter; +import org.moduliths.moments.ShiftedQuarter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; +import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Configuration properties for {@link Moments}. + * + * @author Oliver Drotbohm + * @since 1.3 + */ +@ConfigurationProperties(prefix = "moduliths.moments") +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class MomentsProperties { + + public static final MomentsProperties DEFAULTS = new MomentsProperties(null, null, null, (Month) null, false); + + private final @With Granularity granularity; + + /** + * The {@link ZoneId} to determine times which are attached to the events published. Defaults to + * {@value ZoneOffset#UTC}. + */ + private final @With @Getter ZoneId zoneId; + + /** + * The {@link Locale} to use when determining week boundaries. Defaults to {@value Locale#getDefault()}. + */ + private final @With @Getter Locale locale; + + private final @Getter boolean enableTimeMachine; + + private final ShiftedQuarters quarters; + + /** + * Creates a new {@link MomentsProperties} for the given {@link Granularity}, {@link ZoneId}, {@link Locale} and + * quarter start {@link Month}. + * + * @param granularity can be {@literal null}, defaults to {@value Granularity#HOURS}. + * @param zoneId the time zone id to use, defaults to {@code UTC}. + * @param locale + * @param quarterStartMonth the {@link Month} at which quarters start. Defaults to {@value Month#JANUARY}, resulting + * in {@link ShiftedQuarter}s without any shift. + */ + @ConstructorBinding + private MomentsProperties(@Nullable @DefaultValue("hours") Granularity granularity, + @Nullable ZoneId zoneId, @Nullable Locale locale, @Nullable Month quarterStartMonth, + @DefaultValue("false") boolean enableTimeMachine) { + + this.granularity = granularity == null ? Granularity.HOURS : granularity; + this.zoneId = zoneId == null ? ZoneOffset.UTC : zoneId; + this.locale = locale == null ? Locale.getDefault() : locale; + this.quarters = ShiftedQuarters.of(quarterStartMonth == null ? Month.JANUARY : quarterStartMonth); + this.enableTimeMachine = enableTimeMachine; + } + + /** + * Returns whether to create hourly events. + * + * @return + */ + boolean isHourly() { + return Granularity.HOURS.equals(granularity); + } + + /** + * Returns the {@link ShiftedQuarter} for the given reference date. + * + * @param reference must not be {@literal null}. + * @return + */ + public ShiftedQuarter getShiftedQuarter(LocalDate reference) { + + Assert.notNull(reference, "Reference date must not be null!"); + + return quarters.getCurrent(reference); + } + + static enum Granularity { + HOURS, DAYS; + } + + @RequiredArgsConstructor + private static class ShiftedQuarters { + + private final List quarters; + + public ShiftedQuarter getCurrent(LocalDate reference) { + + return quarters.stream() + .filter(it -> it.contains(reference)) + .findFirst() + .orElseThrow(() -> new IllegalStateException()); + } + + public static ShiftedQuarters of(Month shift) { + + return new ShiftedQuarters(Arrays.stream(Quarter.values()) + .map(it -> ShiftedQuarter.of(it, shift)) + .collect(Collectors.toList())); + } + } +} diff --git a/moduliths-moments/src/main/java/org/moduliths/moments/support/TimeMachine.java b/moduliths-moments/src/main/java/org/moduliths/moments/support/TimeMachine.java new file mode 100644 index 00000000..c1952308 --- /dev/null +++ b/moduliths-moments/src/main/java/org/moduliths/moments/support/TimeMachine.java @@ -0,0 +1,63 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments.support; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; + +import org.springframework.context.ApplicationEventPublisher; + +/** + * Extension of {@link Moments} to publicly expose methods to shift time. + * + * @author Oliver Drotbohm + * @see #now() + * @see #shiftBy(Duration) + * @since 1.3 + */ +public class TimeMachine extends Moments { + + /** + * Creates a new {@link TimeMachine} for the given {@link Clock}, {@link ApplicationEventPublisher} and + * {@link MomentsProperties}. + * + * @param clock must not be {@literal null}. + * @param events must not be {@literal null}. + * @param properties must not be {@literal null}. + */ + public TimeMachine(Clock clock, ApplicationEventPublisher events, MomentsProperties properties) { + super(clock, events, properties); + } + + /* + * (non-Javadoc) + * @see org.moduliths.moments.support.Moments#now() + */ + @Override + public LocalDateTime now() { + return super.now(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.moments.support.Moments#shiftBy(java.time.Duration) + */ + @Override + public Moments shiftBy(Duration duration) { + return super.shiftBy(duration); + } +} diff --git a/moduliths-moments/src/main/resources/META-INF/spring.factories b/moduliths-moments/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..8f448a42 --- /dev/null +++ b/moduliths-moments/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + org.moduliths.moments.autoconfigure.MomentsAutoConfiguration diff --git a/moduliths-moments/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/moduliths-moments/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..e183efc1 --- /dev/null +++ b/moduliths-moments/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.moduliths.moments.autoconfigure.MomentsAutoConfiguration diff --git a/moduliths-moments/src/test/java/org/moduliths/moments/QuarterHasPassedUnitTests.java b/moduliths-moments/src/test/java/org/moduliths/moments/QuarterHasPassedUnitTests.java new file mode 100644 index 00000000..0b3ee176 --- /dev/null +++ b/moduliths-moments/src/test/java/org/moduliths/moments/QuarterHasPassedUnitTests.java @@ -0,0 +1,50 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.Month; +import java.time.Year; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link QuarterHasPassed}. + * + * @author Oliver Drotbohm + */ +class QuarterHasPassedUnitTests { + + @Test + void calculatesStartAndEndDateOfQuarter() { + + QuarterHasPassed event = QuarterHasPassed.of(Year.of(2022), Quarter.Q1); + + assertThat(event.getStartDate()).isEqualTo(LocalDate.of(2022, 1, 1)); + assertThat(event.getEndDate()).isEqualTo(LocalDate.of(2022, 3, 31)); + } + + @Test + void calculatesStartAndEndDateForShiftedQuarter() { + + QuarterHasPassed event = QuarterHasPassed.of(Year.of(2022), Quarter.Q1, Month.FEBRUARY); + + assertThat(event.getStartDate()).isEqualTo(LocalDate.of(2022, 2, 1)); + assertThat(event.getEndDate()).isEqualTo(LocalDate.of(2022, 4, 30)); + } +} diff --git a/moduliths-moments/src/test/java/org/moduliths/moments/QuarterUnitTests.java b/moduliths-moments/src/test/java/org/moduliths/moments/QuarterUnitTests.java new file mode 100644 index 00000000..238b3f10 --- /dev/null +++ b/moduliths-moments/src/test/java/org/moduliths/moments/QuarterUnitTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments; + +import static org.assertj.core.api.Assertions.*; +import static org.moduliths.moments.Quarter.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +/** + * Unit tests for {@link Quarter}. + * + * @author Oliver Drotbohm + */ +class QuarterUnitTests { + + @TestFactory + Stream calculatesNextQuarterCorrectly() { + + Map mappings = new HashMap<>(); + mappings.put(Q1, Q2); + mappings.put(Q2, Q3); + mappings.put(Q3, Q4); + mappings.put(Q4, Q1); + + return DynamicTest.stream(mappings.entrySet().iterator(), + it -> String.format("%s follows %s", it.getValue(), it.getKey()), + it -> assertThat(it.getKey().next()).isEqualTo(it.getValue())); + } +} diff --git a/moduliths-moments/src/test/java/org/moduliths/moments/WeekHasPassedUnitTests.java b/moduliths-moments/src/test/java/org/moduliths/moments/WeekHasPassedUnitTests.java new file mode 100644 index 00000000..b8c9d87e --- /dev/null +++ b/moduliths-moments/src/test/java/org/moduliths/moments/WeekHasPassedUnitTests.java @@ -0,0 +1,45 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.Year; +import java.time.temporal.WeekFields; +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link WeekHasPassed}. + * + * @author Oliver Drotbohm + */ +class WeekHasPassedUnitTests { + + @Test + void returnsStartDateForWeek() { + + int week = LocalDate.of(2022, 2, 17) + .get(WeekFields.of(Locale.getDefault()).weekOfYear()); + + WeekHasPassed event = WeekHasPassed.of(Year.of(2022), week); + + assertThat(event.getStartDate()).isEqualTo(LocalDate.of(2022, 2, 14)); + assertThat(event.getEndDate()).isEqualTo(LocalDate.of(2022, 2, 20)); + } +} diff --git a/moduliths-moments/src/test/java/org/moduliths/moments/YearHasPassedUnitTests.java b/moduliths-moments/src/test/java/org/moduliths/moments/YearHasPassedUnitTests.java new file mode 100644 index 00000000..fb9113de --- /dev/null +++ b/moduliths-moments/src/test/java/org/moduliths/moments/YearHasPassedUnitTests.java @@ -0,0 +1,40 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.Month; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link YearHasPassed}. + * + * @author Oliver Drotbohm + */ +class YearHasPassedUnitTests { + + @Test + void returnStartAndEndDateForYear() { + + YearHasPassed event = YearHasPassed.of(2022); + + assertThat(event.getStartDate()).isEqualTo(LocalDate.of(2022, Month.JANUARY, 1)); + assertThat(event.getEndDate()).isEqualTo(LocalDate.of(2022, Month.DECEMBER, 31)); + } +} diff --git a/moduliths-moments/src/test/java/org/moduliths/moments/autoconfigure/MomentsAutoConfigurationTests.java b/moduliths-moments/src/test/java/org/moduliths/moments/autoconfigure/MomentsAutoConfigurationTests.java new file mode 100644 index 00000000..9bc47d51 --- /dev/null +++ b/moduliths-moments/src/test/java/org/moduliths/moments/autoconfigure/MomentsAutoConfigurationTests.java @@ -0,0 +1,97 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments.autoconfigure; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.ZoneId; + +import org.junit.jupiter.api.Test; +import org.moduliths.moments.Quarter; +import org.moduliths.moments.support.Moments; +import org.moduliths.moments.support.MomentsProperties; +import org.moduliths.moments.support.TimeMachine; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * Integration tests for {@link MomentsAutoConfiguration}. + * + * @author Oliver Drotbohm + */ +class MomentsAutoConfigurationTests { + + @Test + void bootstrapsAutoConfiguration() { + + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MomentsAutoConfiguration.class)) + .run(it -> { + assertThat(it).hasSingleBean(Moments.class) + .doesNotHaveBean(TimeMachine.class); + }); + } + + @Test + void customizesTimeZone() { + + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MomentsAutoConfiguration.class)) + .withPropertyValues("moduliths.moments.zone-id:Europe/Berlin") + .run(it -> { + assertThat(it).getBean(MomentsProperties.class) + .extracting(MomentsProperties::getZoneId) + .isEqualTo(ZoneId.of("Europe/Berlin")); + }); + } + + @Test + void shiftsQuarterIfConfigured() { + + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MomentsAutoConfiguration.class)) + .withPropertyValues("moduliths.moments.quarter-start-month=February") + .run(it -> { + assertThat(it).getBean(MomentsProperties.class).satisfies(props -> { + assertThat(props.getShiftedQuarter(LocalDate.of(2022, 1, 1)).getQuarter()).isEqualTo(Quarter.Q4); + }); + }); + } + + @Test + void doesNotRegisterMomentsBeanIfDisable() { + + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MomentsAutoConfiguration.class)) + .withPropertyValues("moduliths.moments.enabled=false") + .run(it -> { + assertThat(it).doesNotHaveBean(Moments.class); + assertThat(it).doesNotHaveBean(MomentsProperties.class); + }); + } + + @Test + void exposesTimeMachineIfEnabledExplicitly() { + + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MomentsAutoConfiguration.class)) + .withPropertyValues("moduliths.moments.enable-time-machine=true") + .run(it -> { + assertThat(it).hasSingleBean(TimeMachine.class); + }); + } +} diff --git a/moduliths-moments/src/test/java/org/moduliths/moments/support/MomentsUnitTests.java b/moduliths-moments/src/test/java/org/moduliths/moments/support/MomentsUnitTests.java new file mode 100644 index 00000000..94556c52 --- /dev/null +++ b/moduliths-moments/src/test/java/org/moduliths/moments/support/MomentsUnitTests.java @@ -0,0 +1,187 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.moments.support; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Year; +import java.time.temporal.ChronoUnit; +import java.time.temporal.WeekFields; +import java.util.Locale; + +import org.junit.jupiter.api.Test; +import org.moduliths.moments.DayHasPassed; +import org.moduliths.moments.HourHasPassed; +import org.moduliths.moments.MonthHasPassed; +import org.moduliths.moments.QuarterHasPassed; +import org.moduliths.moments.ShiftedQuarter; +import org.moduliths.moments.WeekHasPassed; +import org.moduliths.moments.YearHasPassed; +import org.moduliths.moments.support.MomentsProperties.Granularity; +import org.springframework.context.ApplicationEventPublisher; + +/** + * Unit tests for {@link Moments}. + * + * @author Oliver Drotbohm + */ +class MomentsUnitTests { + + ApplicationEventPublisher events = mock(ApplicationEventPublisher.class); + Clock clock = Clock.systemUTC(); + + Moments hourly = new Moments(clock, events, MomentsProperties.DEFAULTS); + Moments daily = new Moments(clock, events, MomentsProperties.DEFAULTS.withGranularity(Granularity.DAYS)); + + @Test + void emitsHourlyEventOnTimeShift() { + + hourly.shiftBy(Duration.ofDays(1)); + + verify(events, times(1)).publishEvent(any(DayHasPassed.class)); + verify(events, times(24)).publishEvent(any(HourHasPassed.class)); + } + + @Test + void onlyEmitsDailyEventOnTimeShiftIfConfigured() { + + daily.shiftBy(Duration.ofDays(1)); + + verify(events, times(1)).publishEvent(any(DayHasPassed.class)); + verify(events, never()).publishEvent(any(HourHasPassed.class)); + } + + @Test + void emitsMonthHasPassedForShiftAcrossMonths() { + + LocalDate now = LocalDate.now(); + int numberOfDaysIntoNextMonth = (now.lengthOfMonth() - now.getDayOfMonth()) + 1; + Duration shift = Duration.ofDays(numberOfDaysIntoNextMonth); + + daily.shiftBy(shift); + + verify(events, times(numberOfDaysIntoNextMonth)).publishEvent(any(DayHasPassed.class)); + verify(events, times(1)).publishEvent(any(MonthHasPassed.class)); + } + + @Test + void doesNotEmitAnyEventsOnNegativeTimeShift() { + + hourly.shiftBy(Duration.ofDays(-1)); + + verifyNoInteractions(events); + } + + @Test + void emitsHourHasPassedOnScheduledMethod() { + + hourly.everyHour(); + + verify(events, times(1)).publishEvent(any(HourHasPassed.class)); + } + + @Test + void emitsDayHasPassedOnScheduledMethod() { + + hourly.everyMidnight(); + + verify(events, times(1)).publishEvent(any(DayHasPassed.class)); + } + + @Test + void emitsWeekHasPassedIfWeekIsExceeded() { + + LocalDate now = LocalDate.now(); + int weekOfYear = now.get(WeekFields.of(Locale.getDefault()).weekOfYear()); + + daily.shiftBy(Duration.ofDays(7)); + + WeekHasPassed reference = WeekHasPassed.of(Year.from(now), weekOfYear, Locale.getDefault()); + + verify(events, times(1)).publishEvent(eq(reference)); + } + + @Test + void emitsWeekHasPassedWithCustomLocaleIfConfigured() { + + Locale locale = Locale.GERMAN; + MomentsProperties properties = MomentsProperties.DEFAULTS.withLocale(locale); + + LocalDate now = LocalDate.now(); + int weekOfYear = now.get(WeekFields.of(locale).weekOfYear()); + + new Moments(clock, events, properties).shiftBy(Duration.ofDays(7)); + + WeekHasPassed reference = WeekHasPassed.of(Year.from(now), weekOfYear, locale); + + verify(events, times(1)).publishEvent(eq(reference)); + } + + @Test + void emitsQuarterHasPassed() { + + LocalDate now = LocalDate.now(); + Duration duration = getNumberOfDaysForThreeMonth(now); + + ShiftedQuarter quarter = MomentsProperties.DEFAULTS // + .withGranularity(Granularity.DAYS) // + .getShiftedQuarter(now); + + daily.shiftBy(duration); + + QuarterHasPassed reference = QuarterHasPassed.of(Year.from(now), quarter); + + verify(events, times(1)).publishEvent(eq(reference)); + } + + @Test + void emitsYearHasPassed() { + + daily.shiftBy(Duration.ofDays(365)); + + YearHasPassed reference = YearHasPassed.of(Year.now()); + + verify(events, times(1)).publishEvent(eq(reference)); + } + + @Test + void shiftsTimeForDuration() { + + Duration duration = Duration.ofHours(4); + LocalDateTime before = hourly.now(); + LocalDateTime after = hourly.shiftBy(duration).now(); + + assertThat(before.plus(duration)).isCloseTo(after, within(200, ChronoUnit.MILLIS)); + } + + private Duration getNumberOfDaysForThreeMonth(LocalDate date) { + + int days = 0; + + for (int i = 0; i < 3; i++) { + days += date.lengthOfMonth(); + date = date.plusDays(days); + } + + return Duration.ofDays(days); + } +} diff --git a/moduliths-moments/src/test/resources/logback.xml b/moduliths-moments/src/test/resources/logback.xml new file mode 100644 index 00000000..12e067d7 --- /dev/null +++ b/moduliths-moments/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + + + \ No newline at end of file diff --git a/moduliths-observability/pom.xml b/moduliths-observability/pom.xml new file mode 100644 index 00000000..0b1505fa --- /dev/null +++ b/moduliths-observability/pom.xml @@ -0,0 +1,76 @@ + + 4.0.0 + + + org.moduliths + moduliths + 1.4.0-SNAPSHOT + + + moduliths-observability + + Moduliths - Observability + + + org.moduliths.observability + 2021.0.0 + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.moduliths + moduliths-core + ${project.version} + + + + org.springframework.boot + spring-boot-autoconfigure + true + + + + org.springframework.cloud + spring-cloud-sleuth-api + + + + org.springframework.cloud + spring-cloud-sleuth-brave + true + + + + org.springframework.data + spring-data-rest-webmvc + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.cloud + spring-cloud-starter-sleuth + test + + + + + \ No newline at end of file diff --git a/moduliths-observability/src/main/java/org/moduliths/observability/ApplicationRuntime.java b/moduliths-observability/src/main/java/org/moduliths/observability/ApplicationRuntime.java new file mode 100644 index 00000000..bc4ac8fc --- /dev/null +++ b/moduliths-observability/src/main/java/org/moduliths/observability/ApplicationRuntime.java @@ -0,0 +1,58 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability; + +/** + * Abstraction of the application runtime environment. Primarily to keep references to Spring Boot out of the core + * observability implementation. + * + * @author Oliver Drotbohm + */ +public interface ApplicationRuntime { + + /** + * Returns the identifier of the application. + * + * @return + */ + String getId(); + + /** + * Returns the primary application class. + * + * @return + */ + Class getMainApplicationClass(); + + /** + * Obtain the end user class for the given bean and bean name. Necessary to reveal the actual user type from + * potentially proxied instances. + * + * @param bean + * @param beanName + * @return + */ + Class getUserClass(Object bean, String beanName); + + /** + * Returns whether the given type is an application class, i.e. user code in one of the application packages. + * + * @param type + * @return + * @see #getApplicationPackages() + */ + boolean isApplicationClass(Class type); +} diff --git a/moduliths-observability/src/main/java/org/moduliths/observability/DefaultObservedModule.java b/moduliths-observability/src/main/java/org/moduliths/observability/DefaultObservedModule.java new file mode 100644 index 00000000..90d41602 --- /dev/null +++ b/moduliths-observability/src/main/java/org/moduliths/observability/DefaultObservedModule.java @@ -0,0 +1,141 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability; + +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import org.aopalliance.intercept.MethodInvocation; +import org.moduliths.model.FormatableJavaClass; +import org.moduliths.model.Module; +import org.moduliths.model.Modules; +import org.moduliths.model.SpringBean; +import org.springframework.aop.ProxyMethodInvocation; +import org.springframework.aop.framework.Advised; + +import com.tngtech.archunit.core.domain.JavaClass; + +@RequiredArgsConstructor +class DefaultObservedModule implements ObservedModule { + + private final Module module; + + /* + * (non-Javadoc) + * @see org.moduliths.observability.ObservedModule#getName() + */ + @Override + public String getName() { + return module.getName(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.observability.ObservedModule#getDisplayName() + */ + @Override + public String getDisplayName() { + return module.getDisplayName(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.observability.ObservedModule#getInvokedMethod(org.aopalliance.intercept.MethodInvocation) + */ + @Override + public String getInvokedMethod(MethodInvocation invocation) { + + Method method = invocation.getMethod(); + + if (module.contains(method.getDeclaringClass())) { + return toString(invocation.getMethod(), module); + } + + if (!ProxyMethodInvocation.class.isInstance(invocation)) { + return toString(invocation.getMethod(), module); + } + + // For class-based proxies, use the target class + + Advised advised = (Advised) ((ProxyMethodInvocation) invocation).getProxy(); + Class targetClass = advised.getTargetClass(); + + if (module.contains(targetClass)) { + return toString(targetClass, method, module); + } + + // For JDK proxies, find original interface the method was logically declared on + + for (Class type : advised.getProxiedInterfaces()) { + if (module.contains(type)) { + if (Arrays.asList(type.getMethods()).contains(method)) { + return toString(type, method, module); + } + } + } + + return toString(invocation.getMethod(), module); + } + + /* + * (non-Javadoc) + * @see org.moduliths.observability.ObservedModule#exposes(com.tngtech.archunit.core.domain.JavaClass) + */ + @Override + public boolean exposes(JavaClass type) { + return module.isExposed(type); + } + + /* + * (non-Javadoc) + * @see org.moduliths.observability.ObservedModule#isObservedModule(org.moduliths.model.Module) + */ + @Override + public boolean isObservedModule(Module module) { + return this.module.equals(module); + } + + /* + * (non-Javadoc) + * @see org.moduliths.observability.ObservedModule#getInterceptionConfiguration(java.lang.Class, org.moduliths.model.Modules) + */ + public ObservedModuleType getObservedModuleType(Class type, Modules modules) { + + return module.getSpringBeans().stream() + .filter(it -> it.getFullyQualifiedTypeName().equals(type.getName())) + .map(SpringBean::toArchitecturallyEvidentType) + .findFirst() + .map(it -> new ObservedModuleType(modules, this, it)) + .filter(ObservedModuleType::shouldBeTraced) + .orElse(null); + } + + private static String toString(Method method, Module module) { + return toString(method.getDeclaringClass(), method, module); + } + + private static String toString(Class type, Method method, Module module) { + + String typeName = module.getType(type.getName()) + .map(FormatableJavaClass::of) + .map(FormatableJavaClass::getAbbreviatedFullName) + .orElseGet(() -> type.getName()); + + return String.format("%s.%s(…)", typeName, method.getName()); + } +} diff --git a/moduliths-observability/src/main/java/org/moduliths/observability/ModuleEntryInterceptor.java b/moduliths-observability/src/main/java/org/moduliths/observability/ModuleEntryInterceptor.java new file mode 100644 index 00000000..e0c52d30 --- /dev/null +++ b/moduliths-observability/src/main/java/org/moduliths/observability/ModuleEntryInterceptor.java @@ -0,0 +1,93 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.Map; + +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.cloud.sleuth.BaggageInScope; +import org.springframework.cloud.sleuth.Span; +import org.springframework.cloud.sleuth.Tracer; +import org.springframework.cloud.sleuth.Tracer.SpanInScope; + +@Slf4j +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +class ModuleEntryInterceptor implements MethodInterceptor { + + private static Map CACHE = new HashMap<>(); + + private final ObservedModule module; + private final Tracer tracer; + + public static ModuleEntryInterceptor of(ObservedModule module, Tracer tracer) { + + String name = module.getName(); + + return CACHE.computeIfAbsent(name, __ -> { + return new ModuleEntryInterceptor(module, tracer); + }); + } + + /* + * (non-Javadoc) + * @see org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept.MethodInvocation) + */ + @Override + public Object invoke(MethodInvocation invocation) throws Throwable { + + String moduleName = module.getName(); + Span currentSpan = tracer.currentSpan(); + + if (currentSpan != null) { + + BaggageInScope currentBaggage = tracer.getBaggage(ModuleTracingBeanPostProcessor.MODULE_BAGGAGE_KEY); + + if (currentBaggage != null && moduleName.equals(currentBaggage.get())) { + return invocation.proceed(); + } + } + + String invokedMethod = module.getInvokedMethod(invocation); + + LOG.trace("Entering {} via {}.", module.getDisplayName(), invokedMethod); + + Span span = tracer.spanBuilder() + .name(moduleName) + .tag("module.method", invokedMethod) + .tag(ModuleTracingBeanPostProcessor.MODULE_BAGGAGE_KEY, moduleName) + .start(); + + try ( + SpanInScope ws = tracer.withSpan(span); // + BaggageInScope baggage = tracer.createBaggage(ModuleTracingBeanPostProcessor.MODULE_BAGGAGE_KEY, moduleName); // + ) { + + return invocation.proceed(); + + } finally { + + LOG.trace("Leaving {}", module.getDisplayName()); + + span.end(); + } + } +} diff --git a/moduliths-observability/src/main/java/org/moduliths/observability/ModuleEventListener.java b/moduliths-observability/src/main/java/org/moduliths/observability/ModuleEventListener.java new file mode 100644 index 00000000..d86d71c7 --- /dev/null +++ b/moduliths-observability/src/main/java/org/moduliths/observability/ModuleEventListener.java @@ -0,0 +1,73 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability; + +import lombok.RequiredArgsConstructor; + +import java.util.function.Supplier; + +import org.moduliths.model.Module; +import org.springframework.cloud.sleuth.Span; +import org.springframework.cloud.sleuth.Tracer; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.context.PayloadApplicationEvent; + +/** + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor +public class ModuleEventListener implements ApplicationListener { + + private final ModulesRuntime modules; + private final Supplier tracer; + + /* + * (non-Javadoc) + * @see org.springframework.context.ApplicationListener#onApplicationEvent(org.springframework.context.ApplicationEvent) + */ + @Override + public void onApplicationEvent(ApplicationEvent event) { + + if (!PayloadApplicationEvent.class.isInstance(event)) { + return; + } + + PayloadApplicationEvent foo = (PayloadApplicationEvent) event; + Object object = foo.getPayload(); + Class payloadType = object.getClass(); + + if (!modules.isApplicationClass(payloadType)) { + return; + } + + Module moduleByType = modules.get() + .getModuleByType(payloadType.getSimpleName()) + .orElse(null); + + if (moduleByType == null) { + return; + } + + Span span = tracer.get().currentSpan(); + + if (span == null) { + return; + } + + span.event("Published " + payloadType.getName()); + } +} diff --git a/moduliths-observability/src/main/java/org/moduliths/observability/ModuleTracingBeanPostProcessor.java b/moduliths-observability/src/main/java/org/moduliths/observability/ModuleTracingBeanPostProcessor.java new file mode 100644 index 00000000..95c55e2f --- /dev/null +++ b/moduliths-observability/src/main/java/org/moduliths/observability/ModuleTracingBeanPostProcessor.java @@ -0,0 +1,109 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability; + +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +import org.aopalliance.aop.Advice; +import org.moduliths.model.Modules; +import org.springframework.aop.Advisor; +import org.springframework.aop.MethodMatcher; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.ComposablePointcut; +import org.springframework.aop.support.DefaultPointcutAdvisor; +import org.springframework.aop.support.StaticMethodMatcher; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.cloud.sleuth.Tracer; + +/** + * @author Oliver Drotbohm + */ +public class ModuleTracingBeanPostProcessor extends ModuleTracingSupport implements BeanPostProcessor { + + public static final String MODULE_BAGGAGE_KEY = "org.moduliths.module"; + + private final ApplicationRuntime runtime; + private final Tracer tracer; + private final Map advisors = new HashMap<>(); + + public ModuleTracingBeanPostProcessor(ApplicationRuntime runtime, Tracer tracer) { + + super(runtime); + + this.runtime = runtime; + this.tracer = tracer; + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.config.BeanPostProcessor#postProcessAfterInitialization(java.lang.Object, java.lang.String) + */ + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + + Class type = getBeanUserClass(bean, beanName); + + if (!runtime.isApplicationClass(type) || !type.isInstance(bean)) { + return bean; + } + + Modules modules = getModules(); + + return modules.getModuleByType(type.getName()) + .map(DefaultObservedModule::new) + .map(it -> { + + ObservedModuleType moduleType = it.getObservedModuleType(type, modules); + + return moduleType != null // + ? addAdvisor(bean, getOrBuildAdvisor(it, moduleType)) // + : bean; + + }).orElse(bean); + } + + private Advisor getOrBuildAdvisor(ObservedModule module, ObservedModuleType type) { + + return advisors.computeIfAbsent(module.getName(), __ -> { + + Advice interceptor = ModuleEntryInterceptor.of(module, tracer); + MethodMatcher matcher = new ObservableTypeMethodMatcher(type); + Pointcut pointcut = new ComposablePointcut(matcher); + + return new DefaultPointcutAdvisor(pointcut, interceptor); + }); + } + + @RequiredArgsConstructor + private static class ObservableTypeMethodMatcher extends StaticMethodMatcher { + + private final ObservedModuleType type; + + /* + * (non-Javadoc) + * @see org.springframework.aop.MethodMatcher#matches(java.lang.reflect.Method, java.lang.Class) + */ + @Override + public boolean matches(Method method, Class targetClass) { + return type.getMethodsToIntercept().test(method); + } + } +} diff --git a/moduliths-observability/src/main/java/org/moduliths/observability/ModuleTracingSupport.java b/moduliths-observability/src/main/java/org/moduliths/observability/ModuleTracingSupport.java new file mode 100644 index 00000000..49c63bc6 --- /dev/null +++ b/moduliths-observability/src/main/java/org/moduliths/observability/ModuleTracingSupport.java @@ -0,0 +1,87 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.moduliths.model.Modules; +import org.springframework.aop.Advisor; +import org.springframework.aop.framework.Advised; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.util.Assert; + +/** + * @author Oliver Drotbohm + */ +class ModuleTracingSupport implements BeanClassLoaderAware { + + private final Supplier modules; + private final ApplicationRuntime context; + private ClassLoader classLoader; + + protected ModuleTracingSupport(ApplicationRuntime context) { + + Assert.notNull(context, "ApplicationContext must not be null!"); + + this.modules = ModulesRuntime.of(context); + this.context = context; + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.BeanClassLoaderAware#setBeanClassLoader(java.lang.ClassLoader) + */ + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.classLoader = classLoader; + } + + protected final Modules getModules() { + + try { + return modules.get(); + } catch (Exception o_O) { + throw new RuntimeException(o_O); + } + } + + protected final Class getBeanUserClass(Object bean, String beanName) { + return context.getUserClass(bean, beanName); + } + + protected final Object addAdvisor(Object bean, Advisor advisor) { + return addAdvisor(bean, advisor, __ -> {}); + } + + protected final Object addAdvisor(Object bean, Advisor advisor, Consumer customizer) { + + if (Advised.class.isInstance(bean)) { + + ((Advised) bean).addAdvisor(0, advisor); + return bean; + + } else { + + ProxyFactory factory = new ProxyFactory(bean); + customizer.accept(factory); + factory.addAdvisor(advisor); + + return factory.getProxy(classLoader); + } + } +} diff --git a/moduliths-observability/src/main/java/org/moduliths/observability/ModulesRuntime.java b/moduliths-observability/src/main/java/org/moduliths/observability/ModulesRuntime.java new file mode 100644 index 00000000..5e95deec --- /dev/null +++ b/moduliths-observability/src/main/java/org/moduliths/observability/ModulesRuntime.java @@ -0,0 +1,77 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability; + +import lombok.RequiredArgsConstructor; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Supplier; + +import org.moduliths.model.Modules; + +/** + * Bootstrap type to make sure we only bootstrap the initialization of a {@link Modules} instance per application class + * once. + * + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor +public class ModulesRuntime implements Supplier { + + private static final Map MODULES = new HashMap<>(); + + private final Supplier modules; + private final ApplicationRuntime runtime; + + /* + * (non-Javadoc) + * @see java.util.function.Supplier#get() + */ + @Override + public Modules get() { + return modules.get(); + } + + boolean isApplicationClass(Class type) { + return runtime.isApplicationClass(type); + } + + public static ModulesRuntime of(ApplicationRuntime runtime) { + + return MODULES.computeIfAbsent(runtime.getId(), it -> { + + Class mainClass = runtime.getMainApplicationClass(); + Future modules = Executors.newFixedThreadPool(1).submit(() -> Modules.of(mainClass)); + + return new ModulesRuntime(toSupplier(modules), runtime); + }); + } + + private static Supplier toSupplier(Future modules) { + + return () -> { + try { + return modules.get(); + } catch (Exception o_O) { + throw new RuntimeException(o_O); + // TODO: handle exception + } + }; + } +} diff --git a/moduliths-observability/src/main/java/org/moduliths/observability/ObservedModule.java b/moduliths-observability/src/main/java/org/moduliths/observability/ObservedModule.java new file mode 100644 index 00000000..eca03c34 --- /dev/null +++ b/moduliths-observability/src/main/java/org/moduliths/observability/ObservedModule.java @@ -0,0 +1,61 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability; + +import java.lang.reflect.Method; + +import org.aopalliance.intercept.MethodInvocation; +import org.moduliths.model.Module; +import org.moduliths.model.Modules; + +import com.tngtech.archunit.core.domain.JavaClass; + +/** + * @author Oliver Drotbohm + */ +interface ObservedModule { + + String getName(); + + String getDisplayName(); + + /** + * Returns the name of the actually invoked {@link Method}. + * + * @param invocation must not be {@literal null}. + * @return + */ + String getInvokedMethod(MethodInvocation invocation); + + /** + * Returns whether the {@link ObservedModule} exposes the given {@link JavaClass}. + * + * @param type + * @return + */ + boolean exposes(JavaClass type); + + boolean isObservedModule(Module module); + + /** + * Returns the {@link ObservedModuleType} for the given type and {@link Modules}. + * + * @param type + * @param modules + * @return + */ + ObservedModuleType getObservedModuleType(Class type, Modules modules); +} diff --git a/moduliths-observability/src/main/java/org/moduliths/observability/ObservedModuleType.java b/moduliths-observability/src/main/java/org/moduliths/observability/ObservedModuleType.java new file mode 100644 index 00000000..2929acfb --- /dev/null +++ b/moduliths-observability/src/main/java/org/moduliths/observability/ObservedModuleType.java @@ -0,0 +1,85 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability; + +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Method; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import org.moduliths.model.ArchitecturallyEvidentType; +import org.moduliths.model.ArchitecturallyEvidentType.ReferenceMethod; +import org.moduliths.model.Modules; + +/** + * Represents a type in an {@link ObservedModule}. + * + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor +public class ObservedModuleType { + + private final Modules modules; + private final ObservedModule module; + private final ArchitecturallyEvidentType type; + + /** + * Returns whether the type should be traced at all. Can be skipped for types not exposed by the module unless they + * listen to events of other modules. + * + * @return + */ + public boolean shouldBeTraced() { + + boolean isApiType = module.exposes(type.getType()); + + return type.isController() || listensToOtherModulesEvents() || isApiType; + } + + /** + * Returns a predicate to filter the methods to intercept. For event listeners it's the listener methods only. For + * everything else, all (public) methods will be intercepted. + * + * @return + */ + public Predicate getMethodsToIntercept() { + + if (!type.isEventListener()) { + return it -> true; + } + + return candidate -> type.getReferenceMethods() // + .map(ReferenceMethod::getMethod) // + .anyMatch(it -> it.reflect().equals(candidate)); + } + + private boolean listensToOtherModulesEvents() { + + if (!type.isEventListener()) { + return false; + } + + return type.getReferenceTypes() + .flatMap(it -> modules + .getModuleByType(it) + .map(Stream::of) + .orElseGet(Stream::empty)) + .findFirst() + .map(it -> !module.isObservedModule(it)) + .orElse(true); + } +} diff --git a/moduliths-observability/src/main/java/org/moduliths/observability/SpringDataRestModuleTracingBeanPostProcessor.java b/moduliths-observability/src/main/java/org/moduliths/observability/SpringDataRestModuleTracingBeanPostProcessor.java new file mode 100644 index 00000000..37d98a32 --- /dev/null +++ b/moduliths-observability/src/main/java/org/moduliths/observability/SpringDataRestModuleTracingBeanPostProcessor.java @@ -0,0 +1,110 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability; + +import lombok.RequiredArgsConstructor; + +import org.aopalliance.aop.Advice; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.moduliths.model.Module; +import org.moduliths.model.Modules; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.DefaultPointcutAdvisor; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.cloud.sleuth.Tracer; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.data.rest.webmvc.BasePathAwareController; +import org.springframework.data.rest.webmvc.RootResourceInformation; + +/** + * @author Oliver Drotbohm + */ +public class SpringDataRestModuleTracingBeanPostProcessor extends ModuleTracingSupport implements BeanPostProcessor { + + private final Tracer tracer; + private final ApplicationRuntime runtime; + + public SpringDataRestModuleTracingBeanPostProcessor(ApplicationRuntime runtime, Tracer tracer) { + + super(runtime); + + this.tracer = tracer; + this.runtime = runtime; + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.config.BeanPostProcessor#postProcessAfterInitialization(java.lang.Object, java.lang.String) + */ + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + + Class type = runtime.getUserClass(bean, beanName); + + if (!AnnotatedElementUtils.hasAnnotation(type, BasePathAwareController.class)) { + return bean; + } + + Advice interceptor = new DataRestControllerInterceptor(getModules(), tracer); + Advisor advisor = new DefaultPointcutAdvisor(interceptor); + + return addAdvisor(bean, advisor, it -> it.setProxyTargetClass(true)); + } + + @RequiredArgsConstructor + private static class DataRestControllerInterceptor implements MethodInterceptor { + + private final Modules modules; + private final Tracer tracer; + + /* + * (non-Javadoc) + * @see org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept.MethodInvocation) + */ + @Override + public Object invoke(MethodInvocation invocation) throws Throwable { + + Module module = getModuleFrom(invocation.getArguments()); + + if (module == null) { + return invocation.proceed(); + } + + ObservedModule observed = new DefaultObservedModule(module); + + return ModuleEntryInterceptor.of(observed, tracer).invoke(invocation); + } + + private Module getModuleFrom(Object[] arguments) { + + for (Object argument : arguments) { + + if (!RootResourceInformation.class.isInstance(arguments)) { + continue; + } + + RootResourceInformation info = (RootResourceInformation) argument; + + return modules.getModuleByType(info.getDomainType().getName()).orElse(null); + } + + return null; + } + } + +} diff --git a/moduliths-observability/src/main/java/org/moduliths/observability/autoconfigure/ModuleObservabilityAutoConfiguration.java b/moduliths-observability/src/main/java/org/moduliths/observability/autoconfigure/ModuleObservabilityAutoConfiguration.java new file mode 100644 index 00000000..98012105 --- /dev/null +++ b/moduliths-observability/src/main/java/org/moduliths/observability/autoconfigure/ModuleObservabilityAutoConfiguration.java @@ -0,0 +1,106 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability.autoconfigure; + +import brave.TracingCustomizer; +import brave.baggage.BaggageField; +import brave.baggage.BaggagePropagationConfig; +import brave.baggage.BaggagePropagationCustomizer; +import brave.handler.MutableSpan; +import brave.handler.SpanHandler; +import brave.propagation.TraceContext; + +import org.moduliths.observability.ApplicationRuntime; +import org.moduliths.observability.ModuleEventListener; +import org.moduliths.observability.ModuleTracingBeanPostProcessor; +import org.moduliths.observability.ModulesRuntime; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.cloud.sleuth.Tracer; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Oliver Drotbohm + */ +@Configuration(proxyBeanMethods = false) +class ModuleObservabilityAutoConfiguration { + + @Bean + static SpringBootApplicationRuntime modulithsApplicationRuntime(ApplicationContext context) { + return new SpringBootApplicationRuntime(context); + } + + @Bean + static ModuleTracingBeanPostProcessor moduleTracingBeanPostProcessor(ApplicationRuntime runtime, + Tracer tracer) { + return new ModuleTracingBeanPostProcessor(runtime, tracer); + } + + @Bean + static ModuleEventListener tracingModuleEventListener(ApplicationRuntime runtime, ObjectProvider tracer) { + return new ModuleEventListener(ModulesRuntime.of(runtime), () -> tracer.getObject()); + } + + /** + * Brave-specific auto configuration. + * + * @author Oliver Drotbohm + */ + @ConditionalOnClass(TracingCustomizer.class) + static class ModulithsBraveIntegrationAutoConfiguration { + + @Bean + BaggagePropagationCustomizer moduleBaggagePropagationCustomizer() { + + return builder -> builder + .add(BaggagePropagationConfig.SingleBaggageField + .local(BaggageField.create(ModuleTracingBeanPostProcessor.MODULE_BAGGAGE_KEY))); + } + + @Bean + SpanHandler spanHandler() { + + return new SpanHandler() { + + /* + * (non-Javadoc) + * @see brave.handler.SpanHandler#end(brave.propagation.TraceContext, brave.handler.MutableSpan, brave.handler.SpanHandler.Cause) + */ + @Override + public boolean end(TraceContext context, MutableSpan span, Cause cause) { + + String value = span.tag(ModuleTracingBeanPostProcessor.MODULE_BAGGAGE_KEY); + + if (value != null) { + span.localServiceName(value); + return true; + } + + BaggageField field = BaggageField.getByName(context, ModuleTracingBeanPostProcessor.MODULE_BAGGAGE_KEY); + value = field.getValue(); + + if (value != null) { + span.localServiceName(value); + } + + return true; + } + }; + } + } +} diff --git a/moduliths-observability/src/main/java/org/moduliths/observability/autoconfigure/SpringBootApplicationRuntime.java b/moduliths-observability/src/main/java/org/moduliths/observability/autoconfigure/SpringBootApplicationRuntime.java new file mode 100644 index 00000000..52640065 --- /dev/null +++ b/moduliths-observability/src/main/java/org/moduliths/observability/autoconfigure/SpringBootApplicationRuntime.java @@ -0,0 +1,92 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability.autoconfigure; + +import lombok.RequiredArgsConstructor; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.moduliths.observability.ApplicationRuntime; +import org.springframework.boot.autoconfigure.AutoConfigurationPackages; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationContext; +import org.springframework.util.ClassUtils; + +/** + * @author Oliver Drotbohm + */ +@RequiredArgsConstructor +class SpringBootApplicationRuntime implements ApplicationRuntime { + + private static final Map APPLICATION_CLASSES = new ConcurrentHashMap<>(); + + private final ApplicationContext context; + + /* + * (non-Javadoc) + * @see org.moduliths.observability.ApplicationRuntime#getId() + */ + @Override + public String getId() { + return context.getId(); + } + + /* + * (non-Javadoc) + * @see org.moduliths.observability.ApplicationRuntime#getApplicationClass() + */ + @Override + public Class getMainApplicationClass() { + + String[] mainBeanNames = context.getBeanNamesForAnnotation(SpringBootApplication.class); + + return context.getType(mainBeanNames[0]); + } + + /* + * (non-Javadoc) + * @see org.moduliths.observability.ApplicationRuntime#getBeanUserClass(java.lang.Object, java.lang.String) + */ + @Override + public Class getUserClass(Object bean, String beanName) { + + Class beanType = context.containsBean(beanName) + ? context.getType(beanName) + : bean.getClass(); + + return ClassUtils.getUserClass(beanType); + } + + /* + * (non-Javadoc) + * @see org.moduliths.observability.ApplicationRuntime#isApplicationClass(java.lang.Class) + */ + @Override + public boolean isApplicationClass(Class type) { + + return APPLICATION_CLASSES.computeIfAbsent(type.getName(), + it -> { + + if (it.startsWith("org.springframework")) { + return false; + } + + return it.startsWith(getMainApplicationClass().getPackage().getName()) + || AutoConfigurationPackages.get(context).stream().anyMatch(pkg -> it.startsWith(pkg)); + }); + } +} diff --git a/moduliths-observability/src/main/java/org/moduliths/observability/autoconfigure/SpringDataRestModuleObservabilityAutoConfiguration.java b/moduliths-observability/src/main/java/org/moduliths/observability/autoconfigure/SpringDataRestModuleObservabilityAutoConfiguration.java new file mode 100644 index 00000000..5699312c --- /dev/null +++ b/moduliths-observability/src/main/java/org/moduliths/observability/autoconfigure/SpringDataRestModuleObservabilityAutoConfiguration.java @@ -0,0 +1,38 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability.autoconfigure; + +import org.moduliths.observability.ApplicationRuntime; +import org.moduliths.observability.SpringDataRestModuleTracingBeanPostProcessor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.cloud.sleuth.Tracer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.rest.webmvc.RepositoryController; + +/** + * @author Oliver Drotbohm + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(RepositoryController.class) +class SpringDataRestModuleObservabilityAutoConfiguration { + + @Bean + static SpringDataRestModuleTracingBeanPostProcessor springDataRestModuleTracingBeanPostProcessor( + ApplicationRuntime runtime, Tracer tracer) { + return new SpringDataRestModuleTracingBeanPostProcessor(runtime, tracer); + } +} diff --git a/moduliths-observability/src/main/resources/META-INF/spring.factories b/moduliths-observability/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..4133ff65 --- /dev/null +++ b/moduliths-observability/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + org.moduliths.observability.autoconfigure.ModuleObservabilityAutoConfiguration,\ + org.moduliths.observability.autoconfigure.SpringDataRestModuleObservabilityAutoConfiguration \ No newline at end of file diff --git a/moduliths-observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.factories b/moduliths-observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.factories new file mode 100644 index 00000000..d6d5a2b4 --- /dev/null +++ b/moduliths-observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.factories @@ -0,0 +1,2 @@ +org.moduliths.observability.autoconfigure.ModuleObservabilityAutoConfiguration +org.moduliths.observability.autoconfigure.SpringDataRestModuleObservabilityAutoConfiguration diff --git a/moduliths-observability/src/test/java/example/ExampleApplication.java b/moduliths-observability/src/test/java/example/ExampleApplication.java new file mode 100644 index 00000000..267954de --- /dev/null +++ b/moduliths-observability/src/test/java/example/ExampleApplication.java @@ -0,0 +1,30 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Oliver Drotbohm + */ +@SpringBootApplication +public class ExampleApplication { + + public static void main(String[] args) throws Exception { + SpringApplication.run(ExampleApplication.class, args); + } +} diff --git a/moduliths-observability/src/test/java/example/ExampleApplicationIntegrationTests.java b/moduliths-observability/src/test/java/example/ExampleApplicationIntegrationTests.java new file mode 100644 index 00000000..f93e456b --- /dev/null +++ b/moduliths-observability/src/test/java/example/ExampleApplicationIntegrationTests.java @@ -0,0 +1,29 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * @author Oliver Drotbohm + */ +@SpringBootTest +class ExampleApplicationIntegrationTests { + + @Test + void bootstrapsSuccessfully() {} +} diff --git a/moduliths-observability/src/test/java/org/moduliths/observability/autoconfigure/SpringBootApplicationRuntimeUnitTests.java b/moduliths-observability/src/test/java/org/moduliths/observability/autoconfigure/SpringBootApplicationRuntimeUnitTests.java new file mode 100644 index 00000000..5253847c --- /dev/null +++ b/moduliths-observability/src/test/java/org/moduliths/observability/autoconfigure/SpringBootApplicationRuntimeUnitTests.java @@ -0,0 +1,49 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.observability.autoconfigure; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.moduliths.observability.ApplicationRuntime; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.context.ApplicationContext; + +/** + * Unit tests for {@link SpringBootApplicationRuntime}. + * + * @author Oliver Drotbohm + */ +@ExtendWith(MockitoExtension.class) +public class SpringBootApplicationRuntimeUnitTests { + + @Mock ApplicationContext context; + + @Test + void extractsUserTypeFromClassBasedProxy() { + + Object proxy = new ProxyFactory(new Sample()).getProxy(); + ApplicationRuntime runtime = new SpringBootApplicationRuntime(context); + + assertThat(proxy.getClass()).isNotEqualTo(Sample.class); + assertThat(runtime.getUserClass(proxy, "sample")).isEqualTo(Sample.class); + } + + static class Sample {} +} diff --git a/moduliths-observability/src/test/resources/application.properties b/moduliths-observability/src/test/resources/application.properties new file mode 100644 index 00000000..02c4329b --- /dev/null +++ b/moduliths-observability/src/test/resources/application.properties @@ -0,0 +1,2 @@ +# As long as we're on a Spring Cloud version not officially compatible with Spring Boot 2.7 +spring.cloud.compatibility-verifier.enabled=false diff --git a/moduliths-observability/src/test/resources/logback.xml b/moduliths-observability/src/test/resources/logback.xml new file mode 100644 index 00000000..12e067d7 --- /dev/null +++ b/moduliths-observability/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + + + \ No newline at end of file diff --git a/moduliths-sample/jqassistant/index.adoc b/moduliths-sample/jqassistant/index.adoc new file mode 100644 index 00000000..1275a3ca --- /dev/null +++ b/moduliths-sample/jqassistant/index.adoc @@ -0,0 +1,79 @@ += Modulith + +== Overview + +include::jQA:Summary[] + +[[default]] +[role=group,includesConcepts="modulith:ModuleDependencies,modulith:ModuleExposesType"] +== Reports + +[[modulith:ModulithApplication]] +[source,cypher,role=concept] +.Classes annotated by `org.moduliths.Modulith` are labeled with `Modulith` and `Application`. +---- +MATCH + (:Artifact)-[:CONTAINS]->(modulith:Type)-[:ANNOTATED_BY]->()-[:OF_TYPE]->(:Type{fqn:"org.moduliths.Modulith"}) +SET + modulith:Modulith:Application +RETURN + modulith as Modulith +---- + +[[modulith:Module]] +[source,cypher,role=concept,requiresConcepts="modulith:ModulithApplication"] +.Each package that is located within the same package as the Modulith application class is labeled with `Module`. +---- +MATCH + (root:Package)-[:CONTAINS]->(modulith:Modulith:Application), + (root)-[:CONTAINS]->(module:Package) +OPTIONAL MATCH + (module)-[:CONTAINS]->(:Type{name:"package-info"})-[:ANNOTATED_BY]->(moduleInfo), + (moduleInfo)-[:OF_TYPE]->(:Type{fqn:"org.moduliths.Module"}), + (moduleInfo)-[:HAS]->(displayName:Value{name:"displayName"}) +SET + module:Module +SET + module.displayName = coalesce(displayName.value, module.name) +RETURN + module.displayName as Module +ORDER BY + Module +---- + +[[modulith:ModuleDependencies]] +[source,cypher,role=concept,requiresConcepts="modulith:Module",reportType="plantuml-component-diagram"] +.A dependency between two modules exists if there's a type dependency between both. The module dependency is represented by `DEPENDS_ON_MODULE` relationships having a `weight` property indicating the degree of coupling. +---- +MATCH + (module1:Module)-[:CONTAINS*]->(type1:Type), + (module2:Module)-[:CONTAINS*]->(type2:Type), + (type1)-[dependsOn:DEPENDS_ON]->(type2) +WHERE + module1 <> module2 +WITH + module1, module2, count(dependsOn) as weight +MERGE + (module1)-[dependsOnModule:DEPENDS_ON_MODULE]->(module2) +SET + dependsOnModule.weight = weight +RETURN + module1, dependsOnModule, module2 +---- + +[[modulith:ModuleExposesType]] +[source,cypher,role=concept,requiresConcepts="modulith:ModuleDependencies"] +.A type of a module is exposed if it is referenced at least once by a type in another module. +---- +MATCH + (module:Module) +OPTIONAL MATCH + (dependent:Module)-[:DEPENDS_ON_MODULE]->(module), + (dependent)-[:CONTAINS*]->(dependentType:Type), + (module)-[:CONTAINS*]->(type:Type), + (dependentType)-[:DEPENDS_ON]->(type) +RETURN + module.displayName as Module, collect(type.fqn) as ExposedTypes +ORDER BY + Module +---- diff --git a/moduliths-sample/pom.xml b/moduliths-sample/pom.xml new file mode 100644 index 00000000..3d462c29 --- /dev/null +++ b/moduliths-sample/pom.xml @@ -0,0 +1,97 @@ + + + 4.0.0 + + + org.moduliths + moduliths + 1.4.0-SNAPSHOT + ../pom.xml + + + Moduliths - Sample + moduliths-sample + + + org.moduliths.sample + + + + + jqa + + + + com.buschmais.jqassistant + jqassistant-maven-plugin + 1.8.0 + + + default-cli + + scan + analyze + + + + + + + + + + + + + ${project.groupId} + moduliths-api + ${project.version} + + + + ${project.groupId} + moduliths-test + ${project.version} + test + + + + org.jmolecules + jmolecules-events + + + + org.springframework.boot + spring-boot-autoconfigure + + + + org.springframework + spring-tx + + + + org.springframework.boot + spring-boot-starter-test + test + + + junit + junit + + + org.junit.vintage + junit-vintage-engine + + + + + + org.springframework.boot + spring-boot-configuration-processor + provided + + + + + diff --git a/moduliths-sample/src/main/java/com/acme/myproject/Application.java b/moduliths-sample/src/main/java/com/acme/myproject/Application.java new file mode 100644 index 00000000..a4ae6fac --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/Application.java @@ -0,0 +1,26 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject; + +import org.moduliths.Modulith; + +/** + * @author Oliver Gierke + */ +@Modulith +public class Application { + +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/complex/api/ComplexApiComponent.java b/moduliths-sample/src/main/java/com/acme/myproject/complex/api/ComplexApiComponent.java new file mode 100644 index 00000000..077c74f2 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/complex/api/ComplexApiComponent.java @@ -0,0 +1,24 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.complex.api; + +/** + * + * @author Oliver Gierke + */ +public class ComplexApiComponent { + +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/complex/api/package-info.java b/moduliths-sample/src/main/java/com/acme/myproject/complex/api/package-info.java new file mode 100644 index 00000000..15c09d58 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/complex/api/package-info.java @@ -0,0 +1,2 @@ +@org.moduliths.NamedInterface("API") +package com.acme.myproject.complex.api; diff --git a/moduliths-sample/src/main/java/com/acme/myproject/complex/internal/ComplextInternalComponent.java b/moduliths-sample/src/main/java/com/acme/myproject/complex/internal/ComplextInternalComponent.java new file mode 100644 index 00000000..02f6b27b --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/complex/internal/ComplextInternalComponent.java @@ -0,0 +1,26 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.complex.internal; + +import org.springframework.stereotype.Component; + +/** + * @author Oliver Gierke + */ +@Component +class ComplextInternalComponent { + +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/complex/internal/FirstTypeBasedPort.java b/moduliths-sample/src/main/java/com/acme/myproject/complex/internal/FirstTypeBasedPort.java new file mode 100644 index 00000000..7dae2c2e --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/complex/internal/FirstTypeBasedPort.java @@ -0,0 +1,26 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.complex.internal; + +import org.moduliths.NamedInterface; + +/** + * @author Oliver Drotbohm + */ +@NamedInterface({ "Port 1", "Port 2" }) +public class FirstTypeBasedPort { + +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/complex/internal/SecondTypeBasePort.java b/moduliths-sample/src/main/java/com/acme/myproject/complex/internal/SecondTypeBasePort.java new file mode 100644 index 00000000..0178d4f2 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/complex/internal/SecondTypeBasePort.java @@ -0,0 +1,26 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.complex.internal; + +import org.moduliths.NamedInterface; + +/** + * @author Oliver Drotbohm + */ +@NamedInterface({ "Port 2", "Port 3" }) +public class SecondTypeBasePort { + +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/complex/spi/ComplexSpiComponent.java b/moduliths-sample/src/main/java/com/acme/myproject/complex/spi/ComplexSpiComponent.java new file mode 100644 index 00000000..75946b81 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/complex/spi/ComplexSpiComponent.java @@ -0,0 +1,24 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.complex.spi; + +/** + * + * @author Oliver Gierke + */ +public class ComplexSpiComponent { + +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/complex/spi/package-info.java b/moduliths-sample/src/main/java/com/acme/myproject/complex/spi/package-info.java new file mode 100644 index 00000000..9b624dd3 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/complex/spi/package-info.java @@ -0,0 +1,2 @@ +@org.moduliths.NamedInterface("SPI") +package com.acme.myproject.complex.spi; diff --git a/moduliths-sample/src/main/java/com/acme/myproject/cycleA/CycleA.java b/moduliths-sample/src/main/java/com/acme/myproject/cycleA/CycleA.java new file mode 100644 index 00000000..dd8b868b --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/cycleA/CycleA.java @@ -0,0 +1,25 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.cycleA; + +import com.acme.myproject.cycleB.CycleB; + +/** + * @author Oliver Gierke + */ +public class CycleA { + CycleB cycleB; +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/cycleB/CycleB.java b/moduliths-sample/src/main/java/com/acme/myproject/cycleB/CycleB.java new file mode 100644 index 00000000..8f2aa31a --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/cycleB/CycleB.java @@ -0,0 +1,25 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.cycleB; + +import com.acme.myproject.cycleA.CycleA; + +/** + * @author Oliver Gierke + */ +public class CycleB { + CycleA cycleA; +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/fieldinjected/WithFieldInjection.java b/moduliths-sample/src/main/java/com/acme/myproject/fieldinjected/WithFieldInjection.java new file mode 100644 index 00000000..bb97754d --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/fieldinjected/WithFieldInjection.java @@ -0,0 +1,30 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.fieldinjected; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import com.acme.myproject.moduleA.ServiceComponentA; + +/** + * @author Oliver Drotbohm + */ +@Component +public class WithFieldInjection { + + @Autowired ServiceComponentA a; +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/invalid/InvalidComponent.java b/moduliths-sample/src/main/java/com/acme/myproject/invalid/InvalidComponent.java new file mode 100644 index 00000000..290586ea --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/invalid/InvalidComponent.java @@ -0,0 +1,26 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.invalid; + +import com.acme.myproject.moduleB.internal.InternalComponentB; + +/** + * @author Oliver Gierke + */ +public class InvalidComponent { + + InvalidComponent(InternalComponentB invalidComponent) {} +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/invalid2/InvalidModuleDependency.java b/moduliths-sample/src/main/java/com/acme/myproject/invalid2/InvalidModuleDependency.java new file mode 100644 index 00000000..8aba6739 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/invalid2/InvalidModuleDependency.java @@ -0,0 +1,33 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.invalid2; + +import com.acme.myproject.moduleA.ServiceComponentA; + +/** + * @author Oliver Gierke + */ +public class InvalidModuleDependency { + + /** + * The dependency is invalid as the module declaration only allows dependencies to Module B. + * + * @param dependency + */ + public InvalidModuleDependency(ServiceComponentA dependency) { + // TODO Auto-generated constructor stub + } +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/invalid2/package-info.java b/moduliths-sample/src/main/java/com/acme/myproject/invalid2/package-info.java new file mode 100644 index 00000000..0112d114 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/invalid2/package-info.java @@ -0,0 +1,2 @@ +@org.moduliths.Module(allowedDependencies = "moduleB") +package com.acme.myproject.invalid2; diff --git a/moduliths-sample/src/main/java/com/acme/myproject/moduleA/ServiceComponentA.java b/moduliths-sample/src/main/java/com/acme/myproject/moduleA/ServiceComponentA.java new file mode 100644 index 00000000..f8f081e2 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/moduleA/ServiceComponentA.java @@ -0,0 +1,35 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.moduleA; + +import lombok.RequiredArgsConstructor; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * @author Oliver Drotbohm + */ +@Component +@RequiredArgsConstructor +public class ServiceComponentA { + + private final ApplicationEventPublisher publisher; + + public void fireEvent() { + publisher.publishEvent(new SomeEventA("Message")); + } +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/moduleA/SomeConfigurationA.java b/moduliths-sample/src/main/java/com/acme/myproject/moduleA/SomeConfigurationA.java new file mode 100644 index 00000000..f115ad00 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/moduleA/SomeConfigurationA.java @@ -0,0 +1,33 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.moduleA; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Oliver Gierke + */ +@Configuration +public class SomeConfigurationA { + + @Bean + SomeAtBeanComponentA atBeanComponent() { + return null; + } + + public static class SomeAtBeanComponentA {} +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/moduleA/SomeEventA.java b/moduliths-sample/src/main/java/com/acme/myproject/moduleA/SomeEventA.java new file mode 100644 index 00000000..26d8f779 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/moduleA/SomeEventA.java @@ -0,0 +1,29 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.moduleA; + +import lombok.Value; + +import org.jmolecules.event.annotation.DomainEvent; + +/** + * @author Oliver Drotbohm + */ +@Value +@DomainEvent +public class SomeEventA { + String message; +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/moduleB/ServiceComponentB.java b/moduliths-sample/src/main/java/com/acme/myproject/moduleB/ServiceComponentB.java new file mode 100644 index 00000000..9745ff04 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/moduleB/ServiceComponentB.java @@ -0,0 +1,34 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.moduleB; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Component; + +import com.acme.myproject.moduleA.ServiceComponentA; +import com.acme.myproject.moduleB.internal.InternalComponentB; + +/** + * @author Oliver Gierke + */ +@Component +@RequiredArgsConstructor +public class ServiceComponentB { + + private final ServiceComponentA serviceComponentA; + private final InternalComponentB internalComponentB; +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/moduleB/SomeEventListenerB.java b/moduliths-sample/src/main/java/com/acme/myproject/moduleB/SomeEventListenerB.java new file mode 100644 index 00000000..f25990a4 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/moduleB/SomeEventListenerB.java @@ -0,0 +1,31 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.moduleB; + +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import com.acme.myproject.moduleA.SomeEventA; + +/** + * @author Oliver Gierke + */ +@Component +class SomeEventListenerB { + + @EventListener + void on(SomeEventA event) {} +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/moduleB/internal/InternalComponentB.java b/moduliths-sample/src/main/java/com/acme/myproject/moduleB/internal/InternalComponentB.java new file mode 100644 index 00000000..8eee215e --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/moduleB/internal/InternalComponentB.java @@ -0,0 +1,26 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.moduleB.internal; + +import org.springframework.stereotype.Component; + +/** + * @author Oliver Gierke + */ +@Component +public class InternalComponentB { + +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/moduleB/internal/SupportingComponentB.java b/moduliths-sample/src/main/java/com/acme/myproject/moduleB/internal/SupportingComponentB.java new file mode 100644 index 00000000..f3b858a4 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/moduleB/internal/SupportingComponentB.java @@ -0,0 +1,26 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.moduleB.internal; + +import org.springframework.stereotype.Component; + +/** + * @author Oliver Gierke + */ +@Component +class SupportingComponentB { + +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/moduleC/ServiceComponentC.java b/moduliths-sample/src/main/java/com/acme/myproject/moduleC/ServiceComponentC.java new file mode 100644 index 00000000..9e866557 --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/moduleC/ServiceComponentC.java @@ -0,0 +1,31 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.moduleC; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Component; + +import com.acme.myproject.moduleB.ServiceComponentB; + +/** + * @author Oliver Gierke + */ +@Component +@RequiredArgsConstructor +class ServiceComponentC { + private final ServiceComponentB serviceComponentB; +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/moduleC/package-info.java b/moduliths-sample/src/main/java/com/acme/myproject/moduleC/package-info.java new file mode 100644 index 00000000..157da8cb --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/moduleC/package-info.java @@ -0,0 +1,2 @@ +@org.moduliths.Module(displayName = "MyModule C") +package com.acme.myproject.moduleC; diff --git a/moduliths-sample/src/main/java/com/acme/myproject/stereotypes/Stereotypes.java b/moduliths-sample/src/main/java/com/acme/myproject/stereotypes/Stereotypes.java new file mode 100644 index 00000000..ee33f5fc --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/stereotypes/Stereotypes.java @@ -0,0 +1,83 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.stereotypes; + +import lombok.Value; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Controller; +import org.springframework.stereotype.Repository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * @author Oliver Drotbohm + */ +public class Stereotypes { + + @Component + static class SomeComponent {} + + @Controller + static class SomeController {} + + @Service + static class SomeService {} + + @Repository + static class SomeRepository {} + + @Component + static class SomeRepresentations {} + + @Component + static class SomeEventListener { + + @EventListener + void someEventListener(Object event) {} + } + + @Component + static class SomeTxEventListener { + + @TransactionalEventListener + void someTxEventListener(Object event) {} + } + + @Component // Used for documentation purposes + public static interface SomeAppInterface {}; + + @Component + static class SomeAppInterfaceImplementation implements SomeAppInterface {} + + @Value + @ConstructorBinding + @ConfigurationProperties("org.moduliths.sample") + static class SomeConfigurationProperties { + + /** + * Some test property. + */ + String test; + + public SomeConfigurationProperties(String test) { + this.test = test; + } + } +} diff --git a/moduliths-sample/src/main/java/com/acme/myproject/stereotypes/web/WebRepresentations.java b/moduliths-sample/src/main/java/com/acme/myproject/stereotypes/web/WebRepresentations.java new file mode 100644 index 00000000..bffe350e --- /dev/null +++ b/moduliths-sample/src/main/java/com/acme/myproject/stereotypes/web/WebRepresentations.java @@ -0,0 +1,26 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.stereotypes.web; + +import org.springframework.stereotype.Component; + +/** + * @author Oliver Drotbohm + */ +@Component +public class WebRepresentations { + +} diff --git a/moduliths-sample/src/test/java/com/acme/myproject/ModulithTest.java b/moduliths-sample/src/test/java/com/acme/myproject/ModulithTest.java new file mode 100644 index 00000000..18475af2 --- /dev/null +++ b/moduliths-sample/src/test/java/com/acme/myproject/ModulithTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.moduliths.model.Modules; +import org.moduliths.model.Modules.Filters; +import org.moduliths.model.Violations; + +import com.acme.myproject.invalid.InvalidComponent; +import com.acme.myproject.moduleB.internal.InternalComponentB; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; + +/** + * Test cases to verify the validity of the overall modulith rules + * + * @author Oliver Gierke + * @author Peter Gafert + */ +class ModulithTest { + + static final DescribedPredicate DEFAULT_EXCLUSIONS = Filters.withoutModules("cycleA", "cycleB", "invalid2", + "fieldinjected"); + + @Test + void verifyModules() { + + String componentName = InternalComponentB.class.getSimpleName(); + + assertThatExceptionOfType(Violations.class) // + .isThrownBy(() -> Modules.of(Application.class, DEFAULT_EXCLUSIONS).verify()) // + .withMessageContaining(String.format("Module '%s' depends on non-exposed type %s within module 'moduleB'", + "invalid", InternalComponentB.class.getName())) + .withMessageContaining(String.format("%s declares constructor %s(%s)", InvalidComponent.class.getSimpleName(), + InvalidComponent.class.getSimpleName(), componentName)); + } + + @Test + void verifyModulesWithoutInvalid() { + Modules.of(Application.class, DEFAULT_EXCLUSIONS.or(Filters.withoutModule("invalid"))).verify(); + } + + @Test + void detectsCycleBetweenModules() { + + assertThatExceptionOfType(Violations.class) // + .isThrownBy(() -> Modules.of(Application.class, Filters.withoutModules("invalid", "invalid2")).verify()) // + + // mentions modules + .withMessageContaining("cycleA") // + .withMessageContaining("cycleB") // + + // mentions offending types + .withMessageContaining("CycleA") // + .withMessageContaining("CycleB"); + } +} diff --git a/moduliths-sample/src/test/java/com/acme/myproject/NonVerifyingModuleTest.java b/moduliths-sample/src/test/java/com/acme/myproject/NonVerifyingModuleTest.java new file mode 100644 index 00000000..f3a50f15 --- /dev/null +++ b/moduliths-sample/src/test/java/com/acme/myproject/NonVerifyingModuleTest.java @@ -0,0 +1,37 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.moduliths.test.ModuleTest; +import org.moduliths.test.ModuleTest.BootstrapMode; +import org.springframework.core.annotation.AliasFor; + +/** + * @author Oliver Gierke + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@ModuleTest(verifyAutomatically = false) +public @interface NonVerifyingModuleTest { + + @AliasFor(annotation = ModuleTest.class, attribute = "mode") + BootstrapMode value() default BootstrapMode.STANDALONE; +} diff --git a/moduliths-sample/src/test/java/com/acme/myproject/complex/ComplexTest.java b/moduliths-sample/src/test/java/com/acme/myproject/complex/ComplexTest.java new file mode 100644 index 00000000..253508bb --- /dev/null +++ b/moduliths-sample/src/test/java/com/acme/myproject/complex/ComplexTest.java @@ -0,0 +1,44 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.complex; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.moduliths.model.NamedInterface; +import org.moduliths.model.NamedInterfaces; +import org.moduliths.test.ModuleTestExecution; +import org.springframework.beans.factory.annotation.Autowired; + +import com.acme.myproject.NonVerifyingModuleTest; + +/** + * @author Oliver Gierke + */ +@NonVerifyingModuleTest +class ComplexTest { + + @Autowired ModuleTestExecution moduleTest; + + @Test + void exposesNamedInterfaces() { + + NamedInterfaces interfaces = moduleTest.getModule().getNamedInterfaces(); + + assertThat(interfaces.stream().map(NamedInterface::getName)) // + .containsExactlyInAnyOrder("API", "SPI", "Port 1", "Port 2", "Port 3"); + } +} diff --git a/moduliths-sample/src/test/java/com/acme/myproject/fieldinjected/FieldInjectedIntegrationTest.java b/moduliths-sample/src/test/java/com/acme/myproject/fieldinjected/FieldInjectedIntegrationTest.java new file mode 100644 index 00000000..2de5c507 --- /dev/null +++ b/moduliths-sample/src/test/java/com/acme/myproject/fieldinjected/FieldInjectedIntegrationTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.fieldinjected; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.moduliths.model.Modules; +import org.moduliths.test.ModuleTestExecution; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; + +import com.acme.myproject.NonVerifyingModuleTest; +import com.acme.myproject.moduleA.ServiceComponentA; + +/** + * Integration tests to verify field injection is rejected. + * + * @author Oliver Drotbohm + */ +@NonVerifyingModuleTest +class FieldInjectedIntegrationTest { + + @Autowired ModuleTestExecution execution; + + @MockBean ServiceComponentA dependency; + + @Test + void rejectsFieldInjection() { + + Modules modules = execution.getModules(); + + assertThat(execution.getModule().detectDependencies(modules)) // + .hasMessageContaining("field injection") // + .hasMessageContaining("WithFieldInjection.a"); // offending field + } +} diff --git a/moduliths-sample/src/test/java/com/acme/myproject/moduleA/ModuleATest.java b/moduliths-sample/src/test/java/com/acme/myproject/moduleA/ModuleATest.java new file mode 100644 index 00000000..686fed0a --- /dev/null +++ b/moduliths-sample/src/test/java/com/acme/myproject/moduleA/ModuleATest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.moduleA; + +// import static org.moduliths.test.assertj.PublishedEventAssertions.*; +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.moduliths.test.PublishedEvents; +import org.moduliths.test.PublishedEvents.TypedPublishedEvents; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; + +import com.acme.myproject.NonVerifyingModuleTest; +import com.acme.myproject.moduleB.ServiceComponentB; + +/** + * @author Oliver Drotbohm + */ +@NonVerifyingModuleTest +class ModuleATest { + + @Autowired ApplicationContext context; + @Autowired PublishedEvents events; + + @Test + void bootstrapsModuleAOnly() { + + context.getBean(ServiceComponentA.class); + + assertThatExceptionOfType(NoSuchBeanDefinitionException.class) + .isThrownBy(() -> context.getBean(ServiceComponentB.class)); + } + + @Test + void assertEventsFired() { + + context.getBean(ServiceComponentA.class).fireEvent(); + + TypedPublishedEvents matching = events.ofType(SomeEventA.class) // + .matching(it -> it.getMessage().equals("Message")); + + assertThat(matching).hasSize(1); + } + + @Test + void injectsPublishedEventsIntoMethod(PublishedEvents events) { + assertThat(events).isNotNull(); + } +} diff --git a/moduliths-sample/src/test/java/com/acme/myproject/moduleB/ModuleBTest.java b/moduliths-sample/src/test/java/com/acme/myproject/moduleB/ModuleBTest.java new file mode 100644 index 00000000..fc35d5c5 --- /dev/null +++ b/moduliths-sample/src/test/java/com/acme/myproject/moduleB/ModuleBTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.moduleB; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.internal.creation.bytebuddy.MockAccess; +import org.moduliths.test.ModuleTest.BootstrapMode; +import org.moduliths.test.TestUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfigurationPackages; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationContext; + +import com.acme.myproject.NonVerifyingModuleTest; +import com.acme.myproject.moduleA.ServiceComponentA; +import com.acme.myproject.moduleB.internal.InternalComponentB; + +/** + * @author Oliver Gierke + */ +class ModuleBTest { + + @Nested + static class WithoutMocksTest { + + @Autowired ServiceComponentB serviceComponentB; + + @NonVerifyingModuleTest + static class Config {} + + @Test + void failsToStartBecauseServiceComponentAIsMissing() throws Exception { + TestUtils.assertDependencyMissing(WithoutMocksTest.Config.class, ServiceComponentA.class); + } + } + + @Nested + @NonVerifyingModuleTest + static class WithMocksTest { + + @Autowired ApplicationContext context; + @MockBean ServiceComponentA serviceComponentA; + + @Test + void bootstrapsModuleB() { + + context.getBean(ServiceComponentB.class); + + assertThat(context.getBean(ServiceComponentA.class)).isInstanceOf(MockAccess.class); + } + + @Test + void considersNestedPackagePartOfTheModuleByDefault() { + context.getBean(InternalComponentB.class); + } + + @Test + void tweaksAutoConfigurationPackageToModulePackage() { + + assertThat(AutoConfigurationPackages.get(context)) // + .containsExactly(getClass().getPackage().getName()); + } + } + + @Nested + @NonVerifyingModuleTest(BootstrapMode.DIRECT_DEPENDENCIES) + static class WithUpstreamModuleTest { + + @Autowired ServiceComponentA componentA; + @Autowired ServiceComponentB componentB; + + @Test + void bootstrapsContext() {} + } +} diff --git a/moduliths-sample/src/test/java/com/acme/myproject/moduleC/ModuleCTest.java b/moduliths-sample/src/test/java/com/acme/myproject/moduleC/ModuleCTest.java new file mode 100644 index 00000000..b0b419e5 --- /dev/null +++ b/moduliths-sample/src/test/java/com/acme/myproject/moduleC/ModuleCTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.acme.myproject.moduleC; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.moduliths.test.ModuleTest.BootstrapMode; +import org.moduliths.test.TestUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; + +import com.acme.myproject.NonVerifyingModuleTest; +import com.acme.myproject.moduleA.ServiceComponentA; +import com.acme.myproject.moduleB.ServiceComponentB; + +/** + * @author Oliver Gierke + */ +class ModuleCTest { + + @Nested + public static class FailsStandaloneTest { + + @NonVerifyingModuleTest + static class Config {} + + @Test + void failsStandalone() { + TestUtils.assertDependencyMissing(FailsStandaloneTest.Config.class, ServiceComponentB.class); + } + } + + @Nested + static class FailsWithDirectDependencyTest { + + @NonVerifyingModuleTest(BootstrapMode.DIRECT_DEPENDENCIES) + static class Config {} + + @Test + void failsWithDirectDependency() { + TestUtils.assertDependencyMissing(FailsWithDirectDependencyTest.Config.class, ServiceComponentA.class); + } + } + + @Nested + @NonVerifyingModuleTest(BootstrapMode.DIRECT_DEPENDENCIES) + static class SucceedsWithDirectDependencyPlusItsDependenciesMocksTest { + + @MockBean ServiceComponentA serviceComponentA; + + @Test + void bootstrapsContext() { + assertThat(serviceComponentA).isNotNull(); + } + } + + @Nested + @NonVerifyingModuleTest(BootstrapMode.ALL_DEPENDENCIES) + static class SucceedsWithAllDependenciesTest { + + @Autowired ServiceComponentA serviceComponentA; + @Autowired ServiceComponentB serviceComponentB; + + @Test + void bootstrapsContext() { + assertThat(serviceComponentA).isNotNull(); + assertThat(serviceComponentB).isNotNull(); + } + } +} diff --git a/moduliths-sample/src/test/resources/application.properties b/moduliths-sample/src/test/resources/application.properties new file mode 100644 index 00000000..b92adaf4 --- /dev/null +++ b/moduliths-sample/src/test/resources/application.properties @@ -0,0 +1 @@ +spring.main.banner-mode=OFF diff --git a/moduliths-sample/src/test/resources/logback.xml b/moduliths-sample/src/test/resources/logback.xml new file mode 100644 index 00000000..2646298a --- /dev/null +++ b/moduliths-sample/src/test/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + diff --git a/moduliths-starter-jpa-jakarta/pom.xml b/moduliths-starter-jpa-jakarta/pom.xml new file mode 100644 index 00000000..fc1c4b77 --- /dev/null +++ b/moduliths-starter-jpa-jakarta/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + org.moduliths + moduliths + 1.4.0-SNAPSHOT + + + moduliths-starter-jpa-jakarta + Moduliths - Starter JPA (Jakarta) + + + org.moduliths.starter.jpa.jakarta + + + + + + org.moduliths + moduliths-api + 1.4.0-SNAPSHOT + + + org.moduliths + moduliths-moments + 1.4.0-SNAPSHOT + + + + + + org.moduliths + moduliths-events-core + 1.4.0-SNAPSHOT + + + org.moduliths + moduliths-events-jackson + 1.4.0-SNAPSHOT + runtime + + + org.moduliths + moduliths-events-jpa-jakarta + 1.4.0-SNAPSHOT + runtime + + + + + \ No newline at end of file diff --git a/moduliths-starter-jpa/pom.xml b/moduliths-starter-jpa/pom.xml new file mode 100644 index 00000000..95d22094 --- /dev/null +++ b/moduliths-starter-jpa/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + org.moduliths + moduliths + 1.4.0-SNAPSHOT + + + moduliths-starter-jpa + Moduliths - Starter JPA + + + org.moduliths.starter.jpa + + + + + + org.moduliths + moduliths-api + 1.4.0-SNAPSHOT + + + org.moduliths + moduliths-moments + 1.4.0-SNAPSHOT + + + + + + org.moduliths + moduliths-events-core + 1.4.0-SNAPSHOT + + + org.moduliths + moduliths-events-jackson + 1.4.0-SNAPSHOT + runtime + + + org.moduliths + moduliths-events-jpa + 1.4.0-SNAPSHOT + runtime + + + + + \ No newline at end of file diff --git a/moduliths-starter-test/pom.xml b/moduliths-starter-test/pom.xml new file mode 100644 index 00000000..b12f7b2c --- /dev/null +++ b/moduliths-starter-test/pom.xml @@ -0,0 +1,33 @@ + + 4.0.0 + + + org.moduliths + moduliths + 1.4.0-SNAPSHOT + + + moduliths-starter-test + Moduliths - Starter Test + + + org.moduliths.starter.test + + + + + + org.moduliths + moduliths-test + 1.4.0-SNAPSHOT + + + + org.moduliths + moduliths-docs + 1.4.0-SNAPSHOT + + + + + diff --git a/moduliths-test/pom.xml b/moduliths-test/pom.xml new file mode 100644 index 00000000..dfff799b --- /dev/null +++ b/moduliths-test/pom.xml @@ -0,0 +1,60 @@ + + 4.0.0 + + + org.moduliths + moduliths + 1.4.0-SNAPSHOT + ../pom.xml + + + Moduliths - Test + moduliths-test + + + org.moduliths.test + + + + + + ${project.groupId} + moduliths-core + ${project.version} + + + + org.springframework.boot + spring-boot-test-autoconfigure + + + + org.springframework.data + spring-data-commons + true + + + + org.springframework + spring-test + + + + org.assertj + assertj-core + + + + org.junit.jupiter + junit-jupiter-api + true + + + + org.springframework.boot + spring-boot-starter-test + true + + + + diff --git a/moduliths-test/src/main/java/org/moduliths/test/AggregateTestUtils.java b/moduliths-test/src/main/java/org/moduliths/test/AggregateTestUtils.java new file mode 100644 index 00000000..34d24d64 --- /dev/null +++ b/moduliths-test/src/main/java/org/moduliths/test/AggregateTestUtils.java @@ -0,0 +1,89 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.test; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.data.domain.DomainEvents; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.ReflectionUtils.MethodCallback; + +/** + * Test utilities to work with aggregates. + * + * @author Oliver Drotbohm + */ +public class AggregateTestUtils { + + private static Map, Optional> CACHE = new ConcurrentHashMap<>(); + + /** + * Extracts all domain events from the given aggregate that uses Spring Data's {@link DomainEvents} annotation to + * expose them. + * + * @param aggregate must not be {@literal null}. + * @return {@link PublishedEvents} for all events contained in the given aggregate, will never be {@literal null}. + */ + public static PublishedEvents eventsOf(Object aggregate) { + + Collection events = CACHE.computeIfAbsent(aggregate.getClass(), AggregateTestUtils::findAnnotatedMethod) + .map(it -> ReflectionUtils.invokeMethod(it, aggregate)) // + .map(Collection.class::cast) // + .orElseGet(Collections::emptyList); + + return PublishedEvents.of(events); + } + + private static Optional findAnnotatedMethod(Class type) { + + DomainEventsMethodFinder finder = new DomainEventsMethodFinder(); + ReflectionUtils.doWithMethods(type, finder); + + return Optional.ofNullable(finder.method); + } + + /** + * {@link MethodCallback} to find a method annotated with {@link DomainEvents}. + * + * @author Oliver Drotbohm + */ + private static class DomainEventsMethodFinder implements MethodCallback { + + Method method; + + /* + * (non-Javadoc) + * @see org.springframework.util.ReflectionUtils.MethodCallback#doWith(java.lang.reflect.Method) + */ + @Override + public void doWith(Method method) throws IllegalArgumentException, IllegalAccessException { + + if (this.method != null) { + return; + } + + if (method.isAnnotationPresent(DomainEvents.class)) { + this.method = method; + ReflectionUtils.makeAccessible(method); + } + } + } +} diff --git a/moduliths-test/src/main/java/org/moduliths/test/DefaultPublishedEvents.java b/moduliths-test/src/main/java/org/moduliths/test/DefaultPublishedEvents.java new file mode 100644 index 00000000..56d9953f --- /dev/null +++ b/moduliths-test/src/main/java/org/moduliths/test/DefaultPublishedEvents.java @@ -0,0 +1,165 @@ +/* + * Copyright 2019-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.test; + +import lombok.RequiredArgsConstructor; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.context.PayloadApplicationEvent; +import org.springframework.util.Assert; + +/** + * Default implementation of {@link PublishedEvents}. + * + * @author Oliver Drotbohm + */ +class DefaultPublishedEvents implements PublishedEvents, ApplicationListener { + + private final List events; + + /** + * Creates a new, empty {@link DefaultPublishedEvents} instance. + */ + DefaultPublishedEvents() { + this(Collections.emptyList()); + } + + /** + * Creates a new {@link DefaultPublishedEvents} instance with the given events. + * + * @param events must not be {@literal null}. + */ + DefaultPublishedEvents(Collection events) { + + Assert.notNull(events, "Events must not be null!"); + + this.events = new ArrayList<>(events); + } + + /* + * (non-Javadoc) + * @see org.springframework.context.ApplicationListener#onApplicationEvent(org.springframework.context.ApplicationEvent) + */ + @Override + public void onApplicationEvent(ApplicationEvent event) { + this.events.add(unwrapPayloadEvent(event)); + } + + /* + * (non-Javadoc) + * @see org.moduliths.test.PublishedEvents#ofType(java.lang.Class) + */ + @Override + public TypedPublishedEvents ofType(Class type) { + + return SimpleTypedPublishedEvents.of(events.stream()// + .filter(type::isInstance) // + .map(type::cast)); + } + + private static Object unwrapPayloadEvent(Object source) { + + return PayloadApplicationEvent.class.isInstance(source) // + ? ((PayloadApplicationEvent) source).getPayload() // + : source; + } + + @RequiredArgsConstructor(staticName = "of") + private static class SimpleTypedPublishedEvents implements TypedPublishedEvents { + + private final List events; + + private static SimpleTypedPublishedEvents of(Stream stream) { + return new SimpleTypedPublishedEvents<>(stream.collect(Collectors.toList())); + } + + /* + * (non-Javadoc) + * @see org.moduliths.test.PublishedEvents.TypedPublishedEvents#ofSubType(java.lang.Class) + */ + @Override + public TypedPublishedEvents ofSubType(Class subType) { + + return SimpleTypedPublishedEvents.of(getFilteredEvents(subType::isInstance) // + .map(subType::cast)); + + } + + /* + * (non-Javadoc) + * @see org.moduliths.test.PublishedEvents.TypedPublishedEvents#matching(java.util.function.Predicate) + */ + @Override + public TypedPublishedEvents matching(Predicate predicate) { + return SimpleTypedPublishedEvents.of(getFilteredEvents(predicate)); + } + + /* + * (non-Javadoc) + * @see org.moduliths.test.PublishedEvents.TypedPublishedEvents#matchingMapped(java.util.function.Function, java.util.function.Predicate) + */ + @Override + public TypedPublishedEvents matchingMapped(Function mapper, Predicate predicate) { + + return SimpleTypedPublishedEvents.of(events.stream().flatMap(it -> { + + S mapped = mapper.apply(it); + + return predicate.test(mapped) ? Stream.of(it) : Stream.empty(); + + })); + } + + /** + * Returns a {@link Stream} of events filtered by the given {@link Predicate}. + * + * @param predicate must not be {@literal null}. + * @return + */ + private Stream getFilteredEvents(Predicate predicate) { + return events.stream().filter(predicate); + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return events.iterator(); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return events.toString(); + } + } +} diff --git a/moduliths-test/src/main/java/org/moduliths/test/ModuleContextCustomizerFactory.java b/moduliths-test/src/main/java/org/moduliths/test/ModuleContextCustomizerFactory.java new file mode 100644 index 00000000..bee5763a --- /dev/null +++ b/moduliths-test/src/main/java/org/moduliths/test/ModuleContextCustomizerFactory.java @@ -0,0 +1,156 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.test; + +import lombok.EqualsAndHashCode; +import lombok.extern.slf4j.Slf4j; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.moduliths.model.Module; +import org.moduliths.model.Modules; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.test.context.ContextConfigurationAttributes; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.ContextCustomizerFactory; +import org.springframework.test.context.MergedContextConfiguration; + +/** + * @author Oliver Gierke + */ +class ModuleContextCustomizerFactory implements ContextCustomizerFactory { + + /* + * (non-Javadoc) + * @see org.springframework.test.context.ContextCustomizerFactory#createContextCustomizer(java.lang.Class, java.util.List) + */ + @Override + public ContextCustomizer createContextCustomizer(Class testClass, + List configAttributes) { + + ModuleTest moduleTest = AnnotatedElementUtils.getMergedAnnotation(testClass, ModuleTest.class); + + return moduleTest == null ? null : new ModuleContextCustomizer(testClass); + } + + @Slf4j + @EqualsAndHashCode + static class ModuleContextCustomizer implements ContextCustomizer { + + private static final String BEAN_NAME = ModuleTestExecution.class.getName(); + + private final Supplier execution; + + private ModuleContextCustomizer(Class testClass) { + this.execution = ModuleTestExecution.of(testClass); + } + + /* + * (non-Javadoc) + * @see org.springframework.test.context.ContextCustomizer#customizeContext(org.springframework.context.ConfigurableApplicationContext, org.springframework.test.context.MergedContextConfiguration) + */ + @Override + public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) { + + ModuleTestExecution testExecution = execution.get(); + + logModules(testExecution); + + ConfigurableListableBeanFactory beanFactory = context.getBeanFactory(); + beanFactory.registerSingleton(BEAN_NAME, testExecution); + + DefaultPublishedEvents events = new DefaultPublishedEvents(); + beanFactory.registerSingleton(events.getClass().getName(), events); + context.addApplicationListener(events); + } + + private static void logModules(ModuleTestExecution execution) { + + Module module = execution.getModule(); + Modules modules = execution.getModules(); + String moduleName = module.getDisplayName(); + String bootstrapMode = execution.getBootstrapMode().name(); + + String message = String.format("Bootstrapping @ModuleTest for %s in mode %s (%s)…", moduleName, bootstrapMode, + modules.getModulithSource()); + + LOG.info(message); + LOG.info(getSeparator("=", message)); + + Arrays.stream(module.toString(modules).split("\n")).forEach(LOG::info); + + List extraIncludes = execution.getExtraIncludes(); + + if (!extraIncludes.isEmpty()) { + + logHeadline("Extra includes:", message); + + extraIncludes.forEach(it -> LOG.info("> ".concat(it.getName()))); + } + + Set sharedModules = modules.getSharedModules(); + + if (!sharedModules.isEmpty()) { + + logHeadline("Shared modules:", message); + + sharedModules.forEach(it -> LOG.info("> ".concat(it.getName()))); + } + + List dependencies = execution.getDependencies(); + + if (!dependencies.isEmpty() || !sharedModules.isEmpty()) { + + logHeadline("Included dependencies:", message); + + Stream dependenciesPlusMissingSharedOnes = // + Stream.concat(dependencies.stream(), sharedModules.stream() // + .filter(it -> !dependencies.contains(it))); + + dependenciesPlusMissingSharedOnes // + .map(it -> it.toString(modules)) // + .forEach(it -> { + Arrays.stream(it.split("\n")).forEach(LOG::info); + }); + + LOG.info(getSeparator("=", message)); + } + } + + private static String getSeparator(String character, String reference) { + return String.join("", Collections.nCopies(reference.length(), character)); + } + + private static void logHeadline(String headline, String reference) { + logHeadline(headline, reference, () -> {}); + } + + private static void logHeadline(String headline, String reference, Runnable additional) { + + LOG.info(getSeparator("=", reference)); + LOG.info(headline); + additional.run(); + LOG.info(getSeparator("=", reference)); + } + } +} diff --git a/moduliths-test/src/main/java/org/moduliths/test/ModuleTest.java b/moduliths-test/src/main/java/org/moduliths/test/ModuleTest.java new file mode 100644 index 00000000..bdca5345 --- /dev/null +++ b/moduliths-test/src/main/java/org/moduliths/test/ModuleTest.java @@ -0,0 +1,99 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.test; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.extension.ExtendWith; +import org.moduliths.model.Module.DependencyDepth; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.autoconfigure.filter.TypeExcludeFilters; +import org.springframework.boot.test.context.SpringBootTestContextBootstrapper; +import org.springframework.core.annotation.AliasFor; +import org.springframework.test.context.BootstrapWith; +import org.springframework.test.context.TestConstructor; +import org.springframework.test.context.TestConstructor.AutowireMode; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * Bootstraps the module containing the package of the test class annotated with {@link ModuleTest}. Will apply the + * following modifications to the Spring Boot configuration: + *
    + *
  • Restricts the component scanning to the module's package. + *
  • + *
  • Sets the module's package as the only auto-configuration and entity scan package. + *
  • + *
+ * + * @author Oliver Drotbohm + */ +@Retention(RetentionPolicy.RUNTIME) +@BootstrapWith(SpringBootTestContextBootstrapper.class) +@TypeExcludeFilters(ModuleTypeExcludeFilter.class) +@ImportAutoConfiguration(ModuleTestAutoConfiguration.class) +@ExtendWith(SpringExtension.class) +@ExtendWith(PublishedEventsParameterResolver.class) +@TestInstance(Lifecycle.PER_CLASS) +@TestConstructor(autowireMode = AutowireMode.ALL) +public @interface ModuleTest { + + @AliasFor("mode") + BootstrapMode value() default BootstrapMode.STANDALONE; + + @AliasFor("value") + BootstrapMode mode() default BootstrapMode.STANDALONE; + + /** + * Whether to automatically verify the module structure for validity. + * + * @return + */ + boolean verifyAutomatically() default true; + + /** + * Module names of modules to be included in the test run independent of what the {@link #mode()} defines. + * + * @return + */ + String[] extraIncludes() default {}; + + @RequiredArgsConstructor + public enum BootstrapMode { + + /** + * Boorstraps the current module only. + */ + STANDALONE(DependencyDepth.NONE), + + /** + * Bootstraps the current module as well as its direct dependencies. + */ + DIRECT_DEPENDENCIES(DependencyDepth.IMMEDIATE), + + /** + * Bootstraps the current module as well as all upstream dependencies (including transitive ones). + */ + ALL_DEPENDENCIES(DependencyDepth.ALL); + + private final @Getter DependencyDepth depth; + } +} diff --git a/moduliths-test/src/main/java/org/moduliths/test/ModuleTestAutoConfiguration.java b/moduliths-test/src/main/java/org/moduliths/test/ModuleTestAutoConfiguration.java new file mode 100644 index 00000000..cc840a2b --- /dev/null +++ b/moduliths-test/src/main/java/org/moduliths/test/ModuleTestAutoConfiguration.java @@ -0,0 +1,113 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.test; + +import lombok.extern.slf4j.Slf4j; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConstructorArgumentValues.ValueHolder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * An unconditional auto-configuration registering an {@link ImportBeanDefinitionRegistrar} to customize both the entity + * scan and auto-configuration packages to the packages defined by the {@link ModuleTestExecution} in the application + * context. + * + * @author Oliver Gierke + */ +@Configuration +@Import(ModuleTestAutoConfiguration.AutoConfigurationAndEntityScanPackageCustomizer.class) +class ModuleTestAutoConfiguration { + + private static final String AUTOCONFIG_PACKAGES = "org.springframework.boot.autoconfigure.AutoConfigurationPackages"; + private static final String ENTITY_SCAN_PACKAGE = "org.springframework.boot.autoconfigure.domain.EntityScanPackages"; + + @Slf4j + static class AutoConfigurationAndEntityScanPackageCustomizer implements ImportBeanDefinitionRegistrar { + + /* + * (non-Javadoc) + * @see org.springframework.context.annotation.ImportBeanDefinitionRegistrar#registerBeanDefinitions(org.springframework.core.type.AnnotationMetadata, org.springframework.beans.factory.support.BeanDefinitionRegistry) + */ + @Override + public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { + + ModuleTestExecution execution = ((BeanFactory) registry).getBean(ModuleTestExecution.class); + List basePackages = execution.getBasePackages().collect(Collectors.toList()); + + LOG.info("Re-configuring auto-configuration and entity scan packages to: {}.", + StringUtils.collectionToDelimitedString(basePackages, ", ")); + + setBasePackagesOn(registry, AUTOCONFIG_PACKAGES, "BasePackagesBeanDefinition", "basePackages", basePackages); + setBasePackagesOn(registry, ENTITY_SCAN_PACKAGE, "EntityScanPackagesBeanDefinition", "packageNames", + basePackages); + } + + @SuppressWarnings("unchecked") + private void setBasePackagesOn(BeanDefinitionRegistry registry, String beanName, String definitionType, + String fieldName, List packages) { + + if (!registry.containsBeanDefinition(beanName)) { + return; + } + + BeanDefinition definition = registry.getBeanDefinition(beanName); + + // For Boot 2.4, we deal with a BasePackagesBeanDefinition + Field field = Arrays.stream(definition.getClass().getDeclaredFields()) + .filter(__ -> definition.getClass().getSimpleName().equals(definitionType)) + .filter(it -> it.getName().equals(fieldName)) + .findFirst() + .orElse(null); + + if (field != null) { + + // Keep all auto-configuration packages from Moduliths + + ReflectionUtils.makeAccessible(field); + ((Set) ReflectionUtils.getField(field, definition)).stream() + .filter(it -> it.startsWith("org.moduliths")) + .forEach(packages::add); + + ReflectionUtils.setField(field, definition, new HashSet<>(packages)); + + } else { + + ValueHolder holder = definition.getConstructorArgumentValues().getArgumentValue(0, String[].class); + Arrays.stream((String[]) holder.getValue()) + .filter(it -> it.startsWith("org.moduliths")) + .forEach(packages::add); + + // Fall back to customize the bean definition in a Boot 2.3 arrangement + definition.getConstructorArgumentValues().addIndexedArgumentValue(0, packages); + } + } + } +} diff --git a/moduliths-test/src/main/java/org/moduliths/test/ModuleTestExecution.java b/moduliths-test/src/main/java/org/moduliths/test/ModuleTestExecution.java new file mode 100644 index 00000000..1b7e738f --- /dev/null +++ b/moduliths-test/src/main/java/org/moduliths/test/ModuleTestExecution.java @@ -0,0 +1,181 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.test; + +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Value; +import lombok.extern.slf4j.Slf4j; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.moduliths.model.JavaPackage; +import org.moduliths.model.Module; +import org.moduliths.model.Modules; +import org.moduliths.test.ModuleTest.BootstrapMode; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.AnnotatedClassFinder; +import org.springframework.core.annotation.AnnotatedElementUtils; + +import com.tngtech.archunit.thirdparty.com.google.common.base.Supplier; +import com.tngtech.archunit.thirdparty.com.google.common.base.Suppliers; + +/** + * @author Oliver Gierke + */ +@Slf4j +@EqualsAndHashCode(of = "key") +public class ModuleTestExecution implements Iterable { + + private static Map, Class> MODULITH_TYPES = new HashMap<>(); + private static Map EXECUTIONS = new HashMap<>(); + + private final Key key; + + private final @Getter BootstrapMode bootstrapMode; + private final @Getter Module module; + private final @Getter Modules modules; + private final @Getter List extraIncludes; + + private final Supplier> basePackages; + private final Supplier> dependencies; + + private ModuleTestExecution(ModuleTest annotation, Modules modules, Module module) { + + this.key = Key.of(module.getBasePackage().getName(), annotation); + this.modules = modules; + this.bootstrapMode = annotation.mode(); + this.module = module; + + this.extraIncludes = getExtraModules(annotation, modules).collect(Collectors.toList()); + + this.basePackages = Suppliers.memoize(() -> { + + Stream moduleBasePackages = module.getBasePackages(modules, bootstrapMode.getDepth()); + Stream sharedBasePackages = modules.getSharedModules().stream().map(it -> it.getBasePackage()); + Stream extraPackages = extraIncludes.stream().map(Module::getBasePackage); + + Stream intermediate = Stream.concat(moduleBasePackages, extraPackages); + + return Stream.concat(intermediate, sharedBasePackages).distinct().collect(Collectors.toList()); + }); + + this.dependencies = Suppliers.memoize(() -> { + + Stream bootstrapDependencies = module.getBootstrapDependencies(modules, bootstrapMode.getDepth()); + return Stream.concat(bootstrapDependencies, extraIncludes.stream()).collect(Collectors.toList()); + }); + + if (annotation.verifyAutomatically()) { + verify(); + } + } + + public static java.util.function.Supplier of(Class type) { + + return () -> { + + ModuleTest annotation = AnnotatedElementUtils.findMergedAnnotation(type, ModuleTest.class); + String packageName = type.getPackage().getName(); + + Class modulithType = MODULITH_TYPES.computeIfAbsent(type, + it -> new AnnotatedClassFinder(SpringBootApplication.class).findFromPackage(packageName)); + Modules modules = Modules.of(modulithType); + Module module = modules.getModuleForPackage(packageName) // + .orElseThrow( + () -> new IllegalStateException(String.format("Package %s is not part of any module!", packageName))); + + return EXECUTIONS.computeIfAbsent(Key.of(module.getBasePackage().getName(), annotation), + it -> new ModuleTestExecution(annotation, modules, module)); + }; + } + + /** + * Returns all base packages the current execution needs to use for component scanning, auto-configuration etc. + * + * @return + */ + public Stream getBasePackages() { + return basePackages.get().stream().map(JavaPackage::getName); + } + + public boolean includes(String className) { + + boolean result = modules.withinRootPackages(className) // + || basePackages.get().stream().anyMatch(it -> it.contains(className)); + + if (result) { + LOG.debug("Including class {}.", className); + } + + return !result; + } + + /** + * Returns all module dependencies, based on the current {@link BootstrapMode}. + * + * @return + */ + public List getDependencies() { + return dependencies.get(); + } + + /** + * Explicitly trigger the module structure verification. + */ + public void verify() { + modules.verify(); + } + + /** + * Verifies the setup of the module bootstrapped by this execution. + */ + public void verifyModule() { + module.verifyDependencies(modules); + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return modules.iterator(); + } + + private static Stream getExtraModules(ModuleTest annotation, Modules modules) { + + return Arrays.stream(annotation.extraIncludes()) // + .map(modules::getModuleByName) // + .flatMap(it -> it.map(Stream::of).orElseGet(Stream::empty)); + } + + @Value + @RequiredArgsConstructor(staticName = "of", access = AccessLevel.PRIVATE) + private static class Key { + + String moduleBasePackage; + ModuleTest annotation; + } +} diff --git a/moduliths-test/src/main/java/org/moduliths/test/ModuleTypeExcludeFilter.java b/moduliths-test/src/main/java/org/moduliths/test/ModuleTypeExcludeFilter.java new file mode 100644 index 00000000..3e8a52aa --- /dev/null +++ b/moduliths-test/src/main/java/org/moduliths/test/ModuleTypeExcludeFilter.java @@ -0,0 +1,47 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.test; + +import lombok.EqualsAndHashCode; + +import java.io.IOException; +import java.util.function.Supplier; + +import org.springframework.boot.context.TypeExcludeFilter; +import org.springframework.core.type.classreading.MetadataReader; +import org.springframework.core.type.classreading.MetadataReaderFactory; + +/** + * @author Oliver Gierke + */ +@EqualsAndHashCode(callSuper = false) +class ModuleTypeExcludeFilter extends TypeExcludeFilter { + + private final Supplier execution; + + public ModuleTypeExcludeFilter(Class testClass) { + this.execution = ModuleTestExecution.of(testClass); + } + + /* + * (non-Javadoc) + * @see org.springframework.boot.context.TypeExcludeFilter#match(org.springframework.core.type.classreading.MetadataReader, org.springframework.core.type.classreading.MetadataReaderFactory) + */ + @Override + public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) throws IOException { + return execution.get().includes(metadataReader.getClassMetadata().getClassName()); + } +} diff --git a/moduliths-test/src/main/java/org/moduliths/test/PublishedEvents.java b/moduliths-test/src/main/java/org/moduliths/test/PublishedEvents.java new file mode 100644 index 00000000..e3aa0e48 --- /dev/null +++ b/moduliths-test/src/main/java/org/moduliths/test/PublishedEvents.java @@ -0,0 +1,99 @@ +/* + * Copyright 2019-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.test; + +import java.util.Arrays; +import java.util.Collection; +import java.util.function.Function; +import java.util.function.Predicate; + +import org.springframework.util.Assert; + +/** + * All Spring application events fired during the test execution. + * + * @author Oliver Drotbohm + */ +public interface PublishedEvents { + + /** + * Creates a new {@link PublishedEvents} instance for the given events. + * + * @param events must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static PublishedEvents of(Object... events) { + return of(Arrays.asList(events)); + } + + /** + * Creates a new {@link PublishedEvents} instance for the given events. + * + * @param events must not be {@literal null}. + * @return + */ + public static PublishedEvents of(Collection events) { + + Assert.notNull(events, "Events must not be null!"); + + return new DefaultPublishedEvents(events); + } + + /** + * Returns all application events of the given type that were fired during the test execution. + * + * @param the event type + * @param type must not be {@literal null}. + * @return + */ + TypedPublishedEvents ofType(Class type); + + /** + * All application events of a given type that were fired during a test execution. + * + * @author Oliver Drotbohm + * @param the event type + */ + interface TypedPublishedEvents extends Iterable { + + /** + * Further constrain the event type for downstream assertions. + * + * @param + * @param subType the sub type + * @return will never be {@literal null}. + */ + TypedPublishedEvents ofSubType(Class subType); + + /** + * Returns all {@link TypedPublishedEvents} that match the given predicate. + * + * @param predicate must not be {@literal null}. + * @return will never be {@literal null}. + */ + TypedPublishedEvents matching(Predicate predicate); + + /** + * Returns all {@link TypedPublishedEvents} that match the given predicate after applying the given mapping step. + * + * @param the intermediate type to apply the {@link Predicate} on + * @param mapper the mapping step to extract a part of the original event subject to test for the {@link Predicate}. + * @param predicate the {@link Predicate} to apply on the value extracted. + * @return will never be {@literal null}. + */ + TypedPublishedEvents matchingMapped(Function mapper, Predicate predicate); + } +} diff --git a/moduliths-test/src/main/java/org/moduliths/test/PublishedEventsExtension.java b/moduliths-test/src/main/java/org/moduliths/test/PublishedEventsExtension.java new file mode 100644 index 00000000..ea452f24 --- /dev/null +++ b/moduliths-test/src/main/java/org/moduliths/test/PublishedEventsExtension.java @@ -0,0 +1,27 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.test; + +import org.junit.jupiter.api.extension.Extension; + +/** + * JUnit 5 {@link Extension} for standalone usage without {@link ModuleTest}. + * + * @author Oliver Drotbohm + */ +public final class PublishedEventsExtension extends PublishedEventsParameterResolver { + +} diff --git a/moduliths-test/src/main/java/org/moduliths/test/PublishedEventsParameterResolver.java b/moduliths-test/src/main/java/org/moduliths/test/PublishedEventsParameterResolver.java new file mode 100644 index 00000000..d21a6644 --- /dev/null +++ b/moduliths-test/src/main/java/org/moduliths/test/PublishedEventsParameterResolver.java @@ -0,0 +1,137 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.test; + +import java.util.function.Function; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolver; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.util.Assert; + +/** + * Provides instances of {@link PublishedEvents} as test method parameters. + * + * @author Oliver Drotbohm + */ +class PublishedEventsParameterResolver implements ParameterResolver, BeforeAllCallback, AfterEachCallback { + + private ThreadBoundApplicationListenerAdapter listener = new ThreadBoundApplicationListenerAdapter(); + private final Function lookup; + + PublishedEventsParameterResolver() { + this(ctx -> SpringExtension.getApplicationContext(ctx)); + } + + PublishedEventsParameterResolver(Function supplier) { + this.lookup = supplier; + } + + /* + * (non-Javadoc) + * @see org.junit.jupiter.api.extension.BeforeAllCallback#beforeAll(org.junit.jupiter.api.extension.ExtensionContext) + */ + @Override + public void beforeAll(ExtensionContext extensionContext) { + + ApplicationContext context = lookup.apply(extensionContext); + ((ConfigurableApplicationContext) context).addApplicationListener(listener); + } + + /* + * (non-Javadoc) + * @see org.junit.jupiter.api.extension.ParameterResolver#supportsParameter(org.junit.jupiter.api.extension.ParameterContext, org.junit.jupiter.api.extension.ExtensionContext) + */ + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + return PublishedEvents.class.isAssignableFrom(parameterContext.getParameter().getType()); + } + + /* + * (non-Javadoc) + * @see org.junit.jupiter.api.extension.ParameterResolver#resolveParameter(org.junit.jupiter.api.extension.ParameterContext, org.junit.jupiter.api.extension.ExtensionContext) + */ + @Override + public PublishedEvents resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + + DefaultPublishedEvents publishedEvents = new DefaultPublishedEvents(); + listener.registerDelegate(publishedEvents); + + return publishedEvents; + } + + /* + * (non-Javadoc) + * @see org.junit.jupiter.api.extension.AfterEachCallback#afterEach(org.junit.jupiter.api.extension.ExtensionContext) + */ + @Override + public void afterEach(ExtensionContext context) { + listener.unregisterDelegate(); + } + + /** + * {@link ApplicationListener} that allows registering delegate {@link ApplicationListener}s that are held in a + * {@link ThreadLocal} and get used on {@link #onApplicationEvent(ApplicationEvent)} if one is registered for the + * current thread. This allows multiple event listeners to see the events fired in a certain thread in a concurrent + * execution scenario. + * + * @author Oliver Drotbohm + */ + private static class ThreadBoundApplicationListenerAdapter implements ApplicationListener { + + private final ThreadLocal> delegate = new ThreadLocal<>(); + + /** + * Registers the given {@link ApplicationListener} to be used for the current thread. + * + * @param listener must not be {@literal null}. + */ + void registerDelegate(ApplicationListener listener) { + + Assert.notNull(listener, "Delegate ApplicationListener must not be null!"); + + delegate.set(listener); + } + + /** + * Removes the registration of the currently assigned {@link ApplicationListener}. + */ + void unregisterDelegate() { + delegate.remove(); + } + + /* + * (non-Javadoc) + * @see org.springframework.context.ApplicationListener#onApplicationEvent(org.springframework.context.ApplicationEvent) + */ + @Override + public void onApplicationEvent(ApplicationEvent event) { + + ApplicationListener listener = delegate.get(); + + if (listener != null) { + listener.onApplicationEvent(event); + } + } + } +} diff --git a/moduliths-test/src/main/java/org/moduliths/test/TestUtils.java b/moduliths-test/src/main/java/org/moduliths/test/TestUtils.java new file mode 100644 index 00000000..e62d00de --- /dev/null +++ b/moduliths-test/src/main/java/org/moduliths/test/TestUtils.java @@ -0,0 +1,73 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.test; + +import static org.assertj.core.api.Assertions.*; + +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.UnsatisfiedDependencyException; +import org.springframework.boot.test.context.SpringBootContextLoader; +import org.springframework.boot.test.context.SpringBootTestContextBootstrapper; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.BootstrapContext; +import org.springframework.test.context.CacheAwareContextLoaderDelegate; +import org.springframework.test.context.MergedContextConfiguration; +import org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate; +import org.springframework.test.context.support.DefaultBootstrapContext; + +/** + * @author Oliver Gierke + */ +public class TestUtils { + + public static void assertDependencyMissing(Class testClass, Class expectedMissingDependency) { + + CacheAwareContextLoaderDelegate delegate = new DefaultCacheAwareContextLoaderDelegate(); + BootstrapContext bootstrapContext = new DefaultBootstrapContext(testClass, delegate); + + SpringBootTestContextBootstrapper bootstrapper = new SpringBootTestContextBootstrapper(); + bootstrapper.setBootstrapContext(bootstrapContext); + + MergedContextConfiguration configuration = bootstrapper.buildMergedContextConfiguration(); + + AssertableApplicationContext context = AssertableApplicationContext.get(() -> { + + SpringBootContextLoader loader = new SpringBootContextLoader(); + + try { + + return (ConfigurableApplicationContext) loader.loadContext(configuration); + + } catch (Exception e) { + + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } + + throw new RuntimeException(e); + } + }); + + assertThat(context).hasFailed(); + + assertThat(context).getFailure().isInstanceOfSatisfying(UnsatisfiedDependencyException.class, it -> { + assertThat(it.getMostSpecificCause()).isInstanceOfSatisfying(NoSuchBeanDefinitionException.class, ex -> { + assertThat(ex.getBeanType()).isEqualTo(expectedMissingDependency); + }); + }); + } +} diff --git a/moduliths-test/src/main/resources/META-INF/spring.factories b/moduliths-test/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..c8dfaf23 --- /dev/null +++ b/moduliths-test/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.test.context.ContextCustomizerFactory=org.moduliths.test.ModuleContextCustomizerFactory diff --git a/moduliths-test/src/test/java/org/moduliths/test/PublishedEventsParameterResolverUnitTests.java b/moduliths-test/src/test/java/org/moduliths/test/PublishedEventsParameterResolverUnitTests.java new file mode 100644 index 00000000..2e2ba6ab --- /dev/null +++ b/moduliths-test/src/test/java/org/moduliths/test/PublishedEventsParameterResolverUnitTests.java @@ -0,0 +1,103 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.test; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ParameterContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.util.ReflectionUtils; + +/** + * Unit tests for PublishedEventsParameterResolver. + * + * @author Oliver Drotbohm + */ +public class PublishedEventsParameterResolverUnitTests { + + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + + @Test + void supportsPublishedEventsType() throws Exception { + + PublishedEventsParameterResolver resolver = new PublishedEventsParameterResolver(__ -> context); + + assertThat(resolver.supportsParameter(getParameterContext(PublishedEvents.class), null)).isTrue(); + assertThat(resolver.supportsParameter(getParameterContext(Object.class), null)).isFalse(); + } + + @Test + void createsThreadBoundPublishedEvents() throws Exception { + + PublishedEventsParameterResolver resolver = new PublishedEventsParameterResolver(__ -> context); + context.refresh(); + + resolver.beforeAll(null); + + Map allEvents = new ConcurrentHashMap<>(); + List keys = Arrays.asList("first", "second", "third"); + CountDownLatch latch = new CountDownLatch(3); + + for (String it : keys) { + + new Thread(() -> { + + PublishedEvents events = resolver.resolveParameter(null, null); + context.publishEvent(it); + allEvents.put(it, events); + + resolver.afterEach(null); + + latch.countDown(); + + }).start(); + + } + + latch.await(50, TimeUnit.MILLISECONDS); + + keys.forEach(it -> { + assertThat(allEvents.get(it).ofType(String.class)).containsExactly(it); + }); + } + + private static ParameterContext getParameterContext(Class type) { + + Method method = ReflectionUtils.findMethod(Methods.class, "with", type); + + ParameterContext context = mock(ParameterContext.class); + doReturn(method.getParameters()[0]).when(context).getParameter(); + + return context; + } + + interface Methods { + + void with(PublishedEvents events); + + void with(Object object); + } +} diff --git a/moduliths-test/src/test/java/org/moduliths/test/PublishedEventsUnitTests.java b/moduliths-test/src/test/java/org/moduliths/test/PublishedEventsUnitTests.java new file mode 100644 index 00000000..97ebc9df --- /dev/null +++ b/moduliths-test/src/test/java/org/moduliths/test/PublishedEventsUnitTests.java @@ -0,0 +1,38 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.moduliths.test; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link PublishedEvents}. + * + * @author Oliver Drotbohm + */ +class PublishedEventsUnitTests { + + @Test + void createsInstanceFromEvents() { + + Object reference = new Object(); + + PublishedEvents events = PublishedEvents.of(reference); + + assertThat(events.ofType(Object.class)).containsExactly(reference); + } +} diff --git a/moduliths-test/src/test/resources/logback.xml b/moduliths-test/src/test/resources/logback.xml new file mode 100644 index 00000000..12e067d7 --- /dev/null +++ b/moduliths-test/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + %d %5p %40.40c:%4L - %m%n + + + + + + + + + + \ No newline at end of file diff --git a/mvnw b/mvnw new file mode 100755 index 00000000..d2f0ea38 --- /dev/null +++ b/mvnw @@ -0,0 +1,310 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven2 Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/io/takari/maven-wrapper/0.5.5/maven-wrapper-0.5.5.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.5/maven-wrapper-0.5.5.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 00000000..b26ab24f --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,182 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven2 Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.5/maven-wrapper-0.5.5.jar" + +FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.5/maven-wrapper-0.5.5.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..be9702b8 --- /dev/null +++ b/pom.xml @@ -0,0 +1,298 @@ + + 4.0.0 + + org.moduliths + moduliths + 1.4.0-SNAPSHOT + + pom + + Moduliths + + + org.springframework.boot + spring-boot-starter-parent + 2.7.1 + + + + + moduliths-api + moduliths-core + moduliths-events + moduliths-test + moduliths-docs + moduliths-observability + moduliths-moments + moduliths-starter-jpa + moduliths-starter-jpa-jakarta + moduliths-starter-test + + + + + 0.23.1 + 2021.2.4 + UTF-8 + UTF-8 + + + + + Moduliths + http://moduliths.org + + + + + odrotbohm + Oliver Drotbohm + odrotbohm at vmware.com + VMware + http://www.spring.io + + lead + + + + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + +Copyright 2014-2020 the original author or authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. + +See the License for the specific language governing permissions and +limitations under the License. + + + + + + + + + default + + + true + + + + moduliths-sample + moduliths-integration-test + + + + + spring-snapshot + Spring Snapshot Repository + https://repo.spring.io/snapshot + + + + + + spring-snapshot + https://repo.spring.io/snapshot + + false + + + + + + + sonatype + + true + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + attach-javadocs + + jar + + + + + + -Xdoclint:none + + package + + + + + + + sonatype-new + https://s01.oss.sonatype.org/service/local/staging/deploy/maven2 + + + sonatype-new + https://s01.oss.sonatype.org/content/repositories/snapshots + + + + + + + + + + org.jmolecules + jmolecules-bom + ${jmolecules-bom.version} + pom + import + + + + + + + + org.projectlombok + lombok + 1.18.24 + provided + + + + + + + verify + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + + + + maven-jar-plugin + + + + ${module.name} + + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar-no-fork + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + + + + + + org.codehaus.mojo + flatten-maven-plugin + 1.2.7 + + + flatten + process-resources + + flatten + + + true + oss + + remove + remove + remove + remove + + + + + flatten-clean + clean + + clean + + + + + + + org.apache.maven.plugins + maven-release-plugin + 3.0.0-M1 + + sonatype + true + false + @{project.version} + true + + + + + + + + https://github.com/odrotbohm/moduliths + scm:git:https://github.com/odrotbohm/moduliths + scm:git:ssh://git@github.com/odrotbohm/moduliths.git + main + + +