diff --git a/docs/basic/requests.md b/docs/basic/requests.md index 9bcb2e5..d886c1c 100644 --- a/docs/basic/requests.md +++ b/docs/basic/requests.md @@ -116,7 +116,8 @@ final class MyRequest extends Request ### Multi-part requests -If you would like to send files as multi-part requests, you should create your file using `Jenky\Atlas\Body\Multipart` class. This class accept: +If you would like to send files as multi-part requests, you should create your file using `Jenky\Atlas\Body\MultipartResource::from()` static method. This method accepts: +- `resource` stream. - `string` the file absolute path in the system. - `Psr\Http\Message\UploadedFileInterface` instance. - [`SplFileObject`](https://www.php.net/manual/en/class.splfileobject.php) instance. @@ -139,12 +140,20 @@ final class MyRequest extends Request return [ 'hero_name' => 'Superman', 'name' => 'Clark Kent', - 'avatar' => new Multipart(__DIR__.'/../path_to_image'), + 'avatar' => MultipartResource::from(__DIR__.'/../path_to_image.png'), ]; } } ``` +You can also pass the second parameter as the filename and third parameter as content type of the file. + +```php +$request = new MyRequest(); + +$request->with('image', MultipartResource::from(__DIR__.'/../path_to_image.jpg', 'image.jpeg', 'image/jpeg')); +``` + !!! You can create your own multipart file to fit your application logic. It must implement the `Jenky\Atlas\Contracts\MultipartInterface`. !!! diff --git a/src/Body/Multipart.php b/src/Body/Multipart.php deleted file mode 100644 index 4e173ed..0000000 --- a/src/Body/Multipart.php +++ /dev/null @@ -1,170 +0,0 @@ -streamFactory = $streamFactory ?: Psr17FactoryDiscovery::findStreamFactory(); - $this->analyse($part); - } - - /** - * Analyse the part. - * - * @param mixed $part - * @throws \RuntimeException - * @throws \UnexpectedValueException - */ - protected function analyse($part): void - { - if ($part instanceof UploadedFileInterface) { - $this->isFile = true; - $this->filename = $part->getClientFilename(); - $this->stream = $part->getStream(); - } elseif ($part instanceof \SplFileInfo) { - $this->isFile = $part->isFile(); - $this->filename = $part->getBasename(); - $this->stream = $this->createStream( - file_get_contents($part->getPathname()) - ); - } else { - $stream = $part instanceof StreamInterface - ? $part - : $this->createStream($part); - - if ($filename = $this->getFilenameFromStream($stream)) { - $this->isFile = true; - $this->filename = $filename; - } - $this->stream = $stream; - } - } - - /** - * Create a stream for given part. - * - * @param mixed $content - * @throws \UnexpectedValueException - */ - private function createStream($content): StreamInterface - { - if (is_resource($content)) { - return $this->streamFactory->createStreamFromResource($content); - } - - if (! is_string($content)) { - throw new \UnexpectedValueException('Invalid stream content.'); - } - - try { - return $this->streamFactory->createStreamFromFile($content); - } catch (\Throwable $e) { - return $this->streamFactory->createStream($content); - } - } - - /** - * Get the filename form the stream. - */ - private function getFilenameFromStream(StreamInterface $stream): ?string - { - $uri = $stream->getMetadata('uri'); - - if ($uri && is_string($uri) && substr($uri, 0, 6) !== 'php://' && substr($uri, 0, 7) !== 'data://') { - return $uri; - } - - return null; - } - - /** - * Determine whether the part is file. - */ - public function isFile(): bool - { - return $this->isFile; - } - - /** - * Get the filename in case part is file. - */ - public function filename(): ?string - { - return $this->filename; - } - - /** - * Get the content type of the part. - */ - public function mimeType(): ?string - { - $filename = $this->filename(); - - if (! $filename) { - return null; - } - - if (class_exists(MimeType::class)) { - return MimeType::fromFilename($filename); - } - - if (class_exists(MimeTypes::class)) { - return MimeTypes::getDefault()->guessMimeType($filename); - } - - $finfo = finfo_open(FILEINFO_MIME_TYPE); - - if (! $finfo) { - return null; - } - - return finfo_file($finfo, $filename) ?: null; - } - - /** - * Get the stream representing the part. - */ - public function stream(): StreamInterface - { - return $this->stream; - } -} diff --git a/src/Body/MultipartPayload.php b/src/Body/MultipartPayload.php index 4295db0..dcf9c62 100644 --- a/src/Body/MultipartPayload.php +++ b/src/Body/MultipartPayload.php @@ -8,6 +8,7 @@ use Jenky\Atlas\Contracts\MultipartInterface; use Jenky\Atlas\Contracts\PayloadInterface; use Jenky\Atlas\Map; +use Psr\Http\Message\StreamInterface; final class MultipartPayload extends Map implements PayloadInterface { @@ -16,11 +17,6 @@ final class MultipartPayload extends Map implements PayloadInterface */ private $boundary; - /** - * @var \Psr\Http\Message\StreamFactoryInterface - */ - protected $streamFactory; - /** * Create new multipart payload instance. * @@ -33,7 +29,6 @@ public function __construct(array $parameters = [], ?string $boundary = null) parent::__construct($parameters); $this->boundary = $boundary ?: bin2hex(random_bytes(20)); - $this->streamFactory = Psr17FactoryDiscovery::findStreamFactory(); } /** @@ -47,21 +42,17 @@ public function contentType(): ?string /** * Gather all the parts. */ - private function parts(): array + private function parts(): iterable { - $parts = []; - foreach ($this->all() as $key => $value) { - $parts[] = $this->part($key, $value); + yield $this->part($key, $value); } - - return $parts; } /** * Build a single part. * - * @param mixed $value + * @param string|MultipartInterface|StreamInterface $value */ private function part(string $name, $value): string { @@ -71,20 +62,20 @@ private function part(string $name, $value): string ); if ($value instanceof MultipartInterface) { - $stream = $value->stream(); - - if ($value->isFile()) { - if ($filename = $value->filename()) { - $headers['Content-Disposition'] .= sprintf('; filename="%s"', basename($filename)); - } + if ($filename = $value->filename()) { + $headers['Content-Disposition'] .= sprintf('; filename="%s"', basename($filename)); + } - // Set a default Content-Type - if ($type = $value->mimeType()) { - $headers['Content-Type'] = $type; - } + // Set a default Content-Type + if ($type = $value->mimeType()) { + $headers['Content-Type'] = $type; } + + $stream = $value->stream(); } else { - $stream = $this->streamFactory->createStream($value); + $stream = $value instanceof StreamInterface + ? $value + : Psr17FactoryDiscovery::findStreamFactory()->createStream($value); } // Set a default content-length header diff --git a/src/Body/MultipartResource.php b/src/Body/MultipartResource.php new file mode 100644 index 0000000..4ac1677 --- /dev/null +++ b/src/Body/MultipartResource.php @@ -0,0 +1,143 @@ +stream = $stream; + $this->filename = $filename; + $this->mimeType = $mimeType; + } + + public function stream(): StreamInterface + { + return $this->stream; + } + + public function filename(): ?string + { + return $this->filename ?? $this->getFilenameFromStream($this->stream); + } + + public function mimeType(): ?string + { + if ($this->mimeType) { + return $this->mimeType; + } + + $filename = $this->filename(); + + if (! $filename) { + return null; + } + + if (class_exists(MimeType::class)) { + return MimeType::fromFilename($filename); + } + + if (class_exists(MimeTypes::class)) { + return MimeTypes::getDefault()->guessMimeType($filename); + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + + if (! $finfo) { + return null; + } + + return finfo_file($finfo, $filename) ?: null; + } + + /** + * Get the filename form the stream. + */ + private function getFilenameFromStream(StreamInterface $stream): ?string + { + $uri = $stream->getMetadata('uri'); + + if ($uri && is_string($uri) && substr($uri, 0, 6) !== 'php://' && substr($uri, 0, 7) !== 'data://') { + return basename($uri); + } + + return null; + } + + /** + * Create a new multipart resource. + * + * @param mixed $content + * @throws \UnexpectedValueException + */ + public static function from($content, ?string $filename = null, ?string $mimeType = null): self + { + if ($content instanceof StreamInterface) { + return new self($content, $filename, $mimeType); + } + + if ($content instanceof UploadedFileInterface) { + return new self( + $content->getStream(), + $filename ?? $content->getClientFilename(), + $mimeType ?? $content->getClientMediaType() + ); + } + + $streamFactory = Psr17FactoryDiscovery::findStreamFactory(); + + if ($content instanceof \SplFileInfo) { + $stream = $streamFactory->createStream(file_get_contents($content->getPathname()) ?: ''); + + return new self( + $stream, + $filename ?? $content->getBasename(), + $mimeType + ); + } + + if (is_resource($content)) { + return new self( + $streamFactory->createStreamFromResource($content), + $filename, + $mimeType + ); + } + + if (is_string($content)) { + try { + $stream = $streamFactory->createStreamFromFile($content); + } catch (\Throwable $e) { + $stream = $streamFactory->createStream($content); + } + } else { + throw new \UnexpectedValueException('Resource is not valid'); + } + + return new self($stream, $filename, $mimeType); + } +} diff --git a/src/Contracts/MultipartInterface.php b/src/Contracts/MultipartInterface.php index 4e0cf8c..67fa3c6 100644 --- a/src/Contracts/MultipartInterface.php +++ b/src/Contracts/MultipartInterface.php @@ -14,12 +14,7 @@ interface MultipartInterface public function stream(): StreamInterface; /** - * Determine whether the part is file. - */ - public function isFile(): bool; - - /** - * Get the filename in case part is file. + * Get the filename of the part. */ public function filename(): ?string; diff --git a/tests/MultipartTest.php b/tests/MultipartTest.php new file mode 100644 index 0000000..f9f93c3 --- /dev/null +++ b/tests/MultipartTest.php @@ -0,0 +1,66 @@ +assertSame('1.png', $part->filename()); + $this->assertSame('image/png', $part->mimeType()); + } + + public function test_create_multipart_from_file_path(): void + { + $part = MultipartResource::from(__DIR__.'/fixtures/1x1.png'); + + $this->assertSame('1x1.png', $part->filename()); + $this->assertSame('image/png', $part->mimeType()); + } + + public function test_create_multipart_from_resource(): void + { + $part = MultipartResource::from(fopen(__DIR__.'/fixtures/1x1.png', 'r')); + + $this->assertSame('1x1.png', $part->filename()); + $this->assertSame('image/png', $part->mimeType()); + } + + public function test_create_multipart_from_uploaded_file(): void + { + $stream = Psr17FactoryDiscovery::findStreamFactory() + ->createStreamFromFile(__DIR__.'/fixtures/1x1.png'); + $file = Psr17FactoryDiscovery::findUploadedFileFactory() + ->createUploadedFile($stream); + + $part = MultipartResource::from($file); + + $this->assertSame('1x1.png', $part->filename()); + $this->assertSame('image/png', $part->mimeType()); + } + + public function test_create_multipart_from_spl_file(): void + { + $file = new \SplFileObject(__DIR__.'/fixtures/1x1.png'); + + $part = MultipartResource::from($file); + + $this->assertSame('1x1.png', $part->filename()); + $this->assertSame('image/png', $part->mimeType()); + } + + public function test_create_multipart_from_string(): void + { + $part = MultipartResource::from('foo'); + + $this->assertNull($part->filename()); + $this->assertNull($part->mimeType()); + } +} diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 9a93bc0..c8f69f8 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -4,7 +4,7 @@ namespace Jenky\Atlas\Tests; -use Jenky\Atlas\Body\Multipart; +use Jenky\Atlas\Body\MultipartResource; use Jenky\Atlas\Exception\HttpException; use Jenky\Atlas\Response; use Jenky\Atlas\Tests\Services\HTTPBin\Connector; @@ -96,7 +96,7 @@ public function test_request_multipart(): void { $request = new PostRequest('John', 'john.doe@example.com'); $request->body() - ->with('img', new Multipart(__DIR__.'/fixtures/1x1.png')); + ->with('img', MultipartResource::from(__DIR__.'/fixtures/1x1.png')); $response = $this->connector->send($request);