Skip to content

Commit bb98a5b

Browse files
committed
Cleaned up Snapshot naming, path and location to be consistent
1 parent e427fa4 commit bb98a5b

43 files changed

Lines changed: 402 additions & 548 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/jvm/src/main/java/io/eventdriven/strictland/FileSnapshotStorage.java

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@
2020
* and fails the check.</p>
2121
*
2222
* <p> `FileSnapshotStorage` is a thin wrapper that just writes bytes and reads them back.
23-
* The path, layout, test variant label are resolved byt the caller (so test suite).</p>
23+
* The path, layout, and variant are resolved by the caller before the bytes reach storage.</p>
2424
*/
2525
class FileSnapshotStorage implements SnapshotStorage {
2626

2727
private static final String APPROVED_MARKER = ".approved.";
2828
private static final String RECEIVED_MARKER = ".received.";
29-
private static final String SNAP_APPROVED_MARKER = ".snap.approved.";
29+
static final String SNAP_APPROVED_MARKER = ".snap.approved.";
3030

3131
@Override
3232
public void store(String pathWithName, byte[] payload) {
@@ -68,17 +68,16 @@ public Optional<byte[]> read(String pathWithName) {
6868
}
6969

7070
@Override
71-
public List<byte[]> readAll(SnapshotReadLocation location) {
72-
var folder = location.folder();
71+
public List<byte[]> readAll(@Nullable Path folder, String prefix, @Nullable String variant) {
7372
if (folder == null || !Files.isDirectory(folder)) {
7473
return List.of();
7574
}
7675
try (Stream<Path> files = Files.list(folder)) {
7776
var matches = files.filter(path -> {
7877
var name = path.getFileName().toString();
79-
return name.startsWith(location.prefix())
78+
return name.startsWith(prefix)
8079
&& name.contains(APPROVED_MARKER)
81-
&& matchesVariant(name, location.variant());
80+
&& matchesVariant(name, variant);
8281
})
8382
.sorted(Comparator.comparing(path -> path.getFileName().toString()))
8483
.toList();

src/jvm/src/main/java/io/eventdriven/strictland/GivenStep.java

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ public class GivenStep<S> {
1717
final String version;
1818
final MessageSerializer serializer;
1919
final SnapshotStorage storage;
20-
final SnapshotLocation location;
20+
final @Nullable SnapshotLayout layout;
21+
final String fileExtension;
2122
final MessageTypeMapper typeMapper;
2223

2324
GivenStep(
@@ -26,14 +27,16 @@ public class GivenStep<S> {
2627
String version,
2728
MessageSerializer serializer,
2829
SnapshotStorage storage,
29-
SnapshotLocation location,
30+
@Nullable SnapshotLayout layout,
31+
String fileExtension,
3032
MessageTypeMapper typeMapper) {
3133
this.snapshot = snapshot;
3234
this.instance = instance;
3335
this.version = version;
3436
this.serializer = serializer;
3537
this.storage = storage;
36-
this.location = location;
38+
this.layout = layout;
39+
this.fileExtension = fileExtension;
3740
this.typeMapper = typeMapper;
3841
}
3942

@@ -57,7 +60,7 @@ public class GivenStep<S> {
5760
*/
5861
public ThenContractStep<S> whenSerialized() {
5962
var instance = requireInstance();
60-
return new ThenContractStep<>(instance, null, version, serializer, storage, location, typeMapper);
63+
return new ThenContractStep<>(instance, null, version, serializer, storage, layout, fileExtension, typeMapper);
6164
}
6265

6366
/**
@@ -81,7 +84,8 @@ public ThenContractStep<S> whenSerialized() {
8184
*/
8285
public ThenContractStep<S> whenSerializedAs(Snapshot destination) {
8386
var instance = requireInstance();
84-
return new ThenContractStep<>(instance, destination, version, serializer, storage, location, typeMapper);
87+
return new ThenContractStep<>(
88+
instance, destination, version, serializer, storage, layout, fileExtension, typeMapper);
8589
}
8690

8791
/**
@@ -112,7 +116,8 @@ public ThenContractStep<S> whenSerializedAs(SnapshotVariant variant) {
112116
version,
113117
serializer,
114118
storage,
115-
location,
119+
layout,
120+
fileExtension,
116121
typeMapper);
117122
}
118123

@@ -145,6 +150,6 @@ private S requireInstance() {
145150
*/
146151
public <T> ThenCompatibilityStep<S, T> whenDeserializedAs(Class<T> targetType) {
147152
return new ThenCompatibilityStep<>(
148-
snapshot, instance, version, targetType, serializer, storage, location, typeMapper);
153+
snapshot, instance, version, targetType, serializer, storage, layout, typeMapper);
149154
}
150155
}

src/jvm/src/main/java/io/eventdriven/strictland/MessageContract.java

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.eventdriven.strictland;
22

3+
import org.jspecify.annotations.Nullable;
4+
35
/**
46
* Strictland is a contract-testing library for the messages your code sends and stores: events,
57
* commands, queue messages, HTTP requests and responses, and anything else you serialize for someone
@@ -24,28 +26,26 @@
2426
* </pre>
2527
*
2628
* <p>The approved file each check compares against lives in a committed contract registry, under
27-
* {@code src/test/resources/contract-snapshots} by default, so a contract change shows up in the same
29+
* {@code src/test/resources/contract-registry} by default, so a contract change shows up in the same
2830
* pull request as the code that caused it.
2931
* {@link Snapshot} picks which file backs a check, and {@link PublicApiScanner} renders a package's
3032
* public API as text so you can approval-test the surface itself.</p>
3133
*/
3234
public class MessageContract {
3335
private final MessageSerializer serializer;
3436
private final SnapshotStorage storage;
35-
private final SnapshotLocation location;
37+
private final @Nullable SnapshotLayout layout;
3638
private final MessageTypeMapper typeMapper;
3739

3840
private MessageContract(SpecificationOptions options) {
3941
this.serializer = options.serializer();
4042
var configuredStorage = options.storage();
4143
if (configuredStorage != null) {
4244
this.storage = configuredStorage;
43-
this.location = new SnapshotLocation(null, options.serializer().fileExtension(), TestClassNaming.SIMPLE);
45+
this.layout = null;
4446
} else {
45-
var layout = SnapshotLayoutResolver.resolve(options.snapshotLayout());
47+
this.layout = SnapshotLayoutResolver.resolve(options.snapshotLayout());
4648
this.storage = new FileSnapshotStorage();
47-
this.location =
48-
new SnapshotLocation(layout, options.serializer().fileExtension(), layout.testClassNaming());
4949
}
5050
var configuredTypeMapper = options.typeMapper();
5151
this.typeMapper = configuredTypeMapper != null ? configuredTypeMapper : MessageTypeMapper.fullyQualifiedName();
@@ -90,7 +90,15 @@ public static MessageContract specification(SpecificationOptions options) {
9090
* @return the next step, where you choose what to check
9191
*/
9292
public <S> GivenStep<S> given(Snapshot.ByClass<S> snapshot) {
93-
return new GivenStep<>(snapshot, null, snapshot.version(), serializer, storage, location, typeMapper);
93+
return new GivenStep<>(
94+
snapshot,
95+
null,
96+
snapshot.version(),
97+
serializer,
98+
storage,
99+
layout,
100+
serializer.fileExtension(),
101+
typeMapper);
94102
}
95103

96104
/**
@@ -105,7 +113,15 @@ public <S> GivenStep<S> given(Snapshot.ByClass<S> snapshot) {
105113
* @return the next step, where you choose what to check
106114
*/
107115
public GivenStep<Object> given(Snapshot.ByMessageType snapshot) {
108-
return new GivenStep<>(snapshot, null, snapshot.version(), serializer, storage, location, typeMapper);
116+
return new GivenStep<>(
117+
snapshot,
118+
null,
119+
snapshot.version(),
120+
serializer,
121+
storage,
122+
layout,
123+
serializer.fileExtension(),
124+
typeMapper);
109125
}
110126

111127
/**
@@ -119,7 +135,15 @@ public GivenStep<Object> given(Snapshot.ByMessageType snapshot) {
119135
* @return the next step, where you choose what to check
120136
*/
121137
public GivenStep<Object> given(Snapshot.ByPath snapshot) {
122-
return new GivenStep<>(snapshot, null, Snapshot.DEFAULT_VERSION, serializer, storage, location, typeMapper);
138+
return new GivenStep<>(
139+
snapshot,
140+
null,
141+
Snapshot.DEFAULT_VERSION,
142+
serializer,
143+
storage,
144+
layout,
145+
serializer.fileExtension(),
146+
typeMapper);
123147
}
124148

125149
/**
@@ -134,7 +158,15 @@ public GivenStep<Object> given(Snapshot.ByPath snapshot) {
134158
* @return the next step, where you choose what to check
135159
*/
136160
public <S> GivenStep<S> given(S instance) {
137-
return new GivenStep<>(null, instance, Snapshot.DEFAULT_VERSION, serializer, storage, location, typeMapper);
161+
return new GivenStep<>(
162+
null,
163+
instance,
164+
Snapshot.DEFAULT_VERSION,
165+
serializer,
166+
storage,
167+
layout,
168+
serializer.fileExtension(),
169+
typeMapper);
138170
}
139171

140172
/**
@@ -151,6 +183,7 @@ public <S> GivenStep<S> given(S instance) {
151183
* @return the next step, where you choose what to check
152184
*/
153185
public <S> GivenStep<S> given(S instance, String version) {
154-
return new GivenStep<>(null, instance, version, serializer, storage, location, typeMapper);
186+
return new GivenStep<>(
187+
null, instance, version, serializer, storage, layout, serializer.fileExtension(), typeMapper);
155188
}
156189
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package io.eventdriven.strictland;
2+
3+
/**
4+
* A message type's name split into its namespace and short name. The snapshot machinery names files by
5+
* the short name and lays out folders by the namespace, so splitting once here keeps that math in one
6+
* place.
7+
*
8+
* <p>The namespace is everything before the last dot, the short name everything after it. A dotless
9+
* logical name has an empty namespace and is its own short name.</p>
10+
*
11+
* @param namespace the part before the last dot, empty when the name has none
12+
* @param shortName the part after the last dot, the whole name when it has none
13+
*/
14+
record MessageTypeName(String namespace, String shortName) {
15+
16+
/**
17+
* Splits a message type's name on its last dot into a namespace and a short name. A fully-qualified
18+
* name like {@code com.acme.orders.OrderPlaced} splits into the namespace {@code com.acme.orders}
19+
* and the short name {@code OrderPlaced}. A dotless logical name keeps an empty namespace and is its
20+
* own short name.
21+
*
22+
* @param messageType the message type's name to split
23+
* @return the namespace and short name the message type's name carries
24+
*/
25+
static MessageTypeName of(String messageType) {
26+
var dot = messageType.lastIndexOf('.');
27+
return dot < 0
28+
? new MessageTypeName("", messageType)
29+
: new MessageTypeName(messageType.substring(0, dot), messageType.substring(dot + 1));
30+
}
31+
}

src/jvm/src/main/java/io/eventdriven/strictland/SnapshotLayout.java

Lines changed: 27 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,39 +10,38 @@
1010
* <p>The layout is a single registry tree. Snapshots gather under {@code rootPath/wrapperFolder}, and
1111
* the message type's namespace becomes folders beneath that, ending in a folder named after the short
1212
* message type. So {@code com.acme.orders.OrderPlaced} resolves under {@code
13-
* src/test/resources/contract-snapshots/com/acme/orders/OrderPlaced}. A dotless message type has no
13+
* src/test/resources/contract-registry/com/acme/orders/OrderPlaced}. A dotless message type has no
1414
* namespace folders. Each refinement returns a copy, so a layout stays immutable.</p>
1515
*
1616
* <pre>
1717
* SnapshotLayout layout = SnapshotLayout.registry();
1818
* Path path = layout.resolve("com.acme.orders.OrderPlaced", "OrderPlaced", ".json");
19-
* // src/test/resources/contract-snapshots/com/acme/orders/OrderPlaced/OrderPlaced.snap.approved.json
19+
* // src/test/resources/contract-registry/com/acme/orders/OrderPlaced/OrderPlaced.snap.approved.json
2020
* </pre>
2121
*
2222
* @param rootPath the directory the snapshot tree is rooted at
2323
* @param wrapperFolder the segment under the root that holds the per-namespace folders
24-
* @param testClassNaming how the test class is named in an owned snapshot's file name
2524
*/
26-
public record SnapshotLayout(String rootPath, String wrapperFolder, TestClassNaming testClassNaming) {
25+
public record SnapshotLayout(String rootPath, String wrapperFolder) {
2726

2827
private static final String DEFAULT_ROOT_PATH = "src/test/resources";
29-
private static final String DEFAULT_WRAPPER_FOLDER = "contract-snapshots";
28+
private static final String DEFAULT_WRAPPER_FOLDER = "contract-registry";
3029
private static final String SNAP_APPROVED_SUFFIX = ".snap.approved";
3130

3231
/**
33-
* The default registry layout: snapshots under {@code src/test/resources/contract-snapshots}, with
34-
* the message type's namespace as folders beneath it and the test class named by its simple name.
32+
* The default registry layout: snapshots under {@code src/test/resources/contract-registry}, with
33+
* the message type's namespace as folders beneath it.
3534
*
3635
* <pre>
3736
* SnapshotLayout layout = SnapshotLayout.registry();
3837
* Path path = layout.resolve("com.acme.orders.OrderPlaced", "OrderPlaced", ".json");
39-
* // src/test/resources/contract-snapshots/com/acme/orders/OrderPlaced/OrderPlaced.snap.approved.json
38+
* // src/test/resources/contract-registry/com/acme/orders/OrderPlaced/OrderPlaced.snap.approved.json
4039
* </pre>
4140
*
4241
* @return the default registry layout
4342
*/
4443
public static SnapshotLayout registry() {
45-
return new SnapshotLayout(DEFAULT_ROOT_PATH, DEFAULT_WRAPPER_FOLDER, TestClassNaming.SIMPLE);
44+
return new SnapshotLayout(DEFAULT_ROOT_PATH, DEFAULT_WRAPPER_FOLDER);
4645
}
4746

4847
/**
@@ -57,12 +56,12 @@ public static SnapshotLayout registry() {
5756
* @return a copy of this layout rooted at the given directory
5857
*/
5958
public SnapshotLayout rootPath(String rootPath) {
60-
return new SnapshotLayout(rootPath, wrapperFolder, testClassNaming);
59+
return new SnapshotLayout(rootPath, wrapperFolder);
6160
}
6261

6362
/**
6463
* Returns a copy with a different wrapper folder, the segment under the root that holds the
65-
* per-namespace folders. The default is {@code contract-snapshots}; set it to match your convention.
64+
* per-namespace folders. The default is {@code contract-registry}; set it to match your convention.
6665
*
6766
* <pre>
6867
* SnapshotLayout underApproved = SnapshotLayout.registry().wrapperFolder("approved");
@@ -72,23 +71,7 @@ public SnapshotLayout rootPath(String rootPath) {
7271
* @return a copy of this layout using the given wrapper folder
7372
*/
7473
public SnapshotLayout wrapperFolder(String wrapperFolder) {
75-
return new SnapshotLayout(rootPath, wrapperFolder, testClassNaming);
76-
}
77-
78-
/**
79-
* Returns a copy that names the test class differently inside an owned snapshot's file name, for
80-
* keeping snapshots distinct when two test classes share a simple name. It changes only the file
81-
* name, never the folder a snapshot resolves under.
82-
*
83-
* <pre>
84-
* SnapshotLayout qualified = SnapshotLayout.registry().testClassNaming(TestClassNaming.FULL);
85-
* </pre>
86-
*
87-
* @param testClassNaming how the test class is named in an owned snapshot's file name
88-
* @return a copy of this layout using the given test-class naming
89-
*/
90-
public SnapshotLayout testClassNaming(TestClassNaming testClassNaming) {
91-
return new SnapshotLayout(rootPath, wrapperFolder, testClassNaming);
74+
return new SnapshotLayout(rootPath, wrapperFolder);
9275
}
9376

9477
/**
@@ -101,7 +84,7 @@ public SnapshotLayout testClassNaming(TestClassNaming testClassNaming) {
10184
*
10285
* <pre>
10386
* Path path = SnapshotLayout.registry().resolve("com.acme.orders.OrderPlaced", "withCoupon", ".json");
104-
* // src/test/resources/contract-snapshots/com/acme/orders/OrderPlaced/withCoupon.snap.approved.json
87+
* // src/test/resources/contract-registry/com/acme/orders/OrderPlaced/withCoupon.snap.approved.json
10588
* </pre>
10689
*
10790
* @param messageType the message type's fully-qualified name, whose namespace and short name shape
@@ -112,11 +95,22 @@ public SnapshotLayout testClassNaming(TestClassNaming testClassNaming) {
11295
*/
11396
public Path resolve(String messageType, String snapshotName, String fileExtension) {
11497
var fileName = snapshotName + SNAP_APPROVED_SUFFIX + fileExtension;
98+
return folder(MessageTypeName.of(messageType)).resolve(fileName);
99+
}
100+
101+
/**
102+
* Resolves the directory a message type's snapshots live in: the namespace as folders under {@code
103+
* rootPath/wrapperFolder}, ending in a folder named after the short message type. A dotless message
104+
* type has no namespace folders.
105+
*
106+
* @param messageType the message type whose namespace and short name shape the folder
107+
* @return the directory the message type's approved files live in
108+
*/
109+
Path folder(MessageTypeName messageType) {
115110
var folder = Path.of(rootPath, wrapperFolder);
116-
var namespace = SnapshotName.namespace(messageType);
117-
if (!namespace.isEmpty()) {
118-
folder = folder.resolve(namespace.replace('.', '/'));
111+
if (!messageType.namespace().isEmpty()) {
112+
folder = folder.resolve(messageType.namespace().replace('.', '/'));
119113
}
120-
return folder.resolve(SnapshotName.shortName(messageType)).resolve(fileName);
114+
return folder.resolve(messageType.shortName());
121115
}
122116
}

0 commit comments

Comments
 (0)