diff --git a/CHANGELOG.md b/CHANGELOG.md index e85232e..265c222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +* Add workaround for clients uploading files with missing 'filename' directive in 'Content-Disposition' +header of multipart/form-data POST +* Add unit tests + ## v0.1.5 * Adapt to CAMPUSonline DMS API spec version 1.5.0 diff --git a/composer.json b/composer.json index 44ab270..ea90f9b 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "php": ">=8.1", "ext-json": "*", "api-platform/core": "^3.2", - "dbp/relay-blob-bundle": "^0.1.54", + "dbp/relay-blob-bundle": "^0.1.66", "dbp/relay-core-bundle": "^0.1.187", "doctrine/doctrine-migrations-bundle": "^3.3", "symfony/config": "^6.4", diff --git a/composer.lock b/composer.lock index 638508b..d57cce1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d1d5b37bb6c704574590787ba89a31b7", + "content-hash": "316bfe0cb79d8b0f7e4f4c359a076b91", "packages": [ { "name": "api-platform/core", @@ -289,16 +289,16 @@ }, { "name": "dbp/relay-blob-bundle", - "version": "v0.1.65", + "version": "v0.1.66", "source": { "type": "git", "url": "https://github.com/digital-blueprint/relay-blob-bundle.git", - "reference": "83c25e692f2386182ffc665a7a56f185f766cd5f" + "reference": "969e4d635f1c62bf7e54eed14dfcbb97cd1f1462" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/digital-blueprint/relay-blob-bundle/zipball/83c25e692f2386182ffc665a7a56f185f766cd5f", - "reference": "83c25e692f2386182ffc665a7a56f185f766cd5f", + "url": "https://api.github.com/repos/digital-blueprint/relay-blob-bundle/zipball/969e4d635f1c62bf7e54eed14dfcbb97cd1f1462", + "reference": "969e4d635f1c62bf7e54eed14dfcbb97cd1f1462", "shasum": "" }, "require": { @@ -315,6 +315,7 @@ "kekos/multipart-form-data-parser": "^1.1", "nyholm/psr7": "^1.8", "php": ">=8.1", + "psr/log": "^1.0 || ^2 || ^3", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", "symfony/config": "^6.4", @@ -360,9 +361,9 @@ "description": "A bundle for file-serving, persisting and managing", "support": { "issues": "https://github.com/digital-blueprint/relay-blob-bundle/issues", - "source": "https://github.com/digital-blueprint/relay-blob-bundle/tree/v0.1.165" + "source": "https://github.com/digital-blueprint/relay-blob-bundle/tree/v0.1.66" }, - "time": "2024-11-07T14:49:22+00:00" + "time": "2024-11-13T11:18:36+00:00" }, { "name": "dbp/relay-blob-library", @@ -420,16 +421,16 @@ }, { "name": "dbp/relay-core-bundle", - "version": "v0.1.190", + "version": "v0.1.191", "source": { "type": "git", "url": "https://github.com/digital-blueprint/relay-core-bundle.git", - "reference": "715a41d8807f8b3fbb422ba53051b4adfec4f8f2" + "reference": "bbc78e054ef3a350a3bbfb0a944dc0955ad43304" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/digital-blueprint/relay-core-bundle/zipball/715a41d8807f8b3fbb422ba53051b4adfec4f8f2", - "reference": "715a41d8807f8b3fbb422ba53051b4adfec4f8f2", + "url": "https://api.github.com/repos/digital-blueprint/relay-core-bundle/zipball/bbc78e054ef3a350a3bbfb0a944dc0955ad43304", + "reference": "bbc78e054ef3a350a3bbfb0a944dc0955ad43304", "shasum": "" }, "require": { @@ -515,9 +516,9 @@ "description": "The core bundle of the Relay API gateway", "support": { "issues": "https://github.com/digital-blueprint/relay-core-bundle/issues", - "source": "https://github.com/digital-blueprint/relay-core-bundle/tree/v0.1.190" + "source": "https://github.com/digital-blueprint/relay-core-bundle/tree/v0.1.191" }, - "time": "2024-11-06T08:14:32+00:00" + "time": "2024-11-12T14:03:25+00:00" }, { "name": "doctrine/cache", @@ -2593,16 +2594,16 @@ }, { "name": "monolog/monolog", - "version": "3.7.0", + "version": "3.8.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8" + "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/f4393b648b78a5408747de94fca38beb5f7e9ef8", - "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/32e515fdc02cdafbe4593e30a9350d486b125b67", + "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67", "shasum": "" }, "require": { @@ -2622,12 +2623,14 @@ "guzzlehttp/psr7": "^2.2", "mongodb/mongodb": "^1.8", "php-amqplib/php-amqplib": "~2.4 || ^3", - "phpstan/phpstan": "^1.9", - "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-strict-rules": "^1.4", - "phpunit/phpunit": "^10.5.17", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", "predis/predis": "^1.1 || ^2", - "ruflin/elastica": "^7", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", "symfony/mailer": "^5.4 || ^6", "symfony/mime": "^5.4 || ^6" }, @@ -2678,7 +2681,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.7.0" + "source": "https://github.com/Seldaek/monolog/tree/3.8.0" }, "funding": [ { @@ -2690,7 +2693,7 @@ "type": "tidelift" } ], - "time": "2024-06-28T09:40:51+00:00" + "time": "2024-11-12T13:57:08+00:00" }, { "name": "nelmio/cors-bundle", @@ -12887,14 +12890,14 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=8.1", "ext-json": "*" }, - "platform-dev": {}, + "platform-dev": [], "platform-overrides": { "php": "8.1" }, diff --git a/src/Rest/Common.php b/src/Rest/Common.php index 78554f0..9ca18ca 100644 --- a/src/Rest/Common.php +++ b/src/Rest/Common.php @@ -5,7 +5,10 @@ namespace Dbp\Relay\BlobConnectorCampusonlineDmsBundle\Rest; use Dbp\Relay\BlobConnectorCampusonlineDmsBundle\Entity\Error; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class Common @@ -13,17 +16,32 @@ class Common /** * @throws Error */ - public static function ensureUpdatedFileIsValid(mixed $uploadedFile, string $fileParameterName = 'binary_content'): void + public static function getAndValidateUploadedFile(Request $request, string $fileParameterName): File { + $uploadedFile = $request->files->get($fileParameterName); + if ($uploadedFile === null) { + // look into form parameters if the file was sent in the form of a binary string parameter, + // i.e. without the 'filename' directive in the 'content-disposition' header, which CO currently does. + $binaryContent = $request->request->get($fileParameterName); + if ($binaryContent === null) { + throw new Error(Response::HTTP_BAD_REQUEST, 'parameter \'binary_content\' must not be empty', + errorCode: 'REQUIRED_PARAMETER_MISSING', errorDetail: 'binary_content'); + } + $filesystem = new Filesystem(); + $tempFilePath = $filesystem->tempnam('/tmp', 'php'); + file_put_contents($tempFilePath, $binaryContent); + $uploadedFile = new File($tempFilePath); + } + if ($uploadedFile === null) { throw new Error(Response::HTTP_BAD_REQUEST, 'Parameter \''.$fileParameterName.'\' is required', errorCode: 'REQUIRED_PARAMETER_MISSING', errorDetail: $fileParameterName); } - if ($uploadedFile instanceof UploadedFile === false) { + if ($uploadedFile instanceof File === false) { throw new Error(Response::HTTP_BAD_REQUEST, 'Parameter \''.$fileParameterName.'\' must be a file stream', errorCode: 'PARAMETER_TYPE_INVALID', errorDetail: $fileParameterName); } - if ($uploadedFile->getError() !== UPLOAD_ERR_OK) { + if ($uploadedFile instanceof UploadedFile && $uploadedFile->getError() !== UPLOAD_ERR_OK) { throw new Error(Response::HTTP_BAD_REQUEST, sprintf('file stream upload failed: %d', $uploadedFile->getError()), errorCode: 'FILE_UPLOAD_FAILED', errorDetail: $fileParameterName); } @@ -31,5 +49,7 @@ public static function ensureUpdatedFileIsValid(mixed $uploadedFile, string $fil throw new Error(Response::HTTP_BAD_REQUEST, 'uploaded file stream must not be empty', errorCode: 'FILE_MUST_NOT_BE_EMPTY', errorDetail: $fileParameterName); } + + return $uploadedFile; } } diff --git a/src/Rest/CreateDocumentController.php b/src/Rest/CreateDocumentController.php index 452872a..9d885e9 100644 --- a/src/Rest/CreateDocumentController.php +++ b/src/Rest/CreateDocumentController.php @@ -38,8 +38,7 @@ public function __invoke(Request $request): Document errorCode: 'REQUIRED_PARAMETER_MISSING', errorDetail: 'name'); } - $uploadedFile = $request->files->get('binary_content'); - Common::ensureUpdatedFileIsValid($uploadedFile); + $uploadedFile = Common::getAndValidateUploadedFile($request, 'binary_content'); $metadata = $request->request->get('metadata'); if ($metadata === null) { diff --git a/src/Rest/CreateDocumentVersionController.php b/src/Rest/CreateDocumentVersionController.php index 2e7cb24..31d2904 100644 --- a/src/Rest/CreateDocumentVersionController.php +++ b/src/Rest/CreateDocumentVersionController.php @@ -34,8 +34,7 @@ public function __invoke(Request $request, string $uid): ?Document $name = $request->request->get('name'); $documentType = $request->request->get('document_type'); - $uploadedFile = $request->files->get('binary_content'); - Common::ensureUpdatedFileIsValid($uploadedFile); + $uploadedFile = Common::getAndValidateUploadedFile($request, 'binary_content'); $documentVersionMetadataArray = null; $documentVersionMetadata = $request->request->get('metadata'); diff --git a/src/Service/DocumentService.php b/src/Service/DocumentService.php index 43e0b10..aa371ae 100644 --- a/src/Service/DocumentService.php +++ b/src/Service/DocumentService.php @@ -10,16 +10,16 @@ use Dbp\Relay\BlobConnectorCampusonlineDmsBundle\Entity\Document; use Dbp\Relay\BlobConnectorCampusonlineDmsBundle\Entity\DocumentVersionInfo; use Dbp\Relay\BlobConnectorCampusonlineDmsBundle\Entity\Error; -use Dbp\Relay\BlobConnectorCampusonlineDmsBundle\Entity\File; -use Symfony\Component\HttpFoundation\File\UploadedFile; +use Dbp\Relay\BlobConnectorCampusonlineDmsBundle\Entity\File as FileEntity; +use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Uid\Uuid; class DocumentService { - private const DOCUMENT_VERSION_METADATA_TYPE = 'document_version'; // config value? - private const BUCKET_ID = 'campusonline-dms-bucket'; + public const BUCKET_ID = 'campusonline-dms-bucket'; + private const DOCUMENT_VERSION_METADATA_TYPE = 'document_version'; // config value? private const DOCUMENT_VERSION_METADATA_METADATA_KEY = 'doc_version_metadata'; private const DOCUMENT_METADATA_METADATA_KEY = 'doc_metadata'; private const VERSION_NUMBER_METADATA_KEY = 'version'; @@ -64,7 +64,7 @@ public function getDocument(string $uid): Document /** * @throws \Exception */ - public function addDocument(Document $document, UploadedFile $uploadedFile, string $name, + public function addDocument(Document $document, File $uploadedFile, string $name, ?array $documentVersionMetadata = null, ?string $documentType = null): Document { $document->setUid((string) Uuid::v7()); @@ -91,7 +91,7 @@ public function removeDocument(string $uid): void /** * @throws \Exception */ - public function addDocumentVersion(string $documentUid, UploadedFile $uploadedFile, + public function addDocumentVersion(string $documentUid, File $uploadedFile, string $name, ?array $documentVersionMetadata = null, ?string $documentType = null): ?Document { $document = $this->getDocument($documentUid); @@ -140,36 +140,36 @@ public function getDocumentVersionBinaryFileResponse(string $uid): Response } } - public function getFile(string $uid): ?File + public function getFile(string $uid): ?FileEntity { - $file = new File(); + $file = new FileEntity(); $file->setUid($uid); return $file; } - public function addFile(File $file): File + public function addFile(FileEntity $file): FileEntity { $file->setUid((string) Uuid::v7()); return $file; } - public function replaceFile(string $uid, File $file): File + public function replaceFile(string $uid, FileEntity $file): FileEntity { $file->setUid($uid); return $file; } - public function removeFile(string $uid, File $file): void + public function removeFile(string $uid, FileEntity $file): void { } /** * @throws \Exception */ - private function createDocumentVersion(Document $document, UploadedFile $uploadedFile, string $name, + private function createDocumentVersion(Document $document, File $uploadedFile, string $name, ?array $documentVersionMetadata = null, ?string $documentType = null, ?string $lastVersion = null): DocumentVersionInfo { $versionNumber = $lastVersion ? strval(intval($lastVersion) + 1) : '1'; @@ -198,9 +198,10 @@ private function createDocumentVersion(Document $document, UploadedFile $uploade $fileData->setPrefix($document->getUid()); $fileData->setType(self::DOCUMENT_VERSION_METADATA_TYPE); $fileData->setMetadata($metadataEncoded); + $fileData->setBucketId(self::BUCKET_ID); try { - $fileData = $this->fileApi->addFile($fileData, self::BUCKET_ID); + $fileData = $this->fileApi->addFile($fileData); } catch (FileApiException $fileApiException) { throw self::createException($fileApiException); } diff --git a/tests/DocumentServiceTest.php b/tests/DocumentServiceTest.php new file mode 100644 index 0000000..3ded68d --- /dev/null +++ b/tests/DocumentServiceTest.php @@ -0,0 +1,79 @@ +setUpFileApi(); + } + + protected function setUpFileApi(): void + { + $testConfig = BlobTestUtils::getTestConfig(); + $testConfig['buckets'][0]['bucket_id'] = DocumentService::BUCKET_ID; + $testConfig['buckets'][0]['additional_types'] = [ + ['document_version' => __DIR__.'/document_version.schema.json'], + ]; + + $this->blobTestEntityManager = new TestEntityManager(self::bootKernel()->getContainer()); + $this->documentService = new DocumentService(BlobTestUtils::createTestFileApi( + $this->blobTestEntityManager->getEntityManager(), + $testConfig + )); + } + + /** + * @throws \Exception + */ + public function testCreateDocument(): void + { + $document = new Document(); + $documentMetadata = ['foo' => 'bar']; + $document->setMetadata($documentMetadata); + + $file = new File(__DIR__.'/'.self::TEST_FILE_NAME, true); + $documentVersionMetadata = [ + 'bar' => 'baz', + ]; + $documentType = 'transcript_of_records'; + + $document = $this->documentService->addDocument( + $document, $file, self::TEST_FILE_NAME, $documentVersionMetadata, $documentType); + + $this->assertNotEmpty($document->getUid()); + $this->assertEquals($documentMetadata, $document->getMetaData()); + $this->assertNotEmpty($document->getLatestVersion()->getUid()); + $this->assertEquals(self::TEST_FILE_NAME, $document->getLatestVersion()->getName()); + $this->assertEquals($documentVersionMetadata, $document->getLatestVersion()->getMetadata()); + $this->assertEquals('1', $document->getLatestVersion()->getVersionNumber()); + $this->assertEquals($file->getSize(), $document->getLatestVersion()->getSize()); + $this->assertEquals($file->getMimeType(), $document->getLatestVersion()->getMediaType()); + + $this->assertTrue(TestDatasystemProviderService::hasFile( + self::getInternalBucketId(), $document->getLatestVersion()->getUid())); + $this->assertTrue(TestDatasystemProviderService::isContentEqual( + self::getInternalBucketId(), $document->getLatestVersion()->getUid(), $file)); + } + + protected static function getInternalBucketId(): ?string + { + return BlobTestUtils::getTestConfig()['buckets'][0]['internal_bucket_id'] ?? null; + } +} diff --git a/tests/document_version.schema.json b/tests/document_version.schema.json new file mode 100644 index 0000000..547b06c --- /dev/null +++ b/tests/document_version.schema.json @@ -0,0 +1,26 @@ +{ + "name": "Campusonline DMS API document version information", + "properties": { + "version": { + "description": "The version of the document", + "type": "string", + "required": true + }, + "doc_version_metadata": { + "description": "Additional metadata of the document version", + "type": "object", + "required": false + }, + "doc_metadata": { + "description": "Additional metadata of the document", + "type": "object", + "required": false + }, + "doc_type": { + "description": "The CO document type", + "type": "string", + "required": false + } + }, + "additionalProperties": false +} diff --git a/tests/test.txt b/tests/test.txt new file mode 100644 index 0000000..6769dd6 --- /dev/null +++ b/tests/test.txt @@ -0,0 +1 @@ +Hello world! \ No newline at end of file diff --git a/tests/test_patch.txt b/tests/test_patch.txt new file mode 100644 index 0000000..f096afa --- /dev/null +++ b/tests/test_patch.txt @@ -0,0 +1 @@ +Hallo welt! \ No newline at end of file