jamescarr / ankusa
Client SDK for Ankusa deployments: the claim-check gateway client, the route-management, operator (admin) and source-management clients, and a webhook-receiving header helper.
Requires
- php: ^8.3
- guzzlehttp/guzzle: ^7.9
- guzzlehttp/psr7: ^2.13
- psr/http-client: ^1.0
- psr/http-message: ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^12.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
The client SDK for Ankusa deployments: one Packagist package meant to bundle everything a non-Elixir consumer needs to talk to an Ankusa deployment. Today that's the claim-check gateway client, the route-management and operator (admin) clients, the source-management client, and a webhook-receiving header helper; more clients (ingest) land here as they're built.
This repository is the read-only split mirror of
packages/sdk-php
in jamescarr/ankusa, published so
Packagist — which reads composer.json at a repository root — can serve it.
Every push here is generated by the release workflow; development, issues and
pull requests happen in the main repository.
Install
composer require jamescarr/ankusa
Until the first release is on Packagist, depend on it as a local path, the same
way the Elixir packages in this monorepo depend on ankusa core before their
first Hex release:
{
"repositories": [
{ "type": "path", "url": "../../packages/sdk-php" }
],
"require": {
"jamescarr/ankusa": "*"
}
}
PHP 8.3 or newer. guzzlehttp/guzzle and its PSR-7/PSR-18 interfaces are
installed as dependencies; any other PSR-18 client can be injected instead.
Claim-check client
Redeem a claim-check ref, verify the bytes it returns against the message's
sha256, and classify failures into dead-letter vs. retry, without holding any
object-store credentials. Conforms to the framework's own contract,
priv/openapi/claim_check.v1.yaml:
the spec is the source of truth, this package conforms to it, not the reverse.
use Ankusa\ClaimCheck\ClaimCheckClient; use Ankusa\ClaimCheck\ClaimCheckError; $claimCheck = new ClaimCheckClient(getenv('CLAIM_CHECK_URL') ?: 'http://localhost:4001'); // A queue message that carries a claim also carries its sha256: // "claim": "urn:ankusa:claim:v1:<tenant>:<claim_id>" (claim_id: uppercase ULID) // "sha256": 64-char lowercase hex of the claim's bytes function resolveBody(ClaimCheckClient $claimCheck, array $message): string { try { return $claimCheck->redeem($message['claim'], $message['sha256']); } catch (ClaimCheckError $err) { if (! $err->isRetryable()) { // bad ref/sha256, 404, or an integrity mismatch: dead-letter, don't requeue throw $err; } // gateway unreachable or 5xx: safe to retry throw $err; } }
redeem() does three things GET /v1/claims/... alone doesn't:
- Parses the ref into its tenant id, claim id, and gateway path
(
GET /v1/claims/{tenant_id}/{claim_id}) —ParsedClaimRef::parse(), which is also public on its own. - Fetches the bytes.
- Verifies them against the message's
sha256(the gateway itself does not check this, see "Redeem a claim" in docs/claim-check.md) before ever returning them to you.
Every failure is a ClaimCheckError with an isRetryable(), so a consumer
needs exactly one bit to decide dead-letter vs. retry:
| Class | isRetryable() |
Cause |
|---|---|---|
InvalidClaimRefError |
false |
$ref isn't a well-formed claim-check URN, or $sha256 isn't 64-char lowercase hex |
ClaimNotFoundError |
false |
gateway 404: expired by retention, or never written |
ClaimRejectedError |
false |
gateway 4xx other than 404 (public $status, $body) |
ClaimIntegrityError |
false |
sha256 of the returned bytes doesn't match |
ClaimCheckUnavailableError |
true |
gateway 5xx/503, or unreachable |
health() hits GET /health for a liveness probe.
Webhook receiver helper
Every receiver of Ankusa's HTTP sink needs the same handful of headers off each
request; HookHeaders::fromHeaders() replaces the hand-rolled $headers[...]
lookups with one call and a typed result. It takes a PSR-7 message or a plain
array (getallheaders(), Symfony's HeaderBag::all()), and looks the names up
case-insensitively either way:
use Ankusa\Webhook\HookHeaders; use Ankusa\Webhook\MissingHookIdError; try { $hook = HookHeaders::fromHeaders(getallheaders()); } catch (MissingHookIdError) { http_response_code(400); return; } $body = file_get_contents('php://input'); // $hook->id, $hook->source, $hook->tenant, $hook->contentType
HookHeaders->id is what a receiver dedupes on: delivery is at-least-once (see
"HTTP handoff" in
docs/integrations.md),
so the same hook can arrive twice after a retry.
Routes client
Manage route definitions and the global IP rules on the route-management
listener (routes.admin.port, default 4003) — the routes tag of
priv/openapi/admin.v1.yaml.
use Ankusa\Routes\RoutesClient; $routes = new RoutesClient(getenv('ROUTES_URL') ?: 'http://localhost:4003'); $routes->createRoute(['id' => 'stripe', 'path' => '/webhooks/stripe']); $routes->getIpRules(); // ['default' => 'allow', 'rules' => []] $routes->testRoute(['method' => 'POST', 'path' => '/webhooks/stripe', 'ip' => '203.0.113.7']);
Methods: health(), listRoutes(), createRoute(), getRoute(),
replaceRoute(), updateRoute(), deleteRoute(), getIpRules(),
putIpRules(), testRoute(). Route ids are percent-encoded as one path
segment, so /, ?, #, % and a space in an id can't reshape the URL.
Failures are RoutesError subclasses: InvalidRouteIdError (an empty id, or
exactly ./.. — raised before any request, because a URL parser would
otherwise normalize it into the collection endpoint and hand back the list page
as if it were a route), RouteNotFoundError (404), RoutesRejectedError (any
other 4xx, carrying $errorCode, $field, $detail — the body's message,
since PHP's message is taken by Throwable — $conflictingId and
$maxRoutes), and RoutesUnavailableError (5xx, an unfollowed redirect, a
non-JSON success body, or unreachable; retryable).
Admin client
The operator API on admin.port (default 4002): health, Prometheus metrics,
the redacted config, the DLQ, and the quarantine list — the operations, dlq
and quarantine tags of admin.v1.yaml.
use Ankusa\Admin\AdminClient; $admin = new AdminClient(getenv('ADMIN_URL') ?: 'http://localhost:4002'); $admin->health(); // ['status' => 'ok', 'instance' => ..., 'roles' => [...]] $admin->listDeadLetters(['limit' => 10]); // ['total' => ..., 'entries' => [...]] $admin->replayDeadLetters(['source_id' => 'demo']); $admin->listQuarantined();
Methods: health(), metrics() (Prometheus text), config(),
listDeadLetters(), replayDeadLetters(), listQuarantined(). Failures are
AdminError subclasses: RoleNotEnabledError (409 role_not_enabled,
carrying $role), AdminRejectedError (any other 4xx, carrying $errorCode),
and AdminUnavailableError (5xx, an unfollowed redirect, or unreachable;
retryable).
Sources client
Ankusa's ingest sources are tenant-scoped: a source is addressed as
<tenant>.<name>, and Ankusa.Admin.Router serves their CRUD API on the same
admin.port as the operator API. SourcesClient speaks in those terms and
builds the paths for you.
use Ankusa\Sources\SourcesClient; use Ankusa\Sources\SourceSpec; $sources = new SourcesClient(getenv('ADMIN_URL') ?: 'http://localhost:4002'); $sources->listSources('acme'); // Source[] $sources->getSource('acme', 'billing'); // Source $sources->createSource('acme', 'billing', new SourceSpec(sinks: [['type' => 'log']])); $sources->updateSource( 'acme', 'billing', new SourceSpec(sinks: [['type' => 'log']], onVerifyFailure: 'reject'), ); $sources->deleteSource('acme', 'billing');
Methods: serverVersion(), listSources(), getSource(), createSource(),
updateSource(), deleteSource(). A write takes the whole spec (SourceSpec,
whose toJson() omits unset fields); a read returns a Source, which is always
redacted — resending a read-back verify map is not the same as resending the
stored secret, so supply secrets through SourceSpec.
new SourcesClient($url, '0.3.0') is an optional latch: the first API call
fetches GET /health, compares its version field, and raises
VersionMismatchError on a mismatch (the version is cached afterwards, so no
further request checks it). Failures are SourcesError subclasses, each
carrying $status and $body: SourceNotFoundError (404),
SourceConflictError (409), SourceStoreReadOnlyError (409 — the deployment's
source store is a static seed), SourceInvalidError (400, or an invalid
tenant/name caught before any request; getMessage() carries the server's own
message or its error code), VersionMismatchError, and
SourcesUnavailableError (unreachable, timed out, or 5xx).
Tenants and source names must match ^[A-Za-z0-9_-]{1,64}$; anything else
raises SourceInvalidError before a path is built.
Errors and retries
- Every exception this SDK throws implements
Ankusa\AnkusaException, so onecatchcovers everything the package can raise. Each client's errors share a base class as well (ClaimCheckError,RoutesError,AdminError,SourcesError). - The claim-check, routes and admin hierarchies answer
isRetryable():falsefor a rejected request or an invalid input caught locally,truefor an unreachable listener, a timeout, a5xx, or an unfollowed redirect. Sorting a failure into dead-letter vs. retry needs no status-code knowledge. - Every client takes an optional PSR-18 client as its last constructor
argument. The default is Guzzle, which never follows redirects and never
throws on a status code; an injected client owns its own timeout and redirect
policy, though the SDK still treats any
3xxas a failure. Client headers ride on every request. - Request bodies are JSON objects:
replayDeadLetters()with no filter sends{}, never[]. Nested empty objects are the caller's job — pass(object) []for a nested{}, or[]for a nested[].
Layout
src/
AnkusaException.php # marker implemented by every exception this SDK throws
Version.php # the version the release tooling reads
Internal/HttpTransport.php # @internal: query/body encoding + PSR-18 call
Webhook/ # x-ankusa-* header parsing for HTTP-sink receivers
ClaimCheck/ # the claim-check gateway client
Routes/ # the route-management client (routes.admin.port)
Admin/ # the operator client (admin.port)
Sources/ # the tenant-scoped source-management client (admin.port)
tests/
Unit/
Conformance/ # runs the language-neutral vectors in conformance/
Support/RecordingHttpClient.php
A future client (say, an ingest helper) gets its own src/<Name>/ directory with
the same shape.
Develop
composer install composer test # PHPUnit: unit + conformance composer conformance # PHPUnit: only the conformance vectors in ../conformance composer analyse # PHPStan, level 10 composer lint # php-cs-fixer check --diff composer fix # php-cs-fixer fix
composer test runs the same vectors every other SDK in the monorepo runs
(conformance/check.mjs, mise run check:conformance); the runner starts PHP's
built-in server on a free port to stand in for a deployment's gateway.