diff --git a/.gitignore b/.gitignore index f4686f8..358a9a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /.idea/ /vendor/ /composer.lock +/logs/ +/.phpunit.result.cache diff --git a/README.md b/README.md index 9a0f53c..f89176d 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,25 @@ NB: Custom tests were mostly taken from [Nyholm/psr7](https://github.com/Nyholm/ Benchmarks done with `devanych/psr-http-benchmark` on php 8.0+. -Test results (Intel Xeon Gold 6140 CPU @ 2.30GHz, 4 cores): +Checkout `benchmark` branch, start docker composer, then run in container: + +``` +COMPOSER_ROOT_VERSION=1.0 composer update +``` + +Run benchmarks on php 8.0+: + +``` +cd benchmark/ +php benchmark.php fatfree 50000 +``` + +## Test results (best of 3 on MacBook M2 Pro) | Runs: 50,000 | Guzzle | HttpSoft | Laminas | Nyholm | Slim | Fatfree | |----------------------|-----------|-----------|-----------|-----------|-----------|-----------| -| Runs per second | 18599 | 31938 | 22601 | 27999 | 18789 | 35200 | -| Average time per run | 0.0538 ms | 0.0313 ms | 0.0442 ms | 0.0357 ms | 0.0532 ms | 0.0284 ms | -| Total time | 2.6882 s | 1.5655 s | 2.2122 s | 1.7858 s | 2.6611 s | 1.4204 s | +| Runs per second | 14412 | 18608 | 17641 | 20549 | 14444 | 22233 | +| Average time per run | 0.0694 ms | 0.0537 ms | 0.0567 ms | 0.0487 ms | 0.0692 ms | 0.0450 ms | +| Total time | 3.4691 s | 2.6869 s | 2.8342 s | 2.4331 s | 3.4616 s | 2.2488 s | --- diff --git a/composer.json b/composer.json index d8e0b54..38ca004 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "type": "library", "require": { "php": ">=8.0", - "psr/http-message": "^1.0", + "psr/http-message": "^1.1 || ^2.0", "psr/http-factory": "^1.0", "php-http/message-factory": "^1.0" }, @@ -17,13 +17,13 @@ } ], "provide": { - "psr/http-message-implementation": "1.0", + "psr/http-message-implementation": "2.*", "psr/http-factory-implementation": "1.0" }, "require-dev": { - "phpunit/phpunit": "^9.5", - "php-http/psr7-integration-tests": "dev-master", - "http-interop/http-factory-tests": "^0.9.0" + "phpunit/phpunit": "^9.6 || ^10.5 || ^11.5", + "php-http/psr7-integration-tests": "1.*", + "http-interop/http-factory-tests": "^2.2.0" }, "autoload": { "psr-4": { @@ -36,6 +36,6 @@ } }, "scripts": { - "test": "phpunit" + "test": "phpunit --display-deprecations" } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..713edeb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +networks: + internal: +services: + webserver: + container_name: 'f3-psr7' + build: + context: ./docker/bin/php + restart: 'always' + networks: + - internal + volumes: + - ${DOCUMENT_ROOT-./}:/var/www/html:rw + - ${PHP_INI-./docker/config/php/php.ini}:/usr/local/etc/php/php.ini + - ${VHOSTS_DIR-./docker/config/vhosts}:/etc/apache2/sites-enabled + - ${LOG_DIR-./logs/apache2}:/var/log/apache2 + environment: + XDEBUG_MODE: debug + XDEBUG_CONFIG: client_host=host.docker.internal + ports: + - "80:80" + external_links: + - webserver:f3-psr7.localhost \ No newline at end of file diff --git a/docker/bin/php/Dockerfile b/docker/bin/php/Dockerfile new file mode 100644 index 0000000..76fb6ed --- /dev/null +++ b/docker/bin/php/Dockerfile @@ -0,0 +1,59 @@ +FROM php:8.2-apache + +ARG DEBIAN_FRONTEND=noninteractive + +# Update +RUN apt-get -y update --fix-missing && \ + apt-get upgrade -y && \ + apt-get --no-install-recommends install -y apt-utils && \ + rm -rf /var/lib/apt/lists/* + +# Install tools and libaries +RUN apt-get -y update && \ + apt-get -y --no-install-recommends install --fix-missing \ + wget \ + dialog \ + locales \ + zlib1g-dev \ + libzip-dev \ + libicu-dev && \ + apt-get -y --no-install-recommends install --fix-missing apt-utils \ + build-essential \ + git \ + curl \ + libonig-dev && \ + apt-get -y --no-install-recommends install --fix-missing libcurl4 \ + libcurl4-openssl-dev \ + zip \ + unzip \ + openssl && \ + rm -rf /var/lib/apt/lists/* && \ + curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + +# Set the locales +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && \ + locale-gen + +ENV LANG=en_US.UTF-8 \ + LANGUAGE=en_US:en \ + LC_ALL=en_US.UTF-8 + +RUN pecl channel-update pecl.php.net + +# Other PHP extensions +RUN docker-php-ext-install -j$(nproc) intl +RUN docker-php-ext-install zip + +# Install XDebug +RUN pecl install xdebug \ + && docker-php-ext-enable xdebug +COPY docker-php-ext-xdebug.ini /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini + +# Enable apache modules +RUN a2enmod rewrite headers ssl + +# Cleanup +RUN rm -rf /usr/src/* + +RUN usermod -u 1000 www-data +RUN chown -R www-data:www-data /var/www/html diff --git a/docker/bin/php/docker-php-ext-xdebug.ini b/docker/bin/php/docker-php-ext-xdebug.ini new file mode 100644 index 0000000..c20c425 --- /dev/null +++ b/docker/bin/php/docker-php-ext-xdebug.ini @@ -0,0 +1,4 @@ +zend_extension=xdebug.so +xdebug.mode = off +xdebug.log_level=0 +xdebug.output_dir=/var/www/html/logs diff --git a/docker/config/php/php.ini b/docker/config/php/php.ini new file mode 100644 index 0000000..c83bb51 --- /dev/null +++ b/docker/config/php/php.ini @@ -0,0 +1,2 @@ +memory_limit = 32M +short_open_tag = 0 diff --git a/docker/config/vhosts/default.conf b/docker/config/vhosts/default.conf new file mode 100644 index 0000000..139ee49 --- /dev/null +++ b/docker/config/vhosts/default.conf @@ -0,0 +1,8 @@ + + ServerAdmin webmaster@localhost + DocumentRoot "/var/www/html" + ServerName localhost + + AllowOverride all + + diff --git a/src/Http/Factory/Psr17Factory.php b/src/Http/Factory/Psr17Factory.php index 886472b..d19cb69 100644 --- a/src/Http/Factory/Psr17Factory.php +++ b/src/Http/Factory/Psr17Factory.php @@ -45,7 +45,7 @@ public function createStream(string $content = ''): StreamInterface { } public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface { - if (($resource = @\fopen($filename, $mode)) === false) { + if (!$filename || ($resource = @\fopen($filename, $mode)) === false) { throw new \RuntimeException('Unable to to open file'); } return new Stream($resource); diff --git a/src/Http/Message.php b/src/Http/Message.php index 92da319..9659514 100644 --- a/src/Http/Message.php +++ b/src/Http/Message.php @@ -6,11 +6,11 @@ class Message implements MessageInterface { - protected ?string $version = '1.1'; + protected string $version = '1.1'; protected array $headers = []; protected ?StreamInterface $body = NULL; - public function getProtocolVersion(): ?string { + public function getProtocolVersion(): string { return $this->version; } @@ -106,7 +106,7 @@ public function withoutHeader($name): static { return $new; } - public function getBody(): ?StreamInterface { + public function getBody(): StreamInterface { return $this->body; } diff --git a/src/Http/Stream.php b/src/Http/Stream.php index 15a6643..69a4c78 100644 --- a/src/Http/Stream.php +++ b/src/Http/Stream.php @@ -98,8 +98,8 @@ public function rewind(): void { public function isWritable(): bool { if ($this->writable === NULL) { - $mode=$this->getMetadata('mode'); - $this->writable = str_contains($mode,'w') || str_contains($mode,'+') || str_contains($mode,'x') || str_contains($mode,'c') || str_contains($mode,'a'); + $this->writable = ($mode=$this->getMetadata('mode')) + && (\str_contains($mode,'w') || \str_contains($mode,'+') || \str_contains($mode,'x') || \str_contains($mode,'c') || \str_contains($mode,'a')); } return $this->writable; } @@ -115,8 +115,8 @@ public function write($string): int { public function isReadable(): bool { if ($this->readable === NULL) { - $mode=$this->getMetadata('mode'); - $this->readable = \str_contains($mode,'r') || \str_contains($mode,'+'); + $this->readable = ($mode=$this->getMetadata('mode')) + && (\str_contains($mode,'r') || \str_contains($mode,'+')); } return $this->readable; } diff --git a/src/Http/Uri.php b/src/Http/Uri.php index 808ac43..16e4a4a 100644 --- a/src/Http/Uri.php +++ b/src/Http/Uri.php @@ -49,7 +49,7 @@ public function getAuthority(): string { } public function getUserInfo(): string { - return $this->user.($this->pass!==''?':'.$this->pass:''); + return ($this->user).($this->pass!==''?':'.($this->pass):''); } public function getHost(): string { @@ -64,7 +64,10 @@ public function getPort(): ?int { } === $this->port ? null : $this->port; } - public function getPath(): string { + public function getPath(bool $trim=true): string { + if ($trim && $this->host && \str_starts_with($this->path, '/')) { + return '/'.\ltrim($this->path, '/'); + } return $this->path; } @@ -86,8 +89,11 @@ public function withScheme($scheme): Uri { public function withUserInfo($user, $password = NULL): Uri { $new = clone $this; - $new->user = $user; + $pattern = '/[^%a-zA-Z0-9_\-.~!$&\'()*+,;=]+|%(?![A-Fa-f0-9]{2})/'; + $new->user = \preg_match($pattern, $user) ? \rawurlencode($user) : $user; $new->pass = (string) $password; + if (\preg_match($pattern, $new->pass)) + $new->pass = \rawurlencode($new->pass); return $new; } @@ -131,10 +137,10 @@ public function withFragment($fragment): Uri { return $new; } - public function __toString() { + public function __toString(): string { return (($s=$this->getScheme()) !== '' ? $s.':' : ''). (($a=$this->getAuthority()) !== '' ? '//'.$a : ''). - (($p=$this->getPath()) !=='' ? ( + (($p=$this->getPath(false)) !=='' ? ( (!($abs=\str_starts_with($p, '/')) && $a !== '' ? '/' : ''). ($abs && $a === '' ? '/'.\ltrim($p, '/') : $p) ) : ''). diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index c530d59..b1852d4 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -232,7 +232,7 @@ public function testSameInstanceWhenRemovingMissingHeader() $this->assertSame($r, $r->withoutHeader('foo')); } - public function trimmedHeaderValues() + public static function trimmedHeaderValues(): array { return [ [(new Response())->withHeaders(['OWS' => " \t \tFoo\t \t "])], diff --git a/tests/UploadedFileTest.php b/tests/UploadedFileTest.php index 6b76e07..756bbee 100644 --- a/tests/UploadedFileTest.php +++ b/tests/UploadedFileTest.php @@ -26,7 +26,7 @@ public function tearDown(): void } } - public function invalidErrorStatuses() + public static function invalidErrorStatuses(): array { return [ 'null' => [null], @@ -87,7 +87,7 @@ public function testSuccessful() $this->assertEquals($stream->__toString(), file_get_contents($to)); } - public function invalidMovePaths() + public static function invalidMovePaths(): array { return [ 'null' => [null], @@ -144,7 +144,7 @@ public function testCannotRetrieveStreamAfterMove() $upload->getStream(); } - public function nonOkErrorStatus() + public static function nonOkErrorStatus(): array { return [ 'UPLOAD_ERR_INI_SIZE' => [UPLOAD_ERR_INI_SIZE], diff --git a/tests/UriTest.php b/tests/UriTest.php index 8b0b2a4..04e4500 100644 --- a/tests/UriTest.php +++ b/tests/UriTest.php @@ -56,7 +56,7 @@ public function testValidUrisStayValid($input) $this->assertSame($input, (string) $uri); } - public function getValidUris() + public static function getValidUris(): array { return [ ['urn:path-rootless'], @@ -97,7 +97,7 @@ public function testInvalidUrisThrowException($invalidUri) new Uri($invalidUri); } - public function getInvalidUris() + public static function getInvalidUris(): array { return [ // parse_url() requires the host component which makes sense for http(s) @@ -216,7 +216,7 @@ public function testCanConstructFalseyUriParts() $this->assertSame('0://0:0@0/0?0#0', (string) $uri); } - public function getResolveTestCases() + public static function getResolveTestCases(): array { return [ [self::RFC3986_BASE, 'g:h', 'g:h'], @@ -366,7 +366,7 @@ public function testAuthorityWithUserInfoButWithoutHost() $this->assertSame('', $uri->getAuthority()); } - public function uriComponentsEncodingProvider() + public static function uriComponentsEncodingProvider(): array { $unreserved = 'a-zA-Z0-9.-_~!$&\'()*+,;=:@';