From e6a9268ec43e89ce000d909e5880bb0b1a3a3abf Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 12:03:17 +0100
Subject: [PATCH 01/22] feat(calm-hub): add ResourceVersion to own the snapshot
suffix format
---
.../finos/calm/domain/ResourceVersion.java | 57 ++++++++++++++
.../domain/TestResourceVersionShould.java | 75 +++++++++++++++++++
2 files changed, 132 insertions(+)
create mode 100644 calm-hub/src/main/java/org/finos/calm/domain/ResourceVersion.java
create mode 100644 calm-hub/src/test/java/org/finos/calm/domain/TestResourceVersionShould.java
diff --git a/calm-hub/src/main/java/org/finos/calm/domain/ResourceVersion.java b/calm-hub/src/main/java/org/finos/calm/domain/ResourceVersion.java
new file mode 100644
index 000000000..884a3074a
--- /dev/null
+++ b/calm-hub/src/main/java/org/finos/calm/domain/ResourceVersion.java
@@ -0,0 +1,57 @@
+package org.finos.calm.domain;
+
+/**
+ * The one place that knows how a snapshot version is spelled.
+ *
+ * Why this is a type and not a string check
+ * {@link org.finos.calm.store.util.VersionScheme} records why version spelling and version ordering became one type:
+ * left independent, they drifted, and an ADR revision was silently stored and read under the
+ * wrong version. Snapshot-ness is a third property of the same string. A call to
+ * {@code endsWith("-SNAPSHOT")} scattered through the resource, service and store layers is
+ * the same mistake in a new place — each copy free to disagree about case, about a bare
+ * suffix, or about null.
+ *
+ * Canonicalisation stays in {@link org.finos.calm.store.util.CanonicalVersion} and ordering in
+ * {@link org.finos.calm.store.util.SemanticVersionOrder}. This class answers only "is it a snapshot, and what release
+ * does it belong to", and both of those delegate to it.
+ */
+public final class ResourceVersion {
+
+ /** The fixed, case-sensitive marker of a mutable version. */
+ public static final String SNAPSHOT_SUFFIX = "-SNAPSHOT";
+
+ private ResourceVersion() {
+ }
+
+ /**
+ * @param version any version string, or {@code null}
+ * @return {@code true} only for a version with content before the suffix. {@code null},
+ * a lowercase suffix and a bare {@code "-SNAPSHOT"} are all {@code false}: the suffix is
+ * a fixed token, and accepting variants would give one logical version several documents.
+ */
+ public static boolean isSnapshot(String version) {
+ return version != null
+ && version.endsWith(SNAPSHOT_SUFFIX)
+ && version.length() > SNAPSHOT_SUFFIX.length();
+ }
+
+ /**
+ * @return the version with any snapshot suffix removed, in its original spelling.
+ * Folding the spelling is {@link org.finos.calm.store.util.CanonicalVersion}'s job; doing it here as well would
+ * put one rule in two places.
+ */
+ public static String releaseVersion(String version) {
+ if (!isSnapshot(version)) {
+ return version;
+ }
+ return version.substring(0, version.length() - SNAPSHOT_SUFFIX.length());
+ }
+
+ /** @return the snapshot form of a version. Idempotent. */
+ public static String asSnapshot(String version) {
+ if (version == null || isSnapshot(version)) {
+ return version;
+ }
+ return version + SNAPSHOT_SUFFIX;
+ }
+}
diff --git a/calm-hub/src/test/java/org/finos/calm/domain/TestResourceVersionShould.java b/calm-hub/src/test/java/org/finos/calm/domain/TestResourceVersionShould.java
new file mode 100644
index 000000000..97f7324e7
--- /dev/null
+++ b/calm-hub/src/test/java/org/finos/calm/domain/TestResourceVersionShould.java
@@ -0,0 +1,75 @@
+package org.finos.calm.domain;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+
+class TestResourceVersionShould {
+
+ @Test
+ void recognise_a_snapshot_version() {
+ assertThat(ResourceVersion.isSnapshot("1.0.0-SNAPSHOT"), is(true));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"1.0.0", "1-0-0", "100", "2.13.7"})
+ void not_treat_a_release_version_as_a_snapshot(String release) {
+ assertThat(ResourceVersion.isSnapshot(release), is(false));
+ }
+
+ @Test
+ void reject_a_lowercase_suffix() {
+ // The suffix is a fixed token, not a free-form label. Accepting case variants would
+ // let 1.0.0-snapshot and 1.0.0-SNAPSHOT become two documents for one logical version.
+ assertThat(ResourceVersion.isSnapshot("1.0.0-snapshot"), is(false));
+ }
+
+ @Test
+ void not_treat_a_bare_suffix_as_a_snapshot() {
+ // "-SNAPSHOT" with no version in front is not a version at all.
+ assertThat(ResourceVersion.isSnapshot("-SNAPSHOT"), is(false));
+ }
+
+ @Test
+ void treat_a_null_version_as_not_a_snapshot() {
+ // listVersions reads the version field straight out of a document, so an absent
+ // field arrives here as null. Throwing would make a listing endpoint fail.
+ assertThat(ResourceVersion.isSnapshot(null), is(false));
+ }
+
+ @Test
+ void strip_the_suffix_to_give_the_release_version() {
+ assertThat(ResourceVersion.releaseVersion("1.0.0-SNAPSHOT"), is("1.0.0"));
+ }
+
+ @Test
+ void leave_a_release_version_unchanged_when_stripping() {
+ assertThat(ResourceVersion.releaseVersion("1.0.0"), is("1.0.0"));
+ }
+
+ @Test
+ void pass_null_through_when_stripping() {
+ assertThat(ResourceVersion.releaseVersion(null), is(nullValue()));
+ }
+
+ @Test
+ void add_the_suffix_to_a_release_version() {
+ assertThat(ResourceVersion.asSnapshot("1.0.0"), is("1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void not_double_the_suffix_on_a_version_that_already_has_one() {
+ assertThat(ResourceVersion.asSnapshot("1.0.0-SNAPSHOT"), is("1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void preserve_the_original_spelling_when_stripping() {
+ // Canonicalisation is CanonicalVersion's job, not this class's. Folding here as well
+ // would put the same rule in two places, which is how they drift apart.
+ assertThat(ResourceVersion.releaseVersion("1-0-0-SNAPSHOT"), is("1-0-0"));
+ }
+}
From e47cefef840fca9853344064003b9821e5b1c77f Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 12:08:16 +0100
Subject: [PATCH 02/22] feat(calm-hub): fold snapshot version spellings onto
one document key
---
.../calm/store/util/CanonicalVersion.java | 14 ++++++++--
.../util/TestCanonicalVersionShould.java | 27 +++++++++++++++++++
2 files changed, 39 insertions(+), 2 deletions(-)
diff --git a/calm-hub/src/main/java/org/finos/calm/store/util/CanonicalVersion.java b/calm-hub/src/main/java/org/finos/calm/store/util/CanonicalVersion.java
index 8b9d77b4f..261dac143 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/util/CanonicalVersion.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/util/CanonicalVersion.java
@@ -1,5 +1,6 @@
package org.finos.calm.store.util;
+import org.finos.calm.domain.ResourceVersion;
import org.finos.calm.resources.ResourceValidationConstants;
import java.util.regex.Matcher;
@@ -36,6 +37,13 @@
* the callers instead would mean seven resource types each having to
* remember to do it.
*
+ * Snapshots
+ * A snapshot folds exactly as its release version does, and keeps the suffix:
+ * {@code 100-SNAPSHOT} and {@code 1-0-0-SNAPSHOT} both store as {@code 1.0.0-SNAPSHOT}.
+ * The suffix is deliberately not folded away — a snapshot and its release are two
+ * different documents, and merging them would make publishing a release overwrite its own
+ * snapshot instead of creating a new version.
+ *
* Coupling note
* This deliberately reuses {@code ResourceValidationConstants.VERSION_REGEX}
* rather than restating the pattern, even though it points from the store
@@ -62,10 +70,12 @@ public static String of(String version) {
if (version == null) {
return null;
}
- Matcher matcher = VERSION.matcher(version);
+ String release = ResourceVersion.releaseVersion(version);
+ Matcher matcher = VERSION.matcher(release);
if (!matcher.matches()) {
return version;
}
- return matcher.group(1) + "." + matcher.group(2) + "." + matcher.group(3);
+ String canonical = matcher.group(1) + "." + matcher.group(2) + "." + matcher.group(3);
+ return ResourceVersion.isSnapshot(version) ? ResourceVersion.asSnapshot(canonical) : canonical;
}
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/util/TestCanonicalVersionShould.java b/calm-hub/src/test/java/org/finos/calm/store/util/TestCanonicalVersionShould.java
index 5cf9deaa8..f0468a596 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/util/TestCanonicalVersionShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/util/TestCanonicalVersionShould.java
@@ -1,5 +1,6 @@
package org.finos.calm.store.util;
+import org.finos.calm.domain.ResourceVersion;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@@ -9,6 +10,7 @@
import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue;
class TestCanonicalVersionShould {
@@ -60,4 +62,29 @@ void return_input_the_regex_rejects_unchanged_rather_than_guessing(String reject
void pass_a_null_version_through_rather_than_throwing() {
assertThat(CanonicalVersion.of(null), is(nullValue()));
}
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "1.0.0-SNAPSHOT", "1-0-0-SNAPSHOT", "1.0-0-SNAPSHOT",
+ "1-0.0-SNAPSHOT", "1.00-SNAPSHOT", "100-SNAPSHOT"})
+ void fold_every_spelling_of_a_snapshot_onto_one_document_key(String spelling) {
+ // Same reason as the release case: the API accepts all six spellings, and storing them
+ // verbatim would give one logical snapshot six documents.
+ assertThat(CanonicalVersion.of(spelling), is("1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void keep_a_snapshot_distinct_from_its_release() {
+ // The two are different documents by design. Folding the suffix away here would make
+ // publishing 1.0.0 silently overwrite its own snapshot instead of creating a release.
+ assertThat(CanonicalVersion.of("1.0.0-SNAPSHOT"), is(not(CanonicalVersion.of("1.0.0"))));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"1.0.0-snapshot", "1.2-SNAPSHOT", "-SNAPSHOT", "1.0.0-SNAPSHOT-SNAPSHOT"})
+ void return_a_malformed_snapshot_unchanged_rather_than_guessing(String rejected) {
+ // The resource layer rejects these with a 400. Rewriting them here would turn a
+ // refusable request into a document stored under a version nobody asked for.
+ assertThat(CanonicalVersion.of(rejected), is(rejected));
+ }
}
From 86b7a6523e386dce6781673cd0ca5830f1fe50c0 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 12:14:15 +0100
Subject: [PATCH 03/22] feat(calm-hub): parse and order snapshot versions by
their release version
---
.../java/org/finos/calm/domain/Semver.java | 13 ++++++--
.../calm/store/util/SemanticVersionOrder.java | 7 +++++
.../finos/calm/domain/TestSemverShould.java | 31 +++++++++++++++++++
.../util/TestSemanticVersionOrderShould.java | 26 ++++++++++++++++
4 files changed, 75 insertions(+), 2 deletions(-)
diff --git a/calm-hub/src/main/java/org/finos/calm/domain/Semver.java b/calm-hub/src/main/java/org/finos/calm/domain/Semver.java
index 1a3ce587a..30aa58ee8 100644
--- a/calm-hub/src/main/java/org/finos/calm/domain/Semver.java
+++ b/calm-hub/src/main/java/org/finos/calm/domain/Semver.java
@@ -7,8 +7,17 @@
*/
public record Semver(int major, int minor, int patch) implements Comparable {
+ /**
+ * The snapshot suffix is removed before the '-' to '.' replacement below.
+ * Reversing that order turns "1.0.0-SNAPSHOT" into "1.0.0.SNAPSHOT" — four segments,
+ * which is treated as unparseable and sorts as 0.0.0.
+ */
+ private static String[] segments(String version) {
+ return ResourceVersion.releaseVersion(version).replace('-', '.').split("\\.");
+ }
+
public static Semver parse(String version) {
- String[] parts = version.replace('-', '.').split("\\.");
+ String[] parts = segments(version);
if (parts.length != 3) {
throw new IllegalArgumentException("Invalid version format: " + version);
}
@@ -20,7 +29,7 @@ public static Semver parse(String version) {
}
public static Semver tryParse(String version) {
- String[] parts = version.replace('-', '.').split("\\.");
+ String[] parts = segments(version);
if (parts.length != 3) {
return new Semver(0, 0, 0);
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/util/SemanticVersionOrder.java b/calm-hub/src/main/java/org/finos/calm/store/util/SemanticVersionOrder.java
index e89b95b04..7d4d33f10 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/util/SemanticVersionOrder.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/util/SemanticVersionOrder.java
@@ -1,5 +1,6 @@
package org.finos.calm.store.util;
+import org.finos.calm.domain.ResourceVersion;
import org.finos.calm.domain.Semver;
import java.util.Comparator;
@@ -46,6 +47,12 @@ private static int compare(String left, String right) {
if (comparison != 0) {
return comparison;
}
+ // A snapshot precedes the release it belongs to, per semver pre-release ordering.
+ boolean leftSnapshot = ResourceVersion.isSnapshot(leftVersion);
+ boolean rightSnapshot = ResourceVersion.isSnapshot(rightVersion);
+ if (leftSnapshot != rightSnapshot) {
+ return leftSnapshot ? -1 : 1;
+ }
// Total-order tiebreak so equal-ranking values (e.g. two unparseable
// strings, both 0.0.0) still sort deterministically.
return leftVersion.compareTo(rightVersion);
diff --git a/calm-hub/src/test/java/org/finos/calm/domain/TestSemverShould.java b/calm-hub/src/test/java/org/finos/calm/domain/TestSemverShould.java
index 86a846a5f..cc42b8eaa 100644
--- a/calm-hub/src/test/java/org/finos/calm/domain/TestSemverShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/domain/TestSemverShould.java
@@ -73,4 +73,35 @@ void return_zero_semver_for_invalid_version_in_tryParse() {
void format_as_dot_separated_string() {
assertThat(Semver.parse("1.2.3").toString(), is("1.2.3"));
}
+
+ @Test
+ void parse_a_snapshot_as_its_release_version() {
+ assertThat(Semver.tryParse("1.0.0-SNAPSHOT"), is(new Semver(1, 0, 0)));
+ }
+
+ @Test
+ void parse_a_dashed_snapshot_as_its_release_version() {
+ // The suffix must be removed before the '-' to '.' replacement that makes the dashed
+ // form parse. Removing it afterwards yields "1.0.0.SNAPSHOT" — four segments, which
+ // collapses to 0.0.0 and sorts lowest. That is the ADR revision 100 bug.
+ assertThat(Semver.tryParse("1-0-0-SNAPSHOT"), is(new Semver(1, 0, 0)));
+ }
+
+ @Test
+ void still_parse_the_dashed_release_form() {
+ assertThat(Semver.tryParse("1-10-0"), is(new Semver(1, 10, 0)));
+ }
+
+ @Test
+ void still_collapse_a_genuinely_unparseable_version() {
+ assertThat(Semver.tryParse("not-a-version"), is(new Semver(0, 0, 0)));
+ }
+
+ @Test
+ void accept_a_snapshot_in_the_throwing_parse() {
+ // ArchitectureTimelineService uses parse() to classify versions as semver or not.
+ // A snapshot that threw would be classified non-semver and pushed to the end of
+ // every implied timeline.
+ assertThat(Semver.parse("2.3.4-SNAPSHOT"), is(new Semver(2, 3, 4)));
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/util/TestSemanticVersionOrderShould.java b/calm-hub/src/test/java/org/finos/calm/store/util/TestSemanticVersionOrderShould.java
index 48d67301c..b8b748baf 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/util/TestSemanticVersionOrderShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/util/TestSemanticVersionOrderShould.java
@@ -114,4 +114,30 @@ void sort_a_null_version_first_rather_than_throwing() {
void treat_two_null_versions_as_equal() {
assertThat(SemanticVersionOrder.ASCENDING.compare(null, null), is(0));
}
+
+ @Test
+ void rank_a_snapshot_below_its_release() {
+ // Standard semver pre-release ordering. Under the shadowing rule the two cannot
+ // coexist, so this is defensive — but leaving it unspecified is how a version ends
+ // up sorting as 0.0.0.
+ List versions = new ArrayList<>(List.of("1.0.0", "1.0.0-SNAPSHOT"));
+ versions.sort(SemanticVersionOrder.ASCENDING);
+ assertThat(versions, contains("1.0.0-SNAPSHOT", "1.0.0"));
+ }
+
+ @Test
+ void sort_a_snapshot_into_position_rather_than_last() {
+ // Before the Semver fix a snapshot parsed as 0.0.0 and sorted first, so the last
+ // element of a sorted list — the "latest" version — could be stale content.
+ List versions = new ArrayList<>(List.of("2.0.0", "1.0.0", "1.5.0-SNAPSHOT"));
+ versions.sort(SemanticVersionOrder.ASCENDING);
+ assertThat(versions, contains("1.0.0", "1.5.0-SNAPSHOT", "2.0.0"));
+ }
+
+ @Test
+ void order_two_snapshots_by_their_release_versions() {
+ List versions = new ArrayList<>(List.of("1.10.0-SNAPSHOT", "1.9.0-SNAPSHOT"));
+ versions.sort(SemanticVersionOrder.ASCENDING);
+ assertThat(versions, contains("1.9.0-SNAPSHOT", "1.10.0-SNAPSHOT"));
+ }
}
From acc49e3d300da422de62f3d5aece20b1e007dac3 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 12:32:36 +0100
Subject: [PATCH 04/22] feat(calm-hub): accept snapshot versions on namespace
resource endpoints
Adds SNAPSHOT_VERSION_REGEX/MESSAGE alongside the strict VERSION_REGEX and
swaps the six namespace resource endpoint files (architectures, patterns,
flows, standards, interfaces, and the two namespaces/.../versions/{version}
handlers in the mapping controller) onto it. ADR, timeline, control and
layout endpoints are untouched and keep rejecting -SNAPSHOT: ADR runs on
VersionScheme.NUMERIC, where a suffixed value is an unparseable revision,
not a version.
Updates the pre-existing 400 message assertions in the five opted-in
resource test classes to match the new pattern text; validation strictness
itself is unchanged.
---
.../calm/resources/ArchitectureResource.java | 10 +++----
.../finos/calm/resources/FlowResource.java | 10 +++----
.../calm/resources/InterfaceResource.java | 4 +--
.../resources/MappingControllerResource.java | 4 +--
.../finos/calm/resources/PatternResource.java | 10 +++----
.../ResourceValidationConstants.java | 8 ++++++
.../calm/resources/StandardResource.java | 4 +--
.../calm/resources/TestAdrResourceShould.java | 15 +++++++++++
...tArchitectureResourcePutEnabledShould.java | 2 +-
.../TestArchitectureResourceShould.java | 6 ++---
.../TestFlowResourcePutEnabledShould.java | 4 +--
.../resources/TestFlowResourceShould.java | 6 ++---
.../TestInterfaceResourceShould.java | 6 ++---
.../TestMappingControllerResourceShould.java | 26 +++++++++++++++++++
.../TestPatternResourcePutEnabledShould.java | 2 +-
.../resources/TestPatternResourceShould.java | 6 ++---
.../resources/TestStandardResourceShould.java | 6 ++---
17 files changed, 89 insertions(+), 40 deletions(-)
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java b/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java
index 2a9a41855..165ad66db 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java
@@ -43,8 +43,8 @@
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX;
import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY;
-import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
-import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX;
+import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_REGEX;
/**
@@ -174,7 +174,7 @@ public Response getArchitectureVersions(
public Response getArchitecture(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("architectureId") int architectureId,
- @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version) {
+ @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version) {
Architecture architecture = new Architecture.ArchitectureBuilder()
.setNamespace(namespace)
.setId(architectureId)
@@ -203,7 +203,7 @@ public Response getArchitecture(
public Response createVersionedArchitecture(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("architectureId") int architectureId,
- @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
+ @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
ArchitectureRequest architectureRequest
) throws URISyntaxException {
Architecture architecture = new Architecture.ArchitectureBuilder()
@@ -245,7 +245,7 @@ public Response createVersionedArchitecture(
public Response updateVersionedArchitecture(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("architectureId") int architectureId,
- @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
+ @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
ArchitectureRequest architectureRequest) throws URISyntaxException {
Architecture architecture = new Architecture.ArchitectureBuilder()
.setNamespace(namespace)
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java
index 0db651ebb..83db1c182 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java
@@ -34,8 +34,8 @@
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX;
import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY;
-import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
-import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX;
+import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_REGEX;
@Tag(name = "Storage API", description = "Numeric-ID based CALM storage endpoints")
@Path("/api/calm/namespaces")
@@ -172,7 +172,7 @@ public Response getFlowVersions(
public Response getFlow(
@PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("flowId") int flowId,
- @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version
+ @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version
) {
return getFlowInternal(namespace, flowId, version);
}
@@ -210,7 +210,7 @@ private Response getFlowInternal(String namespace, int flowId, String version) {
public Response createVersionedFlow(
@PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("flowId") int flowId,
- @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
+ @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
@Valid @NotNull(message = "Request must not be null") CreateFlowRequest flowRequest
) throws URISyntaxException {
Flow flow = new Flow.FlowBuilder()
@@ -249,7 +249,7 @@ public Response createVersionedFlow(
public Response updateVersionedFlow(
@PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("flowId") int flowId,
- @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
+ @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
@Valid @NotNull(message = "Request must not be null") CreateFlowRequest flowRequest
) throws URISyntaxException {
Flow flow = new Flow.FlowBuilder()
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java b/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java
index b4871d117..2f3c65c54 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java
@@ -110,7 +110,7 @@ public Response getInterfaceVersions(
public Response getInterfaceForVersion(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("interfaceId") Integer interfaceId,
- @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version
+ @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version
) {
try {
return Response.ok(interfaceStore.getInterfaceForVersion(namespace, interfaceId, version)).build();
@@ -134,7 +134,7 @@ public Response getInterfaceForVersion(
public Response createInterfaceForVersion(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("interfaceId") Integer interfaceId,
- @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
+ @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
@Valid @NotNull(message = "Request must not be null") CreateInterfaceRequest createInterfaceRequest
) throws URISyntaxException {
try {
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java b/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java
index 1f1fa6317..6eecc9d36 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java
@@ -248,7 +248,7 @@ public Response createResourceVersion(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("type") String type,
@PathParam("name") @Pattern(regexp = CUSTOM_ID_REGEX, message = CUSTOM_ID_MESSAGE) String name,
- @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
+ @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
String requestBody
) throws URISyntaxException {
return handlePost(namespace, type, name, version, requestBody);
@@ -353,7 +353,7 @@ public Response getResourceVersion(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("type") String type,
@PathParam("name") @Pattern(regexp = CUSTOM_ID_REGEX, message = CUSTOM_ID_MESSAGE) String name,
- @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version
+ @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version
) {
ResourceType resourceType = documentParser.parseTypePlural(type);
if (resourceType == null) {
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
index deafc0903..c57cdf3dc 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
@@ -32,8 +32,8 @@
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX;
import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY;
-import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
-import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX;
+import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_REGEX;
@Tag(name = "Storage API", description = "Numeric-ID based CALM storage endpoints")
@Path("/api/calm/namespaces")
@@ -142,7 +142,7 @@ public Response getPatternVersions(
public Response getPattern(
@PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("patternId") int patternId,
- @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version
+ @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version
) {
Pattern pattern = new Pattern.PatternBuilder()
.setNamespace(namespace)
@@ -176,7 +176,7 @@ public Response getPattern(
public Response createVersionedPattern(
@PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("patternId") int patternId,
- @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
+ @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
@Valid @NotNull(message = "Request must not be null") CreatePatternRequest patternRequest
) throws URISyntaxException {
Pattern pattern = new Pattern.PatternBuilder()
@@ -218,7 +218,7 @@ public Response createVersionedPattern(
public Response updateVersionedPattern(
@PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("patternId") int patternId,
- @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
+ @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
@Valid @NotNull(message = "Request must not be null") CreatePatternRequest patternRequest
) throws URISyntaxException {
Pattern pattern = new Pattern.PatternBuilder()
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java b/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java
index f71ac9ece..930beffd4 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java
@@ -13,6 +13,14 @@ public class ResourceValidationConstants {
public static final String DOMAIN_MESSAGE = "domain name must match pattern '^[A-Za-z0-9-]+$'";
public static final String VERSION_REGEX = "^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)$";
public static final String VERSION_MESSAGE = "version must match pattern '^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)$'";
+ // The same pattern as VERSION_REGEX, plus an optional fixed -SNAPSHOT marker. Applied only
+ // to the five namespace resource types. ADR, timeline, control and layout endpoints keep
+ // VERSION_REGEX: ADR runs on the NUMERIC version scheme, where a suffixed value is an
+ // unparseable revision rather than a version.
+ public static final String SNAPSHOT_VERSION_REGEX =
+ "^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)(-SNAPSHOT)?$";
+ public static final String SNAPSHOT_VERSION_MESSAGE =
+ "version must match pattern '^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)(-SNAPSHOT)?$'";
// First character must be a letter so slugs are never purely numeric (avoids clash with legacy numeric IDs).
public static final String CUSTOM_ID_REGEX = "^[a-z][a-z0-9]*(-[a-z0-9]+)*$";
public static final String CUSTOM_ID_MESSAGE = "customId must match pattern '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'";
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java
index 5f79eb610..6e7ce2539 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java
@@ -102,7 +102,7 @@ public Response getStandardVersions(
public Response getStandardForVersion(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("standardId") Integer standardId,
- @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version
+ @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version
) {
try {
return Response.ok(standardStore.getStandardForVersion(namespace, standardId, version)).build();
@@ -126,7 +126,7 @@ public Response getStandardForVersion(
public Response createStandardForVersion(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("standardId") Integer standardId,
- @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
+ @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
CreateStandardRequest createStandardRequest
) throws URISyntaxException {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestAdrResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestAdrResourceShould.java
index ccd399b77..384d52b9d 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestAdrResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestAdrResourceShould.java
@@ -416,4 +416,19 @@ void respond_correctly_to_delete_adr(String namespace, Throwable exceptionToThro
verify(mockAdrStore, times(1)).deleteAdr(namespace, 12);
}
+
+ @Test
+ void reject_a_snapshot_version_because_adrs_use_integer_revisions() {
+ // ADR runs on VersionScheme.NUMERIC, which stores revisions verbatim. A snapshot
+ // reaching it would be an unparseable revision, which NumericVersionOrder sorts
+ // first -- the exact ADR revision 100 failure. The {revision} path param is a plain
+ // int (see AdrResource#getAdrRevision), not a @Pattern-validated String, so a
+ // -SNAPSHOT suffix fails path-param conversion and never reaches AdrStore at all --
+ // RESTEasy reports that as 404, not 400.
+ given()
+ .when()
+ .get("/api/calm/namespaces/finos/adrs/12/revisions/1-SNAPSHOT")
+ .then()
+ .statusCode(404);
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourcePutEnabledShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourcePutEnabledShould.java
index 252938a35..e94f0a575 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourcePutEnabledShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourcePutEnabledShould.java
@@ -64,7 +64,7 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_create_new_ar
.put("/api/calm/namespaces/finos/architectures/20/versions/1.0invalid.1")
.then()
.statusCode(400)
- .body(containsString("version must match pattern '^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)$"));
+ .body(containsString("version must match pattern '^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)(-SNAPSHOT)?$"));
}
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java
index 837302150..78ccfa696 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java
@@ -32,7 +32,7 @@
import static org.finos.calm.resources.ResourceValidationConstants.LIMIT_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.OFFSET_MESSAGE;
-import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.ArgumentMatchers.any;
@@ -311,7 +311,7 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_get_architect
.get("/api/calm/namespaces/finos/architectures/12/versions/1.0.invalid0")
.then()
.statusCode(400)
- .body(containsString(VERSION_MESSAGE));
+ .body(containsString(SNAPSHOT_VERSION_MESSAGE));
}
private void verifyExpectedGetArchitecture(String namespace) throws ArchitectureNotFoundException, NamespaceNotFoundException, ArchitectureVersionNotFoundException {
@@ -382,7 +382,7 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_create_new_ve
.post("/api/calm/namespaces/finos/architectures/20/versions/1.0.invalid1")
.then()
.statusCode(400)
- .body(containsString(VERSION_MESSAGE));
+ .body(containsString(SNAPSHOT_VERSION_MESSAGE));
}
static Stream provideParametersForCreateArchitectureTests() {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourcePutEnabledShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourcePutEnabledShould.java
index 99c2f1e96..b1a28c87f 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourcePutEnabledShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourcePutEnabledShould.java
@@ -19,7 +19,7 @@
import java.util.stream.Stream;
import static io.restassured.RestAssured.given;
-import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.Mockito.when;
@@ -43,7 +43,7 @@ void return_400_error_when_version_is_not_valid_when_updating_flow_version() {
.put("/api/calm/namespaces/test/flows/20/versions/invalid-version")
.then()
.statusCode(400)
- .body(containsString(VERSION_MESSAGE));
+ .body(containsString(SNAPSHOT_VERSION_MESSAGE));
}
static Stream provideParametersForPutFlowTests() {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java
index 45e11d488..5dccfe4ab 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java
@@ -25,7 +25,7 @@
import static io.restassured.RestAssured.given;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
-import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.ArgumentMatchers.any;
@@ -254,7 +254,7 @@ void return_400_error_when_version_is_not_valid_when_getting_flow_version() {
.get("/api/calm/namespaces/finos/flows/12/versions/invalid-version")
.then()
.statusCode(400)
- .body(containsString(VERSION_MESSAGE));
+ .body(containsString(SNAPSHOT_VERSION_MESSAGE));
}
static Stream provideParametersForGetFlowTests() {
@@ -311,7 +311,7 @@ void return_400_error_when_version_is_not_valid_when_creating_new_flow_version()
.post("/api/calm/namespaces/test/flows/20/versions/invalid-version")
.then()
.statusCode(400)
- .body(containsString(VERSION_MESSAGE));
+ .body(containsString(SNAPSHOT_VERSION_MESSAGE));
}
static Stream provideParametersForCreateFlowTests() {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java
index 34e11e8ec..132f20537 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java
@@ -31,7 +31,7 @@
import static io.restassured.RestAssured.given;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
-import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.nullValue;
@@ -265,7 +265,7 @@ void return_400_when_invalid_version_provided_when_getting_version_of_interface(
.get("/api/calm/namespaces/finos/interfaces/5/versions/invalid_version")
.then()
.statusCode(400)
- .body(containsString(VERSION_MESSAGE));
+ .body(containsString(SNAPSHOT_VERSION_MESSAGE));
}
static Stream provideParametersForGetInterfaceTests() {
@@ -339,7 +339,7 @@ void return_400_when_invalid_version_provided_when_creating_new_version_of_inter
.post("/api/calm/namespaces/finos/interfaces/5/versions/invalid-version")
.then()
.statusCode(400)
- .body(containsString(VERSION_MESSAGE));
+ .body(containsString(SNAPSHOT_VERSION_MESSAGE));
}
static Stream provideParametersForCreateInterfaceTests() {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
index b2d88a864..59b78eab1 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
@@ -1415,4 +1415,30 @@ void return_404_when_control_not_found_on_config_path_post() throws Exception {
.when().post("/calm/domains/security/controls/unknown-ctrl/configurations/my-cfg/versions/1.0.0")
.then().statusCode(404);
}
+
+ // --- Snapshot version acceptance on namespace resource endpoints ---
+
+ @Test
+ void accept_a_snapshot_version_in_the_path() throws Exception {
+ // Only checks the version is not rejected by validation. Mocking an existing mapping
+ // and its versions lets the request reach the handler and complete the add-version
+ // path, proving the -SNAPSHOT suffix passed the @Pattern check on {version}.
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("snapshot-arch")
+ .setResourceType(ResourceType.ARCHITECTURE).setNumericId(20).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "snapshot-arch")).thenReturn(existing);
+ when(mockArchitectureStore.getArchitectureVersions(any(Architecture.class))).thenReturn(List.of("1.0.0"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "architectures", "snapshot-arch", "1.0.0-SNAPSHOT")).when()
+ .post("/calm/namespaces/finos/architectures/snapshot-arch/versions/1.0.0-SNAPSHOT")
+ .then().statusCode(not(400));
+ }
+
+ @Test
+ void reject_a_lowercase_snapshot_suffix() {
+ given().header("Content-Type", "application/json").body("{}").when()
+ .post("/calm/namespaces/finos/architectures/test/versions/1.0.0-snapshot")
+ .then().statusCode(400);
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourcePutEnabledShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourcePutEnabledShould.java
index 369053002..9a069bc7d 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourcePutEnabledShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourcePutEnabledShould.java
@@ -54,7 +54,7 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_create_new_pa
.put("/api/calm/namespaces/finos/patterns/20/versions/1.0invalid.1")
.then()
.statusCode(400)
- .body(containsString("version must match pattern '^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)$"));
+ .body(containsString("version must match pattern '^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)(-SNAPSHOT)?$"));
}
static Stream provideParametersForPutPatternTests() {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java
index 4dba94e64..d37d07dff 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java
@@ -31,7 +31,7 @@
import static org.finos.calm.resources.ResourceValidationConstants.LIMIT_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.OFFSET_MESSAGE;
-import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.nullValue;
@@ -328,7 +328,7 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_get_pattern()
.get("/api/calm/namespaces/finos/patterns/12/versions/1.0.invalid0")
.then()
.statusCode(400)
- .body(containsString(VERSION_MESSAGE));
+ .body(containsString(SNAPSHOT_VERSION_MESSAGE));
}
private void verifyExpectedGetPattern(String namespace) throws PatternNotFoundException, NamespaceNotFoundException, PatternVersionNotFoundException {
@@ -399,7 +399,7 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_create_new_pa
.post("/api/calm/namespaces/finos/patterns/20/versions/1.0invalid.1")
.then()
.statusCode(400)
- .body(containsString(VERSION_MESSAGE));
+ .body(containsString(SNAPSHOT_VERSION_MESSAGE));
}
@Test
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java
index 7efb1c5c5..3ec6da70f 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java
@@ -29,7 +29,7 @@
import static io.restassured.RestAssured.given;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
-import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.ArgumentMatchers.*;
@@ -222,7 +222,7 @@ void return_400_when_invalid_version_provided_when_getting_version_of_standard()
.get("/api/calm/namespaces/finos/standards/5/versions/invalid_version")
.then()
.statusCode(400)
- .body(containsString(VERSION_MESSAGE));
+ .body(containsString(SNAPSHOT_VERSION_MESSAGE));
}
static Stream provideParametersForGetStandardTests() {
@@ -296,7 +296,7 @@ void return_400_when_invalid_version_provided_when_creating_new_version_of_stand
.post("/api/calm/namespaces/finos/standards/5/versions/invalid-version")
.then()
.statusCode(400)
- .body(containsString(VERSION_MESSAGE));
+ .body(containsString(SNAPSHOT_VERSION_MESSAGE));
}
static Stream provideParametersForCreateStandardTests() {
From 30f6f2e61e717116929fe4fa1d1d6e46b8bc78c9 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 12:45:13 +0100
Subject: [PATCH 05/22] feat(calm-hub): add single-version delete to both
version document stores
---
.../store/util/MongoVersionDocumentStore.java | 52 ++++++++++++++++
.../util/NitriteVersionDocumentStore.java | 48 +++++++++++++++
.../TestMongoVersionDocumentStoreShould.java | 45 ++++++++++++++
...TestNitriteVersionDocumentStoreShould.java | 59 +++++++++++++++++++
4 files changed, 204 insertions(+)
diff --git a/calm-hub/src/main/java/org/finos/calm/store/util/MongoVersionDocumentStore.java b/calm-hub/src/main/java/org/finos/calm/store/util/MongoVersionDocumentStore.java
index 91ea2fa6f..1ef399948 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/util/MongoVersionDocumentStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/util/MongoVersionDocumentStore.java
@@ -202,6 +202,39 @@ public boolean deleteResource(String namespace, int resourceId) {
}
}
+ /**
+ * Removes one version document, leaving the header and every other version in place.
+ *
+ * Narrower than {@link #deleteResource}: the caller is promotion, which removes a
+ * snapshot once its release version is published. The snapshot may not exist — the
+ * release may have been published without one — so an absent version is a {@code false}
+ * return, not an exception.
+ *
+ * Decrements the header's {@code versionCount} when, and only when, it actually
+ * deleted. {@link #createVersion} and {@link #upsertVersion} both increment on insert;
+ * without the matching decrement the count drifts permanently high and disagrees with
+ * {@link #listVersions}.
+ *
+ * @return {@code true} if a version document was removed.
+ */
+ public boolean deleteVersion(String namespace, int resourceId, String version) {
+ String canonicalVersion = versionScheme.canonicalise(version);
+ boolean deleted;
+ try {
+ DeleteResult result = versionCollection.deleteOne(
+ versionFilter(namespace, resourceId, canonicalVersion));
+ deleted = result.getDeletedCount() > 0;
+ } catch (MongoException e) {
+ LOG.error("Failed to delete version [namespace={}, {}={}, version={}]",
+ namespace, idField, resourceId, canonicalVersion, e);
+ throw StorageWriteException.writeFailed(e);
+ }
+ if (deleted) {
+ decrementVersionCount(namespace, resourceId);
+ }
+ return deleted;
+ }
+
/**
* Writes the first version of a newly created resource, removing the header again if
* that fails, so a half-created resource never survives the request.
@@ -512,6 +545,25 @@ private void incrementVersionCount(String namespace, int resourceId) {
}
}
+ /**
+ * Undoes {@link #incrementVersionCount}, called only after a version document has
+ * actually been deleted. Best-effort, matching {@link #incrementVersionCount}: a
+ * derived counter must not fail a delete that already succeeded.
+ */
+ private void decrementVersionCount(String namespace, int resourceId) {
+ try {
+ UpdateResult result = headerCollection.updateOne(
+ headerFilter(namespace, resourceId), Updates.inc(VERSION_COUNT_FIELD, -1));
+ if (result.getMatchedCount() == 0) {
+ LOG.warn("Deleted a version with no matching header to count it [namespace={}, {}={}] — "
+ + "versionCount for this resource is now overstated", namespace, idField, resourceId);
+ }
+ } catch (MongoException e) {
+ LOG.warn("Failed to decrement versionCount after deleting a version [namespace={}, {}={}] — "
+ + "versionCount for this resource is now overstated", namespace, idField, resourceId, e);
+ }
+ }
+
private Bson headerFilter(String namespace, int resourceId) {
return Filters.and(Filters.eq(NAMESPACE_FIELD, namespace), Filters.eq(idField, resourceId));
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/util/NitriteVersionDocumentStore.java b/calm-hub/src/main/java/org/finos/calm/store/util/NitriteVersionDocumentStore.java
index 3f7d8a89f..27b48ef03 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/util/NitriteVersionDocumentStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/util/NitriteVersionDocumentStore.java
@@ -188,6 +188,31 @@ public boolean deleteResource(String namespace, int resourceId) {
}
}
+ /**
+ * Removes one version document, leaving the header and every other version in place.
+ * See {@link MongoVersionDocumentStore#deleteVersion} — the reasoning for the absent-version
+ * return and the versionCount decrement is the same on both backends.
+ *
+ * Held under the write lock so no concurrent read sees the version gone while the
+ * header still counts it.
+ */
+ public boolean deleteVersion(String namespace, int resourceId, String version) {
+ String canonicalVersion = versionScheme.canonicalise(version);
+ lock.writeLock().lock();
+ try {
+ Filter filter = versionFilter(namespace, resourceId, canonicalVersion);
+ Document existing = versionCollection.find(filter).firstOrNull();
+ if (existing == null) {
+ return false;
+ }
+ versionCollection.remove(existing);
+ decrementVersionCount(namespace, resourceId);
+ return true;
+ } finally {
+ lock.writeLock().unlock();
+ }
+ }
+
/**
* Writes the first version of a newly created resource, removing the header again if
* that fails. See {@link MongoVersionDocumentStore#createFirstVersion} — the reasoning
@@ -502,6 +527,29 @@ private void incrementVersionCount(String namespace, int resourceId) {
}
}
+ /**
+ * Undoes {@link #incrementVersionCount}, called only after a version document has
+ * actually been removed. Best-effort, matching {@link #incrementVersionCount}: a
+ * derived counter must not fail a delete that already succeeded.
+ */
+ private void decrementVersionCount(String namespace, int resourceId) {
+ try {
+ Filter filter = headerFilter(namespace, resourceId);
+ Document header = headerCollection.find(filter).firstOrNull();
+ if (header == null) {
+ LOG.warn("Deleted a version with no matching header to count it [namespace={}, {}={}] — "
+ + "versionCount for this resource is now overstated", namespace, idField, resourceId);
+ return;
+ }
+ Integer current = header.get(VERSION_COUNT_FIELD, Integer.class);
+ header.put(VERSION_COUNT_FIELD, Math.max(0, (current == null ? 0 : current) - 1));
+ headerCollection.update(filter, header);
+ } catch (NitriteException e) {
+ LOG.warn("Failed to decrement versionCount after deleting a version [namespace={}, {}={}] — "
+ + "versionCount for this resource is now overstated", namespace, idField, resourceId, e);
+ }
+ }
+
private Filter headerFilter(String namespace, int resourceId) {
return Filter.and(where(NAMESPACE_FIELD).eq(namespace), where(idField).eq(resourceId));
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/util/TestMongoVersionDocumentStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/util/TestMongoVersionDocumentStoreShould.java
index e2576c426..d02e1cdf8 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/util/TestMongoVersionDocumentStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/util/TestMongoVersionDocumentStoreShould.java
@@ -675,4 +675,49 @@ void fall_back_to_a_generated_name_and_blank_description_when_a_header_has_neith
assertThat(store.listSummariesPaged(NAMESPACE, PageRequest.UNPAGED),
contains(new NamespaceResourceSummary("Architecture 7", "", 7, 0)));
}
+
+ // --- deleteVersion ---
+
+ @Test
+ void delete_one_version_and_report_that_it_removed_it() {
+ DeleteResult result = DeleteResult.acknowledged(1);
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(result);
+ // A successful delete always follows up with the count decrement, matching the
+ // increment every other write-path test in this file already stubs for.
+ when(headerCollection.updateOne(any(Bson.class), any(Bson.class))).thenReturn(acknowledged(1, null));
+
+ assertThat(store.deleteVersion(NAMESPACE, RESOURCE_ID, "1.0.0-SNAPSHOT"), is(true));
+
+ verify(versionCollection).deleteOne(any(Bson.class));
+ }
+
+ @Test
+ void report_that_it_removed_nothing_when_the_version_is_absent() {
+ // Promotion deletes a snapshot that may never have existed. That is not an error.
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0));
+
+ assertThat(store.deleteVersion(NAMESPACE, RESOURCE_ID, "1.0.0-SNAPSHOT"), is(false));
+ }
+
+ @Test
+ void decrement_the_version_count_when_it_deleted_a_version() {
+ // createVersion and upsertVersion both increment. Without the matching decrement the
+ // header's versionCount drifts permanently high and disagrees with the version list.
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1));
+ when(headerCollection.updateOne(any(Bson.class), any(Bson.class)))
+ .thenReturn(UpdateResult.acknowledged(1, 1L, null));
+
+ store.deleteVersion(NAMESPACE, RESOURCE_ID, "1.0.0-SNAPSHOT");
+
+ verify(headerCollection).updateOne(any(Bson.class), any(Bson.class));
+ }
+
+ @Test
+ void not_decrement_the_version_count_when_nothing_was_deleted() {
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0));
+
+ store.deleteVersion(NAMESPACE, RESOURCE_ID, "1.0.0-SNAPSHOT");
+
+ verify(headerCollection, never()).updateOne(any(Bson.class), any(Bson.class));
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/util/TestNitriteVersionDocumentStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/util/TestNitriteVersionDocumentStoreShould.java
index b5ea0c77e..0ca6981dc 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/util/TestNitriteVersionDocumentStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/util/TestNitriteVersionDocumentStoreShould.java
@@ -568,4 +568,63 @@ void sort_headers_that_have_no_id_first_rather_than_failing_the_listing() {
new NamespaceResourceSummary("No id", "d", null, 0),
new NamespaceResourceSummary("First", "d", 1, 1)));
}
+
+ // --- deleteVersion ---
+
+ @Test
+ void delete_one_version_and_leave_the_others() {
+ // The mock's find(...) is stubbed against any Filter, so it returns exactly the
+ // one document set up here regardless of which version filter deleteVersion builds
+ // — matching how the rest of this file stubs a single lookup-then-act call.
+ Document versionToDelete = versionDocument("1.1.0-SNAPSHOT");
+ stubFind(versionCollection, List.of(versionToDelete));
+ stubFind(headerCollection, List.of(header(RESOURCE_ID, "Test", "desc", 2)));
+
+ assertThat(store.deleteVersion(NAMESPACE, RESOURCE_ID, "1.1.0-SNAPSHOT"), is(true));
+
+ verify(versionCollection).remove(versionToDelete);
+ }
+
+ @Test
+ void report_that_it_removed_nothing_when_the_version_is_absent() {
+ stubFind(versionCollection, List.of());
+
+ assertThat(store.deleteVersion(NAMESPACE, RESOURCE_ID, "9.9.9-SNAPSHOT"), is(false));
+
+ verify(versionCollection, never()).remove(any(Document.class));
+ verify(headerCollection, never()).update(any(Filter.class), any(Document.class));
+ }
+
+ @Test
+ void decrement_the_version_count_when_it_deleted_a_version() {
+ // createVersion and upsertVersion both increment. Without the matching decrement the
+ // header's versionCount drifts permanently high and disagrees with the version list.
+ stubFind(versionCollection, List.of(versionDocument("1.0.0-SNAPSHOT")));
+ stubFind(headerCollection, List.of(header(RESOURCE_ID, "Test", "desc", 1)));
+
+ store.deleteVersion(NAMESPACE, RESOURCE_ID, "1.0.0-SNAPSHOT");
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class);
+ verify(headerCollection).update(any(Filter.class), captor.capture());
+ assertThat(captor.getValue().get("versionCount", Integer.class), is(0));
+ }
+
+ @Test
+ void not_decrement_the_version_count_when_nothing_was_deleted() {
+ stubFind(versionCollection, List.of());
+
+ store.deleteVersion(NAMESPACE, RESOURCE_ID, "1.0.0-SNAPSHOT");
+
+ verify(headerCollection, never()).update(any(Filter.class), any(Document.class));
+ }
+
+ @Test
+ void find_the_version_under_any_accepted_spelling() {
+ // Canonicalisation happens on the way in, so a delete must canonicalise too or it
+ // silently removes nothing.
+ stubFind(versionCollection, List.of(versionDocument("1.0.0-SNAPSHOT")));
+ stubFind(headerCollection, List.of(header(RESOURCE_ID, "Test", "desc", 1)));
+
+ assertThat(store.deleteVersion(NAMESPACE, RESOURCE_ID, "100-SNAPSHOT"), is(true));
+ }
}
From c41fa82787fde30c9c9343fcb13ee88f82de3d79 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 13:01:18 +0100
Subject: [PATCH 06/22] test(calm-hub): strengthen deleteVersion tests to
assert filter/update content
decrement_the_version_count_when_it_deleted_a_version only verified an update
call happened with any arguments, so a flipped +1/-1 sign passed silently.
find_the_version_under_any_accepted_spelling and
delete_one_version_and_leave_the_others only asserted the return value, so a
dropped canonicalise call or a dropped version constraint on the Nitrite
filter also passed silently, since the mocked find(...) returns the stubbed
document regardless of which filter it receives.
Now each captures the Bson/Filter passed to the mock and asserts on its
rendered content, and a Mongo canonicalisation test mirrors the existing
Nitrite one so both backends prove the same behaviour.
---
.../TestMongoVersionDocumentStoreShould.java | 20 ++++++++++++++++++-
...TestNitriteVersionDocumentStoreShould.java | 19 ++++++++++++++++--
2 files changed, 36 insertions(+), 3 deletions(-)
diff --git a/calm-hub/src/test/java/org/finos/calm/store/util/TestMongoVersionDocumentStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/util/TestMongoVersionDocumentStoreShould.java
index d02e1cdf8..fd15055d6 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/util/TestMongoVersionDocumentStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/util/TestMongoVersionDocumentStoreShould.java
@@ -709,7 +709,25 @@ void decrement_the_version_count_when_it_deleted_a_version() {
store.deleteVersion(NAMESPACE, RESOURCE_ID, "1.0.0-SNAPSHOT");
- verify(headerCollection).updateOne(any(Bson.class), any(Bson.class));
+ // Asserting the update's rendered content, not merely that some update happened —
+ // otherwise an increment (+1) sent by mistake would satisfy this just as well.
+ ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(Bson.class);
+ verify(headerCollection).updateOne(any(Bson.class), updateCaptor.capture());
+ assertThat(asJson(updateCaptor.getValue()), containsString("\"versionCount\": -1"));
+ }
+
+ @Test
+ void delete_the_version_under_any_accepted_spelling() {
+ // Canonicalisation happens on the way in, so a delete must canonicalise too or it
+ // silently deletes nothing — mirrors look_up_a_dash_spelled_version_by_its_canonical_form.
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1));
+ when(headerCollection.updateOne(any(Bson.class), any(Bson.class))).thenReturn(acknowledged(1, null));
+
+ assertThat(store.deleteVersion(NAMESPACE, RESOURCE_ID, "100-SNAPSHOT"), is(true));
+
+ ArgumentCaptor filterCaptor = ArgumentCaptor.forClass(Bson.class);
+ verify(versionCollection).deleteOne(filterCaptor.capture());
+ assertThat(asJson(filterCaptor.getValue()), containsString("1.0.0-SNAPSHOT"));
}
@Test
diff --git a/calm-hub/src/test/java/org/finos/calm/store/util/TestNitriteVersionDocumentStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/util/TestNitriteVersionDocumentStoreShould.java
index 0ca6981dc..100f54643 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/util/TestNitriteVersionDocumentStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/util/TestNitriteVersionDocumentStoreShould.java
@@ -21,6 +21,7 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.nullValue;
@@ -575,7 +576,11 @@ void sort_headers_that_have_no_id_first_rather_than_failing_the_listing() {
void delete_one_version_and_leave_the_others() {
// The mock's find(...) is stubbed against any Filter, so it returns exactly the
// one document set up here regardless of which version filter deleteVersion builds
- // — matching how the rest of this file stubs a single lookup-then-act call.
+ // — matching how the rest of this file stubs a single lookup-then-act call. That
+ // alone can't prove selectivity, so the captured Filter's rendered content is
+ // asserted too: Filter's toString includes each field/value pair it was built
+ // from, so a filter that dropped the version constraint (e.g. fell back to
+ // matching on namespace/id alone) would not mention "1.1.0-SNAPSHOT" here.
Document versionToDelete = versionDocument("1.1.0-SNAPSHOT");
stubFind(versionCollection, List.of(versionToDelete));
stubFind(headerCollection, List.of(header(RESOURCE_ID, "Test", "desc", 2)));
@@ -583,6 +588,9 @@ void delete_one_version_and_leave_the_others() {
assertThat(store.deleteVersion(NAMESPACE, RESOURCE_ID, "1.1.0-SNAPSHOT"), is(true));
verify(versionCollection).remove(versionToDelete);
+ ArgumentCaptor filterCaptor = ArgumentCaptor.forClass(Filter.class);
+ verify(versionCollection).find(filterCaptor.capture());
+ assertThat(filterCaptor.getValue().toString(), containsString("version == 1.1.0-SNAPSHOT"));
}
@Test
@@ -621,10 +629,17 @@ void not_decrement_the_version_count_when_nothing_was_deleted() {
@Test
void find_the_version_under_any_accepted_spelling() {
// Canonicalisation happens on the way in, so a delete must canonicalise too or it
- // silently removes nothing.
+ // silently removes nothing. The mock's find(...) matches any Filter and returns the
+ // stubbed document regardless, so the return value alone can't prove canonicalisation
+ // happened — the captured Filter's rendered content must show the canonical
+ // "1.0.0-SNAPSHOT", not the "100-SNAPSHOT" spelling that was passed in.
stubFind(versionCollection, List.of(versionDocument("1.0.0-SNAPSHOT")));
stubFind(headerCollection, List.of(header(RESOURCE_ID, "Test", "desc", 1)));
assertThat(store.deleteVersion(NAMESPACE, RESOURCE_ID, "100-SNAPSHOT"), is(true));
+
+ ArgumentCaptor filterCaptor = ArgumentCaptor.forClass(Filter.class);
+ verify(versionCollection).find(filterCaptor.capture());
+ assertThat(filterCaptor.getValue().toString(), containsString("version == 1.0.0-SNAPSHOT"));
}
}
From fd8c2937e714c12c997df6a5f6cad68e628e2461 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 13:17:43 +0100
Subject: [PATCH 07/22] feat(calm-hub): add version update to standard and
interface stores
Standards and interfaces previously had no update path, which is why
the PUT endpoint returned 501 for both types. Snapshots are mutable by
design, so each store now has an updateStandardForVersion /
updateInterfaceForVersion delegating to the version-document helper's
upsertVersion instead of createVersion, with no existing-version
rejection.
---
.../org/finos/calm/store/InterfaceStore.java | 9 +++
.../org/finos/calm/store/StandardStore.java | 9 +++
.../calm/store/mongo/MongoInterfaceStore.java | 19 +++++++
.../calm/store/mongo/MongoStandardStore.java | 19 +++++++
.../store/nitrite/NitriteInterfaceStore.java | 21 +++++++
.../store/nitrite/NitriteStandardStore.java | 22 ++++++++
.../mongo/TestMongoInterfaceStoreShould.java | 55 +++++++++++++++++++
.../mongo/TestMongoStandardStoreShould.java | 55 +++++++++++++++++++
.../TestNitriteInterfaceStoreShould.java | 52 ++++++++++++++++++
.../TestNitriteStandardStoreShould.java | 52 ++++++++++++++++++
10 files changed, 313 insertions(+)
diff --git a/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java
index 69ef23aa0..0f16e2ede 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java
@@ -17,6 +17,15 @@ public interface InterfaceStore {
String getInterfaceForVersion(String namespace, Integer interfaceId, String version) throws NamespaceNotFoundException, InterfaceNotFoundException, InterfaceVersionNotFoundException;
CalmInterface createInterfaceForVersion(CreateInterfaceRequest interfaceRequest, String namespace, Integer interfaceId, String version) throws NamespaceNotFoundException, InterfaceNotFoundException, InterfaceVersionExistsException;
+ /**
+ * Overwrites an existing version in place. Used by the snapshot path, where a version is
+ * mutable by design. Unlike {@link #createInterfaceForVersion} this does not reject an
+ * existing version.
+ */
+ CalmInterface updateInterfaceForVersion(CreateInterfaceRequest interfaceRequest, String namespace,
+ Integer interfaceId, String version)
+ throws NamespaceNotFoundException, InterfaceNotFoundException;
+
/**
* Deletes an interface and all of its versions.
*/
diff --git a/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java
index 6f54f2940..2d4c9d6b1 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java
@@ -17,6 +17,15 @@ public interface StandardStore {
String getStandardForVersion(String namespace, Integer standardId, String version) throws NamespaceNotFoundException, StandardNotFoundException, StandardVersionNotFoundException;
Standard createStandardForVersion(CreateStandardRequest standardRequest, String namespace, Integer standardId, String version) throws NamespaceNotFoundException, StandardNotFoundException, StandardVersionExistsException;
+ /**
+ * Overwrites an existing version in place. Used by the snapshot path, where a version is
+ * mutable by design. Unlike {@link #createStandardForVersion} this does not reject an
+ * existing version.
+ */
+ Standard updateStandardForVersion(CreateStandardRequest standardRequest, String namespace,
+ Integer standardId, String version)
+ throws NamespaceNotFoundException, StandardNotFoundException;
+
/**
* Deletes a standard and all of its versions.
*/
diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java
index e75c95cea..d41fcc610 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java
@@ -125,6 +125,25 @@ public CalmInterface createInterfaceForVersion(CreateInterfaceRequest interfaceR
return calmInterface;
}
+ @Override
+ public CalmInterface updateInterfaceForVersion(CreateInterfaceRequest interfaceRequest, String namespace,
+ Integer interfaceId, String version)
+ throws NamespaceNotFoundException, InterfaceNotFoundException {
+ requireInterface(namespace, interfaceId);
+
+ Document content = Document.parse(interfaceRequest.getInterfaceJson());
+ documentStore.upsertVersion(namespace, interfaceId, version, content);
+
+ // Unconditional, matching the old shape.
+ documentStore.updateHeaderDetails(namespace, interfaceId,
+ interfaceRequest.getName(), interfaceRequest.getDescription());
+
+ CalmInterface calmInterface = new CalmInterface(interfaceRequest);
+ calmInterface.setId(interfaceId);
+ calmInterface.setVersion(version);
+ return calmInterface;
+ }
+
private void requireInterface(String namespace, Integer interfaceId) throws NamespaceNotFoundException, InterfaceNotFoundException {
namespaceStore.requireNamespace(namespace);
if (!documentStore.headerExists(namespace, interfaceId)) {
diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java
index 63c221f39..9d0e64b9b 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java
@@ -121,6 +121,25 @@ public Standard createStandardForVersion(CreateStandardRequest standardRequest,
return standard;
}
+ @Override
+ public Standard updateStandardForVersion(CreateStandardRequest standardRequest, String namespace,
+ Integer standardId, String version)
+ throws NamespaceNotFoundException, StandardNotFoundException {
+ requireStandard(namespace, standardId);
+
+ Document content = Document.parse(standardRequest.getStandardJson());
+ documentStore.upsertVersion(namespace, standardId, version, content);
+
+ // Unconditional, matching the old shape: Standard did not guard these on blank.
+ documentStore.updateHeaderDetails(namespace, standardId,
+ standardRequest.getName(), standardRequest.getDescription());
+
+ Standard standard = new Standard(standardRequest);
+ standard.setId(standardId);
+ standard.setVersion(version);
+ return standard;
+ }
+
private void requireStandard(String namespace, Integer standardId) throws NamespaceNotFoundException, StandardNotFoundException {
namespaceStore.requireNamespace(namespace);
if (!documentStore.headerExists(namespace, standardId)) {
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java
index b27b43ec4..4f4f1b568 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java
@@ -129,6 +129,27 @@ public CalmInterface createInterfaceForVersion(CreateInterfaceRequest interfaceR
return calmInterface;
}
+ @Override
+ public CalmInterface updateInterfaceForVersion(CreateInterfaceRequest interfaceRequest, String namespace,
+ Integer interfaceId, String version)
+ throws NamespaceNotFoundException, InterfaceNotFoundException {
+ namespaceStore.requireNamespace(namespace);
+ validateInterfaceJson(interfaceRequest.getInterfaceJson());
+ requireInterfaceExists(namespace, interfaceId);
+
+ documentStore.upsertVersion(namespace, interfaceId, version, interfaceRequest.getInterfaceJson());
+
+ // Unconditional, matching the old shape.
+ documentStore.updateHeaderDetails(namespace, interfaceId,
+ interfaceRequest.getName(), interfaceRequest.getDescription());
+
+ LOG.info("Updated version '{}' for interface {} in namespace '{}'", version, interfaceId, namespace);
+ CalmInterface calmInterface = new CalmInterface(interfaceRequest);
+ calmInterface.setId(interfaceId);
+ calmInterface.setVersion(version);
+ return calmInterface;
+ }
+
/**
* Validates that the supplied interface JSON is parseable, throwing
* {@link JsonParseException} if not so the REST layer can surface a 400.
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java
index 37e3cb3cc..965b906af 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java
@@ -125,6 +125,28 @@ public Standard createStandardForVersion(CreateStandardRequest standardRequest,
return standard;
}
+ @Override
+ public Standard updateStandardForVersion(CreateStandardRequest standardRequest, String namespace,
+ Integer standardId, String version)
+ throws NamespaceNotFoundException, StandardNotFoundException {
+ namespaceStore.requireNamespace(namespace);
+ validateStandardJson(standardRequest.getStandardJson());
+ requireStandardExists(namespace, standardId);
+
+ documentStore.upsertVersion(namespace, standardId, version, standardRequest.getStandardJson());
+
+ // Unconditional, matching the old shape: Standard did not guard these on blank.
+ documentStore.updateHeaderDetails(namespace, standardId,
+ standardRequest.getName(), standardRequest.getDescription());
+
+ LOG.info("Updated version '{}' for standard {} in namespace '{}'", version, standardId, namespace);
+ Standard standard = new Standard(standardRequest);
+ standard.setVersion(version);
+ standard.setId(standardId);
+ standard.setNamespace(namespace);
+ return standard;
+ }
+
/**
* Validates that the supplied standard JSON is parseable, throwing
* {@link JsonParseException} if not so the REST layer can surface a 400.
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java
index 5ca717515..9a5ee7189 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java
@@ -6,6 +6,7 @@
import com.mongodb.client.FindIterable;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoDatabase;
+import com.mongodb.client.model.UpdateOptions;
import com.mongodb.client.result.DeleteResult;
import com.mongodb.client.result.UpdateResult;
import io.quarkus.test.InjectMock;
@@ -296,6 +297,60 @@ void overwrite_the_header_details_even_when_blank() throws Exception {
verify(headerCollection, Mockito.times(2)).updateOne(any(Bson.class), any(Bson.class));
}
+ // --- updateInterfaceForVersion ---
+
+ @Test
+ void throw_a_namespace_exception_when_updating_a_version_in_a_missing_namespace() {
+ when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.updateInterfaceForVersion(createRequest(), NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void throw_an_interface_exception_when_updating_a_version_for_a_missing_interface() {
+ interfaceDoesNotExist();
+
+ assertThrows(InterfaceNotFoundException.class,
+ () -> store.updateInterfaceForVersion(createRequest(), NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void overwrite_the_content_of_an_existing_version() throws Exception {
+ interfaceExists();
+ when(versionCollection.updateOne(any(Bson.class), any(Bson.class), any(UpdateOptions.class)))
+ .thenReturn(UpdateResult.acknowledged(1, 1L, null));
+ CreateInterfaceRequest request = new CreateInterfaceRequest("Name", "desc", "{\"marker\":\"OVERWRITTEN\"}");
+
+ store.updateInterfaceForVersion(request, NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT");
+
+ ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(Bson.class);
+ verify(versionCollection).updateOne(any(Bson.class), updateCaptor.capture(), any(UpdateOptions.class));
+ assertThat(updateCaptor.getValue().toBsonDocument().toJson(), containsString("OVERWRITTEN"));
+ }
+
+ @Test
+ void write_the_header_details_once_after_overwriting_the_version() throws Exception {
+ interfaceExists();
+ when(versionCollection.updateOne(any(Bson.class), any(Bson.class), any(UpdateOptions.class)))
+ .thenReturn(UpdateResult.acknowledged(1, 1L, null));
+
+ store.updateInterfaceForVersion(createRequest(), NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT");
+
+ // Overwriting an already-existing version doesn't move versionCount, so the only
+ // header write here is the name/description update — unlike create's two writes.
+ verify(headerCollection, Mockito.times(1)).updateOne(any(Bson.class), any(Bson.class));
+ }
+
+ @Test
+ void refuse_to_update_a_version_of_an_interface_that_does_not_exist() {
+ interfaceDoesNotExist();
+ CreateInterfaceRequest request = new CreateInterfaceRequest("Name", "desc", "{\"a\":2}");
+
+ assertThrows(InterfaceNotFoundException.class,
+ () -> store.updateInterfaceForVersion(request, NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT"));
+ }
+
// --- deleteInterface ---
@Test
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java
index c8a64ddd4..bc2d6a88f 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java
@@ -6,6 +6,7 @@
import com.mongodb.client.FindIterable;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoDatabase;
+import com.mongodb.client.model.UpdateOptions;
import com.mongodb.client.result.DeleteResult;
import com.mongodb.client.result.UpdateResult;
import io.quarkus.test.InjectMock;
@@ -296,6 +297,60 @@ void overwrite_the_header_details_even_when_blank() throws Exception {
verify(headerCollection, Mockito.times(2)).updateOne(any(Bson.class), any(Bson.class));
}
+ // --- updateStandardForVersion ---
+
+ @Test
+ void throw_a_namespace_exception_when_updating_a_version_in_a_missing_namespace() {
+ when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.updateStandardForVersion(createRequest(), NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void throw_a_standard_exception_when_updating_a_version_for_a_missing_standard() {
+ standardDoesNotExist();
+
+ assertThrows(StandardNotFoundException.class,
+ () -> store.updateStandardForVersion(createRequest(), NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void overwrite_the_content_of_an_existing_version() throws Exception {
+ standardExists();
+ when(versionCollection.updateOne(any(Bson.class), any(Bson.class), any(UpdateOptions.class)))
+ .thenReturn(UpdateResult.acknowledged(1, 1L, null));
+ CreateStandardRequest request = new CreateStandardRequest("Name", "desc", "{\"marker\":\"OVERWRITTEN\"}");
+
+ store.updateStandardForVersion(request, NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT");
+
+ ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(Bson.class);
+ verify(versionCollection).updateOne(any(Bson.class), updateCaptor.capture(), any(UpdateOptions.class));
+ assertThat(updateCaptor.getValue().toBsonDocument().toJson(), containsString("OVERWRITTEN"));
+ }
+
+ @Test
+ void write_the_header_details_once_after_overwriting_the_version() throws Exception {
+ standardExists();
+ when(versionCollection.updateOne(any(Bson.class), any(Bson.class), any(UpdateOptions.class)))
+ .thenReturn(UpdateResult.acknowledged(1, 1L, null));
+
+ store.updateStandardForVersion(createRequest(), NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT");
+
+ // Overwriting an already-existing version doesn't move versionCount, so the only
+ // header write here is the name/description update — unlike create's two writes.
+ verify(headerCollection, Mockito.times(1)).updateOne(any(Bson.class), any(Bson.class));
+ }
+
+ @Test
+ void refuse_to_update_a_version_of_a_standard_that_does_not_exist() {
+ standardDoesNotExist();
+ CreateStandardRequest request = new CreateStandardRequest("Name", "desc", "{\"a\":2}");
+
+ assertThrows(StandardNotFoundException.class,
+ () -> store.updateStandardForVersion(request, NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT"));
+ }
+
// --- deleteStandard ---
@Test
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java
index 4c26068df..e4500f5f2 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java
@@ -264,6 +264,58 @@ public void overwrite_the_header_details_even_when_blank() throws Exception {
verify(headerCollection, times(2)).update(any(Filter.class), any(Document.class));
}
+ // --- updateInterfaceForVersion ---
+
+ @Test
+ public void throw_a_namespace_exception_when_updating_a_version_in_a_missing_namespace() {
+ when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.updateInterfaceForVersion(createRequest(), NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void throw_an_interface_exception_when_updating_a_version_for_a_missing_interface() {
+ interfaceDoesNotExist();
+
+ assertThrows(InterfaceNotFoundException.class,
+ () -> store.updateInterfaceForVersion(createRequest(), NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void overwrite_the_content_of_an_existing_version() throws Exception {
+ interfaceExists();
+ stubFind(versionCollection, List.of(Document.createDocument()
+ .put("version", "1.0.0-SNAPSHOT").put("content", "{\"old\":true}")));
+ CreateInterfaceRequest request = new CreateInterfaceRequest("Name", "desc", "{\"marker\":\"OVERWRITTEN\"}");
+
+ store.updateInterfaceForVersion(request, NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT");
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class);
+ verify(versionCollection).update(any(Filter.class), captor.capture());
+ assertThat(captor.getValue().get("content", String.class), is(request.getInterfaceJson()));
+ }
+
+ @Test
+ public void create_the_version_when_updating_one_that_does_not_exist() throws Exception {
+ interfaceExists();
+ stubFind(versionCollection, List.of());
+
+ // Preserves the known create-on-PUT behaviour, matching Pattern's updateForVersion.
+ store.updateInterfaceForVersion(createRequest(), NAMESPACE, INTERFACE_ID, "2.0.0-SNAPSHOT");
+
+ verify(versionCollection).insert(any(Document.class));
+ }
+
+ @Test
+ public void refuse_to_update_a_version_of_an_interface_that_does_not_exist() {
+ interfaceDoesNotExist();
+ CreateInterfaceRequest request = new CreateInterfaceRequest("Name", "desc", "{\"a\":2}");
+
+ assertThrows(InterfaceNotFoundException.class,
+ () -> store.updateInterfaceForVersion(request, NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT"));
+ }
+
// --- deleteInterface ---
@Test
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java
index 358dea793..e1167f683 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java
@@ -264,6 +264,58 @@ public void overwrite_the_header_details_even_when_blank() throws Exception {
verify(headerCollection, times(2)).update(any(Filter.class), any(Document.class));
}
+ // --- updateStandardForVersion ---
+
+ @Test
+ public void throw_a_namespace_exception_when_updating_a_version_in_a_missing_namespace() {
+ when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.updateStandardForVersion(createRequest(), NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void throw_a_standard_exception_when_updating_a_version_for_a_missing_standard() {
+ standardDoesNotExist();
+
+ assertThrows(StandardNotFoundException.class,
+ () -> store.updateStandardForVersion(createRequest(), NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void overwrite_the_content_of_an_existing_version() throws Exception {
+ standardExists();
+ stubFind(versionCollection, List.of(Document.createDocument()
+ .put("version", "1.0.0-SNAPSHOT").put("content", "{\"old\":true}")));
+ CreateStandardRequest request = new CreateStandardRequest("Name", "desc", "{\"marker\":\"OVERWRITTEN\"}");
+
+ store.updateStandardForVersion(request, NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT");
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class);
+ verify(versionCollection).update(any(Filter.class), captor.capture());
+ assertThat(captor.getValue().get("content", String.class), is(request.getStandardJson()));
+ }
+
+ @Test
+ public void create_the_version_when_updating_one_that_does_not_exist() throws Exception {
+ standardExists();
+ stubFind(versionCollection, List.of());
+
+ // Preserves the known create-on-PUT behaviour, matching Pattern's updateForVersion.
+ store.updateStandardForVersion(createRequest(), NAMESPACE, STANDARD_ID, "2.0.0-SNAPSHOT");
+
+ verify(versionCollection).insert(any(Document.class));
+ }
+
+ @Test
+ public void refuse_to_update_a_version_of_a_standard_that_does_not_exist() {
+ standardDoesNotExist();
+ CreateStandardRequest request = new CreateStandardRequest("Name", "desc", "{\"a\":2}");
+
+ assertThrows(StandardNotFoundException.class,
+ () -> store.updateStandardForVersion(request, NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT"));
+ }
+
// --- deleteStandard ---
@Test
From 79b97ec9c9fe241ff6efb16d534e1619f39481c4 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 13:33:32 +0100
Subject: [PATCH 08/22] test(calm-hub): pin version-before-header write order
for update paths
Reorder mutations passed silently in MongoInterfaceStore and
NitriteStandardStore: no test distinguished header write before vs.
after the version write, which updateHeaderDetails' own javadoc
requires. Adds a never-touch-the-header-on-failed-version-write test
per store.
---
.../mongo/TestMongoInterfaceStoreShould.java | 17 +++++++++++++++++
.../mongo/TestMongoStandardStoreShould.java | 17 +++++++++++++++++
.../TestNitriteInterfaceStoreShould.java | 18 ++++++++++++++++++
.../TestNitriteStandardStoreShould.java | 18 ++++++++++++++++++
4 files changed, 70 insertions(+)
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java
index 9a5ee7189..5798a1225 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java
@@ -342,6 +342,23 @@ void write_the_header_details_once_after_overwriting_the_version() throws Except
verify(headerCollection, Mockito.times(1)).updateOne(any(Bson.class), any(Bson.class));
}
+ @Test
+ void never_touch_the_header_when_the_version_write_fails() {
+ interfaceExists();
+ when(versionCollection.updateOne(any(Bson.class), any(Bson.class), any(UpdateOptions.class)))
+ .thenThrow(writeError(10334, "object to insert too large"));
+
+ // updateHeaderDetails is documented to run only after the version write succeeds — a
+ // rename must never land for a write that failed. Reordering the two calls would pass
+ // every other assertion in this class but is caught here: the header write is stubbed
+ // to succeed in interfaceExists(), so a header call happening anyway would go unnoticed
+ // by anything except this "never" check.
+ assertThrows(StorageWriteException.class,
+ () -> store.updateInterfaceForVersion(createRequest(), NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT"));
+
+ verify(headerCollection, never()).updateOne(any(Bson.class), any(Bson.class));
+ }
+
@Test
void refuse_to_update_a_version_of_an_interface_that_does_not_exist() {
interfaceDoesNotExist();
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java
index bc2d6a88f..9fd6e3058 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java
@@ -342,6 +342,23 @@ void write_the_header_details_once_after_overwriting_the_version() throws Except
verify(headerCollection, Mockito.times(1)).updateOne(any(Bson.class), any(Bson.class));
}
+ @Test
+ void never_touch_the_header_when_the_version_write_fails() {
+ standardExists();
+ when(versionCollection.updateOne(any(Bson.class), any(Bson.class), any(UpdateOptions.class)))
+ .thenThrow(writeError(10334, "object to insert too large"));
+
+ // updateHeaderDetails is documented to run only after the version write succeeds — a
+ // rename must never land for a write that failed. Reordering the two calls would pass
+ // every other assertion in this class but is caught here: the header write is stubbed
+ // to succeed in standardExists(), so a header call happening anyway would go unnoticed
+ // by anything except this "never" check.
+ assertThrows(StorageWriteException.class,
+ () -> store.updateStandardForVersion(createRequest(), NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT"));
+
+ verify(headerCollection, never()).updateOne(any(Bson.class), any(Bson.class));
+ }
+
@Test
void refuse_to_update_a_version_of_a_standard_that_does_not_exist() {
standardDoesNotExist();
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java
index e4500f5f2..9c99f5ad7 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java
@@ -34,6 +34,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -307,6 +308,23 @@ public void create_the_version_when_updating_one_that_does_not_exist() throws Ex
verify(versionCollection).insert(any(Document.class));
}
+ @Test
+ public void never_touch_the_header_when_the_version_write_fails() {
+ interfaceExists();
+ stubFind(versionCollection, List.of(Document.createDocument()
+ .put("version", "1.0.0-SNAPSHOT").put("content", "{\"old\":true}")));
+ when(versionCollection.update(any(Filter.class), any(Document.class)))
+ .thenThrow(new NitriteException("write failed"));
+
+ // updateHeaderDetails is documented to run only after the version write succeeds — a
+ // rename must never land for a write that failed. Reordering the two calls would pass
+ // every other assertion in this class but is caught here.
+ assertThrows(NitriteException.class,
+ () -> store.updateInterfaceForVersion(createRequest(), NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT"));
+
+ verify(headerCollection, never()).update(any(Filter.class), any(Document.class));
+ }
+
@Test
public void refuse_to_update_a_version_of_an_interface_that_does_not_exist() {
interfaceDoesNotExist();
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java
index e1167f683..6eeb41d92 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java
@@ -34,6 +34,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -307,6 +308,23 @@ public void create_the_version_when_updating_one_that_does_not_exist() throws Ex
verify(versionCollection).insert(any(Document.class));
}
+ @Test
+ public void never_touch_the_header_when_the_version_write_fails() {
+ standardExists();
+ stubFind(versionCollection, List.of(Document.createDocument()
+ .put("version", "1.0.0-SNAPSHOT").put("content", "{\"old\":true}")));
+ when(versionCollection.update(any(Filter.class), any(Document.class)))
+ .thenThrow(new NitriteException("write failed"));
+
+ // updateHeaderDetails is documented to run only after the version write succeeds — a
+ // rename must never land for a write that failed. Reordering the two calls would pass
+ // every other assertion in this class but is caught here.
+ assertThrows(NitriteException.class,
+ () -> store.updateStandardForVersion(createRequest(), NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT"));
+
+ verify(headerCollection, never()).update(any(Filter.class), any(Document.class));
+ }
+
@Test
public void refuse_to_update_a_version_of_a_standard_that_does_not_exist() {
standardDoesNotExist();
From b3a3018769030e8fc6cb34dd999cd6950b58fcba Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 13:52:25 +0100
Subject: [PATCH 09/22] feat(calm-hub): make snapshot creation idempotent and
refuse shadowed releases
A POST to an existing -SNAPSHOT version now overwrites and returns 200
instead of 409, since a client should never have to know whether the
snapshot already exists. A snapshot whose release version is already
published still returns 409, since promoting that release would
otherwise have to both create and delete the same logical version.
The shadow check canonicalises the request's release-version spelling
before comparing against the stored versions list, since the store
only ever holds canonical spellings (e.g. 100-SNAPSHOT's release
version "100" must match a stored "1.0.0").
Also extends updateVersionedResourceInStore's switch to cover standard
and interface resources, using Task 6's update-for-version store
methods, so overwriting a standard/interface snapshot works the same
way as the other three resource types.
---
.../services/MappingControllerService.java | 42 +++++++--
.../TestMappingControllerResourceShould.java | 86 +++++++++++++++++++
2 files changed, 121 insertions(+), 7 deletions(-)
diff --git a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
index 392bd32e3..1493b2848 100644
--- a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
+++ b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
@@ -14,9 +14,11 @@
import org.finos.calm.domain.interfaces.CreateInterfaceRequest;
import org.finos.calm.domain.pattern.CreatePatternRequest;
import org.finos.calm.domain.standards.CreateStandardRequest;
+import org.finos.calm.domain.ResourceVersion;
import org.finos.calm.resources.CalmDocumentParser;
import org.finos.calm.resources.CalmResourceErrorResponses;
import org.finos.calm.store.*;
+import org.finos.calm.store.util.CanonicalVersion;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -483,18 +485,38 @@ private Response addNewVersion(String namespace, String typePath, String name,
if (versions.isEmpty()) {
return mappingNotFoundResponse(name);
}
- // Reject if the explicit version already exists.
- if (versions.contains(versionSpec.version())) {
+ String newVersion = versionSpec.version();
+ boolean snapshot = ResourceVersion.isSnapshot(newVersion);
+
+ // A snapshot that shadows a published release makes promotion ambiguous: publishing
+ // that release would have to both create and delete the same logical version.
+ // versions holds canonical spellings, so the raw request's release spelling must be
+ // canonicalised before comparison — 100-SNAPSHOT's release version is "100", which
+ // would never match a stored "1.0.0" otherwise.
+ if (snapshot && versions.contains(CanonicalVersion.of(ResourceVersion.releaseVersion(newVersion)))) {
return CalmResourceErrorResponses.versionAlreadyExistsResponse(
- versionSpec.version(), mapping.getResourceType(), name, namespace);
+ ResourceVersion.releaseVersion(newVersion), mapping.getResourceType(), name, namespace);
}
- String newVersion = versionSpec.version();
+ // Releases stay immutable. A snapshot is mutable by design, so a repeat POST
+ // overwrites it — a client never has to know whether it already exists.
+ boolean overwriting = versions.contains(newVersion);
+ if (overwriting && !snapshot) {
+ return CalmResourceErrorResponses.versionAlreadyExistsResponse(
+ newVersion, mapping.getResourceType(), name, namespace);
+ }
+
String title = documentParser.extractStringField(json, "title");
if (title.isBlank()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("'title' is required in the document body").build();
}
String description = documentParser.extractStringField(json, "description");
+
+ if (overwriting) {
+ updateVersionedResourceInStore(mapping.getResourceType(), namespace,
+ mapping.getNumericId(), newVersion, documentParser.stripId(json), title, description);
+ return Response.ok().build();
+ }
createVersionedResourceInStore(mapping.getResourceType(), namespace,
mapping.getNumericId(), newVersion, json, title, description);
@@ -611,8 +633,7 @@ private void createVersionedResourceInStore(ResourceType type, String namespace,
/**
* Updates an existing version of a resource in the type-specific store.
- * Supported for {@link ResourceType#PATTERN}, {@link ResourceType#ARCHITECTURE},
- * and {@link ResourceType#FLOW} only.
+ * Supported for all five {@link ResourceType} values.
*/
private void updateVersionedResourceInStore(ResourceType type, String namespace, int numericId,
String version, String json, String title, String description) throws Exception {
@@ -650,7 +671,14 @@ private void updateVersionedResourceInStore(ResourceType type, String namespace,
.build();
flowStore.updateFlowForVersion(flow);
}
- default -> throw new UnsupportedOperationException("Update not supported for resource type: " + type);
+ case STANDARD -> {
+ CreateStandardRequest req = new CreateStandardRequest(title, description, json);
+ standardStore.updateStandardForVersion(req, namespace, numericId, version);
+ }
+ case INTERFACE -> {
+ CreateInterfaceRequest req = new CreateInterfaceRequest(title, description, json);
+ interfaceStore.updateInterfaceForVersion(req, namespace, numericId, version);
+ }
}
}
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
index 59b78eab1..21e52f31e 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
@@ -268,6 +268,92 @@ void return_201_when_adding_explicit_version_to_existing_architecture() throws E
verify(mockArchitectureStore).createArchitectureForVersion(any(Architecture.class));
}
+ // --- POST snapshot semantics: idempotent create/overwrite, shadow 409 ---
+
+ @Test
+ void create_a_snapshot_that_does_not_exist_yet() throws Exception {
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("snap-test")
+ .setResourceType(ResourceType.ARCHITECTURE).setNumericId(20).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "snap-test")).thenReturn(existing);
+ when(mockArchitectureStore.getArchitectureVersions(any(Architecture.class))).thenReturn(List.of("1.0.0"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "architectures", "snap-test", "2.0.0-SNAPSHOT")).when()
+ .post("/calm/namespaces/finos/architectures/snap-test/versions/2.0.0-SNAPSHOT")
+ .then().statusCode(201)
+ .header("Location", containsString("/versions/2.0.0-SNAPSHOT"));
+
+ verify(mockArchitectureStore).createArchitectureForVersion(any(Architecture.class));
+ }
+
+ @Test
+ void overwrite_a_snapshot_that_already_exists() throws Exception {
+ // The point of the feature: a client must not have to know whether the snapshot is
+ // already there, so a repeat POST is an overwrite rather than a 409.
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("snap-test")
+ .setResourceType(ResourceType.ARCHITECTURE).setNumericId(20).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "snap-test")).thenReturn(existing);
+ when(mockArchitectureStore.getArchitectureVersions(any(Architecture.class))).thenReturn(List.of("2.0.0-SNAPSHOT"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "architectures", "snap-test", "2.0.0-SNAPSHOT")).when()
+ .post("/calm/namespaces/finos/architectures/snap-test/versions/2.0.0-SNAPSHOT")
+ .then().statusCode(200);
+
+ verify(mockArchitectureStore).updateArchitectureForVersion(any(Architecture.class));
+ verify(mockArchitectureStore, never()).createArchitectureForVersion(any(Architecture.class));
+ }
+
+ @Test
+ void refuse_a_snapshot_whose_release_version_is_already_published() throws Exception {
+ // A snapshot that shadows a published version makes "promotion deletes the snapshot"
+ // ambiguous, so it is refused at the point of creation.
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("snap-test")
+ .setResourceType(ResourceType.ARCHITECTURE).setNumericId(20).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "snap-test")).thenReturn(existing);
+ when(mockArchitectureStore.getArchitectureVersions(any(Architecture.class))).thenReturn(List.of("1.0.0"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "architectures", "snap-test", "1.0.0-SNAPSHOT")).when()
+ .post("/calm/namespaces/finos/architectures/snap-test/versions/1.0.0-SNAPSHOT")
+ .then().statusCode(409);
+ }
+
+ @Test
+ void refuse_a_snapshot_whose_canonical_spelling_shadows_a_published_release() throws Exception {
+ // 100-SNAPSHOT canonicalizes to 1.0.0-SNAPSHOT; its release version (100) must be
+ // compared against the stored, canonical spelling of the published release (1.0.0),
+ // not the raw request spelling.
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("snap-test")
+ .setResourceType(ResourceType.ARCHITECTURE).setNumericId(20).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "snap-test")).thenReturn(existing);
+ when(mockArchitectureStore.getArchitectureVersions(any(Architecture.class))).thenReturn(List.of("1.0.0"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "architectures", "snap-test", "100-SNAPSHOT")).when()
+ .post("/calm/namespaces/finos/architectures/snap-test/versions/100-SNAPSHOT")
+ .then().statusCode(409);
+ }
+
+ @Test
+ void still_refuse_a_release_version_that_already_exists() throws Exception {
+ // Releases stay immutable. Only the snapshot target is idempotent.
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("snap-test")
+ .setResourceType(ResourceType.ARCHITECTURE).setNumericId(20).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "snap-test")).thenReturn(existing);
+ when(mockArchitectureStore.getArchitectureVersions(any(Architecture.class))).thenReturn(List.of("1.0.0"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "architectures", "snap-test", "1.0.0")).when()
+ .post("/calm/namespaces/finos/architectures/snap-test/versions/1.0.0")
+ .then().statusCode(409);
+ }
+
@Test
void return_201_when_adding_explicit_version_to_existing_flow() throws Exception {
ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
From b29081042edcaeedacc6889cce3665c476fe73e7 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 14:09:41 +0100
Subject: [PATCH 10/22] test(calm-hub): cover snapshot-overwrite for standard
and interface types
updateVersionedResourceInStore's STANDARD/INTERFACE arms had no test
that could reach them: PUT returns 501 for both types before the
service is called, and the pre-existing STANDARD/INTERFACE tests only
exercise the create branch. The snapshot-overwrite path added in
b3a30187 is the only caller that reaches those arms, so add a
snapshot-overwrite test per type asserting a 200 and that
updateStandardForVersion/updateInterfaceForVersion was called.
---
.../TestMappingControllerResourceShould.java | 39 +++++++++++++++++++
1 file changed, 39 insertions(+)
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
index 21e52f31e..40ce6ea59 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
@@ -354,6 +354,45 @@ void still_refuse_a_release_version_that_already_exists() throws Exception {
.then().statusCode(409);
}
+ @Test
+ void overwrite_a_standard_snapshot_that_already_exists() throws Exception {
+ // STANDARD's update arm is only reachable via this snapshot-overwrite path: PUT
+ // hard-returns 501 for STANDARD, and the pre-existing STANDARD tests only exercise
+ // create. This is the only test that can catch a broken updateStandardForVersion call.
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("snap-standard")
+ .setResourceType(ResourceType.STANDARD).setNumericId(30).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.STANDARD, "snap-standard")).thenReturn(existing);
+ when(mockStandardStore.getStandardVersions("finos", 30)).thenReturn(List.of("2.0.0-SNAPSHOT"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "standards", "snap-standard", "2.0.0-SNAPSHOT")).when()
+ .post("/calm/namespaces/finos/standards/snap-standard/versions/2.0.0-SNAPSHOT")
+ .then().statusCode(200);
+
+ verify(mockStandardStore).updateStandardForVersion(any(CreateStandardRequest.class), eq("finos"), eq(30), eq("2.0.0-SNAPSHOT"));
+ verify(mockStandardStore, never()).createStandardForVersion(any(CreateStandardRequest.class), any(), any(), any());
+ }
+
+ @Test
+ void overwrite_an_interface_snapshot_that_already_exists() throws Exception {
+ // Same rationale as the STANDARD case above: PUT hard-returns 501 for INTERFACE too,
+ // so this snapshot-overwrite path is the only caller reaching updateInterfaceForVersion.
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("snap-interface")
+ .setResourceType(ResourceType.INTERFACE).setNumericId(40).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.INTERFACE, "snap-interface")).thenReturn(existing);
+ when(mockInterfaceStore.getInterfaceVersions("finos", 40)).thenReturn(List.of("2.0.0-SNAPSHOT"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "interfaces", "snap-interface", "2.0.0-SNAPSHOT")).when()
+ .post("/calm/namespaces/finos/interfaces/snap-interface/versions/2.0.0-SNAPSHOT")
+ .then().statusCode(200);
+
+ verify(mockInterfaceStore).updateInterfaceForVersion(any(CreateInterfaceRequest.class), eq("finos"), eq(40), eq("2.0.0-SNAPSHOT"));
+ verify(mockInterfaceStore, never()).createInterfaceForVersion(any(CreateInterfaceRequest.class), any(), any(), any());
+ }
+
@Test
void return_201_when_adding_explicit_version_to_existing_flow() throws Exception {
ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
From ab3d3642547c7779a8b507951eb9c98983a05f81 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 14:37:41 +0100
Subject: [PATCH 11/22] feat(calm-hub): delete a snapshot when its release
version is published
Publishing a release now deletes its matching -SNAPSHOT version after the
release write succeeds. Not atomic: the release is written first, and a
failed snapshot delete is logged and swallowed rather than rolled back, since
the release is the outcome the user asked for and a stranded snapshot is
recoverable.
Adds a delete-one-version method to all five store interfaces (Mongo and
Nitrite), delegating to the existing version-document helper's deleteVersion.
---
.../services/MappingControllerService.java | 48 ++++++
.../finos/calm/store/ArchitectureStore.java | 9 ++
.../java/org/finos/calm/store/FlowStore.java | 9 ++
.../org/finos/calm/store/InterfaceStore.java | 9 ++
.../org/finos/calm/store/PatternStore.java | 9 ++
.../org/finos/calm/store/StandardStore.java | 9 ++
.../store/mongo/MongoArchitectureStore.java | 10 ++
.../calm/store/mongo/MongoFlowStore.java | 10 ++
.../calm/store/mongo/MongoInterfaceStore.java | 10 ++
.../calm/store/mongo/MongoPatternStore.java | 10 ++
.../calm/store/mongo/MongoStandardStore.java | 10 ++
.../nitrite/NitriteArchitectureStore.java | 10 ++
.../calm/store/nitrite/NitriteFlowStore.java | 10 ++
.../store/nitrite/NitriteInterfaceStore.java | 10 ++
.../store/nitrite/NitritePatternStore.java | 10 ++
.../store/nitrite/NitriteStandardStore.java | 10 ++
.../TestMappingControllerResourceShould.java | 151 ++++++++++++++++++
17 files changed, 344 insertions(+)
diff --git a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
index 1493b2848..1d39fb7a5 100644
--- a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
+++ b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
@@ -520,6 +520,10 @@ private Response addNewVersion(String namespace, String typePath, String name,
createVersionedResourceInStore(mapping.getResourceType(), namespace,
mapping.getNumericId(), newVersion, json, title, description);
+ if (!snapshot) {
+ deleteSnapshotForVersion(mapping, newVersion, versions);
+ }
+
URI location = new URI("/calm/namespaces/" + namespace + "/" + typePath + "/" + name + "/versions/" + newVersion);
return Response.created(location).build();
} catch (NamespaceNotFoundException e) {
@@ -682,6 +686,50 @@ private void updateVersionedResourceInStore(ResourceType type, String namespace,
}
}
+ /**
+ * Removes the snapshot belonging to a version that has just been published.
+ *
+ * {@code versions} is the list already fetched at the top of {@code addNewVersion},
+ * before this release was written — checking it first avoids a pointless store
+ * round trip when the resource never had a snapshot, which is the common case.
+ *
+ * Deliberately after the release write, and deliberately not rolled back. The two are
+ * separate store operations with no transaction across them, so one of them has to go
+ * first. If this fails, the release is correct and an orphan snapshot shadows it — a
+ * state the creation rule otherwise forbids, recoverable by deleting the snapshot. If the
+ * order were reversed, a failed release write would have already destroyed the user's
+ * work in progress.
+ */
+ private void deleteSnapshotForVersion(ResourceMapping mapping, String releaseVersion, List versions) {
+ String snapshotVersion = ResourceVersion.asSnapshot(releaseVersion);
+ if (!versions.contains(snapshotVersion)) {
+ return;
+ }
+ try {
+ deleteVersionForMapping(mapping, snapshotVersion);
+ } catch (Exception e) {
+ logger.error("Published version [{}] of [{}] in namespace [{}] but failed to delete its "
+ + "snapshot [{}] — the snapshot now shadows a published version and should "
+ + "be removed manually",
+ STRICT_SANITIZATION_POLICY.sanitize(releaseVersion),
+ STRICT_SANITIZATION_POLICY.sanitize(mapping.getCustomId()),
+ STRICT_SANITIZATION_POLICY.sanitize(mapping.getNamespace()),
+ STRICT_SANITIZATION_POLICY.sanitize(snapshotVersion), e);
+ }
+ }
+
+ private void deleteVersionForMapping(ResourceMapping mapping, String version) throws Exception {
+ String namespace = mapping.getNamespace();
+ int id = mapping.getNumericId();
+ switch (mapping.getResourceType()) {
+ case PATTERN -> patternStore.deletePatternVersion(namespace, id, version);
+ case ARCHITECTURE -> architectureStore.deleteArchitectureVersion(namespace, id, version);
+ case FLOW -> flowStore.deleteFlowVersion(namespace, id, version);
+ case STANDARD -> standardStore.deleteStandardVersion(namespace, id, version);
+ case INTERFACE -> interfaceStore.deleteInterfaceVersion(namespace, id, version);
+ }
+ }
+
private Response mappingNotFoundResponse(String name) {
return Response.status(Response.Status.NOT_FOUND)
.entity("Resource not found: " + STRICT_SANITIZATION_POLICY.sanitize(name)).build();
diff --git a/calm-hub/src/main/java/org/finos/calm/store/ArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/ArchitectureStore.java
index 9279a03c0..4eeb46590 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/ArchitectureStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/ArchitectureStore.java
@@ -40,4 +40,13 @@ default List getArchitecturesForNamespace(String names
* Deletes an architecture and all of its versions.
*/
void deleteArchitecture(String namespace, int architectureId) throws NamespaceNotFoundException, ArchitectureNotFoundException;
+
+ /**
+ * Removes one version, leaving the resource and its other versions in place. Used by
+ * promotion to delete a snapshot once its release version is published.
+ *
+ * @return {@code true} if a version was removed.
+ */
+ boolean deleteArchitectureVersion(String namespace, int architectureId, String version)
+ throws NamespaceNotFoundException, ArchitectureNotFoundException;
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/FlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/FlowStore.java
index 43211b3c3..1a33a5b04 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/FlowStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/FlowStore.java
@@ -22,4 +22,13 @@ public interface FlowStore {
* Deletes a flow and all of its versions.
*/
void deleteFlow(String namespace, int flowId) throws NamespaceNotFoundException, FlowNotFoundException;
+
+ /**
+ * Removes one version, leaving the resource and its other versions in place. Used by
+ * promotion to delete a snapshot once its release version is published.
+ *
+ * @return {@code true} if a version was removed.
+ */
+ boolean deleteFlowVersion(String namespace, int flowId, String version)
+ throws NamespaceNotFoundException, FlowNotFoundException;
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java
index 0f16e2ede..c1eb4c819 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java
@@ -30,4 +30,13 @@ CalmInterface updateInterfaceForVersion(CreateInterfaceRequest interfaceRequest,
* Deletes an interface and all of its versions.
*/
void deleteInterface(String namespace, Integer interfaceId) throws NamespaceNotFoundException, InterfaceNotFoundException;
+
+ /**
+ * Removes one version, leaving the resource and its other versions in place. Used by
+ * promotion to delete a snapshot once its release version is published.
+ *
+ * @return {@code true} if a version was removed.
+ */
+ boolean deleteInterfaceVersion(String namespace, int interfaceId, String version)
+ throws NamespaceNotFoundException, InterfaceNotFoundException;
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/PatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/PatternStore.java
index de5bf622b..4edd843a5 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/PatternStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/PatternStore.java
@@ -42,4 +42,13 @@ default List getPatternsForNamespace(String namespace)
* Deletes a pattern and all of its versions.
*/
void deletePattern(String namespace, int patternId) throws NamespaceNotFoundException, PatternNotFoundException;
+
+ /**
+ * Removes one version, leaving the resource and its other versions in place. Used by
+ * promotion to delete a snapshot once its release version is published.
+ *
+ * @return {@code true} if a version was removed.
+ */
+ boolean deletePatternVersion(String namespace, int patternId, String version)
+ throws NamespaceNotFoundException, PatternNotFoundException;
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java
index 2d4c9d6b1..7d1b5b246 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java
@@ -30,4 +30,13 @@ Standard updateStandardForVersion(CreateStandardRequest standardRequest, String
* Deletes a standard and all of its versions.
*/
void deleteStandard(String namespace, Integer standardId) throws NamespaceNotFoundException, StandardNotFoundException;
+
+ /**
+ * Removes one version, leaving the resource and its other versions in place. Used by
+ * promotion to delete a snapshot once its release version is published.
+ *
+ * @return {@code true} if a version was removed.
+ */
+ boolean deleteStandardVersion(String namespace, int standardId, String version)
+ throws NamespaceNotFoundException, StandardNotFoundException;
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoArchitectureStore.java
index fc3815d46..42a9576bc 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoArchitectureStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoArchitectureStore.java
@@ -164,4 +164,14 @@ public void deleteArchitecture(String namespace, int architectureId) throws Name
throw new ArchitectureNotFoundException();
}
}
+
+ @Override
+ public boolean deleteArchitectureVersion(String namespace, int architectureId, String version)
+ throws NamespaceNotFoundException, ArchitectureNotFoundException {
+ namespaceStore.requireNamespace(namespace);
+ if (!documentStore.headerExists(namespace, architectureId)) {
+ throw new ArchitectureNotFoundException();
+ }
+ return documentStore.deleteVersion(namespace, architectureId, version);
+ }
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoFlowStore.java
index d67fe4d5f..ee6a083b0 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoFlowStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoFlowStore.java
@@ -151,4 +151,14 @@ public void deleteFlow(String namespace, int flowId) throws NamespaceNotFoundExc
throw new FlowNotFoundException();
}
}
+
+ @Override
+ public boolean deleteFlowVersion(String namespace, int flowId, String version)
+ throws NamespaceNotFoundException, FlowNotFoundException {
+ namespaceStore.requireNamespace(namespace);
+ if (!documentStore.headerExists(namespace, flowId)) {
+ throw new FlowNotFoundException();
+ }
+ return documentStore.deleteVersion(namespace, flowId, version);
+ }
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java
index d41fcc610..f5531cfdd 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java
@@ -158,4 +158,14 @@ public void deleteInterface(String namespace, Integer interfaceId) throws Namesp
throw new InterfaceNotFoundException();
}
}
+
+ @Override
+ public boolean deleteInterfaceVersion(String namespace, int interfaceId, String version)
+ throws NamespaceNotFoundException, InterfaceNotFoundException {
+ namespaceStore.requireNamespace(namespace);
+ if (!documentStore.headerExists(namespace, interfaceId)) {
+ throw new InterfaceNotFoundException();
+ }
+ return documentStore.deleteVersion(namespace, interfaceId, version);
+ }
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoPatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoPatternStore.java
index 35dfed3d7..330ec6fc7 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoPatternStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoPatternStore.java
@@ -160,4 +160,14 @@ public void deletePattern(String namespace, int patternId) throws NamespaceNotFo
throw new PatternNotFoundException();
}
}
+
+ @Override
+ public boolean deletePatternVersion(String namespace, int patternId, String version)
+ throws NamespaceNotFoundException, PatternNotFoundException {
+ namespaceStore.requireNamespace(namespace);
+ if (!documentStore.headerExists(namespace, patternId)) {
+ throw new PatternNotFoundException();
+ }
+ return documentStore.deleteVersion(namespace, patternId, version);
+ }
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java
index 9d0e64b9b..9a9a76a40 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java
@@ -154,4 +154,14 @@ public void deleteStandard(String namespace, Integer standardId) throws Namespac
throw new StandardNotFoundException();
}
}
+
+ @Override
+ public boolean deleteStandardVersion(String namespace, int standardId, String version)
+ throws NamespaceNotFoundException, StandardNotFoundException {
+ namespaceStore.requireNamespace(namespace);
+ if (!documentStore.headerExists(namespace, standardId)) {
+ throw new StandardNotFoundException();
+ }
+ return documentStore.deleteVersion(namespace, standardId, version);
+ }
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java
index ab7ae4977..b3a46524f 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java
@@ -205,4 +205,14 @@ public void deleteArchitecture(String namespace, int architectureId) throws Name
}
LOG.info("Deleted architecture with ID {} from namespace '{}'", architectureId, namespace);
}
+
+ @Override
+ public boolean deleteArchitectureVersion(String namespace, int architectureId, String version)
+ throws NamespaceNotFoundException, ArchitectureNotFoundException {
+ namespaceStore.requireNamespace(namespace);
+ if (!documentStore.headerExists(namespace, architectureId)) {
+ throw new ArchitectureNotFoundException();
+ }
+ return documentStore.deleteVersion(namespace, architectureId, version);
+ }
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java
index ebfe0786b..75a2a1bb5 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java
@@ -177,4 +177,14 @@ public void deleteFlow(String namespace, int flowId) throws NamespaceNotFoundExc
}
LOG.info("Deleted flow with ID {} from namespace '{}'", flowId, namespace);
}
+
+ @Override
+ public boolean deleteFlowVersion(String namespace, int flowId, String version)
+ throws NamespaceNotFoundException, FlowNotFoundException {
+ namespaceStore.requireNamespace(namespace);
+ if (!documentStore.headerExists(namespace, flowId)) {
+ throw new FlowNotFoundException();
+ }
+ return documentStore.deleteVersion(namespace, flowId, version);
+ }
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java
index 4f4f1b568..f516caff2 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java
@@ -187,4 +187,14 @@ public void deleteInterface(String namespace, Integer interfaceId) throws Namesp
}
LOG.info("Deleted interface with ID {} from namespace '{}'", interfaceId, namespace);
}
+
+ @Override
+ public boolean deleteInterfaceVersion(String namespace, int interfaceId, String version)
+ throws NamespaceNotFoundException, InterfaceNotFoundException {
+ namespaceStore.requireNamespace(namespace);
+ if (!documentStore.headerExists(namespace, interfaceId)) {
+ throw new InterfaceNotFoundException();
+ }
+ return documentStore.deleteVersion(namespace, interfaceId, version);
+ }
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java
index 58c1d644d..f1065f1ad 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java
@@ -195,4 +195,14 @@ public void deletePattern(String namespace, int patternId) throws NamespaceNotFo
}
LOG.info("Deleted pattern with ID {} from namespace '{}'", patternId, namespace);
}
+
+ @Override
+ public boolean deletePatternVersion(String namespace, int patternId, String version)
+ throws NamespaceNotFoundException, PatternNotFoundException {
+ namespaceStore.requireNamespace(namespace);
+ if (!documentStore.headerExists(namespace, patternId)) {
+ throw new PatternNotFoundException();
+ }
+ return documentStore.deleteVersion(namespace, patternId, version);
+ }
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java
index 965b906af..15e929ff6 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java
@@ -184,4 +184,14 @@ public void deleteStandard(String namespace, Integer standardId) throws Namespac
}
LOG.info("Deleted standard with ID {} from namespace '{}'", standardId, namespace);
}
+
+ @Override
+ public boolean deleteStandardVersion(String namespace, int standardId, String version)
+ throws NamespaceNotFoundException, StandardNotFoundException {
+ namespaceStore.requireNamespace(namespace);
+ if (!documentStore.headerExists(namespace, standardId)) {
+ throw new StandardNotFoundException();
+ }
+ return documentStore.deleteVersion(namespace, standardId, version);
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
index 40ce6ea59..3ceeb2a1b 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
@@ -16,6 +16,7 @@
import org.finos.calm.store.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
import org.mockito.junit.jupiter.MockitoExtension;
import org.finos.calm.security.CalmHubPermissionChecker;
@@ -26,6 +27,7 @@
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@@ -1566,4 +1568,153 @@ void reject_a_lowercase_snapshot_suffix() {
.post("/calm/namespaces/finos/architectures/test/versions/1.0.0-snapshot")
.then().statusCode(400);
}
+
+ // --- Promotion: publishing a release deletes its snapshot ---
+
+ private static final int PROMOTION_ARCHITECTURE_ID = 60;
+
+ /** Sets up an existing architecture mapping whose only known version is {@code version}. */
+ private void givenAnExistingArchitecture(String name, String version) throws Exception {
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId(name)
+ .setResourceType(ResourceType.ARCHITECTURE).setNumericId(PROMOTION_ARCHITECTURE_ID).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, name)).thenReturn(existing);
+ when(mockArchitectureStore.getArchitectureVersions(any(Architecture.class))).thenReturn(List.of(version));
+ }
+
+ private static String architectureBody(String name, String version) {
+ return versionedDoc("finos", "architectures", name, version);
+ }
+
+ @Test
+ void delete_the_snapshot_when_its_release_version_is_published() throws Exception {
+ givenAnExistingArchitecture("test", "1.0.0-SNAPSHOT");
+
+ given()
+ .contentType("application/json")
+ .body(architectureBody("test", "1.0.0"))
+ .when()
+ .post("/calm/namespaces/finos/architectures/test/versions/1.0.0")
+ .then()
+ .statusCode(201);
+
+ verify(mockArchitectureStore).deleteArchitectureVersion("finos", PROMOTION_ARCHITECTURE_ID, "1.0.0-SNAPSHOT");
+ }
+
+ @Test
+ void publish_a_release_normally_when_there_was_never_a_snapshot() throws Exception {
+ // A release POST for a resource with no snapshot must be exactly the operation it was
+ // before this feature, so no client needs promotion-specific code.
+ givenAnExistingArchitecture("test", "1.0.0");
+
+ given()
+ .contentType("application/json")
+ .body(architectureBody("test", "1.1.0"))
+ .when()
+ .post("/calm/namespaces/finos/architectures/test/versions/1.1.0")
+ .then()
+ .statusCode(201);
+
+ verify(mockArchitectureStore, never()).deleteArchitectureVersion(any(), anyInt(), any());
+ }
+
+ @Test
+ void still_publish_the_release_when_deleting_the_snapshot_fails() throws Exception {
+ // Promotion is not atomic. The release is what the user asked for; a stranded
+ // snapshot is recoverable, a lost release is not.
+ givenAnExistingArchitecture("test", "1.0.0-SNAPSHOT");
+ doThrow(new RuntimeException("mongo down"))
+ .when(mockArchitectureStore).deleteArchitectureVersion(any(), anyInt(), any());
+
+ given()
+ .contentType("application/json")
+ .body(architectureBody("test", "1.0.0"))
+ .when()
+ .post("/calm/namespaces/finos/architectures/test/versions/1.0.0")
+ .then()
+ .statusCode(201);
+ }
+
+ @Test
+ void write_the_release_before_deleting_its_snapshot() throws Exception {
+ // Promotion is deliberately not atomic, and the order is load-bearing: reversing it
+ // would delete the snapshot before knowing the release write succeeds.
+ givenAnExistingArchitecture("test", "1.0.0-SNAPSHOT");
+
+ given()
+ .contentType("application/json")
+ .body(architectureBody("test", "1.0.0"))
+ .when()
+ .post("/calm/namespaces/finos/architectures/test/versions/1.0.0")
+ .then()
+ .statusCode(201);
+
+ InOrder order = inOrder(mockArchitectureStore);
+ order.verify(mockArchitectureStore).createArchitectureForVersion(any(Architecture.class));
+ order.verify(mockArchitectureStore).deleteArchitectureVersion("finos", PROMOTION_ARCHITECTURE_ID, "1.0.0-SNAPSHOT");
+ }
+
+ @Test
+ void delete_the_snapshot_when_publishing_a_pattern_release() throws Exception {
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("promo-pattern")
+ .setResourceType(ResourceType.PATTERN).setNumericId(61).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.PATTERN, "promo-pattern")).thenReturn(existing);
+ when(mockPatternStore.getPatternVersions(any(Pattern.class))).thenReturn(List.of("1.0.0-SNAPSHOT"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "patterns", "promo-pattern", "1.0.0")).when()
+ .post("/calm/namespaces/finos/patterns/promo-pattern/versions/1.0.0")
+ .then().statusCode(201);
+
+ verify(mockPatternStore).deletePatternVersion("finos", 61, "1.0.0-SNAPSHOT");
+ }
+
+ @Test
+ void delete_the_snapshot_when_publishing_a_flow_release() throws Exception {
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("promo-flow")
+ .setResourceType(ResourceType.FLOW).setNumericId(62).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.FLOW, "promo-flow")).thenReturn(existing);
+ when(mockFlowStore.getFlowVersions(any(Flow.class))).thenReturn(List.of("1.0.0-SNAPSHOT"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "flows", "promo-flow", "1.0.0")).when()
+ .post("/calm/namespaces/finos/flows/promo-flow/versions/1.0.0")
+ .then().statusCode(201);
+
+ verify(mockFlowStore).deleteFlowVersion("finos", 62, "1.0.0-SNAPSHOT");
+ }
+
+ @Test
+ void delete_the_snapshot_when_publishing_a_standard_release() throws Exception {
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("promo-standard")
+ .setResourceType(ResourceType.STANDARD).setNumericId(63).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.STANDARD, "promo-standard")).thenReturn(existing);
+ when(mockStandardStore.getStandardVersions("finos", 63)).thenReturn(List.of("1.0.0-SNAPSHOT"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "standards", "promo-standard", "1.0.0")).when()
+ .post("/calm/namespaces/finos/standards/promo-standard/versions/1.0.0")
+ .then().statusCode(201);
+
+ verify(mockStandardStore).deleteStandardVersion("finos", 63, "1.0.0-SNAPSHOT");
+ }
+
+ @Test
+ void delete_the_snapshot_when_publishing_an_interface_release() throws Exception {
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("promo-interface")
+ .setResourceType(ResourceType.INTERFACE).setNumericId(64).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.INTERFACE, "promo-interface")).thenReturn(existing);
+ when(mockInterfaceStore.getInterfaceVersions("finos", 64)).thenReturn(List.of("1.0.0-SNAPSHOT"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "interfaces", "promo-interface", "1.0.0")).when()
+ .post("/calm/namespaces/finos/interfaces/promo-interface/versions/1.0.0")
+ .then().statusCode(201);
+
+ verify(mockInterfaceStore).deleteInterfaceVersion("finos", 64, "1.0.0-SNAPSHOT");
+ }
}
From 6f27da8cc35b742f32a8ac41ebbfc21078add0eb Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 15:09:59 +0100
Subject: [PATCH 12/22] fix(calm-hub): canonicalise the release spelling before
deleting its snapshot
deleteSnapshotForVersion compared a raw path-param release spelling against
the canonically stored snapshot version, so a non-canonical release POST
(e.g. "100" for "1.0.0") silently orphaned its snapshot instead of deleting
it. Canonicalise before both the presence check and the delete call, matching
the existing shadow-check pattern earlier in the same method.
Also adds the missing success LOG.info line to the five Nitrite
deleteXVersion delegates, matching their neighbouring deleteX methods.
---
.../org/finos/calm/services/MappingControllerService.java | 6 +++++-
.../finos/calm/store/nitrite/NitriteArchitectureStore.java | 6 +++++-
.../java/org/finos/calm/store/nitrite/NitriteFlowStore.java | 6 +++++-
.../org/finos/calm/store/nitrite/NitriteInterfaceStore.java | 6 +++++-
.../org/finos/calm/store/nitrite/NitritePatternStore.java | 6 +++++-
.../org/finos/calm/store/nitrite/NitriteStandardStore.java | 6 +++++-
6 files changed, 30 insertions(+), 6 deletions(-)
diff --git a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
index 1d39fb7a5..1ccfff374 100644
--- a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
+++ b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
@@ -701,7 +701,11 @@ private void updateVersionedResourceInStore(ResourceType type, String namespace,
* work in progress.
*/
private void deleteSnapshotForVersion(ResourceMapping mapping, String releaseVersion, List versions) {
- String snapshotVersion = ResourceVersion.asSnapshot(releaseVersion);
+ // versions holds canonical spellings, so the raw request's release spelling must be
+ // canonicalised first — "100"'s snapshot is stored as "1.0.0-SNAPSHOT", which would
+ // never match a naive "100-SNAPSHOT" otherwise (see the shadow check above this method's
+ // call site, which canonicalises for the same reason).
+ String snapshotVersion = ResourceVersion.asSnapshot(CanonicalVersion.of(releaseVersion));
if (!versions.contains(snapshotVersion)) {
return;
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java
index b3a46524f..3901570e8 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java
@@ -213,6 +213,10 @@ public boolean deleteArchitectureVersion(String namespace, int architectureId, S
if (!documentStore.headerExists(namespace, architectureId)) {
throw new ArchitectureNotFoundException();
}
- return documentStore.deleteVersion(namespace, architectureId, version);
+ boolean deleted = documentStore.deleteVersion(namespace, architectureId, version);
+ if (deleted) {
+ LOG.info("Deleted version '{}' of architecture {} from namespace '{}'", version, architectureId, namespace);
+ }
+ return deleted;
}
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java
index 75a2a1bb5..06e23b601 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java
@@ -185,6 +185,10 @@ public boolean deleteFlowVersion(String namespace, int flowId, String version)
if (!documentStore.headerExists(namespace, flowId)) {
throw new FlowNotFoundException();
}
- return documentStore.deleteVersion(namespace, flowId, version);
+ boolean deleted = documentStore.deleteVersion(namespace, flowId, version);
+ if (deleted) {
+ LOG.info("Deleted version '{}' of flow {} from namespace '{}'", version, flowId, namespace);
+ }
+ return deleted;
}
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java
index f516caff2..7229fb689 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java
@@ -195,6 +195,10 @@ public boolean deleteInterfaceVersion(String namespace, int interfaceId, String
if (!documentStore.headerExists(namespace, interfaceId)) {
throw new InterfaceNotFoundException();
}
- return documentStore.deleteVersion(namespace, interfaceId, version);
+ boolean deleted = documentStore.deleteVersion(namespace, interfaceId, version);
+ if (deleted) {
+ LOG.info("Deleted version '{}' of interface {} from namespace '{}'", version, interfaceId, namespace);
+ }
+ return deleted;
}
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java
index f1065f1ad..553b3d9ed 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java
@@ -203,6 +203,10 @@ public boolean deletePatternVersion(String namespace, int patternId, String vers
if (!documentStore.headerExists(namespace, patternId)) {
throw new PatternNotFoundException();
}
- return documentStore.deleteVersion(namespace, patternId, version);
+ boolean deleted = documentStore.deleteVersion(namespace, patternId, version);
+ if (deleted) {
+ LOG.info("Deleted version '{}' of pattern {} from namespace '{}'", version, patternId, namespace);
+ }
+ return deleted;
}
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java
index 15e929ff6..a54a1d68f 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java
@@ -192,6 +192,10 @@ public boolean deleteStandardVersion(String namespace, int standardId, String ve
if (!documentStore.headerExists(namespace, standardId)) {
throw new StandardNotFoundException();
}
- return documentStore.deleteVersion(namespace, standardId, version);
+ boolean deleted = documentStore.deleteVersion(namespace, standardId, version);
+ if (deleted) {
+ LOG.info("Deleted version '{}' of standard {} from namespace '{}'", version, standardId, namespace);
+ }
+ return deleted;
}
}
From d8e79875b0e388028605e5a613118cee93a5f591 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 15:10:15 +0100
Subject: [PATCH 13/22] test(calm-hub): cover the ten store deleteXVersion
delegates directly
TestMappingControllerResourceShould only exercised these through mocked
store interfaces, so the concrete Mongo and Nitrite implementations had zero
effective coverage. Adds a namespace-missing, a resource-missing, a
successful-delete and an absent-version test for each of the ten
deletePatternVersion/deleteArchitectureVersion/deleteFlowVersion/
deleteStandardVersion/deleteInterfaceVersion implementations.
Also adds a regression test for a non-canonical release spelling ("100")
correctly deleting its canonically-stored snapshot ("1.0.0-SNAPSHOT").
---
.../TestMappingControllerResourceShould.java | 18 +++++++++
.../TestMongoArchitectureStoreShould.java | 39 +++++++++++++++++++
.../store/mongo/TestMongoFlowStoreShould.java | 39 +++++++++++++++++++
.../mongo/TestMongoInterfaceStoreShould.java | 39 +++++++++++++++++++
.../mongo/TestMongoPatternStoreShould.java | 39 +++++++++++++++++++
.../mongo/TestMongoStandardStoreShould.java | 39 +++++++++++++++++++
.../TestNitriteArchitectureStoreShould.java | 39 +++++++++++++++++++
.../nitrite/TestNitriteFlowStoreShould.java | 39 +++++++++++++++++++
.../TestNitriteInterfaceStoreShould.java | 39 +++++++++++++++++++
.../TestNitritePatternStoreShould.java | 39 +++++++++++++++++++
.../TestNitriteStandardStoreShould.java | 39 +++++++++++++++++++
11 files changed, 408 insertions(+)
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
index 3ceeb2a1b..b52bb8ba8 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
@@ -1635,6 +1635,24 @@ void still_publish_the_release_when_deleting_the_snapshot_fails() throws Excepti
.statusCode(201);
}
+ @Test
+ void delete_the_snapshot_when_publishing_a_non_canonical_release_spelling() throws Exception {
+ // VERSION_REGEX accepts several spellings of one version ("100" == "1.0.0"). The
+ // release spelling must be canonicalised before it's compared against — and used to
+ // delete — the canonically stored snapshot, or the snapshot is silently orphaned.
+ givenAnExistingArchitecture("test", "1.0.0-SNAPSHOT");
+
+ given()
+ .contentType("application/json")
+ .body(architectureBody("test", "100"))
+ .when()
+ .post("/calm/namespaces/finos/architectures/test/versions/100")
+ .then()
+ .statusCode(201);
+
+ verify(mockArchitectureStore).deleteArchitectureVersion("finos", PROMOTION_ARCHITECTURE_ID, "1.0.0-SNAPSHOT");
+ }
+
@Test
void write_the_release_before_deleting_its_snapshot() throws Exception {
// Promotion is deliberately not atomic, and the order is load-bearing: reversing it
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoArchitectureStoreShould.java
index 9e32c04f7..609136c3a 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoArchitectureStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoArchitectureStoreShould.java
@@ -458,6 +458,45 @@ void throw_an_architecture_exception_when_deleting_a_missing_architecture() {
assertThrows(ArchitectureNotFoundException.class, () -> store.deleteArchitecture(NAMESPACE, ARCHITECTURE_ID));
}
+ // --- deleteArchitectureVersion ---
+
+ @Test
+ void throw_a_namespace_exception_when_deleting_a_version_in_a_missing_namespace() {
+ when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.deleteArchitectureVersion(NAMESPACE, ARCHITECTURE_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void throw_an_architecture_exception_when_deleting_a_version_of_a_missing_architecture() {
+ architectureDoesNotExist();
+
+ assertThrows(ArchitectureNotFoundException.class,
+ () -> store.deleteArchitectureVersion(NAMESPACE, ARCHITECTURE_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void delete_the_version_document_when_the_snapshot_exists() throws Exception {
+ architectureExists();
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1));
+
+ boolean deleted = store.deleteArchitectureVersion(NAMESPACE, ARCHITECTURE_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(true));
+ verify(versionCollection).deleteOne(any(Bson.class));
+ }
+
+ @Test
+ void return_false_when_the_version_to_delete_does_not_exist() throws Exception {
+ architectureExists();
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0));
+
+ boolean deleted = store.deleteArchitectureVersion(NAMESPACE, ARCHITECTURE_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(false));
+ }
+
@Test
void page_the_summary_window_at_the_database() throws NamespaceNotFoundException {
FindIterable iterable = stubFind(headerCollection, List.of());
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoFlowStoreShould.java
index 0db7f214d..9d7628074 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoFlowStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoFlowStoreShould.java
@@ -439,4 +439,43 @@ void throw_a_flow_exception_when_deleting_a_missing_flow() {
assertThrows(FlowNotFoundException.class, () -> store.deleteFlow(NAMESPACE, FLOW_ID));
}
+
+ // --- deleteFlowVersion ---
+
+ @Test
+ void throw_a_namespace_exception_when_deleting_a_version_in_a_missing_namespace() {
+ when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.deleteFlowVersion(NAMESPACE, FLOW_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void throw_a_flow_exception_when_deleting_a_version_of_a_missing_flow() {
+ flowDoesNotExist();
+
+ assertThrows(FlowNotFoundException.class,
+ () -> store.deleteFlowVersion(NAMESPACE, FLOW_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void delete_the_version_document_when_the_snapshot_exists() throws Exception {
+ flowExists();
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1));
+
+ boolean deleted = store.deleteFlowVersion(NAMESPACE, FLOW_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(true));
+ verify(versionCollection).deleteOne(any(Bson.class));
+ }
+
+ @Test
+ void return_false_when_the_version_to_delete_does_not_exist() throws Exception {
+ flowExists();
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0));
+
+ boolean deleted = store.deleteFlowVersion(NAMESPACE, FLOW_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(false));
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java
index 5798a1225..c72a68a56 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java
@@ -393,4 +393,43 @@ void throw_an_interface_exception_when_deleting_a_missing_interface() {
assertThrows(InterfaceNotFoundException.class, () -> store.deleteInterface(NAMESPACE, INTERFACE_ID));
}
+
+ // --- deleteInterfaceVersion ---
+
+ @Test
+ void throw_a_namespace_exception_when_deleting_a_version_in_a_missing_namespace() {
+ when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.deleteInterfaceVersion(NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void throw_an_interface_exception_when_deleting_a_version_of_a_missing_interface() {
+ interfaceDoesNotExist();
+
+ assertThrows(InterfaceNotFoundException.class,
+ () -> store.deleteInterfaceVersion(NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void delete_the_version_document_when_the_snapshot_exists() throws Exception {
+ interfaceExists();
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1));
+
+ boolean deleted = store.deleteInterfaceVersion(NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(true));
+ verify(versionCollection).deleteOne(any(Bson.class));
+ }
+
+ @Test
+ void return_false_when_the_version_to_delete_does_not_exist() throws Exception {
+ interfaceExists();
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0));
+
+ boolean deleted = store.deleteInterfaceVersion(NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(false));
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoPatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoPatternStoreShould.java
index 72bd0c527..c2440c773 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoPatternStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoPatternStoreShould.java
@@ -205,6 +205,45 @@ void throw_a_pattern_exception_when_deleting_a_missing_pattern() {
assertThrows(PatternNotFoundException.class, () -> store.deletePattern(NAMESPACE, PATTERN_ID));
}
+ // --- deletePatternVersion ---
+
+ @Test
+ void throw_a_namespace_exception_when_deleting_a_version_in_a_missing_namespace() {
+ when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.deletePatternVersion(NAMESPACE, PATTERN_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void throw_a_pattern_exception_when_deleting_a_version_of_a_missing_pattern() {
+ patternDoesNotExist();
+
+ assertThrows(PatternNotFoundException.class,
+ () -> store.deletePatternVersion(NAMESPACE, PATTERN_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void delete_the_version_document_when_the_snapshot_exists() throws Exception {
+ patternExists();
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1));
+
+ boolean deleted = store.deletePatternVersion(NAMESPACE, PATTERN_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(true));
+ verify(versionCollection).deleteOne(any(Bson.class));
+ }
+
+ @Test
+ void return_false_when_the_version_to_delete_does_not_exist() throws Exception {
+ patternExists();
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0));
+
+ boolean deleted = store.deletePatternVersion(NAMESPACE, PATTERN_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(false));
+ }
+
@Test
void page_the_summary_window_at_the_database() throws NamespaceNotFoundException {
FindIterable iterable = stubFind(headerCollection, List.of());
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java
index 9fd6e3058..19ee9e894 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java
@@ -393,4 +393,43 @@ void throw_a_standard_exception_when_deleting_a_missing_standard() {
assertThrows(StandardNotFoundException.class, () -> store.deleteStandard(NAMESPACE, STANDARD_ID));
}
+
+ // --- deleteStandardVersion ---
+
+ @Test
+ void throw_a_namespace_exception_when_deleting_a_version_in_a_missing_namespace() {
+ when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.deleteStandardVersion(NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void throw_a_standard_exception_when_deleting_a_version_of_a_missing_standard() {
+ standardDoesNotExist();
+
+ assertThrows(StandardNotFoundException.class,
+ () -> store.deleteStandardVersion(NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void delete_the_version_document_when_the_snapshot_exists() throws Exception {
+ standardExists();
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(1));
+
+ boolean deleted = store.deleteStandardVersion(NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(true));
+ verify(versionCollection).deleteOne(any(Bson.class));
+ }
+
+ @Test
+ void return_false_when_the_version_to_delete_does_not_exist() throws Exception {
+ standardExists();
+ when(versionCollection.deleteOne(any(Bson.class))).thenReturn(DeleteResult.acknowledged(0));
+
+ boolean deleted = store.deleteStandardVersion(NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(false));
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteArchitectureStoreShould.java
index 454a3f491..ec12e85b0 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteArchitectureStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteArchitectureStoreShould.java
@@ -457,4 +457,43 @@ public void throw_an_architecture_exception_when_deleting_a_missing_architecture
assertThrows(ArchitectureNotFoundException.class, () -> store.deleteArchitecture(NAMESPACE, ARCHITECTURE_ID));
}
+
+ // --- deleteArchitectureVersion ---
+
+ @Test
+ public void throw_a_namespace_exception_when_deleting_a_version_in_a_missing_namespace() {
+ when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.deleteArchitectureVersion(NAMESPACE, ARCHITECTURE_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void throw_an_architecture_exception_when_deleting_a_version_of_a_missing_architecture() {
+ architectureDoesNotExist();
+
+ assertThrows(ArchitectureNotFoundException.class,
+ () -> store.deleteArchitectureVersion(NAMESPACE, ARCHITECTURE_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void delete_the_version_document_when_the_snapshot_exists() throws Exception {
+ architectureExists();
+ stubFind(versionCollection, List.of(Document.createDocument().put("version", "1.0.0-SNAPSHOT")));
+
+ boolean deleted = store.deleteArchitectureVersion(NAMESPACE, ARCHITECTURE_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(true));
+ verify(versionCollection).remove(any(Document.class));
+ }
+
+ @Test
+ public void return_false_when_the_version_to_delete_does_not_exist() throws Exception {
+ architectureExists();
+ stubFind(versionCollection, List.of());
+
+ boolean deleted = store.deleteArchitectureVersion(NAMESPACE, ARCHITECTURE_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(false));
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteFlowStoreShould.java
index 349db831e..a8b18d5b1 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteFlowStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteFlowStoreShould.java
@@ -418,4 +418,43 @@ public void throw_a_flow_exception_when_deleting_a_missing_flow() {
assertThrows(FlowNotFoundException.class, () -> store.deleteFlow(NAMESPACE, FLOW_ID));
}
+
+ // --- deleteFlowVersion ---
+
+ @Test
+ public void throw_a_namespace_exception_when_deleting_a_version_in_a_missing_namespace() {
+ when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.deleteFlowVersion(NAMESPACE, FLOW_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void throw_a_flow_exception_when_deleting_a_version_of_a_missing_flow() {
+ flowDoesNotExist();
+
+ assertThrows(FlowNotFoundException.class,
+ () -> store.deleteFlowVersion(NAMESPACE, FLOW_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void delete_the_version_document_when_the_snapshot_exists() throws Exception {
+ flowExists();
+ stubFind(versionCollection, List.of(Document.createDocument().put("version", "1.0.0-SNAPSHOT")));
+
+ boolean deleted = store.deleteFlowVersion(NAMESPACE, FLOW_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(true));
+ verify(versionCollection).remove(any(Document.class));
+ }
+
+ @Test
+ public void return_false_when_the_version_to_delete_does_not_exist() throws Exception {
+ flowExists();
+ stubFind(versionCollection, List.of());
+
+ boolean deleted = store.deleteFlowVersion(NAMESPACE, FLOW_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(false));
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java
index 9c99f5ad7..7c3896b4e 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java
@@ -362,4 +362,43 @@ public void throw_an_interface_exception_when_deleting_a_missing_interface() {
assertThrows(InterfaceNotFoundException.class, () -> store.deleteInterface(NAMESPACE, INTERFACE_ID));
}
+
+ // --- deleteInterfaceVersion ---
+
+ @Test
+ public void throw_a_namespace_exception_when_deleting_a_version_in_a_missing_namespace() {
+ when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.deleteInterfaceVersion(NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void throw_an_interface_exception_when_deleting_a_version_of_a_missing_interface() {
+ interfaceDoesNotExist();
+
+ assertThrows(InterfaceNotFoundException.class,
+ () -> store.deleteInterfaceVersion(NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void delete_the_version_document_when_the_snapshot_exists() throws Exception {
+ interfaceExists();
+ stubFind(versionCollection, List.of(Document.createDocument().put("version", "1.0.0-SNAPSHOT")));
+
+ boolean deleted = store.deleteInterfaceVersion(NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(true));
+ verify(versionCollection).remove(any(Document.class));
+ }
+
+ @Test
+ public void return_false_when_the_version_to_delete_does_not_exist() throws Exception {
+ interfaceExists();
+ stubFind(versionCollection, List.of());
+
+ boolean deleted = store.deleteInterfaceVersion(NAMESPACE, INTERFACE_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(false));
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitritePatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitritePatternStoreShould.java
index e0787b0d4..80d15947f 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitritePatternStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitritePatternStoreShould.java
@@ -460,4 +460,43 @@ public void throw_a_pattern_exception_when_deleting_a_missing_pattern() {
assertThrows(PatternNotFoundException.class, () -> store.deletePattern(NAMESPACE, PATTERN_ID));
}
+
+ // --- deletePatternVersion ---
+
+ @Test
+ public void throw_a_namespace_exception_when_deleting_a_version_in_a_missing_namespace() {
+ when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.deletePatternVersion(NAMESPACE, PATTERN_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void throw_a_pattern_exception_when_deleting_a_version_of_a_missing_pattern() {
+ patternDoesNotExist();
+
+ assertThrows(PatternNotFoundException.class,
+ () -> store.deletePatternVersion(NAMESPACE, PATTERN_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void delete_the_version_document_when_the_snapshot_exists() throws Exception {
+ patternExists();
+ stubFind(versionCollection, List.of(Document.createDocument().put("version", "1.0.0-SNAPSHOT")));
+
+ boolean deleted = store.deletePatternVersion(NAMESPACE, PATTERN_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(true));
+ verify(versionCollection).remove(any(Document.class));
+ }
+
+ @Test
+ public void return_false_when_the_version_to_delete_does_not_exist() throws Exception {
+ patternExists();
+ stubFind(versionCollection, List.of());
+
+ boolean deleted = store.deletePatternVersion(NAMESPACE, PATTERN_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(false));
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java
index 6eeb41d92..f058493f1 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java
@@ -362,4 +362,43 @@ public void throw_a_standard_exception_when_deleting_a_missing_standard() {
assertThrows(StandardNotFoundException.class, () -> store.deleteStandard(NAMESPACE, STANDARD_ID));
}
+
+ // --- deleteStandardVersion ---
+
+ @Test
+ public void throw_a_namespace_exception_when_deleting_a_version_in_a_missing_namespace() {
+ when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
+
+ assertThrows(NamespaceNotFoundException.class,
+ () -> store.deleteStandardVersion(NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void throw_a_standard_exception_when_deleting_a_version_of_a_missing_standard() {
+ standardDoesNotExist();
+
+ assertThrows(StandardNotFoundException.class,
+ () -> store.deleteStandardVersion(NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ public void delete_the_version_document_when_the_snapshot_exists() throws Exception {
+ standardExists();
+ stubFind(versionCollection, List.of(Document.createDocument().put("version", "1.0.0-SNAPSHOT")));
+
+ boolean deleted = store.deleteStandardVersion(NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(true));
+ verify(versionCollection).remove(any(Document.class));
+ }
+
+ @Test
+ public void return_false_when_the_version_to_delete_does_not_exist() throws Exception {
+ standardExists();
+ stubFind(versionCollection, List.of());
+
+ boolean deleted = store.deleteStandardVersion(NAMESPACE, STANDARD_ID, "1.0.0-SNAPSHOT");
+
+ assertThat(deleted, is(false));
+ }
}
From a086b5ae67dce93928e1da7c7cbc9aa9940d3db5 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 15:54:41 +0100
Subject: [PATCH 14/22] feat(calm-hub): allow a new resource to start at a
snapshot version
The first version of a resource may now be 1.0.0 or 1.0.0-SNAPSHOT, letting a
client iterate a document before its first publish. The rule still applies to
the release version, so 2.0.0-SNAPSHOT is rejected as a first version.
Threads the requested first version down through createResourceInStore into
the five createForNamespace store methods (Pattern, Flow, Standard and
Interface gain a version parameter; the two Architecture stores already carry
it on the Architecture object). Canonicalises the release spelling before
comparing it against "1.0.0" so a first version like 100-SNAPSHOT is accepted
rather than wrongly refused.
---
.../finos/calm/mcp/tools/InterfaceTools.java | 2 +-
.../finos/calm/mcp/tools/PatternTools.java | 2 +-
.../finos/calm/mcp/tools/StandardTools.java | 2 +-
.../finos/calm/resources/FlowResource.java | 2 +-
.../calm/resources/InterfaceResource.java | 2 +-
.../finos/calm/resources/PatternResource.java | 2 +-
.../calm/resources/StandardResource.java | 2 +-
.../services/MappingControllerService.java | 28 +++--
.../java/org/finos/calm/store/FlowStore.java | 2 +-
.../org/finos/calm/store/InterfaceStore.java | 2 +-
.../org/finos/calm/store/PatternStore.java | 2 +-
.../org/finos/calm/store/StandardStore.java | 2 +-
.../store/mongo/MongoArchitectureStore.java | 8 +-
.../calm/store/mongo/MongoFlowStore.java | 8 +-
.../calm/store/mongo/MongoInterfaceStore.java | 8 +-
.../calm/store/mongo/MongoPatternStore.java | 8 +-
.../calm/store/mongo/MongoStandardStore.java | 8 +-
.../nitrite/NitriteArchitectureStore.java | 8 +-
.../calm/store/nitrite/NitriteFlowStore.java | 8 +-
.../store/nitrite/NitriteInterfaceStore.java | 8 +-
.../store/nitrite/NitritePatternStore.java | 8 +-
.../store/nitrite/NitriteStandardStore.java | 8 +-
.../mcp/tools/TestInterfaceToolsShould.java | 4 +-
.../mcp/tools/TestPatternToolsShould.java | 4 +-
.../mcp/tools/TestStandardToolsShould.java | 4 +-
.../resources/TestFlowResourceShould.java | 12 +-
.../TestInterfaceResourceShould.java | 10 +-
.../TestMappingControllerResourceShould.java | 107 ++++++++++++++++--
...ngControllerResourceWithBaseUrlShould.java | 10 +-
.../resources/TestPatternResourceShould.java | 12 +-
.../resources/TestStandardResourceShould.java | 8 +-
.../TestMongoArchitectureStoreShould.java | 16 +++
.../store/mongo/TestMongoFlowStoreShould.java | 24 +++-
.../mongo/TestMongoInterfaceStoreShould.java | 24 +++-
.../mongo/TestMongoPatternStoreShould.java | 26 ++++-
.../mongo/TestMongoStandardStoreShould.java | 24 +++-
.../TestNitriteArchitectureStoreShould.java | 17 +++
.../nitrite/TestNitriteFlowStoreShould.java | 29 ++++-
.../TestNitriteInterfaceStoreShould.java | 25 +++-
.../TestNitritePatternStoreShould.java | 29 ++++-
.../TestNitriteStandardStoreShould.java | 25 +++-
41 files changed, 396 insertions(+), 144 deletions(-)
diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/InterfaceTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/InterfaceTools.java
index e97cf641b..d19026823 100644
--- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/InterfaceTools.java
+++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/InterfaceTools.java
@@ -130,7 +130,7 @@ public ToolResponse createInterface(
try {
CreateInterfaceRequest request = new CreateInterfaceRequest(name, description, interfaceJson);
- CalmInterface result = interfaceStore.createInterfaceForNamespace(request, namespace);
+ CalmInterface result = interfaceStore.createInterfaceForNamespace(request, namespace, "1.0.0");
logger.info("Interface created with ID [{}] in namespace [{}]", result.getId(), namespace);
return ToolResponse.success("Interface created successfully with ID: " + result.getId() + " (version " + result.getVersion() + ") in namespace '" + namespace + "'.");
} catch (NamespaceNotFoundException e) {
diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/PatternTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/PatternTools.java
index c6011ea34..47252bcbc 100644
--- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/PatternTools.java
+++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/PatternTools.java
@@ -142,7 +142,7 @@ public ToolResponse createPattern(
try {
CreatePatternRequest request = new CreatePatternRequest(name, description, patternJson);
- Pattern result = patternStore.createPatternForNamespace(request, namespace);
+ Pattern result = patternStore.createPatternForNamespace(request, namespace, "1.0.0");
logger.info("Pattern created with ID [{}] in namespace [{}]", result.getId(), namespace);
return ToolResponse.success("Pattern created successfully with ID: " + result.getId() + " (version " + result.getDotVersion() + ") in namespace '" + namespace + "'.");
} catch (NamespaceNotFoundException e) {
diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/StandardTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/StandardTools.java
index ab8c20add..3c1eec529 100644
--- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/StandardTools.java
+++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/StandardTools.java
@@ -129,7 +129,7 @@ public ToolResponse createStandard(
try {
CreateStandardRequest request = new CreateStandardRequest(name, description, standardJson);
- Standard result = standardStore.createStandardForNamespace(request, namespace);
+ Standard result = standardStore.createStandardForNamespace(request, namespace, "1.0.0");
logger.info("Standard created with ID [{}] in namespace [{}]", result.getId(), namespace);
return ToolResponse.success("Standard created successfully with ID: " + result.getId() + " (version " + result.getVersion() + ") in namespace '" + namespace + "'.");
} catch (NamespaceNotFoundException e) {
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java
index 83db1c182..417172fd4 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java
@@ -91,7 +91,7 @@ public Response createFlowForNamespace(
@Valid @NotNull(message = "Request must not be null") CreateFlowRequest flowRequest
) throws URISyntaxException {
try {
- Flow flowForNamespace = store.createFlowForNamespace(flowRequest, namespace);
+ Flow flowForNamespace = store.createFlowForNamespace(flowRequest, namespace, "1.0.0");
return flowWithLocationResponse(flowForNamespace);
} catch (NamespaceNotFoundException e) {
logger.error("Invalid namespace [{}] when creating flow", namespace, e);
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java b/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java
index 2f3c65c54..e6eb4b097 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java
@@ -73,7 +73,7 @@ public Response createInterfaceForNamespace(
@Valid @NotNull(message = "Request must not be null") CreateInterfaceRequest interfaceRequest
) throws URISyntaxException {
try {
- CalmInterface createdInterface = interfaceStore.createInterfaceForNamespace(interfaceRequest, namespace);
+ CalmInterface createdInterface = interfaceStore.createInterfaceForNamespace(interfaceRequest, namespace, "1.0.0");
return Response.created(new URI("/api/calm/namespaces/" + namespace + "/interfaces/" + createdInterface.getId() + "/versions/1.0.0")).build();
} catch (NamespaceNotFoundException e) {
logger.error("Invalid namespace [{}] when creating interface", namespace, e);
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
index c57cdf3dc..405f458d9 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
@@ -92,7 +92,7 @@ public Response createPatternForNamespace(
@Valid @NotNull(message = "Request must not be null") CreatePatternRequest patternRequest
) throws URISyntaxException {
try {
- return patternWithLocationResponse(store.createPatternForNamespace(patternRequest, namespace));
+ return patternWithLocationResponse(store.createPatternForNamespace(patternRequest, namespace, "1.0.0"));
} catch (NamespaceNotFoundException e) {
logger.error("Invalid namespace [{}] when creating pattern", namespace, e);
return CalmResourceErrorResponses.invalidNamespaceResponse(namespace);
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java
index 6e7ce2539..5d370a429 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java
@@ -68,7 +68,7 @@ public Response createStandardForNamespace(
CreateStandardRequest standard
) throws URISyntaxException {
try {
- Standard createdStandard = standardStore.createStandardForNamespace(standard, namespace);
+ Standard createdStandard = standardStore.createStandardForNamespace(standard, namespace, "1.0.0");
return Response.created(new URI("/api/calm/namespaces/" + namespace + "/standards/" + createdStandard.getId() + "/versions/1.0.0")).build();
} catch (NamespaceNotFoundException e) {
logger.error("Invalid namespace [{}] when creating standard", namespace, e);
diff --git a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
index 1ccfff374..5dec166f5 100644
--- a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
+++ b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
@@ -423,14 +423,17 @@ public int resolveConfigId(String domain, int controlId, String configName)
/**
* Creates a brand-new resource (no mapping exists yet).
- * The underlying stores always initialise the first version as {@code 1.0.0}. The first
- * version of a resource must therefore be {@code 1.0.0}; any other requested version is
- * rejected with {@code 400 Bad Request}.
+ * The first version of a resource must be {@code 1.0.0} or {@code 1.0.0-SNAPSHOT} — this
+ * is the main flow that lets a resource be iterated before its first publish. The rule is
+ * about the release version, so {@code 2.0.0-SNAPSHOT} is still rejected with
+ * {@code 400 Bad Request}.
*/
private Response createNewResource(String namespace, ResourceType resourceType, String typePath,
String name, String json, CalmDocumentParser.VersionSpec versionSpec) throws URISyntaxException {
String finalVersion = versionSpec.version() != null ? versionSpec.version() : "1.0.0";
- if (!"1.0.0".equals(finalVersion)) {
+ // The request's release spelling must be canonicalised before comparison — releaseVersion
+ // alone leaves "100-SNAPSHOT" as "100", which would never equal "1.0.0".
+ if (!"1.0.0".equals(CanonicalVersion.of(ResourceVersion.releaseVersion(finalVersion)))) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("The first version of a resource must be 1.0.0, but " + finalVersion + " was requested")
.build();
@@ -444,7 +447,7 @@ private Response createNewResource(String namespace, ResourceType resourceType,
try {
mappingStore.createMapping(namespace, name, resourceType, 0);
try {
- int numericId = createResourceInStore(resourceType, namespace, json, title, description);
+ int numericId = createResourceInStore(resourceType, namespace, json, title, description, finalVersion);
mappingStore.updateMappingNumericId(namespace, resourceType, name, numericId);
} catch (Exception e) {
try {
@@ -541,24 +544,25 @@ private Response addNewVersion(String namespace, String typePath, String name,
/**
* Creates a new resource in the type-specific store and returns the assigned numeric ID.
- * The underlying stores always initialise the first stored version as {@code 1.0.0}.
+ * {@code version} is the resource's first version — {@code 1.0.0} or
+ * {@code 1.0.0-SNAPSHOT} — already validated by {@link #createNewResource}.
*/
private int createResourceInStore(ResourceType type, String namespace, String json,
- String resourceName, String description) throws Exception {
+ String resourceName, String description, String version) throws Exception {
// The $id was already verified against the canonical URL; strip it before storage as it is
// re-derived on read and MongoDB rejects a top-level $id field (write error code 55).
json = documentParser.stripId(json);
return switch (type) {
case PATTERN -> {
CreatePatternRequest req = new CreatePatternRequest(resourceName, description, json);
- Pattern created = patternStore.createPatternForNamespace(req, namespace);
+ Pattern created = patternStore.createPatternForNamespace(req, namespace, version);
yield created.getId();
}
case ARCHITECTURE -> {
Architecture arch = new Architecture.ArchitectureBuilder()
.setNamespace(namespace)
.setArchitecture(json)
- .setVersion("1.0.0")
+ .setVersion(version)
.setName(resourceName)
.setDescription(description)
.build();
@@ -567,17 +571,17 @@ private int createResourceInStore(ResourceType type, String namespace, String js
}
case FLOW -> {
CreateFlowRequest req = new CreateFlowRequest(resourceName, description, json);
- Flow created = flowStore.createFlowForNamespace(req, namespace);
+ Flow created = flowStore.createFlowForNamespace(req, namespace, version);
yield created.getId();
}
case STANDARD -> {
CreateStandardRequest req = new CreateStandardRequest(resourceName, description, json);
- Standard created = standardStore.createStandardForNamespace(req, namespace);
+ Standard created = standardStore.createStandardForNamespace(req, namespace, version);
yield created.getId();
}
case INTERFACE -> {
CreateInterfaceRequest req = new CreateInterfaceRequest(resourceName, description, json);
- CalmInterface created = interfaceStore.createInterfaceForNamespace(req, namespace);
+ CalmInterface created = interfaceStore.createInterfaceForNamespace(req, namespace, version);
yield created.getId();
}
};
diff --git a/calm-hub/src/main/java/org/finos/calm/store/FlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/FlowStore.java
index 1a33a5b04..c452d75d4 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/FlowStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/FlowStore.java
@@ -12,7 +12,7 @@
public interface FlowStore {
List getFlowsForNamespace(String namespace) throws NamespaceNotFoundException;
- Flow createFlowForNamespace(CreateFlowRequest flowRequest, String namespace) throws NamespaceNotFoundException;
+ Flow createFlowForNamespace(CreateFlowRequest flowRequest, String namespace, String version) throws NamespaceNotFoundException;
List getFlowVersions(Flow flow) throws NamespaceNotFoundException, FlowNotFoundException;
String getFlowForVersion(Flow flow) throws NamespaceNotFoundException, FlowNotFoundException, FlowVersionNotFoundException;
Flow createFlowForVersion(Flow flow) throws NamespaceNotFoundException, FlowNotFoundException, FlowVersionExistsException;
diff --git a/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java
index c1eb4c819..63303ff43 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/InterfaceStore.java
@@ -12,7 +12,7 @@
public interface InterfaceStore {
List getInterfacesForNamespace(String namespace) throws NamespaceNotFoundException;
- CalmInterface createInterfaceForNamespace(CreateInterfaceRequest interfaceRequest, String namespace) throws NamespaceNotFoundException;
+ CalmInterface createInterfaceForNamespace(CreateInterfaceRequest interfaceRequest, String namespace, String version) throws NamespaceNotFoundException;
List getInterfaceVersions(String namespace, Integer interfaceId) throws NamespaceNotFoundException, InterfaceNotFoundException;
String getInterfaceForVersion(String namespace, Integer interfaceId, String version) throws NamespaceNotFoundException, InterfaceNotFoundException, InterfaceVersionNotFoundException;
CalmInterface createInterfaceForVersion(CreateInterfaceRequest interfaceRequest, String namespace, Integer interfaceId, String version) throws NamespaceNotFoundException, InterfaceNotFoundException, InterfaceVersionExistsException;
diff --git a/calm-hub/src/main/java/org/finos/calm/store/PatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/PatternStore.java
index 4edd843a5..42bf5d3cf 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/PatternStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/PatternStore.java
@@ -32,7 +32,7 @@ default List getPatternsForNamespace(String namespace)
*/
List getPatternsForNamespace(String namespace, PageRequest page) throws NamespaceNotFoundException;
- Pattern createPatternForNamespace(CreatePatternRequest patternRequest, String namespace) throws NamespaceNotFoundException, JsonParseException;
+ Pattern createPatternForNamespace(CreatePatternRequest patternRequest, String namespace, String version) throws NamespaceNotFoundException, JsonParseException;
List getPatternVersions(Pattern pattern) throws NamespaceNotFoundException, PatternNotFoundException;
String getPatternForVersion(Pattern pattern) throws NamespaceNotFoundException, PatternNotFoundException, PatternVersionNotFoundException;
Pattern createPatternForVersion(Pattern pattern) throws NamespaceNotFoundException, PatternNotFoundException, PatternVersionExistsException;
diff --git a/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java
index 7d1b5b246..3b265c802 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/StandardStore.java
@@ -12,7 +12,7 @@
public interface StandardStore {
List getStandardsForNamespace(String namespace) throws NamespaceNotFoundException;
- Standard createStandardForNamespace(CreateStandardRequest standardRequest, String namespace) throws NamespaceNotFoundException;
+ Standard createStandardForNamespace(CreateStandardRequest standardRequest, String namespace, String version) throws NamespaceNotFoundException;
List getStandardVersions(String namespace, Integer standardId) throws NamespaceNotFoundException, StandardNotFoundException;
String getStandardForVersion(String namespace, Integer standardId, String version) throws NamespaceNotFoundException, StandardNotFoundException, StandardVersionNotFoundException;
Standard createStandardForVersion(CreateStandardRequest standardRequest, String namespace, Integer standardId, String version) throws NamespaceNotFoundException, StandardNotFoundException, StandardVersionExistsException;
diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoArchitectureStore.java
index 42a9576bc..c44180794 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoArchitectureStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoArchitectureStore.java
@@ -80,13 +80,17 @@ public Architecture createArchitectureForNamespace(Architecture architecture) th
// JSON can't leave a header behind with no version to go with it.
Document content = Document.parse(architecture.getArchitectureJson());
+ // The version already carried on the Architecture object; callers that don't set one
+ // (the numeric-ID API, the MCP tool) still get the historical 1.0.0 default.
+ String version = architecture.getDotVersion() != null ? architecture.getDotVersion() : INITIAL_VERSION;
+
int id = counterStore.getNextArchitectureSequenceValue();
documentStore.createHeader(architecture.getNamespace(), id, architecture.getName(), architecture.getDescription());
- documentStore.createFirstVersion(architecture.getNamespace(), id, content);
+ documentStore.createFirstVersion(architecture.getNamespace(), id, version, content);
return new Architecture.ArchitectureBuilder()
.setId(id)
- .setVersion(INITIAL_VERSION)
+ .setVersion(version)
.setNamespace(architecture.getNamespace())
.setArchitecture(architecture.getArchitectureJson())
.build();
diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoFlowStore.java
index ee6a083b0..f230d16ba 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoFlowStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoFlowStore.java
@@ -19,8 +19,6 @@
import io.quarkus.arc.lookup.LookupIfProperty;
-import static org.finos.calm.store.util.MongoVersionDocumentStore.INITIAL_VERSION;
-
/**
* MongoDB-backed implementation of {@link FlowStore}.
*
@@ -68,7 +66,7 @@ public List getFlowsForNamespace(String namespace) thr
}
@Override
- public Flow createFlowForNamespace(CreateFlowRequest flowRequest, String namespace) throws NamespaceNotFoundException {
+ public Flow createFlowForNamespace(CreateFlowRequest flowRequest, String namespace, String version) throws NamespaceNotFoundException {
namespaceStore.requireNamespace(namespace);
// Parsed before the counter is drawn and before anything is written, so malformed
@@ -77,11 +75,11 @@ public Flow createFlowForNamespace(CreateFlowRequest flowRequest, String namespa
int id = counterStore.getNextFlowSequenceValue();
documentStore.createHeader(namespace, id, flowRequest.getName(), flowRequest.getDescription());
- documentStore.createFirstVersion(namespace, id, content);
+ documentStore.createFirstVersion(namespace, id, version, content);
return new Flow.FlowBuilder()
.setId(id)
- .setVersion(INITIAL_VERSION)
+ .setVersion(version)
.setNamespace(namespace)
.setFlow(flowRequest.getFlowJson())
.build();
diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java
index f5531cfdd..0b585bcae 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoInterfaceStore.java
@@ -20,8 +20,6 @@
import io.quarkus.arc.lookup.LookupIfProperty;
-import static org.finos.calm.store.util.MongoVersionDocumentStore.INITIAL_VERSION;
-
/**
* MongoDB-backed implementation of {@link InterfaceStore}.
*
@@ -74,7 +72,7 @@ private static NamespaceInterfaceSummary toInterfaceSummary(NamespaceResourceSum
}
@Override
- public CalmInterface createInterfaceForNamespace(CreateInterfaceRequest interfaceRequest, String namespace) throws NamespaceNotFoundException {
+ public CalmInterface createInterfaceForNamespace(CreateInterfaceRequest interfaceRequest, String namespace, String version) throws NamespaceNotFoundException {
CalmInterface createdInterface = new CalmInterface(interfaceRequest);
namespaceStore.requireNamespace(namespace);
@@ -82,10 +80,10 @@ public CalmInterface createInterfaceForNamespace(CreateInterfaceRequest interfac
int id = counterStore.getNextInterfaceSequenceValue();
documentStore.createHeader(namespace, id, interfaceRequest.getName(), interfaceRequest.getDescription());
- documentStore.createFirstVersion(namespace, id, content);
+ documentStore.createFirstVersion(namespace, id, version, content);
createdInterface.setId(id);
- createdInterface.setVersion(INITIAL_VERSION);
+ createdInterface.setVersion(version);
return createdInterface;
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoPatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoPatternStore.java
index 330ec6fc7..9088d1e2f 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoPatternStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoPatternStore.java
@@ -19,8 +19,6 @@
import io.quarkus.arc.lookup.LookupIfProperty;
-import static org.finos.calm.store.util.MongoVersionDocumentStore.INITIAL_VERSION;
-
/**
* MongoDB-backed implementation of {@link PatternStore}.
*
@@ -69,7 +67,7 @@ public List getPatternsForNamespace(String namespace,
}
@Override
- public Pattern createPatternForNamespace(CreatePatternRequest patternRequest, String namespace) throws NamespaceNotFoundException {
+ public Pattern createPatternForNamespace(CreatePatternRequest patternRequest, String namespace, String version) throws NamespaceNotFoundException {
namespaceStore.requireNamespace(namespace);
// Parsed before the counter is drawn and before anything is written, so malformed
@@ -78,11 +76,11 @@ public Pattern createPatternForNamespace(CreatePatternRequest patternRequest, St
int id = counterStore.getNextPatternSequenceValue();
documentStore.createHeader(namespace, id, patternRequest.getName(), patternRequest.getDescription());
- documentStore.createFirstVersion(namespace, id, content);
+ documentStore.createFirstVersion(namespace, id, version, content);
return new Pattern.PatternBuilder()
.setId(id)
- .setVersion(INITIAL_VERSION)
+ .setVersion(version)
.setNamespace(namespace)
.setPattern(patternRequest.getPatternJson())
.build();
diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java
index 9a9a76a40..212c7c2f8 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoStandardStore.java
@@ -19,8 +19,6 @@
import io.quarkus.arc.lookup.LookupIfProperty;
-import static org.finos.calm.store.util.MongoVersionDocumentStore.INITIAL_VERSION;
-
/**
* MongoDB-backed implementation of {@link StandardStore}.
*
@@ -70,18 +68,18 @@ public List getStandardsForNamespace(String namespace)
}
@Override
- public Standard createStandardForNamespace(CreateStandardRequest standardRequest, String namespace) throws NamespaceNotFoundException {
+ public Standard createStandardForNamespace(CreateStandardRequest standardRequest, String namespace, String version) throws NamespaceNotFoundException {
namespaceStore.requireNamespace(namespace);
Document content = Document.parse(standardRequest.getStandardJson());
int id = counterStore.getNextStandardSequenceValue();
documentStore.createHeader(namespace, id, standardRequest.getName(), standardRequest.getDescription());
- documentStore.createFirstVersion(namespace, id, content);
+ documentStore.createFirstVersion(namespace, id, version, content);
Standard createdStandard = new Standard(standardRequest);
createdStandard.setId(id);
- createdStandard.setVersion(INITIAL_VERSION);
+ createdStandard.setVersion(version);
return createdStandard;
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java
index 3901570e8..3c6ca5dcc 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteArchitectureStore.java
@@ -84,14 +84,18 @@ public Architecture createArchitectureForNamespace(Architecture architecture) th
namespaceStore.requireNamespace(architecture.getNamespace());
validateArchitectureJson(architecture.getArchitectureJson());
+ // The version already carried on the Architecture object; callers that don't set one
+ // (the numeric-ID API, the MCP tool) still get the historical 1.0.0 default.
+ String version = architecture.getDotVersion() != null ? architecture.getDotVersion() : INITIAL_VERSION;
+
int id = counterStore.getNextArchitectureSequenceValue();
documentStore.createHeader(architecture.getNamespace(), id, architecture.getName(), architecture.getDescription());
- documentStore.createFirstVersion(architecture.getNamespace(), id, architecture.getArchitectureJson());
+ documentStore.createFirstVersion(architecture.getNamespace(), id, version, architecture.getArchitectureJson());
LOG.info("Created architecture with ID {} for namespace '{}'", id, architecture.getNamespace());
return new Architecture.ArchitectureBuilder()
.setId(id)
- .setVersion(INITIAL_VERSION)
+ .setVersion(version)
.setNamespace(architecture.getNamespace())
.setName(architecture.getName())
.setDescription(architecture.getDescription())
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java
index 06e23b601..c44c5bd57 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteFlowStore.java
@@ -23,8 +23,6 @@
import io.quarkus.arc.lookup.LookupIfProperty;
-import static org.finos.calm.store.util.NitriteVersionDocumentStore.INITIAL_VERSION;
-
/**
* NitriteDB-backed implementation of {@link FlowStore}, used in standalone mode.
*
@@ -71,18 +69,18 @@ public List getFlowsForNamespace(String namespace) thr
}
@Override
- public Flow createFlowForNamespace(CreateFlowRequest flowRequest, String namespace) throws NamespaceNotFoundException {
+ public Flow createFlowForNamespace(CreateFlowRequest flowRequest, String namespace, String version) throws NamespaceNotFoundException {
namespaceStore.requireNamespace(namespace);
validateFlowJson(flowRequest.getFlowJson());
int id = counterStore.getNextFlowSequenceValue();
documentStore.createHeader(namespace, id, flowRequest.getName(), flowRequest.getDescription());
- documentStore.createFirstVersion(namespace, id, flowRequest.getFlowJson());
+ documentStore.createFirstVersion(namespace, id, version, flowRequest.getFlowJson());
LOG.info("Created flow with ID {} for namespace '{}'", id, namespace);
return new Flow.FlowBuilder()
.setId(id)
- .setVersion(INITIAL_VERSION)
+ .setVersion(version)
.setNamespace(namespace)
.setFlow(flowRequest.getFlowJson())
.build();
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java
index 7229fb689..b86f8d6ad 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteInterfaceStore.java
@@ -24,8 +24,6 @@
import io.quarkus.arc.lookup.LookupIfProperty;
-import static org.finos.calm.store.util.NitriteVersionDocumentStore.INITIAL_VERSION;
-
/**
* NitriteDB-backed implementation of {@link InterfaceStore}, used in standalone mode.
* Mirrors {@link org.finos.calm.store.mongo.MongoInterfaceStore}: content held as a JSON
@@ -74,18 +72,18 @@ private static NamespaceInterfaceSummary toInterfaceSummary(NamespaceResourceSum
}
@Override
- public CalmInterface createInterfaceForNamespace(CreateInterfaceRequest createInterfaceRequest, String namespace) throws NamespaceNotFoundException {
+ public CalmInterface createInterfaceForNamespace(CreateInterfaceRequest createInterfaceRequest, String namespace, String version) throws NamespaceNotFoundException {
CalmInterface createdInterface = new CalmInterface(createInterfaceRequest);
namespaceStore.requireNamespace(namespace);
validateInterfaceJson(createInterfaceRequest.getInterfaceJson());
int id = counterStore.getNextInterfaceSequenceValue();
documentStore.createHeader(namespace, id, createInterfaceRequest.getName(), createInterfaceRequest.getDescription());
- documentStore.createFirstVersion(namespace, id, createInterfaceRequest.getInterfaceJson());
+ documentStore.createFirstVersion(namespace, id, version, createInterfaceRequest.getInterfaceJson());
LOG.info("Created interface with ID {} for namespace '{}'", id, namespace);
createdInterface.setId(id);
- createdInterface.setVersion(INITIAL_VERSION);
+ createdInterface.setVersion(version);
return createdInterface;
}
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java
index 553b3d9ed..a22a06dc2 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitritePatternStore.java
@@ -23,8 +23,6 @@
import io.quarkus.arc.lookup.LookupIfProperty;
-import static org.finos.calm.store.util.NitriteVersionDocumentStore.INITIAL_VERSION;
-
/**
* NitriteDB-backed implementation of {@link PatternStore}, used in standalone mode.
*
@@ -73,13 +71,13 @@ public List getPatternsForNamespace(String namespace,
}
@Override
- public Pattern createPatternForNamespace(CreatePatternRequest patternRequest, String namespace) throws NamespaceNotFoundException, JsonParseException {
+ public Pattern createPatternForNamespace(CreatePatternRequest patternRequest, String namespace, String version) throws NamespaceNotFoundException, JsonParseException {
namespaceStore.requireNamespace(namespace);
validatePatternJson(patternRequest.getPatternJson());
int id = counterStore.getNextPatternSequenceValue();
documentStore.createHeader(namespace, id, patternRequest.getName(), patternRequest.getDescription());
- documentStore.createFirstVersion(namespace, id, patternRequest.getPatternJson());
+ documentStore.createFirstVersion(namespace, id, version, patternRequest.getPatternJson());
LOG.info("Created pattern with ID {} for namespace '{}'", id, namespace);
return new Pattern.PatternBuilder()
@@ -87,7 +85,7 @@ public Pattern createPatternForNamespace(CreatePatternRequest patternRequest, St
// Dot-separated, matching the Mongo store and what is actually stored. This
// backend used to return "1-0-0" here, so the Location header differed by
// backend for the same operation.
- .setVersion(INITIAL_VERSION)
+ .setVersion(version)
.setNamespace(namespace)
.setPattern(patternRequest.getPatternJson())
.build();
diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java
index a54a1d68f..58d326bd1 100644
--- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java
+++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteStandardStore.java
@@ -23,8 +23,6 @@
import io.quarkus.arc.lookup.LookupIfProperty;
-import static org.finos.calm.store.util.NitriteVersionDocumentStore.INITIAL_VERSION;
-
/**
* NitriteDB-backed implementation of {@link StandardStore}, used in standalone mode.
*
@@ -69,18 +67,18 @@ public List getStandardsForNamespace(String namespace)
}
@Override
- public Standard createStandardForNamespace(CreateStandardRequest createStandardRequest, String namespace) throws NamespaceNotFoundException {
+ public Standard createStandardForNamespace(CreateStandardRequest createStandardRequest, String namespace, String version) throws NamespaceNotFoundException {
Standard createdStandard = new Standard(createStandardRequest);
namespaceStore.requireNamespace(namespace);
validateStandardJson(createStandardRequest.getStandardJson());
int id = counterStore.getNextStandardSequenceValue();
documentStore.createHeader(namespace, id, createStandardRequest.getName(), createStandardRequest.getDescription());
- documentStore.createFirstVersion(namespace, id, createStandardRequest.getStandardJson());
+ documentStore.createFirstVersion(namespace, id, version, createStandardRequest.getStandardJson());
LOG.info("Created standard with ID {} for namespace '{}'", id, namespace);
createdStandard.setId(id);
- createdStandard.setVersion(INITIAL_VERSION);
+ createdStandard.setVersion(version);
return createdStandard;
}
diff --git a/calm-hub/src/test/java/org/finos/calm/mcp/tools/TestInterfaceToolsShould.java b/calm-hub/src/test/java/org/finos/calm/mcp/tools/TestInterfaceToolsShould.java
index 2d1ac8632..7522f8cb9 100644
--- a/calm-hub/src/test/java/org/finos/calm/mcp/tools/TestInterfaceToolsShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/mcp/tools/TestInterfaceToolsShould.java
@@ -220,7 +220,7 @@ void reject_non_positive_id_for_get_interface() {
@Test
void create_interface_successfully() throws NamespaceNotFoundException {
CalmInterface created = new CalmInterface("Trading API", "REST interface for trades", "{}", 7, "1.0.0");
- when(interfaceStore.createInterfaceForNamespace(any(), eq("finos")))
+ when(interfaceStore.createInterfaceForNamespace(any(), eq("finos"), anyString()))
.thenReturn(created);
ToolResponse result = interfaceTools.createInterface("finos", "Trading API", "REST interface for trades", "{}");
@@ -233,7 +233,7 @@ void create_interface_successfully() throws NamespaceNotFoundException {
@Test
void return_error_when_creating_interface_in_missing_namespace() throws NamespaceNotFoundException {
- when(interfaceStore.createInterfaceForNamespace(any(), anyString()))
+ when(interfaceStore.createInterfaceForNamespace(any(), anyString(), anyString()))
.thenThrow(new NamespaceNotFoundException());
ToolResponse result = interfaceTools.createInterface("missing", "API", "desc", "{}");
diff --git a/calm-hub/src/test/java/org/finos/calm/mcp/tools/TestPatternToolsShould.java b/calm-hub/src/test/java/org/finos/calm/mcp/tools/TestPatternToolsShould.java
index 02b2eed1d..a2456f57d 100644
--- a/calm-hub/src/test/java/org/finos/calm/mcp/tools/TestPatternToolsShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/mcp/tools/TestPatternToolsShould.java
@@ -227,7 +227,7 @@ void create_pattern_successfully() throws NamespaceNotFoundException {
.setId(42)
.setVersion("1.0.0")
.build();
- when(patternStore.createPatternForNamespace(any(), anyString())).thenReturn(returnedPattern);
+ when(patternStore.createPatternForNamespace(any(), anyString(), anyString())).thenReturn(returnedPattern);
ToolResponse result = patternTools.createPattern("workshop", "My Pattern", "A description", "{\"nodes\":[]}");
@@ -239,7 +239,7 @@ void create_pattern_successfully() throws NamespaceNotFoundException {
@Test
void return_error_when_creating_pattern_in_missing_namespace() throws NamespaceNotFoundException {
- when(patternStore.createPatternForNamespace(any(), anyString())).thenThrow(new NamespaceNotFoundException());
+ when(patternStore.createPatternForNamespace(any(), anyString(), anyString())).thenThrow(new NamespaceNotFoundException());
ToolResponse result = patternTools.createPattern("missing", "My Pattern", "desc", "{}");
diff --git a/calm-hub/src/test/java/org/finos/calm/mcp/tools/TestStandardToolsShould.java b/calm-hub/src/test/java/org/finos/calm/mcp/tools/TestStandardToolsShould.java
index 9f8454052..9677319f0 100644
--- a/calm-hub/src/test/java/org/finos/calm/mcp/tools/TestStandardToolsShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/mcp/tools/TestStandardToolsShould.java
@@ -259,7 +259,7 @@ void create_standard_successfully() throws Exception {
Standard created = new Standard(new CreateStandardRequest("My Standard", "A description", "{}"));
created.setId(42);
created.setVersion("1.0.0");
- when(standardStore.createStandardForNamespace(any(CreateStandardRequest.class), eq("finos")))
+ when(standardStore.createStandardForNamespace(any(CreateStandardRequest.class), eq("finos"), anyString()))
.thenReturn(created);
ToolResponse result = standardTools.createStandard("finos", "My Standard", "A description", "{}");
@@ -271,7 +271,7 @@ void create_standard_successfully() throws Exception {
@Test
void return_error_when_namespace_not_found_for_create_standard() throws Exception {
- when(standardStore.createStandardForNamespace(any(), anyString()))
+ when(standardStore.createStandardForNamespace(any(), anyString(), anyString()))
.thenThrow(new NamespaceNotFoundException());
ToolResponse result = standardTools.createStandard("missing", "My Standard", "A description", "{}");
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java
index 5dccfe4ab..25cbcb96c 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java
@@ -143,7 +143,7 @@ void return_404_with_invalid_flow_response_when_flow_not_found() throws Namespac
@Test
void return_a_404_when_invalid_namespace_is_provided_on_create_flow() throws NamespaceNotFoundException {
- when(mockFlowStore.createFlowForNamespace(any(CreateFlowRequest.class), anyString()))
+ when(mockFlowStore.createFlowForNamespace(any(CreateFlowRequest.class), anyString(), anyString()))
.thenThrow(new NamespaceNotFoundException());
String requestBody = "{ \"name\": \"Test\", \"description\": \"desc\", \"flowJson\": \"{ \\\"test\\\": \\\"json\\\" }\" }";
@@ -156,12 +156,12 @@ void return_a_404_when_invalid_namespace_is_provided_on_create_flow() throws Nam
.then()
.statusCode(404);
- verify(mockFlowStore, times(1)).createFlowForNamespace(any(CreateFlowRequest.class), eq("invalid"));
+ verify(mockFlowStore, times(1)).createFlowForNamespace(any(CreateFlowRequest.class), eq("invalid"), eq("1.0.0"));
}
@Test
void return_a_400_when_invalid_flow_json_is_provided_on_create_flow() throws NamespaceNotFoundException {
- when(mockFlowStore.createFlowForNamespace(any(CreateFlowRequest.class), anyString()))
+ when(mockFlowStore.createFlowForNamespace(any(CreateFlowRequest.class), anyString(), anyString()))
.thenThrow(new JsonParseException());
String requestBody = "{ \"name\": \"Test\", \"description\": \"desc\", \"flowJson\": \"invalid json\" }";
@@ -174,7 +174,7 @@ void return_a_400_when_invalid_flow_json_is_provided_on_create_flow() throws Nam
.then()
.statusCode(400);
- verify(mockFlowStore, times(1)).createFlowForNamespace(any(CreateFlowRequest.class), eq("invalid"));
+ verify(mockFlowStore, times(1)).createFlowForNamespace(any(CreateFlowRequest.class), eq("invalid"), eq("1.0.0"));
}
@Test
@@ -189,7 +189,7 @@ void return_a_created_with_location_of_flow_when_creating_flow() throws Namespac
.setNamespace(namespace)
.build();
- when(mockFlowStore.createFlowForNamespace(any(CreateFlowRequest.class), eq(namespace))).thenReturn(stubbedReturnFlow);
+ when(mockFlowStore.createFlowForNamespace(any(CreateFlowRequest.class), eq(namespace), eq("1.0.0"))).thenReturn(stubbedReturnFlow);
String requestBody = "{ \"name\": \"Test\", \"description\": \"desc\", \"flowJson\": \"{ \\\"test\\\": \\\"json\\\" }\" }";
@@ -202,7 +202,7 @@ void return_a_created_with_location_of_flow_when_creating_flow() throws Namespac
.statusCode(201)
.header("Location", containsString("/api/calm/namespaces/valid/flows/12/versions/1.0.0"));
- verify(mockFlowStore, times(1)).createFlowForNamespace(any(CreateFlowRequest.class), eq(namespace));
+ verify(mockFlowStore, times(1)).createFlowForNamespace(any(CreateFlowRequest.class), eq(namespace), eq("1.0.0"));
}
static Stream provideParametersForFlowVersionTests() {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java
index 132f20537..68aeb111f 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java
@@ -127,7 +127,7 @@ void include_the_custom_id_on_interface_summaries_that_have_a_mapping() throws E
@Test
void return_a_404_when_namespace_is_provided_that_does_not_exist_on_create_interfaces() throws NamespaceNotFoundException, JsonProcessingException {
- when(mockInterfaceStore.createInterfaceForNamespace(any(CreateInterfaceRequest.class), eq("invalid"))).thenThrow(new NamespaceNotFoundException());
+ when(mockInterfaceStore.createInterfaceForNamespace(any(CreateInterfaceRequest.class), eq("invalid"), eq("1.0.0"))).thenThrow(new NamespaceNotFoundException());
CreateInterfaceRequest createInterfaceRequest = new CreateInterfaceRequest();
createInterfaceRequest.setName("tcp-port");
createInterfaceRequest.setDescription("TCP Port Interface");
@@ -141,7 +141,7 @@ void return_a_404_when_namespace_is_provided_that_does_not_exist_on_create_inter
.then()
.statusCode(404);
- verify(mockInterfaceStore).createInterfaceForNamespace(createInterfaceRequest, "invalid");
+ verify(mockInterfaceStore).createInterfaceForNamespace(createInterfaceRequest, "invalid", "1.0.0");
}
@Test
@@ -168,7 +168,7 @@ void return_a_created_status_code_with_location_of_interface_when_creating_an_in
createInterfaceRequest.setName("tcp-port");
createInterfaceRequest.setDescription("TCP Port Interface");
createInterfaceRequest.setInterfaceJson("{ \"test\": \"json\" }");
- when(mockInterfaceStore.createInterfaceForNamespace(createInterfaceRequest, "valid")).thenReturn(storedInterface);
+ when(mockInterfaceStore.createInterfaceForNamespace(createInterfaceRequest, "valid", "1.0.0")).thenReturn(storedInterface);
given()
.header("Content-Type", "application/json")
@@ -179,12 +179,12 @@ void return_a_created_status_code_with_location_of_interface_when_creating_an_in
.statusCode(201)
.header("Location", containsString("/api/calm/namespaces/valid/interfaces/5/versions/1.0.0"));
- verify(mockInterfaceStore).createInterfaceForNamespace(createInterfaceRequest, "valid");
+ verify(mockInterfaceStore).createInterfaceForNamespace(createInterfaceRequest, "valid", "1.0.0");
}
@Test
void return_a_400_when_invalid_json_is_provided_on_create_interface() throws NamespaceNotFoundException, JsonProcessingException {
- when(mockInterfaceStore.createInterfaceForNamespace(any(CreateInterfaceRequest.class), eq("valid")))
+ when(mockInterfaceStore.createInterfaceForNamespace(any(CreateInterfaceRequest.class), eq("valid"), eq("1.0.0")))
.thenThrow(new JsonParseException());
CreateInterfaceRequest createInterfaceRequest = new CreateInterfaceRequest();
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
index b52bb8ba8..c166a890d 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
@@ -16,6 +16,7 @@
import org.finos.calm.store.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -25,6 +26,7 @@
import java.util.List;
import static io.restassured.RestAssured.given;
+import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -75,7 +77,7 @@ void return_201_when_creating_a_new_pattern_resource() throws Exception {
.setResourceType(ResourceType.PATTERN).setNumericId(0).build());
Pattern pattern = new Pattern.PatternBuilder()
.setNamespace("finos").setId(1).setVersion("1.0.0").setPattern("{}").build();
- when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), eq("finos"))).thenReturn(pattern);
+ when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), eq("finos"), eq("1.0.0"))).thenReturn(pattern);
given().header("Content-Type", "application/json").body(versionedDoc("finos", "patterns", "api-gateway", "1.0.0")).when()
.post("/calm")
@@ -113,7 +115,7 @@ void return_201_when_creating_a_new_flow_resource() throws Exception {
.setResourceType(ResourceType.FLOW).setNumericId(0).build());
Flow flow = new Flow.FlowBuilder()
.setNamespace("finos").setId(5).setVersion("1.0.0").setFlow("{}").build();
- when(mockFlowStore.createFlowForNamespace(any(CreateFlowRequest.class), eq("finos"))).thenReturn(flow);
+ when(mockFlowStore.createFlowForNamespace(any(CreateFlowRequest.class), eq("finos"), eq("1.0.0"))).thenReturn(flow);
given().header("Content-Type", "application/json").body(versionedDoc("finos", "flows", "my-flow", "1.0.0")).when()
.post("/calm")
@@ -132,7 +134,7 @@ void return_201_when_creating_a_new_standard_resource() throws Exception {
.setResourceType(ResourceType.STANDARD).setNumericId(0).build());
Standard standard = new Standard("", "", "{}", 3, "1.0.0");
standard.setNamespace("finos");
- when(mockStandardStore.createStandardForNamespace(any(CreateStandardRequest.class), eq("finos"))).thenReturn(standard);
+ when(mockStandardStore.createStandardForNamespace(any(CreateStandardRequest.class), eq("finos"), eq("1.0.0"))).thenReturn(standard);
given().header("Content-Type", "application/json").body(versionedDoc("finos", "standards", "my-standard", "1.0.0")).when()
.post("/calm")
@@ -151,7 +153,7 @@ void return_201_when_creating_a_new_interface_resource() throws Exception {
.setResourceType(ResourceType.INTERFACE).setNumericId(0).build());
CalmInterface iface = new CalmInterface("", "", "{}", 4, "1.0.0");
iface.setNamespace("finos");
- when(mockInterfaceStore.createInterfaceForNamespace(any(CreateInterfaceRequest.class), eq("finos"))).thenReturn(iface);
+ when(mockInterfaceStore.createInterfaceForNamespace(any(CreateInterfaceRequest.class), eq("finos"), eq("1.0.0"))).thenReturn(iface);
given().header("Content-Type", "application/json").body(versionedDoc("finos", "interfaces", "my-interface", "1.0.0")).when()
.post("/calm")
@@ -210,7 +212,7 @@ void rollback_mapping_when_store_creation_fails() throws Exception {
.thenReturn(new ResourceMapping.ResourceMappingBuilder()
.setNamespace("finos").setCustomId("fail-create")
.setResourceType(ResourceType.PATTERN).setNumericId(0).build());
- when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), eq("finos")))
+ when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), eq("finos"), eq("1.0.0")))
.thenThrow(new RuntimeException("Store failure"));
given().header("Content-Type", "application/json").body(versionedDoc("finos", "patterns", "fail-create", "1.0.0")).when()
@@ -227,7 +229,7 @@ void rollback_mapping_even_when_rollback_itself_fails() throws Exception {
.thenReturn(new ResourceMapping.ResourceMappingBuilder()
.setNamespace("finos").setCustomId("rollback-me")
.setResourceType(ResourceType.PATTERN).setNumericId(0).build());
- when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), eq("finos")))
+ when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), eq("finos"), eq("1.0.0")))
.thenThrow(new RuntimeException("store failure"));
doThrow(new RuntimeException("rollback failed")).when(mockMappingStore).deleteMapping("finos", ResourceType.PATTERN, "rollback-me");
@@ -518,7 +520,7 @@ void return_201_when_first_create_uses_versioned_id_of_1_0_0() throws Exception
.setResourceType(ResourceType.PATTERN).setNumericId(7).build());
Pattern pattern = new Pattern.PatternBuilder()
.setNamespace("finos").setId(7).setVersion("1.0.0").setPattern("{}").build();
- when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), eq("finos"))).thenReturn(pattern);
+ when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), eq("finos"), eq("1.0.0"))).thenReturn(pattern);
given().header("Content-Type", "application/json").body(versionedDoc("finos", "patterns", "seed-one", "1.0.0")).when()
.post("/calm")
@@ -554,7 +556,7 @@ void return_201_when_creating_specific_version_on_new_resource() throws Exceptio
.setResourceType(ResourceType.PATTERN).setNumericId(8).build());
Pattern pattern = new Pattern.PatternBuilder()
.setNamespace("finos").setId(8).setVersion("1.0.0").setPattern("{}").build();
- when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), eq("finos"))).thenReturn(pattern);
+ when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), eq("finos"), eq("1.0.0"))).thenReturn(pattern);
given().header("Content-Type", "application/json").body(versionedDoc("finos", "patterns", "v-new", "1.0.0")).when()
.post("/calm/namespaces/finos/patterns/v-new/versions/1.0.0")
@@ -1735,4 +1737,93 @@ void delete_the_snapshot_when_publishing_an_interface_release() throws Exception
verify(mockInterfaceStore).deleteInterfaceVersion("finos", 64, "1.0.0-SNAPSHOT");
}
+
+ // --- A new resource may start at a snapshot ---
+
+ @Test
+ void create_a_brand_new_resource_at_a_snapshot_version() throws Exception {
+ // Iterating before the first publish is the main flow the feature exists for. The
+ // "first version must be 1.0.0" rule is about the release version.
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "brand-new")).thenThrow(new MappingNotFoundException());
+ when(mockMappingStore.createMapping(eq("finos"), eq("brand-new"), eq(ResourceType.ARCHITECTURE), eq(0)))
+ .thenReturn(new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("brand-new")
+ .setResourceType(ResourceType.ARCHITECTURE).setNumericId(0).build());
+ Architecture arch = new Architecture.ArchitectureBuilder()
+ .setNamespace("finos").setId(70).setVersion("1.0.0-SNAPSHOT").setArchitecture("{}").build();
+ when(mockArchitectureStore.createArchitectureForNamespace(any(Architecture.class))).thenReturn(arch);
+
+ given()
+ .contentType("application/json")
+ .body(architectureBody("brand-new", "1.0.0-SNAPSHOT"))
+ .when()
+ .post("/calm/namespaces/finos/architectures/brand-new/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(201)
+ .header("Location", containsString("/versions/1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void refuse_a_brand_new_resource_at_a_later_snapshot_version() throws Exception {
+ // The release-version rule still applies: 2.0.0-SNAPSHOT is not a first version.
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "brand-new")).thenThrow(new MappingNotFoundException());
+
+ given()
+ .contentType("application/json")
+ .body(architectureBody("brand-new", "2.0.0-SNAPSHOT"))
+ .when()
+ .post("/calm/namespaces/finos/architectures/brand-new/versions/2.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString("first version of a resource must be 1.0.0"));
+ }
+
+ @Test
+ void accept_a_non_canonically_spelled_first_snapshot() throws Exception {
+ // VERSION_REGEX accepts several spellings of one version ("100" == "1.0.0"), and the
+ // guard must canonicalise the release spelling before comparing it against "1.0.0" —
+ // releaseVersion alone leaves "100-SNAPSHOT" as "100", which would be wrongly refused.
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "brand-new-2")).thenThrow(new MappingNotFoundException());
+ when(mockMappingStore.createMapping(eq("finos"), eq("brand-new-2"), eq(ResourceType.ARCHITECTURE), eq(0)))
+ .thenReturn(new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("brand-new-2")
+ .setResourceType(ResourceType.ARCHITECTURE).setNumericId(0).build());
+ Architecture arch = new Architecture.ArchitectureBuilder()
+ .setNamespace("finos").setId(71).setVersion("100-SNAPSHOT").setArchitecture("{}").build();
+ when(mockArchitectureStore.createArchitectureForNamespace(any(Architecture.class))).thenReturn(arch);
+
+ given()
+ .contentType("application/json")
+ .body(architectureBody("brand-new-2", "100-SNAPSHOT"))
+ .when()
+ .post("/calm/namespaces/finos/architectures/brand-new-2/versions/100-SNAPSHOT")
+ .then()
+ .statusCode(201);
+ }
+
+ @Test
+ void thread_the_requested_snapshot_version_into_the_architecture_passed_to_the_store() throws Exception {
+ // The stores no longer always initialise the first version as 1.0.0 — the requested
+ // version must actually reach the store, not a hardcoded literal.
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "brand-new-3")).thenThrow(new MappingNotFoundException());
+ when(mockMappingStore.createMapping(eq("finos"), eq("brand-new-3"), eq(ResourceType.ARCHITECTURE), eq(0)))
+ .thenReturn(new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("brand-new-3")
+ .setResourceType(ResourceType.ARCHITECTURE).setNumericId(0).build());
+ Architecture arch = new Architecture.ArchitectureBuilder()
+ .setNamespace("finos").setId(72).setVersion("1.0.0-SNAPSHOT").setArchitecture("{}").build();
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Architecture.class);
+ when(mockArchitectureStore.createArchitectureForNamespace(captor.capture())).thenReturn(arch);
+
+ given()
+ .contentType("application/json")
+ .body(architectureBody("brand-new-3", "1.0.0-SNAPSHOT"))
+ .when()
+ .post("/calm/namespaces/finos/architectures/brand-new-3/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(201);
+
+ assertThat("the requested version must reach the store, not a hardcoded 1.0.0",
+ captor.getValue().getDotVersion(), is("1.0.0-SNAPSHOT"));
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceWithBaseUrlShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceWithBaseUrlShould.java
index 7f0ec9c22..70bc44e42 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceWithBaseUrlShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceWithBaseUrlShould.java
@@ -96,7 +96,7 @@ void return_201_when_post_to_calm_with_versioned_id_matching_canonical_url() thr
.setResourceType(ResourceType.PATTERN).setNumericId(0).build());
Pattern pattern = new Pattern.PatternBuilder()
.setNamespace("finos").setId(1).setVersion("1.0.0").setPattern("{}").build();
- when(mockPatternStore.createPatternForNamespace(any(), eq("finos"))).thenReturn(pattern);
+ when(mockPatternStore.createPatternForNamespace(any(), eq("finos"), eq("1.0.0"))).thenReturn(pattern);
String body = "{ \"$id\": \"https://hub.example.com/calm/namespaces/finos/patterns/api-gateway/versions/1.0.0\","
+ " \"title\": \"API Gateway Pattern\" }";
@@ -171,7 +171,7 @@ void return_201_when_post_with_versioned_id_of_1_0_0_on_new_resource() throws Ex
.setResourceType(ResourceType.PATTERN).setNumericId(0).build());
Pattern pattern = new Pattern.PatternBuilder()
.setNamespace("finos").setId(1).setVersion("1.0.0").setPattern("{}").build();
- when(mockPatternStore.createPatternForNamespace(any(), eq("finos"))).thenReturn(pattern);
+ when(mockPatternStore.createPatternForNamespace(any(), eq("finos"), eq("1.0.0"))).thenReturn(pattern);
String body = "{ \"$id\": \"https://hub.example.com/calm/namespaces/finos/patterns/api-gateway/versions/1.0.0\","
+ " \"title\": \"API Gateway Pattern\" }";
@@ -198,7 +198,7 @@ void return_201_when_specific_version_endpoint_creates_1_0_0_on_new_resource() t
.setResourceType(ResourceType.PATTERN).setNumericId(0).build());
Pattern pattern = new Pattern.PatternBuilder()
.setNamespace("finos").setId(1).setVersion("1.0.0").setPattern("{}").build();
- when(mockPatternStore.createPatternForNamespace(any(), eq("finos"))).thenReturn(pattern);
+ when(mockPatternStore.createPatternForNamespace(any(), eq("finos"), eq("1.0.0"))).thenReturn(pattern);
String body = "{ \"$id\": \"https://hub.example.com/calm/namespaces/finos/patterns/api-gateway/versions/1.0.0\","
+ " \"title\": \"API Gateway Pattern\" }";
@@ -316,7 +316,7 @@ void post_strips_id_but_preserves_document_content() throws Exception {
Pattern pattern = new Pattern.PatternBuilder()
.setNamespace("finos").setId(1).setVersion("1.0.0").setPattern("{}").build();
ArgumentCaptor captor = ArgumentCaptor.forClass(CreatePatternRequest.class);
- when(mockPatternStore.createPatternForNamespace(captor.capture(), eq("finos"))).thenReturn(pattern);
+ when(mockPatternStore.createPatternForNamespace(captor.capture(), eq("finos"), eq("1.0.0"))).thenReturn(pattern);
String body = "{ \"$id\": \"https://hub.example.com/calm/namespaces/finos/patterns/api-gateway/versions/1.0.0\","
+ " \"title\": \"API Gateway\", \"name\": \"my-pattern\" }";
@@ -329,7 +329,7 @@ void post_strips_id_but_preserves_document_content() throws Exception {
.then()
.statusCode(201);
- verify(mockPatternStore).createPatternForNamespace(any(), eq("finos"));
+ verify(mockPatternStore).createPatternForNamespace(any(), eq("finos"), eq("1.0.0"));
assertThat("store does not receive the $id field (stripped before persistence)",
captor.getValue().getPatternJson(), not(containsString("$id")));
assertThat("store receives the full document content",
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java
index d37d07dff..78adbe57d 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java
@@ -177,7 +177,7 @@ void return_a_400_when_offset_is_negative_on_get_patterns() {
@Test
void return_a_404_when_invalid_namespace_is_provided_on_create_pattern() throws NamespaceNotFoundException {
- when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), anyString()))
+ when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), anyString(), anyString()))
.thenThrow(new NamespaceNotFoundException());
String requestBody = "{ \"name\": \"Test\", \"description\": \"desc\", \"patternJson\": \"{ \\\"test\\\": \\\"json\\\" }\" }";
@@ -190,12 +190,12 @@ void return_a_404_when_invalid_namespace_is_provided_on_create_pattern() throws
.then()
.statusCode(404);
- verify(mockPatternStore, times(1)).createPatternForNamespace(any(CreatePatternRequest.class), eq("invalid"));
+ verify(mockPatternStore, times(1)).createPatternForNamespace(any(CreatePatternRequest.class), eq("invalid"), eq("1.0.0"));
}
@Test
void return_a_400_when_invalid_pattern_json_is_provided_on_create_pattern() throws NamespaceNotFoundException {
- when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), anyString()))
+ when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), anyString(), anyString()))
.thenThrow(new JsonParseException());
String requestBody = "{ \"name\": \"Test\", \"description\": \"desc\", \"patternJson\": \"invalid json\" }";
@@ -208,7 +208,7 @@ void return_a_400_when_invalid_pattern_json_is_provided_on_create_pattern() thro
.then()
.statusCode(400);
- verify(mockPatternStore, times(1)).createPatternForNamespace(any(CreatePatternRequest.class), eq("invalid"));
+ verify(mockPatternStore, times(1)).createPatternForNamespace(any(CreatePatternRequest.class), eq("invalid"), eq("1.0.0"));
}
@Test
@@ -238,7 +238,7 @@ void return_a_created_with_location_of_pattern_when_creating_pattern() throws Na
.setNamespace(namespace)
.build();
- when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), eq(namespace))).thenReturn(stubbedReturnPattern);
+ when(mockPatternStore.createPatternForNamespace(any(CreatePatternRequest.class), eq(namespace), eq("1.0.0"))).thenReturn(stubbedReturnPattern);
String requestBody = "{ \"name\": \"Test\", \"description\": \"desc\", \"patternJson\": \"{ \\\"test\\\": \\\"json\\\" }\" }";
@@ -252,7 +252,7 @@ void return_a_created_with_location_of_pattern_when_creating_pattern() throws Na
//Derived from stubbed pattern in resource
.header("Location", containsString("/api/calm/namespaces/finos/patterns/12/versions/1.0.0"));
- verify(mockPatternStore, times(1)).createPatternForNamespace(any(CreatePatternRequest.class), eq(namespace));
+ verify(mockPatternStore, times(1)).createPatternForNamespace(any(CreatePatternRequest.class), eq(namespace), eq("1.0.0"));
}
@Test
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java
index 3ec6da70f..5d72e0e76 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java
@@ -103,7 +103,7 @@ void return_list_of_standards_response_when_valid_namespace_provided_on_get_stan
@Test
void return_a_404_when_namespace_is_provided_that_does_not_exist_on_create_standards() throws NamespaceNotFoundException, JsonProcessingException {
- when(mockStandardStore.createStandardForNamespace(any(CreateStandardRequest.class), eq("invalid"))).thenThrow(new NamespaceNotFoundException());
+ when(mockStandardStore.createStandardForNamespace(any(CreateStandardRequest.class), eq("invalid"), eq("1.0.0"))).thenThrow(new NamespaceNotFoundException());
CreateStandardRequest createStandardRequest = new CreateStandardRequest();
createStandardRequest.setName("nist");
createStandardRequest.setDescription("NIST Standard");
@@ -117,7 +117,7 @@ void return_a_404_when_namespace_is_provided_that_does_not_exist_on_create_stand
.then()
.statusCode(404);
- verify(mockStandardStore).createStandardForNamespace(createStandardRequest, "invalid");
+ verify(mockStandardStore).createStandardForNamespace(createStandardRequest, "invalid", "1.0.0");
}
@Test
@@ -144,7 +144,7 @@ void return_a_created_status_code_with_location_of_standard_when_creating_a_stan
createStandardRequest.setName("nist");
createStandardRequest.setDescription("NIST Standard");
createStandardRequest.setStandardJson("{ \"test\": \"json\" }");
- when(mockStandardStore.createStandardForNamespace(createStandardRequest, "valid")).thenReturn(storedNist);
+ when(mockStandardStore.createStandardForNamespace(createStandardRequest, "valid", "1.0.0")).thenReturn(storedNist);
given()
.header("Content-Type", "application/json")
@@ -155,7 +155,7 @@ void return_a_created_status_code_with_location_of_standard_when_creating_a_stan
.statusCode(201)
.header("Location", containsString(("/api/calm/namespaces/valid/standards/5/versions/1.0.0")));
- verify(mockStandardStore).createStandardForNamespace(createStandardRequest, "valid");
+ verify(mockStandardStore).createStandardForNamespace(createStandardRequest, "valid", "1.0.0");
}
@Test
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoArchitectureStoreShould.java
index 609136c3a..c663dab66 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoArchitectureStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoArchitectureStoreShould.java
@@ -216,6 +216,22 @@ void create_a_header_and_an_initial_version() throws NamespaceNotFoundException
assertThat(versionCaptor.getValue().getString("version"), is("1.0.0"));
}
+ @Test
+ void thread_the_requested_first_version_through_to_the_stored_version() throws NamespaceNotFoundException {
+ // A brand-new resource may start at a snapshot rather than always 1.0.0.
+ when(counterStore.getNextArchitectureSequenceValue()).thenReturn(99);
+ when(headerCollection.updateOne(any(Bson.class), any(Bson.class)))
+ .thenReturn(UpdateResult.acknowledged(1, 1L, null));
+
+ Architecture created = store.createArchitectureForNamespace(architecture("1.0.0-SNAPSHOT"));
+
+ assertThat(created.getDotVersion(), is("1.0.0-SNAPSHOT"));
+
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(Document.class);
+ verify(versionCollection).insertOne(versionCaptor.capture());
+ assertThat(versionCaptor.getValue().getString("version"), is("1.0.0-SNAPSHOT"));
+ }
+
@Test
void remove_the_header_again_when_the_first_version_write_fails() {
when(counterStore.getNextArchitectureSequenceValue()).thenReturn(99);
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoFlowStoreShould.java
index 9d7628074..b1a61acc4 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoFlowStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoFlowStoreShould.java
@@ -186,14 +186,14 @@ void throw_a_namespace_exception_when_creating_a_flow_in_a_missing_namespace() {
when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
assertThrows(NamespaceNotFoundException.class,
- () -> store.createFlowForNamespace(createRequest(), NAMESPACE));
+ () -> store.createFlowForNamespace(createRequest(), NAMESPACE, "1.0.0"));
}
@Test
void reject_invalid_json_before_drawing_an_id_or_writing_anything() {
CreateFlowRequest invalid = new CreateFlowRequest("n", "d", "{invalid json}");
- assertThrows(JsonParseException.class, () -> store.createFlowForNamespace(invalid, NAMESPACE));
+ assertThrows(JsonParseException.class, () -> store.createFlowForNamespace(invalid, NAMESPACE, "1.0.0"));
verify(counterStore, never()).getNextFlowSequenceValue();
verify(headerCollection, never()).insertOne(any(Document.class));
@@ -205,7 +205,7 @@ void create_a_header_and_an_initial_version() throws NamespaceNotFoundException
when(headerCollection.updateOne(any(Bson.class), any(Bson.class)))
.thenReturn(UpdateResult.acknowledged(1, 1L, null));
- Flow created = store.createFlowForNamespace(createRequest(), NAMESPACE);
+ Flow created = store.createFlowForNamespace(createRequest(), NAMESPACE, "1.0.0");
assertThat(created.getId(), is(99));
// Dot-separated on both backends now; Nitrite used to return "1-0-0" here.
@@ -221,6 +221,22 @@ void create_a_header_and_an_initial_version() throws NamespaceNotFoundException
assertThat(versionCaptor.getValue().getString("version"), is("1.0.0"));
}
+ @Test
+ void thread_the_requested_first_version_through_to_the_stored_version() throws NamespaceNotFoundException {
+ // A brand-new resource may start at a snapshot rather than always 1.0.0.
+ when(counterStore.getNextFlowSequenceValue()).thenReturn(99);
+ when(headerCollection.updateOne(any(Bson.class), any(Bson.class)))
+ .thenReturn(UpdateResult.acknowledged(1, 1L, null));
+
+ Flow created = store.createFlowForNamespace(createRequest(), NAMESPACE, "1.0.0-SNAPSHOT");
+
+ assertThat(created.getDotVersion(), is("1.0.0-SNAPSHOT"));
+
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(Document.class);
+ verify(versionCollection).insertOne(versionCaptor.capture());
+ assertThat(versionCaptor.getValue().getString("version"), is("1.0.0-SNAPSHOT"));
+ }
+
@Test
void remove_the_header_again_when_the_first_version_write_fails() {
when(counterStore.getNextFlowSequenceValue()).thenReturn(99);
@@ -229,7 +245,7 @@ void remove_the_header_again_when_the_first_version_write_fails() {
}).when(versionCollection).insertOne(any(Document.class));
assertThrows(StorageWriteException.class,
- () -> store.createFlowForNamespace(createRequest(), NAMESPACE));
+ () -> store.createFlowForNamespace(createRequest(), NAMESPACE, "1.0.0"));
verify(headerCollection).deleteOne(any(Bson.class));
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java
index c72a68a56..cee1b8030 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoInterfaceStoreShould.java
@@ -160,14 +160,14 @@ void throw_a_namespace_exception_when_creating_a_interface_in_a_missing_namespac
when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
assertThrows(NamespaceNotFoundException.class,
- () -> store.createInterfaceForNamespace(createRequest(), NAMESPACE));
+ () -> store.createInterfaceForNamespace(createRequest(), NAMESPACE, "1.0.0"));
}
@Test
void reject_invalid_json_before_drawing_an_id_or_writing_anything() {
CreateInterfaceRequest invalid = new CreateInterfaceRequest("n", "d", "{invalid json}");
- assertThrows(JsonParseException.class, () -> store.createInterfaceForNamespace(invalid, NAMESPACE));
+ assertThrows(JsonParseException.class, () -> store.createInterfaceForNamespace(invalid, NAMESPACE, "1.0.0"));
verify(counterStore, never()).getNextInterfaceSequenceValue();
verify(headerCollection, never()).insertOne(any(Document.class));
@@ -179,7 +179,7 @@ void create_a_header_and_an_initial_version() throws NamespaceNotFoundException
when(headerCollection.updateOne(any(Bson.class), any(Bson.class)))
.thenReturn(UpdateResult.acknowledged(1, 1L, null));
- CalmInterface created = store.createInterfaceForNamespace(createRequest(), NAMESPACE);
+ CalmInterface created = store.createInterfaceForNamespace(createRequest(), NAMESPACE, "1.0.0");
assertThat(created.getId(), is(99));
assertThat(created.getVersion(), is("1.0.0"));
@@ -189,6 +189,22 @@ void create_a_header_and_an_initial_version() throws NamespaceNotFoundException
assertThat(versionCaptor.getValue().getString("version"), is("1.0.0"));
}
+ @Test
+ void thread_the_requested_first_version_through_to_the_stored_version() throws NamespaceNotFoundException {
+ // A brand-new resource may start at a snapshot rather than always 1.0.0.
+ when(counterStore.getNextInterfaceSequenceValue()).thenReturn(99);
+ when(headerCollection.updateOne(any(Bson.class), any(Bson.class)))
+ .thenReturn(UpdateResult.acknowledged(1, 1L, null));
+
+ CalmInterface created = store.createInterfaceForNamespace(createRequest(), NAMESPACE, "1.0.0-SNAPSHOT");
+
+ assertThat(created.getVersion(), is("1.0.0-SNAPSHOT"));
+
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(Document.class);
+ verify(versionCollection).insertOne(versionCaptor.capture());
+ assertThat(versionCaptor.getValue().getString("version"), is("1.0.0-SNAPSHOT"));
+ }
+
@Test
void remove_the_header_again_when_the_first_version_write_fails() {
when(counterStore.getNextInterfaceSequenceValue()).thenReturn(99);
@@ -197,7 +213,7 @@ void remove_the_header_again_when_the_first_version_write_fails() {
}).when(versionCollection).insertOne(any(Document.class));
assertThrows(StorageWriteException.class,
- () -> store.createInterfaceForNamespace(createRequest(), NAMESPACE));
+ () -> store.createInterfaceForNamespace(createRequest(), NAMESPACE, "1.0.0"));
verify(headerCollection).deleteOne(any(Bson.class));
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoPatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoPatternStoreShould.java
index c2440c773..c773be5d2 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoPatternStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoPatternStoreShould.java
@@ -263,14 +263,14 @@ void throw_a_namespace_exception_when_creating_a_pattern_in_a_missing_namespace(
when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
assertThrows(NamespaceNotFoundException.class,
- () -> store.createPatternForNamespace(createRequest(), NAMESPACE));
+ () -> store.createPatternForNamespace(createRequest(), NAMESPACE, "1.0.0"));
}
@Test
void reject_invalid_json_before_drawing_an_id_or_writing_anything() {
CreatePatternRequest invalid = new CreatePatternRequest("n", "d", "{invalid json}");
- assertThrows(JsonParseException.class, () -> store.createPatternForNamespace(invalid, NAMESPACE));
+ assertThrows(JsonParseException.class, () -> store.createPatternForNamespace(invalid, NAMESPACE, "1.0.0"));
verify(counterStore, never()).getNextPatternSequenceValue();
verify(headerCollection, never()).insertOne(any(Document.class));
@@ -282,7 +282,7 @@ void create_a_header_and_an_initial_version() throws NamespaceNotFoundException
when(headerCollection.updateOne(any(Bson.class), any(Bson.class)))
.thenReturn(UpdateResult.acknowledged(1, 1L, null));
- Pattern created = store.createPatternForNamespace(createRequest(), NAMESPACE);
+ Pattern created = store.createPatternForNamespace(createRequest(), NAMESPACE, "1.0.0");
assertThat(created.getId(), is(99));
// Dot-separated on both backends now; Nitrite used to return "1-0-0" here.
@@ -298,6 +298,22 @@ void create_a_header_and_an_initial_version() throws NamespaceNotFoundException
assertThat(versionCaptor.getValue().getString("version"), is("1.0.0"));
}
+ @Test
+ void thread_the_requested_first_version_through_to_the_stored_version() throws NamespaceNotFoundException {
+ // A brand-new resource may start at a snapshot rather than always 1.0.0.
+ when(counterStore.getNextPatternSequenceValue()).thenReturn(99);
+ when(headerCollection.updateOne(any(Bson.class), any(Bson.class)))
+ .thenReturn(UpdateResult.acknowledged(1, 1L, null));
+
+ Pattern created = store.createPatternForNamespace(createRequest(), NAMESPACE, "1.0.0-SNAPSHOT");
+
+ assertThat(created.getDotVersion(), is("1.0.0-SNAPSHOT"));
+
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(Document.class);
+ verify(versionCollection).insertOne(versionCaptor.capture());
+ assertThat(versionCaptor.getValue().getString("version"), is("1.0.0-SNAPSHOT"));
+ }
+
@Test
void remove_the_header_again_when_the_first_version_write_fails() {
when(counterStore.getNextPatternSequenceValue()).thenReturn(99);
@@ -306,7 +322,7 @@ void remove_the_header_again_when_the_first_version_write_fails() {
}).when(versionCollection).insertOne(any(Document.class));
assertThrows(StorageWriteException.class,
- () -> store.createPatternForNamespace(createRequest(), NAMESPACE));
+ () -> store.createPatternForNamespace(createRequest(), NAMESPACE, "1.0.0"));
verify(headerCollection).deleteOne(any(Bson.class));
}
@@ -322,7 +338,7 @@ void fail_rather_than_report_success_when_the_initial_version_already_exists() {
// inconsistency, not a normal "already exists". Returning the caller's payload with
// a 201 would report success for content that was never stored.
assertThrows(StorageWriteException.class,
- () -> store.createPatternForNamespace(createRequest(), NAMESPACE));
+ () -> store.createPatternForNamespace(createRequest(), NAMESPACE, "1.0.0"));
verify(headerCollection).deleteOne(any(Bson.class));
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java
index 19ee9e894..f9f620cb2 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoStandardStoreShould.java
@@ -160,14 +160,14 @@ void throw_a_namespace_exception_when_creating_a_standard_in_a_missing_namespace
when(namespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
assertThrows(NamespaceNotFoundException.class,
- () -> store.createStandardForNamespace(createRequest(), NAMESPACE));
+ () -> store.createStandardForNamespace(createRequest(), NAMESPACE, "1.0.0"));
}
@Test
void reject_invalid_json_before_drawing_an_id_or_writing_anything() {
CreateStandardRequest invalid = new CreateStandardRequest("n", "d", "{invalid json}");
- assertThrows(JsonParseException.class, () -> store.createStandardForNamespace(invalid, NAMESPACE));
+ assertThrows(JsonParseException.class, () -> store.createStandardForNamespace(invalid, NAMESPACE, "1.0.0"));
verify(counterStore, never()).getNextStandardSequenceValue();
verify(headerCollection, never()).insertOne(any(Document.class));
@@ -179,7 +179,7 @@ void create_a_header_and_an_initial_version() throws NamespaceNotFoundException
when(headerCollection.updateOne(any(Bson.class), any(Bson.class)))
.thenReturn(UpdateResult.acknowledged(1, 1L, null));
- Standard created = store.createStandardForNamespace(createRequest(), NAMESPACE);
+ Standard created = store.createStandardForNamespace(createRequest(), NAMESPACE, "1.0.0");
assertThat(created.getId(), is(99));
assertThat(created.getVersion(), is("1.0.0"));
@@ -189,6 +189,22 @@ void create_a_header_and_an_initial_version() throws NamespaceNotFoundException
assertThat(versionCaptor.getValue().getString("version"), is("1.0.0"));
}
+ @Test
+ void thread_the_requested_first_version_through_to_the_stored_version() throws NamespaceNotFoundException {
+ // A brand-new resource may start at a snapshot rather than always 1.0.0.
+ when(counterStore.getNextStandardSequenceValue()).thenReturn(99);
+ when(headerCollection.updateOne(any(Bson.class), any(Bson.class)))
+ .thenReturn(UpdateResult.acknowledged(1, 1L, null));
+
+ Standard created = store.createStandardForNamespace(createRequest(), NAMESPACE, "1.0.0-SNAPSHOT");
+
+ assertThat(created.getVersion(), is("1.0.0-SNAPSHOT"));
+
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(Document.class);
+ verify(versionCollection).insertOne(versionCaptor.capture());
+ assertThat(versionCaptor.getValue().getString("version"), is("1.0.0-SNAPSHOT"));
+ }
+
@Test
void remove_the_header_again_when_the_first_version_write_fails() {
when(counterStore.getNextStandardSequenceValue()).thenReturn(99);
@@ -197,7 +213,7 @@ void remove_the_header_again_when_the_first_version_write_fails() {
}).when(versionCollection).insertOne(any(Document.class));
assertThrows(StorageWriteException.class,
- () -> store.createStandardForNamespace(createRequest(), NAMESPACE));
+ () -> store.createStandardForNamespace(createRequest(), NAMESPACE, "1.0.0"));
verify(headerCollection).deleteOne(any(Bson.class));
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteArchitectureStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteArchitectureStoreShould.java
index ec12e85b0..0496540e5 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteArchitectureStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteArchitectureStoreShould.java
@@ -217,6 +217,23 @@ public void create_a_header_and_an_initial_version() throws NamespaceNotFoundExc
assertThat(versionCaptor.getValue().get("content", String.class), is(VALID_JSON));
}
+ @Test
+ public void thread_the_requested_first_version_through_to_the_stored_version() throws NamespaceNotFoundException {
+ // A brand-new resource may start at a snapshot rather than always 1.0.0.
+ when(mockCounterStore.getNextArchitectureSequenceValue()).thenReturn(99);
+ stubFind(headerCollection, List.of(Document.createDocument()
+ .put("architectureId", 99).put("versionCount", 0)));
+ stubFind(versionCollection, List.of());
+
+ Architecture created = store.createArchitectureForNamespace(architecture("1.0.0-SNAPSHOT"));
+
+ assertThat(created.getDotVersion(), is("1.0.0-SNAPSHOT"));
+
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(Document.class);
+ verify(versionCollection).insert(versionCaptor.capture());
+ assertThat(versionCaptor.getValue().get("version", String.class), is("1.0.0-SNAPSHOT"));
+ }
+
@Test
public void remove_the_header_again_when_the_first_version_write_fails() {
when(mockCounterStore.getNextArchitectureSequenceValue()).thenReturn(99);
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteFlowStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteFlowStoreShould.java
index a8b18d5b1..bcf34a840 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteFlowStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteFlowStoreShould.java
@@ -164,14 +164,14 @@ public void throw_a_namespace_exception_when_creating_a_flow_in_a_missing_namesp
when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
assertThrows(NamespaceNotFoundException.class,
- () -> store.createFlowForNamespace(createRequest(), NAMESPACE));
+ () -> store.createFlowForNamespace(createRequest(), NAMESPACE, "1.0.0"));
}
@Test
public void reject_invalid_json_when_creating_a_flow() {
CreateFlowRequest invalid = new CreateFlowRequest("n", "d", "{invalid json}");
- assertThrows(JsonParseException.class, () -> store.createFlowForNamespace(invalid, NAMESPACE));
+ assertThrows(JsonParseException.class, () -> store.createFlowForNamespace(invalid, NAMESPACE, "1.0.0"));
verify(headerCollection, never()).insert(any(Document.class));
}
@@ -180,7 +180,7 @@ public void reject_null_json_when_creating_a_flow() {
CreateFlowRequest noJson = new CreateFlowRequest("n", "d", null);
// This backend validates up front; Mongo would NPE inside Document.parse instead.
- assertThrows(JsonParseException.class, () -> store.createFlowForNamespace(noJson, NAMESPACE));
+ assertThrows(JsonParseException.class, () -> store.createFlowForNamespace(noJson, NAMESPACE, "1.0.0"));
}
@Test
@@ -190,7 +190,7 @@ public void create_a_header_and_an_initial_version() throws NamespaceNotFoundExc
.put("flowId", 99).put("versionCount", 0)));
stubFind(versionCollection, List.of());
- Flow created = store.createFlowForNamespace(createRequest(), NAMESPACE);
+ Flow created = store.createFlowForNamespace(createRequest(), NAMESPACE, "1.0.0");
assertThat(created.getId(), is(99));
// Was "1-0-0" before this port, so the Location header differed by backend for the
@@ -209,6 +209,23 @@ public void create_a_header_and_an_initial_version() throws NamespaceNotFoundExc
assertThat(versionCaptor.getValue().get("content", String.class), is(VALID_JSON));
}
+ @Test
+ public void thread_the_requested_first_version_through_to_the_stored_version() throws NamespaceNotFoundException {
+ // A brand-new resource may start at a snapshot rather than always 1.0.0.
+ when(mockCounterStore.getNextFlowSequenceValue()).thenReturn(99);
+ stubFind(headerCollection, List.of(Document.createDocument()
+ .put("flowId", 99).put("versionCount", 0)));
+ stubFind(versionCollection, List.of());
+
+ Flow created = store.createFlowForNamespace(createRequest(), NAMESPACE, "1.0.0-SNAPSHOT");
+
+ assertThat(created.getDotVersion(), is("1.0.0-SNAPSHOT"));
+
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(Document.class);
+ verify(versionCollection).insert(versionCaptor.capture());
+ assertThat(versionCaptor.getValue().get("version", String.class), is("1.0.0-SNAPSHOT"));
+ }
+
@Test
public void remove_the_header_again_when_the_first_version_write_fails() {
when(mockCounterStore.getNextFlowSequenceValue()).thenReturn(99);
@@ -218,7 +235,7 @@ public void remove_the_header_again_when_the_first_version_write_fails() {
.thenThrow(new NitriteException("store is closed"));
assertThrows(NitriteException.class,
- () -> store.createFlowForNamespace(createRequest(), NAMESPACE));
+ () -> store.createFlowForNamespace(createRequest(), NAMESPACE, "1.0.0"));
verify(headerCollection).remove(any(Filter.class));
}
@@ -230,7 +247,7 @@ public void fail_rather_than_report_success_when_the_initial_version_already_exi
stubFind(versionCollection, List.of(Document.createDocument().put("version", "1.0.0")));
assertThrows(StorageWriteException.class,
- () -> store.createFlowForNamespace(createRequest(), NAMESPACE));
+ () -> store.createFlowForNamespace(createRequest(), NAMESPACE, "1.0.0"));
verify(headerCollection).remove(any(Filter.class));
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java
index 7c3896b4e..5c22d26ad 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteInterfaceStoreShould.java
@@ -135,7 +135,7 @@ public void return_a_summary_per_header_document_without_a_version_count() throw
public void reject_invalid_json_when_creating_a_interface() {
CreateInterfaceRequest invalid = new CreateInterfaceRequest("n", "d", "{invalid json}");
- assertThrows(JsonParseException.class, () -> store.createInterfaceForNamespace(invalid, NAMESPACE));
+ assertThrows(JsonParseException.class, () -> store.createInterfaceForNamespace(invalid, NAMESPACE, "1.0.0"));
verify(headerCollection, org.mockito.Mockito.never()).insert(any(Document.class));
}
@@ -146,7 +146,7 @@ public void create_a_header_and_an_initial_version() throws NamespaceNotFoundExc
.put("interfaceId", 99).put("versionCount", 0)));
stubFind(versionCollection, List.of());
- CalmInterface created = store.createInterfaceForNamespace(createRequest(), NAMESPACE);
+ CalmInterface created = store.createInterfaceForNamespace(createRequest(), NAMESPACE, "1.0.0");
assertThat(created.getId(), is(99));
assertThat(created.getVersion(), is("1.0.0"));
@@ -157,6 +157,23 @@ public void create_a_header_and_an_initial_version() throws NamespaceNotFoundExc
assertThat(versionCaptor.getValue().get("content", String.class), is(VALID_JSON));
}
+ @Test
+ public void thread_the_requested_first_version_through_to_the_stored_version() throws NamespaceNotFoundException {
+ // A brand-new resource may start at a snapshot rather than always 1.0.0.
+ when(mockCounterStore.getNextInterfaceSequenceValue()).thenReturn(99);
+ stubFind(headerCollection, List.of(Document.createDocument()
+ .put("interfaceId", 99).put("versionCount", 0)));
+ stubFind(versionCollection, List.of());
+
+ CalmInterface created = store.createInterfaceForNamespace(createRequest(), NAMESPACE, "1.0.0-SNAPSHOT");
+
+ assertThat(created.getVersion(), is("1.0.0-SNAPSHOT"));
+
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(Document.class);
+ verify(versionCollection).insert(versionCaptor.capture());
+ assertThat(versionCaptor.getValue().get("version", String.class), is("1.0.0-SNAPSHOT"));
+ }
+
@Test
public void remove_the_header_again_when_the_first_version_write_fails() {
when(mockCounterStore.getNextInterfaceSequenceValue()).thenReturn(99);
@@ -166,7 +183,7 @@ public void remove_the_header_again_when_the_first_version_write_fails() {
.thenThrow(new NitriteException("store is closed"));
assertThrows(NitriteException.class,
- () -> store.createInterfaceForNamespace(createRequest(), NAMESPACE));
+ () -> store.createInterfaceForNamespace(createRequest(), NAMESPACE, "1.0.0"));
verify(headerCollection).remove(any(Filter.class));
}
@@ -178,7 +195,7 @@ public void fail_rather_than_report_success_when_the_initial_version_already_exi
stubFind(versionCollection, List.of(Document.createDocument().put("version", "1.0.0")));
assertThrows(StorageWriteException.class,
- () -> store.createInterfaceForNamespace(createRequest(), NAMESPACE));
+ () -> store.createInterfaceForNamespace(createRequest(), NAMESPACE, "1.0.0"));
verify(headerCollection).remove(any(Filter.class));
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitritePatternStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitritePatternStoreShould.java
index 80d15947f..45256aa06 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitritePatternStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitritePatternStoreShould.java
@@ -176,14 +176,14 @@ public void throw_a_namespace_exception_when_creating_a_pattern_in_a_missing_nam
when(mockNamespaceStore.namespaceExists(NAMESPACE)).thenReturn(false);
assertThrows(NamespaceNotFoundException.class,
- () -> store.createPatternForNamespace(createRequest(), NAMESPACE));
+ () -> store.createPatternForNamespace(createRequest(), NAMESPACE, "1.0.0"));
}
@Test
public void reject_invalid_json_when_creating_a_pattern() {
CreatePatternRequest invalid = new CreatePatternRequest("n", "d", "{invalid json}");
- assertThrows(JsonParseException.class, () -> store.createPatternForNamespace(invalid, NAMESPACE));
+ assertThrows(JsonParseException.class, () -> store.createPatternForNamespace(invalid, NAMESPACE, "1.0.0"));
verify(headerCollection, never()).insert(any(Document.class));
}
@@ -192,7 +192,7 @@ public void reject_null_json_when_creating_a_pattern() {
CreatePatternRequest noJson = new CreatePatternRequest("n", "d", null);
// This backend validates up front; Mongo would NPE inside Document.parse instead.
- assertThrows(JsonParseException.class, () -> store.createPatternForNamespace(noJson, NAMESPACE));
+ assertThrows(JsonParseException.class, () -> store.createPatternForNamespace(noJson, NAMESPACE, "1.0.0"));
}
@Test
@@ -202,7 +202,7 @@ public void create_a_header_and_an_initial_version() throws NamespaceNotFoundExc
.put("patternId", 99).put("versionCount", 0)));
stubFind(versionCollection, List.of());
- Pattern created = store.createPatternForNamespace(createRequest(), NAMESPACE);
+ Pattern created = store.createPatternForNamespace(createRequest(), NAMESPACE, "1.0.0");
assertThat(created.getId(), is(99));
// Was "1-0-0" before this port, so the Location header differed by backend for the
@@ -221,6 +221,23 @@ public void create_a_header_and_an_initial_version() throws NamespaceNotFoundExc
assertThat(versionCaptor.getValue().get("content", String.class), is(VALID_JSON));
}
+ @Test
+ public void thread_the_requested_first_version_through_to_the_stored_version() throws NamespaceNotFoundException {
+ // A brand-new resource may start at a snapshot rather than always 1.0.0.
+ when(mockCounterStore.getNextPatternSequenceValue()).thenReturn(99);
+ stubFind(headerCollection, List.of(Document.createDocument()
+ .put("patternId", 99).put("versionCount", 0)));
+ stubFind(versionCollection, List.of());
+
+ Pattern created = store.createPatternForNamespace(createRequest(), NAMESPACE, "1.0.0-SNAPSHOT");
+
+ assertThat(created.getDotVersion(), is("1.0.0-SNAPSHOT"));
+
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(Document.class);
+ verify(versionCollection).insert(versionCaptor.capture());
+ assertThat(versionCaptor.getValue().get("version", String.class), is("1.0.0-SNAPSHOT"));
+ }
+
@Test
public void remove_the_header_again_when_the_first_version_write_fails() {
when(mockCounterStore.getNextPatternSequenceValue()).thenReturn(99);
@@ -230,7 +247,7 @@ public void remove_the_header_again_when_the_first_version_write_fails() {
.thenThrow(new NitriteException("store is closed"));
assertThrows(NitriteException.class,
- () -> store.createPatternForNamespace(createRequest(), NAMESPACE));
+ () -> store.createPatternForNamespace(createRequest(), NAMESPACE, "1.0.0"));
verify(headerCollection).remove(any(Filter.class));
}
@@ -242,7 +259,7 @@ public void fail_rather_than_report_success_when_the_initial_version_already_exi
stubFind(versionCollection, List.of(Document.createDocument().put("version", "1.0.0")));
assertThrows(StorageWriteException.class,
- () -> store.createPatternForNamespace(createRequest(), NAMESPACE));
+ () -> store.createPatternForNamespace(createRequest(), NAMESPACE, "1.0.0"));
verify(headerCollection).remove(any(Filter.class));
}
diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java
index f058493f1..fc554aec7 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteStandardStoreShould.java
@@ -135,7 +135,7 @@ public void return_a_summary_per_header_document() throws NamespaceNotFoundExcep
public void reject_invalid_json_when_creating_a_standard() {
CreateStandardRequest invalid = new CreateStandardRequest("n", "d", "{invalid json}");
- assertThrows(JsonParseException.class, () -> store.createStandardForNamespace(invalid, NAMESPACE));
+ assertThrows(JsonParseException.class, () -> store.createStandardForNamespace(invalid, NAMESPACE, "1.0.0"));
verify(headerCollection, org.mockito.Mockito.never()).insert(any(Document.class));
}
@@ -146,7 +146,7 @@ public void create_a_header_and_an_initial_version() throws NamespaceNotFoundExc
.put("standardId", 99).put("versionCount", 0)));
stubFind(versionCollection, List.of());
- Standard created = store.createStandardForNamespace(createRequest(), NAMESPACE);
+ Standard created = store.createStandardForNamespace(createRequest(), NAMESPACE, "1.0.0");
assertThat(created.getId(), is(99));
assertThat(created.getVersion(), is("1.0.0"));
@@ -157,6 +157,23 @@ public void create_a_header_and_an_initial_version() throws NamespaceNotFoundExc
assertThat(versionCaptor.getValue().get("content", String.class), is(VALID_JSON));
}
+ @Test
+ public void thread_the_requested_first_version_through_to_the_stored_version() throws NamespaceNotFoundException {
+ // A brand-new resource may start at a snapshot rather than always 1.0.0.
+ when(mockCounterStore.getNextStandardSequenceValue()).thenReturn(99);
+ stubFind(headerCollection, List.of(Document.createDocument()
+ .put("standardId", 99).put("versionCount", 0)));
+ stubFind(versionCollection, List.of());
+
+ Standard created = store.createStandardForNamespace(createRequest(), NAMESPACE, "1.0.0-SNAPSHOT");
+
+ assertThat(created.getVersion(), is("1.0.0-SNAPSHOT"));
+
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(Document.class);
+ verify(versionCollection).insert(versionCaptor.capture());
+ assertThat(versionCaptor.getValue().get("version", String.class), is("1.0.0-SNAPSHOT"));
+ }
+
@Test
public void remove_the_header_again_when_the_first_version_write_fails() {
when(mockCounterStore.getNextStandardSequenceValue()).thenReturn(99);
@@ -166,7 +183,7 @@ public void remove_the_header_again_when_the_first_version_write_fails() {
.thenThrow(new NitriteException("store is closed"));
assertThrows(NitriteException.class,
- () -> store.createStandardForNamespace(createRequest(), NAMESPACE));
+ () -> store.createStandardForNamespace(createRequest(), NAMESPACE, "1.0.0"));
verify(headerCollection).remove(any(Filter.class));
}
@@ -178,7 +195,7 @@ public void fail_rather_than_report_success_when_the_initial_version_already_exi
stubFind(versionCollection, List.of(Document.createDocument().put("version", "1.0.0")));
assertThrows(StorageWriteException.class,
- () -> store.createStandardForNamespace(createRequest(), NAMESPACE));
+ () -> store.createStandardForNamespace(createRequest(), NAMESPACE, "1.0.0"));
verify(headerCollection).remove(any(Filter.class));
}
From e085d60545b9a2435de2f086c08978c54fba4223 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 16:33:17 +0100
Subject: [PATCH 15/22] feat(calm-hub): audit snapshot deletion as a delete,
pin timeline snapshot ordering
Adds AuditRequestFilter.restageAction so a resource layer's provisional
staged action can be corrected once the service knows a write actually
overwrites an existing snapshot. deleteSnapshotForVersion now stages a
DELETE for the removed snapshot, carrying its own version, before the
store call.
Pins ArchitectureTimelineService's existing snapshot-in-position
ordering with a regression test (Semver.parse already accepts the
-SNAPSHOT suffix from an earlier task).
Note: the overwrite->UPDATE restage has no live effect yet. The only
endpoint that can currently write a snapshot (POST .../versions/{version})
never stages a context, so AuditRequestFilter falls back to its
path-based resolution (hardcoded UPDATE for that path) regardless. The
generic POST /calm endpoint, which does stage a context, rejects
snapshot versions outright (CalmDocumentParser#parseCanonicalId still
validates against VERSION_REGEX, not SNAPSHOT_VERSION_REGEX). See the
task report for details.
---
.../calm/security/AuditRequestFilter.java | 15 ++++++
.../services/MappingControllerService.java | 14 +++++
.../TestMappingControllerResourceShould.java | 52 +++++++++++++++++++
.../TestAuditRequestFilterShould.java | 36 +++++++++++++
...TestArchitectureTimelineServiceShould.java | 14 +++++
5 files changed, 131 insertions(+)
diff --git a/calm-hub/src/main/java/org/finos/calm/security/AuditRequestFilter.java b/calm-hub/src/main/java/org/finos/calm/security/AuditRequestFilter.java
index 549ede538..ac4c0d6a0 100644
--- a/calm-hub/src/main/java/org/finos/calm/security/AuditRequestFilter.java
+++ b/calm-hub/src/main/java/org/finos/calm/security/AuditRequestFilter.java
@@ -148,6 +148,21 @@ public static void stage(AuditContext context) {
STAGED_CONTEXT.set(context);
}
+ /**
+ * Replaces just the {@code action} on the currently staged {@link AuditContext},
+ * leaving every other field as-is. Does nothing when no context is staged. For
+ * callers (e.g. {@code MappingControllerService#addNewVersion}) that only learn the
+ * true action after the resource layer has already staged a provisional one.
+ */
+ public static void restageAction(AuditAction action) {
+ AuditContext current = STAGED_CONTEXT.get();
+ if (current == null) {
+ return;
+ }
+ STAGED_CONTEXT.set(new AuditContext(current.entityType(), action, current.namespace(),
+ current.domain(), current.entityId(), current.version()));
+ }
+
private static final Map, AuditEntityType> RESOURCE_CLASS_TO_ENTITY_TYPE = Map.ofEntries(
Map.entry(NamespaceResource.class, AuditEntityType.NAMESPACE),
Map.entry(DomainResource.class, AuditEntityType.DOMAIN),
diff --git a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
index 5dec166f5..308e3a2b3 100644
--- a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
+++ b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
@@ -5,6 +5,8 @@
import jakarta.inject.Inject;
import jakarta.ws.rs.core.Response;
import org.finos.calm.domain.*;
+import org.finos.calm.domain.audit.AuditAction;
+import org.finos.calm.domain.audit.AuditEntityType;
import org.finos.calm.domain.controls.ControlConfigDetail;
import org.finos.calm.domain.controls.ControlDetail;
import org.finos.calm.domain.controls.CreateControlConfiguration;
@@ -17,6 +19,7 @@
import org.finos.calm.domain.ResourceVersion;
import org.finos.calm.resources.CalmDocumentParser;
import org.finos.calm.resources.CalmResourceErrorResponses;
+import org.finos.calm.security.AuditRequestFilter;
import org.finos.calm.store.*;
import org.finos.calm.store.util.CanonicalVersion;
import org.slf4j.Logger;
@@ -516,6 +519,10 @@ private Response addNewVersion(String namespace, String typePath, String name,
String description = documentParser.extractStringField(json, "description");
if (overwriting) {
+ // The resource layer stages a provisional CREATE before it knows whether this
+ // version already exists — only here, once `versions` has been fetched, is it
+ // known that this write destroys an existing snapshot rather than creating one.
+ AuditRequestFilter.restageAction(AuditAction.UPDATE);
updateVersionedResourceInStore(mapping.getResourceType(), namespace,
mapping.getNumericId(), newVersion, documentParser.stripId(json), title, description);
return Response.ok().build();
@@ -713,6 +720,13 @@ private void deleteSnapshotForVersion(ResourceMapping mapping, String releaseVer
if (!versions.contains(snapshotVersion)) {
return;
}
+ // Stages a DELETE for the snapshot being removed, carrying the snapshot's own version
+ // (not the release version that triggered it) — the only record that this destructive
+ // side effect happened. See the class-level note on AuditRequestFilter's single-row
+ // limitation: this replaces whatever action was staged for the release write itself.
+ AuditRequestFilter.stage(new AuditRequestFilter.AuditContext(
+ AuditEntityType.valueOf(mapping.getResourceType().name()), AuditAction.DELETE,
+ mapping.getNamespace(), null, mapping.getCustomId(), snapshotVersion));
try {
deleteVersionForMapping(mapping, snapshotVersion);
} catch (Exception e) {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
index c166a890d..49d70fd53 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
@@ -4,6 +4,8 @@
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import org.finos.calm.domain.*;
+import org.finos.calm.domain.audit.AuditAction;
+import org.finos.calm.domain.audit.AuditLogEntry;
import org.finos.calm.domain.controls.ControlConfigDetail;
import org.finos.calm.domain.controls.ControlDetail;
import org.finos.calm.domain.controls.CreateControlConfiguration;
@@ -20,6 +22,7 @@
import org.mockito.InOrder;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.finos.calm.security.AuditService;
import org.finos.calm.security.CalmHubPermissionChecker;
import java.util.Collections;
@@ -52,6 +55,7 @@ public class TestMappingControllerResourceShould {
@InjectMock DomainStore mockDomainStore;
@InjectMock ControlStore mockControlStore;
@InjectMock CalmHubPermissionChecker mockPermissionChecker;
+ @InjectMock AuditService mockAuditService;
@org.junit.jupiter.api.BeforeEach
void allowWritesByDefault() {
@@ -1826,4 +1830,52 @@ void thread_the_requested_snapshot_version_into_the_architecture_passed_to_the_s
assertThat("the requested version must reach the store, not a hardcoded 1.0.0",
captor.getValue().getDotVersion(), is("1.0.0-SNAPSHOT"));
}
+
+ // --- Audit: snapshot deletion on promotion is recorded as a DELETE ---
+
+ /** The most recently recorded {@link AuditLogEntry} passed to {@code AuditService.record}. */
+ private AuditLogEntry lastRecordedAuditEntry() {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(AuditLogEntry.class);
+ verify(mockAuditService, atLeastOnce()).record(captor.capture());
+ List entries = captor.getAllValues();
+ return entries.get(entries.size() - 1);
+ }
+
+ @Test
+ void record_the_snapshot_deletion_as_a_delete_when_promoting_a_release() throws Exception {
+ // Promotion deletes the snapshot as a side effect of publishing its release. That
+ // deletion must be audited as DELETE, carrying the snapshot's own version — not the
+ // release version that triggered it. This is the accepted-single-row behaviour: see
+ // the task report for why the release write itself is not separately recorded here.
+ givenAnExistingArchitecture("test", "1.0.0-SNAPSHOT");
+
+ given()
+ .contentType("application/json")
+ .body(architectureBody("test", "1.0.0"))
+ .when()
+ .post("/calm/namespaces/finos/architectures/test/versions/1.0.0")
+ .then()
+ .statusCode(201);
+
+ AuditLogEntry entry = lastRecordedAuditEntry();
+ assertThat(entry.getAction(), is(AuditAction.DELETE));
+ assertThat(entry.getVersion(), is("1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void not_record_a_delete_when_publishing_a_release_with_no_snapshot_to_remove() throws Exception {
+ // No snapshot existed, so deleteSnapshotForVersion never stages anything — the
+ // pre-existing path-based resolution (UPDATE for any POST to this path shape) applies.
+ givenAnExistingArchitecture("test", "1.0.0");
+
+ given()
+ .contentType("application/json")
+ .body(architectureBody("test", "1.1.0"))
+ .when()
+ .post("/calm/namespaces/finos/architectures/test/versions/1.1.0")
+ .then()
+ .statusCode(201);
+
+ assertThat(lastRecordedAuditEntry().getAction(), is(AuditAction.UPDATE));
+ }
}
diff --git a/calm-hub/src/test/java/org/finos/calm/security/TestAuditRequestFilterShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestAuditRequestFilterShould.java
index 4e1ad1106..3a4fd6d99 100644
--- a/calm-hub/src/test/java/org/finos/calm/security/TestAuditRequestFilterShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/security/TestAuditRequestFilterShould.java
@@ -539,6 +539,42 @@ void use_staged_context_verbatim_when_present() {
assertThat(entry.getOutcome(), is(AuditOutcome.DENIED));
}
+ // --- restageAction ------------------------------------------------------------
+
+ @Test
+ void replace_only_the_action_on_the_staged_context() {
+ AuditRequestFilter.stage(new AuditRequestFilter.AuditContext(
+ AuditEntityType.ARCHITECTURE, AuditAction.CREATE, "finos", null, "my-arch", "2.0.0-SNAPSHOT"));
+
+ AuditRequestFilter.restageAction(AuditAction.UPDATE);
+
+ when(resourceInfo.getResourceClass()).thenReturn((Class) NamespaceResource.class);
+ ContainerRequestContext requestContext = mockRequest("POST", new MultivaluedHashMap<>());
+ filter.filter(requestContext, mockResponse(200, null));
+
+ AuditLogEntry entry = captureRecordedEntry();
+ assertThat(entry.getAction(), is(AuditAction.UPDATE));
+ assertThat(entry.getEntityType(), is(AuditEntityType.ARCHITECTURE));
+ assertThat(entry.getNamespace(), is("finos"));
+ assertThat(entry.getEntityId(), is("my-arch"));
+ assertThat(entry.getVersion(), is("2.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void do_nothing_when_restaging_with_no_context_staged() {
+ // No stage() call happened for this (simulated) request — restageAction must not
+ // fabricate a context of its own.
+ AuditRequestFilter.restageAction(AuditAction.DELETE);
+
+ when(resourceInfo.getResourceClass()).thenReturn((Class) NamespaceResource.class);
+ ContainerRequestContext requestContext = mockRequest("POST", new MultivaluedHashMap<>());
+ filter.filter(requestContext, mockResponse(201, null));
+
+ // Falls through to the generic path-based resolution (POST with no id param -> CREATE),
+ // proving the DELETE passed to restageAction above had no effect.
+ assertThat(captureRecordedEntry().getAction(), is(AuditAction.CREATE));
+ }
+
@Test
void clear_staged_context_after_each_request_to_avoid_leaking_across_requests() {
when(resourceInfo.getResourceClass()).thenReturn((Class) NamespaceResource.class);
diff --git a/calm-hub/src/test/java/org/finos/calm/services/TestArchitectureTimelineServiceShould.java b/calm-hub/src/test/java/org/finos/calm/services/TestArchitectureTimelineServiceShould.java
index 1d3546b03..1220f669d 100644
--- a/calm-hub/src/test/java/org/finos/calm/services/TestArchitectureTimelineServiceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/services/TestArchitectureTimelineServiceShould.java
@@ -277,6 +277,20 @@ void propagate_namespace_not_found_exception() throws Exception {
() -> service.getTimelineForArchitecture(NAMESPACE, ARCHITECTURE_ID));
}
+ @Test
+ void order_a_snapshot_into_position_rather_than_after_every_release() throws Exception {
+ // Before Semver.parse accepted the suffix, a snapshot was classified non-semver and
+ // appended after the sorted versions, so every implied timeline ended with it.
+ JsonNode timeline = impliedTimelineFor(List.of("2.0.0", "1.5.0-SNAPSHOT", "1.0.0"));
+
+ JsonNode moments = timeline.get("moments");
+ assertThat(moments.size(), is(3));
+ assertThat(moments.get(0).get("unique-id").asText(), is("1.0.0"));
+ assertThat(moments.get(1).get("unique-id").asText(), is("1.5.0-SNAPSHOT"));
+ assertThat(moments.get(2).get("unique-id").asText(), is("2.0.0"));
+ assertThat(timeline.get("current-moment").asText(), is("2.0.0"));
+ }
+
@Test
void propagate_architecture_not_found_exception() throws Exception {
when(mockTimelineStore.getTimelinesForNamespace(NAMESPACE)).thenReturn(List.of());
From 22a7acd45b01328ac7d64bfe883a8ec5f5af97c9 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 16:53:48 +0100
Subject: [PATCH 16/22] fix(calm-hub): accept snapshot ids on POST /calm, keep
the release row on promotion
CalmDocumentParser#parseCanonicalId validated a $id's version against
VERSION_REGEX, so the generic POST/PUT /calm endpoints rejected every
snapshot version while their path-driven sibling
(POST .../versions/{version}) already accepted it. Switches to
SNAPSHOT_VERSION_REGEX so both agree. validateVersion (used only by the
domain-control $id branches) is untouched: controls stay out of
snapshot scope.
This also makes the generic /calm endpoint's staged CREATE reachable
with a snapshot version, so addNewVersion's restageAction(UPDATE) call
now has a live effect and is exercised through HTTP, not only at the
unit level.
Reverts the DELETE staging added for snapshot deletion during
promotion. AuditRequestFilter supports exactly one recorded row per
request; staging a DELETE there overwrote the release write's own row,
so a promotion was audited solely as "deleted the snapshot" with no
record of the release it published. The release write is the durable
event; keeping its row is the better of the two single-row outcomes
available.
---
.../calm/resources/CalmDocumentParser.java | 8 +-
.../services/MappingControllerService.java | 15 ++-
.../TestMappingControllerResourceShould.java | 94 ++++++++++++++++---
3 files changed, 93 insertions(+), 24 deletions(-)
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java b/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java
index 6ae0d2bcd..01dad0842 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java
@@ -208,7 +208,13 @@ public CanonicalId parseCanonicalId(String json) throws JsonProcessingException
throw new IllegalArgumentException(
"'versions' is a reserved path segment and cannot be used as a resource name");
}
- if (!version.matches(VERSION_REGEX)) {
+ // SNAPSHOT_VERSION_REGEX, not VERSION_REGEX: this is the $id-driven sibling of
+ // createResourceVersion's path-driven POST, which already accepts a -SNAPSHOT
+ // suffix via its @Pattern(SNAPSHOT_VERSION_REGEX) path param. Without this, the two
+ // POST /calm entry points for the same five namespace-resource types disagreed on
+ // whether a snapshot version is valid. Domain controls use validateVersion (below),
+ // not this method, and deliberately keep the strict VERSION_REGEX.
+ if (!version.matches(SNAPSHOT_VERSION_REGEX)) {
throw new IllegalArgumentException("Invalid version in $id: " + version);
}
ResourceType resourceType = TYPE_MAP.get(type.toLowerCase());
diff --git a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
index 308e3a2b3..63d0a8ed2 100644
--- a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
+++ b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
@@ -6,7 +6,6 @@
import jakarta.ws.rs.core.Response;
import org.finos.calm.domain.*;
import org.finos.calm.domain.audit.AuditAction;
-import org.finos.calm.domain.audit.AuditEntityType;
import org.finos.calm.domain.controls.ControlConfigDetail;
import org.finos.calm.domain.controls.ControlDetail;
import org.finos.calm.domain.controls.CreateControlConfiguration;
@@ -720,13 +719,13 @@ private void deleteSnapshotForVersion(ResourceMapping mapping, String releaseVer
if (!versions.contains(snapshotVersion)) {
return;
}
- // Stages a DELETE for the snapshot being removed, carrying the snapshot's own version
- // (not the release version that triggered it) — the only record that this destructive
- // side effect happened. See the class-level note on AuditRequestFilter's single-row
- // limitation: this replaces whatever action was staged for the release write itself.
- AuditRequestFilter.stage(new AuditRequestFilter.AuditContext(
- AuditEntityType.valueOf(mapping.getResourceType().name()), AuditAction.DELETE,
- mapping.getNamespace(), null, mapping.getCustomId(), snapshotVersion));
+ // Deliberately does NOT stage a DELETE for this snapshot removal. AuditRequestFilter
+ // supports exactly one recorded row per request (a single ThreadLocal, read once at the
+ // end of the request) — staging here would overwrite, not add to, whatever was staged
+ // for the release write itself. The release write is the durable event an auditor asks
+ // about ("who published 1.0.0?"); the snapshot delete is cleanup of the same request. If
+ // a second row is ever wanted, the filter needs to support more than one context per
+ // request — do not re-add a stage() call here without that.
try {
deleteVersionForMapping(mapping, snapshotVersion);
} catch (Exception e) {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
index 49d70fd53..796a2676a 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
@@ -1831,7 +1831,7 @@ void thread_the_requested_snapshot_version_into_the_architecture_passed_to_the_s
captor.getValue().getDotVersion(), is("1.0.0-SNAPSHOT"));
}
- // --- Audit: snapshot deletion on promotion is recorded as a DELETE ---
+ // --- Audit: promotion must not clobber the release write's own audit row ---
/** The most recently recorded {@link AuditLogEntry} passed to {@code AuditService.record}. */
private AuditLogEntry lastRecordedAuditEntry() {
@@ -1842,11 +1842,11 @@ private AuditLogEntry lastRecordedAuditEntry() {
}
@Test
- void record_the_snapshot_deletion_as_a_delete_when_promoting_a_release() throws Exception {
- // Promotion deletes the snapshot as a side effect of publishing its release. That
- // deletion must be audited as DELETE, carrying the snapshot's own version — not the
- // release version that triggered it. This is the accepted-single-row behaviour: see
- // the task report for why the release write itself is not separately recorded here.
+ void keep_the_releases_own_audit_row_when_promotion_deletes_a_snapshot() throws Exception {
+ // AuditRequestFilter records exactly one row per request. deleteSnapshotForVersion must
+ // NOT stage a DELETE for the snapshot it removes, or it would overwrite the release
+ // write's own row — leaving no record of the release itself. The release write is the
+ // durable event; the snapshot delete is cleanup of the same request.
givenAnExistingArchitecture("test", "1.0.0-SNAPSHOT");
given()
@@ -1858,24 +1858,88 @@ void record_the_snapshot_deletion_as_a_delete_when_promoting_a_release() throws
.statusCode(201);
AuditLogEntry entry = lastRecordedAuditEntry();
- assertThat(entry.getAction(), is(AuditAction.DELETE));
- assertThat(entry.getVersion(), is("1.0.0-SNAPSHOT"));
+ assertThat(entry.getAction(), is(not(AuditAction.DELETE)));
+ assertThat(entry.getVersion(), is("1.0.0"));
}
+ // --- Snapshot scope: generic POST /calm accepts it for namespace resources, but domain
+ // --- controls (out of scope for this feature) must keep rejecting it.
+
@Test
- void not_record_a_delete_when_publishing_a_release_with_no_snapshot_to_remove() throws Exception {
- // No snapshot existed, so deleteSnapshotForVersion never stages anything — the
- // pre-existing path-based resolution (UPDATE for any POST to this path shape) applies.
- givenAnExistingArchitecture("test", "1.0.0");
+ void accept_a_snapshot_id_for_a_namespace_resource_via_generic_post() throws Exception {
+ // The $id-driven POST /calm is a separate validation path (CalmDocumentParser#parseCanonicalId)
+ // from the path-driven POST .../versions/{version} (which already accepted snapshots).
+ // The two must agree on what's a valid version.
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "snap-generic"))
+ .thenThrow(new MappingNotFoundException());
+ when(mockMappingStore.createMapping(eq("finos"), eq("snap-generic"), eq(ResourceType.ARCHITECTURE), eq(0)))
+ .thenReturn(new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("snap-generic")
+ .setResourceType(ResourceType.ARCHITECTURE).setNumericId(80).build());
+ Architecture arch = new Architecture.ArchitectureBuilder()
+ .setNamespace("finos").setId(80).setVersion("1.0.0-SNAPSHOT").setArchitecture("{}").build();
+ when(mockArchitectureStore.createArchitectureForNamespace(any(Architecture.class))).thenReturn(arch);
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "architectures", "snap-generic", "1.0.0-SNAPSHOT")).when()
+ .post("/calm")
+ .then().statusCode(201)
+ .header("Location", containsString("/versions/1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ void record_a_snapshot_overwrite_via_generic_post_as_an_update_not_a_create() throws Exception {
+ // Now that the generic /calm endpoint can reach a snapshot at all (the fix above),
+ // this exercises the addNewVersion overwrite branch's AuditRequestFilter.restageAction
+ // call through the ONE endpoint that both accepts snapshots AND stages a context
+ // (createResourceFromDocument stages CREATE; the specific-version-path endpoint never
+ // stages anything at all, so it can't exercise this).
+ givenAnExistingArchitecture("test", "2.0.0-SNAPSHOT");
given()
.contentType("application/json")
- .body(architectureBody("test", "1.1.0"))
+ .body(architectureBody("test", "2.0.0-SNAPSHOT"))
.when()
- .post("/calm/namespaces/finos/architectures/test/versions/1.1.0")
+ .post("/calm")
.then()
- .statusCode(201);
+ .statusCode(200);
assertThat(lastRecordedAuditEntry().getAction(), is(AuditAction.UPDATE));
}
+
+ @Test
+ void still_reject_a_snapshot_id_for_a_control_requirement_via_generic_post() throws Exception {
+ // Domain controls are deliberately out of snapshot scope — validateVersion (a
+ // different check from parseCanonicalId's) must keep rejecting -SNAPSHOT here.
+ //
+ // The control is mocked as already EXISTING so that, if validateVersion's own gate
+ // were ever bypassed, the request would fall through to the "add a version to an
+ // existing control" success path (201) rather than coincidentally hitting the
+ // unrelated "a new control's first version must be 1.0.0" 400 — isolating this test
+ // to the version-format check it's meant to pin.
+ when(mockControlStore.getControlsForDomain("security"))
+ .thenReturn(List.of(new ControlDetail(5, "my-ctrl", "Desc")));
+
+ String body = "{\"$id\":\"http://localhost:8080/calm/domains/security/controls/my-ctrl/requirement/versions/1.0.0-SNAPSHOT\"}";
+ given().header("Content-Type", "application/json")
+ .body(body)
+ .when().post("/calm")
+ .then().statusCode(400);
+ }
+
+ @Test
+ void still_reject_a_snapshot_id_for_a_control_configuration_via_generic_post() throws Exception {
+ // Same isolation rationale as the requirement test above: mock both the control and
+ // the configuration as already existing.
+ when(mockControlStore.getControlsForDomain("security"))
+ .thenReturn(List.of(new ControlDetail(5, "my-ctrl", "Desc")));
+ when(mockControlStore.getConfigurationDetailsForControl("security", 5))
+ .thenReturn(List.of(new ControlConfigDetail(10, "my-cfg")));
+
+ String body = "{\"$id\":\"http://localhost:8080/calm/domains/security/controls/my-ctrl/configurations/my-cfg/versions/1.0.0-SNAPSHOT\"}";
+ given().header("Content-Type", "application/json")
+ .body(body)
+ .when().post("/calm")
+ .then().statusCode(400);
+ }
}
From 61fb1644c08ce72b8ec41e2b05e68e0c28f60150 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Fri, 18 Sep 2026 17:29:02 +0100
Subject: [PATCH 17/22] test(calm-hub): cover the snapshot lifecycle end to end
Adds a TestContainers-backed integration test proving the full -SNAPSHOT
lifecycle against a real MongoDB: create, overwrite, list, promote (with
snapshot deletion), shadowing rejection, and search visibility. Mocked
store tests can't catch a failure in the create-then-delete promotion
sequence or in versionCount bookkeeping split across header and version
documents.
Also extends the OpenAPI descriptions on the two snapshot-capable POST
endpoints and adds a short snapshot-lifecycle table to the CalmHub API
docs.
---
.../SnapshotLifecycleIntegration.java | 196 ++++++++++++++++++
.../resources/MappingControllerResource.java | 10 +-
docs/docs/calm-hub/mcp-and-api.md | 12 ++
3 files changed, 216 insertions(+), 2 deletions(-)
create mode 100644 calm-hub/src/integration-test/java/integration/SnapshotLifecycleIntegration.java
diff --git a/calm-hub/src/integration-test/java/integration/SnapshotLifecycleIntegration.java b/calm-hub/src/integration-test/java/integration/SnapshotLifecycleIntegration.java
new file mode 100644
index 000000000..877443d4e
--- /dev/null
+++ b/calm-hub/src/integration-test/java/integration/SnapshotLifecycleIntegration.java
@@ -0,0 +1,196 @@
+package integration;
+
+import com.mongodb.client.MongoClient;
+import com.mongodb.client.MongoClients;
+import com.mongodb.client.MongoDatabase;
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.TestProfile;
+import org.eclipse.microprofile.config.ConfigProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static io.restassured.RestAssured.given;
+import static integration.MongoSetup.counterSetup;
+import static integration.MongoSetup.namespaceSetup;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.not;
+
+/**
+ * End-to-end lifecycle for a {@code -SNAPSHOT} version, exercised against a real MongoDB
+ * (TestContainers) rather than mocked stores. Promotion is a create-then-delete sequence
+ * across two separate store operations with no transaction, and {@code versionCount}
+ * bookkeeping spans a header document and version documents — a mocked test cannot catch a
+ * failure in either half of that sequence.
+ *
+ * Uses the shared Mongo container and the "finos" namespace already seeded by
+ * {@link MongoSetup#namespaceSetup}, like every other Mongo*Integration test — the
+ * "lifecycle" custom ID is unique to this class, so it does not collide with resources
+ * created by the other suites sharing that container.
+ */
+@QuarkusTest
+@TestProfile(IntegrationTestProfile.class)
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class SnapshotLifecycleIntegration {
+
+ private static final Logger logger = LoggerFactory.getLogger(SnapshotLifecycleIntegration.class);
+
+ // MappingControllerResource is mounted at "/calm" with no "/api" prefix (that prefix
+ // belongs to the separate numeric-ID resources such as ArchitectureResource).
+ private static final String PATH_BASE = "/calm/namespaces/finos/architectures/lifecycle";
+
+ // The $id must equal the full canonical URL, including the base URL that
+ // IntegrationTestProfile configures via calm.hub.base-url — otherwise
+ // CalmDocumentParser.resolveAndVerify rejects the request with 400.
+ private static final String ID_BASE = "http://localhost:8080" + PATH_BASE;
+
+ // Same idempotent setup as every other Mongo*Integration class sharing this container —
+ // required because test-class execution order across the suite is not guaranteed, and
+ // this class does not otherwise touch the counters/namespaces collections that other
+ // classes' @BeforeEach primes.
+ @BeforeEach
+ void setup() {
+ String mongoUri = ConfigProvider.getConfig().getValue("quarkus.mongodb.connection-string", String.class);
+ String mongoDatabase = ConfigProvider.getConfig().getValue("quarkus.mongodb.database", String.class);
+
+ if (mongoUri == null || mongoUri.isBlank()) {
+ logger.error("MongoDB URI is not set. Check the EndToEndResource configuration.");
+ throw new IllegalStateException("MongoDB URI is not set. Check the EndToEndResource configuration.");
+ }
+
+ try (MongoClient mongoClient = MongoClients.create(mongoUri)) {
+ MongoDatabase database = mongoClient.getDatabase(mongoDatabase);
+ counterSetup(database);
+ namespaceSetup(database);
+ }
+ }
+
+ @Test
+ @Order(1)
+ void create_the_resource_at_a_snapshot() {
+ given()
+ .contentType("application/json")
+ .body(document("1.0.0-SNAPSHOT", "first"))
+ .when()
+ .post(PATH_BASE + "/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(201);
+ }
+
+ @Test
+ @Order(2)
+ void overwrite_the_snapshot_and_read_the_new_content_back() {
+ given()
+ .contentType("application/json")
+ .body(document("1.0.0-SNAPSHOT", "second"))
+ .when()
+ .post(PATH_BASE + "/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(200);
+
+ given()
+ .when()
+ .get(PATH_BASE + "/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(200)
+ .body("title", equalTo("second"));
+ }
+
+ @Test
+ @Order(3)
+ void list_the_snapshot_as_the_only_version() {
+ given()
+ .when()
+ .get(PATH_BASE + "/versions")
+ .then()
+ .statusCode(200)
+ .body("values", contains("1.0.0-SNAPSHOT"));
+ }
+
+ @Test
+ @Order(4)
+ void publish_the_release_and_lose_the_snapshot() {
+ given()
+ .contentType("application/json")
+ .body(document("1.0.0", "published"))
+ .when()
+ .post(PATH_BASE + "/versions/1.0.0")
+ .then()
+ .statusCode(201);
+
+ given()
+ .when()
+ .get(PATH_BASE + "/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(404);
+
+ given()
+ .when()
+ .get(PATH_BASE + "/versions")
+ .then()
+ .statusCode(200)
+ .body("values", hasItem("1.0.0"))
+ .body("values", not(hasItem("1.0.0-SNAPSHOT")));
+ }
+
+ @Test
+ @Order(5)
+ void refuse_a_snapshot_that_shadows_the_published_release() {
+ given()
+ .contentType("application/json")
+ .body(document("1.0.0-SNAPSHOT", "shadow"))
+ .when()
+ .post(PATH_BASE + "/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(409);
+ }
+
+ @Test
+ @Order(6)
+ void return_a_snapshot_from_search() {
+ // The spec decides snapshots are searchable: they are real documents in the
+ // namespace, and filtering work in progress out of results is a UI concern, not a
+ // storage one.
+ //
+ // GroupedSearchResults/SearchResult (org.finos.calm.domain.search) carry no version
+ // field at all — search matches and returns namespace/id/name/description, grouped
+ // by type ("architectures", "patterns", ... — not a flat "values" array), and the
+ // endpoint's query parameter is "q", not "query". MongoSearchStore matches against
+ // the resource's header, whose name/description are denormalized from the most
+ // recently written version's title/description (see
+ // MongoArchitectureStore#updateHeaderDetails) — so posting this snapshot makes the
+ // *resource* (not a specific version) discoverable by the snapshot's title.
+ given()
+ .contentType("application/json")
+ .body(document("2.0.0-SNAPSHOT", "searchable-snapshot"))
+ .when()
+ .post(PATH_BASE + "/versions/2.0.0-SNAPSHOT")
+ .then()
+ .statusCode(201);
+
+ given()
+ .queryParam("q", "searchable-snapshot")
+ .when()
+ .get("/calm/search")
+ .then()
+ .statusCode(200)
+ .body("architectures.name", hasItem("searchable-snapshot"));
+ }
+
+ private String document(String version, String title) {
+ return """
+ {
+ "$id": "%s/versions/%s",
+ "title": "%s",
+ "nodes": [],
+ "relationships": []
+ }
+ """.formatted(ID_BASE, version, title);
+ }
+}
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java b/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java
index 6eecc9d36..4a46e7b49 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/MappingControllerResource.java
@@ -96,7 +96,10 @@ public MappingControllerResource(MappingControllerService service,
"For control requirements: {baseUrl}/calm/domains/{domain}/controls/{controlName}/requirement/versions/{version}. " +
"For configurations: {baseUrl}/calm/domains/{domain}/controls/{controlName}/configurations/{configName}/versions/{version}. " +
"A version is always required. For a brand-new resource the first version must be 1.0.0. " +
- "For an existing resource the requested version is created (409 if it already exists)."
+ "For an existing resource the requested version is created (409 if it already exists). " +
+ "A version ending in -SNAPSHOT is mutable: posting to it again replaces its content and " +
+ "returns 200. Creating a snapshot whose release version is already published returns 409. " +
+ "Publishing a release version deletes the matching snapshot, if one exists."
)
@Authenticated
public Response createResourceFromDocument(String requestBody) throws URISyntaxException {
@@ -241,7 +244,10 @@ public Response updateResourceFromDocument(String requestBody) throws URISyntaxE
summary = "Create a specific version of a named resource",
description = "The request body must be the raw CALM document, and its \"$id\" must equal the canonical " +
"versioned URL for the exact version in the path. For a brand-new resource the version must be " +
- "1.0.0; for an existing resource the requested version is created (409 if it already exists)."
+ "1.0.0; for an existing resource the requested version is created (409 if it already exists). " +
+ "A version ending in -SNAPSHOT is mutable: posting to it again replaces its content and " +
+ "returns 200. Creating a snapshot whose release version is already published returns 409. " +
+ "Publishing a release version deletes the matching snapshot, if one exists."
)
@PermissionsAllowed(CalmHubScopes.WRITE)
public Response createResourceVersion(
diff --git a/docs/docs/calm-hub/mcp-and-api.md b/docs/docs/calm-hub/mcp-and-api.md
index fe7ad57fc..6d637fdc5 100644
--- a/docs/docs/calm-hub/mcp-and-api.md
+++ b/docs/docs/calm-hub/mcp-and-api.md
@@ -57,6 +57,18 @@ GET /calm/namespaces/{namespace}/architectures
The full endpoint list with request/response schemas is visible in the Swagger UI at `/q/swagger-ui`.
+### Snapshot Versions
+
+A version ending in `-SNAPSHOT` (e.g. `1.0.0-SNAPSHOT`) is mutable — it can be re-posted with new content. A release version (e.g. `1.0.0`) is immutable once created. Publishing a release deletes the matching snapshot, if one exists. Snapshots are supported on architectures, patterns, flows, standards, and interfaces only.
+
+| Request | Effect | Status |
+|:--------|:-------|:-------|
+| `POST .../versions/1.0.0-SNAPSHOT` (first time) | Creates the snapshot | `201` |
+| `POST .../versions/1.0.0-SNAPSHOT` (again) | Replaces the snapshot's content | `200` |
+| `POST .../versions/1.0.0` | Publishes the release, deletes `1.0.0-SNAPSHOT` | `201` |
+
+A snapshot cannot be created if its release version is already published — CALM Hub returns `409 Conflict`.
+
### Access Control
Endpoints are protected by **per-namespace permissions**. Access is granted via `UserAccess` records stored in the active backend; each record ties a username to a permission level for a specific namespace or control domain.
From 3ba7b7d42a52431a1703d8b7cb427c7d994991be Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Sat, 19 Sep 2026 15:05:22 +0100
Subject: [PATCH 18/22] fix(calm-hub): scope snapshot versions back out of the
numeric-ID API
The numeric /api/calm/namespaces/... endpoints (ArchitectureResource,
FlowResource, InterfaceResource, PatternResource, StandardResource) call
their stores directly and never reach MappingControllerService, which is
the only place the three snapshot rules (idempotent overwrite, shadow
409, promotion delete) are enforced. An earlier task widened the version
@Pattern on POST and PUT for this API too, letting it durably create a
snapshot shadowing a published release with no rules applied.
Revert POST/PUT version path params to VERSION_REGEX/VERSION_MESSAGE.
GET stays on SNAPSHOT_VERSION_REGEX so snapshots created via the
name-based /calm/... API remain readable here.
---
.../calm/resources/ArchitectureResource.java | 6 ++-
.../finos/calm/resources/FlowResource.java | 6 ++-
.../calm/resources/InterfaceResource.java | 2 +-
.../finos/calm/resources/PatternResource.java | 6 ++-
.../calm/resources/StandardResource.java | 2 +-
...tArchitectureResourcePutEnabledShould.java | 17 ++++++++-
.../TestArchitectureResourceShould.java | 31 +++++++++++++++-
.../TestFlowResourcePutEnabledShould.java | 4 +-
.../resources/TestFlowResourceShould.java | 3 +-
.../TestInterfaceResourceShould.java | 22 ++++++++++-
.../TestPatternResourcePutEnabledShould.java | 17 ++++++++-
.../resources/TestPatternResourceShould.java | 31 +++++++++++++++-
.../resources/TestStandardResourceShould.java | 37 ++++++++++++++++++-
13 files changed, 167 insertions(+), 17 deletions(-)
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java b/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java
index 165ad66db..89ed5e553 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java
@@ -45,6 +45,8 @@
import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_REGEX;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX;
/**
@@ -203,7 +205,7 @@ public Response getArchitecture(
public Response createVersionedArchitecture(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("architectureId") int architectureId,
- @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
+ @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
ArchitectureRequest architectureRequest
) throws URISyntaxException {
Architecture architecture = new Architecture.ArchitectureBuilder()
@@ -245,7 +247,7 @@ public Response createVersionedArchitecture(
public Response updateVersionedArchitecture(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("architectureId") int architectureId,
- @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
+ @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
ArchitectureRequest architectureRequest) throws URISyntaxException {
Architecture architecture = new Architecture.ArchitectureBuilder()
.setNamespace(namespace)
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java
index 417172fd4..b6f7361b2 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java
@@ -36,6 +36,8 @@
import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_REGEX;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX;
@Tag(name = "Storage API", description = "Numeric-ID based CALM storage endpoints")
@Path("/api/calm/namespaces")
@@ -210,7 +212,7 @@ private Response getFlowInternal(String namespace, int flowId, String version) {
public Response createVersionedFlow(
@PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("flowId") int flowId,
- @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
+ @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
@Valid @NotNull(message = "Request must not be null") CreateFlowRequest flowRequest
) throws URISyntaxException {
Flow flow = new Flow.FlowBuilder()
@@ -249,7 +251,7 @@ public Response createVersionedFlow(
public Response updateVersionedFlow(
@PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("flowId") int flowId,
- @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
+ @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
@Valid @NotNull(message = "Request must not be null") CreateFlowRequest flowRequest
) throws URISyntaxException {
Flow flow = new Flow.FlowBuilder()
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java b/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java
index e6eb4b097..9e0f8c07a 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java
@@ -134,7 +134,7 @@ public Response getInterfaceForVersion(
public Response createInterfaceForVersion(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("interfaceId") Integer interfaceId,
- @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
+ @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
@Valid @NotNull(message = "Request must not be null") CreateInterfaceRequest createInterfaceRequest
) throws URISyntaxException {
try {
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
index 405f458d9..1be5e70ff 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
@@ -34,6 +34,8 @@
import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_REGEX;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX;
@Tag(name = "Storage API", description = "Numeric-ID based CALM storage endpoints")
@Path("/api/calm/namespaces")
@@ -176,7 +178,7 @@ public Response getPattern(
public Response createVersionedPattern(
@PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("patternId") int patternId,
- @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
+ @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
@Valid @NotNull(message = "Request must not be null") CreatePatternRequest patternRequest
) throws URISyntaxException {
Pattern pattern = new Pattern.PatternBuilder()
@@ -218,7 +220,7 @@ public Response createVersionedPattern(
public Response updateVersionedPattern(
@PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("patternId") int patternId,
- @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
+ @PathParam("version") @jakarta.validation.constraints.Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
@Valid @NotNull(message = "Request must not be null") CreatePatternRequest patternRequest
) throws URISyntaxException {
Pattern pattern = new Pattern.PatternBuilder()
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java
index 5d370a429..66908a0ad 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java
@@ -126,7 +126,7 @@ public Response getStandardForVersion(
public Response createStandardForVersion(
@PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace,
@PathParam("standardId") Integer standardId,
- @PathParam("version") @Pattern(regexp = SNAPSHOT_VERSION_REGEX, message = SNAPSHOT_VERSION_MESSAGE) String version,
+ @PathParam("version") @Pattern(regexp = VERSION_REGEX, message = VERSION_MESSAGE) String version,
CreateStandardRequest createStandardRequest
) throws URISyntaxException {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourcePutEnabledShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourcePutEnabledShould.java
index e94f0a575..d2a75b43c 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourcePutEnabledShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourcePutEnabledShould.java
@@ -25,6 +25,7 @@
import static io.restassured.RestAssured.given;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.Mockito.*;
@@ -64,7 +65,21 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_create_new_ar
.put("/api/calm/namespaces/finos/architectures/20/versions/1.0invalid.1")
.then()
.statusCode(400)
- .body(containsString("version must match pattern '^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)(-SNAPSHOT)?$"));
+ .body(containsString(VERSION_MESSAGE));
+ }
+
+ @Test
+ void return_a_400_when_a_snapshot_version_is_provided_on_put_architecture_version() throws NamespaceNotFoundException {
+ // PUT on the numeric API keeps the strict VERSION_REGEX -- snapshots are only accepted
+ // through the name-based /calm/... API.
+ given()
+ .when()
+ .header("Content-Type", "application/json")
+ .body(ARCHITECTURE_JSON)
+ .put("/api/calm/namespaces/finos/architectures/20/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString(VERSION_MESSAGE));
}
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java
index 78ccfa696..4f2c35789 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java
@@ -33,6 +33,7 @@
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.OFFSET_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.ArgumentMatchers.any;
@@ -382,7 +383,35 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_create_new_ve
.post("/api/calm/namespaces/finos/architectures/20/versions/1.0.invalid1")
.then()
.statusCode(400)
- .body(containsString(SNAPSHOT_VERSION_MESSAGE));
+ .body(containsString(VERSION_MESSAGE));
+ }
+
+ @Test
+ void return_a_400_when_a_snapshot_version_is_provided_on_create_new_version_of_architecture() throws NamespaceNotFoundException {
+ // POST/PUT on the numeric API keep the strict VERSION_REGEX -- snapshots are only
+ // accepted through the name-based /calm/... API, which holds the snapshot rules.
+ given()
+ .header("Content-Type", "application/json")
+ .body("{ \"test\": \"json\" }")
+ .when()
+ .post("/api/calm/namespaces/finos/architectures/20/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString(VERSION_MESSAGE));
+ }
+
+ @Test
+ void not_reject_a_snapshot_version_on_get_architecture() throws NamespaceNotFoundException, ArchitectureNotFoundException, ArchitectureVersionNotFoundException {
+ // GET keeps SNAPSHOT_VERSION_REGEX, so a snapshot created via the name-based API stays
+ // readable here. A 404 (not 400) proves the path param passed validation and reached
+ // the store.
+ when(mockArchitectureStore.getArchitectureForVersion(any(Architecture.class))).thenThrow(new ArchitectureVersionNotFoundException());
+
+ given()
+ .when()
+ .get("/api/calm/namespaces/finos/architectures/12/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(404);
}
static Stream provideParametersForCreateArchitectureTests() {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourcePutEnabledShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourcePutEnabledShould.java
index b1a28c87f..99c2f1e96 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourcePutEnabledShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourcePutEnabledShould.java
@@ -19,7 +19,7 @@
import java.util.stream.Stream;
import static io.restassured.RestAssured.given;
-import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.Mockito.when;
@@ -43,7 +43,7 @@ void return_400_error_when_version_is_not_valid_when_updating_flow_version() {
.put("/api/calm/namespaces/test/flows/20/versions/invalid-version")
.then()
.statusCode(400)
- .body(containsString(SNAPSHOT_VERSION_MESSAGE));
+ .body(containsString(VERSION_MESSAGE));
}
static Stream provideParametersForPutFlowTests() {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java
index 25cbcb96c..f9b19ad62 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java
@@ -26,6 +26,7 @@
import static io.restassured.RestAssured.given;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.ArgumentMatchers.any;
@@ -311,7 +312,7 @@ void return_400_error_when_version_is_not_valid_when_creating_new_flow_version()
.post("/api/calm/namespaces/test/flows/20/versions/invalid-version")
.then()
.statusCode(400)
- .body(containsString(SNAPSHOT_VERSION_MESSAGE));
+ .body(containsString(VERSION_MESSAGE));
}
static Stream provideParametersForCreateFlowTests() {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java
index 68aeb111f..a411260fa 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java
@@ -32,6 +32,7 @@
import static io.restassured.RestAssured.given;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.nullValue;
@@ -339,7 +340,26 @@ void return_400_when_invalid_version_provided_when_creating_new_version_of_inter
.post("/api/calm/namespaces/finos/interfaces/5/versions/invalid-version")
.then()
.statusCode(400)
- .body(containsString(SNAPSHOT_VERSION_MESSAGE));
+ .body(containsString(VERSION_MESSAGE));
+ }
+
+ @Test
+ void return_400_when_a_snapshot_version_is_provided_when_creating_new_version_of_interface() {
+ // POST on the numeric API keeps the strict VERSION_REGEX -- snapshots are only accepted
+ // through the name-based /calm/... API, which holds the snapshot rules.
+ CreateInterfaceRequest createInterfaceRequest = new CreateInterfaceRequest();
+ createInterfaceRequest.setName("amazing-interface");
+ createInterfaceRequest.setDescription("An amazing interface");
+ createInterfaceRequest.setInterfaceJson("{}");
+
+ given()
+ .header("Content-Type", "application/json")
+ .body(createInterfaceRequest)
+ .when()
+ .post("/api/calm/namespaces/finos/interfaces/5/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString(VERSION_MESSAGE));
}
static Stream provideParametersForCreateInterfaceTests() {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourcePutEnabledShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourcePutEnabledShould.java
index 9a069bc7d..f3066445b 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourcePutEnabledShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourcePutEnabledShould.java
@@ -21,6 +21,7 @@
import static io.restassured.RestAssured.given;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.Mockito.when;
@@ -54,7 +55,21 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_create_new_pa
.put("/api/calm/namespaces/finos/patterns/20/versions/1.0invalid.1")
.then()
.statusCode(400)
- .body(containsString("version must match pattern '^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)(-SNAPSHOT)?$"));
+ .body(containsString(VERSION_MESSAGE));
+ }
+
+ @Test
+ void return_a_400_when_a_snapshot_version_is_provided_on_put_pattern_version() {
+ // PUT on the numeric API keeps the strict VERSION_REGEX -- snapshots are only accepted
+ // through the name-based /calm/... API.
+ given()
+ .when()
+ .header("Content-Type", "application/json")
+ .body("{\"name\":\"n\",\"description\":\"d\",\"patternJson\":\"{ \\\"test\\\": \\\"json\\\" }\"}")
+ .put("/api/calm/namespaces/finos/patterns/20/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString(VERSION_MESSAGE));
}
static Stream provideParametersForPutPatternTests() {
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java
index 78adbe57d..8185c1f0b 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java
@@ -32,6 +32,7 @@
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.OFFSET_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.nullValue;
@@ -399,7 +400,35 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_create_new_pa
.post("/api/calm/namespaces/finos/patterns/20/versions/1.0invalid.1")
.then()
.statusCode(400)
- .body(containsString(SNAPSHOT_VERSION_MESSAGE));
+ .body(containsString(VERSION_MESSAGE));
+ }
+
+ @Test
+ void return_a_400_when_a_snapshot_version_is_provided_on_create_new_pattern_version() {
+ // POST/PUT on the numeric API keep the strict VERSION_REGEX -- snapshots are only
+ // accepted through the name-based /calm/... API, which holds the snapshot rules.
+ given()
+ .when()
+ .header("Content-Type", "application/json")
+ .body("{\"name\":\"n\",\"description\":\"d\",\"patternJson\":\"{ \\\"test\\\": \\\"json\\\" }\"}")
+ .post("/api/calm/namespaces/finos/patterns/20/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString(VERSION_MESSAGE));
+ }
+
+ @Test
+ void not_reject_a_snapshot_version_on_get_pattern() throws Exception {
+ // GET keeps SNAPSHOT_VERSION_REGEX, so a snapshot created via the name-based API stays
+ // readable here. A 404 (not 400) proves the path param passed validation and reached
+ // the store.
+ when(mockPatternStore.getPatternForVersion(any(Pattern.class))).thenThrow(new PatternVersionNotFoundException());
+
+ given()
+ .when()
+ .get("/api/calm/namespaces/finos/patterns/12/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(404);
}
@Test
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java
index 5d72e0e76..5f52f0102 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java
@@ -30,6 +30,7 @@
import static io.restassured.RestAssured.given;
import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE;
import static org.finos.calm.resources.ResourceValidationConstants.SNAPSHOT_VERSION_MESSAGE;
+import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.ArgumentMatchers.*;
@@ -296,7 +297,41 @@ void return_400_when_invalid_version_provided_when_creating_new_version_of_stand
.post("/api/calm/namespaces/finos/standards/5/versions/invalid-version")
.then()
.statusCode(400)
- .body(containsString(SNAPSHOT_VERSION_MESSAGE));
+ .body(containsString(VERSION_MESSAGE));
+ }
+
+ @Test
+ void return_400_when_a_snapshot_version_is_provided_when_creating_new_version_of_standard() {
+ // POST on the numeric API keeps the strict VERSION_REGEX -- snapshots are only accepted
+ // through the name-based /calm/... API, which holds the snapshot rules.
+ CreateStandardRequest createStandardRequest = new CreateStandardRequest();
+ createStandardRequest.setName("amazing-standard");
+ createStandardRequest.setDescription("An amazing standard");
+ createStandardRequest.setStandardJson("{}");
+
+ given()
+ .header("Content-Type", "application/json")
+ .body(createStandardRequest)
+ .when()
+ .post("/api/calm/namespaces/finos/standards/5/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString(VERSION_MESSAGE));
+ }
+
+ @Test
+ void not_reject_a_snapshot_version_on_get_standard() throws Exception {
+ // GET keeps SNAPSHOT_VERSION_REGEX, so a snapshot created via the name-based API stays
+ // readable here. A 404 (not 400) proves the path param passed validation and reached
+ // the store.
+ when(mockStandardStore.getStandardForVersion(eq("finos"), eq(5), eq("1.0.0-SNAPSHOT")))
+ .thenThrow(new StandardVersionNotFoundException());
+
+ given()
+ .when()
+ .get("/api/calm/namespaces/finos/standards/5/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(404);
}
static Stream provideParametersForCreateStandardTests() {
From 1438ad18dc9e42cc7fcb716ea4583903ed853af2 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Sat, 19 Sep 2026 15:05:28 +0100
Subject: [PATCH 19/22] fix(calm-hub): canonicalise the overwrite check in
addNewVersion
versions holds canonical spellings, but the overwrite check compared it
against the raw request spelling directly, so a request using a
different accepted spelling than the one stored (e.g. 200-SNAPSHOT vs.
stored 2.0.0-SNAPSHOT, or 100 vs. stored 1.0.0) missed the match. That
also broke the release path: POST 100 against a stored 1.0.0 returned
400 instead of the correct 409.
Canonicalise the request version before the versions.contains check,
matching the shadow check above and deleteSnapshotForVersion below.
---
.../services/MappingControllerService.java | 5 ++-
.../TestMappingControllerResourceShould.java | 40 +++++++++++++++++++
2 files changed, 44 insertions(+), 1 deletion(-)
diff --git a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
index 63d0a8ed2..66449d66f 100644
--- a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
+++ b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
@@ -504,7 +504,10 @@ private Response addNewVersion(String namespace, String typePath, String name,
}
// Releases stay immutable. A snapshot is mutable by design, so a repeat POST
// overwrites it — a client never has to know whether it already exists.
- boolean overwriting = versions.contains(newVersion);
+ // versions holds canonical spellings, so the raw request spelling must be
+ // canonicalised before comparison, exactly as the shadow check above and
+ // deleteSnapshotForVersion below already do.
+ boolean overwriting = versions.contains(CanonicalVersion.of(newVersion));
if (overwriting && !snapshot) {
return CalmResourceErrorResponses.versionAlreadyExistsResponse(
newVersion, mapping.getResourceType(), name, namespace);
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
index 796a2676a..9765916a4 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
@@ -314,6 +314,28 @@ void overwrite_a_snapshot_that_already_exists() throws Exception {
verify(mockArchitectureStore, never()).createArchitectureForVersion(any(Architecture.class));
}
+ @Test
+ void overwrite_a_snapshot_whose_raw_request_spelling_differs_from_the_stored_canonical_spelling() throws Exception {
+ // versions holds the canonical spelling ("2.0.0-SNAPSHOT"); the request uses a
+ // different accepted spelling ("200-SNAPSHOT") for the same logical version. The
+ // create-versus-overwrite decision must canonicalise the raw request before comparing
+ // against the stored list, or this lands on the create branch and fails with a 400
+ // when the store rejects the duplicate.
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("snap-test")
+ .setResourceType(ResourceType.ARCHITECTURE).setNumericId(20).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "snap-test")).thenReturn(existing);
+ when(mockArchitectureStore.getArchitectureVersions(any(Architecture.class))).thenReturn(List.of("2.0.0-SNAPSHOT"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "architectures", "snap-test", "200-SNAPSHOT")).when()
+ .post("/calm/namespaces/finos/architectures/snap-test/versions/200-SNAPSHOT")
+ .then().statusCode(200);
+
+ verify(mockArchitectureStore).updateArchitectureForVersion(any(Architecture.class));
+ verify(mockArchitectureStore, never()).createArchitectureForVersion(any(Architecture.class));
+ }
+
@Test
void refuse_a_snapshot_whose_release_version_is_already_published() throws Exception {
// A snapshot that shadows a published version makes "promotion deletes the snapshot"
@@ -362,6 +384,24 @@ void still_refuse_a_release_version_that_already_exists() throws Exception {
.then().statusCode(409);
}
+ @Test
+ void refuse_a_release_version_whose_raw_request_spelling_differs_from_the_stored_canonical_spelling() throws Exception {
+ // versions holds the canonical spelling ("1.0.0"); the request uses a different
+ // accepted spelling ("100") for the same logical release. Without canonicalising the
+ // raw request first, this fails to detect the clash, falls through to the create
+ // branch and returns a 400 from the store instead of the correct 409.
+ ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
+ .setNamespace("finos").setCustomId("snap-test")
+ .setResourceType(ResourceType.ARCHITECTURE).setNumericId(20).build();
+ when(mockMappingStore.getMapping("finos", ResourceType.ARCHITECTURE, "snap-test")).thenReturn(existing);
+ when(mockArchitectureStore.getArchitectureVersions(any(Architecture.class))).thenReturn(List.of("1.0.0"));
+
+ given().header("Content-Type", "application/json")
+ .body(versionedDoc("finos", "architectures", "snap-test", "100")).when()
+ .post("/calm/namespaces/finos/architectures/snap-test/versions/100")
+ .then().statusCode(409);
+ }
+
@Test
void overwrite_a_standard_snapshot_that_already_exists() throws Exception {
// STANDARD's update arm is only reachable via this snapshot-overwrite path: PUT
From b5da4ba1c0fed99610e1508b750bf5a7c24e342c Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Sat, 19 Sep 2026 15:05:32 +0100
Subject: [PATCH 20/22] test(calm-hub): assert Timeline and Control reject
snapshot versions
TimelineResource and ControlResource already keep the strict
VERSION_REGEX (snapshots are scoped to the five namespace resource
types only), but that boundary had no test coverage of its own and
could regress silently.
---
.../resources/TestControlResourceShould.java | 46 +++++++++++++++++++
.../resources/TestTimelineResourceShould.java | 40 ++++++++++++++++
2 files changed, 86 insertions(+)
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java
index 2e8e39934..279ab7f46 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java
@@ -207,6 +207,18 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_get_requireme
.body(containsString(VERSION_MESSAGE));
}
+ @Test
+ void return_a_400_when_a_snapshot_version_is_provided_on_get_requirement_for_version() {
+ // Control endpoints keep the strict VERSION_REGEX -- snapshots are scoped to the five
+ // namespace resource types only, and must stay refused here.
+ given()
+ .when()
+ .get("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/requirement/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString(VERSION_MESSAGE));
+ }
+
static Stream provideParametersForGetRequirementTests() {
return Stream.of(
Arguments.of(INVALID_DOMAIN, new DomainNotFoundException(INVALID_DOMAIN), 404),
@@ -384,6 +396,16 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_get_configura
.body(containsString(VERSION_MESSAGE));
}
+ @Test
+ void return_a_400_when_a_snapshot_version_is_provided_on_get_configuration_for_version() {
+ given()
+ .when()
+ .get("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/configurations/10/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString(VERSION_MESSAGE));
+ }
+
static Stream provideParametersForGetConfigurationForVersionTests() {
return Stream.of(
Arguments.of(INVALID_DOMAIN, new DomainNotFoundException(INVALID_DOMAIN), 404),
@@ -447,6 +469,18 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_create_requir
.body(containsString(VERSION_MESSAGE));
}
+ @Test
+ void return_a_400_when_a_snapshot_version_is_provided_on_create_requirement_version() {
+ given()
+ .header("Content-Type", "application/json")
+ .body(new CreateControlRequirement("n", "d", "{}"))
+ .when()
+ .post("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/requirement/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString(VERSION_MESSAGE));
+ }
+
static Stream provideParametersForCreateRequirementVersionTests() {
return Stream.of(
Arguments.of(new DomainNotFoundException(INVALID_DOMAIN), 404),
@@ -575,6 +609,18 @@ void return_a_400_when_an_invalid_format_of_version_is_provided_on_create_config
.body(containsString(VERSION_MESSAGE));
}
+ @Test
+ void return_a_400_when_a_snapshot_version_is_provided_on_create_configuration_version() {
+ given()
+ .header("Content-Type", "application/json")
+ .body(new CreateControlConfiguration("{}"))
+ .when()
+ .post("/api/calm/domains/" + VALID_DOMAIN + "/controls/1/configurations/10/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString(VERSION_MESSAGE));
+ }
+
static Stream provideParametersForCreateConfigurationVersionTests() {
return Stream.of(
Arguments.of(new DomainNotFoundException(INVALID_DOMAIN), 404),
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java
index e3630f748..62ab4be24 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java
@@ -209,6 +209,18 @@ void return_400_error_when_version_is_not_valid_when_getting_timeline_version()
.body(containsString(VERSION_MESSAGE));
}
+ @Test
+ void return_400_when_a_snapshot_version_is_provided_on_get_timeline_version() {
+ // Timeline endpoints keep the strict VERSION_REGEX -- snapshots are scoped to the five
+ // namespace resource types only, and must stay refused here.
+ given()
+ .when()
+ .get("/api/calm/namespaces/finos/timelines/12/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString(VERSION_MESSAGE));
+ }
+
static Stream provideParametersForGetTimelineTests() {
return Stream.of(
Arguments.of("invalid", new NamespaceNotFoundException(), 404),
@@ -266,6 +278,34 @@ void return_400_error_when_version_is_not_valid_when_creating_new_timeline_versi
.body(containsString(VERSION_MESSAGE));
}
+ @Test
+ void return_400_when_a_snapshot_version_is_provided_on_create_new_timeline_version() {
+ String envelopeBody = "{\"name\":\"n\",\"description\":\"d\",\"timelineJson\":\"{ \\\"moments\\\": [] }\"}";
+
+ given()
+ .header("Content-Type", "application/json")
+ .body(envelopeBody)
+ .when()
+ .post("/api/calm/namespaces/test/timelines/20/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString(VERSION_MESSAGE));
+ }
+
+ @Test
+ void return_400_when_a_snapshot_version_is_provided_on_put_timeline_version() {
+ // Bean validation on the {version} path param runs before allow.put.operations is
+ // checked, so this still 400s even with PUT disabled by default.
+ given()
+ .header("Content-Type", "application/json")
+ .body("{\"name\":\"n\",\"description\":\"d\",\"timelineJson\":\"{ \\\"moments\\\": [] }\"}")
+ .when()
+ .put("/api/calm/namespaces/test/timelines/20/versions/1.0.0-SNAPSHOT")
+ .then()
+ .statusCode(400)
+ .body(containsString(VERSION_MESSAGE));
+ }
+
static Stream provideParametersForCreateTimelineTests() {
return Stream.of(
Arguments.of(new NamespaceNotFoundException(), 404),
From ab80e10faaf01975f4e240cd775c83b0157c7da8 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Sat, 19 Sep 2026 15:05:37 +0100
Subject: [PATCH 21/22] refactor(calm-hub): derive SNAPSHOT_VERSION_REGEX from
ResourceVersion
SNAPSHOT_VERSION_REGEX restated the -SNAPSHOT literal instead of
concatenating ResourceVersion.SNAPSHOT_SUFFIX, the one place that
spelling should live. It's a compile-time constant, so concatenating it
into the annotation constant expression is legal.
Also drops an unused ResourceVersion import in TestCanonicalVersionShould.
---
.../org/finos/calm/resources/ResourceValidationConstants.java | 3 ++-
.../org/finos/calm/store/util/TestCanonicalVersionShould.java | 1 -
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java b/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java
index 930beffd4..9493b97ca 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/ResourceValidationConstants.java
@@ -1,5 +1,6 @@
package org.finos.calm.resources;
+import org.finos.calm.domain.ResourceVersion;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
@@ -18,7 +19,7 @@ public class ResourceValidationConstants {
// VERSION_REGEX: ADR runs on the NUMERIC version scheme, where a suffixed value is an
// unparseable revision rather than a version.
public static final String SNAPSHOT_VERSION_REGEX =
- "^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)(-SNAPSHOT)?$";
+ "^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)(" + ResourceVersion.SNAPSHOT_SUFFIX + ")?$";
public static final String SNAPSHOT_VERSION_MESSAGE =
"version must match pattern '^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)(-SNAPSHOT)?$'";
// First character must be a letter so slugs are never purely numeric (avoids clash with legacy numeric IDs).
diff --git a/calm-hub/src/test/java/org/finos/calm/store/util/TestCanonicalVersionShould.java b/calm-hub/src/test/java/org/finos/calm/store/util/TestCanonicalVersionShould.java
index f0468a596..7c14d67a6 100644
--- a/calm-hub/src/test/java/org/finos/calm/store/util/TestCanonicalVersionShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/store/util/TestCanonicalVersionShould.java
@@ -1,6 +1,5 @@
package org.finos.calm.store.util;
-import org.finos.calm.domain.ResourceVersion;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
From 8a7cb28cb531717e55fe0051e551d328b319a1f4 Mon Sep 17 00:00:00 2001
From: Will Osborne
Date: Mon, 21 Sep 2026 09:47:56 +0100
Subject: [PATCH 22/22] fix(calm-hub): cleanup excessive coments
---
.../java/org/finos/calm/domain/ResourceVersion.java | 3 +--
.../finos/calm/resources/CalmDocumentParser.java | 8 ++------
.../calm/services/MappingControllerService.java | 9 ++-------
.../TestMappingControllerResourceShould.java | 13 ++++---------
4 files changed, 9 insertions(+), 24 deletions(-)
diff --git a/calm-hub/src/main/java/org/finos/calm/domain/ResourceVersion.java b/calm-hub/src/main/java/org/finos/calm/domain/ResourceVersion.java
index 884a3074a..c420db273 100644
--- a/calm-hub/src/main/java/org/finos/calm/domain/ResourceVersion.java
+++ b/calm-hub/src/main/java/org/finos/calm/domain/ResourceVersion.java
@@ -17,7 +17,6 @@
*/
public final class ResourceVersion {
- /** The fixed, case-sensitive marker of a mutable version. */
public static final String SNAPSHOT_SUFFIX = "-SNAPSHOT";
private ResourceVersion() {
@@ -47,7 +46,7 @@ public static String releaseVersion(String version) {
return version.substring(0, version.length() - SNAPSHOT_SUFFIX.length());
}
- /** @return the snapshot form of a version. Idempotent. */
+ /** @return {@code version} unchanged if it is {@code null} or already a snapshot. */
public static String asSnapshot(String version) {
if (version == null || isSnapshot(version)) {
return version;
diff --git a/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java b/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java
index 01dad0842..8bbff8d60 100644
--- a/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java
+++ b/calm-hub/src/main/java/org/finos/calm/resources/CalmDocumentParser.java
@@ -208,12 +208,8 @@ public CanonicalId parseCanonicalId(String json) throws JsonProcessingException
throw new IllegalArgumentException(
"'versions' is a reserved path segment and cannot be used as a resource name");
}
- // SNAPSHOT_VERSION_REGEX, not VERSION_REGEX: this is the $id-driven sibling of
- // createResourceVersion's path-driven POST, which already accepts a -SNAPSHOT
- // suffix via its @Pattern(SNAPSHOT_VERSION_REGEX) path param. Without this, the two
- // POST /calm entry points for the same five namespace-resource types disagreed on
- // whether a snapshot version is valid. Domain controls use validateVersion (below),
- // not this method, and deliberately keep the strict VERSION_REGEX.
+ // Matches createResourceVersion's path-driven POST, which already accepts -SNAPSHOT;
+ // domain controls use validateVersion instead and deliberately stay strict.
if (!version.matches(SNAPSHOT_VERSION_REGEX)) {
throw new IllegalArgumentException("Invalid version in $id: " + version);
}
diff --git a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
index 66449d66f..e0494d7e2 100644
--- a/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
+++ b/calm-hub/src/main/java/org/finos/calm/services/MappingControllerService.java
@@ -504,9 +504,7 @@ private Response addNewVersion(String namespace, String typePath, String name,
}
// Releases stay immutable. A snapshot is mutable by design, so a repeat POST
// overwrites it — a client never has to know whether it already exists.
- // versions holds canonical spellings, so the raw request spelling must be
- // canonicalised before comparison, exactly as the shadow check above and
- // deleteSnapshotForVersion below already do.
+ // Canonicalised before comparing, as above.
boolean overwriting = versions.contains(CanonicalVersion.of(newVersion));
if (overwriting && !snapshot) {
return CalmResourceErrorResponses.versionAlreadyExistsResponse(
@@ -714,10 +712,7 @@ private void updateVersionedResourceInStore(ResourceType type, String namespace,
* work in progress.
*/
private void deleteSnapshotForVersion(ResourceMapping mapping, String releaseVersion, List versions) {
- // versions holds canonical spellings, so the raw request's release spelling must be
- // canonicalised first — "100"'s snapshot is stored as "1.0.0-SNAPSHOT", which would
- // never match a naive "100-SNAPSHOT" otherwise (see the shadow check above this method's
- // call site, which canonicalises for the same reason).
+ // Canonicalised before comparing, as in the shadow check above this method's call site.
String snapshotVersion = ResourceVersion.asSnapshot(CanonicalVersion.of(releaseVersion));
if (!versions.contains(snapshotVersion)) {
return;
diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
index 9765916a4..4804dd9f0 100644
--- a/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
+++ b/calm-hub/src/test/java/org/finos/calm/resources/TestMappingControllerResourceShould.java
@@ -316,11 +316,8 @@ void overwrite_a_snapshot_that_already_exists() throws Exception {
@Test
void overwrite_a_snapshot_whose_raw_request_spelling_differs_from_the_stored_canonical_spelling() throws Exception {
- // versions holds the canonical spelling ("2.0.0-SNAPSHOT"); the request uses a
- // different accepted spelling ("200-SNAPSHOT") for the same logical version. The
- // create-versus-overwrite decision must canonicalise the raw request before comparing
- // against the stored list, or this lands on the create branch and fails with a 400
- // when the store rejects the duplicate.
+ // "200-SNAPSHOT" must canonicalise to the stored "2.0.0-SNAPSHOT" to be recognised
+ // as an overwrite, or this wrongly falls through to the create branch and fails with 400.
ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
.setNamespace("finos").setCustomId("snap-test")
.setResourceType(ResourceType.ARCHITECTURE).setNumericId(20).build();
@@ -386,10 +383,8 @@ void still_refuse_a_release_version_that_already_exists() throws Exception {
@Test
void refuse_a_release_version_whose_raw_request_spelling_differs_from_the_stored_canonical_spelling() throws Exception {
- // versions holds the canonical spelling ("1.0.0"); the request uses a different
- // accepted spelling ("100") for the same logical release. Without canonicalising the
- // raw request first, this fails to detect the clash, falls through to the create
- // branch and returns a 400 from the store instead of the correct 409.
+ // "100" must canonicalise to the stored "1.0.0" to be recognised as a clash, or
+ // this falls through to the create branch and returns 400 instead of 409.
ResourceMapping existing = new ResourceMapping.ResourceMappingBuilder()
.setNamespace("finos").setCustomId("snap-test")
.setResourceType(ResourceType.ARCHITECTURE).setNumericId(20).build();