diff --git a/README.md b/README.md index 6bfeba5..6d3a777 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Here’s a basic example of how to use the SDK: require 'vendor/autoload.php'; -use Suicore\Walrus\Responses\StoreBlobOptions; +use Suicore\Walrus\Types\StoreBlobOrQuiltOptions; use Suicore\Walrus\WalrusClient; @@ -37,7 +37,7 @@ $aggregatorUrl = 'https://aggregator.walrus-testnet.walrus.space'; $client = new WalrusClient($publisherUrl, $aggregatorUrl); // Prepare options for storing a blob -$options = new StoreBlobOptions(epochs: 2); +$options = new StoreBlobOrQuiltOptions(epochs: 2); // Store a text blob $storeResponse = $client->storeBlob("Hello, Walrus!", $options); @@ -58,15 +58,54 @@ echo "Retrieved content: {$content}\n"; ``` ### Uploading Files -To upload a file, simply pass the file path to the `storeBlob` method and set the $isFile parameter to `true`: +To upload a blob, simply pass the file path to the `storeBlob` method and set the $isFile parameter to `true`: ```php // Save a file as a blob $file = '/path/to/file.txt'; -$options = new StoreBlobOptions(epochs: 2); +$options = new StoreBlobOrQuiltOptions(epochs: 2); $storeResponse = $client->storeBlob(dataOrPath: $file, options: $options, isFile: true); ``` +### Uploading a file in a quilt +The SDK supports uploading multiple files as a single quilt using the `storeQuilt` method. Each file can be associated with custom metadata. + +```php +use Suicore\Walrus\WalrusClient; +use Suicore\Walrus\Types\StoreBlobOrQuiltOptions; +use Suicore\Walrus\Types\QuiltElementFile; +use Suicore\Walrus\Types\QuiltElementFileMetadata; + +$client = new WalrusClient($publisherUrl, $aggregatorUrl); +$options = new StoreBlobOrQuiltOptions(epochs: 2); + +$files = [ + new QuiltElementFile('wal1.jpg', __DIR__ . '/walrus.jpg'), + new QuiltElementFile('wal2.jpg', fopen(__DIR__ . '/walrus.jpg', 'rb')), // Open resource +]; + +$metadata = [ + new QuiltElementFileMetadata('wal1.jpg', (object)['creator' => 'walrus', 'version' => '1.0']), + new QuiltElementFileMetadata('wal2.jpg', (object)['type' => 'logo', 'format' => 'png']), +]; + +$storeResponse = $client->storeQuilt($files, $options, $metadata); + +$files = $storeResponse->getStoredQuiltBlobs()->getQuiltFiles(); +foreach ($files as $file) { + echo "Stored patch ID: " . $file->getQuiltPatchId() . "\n"; +} +``` + +### Retrieving a Quilt Patch or Quilt by name +```php +$patchId = 'quilt-file-patch-id'; +$content = $client->getQuilt($patchId); + +$blobId = 'quilt-blob-id'; +$fileContent = $client->getQuilt($blobId, 'wal1.jpg'); +``` + ### Encryption The Walrus SDK provides a simple, but secure way to encrypt and decrypt data using the `Aes256Encryptor` class. Here’s an example: ```php @@ -89,18 +128,37 @@ A key length of 32 bytes is required for AES-256 encryption. Shorter or longer k #### storeBlob Stores a blob using the publisher API. + Parameters: - `string dataOrPath`: The blob data or file path. -- `StoreBlobOptions $options`: Options such as epochs, sendObjectTo address, and deletability. +- `StoreBlobOrQuiltOptions $options`: Options such as epochs, sendObjectTo address, and deletability. - `bool $isFile`: Set to true if $dataOrPath is a file path. - Returns: A StoreBlobResponse object representing the response from the API. #### getBlob Retrieves a blob from the aggregator API. + Parameters: - `string $blobId`: The unique blob identifier. - Returns: The blob content as a string. +#### storeQuilt +Stores multiple files as a quilt with optional metadata. + +Parameters: +- `QuiltFile[] $files`: An array of QuiltFile objects containing identifier and file source. +- `StoreBlobOrQuiltOptions $options`: Storage options. +- `QuiltMetadata[] $metadata`: (Optional) Metadata associated with each file. +- Returns: StoreQuiltResponse + +#### getQuilt +Retrieves quilt content from the aggregator. + +Parameters: +- `string $blobOrPatchId`: A blob ID (for full quilt) or a patch ID (for specific patch). +- `string|null $filename`: Optional filename to extract a specific file from the quilt. +- Returns: File contents as a string. + ### Testing To run the test suite, use the following commands: diff --git a/src/Responses/AlreadyCertifiedEvent.php b/src/Types/AlreadyCertifiedEvent.php similarity index 95% rename from src/Responses/AlreadyCertifiedEvent.php rename to src/Types/AlreadyCertifiedEvent.php index 0468cd4..099187e 100644 --- a/src/Responses/AlreadyCertifiedEvent.php +++ b/src/Types/AlreadyCertifiedEvent.php @@ -1,6 +1,6 @@ encodingType; } - public function getCertifiedEpoch(): int + public function getCertifiedEpoch(): int | null { return $this->certifiedEpoch; } diff --git a/src/Responses/NewlyCreatedResponse.php b/src/Types/NewlyCreatedResponse.php similarity index 97% rename from src/Responses/NewlyCreatedResponse.php rename to src/Types/NewlyCreatedResponse.php index b8d54fa..7fa5622 100644 --- a/src/Responses/NewlyCreatedResponse.php +++ b/src/Types/NewlyCreatedResponse.php @@ -1,6 +1,6 @@ identifier = $identifier; + $this->quiltPatchId = $quiltPatchId; + } + + public static function fromObject(\stdClass $data): self + { + if (!isset($data->identifier, $data->quiltPatchId)) { + throw new \InvalidArgumentException("Invalid data for QuiltElement"); + } + return new self($data->identifier, $data->quiltPatchId); + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getQuiltPatchId(): string + { + return $this->quiltPatchId; + } +} diff --git a/src/Types/QuiltElementFile.php b/src/Types/QuiltElementFile.php new file mode 100644 index 0000000..9124668 --- /dev/null +++ b/src/Types/QuiltElementFile.php @@ -0,0 +1,36 @@ +identifier = $identifier; + $this->source = $source; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** @return string|resource */ + public function getSource() + { + return $this->source; + } +} diff --git a/src/Types/QuiltElementFileMetadata.php b/src/Types/QuiltElementFileMetadata.php new file mode 100644 index 0000000..4e45f91 --- /dev/null +++ b/src/Types/QuiltElementFileMetadata.php @@ -0,0 +1,37 @@ +identifier = $identifier; + $this->tags = $tags; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function getTags(): array + { + return $this->tags; + } + + public function toArray(): array + { + $result = [ + 'identifier' => $this->identifier + ]; + if (!empty($this->tags)) { + $result['tags'] = $this->tags; + } + + return $result; + } +} diff --git a/src/Responses/RegisterFromScratch.php b/src/Types/RegisterFromScratch.php similarity index 95% rename from src/Responses/RegisterFromScratch.php rename to src/Types/RegisterFromScratch.php index db7e76e..8ddee24 100644 --- a/src/Responses/RegisterFromScratch.php +++ b/src/Types/RegisterFromScratch.php @@ -1,6 +1,6 @@ newlyCreated !== null; diff --git a/src/Types/StoreQuiltResponse.php b/src/Types/StoreQuiltResponse.php new file mode 100644 index 0000000..9990713 --- /dev/null +++ b/src/Types/StoreQuiltResponse.php @@ -0,0 +1,64 @@ +blobStoreResult->getNewlyCreated() !== null; + } + + public function isAlreadyCertified(): bool + { + return $this->blobStoreResult->getAlreadyCertified() !== null; + } + + public function getNewlyCreated(): ?NewlyCreatedResponse + { + return $this->blobStoreResult->getNewlyCreated(); + } + + public function getAlreadyCertified(): ?AlreadyCertifiedResponse + { + return $this->blobStoreResult->getAlreadyCertified(); + } + + public function getBlobStoreResult(): ?StoreBlobResponse + { + return $this->blobStoreResult; + } + + public function getStoredQuiltBlobs(): ?StoredQuiltBlobs + { + return $this->storedQuiltBlobs; + } +} diff --git a/src/Types/StoredQuiltBlobs.php b/src/Types/StoredQuiltBlobs.php new file mode 100644 index 0000000..0dbb801 --- /dev/null +++ b/src/Types/StoredQuiltBlobs.php @@ -0,0 +1,29 @@ +files = $files; + } + + public static function fromArray(array $files): self + { + $files = array_map(function ($file) { + $obj = new \stdClass(); + $obj->identifier = $file["identifier"]; + $obj->quiltPatchId = $file["quiltPatchId"]; + return QuiltElement::fromObject($obj); + }, $files); + return new self($files); + } + + public function getQuiltFiles(): array + { + return $this->files; + } +} diff --git a/src/WalrusClient.php b/src/WalrusClient.php index 56d5f9b..f212b51 100644 --- a/src/WalrusClient.php +++ b/src/WalrusClient.php @@ -4,8 +4,11 @@ use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; -use Suicore\Walrus\Responses\StoreBlobOptions; -use Suicore\Walrus\Responses\StoreBlobResponse; +use Suicore\Walrus\Types\QuiltElementFile; +use Suicore\Walrus\Types\QuiltElementFileMetadata; +use Suicore\Walrus\Types\StoreBlobOrQuiltOptions; +use Suicore\Walrus\Types\StoreBlobResponse; +use Suicore\Walrus\Types\StoreQuiltResponse; class WalrusClient { @@ -45,15 +48,18 @@ public function __construct( * Store a blob using the publisher API. * * @param string $dataOrPath The data to store or a file path. - * @param StoreBlobOptions $options Options for the store request. + * @param StoreBlobOrQuiltOptions $options Options for the store request. * @param bool $isFile Whether $dataOrPath is a file path. * * @return StoreBlobResponse * * @throws \Exception if the request fails. */ - public function storeBlob(string $dataOrPath, StoreBlobOptions $options, bool $isFile = false): StoreBlobResponse - { + public function storeBlob( + string $dataOrPath, + StoreBlobOrQuiltOptions $options, + bool $isFile = false, + ): StoreBlobResponse { $query = []; $query['epochs'] = $options->getEpochs(); if ($options->getSendObjectTo() !== '') { @@ -80,6 +86,71 @@ public function storeBlob(string $dataOrPath, StoreBlobOptions $options, bool $i } } + /** + * Store a Walrus quilt (multiple blobs in one request). + * + * @param array $files key = identifier, value = path or stream + * @param StoreBlobOrQuiltOptions $options epochs / send_object_to / deletable + * @param array $metadata optional Walrus-native metadata + * e.g. [ + * ['identifier'=>'logo.png','tags'=>['type'=>'logo']] + * ] + * + * @throws \Exception on HTTP/Guzzle errors + */ + public function storeQuilt( + array $files, + StoreBlobOrQuiltOptions $options, + array $metadata = [] + ): StoreQuiltResponse { + $query = ['epochs' => $options->getEpochs()]; + if ($options->getSendObjectTo() !== '') { + $query['send_object_to'] = $options->getSendObjectTo(); + } + if ($options->isDeletable()) { + $query['deletable'] = 'true'; + } + + $uri = '/v1/quilts' . (!empty($query) ? '?' . http_build_query($query) : ''); + + $multipart = []; + + foreach ($files as $file) { + $identifier = $file->getIdentifier(); + $source = $file->getSource(); + + $multipart[] = [ + 'name' => $identifier, + 'contents' => is_resource($source) ? $source : fopen($source, 'rb'), + 'filename' => is_string($source) ? basename($source) : $identifier, + ]; + } + + + if ($metadata !== []) { + $multipart[] = [ + 'name' => '_metadata', + 'contents' => json_encode( + array_map( + fn(QuiltElementFileMetadata $meta) => $meta->toArray(), + $metadata + ), + JSON_THROW_ON_ERROR + ), + ]; + } + + try { + $response = $this->publisherClient->request('PUT', $uri, [ + 'multipart' => $multipart, + ]); + + return StoreQuiltResponse::fromJson($response->getBody()->getContents()); + } catch (GuzzleException $e) { + throw new \Exception("Guzzle error: " . $e->getMessage(), (int)$e->getCode(), $e); + } + } + public function getBlob(string $blobId): string { $uri = "/v1/blobs/{$blobId}"; @@ -90,4 +161,19 @@ public function getBlob(string $blobId): string throw new \Exception("Guzzle error: " . $e->getMessage(), $e->getCode(), $e); } } + + public function getQuilt(string $quiltId, string $name = null): string + { + if (is_null($name)) { + $uri = "/v1/blobs/by-quilt-patch-id/{$quiltId}"; + } else { + $uri = "/v1/blobs/by-quilt-id/{$quiltId}/{$name}"; + } + try { + $response = $this->aggregatorClient->request('GET', $uri); + return $response->getBody()->getContents(); + } catch (GuzzleException $e) { + throw new \Exception("Guzzle error: " . $e->getMessage(), $e->getCode(), $e); + } + } } diff --git a/tests/WalrusClientE2ETest.php b/tests/WalrusClientBlobsE2ETest.php similarity index 93% rename from tests/WalrusClientE2ETest.php rename to tests/WalrusClientBlobsE2ETest.php index 376e317..ff7af76 100644 --- a/tests/WalrusClientE2ETest.php +++ b/tests/WalrusClientBlobsE2ETest.php @@ -3,10 +3,10 @@ namespace Suicore\Walrus\Tests; use PHPUnit\Framework\TestCase; -use Suicore\Walrus\Responses\StoreBlobOptions; +use Suicore\Walrus\Types\StoreBlobOrQuiltOptions; use Suicore\Walrus\WalrusClient; -final class WalrusClientE2ETest extends TestCase +final class WalrusClientBlobsE2ETest extends TestCase { private string $publisherUrl = 'https://publisher.walrus-testnet.walrus.space'; private string $aggregatorUrl = 'https://aggregator.walrus-testnet.walrus.space'; @@ -22,7 +22,7 @@ public function testStoreBlobReturnsNewlyCreated(): void { $client = new WalrusClient($this->publisherUrl, $this->aggregatorUrl); $data = "end-to-end test blob " . uniqid(); - $options = new StoreBlobOptions(2, '', false); + $options = new StoreBlobOrQuiltOptions(2, '', false); $storeResponse = $client->storeBlob($data, $options, false); // Check that we got a newly created response. @@ -46,7 +46,7 @@ public function testStoreBlobWithFileUpload(): void if (!file_exists($filePath)) { $this->markTestSkipped("Test file not found: {$filePath}"); } - $options = new StoreBlobOptions(2, '', false); + $options = new StoreBlobOrQuiltOptions(2, '', false); $storeResponse = $client->storeBlob($filePath, $options, true); $this->assertTrue($storeResponse->isAlreadyCertified(), 'Expected response to be alreadyCertified.'); @@ -63,7 +63,7 @@ public function testStoreBlobWithMultipleEpochs(): void { $client = new WalrusClient($this->publisherUrl, $this->aggregatorUrl); $data = "Test data with multiple epochs: " . uniqid(); - $options = new StoreBlobOptions(5, '', false); + $options = new StoreBlobOrQuiltOptions(5, '', false); $storeResponse = $client->storeBlob($data, $options, false); $this->assertTrue($storeResponse->isNewlyCreated(), 'Expected response to be newlyCreated.'); @@ -88,7 +88,7 @@ public function testStoreBlobWithSendObjectTo(): void $client = new WalrusClient($this->publisherUrl, $this->aggregatorUrl); $data = "Test data with sendObjectTo: " . uniqid(); $sendObjectTo = "0xed20e3646700113065bb4a9c60565b671a3545800979cc9e82fcf3fabecb6e41"; - $options = new StoreBlobOptions(2, $sendObjectTo, false); + $options = new StoreBlobOrQuiltOptions(2, $sendObjectTo, false); $storeResponse = $client->storeBlob($data, $options, false); $this->assertTrue($storeResponse->isNewlyCreated(), 'Expected response to be newlyCreated.'); @@ -113,7 +113,7 @@ public function testStoreBlobWithFileMultipleEpochsAndSendObjectTo(): void $this->markTestSkipped("Test file not found: {$filePath}"); } $sendObjectTo = "0xed20e3646700113065bb4a9c60565b671a3545800979cc9e82fcf3fabecb6e41"; - $options = new StoreBlobOptions(10, $sendObjectTo, false); + $options = new StoreBlobOrQuiltOptions(10, $sendObjectTo, false); $storeResponse = $client->storeBlob($filePath, $options, true); $this->assertTrue($storeResponse->isAlreadyCertified(), 'Expected response to be alreadyCertified.'); @@ -138,7 +138,7 @@ public function testStoreBlobWithTemporaryFile(): void try { $this->assertFileExists($filePath, "Temporary test file was not created."); - $options = new StoreBlobOptions(2, '', false); + $options = new StoreBlobOrQuiltOptions(2, '', false); $storeResponse = $client->storeBlob($filePath, $options, true); $this->assertTrue( diff --git a/tests/WalrusClientQuiltsE2ETest.php b/tests/WalrusClientQuiltsE2ETest.php new file mode 100644 index 0000000..d5475e2 --- /dev/null +++ b/tests/WalrusClientQuiltsE2ETest.php @@ -0,0 +1,56 @@ +markTestSkipped('Integration tests are disabled. Set RUN_INTEGRATION_TESTS=1 to run them.'); + } + } + + public function testStoreQuiltReturnsNewlyCreated(): void + { + $client = new WalrusClient($this->publisherUrl, $this->aggregatorUrl); + $options = new StoreBlobOrQuiltOptions(2, '', false); + $files = [ + new QuiltElementFile('wal1.jpg', __DIR__ . '/walrus.jpg'), + new QuiltElementFile('wal2.jpg', fopen(__DIR__ . '/walrus.jpg', 'rb')), // resource + ]; + $metadata = [ + new QuiltElementFileMetadata('wal1.jpg', ['creator' => 'walrus', 'version' => '1.0']), + new QuiltElementFileMetadata('wal2.jpg', ['type' => 'logo', 'format' => 'png']), + ]; + + $storeResponse = $client->storeQuilt($files, $options, $metadata); + + // Check that we got a newly created response. + $this->assertTrue($storeResponse->isAlreadyCertified(), 'Expected response to be alreadyCertified.'); + $blobId = $storeResponse->getAlreadyCertified()->getBlobId(); + $this->assertNotEmpty($blobId, 'Expected non-empty blobId.'); + $endEpoch = $storeResponse->getAlreadyCertified()->getEndEpoch(); + $this->assertNotEmpty($endEpoch, 'Expected end epoch to be set.'); + $elements = $storeResponse->getStoredQuiltBlobs()->getQuiltFiles(); + $this->assertCount(2, $elements, 'Expected 2 quilts to be stored.'); + $patchIds = array_map(fn($q) => $q->getQuiltPatchId(), $elements); + + sleep(2); + foreach ($patchIds as $patchId) { + $retrievedContent = $client->getQuilt($patchId); + $this->assertNotEmpty($retrievedContent, 'Retrieved content for patch should not be empty.'); + $retrievedContentWithName = $client->getQuilt($blobId, 'wal1.jpg'); + $this->assertNotEmpty($retrievedContentWithName, 'Retrieved content with name should not be empty.'); + } + } +} diff --git a/tests/WalrusClientTest.php b/tests/WalrusClientTest.php index 59de737..06ef8b9 100644 --- a/tests/WalrusClientTest.php +++ b/tests/WalrusClientTest.php @@ -7,7 +7,7 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; -use Suicore\Walrus\Responses\StoreBlobOptions; +use Suicore\Walrus\Types\StoreBlobOrQuiltOptions; use Suicore\Walrus\WalrusClient; final class WalrusClientTest extends TestCase @@ -69,8 +69,8 @@ public function testStoreBlobReturnsNewlyCreated() aggregatorClient: $aggregatorClient ); - $storeBlobOptions = new StoreBlobOptions(2, '', false); - $result = $client->storeBlob("some string", $storeBlobOptions); + $StoreBlobOrQuiltOptions = new StoreBlobOrQuiltOptions(2, '', false); + $result = $client->storeBlob("some string", $StoreBlobOrQuiltOptions); // Use the strongly-typed methods to check the response. $this->assertTrue($result->isNewlyCreated(), 'Expected response to be newly created.');