diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache deleted file mode 100644 index bb86369..0000000 --- a/.php-cs-fixer.cache +++ /dev/null @@ -1 +0,0 @@ -{"php":"8.2.19","version":"3.75.0","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"new_with_parentheses":true,"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"visibility_required":true,"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true},"hashes":{"src\/Exception\/RuleException.php":"0f48d4b0d809e8eab61416c2db16a7d8","src\/FileInfo.php":"92199a788c035eee744222d874975264","src\/UploadProcessing.php":"d071d78696edfb09f8087fba2d2fe0ce","src\/RuleInterface.php":"510ec2377e44bc9cd60734290b78a182","src\/Rule\/Extension.php":"91951f96a6b9a0880fb555c0a16cec54","src\/Rule\/MediaType.php":"2a1e550418cdc2041a8cdbace9ff6375","src\/Rule\/Size.php":"80e2e7c2888a6e79b6cf71ab8e6c80e6","tests\/FileInfoTest.php":"75ad22380821d20a861e036706f70333","tests\/UploadProcessingTest.php":"6fda40150d71be7fe67c61d154f74233","tests\/Rule\/SizeTest.php":"67f50eca6d49ff1969f14adee342f6f9","tests\/Rule\/ExtensionTest.php":"5f5adc4bbe15b6c2ce19320df3c377c9","tests\/Rule\/MediaTypeTest.php":"ac581940014eb72cb2f6bd75ee28104f"}} \ No newline at end of file diff --git a/README.md b/README.md index 2fa1080..32dacdf 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,49 @@ $rule->allow('image/*') // All image types ->allow('application/pdf') // PDF files ->allow('*/vnd.openxmlformats-officedocument.*'); // Office documents ``` +### Event System + +The library provides PSR-14 compatible events: + +#### Available Events: +- **`BeforeValidationEvent`** - Dispatched before validation starts +- **`BeforeUploadEvent`** - Dispatched after validation, before file upload +- **`AfterUploadEvent`** - Dispatched after successful file upload +- **`UploadErrorEvent`** - Dispatched when any error occurs + +#### Usage Example: +```php +use Enjoys\Upload\Event\AfterUploadEvent; +use Psr\EventDispatcher\EventDispatcherInterface; + +/** @var EventDispatcherInterface $dispatcher */ + +// Initialize with event dispatcher +$upload = new UploadProcessing($uploadedFile, $filesystem, $dispatcher); + +// Add event listener +$dispatcher->addListener( + AfterUploadEvent::class, + function (AfterUploadEvent $event) { + logger()->info("File uploaded to: " . $event->uploadProcessing->getTargetPath()); + } +); + +$upload->upload(); +``` + +#### Event Propagation: +All events implement `StoppableEventInterface`. To stop further processing: +```php +$dispatcher->addListener( + BeforeUploadEvent::class, + function (BeforeUploadEvent $event) { + if ($shouldStop) { + $event->stopPropagation(); // Stops other listeners + } + } +); +``` ### API Reference diff --git a/composer.json b/composer.json index f841c98..c1f8409 100644 --- a/composer.json +++ b/composer.json @@ -18,13 +18,15 @@ "require": { "php": "~8.2.0 | ~8.3.0 | ~8.4.0", "psr/http-message": "^1.0 | ^2.0", - "league/flysystem": "^3.30.0" + "league/flysystem": "^3.30.0", + "psr/event-dispatcher": "^1.0" }, "require-dev": { "vimeo/psalm": "^6.12.0", - "phpunit/phpunit": "^11.5.24", + "phpunit/phpunit": "^11.5.25", "infection/infection": "^0.29.14", "league/flysystem-memory": "^3.29.0", + "symfony/var-dumper": "^6.0 | ^7.0", "guzzlehttp/psr7": "^2.7.1", "friendsofphp/php-cs-fixer": "~v3.75.0" }, diff --git a/src/Event/AbstractUploadEvent.php b/src/Event/AbstractUploadEvent.php new file mode 100644 index 0000000..d42c08a --- /dev/null +++ b/src/Event/AbstractUploadEvent.php @@ -0,0 +1,47 @@ +propagationStopped; + } + + /** + * Stops the event propagation + * + * When called, prevents the event from being passed to additional listeners. + * This is useful when a listener has handled the event and wants to prevent + * other listeners from processing it further. + */ + public function stopPropagation(): void + { + $this->propagationStopped = true; + } +} diff --git a/src/Event/AfterUploadEvent.php b/src/Event/AfterUploadEvent.php new file mode 100644 index 0000000..67c76cc --- /dev/null +++ b/src/Event/AfterUploadEvent.php @@ -0,0 +1,29 @@ +fileInfo = new FileInfo($uploadedFile); } @@ -42,16 +51,24 @@ public function __construct( * * @param string $targetPath The target directory path (defaults to '/') * @throws FilesystemException If there's an error during file system operations + * @throws RuleException Thrown when validation fails + * @throws Throwable */ public function upload(string $targetPath = '/'): void { + $this->dispatcher?->dispatch(new BeforeValidationEvent($this)); $this->validate(); + $this->dispatcher?->dispatch(new BeforeUploadEvent($this)); $this->targetPath = rtrim($targetPath, '/') . '/' . $this->fileInfo->getFilename(); $resource = $this->uploadedFile->getStream()->detach(); try { $this->filesystem->writeStream($this->targetPath, $resource); + $this->dispatcher?->dispatch(new AfterUploadEvent($this)); + } catch (Throwable $e) { + $this->dispatcher?->dispatch(new UploadErrorEvent($this, $e)); + throw $e; } finally { if (is_resource($resource)) { fclose($resource); @@ -61,6 +78,8 @@ public function upload(string $targetPath = '/'): void /** * Validates the uploaded file against all registered rules + * + * @throws RuleException Thrown when validation fails */ private function validate(): void { @@ -149,6 +168,4 @@ public function getRules(): array { return $this->rules; } - - } diff --git a/tests/UploadProcessingEventTest.php b/tests/UploadProcessingEventTest.php new file mode 100644 index 0000000..bc34f6f --- /dev/null +++ b/tests/UploadProcessingEventTest.php @@ -0,0 +1,214 @@ +tmpFile = tempnam(sys_get_temp_dir(), 'testUpload'); + file_put_contents($this->tmpFile, 'Content'); + $this->filesystem = new Filesystem(new InMemoryFilesystemAdapter()); + } + + protected function tearDown(): void + { + if (file_exists($this->tmpFile)) { + unlink($this->tmpFile); + } + } + + /** + * @throws FilesystemException + * @throws Throwable + * @throws Exception + */ + public function testEventsAreDispatchedInCorrectOrder(): void + { + $uploadedFile = $this->createUploadedFile(); + $dispatcher = $this->createMock(EventDispatcherInterface::class); + + $dispatchedEvents = []; + $dispatcher->method('dispatch') + ->willReturnCallback(function ($event) use (&$dispatchedEvents) { + $dispatchedEvents[] = $event; + return $event; + }); + + $upload = new UploadProcessing($uploadedFile, $this->filesystem, $dispatcher); + $upload->upload(); + + $this->assertCount(3, $dispatchedEvents); + $this->assertInstanceOf(BeforeValidationEvent::class, $dispatchedEvents[0]); + $this->assertInstanceOf(BeforeUploadEvent::class, $dispatchedEvents[1]); + $this->assertInstanceOf(AfterUploadEvent::class, $dispatchedEvents[2]); + } + + /** + * @throws Exception + * @throws Throwable + */ + public function testErrorEventIsDispatchedOnException(): void + { + $this->expectException(RuntimeException::class); + $uploadedFile = $this->createUploadedFile(); + $dispatcher = $this->createMock(EventDispatcherInterface::class); + + $filesystem = $this->createMock(Filesystem::class); + $filesystem->method('writeStream') + ->willThrowException(new RuntimeException('Test error')); + + $dispatchedEvents = []; + $dispatcher->method('dispatch') + ->willReturnCallback(function ($event) use (&$dispatchedEvents) { + $dispatchedEvents[] = $event; + return $event; + }); + + $upload = new UploadProcessing($uploadedFile, $filesystem, $dispatcher); + + try { + $upload->upload(); + $this->fail('Expected exception was not thrown'); + } catch (\Throwable $e) { + $this->assertSame('Test error', $e->getMessage()); + + $this->assertCount(3, $dispatchedEvents); + $this->assertInstanceOf(BeforeValidationEvent::class, $dispatchedEvents[0]); + $this->assertInstanceOf(BeforeUploadEvent::class, $dispatchedEvents[1]); + $this->assertInstanceOf(UploadErrorEvent::class, $dispatchedEvents[2]); + + /** @var UploadErrorEvent $errorEvent */ + $errorEvent = $dispatchedEvents[2]; + $this->assertSame($upload, $errorEvent->uploadProcessing); + $this->assertSame($e, $errorEvent->exception); + throw $e; + } + } + + /** + * @throws FilesystemException + * @throws Throwable + */ + public function testWorksWithoutDispatcher(): void + { + $uploadedFile = $this->createUploadedFile(); + $upload = new UploadProcessing($uploadedFile, $this->filesystem); + $upload->upload(); + + $this->assertNotNull($upload->getTargetPath()); + $this->assertTrue($this->filesystem->fileExists($upload->getTargetPath())); + } + + /** + * @throws FilesystemException + * @throws Throwable + */ + public function testEventContainsCorrectContext(): void + { + $uploadedFile = $this->createUploadedFile(); + $dispatcher = new class () implements EventDispatcherInterface { + public array $dispatchedEvents = []; + + public function dispatch(object $event): object + { + $this->dispatchedEvents[] = $event; + return $event; + } + }; + + $upload = new UploadProcessing($uploadedFile, $this->filesystem, $dispatcher); + $upload->setFilename('test.txt'); + $upload->upload('/test/path'); + + $this->assertCount(3, $dispatcher->dispatchedEvents); + + $beforeValidationEvent = $dispatcher->dispatchedEvents[0]; + $this->assertSame($upload, $beforeValidationEvent->uploadProcessing); + + $beforeUploadEvent = $dispatcher->dispatchedEvents[1]; + $this->assertSame($upload, $beforeUploadEvent->uploadProcessing); + + $afterUploadEvent = $dispatcher->dispatchedEvents[2]; + $this->assertSame('/test/path/test.txt', $afterUploadEvent->uploadProcessing->getTargetPath()); + } + + private function createUploadedFile(?string $clientFilename = null, ?string $mediaType = null): UploadedFile + { + return new UploadedFile( + $this->tmpFile, + 128, + UPLOAD_ERR_OK, + $clientFilename ?? 'original_file_name.txt', + $mediaType ?? 'plain/text' + ); + } + + public function testEventPropagation(): void + { + $event = new class () extends AbstractUploadEvent {}; + $this->assertFalse($event->isPropagationStopped()); + $event->stopPropagation(); + $this->assertTrue($event->isPropagationStopped()); + } + + public function testEventPropagationStopping(): void + { + + $dispatcher = new class () implements EventDispatcherInterface { + public array $dispatchedEvents = []; + + public function dispatch(object $event): object + { + if ($event->isPropagationStopped()) { + return $event; + } + + $this->dispatchedEvents[] = $event; + return $event; + } + }; + + $uploadProcessing = new UploadProcessing($this->createUploadedFile(), $this->filesystem, $dispatcher); + + $event1 = new AfterUploadEvent($uploadProcessing); + $event2 = new AfterUploadEvent($uploadProcessing); + + $event1->stopPropagation(); + + $dispatcher->dispatch($event1); + $dispatcher->dispatch($event2); + + $this->assertCount(1, $dispatcher->dispatchedEvents); + $this->assertSame($event2, $dispatcher->dispatchedEvents[0]); + } +}