Running a server¶
The server is one PSR-15 request handler. You give it a configuration, the services a host must provide (storage, authentication, access policy, an outbox for notifications) and PSR-17 factories; it answers every ONE Record endpoint under the base path you mount it on.
The shortest path: in-memory everything¶
InMemoryServer wires the handler with an in-memory implementation of every
interface. It is what the test suite and bin/serve run, and the starting
point for a host: replace one store at a time with your own implementation of
the SPI.
<?php
declare(strict_types=1);
use LambdaTwelve\OneRecord\Api\Permission;
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\Message\ServerRequestInterface;
// Who is calling? In production this is JwtAuthenticator (RS256 bearer tokens);
// here a header stands in so the example runs without keys.
$authenticator = new class implements Authenticator {
public function authenticate(ServerRequestInterface $request): ?Agent
{
$agent = $request->getHeaderLine('X-Agent');
return $agent === '' ? null : new Agent(new Iri($agent));
}
};
// Any PSR-14 dispatcher; the SDK raises events such as LogisticsObjectCreated.
$dispatcher = new class implements EventDispatcherInterface {
public function dispatch(object $event): object
{
return $event;
}
};
// Any PSR-17 factory does; nyholm/psr7 is used here.
$factory = new Psr17Factory();
$holder = new Iri('https://1r.example.com/logistics-objects/holder');
$server = new InMemoryServer(
new ServerConfig('https://1r.example.com', $holder, dataHolderType: Cargo::Company),
$authenticator,
new SystemClock(),
$dispatcher,
$factory,
$factory,
);
$server->policy->addInternal($holder);
// The host publishes its own data through the PHP API.
$dataHolder = new DataHolder($server->services);
$piece = $dataHolder->create(
ObjectBuilder::of(Cargo::Piece)
->set(Cargo::goodsDescription, 'Machine parts')
->set(Cargo::grossWeight, Values::quantity(190.5, MeasurementUnitCode::KGM))
->build(new Iri('https://1r.example.com/logistics-objects/piece-1')),
);
// A partner may read it once the access policy says so.
$partner = new Iri('https://1r.partner.example/logistics-objects/forwarder');
$server->policy->allow($partner, $piece->object->iri, [Permission::GetLogisticsObject]);
// $server->handler is the PSR-15 handler to mount; here it is called directly.
$request = new ServerRequest('GET', 'https://1r.example.com/logistics-objects/piece-1', [
'Accept' => 'application/ld+json; version=2.3.0',
'X-Agent' => $partner->value,
]);
$response = $server->handler->handle($request);
echo $response->getStatusCode(), ' ', $response->getHeaderLine('Content-Type'), "\n";
echo 'Type: ', $response->getHeaderLine('Type'), "\n";
echo 'Revision: ', $response->getHeaderLine('Revision'), "\n";
echo $response->getBody(), "\n";
Running it prints the partner's view of the piece:
200 application/ld+json; version=2.3.0
Type: https://onerecord.iata.org/ns/cargo#Piece
Revision: 1
{ "@context": ..., "@id": "https://1r.example.com/logistics-objects/piece-1", ... }
Mounting the handler¶
$server->handler (an OneRecordServer) implements
Psr\Http\Server\RequestHandlerInterface. Mount it wherever your framework
lets a handler own a path prefix, and tell ServerConfig about that prefix:
$config = new ServerConfig(
baseUrl: 'https://1r.example.com', // scheme and host only
dataHolder: new Iri('https://1r.example.com/one-record/logistics-objects/holder'),
basePath: '/one-record', // the prefix the handler is mounted on
);
Logistics-object URIs are then https://1r.example.com/one-record/logistics-objects/{id}.
Requests outside the base path are answered 404; the server never redirects.
A site served from a subdirectory puts the subdirectory in basePath too,
because the SDK sees the full request path.
Frameworks that want one named route per endpoint read
ServerBuilder::routes(): every pattern with every method the SDK answers,
so the framework passes them all through and the SDK, not the framework,
answers 405 with the spec's error body and Allow header.
Other ServerConfig options:
| Option | Default | Purpose |
|---|---|---|
apiVersions |
both, highest first | The API versions offered in server information and accepted in Accept |
dataModelVersions |
both | The ontology versions listed in server information |
languages |
['en-US'] |
Content-Language of responses |
maxBodyBytes |
1 MiB | Request bodies above this are refused with 413 |
embeddedDepth |
3 | How deep ?embedded=true follows links to other objects of this server |
bulkLogisticsEvents |
false |
Serve the optional 2.3 POST /logistics-events |
dataHolderType |
null |
The class of the data holder (cargo:Company, say) for server information |
Authentication¶
The handler asks your Authenticator for the calling agent on every request
and answers 401 when it returns null. Auth\JwtAuthenticator verifies RS256
bearer tokens as the ONE Record security model requires; see
Authentication. Tests and examples use a header instead.
Publishing your own data¶
Partners never create your objects; your application does, through
Server\DataHolder:
create(LogisticsObject)stores a new object as revision 1.update(LogisticsObject)diffs against the stored revision, records the difference as an accepted change request (so the audit trail is complete) and stores the next revision. Returns null when nothing changed.publish(LocalGraph, IriMinter, $existing)does both for a whole graph of linked objects, idempotently. Pass the URIs of objects already published so they keep them;UuidIriMinterwith a seed gives the same URIs every time.announce(object, recipient)queues aLOGISTICS_OBJECT_AVAILABLEnotification so a partner learns the URI.accept,reject,acknowledge,revokedecide action requests.forget(object)erases every revision; see Forgetting data.
Embedded objects (a cargo:Value, a cargo:Party) receive stable
internal:<uuid5> ids the moment an object is first stored, so later
changes can address them.
Who may do what¶
The AccessPolicy decides every action: reading an object, changing it,
posting or reading its events, reading the audit trail, creating objects over
HTTP, deciding action requests. The in-memory policy denies everything unless
told otherwise:
$server->policy->addInternal($holderAgent); // your own systems: everything
$server->policy->allow($partner, $objectIri, [Permission::GetLogisticsObject]);
$server->policy->allowEveryone($objectIri, [Permission::GetLogisticsObject]);
Grants from accepted access delegations are honoured automatically. A denial
answers 403 as the spec says; construct the policy with Decision::Hide to
answer 404 and not confirm that an object exists.
The example server¶
bin/serve runs this in-memory server under PHP's built-in web server with an
OAuth 2.0 token endpoint, for manual testing and the compliance collection:
bin/serve --port=8080
bin/serve --print-token=holder # a bearer token for the data holder client
It is a development tool and needs the dev dependencies.