Search by

jamescarr / ankusa

jamescarr

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.

v0.3.0 2026-10-01 03:50 UTC

This package is auto-updated.

Last update: 2026-10-01 03:52:23 UTC


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:

  1. 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.
  2. Fetches the bytes.
  3. 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 one catch covers 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(): false for a rejected request or an invalid input caught locally, true for an unreachable listener, a timeout, a 5xx, 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 3xx as 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.