feat: implement entiry read and write
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -44,8 +44,8 @@ class EntityProperties extends EntityPropertiesMutableAbstract {
|
||||
public function toJmap(): array {
|
||||
|
||||
$parameters = array_filter([
|
||||
'name' => $this->data[self::PROPERTY_LABEL],
|
||||
'type' => $this->data[self::PROPERTY_MIME] ?? 'application/octet-stream'
|
||||
'name' => $this->data[self::PROPERTY_LABEL] ?? null,
|
||||
'type' => $this->data[self::PROPERTY_MIME] ?? null
|
||||
], static fn($value) => $value !== null);
|
||||
|
||||
return $parameters;
|
||||
|
||||
@@ -10,19 +10,27 @@ declare(strict_types=1);
|
||||
namespace KTXM\ProviderJmapc\Providers\Document;
|
||||
|
||||
use Generator;
|
||||
use KTXF\Blob\Signature;
|
||||
use KTXF\Documents\Collection\CollectionBaseInterface;
|
||||
use KTXF\Documents\Collection\CollectionMutableInterface;
|
||||
use KTXF\Documents\Collection\CollectionPropertiesBaseInterface;
|
||||
use KTXF\Documents\Entity\EntityBaseInterface;
|
||||
use KTXF\Documents\Entity\EntityMutableInterface;
|
||||
use KTXF\Documents\Entity\EntityPropertiesMutableInterface;
|
||||
use KTXF\Documents\Service\ServiceBaseInterface;
|
||||
use KTXF\Documents\Service\ServiceCollectionMutableInterface;
|
||||
use KTXF\Documents\Service\ServiceConfigurableInterface;
|
||||
use KTXF\Documents\Service\ServiceEntityMutableInterface;
|
||||
use KTXF\Documents\Service\ServiceMutableInterface;
|
||||
use KTXF\Documents\Service\ServiceNodeListInterface;
|
||||
use KTXF\Resource\Delta\Delta;
|
||||
use KTXF\Resource\Exceptions\InvalidParameterException;
|
||||
use KTXF\Resource\Exceptions\UnsupportedException;
|
||||
use KTXF\Resource\Filter\Filter;
|
||||
use KTXF\Resource\Filter\IFilter;
|
||||
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||
use KTXF\Resource\Identifier\CollectionIdentifierInterface;
|
||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||
use KTXF\Resource\Identifier\EntityIdentifierInterface;
|
||||
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
|
||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
||||
@@ -38,7 +46,7 @@ use KTXM\ProviderJmapc\Providers\ServiceLocation;
|
||||
use KTXM\ProviderJmapc\Service\Remote\RemoteFilesService;
|
||||
use KTXM\ProviderJmapc\Service\Remote\RemoteService;
|
||||
|
||||
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceNodeListInterface
|
||||
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceNodeListInterface
|
||||
{
|
||||
public const JSON_TYPE = ServiceBaseInterface::JSON_TYPE;
|
||||
|
||||
@@ -80,6 +88,13 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
self::CAPABILITY_ENTITY_DELTA => true,
|
||||
self::CAPABILITY_ENTITY_EXTANT => true,
|
||||
self::CAPABILITY_ENTITY_FETCH => true,
|
||||
self::CAPABILITY_ENTITY_READ => true,
|
||||
self::CAPABILITY_ENTITY_CREATE => true,
|
||||
self::CAPABILITY_ENTITY_MODIFY => true,
|
||||
self::CAPABILITY_ENTITY_DELETE => true,
|
||||
self::CAPABILITY_ENTITY_MOVE => true,
|
||||
self::CAPABILITY_ENTITY_COPY => true,
|
||||
self::CAPABILITY_ENTITY_WRITE => true,
|
||||
self::CAPABILITY_NODE_LIST => true,
|
||||
self::CAPABILITY_NODE_LIST_FILTER => [
|
||||
self::CAPABILITY_NODE_FILTER_LABEL => 's:100:256:256',
|
||||
@@ -504,20 +519,209 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
yield from $this->entityFetchBulk(...$identifiers);
|
||||
}
|
||||
|
||||
public function entityFresh(): EntityMutableInterface
|
||||
{
|
||||
return new EntityResource(provider: $this->provider(), service: $this->identifier());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert incoming entity properties into a native EntityResource
|
||||
*/
|
||||
private function entityNative(EntityPropertiesMutableInterface $properties, string|int|null $identifier = null): EntityResource
|
||||
{
|
||||
if ($properties instanceof EntityResource) {
|
||||
return clone $properties;
|
||||
}
|
||||
$object = new EntityResource(provider: $this->provider(), service: $this->identifier());
|
||||
$object->jsonDeserialize(array_filter([
|
||||
EntityResource::PROPERTY_IDENTIFIER => $identifier,
|
||||
EntityResource::PROPERTY_PROPERTIES => $properties->jsonSerialize(),
|
||||
], fn($v) => $v !== null));
|
||||
return $object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the remote parameters of an entity
|
||||
*/
|
||||
private function entityRemote(EntityIdentifierInterface $target): ?array
|
||||
{
|
||||
$this->initialize();
|
||||
|
||||
$identifier = (string)$target->entity();
|
||||
$entity = $this->remoteService->entityFetch($identifier)[$identifier] ?? null;
|
||||
// folders are not entities
|
||||
if (!is_array($entity) || empty($entity['blobId'])) {
|
||||
return null;
|
||||
}
|
||||
return $entity;
|
||||
}
|
||||
|
||||
public function entityCreate(CollectionIdentifierInterface $target, EntityPropertiesMutableInterface $properties, array $options = []): EntityBaseInterface
|
||||
{
|
||||
$this->initialize();
|
||||
|
||||
$entity = $this->entityNative($properties)->toJmap();
|
||||
$entity['type'] ??= 'application/octet-stream';
|
||||
// a file node must reference a blob, so start with empty content until it is written
|
||||
$content = '';
|
||||
$blob = $this->remoteService->blobUpload($entity['type'], $content);
|
||||
$entity['blobId'] = $blob['blobId'];
|
||||
$entity = $this->remoteService->entityCreate((string)$target->collection(), $entity);
|
||||
if ($entity === null) {
|
||||
throw new \RuntimeException("Failed to create entity in collection: {$target->collection()}");
|
||||
}
|
||||
|
||||
$object = new EntityResource(provider: $this->provider(), service: $this->identifier());
|
||||
$object->fromJmap($entity);
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
||||
public function entityModify(EntityIdentifierInterface $target, EntityPropertiesMutableInterface $properties): EntityBaseInterface
|
||||
{
|
||||
$this->initialize();
|
||||
|
||||
$identifier = (string)$target->entity();
|
||||
$entity = $this->entityNative($properties, $identifier)->toJmap();
|
||||
if ($this->remoteService->entityModify($identifier, $entity) === null) {
|
||||
throw new \RuntimeException("Failed to modify entity: $identifier");
|
||||
}
|
||||
|
||||
$object = new EntityResource(provider: $this->provider(), service: $this->identifier());
|
||||
$object->fromJmap($this->entityRemote($target) ?? $entity);
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
||||
public function entityDelete(EntityIdentifierInterface ...$targets): array
|
||||
{
|
||||
$this->initialize();
|
||||
|
||||
$results = [];
|
||||
foreach ($targets as $target) {
|
||||
$key = (string)$target;
|
||||
try {
|
||||
$deleted = $this->remoteService->entityDelete((string)$target->entity()) !== null;
|
||||
} catch (\Throwable) {
|
||||
$deleted = false;
|
||||
}
|
||||
$results[$key] = ['disposition' => $deleted ? 'deleted' : 'error', 'destination' => null, 'mutation' => $target];
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function entityMove(CollectionIdentifierInterface $target, EntityIdentifierInterface ...$sources): array
|
||||
{
|
||||
return $this->entityRelocate($target, true, ...$sources);
|
||||
}
|
||||
|
||||
public function entityCopy(CollectionIdentifierInterface $target, EntityIdentifierInterface ...$sources): array
|
||||
{
|
||||
return $this->entityRelocate($target, false, ...$sources);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relocate (move or copy) entities into a target collection
|
||||
*
|
||||
* @return array<string, array{disposition: string, destination: ?CollectionIdentifierInterface, mutation: EntityIdentifierInterface}>
|
||||
*/
|
||||
private function entityRelocate(CollectionIdentifierInterface $target, bool $remove, EntityIdentifierInterface ...$sources): array
|
||||
{
|
||||
$this->initialize();
|
||||
|
||||
$results = [];
|
||||
$collection = (string)$target->collection();
|
||||
foreach ($sources as $source) {
|
||||
$key = (string)$source;
|
||||
try {
|
||||
$entity = $remove
|
||||
? $this->remoteService->entityMove($collection, (string)$source->entity())
|
||||
: $this->remoteService->entityCopy($collection, (string)$source->entity());
|
||||
} catch (\Throwable) {
|
||||
$entity = null;
|
||||
}
|
||||
if ($entity === null) {
|
||||
$results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $source];
|
||||
continue;
|
||||
}
|
||||
$results[$key] = [
|
||||
'disposition' => $remove ? 'moved' : 'copied',
|
||||
'destination' => new CollectionIdentifier($this->provider(), $this->identifier(), $collection),
|
||||
'mutation' => new EntityIdentifier($this->provider(), $this->identifier(), $collection, (string)$entity['id']),
|
||||
];
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function entityRead(EntityIdentifierInterface $target): ?string
|
||||
{
|
||||
$entity = $this->entityRemote($target);
|
||||
if ($entity === null) {
|
||||
return null;
|
||||
}
|
||||
return $this->remoteService->blobDownload($entity['blobId'], $entity['type'] ?? 'application/octet-stream');
|
||||
}
|
||||
|
||||
public function entityReadStream(EntityIdentifierInterface $target)
|
||||
{
|
||||
$entity = $this->entityRemote($target);
|
||||
if ($entity === null) {
|
||||
return null;
|
||||
}
|
||||
return $this->remoteService->blobDownloadStream($entity['blobId'], $entity['type'] ?? 'application/octet-stream');
|
||||
}
|
||||
|
||||
public function entityReadChunk(EntityIdentifierInterface $target, int $offset, int $length): ?string
|
||||
{
|
||||
$content = $this->entityRead($target);
|
||||
if ($content === null) {
|
||||
return null;
|
||||
}
|
||||
return substr($content, $offset, $length);
|
||||
}
|
||||
|
||||
public function entityWrite(EntityIdentifierInterface $target, string $data): int
|
||||
{
|
||||
$entity = $this->entityRemote($target);
|
||||
$identifier = (string)$target->entity();
|
||||
if ($entity === null) {
|
||||
throw new InvalidParameterException("Entity not found: $identifier");
|
||||
}
|
||||
|
||||
// blobs are immutable, so content is replaced by pointing the node at a new blob
|
||||
$type = $entity['type'] ?? null;
|
||||
if (empty($type) || $type === 'application/octet-stream') {
|
||||
$type = Signature::detect(substr($data, 0, Signature::SAMPLE_SIZE), $data)['mime'];
|
||||
}
|
||||
$blob = $this->remoteService->blobUpload($type, $data);
|
||||
if ($this->remoteService->entityModify($identifier, ['blobId' => $blob['blobId'], 'type' => $type]) === null) {
|
||||
throw new \RuntimeException("Failed to write to entity: $identifier");
|
||||
}
|
||||
|
||||
return strlen($data);
|
||||
}
|
||||
|
||||
public function entityWriteChunk(EntityIdentifierInterface $target, int $offset, string $data): int
|
||||
{
|
||||
if ($offset === 0) {
|
||||
$content = $data;
|
||||
} else {
|
||||
// blobs can not be partially updated, so the chunk is merged into the current content
|
||||
$content = $this->entityRead($target);
|
||||
if ($content === null) {
|
||||
throw new InvalidParameterException("Entity not found: {$target->entity()}");
|
||||
}
|
||||
$content = substr(str_pad($content, $offset, "\0"), 0, $offset) . $data . substr($content, $offset + strlen($data));
|
||||
}
|
||||
$this->entityWrite($target, $content);
|
||||
|
||||
return strlen($data);
|
||||
}
|
||||
|
||||
public function entityWriteStream(EntityIdentifierInterface $target)
|
||||
{
|
||||
throw new UnsupportedException('Streamed writes are not supported by JMAP file storage');
|
||||
}
|
||||
|
||||
// Node operations (unified collections + entities)
|
||||
|
||||
|
||||
@@ -649,42 +649,35 @@ class RemoteFilesService {
|
||||
/**
|
||||
* create entity in remote storage
|
||||
*
|
||||
* a file node must reference a blob, a node without one is a folder
|
||||
*
|
||||
* @since Release 1.0.0
|
||||
*/
|
||||
public function entityCreate(string $location, array $so): ?array {
|
||||
public function entityCreate(string|null $location, array $so): ?array {
|
||||
if (empty($so['blobId'])) {
|
||||
throw new Exception('invalidArguments: A file node requires a blob identifier', 1);
|
||||
}
|
||||
// convert entity
|
||||
$to = new NodeParametersRequest();
|
||||
$to->parametersRaw($so);
|
||||
// define location
|
||||
$location = $this->locationNormalize($location);
|
||||
if ($location !== null) {
|
||||
$to->in($location);
|
||||
}
|
||||
$id = uniqid();
|
||||
// construct request
|
||||
$r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel);
|
||||
$r0->create($id, $to);
|
||||
// transceive
|
||||
$bundle = $this->dataStore->perform([$r0]);
|
||||
// extract response
|
||||
$response = $bundle->response(0);
|
||||
// check for command error
|
||||
if ($response instanceof ResponseException) {
|
||||
if ($response->type() === 'unknownMethod') {
|
||||
throw new JmapUnknownMethod($response->description(), 1);
|
||||
} else {
|
||||
throw new Exception($response->type() . ': ' . $response->description(), 1);
|
||||
}
|
||||
}
|
||||
$response = $this->performSet($r0);
|
||||
// check for success
|
||||
$result = $response->createSuccess($id);
|
||||
if ($result !== null) {
|
||||
return array_merge($so, $result);
|
||||
return array_merge($so, ['parentId' => $location], $result);
|
||||
}
|
||||
// check for failure
|
||||
$result = $response->createFailure($id);
|
||||
if ($result !== null) {
|
||||
$type = $result['type'] ?? 'unknownError';
|
||||
$description = $result['description'] ?? 'An unknown error occurred during collection creation.';
|
||||
throw new Exception("$type: $description", 1);
|
||||
}
|
||||
// return null if creation failed without failure reason
|
||||
$this->throwSetFailure($response->createFailure($id), 'entity creation');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -693,26 +686,22 @@ class RemoteFilesService {
|
||||
*
|
||||
* @since Release 1.0.0
|
||||
*/
|
||||
public function entityModify(array $so): ?array {
|
||||
// extract entity id
|
||||
$id = $so['id'];
|
||||
public function entityModify(string $identifier, array $so): ?array {
|
||||
// convert entity
|
||||
$to = new NodeParametersRequest();
|
||||
$to->parametersRaw($so);
|
||||
// construct request
|
||||
$r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel);
|
||||
$r0->update($id, $to);
|
||||
// transmit request and receive response
|
||||
$bundle = $this->dataStore->perform([$r0]);
|
||||
// extract response
|
||||
$response = $bundle->response(0);
|
||||
// determine if command succeeded
|
||||
if (array_key_exists($id, $response->updated())) {
|
||||
// update entity
|
||||
$ro = $response->updated()[$id];
|
||||
$so = array_merge($so, $ro);
|
||||
return $so;
|
||||
$r0->update($identifier, $to);
|
||||
// transceive
|
||||
$response = $this->performSet($r0);
|
||||
// check for success
|
||||
$result = $response->updateSuccess($identifier);
|
||||
if ($result !== null) {
|
||||
return array_merge($so, $result, ['id' => $identifier]);
|
||||
}
|
||||
// check for failure
|
||||
$this->throwSetFailure($response->updateFailure($identifier), 'entity modification');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -720,55 +709,130 @@ class RemoteFilesService {
|
||||
* delete entity from remote storage
|
||||
*
|
||||
* @since Release 1.0.0
|
||||
*
|
||||
* @return string|null new state on success
|
||||
*/
|
||||
public function entityDelete(string $id): ?string {
|
||||
// construct set request
|
||||
public function entityDelete(string $identifier): ?string {
|
||||
// construct request
|
||||
$r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel);
|
||||
// construct object
|
||||
$r0->delete($id);
|
||||
// transmit request and receive response
|
||||
$bundle = $this->dataStore->perform([$r0]);
|
||||
// extract response
|
||||
$response = $bundle->response(0);
|
||||
// determine if command succeeded
|
||||
if (array_search($id, $response->deleted()) !== false) {
|
||||
$r0->delete($identifier);
|
||||
// transceive
|
||||
$response = $this->performSet($r0);
|
||||
// check for success
|
||||
if ($response->deleteSuccess($identifier) !== null) {
|
||||
return $response->stateNew();
|
||||
}
|
||||
// check for failure
|
||||
$this->throwSetFailure($response->deleteFailure($identifier), 'entity deletion');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* copy entity in remote storage
|
||||
*
|
||||
* @since Release 1.0.0
|
||||
* the copy references the same blob as the source, blobs are immutable
|
||||
*
|
||||
* @since Release 1.0.0
|
||||
*/
|
||||
public function entityCopy(string $location, MailMessageObject $so): ?MailMessageObject {
|
||||
public function entityCopy(string|null $location, string $identifier): ?array {
|
||||
$source = $this->entityFetch($identifier)[$identifier] ?? null;
|
||||
if ($source === null) {
|
||||
return null;
|
||||
}
|
||||
return $this->entityCreate($location, array_intersect_key($source, array_flip(['blobId', 'name', 'type', 'executable'])));
|
||||
}
|
||||
|
||||
/**
|
||||
* move entity in remote storage
|
||||
*
|
||||
* @since Release 1.0.0
|
||||
*
|
||||
*/
|
||||
public function entityMove(string $location, array $so): ?array {
|
||||
// extract entity id
|
||||
$id = $so['id'];
|
||||
public function entityMove(string|null $location, string $identifier): ?array {
|
||||
$location = $this->locationNormalize($location);
|
||||
// construct request
|
||||
$r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel);
|
||||
$r0->update($id)->in($location);
|
||||
// transmit request and receive response
|
||||
$bundle = $this->dataStore->perform([$r0]);
|
||||
// extract response
|
||||
$response = $bundle->response(0);
|
||||
// determine if command succeeded
|
||||
if (array_key_exists($id, $response->updated())) {
|
||||
$so = array_merge($so, ['mailboxIds' => [$location => true]]);
|
||||
return $so;
|
||||
$r0->update($identifier)->in($location);
|
||||
// transceive
|
||||
$response = $this->performSet($r0);
|
||||
// check for success
|
||||
$result = $response->updateSuccess($identifier);
|
||||
if ($result !== null) {
|
||||
return array_merge($result, ['id' => $identifier, 'parentId' => $location]);
|
||||
}
|
||||
// check for failure
|
||||
$this->throwSetFailure($response->updateFailure($identifier), 'entity move');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* upload blob to remote storage
|
||||
*
|
||||
* @since 2026.10.01
|
||||
*
|
||||
* @return array{blobId: string, type?: string, size?: int}
|
||||
*/
|
||||
public function blobUpload(string $type, string &$data): array {
|
||||
$response = json_decode($this->dataStore->upload($this->dataAccount, $type, $data), true);
|
||||
if (!is_array($response) || empty($response['blobId'])) {
|
||||
throw new Exception('serverFail: Blob upload did not return a blob identifier', 1);
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* download blob from remote storage
|
||||
*
|
||||
* @since 2026.10.01
|
||||
*/
|
||||
public function blobDownload(string $identifier, string $type = 'application/octet-stream'): string {
|
||||
$data = '';
|
||||
$this->dataStore->download($this->dataAccount, $identifier, $data, $type);
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* download blob from remote storage as stream
|
||||
*
|
||||
* @since 2026.10.01
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
public function blobDownloadStream(string $identifier, string $type = 'application/octet-stream') {
|
||||
$stream = $this->dataStore->downloadStream($this->dataAccount, $identifier, $type);
|
||||
return \GuzzleHttp\Psr7\StreamWrapper::getResource($stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* root location is represented by a null parent in remote storage
|
||||
*/
|
||||
private function locationNormalize(string|null $location): ?string {
|
||||
return (empty($location) || $location === self::ROOT_ID) ? null : $location;
|
||||
}
|
||||
|
||||
/**
|
||||
* transceive a set request and return its response, throwing on command error
|
||||
*/
|
||||
private function performSet(NodeSet $request): mixed {
|
||||
$response = $this->dataStore->perform([$request])->response(0);
|
||||
if ($response instanceof ResponseException) {
|
||||
if ($response->type() === 'unknownMethod') {
|
||||
throw new JmapUnknownMethod($response->description(), 1);
|
||||
} else {
|
||||
throw new Exception($response->type() . ': ' . $response->description(), 1);
|
||||
}
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* throw set failure reason if present
|
||||
*/
|
||||
private function throwSetFailure(?array $failure, string $operation): void {
|
||||
if ($failure !== null) {
|
||||
$type = $failure['type'] ?? 'unknownError';
|
||||
$description = $failure['description'] ?? "An unknown error occurred during $operation.";
|
||||
throw new Exception("$type: $description", 1);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user