From 5059d0ca2652ce305757b8703b486293da0737a6 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Tue, 18 Aug 2026 10:38:00 +0100 Subject: [PATCH 1/2] fix(http): recursively map nested uploaded files --- packages/http/src/IsRequest.php | 2 +- .../PsrRequestToGenericRequestMapper.php | 21 +++++++++--- packages/http/src/Request.php | 2 +- .../PsrRequestToGenericRequestMapperTest.php | 32 +++++++++++++++++++ 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/http/src/IsRequest.php b/packages/http/src/IsRequest.php index 930b07dce..493216508 100644 --- a/packages/http/src/IsRequest.php +++ b/packages/http/src/IsRequest.php @@ -38,7 +38,7 @@ trait IsRequest #[SkipValidation] private(set) array $query; - /** @var \Tempest\Http\Upload[] */ + /** @var array */ #[SkipValidation] private(set) array $files; diff --git a/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php b/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php index 3ba06ebe0..c30eecbf4 100644 --- a/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php +++ b/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php @@ -51,10 +51,7 @@ public function map(mixed $from, mixed $to): GenericRequest parse_str($from->getUri()->getQuery(), $query); - $uploads = array_map( - fn (UploadedFileInterface $uploadedFile) => new Upload($uploadedFile), - $from->getUploadedFiles(), - ); + $uploads = $this->createUploads($from->getUploadedFiles()); return map([ 'method' => $this->requestMethod($from, $data), @@ -88,6 +85,22 @@ public function map(mixed $from, mixed $to): GenericRequest ->to(GenericRequest::class); } + /** + * Array-style file inputs, such as `files[]`, are exposed by PSR-7 as nested arrays. + * + * @param array $uploadedFiles + * @return array + */ + private function createUploads(array $uploadedFiles): array + { + return array_map( + fn (UploadedFileInterface|array $uploadedFile) => $uploadedFile instanceof UploadedFileInterface + ? new Upload($uploadedFile) + : $this->createUploads($uploadedFile), + $uploadedFiles, + ); + } + private function requestMethod(PsrRequest $request, array $data): Method { $originalMethod = Method::from($request->getMethod()); diff --git a/packages/http/src/Request.php b/packages/http/src/Request.php index 7193d4f57..2d1c373cc 100644 --- a/packages/http/src/Request.php +++ b/packages/http/src/Request.php @@ -22,7 +22,7 @@ interface Request public array $query { get; } - /** @var \Tempest\Http\Upload[] $files */ + /** @var array $files */ public array $files { get; } /** @var Cookie[] $cookies */ diff --git a/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php b/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php index ec6bbb1a3..61b097a46 100644 --- a/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php +++ b/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php @@ -6,6 +6,7 @@ use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\Stream; +use Laminas\Diactoros\UploadedFile; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -25,6 +26,7 @@ use Tempest\Http\Cookie\CookieManager; use Tempest\Http\Mappers\PsrRequestToGenericRequestMapper; use Tempest\Http\Method; +use Tempest\Http\Upload; final class PsrRequestToGenericRequestMapperTest extends TestCase { @@ -32,6 +34,8 @@ final class PsrRequestToGenericRequestMapperTest extends TestCase private ReflectionMethod $requestMethod; + private ReflectionMethod $createUploads; + protected function setUp(): void { parent::setUp(); @@ -47,6 +51,24 @@ protected function setUp(): void $reflection = new ReflectionClass($this->mapper); $this->requestMethod = $reflection->getMethod('requestMethod'); + $this->createUploads = $reflection->getMethod('createUploads'); + } + + #[Test] + public function nested_uploaded_files_are_mapped_to_uploads(): void + { + $files = $this->createUploads->invoke($this->mapper, [ + 'avatar' => $this->createUploadedFile('avatar.png'), + 'documents' => [ + $this->createUploadedFile('one.txt'), + 'contract' => $this->createUploadedFile('contract.pdf'), + ], + ]); + + $this->assertInstanceOf(Upload::class, $files['avatar']); + $this->assertSame('avatar.png', $files['avatar']->getClientFilename()); + $this->assertSame('one.txt', $files['documents'][0]->getClientFilename()); + $this->assertSame('contract.pdf', $files['documents']['contract']->getClientFilename()); } #[DataProvider('nonPostMethodsProvider')] @@ -156,4 +178,14 @@ private function createServerRequest(string $method, array $body = []): ServerRe $stream = new Stream('php://temp', 'r+'); return $request->withBody($stream); } + + private function createUploadedFile(string $filename): UploadedFile + { + return new UploadedFile( + streamOrFile: new Stream('php://temp', 'r+'), + size: 0, + errorStatus: UPLOAD_ERR_OK, + clientFilename: $filename, + ); + } } From d698ba0637cbe88970e062c7b8a147400397edf6 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Thu, 20 Aug 2026 00:16:28 +0100 Subject: [PATCH 2/2] test(http): cover nested uploaded files through the mapper --- .../PsrRequestToGenericRequestMapperTest.php | 32 ----------------- .../PsrRequestToGenericRequestMapperTest.php | 35 +++++++++++++++++++ 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php b/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php index 61b097a46..ec6bbb1a3 100644 --- a/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php +++ b/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php @@ -6,7 +6,6 @@ use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\Stream; -use Laminas\Diactoros\UploadedFile; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -26,7 +25,6 @@ use Tempest\Http\Cookie\CookieManager; use Tempest\Http\Mappers\PsrRequestToGenericRequestMapper; use Tempest\Http\Method; -use Tempest\Http\Upload; final class PsrRequestToGenericRequestMapperTest extends TestCase { @@ -34,8 +32,6 @@ final class PsrRequestToGenericRequestMapperTest extends TestCase private ReflectionMethod $requestMethod; - private ReflectionMethod $createUploads; - protected function setUp(): void { parent::setUp(); @@ -51,24 +47,6 @@ protected function setUp(): void $reflection = new ReflectionClass($this->mapper); $this->requestMethod = $reflection->getMethod('requestMethod'); - $this->createUploads = $reflection->getMethod('createUploads'); - } - - #[Test] - public function nested_uploaded_files_are_mapped_to_uploads(): void - { - $files = $this->createUploads->invoke($this->mapper, [ - 'avatar' => $this->createUploadedFile('avatar.png'), - 'documents' => [ - $this->createUploadedFile('one.txt'), - 'contract' => $this->createUploadedFile('contract.pdf'), - ], - ]); - - $this->assertInstanceOf(Upload::class, $files['avatar']); - $this->assertSame('avatar.png', $files['avatar']->getClientFilename()); - $this->assertSame('one.txt', $files['documents'][0]->getClientFilename()); - $this->assertSame('contract.pdf', $files['documents']['contract']->getClientFilename()); } #[DataProvider('nonPostMethodsProvider')] @@ -178,14 +156,4 @@ private function createServerRequest(string $method, array $body = []): ServerRe $stream = new Stream('php://temp', 'r+'); return $request->withBody($stream); } - - private function createUploadedFile(string $filename): UploadedFile - { - return new UploadedFile( - streamOrFile: new Stream('php://temp', 'r+'), - size: 0, - errorStatus: UPLOAD_ERR_OK, - clientFilename: $filename, - ); - } } diff --git a/tests/Integration/Route/PsrRequestToGenericRequestMapperTest.php b/tests/Integration/Route/PsrRequestToGenericRequestMapperTest.php index d9c18fea0..e2311e48f 100644 --- a/tests/Integration/Route/PsrRequestToGenericRequestMapperTest.php +++ b/tests/Integration/Route/PsrRequestToGenericRequestMapperTest.php @@ -106,6 +106,31 @@ public function files(): void $this->assertSame('application/octet-stream', $upload->getClientMediaType()); } + #[Test] + public function nested_files(): void + { + /** @var GenericRequest $request */ + $request = $this->mapper->map( + from: $this->http->makePsrRequest('/', files: [ + 'avatar' => $this->createUploadedFile('avatar.png'), + 'documents' => [ + $this->createUploadedFile('one.txt'), + 'contract' => $this->createUploadedFile('contract.pdf'), + ], + ]), + to: Request::class, + ); + + $this->assertInstanceOf(Upload::class, $request->files['avatar']); + $this->assertSame('avatar.png', $request->files['avatar']->getClientFilename()); + + $this->assertInstanceOf(Upload::class, $request->files['documents'][0]); + $this->assertSame('one.txt', $request->files['documents'][0]->getClientFilename()); + + $this->assertInstanceOf(Upload::class, $request->files['documents']['contract']); + $this->assertSame('contract.pdf', $request->files['documents']['contract']->getClientFilename()); + } + #[Test] public function body_field_in_body(): void { @@ -144,4 +169,14 @@ public function unencrypted_cookies_are_discarded(): void $this->assertSame($cookies['foo']->expiresAt, -1); $this->assertSame($cookies['foo']->value, ''); } + + private function createUploadedFile(string $filename): UploadedFile + { + return new UploadedFile( + streamOrFile: new Stream('php://temp', 'r+'), + size: 0, + errorStatus: UPLOAD_ERR_OK, + clientFilename: $filename, + ); + } }