Files
CloudObjects-PHP-SDK/CloudObjects/SDK/ObjectRetriever.php

464 lines
15 KiB
PHP

<?php
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
namespace CloudObjects\SDK;
use Exception;
use ML\IRI\IRI, ML\JsonLD\JsonLD;
use Doctrine\Common\Cache\RedisCache;
use Psr\Log\LoggerInterface, Psr\Log\LoggerAwareTrait;
use GuzzleHttp\ClientInterface, GuzzleHttp\Client, GuzzleHttp\HandlerStack;
use GuzzleHttp\Exception\RequestException;
use Kevinrob\GuzzleCache\CacheMiddleware, Kevinrob\GuzzleCache\Storage\DoctrineCacheStorage;
use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy;
use CloudObjects\SDK\Exceptions\CoreAPIException;
use CloudObjects\SDK\AccountGateway\AccountContext;
/**
* The ObjectRetriever provides access to objects on CloudObjects.
*/
class ObjectRetriever {
use LoggerAwareTrait;
private $client;
private $prefix;
private $options;
private $cache;
private $objects;
const CO_API_URL = 'https://api.cloudobjects.net/';
const REVISION_PROPERTY = 'coid://cloudobjects.io/isAtRevision';
public function __construct($options = []) {
// Merge options with defaults
$this->options = array_merge([
'cache_provider' => 'none',
'cache_prefix' => 'clobj:',
'cache_ttl' => 60,
'static_config_path' => null,
'auth_ns' => null,
'auth_secret' => null,
'api_base_url' => null,
'logger' => null,
'timeout' => 20,
'connect_timeout' => 5
], $options);
// Set up object cache
switch ($this->options['cache_provider']) {
case 'none':
// no caching
$this->cache = null;
break;
case 'redis':
// caching with Redis
$redis = new \Redis();
$redis->pconnect(
isset($this->options['cache_provider.redis.host']) ? $this->options['cache_provider.redis.host'] : '127.0.0.1',
isset($this->options['cache_provider.redis.port']) ? $this->options['cache_provider.redis.port'] : 6379);
$this->cache = new RedisCache();
$this->cache->setRedis($redis);
break;
case 'file':
// caching on the filesystem
$this->cache = new \Doctrine\Common\Cache\FilesystemCache(
isset($this->options['cache_provider.file.directory']) ? $this->options['cache_provider.file.directory'] : sys_get_temp_dir()
);
break;
default:
throw new Exception('Valid values for cache_provider are: none, redis, file');
}
// Set up logger
if (is_a($this->options['logger'], LoggerInterface::class))
$this->setLogger($this->options['logger']);
// Set up handler stack
$stack = HandlerStack::create();
// Add HTTP cache if specified
if (isset($this->cache)) {
$stack->push(
new CacheMiddleware(
new PrivateCacheStrategy(
new DoctrineCacheStorage($this->cache)
)
)
);
}
// Initialize client
$options = [
'base_uri' => isset($options['api_base_url']) ? $options['api_base_url'] : self::CO_API_URL,
'handler' => $stack,
'connect_timeout' => $this->options['connect_timeout'],
'timeout' => $this->options['timeout']
];
if (isset($this->options['auth_ns']) && isset($this->options['auth_secret']))
$options['auth'] = [$this->options['auth_ns'], $this->options['auth_secret']];
$this->client = new Client($options);
}
private function logInfoWithTime($message, $ts) {
if (isset($this->logger))
$this->logger->info($message, [ 'elapsed_ms' => round((microtime(true) - $ts) * 1000) ]);
}
private function getFromCache($id) {
return (isset($this->cache) && $this->cache->contains($this->options['cache_prefix'].$id))
? $this->cache->fetch($this->options['cache_prefix'].$id) : null;
}
private function putIntoCache($id, $data, $ttl) {
if (isset($this->cache))
$this->cache->save($this->options['cache_prefix'].$id, $data, $ttl);
}
/**
* Get the HTTP client that is used to access the API.
*/
public function getClient() {
return $this->client;
}
/**
* Set the HTTP client that is used to access the API.
*
* @param ClientInterface $client The HTTP client.
* @param string $prefix An optional prefix (e.g. an AccountGateway mountpoint)
*/
public function setClient(ClientInterface $client, $prefix = null) {
$this->client = $client;
$this->prefix = $prefix;
}
/**
* Creates a clone of this object retriever that uses the given account
* context to access the API as a developer API. Cache settings are inherited
* but the prefix is extended to keep cache content specific to account.
*
* @param AccountContext $accountContext
* @param string $mountpointName The name for the API mountpoint.
*/
public function withAccountContext(AccountContext $accountContext, string $mountpointName) {
$newRetriever = new self($this->options);
$newRetriever->options['cache_prefix'] .= (string)$accountContext->getAAUID();
$newRetriever->client = $accountContext->getClient();
$newRetriever->prefix = '/'.$mountpointName.'/';
return $newRetriever;
}
/**
* Get an object description from CloudObjects. Attempts to get object
* from in-memory cache first, stored static configurations next,
* configured external cache third, and finally calls the Object API
* on CloudObjects Core. Returns null if the object was not found.
*
* @param IRI $coid COID of the object
* @return Node|null
*/
public function getObject(IRI $coid) {
if (!COIDParser::isValidCOID($coid))
throw new Exception("Not a valid COID.");
$uriString = (string)$coid;
if (isset($this->objects[$uriString]))
// Return from in-memory cache if it exists
return $this->objects[$uriString];
$ts = microtime(true);
if (isset($this->options['static_config_path'])) {
$location = realpath($this->options['static_config_path'].DIRECTORY_SEPARATOR.
$coid->getHost().str_replace('/', DIRECTORY_SEPARATOR, $coid->getPath())
.DIRECTORY_SEPARATOR.'object.jsonld');
if ($location && file_exists($location)) {
$object = $location;
$this->logInfoWithTime('Fetched <'.$uriString.'> from static configuration.', $ts);
}
}
if (!isset($object)) {
$object = $this->getFromCache($uriString);
if (isset($object))
$this->logInfoWithTime('Fetched <'.$uriString.'> from object cache.', $ts);
}
if (!isset($object)) {
try {
$response = $this->client
->get((isset($this->prefix) ? $this->prefix : '').$coid->getHost().$coid->getPath().'/object',
['headers' => ['Accept' => 'application/ld+json']]);
$object = (string)$response->getBody();
$this->putIntoCache($uriString, $object, $this->options['cache_ttl']);
$this->logInfoWithTime('Fetched <'.$uriString.'> from Core API ['.$response->getStatusCode().'].', $ts);
} catch (RequestException $e) {
if ($e->hasResponse())
$this->logInfoWithTime('Object <'.$uriString.'> not found in Core API ['.$e->getResponse()->getStatusCode().'].', $ts);
else
$this->logInfoWithTime('Object <'.$uriString.'> could not be retrieved from Core API.', $ts);
return null;
}
}
$document = JsonLD::getDocument($object);
$this->objects[$uriString] = $document->getGraph()->getNode($uriString);
return $this->objects[$uriString];
}
/**
* Fetch all object descriptions for objects in a specific namespace
* and with a certain type from CloudObjects. Adds individual objects
* to cache and returns a list of COIDs (as IRI) for them. The list
* itself is not cached, which means that every call of this function
* goes to the Object API.
*
* @param IRI $namespaceCoid COID of the namespace
* @param $type RDF type that objects should have
* @return array<IRI>
*/
public function fetchObjectsInNamespaceWithType(IRI $namespaceCoid, $type) {
if (COIDParser::getType($namespaceCoid) != COIDParser::COID_ROOT)
throw new Exception("Not a valid namespace COID.");
$ts = microtime(true);
$type = (string)$type;
try {
$response = $this->client
->get((isset($this->prefix) ? $this->prefix : '').$namespaceCoid->getHost().'/all',
[
'headers' => [ 'Accept' => 'application/ld+json' ],
'query' => [ 'type' => $type ]
]);
$document = JsonLD::getDocument((string)$response->getBody());
$allObjects = $document->getGraph()->getNodesByType($type);
$allIris = [];
foreach ($allObjects as $object) {
$iri = new IRI($object->getId());
if (!COIDParser::isValidCOID($iri)) continue;
if ($iri->getHost() != $namespaceCoid->getHost()) continue;
$this->objects[$object->getId()] = $object;
$this->putIntoCache($object->getId(), $object, $this->options['cache_ttl']);
$allIris[] = $iri;
}
$this->logInfoWithTime('Fetched all objects with <'.$type.'> for <'.$namespaceCoid->getHost().'> from Core API ['.$response->getStatusCode().'].', $ts);
} catch (Exception $e) {
throw new CoreAPIException;
}
return $allIris;
}
/**
* Fetch all object descriptions for objects in a specific namespace
* from CloudObjects. Adds individual objects to cache and returns a
* list of COIDs (as IRI) for them. The list itself is not cached,
* which means that every call of this function goes to the Object API.
*
* @param IRI $namespaceCoid COID of the namespace
* @return array<IRI>
*/
public function fetchAllObjectsInNamespace(IRI $namespaceCoid) {
if (COIDParser::getType($namespaceCoid) != COIDParser::COID_ROOT)
throw new Exception("Not a valid namespace COID.");
$ts = microtime(true);
try {
$response = $this->client
->get((isset($this->prefix) ? $this->prefix : '').$namespaceCoid->getHost().'/all',
[ 'headers' => [ 'Accept' => 'application/ld+json' ] ]);
$document = JsonLD::getDocument((string)$response->getBody());
$allObjects = $document->getGraph()->getNodes();
$allIris = [];
foreach ($allObjects as $object) {
$iri = new IRI($object->getId());
if (!COIDParser::isValidCOID($iri)) continue;
if ($iri->getHost() != $namespaceCoid->getHost()) continue;
$this->objects[$object->getId()] = $object;
$this->putIntoCache($object->getId(), $object, $this->options['cache_ttl']);
$allIris[] = $iri;
}
$this->logInfoWithTime('Fetched all objects for <'.$namespaceCoid->getHost().'> from Core API ['.$response->getStatusCode().'].', $ts);
} catch (Exception $e) {
throw new CoreAPIException;
}
return $allIris;
}
/**
* Fetch a list of COIDs for all objects in a specific namespace
* from CloudObjects, but not the objects itself. The list is not cached,
* which means that every call of this function goes to the Object API.
*
* @param IRI $namespaceCoid COID of the namespace
* @return array<IRI>
*/
public function getCOIDListForNamespace(IRI $namespaceCoid) {
if (COIDParser::getType($namespaceCoid) != COIDParser::COID_ROOT)
throw new Exception("Not a valid namespace COID.");
$ts = microtime(true);
try {
$response = $this->client
->get((isset($this->prefix) ? $this->prefix : '').$namespaceCoid->getHost().'/coids',
[ 'headers' => [ 'Accept' => 'application/ld+json' ] ]);
$document = JsonLD::getDocument((string)$response->getBody());
$containerNode = $document->getGraph()->getNode('co-namespace-members://'.$namespaceCoid->getHost());
$reader = new NodeReader([ 'prefixes' => [ 'rdfs' => 'http://www.w3.org/2000/01/rdf-schema#' ]]);
$allIris = $reader->getAllValuesIRI($containerNode, 'rdfs:member');
$this->logInfoWithTime('Fetched object list for <'.$namespaceCoid->getHost().'> from Core API ['.$response->getStatusCode().'].', $ts);
} catch (Exception $e) {
throw new CoreAPIException;
}
return $allIris;
}
/**
* Fetch a list of COIDs for all objects in a specific namespace
* from CloudObjects, but not the objects itself. The list is not cached,
* which means that every call of this function goes to the Object API.
*
* @param IRI $namespaceCoid COID of the namespace
* @param $type RDF type that objects should have
* @return array<IRI>
*/
public function getCOIDListForNamespaceWithType(IRI $namespaceCoid, $type) {
if (COIDParser::getType($namespaceCoid) != COIDParser::COID_ROOT)
throw new Exception("Not a valid namespace COID.");
$ts = microtime(true);
$type = (string)$type;
try {
$response = $this->client
->get((isset($this->prefix) ? $this->prefix : '').$namespaceCoid->getHost().'/coids',
[
'headers' => [ 'Accept' => 'application/ld+json' ],
'query' => [ 'type' => $type ]
]);
$document = JsonLD::getDocument((string)$response->getBody());
$containerNode = $document->getGraph()->getNode('co-namespace-members://'.$namespaceCoid->getHost());
$reader = new NodeReader([ 'prefixes' => [ 'rdfs' => 'http://www.w3.org/2000/01/rdf-schema#' ]]);
$allIris = $reader->getAllValuesIRI($containerNode, 'rdfs:member');
$this->logInfoWithTime('Fetched object list with <'.$type.'> for <'.$namespaceCoid->getHost().'> from Core API ['.$response->getStatusCode().'].', $ts);
} catch (Exception $e) {
throw new CoreAPIException;
}
return $allIris;
}
/**
* Get an object description from CloudObjects. Shorthand method for
* "getObject" which allows passing the COID as string instead of IRI.
*
* @param any $coid
* @return Node|null
*/
public function get($coid) {
if (is_string($coid))
return $this->getObject(new IRI($coid));
if (is_object($coid) && get_class($coid)=='ML\IRI\IRI')
return $this->getObject($coid);
throw new Exception('COID must be passed as a string or an IRI object.');
}
/**
* Get a object's attachment.
*
* @param IRI $coid
* @param string $filename
*/
public function getAttachment(IRI $coid, $filename) {
$object = $this->getObject($coid);
if (!$object)
// Cannot get attachment for non-existing object
return null;
$ts = microtime(true);
$cacheId = $object->getId().'#'.$filename;
$fileData = $this->getFromCache($cacheId);
// Parse cached data into revision and content
if (isset($fileData)) {
$this->logInfoWithTime('Fetched attachment <'.$filename.'> for <'.$object->getId().'> from object cache.', $ts);
list($fileRevision, $fileContent) = explode('#', $fileData, 2);
}
if (!isset($fileData)
|| $fileRevision!=$object->getProperty(self::REVISION_PROPERTY)->getValue()) {
// Does not exist in cache or is outdated, fetch from CloudObjects
try {
$response = $this->client->get((isset($this->prefix) ? $this->prefix : '').$coid->getHost().$coid->getPath()
.'/'.basename($filename));
$fileContent = $response->getBody()->getContents();
$fileData = $object->getProperty(self::REVISION_PROPERTY)->getValue().'#'.$fileContent;
$this->putIntoCache($cacheId, $fileData, 0);
$this->logInfoWithTime('Fetched attachment <'.$filename.'> for <'.$object->getId().'> from Core API ['.$response->getStatusCode().'].', $ts);
} catch (Exception $e) {
$this->logInfoWithTime('Attachment <'.$filename.'> for <'.$object->getId().'> not found in Core API ['.$e->getResponse()->getStatusCode().'].', $ts);
// ignore exception - treat as non-existing file
}
}
return $fileContent;
}
/**
* Retrieve the object that describes the namespace provided with the "auth_ns"
* configuration option.
*
* @return Node
*/
public function getAuthenticatingNamespaceObject() {
if (!isset($this->options['auth_ns']))
throw new Exception("Missing 'auth_ns' configuration option.");
$namespaceCoid = COIDParser::fromString($this->options['auth_ns']);
if (COIDParser::getType($namespaceCoid) != COIDParser::COID_ROOT)
throw new Exception("The 'auth_ns' configuration option is not a valid namespace/root COID.");
return $this->getObject($namespaceCoid);
}
}