050467919b
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
902 lines
35 KiB
PHP
902 lines
35 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
namespace KTXM\DocumentsManager\Controllers;
|
|
|
|
use InvalidArgumentException;
|
|
use KTXC\Http\Response\JsonResponse;
|
|
use KTXC\Http\Response\Response;
|
|
use KTXC\Http\Response\StreamedNdJsonResponse;
|
|
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\Routing\Attributes\AuthenticatedRoute;
|
|
use KTXM\DocumentsManager\Manager;
|
|
use KTXM\DocumentsManager\Stream\ExpectedTotal;
|
|
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_SOURCE = 'Missing parameter: source';
|
|
private const ERR_MISSING_TARGET = 'Missing parameter: target';
|
|
private const ERR_MISSING_TARGETS = 'Missing parameter: targets';
|
|
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_SOURCE = 'Invalid parameter: source must be a string';
|
|
private const ERR_INVALID_TARGET = 'Invalid parameter: target must be a string';
|
|
private const ERR_INVALID_TARGETS = 'Invalid parameter: targets must be an array';
|
|
private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array';
|
|
private const ERR_TARGET_COLLECTION = 'Invalid parameter: target must be provider:service:collection';
|
|
private const ERR_TARGET_ENTITY = 'Invalid parameter: target must be provider:service:collection:entity';
|
|
|
|
public function __construct(
|
|
private readonly SessionTenant $tenantIdentity,
|
|
private readonly SessionIdentity $userIdentity,
|
|
private readonly Manager $manager,
|
|
private readonly LoggerInterface $logger
|
|
) {}
|
|
|
|
/**
|
|
* Main API endpoint for documents operations
|
|
*
|
|
* Single operation:
|
|
* {
|
|
* "version": 1,
|
|
* "transaction": "tx-1",
|
|
* "operation": "entity.create",
|
|
* "data": {...}
|
|
* }
|
|
*
|
|
* @return Response
|
|
*/
|
|
#[AuthenticatedRoute('/v1', name: 'documents.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.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.create' => $this->collectionCreate($tenantId, $userId, $data),
|
|
'collection.update' => $this->collectionUpdate($tenantId, $userId, $data),
|
|
'collection.delete' => $this->collectionDelete($tenantId, $userId, $data),
|
|
'collection.copy' => $this->collectionCopy($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' => $this->entityCreate($tenantId, $userId, $data),
|
|
'entity.update' => $this->entityUpdate($tenantId, $userId, $data),
|
|
'entity.delete' => $this->entityDelete($tenantId, $userId, $data),
|
|
'entity.copy' => $this->entityCopy($tenantId, $userId, $data),
|
|
'entity.move' => $this->entityMove($tenantId, $userId, $data),
|
|
'entity.read' => $this->entityRead($tenantId, $userId, $data),
|
|
'entity.read.chunk' => $this->entityReadChunk($tenantId, $userId, $data),
|
|
'entity.write' => $this->entityWrite($tenantId, $userId, $data),
|
|
'entity.write.chunk' => $this->entityWriteChunk($tenantId, $userId, $data),
|
|
|
|
default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation)
|
|
};
|
|
}
|
|
|
|
// ==================== Identifier Helpers ====================
|
|
|
|
/**
|
|
* Parse a required collection target identifier (provider:service:collection)
|
|
*/
|
|
private function collectionTarget(array $data, string $key = 'target'): CollectionIdentifier {
|
|
if (!isset($data[$key])) {
|
|
throw new InvalidArgumentException('Missing parameter: ' . $key);
|
|
}
|
|
if (!is_string($data[$key])) {
|
|
throw new InvalidArgumentException("Invalid parameter: $key must be a string");
|
|
}
|
|
$identifier = ResourceIdentifier::fromString($data[$key]);
|
|
if (!$identifier instanceof CollectionIdentifier) {
|
|
throw new InvalidArgumentException(self::ERR_TARGET_COLLECTION);
|
|
}
|
|
return $identifier;
|
|
}
|
|
|
|
/**
|
|
* Parse an optional collection target identifier (absent = root)
|
|
*/
|
|
private function collectionTargetOptional(array $data, string $key = 'target'): ?CollectionIdentifier {
|
|
if (!isset($data[$key]) || $data[$key] === null || $data[$key] === '') {
|
|
return null;
|
|
}
|
|
return $this->collectionTarget($data, $key);
|
|
}
|
|
|
|
/**
|
|
* Parse a required entity target identifier (provider:service:collection:entity)
|
|
*/
|
|
private function entityTarget(array $data, string $key = 'target'): EntityIdentifier {
|
|
if (!isset($data[$key])) {
|
|
throw new InvalidArgumentException('Missing parameter: ' . $key);
|
|
}
|
|
if (!is_string($data[$key])) {
|
|
throw new InvalidArgumentException("Invalid parameter: $key must be a string");
|
|
}
|
|
$identifier = ResourceIdentifier::fromString($data[$key]);
|
|
if (!$identifier instanceof EntityIdentifier) {
|
|
throw new InvalidArgumentException(self::ERR_TARGET_ENTITY);
|
|
}
|
|
return $identifier;
|
|
}
|
|
|
|
// ==================== 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,
|
|
);
|
|
}
|
|
|
|
// ==================== 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 or provider:service:collection 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(self::ERR_TARGET_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:collection 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['properties'])) {
|
|
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
|
|
}
|
|
if (!is_array($data['properties'])) {
|
|
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
|
}
|
|
|
|
$targetIdentifier = $this->collectionTargetOptional($data);
|
|
|
|
return $this->manager->collectionCreate(
|
|
$tenantId,
|
|
$userId,
|
|
$data['provider'],
|
|
$data['service'],
|
|
$targetIdentifier,
|
|
$data['properties'],
|
|
$data['options'] ?? []
|
|
);
|
|
}
|
|
|
|
private function collectionUpdate(string $tenantId, string $userId, array $data): mixed {
|
|
if (!isset($data['properties'])) {
|
|
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
|
|
}
|
|
if (!is_array($data['properties'])) {
|
|
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
|
}
|
|
|
|
$targetIdentifier = $this->collectionTarget($data);
|
|
|
|
return $this->manager->collectionUpdate(
|
|
$tenantId,
|
|
$userId,
|
|
$targetIdentifier,
|
|
$data['properties']
|
|
);
|
|
}
|
|
|
|
private function collectionDelete(string $tenantId, string $userId, array $data): mixed {
|
|
$targetIdentifier = $this->collectionTarget($data);
|
|
|
|
$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 collectionCopy(string $tenantId, string $userId, array $data): mixed {
|
|
$sourceIdentifier = $this->collectionTarget($data, 'source');
|
|
$targetIdentifier = $this->collectionTargetOptional($data);
|
|
|
|
return $this->manager->collectionCopy($tenantId, $userId, $targetIdentifier, $sourceIdentifier);
|
|
}
|
|
|
|
private function collectionMove(string $tenantId, string $userId, array $data): mixed {
|
|
$sourceIdentifier = $this->collectionTarget($data, 'source');
|
|
$targetIdentifier = $this->collectionTargetOptional($data);
|
|
|
|
return $this->manager->collectionMove($tenantId, $userId, $targetIdentifier, $sourceIdentifier);
|
|
}
|
|
|
|
// ==================== Entity Operations ====================
|
|
|
|
private function entityListBulk(string $tenantId, string $userId, array $data): mixed {
|
|
|
|
if (isset($data['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');
|
|
}
|
|
}
|
|
} else {
|
|
$sources = null;
|
|
}
|
|
|
|
$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'])) {
|
|
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');
|
|
}
|
|
}
|
|
} else {
|
|
$sources = null;
|
|
}
|
|
|
|
$filter = $data['filter'] ?? null;
|
|
$sort = $data['sort'] ?? null;
|
|
$range = $data['range'] ?? null;
|
|
|
|
$entities = $this->manager->entityListStream($tenantId, $userId, $sources, $filter, $sort, $range);
|
|
|
|
return new StreamedNdJsonResponse(
|
|
$this->streamEnvelope($entities, $version, $transaction),
|
|
1,
|
|
200,
|
|
['Content-Type' => 'application/json'],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Wrap a generator of JsonSerializable domain objects in the canonical NDJSON
|
|
* stream envelope shared by every streaming operation:
|
|
*
|
|
* control:start {version, transaction, total?} — total? = expected count
|
|
* data {data} — one per domain object
|
|
* error {message} — on failure, then stop
|
|
* control:end {total} — total = objects emitted
|
|
*
|
|
* If the generator leads with an {@see ExpectedTotal} event, its value is
|
|
* folded into the start frame's `total` (the progress denominator) rather
|
|
* than emitted as a data frame.
|
|
*
|
|
* @param \Generator<\JsonSerializable> $items
|
|
*/
|
|
private function streamEnvelope(\Generator $items, int $version, string $transaction): \Generator {
|
|
// Peek the first event: an expected-total marker rides on the start frame.
|
|
$expected = null;
|
|
$items->rewind();
|
|
if ($items->valid() && $items->current() instanceof ExpectedTotal) {
|
|
$expected = $items->current()->expectedTotal();
|
|
$items->next();
|
|
}
|
|
|
|
$start = ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction];
|
|
if ($expected !== null) {
|
|
$start['total'] = $expected;
|
|
}
|
|
yield $start;
|
|
|
|
$total = 0;
|
|
try {
|
|
for (; $items->valid(); $items->next()) {
|
|
$item = $items->current();
|
|
if (!$item instanceof \JsonSerializable) {
|
|
continue;
|
|
}
|
|
yield ['type' => 'data', 'data' => $item->jsonSerialize()];
|
|
$total++;
|
|
}
|
|
} catch (\Throwable $t) {
|
|
$this->logger->error('Error streaming response', ['exception' => $t]);
|
|
yield ['type' => 'error', 'message' => $t->getMessage()];
|
|
return;
|
|
}
|
|
|
|
yield ['type' => 'control', 'status' => 'end', 'total' => $total];
|
|
}
|
|
|
|
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 entityCreate(string $tenantId, string $userId, array $data = []): mixed {
|
|
if (!isset($data['properties'])) {
|
|
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
|
|
}
|
|
if (!is_array($data['properties'])) {
|
|
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
|
}
|
|
|
|
$target = $this->collectionTarget($data);
|
|
$options = $data['options'] ?? [];
|
|
|
|
return $this->manager->entityCreate($tenantId, $userId, $target, $data['properties'], $options);
|
|
}
|
|
|
|
private function entityUpdate(string $tenantId, string $userId, array $data = []): mixed {
|
|
if (!isset($data['properties'])) {
|
|
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
|
|
}
|
|
if (!is_array($data['properties'])) {
|
|
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
|
}
|
|
|
|
$target = $this->entityTarget($data);
|
|
|
|
return $this->manager->entityModify($tenantId, $userId, $target, $data['properties']);
|
|
}
|
|
|
|
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 {
|
|
return $this->entityRelocate($tenantId, $userId, $data, 'entityMove');
|
|
}
|
|
|
|
private function entityCopy(string $tenantId, string $userId, array $data): mixed {
|
|
return $this->entityRelocate($tenantId, $userId, $data, 'entityCopy');
|
|
}
|
|
|
|
/**
|
|
* Shared request handling for entity move/copy operations
|
|
*/
|
|
private function entityRelocate(string $tenantId, string $userId, array $data, string $method): mixed {
|
|
if (!isset($data['sources'])) {
|
|
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
|
}
|
|
if (!is_array($data['sources'])) {
|
|
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
|
}
|
|
|
|
$target = $this->collectionTarget($data);
|
|
|
|
$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->{$method}($tenantId, $userId, $target, ...$sources->all());
|
|
}
|
|
|
|
// ==================== Entity Content Operations ====================
|
|
|
|
private function entityRead(string $tenantId, string $userId, array $data = []): mixed {
|
|
$target = $this->entityTarget($data);
|
|
|
|
$content = $this->manager->entityRead($tenantId, $userId, $target);
|
|
|
|
return [
|
|
'content' => $content !== null ? base64_encode($content) : null,
|
|
'encoding' => 'base64'
|
|
];
|
|
}
|
|
|
|
private function entityReadChunk(string $tenantId, string $userId, array $data = []): mixed {
|
|
if (!isset($data['offset']) || !is_int($data['offset'])) {
|
|
throw new InvalidArgumentException('Missing parameter: offset');
|
|
}
|
|
if (!isset($data['length']) || !is_int($data['length'])) {
|
|
throw new InvalidArgumentException('Missing parameter: length');
|
|
}
|
|
|
|
$target = $this->entityTarget($data);
|
|
|
|
$chunk = $this->manager->entityReadChunk(
|
|
$tenantId,
|
|
$userId,
|
|
$target,
|
|
$data['offset'],
|
|
$data['length']
|
|
);
|
|
|
|
return [
|
|
'content' => $chunk !== null ? base64_encode($chunk) : null,
|
|
'encoding' => 'base64',
|
|
'offset' => $data['offset'],
|
|
'length' => $chunk !== null ? strlen($chunk) : 0,
|
|
];
|
|
}
|
|
|
|
private function entityWrite(string $tenantId, string $userId, array $data = []): mixed {
|
|
if (!isset($data['content'])) {
|
|
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
|
|
}
|
|
|
|
$target = $this->entityTarget($data);
|
|
|
|
// Decode content if base64 encoded
|
|
$content = $data['content'];
|
|
if (isset($data['encoding']) && $data['encoding'] === 'base64') {
|
|
$content = base64_decode($content);
|
|
if ($content === false) {
|
|
throw new InvalidArgumentException('Invalid base64 encoded content');
|
|
}
|
|
}
|
|
|
|
$bytesWritten = $this->manager->entityWrite($tenantId, $userId, $target, $content);
|
|
|
|
return [
|
|
'bytesWritten' => $bytesWritten
|
|
];
|
|
}
|
|
|
|
private function entityWriteChunk(string $tenantId, string $userId, array $data = []): mixed {
|
|
if (!isset($data['offset']) || !is_int($data['offset'])) {
|
|
throw new InvalidArgumentException('Missing parameter: offset');
|
|
}
|
|
if (!isset($data['content'])) {
|
|
throw new InvalidArgumentException(self::ERR_MISSING_DATA);
|
|
}
|
|
|
|
$target = $this->entityTarget($data);
|
|
|
|
// Decode content if base64 encoded
|
|
$content = $data['content'];
|
|
if (isset($data['encoding']) && $data['encoding'] === 'base64') {
|
|
$content = base64_decode($content);
|
|
if ($content === false) {
|
|
throw new InvalidArgumentException('Invalid base64 encoded content');
|
|
}
|
|
}
|
|
|
|
$bytesWritten = $this->manager->entityWriteChunk(
|
|
$tenantId,
|
|
$userId,
|
|
$target,
|
|
$data['offset'],
|
|
$content
|
|
);
|
|
|
|
return [
|
|
'bytesWritten' => $bytesWritten,
|
|
'offset' => $data['offset'],
|
|
];
|
|
}
|
|
|
|
}
|