diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 0e6bfb6..74b6b93 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -43,6 +43,7 @@ class DefaultController extends ControllerAbstract { private const ERR_MISSING_DATA = 'Missing parameter: data'; private const ERR_MISSING_SOURCES = 'Missing parameter: sources'; private const ERR_MISSING_TARGET = 'Missing parameter: target'; + private const ERR_MISSING_TARGETS = 'Missing parameter: targets'; private const ERR_MISSING_IDENTIFIERS = 'Missing parameter: identifiers'; private const ERR_INVALID_OPERATION = 'Invalid operation: '; private const ERR_INVALID_PROVIDER = 'Invalid parameter: provider must be a string'; @@ -51,6 +52,7 @@ class DefaultController extends ControllerAbstract { private const ERR_INVALID_COLLECTION = 'Invalid parameter: collection must be a string or integer'; private const ERR_INVALID_SOURCES = 'Invalid parameter: sources must be an array'; private const ERR_INVALID_TARGET = 'Invalid parameter: target must be an array'; + private const ERR_INVALID_TARGETS = 'Invalid parameter: targets must be an array'; private const ERR_INVALID_IDENTIFIERS = 'Invalid parameter: identifiers must be an array'; private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array'; @@ -148,21 +150,22 @@ class DefaultController extends ControllerAbstract { 'collection.list' => $this->collectionList($tenantId, $userId, $data), 'collection.fetch' => $this->collectionFetch($tenantId, $userId, $data), 'collection.extant' => $this->collectionExtant($tenantId, $userId, $data), + 'collection.delta' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'collection.create' => $this->collectionCreate($tenantId, $userId, $data), 'collection.update' => $this->collectionUpdate($tenantId, $userId, $data), 'collection.delete' => $this->collectionDelete($tenantId, $userId, $data), - 'collection.delta' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'collection.move' => $this->collectionMove($tenantId, $userId, $data), // Entity operations 'entity.list' => $this->entityList($tenantId, $userId, $data), + 'entity.stream' => $this->entityStream($tenantId, $userId, $data, $version, $transaction), 'entity.fetch' => $this->entityFetch($tenantId, $userId, $data), 'entity.extant' => $this->entityExtant($tenantId, $userId, $data), + 'entity.delta' => $this->entityDelta($tenantId, $userId, $data), 'entity.create' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.update' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.delete' => $this->entityDelete($tenantId, $userId, $data), - 'entity.stream' => $this->entityStream($tenantId, $userId, $data, $version, $transaction), - 'entity.delta' => $this->entityDelta($tenantId, $userId, $data), + 'entity.patch' => $this->entityPatch($tenantId, $userId, $data), 'entity.move' => $this->entityMove($tenantId, $userId, $data), 'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data), @@ -417,20 +420,6 @@ class DefaultController extends ControllerAbstract { return $this->mailManager->collectionList($tenantId, $userId, $sources, $filter, $sort); } - private function collectionExtant(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['sources'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); - } - if (!is_array($data['sources'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); - } - - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - - return $this->mailManager->collectionExtant($tenantId, $userId, $sources); - } - private function collectionFetch(string $tenantId, string $userId, array $data): mixed { if (!isset($data['provider'])) { throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); @@ -460,6 +449,20 @@ class DefaultController extends ControllerAbstract { ); } + private function collectionExtant(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + + return $this->mailManager->collectionExtant($tenantId, $userId, $sources); + } + private function collectionCreate(string $tenantId, string $userId, array $data): mixed { if (!isset($data['provider'])) { throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); @@ -623,6 +626,51 @@ class DefaultController extends ControllerAbstract { } + private function entityStream(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse { + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + + $filter = $data['filter'] ?? null; + $sort = $data['sort'] ?? null; + $range = $data['range'] ?? null; + + $entityGenerator = $this->mailManager->entityStream($tenantId, $userId, $sources, $filter, $sort, $range); + $logger = $this->logger; + + $responseGenerator = (function () use ($entityGenerator, $version, $transaction, $logger): \Generator { + yield ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction]; + + $total = 0; + try { + foreach ($entityGenerator as $entity) { + if (!$entity instanceof JsonSerializable) { + continue; + } + yield [ + 'type' => 'data', + 'data' => $entity->jsonSerialize() + ]; + $total++; + } + } catch (\Throwable $t) { + $logger->error('Error streaming entities', ['exception' => $t]); + yield ['type' => 'error', 'message' => $t->getMessage()]; + return; + } + + yield ['type' => 'control', 'status' => 'end', 'total' => $total]; + })(); + + return new StreamedNdJsonResponse($responseGenerator, 1, 200, ['Content-Type' => 'application/json']); + } + private function entityFetch(string $tenantId, string $userId, array $data): mixed { if (!isset($data['provider'])) { throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); @@ -705,6 +753,30 @@ class DefaultController extends ControllerAbstract { return $this->mailManager->entityDelete($tenantId, $userId, $sources); } + private function entityPatch(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); + } + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); + } + if (!isset($data['data'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['data'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection:entity identifiers'); + } + } + + return $this->mailManager->entityPatch($tenantId, $userId, $targets, $data['data']); + } + private function entityMove(string $tenantId, string $userId, array $data): mixed { if (!isset($data['target'])) { throw new InvalidArgumentException(self::ERR_MISSING_TARGET); @@ -759,49 +831,4 @@ class DefaultController extends ControllerAbstract { return ['jobId' => $jobId]; } - private function entityStream(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse { - if (!isset($data['sources'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); - } - if (!is_array($data['sources'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); - } - - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - - $filter = $data['filter'] ?? null; - $sort = $data['sort'] ?? null; - $range = $data['range'] ?? null; - - $entityGenerator = $this->mailManager->entityStream($tenantId, $userId, $sources, $filter, $sort, $range); - $logger = $this->logger; - - $responseGenerator = (function () use ($entityGenerator, $version, $transaction, $logger): \Generator { - yield ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction]; - - $total = 0; - try { - foreach ($entityGenerator as $entity) { - if (!$entity instanceof JsonSerializable) { - continue; - } - yield [ - 'type' => 'data', - 'data' => $entity->jsonSerialize() - ]; - $total++; - } - } catch (\Throwable $t) { - $logger->error('Error streaming entities', ['exception' => $t]); - yield ['type' => 'error', 'message' => $t->getMessage()]; - return; - } - - yield ['type' => 'control', 'status' => 'end', 'total' => $total]; - })(); - - return new StreamedNdJsonResponse($responseGenerator, 1, 200, ['Content-Type' => 'application/json']); - } - } diff --git a/lib/Manager.php b/lib/Manager.php index b5c4c0c..2a84394 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -1017,6 +1017,21 @@ class Manager { return $responseData; } + /** + * Deletes entities + * + * @since 2026.04.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param ResourceIdentifiers $sources Source entities to delete + * + * @return array Results keyed by source entity identifier + */ public function entityDelete(string $tenantId, string $userId, ResourceIdentifiers $sources): array { $operationOutcome = []; @@ -1066,6 +1081,90 @@ class Manager { return $operationOutcome; } + /** + * Patches entities + * + * @since 2026.04.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param ResourceIdentifiers $sources Source entities to patch + * @param array $patchData Data to patch + * + * @return array Results keyed by source entity identifier + */ + public function entityPatch(string $tenantId, string $userId, ResourceIdentifiers $targets, array $patch): array { + $operationOutcome = []; + + // Process targets grouped by provider and service to minimize redundant fetches and operations + foreach ($targets->providers() as $providerName) { + $providerTargets = $targets->byProvider($providerName); + $provider = $this->providerFetch($tenantId, $userId, $providerName); + + // If provider is not found, mark all identifiers as failed and continue to next provider + if ($provider === null) { + foreach ($providerTargets->services() as $serviceName) { + $serviceTargets = $providerTargets->byService($serviceName); + foreach ($serviceTargets as $identifier) { + $operationOutcome[$identifier] = ['disposition' => 'error', 'error' => 'provider not found']; + } + } + continue; + } + + foreach ($providerTargets->services() as $serviceName) { + $serviceTargets = $providerTargets->byService($serviceName); + $service = $this->serviceFetch($tenantId, $userId, $providerName, $serviceName); + // If service is not found, mark all identifiers as failed and continue to next service + if ($service === null) { + foreach ($serviceTargets as $identifier) { + $operationOutcome[$identifier] = ['disposition' => 'error', 'error' => 'service not found']; + } + continue; + } + // If service does not support entity mutation, mark all identifiers as failed and continue to next service + if (!($service instanceof ServiceEntityMutableInterface)) { + foreach ($serviceTargets as $identifier) { + $operationOutcome[$identifier] = ['disposition' => 'error', 'error' => 'entity mutations not supported']; + } + continue; + } + // If service does not support entity patching, mark all identifiers as failed and continue to next service + if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_PATCH)) { + foreach ($serviceTargets as $identifier) { + $operationOutcome[$identifier] = ['disposition' => 'error', 'error' => 'entity patching not supported']; + } + continue; + } + + $properties = $service->entityFresh()->getProperties()->jsonDeserialize($patch); + + return $service->entityPatch(...$serviceTargets->all(), patch: $patch); + } + } + + return $operationOutcome; + } + + /** + * Moves entities to another collection + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param CollectionIdentifier $target Target collection identifier + * @param ResourceIdentifiers $sources Source entities to move + * + * @return array Results keyed by source entity identifier + */ public function entityMove(string $tenantId, string $userId, CollectionIdentifier $target, ResourceIdentifiers $sources): array { $targetService = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service());