Skip to content

Commit 2a5c500

Browse files
committed
Add EnvelopeCodec methods and conformance suite with fixtures
1 parent 3461ab3 commit 2a5c500

11 files changed

Lines changed: 380 additions & 0 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ The envelope wire format is versioned separately by `meta.schema_version`
1414
delivery, `basic_get` + manual ack, and the contract AMQP properties (`type`=URN,
1515
`correlation_id`=trace_id, `x-schema-version`/`x-source-lang`/`x-attempts`).
1616
Optional `[amqp]` extra (lazy `pika` import) — the core stays zero-dep.
17+
- `EnvelopeCodec.urn()` — resolve the URN (`job`, accepting `urn` as an alias).
18+
- `EnvelopeCodec.accepts()` — consumer-side envelope validation (rejects empty URN,
19+
unsupported `meta.schema_version`, blank `trace_id`, non-object `data`).
20+
- Shared **cross-SDK conformance suite** under `tests/conformance/` (vendored from
21+
the canonical `conformance/` set) plus a `test_conformance.py` runner.
1722

1823
## [0.2.0] - 2026-06-06
1924

src/babelqueue/codec.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,36 @@ def decode(raw: str) -> Dict[str, Any]:
9494
except (ValueError, TypeError):
9595
return {}
9696
return decoded if isinstance(decoded, dict) else {}
97+
98+
@staticmethod
99+
def urn(envelope: Mapping[str, Any]) -> str:
100+
"""The message URN: canonical ``job``, with ``urn`` accepted as an alias."""
101+
return str(envelope.get("job") or envelope.get("urn") or "")
102+
103+
@staticmethod
104+
def accepts(envelope: Mapping[str, Any]) -> bool:
105+
"""Whether a consumer should accept this envelope (consumer-side validation).
106+
107+
Rejects messages with no URN, an unsupported ``meta.schema_version``, a
108+
missing/blank ``trace_id``, or a non-object ``data`` / non-integer
109+
``attempts``. (Accepts the ``urn`` alias, unlike the producer JSON Schema.)
110+
"""
111+
if EnvelopeCodec.urn(envelope) == "":
112+
return False
113+
114+
meta = envelope.get("meta")
115+
if not isinstance(meta, dict) or meta.get("schema_version") != SCHEMA_VERSION:
116+
return False
117+
118+
if not isinstance(envelope.get("data"), dict):
119+
return False
120+
121+
attempts = envelope.get("attempts")
122+
if not isinstance(attempts, int) or isinstance(attempts, bool):
123+
return False
124+
125+
trace_id = envelope.get("trace_id")
126+
if not isinstance(trace_id, str) or trace_id == "":
127+
return False
128+
129+
return True
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"job": "urn:babel:orders:created",
3+
"trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
4+
"data": {
5+
"order_id": 1042
6+
},
7+
"meta": {
8+
"id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
9+
"queue": "orders",
10+
"lang": "php",
11+
"schema_version": 1,
12+
"created_at": 1749132727000
13+
},
14+
"attempts": 3,
15+
"dead_letter": {
16+
"reason": "failed",
17+
"error": "Payment gateway timeout",
18+
"exception": "App\\Exceptions\\GatewayTimeout",
19+
"failed_at": 1749132730000,
20+
"original_queue": "orders",
21+
"attempts": 3,
22+
"lang": "php"
23+
}
24+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
3+
"data": {
4+
"order_id": 1042
5+
},
6+
"meta": {
7+
"id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
8+
"queue": "orders",
9+
"lang": "php",
10+
"schema_version": 1,
11+
"created_at": 1749132727000
12+
},
13+
"attempts": 0
14+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"job": "urn:babel:orders:created",
3+
"trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
4+
"data": {
5+
"order_id": 1042
6+
},
7+
"meta": {
8+
"id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
9+
"queue": "orders",
10+
"lang": "php",
11+
"schema_version": 2,
12+
"created_at": 1749132727000
13+
},
14+
"attempts": 0
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"job": "urn:babel:orders:created",
3+
"trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
4+
"data": {
5+
"order_id": 1042
6+
},
7+
"meta": {
8+
"id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
9+
"queue": "orders",
10+
"lang": "php",
11+
"schema_version": 1,
12+
"created_at": 1749132727000
13+
},
14+
"attempts": 0
15+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"job": "urn:babel:catalog:item.indexed",
3+
"trace_id": "3f7a1d2e-9b4c-4a8d-bc1e-0f5a6b7c8d90",
4+
"data": {
5+
"title": "Café — naïve ☕",
6+
"qty": 7,
7+
"price_cents": 1299,
8+
"ratio": 0.5,
9+
"active": true,
10+
"note": null
11+
},
12+
"meta": {
13+
"id": "b2c3d4e5-f607-4890-a1b2-c3d4e5f60718",
14+
"queue": "catalog",
15+
"lang": "python",
16+
"schema_version": 1,
17+
"created_at": 1749132727000
18+
},
19+
"attempts": 2
20+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"urn": "urn:babel:orders:created",
3+
"trace_id": "9c1e0b44-7a2d-4e6f-8a10-2b3c4d5e6f70",
4+
"data": {
5+
"order_id": 1042
6+
},
7+
"meta": {
8+
"id": "a1b2c3d4-e5f6-4789-90ab-cdef01234567",
9+
"queue": "orders",
10+
"lang": "go",
11+
"schema_version": 1,
12+
"created_at": 1749132727000
13+
},
14+
"attempts": 0
15+
}

tests/conformance/manifest.json

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"schema_version": 1,
3+
"description": "Cross-SDK conformance cases. Every BabelQueue SDK core must satisfy these against the canonical wire envelope. Per-message fields (meta.id, trace_id, meta.created_at) are intrinsically unique and are NOT asserted by value.",
4+
"cases": [
5+
{
6+
"name": "order-created",
7+
"file": "fixtures/order-created.json",
8+
"valid": true,
9+
"description": "A normal produced envelope.",
10+
"expect": {
11+
"urn": "urn:babel:orders:created",
12+
"data": { "order_id": 1042 },
13+
"attempts": 0,
14+
"lang": "php",
15+
"schema_version": 1
16+
}
17+
},
18+
{
19+
"name": "urn-alias",
20+
"file": "fixtures/urn-alias.json",
21+
"valid": true,
22+
"description": "Consumers MUST accept 'urn' as an inbound alias for 'job'.",
23+
"expect": {
24+
"urn": "urn:babel:orders:created",
25+
"data": { "order_id": 1042 },
26+
"attempts": 0,
27+
"lang": "go",
28+
"schema_version": 1
29+
}
30+
},
31+
{
32+
"name": "dead-lettered",
33+
"file": "fixtures/dead-lettered.json",
34+
"valid": true,
35+
"description": "A dead-lettered message: original preserved + additive dead_letter block.",
36+
"expect": {
37+
"urn": "urn:babel:orders:created",
38+
"data": { "order_id": 1042 },
39+
"attempts": 3,
40+
"lang": "php",
41+
"schema_version": 1,
42+
"dead_letter": { "reason": "failed", "original_queue": "orders" }
43+
}
44+
},
45+
{
46+
"name": "unicode-and-numbers",
47+
"file": "fixtures/unicode-and-numbers.json",
48+
"valid": true,
49+
"description": "UTF-8 strings, integers, an exact float, boolean and null round-trip identically.",
50+
"expect": {
51+
"urn": "urn:babel:catalog:item.indexed",
52+
"data": { "title": "Café — naïve ☕", "qty": 7, "price_cents": 1299, "ratio": 0.5, "active": true, "note": null },
53+
"attempts": 2,
54+
"lang": "python",
55+
"schema_version": 1
56+
}
57+
},
58+
{
59+
"name": "invalid-unknown-schema-version",
60+
"file": "fixtures/invalid-unknown-schema-version.json",
61+
"valid": false,
62+
"reason": "meta.schema_version is not a version this SDK supports"
63+
},
64+
{
65+
"name": "invalid-missing-urn",
66+
"file": "fixtures/invalid-missing-urn.json",
67+
"valid": false,
68+
"reason": "no 'job' or 'urn' — the message has no identity"
69+
}
70+
]
71+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "https://babelqueue.com/contracts/message-envelope.schema.json",
4+
"title": "BabelQueueMessageEnvelope",
5+
"description": "The canonical, language-agnostic BabelQueue wire envelope (schema_version 1). This schema is authoritative alongside contracts/message-envelope.md.",
6+
"type": "object",
7+
"additionalProperties": true,
8+
"required": ["job", "trace_id", "data", "meta", "attempts"],
9+
"properties": {
10+
"job": {
11+
"type": "string",
12+
"minLength": 1,
13+
"description": "The message URN — language-agnostic identity. Canonical producer field name. Consumers also accept 'urn' as an inbound alias. Never a class name.",
14+
"examples": ["urn:babel:orders:created", "urn:babel:orders:invoice.requested"]
15+
},
16+
"urn": {
17+
"type": "string",
18+
"minLength": 1,
19+
"description": "Inbound alias for 'job'. Accepted by consumers for interoperability; producers SHOULD emit 'job'."
20+
},
21+
"trace_id": {
22+
"type": "string",
23+
"format": "uuid",
24+
"description": "Cross-service correlation id. Generated by the first producer, preserved and forwarded unchanged by every SDK across every hop."
25+
},
26+
"data": {
27+
"type": "object",
28+
"description": "Pure, JSON-encodable business payload. No language-specific types. Numbers/time/binary per contracts/message-envelope.md section 6.",
29+
"additionalProperties": true
30+
},
31+
"meta": {
32+
"type": "object",
33+
"description": "Producer-set, immutable descriptive metadata.",
34+
"additionalProperties": true,
35+
"required": ["id", "queue", "lang", "schema_version", "created_at"],
36+
"properties": {
37+
"id": {
38+
"type": "string",
39+
"format": "uuid",
40+
"description": "Unique id for THIS message (distinct from trace_id)."
41+
},
42+
"queue": {
43+
"type": "string",
44+
"minLength": 1,
45+
"description": "Logical queue name (not the broker key)."
46+
},
47+
"lang": {
48+
"type": "string",
49+
"enum": ["php", "go", "python", "java", "dotnet", "node"],
50+
"description": "Producer language tag."
51+
},
52+
"schema_version": {
53+
"type": "integer",
54+
"const": 1,
55+
"description": "Envelope schema version. Consumers MUST reject versions they do not support."
56+
},
57+
"created_at": {
58+
"type": "integer",
59+
"minimum": 0,
60+
"description": "Production time as Unix epoch MILLISECONDS, UTC."
61+
}
62+
}
63+
},
64+
"attempts": {
65+
"type": "integer",
66+
"minimum": 0,
67+
"description": "Top-level transport retry counter, mutated by the broker/worker. Deliberately kept OUT of the immutable meta block."
68+
},
69+
"dead_letter": {
70+
"type": "object",
71+
"description": "Optional. Present ONLY on messages sitting on a dead-letter queue (ADR-0009). Additive, so it does not change schema_version. Normal consumers ignore it.",
72+
"additionalProperties": true,
73+
"required": ["reason", "failed_at", "original_queue", "attempts"],
74+
"properties": {
75+
"reason": {
76+
"type": "string",
77+
"enum": ["failed", "unknown_urn", "poison"],
78+
"description": "Why the message was dead-lettered."
79+
},
80+
"error": {
81+
"type": ["string", "null"],
82+
"description": "Human-readable error message, if any."
83+
},
84+
"exception": {
85+
"type": ["string", "null"],
86+
"description": "Producer-language exception/type name, if any (informational, language-specific)."
87+
},
88+
"failed_at": {
89+
"type": "integer",
90+
"minimum": 0,
91+
"description": "Dead-letter time as Unix epoch milliseconds, UTC."
92+
},
93+
"original_queue": {
94+
"type": "string",
95+
"description": "The queue the message was consumed from before dead-lettering."
96+
},
97+
"attempts": {
98+
"type": "integer",
99+
"minimum": 0,
100+
"description": "Delivery attempts made before dead-lettering."
101+
},
102+
"lang": {
103+
"type": "string",
104+
"enum": ["php", "go", "python", "java", "dotnet", "node"],
105+
"description": "Language of the SDK that dead-lettered the message."
106+
}
107+
}
108+
}
109+
}
110+
}

0 commit comments

Comments
 (0)