Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/jvm/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import com.vanniktech.maven.publish.JavadocJar
import com.vanniktech.maven.publish.SourcesJar
import me.champeau.gradle.japicmp.JapicmpTask
import net.ltgt.gradle.errorprone.errorprone
import org.gradle.testing.jacoco.tasks.JacocoReport

plugins {
`java-library`
jacoco
alias(libs.plugins.maven.publish)
alias(libs.plugins.japicmp)
alias(libs.plugins.spotless)
Expand Down Expand Up @@ -39,10 +41,27 @@ val testJdk = providers.gradleProperty("testJdk").map(String::toInt).getOrElse(2
tasks.withType<Test>().configureEach {
useJUnitPlatform()
javaLauncher.set(javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(testJdk) })
finalizedBy(tasks.named("jacocoTestReport"))
}

tasks.named<JacocoReport>("jacocoTestReport") {
dependsOn(tasks.named("test"))
reports {
xml.required = true
html.required = true
}
}

tasks.named("check") { dependsOn("jacocoTestReport") }

dependencies {
api(libs.jspecify)
implementation(libs.classgraph)
api(libs.jackson.databind)
implementation(libs.jackson.datatype.jsr310)
implementation(libs.approvaltests)
implementation(platform(libs.junit.bom))
implementation(libs.junit.jupiter)
errorprone(libs.errorprone.core)
errorprone(libs.errorprone.contrib)
errorprone(libs.errorprone.refaster)
Expand Down
7 changes: 7 additions & 0 deletions src/jvm/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
kotlin = "2.3.21"
scala = "3.8.3"
jspecify = "1.0.0"
jackson = "2.17.0"
classgraph = "4.8.179"
approvaltests = "24.0.0"
junit = "5.12.2"
spotless = "8.6.0"
palantir-java-format = "2.91.0"
Expand All @@ -14,6 +17,10 @@ nullaway-plugin = "3.0.0"

[libraries]
jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" }
classgraph = { module = "io.github.classgraph:classgraph", version.ref = "classgraph" }
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" }
approvaltests = { module = "com.approvaltests:approvaltests", version.ref = "approvaltests" }
scala-library = { module = "org.scala-lang:scala3-library_3", version.ref = "scala" }
scalatest = { module = "org.scalatest:scalatest_3", version = "3.2.19" }
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
Expand Down
47 changes: 47 additions & 0 deletions src/jvm/src/main/java/io/eventdriven/strictland/Contract.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package io.eventdriven.strictland;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

/**
* Entry point for the contract DSL used to verify event schema versioning and serialization compatibility.
*/
public class Contract {
private static final ObjectMapper DEFAULT_MAPPER = new JsonMapper()
.registerModule(new JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

private final ObjectMapper mapper;

private Contract(ObjectMapper mapper) {
this.mapper = mapper;
}

public static Contract specification() {
return new Contract(DEFAULT_MAPPER);
}

public static Contract specification(ObjectMapper mapper) {
return new Contract(mapper);
}

public <S> GivenStep<S> given(Snapshot.ByClass<S> snapshot) {
return new GivenStep<>(snapshot, null, mapper);
}

public GivenStep<Object> given(Snapshot.ByMessageType snapshot) {
return new GivenStep<>(snapshot, null, mapper);
}

public GivenStep<Object> given(Snapshot.ByPath snapshot) {
return new GivenStep<>(snapshot, null, mapper);
}

public <S> GivenStep<S> given(S instance) {
return new GivenStep<>(null, instance, mapper);
}
}
41 changes: 41 additions & 0 deletions src/jvm/src/main/java/io/eventdriven/strictland/GivenStep.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.eventdriven.strictland;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.jspecify.annotations.Nullable;

/**
* The "given" step of the contract DSL, holding either a snapshot or an instance to work with.
*/
public class GivenStep<S> {
final @Nullable Snapshot snapshot;
final @Nullable S instance;
final ObjectMapper mapper;

GivenStep(@Nullable Snapshot snapshot, @Nullable S instance, ObjectMapper mapper) {
this.snapshot = snapshot;
this.instance = instance;
this.mapper = mapper;
}

public ThenContractStep<S> whenSerialized() {
var instance = requireInstance();
return new ThenContractStep<>(instance, null, mapper);
}

public ThenContractStep<S> whenSerialized(Snapshot destination) {
var instance = requireInstance();
return new ThenContractStep<>(instance, destination, mapper);
}

private S requireInstance() {
if (instance == null) {
throw new IllegalStateException(
"whenSerialized() requires an instance — use Contract.given(instance), not Contract.given(Snapshot)");
}
return instance;
}

public <T> ThenCompatibilityStep<S, T> whenDeserializedAs(Class<T> targetType) {
return new ThenCompatibilityStep<>(snapshot, instance, targetType, mapper);
}
}
123 changes: 123 additions & 0 deletions src/jvm/src/main/java/io/eventdriven/strictland/PublicApiScanner.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package io.eventdriven.strictland;

import io.github.classgraph.ClassGraph;
import io.github.classgraph.ClassInfo;
import java.lang.reflect.*;
import java.util.Arrays;
import java.util.Comparator;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class PublicApiScanner {
private final String packageName;
private Predicate<Method> methodFilter = m -> true;
private boolean excludeConstructors;

private PublicApiScanner(String packageName) {
this.packageName = packageName;
}

public static PublicApiScanner forPackage(String packageName) {
return new PublicApiScanner(packageName);
}

public PublicApiScanner excludingMethods(Predicate<Method> filter) {
this.methodFilter = this.methodFilter.and(filter.negate());
return this;
}

public PublicApiScanner excludeConstructors() {
this.excludeConstructors = true;
return this;
}

public PublicApiScanner onlyGettersAndFields() {
return excludeConstructors()
.excludeStandardObjectMethods()
.excludingMethods(m -> m.getParameterCount() > 0 || m.getReturnType() == void.class);
}

public PublicApiScanner excludeStandardObjectMethods() {
return excludingMethods(m -> (m.getName().equals("equals") && m.getParameterCount() == 1)
|| (m.getName().equals("hashCode") && m.getParameterCount() == 0)
|| (m.getName().equals("toString") && m.getParameterCount() == 0));
}

public String generate() {
try (var scanResult =
new ClassGraph().enableAllInfo().acceptPackages(packageName).scan()) {

return scanResult.getAllClasses().stream()
.map(ClassInfo::loadClass)
.filter(c -> Modifier.isPublic(c.getModifiers()))
.sorted(Comparator.comparing(Class::getName))
.map(this::describeType)
.collect(Collectors.joining("\n\n"));
}
}

private String describeType(Class<?> clazz) {
var sb = new StringBuilder();
sb.append(typeDeclaration(clazz)).append(" {\n");

Arrays.stream(clazz.getDeclaredFields())
.filter(f -> Modifier.isPublic(f.getModifiers()) && !f.isSynthetic())
.sorted(Comparator.comparing(Field::getName))
.forEach(f -> sb.append(" ").append(f.toGenericString()).append(";\n"));

if (!excludeConstructors) {
Arrays.stream(clazz.getDeclaredConstructors())
.filter(c -> Modifier.isPublic(c.getModifiers()))
.sorted(Comparator.comparing(Constructor::toGenericString))
.forEach(c -> sb.append(" ").append(formatConstructor(c)).append(";\n"));
}

Arrays.stream(clazz.getDeclaredMethods())
.filter(m -> Modifier.isPublic(m.getModifiers()) && !m.isSynthetic() && !m.isBridge())
.filter(methodFilter)
.sorted(Comparator.comparing(Method::getName).thenComparing(Method::toGenericString))
.forEach(m -> sb.append(" ").append(formatMethod(m)).append(";\n"));

sb.append("}");
return sb.toString();
}

private static String formatConstructor(Constructor<?> c) {
var params = Arrays.stream(c.getGenericParameterTypes())
.map(Type::getTypeName)
.collect(Collectors.joining(", "));
return Modifier.toString(c.getModifiers()) + " " + c.getDeclaringClass().getSimpleName() + "(" + params + ")";
}

private static String formatMethod(Method m) {
var params = Arrays.stream(m.getGenericParameterTypes())
.map(Type::getTypeName)
.collect(Collectors.joining(", "));
return Modifier.toString(m.getModifiers()) + " "
+ m.getGenericReturnType().getTypeName() + " " + m.getName() + "(" + params + ")";
}

private static String typeDeclaration(Class<?> clazz) {
var parts = new StringBuilder();

if (Modifier.isPublic(clazz.getModifiers())) parts.append("public ");
if (Modifier.isStatic(clazz.getModifiers())) parts.append("static ");
if (Modifier.isFinal(clazz.getModifiers()) && !clazz.isEnum()) parts.append("final ");

if (clazz.isRecord()) parts.append("record");
else if (clazz.isInterface()) parts.append("interface");
else if (clazz.isEnum()) parts.append("enum");
else parts.append("class");

parts.append(" ").append(clazz.getName());

var interfaces = clazz.getInterfaces();
if (interfaces.length > 0) {
var keyword = clazz.isInterface() ? " extends " : " implements ";
parts.append(keyword)
.append(Arrays.stream(interfaces).map(Class::getName).collect(Collectors.joining(", ")));
}

return parts.toString();
}
}
36 changes: 36 additions & 0 deletions src/jvm/src/main/java/io/eventdriven/strictland/Snapshot.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package io.eventdriven.strictland;

import java.nio.file.Path;
import org.jspecify.annotations.Nullable;

/**
* Identifies the source of an approved snapshot used to verify schema contracts and compatibility.
*/
public sealed interface Snapshot permits Snapshot.ByClass, Snapshot.ByMessageType, Snapshot.ByPath {

record ByClass<T>(Class<T> sourceType) implements Snapshot {}

record ByMessageType(String messageType, @Nullable Class<?> sourceType) implements Snapshot {}

record ByPath(Path path, @Nullable Class<?> sourceType) implements Snapshot {}

static <T> ByClass<T> of(Class<T> sourceType) {
return new ByClass<>(sourceType);
}

static ByPath at(Path path) {
return new ByPath(path, null);
}

static <T> ByPath at(Path path, Class<T> sourceType) {
return new ByPath(path, sourceType);
}

static ByMessageType forMessageType(String messageType) {
return new ByMessageType(messageType, null);
}

static <T> ByMessageType forMessageType(String messageType, Class<T> sourceType) {
return new ByMessageType(messageType, sourceType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.eventdriven.strictland;

import java.nio.file.Path;
import java.util.Set;

class SnapshotPathResolver {
private static final Set<String> DSL_CLASSES = Set.of(
Contract.class.getName(),
GivenStep.class.getName(),
ThenContractStep.class.getName(),
ThenCompatibilityStep.class.getName(),
SnapshotPathResolver.class.getName());
private static final String SOURCE_ROOT = "src/test/java";

static Path resolve(String snapshotName) {
var callerClass = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE)
.walk(frames -> frames.filter(f -> !DSL_CLASSES.contains(f.getClassName()))
.filter(f -> !f.getClassName().startsWith("java."))
.filter(f -> !f.getClassName().startsWith("jdk."))
.filter(f -> !f.getClassName().startsWith("sun."))
.filter(f -> !f.getClassName().startsWith("org.junit."))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Cannot determine calling test class from stack")))
.getDeclaringClass();

var packagePath = callerClass.getPackageName().replace('.', '/');
return Path.of(SOURCE_ROOT, packagePath, snapshotName + ".approved.txt");
}
}
Loading
Loading