diff --git a/build.gradle.kts b/build.gradle.kts index 1f1dd6c..0776aaf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,6 +46,8 @@ dependencies { annotationProcessor("org.openjdk.jmh:jmh-generator-annprocess:$jmhVersion") implementation("com.fasterxml.jackson.core:jackson-databind") + implementation("org.ow2.asm:asm:9.4") + implementation("org.json:json:20230227") implementation("com.worldturner.medeia:medeia-validator-jackson:1.1.0") diff --git a/docs/_docs/1. implementations.md b/docs/_docs/1. implementations.md index 8caa46b..d6c5edd 100644 --- a/docs/_docs/1. implementations.md +++ b/docs/_docs/1. implementations.md @@ -34,19 +34,21 @@ against the underlying [  GitHub Repo](http "headings": [ "Implementation", "Short Name", + "Supported Schema Versions", "Language", "Licence", - "Jar size", - "Supported Schema Versions", + "Minimum Java Version", + "Jar size", "Project activity" ], "data": implData.filter(row => row.shortName !== "Jackson").map(row => [ "" + row.longName + "", - row.shortName, + row.shortName, + row.supported.join(', '), row.language, row.licence, + row.minJavaVersion, Math.ceil(row.jarSize / 1024) + ' KB', - row.supported.join(', '), row.inactive ?? 'Active' ]) } diff --git a/src/main/java/org/creekservice/kafka/test/perf/implementations/Implementation.java b/src/main/java/org/creekservice/kafka/test/perf/implementations/Implementation.java index 34e717d..d993386 100644 --- a/src/main/java/org/creekservice/kafka/test/perf/implementations/Implementation.java +++ b/src/main/java/org/creekservice/kafka/test/perf/implementations/Implementation.java @@ -30,7 +30,7 @@ import org.creekservice.kafka.test.perf.model.TestModel; import org.creekservice.kafka.test.perf.testsuite.AdditionalSchemas; import org.creekservice.kafka.test.perf.testsuite.SchemaSpec; -import org.creekservice.kafka.test.perf.util.JarFile; +import org.creekservice.kafka.test.perf.util.ImplJarFile; public interface Implementation { @@ -100,6 +100,7 @@ class MetaData { private final URL url; private final Color color; private final long jarSize; + private final String minJavaVersion; private final String inactiveMsg; /** @@ -143,8 +144,9 @@ public MetaData( } this.color = requireNonNull(color, "color"); this.jarSize = - JarFile.jarSizeForClass( + ImplJarFile.jarSizeForClass( requireNonNull(typeFromImplementation, "typeFromImplementation")); + this.minJavaVersion = ImplJarFile.jarMinJavaVersion(typeFromImplementation); this.inactiveMsg = requireNonNull(inactiveMsg, "inactiveMsg").trim(); if (longName.isBlank()) { @@ -201,6 +203,11 @@ public long jarSize() { return jarSize; } + @JsonProperty("minJavaVersion") + public String minJavaVersion() { + return minJavaVersion; + } + @JsonProperty("inactive") @JsonInclude(JsonInclude.Include.NON_EMPTY) public String inactiveMsg() { diff --git a/src/main/java/org/creekservice/kafka/test/perf/util/ImplJarFile.java b/src/main/java/org/creekservice/kafka/test/perf/util/ImplJarFile.java new file mode 100644 index 0000000..4b8d1fc --- /dev/null +++ b/src/main/java/org/creekservice/kafka/test/perf/util/ImplJarFile.java @@ -0,0 +1,190 @@ +/* + * Copyright 2023 Creek Contributors (https://github.com/creek-service) + * + * 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.creekservice.kafka.test.perf.util; + +import static java.util.Objects.requireNonNull; + +import com.google.common.annotations.VisibleForTesting; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.CodeSource; +import java.util.Enumeration; +import java.util.function.Function; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import org.objectweb.asm.ClassReader; + +/** Utility for determining the size of a jar file a class is loaded from. */ +public final class ImplJarFile { + + /** + * Determine the size of the jar the supplied type is loaded from. + * + * @param type a class loaded from the jar. + * @return the size of the jar, in bytes. + * @throws IllegalArgumentException if the supplied {@code type} isn't loaded from an accessible + * jar. + */ + public static long jarSizeForClass(final Class type) { + return new JarSize(JarTaskBase::sourceCode).size(type); + } + + /** + * Determine the minimum supported Java version for the jar the supplied type is loaded from. + * + * @param type a class loaded from the jar. + * @return the minimum supported Java version + * @throws IllegalArgumentException if the supplied {@code type} isn't loaded from an accessible + * jar. + */ + public static String jarMinJavaVersion(final Class type) { + return new JarMinJavaVersion(JarTaskBase::sourceCode).minJavaVersion(type); + } + + private ImplJarFile() {} + + @VisibleForTesting + static class JarTaskBase { + + final Function, CodeSource> sourceAccessor; + + JarTaskBase(final Function, CodeSource> locator) { + this.sourceAccessor = requireNonNull(locator, "locator"); + } + + @SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "Location supplied by JVM") + Path pathToJar(final Class type) { + final URL location = location(type); + + try { + return Paths.get(location.toURI()); + } catch (final Exception e) { + throw new IllegalArgumentException( + "Type not loaded from accessible jar file. location: " + location, e); + } + } + + private URL location(final Class type) { + final CodeSource codeSource = sourceAccessor.apply(type); + if (codeSource == null) { + throw new IllegalArgumentException("Type not loaded from a jar file: " + type); + } + + return codeSource.getLocation(); + } + + private static CodeSource sourceCode(final Class type) { + return type.getProtectionDomain().getCodeSource(); + } + } + + @VisibleForTesting + static final class JarSize extends JarTaskBase { + + JarSize(final Function, CodeSource> locator) { + super(locator); + } + + long size(final Class type) { + final Path path = pathToJar(type); + + try { + return Files.size(path); + } catch (final Exception e) { + throw new IllegalArgumentException( + "Failed to read file size. location: " + path, e); + } + } + } + + @VisibleForTesting + static final class JarMinJavaVersion extends JarTaskBase { + + JarMinJavaVersion(final Function, CodeSource> locator) { + super(locator); + } + + String minJavaVersion(final Class type) { + final Path path = pathToJar(type); + + try (JarFile jarFile = new JarFile(path.toFile())) { + final Enumeration entries = jarFile.entries(); + + while (entries.hasMoreElements()) { + final JarEntry entry = entries.nextElement(); + + if (entry.getName().endsWith(".class")) { + try (InputStream classInputStream = jarFile.getInputStream(entry)) { + return minJavaVersion(classInputStream); + } + } + } + } catch (IOException e) { + throw new IllegalArgumentException( + "Failed to extract the min Java version the jar supports. Path: " + path, + e); + } + + return "Unknown"; + } + + public static String minJavaVersion(final InputStream classFile) throws IOException { + final ClassReader classReader = new ClassReader(classFile); + final VersionClassVisitor versionClassVisitor = new VersionClassVisitor(); + classReader.accept(versionClassVisitor, 0); + + final int majorVersion = versionClassVisitor.majorVersion; + if (majorVersion < 0) { + throw new IllegalArgumentException("Failed to determine Java version"); + } + + final String javaVersion = + majorVersion < 49 + ? "1." + (majorVersion - 44) + : String.valueOf(majorVersion - 44); + + return "Java " + javaVersion; + } + + private static class VersionClassVisitor extends org.objectweb.asm.ClassVisitor { + + private int majorVersion = -1; + + VersionClassVisitor() { + super(org.objectweb.asm.Opcodes.ASM9); + } + + @Override + public void visit( + final int version, + final int access, + final String name, + final String signature, + final String superName, + final String[] interfaces) { + if (version > majorVersion) { + majorVersion = version; + } + } + } + } +} diff --git a/src/main/java/org/creekservice/kafka/test/perf/util/JarFile.java b/src/main/java/org/creekservice/kafka/test/perf/util/JarFile.java deleted file mode 100644 index 529c56a..0000000 --- a/src/main/java/org/creekservice/kafka/test/perf/util/JarFile.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2023 Creek Contributors (https://github.com/creek-service) - * - * 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.creekservice.kafka.test.perf.util; - -import static java.util.Objects.requireNonNull; - -import com.google.common.annotations.VisibleForTesting; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.CodeSource; -import java.util.function.Function; - -/** Utility for determining the size of a jar file a class is loaded from. */ -public final class JarFile { - - private final Function, CodeSource> sourceAccessor; - - /** - * Determine the size of the jar the supplied type is loaded from. - * - * @param type a class loaded from the jar. - * @return the size of the jar, in bytes. - * @throws IllegalArgumentException if the supplied {@code type} isn't loaded from an accessible - * jar. - */ - public static long jarSizeForClass(final Class type) { - return new JarFile(JarFile::sourceCode).size(type); - } - - @VisibleForTesting - JarFile(final Function, CodeSource> locator) { - this.sourceAccessor = requireNonNull(locator, "locator"); - } - - long size(final Class type) { - final Path path = pathToJar(type); - return size(path); - } - - private Path pathToJar(final Class type) { - return asPath(location(type)); - } - - private URL location(final Class type) { - final CodeSource codeSource = sourceAccessor.apply(type); - if (codeSource == null) { - throw new IllegalArgumentException("Type not loaded from a jar file: " + type); - } - - return codeSource.getLocation(); - } - - private static CodeSource sourceCode(final Class type) { - return type.getProtectionDomain().getCodeSource(); - } - - @SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "Location supplied by JVM") - private static Path asPath(final URL location) { - try { - return Paths.get(location.toURI()); - } catch (final Exception e) { - throw new IllegalArgumentException( - "Type not loaded from accessible jar file. location: " + location, e); - } - } - - private static long size(final Path path) { - try { - return Files.size(path); - } catch (final Exception e) { - throw new IllegalArgumentException("Failed to read file size. location: " + path, e); - } - } -} diff --git a/src/test/java/org/creekservice/kafka/test/perf/util/ImplJarFileTest.java b/src/test/java/org/creekservice/kafka/test/perf/util/ImplJarFileTest.java new file mode 100644 index 0000000..a3c7219 --- /dev/null +++ b/src/test/java/org/creekservice/kafka/test/perf/util/ImplJarFileTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2023 Creek Contributors (https://github.com/creek-service) + * + * 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.creekservice.kafka.test.perf.util; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mock.Strictness.LENIENT; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.net.URL; +import java.nio.file.Path; +import java.security.CodeSource; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ImplJarFileTest { + + @Mock(strictness = LENIENT) + private Function, CodeSource> sourceAccessor; + + @Mock(strictness = LENIENT) + private CodeSource codeSource; + + @BeforeEach + void setUp() { + when(sourceAccessor.apply(any())).thenReturn(codeSource); + when(codeSource.getLocation()) + .thenReturn(Test.class.getProtectionDomain().getCodeSource().getLocation()); + } + + @Nested + class JarBaseTaskTest { + + private ExampleTask task; + + @BeforeEach + void setUp() { + task = new ExampleTask(sourceAccessor); + } + + @Test + void shouldPassSuppliedTypeToLocator() { + // When: + task.getPath(Test.class); + + // Then: + verify(sourceAccessor).apply(Test.class); + } + + @Test + void shouldThrowIfSourceCodeNotAvailable() { + // Given: + when(sourceAccessor.apply(any())).thenReturn(null); + + // When: + final Exception e = + assertThrows(IllegalArgumentException.class, () -> task.getPath(Test.class)); + + // Then: + assertThat(e.getMessage(), is("Type not loaded from a jar file: " + Test.class)); + } + + @Test + void shouldThrowIfNotLoadedFromFile() throws Exception { + // Given: + when(codeSource.getLocation()).thenReturn(new URL("ftp:/localhost/something")); + + // When: + final Exception e = + assertThrows(IllegalArgumentException.class, () -> task.getPath(Test.class)); + + // Then: + assertThat( + e.getMessage(), + is( + "Type not loaded from accessible jar file. location:" + + " ftp:/localhost/something")); + } + + @Test + void shouldGetPathOfJar() { + // When: + final Path path = task.getPath(Test.class); + + // Then: + assertThat(path.toString(), containsString(".gradle" + File.separator + "caches")); + } + + private final class ExampleTask extends ImplJarFile.JarTaskBase { + + ExampleTask(final Function, CodeSource> locator) { + super(locator); + } + + Path getPath(final Class testClass) { + return pathToJar(testClass); + } + } + } + + @Nested + class JarSizeTest { + @Test + void shouldReturnJarSize() { + assertThat(ImplJarFile.jarSizeForClass(Test.class), is(greaterThan(1000L))); + } + } + + @Nested + class JarMinJavaVersionTest { + @Test + void shouldReturnJarMinJavaVersion() { + assertThat(ImplJarFile.jarMinJavaVersion(Test.class), is("Java 9")); + } + } +} diff --git a/src/test/java/org/creekservice/kafka/test/perf/util/ImplsJsonFormatterTest.java b/src/test/java/org/creekservice/kafka/test/perf/util/ImplsJsonFormatterTest.java index b5c78d5..c29e0d7 100644 --- a/src/test/java/org/creekservice/kafka/test/perf/util/ImplsJsonFormatterTest.java +++ b/src/test/java/org/creekservice/kafka/test/perf/util/ImplsJsonFormatterTest.java @@ -17,7 +17,10 @@ package org.creekservice.kafka.test.perf.util; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.matchesPattern; +import static org.hamcrest.Matchers.not; +import static org.mockito.Mock.Strictness.LENIENT; import static org.mockito.Mockito.when; import java.awt.Color; @@ -58,9 +61,11 @@ class ImplsJsonFormatterTest { Test.class, "No release since dot"); - @Mock private Implementation implA; + @Mock(strictness = LENIENT) + private Implementation implA; - @Mock private Implementation implB; + @Mock(strictness = LENIENT) + private Implementation implB; @BeforeEach void setUp() { @@ -69,16 +74,27 @@ void setUp() { } @Test - void shouldFormatAsJson() { + void shouldFormatAll() { // Given: // When: final String json = ImplsJsonFormatter.implDetailsAsJson(List.of(implA, implB)); + // Then: + assertThat(json, containsString("[{\"longName\":\"Implementation A\",")); + + assertThat(json, containsString("{\"longName\":\"Implementation B\",")); + } + + @Test + void shouldFormatAsJson() { + // When: + final String json = ImplsJsonFormatter.implDetailsAsJson(List.of(implA)); + // Then: assertThat( json, - is( + containsString( "[{\"longName\":\"Implementation A\"," + "\"shortName\":\"ImplA\"," + "\"language\":\"Java\"," @@ -86,16 +102,42 @@ void shouldFormatAsJson() { + "\"supported\":[\"DRAFT_04\"," + "\"DRAFT_2019_09\"]," + "\"url\":\"http://a\"," - + "\"color\":\"rgb(0,0,0)\"," - + "\"jarSize\":210954}," - + "{\"longName\":\"Implementation B\"," - + "\"shortName\":\"ImplB\"," - + "\"language\":\"Java\"," - + "\"licence\":\"Apache Licence 2.0\"," - + "\"supported\":[\"DRAFT_07\"]," - + "\"url\":\"http://b\"," - + "\"color\":\"rgb(0,0,255)\"," - + "\"jarSize\":210954," - + "\"inactive\":\"No release since dot\"}]")); + + "\"color\":\"rgb(0,0,0)\",")); + } + + @Test + void shouldNotIncludeInactiveForActiveProjects() { + // When: + final String json = ImplsJsonFormatter.implDetailsAsJson(List.of(implA)); + + // Then: + assertThat(json, not(containsString("\"inactive\":"))); + } + + @Test + void shouldIncludeInactiveMsgForInactiveProjects() { + // When: + final String json = ImplsJsonFormatter.implDetailsAsJson(List.of(implB)); + + // Then: + assertThat(json, containsString("\"inactive\":\"No release since dot\"")); + } + + @Test + void shouldIncludeJarSize() { + // When: + final String json = ImplsJsonFormatter.implDetailsAsJson(List.of(implA)); + + // Then: + assertThat(json, matchesPattern(".*\"jarSize\":\\d+[,}].*")); + } + + @Test + void shouldIncludeMinJavaVersion() { + // When: + final String json = ImplsJsonFormatter.implDetailsAsJson(List.of(implA)); + + // Then: + assertThat(json, matchesPattern(".*\"minJavaVersion\":\"Java \\d+\".*")); } } diff --git a/src/test/java/org/creekservice/kafka/test/perf/util/JarFileTest.java b/src/test/java/org/creekservice/kafka/test/perf/util/JarFileTest.java deleted file mode 100644 index 4d81ca9..0000000 --- a/src/test/java/org/creekservice/kafka/test/perf/util/JarFileTest.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2023 Creek Contributors (https://github.com/creek-service) - * - * 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.creekservice.kafka.test.perf.util; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.greaterThan; -import static org.hamcrest.Matchers.is; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.net.MalformedURLException; -import java.net.URL; -import java.security.CodeSource; -import java.util.function.Function; -import org.creekservice.api.test.util.TestPaths; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class JarFileTest { - - private static final URL THIS_FILE; - - static { - try { - THIS_FILE = - TestPaths.moduleRoot("json-schema-validation-comparison") - .resolve( - "src/test/java/org/creekservice/kafka/test/perf/util/JarFileTest.java") - .toUri() - .toURL(); - } catch (MalformedURLException e) { - throw new RuntimeException(e); - } - } - - @Mock(strictness = Mock.Strictness.LENIENT) - private Function, CodeSource> sourceAccessor; - - @Mock private CodeSource codeSource; - private JarFile jarFile; - - @BeforeEach - void setUp() { - jarFile = new JarFile(sourceAccessor); - - when(sourceAccessor.apply(any())).thenReturn(codeSource); - } - - @Test - void shouldPassSuppliedTypeToLocator() throws Exception { - // Given: - when(codeSource.getLocation()).thenReturn(THIS_FILE.toURI().toURL()); - - // When: - jarFile.size(Test.class); - - // Then: - verify(sourceAccessor).apply(Test.class); - } - - @Test - void shouldThrowIfSourceCodeNotAvailable() { - // Given: - when(sourceAccessor.apply(any())).thenReturn(null); - - // When: - final Exception e = - assertThrows(IllegalArgumentException.class, () -> jarFile.size(Test.class)); - - // Then: - assertThat(e.getMessage(), is("Type not loaded from a jar file: " + Test.class)); - } - - @Test - void shouldThrowIfNotLoadedFromFile() throws Exception { - // Given: - when(codeSource.getLocation()).thenReturn(new URL("ftp:/localhost/something")); - - // When: - final Exception e = - assertThrows(IllegalArgumentException.class, () -> jarFile.size(Test.class)); - - // Then: - assertThat( - e.getMessage(), - is("Type not loaded from accessible jar file. location: ftp:/localhost/something")); - } - - @Test - void shouldReturnJarSize() { - assertThat(JarFile.jarSizeForClass(Test.class), is(greaterThan(1000L))); - } -}