diff --git a/spring-modulith-actuator/src/main/java/org/springframework/modulith/actuator/ApplicationModulesEndpoint.java b/spring-modulith-actuator/src/main/java/org/springframework/modulith/actuator/ApplicationModulesEndpoint.java index d44acdc8..de29abf3 100644 --- a/spring-modulith-actuator/src/main/java/org/springframework/modulith/actuator/ApplicationModulesEndpoint.java +++ b/spring-modulith-actuator/src/main/java/org/springframework/modulith/actuator/ApplicationModulesEndpoint.java @@ -15,26 +15,17 @@ */ package org.springframework.modulith.actuator; -import static java.util.stream.Collectors.*; - -import java.util.LinkedHashMap; import java.util.Map; -import java.util.Map.Entry; -import java.util.Set; -import java.util.function.Function; import java.util.function.Supplier; -import java.util.stream.Collector; -import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; -import org.springframework.modulith.core.ApplicationModule; -import org.springframework.modulith.core.ApplicationModuleDependency; import org.springframework.modulith.core.ApplicationModules; -import org.springframework.modulith.core.DependencyType; +import org.springframework.modulith.core.util.ApplicationModulesExporter; import org.springframework.util.Assert; +import org.springframework.util.function.SingletonSupplier; /** * A Spring Boot actuator endpoint to expose the application module structure of a Spring Modulith based application. @@ -46,20 +37,7 @@ public class ApplicationModulesEndpoint { private static final Logger LOGGER = LoggerFactory.getLogger(ApplicationModulesEndpoint.class); - private static final Function, Set> REMOVE_DEFAULT_DEPENDENCY_TYPE_IF_OTHERS_PRESENT = it -> { - - if (it.stream().anyMatch(type -> type != DependencyType.DEFAULT)) { - it.remove(DependencyType.DEFAULT); - } - - return it; - }; - - private static final Collector> MAPPER = mapping( - ApplicationModuleDependency::getDependencyType, - collectingAndThen(toSet(), REMOVE_DEFAULT_DEPENDENCY_TYPE_IF_OTHERS_PRESENT)); - - private final Supplier runtime; + private final SingletonSupplier structure; /** * Creates a new {@link ApplicationModulesEndpoint} for the given {@link ApplicationModules}. @@ -72,7 +50,7 @@ public class ApplicationModulesEndpoint { LOGGER.debug("Activating Spring Modulith actuator."); - this.runtime = runtime; + this.structure = SingletonSupplier.of(new ApplicationModulesExporter(runtime.get())::toJson); } /** @@ -81,34 +59,7 @@ public class ApplicationModulesEndpoint { * @return will never be {@literal null}. */ @ReadOperation - Map getApplicationModules() { - - var modules = runtime.get(); - - return modules.stream() - .collect( - Collectors.toMap(ApplicationModule::getName, it -> toInfo(it, modules), (l, r) -> r, LinkedHashMap::new)); - } - - private static Map toInfo(ApplicationModule module, ApplicationModules modules) { - - return Map.of( // - "displayName", module.getDisplayName(), // - "basePackage", module.getBasePackage().getName(), // - "dependencies", module.getDependencies(modules).stream() // - .collect(Collectors.groupingBy(ApplicationModuleDependency::getTargetModule, MAPPER)) - .entrySet() // - .stream() // - .map(ApplicationModulesEndpoint::toInfo) // - .toList() // - ); - } - - private static Map toInfo(Entry> types) { - - return Map.of( // - "target", types.getKey().getName(), // - "types", types.getValue() // - ); + String getApplicationModules() { + return structure.obtain(); } } diff --git a/spring-modulith-actuator/src/test/java/org/springframework/modulith/actuator/ApplicationModulesEndpointIntegrationTests.java b/spring-modulith-actuator/src/test/java/org/springframework/modulith/actuator/ApplicationModulesEndpointIntegrationTests.java index c19031ed..063a90cd 100644 --- a/spring-modulith-actuator/src/test/java/org/springframework/modulith/actuator/ApplicationModulesEndpointIntegrationTests.java +++ b/spring-modulith-actuator/src/test/java/org/springframework/modulith/actuator/ApplicationModulesEndpointIntegrationTests.java @@ -22,7 +22,6 @@ import net.minidev.json.JSONArray; import org.junit.jupiter.api.Test; import org.springframework.modulith.test.TestApplicationModules; -import com.fasterxml.jackson.databind.ObjectMapper; import com.jayway.jsonpath.JsonPath; /** @@ -38,7 +37,7 @@ class ApplicationModulesEndpointIntegrationTests { var modules = TestApplicationModules.of("example"); var endpoint = new ApplicationModulesEndpoint(() -> modules); var result = endpoint.getApplicationModules(); - var context = JsonPath.parse(new ObjectMapper().writeValueAsString(result)); + var context = JsonPath.parse(result); assertThat(context. read("$.a.basePackage")).isEqualTo("example.a"); assertThat(context. read("$.a.dependencies")).isEmpty(); diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/util/ApplicationModulesExporter.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/util/ApplicationModulesExporter.java new file mode 100644 index 00000000..12531539 --- /dev/null +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/util/ApplicationModulesExporter.java @@ -0,0 +1,120 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.core.util; + +import static java.util.stream.Collectors.*; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collector; +import java.util.stream.Collectors; + +import org.springframework.modulith.core.ApplicationModule; +import org.springframework.modulith.core.ApplicationModuleDependency; +import org.springframework.modulith.core.ApplicationModules; +import org.springframework.modulith.core.DependencyType; +import org.springframework.util.Assert; + +/** + * Export the structure of {@link ApplicationModules} as JSON. + * + * @author Oliver Drotbohm + */ +public class ApplicationModulesExporter { + + private static final Function, Set> REMOVE_DEFAULT_DEPENDENCY_TYPE_IF_OTHERS_PRESENT = it -> { + + if (it.stream().anyMatch(type -> type != DependencyType.DEFAULT)) { + it.remove(DependencyType.DEFAULT); + } + + return it; + }; + + private static final Collector> MAPPER = mapping( + ApplicationModuleDependency::getDependencyType, + collectingAndThen(toSet(), REMOVE_DEFAULT_DEPENDENCY_TYPE_IF_OTHERS_PRESENT)); + + private final ApplicationModules modules; + + /** + * Creates a new {@link ApplicationModulesExporter} for the given {@link ApplicationModules}. + * + * @param modules must not be {@literal null}. + */ + public ApplicationModulesExporter(ApplicationModules modules) { + + Assert.notNull(modules, "ApplicationModules must not be null!"); + + this.modules = modules; + } + + /** + * Simple main method to render the {@link ApplicationModules} instance defined for the Java package given as first + * argument. + * + * @param args a single-element array containing a Java package name to bootstrap an {@link ApplicationModules} + * instance from. + */ + public static void main(String[] args) { + + Assert.notNull(args, "Arguments must not be null!"); + Assert.isTrue(args.length == 1, "A java package name is required as only argument!"); + + System.out.println(new ApplicationModulesExporter(ApplicationModules.of(args[0])).toJson()); + } + + /** + * Returns the {@link ApplicationModules} structure as JSON String. + * + * @return will never be {@literal null}. + */ + public String toJson() { + return Json.toString(toMap()); + } + + private Map toMap() { + + return modules.stream() + .collect( + Collectors.toMap(ApplicationModule::getName, it -> toInfo(it, modules), (l, r) -> r, LinkedHashMap::new)); + } + + private static Map toInfo(ApplicationModule module, ApplicationModules modules) { + + return Map.of( // + "displayName", module.getDisplayName(), // + "basePackage", module.getBasePackage().getName(), // + "dependencies", module.getDependencies(modules).stream() // + .collect(Collectors.groupingBy(ApplicationModuleDependency::getTargetModule, MAPPER)) + .entrySet() // + .stream() // + .map(ApplicationModulesExporter::toInfo) // + .toList() // + ); + } + + private static Map toInfo(Entry> types) { + + return Map.of( // + "target", types.getKey().getName(), // + "types", types.getValue() // + ); + } +} diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/util/Json.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/util/Json.java new file mode 100644 index 00000000..ee4ca668 --- /dev/null +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/util/Json.java @@ -0,0 +1,76 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.core.util; + +import java.util.Collection; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +/** + * Helper to render a {@link Map} as JSON. Key need to be {@link #toString()}-able, values need to be + * {@link #toString()}-able, too, be an enum, a {@link Collection} or {@link Map}. + * + * @author Oliver Drotbohm + */ +class Json { + + @SuppressWarnings({ "rawtypes", "unchecked" }) + static String toString(Object object) { + + if (object instanceof String string) { + return quote(string); + } + + if (object instanceof Collection collection) { + return toString(collection); + } + + if (object instanceof Map map) { + return toString(map); + } + + if (object instanceof Enum value) { + return quote(value.name()); + } + + return object.toString(); + } + + private static String toString(Map map) { + + return map.isEmpty() + ? "{}" + : map.entrySet().stream() + .map(Json::toString) + .collect(Collectors.joining(", ", "{ ", " }")); + } + + private static String toString(Entry entry) { + return "\"" + entry.getKey() + "\" : " + toString(entry.getValue()); + } + + private static String toString(Collection collection) { + + return collection.isEmpty() + ? "[]" + : collection.stream().map(Json::toString).collect(Collectors.joining(", ", "[ ", " ]")); + } + + private static String quote(String string) { + return "\"" + string + "\""; + } +} diff --git a/spring-modulith-integration-test/pom.xml b/spring-modulith-integration-test/pom.xml index 6b8ba029..99da9cba 100644 --- a/spring-modulith-integration-test/pom.xml +++ b/spring-modulith-integration-test/pom.xml @@ -52,12 +52,20 @@ spring-tx + + org.springframework.boot spring-boot-starter-test test - + + + com.fasterxml.jackson.core + jackson-databind + test + + org.jgrapht jgrapht-core diff --git a/spring-modulith-integration-test/src/test/java/org/springframework/modulith/core/util/ApplicationModulesExporterUnitTests.java b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/core/util/ApplicationModulesExporterUnitTests.java new file mode 100644 index 00000000..b9fe7feb --- /dev/null +++ b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/core/util/ApplicationModulesExporterUnitTests.java @@ -0,0 +1,42 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.core.util; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.modulith.core.ApplicationModules; + +import com.acme.myproject.Application; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Unit tests for {@link ApplicationModulesExporter}. + * + * @author Oliver Drotbohm + */ +public class ApplicationModulesExporterUnitTests { + + @Test // #119 + void rendersApplicationModulesAsJson() { + + var json = new ApplicationModulesExporter(ApplicationModules.of(Application.class)).toJson(); + + assertThatNoException().isThrownBy(() -> { + new ObjectMapper().readTree(json); + }); + } +}