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/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, + ); + } }