From a81234debdf0435d252c268c570e56c624db6014 Mon Sep 17 00:00:00 2001 From: opendpp-node Date: Wed, 9 Sep 2026 22:57:27 +0200 Subject: [PATCH] The live IT compiles against a client generated from either contract, so a node-side spec change cannot block its own release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Java live IT is hand-written against the GENERATED client, and two lanes generate that client from two different specs: this repository's CI from the vendored `openapi.json`, opendpp-node's "SDK regen" gate from its live contract. They can sit a contract apart, so any hand-written reference to a generated signature or getter compiles in one lane and breaks the other. That is what happened. Contract 1.16.0 added the `representation` query parameter to both public resolvers and moved the passport body out of a `metadata` object onto the document root. This file kept compiling here, against the vendored 1.15.0, and stopped compiling in node's gate — four call sites on argument count, one on a getter that no longer exists. Since that gate feeds the required rollup on `release/train-*`, a contract change was blocked by a test in another repository that could not have been green in both places. So the two things that differ by contract are no longer named at compile time: - `resolve(api, name, key)` finds the resolver by NAME and passes `null` for every parameter after the key. Those are all optional on the wire — a grant token, and since 1.16.0 the representation flag — so the request is the same one the two-argument form made: anonymous tier, default compressed document. It unwraps `InvocationTargetException` so the 404 case still asserts on a typed `ApiException` rather than a reflection wrapper. - The old `getMetadata()` assertion is split. What it was really testing — that untyped JSON survives Jackson — is now asserted through `@context`, which both contracts declare as a relaxed Object, by requiring a structured value rather than a flattened string. The map check itself is kept opportunistically through `optionalMap`, so contracts up to 1.15.0 still prove the body is non-empty; on 1.16.0 the property is gone and the body's elements are root members with no generated getter, so there is nothing there to read. Everything asserted unconditionally is now true of both contracts, and the file header says so, so the next contract change does not rediscover this. Verified both directions with node's own gate script (`scripts/sdk-regen-verify.ts … --only=java`), which is what CI runs: against a client generated from the live 1.16.0 contract it failed on exactly the five reported errors before this change and passes after, regenerating, building and testing at 1.16.0. Against the vendored 1.15.0 in this tree, `./gradlew build` passes unchanged. Only the test file is touched — no regenerated client and no version bump, which stay the mirror sync's to write. --- .../eu/opendppnode/sdk/OpenDppLiveIT.java | 89 +++++++++++++++++-- 1 file changed, 80 insertions(+), 9 deletions(-) diff --git a/java/src/test/java/eu/opendppnode/sdk/OpenDppLiveIT.java b/java/src/test/java/eu/opendppnode/sdk/OpenDppLiveIT.java index 4301607..f482f7f 100644 --- a/java/src/test/java/eu/opendppnode/sdk/OpenDppLiveIT.java +++ b/java/src/test/java/eu/opendppnode/sdk/OpenDppLiveIT.java @@ -7,7 +7,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -21,6 +20,9 @@ import eu.opendppnode.sdk.model.MerkleTreeAttestationProof; import eu.opendppnode.sdk.model.PublicPassportJsonLd; import eu.opendppnode.sdk.model.ServiceVersion; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; @@ -34,6 +36,21 @@ * Opt-in (network + public rate limits): {@code OPENDPP_LIVE_TEST=1 ./gradlew test}. Uses only * public endpoints (no API key) and a curated, stable demo passport listed in the public sitemap. * Stays well under the 30 req/min public-resolution limit. + * + *

SPEC-TOLERANT BY DESIGN, and it has to be: this file is compiled against the client generated + * from whichever spec is in play, and there are two. This repository's own CI generates from the + * VENDORED {@code openapi.json}, while opendpp-node's "SDK regen" gate generates from its LIVE + * contract — so at any moment the two can be a contract apart. A hand-written test that names a + * generated signature or a generated getter therefore breaks the OTHER lane, which is exactly what + * happened when contract 1.16.0 added the {@code representation} query parameter to the two public + * resolvers and moved the passport body out of a {@code metadata} object onto the document root: this + * file stopped compiling in node's gate and blocked that release, while remaining green here. + * + *

So resolver calls go through {@link #resolve} — which finds the method by NAME and supplies + * {@code null} for every parameter after the key, i.e. no grant and the default representation — and + * an assertion about a field only one spec declares is made through {@link #optionalMap}. Both keep + * this file compiling against a client generated from either contract. Anything asserted + * unconditionally below must be true of BOTH. */ @EnabledIfEnvironmentVariable(named = "OPENDPP_LIVE_TEST", matches = "1") class OpenDppLiveIT { @@ -42,6 +59,49 @@ class OpenDppLiveIT { private final ApiClient client = OpenDpp.client(); + /** + * Call a generated resolver by name, whatever its arity. The key is the first parameter in every + * contract; everything after it is optional on the wire (a grant token, and since 1.16.0 the + * {@code representation} flag), so passing {@code null} asks for the anonymous tier and the + * default compressed document — the same request the two-argument form used to make. + */ + private PublicPassportJsonLd resolve(PublicResolutionApi api, String name, String key) throws ApiException { + Method m = Arrays.stream(PublicResolutionApi.class.getMethods()) + .filter(x -> x.getName().equals(name) && x.getReturnType() == PublicPassportJsonLd.class) + .findFirst() + .orElseThrow(() -> new AssertionError( + "the generated client has no " + name + " returning PublicPassportJsonLd — has the operation been renamed?")); + Object[] args = new Object[m.getParameterCount()]; + args[0] = key; + try { + return (PublicPassportJsonLd) m.invoke(api, args); + } catch (InvocationTargetException e) { + // Surface the real failure: the 404 case below asserts on a typed ApiException. + if (e.getCause() instanceof ApiException cause) { + throw cause; + } + if (e.getCause() instanceof RuntimeException cause) { + throw cause; + } + throw new AssertionError(name + " failed", e.getCause()); + } catch (ReflectiveOperationException e) { + throw new AssertionError("cannot invoke " + name, e); + } + } + + /** + * Read a {@code Map}-valued getter that only SOME contracts declare, without naming it at compile + * time. Returns {@code null} when this client's model has no such property. + */ + private Map optionalMap(Object model, String getter) { + try { + Object value = model.getClass().getMethod(getter).invoke(model); + return value instanceof Map map ? map : null; + } catch (ReflectiveOperationException e) { + return null; + } + } + @Test void healthAndVersionRoundTrip() throws ApiException { ServiceApi service = new ServiceApi(client); @@ -57,7 +117,7 @@ void healthAndVersionRoundTrip() throws ApiException { @Test void resolvesDemoPassportIntoTypedModel() throws ApiException { - PublicPassportJsonLd passport = new PublicResolutionApi(client).resolvePublicPassport(DEMO_PASSPORT_ID, null); + PublicPassportJsonLd passport = resolve(new PublicResolutionApi(client), "resolvePublicPassport", DEMO_PASSPORT_ID); // Identity + typed fields survived deserialization. assertEquals(DEMO_PASSPORT_ID, passport.getId(), "id"); @@ -65,10 +125,21 @@ void resolvesDemoPassportIntoTypedModel() throws ApiException { assertNotNull(passport.getAtContext(), "@context (relaxed to Object) should still carry the value"); assertNotNull(passport.getCreatedAt(), "createdAt should parse as OffsetDateTime"); - // The free-form metadata object deserializes as a non-empty JSON map. - Object metadata = passport.getMetadata(); - Map metadataMap = assertInstanceOf(Map.class, metadata, "metadata"); - assertFalse(metadataMap.isEmpty(), "demo passport metadata should be non-empty"); + // Untyped JSON survives deserialization. This is what the old `getMetadata()` assertion was + // really about, and it is asserted here through a field BOTH contracts declare: `@context` is + // relaxed to Object in the spec, so a structured value arriving intact proves Jackson handled + // an untyped shape rather than flattening it to a string. + assertTrue(passport.getAtContext() instanceof Map || passport.getAtContext() instanceof java.util.List, + "@context should deserialize as a structured untyped value, got " + passport.getAtContext().getClass()); + + // And where the client's model still declares `metadata` — contracts up to 1.15.0, before the + // body moved onto the document root — the map must be non-empty. Absent on 1.16.0+, and then + // the body's own elements are root members with no generated getter, so there is nothing to + // read here: the untyped-deserialization claim above is the part that holds either way. + Map legacyMetadata = optionalMap(passport, "getMetadata"); + if (legacyMetadata != null) { + assertFalse(legacyMetadata.isEmpty(), "demo passport metadata should be non-empty"); + } // Enum tolerance: the live status must parse into a KNOWN constant, not the unknown sentinel. assertNotNull(passport.getStatus(), "status enum"); @@ -84,13 +155,13 @@ void resolvesDemoPassportIntoTypedModel() throws ApiException { @Test void resolvesTheSamePassportThroughTheGs1Path() throws ApiException { PublicResolutionApi resolution = new PublicResolutionApi(client); - PublicPassportJsonLd byId = resolution.resolvePublicPassport(DEMO_PASSPORT_ID, null); + PublicPassportJsonLd byId = resolve(resolution, "resolvePublicPassport", DEMO_PASSPORT_ID); String gtin = byId.getProductId(); assertTrue(gtin != null && gtin.matches("\\d{14}"), "demo battery passport should be GS1-keyed (14-digit GTIN), got " + gtin); - PublicPassportJsonLd byGtin = resolution.resolveGs1Gtin(gtin, null); + PublicPassportJsonLd byGtin = resolve(resolution, "resolveGs1Gtin", gtin); assertEquals(byId.getId(), byGtin.getId(), "GS1 Digital Link resolution should land on the same passport"); } @@ -98,7 +169,7 @@ void resolvesTheSamePassportThroughTheGs1Path() throws ApiException { @Test void missingPassportSurfacesTypedApiException() { ApiException e = assertThrows(ApiException.class, - () -> new PublicResolutionApi(client).resolvePublicPassport("definitely-not-a-passport-xyz", null)); + () -> resolve(new PublicResolutionApi(client), "resolvePublicPassport", "definitely-not-a-passport-xyz")); assertEquals(404, e.getCode(), "expected a 404 for a missing passport"); assertNotNull(e.getResponseBody(), "error body should be captured for diagnostics"); }