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;
}
}