Skip to content

Using the client

Client\OneRecordClient talks to one partner's ONE Record server over any PSR-18 HTTP client. It discovers the partner's server information, picks the highest API version both sides support, and parses answers into the same model and API documents the server side uses.

Construction

use LambdaTwelve\OneRecord\Client\ClientCredentialsTokenProvider;
use LambdaTwelve\OneRecord\Client\OneRecordClient;

$tokens = new ClientCredentialsTokenProvider(
    $httpClient, $requestFactory, $streamFactory, $clock,
    tokenUrl: 'https://1r.partner.example/oauth/token',
    clientId: 'our-client-id',
    clientSecret: $secret,
    cache: $psr16Cache,          // optional: tokens survive the process
);
$client = new OneRecordClient(
    $httpClient, $requestFactory, $streamFactory, $tokens,
    serverEndpoint: 'https://1r.partner.example',
    cache: $psr16Cache,          // optional: caches the partner's server information
);

Everything is an interface: PSR-18 client, PSR-17 factories, PSR-20 clock, PSR-16 cache, PSR-3 logger. The token provider decides where bearer tokens come from: ClientCredentialsTokenProvider runs the OAuth 2.0 client credentials flow the ONE Record security model prescribes and refreshes before expiry; StaticTokenProvider is for fixed tokens and tests.

Version negotiation

The first call fetches GET / and keeps the ServerInformation. The client then speaks the highest API version both it and the partner list, in Accept and Content-Type, and reads 2.3-only properties as optional so a 2.2 partner parses fine. withApiVersion() forces a version; apiVersion() tells which one is in use; a partner with no common version raises ClientException.

A worked example

<?php

declare(strict_types=1);

use LambdaTwelve\OneRecord\Api\Permission;
use LambdaTwelve\OneRecord\Change\ChangeBuilder;
use LambdaTwelve\OneRecord\Client\OneRecordClient;
use LambdaTwelve\OneRecord\Client\OneRecordHttpException;
use LambdaTwelve\OneRecord\Client\StaticTokenProvider;
use LambdaTwelve\OneRecord\Model\Builder\ObjectBuilder;
use LambdaTwelve\OneRecord\Model\Builder\Values;
use LambdaTwelve\OneRecord\Rdf\Iri;
use LambdaTwelve\OneRecord\Server\DataHolder;
use LambdaTwelve\OneRecord\Server\InMemory\InMemoryServer;
use LambdaTwelve\OneRecord\Server\ServerConfig;
use LambdaTwelve\OneRecord\Server\Spi\Agent;
use LambdaTwelve\OneRecord\Server\Spi\Authenticator;
use LambdaTwelve\OneRecord\Server\SystemClock;
use LambdaTwelve\OneRecord\Vocabulary\Generated\Cargo;
use LambdaTwelve\OneRecord\Vocabulary\Generated\CodeLists\MeasurementUnitCode;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\ServerRequest;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

// --- A partner's server to talk to. In real use this is someone else's
// ONE Record server on the network; here it is this package's own server,
// in-process, so the example runs anywhere.
$factory = new Psr17Factory();
$partnerHolder = new Iri('https://1r.partner.example/logistics-objects/airline');
$partner = new InMemoryServer(
    new ServerConfig('https://1r.partner.example', $partnerHolder),
    new class implements Authenticator {
        public function authenticate(ServerRequestInterface $request): ?Agent
        {
            // Trusts the bearer token as the agent IRI. Production servers verify RS256 tokens.
            return preg_match('/^Bearer (.+)$/', $request->getHeaderLine('Authorization'), $m) === 1 ? new Agent(new Iri($m[1])) : null;
        }
    },
    new SystemClock(),
    new class implements EventDispatcherInterface {
        public function dispatch(object $event): object
        {
            return $event;
        }
    },
    $factory,
    $factory,
);
$partner->policy->addInternal($partnerHolder);
$piece = (new DataHolder($partner->services))->create(
    ObjectBuilder::of(Cargo::Piece)
        ->set(Cargo::goodsDescription, 'Machine parts')
        ->set(Cargo::grossWeight, Values::quantity(190.5, MeasurementUnitCode::KGM))
        ->build(new Iri('https://1r.partner.example/logistics-objects/piece-1')),
);
$us = new Iri('https://1r.example.com/logistics-objects/forwarder');
$partner->policy->allow($us, $piece->object->iri, [Permission::GetLogisticsObject, Permission::PatchLogisticsObject]);

// --- Any PSR-18 client works (Guzzle, Symfony HttpClient, ...). This one
// hands requests to the in-process server.
$http = new class ($partner->handler) implements ClientInterface {
    public function __construct(private readonly Psr\Http\Server\RequestHandlerInterface $handler) {}

    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        return $this->handler->handle(new ServerRequest($request->getMethod(), $request->getUri(), $request->getHeaders(), (string) $request->getBody()));
    }
};

// --- The client: PSR-18 + PSR-17 + a token provider + the partner's endpoint.
// ClientCredentialsTokenProvider fetches OAuth 2.0 tokens; a fixed token is used here.
$client = new OneRecordClient($http, $factory, $factory, new StaticTokenProvider($us->value), 'https://1r.partner.example');

$information = $client->serverInformation();
echo 'Partner: ', $information->dataHolder->value, ' speaking API ', $client->apiVersion()->value, "\n";

$read = $client->getLogisticsObject($piece->object->iri);
$current = $read->object ?? throw new RuntimeException('GET always carries the object.');
echo 'Piece revision ', $read->revision, ': ', $current->literal(Cargo::goodsDescription), "\n";

// Ask for a change: diff what we read against what we want, send the Change.
$wanted = ObjectBuilder::of(Cargo::Piece)
    ->set(Cargo::goodsDescription, 'Machine parts, repacked')
    ->set(Cargo::grossWeight, Values::quantity(192.0, MeasurementUnitCode::KGM))
    ->build($piece->object->iri);
$change = (new ChangeBuilder())->diff($current, $wanted, $read->revision, 'Repacked at the warehouse');
if ($change !== null) {
    $requestIri = $client->requestChange($change);
    echo 'Change request: ', $requestIri->value, ' is ', $client->getActionRequest($requestIri)->status->shortName(), "\n";
}

// Errors are typed: the partner's api:Error comes along.
try {
    $client->getLogisticsObject('https://1r.partner.example/logistics-objects/not-ours');
} catch (OneRecordHttpException $e) {
    echo 'Refused with ', $e->status, ': ', $e->error?->title, "\n";
}

What it can do

Method Endpoint Returns
serverInformation() GET / Api\ServerInformation
getLogisticsObject($iri, at:, embedded:) GET /logistics-objects/{id} LogisticsObjectResponse (object plus revision headers)
headLogisticsObject($iri) HEAD LogisticsObjectResponse without the object
createLogisticsObject($object) POST /logistics-objects the new URI
requestChange(Change) PATCH /logistics-objects/{id} the ChangeRequest URI
requestVerification(Verification) POST /logistics-objects/{id} the VerificationRequest URI
getAuditTrail($iri, updatedFrom:, updatedTo:, status:) GET …/audit-trail AuditTrail with parsed action requests
postLogisticsEvent($iri, $event) POST …/logistics-events the event URI
postLogisticsEvents($event, $objects) POST /logistics-events (2.3), else per object one BulkEventResult per object
getLogisticsEvents($iri, EventFilter) GET …/logistics-events EventList
getLogisticsEvent($iri) GET …/logistics-events/{id} Model\LogisticsEvent
subscribe(Subscription) POST /subscriptions the SubscriptionRequest URI
getSubscriptions(TopicType, $topic) GET /subscriptions?… list<Subscription>
requestAccessDelegation(AccessDelegation) POST /access-delegations the request URI
getActionRequest($iri) GET /action-requests/{id} Api\ActionRequest
updateActionRequestStatus($iri, RequestStatus) PATCH …?status= nothing
revokeActionRequest($iri) DELETE /action-requests/{id} nothing
sendNotification(Notification) POST /notifications nothing

The object a GET returns has the server's api:hasRevision and api:hasLatestRevision stripped (they are on the response object instead), so it compares equal to what the holder stored and can be diffed with ChangeBuilder straight away.

Errors

A 4xx or 5xx answer is an OneRecordHttpException with the status, the parsed api:Error when the partner sent one, and the raw response. Transport failures, unreadable bodies and negotiation failures are ClientException. Both are runtime exceptions; neither is retried, because whether and when to retry is the host's decision.

Delivering notifications

The server queues outgoing notifications in the NotificationOutbox; the host drains it and calls sendNotification() on a client built for the recipient's endpoint (OutboundNotification::suggestedEndpoint() derives it from the recipient's agent URI). One client per partner, cached by the host, keeps server information and tokens warm.