From c41dae1f940ca0a37c845807e1bcaf482a78821c Mon Sep 17 00:00:00 2001 From: Edouard Courty Date: Tue, 1 Apr 2025 13:24:48 +0200 Subject: [PATCH] feat(upload): support file and folder upload --- CHANGELOG.md | 19 ++++ README.md | 3 +- composer.json | 3 +- examples/upload_directory.php | 18 ++++ examples/upload_file.php | 23 +++++ examples/{upload.php => upload_raw_data.php} | 0 src/Client/IPFSClient.php | 98 +++++++++++++++++++ src/Helper/FilesystemHelper.php | 31 ++++++ src/Model/Directory.php | 26 +++++ src/Transformer/DirectoryTransformer.php | 22 +++++ tests/Client/IPFSClientTest.php | 81 ++++++++++++++- tests/Helper/FilesystemHelperTest.php | 27 +++++ .../Helper/test_folder/nested/test_file_2.txt | 0 tests/Helper/test_folder/test_file_1.txt | 0 .../Transformer/DirectoryTransformerTest.php | 41 ++++++++ 15 files changed, 388 insertions(+), 4 deletions(-) create mode 100644 examples/upload_directory.php create mode 100644 examples/upload_file.php rename examples/{upload.php => upload_raw_data.php} (100%) create mode 100644 src/Helper/FilesystemHelper.php create mode 100644 src/Model/Directory.php create mode 100644 src/Transformer/DirectoryTransformer.php create mode 100644 tests/Helper/FilesystemHelperTest.php create mode 100644 tests/Helper/test_folder/nested/test_file_2.txt create mode 100644 tests/Helper/test_folder/test_file_1.txt create mode 100644 tests/Transformer/DirectoryTransformerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a9e008..2e6859a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,3 +52,22 @@ This release brings support for the `resolve` command. - Added the `IPFSClient::resolve` method, which returns the path to a given IPFS name. - Added corresponding tests for the new method. + +## v1.4.0 + +This release enhances the `add` feature, by allowing to precisely upload files and directories instead of raw data. + +#### Additions + +- Added the `IPFSClient::addFile` method, which allows for adding files to IPFS. + - Added corresponding tests for the new method. +- Added the `IPFSClient::addDirectory` method, which allows for adding directories to IPFS. + - Added corresponding tests for the new method. +- Added the `Directory` model and corresponding transformer. + - Added corresponding tests for the new transformer. +- Other minor additions such as `Helper\FilesytemHelper` +- Added [code examples](examples). + +#### Updates + +- Updated `README.md` to use the new `IPFSClient::addFile` method instead of `IPFSClient::add` in the provided code example. \ No newline at end of file diff --git a/README.md b/README.md index c83e975..2b29691 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,7 @@ $client = new IPFSClient(url: 'http://localhost:5001'); // $client = new IPFSClient(host: 'localhost', port: 5001); // Add a file -$fileContent = file_get_contents('file.txt'); -$file = $client->add($fileContent); +$file = $client->addFile('file.txt'); echo 'File uploaded: ' . $file->hash; // File uploaded: QmWGeRAEgtsHW3ec7U4qW2CyVy7eA2mFRVbk1nb24jFyks diff --git a/composer.json b/composer.json index e99f81d..8e6b6f2 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,8 @@ "require": { "php": ">= 8.3", "symfony/http-client": "^7.2", - "selective/base32": "^2.0" + "selective/base32": "^2.0", + "symfony/mime": "^7.2" }, "require-dev": { "phpunit/phpunit": "^12.0", diff --git a/examples/upload_directory.php b/examples/upload_directory.php new file mode 100644 index 0000000..de35c8d --- /dev/null +++ b/examples/upload_directory.php @@ -0,0 +1,18 @@ +addDirectory(__DIR__ . '/../src'); + +echo '-- Directory added to IPFS --' . \PHP_EOL; +echo 'Directory hash: ' . $directory->hash . \PHP_EOL; +echo 'Directory size: ' . $directory->size . \PHP_EOL; +echo 'Number of files in this directory: ' . \count($directory->files) . \PHP_EOL; diff --git a/examples/upload_file.php b/examples/upload_file.php new file mode 100644 index 0000000..1ba956a --- /dev/null +++ b/examples/upload_file.php @@ -0,0 +1,23 @@ +addFile(__DIR__ . '/upload_file.php'); + +echo '-- File added to IPFS --' . \PHP_EOL; +echo 'File hash: ' . $file->hash . \PHP_EOL; +echo 'File size: ' . $file->size . \PHP_EOL; + +echo \PHP_EOL . '-- Retrieving the file content from IPFS --' . \PHP_EOL; + +// Retrieve the file content from IPFS +$ipfsFileContent = $client->cat($file->hash); +echo 'IPFS File content: ' . \PHP_EOL . $ipfsFileContent . \PHP_EOL; diff --git a/examples/upload.php b/examples/upload_raw_data.php similarity index 100% rename from examples/upload.php rename to examples/upload_raw_data.php diff --git a/src/Client/IPFSClient.php b/src/Client/IPFSClient.php index 7420834..505ed94 100644 --- a/src/Client/IPFSClient.php +++ b/src/Client/IPFSClient.php @@ -5,7 +5,9 @@ namespace IPFS\Client; use IPFS\Exception\IPFSTransportException; +use IPFS\Helper\FilesystemHelper; use IPFS\Model\File; +use IPFS\Model\Directory; use IPFS\Model\ListFileEntry; use IPFS\Model\Node; use IPFS\Model\Peer; @@ -14,12 +16,15 @@ use IPFS\Transformer\FileLinkTransformer; use IPFS\Transformer\FileListTransformer; use IPFS\Transformer\FileTransformer; +use IPFS\Transformer\DirectoryTransformer; use IPFS\Transformer\NodeTransformer; use IPFS\Transformer\PeerIdentityTransformer; use IPFS\Transformer\PeerStreamTransformer; use IPFS\Transformer\PeerTransformer; use IPFS\Transformer\PingTransformer; use IPFS\Transformer\VersionTransformer; +use Symfony\Component\Mime\Part\DataPart; +use Symfony\Component\Mime\Part\Multipart\FormDataPart; class IPFSClient { @@ -63,6 +68,99 @@ public function add(string $file, array $parameters = []): File return $transformer->transform($parsedResponse); } + public function addFile(string $path, array $parameters = []): File + { + if (is_file($path) === false) { + throw new \InvalidArgumentException('Path must be a file.'); + } + $parameters['wrap-with-directory'] = false; + + $response = $this->httpClient->request('POST', '/api/v0/add', [ + 'body' => [ + 'file' => fopen($path, 'r'), + ], + 'query' => $parameters, + 'headers' => [ + 'Content-Type' => 'multipart/form-data', + ], + ]); + + $parsedResponse = json_decode($response, true); + + $transformer = new FileTransformer(); + return $transformer->transform($parsedResponse); + } + + public function addDirectory(string $path, array $parameters = []): Directory + { + if (is_dir($path) === false) { + throw new \InvalidArgumentException('Path must be a directory.'); + } + $parameters['wrap-with-directory'] = true; + + $files = FilesystemHelper::listFiles($path); + + $dataParts = []; + $basePath = realpath($path); + if ($basePath === false) { + throw new \UnexpectedValueException('Path ' . $path . ' does not exist.'); + } + + foreach ($files as $filePath) { + $filePathReal = realpath($filePath); + if ($filePathReal === false) { + throw new \UnexpectedValueException('Path ' . $path . ' does not exist.'); + } + + if (str_starts_with($filePathReal, $basePath) === false) { + throw new \RuntimeException("File $filePath is outside of base path."); + } + + $relativePath = mb_substr($filePathReal, mb_strlen($basePath) + 1); // +1 to remove leading slash + $relativePath = str_replace(\DIRECTORY_SEPARATOR, '/', $relativePath); // For Windows + + $fileHandle = fopen($filePath, 'r'); + if ($fileHandle === false) { + throw new \RuntimeException('Unable to open file ' . $filePath); + } + + $dataParts[] = new DataPart($fileHandle, $relativePath); + } + + // Use Symfony's FormDataPart to build the body + headers + $formData = new FormDataPart([ + 'file' => $dataParts, + ]); + + $response = $this->httpClient->request('POST', '/api/v0/add', [ + 'body' => $formData->bodyToString(), + 'query' => $parameters, + 'headers' => $formData->getPreparedHeaders()->toArray(), + ]); + + $parts = explode("\n", $response); + $filtered = array_filter($parts, function (string $value) { + return mb_strlen(trim($value)) > 0; + }); + $deserializedParts = array_map(fn (string $part) => json_decode($part, true), $filtered); + // Sort by size, larger first + usort($deserializedParts, function ($a, $b) { + return (int) $a['Size'] < (int) $b['Size'] ? 1 : -1; + }); + + $directoryTransformer = new DirectoryTransformer(); + + $directoryData = array_shift($deserializedParts); + $directory = $directoryTransformer->transform($directoryData); + + $fileTransformer = new FileTransformer(); + foreach ($deserializedParts as $part) { + $directory->addFile($fileTransformer->transform($part)); + } + + return $directory; + } + public function cat(string $hash, int $offset = 0, ?int $length = null): string { return $this->httpClient->request('POST', '/api/v0/cat', [ diff --git a/src/Helper/FilesystemHelper.php b/src/Helper/FilesystemHelper.php new file mode 100644 index 0000000..0df1cbf --- /dev/null +++ b/src/Helper/FilesystemHelper.php @@ -0,0 +1,31 @@ +isFile()) { + $files[] = $fileInfo->getRealPath(); + } + } + + return $files; + } +} diff --git a/src/Model/Directory.php b/src/Model/Directory.php new file mode 100644 index 0000000..cc8ade7 --- /dev/null +++ b/src/Model/Directory.php @@ -0,0 +1,26 @@ +files[] = $file; + + return $this; + } +} diff --git a/src/Transformer/DirectoryTransformer.php b/src/Transformer/DirectoryTransformer.php new file mode 100644 index 0000000..1a58403 --- /dev/null +++ b/src/Transformer/DirectoryTransformer.php @@ -0,0 +1,22 @@ +assertParameters($input, ['Name', 'Hash', 'Size']); + + return new Directory( + name: $input['Name'], + hash: $input['Hash'], + size: $input['Size'], + files: [], + ); + } +} diff --git a/tests/Client/IPFSClientTest.php b/tests/Client/IPFSClientTest.php index 0ccc959..c30a300 100644 --- a/tests/Client/IPFSClientTest.php +++ b/tests/Client/IPFSClientTest.php @@ -31,7 +31,7 @@ protected function setUp(): void /** * @covers ::add */ - public function testAddFile(): void + public function testAdd(): void { $content = 'Hello, World!'; @@ -62,6 +62,85 @@ public function testAddFile(): void $this->assertSame((int) $mockReturn['Size'], $result->size); } + /** + * @covers ::addFile + */ + public function testAddFile(): void + { + $mockReturn = [ + 'Name' => 'hello.txt', + 'Hash' => 'QmZ4tDuvese8GKQ3vz8Fq8bKz1q3z1z1z1z1z1z1z1z1z1z', + 'Size' => '13', + ]; + + $this->httpClient + ->expects($this->once()) + ->method('request') + ->with('POST', '/api/v0/add', $this->anything()) + ->willReturn(json_encode($mockReturn)); + + $result = $this->client->addFile('./tests/Client/IPFSClientTest.php'); + + $this->assertSame($mockReturn['Name'], $result->name); + $this->assertSame($mockReturn['Hash'], $result->hash); + $this->assertSame((int) $mockReturn['Size'], $result->size); + } + + /** + * @covers ::addFile + */ + public function testCannotAddFileThatDoesNotExist(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->client->addFile('/path/to/non/existent/file'); + } + + /** + * @covers ::addDirectory + */ + public function testAddDirectory(): void + { + $directoryReturn = [ + 'Name' => 'Directory name', + 'Hash' => 'QmZ4tDuvese8GKQ3vz8Fq8bKz1q3z1z1z1z1z1z1z1z1z', + 'Size' => '999999999999', // Should be bigger than the files sizes + ]; + $mockReturn = [ + 'Name' => 'File name', + 'Hash' => 'QmZ4tDuvese8GKQ3vz8Fq8bKz1q3z1z1z1z1z1z1z1z1z', + 'Size' => '1726312', + ]; + $jsonEncoded = json_encode($mockReturn); + $jsonEncodedDIrectoryPayload = json_encode($directoryReturn); + // wrap-with-directory responses contain multiple JSON objects separated by newlines. + $actualMockReturn = implode("\n", [$jsonEncoded, $jsonEncoded, $jsonEncoded, $jsonEncodedDIrectoryPayload]); + + $this->httpClient + ->expects($this->once()) + ->method('request') + ->with('POST', '/api/v0/add', $this->anything()) + ->willReturn($actualMockReturn); + + $result = $this->client->addDirectory('./tests'); + + $this->assertSame($directoryReturn['Name'], $result->name); + $this->assertSame($directoryReturn['Hash'], $result->hash); + $this->assertSame($directoryReturn['Size'], $result->size); + + $this->assertCount(3, $result->files); + } + + /** + * @covers ::addDirectory + */ + public function testCannotaddDirectoryThatDoesNotExist(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->client->addDirectory('/path/to/non/existent/folder'); + } + /** * @covers ::cat */ diff --git a/tests/Helper/FilesystemHelperTest.php b/tests/Helper/FilesystemHelperTest.php new file mode 100644 index 0000000..45c1485 --- /dev/null +++ b/tests/Helper/FilesystemHelperTest.php @@ -0,0 +1,27 @@ +assertCount(2, $result); + $this->assertContains($currentDirectory . '/test_folder/test_file_1.txt', $result); + $this->assertContains($currentDirectory . '/test_folder/nested/test_file_2.txt', $result); + } +} diff --git a/tests/Helper/test_folder/nested/test_file_2.txt b/tests/Helper/test_folder/nested/test_file_2.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/test_folder/test_file_1.txt b/tests/Helper/test_folder/test_file_1.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/Transformer/DirectoryTransformerTest.php b/tests/Transformer/DirectoryTransformerTest.php new file mode 100644 index 0000000..26344fa --- /dev/null +++ b/tests/Transformer/DirectoryTransformerTest.php @@ -0,0 +1,41 @@ +directoryTransformer = new DirectoryTransformer(); + } + + /** + * @covers ::transform + */ + public function testItTransforms(): void + { + $data = [ + 'Name' => 'Directory name', + 'Hash' => 'NiceHash', + 'Size' => '123456', + ]; + + $result = $this->directoryTransformer->transform($data); + + $this->assertSame($data['Name'], $result->name); + $this->assertSame($data['Hash'], $result->hash); + $this->assertSame($data['Size'], $result->size); + + $this->assertEmpty($result->files); + } +}