Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .devcontainer/jvm/devcontainer-lock.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"features": {
"ghcr.io/devcontainers/features/java:1": {
"version": "1.8.0",
"resolved": "ghcr.io/devcontainers/features/java@sha256:9663ce0219ff85786e87901ce5f0a59f488edd5f99b46015192cda48468b233a",
"integrity": "sha256:9663ce0219ff85786e87901ce5f0a59f488edd5f99b46015192cda48468b233a"
"version": "1.8.3",
"resolved": "ghcr.io/devcontainers/features/java@sha256:61a839211feda1e2a1f10646648a948e733e4d98d770f3a4b6698a3c5fc92abe",
"integrity": "sha256:61a839211feda1e2a1f10646648a948e733e4d98d770f3a4b6698a3c5fc92abe"
}
}
}
}
2 changes: 1 addition & 1 deletion .devcontainer/jvm/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/java:1": {
"version": "26",
"version": "26.0.2-tem",
"jdkDistro": "tem",
"installGradle": false
}
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Strictland is on Maven Central as `io.event-driven:strictland`. It runs on JDK 2
Gradle (Kotlin DSL):

```kotlin
testImplementation("io.event-driven:strictland:0.4.0")
testImplementation("io.event-driven:strictland:0.4.1")
```

Maven:
Expand All @@ -39,7 +39,7 @@ Maven:
<dependency>
<groupId>io.event-driven</groupId>
<artifactId>strictland</artifactId>
<version>0.4.0</version>
<version>0.4.1</version>
<scope>test</scope>
</dependency>
```
Expand Down Expand Up @@ -278,6 +278,21 @@ MessageContract.specification(Json.Jackson.of(yourObjectMapper))
.thenForwardCompatible();
```

### What decides the outcome

Both checks read the stored bytes as the type you pass to `whenDeserializedAs`. That type is the reader, and it's the reader's own declaration that decides pass or fail: every field it declares must be present in the data, unless it's marked optional with JSpecify's `@Nullable`.

```java
record OrderPlaced(UUID orderId, String customer, String coupon) {} // coupon required
record OrderPlaced(UUID orderId, String customer, @Nullable String coupon) {} // coupon optional
```

Given `{"orderId": "...", "customer": "Alice"}`, the first fails with `Required field 'coupon'` and the second passes.

That's one rule, and it runs the same way in both directions - the direction is which type you hand to `whenDeserializedAs`, not which method you call. Pass the newer type and you're checking it against older data, which is backward. Pass the older type and you're checking it against newer data, which is forward.

It applies to records and to plain classes alike. For a class, its instance fields play the part of record components.

### Read an approved older snapshot

Use `MessageSnapshot` when the old shape is already saved in the contract registry. This checks current code against the same bytes that were previously approved, instead of rebuilding the old message in the test:
Expand Down
2 changes: 1 addition & 1 deletion src/jvm/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ plugins {
}

group = "io.event-driven"
version = providers.gradleProperty("version").getOrElse("0.4.0-SNAPSHOT")
version = providers.gradleProperty("version").getOrElse("0.4.1-SNAPSHOT")

java {
toolchain { languageVersion = JavaLanguageVersion.of(26) }
Expand Down
2 changes: 1 addition & 1 deletion src/jvm/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version=0.4.0-SNAPSHOT
version=0.4.1-SNAPSHOT
baselineVersion=
org.gradle.caching=true
org.gradle.parallel=true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.reflect.AnnotatedType;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.lang.reflect.RecordComponent;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -134,20 +139,19 @@ private void verifyOne(byte[] sourceBytes, Consumer<T> extra) {
sourceMap = toMap(jsonMapper, sourceBytes);
targetMap = toMap(jsonMapper, serializer.serialize(deserialized));
} catch (UncheckedIOException | IOException e) {
throw new RuntimeException("Deserialization as " + targetType.getSimpleName() + " failed", e);
throw new RuntimeException(
"Deserialization as " + targetType.getSimpleName() + " failed: " + e.getMessage(), e);
}
assertRequiredFieldsSatisfied(sourceMap);
assertSharedFieldsMatch(sourceMap, targetMap);
extra.accept(deserialized);
}

private void assertRequiredFieldsSatisfied(Map<String, Object> sourceMap) {
if (!targetType.isRecord()) return;
for (var component : targetType.getRecordComponents()) {
if (component.getAnnotatedType().getDeclaredAnnotation(Nullable.class) != null) continue;
if (!sourceMap.containsKey(component.getName()) || sourceMap.get(component.getName()) == null) {
for (var name : requiredFieldNames()) {
if (!sourceMap.containsKey(name) || sourceMap.get(name) == null) {
throw new AssertionError("Required field '"
+ component.getName()
+ name
+ "' in "
+ targetType.getSimpleName()
+ " is null after deserialization. Source had keys: "
Expand All @@ -156,6 +160,24 @@ private void assertRequiredFieldsSatisfied(Map<String, Object> sourceMap) {
}
}

private List<String> requiredFieldNames() {
if (targetType.isRecord()) {
return Arrays.stream(targetType.getRecordComponents())
.filter(component -> !isNullable(component.getAnnotatedType()))
.map(RecordComponent::getName)
.toList();
}
return Arrays.stream(targetType.getDeclaredFields())
.filter(field -> !Modifier.isStatic(field.getModifiers()))
.filter(field -> !isNullable(field.getAnnotatedType()))
.map(Field::getName)
.toList();
}

private static boolean isNullable(AnnotatedType type) {
return type.getDeclaredAnnotation(Nullable.class) != null;
}

private void assertSharedFieldsMatch(Map<String, Object> sourceMap, Map<String, Object> targetMap) {
var sourceOnly = new ArrayList<>(sourceMap.keySet());
sourceOnly.removeAll(targetMap.keySet());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,13 +267,60 @@ static final class StoredOrder {
}
}

static final class StoredOrderWithCurrency {
public final UUID orderId;
public final String currency;

@JsonCreator
StoredOrderWithCurrency(@JsonProperty("orderId") UUID orderId, @JsonProperty("currency") String currency) {
this.orderId = orderId;
this.currency = currency;
}
}

static final class StoredOrderWithOptionalCoupon {
// A constant on the event class is not part of the message, so the check must skip it.
public static final String NO_COUPON = "none";

public final UUID orderId;
public final @Nullable String couponCode;

@JsonCreator
StoredOrderWithOptionalCoupon(
@JsonProperty("orderId") UUID orderId, @JsonProperty("couponCode") @Nullable String couponCode) {
this.orderId = orderId;
this.couponCode = couponCode;
}
}

@Test
void given_anEventModelledAsAClass_whenReadByANewerClassVersion_thenBackwardCompatible() {
MessageContract.specification(Json.Jackson.defaults())
.given(new StoredOrderV1(FIXED_ID))
.whenDeserializedAs(StoredOrder.class)
.thenBackwardCompatible();
}

@Test
void givenAnEventWithoutCoupon_whenReadByAClassThatAddedOptionalCoupon_thenBackwardCompatible() {
MessageContract.specification(Json.Jackson.defaults())
.given(new StoredOrderV1(FIXED_ID))
.whenDeserializedAs(StoredOrderWithOptionalCoupon.class)
.thenBackwardCompatible(order -> assertNull(order.couponCode));
}

@Test
void givenAnEventWithoutCurrency_whenReadByAClassThatAddedRequiredCurrency_thenNotBackwardCompatible() {
var error = assertThrows(
AssertionError.class,
() -> MessageContract.specification(Json.Jackson.defaults())
.given(new StoredOrderV1(FIXED_ID))
.whenDeserializedAs(StoredOrderWithCurrency.class)
.thenBackwardCompatible());

var message = requireNonNull(error.getMessage());
assertTrue(message.contains("currency"), message);
}
}

@Nested
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,15 @@ private record OrderStatusChangedV1(UUID orderId, OrderStatusV1 status) {}

@Test
void givenNewOrderWithAShippedStatus_whenReadByOldEnumWithoutIt_thenNotForwardCompatible() {
assertThrows(
var error = assertThrows(
RuntimeException.class,
() -> MessageContract.specification(Json.Jackson.defaults())
.given(new OrderStatusChangedV2(FIXED_ID, OrderStatusV2.SHIPPED))
.whenDeserializedAs(OrderStatusChangedV1.class)
.thenForwardCompatible());

var message = requireNonNull(error.getMessage());
assertTrue(message.contains("SHIPPED"), message);
}
}

Expand All @@ -102,12 +105,15 @@ void givenNewOrderWithExtraField_whenReadByOldTypeUsingAStrictMapper_thenNotForw
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();

assertThrows(
var error = assertThrows(
RuntimeException.class,
() -> MessageContract.specification(Json.Jackson.of(strictMapper))
.given(new OrderReceivedWithCoupon(FIXED_ID, "Alice", "SAVE10"))
.whenDeserializedAs(OrderReceived.class)
.thenForwardCompatible());

var message = requireNonNull(error.getMessage());
assertTrue(message.contains("couponCode"), message);
}

@Test
Expand Down