From 090ddc6fba176605565fa966ea2d29f8e971e977 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Wed, 18 Jan 2023 18:31:56 +0100 Subject: [PATCH] GH-119 - Improve rendering of application modules structure as JSON. Introduced ApplicationModulesExporter to render an ApplicationModules instances as JSON directly. To avoid a dependency to a JSON library and as we only have to be able to render rather simple arrangements, we just build up the JSON string ourselves. ApplicationModulesEndpoint now caches the structure calculated once to avoid repeated work. --- .../actuator/ApplicationModulesEndpoint.java | 61 +-------- ...cationModulesEndpointIntegrationTests.java | 3 +- .../core/util/ApplicationModulesExporter.java | 120 ++++++++++++++++++ .../modulith/core/util/Json.java | 76 +++++++++++ spring-modulith-integration-test/pom.xml | 10 +- .../ApplicationModulesExporterUnitTests.java | 42 ++++++ 6 files changed, 254 insertions(+), 58 deletions(-) create mode 100644 spring-modulith-core/src/main/java/org/springframework/modulith/core/util/ApplicationModulesExporter.java create mode 100644 spring-modulith-core/src/main/java/org/springframework/modulith/core/util/Json.java create mode 100644 spring-modulith-integration-test/src/test/java/org/springframework/modulith/core/util/ApplicationModulesExporterUnitTests.java 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); + }); + } +}