Files
mail_manager/lib/Controllers/DefaultController.php
T
2026-06-19 23:56:46 -04:00

904 lines
36 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\MailManager\Controllers;
use InvalidArgumentException;
use KTXC\Http\Response\JsonResponse;
use KTXC\Http\Response\Response;
use KTXC\Http\Response\StreamedNdJsonResponse;
use KTXC\Http\Response\StreamedResponse;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXF\Controller\ControllerAbstract;
use KTXF\Json\JsonSerializable;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifiers;
use KTXF\Resource\Identifier\ServiceIdentifier;
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
use KTXF\Routing\Attributes\AuthenticatedRoute;
use KTXM\MailManager\Manager;
use Psr\Log\LoggerInterface;
use Throwable;
class DefaultController extends ControllerAbstract {
private const ERR_MISSING_PROVIDER = 'Missing parameter: provider';
private const ERR_MISSING_IDENTIFIER = 'Missing parameter: identifier';
private const ERR_MISSING_SERVICE = 'Missing parameter: service';
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_SENDER = 'Missing parameter: sender';
private const ERR_INVALID_OPERATION = 'Invalid operation: ';
private const ERR_INVALID_PROVIDER = 'Invalid parameter: provider must be a string';
private const ERR_INVALID_SERVICE = 'Invalid parameter: service must be a string';
private const ERR_INVALID_IDENTIFIER = 'Invalid parameter: identifier must be a string';
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_SENDER = 'Invalid parameter: sender must be a string';
private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array';
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private Manager $manager,
private readonly LoggerInterface $logger
) {}
/**
* Main API endpoint for mail operations
*
* Single operation:
* {
* "version": 1,
* "transaction": "tx-1",
* "operation": "entity.create",
* "data": {...}
* }
*
* @return Response
*/
#[AuthenticatedRoute('/v1', name: 'mail.manager.v1', methods: ['POST'])]
public function index(
int $version,
string $transaction,
string|null $operation = null,
array|null $data = null,
string|null $user = null
): Response {
// authorize request
$tenantId = $this->tenantIdentity->identifier();
$userId = $this->userIdentity->identifier();
try {
if ($operation !== null) {
$result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], $version, $transaction);
if ($result instanceof Response) {
return $result;
}
return new JsonResponse([
'version' => $version,
'transaction' => $transaction,
'operation' => $operation,
'status' => 'success',
'data' => $result
], JsonResponse::HTTP_OK);
}
throw new InvalidArgumentException('Operation must be provided');
} catch (Throwable $t) {
$this->logger->error('Error processing request', ['exception' => $t]);
return new JsonResponse([
'version' => $version,
'transaction' => $transaction,
'operation' => $operation,
'status' => 'error',
'data' => [
'code' => $t->getCode(),
'message' => $t->getMessage()
]
], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Process a single operation
*/
private function processOperation(string $tenantId, string $userId, string $operation, array $data, int $version = 1, string $transaction = ''): mixed {
return match ($operation) {
// Provider operations
'provider.list' => $this->providerList($tenantId, $userId, $data),
'provider.fetch' => $this->providerFetch($tenantId, $userId, $data),
'provider.extant' => $this->providerExtant($tenantId, $userId, $data),
// Service operations
'service.list' => $this->serviceList($tenantId, $userId, $data),
'service.fetch' => $this->serviceFetch($tenantId, $userId, $data),
'service.extant' => $this->serviceExtant($tenantId, $userId, $data),
'service.create' => $this->serviceCreate($tenantId, $userId, $data),
'service.update' => $this->serviceUpdate($tenantId, $userId, $data),
'service.delete' => $this->serviceDelete($tenantId, $userId, $data),
'service.discover' => $this->serviceDiscover($tenantId, $userId, $data, $version, $transaction),
'service.test' => $this->serviceTest($tenantId, $userId, $data),
// Collection operations
'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.move' => $this->collectionMove($tenantId, $userId, $data),
// Entity operations
'entity.listBulk' => $this->entityListBulk($tenantId, $userId, $data),
'entity.listStream' => $this->entityListStream($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.patch' => $this->entityPatch($tenantId, $userId, $data),
'entity.move' => $this->entityMove($tenantId, $userId, $data),
'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
'entity.submit' => $this->entitySubmit($tenantId, $userId, $data),
'entity.download' => $this->entityDownload($tenantId, $userId, $data),
'entity.blobs' => $this->entityBlobs($tenantId, $userId, $data),
default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation)
};
}
// ==================== Provider Operations ====================
private function providerList(string $tenantId, string $userId, array $data): mixed {
if (isset($data['targets'])) {
if (!is_array($data['targets'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
}
foreach ($data['targets'] as $target) {
if (!is_string($target)) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
}
}
}
return $this->manager->providerList($tenantId, $userId, $data['targets'] ?? []);
}
private function providerFetch(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['target'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
}
if (!is_string($data['target'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGET);
}
return $this->manager->providerFetch($tenantId, $userId, $data['target']);
}
private function providerExtant(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['targets'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
}
foreach ($data['targets'] as $target) {
if (!is_string($target)) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
}
}
return $this->manager->providerExtant($tenantId, $userId, $data['targets']);
}
// ==================== Service Operations =====================
private function serviceList(string $tenantId, string $userId, array $data): mixed {
$targets = null;
if (isset($data['targets']) && is_array($data['targets'])) {
$targets = ResourceIdentifiers::fromArray($data['targets']);
foreach ($targets as $target) {
if (!$target instanceof CollectionIdentifier && !$target instanceof ServiceIdentifier) {
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers');
}
}
}
return $this->manager->serviceList($tenantId, $userId, $targets);
}
private function serviceFetch(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['provider'])) {
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
}
if (!is_string($data['provider'])) {
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
}
if (!isset($data['identifier'])) {
throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER);
}
if (!is_string($data['identifier'])) {
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
}
return $this->manager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']);
}
private function serviceExtant(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);
}
$targets = ResourceIdentifiers::fromArray($data['targets']);
foreach ($targets as $target) {
if (!$target instanceof ServiceIdentifier) {
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service identifiers');
}
}
return $this->manager->serviceExtant($tenantId, $userId, $targets);
}
private function serviceCreate(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['provider'])) {
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
}
if (!is_string($data['provider'])) {
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
}
if (!isset($data['data'])) {
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
}
if (!is_array($data['data'])) {
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
}
return $this->manager->serviceCreate(
$tenantId,
$userId,
$data['provider'],
$data['data']
);
}
private function serviceUpdate(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['provider'])) {
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
}
if (!is_string($data['provider'])) {
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
}
if (!isset($data['identifier'])) {
throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER);
}
if (!is_string($data['identifier'])) {
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
}
if (!isset($data['data'])) {
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
}
if (!is_array($data['data'])) {
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
}
if (isset($data['delta']) && !is_bool($data['delta'])) {
throw new InvalidArgumentException('Invalid parameter: delta must be a boolean');
}
return $this->manager->serviceUpdate(
$tenantId,
$userId,
$data['provider'],
$data['identifier'],
$data['data'],
$data['delta'] ?? false,
);
}
private function serviceDelete(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['provider'])) {
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
}
if (!is_string($data['provider'])) {
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
}
if (!isset($data['identifier'])) {
throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER);
}
if (!is_string($data['identifier'])) {
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
}
return $this->manager->serviceDelete(
$tenantId,
$userId,
$data['provider'],
$data['identifier']
);
}
private function serviceTest(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['provider'])) {
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
}
if (!is_string($data['provider'])) {
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
}
if (!isset($data['identifier']) && !isset($data['location']) && !isset($data['identity'])) {
throw new InvalidArgumentException('Either a service identifier or location and identity must be provided for service test');
}
return $this->manager->serviceTest(
$tenantId,
$userId,
$data['provider'],
$data['identifier'] ?? null,
$data['location'] ?? null,
$data['identity'] ?? null,
);
}
private function serviceDiscover(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse {
if (!isset($data['identity']) || empty($data['identity']) || !is_string($data['identity'])) {
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
}
$provider = $data['provider'] ?? null;
$identity = $data['identity'];
$location = $data['location'] ?? null;
$secret = $data['secret'] ?? null;
$discoverGenerator = $this->manager->serviceDiscover($tenantId, $userId, $provider, $identity, $location, $secret);
$logger = $this->logger;
$response = (function () use ($discoverGenerator, $version, $transaction, $logger): \Generator {
yield ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction];
$total = 0;
try {
foreach ($discoverGenerator as $providerId => $serviceLocation) {
if (!$serviceLocation instanceof ResourceServiceLocationInterface) {
continue;
}
yield [
'type' => 'data',
'data' => [
'provider' => $providerId,
'location' => $serviceLocation->jsonSerialize()
]
];
$total++;
}
} catch (\Throwable $t) {
$logger->error('Error streaming service discovery', ['exception' => $t]);
yield ['type' => 'error', 'message' => $t->getMessage()];
return;
}
yield ['type' => 'control', 'status' => 'end', 'total' => $total];
})();
return new StreamedNdJsonResponse($response, 1, 200, ['Content-Type' => 'application/json']);
}
// ==================== Collection Operations ====================
private function collectionList(string $tenantId, string $userId, array $data): mixed {
$sources = null;
if (isset($data['sources']) && is_array($data['sources'])) {
$sources = ResourceIdentifiers::fromArray($data['sources']);
foreach ($sources as $source) {
if (!$source instanceof CollectionIdentifier && !$source instanceof ServiceIdentifier) {
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers');
}
}
}
$filter = $data['filter'] ?? null;
$sort = $data['sort'] ?? null;
return $this->manager->collectionList($tenantId, $userId, $sources, $filter, $sort);
}
private function collectionFetch(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);
}
$targetIdentifiers = ResourceIdentifiers::fromArray($data['targets']);
foreach ($targetIdentifiers as $targetIdentifier) {
if (!$targetIdentifier instanceof CollectionIdentifier) {
throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection');
}
}
$list = $this->manager->collectionFetch(
$tenantId,
$userId,
$targetIdentifier
);
return $list;
}
private function collectionExtant(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);
}
$sources = ResourceIdentifiers::fromArray($data['targets']);
foreach ($sources as $source) {
if (!$source instanceof CollectionIdentifier) {
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers');
}
}
return $this->manager->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);
}
if (!is_string($data['provider'])) {
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
}
if (!isset($data['service'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SERVICE);
}
if (!is_string($data['service'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SERVICE);
}
if (isset($data['target']) && !is_string($data['target']) && !is_int($data['target'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGET);
}
if (!isset($data['properties'])) {
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
}
if (!is_array($data['properties'])) {
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
}
if (isset($data['target'])) {
$targetIdentifier = ResourceIdentifier::fromString($data['target']);
if (!$targetIdentifier instanceof CollectionIdentifier) {
throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection');
}
}
return $this->manager->collectionCreate(
$tenantId,
$userId,
$data['provider'],
$data['service'],
$targetIdentifier ?? null,
$data['properties']
);
}
private function collectionUpdate(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['target'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
}
if (!is_string($data['target'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGET);
}
if (!isset($data['properties'])) {
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
}
if (!is_array($data['properties'])) {
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
}
$targetIdentifier = ResourceIdentifier::fromString($data['target']);
if (!$targetIdentifier instanceof CollectionIdentifier) {
throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection');
}
return $this->manager->collectionUpdate(
$tenantId,
$userId,
$targetIdentifier,
$data['properties']
);
}
private function collectionDelete(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['target'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
}
if (!is_string($data['target'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGET);
}
$targetIdentifier = ResourceIdentifier::fromString($data['target']);
if (!$targetIdentifier instanceof CollectionIdentifier) {
throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection');
}
$result = $this->manager->collectionDelete($tenantId, $userId, $targetIdentifier, $data['options'] ?? [] );
if (is_bool($result)) {
return [
'disposition' => 'deleted'
];
}
if ($result instanceof JsonSerializable) {
return [
'disposition' => 'moved',
'mutation' => $result
];
}
return $result;
}
private function collectionMove(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['target'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
}
if (!is_string($data['target'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGET);
}
if (!isset($data['source'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
}
if (!is_string($data['source'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
}
$target = ResourceIdentifier::fromString($data['target']);
if (!$target instanceof CollectionIdentifier) {
throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection');
}
$source = ResourceIdentifier::fromString($data['source']);
if (!$source instanceof CollectionIdentifier) {
throw new InvalidArgumentException('Invalid parameter: source must be provider:service:collection');
}
return $this->manager->collectionMove($tenantId, $userId, $target, $source);
}
// ==================== Entity Operations ====================
private function entityListBulk(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 = ResourceIdentifiers::fromArray($data['sources']);
foreach ($sources as $source) {
if (!$source instanceof EntityIdentifier) {
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers');
}
}
$filter = $data['filter'] ?? null;
$sort = $data['sort'] ?? null;
$range = $data['range'] ?? null;
return $this->manager->entityListBulk($tenantId, $userId, $sources, $filter, $sort, $range);
}
private function entityListStream(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 = ResourceIdentifiers::fromArray($data['sources']);
foreach ($sources as $source) {
if (!$source instanceof ServiceIdentifier && !$source instanceof CollectionIdentifier) {
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service or provider:service:collection identifiers');
}
}
$filter = $data['filter'] ?? null;
$sort = $data['sort'] ?? null;
$range = $data['range'] ?? null;
$entityGenerator = $this->manager->entityListStream($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['targets'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
}
if (!is_array($data['targets'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
}
$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->manager->entityFetchBulk(
$tenantId,
$userId,
...$targets->all()
);
}
private function entityExtant(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);
}
$targets = ResourceIdentifiers::fromArray($data['targets']);
foreach ($targets as $target) {
if (!$target instanceof CollectionIdentifier && !$target instanceof EntityIdentifier) {
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection or provider:service:collection:entity identifiers');
}
}
return $this->manager->entityExtant($tenantId, $userId, $targets);
}
private function entityDelta(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);
}
$targets = ResourceIdentifiers::fromArray($data['targets']);
foreach ($targets as $target) {
if (!$target instanceof CollectionIdentifier && !$target instanceof EntityIdentifier) {
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection or provider:service:collection:signature identifiers');
}
}
return $this->manager->entityDelta($tenantId, $userId, $targets);
}
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['properties'])) {
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
}
if (!is_array($data['properties'])) {
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->manager->entityPatch($tenantId, $userId, $data['properties'], ...$targets->all());
}
private function entityDelete(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);
}
$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->manager->entityDelete($tenantId, $userId, ...$targets->all());
}
private function entityMove(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['target'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
}
if (!is_string($data['target'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGET);
}
if (!isset($data['sources'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
}
if (!is_array($data['sources'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
}
$target = ResourceIdentifier::fromString($data['target']);
if (!$target instanceof CollectionIdentifier) {
throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection');
}
$sources = ResourceIdentifiers::fromArray($data['sources']);
foreach ($sources as $source) {
if (!$source instanceof EntityIdentifier) {
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers');
}
}
return $this->manager->entityMove($tenantId, $userId, $target, ...$sources->all());
}
private function entitySubmit(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['sender'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SENDER);
}
if (!is_string($data['sender'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SENDER);
}
return $this->manager->entitySubmit(
$tenantId,
$userId,
$data['sender'],
$data['source'] ?? null,
$data['message'] ?? null,
);
}
private function entityDownload(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['target'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
}
if (!is_string($data['target'])) {
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
}
// 'part' is optional — null means full RFC 822 message download
$part = isset($data['part']) && is_array($data['part']) ? $data['part'] : null;
$target = ResourceIdentifier::fromString($data['target']);
$logger = $this->logger;
$result = $this->manager->entityDownload($tenantId, $userId, $target, $part);
$filename = $result->filename();
$asciiFilename = preg_replace('/[^\x20-\x7E]|[\\\\"]/', '_', $filename);
$encodedFilename = rawurlencode($filename);
$disposition = sprintf(
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
$asciiFilename,
$encodedFilename,
);
$responseGenerator = (static function () use ($result, $logger): \Generator {
try {
yield from $result->stream();
} catch (\Throwable $t) {
$logger->error('Error streaming entity download', ['exception' => $t]);
// Headers already sent — cannot change status code; stop output cleanly
}
})();
return new StreamedResponse($responseGenerator, 200, [
'Content-Disposition' => $disposition,
'Content-Type' => $result->mimeType(),
'Content-Transfer-Encoding' => 'binary',
'Cache-Control' => 'no-store',
]);
}
private function entityBlobs(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['target'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
}
if (!is_string($data['target'])) {
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
}
if (!isset($data['parts']) || !is_array($data['parts']) || $data['parts'] === []) {
throw new InvalidArgumentException('At least one part selector is required');
}
$target = ResourceIdentifier::fromString($data['target']);
$results = [];
foreach ($data['parts'] as $part) {
if (!is_array($part)) {
throw new InvalidArgumentException('Invalid part selector');
}
$resource = $this->manager->entityDownload($tenantId, $userId, $target, $part);
$bytes = '';
foreach ($resource->stream() as $chunk) {
$bytes .= $chunk;
}
$results[] = [
'source' => $data['target'],
'part' => $part,
'mime' => $resource->mimeType(),
'filename' => $resource->filename(),
'bytes' => base64_encode($bytes),
];
}
return $results;
}
}