Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,24 @@ try {
}
```

## Callback Signatures
## Callbacks

Verify the signature, then decode the CloudEvent. A callback that reports a
failure carries an `error` with a stable `code` to branch on and a `message`
to show:

```php
use OpenRuntimes\Orchestrator\Callback\CloudEvent;
use OpenRuntimes\Orchestrator\Callback\Signature;

$valid = Signature::verifyEvent($rawBody, $headers['x-signature-256'] ?? '', $secret);
if (! Signature::verifyEvent($rawBody, $headers['x-signature-256'] ?? '', $secret)) {
return;
}

$event = CloudEvent::fromArray(\json_decode($rawBody, true));
if ($failure = $event->failure()) {
echo "{$event->type}: {$failure->code} — {$failure->message}";
}
```

## Development
Expand Down
8 changes: 8 additions & 0 deletions src/Callback/CloudEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,12 @@ public static function fromArray(array $payload): self
data: \is_array($data) ? $data : [],
);
}

/**
* The failure a callback reports, or null when it reports success.
*/
public function failure(): ?Failure
{
return isset($this->data['error']) ? Failure::fromValue($this->data['error']) : null;
Comment thread
loks0n marked this conversation as resolved.
Outdated
}
}
37 changes: 37 additions & 0 deletions src/Callback/Failure.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace OpenRuntimes\Orchestrator\Callback;

use OpenRuntimes\Orchestrator\Exception\ClientException;

/**
* The error carried by a failed callback: a stable snake_case code to branch
* on, and a sentence about this occurrence to show — never to parse.
*/
final readonly class Failure
{
public function __construct(
public string $code,
public string $message,
) {}

/**
* Orchestrator 2.2 sends `{code, message}`; a 2.1 orchestrator mid-upgrade
* still sends the bare code as a string, so that is accepted with no message.
*/
public static function fromValue(mixed $error): self
{
if (\is_string($error) && $error !== '') {
return new self($error, '');
}
if (! \is_array($error) || ! \is_string($error['code'] ?? null) || $error['code'] === '') {
throw new ClientException('Invalid callback error: missing string code.');
}

$message = $error['message'] ?? '';

return new self($error['code'], \is_string($message) ? $message : '');
Comment thread
loks0n marked this conversation as resolved.
Outdated
}
}
40 changes: 40 additions & 0 deletions tests/Callback/CloudEventTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace OpenRuntimes\Orchestrator\Tests\Callback;

use OpenRuntimes\Orchestrator\Callback\CloudEvent;
use OpenRuntimes\Orchestrator\Callback\Failure;
use OpenRuntimes\Orchestrator\Exception\ClientException;
use PHPUnit\Framework\TestCase;

Expand All @@ -25,4 +26,43 @@ public function test_rejects_malformed_time(): void

CloudEvent::fromArray(['time' => 'not-a-time']);
}

public function test_exposes_failure(): void
{
$event = CloudEvent::fromArray([
'time' => '2026-01-15T10:30:00Z',
'type' => 'orchestrator.job.artifact',
'data' => ['status' => 'failed', 'error' => ['code' => 'archive_unknown_format', 'message' => 'Unrecognized archive format for source.tar.gz']],
]);

$failure = $event->failure();
$this->assertInstanceOf(Failure::class, $failure);
$this->assertSame('archive_unknown_format', $failure->code);
$this->assertSame('Unrecognized archive format for source.tar.gz', $failure->message);
}

public function test_success_has_no_failure(): void
{
$event = CloudEvent::fromArray(['time' => '2026-01-15T10:30:00Z', 'data' => ['status' => 'success']]);

$this->assertNotInstanceOf(Failure::class, $event->failure());
Comment thread
loks0n marked this conversation as resolved.
Outdated
}

public function test_accepts_legacy_string_error(): void
{
$event = CloudEvent::fromArray(['time' => '2026-01-15T10:30:00Z', 'data' => ['error' => 'job_oom']]);

$failure = $event->failure();
$this->assertInstanceOf(Failure::class, $failure);
$this->assertSame('job_oom', $failure->code);
$this->assertSame('', $failure->message);
}

public function test_rejects_error_without_code(): void
{
$this->expectException(ClientException::class);
$this->expectExceptionMessage('Invalid callback error: missing string code.');

CloudEvent::fromArray(['time' => '2026-01-15T10:30:00Z', 'data' => ['error' => ['message' => 'no code']]])->failure();
}
}
Loading