diff --git a/.phive/phars.xml b/.phive/phars.xml index 87b5277..6f29ee8 100644 --- a/.phive/phars.xml +++ b/.phive/phars.xml @@ -1,6 +1,6 @@ - - + + diff --git a/src/HttpMessage/HasMethod.php b/src/HttpMessage/HasMethod.php index 5c6e3e0..b8427cb 100644 --- a/src/HttpMessage/HasMethod.php +++ b/src/HttpMessage/HasMethod.php @@ -17,7 +17,7 @@ */ trait HasMethod { - protected string $method = 'GET'; + protected string $method = 'GET'; // default public function getMethod(): string { @@ -27,6 +27,7 @@ public function getMethod(): string public function withMethod(string $method): RequestInterface { $new_instance = clone $this; + // This should not modify the case of the method $new_instance->method = $method; return $new_instance; diff --git a/src/HttpMessage/HasProtocolVersion.php b/src/HttpMessage/HasProtocolVersion.php index 7ae180e..2c9e9ec 100644 --- a/src/HttpMessage/HasProtocolVersion.php +++ b/src/HttpMessage/HasProtocolVersion.php @@ -14,7 +14,7 @@ */ trait HasProtocolVersion { - protected string $protocol_version = '1.1'; + protected string $protocol_version = '1.1'; // default /** * Get protocol version(e.g. HTTP/1.1 returns "1.1") @@ -32,9 +32,17 @@ public function getProtocolVersion(): string */ public function withProtocolVersion(string $version): static { + $this->validateProtocolVersion($version); $new_instance = clone $this; $new_instance->protocol_version = $version; return $new_instance; } + + protected function validateProtocolVersion(string $version): void + { + if (!preg_match('/^\d\.\d$/', $version)) { + throw new \InvalidArgumentException(\sprintf('Invalid protocol version, "%s" given', $version)); + } + } } diff --git a/src/HttpMessage/HasUploadedFiles.php b/src/HttpMessage/HasUploadedFiles.php index bf08f58..f0d484a 100644 --- a/src/HttpMessage/HasUploadedFiles.php +++ b/src/HttpMessage/HasUploadedFiles.php @@ -19,12 +19,12 @@ */ trait HasUploadedFiles { - /** @var array $uploaded_files */ + /** @var array $uploaded_files */ protected array $uploaded_files = []; /** * Get uploaded files - * @return array + * @return array */ public function getUploadedFiles(): array { @@ -41,13 +41,20 @@ public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface { $new_instance = clone $this; $new_instance->uploaded_files = []; - foreach ($uploadedFiles as $key => $uploaded_file) { - if (!$uploaded_file instanceof UploadedFileInterface) { - throw new InvalidArgumentException('Invalid uploaded file'); - } - $new_instance->uploaded_files[$key] = $uploaded_file; - } + $this->validateUploadedFiles($uploadedFiles); + $new_instance->uploaded_files = $uploadedFiles; return $new_instance; } + + protected function validateUploadedFiles(mixed $uploaded_files): void + { + if (\is_array($uploaded_files)) { + foreach ($uploaded_files as $uploaded_file) { + $this->validateUploadedFiles($uploaded_file); + } + } elseif ($uploaded_files instanceof UploadedFileInterface === false) { + throw new InvalidArgumentException('Invalid uploaded file'); + } + } } diff --git a/src/HttpMessage/Request.php b/src/HttpMessage/Request.php index 01da2f6..f84e5b0 100644 --- a/src/HttpMessage/Request.php +++ b/src/HttpMessage/Request.php @@ -44,6 +44,7 @@ public function __construct( // case-sensitive $this->method = $method; $this->uri = $uri instanceof UriInterface ? $uri : new Uri($uri); + $this->validateProtocolVersion($protocol_version); $this->protocol_version = $protocol_version; if ($body !== null && $body !== '') { $this->body = new Stream($body); diff --git a/src/HttpMessage/ServerRequest.php b/src/HttpMessage/ServerRequest.php index 7034358..3f15ecc 100644 --- a/src/HttpMessage/ServerRequest.php +++ b/src/HttpMessage/ServerRequest.php @@ -12,7 +12,6 @@ use InvalidArgumentException; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamInterface; -use Psr\Http\Message\UploadedFileInterface; use Psr\Http\Message\UriInterface; /** @@ -34,9 +33,9 @@ class ServerRequest extends Request implements ServerRequestInterface * @param StreamInterface|resource|string|null $body * @param string $protocol_version * @param array $server_params - * @param array $cookie_params + * @param array $cookie_params * @param array $query_params - * @param array $uploaded_files + * @param array $uploaded_files * @param array|object|null $parsed_body * @param array $attributes * @throws InvalidArgumentException @@ -74,12 +73,8 @@ public function __construct( $this->query_params[$name] = $value; } } - foreach ($uploaded_files as $name => $uploaded_file) { - if (!$uploaded_file instanceof UploadedFileInterface) { - throw new InvalidArgumentException('Invalid uploaded file'); - } - $this->uploaded_files[$name] = $uploaded_file; - } + $this->validateUploadedFiles($uploaded_files); + $this->uploaded_files = $uploaded_files; $this->parsed_body = $parsed_body; $this->attributes = $attributes; } diff --git a/src/HttpMessage/Tests/MessageTest.php b/src/HttpMessage/Tests/MessageTest.php index 6dc6b03..6ad475f 100644 --- a/src/HttpMessage/Tests/MessageTest.php +++ b/src/HttpMessage/Tests/MessageTest.php @@ -46,6 +46,17 @@ public function testProtocolVersion(): void self::assertSame('2.0', $message2->getProtocolVersion()); } + #[Test] + public function testInvalidProtocolVersion(): void + { + $message = new class extends Message {}; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid protocol version, "2" given'); + + $message->withProtocolVersion('2'); + } + #[Test] public function testBody(): void { diff --git a/src/HttpMessage/Tests/ServerRequestTest.php b/src/HttpMessage/Tests/ServerRequestTest.php index 7396f54..81ca4a1 100644 --- a/src/HttpMessage/Tests/ServerRequestTest.php +++ b/src/HttpMessage/Tests/ServerRequestTest.php @@ -146,7 +146,6 @@ public function testInvalidUploadedFile(): void $this->expectExceptionMessage('Invalid uploaded file'); - // @phpstan-ignore argument.type new ServerRequest(method: 'GET', uploaded_files: ['a.txt' => $file]); } diff --git a/src/HttpServer/Emitter.php b/src/HttpServer/Emitter.php index 5da9dc3..2efefd3 100644 --- a/src/HttpServer/Emitter.php +++ b/src/HttpServer/Emitter.php @@ -5,7 +5,6 @@ /** * @author Masaru Yamagishi * @license Apache-2.0 - * @codeCoverageIgnoreFile because all of methods are wrapper of PHP built-in functions */ namespace Rayleigh\HttpServer; @@ -15,6 +14,7 @@ /** * Header and body emitter function wrapper * @package Rayleigh\HttpServer + * @codeCoverageIgnore because all of methods are wrapper of PHP built-in functions */ /* final readonly */ class Emitter { diff --git a/src/HttpServer/Tests/TraditionalServerRequestBuilderTest.php b/src/HttpServer/Tests/TraditionalServerRequestBuilderTest.php new file mode 100644 index 0000000..367805c --- /dev/null +++ b/src/HttpServer/Tests/TraditionalServerRequestBuilderTest.php @@ -0,0 +1,518 @@ + + * @license Apache-2.0 + */ + +namespace Rayleigh\HttpServer\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\Attributes\UsesTrait; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UploadedFileInterface; +use Rayleigh\HttpServer\TraditionalServerRequestBuilder; + +#[CoversClass(TraditionalServerRequestBuilder::class)] +#[UsesTrait(\Rayleigh\HttpMessage\HasAttributes::class)] +#[UsesTrait(\Rayleigh\HttpMessage\HasMethod::class)] +#[UsesTrait(\Rayleigh\HttpMessage\HasParams::class)] +#[UsesTrait(\Rayleigh\HttpMessage\HasParsedBody::class)] +#[UsesTrait(\Rayleigh\HttpMessage\HasProtocolVersion::class)] +#[UsesTrait(\Rayleigh\HttpMessage\HasUploadedFiles::class)] +#[UsesClass(\Rayleigh\HttpMessage\HeaderBag::class)] +#[UsesClass(\Rayleigh\HttpMessage\Internal\UriPartsParser::class)] +#[UsesClass(\Rayleigh\HttpMessage\Message::class)] +#[UsesClass(\Rayleigh\HttpMessage\Request::class)] +#[UsesClass(\Rayleigh\HttpMessage\ServerRequest::class)] +#[UsesClass(\Rayleigh\HttpMessage\UploadedFile::class)] +#[UsesClass(\Rayleigh\HttpMessage\Uri::class)] +final class TraditionalServerRequestBuilderTest extends TestCase +{ + #[Test] + public function testBuiltinServer(): void + { + $SERVER = [ + 'DOCUMENT_ROOT' => '/app/rayleigh', + 'REMOTE_ADDR' => '127.0.0.1', + 'REMOTE_PORT' => '52586', + 'SERVER_SOFTWARE' => 'PHP/8.3.10 (Development Server)', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'SERVER_NAME' => '0.0.0.0', + 'SERVER_PORT' => '8080', + 'REQUEST_URI' => '/test.php?test=foo', + 'REQUEST_METHOD' => 'GET', + 'SCRIPT_NAME' => '/test.php', + 'SCRIPT_FILENAME' => '/app/rayleigh/test.php', + 'PHP_SELF' => '/test.php', + 'QUERY_STRING' => 'test=foo', + 'HTTP_HOST' => 'localhost:8080', + 'HTTP_CONNECTION' => 'keep-alive', + 'HTTP_CACHE_CONTROL' => 'max-age=0', + 'HTTP_SEC_CH_UA' => '"Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"', + 'HTTP_SEC_CH_UA_MOBILE' => '?0', + 'HTTP_SEC_CH_UA_PLATFORM' => '"Windows"', + 'HTTP_UPGRADE_INSECURE_REQUESTS' => '1', + 'HTTP_USER_AGENT' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36', + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'HTTP_SEC_FETCH_SITE' => 'none', + 'HTTP_SEC_FETCH_MODE' => 'navigate', + 'HTTP_SEC_FETCH_USER' => '?1', + 'HTTP_SEC_FETCH_DEST' => 'document', + 'HTTP_ACCEPT_ENCODING' => 'gzip, deflate, br, zstd', + 'HTTP_ACCEPT_LANGUAGE' => 'ja,en-US;q=0.9,en;q=0.8', + 'HTTP_COOKIE' => 'PHPSESSID=ra3rpdd1aba6tglm2q70mbb29f', + 'REQUEST_TIME_FLOAT' => 1724641506.684002, + 'REQUEST_TIME' => 1724641506, + ]; + $COOKIE = [ + 'PHPSESSID' => 'ra3rpdd1aba6tglm2q70mbb29f', + ]; + $FILES = []; + $GET = [ + 'test' => 'foo', + ]; + $POST = []; + $REQUEST = [ + 'test' => 'foo', + ]; + + $request = TraditionalServerRequestBuilder::build($SERVER, $COOKIE, $FILES, $GET, $POST, $REQUEST); + + self::assertInstanceOf(ServerRequestInterface::class, $request); + self::assertSame('GET', $request->getMethod()); + self::assertSame(['PHPSESSID' => 'ra3rpdd1aba6tglm2q70mbb29f'], $request->getCookieParams()); + self::assertSame([], $request->getUploadedFiles()); + self::assertNull($request->getParsedBody()); + self::assertSame(['test' => 'foo'], $request->getQueryParams()); + self::assertSame('1.1', $request->getProtocolVersion()); + self::assertSame([], $request->getAttributes()); + } + + public function testApache(): void + { + $SERVER = [ + 'HTTP_HOST' => 'localhost:8080', + 'HTTP_CONNECTION' => 'keep-alive', + 'HTTP_SEC_CH_UA' => '"Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"', + 'HTTP_SEC_CH_UA_MOBILE' => '?0', + 'HTTP_SEC_CH_UA_PLATFORM' => '"Windows"', + 'HTTP_UPGRADE_INSECURE_REQUESTS' => '1', + 'HTTP_USER_AGENT' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36', + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'HTTP_SEC_FETCH_SITE' => 'none', + 'HTTP_SEC_FETCH_MODE' => 'navigate', + 'HTTP_SEC_FETCH_USER' => '?1', + 'HTTP_SEC_FETCH_DEST' => 'document', + 'HTTP_ACCEPT_ENCODING' => 'gzip, deflate, br, zstd', + 'HTTP_ACCEPT_LANGUAGE' => 'ja,en-US;q=0.9,en;q=0.8', + 'HTTP_COOKIE' => 'PHPSESSID=ra3rpdd1aba6tglm2q70mbb29f', + 'PATH' => '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + 'SERVER_SIGNATURE' => ' +Apache/2.4.61 (Debian) Server at localhost Port 8080 + +', + 'SERVER_SOFTWARE' => 'Apache/2.4.61 (Debian)', + 'SERVER_NAME' => 'localhost', + 'SERVER_ADDR' => '172.17.0.2', + 'SERVER_PORT' => '8080', + 'REMOTE_ADDR' => '172.17.0.1', + 'DOCUMENT_ROOT' => '/var/www/html', + 'REQUEST_SCHEME' => 'http', + 'CONTEXT_PREFIX' => '', + 'CONTEXT_DOCUMENT_ROOT' => '/var/www/html', + 'SERVER_ADMIN' => 'webmaster@localhost', + 'SCRIPT_FILENAME' => '/var/www/html/test.php', + 'REMOTE_PORT' => '43044', + 'GATEWAY_INTERFACE' => 'CGI/1.1', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'test=foo', + 'REQUEST_URI' => '/test.php?test=foo', + 'SCRIPT_NAME' => '/test.php', + 'PHP_SELF' => '/test.php', + 'REQUEST_TIME_FLOAT' => 1724654454.427512, + 'REQUEST_TIME' => 1724654454, + 'argv' => [ + 'test=foo', + ], + 'argc' => 1, + ]; + $COOKIE = [ + 'PHPSESSID' => 'ra3rpdd1aba6tglm2q70mbb29f', + ]; + $FILES = []; + $GET = [ + 'test' => 'foo', + ]; + $POST = []; + $REQUEST = [ + 'test' => 'foo', + 'PHPSESSID' => 'ra3rpdd1aba6tglm2q70mbb29f', + ]; + + $request = TraditionalServerRequestBuilder::build($SERVER, $COOKIE, $FILES, $GET, $POST, $REQUEST); + + self::assertInstanceOf(ServerRequestInterface::class, $request); + self::assertSame('GET', $request->getMethod()); + self::assertSame(['PHPSESSID' => 'ra3rpdd1aba6tglm2q70mbb29f'], $request->getCookieParams()); + self::assertSame([], $request->getUploadedFiles()); + self::assertNull($request->getParsedBody()); + self::assertSame(['test' => 'foo'], $request->getQueryParams()); + self::assertSame('1.1', $request->getProtocolVersion()); + self::assertSame([], $request->getAttributes()); + } + + /** + * @return iterable, 1: array, 2: string, 3: ?string}> + */ + public static function getBuildMethodList(): iterable + { + // [$server, $post, $expected] + yield 'GET' => [['REQUEST_METHOD' => 'GET'], [], 'GET', null]; + + yield 'POST' => [['REQUEST_METHOD' => 'POST'], [], 'POST', null]; + + yield 'PUT' => [['REQUEST_METHOD' => 'PUT'], [], 'PUT', null]; + + yield 'PUT_OVERRIDE' => [['REQUEST_METHOD' => 'POST', 'HTTP_X_HTTP_METHOD_OVERRIDE' => 'PUT'], [], 'POST', 'PUT']; + + yield 'PUT_BUT_GET' => [['REQUEST_METHOD' => 'GET', 'HTTP_X_HTTP_METHOD_OVERRIDE' => 'PUT'], [], 'GET', 'PUT']; + + yield 'DELETE_FORM' => [['REQUEST_METHOD' => 'POST'], ['_method' => 'DELETE'], 'POST', 'DELETE']; + + yield 'ALL' => [['REQUEST_METHOD' => 'POST', 'HTTP_X_HTTP_METHOD_OVERRIDE' => 'PATCH'], ['_method' => 'DELETE'], 'POST', 'PATCH']; + + yield 'UNDEFINED' => [['REQUEST_METHOD' => 'UNDEFINED'], [], 'UNDEFINED', null]; + + yield 'CASE_SENSITIVE' => [['REQUEST_METHOD' => 'POST'], ['_method' => 'dELeTe'], 'POST', null]; + } + + /** + * @param array $server + * @param array $post + * @param string $expected + * @return void + */ + #[Test] + #[DataProvider('getBuildMethodList')] + public function testBuildMethod(array $server, array $post, string $expected): void + { + $builder = self::getExtendedBuilder( + $server, + [], + [], + [], + $post, + $post, + ); + + $actual = $builder->buildMethod(); + + self::assertSame($expected, $actual); + } + + /** + * @param array $server + * @param array $post + * @param string $_ + * @param ?string $expected + * @return void + */ + #[Test] + #[DataProvider('getBuildMethodList')] + public function testBuildOverrideMethodPost(array $server, array $post, string $_, ?string $expected): void + { + $builder = self::getExtendedBuilder( + $server, + [], + [], + [], + $post, + $post, + ); + + $actual = $builder->buildOverrideMethod(); + + self::assertSame($expected, $actual); + } + + /** + * @param array $server + * @param array $get + * @param string $_ + * @param ?string $expected + * @return void + */ + #[Test] + #[DataProvider('getBuildMethodList')] + public function testBuildOverrideMethodGet(array $server, array $get, string $_, ?string $expected): void + { + $builder = self::getExtendedBuilder( + $server, + [], + [], + $get, + [], + $get, + ); + + $actual = $builder->buildOverrideMethod(); + + self::assertSame($expected, $actual); + } + + /** + * @return iterable, 1: string}> + */ + public static function getServerProtocolList(): iterable + { + yield '0.9' => [['SERVER_PROTOCOL' => 'HTTP/0.9'], '0.9']; + + yield '1.0' => [['SERVER_PROTOCOL' => 'HTTP/1.0'], '1.0']; + + yield '1.1' => [['SERVER_PROTOCOL' => 'HTTP/1.1'], '1.1']; + + yield '2.0' => [['SERVER_PROTOCOL' => 'HTTP/2.0'], '2.0']; + + yield '3.0' => [['SERVER_PROTOCOL' => 'HTTP/3.0'], '3.0']; + } + + /** + * @param array $server + * @param string $expected + */ + #[Test] + #[DataProvider('getServerProtocolList')] + public function testBuildProtocolVersion(array $server, string $expected): void + { + $builder = self::getExtendedBuilder( + $server, + ); + + $actual = $builder->buildProtocolVersion(); + + self::assertSame($expected, $actual); + } + + #[Test] + public function testBuildProtocolVersionUnknown(): void + { + $builder = self::getExtendedBuilder( + ['SERVER_PROTOCOL' => 'FTP/1.0'], + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unknown server protocol: FTP/1.0'); + + $builder->buildProtocolVersion(); + } + + #[Test] + public function testBuildHeaders(): void + { + $server = [ + 'HTTP_HOST' => 'localhost:8080', + 'HTTP_CONNECTION' => 'keep-alive', + 'HTTP_SEC_CH_UA' => '"Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"', + 'CONTENT_TYPE' => 'plain/html', + 'CONTENT_LENGTH' => '1', + 'CONTENT_MD5' => 'd41d8cd98f00b204e9800998ecf8427e', + ]; + $builder = self::getExtendedBuilder( + $server, + ); + + $actual = $builder->buildHeaders(); + + self::assertSame([ + 'Content-Type' => ['plain/html'], + 'Content-Length' => ['1'], + 'Content-Md5' => ['d41d8cd98f00b204e9800998ecf8427e'], + 'Host' => ['localhost:8080'], + 'Connection' => ['keep-alive'], + 'Sec-Ch-Ua' => ['"Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"'], + ], $actual); + } + + #[Test] + public function testBuildUploadedFiles(): void + { + $files = [ + 'avatar' => [ + 'tmp_name' => 'phpUxcOty', + 'name' => 'my-avatar.png', + 'size' => 90996, + 'type' => 'image/png', + 'error' => 0, + ], + ]; + + $builder = self::getExtendedBuilder(files: $files); + + $actual = $builder->buildUploadedFiles(); + + self::assertCount(1, $actual); + self::assertArrayHasKey('avatar', $actual); + self::assertInstanceOf(UploadedFileInterface::class, $actual['avatar']); + } + + #[Test] + public function testBuildUploadedFilesRecursively2(): void + { + $files = [ + 'my-form' => [ + 'name' => [ + 'details' => [ + 'avatars' => [ + 0 => 'my-avatar.png', + 1 => 'my-avatar2.png', + 2 => 'my-avatar3.png', + ], + ], + ], + 'type' => [ + 'details' => [ + 'avatars' => [ + 0 => 'image/png', + 1 => 'image/png', + 2 => 'image/png', + ], + ], + ], + 'tmp_name' => [ + 'details' => [ + 'avatars' => [ + 0 => 'phpmFLrzD', + 1 => 'phpV2pBil', + 2 => 'php8RUG8v', + ], + ], + ], + 'error' => [ + 'details' => [ + 'avatars' => [ + 0 => 0, + 1 => 0, + 2 => 0, + ], + ], + ], + 'size' => [ + 'details' => [ + 'avatars' => [ + 0 => 90996, + 1 => 90996, + 2 => 90996, + ], + ], + ], + ], + ]; + + $builder = self::getExtendedBuilder(files: $files); + + $actual = $builder->buildUploadedFiles(); + + self::assertArrayHasKey('my-form', $actual); + self::assertIsArray($actual['my-form']); + self::assertArrayHasKey('details', $actual['my-form']); + self::assertIsArray($actual['my-form']['details']); + self::assertArrayHasKey('avatars', $actual['my-form']['details']); + self::assertCount(3, $actual['my-form']['details']['avatars']); + self::assertInstanceOf(UploadedFileInterface::class, $actual['my-form']['details']['avatars'][0]); + self::assertInstanceOf(UploadedFileInterface::class, $actual['my-form']['details']['avatars'][1]); + self::assertInstanceOf(UploadedFileInterface::class, $actual['my-form']['details']['avatars'][2]); + } + + #[Test] + public function testBuildUploadedFilesRecursively(): void + { + $files = [ + 'my-form' => [ + 'name' => [ + 'details' => [ + 'avatar' => 'my-avatar.png', + ], + ], + 'type' => [ + 'details' => [ + 'avatar' => 'image/png', + ], + ], + 'tmp_name' => [ + 'details' => [ + 'avatar' => 'phpmFLrzD', + ], + ], + 'error' => [ + 'details' => [ + 'avatar' => 0, + ], + ], + 'size' => [ + 'details' => [ + 'avatar' => 90996, + ], + ], + ], + ]; + + $builder = self::getExtendedBuilder(files: $files); + + $actual = $builder->buildUploadedFiles(); + + self::assertArrayHasKey('my-form', $actual); + self::assertIsArray($actual['my-form']); + self::assertArrayHasKey('details', $actual['my-form']); + self::assertIsArray($actual['my-form']['details']); + self::assertArrayHasKey('avatar', $actual['my-form']['details']); + self::assertInstanceOf(UploadedFileInterface::class, $actual['my-form']['details']['avatar']); + } + + /** + * @param array $server + * @param array $cookie + * @param array $files + * @param array $get + * @param array $post + * @param array $request + */ + private static function getExtendedBuilder( + array $server = [], + array $cookie = [], + array $files = [], + array $get = [], + array $post = [], + array $request = [], + ): TraditionalServerRequestBuilder + { + // use anonymous class for public constructor + return new class ($server, $cookie, $files, $get, $post, $request) extends TraditionalServerRequestBuilder { + /** + * @param array $server + * @param array $cookie + * @param array $files + * @param array $get + * @param array $post + * @param array $request + */ + public function __construct( + array $server, + array $cookie, + array $files, + array $get, + array $post, + array $request, + ){ + parent::__construct($server, $cookie, $files, $get, $post, $request); + } + }; + } +} diff --git a/src/HttpServer/TraditionalServerRequestBuilder.php b/src/HttpServer/TraditionalServerRequestBuilder.php index a13e9b2..6310f7c 100644 --- a/src/HttpServer/TraditionalServerRequestBuilder.php +++ b/src/HttpServer/TraditionalServerRequestBuilder.php @@ -30,158 +30,187 @@ * (new ResponseEmitter(new Emitter()))->emit($response); * ``` */ -final /* readonly */ class TraditionalServerRequestBuilder +/* final readonly */ class TraditionalServerRequestBuilder { + protected const VALID_HTTP_METHODS = ['GET', 'POST', 'HEAD', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH']; + + /** @var bool Whether to override HTTP method from Header or form */ + protected static bool $override_method = false; + /** - * This class cannot be instantiated + * This class cannot be instantiated, use TraditionalServerRequestBuilder::build method instead + * Only in tests, you may use this constructor + * @param array $server + * @param array $cookie + * @param array $files + * @param array $get + * @param array $post + * @param array $request */ - private function __construct() {} + protected function __construct( + protected array $server, + protected array $cookie, + protected array $files, + protected array $get, + protected array $post, + protected array $request, + ) {} /** - * Build ServerRequest from global variables - * @param array $server - * @param array $cookie - * @param array $files - * @param array $get - * @param array $post - * @param array $request + * Build ServerRequest from superglobal variables + * @return ServerRequestInterface + * @codeCoverageIgnore + */ + public static function buildFromSuperGlobals(): ServerRequestInterface + { + return self::build( + $_SERVER, + $_COOKIE, + $_FILES, + $_GET, + $_POST, + $_REQUEST, + ); + } + + /** + * Enable override method from Header or form + * @return void + */ + public static function enableOverrideMethod(): void + { + self::$override_method = true; + } + + /** + * Build ServerRequest from provided variables + * @param array $server + * @param array $cookie + * @param array $files + * @param array $get + * @param array $post + * @param array $request * @return ServerRequestInterface - * @throws MalformedUriException */ public static function build( - array $server = $_SERVER, - array $cookie = $_COOKIE, - array $files = $_FILES, - array $get = $_GET, - array $post = $_POST, - array $request = $_REQUEST, + array $server, + array $cookie, + array $files, + array $get, + array $post, + array $request, ): ServerRequestInterface { - $method = self::generateMethod($server, $get, $post); - switch (\strtoupper($method)) { - case 'POST': - case 'PUT': - case 'DELETE': - if (\array_key_exists('CONTENT_TYPE', $server) === false) { - $server['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; // Override default content type - } - break; - case 'PATCH': - break; // Do nothing - default: - $request = []; // clear request - break; + $builder = new self( + self::normalizeKey($server), + self::normalizeKey($cookie), + self::normalizeKey($files), + self::normalizeKey($get), + self::normalizeKey($post), + self::normalizeKey($request), + ); + $method = $builder->buildMethod(); + $overrided_method = $builder->buildOverrideMethod(); + if (self::$override_method) { + $method = $overrided_method ?? $method; } + self::$override_method = false; // reset + $builder->normalizeByMethod($method); + $protocol_version = $builder->buildProtocolVersion(); return new ServerRequest( method: $method, uri: self::generateUri($server), - headers: self::generateHeaders($server), + headers: $builder->buildHeaders(), body: self::generateBody($method), - protocol_version: self::generateProtocolVersion($server), - server_params: $server, - cookie_params: self::generateCookieParams($cookie), - query_params: self::generateQueryParams($method, $get, $post, $request), - uploaded_files: self::generateUploadedFiles($files), + protocol_version: $protocol_version, + server_params: $builder->server, + cookie_params: $builder->cookie, + query_params: $builder->get, + uploaded_files: $builder->buildUploadedFiles(), parsed_body: null, // default null attributes: [], // default empty ); } /** - * Generate method from $_SERVER['REQUEST_METHOD'], preserve case - * @param array $server - * @param array $get - * @param array $post + * Find HTTP method * @return string - * @throws RuntimeException + * @see https://github.com/laminas/laminas-diactoros/blob/dfc42c1bddb1b81f11f8093b1daa68a13f481146/src/functions/marshal_method_from_sapi.php */ - private static function generateMethod(array $server, array $get, array $post): string + public function buildMethod(): string { - if ($method = self::findStringFromArray($server, 'HTTP_X_HTTP_METHOD_OVERRIDE')) { - return $method; - } - if ($method = self::findStringFromArray($post, '_method')) { - return $method; - } - if ($method = self::findStringFromArray($get, '_method')) { - return $method; - } - if ($method = self::findStringFromArray($server, 'REQUEST_METHOD')) { - return $method; + $request_method = self::findStringFromArray($this->server, 'REQUEST_METHOD'); + + if (\is_null($request_method)) { + return 'GET'; // @codeCoverageIgnore default } - throw new RuntimeException('REQUEST_METHOD is invalid'); + + return $request_method; } /** - * find and satisfies the value is string - * @param array $str - * @param string $key + * Find override method from the application * @return null|string + * @see https://github.com/symfony/symfony/blob/e99e052f19c8bfa21d48ce2e768d794a113e873e/src/Symfony/Component/HttpFoundation/Request.php#L1135 */ - private static function findStringFromArray(array $str, string $key): ?string + public function buildOverrideMethod(): ?string { - if (\array_key_exists($key, $str)) { - $value = $str[$key]; - if (\is_string($value)) { - return $value; - } + // check override method + $overrided_method = self::findStringFromArray($this->server, 'HTTP_X_HTTP_METHOD_OVERRIDE'); + if (\is_string($overrided_method) && \in_array($overrided_method, self::VALID_HTTP_METHODS, true)) { + return $overrided_method; + } + + // check form method + $get_form_method = self::findStringFromArray($this->get, '_method'); + if (\is_string($get_form_method) && \in_array($get_form_method, self::VALID_HTTP_METHODS, true)) { + return $get_form_method; } + + $post_form_method = self::findStringFromArray($this->post, '_method'); + if (\is_string($post_form_method) && \in_array($post_form_method, self::VALID_HTTP_METHODS, true)) { + return $post_form_method; + } + return null; } - /** - * @param array $server - * @return UriInterface - * @throws MalformedUriException - * @link http://www.faqs.org/rfcs/rfc3875.html - */ - private static function generateUri(array $server): UriInterface + public function buildProtocolVersion(): string { - $uri = new Uri(); - $server_protocol = $server['SERVER_PROTOCOL'] ?? ''; - $https = $server['HTTPS'] ?? ''; - if (\is_string($https) && $https !== '') { - $uri = $uri->withScheme('https'); - } elseif (\is_string($server_protocol)) { - if (\str_starts_with($server_protocol, 'HTTP/')) { - $uri = $uri->withScheme('http'); - } - } - $user = $server['PHP_AUTH_USER'] ?? null; - $pass = $server['PHP_AUTH_PASS'] ?? $server['PHP_AUTH_PW'] ?? null; - if (\is_string($user) && (\is_string($pass) || $pass === null)) { - $uri = $uri->withUserInfo($user, $pass); + $server_protocol = self::findStringFromArray($this->server, 'SERVER_PROTOCOL'); + if ($server_protocol !== null && \str_starts_with($server_protocol, 'HTTP/')) { + return \substr($server_protocol, 5); } - $uri = $uri->withHost($_SERVER['SERVER_NAME'] ?? 'localhost'); - $uri = $uri->withPort($_SERVER['SERVER_PORT'] ?? ($uri->getScheme() === 'https' ? 443 : 80)); - $uri = $uri->withPath($_SERVER['SCRIPT_NAME'] ?? '/'); - $uri = $uri->withQuery($_SERVER['QUERY_STRING'] ?? ''); - return $uri; + throw new RuntimeException(\sprintf('Unknown server protocol: %s', $server_protocol)); } /** * Generate headers from $_SERVER['HTTP_*'] - * @param array $server * @return array */ - private static function generateHeaders(array $server): array + public function buildHeaders(): array { $headers = []; - if ($content_type = self::findStringFromArray($server, 'CONTENT_TYPE')) { + if ($content_type = self::findStringFromArray($this->server, 'CONTENT_TYPE')) { $headers['Content-Type'] = [$content_type]; } - if ($content_length = self::findStringFromArray($server, 'CONTENT_LENGTH')) { + if ($content_length = self::findStringFromArray($this->server, 'CONTENT_LENGTH')) { $headers['Content-Length'] = [$content_length]; } + if ($content_md5 = self::findStringFromArray($this->server, 'CONTENT_MD5')) { + $headers['Content-Md5'] = [$content_md5]; + } - foreach ($server as $key => $value) { + foreach ($this->server as $key => $value) { if (\str_starts_with($key, 'HTTP_')) { - $header_name = \ucwords(\strtolower(\str_replace('_', '-', \substr($key, 5)))); + $header_name = \ucwords(\strtolower(\str_replace('_', '-', \substr($key, 5))), '-'); if (\array_key_exists($header_name, $headers) === false) { $headers[$header_name] = []; } - $headers[$header_name][] = $value; + if (\is_scalar($value)) { + $headers[$header_name][] = $value; + } } } @@ -189,82 +218,228 @@ private static function generateHeaders(array $server): array } /** - * @param string $method - * @return resource|null + * Recursively build uploaded files + * @return array */ - private static function generateBody(string $method): mixed + public function buildUploadedFiles(): array { - $body = match (\strtoupper($method)) { - 'POST', 'PUT', 'DELETE', 'PATCH' => \fopen('php://input', 'r'), - default => null, + $normalized = []; + + /** + * @param array $tmp_names + * @param array $sizes + * @param array $errors + * @param array|null $names + * @param array|null $types + * @return array + */ + $recursivelyNormalizeTree = static function ( + array $tmp_names, + array $sizes, + array $errors, + ?array $names, + ?array $types, + ) use (&$recursivelyNormalizeTree): array { + $normalized = []; + + foreach ($tmp_names as $key => $value) { + if (\is_array($value)) { + \assert(\array_key_exists($key, $sizes)); + \assert(\array_key_exists($key, $errors)); + // recursive + $normalized[$key] = $recursivelyNormalizeTree( + $tmp_names[$key], + $sizes[$key], + $errors[$key], + $names[$key] ?? null, + $types[$key] ?? null, + ); + continue; + } + \assert(\array_key_exists($key, $sizes)); + \assert(\array_key_exists($key, $errors)); + \assert(\is_int($sizes[$key])); + \assert(\is_int($errors[$key])); + $normalized[$key] = new UploadedFile( + $tmp_names[$key], + $sizes[$key], + $errors[$key], + $names[$key] ?? null, + $types[$key] ?? null, + ); + } + + return $normalized; }; - \assert($body === null || \is_resource($body)); // for phpstan - return $body; + + /** + * @param array $files + * @return array + */ + $normalizeUploadedFileTree = static function (array $files) use ($recursivelyNormalizeTree): array { + if (!\array_key_exists('tmp_name', $files) || !\is_array($files['tmp_name'])) { + throw new \InvalidArgumentException('Invalid uploaded files'); // @codeCoverageIgnore + } elseif (!\array_key_exists('size', $files) || !\is_array($files['size'])) { + throw new \InvalidArgumentException('Invalid uploaded files'); // @codeCoverageIgnore + } elseif (!\array_key_exists('error', $files) || !\is_array($files['error'])) { + throw new \InvalidArgumentException('Invalid uploaded files'); // @codeCoverageIgnore + } + + return $recursivelyNormalizeTree( + $files['tmp_name'], + $files['size'], + $files['error'], + $files['name'] ?? null, + $files['type'] ?? null, + ); + }; + + foreach ($this->files as $key => $value) { + if ($value instanceof UploadedFileInterface) { + $normalized[$key] = $value; // @codeCoverageIgnore + continue; // @codeCoverageIgnore + } + + if (\is_array($value)) { + if (\array_key_exists('tmp_name', $value)) { + if (\is_array($value['tmp_name'])) { + // recursive + $normalized[$key] = $normalizeUploadedFileTree($value); + continue; + } + \assert(\array_key_exists('size', $value)); + \assert(\is_int($value['size'])); + \assert(\array_key_exists('error', $value)); + \assert(\is_int($value['error'])); + $normalized[$key] = new UploadedFile( + $value['tmp_name'], + $value['size'], + $value['error'], + $value['name'] ?? null, + $value['type'] ?? null, + ); + continue; + } + } + } + + return $normalized; + } + + private function normalizeByMethod(string $method): void + { + switch (\strtoupper($method)) { + case 'POST': + case 'PUT': + case 'DELETE': + if (\array_key_exists('CONTENT_TYPE', $this->server) === false) { + $this->server['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; // Override default content type + } + break; + case 'PATCH': + break; // Do nothing + default: + $this->post = []; // clear post + $this->request = []; // clear request + break; + } } /** - * @param array $server - * @return string + * Normalize superglobals + * @param array $arr + * @return array */ - private static function generateProtocolVersion(array $server): string + private static function normalizeKey(array $arr): array { - $server_protocol = self::findStringFromArray($server, 'SERVER_PROTOCOL'); - if ($server_protocol !== null && \str_starts_with($server_protocol, 'HTTP/')) { - return \substr($server_protocol, 5); + /** @var array $normalized */ + $normalized = []; + foreach ($arr as $key => $value) { + if (\is_string($key) === false) { + continue; // ignore integer key + } + if ($key === '') { + continue; // ignore empty key + } + $normalized[$key] = $value; } - throw new RuntimeException(\sprintf('Unknown server protocol: %s', $server_protocol)); + return $normalized; } /** - * @param array $cookie - * @return array + * find and satisfies the value is string + * @param array $arr + * @param string $key + * @return ?string */ - private static function generateCookieParams(array $cookie): array + private static function findStringFromArray(array $arr, string $key): ?string { - $cookies = []; - foreach ($cookie as $name => $value) { + if (\array_key_exists($key, $arr)) { + $value = $arr[$key]; if (\is_string($value)) { - $cookies[$name] = $value; + return $value; } } - - return $cookies; + return null; } /** - * @param string $method - * @param array $get - * @param array $post - * @param array $request - * @return array + * @param array $server + * @return UriInterface + * @throws MalformedUriException + * @link http://www.faqs.org/rfcs/rfc3875.html */ - private static function generateQueryParams(string $method, array $get, array $post, array $request): array + private static function generateUri(array $server): UriInterface { - return match (\strtoupper($method)) { - 'GET' => $get, - 'POST', 'PUT', 'DELETE', 'PATCH' => $post, - default => [], - }; + $uri = new Uri(); + $server_protocol = $server['SERVER_PROTOCOL'] ?? ''; + $https = $server['HTTPS'] ?? $server['https'] ?? ''; + if (\is_string($https) && $https !== 'off') { + $uri = $uri->withScheme('https'); + } elseif (\is_string($server_protocol)) { + if (\str_starts_with($server_protocol, 'HTTP/')) { + $uri = $uri->withScheme('http'); + } + } + $user = $server['PHP_AUTH_USER'] ?? null; + $pass = $server['PHP_AUTH_PASS'] ?? $server['PHP_AUTH_PW'] ?? null; + if (\is_string($user) && (\is_string($pass) || $pass === null)) { + $uri = $uri->withUserInfo($user, $pass); + } + if (\array_key_exists('SERVER_NAME', $server) && \is_string($server['SERVER_NAME'])) { + $uri = $uri->withHost((string)$server['SERVER_NAME']); + } else { + $uri = $uri->withHost('localhost'); + } + if (\array_key_exists('SERVER_PORT', $server) && \is_numeric($server['SERVER_PORT'])) { + $uri = $uri->withPort((int)$server['SERVER_PORT']); + } else { + $uri = $uri->withPort($uri->getScheme() === 'https' ? 443 : 80); + } + if (\array_key_exists('SCRIPT_NAME', $server) && \is_string($server['SCRIPT_NAME'])) { + $uri = $uri->withPath((string)$server['SCRIPT_NAME']); + } else { + $uri = $uri->withPath('/'); + } + if (\array_key_exists('QUERY_STRING', $server) && \is_string($server['QUERY_STRING'])) { + $uri = $uri->withQuery((string)$server['QUERY_STRING']); + } else { + $uri = $uri->withQuery(''); + } + return $uri; } /** - * Generate UploadedFiles from $_FILES - * @param array $files - * @return array + * @param string $method + * @return resource|null */ - private static function generateUploadedFiles(array $files): array + private static function generateBody(string $method): mixed { - $uploaded_files = []; - foreach ($files as $name => $file) { - $uploaded_files[$name] = new UploadedFile( - $file['tmp_name'] ?? throw new RuntimeException('$_FILES tmp_name is required'), - $file['size'], - $file['error'] ?? throw new RuntimeException('$_FILES error is required'), - $file['name'], - $file['type'], - ); - } - - return $uploaded_files; + $body = match (\strtoupper($method)) { + 'POST', 'PUT', 'DELETE', 'PATCH' => \fopen('php://input', 'r'), + default => null, + }; + \assert($body === null || \is_resource($body)); // for phpstan + return $body; } }