Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions examples/upload_directory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use IPFS\Client\IPFSClient;

// Instantiate the IPFS client
$client = new IPFSClient(host: 'localhost', port: 5001);

// Add a file to IPFS
$directory = $client->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;
23 changes: 23 additions & 0 deletions examples/upload_file.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use IPFS\Client\IPFSClient;

// Instantiate the IPFS client
$client = new IPFSClient(host: 'localhost', port: 5001);

// Add a file to IPFS
$file = $client->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;
File renamed without changes.
98 changes: 98 additions & 0 deletions src/Client/IPFSClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand Down Expand Up @@ -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', [
Expand Down
31 changes: 31 additions & 0 deletions src/Helper/FilesystemHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace IPFS\Helper;

class FilesystemHelper
{
/**
* @return string[]
*/
public static function listFiles(string $directory): array
{
if (is_dir($directory) === false) {
throw new \InvalidArgumentException('Directory not found.');
}

$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
);

foreach ($iterator as $fileInfo) {
if ($fileInfo->isFile()) {
$files[] = $fileInfo->getRealPath();
}
}

return $files;
}
}
26 changes: 26 additions & 0 deletions src/Model/Directory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace IPFS\Model;

class Directory
{
/**
* @param File[] $files
*/
public function __construct(
public readonly string $name,
public readonly string $hash,
public readonly string $size,
public array $files = [],
) {
}

public function addFile(File $file): self
{
$this->files[] = $file;

return $this;
}
}
22 changes: 22 additions & 0 deletions src/Transformer/DirectoryTransformer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace IPFS\Transformer;

use IPFS\Model\Directory;

class DirectoryTransformer extends AbstractTransformer
{
public function transform(array $input): Directory
{
$this->assertParameters($input, ['Name', 'Hash', 'Size']);

return new Directory(
name: $input['Name'],
hash: $input['Hash'],
size: $input['Size'],
files: [],
);
}
}
81 changes: 80 additions & 1 deletion tests/Client/IPFSClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ protected function setUp(): void
/**
* @covers ::add
*/
public function testAddFile(): void
public function testAdd(): void
{
$content = 'Hello, World!';

Expand Down Expand Up @@ -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
*/
Expand Down
Loading