Update StoredObjectManager to handle versioned StoredObjects

The StoredObjectManager and related test cases have been updated to handle versioned StoredObjects, allowing the same methods to work with either a StoredObject or its versions. The changes also involve return information for the write method and enhancements to the write test procedure. This provides more functionality and flexibility for handling StoredObjects in different versions.
This commit is contained in:
Julien Fastré 2024-07-09 22:24:55 +02:00
parent 4fbb7811ac
commit 3978ea9a47
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
3 changed files with 199 additions and 77 deletions

View File

@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\Service;
use Base64Url\Base64Url;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -32,10 +33,12 @@ final class StoredObjectManager implements StoredObjectManagerInterface
private readonly TempUrlGeneratorInterface $tempUrlGenerator
) {}
public function getLastModified(StoredObject $document): \DateTimeInterface
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface
{
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if ($this->hasCache($version)) {
$response = $this->getResponseFromCache($version);
} else {
try {
$response = $this
@ -46,7 +49,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
$version->getFilename()
)
->url
);
@ -58,11 +61,13 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $this->extractLastModifiedFromResponse($response);
}
public function getContentLength(StoredObject $document): int
public function getContentLength(StoredObject|StoredObjectVersion $document): int
{
if ([] === $document->getKeyInfos()) {
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if (!$this->isVersionEncrypted($version)) {
if ($this->hasCache($version)) {
$response = $this->getResponseFromCache($version);
} else {
try {
$response = $this
@ -73,7 +78,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
$version->getFilename()
)
->url
);
@ -88,10 +93,12 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return strlen($this->read($document));
}
public function etag(StoredObject $document): string
public function etag(StoredObject|StoredObjectVersion $document): string
{
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document);
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if ($this->hasCache($version)) {
$response = $this->getResponseFromCache($version);
} else {
try {
$response = $this
@ -111,12 +118,14 @@ final class StoredObjectManager implements StoredObjectManagerInterface
}
}
return $this->extractEtagFromResponse($response, $document);
return $this->extractEtagFromResponse($response);
}
public function read(StoredObject $document): string
public function read(StoredObject|StoredObjectVersion $document, ?int $version = null): string
{
$response = $this->getResponseFromCache($document);
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
$response = $this->getResponseFromCache($version);
try {
$data = $response->getContent();
@ -124,7 +133,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
throw StoredObjectManagerException::unableToGetResponseContent($e);
}
if (false === $this->hasKeysAndIv($document)) {
if (!$this->isVersionEncrypted($version)) {
return $data;
}
@ -132,9 +141,9 @@ final class StoredObjectManager implements StoredObjectManagerInterface
$data,
self::ALGORITHM,
// TODO: Why using this library and not use base64_decode() ?
Base64Url::decode($document->getKeyInfos()['k']),
Base64Url::decode($version->getKeyInfos()['k']),
\OPENSSL_RAW_DATA,
pack('C*', ...$document->getIv())
pack('C*', ...$version->getIv())
);
if (false === $clearData) {
@ -144,20 +153,25 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $clearData;
}
public function write(StoredObject $document, string $clearContent): void
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion
{
if ($this->hasCache($document)) {
unset($this->inMemory[$document->getUuid()->toString()]);
}
$newIv = $document->getIv();
$newKey = $document->getKeyInfos();
$newType = $contentType ?? $document->getType();
$version = $document->registerVersion(
$newIv,
$newKey,
$newType
);
$encryptedContent = $this->hasKeysAndIv($document)
$encryptedContent = $this->isVersionEncrypted($version)
? openssl_encrypt(
$clearContent,
self::ALGORITHM,
// TODO: Why using this library and not use base64_decode() ?
Base64Url::decode($document->getKeyInfos()['k']),
Base64Url::decode($version->getKeyInfos()['k']),
\OPENSSL_RAW_DATA,
pack('C*', ...$document->getIv())
pack('C*', ...$version->getIv())
)
: $clearContent;
@ -176,7 +190,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->tempUrlGenerator
->generate(
Request::METHOD_PUT,
$document->getFilename()
$version->getFilename()
)
->url,
[
@ -191,6 +205,8 @@ final class StoredObjectManager implements StoredObjectManagerInterface
if (Response::HTTP_CREATED !== $response->getStatusCode()) {
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
}
return $version;
}
public function clearCache(): void
@ -215,12 +231,19 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $date;
}
/**
* Extracts the content length from a ResponseInterface object.
*
* Does work only if the object is not encrypted.
*
* @return int the extracted content length as an integer
*/
private function extractContentLengthFromResponse(ResponseInterface $response): int
{
return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0];
}
private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string
private function extractEtagFromResponse(ResponseInterface $response): ?string
{
$etag = ($response->getHeaders()['etag'] ?? [''])[0];
@ -231,7 +254,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $etag;
}
private function fillCache(StoredObject $document): void
private function fillCache(StoredObjectVersion $document): void
{
try {
$response = $this
@ -254,25 +277,30 @@ final class StoredObjectManager implements StoredObjectManagerInterface
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
}
$this->inMemory[$document->getUuid()->toString()] = $response;
$this->inMemory[$this->buildCacheKey($document)] = $response;
}
private function getResponseFromCache(StoredObject $document): ResponseInterface
private function buildCacheKey(StoredObjectVersion $storedObjectVersion): string
{
return $storedObjectVersion->getStoredObject()->getUuid()->toString().$storedObjectVersion->getId();
}
private function getResponseFromCache(StoredObjectVersion $document): ResponseInterface
{
if (!$this->hasCache($document)) {
$this->fillCache($document);
}
return $this->inMemory[$document->getUuid()->toString()];
return $this->inMemory[$this->buildCacheKey($document)];
}
private function hasCache(StoredObject $document): bool
private function hasCache(StoredObjectVersion $document): bool
{
return \array_key_exists($document->getUuid()->toString(), $this->inMemory);
return \array_key_exists($this->buildCacheKey($document), $this->inMemory);
}
private function hasKeysAndIv(StoredObject $storedObject): bool
private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool
{
return ([] !== $storedObject->getKeyInfos()) && ([] !== $storedObject->getIv());
return ([] !== $storedObjectVersion->getKeyInfos()) && ([] !== $storedObjectVersion->getIv());
}
}

View File

@ -12,36 +12,56 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
interface StoredObjectManagerInterface
{
public function getLastModified(StoredObject $document): \DateTimeInterface;
/**
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
*/
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface;
public function getContentLength(StoredObject $document): int;
/**
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
*/
public function getContentLength(StoredObject|StoredObjectVersion $document): int;
/**
* Get the content of a StoredObject.
*
* @param StoredObject $document the document
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
*
* @return string the retrieved content in clear
*
* @throws StoredObjectManagerException if unable to read or decrypt the content
*/
public function read(StoredObject $document): string;
public function read(StoredObject|StoredObjectVersion $document): string;
/**
* Set the content of a StoredObject.
*
* @param StoredObject $document the document
* @param $clearContent The content to store in clear
* @param string $clearContent The content to store in clear
* @param string|null $contentType The new content type. If set to null, the content-type is supposed not to change. If there is no content type, an empty string will be used.
*
* @return StoredObjectVersion the newly created @see{StoredObjectVersion} for the given @see{StoredObject}
*
* @throws StoredObjectManagerException
*/
public function write(StoredObject $document, string $clearContent): void;
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion;
public function etag(StoredObject $document): string;
/**
* return or compute the etag for the document.
*
* @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used
*
* @return string the etag of this document
*/
public function etag(StoredObject|StoredObjectVersion $document): string;
/**
* Clears the cache for the stored object.
*/
public function clearCache(): void;
}

View File

@ -31,23 +31,25 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/
final class StoredObjectManagerTest extends TestCase
{
public static function getDataProvider(): \Generator
public static function getDataProviderForRead(): \Generator
{
/* HAPPY SCENARIO */
// Encrypted object
yield [
(new StoredObject())
->setFilename('encrypted.txt')
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')])
->setIv(unpack('C*', 'abcdefghijklmnop')),
->registerVersion(
unpack('C*', 'abcdefghijklmnop'),
['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
filename: 'encrypted.txt'
)->getStoredObject(),
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear
];
// Non-encrypted object
yield [
(new StoredObject())->setFilename('non-encrypted.txt'), // The StoredObject
(new StoredObject())->registerVersion(filename: 'non-encrypted.txt')->getStoredObject(), // The StoredObject
'The quick brown fox jumps over the lazy dog', // Encrypted
'The quick brown fox jumps over the lazy dog', // Clear
];
@ -57,9 +59,11 @@ final class StoredObjectManagerTest extends TestCase
// Encrypted object with issue during HTTP communication
yield [
(new StoredObject())
->setFilename('error_during_http_request.txt')
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')])
->setIv(unpack('C*', 'abcdefghijklmnop')),
->registerVersion(
unpack('C*', 'abcdefghijklmnop'),
['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
filename: 'error_during_http_request.txt'
)->getStoredObject(),
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear
StoredObjectManagerException::class,
@ -68,9 +72,11 @@ final class StoredObjectManagerTest extends TestCase
// Encrypted object with issue during HTTP communication: Invalid status code
yield [
(new StoredObject())
->setFilename('invalid_statuscode.txt')
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')])
->setIv(unpack('C*', 'abcdefghijklmnop')),
->registerVersion(
unpack('C*', 'abcdefghijklmnop'),
['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
filename: 'invalid_statuscode.txt'
)->getStoredObject(),
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear
StoredObjectManagerException::class,
@ -79,17 +85,73 @@ final class StoredObjectManagerTest extends TestCase
// Erroneous encrypted: Unable to decrypt exception.
yield [
(new StoredObject())
->setFilename('unable_to_decrypt.txt')
->setKeyInfos(['k' => base64_encode('WRONG_PASS_PHRASE')])
->setIv(unpack('C*', 'abcdefghijklmnop')),
->registerVersion(
unpack('C*', 'abcdefghijklmnop'),
['k' => base64_encode('WRONG_PASS_PHRASE')],
filename: 'unable_to_decrypt.txt'
)->getStoredObject(),
'WRONG_ENCODED_VALUE', // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear
StoredObjectManagerException::class,
];
}
public static function getDataProviderForWrite(): \Generator
{
/* HAPPY SCENARIO */
// Encrypted object
yield [
(new StoredObject())
->registerVersion(
unpack('C*', 'abcdefghijklmnop'),
['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
filename: 'encrypted.txt'
)->getStoredObject(),
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear
];
// Non-encrypted object
yield [
(new StoredObject())->registerVersion(filename: 'non-encrypted.txt')->getStoredObject(), // The StoredObject
'The quick brown fox jumps over the lazy dog', // Encrypted
'The quick brown fox jumps over the lazy dog', // Clear
];
/* UNHAPPY SCENARIO */
// Encrypted object with issue during HTTP communication
yield [
(new StoredObject())
->registerVersion(
unpack('C*', 'abcdefghijklmnop'),
['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
filename: 'error_during_http_request.txt'
)->getStoredObject(),
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear
StoredObjectManagerException::class,
-1,
];
// Encrypted object with issue during HTTP communication: Invalid status code
yield [
(new StoredObject())
->registerVersion(
unpack('C*', 'abcdefghijklmnop'),
['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
filename: 'invalid_statuscode.txt'
)->getStoredObject(),
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear
StoredObjectManagerException::class,
408,
];
}
/**
* @dataProvider getDataProvider
* @dataProvider getDataProviderForRead
*/
public function testRead(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null)
{
@ -103,19 +165,37 @@ final class StoredObjectManagerTest extends TestCase
}
/**
* @dataProvider getDataProvider
* @dataProvider getDataProviderForWrite
*/
public function testWrite(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null)
public function testWrite(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null, ?int $errorCode = null)
{
if (null !== $exceptionClass) {
$this->expectException($exceptionClass);
}
$storedObjectManager = $this->getSubject($storedObject, $encodedContent);
$previousVersion = $storedObject->getCurrentVersion();
$previousFilename = $previousVersion->getFilename();
$storedObjectManager->write($storedObject, $clearContent);
$client = new MockHttpClient(function ($method, $url, $options) use ($encodedContent, $previousFilename, $errorCode) {
self::assertEquals('PUT', $method);
self::assertStringStartsWith('https://example.com/', $url);
self::assertStringNotContainsString($previousFilename, $url, 'test that the PUT operation is not performed on the same file');
self::assertArrayHasKey('body', $options);
self::assertEquals($encodedContent, $options['body']);
self::assertEquals($clearContent, $storedObjectManager->read($storedObject));
if (-1 === $errorCode) {
throw new TransportException();
}
return new MockResponse('', ['http_code' => $errorCode ?? 201]);
});
$storedObjectManager = new StoredObjectManager($client, $this->getTempUrlGenerator($storedObject));
$newVersion = $storedObjectManager->write($storedObject, $clearContent);
self::assertNotSame($previousVersion, $newVersion);
self::assertSame($storedObject->getCurrentVersion(), $newVersion);
}
public function testWriteWithDeleteAt()
@ -170,19 +250,6 @@ final class StoredObjectManagerTest extends TestCase
}
}
if (Request::METHOD_PUT === $method) {
switch ($url) {
case 'https://example.com/non-encrypted.txt':
case 'https://example.com/encrypted.txt':
return new MockResponse($encodedContent, ['http_code' => 201]);
case 'https://example.com/error_during_http_request.txt':
throw new TransportException('error_during_http_request.txt');
case 'https://example.com/invalid_statuscode.txt':
return new MockResponse($encodedContent, ['http_code' => 404]);
}
}
return new MockResponse('Not found');
};
@ -209,9 +276,16 @@ final class StoredObjectManagerTest extends TestCase
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
$tempUrlGenerator
->expects($this->atLeastOnce())
->method('generate')
->withAnyParameters()
->willReturn($response);
->with($this->logicalOr($this->identicalTo('GET'), $this->identicalTo('PUT')), $this->isType('string'))
->willReturnCallback(function (string $method, string $objectName) {
return new SignedUrl(
$method,
'https://example.com/'.$objectName,
new \DateTimeImmutable('1 hours')
);
});
return $tempUrlGenerator;
}