Skip to content

Commit ca6d104

Browse files
committed
Add EnvelopeCodec::urn() and accepts() methods, ConformanceTest
1 parent 0ee32a3 commit ca6d104

11 files changed

Lines changed: 424 additions & 1 deletion

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ The envelope wire format is versioned separately by `meta.schema_version`
99

1010
## [Unreleased]
1111

12+
### Added
13+
- `EnvelopeCodec::urn()` — resolve the message URN (`job`, accepting `urn` as an
14+
inbound alias).
15+
- `EnvelopeCodec::accepts()` — consumer-side envelope validation (rejects an empty
16+
URN, an unsupported `meta.schema_version`, a blank `trace_id`, non-object `data`
17+
or non-integer `attempts`).
18+
- Shared **cross-SDK conformance suite** under `tests/conformance/` (vendored from
19+
the canonical `conformance/` set) plus a `ConformanceTest` runner.
20+
21+
## [0.1.0] - 2026-06-06
22+
1223
### Added
1324
- `Codec\EnvelopeCodec` — builds, encodes and decodes the canonical
1425
`{job, trace_id, data, meta, attempts}` envelope (`SCHEMA_VERSION`, `SOURCE_LANG`).
@@ -25,4 +36,5 @@ The envelope wire format is versioned separately by `meta.schema_version`
2536
- Framework-agnostic core. Requires PHP `^8.2` and `ext-json` only — no heavy deps.
2637
- Framework adapters (`babelqueue/laravel`, `babelqueue/symfony`) build on this.
2738

28-
[Unreleased]: https://github.com/BabelQueue/php-sdk/commits/main
39+
[Unreleased]: https://github.com/BabelQueue/php-sdk/compare/v0.1.0...HEAD
40+
[0.1.0]: https://github.com/BabelQueue/php-sdk/releases/tag/v0.1.0

src/Codec/EnvelopeCodec.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,48 @@ public static function decode(string $rawBody): array
9292
return is_array($decoded) ? $decoded : [];
9393
}
9494

95+
/**
96+
* The message URN: canonical "job", with "urn" accepted as an inbound alias.
97+
*
98+
* @param array<string, mixed> $envelope
99+
*/
100+
public static function urn(array $envelope): string
101+
{
102+
return (string) ($envelope['job'] ?? $envelope['urn'] ?? '');
103+
}
104+
105+
/**
106+
* Whether a consumer should accept this envelope (consumer-side validation):
107+
* a non-empty URN, a supported meta.schema_version, a non-blank trace_id, an
108+
* object "data" and an integer "attempts". Accepts the "urn" alias (unlike the
109+
* producer JSON Schema, which requires "job").
110+
*
111+
* @param array<string, mixed> $envelope
112+
*/
113+
public static function accepts(array $envelope): bool
114+
{
115+
if (self::urn($envelope) === '') {
116+
return false;
117+
}
118+
119+
$meta = $envelope['meta'] ?? null;
120+
if (! is_array($meta) || ($meta['schema_version'] ?? null) !== self::SCHEMA_VERSION) {
121+
return false;
122+
}
123+
124+
if (! is_array($envelope['data'] ?? null)) {
125+
return false;
126+
}
127+
128+
if (! is_int($envelope['attempts'] ?? null)) {
129+
return false;
130+
}
131+
132+
$traceId = $envelope['trace_id'] ?? null;
133+
134+
return is_string($traceId) && $traceId !== '';
135+
}
136+
95137
/**
96138
* Resolve and validate the job's URN. A blank URN is a programming error.
97139
*
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BabelQueue\Tests\Conformance;
6+
7+
use BabelQueue\Codec\EnvelopeCodec;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use PHPUnit\Framework\TestCase;
10+
11+
/**
12+
* Runs the shared cross-SDK conformance suite (vendored under tests/conformance/).
13+
* The same manifest + fixtures are run by every BabelQueue SDK; passing here proves
14+
* this SDK reads/writes the canonical envelope identically to the others.
15+
*/
16+
final class ConformanceTest extends TestCase
17+
{
18+
private static function suiteDir(): string
19+
{
20+
return __DIR__ . '/../conformance';
21+
}
22+
23+
/**
24+
* @return array<string, array{0: array<string, mixed>}>
25+
*/
26+
public static function cases(): array
27+
{
28+
$manifest = json_decode(
29+
(string) file_get_contents(self::suiteDir() . '/manifest.json'),
30+
true,
31+
512,
32+
JSON_THROW_ON_ERROR,
33+
);
34+
35+
$provided = [];
36+
foreach ($manifest['cases'] as $case) {
37+
$provided[$case['name']] = [$case];
38+
}
39+
40+
return $provided;
41+
}
42+
43+
/**
44+
* @param array<string, mixed> $case
45+
*/
46+
#[DataProvider('cases')]
47+
public function test_case(array $case): void
48+
{
49+
$raw = (string) file_get_contents(self::suiteDir() . '/' . $case['file']);
50+
$envelope = EnvelopeCodec::decode($raw);
51+
52+
$this->assertNotSame([], $envelope, 'fixture must decode');
53+
54+
if ($case['valid'] === false) {
55+
$this->assertFalse(
56+
EnvelopeCodec::accepts($envelope),
57+
$case['name'] . ' must be rejected: ' . ($case['reason'] ?? ''),
58+
);
59+
60+
return;
61+
}
62+
63+
$expect = $case['expect'];
64+
65+
$this->assertTrue(EnvelopeCodec::accepts($envelope), $case['name'] . ' must be accepted');
66+
$this->assertSame($expect['urn'], EnvelopeCodec::urn($envelope));
67+
$this->assertSame($expect['attempts'], $envelope['attempts']);
68+
$this->assertSame($expect['lang'], $envelope['meta']['lang']);
69+
$this->assertSame($expect['schema_version'], $envelope['meta']['schema_version']);
70+
71+
if (isset($expect['data'])) {
72+
$this->assertEquals($expect['data'], $envelope['data']);
73+
}
74+
75+
if (isset($expect['dead_letter'])) {
76+
foreach ($expect['dead_letter'] as $key => $value) {
77+
$this->assertSame($value, $envelope['dead_letter'][$key]);
78+
}
79+
}
80+
81+
// Per-message fields must be present (not asserted by value).
82+
$this->assertArrayHasKey('id', $envelope['meta']);
83+
$this->assertNotSame('', (string) $envelope['trace_id']);
84+
}
85+
}
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+
}

0 commit comments

Comments
 (0)