Skip to content

Commit d13bfc1

Browse files
dmealingclaude
andcommitted
feat(java): metaJson helper for the GET /_meta contract, reachable from Kotlin
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NrWUxA6euwvnFcwKxcK6A
1 parent 19ad4b3 commit d13bfc1

3 files changed

Lines changed: 201 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.metaobjects.metadata.ktx
2+
3+
import com.metaobjects.io.json.CanonicalJsonSerializer
4+
import com.metaobjects.io.json.MetaEndpoint
5+
import kotlin.test.Test
6+
import kotlin.test.assertEquals
7+
8+
/**
9+
* Kotlin consumes the Java [MetaEndpoint] symbol directly — there is no separate Kotlin
10+
* implementation of the GET /_meta contract. These tests prove it is reachable, and behaves
11+
* correctly, from the Kotlin facade.
12+
*/
13+
class MetaEndpointKtxTest {
14+
15+
private val tinyJson = """{ "metadata.root": { "package": "acme", "children": [] } }"""
16+
17+
@Test fun `the route path is the cross-port contract value`() {
18+
assertEquals("/_meta", MetaEndpoint.META_ROUTE_PATH)
19+
}
20+
21+
@Test fun `metaJson is callable from kotlin and matches the effective serialization`() {
22+
val loader = loadString("meta-endpoint-ktx-test", tinyJson)
23+
assertEquals(
24+
CanonicalJsonSerializer.canonicalSerializeEffective(loader.root),
25+
MetaEndpoint.metaJson(loader.root))
26+
}
27+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.metaobjects.io.json;
2+
3+
import com.metaobjects.MetaData;
4+
5+
/**
6+
* UI-1 — the metadata API contract.
7+
*
8+
* <p>Java ships the helper and not a mount: {@code core-spring} carries
9+
* {@code spring-context} but no {@code spring-web}, and adding a web dependency
10+
* for one endpoint would put it on every consumer. Mount it yourself:
11+
*
12+
* <pre>
13+
* &#64;GetMapping(value = "/api" + MetaEndpoint.META_ROUTE_PATH,
14+
* produces = MediaType.APPLICATION_JSON_VALUE)
15+
* public String meta() { return MetaEndpoint.metaJson(root); }
16+
* </pre>
17+
*
18+
* <p>Guard that route with your own authorization — {@code /_meta} publishes the
19+
* shape of the model, though no row data.
20+
*/
21+
public final class MetaEndpoint {
22+
23+
private MetaEndpoint() {}
24+
25+
/**
26+
* The endpoint path, mounted under the host's API prefix. A cross-port contract
27+
* value — one browser read-model works against every backend.
28+
*/
29+
public static final String META_ROUTE_PATH = "/_meta";
30+
31+
/**
32+
* The response body: the model as EFFECTIVE canonical JSON, so a browser reading
33+
* it never resolves {@code extends} itself.
34+
*/
35+
public static String metaJson(MetaData root) {
36+
return CanonicalJsonSerializer.canonicalSerializeEffective(root);
37+
}
38+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package com.metaobjects.io.json;
2+
3+
import com.google.gson.JsonArray;
4+
import com.google.gson.JsonElement;
5+
import com.google.gson.JsonObject;
6+
import com.google.gson.JsonParser;
7+
import com.metaobjects.MetaRoot;
8+
import com.metaobjects.conformance.CorpusRoot;
9+
import com.metaobjects.loader.InMemoryStringSource;
10+
import com.metaobjects.loader.LoaderOptions;
11+
import com.metaobjects.loader.MetaDataLoader;
12+
import org.junit.Test;
13+
14+
import java.io.IOException;
15+
import java.nio.charset.StandardCharsets;
16+
import java.nio.file.Files;
17+
import java.nio.file.Path;
18+
import java.util.List;
19+
20+
import static org.junit.Assert.assertEquals;
21+
import static org.junit.Assert.assertFalse;
22+
import static org.junit.Assert.assertTrue;
23+
24+
/**
25+
* Tests for {@link MetaEndpoint} — the {@code GET /_meta} cross-port contract helper.
26+
*
27+
* <p>Uses the {@code extends-abstract-base} conformance fixture
28+
* ({@code fixtures/conformance/extends-abstract-base/input/meta.common.json}), which has
29+
* real OBJECT-level inheritance: {@code Subscriber extends acme::BaseEntity}, and
30+
* {@code BaseEntity} declares {@code createdAt}. That makes {@code canonicalSerialize}
31+
* (raw — Subscriber's own children only) and {@code canonicalSerializeEffective}
32+
* (Subscriber's children plus everything inherited from BaseEntity) genuinely different
33+
* documents, so equality against the effective form is a real discriminator rather than a
34+
* call-forwarding smoke test.
35+
*/
36+
public class MetaEndpointTest {
37+
38+
@Test
39+
public void metaRoutePathIsTheCrossPortContractValue() {
40+
assertEquals("/_meta", MetaEndpoint.META_ROUTE_PATH);
41+
}
42+
43+
@Test
44+
public void metaJsonReturnsTheEffectiveCanonicalSerialization() throws IOException {
45+
MetaRoot root = loadExtendsAbstractBaseFixture();
46+
47+
String effective = CanonicalJsonSerializer.canonicalSerializeEffective(root);
48+
String raw = CanonicalJsonSerializer.canonicalSerialize(root);
49+
assertFalse("control: the fixture must actually exercise inheritance (raw must differ "
50+
+ "from effective), otherwise this equality proves nothing",
51+
raw.equals(effective));
52+
53+
assertEquals(effective, MetaEndpoint.metaJson(root));
54+
}
55+
56+
/**
57+
* Structural discriminator that cannot be fooled by a substring match: the RAW
58+
* serialization ALSO contains the string "createdAt" (it's BaseEntity's own field),
59+
* so a naive {@code contains("createdAt")} check on the whole document would pass
60+
* even for a helper that (bug) called the raw serializer. This test instead locates
61+
* Subscriber's own "children" array specifically and checks THAT — true only for the
62+
* effective serialization.
63+
*/
64+
@Test
65+
public void metaJsonSubscriberChildrenIncludeInheritedCreatedAt() throws IOException {
66+
MetaRoot root = loadExtendsAbstractBaseFixture();
67+
68+
JsonObject effectiveSubscriber = findObjectByName(MetaEndpoint.metaJson(root), "Subscriber");
69+
assertTrue("effective Subscriber's own children must include the inherited 'createdAt' field",
70+
hasFieldNamed(effectiveSubscriber, "createdAt"));
71+
72+
JsonObject rawSubscriber =
73+
findObjectByName(CanonicalJsonSerializer.canonicalSerialize(root), "Subscriber");
74+
assertFalse("raw Subscriber's own children must NOT include the inherited 'createdAt' field",
75+
hasFieldNamed(rawSubscriber, "createdAt"));
76+
}
77+
78+
// -----------------------------------------------------------------------
79+
// Fixture loading — mirrors the established Java conformance idiom (see
80+
// conformance.StrictRunnerHardFailTest / object.ObjectModelConformanceTest): a fresh
81+
// manual-subtype MetaDataLoader fed the fixture's input file(s) via InMemoryStringSource.
82+
// -----------------------------------------------------------------------
83+
84+
private static MetaRoot loadExtendsAbstractBaseFixture() throws IOException {
85+
Path inputDir = CorpusRoot.locate().resolve("extends-abstract-base").resolve("input");
86+
Path metaFile = inputDir.resolve("meta.common.json");
87+
String content = new String(Files.readAllBytes(metaFile), StandardCharsets.UTF_8);
88+
89+
MetaDataLoader loader = new MetaDataLoader(
90+
LoaderOptions.create(false, false, true),
91+
MetaDataLoader.SUBTYPE_MANUAL, "meta-endpoint-test");
92+
loader.init();
93+
loader.load(List.of(new InMemoryStringSource(content, "meta.common.json")));
94+
95+
assertTrue("fixture load must report no errors, got: " + loader.getErrors(),
96+
loader.getErrors().isEmpty());
97+
98+
return loader.getRoot();
99+
}
100+
101+
/** Finds the named {@code object.*} node anywhere in the document and returns its JSON body. */
102+
private static JsonObject findObjectByName(String json, String objectName) {
103+
JsonElement parsed = JsonParser.parseString(json);
104+
JsonArray topChildren = parsed.getAsJsonObject()
105+
.getAsJsonObject("metadata.root")
106+
.getAsJsonArray("children");
107+
for (JsonElement child : topChildren) {
108+
JsonObject wrapper = child.getAsJsonObject();
109+
for (String key : wrapper.keySet()) {
110+
if (!key.startsWith("object.")) continue;
111+
JsonObject obj = wrapper.getAsJsonObject(key);
112+
if (objectName.equals(obj.get("name").getAsString())) {
113+
return obj;
114+
}
115+
}
116+
}
117+
throw new AssertionError("object '" + objectName + "' not found in: " + json);
118+
}
119+
120+
/** True when the given {@code object.*} body has a {@code field.*} child named {@code fieldName}. */
121+
private static boolean hasFieldNamed(JsonObject objectEntity, String fieldName) {
122+
JsonArray children = objectEntity.getAsJsonArray("children");
123+
if (children == null) return false;
124+
for (JsonElement child : children) {
125+
JsonObject wrapper = child.getAsJsonObject();
126+
for (String key : wrapper.keySet()) {
127+
if (!key.startsWith("field.")) continue;
128+
JsonObject field = wrapper.getAsJsonObject(key);
129+
if (fieldName.equals(field.get("name").getAsString())) {
130+
return true;
131+
}
132+
}
133+
}
134+
return false;
135+
}
136+
}

0 commit comments

Comments
 (0)