Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement BlobHandler service

+731
+352
src/Services/BlobHandler.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Services; 4 + 5 + use Illuminate\Contracts\Filesystem\Filesystem; 6 + use Illuminate\Http\UploadedFile; 7 + use Illuminate\Support\Facades\Storage; 8 + use SocialDept\Schema\Data\BlobReference; 9 + use SocialDept\Schema\Exceptions\RecordValidationException; 10 + 11 + class BlobHandler 12 + { 13 + /** 14 + * Storage disk name. 15 + */ 16 + protected string $disk; 17 + 18 + /** 19 + * Base path for blob storage. 20 + */ 21 + protected string $basePath; 22 + 23 + /** 24 + * Create a new BlobHandler. 25 + */ 26 + public function __construct(?string $disk = null, string $basePath = 'blobs') 27 + { 28 + $this->disk = $disk ?? config('filesystems.default', 'local'); 29 + $this->basePath = $basePath; 30 + } 31 + 32 + /** 33 + * Store a blob from an uploaded file. 34 + */ 35 + public function store(UploadedFile $file, ?array $constraints = null): BlobReference 36 + { 37 + // Validate constraints if provided 38 + if ($constraints !== null) { 39 + $this->validateConstraints($file, $constraints); 40 + } 41 + 42 + // Generate CID-like identifier (in production, this would be actual CID) 43 + $cid = $this->generateCid($file); 44 + 45 + // Store the file 46 + $path = $this->getPath($cid); 47 + $this->getStorage()->put($path, $file->get()); 48 + 49 + return new BlobReference( 50 + ref: $cid, 51 + mimeType: $file->getMimeType() ?? 'application/octet-stream', 52 + size: $file->getSize() 53 + ); 54 + } 55 + 56 + /** 57 + * Store a blob from string content. 58 + */ 59 + public function storeFromString(string $content, string $mimeType, ?array $constraints = null): BlobReference 60 + { 61 + $size = strlen($content); 62 + 63 + // Validate constraints if provided 64 + if ($constraints !== null) { 65 + $this->validateStringConstraints($content, $mimeType, $size, $constraints); 66 + } 67 + 68 + // Generate CID 69 + $cid = $this->generateCidFromContent($content); 70 + 71 + // Store the content 72 + $path = $this->getPath($cid); 73 + $this->getStorage()->put($path, $content); 74 + 75 + return new BlobReference( 76 + ref: $cid, 77 + mimeType: $mimeType, 78 + size: $size 79 + ); 80 + } 81 + 82 + /** 83 + * Retrieve blob content. 84 + */ 85 + public function get(string $cid): ?string 86 + { 87 + $path = $this->getPath($cid); 88 + 89 + if (! $this->getStorage()->exists($path)) { 90 + return null; 91 + } 92 + 93 + return $this->getStorage()->get($path); 94 + } 95 + 96 + /** 97 + * Check if blob exists. 98 + */ 99 + public function exists(string $cid): bool 100 + { 101 + return $this->getStorage()->exists($this->getPath($cid)); 102 + } 103 + 104 + /** 105 + * Delete a blob. 106 + */ 107 + public function delete(string $cid): bool 108 + { 109 + $path = $this->getPath($cid); 110 + 111 + if (! $this->getStorage()->exists($path)) { 112 + return false; 113 + } 114 + 115 + return $this->getStorage()->delete($path); 116 + } 117 + 118 + /** 119 + * Get blob size. 120 + */ 121 + public function size(string $cid): ?int 122 + { 123 + $path = $this->getPath($cid); 124 + 125 + if (! $this->getStorage()->exists($path)) { 126 + return null; 127 + } 128 + 129 + return $this->getStorage()->size($path); 130 + } 131 + 132 + /** 133 + * Validate blob against constraints. 134 + */ 135 + public function validate(BlobReference $blob, array $constraints): void 136 + { 137 + // Validate MIME type 138 + if (isset($constraints['accept'])) { 139 + $accepted = (array) $constraints['accept']; 140 + $matches = false; 141 + 142 + foreach ($accepted as $pattern) { 143 + if ($blob->matchesMimeType($pattern)) { 144 + $matches = true; 145 + 146 + break; 147 + } 148 + } 149 + 150 + if (! $matches) { 151 + throw RecordValidationException::invalidValue( 152 + 'blob', 153 + "MIME type '{$blob->mimeType}' not accepted. Allowed: ".implode(', ', $accepted) 154 + ); 155 + } 156 + } 157 + 158 + // Validate size constraints 159 + if (isset($constraints['maxSize']) && $blob->size > $constraints['maxSize']) { 160 + throw RecordValidationException::invalidValue( 161 + 'blob', 162 + "Blob size {$blob->size} exceeds maximum {$constraints['maxSize']}" 163 + ); 164 + } 165 + 166 + if (isset($constraints['minSize']) && $blob->size < $constraints['minSize']) { 167 + throw RecordValidationException::invalidValue( 168 + 'blob', 169 + "Blob size {$blob->size} is less than minimum {$constraints['minSize']}" 170 + ); 171 + } 172 + } 173 + 174 + /** 175 + * Validate file against constraints. 176 + */ 177 + protected function validateConstraints(UploadedFile $file, array $constraints): void 178 + { 179 + $mimeType = $file->getMimeType() ?? 'application/octet-stream'; 180 + $size = $file->getSize(); 181 + 182 + // Validate MIME type 183 + if (isset($constraints['accept'])) { 184 + $accepted = (array) $constraints['accept']; 185 + $matches = false; 186 + 187 + foreach ($accepted as $pattern) { 188 + if ($this->matchesMimeType($mimeType, $pattern)) { 189 + $matches = true; 190 + 191 + break; 192 + } 193 + } 194 + 195 + if (! $matches) { 196 + throw RecordValidationException::invalidValue( 197 + 'file', 198 + "MIME type '{$mimeType}' not accepted. Allowed: ".implode(', ', $accepted) 199 + ); 200 + } 201 + } 202 + 203 + // Validate size 204 + if (isset($constraints['maxSize']) && $size > $constraints['maxSize']) { 205 + throw RecordValidationException::invalidValue( 206 + 'file', 207 + "File size {$size} exceeds maximum {$constraints['maxSize']}" 208 + ); 209 + } 210 + 211 + if (isset($constraints['minSize']) && $size < $constraints['minSize']) { 212 + throw RecordValidationException::invalidValue( 213 + 'file', 214 + "File size {$size} is less than minimum {$constraints['minSize']}" 215 + ); 216 + } 217 + } 218 + 219 + /** 220 + * Validate string content against constraints. 221 + */ 222 + protected function validateStringConstraints(string $content, string $mimeType, int $size, array $constraints): void 223 + { 224 + // Validate MIME type 225 + if (isset($constraints['accept'])) { 226 + $accepted = (array) $constraints['accept']; 227 + $matches = false; 228 + 229 + foreach ($accepted as $pattern) { 230 + if ($this->matchesMimeType($mimeType, $pattern)) { 231 + $matches = true; 232 + 233 + break; 234 + } 235 + } 236 + 237 + if (! $matches) { 238 + throw RecordValidationException::invalidValue( 239 + 'content', 240 + "MIME type '{$mimeType}' not accepted. Allowed: ".implode(', ', $accepted) 241 + ); 242 + } 243 + } 244 + 245 + // Validate size 246 + if (isset($constraints['maxSize']) && $size > $constraints['maxSize']) { 247 + throw RecordValidationException::invalidValue( 248 + 'content', 249 + "Content size {$size} exceeds maximum {$constraints['maxSize']}" 250 + ); 251 + } 252 + 253 + if (isset($constraints['minSize']) && $size < $constraints['minSize']) { 254 + throw RecordValidationException::invalidValue( 255 + 'content', 256 + "Content size {$size} is less than minimum {$constraints['minSize']}" 257 + ); 258 + } 259 + } 260 + 261 + /** 262 + * Check if MIME type matches pattern. 263 + */ 264 + protected function matchesMimeType(string $mimeType, string $pattern): bool 265 + { 266 + if (str_contains($pattern, '*')) { 267 + $regex = '/^'.str_replace('\\*', '.*', preg_quote($pattern, '/')).'$/'; 268 + 269 + return (bool) preg_match($regex, $mimeType); 270 + } 271 + 272 + return $mimeType === $pattern; 273 + } 274 + 275 + /** 276 + * Generate a CID-like identifier from file. 277 + */ 278 + protected function generateCid(UploadedFile $file): string 279 + { 280 + // In production, this would generate an actual CID 281 + // For now, use a hash-based approach 282 + $hash = hash('sha256', $file->get()); 283 + 284 + return 'bafyrei'.substr($hash, 0, 52); 285 + } 286 + 287 + /** 288 + * Generate a CID-like identifier from content. 289 + */ 290 + protected function generateCidFromContent(string $content): string 291 + { 292 + $hash = hash('sha256', $content); 293 + 294 + return 'bafyrei'.substr($hash, 0, 52); 295 + } 296 + 297 + /** 298 + * Get storage path for CID. 299 + */ 300 + protected function getPath(string $cid): string 301 + { 302 + // Use first 2 chars for directory partitioning 303 + $prefix = substr($cid, 0, 2); 304 + $middle = substr($cid, 2, 2); 305 + 306 + return "{$this->basePath}/{$prefix}/{$middle}/{$cid}"; 307 + } 308 + 309 + /** 310 + * Get the storage instance. 311 + */ 312 + protected function getStorage(): Filesystem 313 + { 314 + return Storage::disk($this->disk); 315 + } 316 + 317 + /** 318 + * Set the storage disk. 319 + */ 320 + public function setDisk(string $disk): self 321 + { 322 + $this->disk = $disk; 323 + 324 + return $this; 325 + } 326 + 327 + /** 328 + * Set the base path. 329 + */ 330 + public function setBasePath(string $basePath): self 331 + { 332 + $this->basePath = $basePath; 333 + 334 + return $this; 335 + } 336 + 337 + /** 338 + * Get the current disk name. 339 + */ 340 + public function getDisk(): string 341 + { 342 + return $this->disk; 343 + } 344 + 345 + /** 346 + * Get the current base path. 347 + */ 348 + public function getBasePath(): string 349 + { 350 + return $this->basePath; 351 + } 352 + }
+379
tests/Unit/Services/BlobHandlerTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Services; 4 + 5 + use Illuminate\Http\UploadedFile; 6 + use Illuminate\Support\Facades\Storage; 7 + use Orchestra\Testbench\TestCase; 8 + use SocialDept\Schema\Data\BlobReference; 9 + use SocialDept\Schema\Exceptions\RecordValidationException; 10 + use SocialDept\Schema\Services\BlobHandler; 11 + 12 + class BlobHandlerTest extends TestCase 13 + { 14 + protected BlobHandler $handler; 15 + 16 + protected function setUp(): void 17 + { 18 + parent::setUp(); 19 + 20 + Storage::fake('local'); 21 + $this->handler = new BlobHandler('local'); 22 + } 23 + 24 + public function test_it_stores_uploaded_file(): void 25 + { 26 + $file = UploadedFile::fake()->image('photo.jpg', 100, 100); 27 + 28 + $blob = $this->handler->store($file); 29 + 30 + $this->assertInstanceOf(BlobReference::class, $blob); 31 + $this->assertStringStartsWith('bafyrei', $blob->ref); 32 + $this->assertEquals('image/jpeg', $blob->mimeType); 33 + $this->assertGreaterThan(0, $blob->size); 34 + $this->assertTrue($this->handler->exists($blob->ref)); 35 + } 36 + 37 + public function test_it_stores_file_with_valid_constraints(): void 38 + { 39 + $file = UploadedFile::fake()->image('photo.jpg', 100, 100); 40 + 41 + $constraints = [ 42 + 'accept' => ['image/*'], 43 + 'maxSize' => 1024 * 1024 * 10, // 10MB 44 + ]; 45 + 46 + $blob = $this->handler->store($file, $constraints); 47 + 48 + $this->assertInstanceOf(BlobReference::class, $blob); 49 + $this->assertTrue($this->handler->exists($blob->ref)); 50 + } 51 + 52 + public function test_it_rejects_file_with_invalid_mime_type(): void 53 + { 54 + $this->expectException(RecordValidationException::class); 55 + 56 + $file = UploadedFile::fake()->image('photo.jpg'); 57 + 58 + $constraints = [ 59 + 'accept' => ['video/*'], 60 + ]; 61 + 62 + $this->handler->store($file, $constraints); 63 + } 64 + 65 + public function test_it_rejects_file_exceeding_max_size(): void 66 + { 67 + $this->expectException(RecordValidationException::class); 68 + 69 + $file = UploadedFile::fake()->create('large.pdf', 2000); // 2MB 70 + 71 + $constraints = [ 72 + 'maxSize' => 1024, // 1KB 73 + ]; 74 + 75 + $this->handler->store($file, $constraints); 76 + } 77 + 78 + public function test_it_rejects_file_below_min_size(): void 79 + { 80 + $this->expectException(RecordValidationException::class); 81 + 82 + $file = UploadedFile::fake()->create('small.txt', 1); 83 + 84 + $constraints = [ 85 + 'minSize' => 1024 * 1024, // 1MB 86 + ]; 87 + 88 + $this->handler->store($file, $constraints); 89 + } 90 + 91 + public function test_it_stores_from_string(): void 92 + { 93 + $content = 'Hello, world!'; 94 + 95 + $blob = $this->handler->storeFromString($content, 'text/plain'); 96 + 97 + $this->assertInstanceOf(BlobReference::class, $blob); 98 + $this->assertEquals('text/plain', $blob->mimeType); 99 + $this->assertEquals(strlen($content), $blob->size); 100 + $this->assertTrue($this->handler->exists($blob->ref)); 101 + } 102 + 103 + public function test_it_stores_string_with_valid_constraints(): void 104 + { 105 + $content = 'Test content'; 106 + 107 + $constraints = [ 108 + 'accept' => ['text/*'], 109 + 'maxSize' => 1024, 110 + ]; 111 + 112 + $blob = $this->handler->storeFromString($content, 'text/plain', $constraints); 113 + 114 + $this->assertInstanceOf(BlobReference::class, $blob); 115 + } 116 + 117 + public function test_it_rejects_string_with_invalid_mime_type(): void 118 + { 119 + $this->expectException(RecordValidationException::class); 120 + 121 + $constraints = [ 122 + 'accept' => ['image/*'], 123 + ]; 124 + 125 + $this->handler->storeFromString('content', 'text/plain', $constraints); 126 + } 127 + 128 + public function test_it_retrieves_blob_content(): void 129 + { 130 + $content = 'Test content'; 131 + $blob = $this->handler->storeFromString($content, 'text/plain'); 132 + 133 + $retrieved = $this->handler->get($blob->ref); 134 + 135 + $this->assertEquals($content, $retrieved); 136 + } 137 + 138 + public function test_it_returns_null_for_nonexistent_blob(): void 139 + { 140 + $content = $this->handler->get('nonexistent-cid'); 141 + 142 + $this->assertNull($content); 143 + } 144 + 145 + public function test_it_checks_blob_existence(): void 146 + { 147 + $blob = $this->handler->storeFromString('content', 'text/plain'); 148 + 149 + $this->assertTrue($this->handler->exists($blob->ref)); 150 + $this->assertFalse($this->handler->exists('nonexistent-cid')); 151 + } 152 + 153 + public function test_it_deletes_blob(): void 154 + { 155 + $blob = $this->handler->storeFromString('content', 'text/plain'); 156 + 157 + $this->assertTrue($this->handler->exists($blob->ref)); 158 + 159 + $result = $this->handler->delete($blob->ref); 160 + 161 + $this->assertTrue($result); 162 + $this->assertFalse($this->handler->exists($blob->ref)); 163 + } 164 + 165 + public function test_it_returns_false_when_deleting_nonexistent_blob(): void 166 + { 167 + $result = $this->handler->delete('nonexistent-cid'); 168 + 169 + $this->assertFalse($result); 170 + } 171 + 172 + public function test_it_gets_blob_size(): void 173 + { 174 + $content = 'Test content'; 175 + $blob = $this->handler->storeFromString($content, 'text/plain'); 176 + 177 + $size = $this->handler->size($blob->ref); 178 + 179 + $this->assertEquals(strlen($content), $size); 180 + } 181 + 182 + public function test_it_returns_null_size_for_nonexistent_blob(): void 183 + { 184 + $size = $this->handler->size('nonexistent-cid'); 185 + 186 + $this->assertNull($size); 187 + } 188 + 189 + public function test_it_validates_blob_reference(): void 190 + { 191 + $blob = new BlobReference('cid', 'image/png', 1024); 192 + 193 + $constraints = [ 194 + 'accept' => ['image/*'], 195 + 'maxSize' => 2048, 196 + ]; 197 + 198 + $this->handler->validate($blob, $constraints); 199 + 200 + $this->assertTrue(true); // No exception thrown 201 + } 202 + 203 + public function test_it_rejects_blob_with_invalid_mime_type(): void 204 + { 205 + $this->expectException(RecordValidationException::class); 206 + 207 + $blob = new BlobReference('cid', 'text/plain', 1024); 208 + 209 + $constraints = [ 210 + 'accept' => ['image/*'], 211 + ]; 212 + 213 + $this->handler->validate($blob, $constraints); 214 + } 215 + 216 + public function test_it_rejects_blob_exceeding_max_size(): void 217 + { 218 + $this->expectException(RecordValidationException::class); 219 + 220 + $blob = new BlobReference('cid', 'image/png', 2048); 221 + 222 + $constraints = [ 223 + 'maxSize' => 1024, 224 + ]; 225 + 226 + $this->handler->validate($blob, $constraints); 227 + } 228 + 229 + public function test_it_rejects_blob_below_min_size(): void 230 + { 231 + $this->expectException(RecordValidationException::class); 232 + 233 + $blob = new BlobReference('cid', 'image/png', 512); 234 + 235 + $constraints = [ 236 + 'minSize' => 1024, 237 + ]; 238 + 239 + $this->handler->validate($blob, $constraints); 240 + } 241 + 242 + public function test_it_validates_multiple_mime_types(): void 243 + { 244 + $blob = new BlobReference('cid', 'image/png', 1024); 245 + 246 + $constraints = [ 247 + 'accept' => ['image/jpeg', 'image/png', 'image/webp'], 248 + ]; 249 + 250 + $this->handler->validate($blob, $constraints); 251 + 252 + $this->assertTrue(true); 253 + } 254 + 255 + public function test_it_validates_wildcard_mime_types(): void 256 + { 257 + $imageBlob = new BlobReference('cid1', 'image/png', 1024); 258 + $videoBlob = new BlobReference('cid2', 'video/mp4', 1024); 259 + 260 + $imageConstraints = ['accept' => ['image/*']]; 261 + $videoConstraints = ['accept' => ['video/*']]; 262 + 263 + $this->handler->validate($imageBlob, $imageConstraints); 264 + $this->handler->validate($videoBlob, $videoConstraints); 265 + 266 + $this->assertTrue(true); 267 + } 268 + 269 + public function test_it_generates_consistent_cids_for_same_content(): void 270 + { 271 + $content = 'Test content'; 272 + 273 + $blob1 = $this->handler->storeFromString($content, 'text/plain'); 274 + $blob2 = $this->handler->storeFromString($content, 'text/plain'); 275 + 276 + $this->assertEquals($blob1->ref, $blob2->ref); 277 + } 278 + 279 + public function test_it_generates_different_cids_for_different_content(): void 280 + { 281 + $blob1 = $this->handler->storeFromString('Content 1', 'text/plain'); 282 + $blob2 = $this->handler->storeFromString('Content 2', 'text/plain'); 283 + 284 + $this->assertNotEquals($blob1->ref, $blob2->ref); 285 + } 286 + 287 + public function test_it_uses_directory_partitioning_for_storage(): void 288 + { 289 + $blob = $this->handler->storeFromString('content', 'text/plain'); 290 + 291 + // CID should be used to create partitioned path 292 + $prefix = substr($blob->ref, 0, 2); 293 + $middle = substr($blob->ref, 2, 2); 294 + $expectedPath = "blobs/{$prefix}/{$middle}/{$blob->ref}"; 295 + 296 + Storage::disk('local')->assertExists($expectedPath); 297 + } 298 + 299 + public function test_it_can_change_storage_disk(): void 300 + { 301 + Storage::fake('custom'); 302 + 303 + $this->handler->setDisk('custom'); 304 + 305 + $blob = $this->handler->storeFromString('content', 'text/plain'); 306 + 307 + $prefix = substr($blob->ref, 0, 2); 308 + $middle = substr($blob->ref, 2, 2); 309 + $expectedPath = "blobs/{$prefix}/{$middle}/{$blob->ref}"; 310 + 311 + Storage::disk('custom')->assertExists($expectedPath); 312 + } 313 + 314 + public function test_it_can_change_base_path(): void 315 + { 316 + $this->handler->setBasePath('custom-path'); 317 + 318 + $blob = $this->handler->storeFromString('content', 'text/plain'); 319 + 320 + $prefix = substr($blob->ref, 0, 2); 321 + $middle = substr($blob->ref, 2, 2); 322 + $expectedPath = "custom-path/{$prefix}/{$middle}/{$blob->ref}"; 323 + 324 + Storage::disk('local')->assertExists($expectedPath); 325 + } 326 + 327 + public function test_it_gets_disk_name(): void 328 + { 329 + $this->assertEquals('local', $this->handler->getDisk()); 330 + 331 + $this->handler->setDisk('custom'); 332 + 333 + $this->assertEquals('custom', $this->handler->getDisk()); 334 + } 335 + 336 + public function test_it_gets_base_path(): void 337 + { 338 + $this->assertEquals('blobs', $this->handler->getBasePath()); 339 + 340 + $this->handler->setBasePath('custom-path'); 341 + 342 + $this->assertEquals('custom-path', $this->handler->getBasePath()); 343 + } 344 + 345 + public function test_it_handles_file_without_mime_type(): void 346 + { 347 + $file = UploadedFile::fake()->create('unknown.bin'); 348 + 349 + $blob = $this->handler->store($file); 350 + 351 + // Should default to application/octet-stream if mime type is null 352 + $this->assertNotNull($blob->mimeType); 353 + } 354 + 355 + public function test_it_accepts_constraints_as_array(): void 356 + { 357 + $blob = new BlobReference('cid', 'image/png', 1024); 358 + 359 + $constraints = [ 360 + 'accept' => ['image/png', 'image/jpeg'], // Array of types 361 + 'maxSize' => 2048, 362 + ]; 363 + 364 + $this->handler->validate($blob, $constraints); 365 + 366 + $this->assertTrue(true); 367 + } 368 + 369 + public function test_it_stores_binary_content(): void 370 + { 371 + $binaryContent = random_bytes(1024); 372 + 373 + $blob = $this->handler->storeFromString($binaryContent, 'application/octet-stream'); 374 + 375 + $retrieved = $this->handler->get($blob->ref); 376 + 377 + $this->assertEquals($binaryContent, $retrieved); 378 + } 379 + }