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.