diff --git a/Multipart/UploadedFileStream.php b/Multipart/UploadedFileStream.php new file mode 100644 index 0000000..8eb2bb5 --- /dev/null +++ b/Multipart/UploadedFileStream.php @@ -0,0 +1,132 @@ +inner = $inner; + $this->uploadedFile = $uploadedFile; + } + + public function __toString(): string + { + return (string) $this->inner; + } + + public function close(): void + { + $this->inner->close(); + } + + public function detach() + { + return $this->inner->detach(); + } + + public function getSize(): ?int + { + return $this->inner->getSize(); + } + + public function tell(): int + { + return $this->inner->tell(); + } + + public function eof(): bool + { + return $this->inner->eof(); + } + + public function isSeekable(): bool + { + return $this->inner->isSeekable(); + } + + public function seek(int $offset, int $whence = SEEK_SET): void + { + $this->inner->seek($offset, $whence); + } + + public function rewind(): void + { + $this->inner->rewind(); + } + + public function isWritable(): bool + { + return $this->inner->isWritable(); + } + + public function write(string $string): int + { + return $this->inner->write($string); + } + + public function isReadable(): bool + { + return $this->inner->isReadable(); + } + + public function read(int $length): string + { + return $this->inner->read($length); + } + + public function getContents(): string + { + return $this->inner->getContents(); + } + + public function getMetadata(?string $key = null) + { + if ('mime-type' === $key) { + return $this->uploadedFile->getClientMimeType(); + } + + if ('filename' === $key) { + return $this->uploadedFile->getClientOriginalName(); + } + + if (null === $key) { + $innerMetadata = $this->inner->getMetadata(); + + return array_merge( + is_array($innerMetadata) ? $innerMetadata : [], + [ + 'mime-type' => $this->uploadedFile->getClientMimeType(), + 'filename' => $this->uploadedFile->getClientOriginalName(), + ] + ); + } + + return $this->inner->getMetadata($key); + } + + /** + * Escape hatch for code that needs Symfony-specific file operations + */ + public function getUploadedFile(): UploadedFile + { + return $this->uploadedFile; + } +} diff --git a/Resources/config/symfony_services.yml b/Resources/config/symfony_services.yml index 51fef61..151aa87 100644 --- a/Resources/config/symfony_services.yml +++ b/Resources/config/symfony_services.yml @@ -39,8 +39,13 @@ services: auto1.api.response.serializer: class: Symfony\Component\Serializer\Serializer arguments: - - ['@auto1.api.serializer.denormalizer.array_access', '@auto1.api.serializer.denormalizer.array', '@auto1.api.serializer.normalizer.datetime', '@auto1.api.response.transformer.normalizer'] - - ['@serializer.encoder.json'] + $normalizers: + - '@auto1.api.serializer.denormalizer.array_access' + - '@auto1.api.serializer.denormalizer.array' + - '@auto1.api.serializer.normalizer.datetime' + - '@auto1.api.response.transformer.normalizer' + $encoders: + - '@serializer.encoder.json' auto1.api.serializer.denormalizer.array_access: class: Auto1\ServiceAPIComponentsBundle\Service\Serializer\Normalizer\ArrayAccessNormalizer @@ -67,8 +72,19 @@ services: - '@serializer.property_accessor' - '@property_info' + auto1.api.serializer.normalizer.stream_interface: + class: Auto1\ServiceAPIComponentsBundle\Service\Serializer\Normalizer\StreamInterfaceDenormalizer + auto1.api.request.serializer: class: Symfony\Component\Serializer\Serializer arguments: - - ['@auto1.api.serializer.normalizer.datetime', '@auto1.api.serializer.denormalizer.array', '@auto1.api.serializer.normalizer.object', '@auto1.api.serializer.normalizer.get_set_method'] - - ['@auto1.api.encoder.json', '@auto1.api.encoder.url', '@auto1.api.encoder.void'] + $normalizers: + - '@auto1.api.serializer.normalizer.datetime' + - '@auto1.api.serializer.normalizer.stream_interface' + - '@auto1.api.serializer.denormalizer.array' + - '@auto1.api.serializer.normalizer.object' + - '@auto1.api.serializer.normalizer.get_set_method' + $encoders: + - '@auto1.api.encoder.json' + - '@auto1.api.encoder.url' + - '@auto1.api.encoder.void' diff --git a/Service/Logger/LoggerAwareTrait.php b/Service/Logger/LoggerAwareTrait.php index 2a653e1..c4446ee 100644 --- a/Service/Logger/LoggerAwareTrait.php +++ b/Service/Logger/LoggerAwareTrait.php @@ -32,13 +32,9 @@ protected function getLogger() : LoggerInterface /** * @param LoggerInterface $logger - * - * @return $this */ - public function setLogger(LoggerInterface $logger) + public function setLogger(LoggerInterface $logger) : void { $this->traitLogger = $logger; - - return $this; } } diff --git a/Service/Serializer/Normalizer/StreamInterfaceDenormalizer.php b/Service/Serializer/Normalizer/StreamInterfaceDenormalizer.php new file mode 100644 index 0000000..5c76275 --- /dev/null +++ b/Service/Serializer/Normalizer/StreamInterfaceDenormalizer.php @@ -0,0 +1,45 @@ +inner = $this->createMock(StreamInterface::class); + $this->uploadedFile = $this->createMock(UploadedFile::class); + } + + public function testGetMetadataMimeTypeReturnsClientMimeType(): void + { + $this->uploadedFile + ->method('getClientMimeType') + ->willReturn(self::$targetMimeType); + ; + + $this->inner + ->expects(self::never()) + ->method('getMetadata') + ; + + $service = $this->getCut(); + $result = $service->getMetadata('mime-type'); + + self::assertSame(self::$targetMimeType, $result); + } + + public function testGetMetadataFilenameReturnsClientOriginalName(): void + { + $this->uploadedFile + ->method('getClientOriginalName') + ->willReturn(self::$targetFilename) + ; + + $this->inner + ->expects(self::never()) + ->method('getMetadata') + ; + + $service = $this->getCut(); + $result = $service->getMetadata('filename'); + + self::assertSame(self::$targetFilename, $result); + } + + public function testGetMetadataWithoutKeyMergesInnerWithMimeTypeAndFilename(): void + { + $fileMetadata = [ + 'wrapper_type' => 'plainfile', + 'mode' => self::$targetMode, + ]; + + $this->inner + ->method('getMetadata') + ->with(null) + ->willReturn($fileMetadata) + ; + + $this->uploadedFile + ->method('getClientMimeType') + ->willReturn(self::$targetMimeType) + ; + + $this->uploadedFile + ->method('getClientOriginalName') + ->willReturn(self::$targetFilename) + ; + + $service = $this->getCut(); + $result = $service->getMetadata(); + + self::assertSame( + array_merge( + $fileMetadata, + [ + 'mime-type' => self::$targetMimeType, + 'filename' => self::$targetFilename, + ] + ), + $result + ); + } + + public function testGetMetadataOtherKeyDelegatesToInner(): void + { + $this->inner + ->expects(self::once()) + ->method('getMetadata') + ->with('mode') + ->willReturn(self::$targetMode) + ; + + $service = $this->getCut(); + $result = $service->getMetadata('mode'); + + self::assertSame(self::$targetMode, $result); + } + + public function testReadDelegatesToInner(): void + { + $targetLength = 1024; + + $this->inner->expects(self::once()) + ->method('read') + ->with($targetLength) + ->willReturn(self::$targetPayload) + ; + + $service = $this->getCut(); + $result = $service->read($targetLength); + + self::assertSame(self::$targetPayload, $result); + } + + public function testSeekDelegatesToInner(): void + { + $targetOffset = 100; + + $this->inner + ->expects(self::once()) + ->method('seek') + ->with($targetOffset, SEEK_SET) + ; + + $service = $this->getCut(); + $service->seek($targetOffset); + } + + public function testGetContentsDelegatesToInner(): void + { + $this->inner + ->expects(self::once()) + ->method('getContents') + ->willReturn(self::$targetPayload) + ; + + $service = $this->getCut(); + $result = $service->getContents(); + + self::assertSame(self::$targetPayload, $result); + } + + public function testToStringDelegatesToInner(): void + { + $stringified = 'targetStringified'; + + $this->inner + ->expects(self::once()) + ->method('__toString') + ->willReturn($stringified) + ; + + $service = $this->getCut(); + + self::assertSame($stringified, (string) $service); + } + + public function testCloseDelegatesToInner(): void + { + $this->inner + ->expects(self::once()) + ->method('close') + ; + + $service = $this->getCut(); + $service->close(); + } + + public function testDetachDelegatesToInner(): void + { + $targetResource = fopen('php://memory', 'r'); + + $this->inner + ->expects(self::once()) + ->method('detach') + ->willReturn($targetResource) + ; + + $service = $this->getCut(); + $result = $service->detach(); + + self::assertSame($targetResource, $result); + + fclose($targetResource); + } + + public function testGetSizeDelegatesToInner(): void + { + $targetSize = 2048; + + $this->inner + ->expects(self::once()) + ->method('getSize') + ->willReturn($targetSize) + ; + + $service = $this->getCut(); + $result = $service->getSize(); + + self::assertSame($targetSize, $result); + } + + public function testTellDelegatesToInner(): void + { + $targetPosition = 42; + + $this->inner + ->expects(self::once()) + ->method('tell') + ->willReturn($targetPosition) + ; + + $service = $this->getCut(); + $result = $service->tell(); + + self::assertSame($targetPosition, $result); + } + + public function testEofDelegatesToInner(): void + { + $targetEof = true; + + $this->inner + ->expects(self::once()) + ->method('eof') + ->willReturn($targetEof) + ; + + $service = $this->getCut(); + $result = $service->eof(); + + self::assertSame($targetEof, $result); + } + + public function testIsSeekableDelegatesToInner(): void + { + $targetIsSeekable = true; + + $this->inner + ->expects(self::once()) + ->method('isSeekable') + ->willReturn($targetIsSeekable) + ; + + $service = $this->getCut(); + $result = $service->isSeekable(); + + self::assertSame($targetIsSeekable, $result); + } + + public function testRewindDelegatesToInner(): void + { + $this->inner + ->expects(self::once()) + ->method('rewind') + ; + + $service = $this->getCut(); + $service->rewind(); + } + + public function testIsWritableDelegatesToInner(): void + { + $targetIsWritable = false; + + $this->inner + ->expects(self::once()) + ->method('isWritable') + ->willReturn($targetIsWritable) + ; + + $service = $this->getCut(); + $result = $service->isWritable(); + + self::assertSame($targetIsWritable, $result); + } + + public function testWriteDelegatesToInner(): void + { + $targetBytesWritten = 12; + + $this->inner + ->expects(self::once()) + ->method('write') + ->with(self::$targetPayload) + ->willReturn($targetBytesWritten) + ; + + $service = $this->getCut(); + $result = $service->write(self::$targetPayload); + + self::assertSame($targetBytesWritten, $result); + } + + public function testIsReadableDelegatesToInner(): void + { + $targetIsReadable = true; + + $this->inner + ->expects(self::once()) + ->method('isReadable') + ->willReturn($targetIsReadable) + ; + + $service = $this->getCut(); + $result = $service->isReadable(); + + self::assertSame($targetIsReadable, $result); + } + + public function testGetUploadedFileReturnsConstructorArgument(): void + { + $service = $this->getCut(); + $result = $service->getUploadedFile(); + + self::assertSame($this->uploadedFile, $result); + } + + private function getCut(): UploadedFileStream + { + return new UploadedFileStream($this->inner, $this->uploadedFile); + } +} diff --git a/Tests/Serializer/Normalizer/StreamInterfaceDenormalizerTest.php b/Tests/Serializer/Normalizer/StreamInterfaceDenormalizerTest.php new file mode 100644 index 0000000..7ee9a8e --- /dev/null +++ b/Tests/Serializer/Normalizer/StreamInterfaceDenormalizerTest.php @@ -0,0 +1,77 @@ +getCut(); + + $result = $denormalizer->supportsDenormalization(null, StreamInterface::class); + + self::assertTrue($result); + } + + public function testDoesNotSupportOtherTypes(): void + { + $targetUnsupportedType = \stdClass::class; + + $denormalizer = $this->getCut(); + + $result = $denormalizer->supportsDenormalization(null, $targetUnsupportedType); + + self::assertFalse($result); + } + + public function testPassesThroughStreamInstance(): void + { + $targetStream = $this->createMock(StreamInterface::class); + + $denormalizer = $this->getCut(); + + $result = $denormalizer->denormalize($targetStream, StreamInterface::class); + + self::assertSame($targetStream, $result); + } + + public function testReturnsNullForNullData(): void + { + $denormalizer = $this->getCut(); + + $result = $denormalizer->denormalize(null, StreamInterface::class); + + self::assertNull($result); + } + + public function testThrowsForNonStreamData(): void + { + $targetNonStreamData = 'not a stream'; + + $denormalizer = $this->getCut(); + + $this->expectException(InvalidArgumentException::class); + $denormalizer->denormalize($targetNonStreamData, StreamInterface::class); + } +} diff --git a/composer.json b/composer.json index 0f7b53b..07389a8 100644 --- a/composer.json +++ b/composer.json @@ -16,15 +16,16 @@ "require": { "php": "^7.4|^8.0", "symfony/serializer": "~4.2|~5.0|~6.0|~7.0", - "symfony/monolog-bridge": "~4.0|~5.0|~6.0|~7.0", "symfony/dependency-injection": "~4.0|~5.0|~6.0|~7.0", "symfony/config": "~4.0|~5.0|~6.0|~7.0", "symfony/http-kernel": "~4.0|~5.0|~6.0|~7.0", "symfony/property-info": "~4.0|~5.0|~6.0|~7.0", "symfony/property-access": "~4.0|~5.0|~6.0|~7.0", "symfony/yaml": "~4.0|~5.0|~6.0|~7.0", - "monolog/monolog": "~1.22|^2.9|^3.0", "fig/http-message-util": "^1.1.2", + "psr/log": "^1.1|^2.0|^3.0", + "psr/http-message": "^1.1|^2.0", + "symfony/http-foundation": "~4.0|~5.0|~6.0|~7.0", "auto1-oss/service-api-request": "^1.0" }, "require-dev": {