Skip to content

Commit 695d1f3

Browse files
committed
Merge FR-008 §2.6 Java + C# + Python api-contract conformance runners (15/15 cross-port 10/10)
All 3 remaining backends now pass the cross-port API contract conformance corpus 10/10: - Java: server/java/integration-tests/.../api/ApiContractConformanceTest.java + AuthorApiServer.java (JDK HttpServer + JDBC). Module: 22/22 (12 + 10 new). - C#: server/csharp/MetaObjects.IntegrationTests/Api/ApiContractConformanceTest.cs + AuthorApiServer.cs (HttpListener + Npgsql). Uses YamlStream representation model (not Deserialize<object>) to preserve YAML 1.2 scalar inference — Quoted scalars stay strings; plain scalars get inferred type. IntegrationTests: 22/22 (12 + 10 new). - Python: server/python/tests/integration/test_api_contract.py + FastAPI TestClient + pg8000. Added [integration] extra to pyproject.toml. Full suite: 460 pass + 1 pre-existing unrelated failure. CONFORMANCE.md updated — every port now shows 10/10 for api-contract corpus. All 5 ports (TS + Java + Kotlin + C# + Python) share the verified cross-port REST API contract. The corpus IS the contract; any backend whose routes fail this corpus diverges from the cross-port shape. # Conflicts: # server/python/pyproject.toml
2 parents 7501c47 + ce4a756 commit 695d1f3

15 files changed

Lines changed: 2370 additions & 9 deletions

docs/CONFORMANCE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,18 @@ human-readable explanation somewhere, look it up in the
1919
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | 31 / 31 | 31 / 31 | inherits via Java | 31 / 31 | 31 / 31 |
2020
| [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 4 | 4 / 4 | 4 / 4 | inherits via Java | 4 / 4 | 4 / 4 |
2121
| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 12 (9 query + 3 migration) | 12 / 12 | 12 / 12 | 12 / 12 (via Exposed) | 12 / 12 | 12 / 12 |
22-
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 10 | 10 / 10 (Fastify reference runner) | not yet wired | 10 / 10 (embedded HTTP + Exposed reference runner) | not yet wired | not yet wired |
22+
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 10 | 10 / 10 (Fastify reference runner) | 10 / 10 (embedded HTTP + JDBC reference runner) | 10 / 10 (embedded HTTP + Exposed reference runner) | 10 / 10 (HttpListener + Npgsql reference runner) | 10 / 10 (FastAPI + pg8000 reference runner) |
2323
| `fixtures/codegen-conformance/` (FR-007 — DROPPED in favor of `persistence-conformance` participation) | 0 | n/a | n/a | n/a | n/a | n/a |
2424

2525
Per-port runners + commands:
2626

2727
| Port | Metamodel + YAML + render + verify | Persistence | API contract |
2828
|---|---|---|---|
2929
| TypeScript | `cd server/typescript && bun test` (per-package, `~3s`) | `scripts/integration-test.sh ts` (needs Docker) | `cd server/typescript/packages/integration-tests && bun test test/api-contract.test.ts` (needs Docker) |
30-
| Java | `mvn -pl metaobjects-conformance test` (and per-tier `-pl render`, etc.) | `scripts/integration-test.sh java` (needs Docker) | not yet wired |
30+
| Java | `mvn -pl metaobjects-conformance test` (and per-tier `-pl render`, etc.) | `scripts/integration-test.sh java` (needs Docker) | `mvn -f server/java/integration-tests/pom.xml test -Dtest=ApiContractConformanceTest` (needs Docker) |
3131
| Kotlin | `mvn -pl codegen-kotlin test` (snapshot suite) | `mvn -pl integration-tests-kotlin test` (needs Docker) | `mvn -f server/java/integration-tests-kotlin/pom.xml test -Dtest=ApiContractConformanceTest` (needs Docker) |
32-
| C# | `dotnet test` (per project) | `scripts/integration-test.sh csharp` (needs Docker) | not yet wired |
33-
| Python | `pytest` (per package) | `scripts/integration-test.sh python` (needs Docker) | not yet wired |
32+
| C# | `dotnet test` (per project) | `scripts/integration-test.sh csharp` (needs Docker) | `dotnet test server/csharp/MetaObjects.IntegrationTests/MetaObjects.IntegrationTests.csproj --filter "FullyQualifiedName~ApiContractConformanceTest"` (needs Docker) |
33+
| Python | `pytest` (per package) | `scripts/integration-test.sh python` (needs Docker) | `cd server/python && uv run --extra integration pytest tests/integration/test_api_contract.py` (needs Docker) |
3434

3535
The `persistence-conformance` corpus is intentionally **on-demand** — none of the
3636
unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// ApiContractAssertions — assertion vocabulary for api-contract-conformance.
2+
// The recognized keys (equals / length / ids / names / row / hasId / envelope /
3+
// error / empty) are the cross-port contract — every per-port runner must
4+
// implement them identically. See
5+
// fixtures/api-contract-conformance/README.md.
6+
7+
namespace MetaObjects.IntegrationTests.Api;
8+
9+
internal static class ApiContractAssertions
10+
{
11+
public static void AssertResponse(string scenarioName, ApiRequest request, int status, object? body)
12+
{
13+
if (status != request.ExpectStatus)
14+
throw new Xunit.Sdk.XunitException(
15+
$"{scenarioName} / {request.Id}: expected status {request.ExpectStatus}, got {status}; body: {Render(body)}");
16+
17+
var want = request.ExpectBody;
18+
if (want is null) return;
19+
20+
if (want.TryGetValue("empty", out var emp) && emp is bool eb && eb)
21+
{
22+
if (!IsEmpty(body))
23+
throw new Xunit.Sdk.XunitException(
24+
$"{scenarioName} / {request.Id}: expected empty body, got: {Render(body)}");
25+
return;
26+
}
27+
if (want.TryGetValue("equals", out var eq) && eq is not null)
28+
{
29+
if (!StructuralEquals(body, eq))
30+
throw new Xunit.Sdk.XunitException(
31+
$"{scenarioName} / {request.Id}: body mismatch\n expected: {Render(eq)}\n actual: {Render(body)}");
32+
return;
33+
}
34+
if (want.TryGetValue("envelope", out var env) && env is bool envB && envB)
35+
{
36+
if (body is not Dictionary<string, object?> map)
37+
throw new Xunit.Sdk.XunitException(
38+
$"{scenarioName} / {request.Id}: expected envelope {{ rows, total }}, got: {Render(body)}");
39+
if (!map.TryGetValue("rows", out var rowsObj) || rowsObj is not List<object?> rows)
40+
throw new Xunit.Sdk.XunitException(
41+
$"{scenarioName} / {request.Id}: envelope.rows missing or not a list; got: {Render(body)}");
42+
if (!map.TryGetValue("total", out var totalObj) || totalObj is null)
43+
throw new Xunit.Sdk.XunitException(
44+
$"{scenarioName} / {request.Id}: envelope.total missing; got: {Render(body)}");
45+
long total = Convert.ToInt64(totalObj);
46+
if (want.TryGetValue("rowsLength", out var wantLenObj) && wantLenObj is not null)
47+
{
48+
int wantLen = Convert.ToInt32(wantLenObj);
49+
if (rows.Count != wantLen)
50+
throw new Xunit.Sdk.XunitException(
51+
$"{scenarioName} / {request.Id}: expected rows.length={wantLen}, got {rows.Count}");
52+
}
53+
if (want.TryGetValue("total", out var wantTotalObj) && wantTotalObj is not null)
54+
{
55+
long wantTotal = Convert.ToInt64(wantTotalObj);
56+
if (total != wantTotal)
57+
throw new Xunit.Sdk.XunitException(
58+
$"{scenarioName} / {request.Id}: expected total={wantTotal}, got {total}");
59+
}
60+
return;
61+
}
62+
if (want.TryGetValue("error", out var errObj) && errObj is string wantErr)
63+
{
64+
string? actual = null;
65+
if (body is Dictionary<string, object?> bm
66+
&& bm.TryGetValue("error", out var e) && e is string s) actual = s;
67+
if (!wantErr.Equals(actual))
68+
throw new Xunit.Sdk.XunitException(
69+
$"{scenarioName} / {request.Id}: expected error=\"{wantErr}\", got: {Render(body)}");
70+
return;
71+
}
72+
if (want.TryGetValue("length", out var lenObj) && lenObj is not null)
73+
{
74+
int wantLen = Convert.ToInt32(lenObj);
75+
if (body is not List<object?> list)
76+
throw new Xunit.Sdk.XunitException(
77+
$"{scenarioName} / {request.Id}: expected array, got: {Render(body)}");
78+
if (list.Count != wantLen)
79+
throw new Xunit.Sdk.XunitException(
80+
$"{scenarioName} / {request.Id}: expected length={wantLen}, got {list.Count}");
81+
}
82+
if (want.TryGetValue("ids", out var idsObj) && idsObj is List<object?> wantIds)
83+
{
84+
if (body is not List<object?> list)
85+
throw new Xunit.Sdk.XunitException(
86+
$"{scenarioName} / {request.Id}: expected array, got: {Render(body)}");
87+
var actualIds = list.Select(it =>
88+
it is Dictionary<string, object?> m && m.TryGetValue("id", out var idV) && idV is not null
89+
? (long?)Convert.ToInt64(idV)
90+
: null).ToList();
91+
var wantLongs = wantIds.Select(it => it is null ? (long?)null : (long?)Convert.ToInt64(it)).ToList();
92+
if (!actualIds.SequenceEqual(wantLongs))
93+
throw new Xunit.Sdk.XunitException(
94+
$"{scenarioName} / {request.Id}: expected ids [{string.Join(", ", wantLongs)}], got [{string.Join(", ", actualIds)}]");
95+
}
96+
if (want.TryGetValue("names", out var namesObj) && namesObj is List<object?> wantNames)
97+
{
98+
if (body is not List<object?> list)
99+
throw new Xunit.Sdk.XunitException(
100+
$"{scenarioName} / {request.Id}: expected array, got: {Render(body)}");
101+
var actualNames = list.Select(it =>
102+
it is Dictionary<string, object?> m && m.TryGetValue("name", out var n) ? n?.ToString() : null).ToList();
103+
var wantStrs = wantNames.Select(it => it?.ToString()).ToList();
104+
if (!actualNames.SequenceEqual(wantStrs))
105+
throw new Xunit.Sdk.XunitException(
106+
$"{scenarioName} / {request.Id}: expected names [{string.Join(", ", wantStrs)}], got [{string.Join(", ", actualNames)}]");
107+
}
108+
if (want.TryGetValue("row", out var rowObj) && rowObj is Dictionary<string, object?> wantRow)
109+
{
110+
if (body is not Dictionary<string, object?> row)
111+
throw new Xunit.Sdk.XunitException(
112+
$"{scenarioName} / {request.Id}: expected object, got: {Render(body)}");
113+
foreach (var kvp in wantRow)
114+
{
115+
row.TryGetValue(kvp.Key, out var actual);
116+
if (!StructuralEquals(actual, kvp.Value))
117+
throw new Xunit.Sdk.XunitException(
118+
$"{scenarioName} / {request.Id}: expected row.{kvp.Key}={Render(kvp.Value)}, got {Render(actual)}");
119+
}
120+
}
121+
if (want.TryGetValue("hasId", out var hasIdObj) && hasIdObj is bool hi && hi)
122+
{
123+
if (body is not Dictionary<string, object?> row)
124+
throw new Xunit.Sdk.XunitException(
125+
$"{scenarioName} / {request.Id}: expected object, got: {Render(body)}");
126+
if (!row.TryGetValue("id", out var id) || id is null
127+
|| (id is not int && id is not long && id is not double && id is not decimal))
128+
throw new Xunit.Sdk.XunitException(
129+
$"{scenarioName} / {request.Id}: expected numeric id in body, got: {Render(body)}");
130+
}
131+
}
132+
133+
/// <summary>
134+
/// Structural equality that normalizes numeric subtypes (int/long/etc are equal
135+
/// when their long value matches), recurses into maps + lists, and treats
136+
/// missing keys as null.
137+
/// </summary>
138+
private static bool StructuralEquals(object? a, object? b)
139+
{
140+
if (a is null && b is null) return true;
141+
if (a is null || b is null) return false;
142+
if (IsNumber(a) && IsNumber(b)) return Convert.ToInt64(a) == Convert.ToInt64(b);
143+
if (a is List<object?> al && b is List<object?> bl)
144+
{
145+
if (al.Count != bl.Count) return false;
146+
for (int i = 0; i < al.Count; i++)
147+
if (!StructuralEquals(al[i], bl[i])) return false;
148+
return true;
149+
}
150+
if (a is Dictionary<string, object?> am && b is Dictionary<string, object?> bm)
151+
{
152+
// Two-way key check.
153+
foreach (var k in am.Keys) if (!bm.ContainsKey(k)) return false;
154+
foreach (var k in bm.Keys) if (!am.ContainsKey(k)) return false;
155+
foreach (var k in am.Keys) if (!StructuralEquals(am[k], bm[k])) return false;
156+
return true;
157+
}
158+
return a.Equals(b);
159+
}
160+
161+
private static bool IsNumber(object o) =>
162+
o is sbyte or byte or short or ushort or int or uint or long or ulong;
163+
164+
private static bool IsEmpty(object? body) => body switch
165+
{
166+
null => true,
167+
string s => s.Length == 0,
168+
Dictionary<string, object?> m => m.Count == 0,
169+
List<object?> l => l.Count == 0,
170+
_ => false,
171+
};
172+
173+
private static string Render(object? o)
174+
{
175+
if (o is null) return "null";
176+
if (o is string s) return $"\"{s}\"";
177+
if (o is List<object?> l) return "[" + string.Join(", ", l.Select(Render)) + "]";
178+
if (o is Dictionary<string, object?> m)
179+
return "{" + string.Join(", ", m.Select(kvp => $"{kvp.Key}={Render(kvp.Value)}")) + "}";
180+
return o.ToString() ?? "null";
181+
}
182+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// ApiContractConformanceTest — C# reference runner for the cross-port
2+
// api-contract-conformance corpus.
3+
//
4+
// One xUnit Theory invocation per scenario YAML under
5+
// fixtures/api-contract-conformance/scenarios/. Each invocation spins up a
6+
// fresh Postgres testcontainer + AuthorApiServer, walks the scenario's
7+
// requests, and asserts each response against the cross-port assertion
8+
// vocabulary (equals / length / ids / names / row / hasId / envelope /
9+
// error / empty). Mirror of ApiContractConformanceTest.java in
10+
// server/java/integration-tests.
11+
//
12+
// Run on-demand:
13+
// dotnet test server/csharp/MetaObjects.IntegrationTests/MetaObjects.IntegrationTests.csproj
14+
// # or, focused:
15+
// dotnet test server/csharp/MetaObjects.IntegrationTests/MetaObjects.IntegrationTests.csproj \
16+
// --filter "FullyQualifiedName~ApiContractConformanceTest"
17+
18+
using System.Text.Json;
19+
using System.Text.Json.Nodes;
20+
using MetaObjects.IntegrationTests.Runner;
21+
using Xunit;
22+
23+
namespace MetaObjects.IntegrationTests.Api;
24+
25+
public sealed class ApiContractConformanceTest
26+
{
27+
private static readonly Lazy<IReadOnlyList<Dictionary<string, object?>>> SeedRows =
28+
new(LoadSeedRows);
29+
30+
[Theory]
31+
[MemberData(nameof(Scenarios))]
32+
public async Task Api_contract_scenario(string scenarioPath)
33+
{
34+
var scenario = ApiContractScenarioLoader.LoadScenario(scenarioPath);
35+
await using var pg = await PostgresContainer.StartAsync();
36+
await using var server = await AuthorApiServer.StartAsync(pg);
37+
if (scenario.Truncate) await server.TruncateAsync();
38+
else await server.ApplySeedAsync(SeedRows.Value);
39+
40+
using var client = new HttpClient { BaseAddress = new Uri(server.BaseUrl) };
41+
foreach (var req in scenario.Requests)
42+
{
43+
var request = new HttpRequestMessage(new HttpMethod(req.Method), req.Path);
44+
if (req.Body is not null)
45+
{
46+
string json = JsonSerializer.Serialize(req.Body, JsonOpts);
47+
request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
48+
}
49+
var response = await client.SendAsync(request);
50+
string bodyText = await response.Content.ReadAsStringAsync();
51+
object? parsed = string.IsNullOrEmpty(bodyText) ? null : ToObject(JsonNode.Parse(bodyText));
52+
ApiContractAssertions.AssertResponse(scenario.Name, req, (int)response.StatusCode, parsed);
53+
}
54+
}
55+
56+
public static IEnumerable<object[]> Scenarios() =>
57+
Directory.EnumerateFiles(ApiContractCorpusPaths.ScenariosDir, "*.yaml", SearchOption.TopDirectoryOnly)
58+
.OrderBy(p => p, StringComparer.Ordinal)
59+
.Select(p => new object[] { p });
60+
61+
private static readonly JsonSerializerOptions JsonOpts = new()
62+
{
63+
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never,
64+
};
65+
66+
private static IReadOnlyList<Dictionary<string, object?>> LoadSeedRows()
67+
{
68+
string text = File.ReadAllText(ApiContractCorpusPaths.SeedFile);
69+
var root = JsonNode.Parse(text) as JsonObject
70+
?? throw new InvalidOperationException("seed.json: top-level must be an object");
71+
var rowsNode = root["rows"] as JsonArray
72+
?? throw new InvalidOperationException("seed.json: missing 'rows' array");
73+
74+
var rows = new List<Dictionary<string, object?>>(rowsNode.Count);
75+
foreach (var row in rowsNode)
76+
{
77+
if (row is not JsonObject obj)
78+
throw new InvalidOperationException("seed.json: each row must be an object");
79+
var d = new Dictionary<string, object?>(StringComparer.Ordinal);
80+
foreach (var kvp in obj) d[kvp.Key] = ToObject(kvp.Value);
81+
rows.Add(d);
82+
}
83+
return rows;
84+
}
85+
86+
private static object? ToObject(JsonNode? node)
87+
{
88+
if (node is null) return null;
89+
if (node is JsonObject obj)
90+
{
91+
var d = new Dictionary<string, object?>(StringComparer.Ordinal);
92+
foreach (var kvp in obj) d[kvp.Key] = ToObject(kvp.Value);
93+
return d;
94+
}
95+
if (node is JsonArray arr)
96+
{
97+
var l = new List<object?>(arr.Count);
98+
foreach (var item in arr) l.Add(ToObject(item));
99+
return l;
100+
}
101+
if (node is JsonValue jv)
102+
{
103+
if (jv.TryGetValue<bool>(out var b)) return b;
104+
if (jv.TryGetValue<long>(out var lv)) return lv;
105+
if (jv.TryGetValue<double>(out var dv)) return dv;
106+
if (jv.TryGetValue<string>(out var sv)) return sv;
107+
return jv.ToString();
108+
}
109+
return null;
110+
}
111+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// ApiContractCorpusPaths — locate the api-contract-conformance corpus directory
2+
// relative to the test assembly. Mirrors the Java runner's findCorpusRoot()
3+
// walk so the fixture path resolves identically across ports.
4+
5+
namespace MetaObjects.IntegrationTests.Api;
6+
7+
internal static class ApiContractCorpusPaths
8+
{
9+
// Test assemblies run from bin/Debug/net8.0; the corpus lives 6 levels up
10+
// at the repo root. AppContext.BaseDirectory points at the bin dir.
11+
public static readonly string Repo = Path.GetFullPath(
12+
Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", ".."));
13+
14+
public static readonly string Corpus = Path.Combine(Repo, "fixtures", "api-contract-conformance");
15+
public static readonly string ScenariosDir = Path.Combine(Corpus, "scenarios");
16+
public static readonly string SeedFile = Path.Combine(Corpus, "seed.json");
17+
}

0 commit comments

Comments
 (0)