initial commit

This commit is contained in:
crazywhalecc 2022-12-26 19:10:28 +08:00
commit 1f4427b5b7
27 changed files with 2326 additions and 0 deletions

54
.gitignore vendored Normal file
View File

@ -0,0 +1,54 @@
### Composer ###
composer.phar
/vendor/
composer.lock
# CGHooks
cghooks.lock
# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
# composer.lock
### Git ###
# Created by git for backups. To disable backups in Git:
# $ git config --global mergetool.keepBackup false
*.orig
# Created by git when using merge tools for conflicts
*.BACKUP.*
*.BASE.*
*.LOCAL.*
*.REMOTE.*
*_BACKUP_*.txt
*_BASE_*.txt
*_LOCAL_*.txt
*_REMOTE_*.txt
### PhpStorm ###
/.idea
### VisualStudioCode ###
/.vscode
*.code-workspace
# Local History for Visual Studio Code
.history/
# Ignore all local history of files
.history
.ionide
.phpunit.result.cache
### ASDF ###
.tool-versions
### Phive ###
tools
.phive
### pcov coverage report
build/
data/

82
.php-cs-fixer.php Normal file
View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
return (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->setRules([
'@PSR12' => true,
'@Symfony' => true,
'@PhpCsFixer' => true,
'array_syntax' => [
'syntax' => 'short',
],
'list_syntax' => [
'syntax' => 'short',
],
'concat_space' => [
'spacing' => 'one',
],
'blank_line_before_statement' => [
'statements' => [
'declare',
],
],
'general_phpdoc_annotation_remove' => [
'annotations' => [
'author',
],
],
'ordered_imports' => [
'imports_order' => [
'class',
'function',
'const',
],
'sort_algorithm' => 'alpha',
],
'single_line_comment_style' => [
'comment_types' => [
],
],
'yoda_style' => [
'always_move_variable' => false,
'equal' => false,
'identical' => false,
],
'phpdoc_align' => true,
'multiline_whitespace_before_semicolons' => [
'strategy' => 'no_multi_line',
],
'constant_case' => [
'case' => 'lower',
],
'class_attributes_separation' => true,
'combine_consecutive_unsets' => true,
'declare_strict_types' => true,
'linebreak_after_opening_tag' => true,
'lowercase_static_reference' => true,
'no_useless_else' => true,
'no_unused_imports' => true,
'not_operator_with_successor_space' => false,
'not_operator_with_space' => false,
'ordered_class_elements' => true,
'php_unit_strict' => false,
'phpdoc_separation' => false,
'single_quote' => true,
'standardize_not_equals' => true,
'multiline_comment_opening_closing' => true,
'phpdoc_summary' => false,
'types_spaces' => false,
'braces' => false,
'blank_line_between_import_groups' => false,
'phpdoc_order' => ['order' => ['param', 'throws', 'return']],
'php_unit_test_class_requires_covers' => false,
])
->setFinder(
PhpCsFixer\Finder::create()
->exclude('vendor')
->exclude('docs')
->in(__DIR__)
)
->setUsingCache(false);

70
composer.json Normal file
View File

@ -0,0 +1,70 @@
{
"name": "choir/psr-http",
"description": "PHP 写的 Socket Server 库的 psr-http 实现部分",
"license": "MIT",
"type": "library",
"keywords": [
"php",
"workerman",
"choir",
"swoole"
],
"authors": [
{
"name": "crazywhalecc",
"email": "crazywhalecc@163.com"
}
],
"require": {
"php": "^7.4 || ^8.0 || ^8.1 || ^8.2",
"psr/http-client": "^1.0"
},
"require-dev": {
"brainmaestro/composer-git-hooks": "^2.8",
"friendsofphp/php-cs-fixer": "^3.2",
"phpstan/phpstan": "^1.1",
"phpunit/phpunit": "^9.0 || ^8.0",
"swoole/ide-helper": "^4.8",
"symfony/var-dumper": "^5.3"
},
"minimum-stability": "dev",
"prefer-stable": true,
"autoload": {
"psr-4": {
"Choir\\": "src/Choir"
},
"files": [
"src/Choir/globals.php"
]
},
"autoload-dev": {
"psr-4": {
"Tests\\Choir\\": "tests/Choir"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
},
"extra": {
"hooks": {
"post-merge": "composer install",
"pre-commit": [
"echo committing as $(git config user.name)",
"composer cs-fix -- --diff"
],
"pre-push": [
"composer cs-fix -- --dry-run --diff",
"composer analyse"
]
}
},
"scripts": {
"post-install-cmd": [
"[ $COMPOSER_DEV_MODE -eq 0 ] || vendor/bin/cghooks add"
],
"analyse": "phpstan analyse --memory-limit 300M",
"cs-fix": "php-cs-fixer fix",
"test": "phpunit --no-coverage"
}
}

9
phpstan.neon Normal file
View File

@ -0,0 +1,9 @@
parameters:
reportUnmatchedIgnoredErrors: false
treatPhpDocTypesAsCertain: false
level: 4
paths:
- ./src/
ignoreErrors:
- '#OS_TYPE_(LINUX|WINDOWS) not found#'
- '#class Fiber#'

33
phpunit.xml.dist Normal file
View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="./tests/bootstrap.php"
backupGlobals="false"
backupStaticAttributes="false"
colors="true"
convertDeprecationsToExceptions="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnError="false"
stopOnFailure="false"
testdox="true"
verbose="true"
>
<testsuites>
<testsuite name="Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">./src/Choir</directory>
</include>
<report>
<html outputDirectory="./build/html-coverage"/>
<clover outputFile="./build/coverage.xml"/>
</report>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
</php>
</phpunit>

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Choir\Http;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
/**
* PSR Http 工厂函数,用于快速构造相关对象
*/
class HttpFactory
{
/**
* 创建一个符合 PSR-7 Request 对象
*
* @param string $method HTTP 请求方法
* @param string|UriInterface $uri 传入的 URI可传入字符串或 Uri 对象
* @param array<string, array|string> $headers 请求头列表
* @param null|resource|StreamInterface|string $body HTTP 包体,可传入 Stream、resource、字符串等
* @param string $protocolVersion HTTP 协议版本
*/
public static function createRequest(string $method, $uri, array $headers = [], $body = null, string $protocolVersion = '1.1'): RequestInterface
{
return new Request($method, $uri, $headers, $body, $protocolVersion);
}
/**
* 创建一个符合 PSR-7 ServerRequest 对象
*
* @param string $method HTTP 请求方法
* @param string|UriInterface $uri 传入的 URI可传入字符串或 Uri 对象
* @param array<string, array|string> $headers 请求头列表
* @param null|resource|StreamInterface|string $body HTTP 包体,可传入 Stream、resource、字符串等
* @param string $version HTTP 协议版本
* @param array $serverParams 服务器请求需要的额外参数
*/
public static function createServerRequest(string $method, $uri, array $headers = [], $body = null, string $version = '1.1', array $serverParams = [], array $queryParams = []): ServerRequestInterface
{
return new ServerRequest($method, $uri, $headers, $body, $version, $serverParams, $queryParams);
}
/**
* 创建一个符合 PSR-7 Response 对象
*
* @param int|string $statusCode HTTP 的响应状态码
* @param mixed|string $reasonPhrase HTTP 响应简短语句
* @param array<string, array|string> $headers HTTP 响应头列表
* @param null|resource|StreamInterface|string $body HTTP 包体
* @param string $protocolVersion HTTP 协议版本
*/
public static function createResponse($statusCode = 200, $reasonPhrase = null, array $headers = [], $body = null, string $protocolVersion = '1.1'): ResponseInterface
{
return new Response((int) $statusCode, $headers, $body, $protocolVersion, $reasonPhrase);
}
/**
* 创建一个符合 PSR-7 Stream 对象
*
* @param null|resource|StreamInterface|string $body 传入的数据
* @throws \InvalidArgumentException 如果传入的类型非法,则会抛出此异常
* @throws \RuntimeException 如果创建 Stream 对象失败,则会抛出此异常
*/
public static function createStream($body = null): StreamInterface
{
return Stream::create($body ?? '');
}
/**
* 创建一个符合 PSR-7 Uri 对象
*
* @param string|UriInterface $uri URI 对象或字符串
* @throws \InvalidArgumentException 如果解析 URI 失败,则抛出此异常
*/
public static function createUri($uri): UriInterface
{
if ($uri instanceof UriInterface) {
return $uri;
}
return new Uri($uri);
}
}

View File

@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Choir\Http;
use Psr\Http\Message\StreamInterface;
trait MessageTrait
{
/** @var array Map of all registered headers, as original name => array of values */
private array $headers = [];
/** @var array Map of lowercase header name => original name at registration */
private array $headerNames = [];
private string $protocol = '1.1';
private ?StreamInterface $stream = null;
public function getProtocolVersion(): string
{
return $this->protocol;
}
public function withProtocolVersion($version): self
{
$new = clone $this;
$new->protocol = $version;
return $new;
}
public function getHeaders(): array
{
return $this->headers;
}
public function hasHeader($header): bool
{
return isset($this->headerNames[\strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')]);
}
public function getHeader($header): array
{
$header = \strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
if (!isset($this->headerNames[$header])) {
return [];
}
$header = $this->headerNames[$header];
return $this->headers[$header];
}
public function getHeaderLine($header): string
{
return \implode(', ', $this->getHeader($header));
}
public function withHeader($header, $value): self
{
$value = $this->validateAndTrimHeader($header, $value);
$normalized = \strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
$new = clone $this;
$new->headerNames[$normalized] = $header;
$new->headers[$header] = $value;
return $new;
}
public function withAddedHeader($header, $value): self
{
if (!\is_string($header) || $header === '') {
throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string.');
}
$new = clone $this;
$new->setHeaders([$header => $value]);
return $new;
}
public function withoutHeader($header): self
{
$normalized = \strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
$new = clone $this;
unset($new->headers[$header], $new->headerNames[$normalized]);
return $new;
}
public function getBody(): StreamInterface
{
if ($this->stream === null) {
$this->stream = Stream::create();
}
$this->stream->rewind();
return $this->stream;
}
public function withBody(StreamInterface $body): self
{
$new = clone $this;
$new->stream = $body;
return $new;
}
private function setHeaders(array $headers): void
{
foreach ($headers as $header => $value) {
if (\is_int($header)) {
// If a header name was set to a numeric string, PHP will cast the key to an int.
// We must cast it back to a string in order to comply with validation.
$header = (string) $header;
}
$value = $this->validateAndTrimHeader($header, $value);
$normalized = \strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
if (isset($this->headerNames[$normalized])) {
$header = $this->headerNames[$normalized];
$this->headers[$header] = \array_merge($this->headers[$header], $value);
} else {
$this->headerNames[$normalized] = $header;
$this->headers[$header] = $value;
}
}
}
/**
* Make sure the header complies with RFC 7230.
*
* Header names must be a non-empty string consisting of token characters.
*
* Header values must be strings consisting of visible characters with all optional
* leading and trailing whitespace stripped. This method will always strip such
* optional whitespace. Note that the method does not allow folding whitespace within
* the values as this was deprecated for almost all instances by the RFC.
*
* header-field = field-name ":" OWS field-value OWS
* field-name = 1*( "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^"
* / "_" / "`" / "|" / "~" / %x30-39 / ( %x41-5A / %x61-7A ) )
* OWS = *( SP / HTAB )
* field-value = *( ( %x21-7E / %x80-FF ) [ 1*( SP / HTAB ) ( %x21-7E / %x80-FF ) ] )
*
* @see https://tools.ietf.org/html/rfc7230#section-3.2.4
* @param mixed $header
* @param mixed $values
*/
private function validateAndTrimHeader($header, $values): array
{
if (!\is_string($header) || \preg_match("@^[!#$%&'*+.^_`|~\\dA-Za-z-]+$@", $header) !== 1) {
throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string.');
}
if (!\is_array($values)) {
// This is simple, just one value.
if ((!\is_numeric($values) && !\is_string($values)) || \preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string) $values) !== 1) {
throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings.');
}
return [\trim((string) $values, " \t")];
}
if (empty($values)) {
throw new \InvalidArgumentException('Header values must be a string or an array of strings, empty array given.');
}
// Assert Non-empty array
$returnValues = [];
foreach ($values as $v) {
if ((!\is_numeric($v) && !\is_string($v)) || \preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string) $v) !== 1) {
throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings.');
}
$returnValues[] = \trim((string) $v, " \t");
}
return $returnValues;
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Choir\Http;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
class Request implements RequestInterface
{
use RequestTrait;
/**
* @param string $method HTTP method
* @param string|UriInterface $uri URI
* @param array $headers Request headers
* @param null|resource|StreamInterface|string $body Request body
* @param string $version Protocol version
*/
public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1')
{
if (!$uri instanceof UriInterface) {
$uri = new Uri($uri);
}
$this->method = $method;
$this->uri = $uri;
$this->setHeaders($headers);
$this->protocol = $version;
if (!$this->hasHeader('Host')) {
$this->updateHostFromUri();
}
// If we got nobody, defer initialization of the stream until Request::getBody()
if ($body !== '' && $body !== null) {
$this->stream = Stream::create($body);
}
}
}

View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Choir\Http;
use Psr\Http\Message\UriInterface;
trait RequestTrait
{
use MessageTrait;
private string $method;
private ?string $requestTarget = null;
private ?UriInterface $uri;
public function getRequestTarget(): string
{
if ($this->requestTarget !== null) {
return $this->requestTarget;
}
if ('' === $target = $this->uri->getPath()) {
$target = '/';
}
if ($this->uri->getQuery() !== '') {
$target .= '?' . $this->uri->getQuery();
}
return $target;
}
public function withRequestTarget($requestTarget): self
{
if (\preg_match('#\s#', $requestTarget)) {
throw new \InvalidArgumentException('Invalid request target provided; cannot contain whitespace');
}
$new = clone $this;
$new->requestTarget = $requestTarget;
return $new;
}
public function getMethod(): string
{
return $this->method;
}
public function withMethod($method): self
{
if (!\is_string($method)) {
throw new \InvalidArgumentException('Method must be a string');
}
$new = clone $this;
$new->method = $method;
return $new;
}
public function getUri(): UriInterface
{
return $this->uri;
}
public function withUri(UriInterface $uri, $preserveHost = false): self
{
$new = clone $this;
$new->uri = $uri;
if (!$preserveHost || !$this->hasHeader('Host')) {
$new->updateHostFromUri();
}
return $new;
}
/**
* Uri 或原始 Header 中更新 Host Header有些请求例如 ServerRequest 必须第一个 Header Host
*/
private function updateHostFromUri(): void
{
if ('' === ($host = $this->uri->getHost())) {
return;
}
if (null !== ($port = $this->uri->getPort())) {
$host .= ':' . $port;
}
if (isset($this->headerNames['host'])) {
$header = $this->headerNames['host'];
} else {
$this->headerNames['host'] = $header = 'Host';
}
// Ensure Host is the first header.
// See: http://tools.ietf.org/html/rfc7230#section-5.4
$this->headers = [$header => [$host]] + $this->headers;
}
}

134
src/Choir/Http/Response.php Normal file
View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Choir\Http;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
class Response implements ResponseInterface
{
use MessageTrait;
/** @var array Map of standard HTTP status code/reason phrases */
private const PHRASES = [
100 => 'Continue', 101 => 'Switching Protocols', 102 => 'Processing',
200 => 'OK', 201 => 'Created', 202 => 'Accepted', 203 => 'Non-Authoritative Information', 204 => 'No Content', 205 => 'Reset Content', 206 => 'Partial Content', 207 => 'Multi-status', 208 => 'Already Reported',
300 => 'Multiple Choices', 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified', 305 => 'Use Proxy', 306 => 'Switch Proxy', 307 => 'Temporary Redirect',
400 => 'Bad Request', 401 => 'Unauthorized', 402 => 'Payment Required', 403 => 'Forbidden', 404 => 'Not Found', 405 => 'Method Not Allowed', 406 => 'Not Acceptable', 407 => 'Proxy Authentication Required', 408 => 'Request Time-out', 409 => 'Conflict', 410 => 'Gone', 411 => 'Length Required', 412 => 'Precondition Failed', 413 => 'Request Entity Too Large', 414 => 'Request-URI Too Large', 415 => 'Unsupported Media Type', 416 => 'Requested range not satisfiable', 417 => 'Expectation Failed', 418 => 'I\'m a teapot', 422 => 'Unprocessable Entity', 423 => 'Locked', 424 => 'Failed Dependency', 425 => 'Unordered Collection', 426 => 'Upgrade Required', 428 => 'Precondition Required', 429 => 'Too Many Requests', 431 => 'Request Header Fields Too Large', 451 => 'Unavailable For Legal Reasons',
500 => 'Internal Server Error', 501 => 'Not Implemented', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Time-out', 505 => 'HTTP Version not supported', 506 => 'Variant Also Negotiates', 507 => 'Insufficient Storage', 508 => 'Loop Detected', 511 => 'Network Authentication Required',
];
private string $str_cache = '';
/** @var string */
private $reasonPhrase;
/** @var int */
private $statusCode;
/**
* @param int|string $status Status code
* @param array $headers Response headers
* @param null|resource|StreamInterface|string $body Response body
* @param string $version Protocol version
* @param null|string $reason Reason phrase (when empty a default will be used based on the status code)
*/
public function __construct($status = 200, array $headers = [], $body = null, string $version = '1.1', string $reason = null)
{
// If we got nobody, defer initialization of the stream until Response::getBody()
if ($body !== '' && $body !== null) {
$this->stream = Stream::create($body);
}
if (\is_string($status)) {
$status = (int) $status;
}
$this->statusCode = $status;
$this->setHeaders($headers);
if ($reason === null && isset(self::PHRASES[$this->statusCode])) {
$this->reasonPhrase = self::PHRASES[$status];
} else {
$this->reasonPhrase = $reason ?? '';
}
$this->protocol = $version;
}
public function __toString()
{
if ($this->str_cache !== '') {
return $this->str_cache;
}
$reason = $this->reasonPhrase;
$this->getBody()->rewind();
$body_len = $this->getBody()->getSize();
$this->getBody()->rewind();
if (empty($this->headers)) {
return $this->str_cache = "HTTP/{$this->protocol} {$this->statusCode} {$reason}\r\nContent-Type: text/html;charset=utf-8\r\nContent-Length: {$body_len}\r\nConnection: keep-alive\r\n\r\n{$this->getBody()->getContents()}";
}
$head = "HTTP/{$this->protocol} {$this->statusCode} {$reason}\r\n";
$headers = $this->headers;
foreach ($headers as $name => $value) {
if (\is_array($value)) {
foreach ($value as $item) {
$head .= "{$name}: {$item}\r\n";
}
continue;
}
$head .= "{$name}: {$value}\r\n";
}
if (!isset($headers['Connection'])) {
$head .= "Connection: keep-alive\r\n";
}
if (!isset($headers['Content-Type'])) {
$head .= "Content-Type: text/html;charset=utf-8\r\n";
} elseif ($headers['Content-Type'] === 'text/event-stream') {
return $this->str_cache = $head . $this->getBody()->getContents();
}
if (!isset($headers['Transfer-Encoding'])) {
$head .= "Content-Length: {$body_len}\r\n\r\n";
} else {
return $this->str_cache = "{$head}\r\n" . dechex($body_len) . "\r\n{$this->getBody()->getContents()}\r\n";
}
// The whole http package
return $this->str_cache = $head . $this->getBody()->getContents();
}
public function getStatusCode(): int
{
return $this->statusCode;
}
public function getReasonPhrase(): string
{
return $this->reasonPhrase;
}
public function withStatus($code, $reasonPhrase = ''): self
{
if (!\is_int($code) && !\is_string($code)) {
throw new \InvalidArgumentException('Status code has to be an integer');
}
$code = (int) $code;
if ($code < 100 || $code > 599) {
throw new \InvalidArgumentException(\sprintf('Status code has to be an integer between 100 and 599. A status code of %d was given', $code));
}
$new = clone $this;
$new->statusCode = $code;
if (($reasonPhrase === null || $reasonPhrase === '') && isset(self::PHRASES[$new->statusCode])) {
$reasonPhrase = self::PHRASES[$new->statusCode];
}
$new->reasonPhrase = $reasonPhrase;
return $new;
}
}

View File

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Choir\Http;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\UriInterface;
class ServerRequest implements ServerRequestInterface
{
use RequestTrait;
private array $attributes = [];
private array $cookieParams = [];
/** @var null|array|object */
private $parsedBody;
private array $queryParams;
private array $serverParams;
/** @var UploadedFileInterface[] */
private array $uploadedFiles = [];
/**
* @param string $method HTTP method
* @param string|UriInterface $uri URI
* @param array $headers Request headers
* @param null|resource|StreamInterface|string $body Request body
* @param string $version Protocol version
*/
public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1', array $serverParams = [], array $queryParams = [])
{
$this->serverParams = $serverParams;
$this->queryParams = $queryParams;
if (!$uri instanceof UriInterface) {
$uri = new Uri($uri);
}
$this->method = $method;
$this->uri = $uri;
$this->setHeaders($headers);
$this->protocol = $version;
if (!$this->hasHeader('Host')) {
$this->updateHostFromUri();
}
// If we got nobody, defer initialization of the stream until Request::getBody()
if ($body !== '' && $body !== null) {
$this->stream = Stream::create($body);
}
}
public function getServerParams(): array
{
return $this->serverParams;
}
public function getUploadedFiles(): array
{
return $this->uploadedFiles;
}
public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface
{
$new = clone $this;
$new->uploadedFiles = $uploadedFiles;
return $new;
}
public function getCookieParams(): array
{
return $this->cookieParams;
}
public function withCookieParams(array $cookies): ServerRequestInterface
{
$new = clone $this;
$new->cookieParams = $cookies;
return $new;
}
public function getQueryParams(): array
{
return $this->queryParams;
}
public function withQueryParams(array $query): ServerRequestInterface
{
$new = clone $this;
$new->queryParams = $query;
return $new;
}
public function getParsedBody()
{
return $this->parsedBody;
}
/**
* @param null|array|object $data
*/
public function withParsedBody($data): ServerRequestInterface
{
if (!\is_array($data) && !\is_object($data) && $data !== null) {
throw new \InvalidArgumentException('First parameter to withParsedBody MUST be object, array or null');
}
$new = clone $this;
$new->parsedBody = $data;
return $new;
}
public function getAttributes(): array
{
return $this->attributes;
}
/**
* @param mixed $name
* @param null|mixed $default
* @return mixed
*/
public function getAttribute($name, $default = null)
{
if (\array_key_exists($name, $this->attributes) === false) {
return $default;
}
return $this->attributes[$name];
}
public function withAttribute($name, $value): self
{
$new = clone $this;
$new->attributes[$name] = $value;
return $new;
}
public function withoutAttribute($name): self
{
if (\array_key_exists($name, $this->attributes) === false) {
return $this;
}
$new = clone $this;
unset($new->attributes[$name]);
return $new;
}
}

312
src/Choir/Http/Stream.php Normal file
View File

@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace Choir\Http;
use Psr\Http\Message\StreamInterface;
class Stream implements StreamInterface
{
/** @var array Hash of readable and writable stream types */
private const READ_WRITE_HASH = [
'read' => [
'r' => true, 'w+' => true, 'r+' => true, 'x+' => true, 'c+' => true,
'rb' => true, 'w+b' => true, 'r+b' => true, 'x+b' => true,
'c+b' => true, 'rt' => true, 'w+t' => true, 'r+t' => true,
'x+t' => true, 'c+t' => true, 'a+' => true,
],
'write' => [
'w' => true, 'w+' => true, 'rw' => true, 'r+' => true, 'x+' => true,
'c+' => true, 'wb' => true, 'w+b' => true, 'r+b' => true,
'x+b' => true, 'c+b' => true, 'w+t' => true, 'r+t' => true,
'x+t' => true, 'c+t' => true, 'a' => true, 'a+' => true,
],
];
/** @var null|resource A resource reference */
private $stream;
private bool $seekable;
private bool $readable;
private bool $writable;
/** @var null|array|bool|mixed|void */
private $uri;
private ?int $size = null;
private function __construct()
{
}
/**
* Closes the stream when the destructed.
*/
public function __destruct()
{
$this->close();
}
/**
* @throws \Throwable
* @return string
*/
public function __toString()
{
try {
if ($this->isSeekable()) {
$this->seek(0);
}
return $this->getContents();
} catch (\Throwable $e) {
if (\PHP_VERSION_ID >= 70400) {
throw $e;
}
\restore_error_handler();
if ($e instanceof \Error) {
\trigger_error((string) $e, \E_USER_ERROR);
}
return '';
}
}
/**
* Creates a new PSR-7 stream.
*
* @param mixed|resource|StreamInterface|string $body
*
* @throws \InvalidArgumentException
*/
public static function create($body = ''): StreamInterface
{
if ($body instanceof StreamInterface) {
return $body;
}
if (\is_string($body)) {
// 此处使用 b 模式(二进制),以提高跨平台兼容性
$resource = \fopen('php://temp', 'rwb+');
if ($resource === false) {
throw new \RuntimeException('Unable to create stream');
}
\fwrite($resource, $body);
rewind($resource);
$body = $resource;
}
if (\is_resource($body)) {
$new = new self();
$new->stream = $body;
$meta = \stream_get_meta_data($new->stream);
$new->seekable = $meta['seekable'] && \fseek($new->stream, 0, \SEEK_CUR) === 0;
$new->readable = isset(self::READ_WRITE_HASH['read'][$meta['mode']]);
$new->writable = isset(self::READ_WRITE_HASH['write'][$meta['mode']]);
return $new;
}
throw new \InvalidArgumentException('First argument to Stream::create() must be a string, resource or StreamInterface.');
}
/**
* Copy the contents of a stream into another stream until the given number
* of bytes have been read.
*
* @param StreamInterface $source Stream to read from
* @param StreamInterface $dest Stream to write to
* @param int $maxLen Maximum number of bytes to read. Pass -1
* to read the entire stream.
*
* @throws \RuntimeException on error
*/
public static function copyToStream(StreamInterface $source, StreamInterface $dest, int $maxLen = -1): void
{
$bufferSize = 8192;
if ($maxLen === -1) {
while (!$source->eof()) {
if (!$dest->write($source->read($bufferSize))) {
break;
}
}
} else {
$remaining = $maxLen;
while ($remaining > 0 && !$source->eof()) {
$buf = $source->read(min($bufferSize, $remaining));
$len = strlen($buf);
if (!$len) {
break;
}
$remaining -= $len;
$dest->write($buf);
}
}
}
public function close(): void
{
if (isset($this->stream)) {
if (\is_resource($this->stream)) {
\fclose($this->stream);
}
$this->detach();
}
}
public function detach()
{
if (!isset($this->stream)) {
return null;
}
$result = $this->stream;
unset($this->stream);
$this->size = $this->uri = null;
$this->readable = $this->writable = $this->seekable = false;
return $result;
}
public function getSize(): ?int
{
if ($this->size !== null) {
return $this->size;
}
if (!isset($this->stream)) {
return null;
}
// Clear the stat cache if the stream has a URI
if ($uri = $this->getUri()) {
\clearstatcache(true, $uri);
}
$stats = \fstat($this->stream);
if (isset($stats['size'])) {
$this->size = $stats['size'];
return $this->size;
}
return null;
}
public function tell(): int
{
if (false === $result = \ftell($this->stream)) {
throw new \RuntimeException('Unable to determine stream position');
}
return $result;
}
public function eof(): bool
{
return !$this->stream || \feof($this->stream);
}
public function isSeekable(): bool
{
return $this->seekable;
}
public function seek($offset, $whence = \SEEK_SET): void
{
if (!$this->seekable) {
throw new \RuntimeException('Stream is not seekable');
}
if (\fseek($this->stream, $offset, $whence) === -1) {
throw new \RuntimeException('Unable to seek to stream position "' . $offset . '" with whence ' . \var_export($whence, true));
}
}
public function rewind(): void
{
$this->seek(0);
}
public function isWritable(): bool
{
return $this->writable;
}
public function write($string): int
{
if (!$this->writable) {
throw new \RuntimeException('Cannot write to a non-writable stream');
}
// We can't know the size after writing anything
$this->size = null;
if (false === $result = \fwrite($this->stream, $string)) {
throw new \RuntimeException('Unable to write to stream');
}
return $result;
}
public function isReadable(): bool
{
return $this->readable;
}
public function read($length): string
{
if (!$this->readable) {
throw new \RuntimeException('Cannot read from non-readable stream');
}
if (false === $result = \fread($this->stream, $length)) {
throw new \RuntimeException('Unable to read from stream');
}
return $result;
}
public function getContents(): string
{
if (!isset($this->stream)) {
throw new \RuntimeException('Unable to read stream contents');
}
if (false === $contents = \stream_get_contents($this->stream)) {
throw new \RuntimeException('Unable to read stream contents');
}
return $contents;
}
public function getMetadata($key = null)
{
if (!isset($this->stream)) {
return $key ? null : [];
}
$meta = \stream_get_meta_data($this->stream);
if ($key === null) {
return $meta;
}
return $meta[$key] ?? null;
}
private function getUri()
{
if ($this->uri !== false) {
$this->uri = $this->getMetadata('uri') ?? false;
}
return $this->uri;
}
}

View File

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Choir\Http;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
class UploadedFile implements UploadedFileInterface
{
private ?string $client_filename;
private ?string $client_media_type;
/**
* @var int
*/
private $error;
/**
* @var null|string
*/
private $file;
private bool $moved = false;
private ?int $size;
private array $fileinfo;
/**
* @throws \InvalidArgumentException
*/
public function __construct(array $fileinfo)
{
$this->fileinfo = $fileinfo;
// 验证上传文件的信息真实有效
if (!isset($fileinfo['key'], $fileinfo['name'], $fileinfo['error'], $fileinfo['tmp_name'], $fileinfo['size'])) {
throw new \InvalidArgumentException('uploaded file needs ' . implode(', ', ['key', 'name', 'error', 'tmp_name', 'size']));
}
$this->client_filename = $this->fileinfo['name'];
$this->client_media_type = $this->fileinfo['type'] ?? null;
$this->error = $this->fileinfo['error'];
$this->size = $this->fileinfo['size'];
if ($this->isOk()) {
$this->file = $this->fileinfo['tmp_name'];
}
}
public function isMoved(): bool
{
return $this->moved;
}
public function getStream(): StreamInterface
{
$this->validateActive();
/** @var string $file */
$file = $this->file;
return Stream::create(fopen($file, 'r+'));
}
public function moveTo($targetPath): void
{
$this->validateActive();
if ($this->isStringNotEmpty($targetPath) === false) {
throw new \InvalidArgumentException(
'Invalid path provided for move operation; must be a non-empty string'
);
}
$this->moved = PHP_SAPI === 'cli' || PHP_SAPI === 'micro'
? rename($this->file, $targetPath)
: move_uploaded_file($this->file, $targetPath);
if ($this->moved === false) {
throw new \RuntimeException(
sprintf('Uploaded file could not be moved to %s', $targetPath)
);
}
}
public function getSize(): ?int
{
return $this->size;
}
public function getError(): int
{
return $this->error;
}
public function getClientFilename(): ?string
{
return $this->client_filename;
}
public function getClientMediaType(): ?string
{
return $this->client_media_type;
}
private function isStringNotEmpty($param): bool
{
return is_string($param) && empty($param) === false;
}
/**
* Return true if there is no upload error
*/
private function isOk(): bool
{
return $this->error === UPLOAD_ERR_OK;
}
/**
* @throws \RuntimeException if is moved or not ok
*/
private function validateActive(): void
{
if ($this->isOk() === false) {
throw new \RuntimeException('Cannot retrieve stream due to upload error');
}
if ($this->isMoved()) {
throw new \RuntimeException('Cannot retrieve stream after it has already been moved');
}
}
}

305
src/Choir/Http/Uri.php Normal file
View File

@ -0,0 +1,305 @@
<?php
/** @noinspection RegExpRedundantEscape */
/* @noinspection RegExpDuplicateCharacterInClass */
/* @noinspection RegExpUnnecessaryNonCapturingGroup */
declare(strict_types=1);
namespace Choir\Http;
use Psr\Http\Message\UriInterface;
class Uri implements UriInterface
{
private const SCHEMES = ['http' => 80, 'https' => 443];
private const CHAR_UNRESERVED = 'a-zA-Z\d_\-\.~';
private const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
/** @var string Uri scheme. */
private string $scheme = '';
/** @var string Uri user info. */
private $userInfo = '';
/** @var string Uri host. */
private string $host = '';
/** @var null|int Uri port. */
private ?int $port;
/** @var string Uri path. */
private string $path = '';
/** @var string Uri query string. */
private string $query = '';
/** @var string Uri fragment. */
private string $fragment = '';
public function __construct(string $uri = '')
{
if ($uri !== '') {
if (false === $parts = \parse_url($uri)) {
throw new \InvalidArgumentException(\sprintf('Unable to parse URI: "%s"', $uri));
}
// Apply parse_url parts to a URI.
$this->scheme = isset($parts['scheme']) ? \strtr($parts['scheme'], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') : '';
$this->userInfo = $parts['user'] ?? '';
$this->host = isset($parts['host']) ? \strtr($parts['host'], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') : '';
$this->port = isset($parts['port']) ? $this->filterPort($parts['port']) : null;
$this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : '';
$this->query = isset($parts['query']) ? $this->filterQueryAndFragment($parts['query']) : '';
$this->fragment = isset($parts['fragment']) ? $this->filterQueryAndFragment($parts['fragment']) : '';
if (isset($parts['pass'])) {
$this->userInfo .= ':' . $parts['pass'];
}
}
}
public function __toString(): string
{
return self::createUriString($this->scheme, $this->getAuthority(), $this->path, $this->query, $this->fragment);
}
public function getScheme(): string
{
return $this->scheme;
}
public function getAuthority(): string
{
if ($this->host === '') {
return '';
}
$authority = $this->host;
if ($this->userInfo !== '') {
$authority = $this->userInfo . '@' . $authority;
}
if ($this->port !== null) {
$authority .= ':' . $this->port;
}
return $authority;
}
public function getUserInfo(): string
{
return $this->userInfo;
}
public function getHost(): string
{
return $this->host;
}
public function getPort(): ?int
{
return $this->port;
}
public function getPath(): string
{
return $this->path;
}
public function getQuery(): string
{
return $this->query;
}
public function getFragment(): string
{
return $this->fragment;
}
public function withScheme($scheme): self
{
if (!\is_string($scheme)) {
throw new \InvalidArgumentException('Scheme must be a string');
}
if ($this->scheme === $scheme = \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')) {
return $this;
}
$new = clone $this;
$new->scheme = $scheme;
$new->port = $new->filterPort($new->port);
return $new;
}
public function withUserInfo($user, $password = null): self
{
$info = $user;
if ($password !== null && $password !== '') {
$info .= ':' . $password;
}
if ($this->userInfo === $info) {
return $this;
}
$new = clone $this;
$new->userInfo = $info;
return $new;
}
public function withHost($host): self
{
if (!\is_string($host)) {
throw new \InvalidArgumentException('Host must be a string');
}
if ($this->host === $host = \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')) {
return $this;
}
$new = clone $this;
$new->host = $host;
return $new;
}
public function withPort($port): self
{
if ($this->port === $port = $this->filterPort($port)) {
return $this;
}
$new = clone $this;
$new->port = $port;
return $new;
}
public function withPath($path): self
{
if ($this->path === $path = $this->filterPath($path)) {
return $this;
}
$new = clone $this;
$new->path = $path;
return $new;
}
public function withQuery($query): self
{
if ($this->query === $query = $this->filterQueryAndFragment($query)) {
return $this;
}
$new = clone $this;
$new->query = $query;
return $new;
}
public function withFragment($fragment): self
{
if ($this->fragment === $fragment = $this->filterQueryAndFragment($fragment)) {
return $this;
}
$new = clone $this;
$new->fragment = $fragment;
return $new;
}
/**
* Create a URI string from its various parts.
*/
private static function createUriString(string $scheme, string $authority, string $path, string $query, string $fragment): string
{
$uri = '';
if ($scheme !== '') {
$uri .= $scheme . ':';
}
if ($authority !== '') {
$uri .= '//' . $authority;
}
if ($path !== '') {
if ($path[0] !== '/') {
if ($authority !== '') {
// If the path is rootless and an authority is present, the path MUST be prefixed by "/"
$path = '/' . $path;
}
} elseif (isset($path[1]) && $path[1] === '/') {
if ($authority === '') {
// If the path is starting with more than one "/" and no authority is present, the
// starting slashes MUST be reduced to one.
$path = '/' . \ltrim($path, '/');
}
}
$uri .= $path;
}
if ($query !== '') {
$uri .= '?' . $query;
}
if ($fragment !== '') {
$uri .= '#' . $fragment;
}
return $uri;
}
/**
* Is a given port non-standard for the current scheme?
*/
private static function isNonStandardPort(string $scheme, int $port): bool
{
return !isset(self::SCHEMES[$scheme]) || $port !== self::SCHEMES[$scheme];
}
private function filterPort($port): ?int
{
if ($port === null) {
return null;
}
$port = (int) $port;
if (0 > $port || 0xFFFF < $port) {
throw new \InvalidArgumentException(\sprintf('Invalid port: %d. Must be between 0 and 65535', $port));
}
return self::isNonStandardPort($this->scheme, $port) ? $port : null;
}
private function filterPath($path): string
{
if (!\is_string($path)) {
throw new \InvalidArgumentException('Path must be a string');
}
return \preg_replace_callback('/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/]++|%(?![A-Fa-f\d]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $path);
}
private function filterQueryAndFragment($str): string
{
if (!\is_string($str)) {
throw new \InvalidArgumentException('Query and fragment must be a string');
}
return \preg_replace_callback('/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]++|%(?![A-Fa-f\d]{2}))/', [__CLASS__, 'rawurlencodeMatchZero'], $str);
}
private static function rawurlencodeMatchZero(array $match): string
{
return \rawurlencode($match[0]);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Choir\WebSocket;
/**
* WebSocket 的关闭帧
*/
class CloseFrame extends Frame implements CloseFrameInterface
{
protected int $code;
/**
* @var string (UTF-8)
*/
protected string $reason;
public function __construct(int $code, string $reason = '', bool $mask = false)
{
$data = hex2bin(str_pad(dechex($code), 4, '0', STR_PAD_LEFT)) . $reason;
parent::__construct($data, Opcode::CLOSE, $mask, true);
$this->code = $code;
$this->reason = $reason;
}
public function getCode(): int
{
return $this->code;
}
public function getReason(): string
{
return $this->reason;
}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Choir\WebSocket;
interface CloseFrameInterface extends FrameInterface
{
public function getCode(): int;
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Choir\WebSocket;
/**
* Interface ConnectionInterface
*/
interface ConnectionInterface
{
/**
* @return mixed
*/
public function send(FrameInterface $frame);
/**
* RFC6455
*
* @return mixed
*/
public function close(int $close_code = 1000);
}

View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Choir\WebSocket;
/**
* psr-7 extended websocket frame
*/
class Frame implements FrameInterface
{
/** @var string 默认的 Mask 掩码 */
public static string $mask_key = "\x7a\x6d\x5a\x4d";
/**
* @var mixed|string
*/
protected $data;
/**
* @var int The opcode of the frame
*/
protected int $opcode;
/**
* @var bool WebSocket Mask, RFC 6455 Section 10.3
*/
protected bool $mask;
/**
* @var bool FIN
*/
protected bool $finish;
private ?string $raw_cache = null;
public function __construct($data, int $opcode, bool $mask, bool $fin)
{
$this->data = $data;
$this->opcode = $opcode;
$this->mask = $mask;
$this->finish = $fin;
}
public function getData()
{
return $this->data;
}
public function getOpcode(): int
{
return $this->opcode;
}
/**
* 规定当且仅当由客户端向服务端发送的 frame, 需要使用掩码覆盖
*/
public function isMasked(): bool
{
return $this->mask;
}
public function isFinish(): bool
{
return $this->finish;
}
/**
* 获取 Frame 的二进制段
*
* @param bool $masked 是否返回被掩码的数据,默认为 false
*/
public function getRaw(bool $masked = false): string
{
var_dump($masked);
if ($this->raw_cache !== null) {
return $this->raw_cache;
}
// FIN
$byte_0 = ($this->finish ? 1 : 0) << 7;
// Opcode
$byte_0 = $byte_0 | $this->opcode;
$len = strlen($this->data);
// 掩码状态
if ($masked) {
$masks = static::$mask_key;
$masks = \str_repeat($masks, (int) floor($len / 4)) . \substr($masks, 0, $len % 4);
$data = $this->data ^ $masks;
} else {
$data = $this->data;
}
if ($len <= 125) {
$encode_buffer = chr($byte_0) . chr($masked ? $len | 128 : $len) . $data;
} elseif ($len <= 65535) {
$encode_buffer = chr($byte_0) . chr($masked ? 254 : 126) . pack('n', $len) . $data;
} else {
$encode_buffer = chr($byte_0) . chr($masked ? 255 : 127) . pack('xxxxN', $len) . $data;
}
return $this->raw_cache = $encode_buffer;
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Choir\WebSocket;
class FrameFactory
{
public static function createPingFrame(): Frame
{
return new Frame(null, Opcode::PING, true, true);
}
public static function createPongFrame(): Frame
{
return new Frame(null, Opcode::PONG, true, true);
}
public static function createTextFrame(string $payload): Frame
{
return new Frame($payload, Opcode::TEXT, true, true);
}
public static function createBinaryFrame(string $payload): Frame
{
return new Frame($payload, Opcode::BINARY, true, true);
}
public static function createCloseFrame(int $code = null, string $reason = null): Frame
{
return new CloseFrame($code, $reason);
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Choir\WebSocket;
interface FrameInterface
{
public function getData();
public function getOpcode();
public function isMasked(): bool;
public function isFinish(): bool;
public function getRaw(bool $masked = false): string;
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Choir\WebSocket;
interface Opcode
{
public const CONTINUATION = 0x0;
public const TEXT = 0x1;
public const BINARY = 0x2;
public const CLOSE = 0x8;
public const PING = 0x9;
public const PONG = 0xA;
}

3
src/Choir/globals.php Normal file
View File

@ -0,0 +1,3 @@
<?php
const CHOIR_PSR_HTTP_VERSION = '1.0.0';

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Tests\Choir\Http;
use Choir\Http\HttpFactory;
use Choir\Http\Uri;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
/**
* @internal
*/
class HttpFactoryTest extends TestCase
{
public function testCreateStream()
{
$this->assertInstanceOf(StreamInterface::class, HttpFactory::createStream());
}
public function testCreateUri()
{
$this->assertInstanceOf(UriInterface::class, HttpFactory::createUri('/'));
$uri = new Uri();
$this->assertSame(HttpFactory::createUri($uri), $uri);
}
public function testCreateServerRequest()
{
$this->assertInstanceOf(ServerRequestInterface::class, HttpFactory::createServerRequest('GET', '/'));
}
public function testCreateRequest()
{
$this->assertInstanceOf(RequestInterface::class, HttpFactory::createRequest('GET', '/'));
}
public function testCreateResponse()
{
$this->assertInstanceOf(ResponseInterface::class, HttpFactory::createResponse());
}
}

View File

@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Tests\Choir\Http;
use Choir\Http\HttpFactory;
use Choir\Http\Request;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
/**
* @internal
*/
class MessageTraitTest extends TestCase
{
private static RequestInterface $trait_class;
public static function setUpBeforeClass(): void
{
self::$trait_class = HttpFactory::createRequest(
'POST',
'/test',
[
'A' => 'B',
'C' => [
'123',
'456',
],
],
'hello'
);
}
public function testGetHeaders()
{
$this->assertIsArray(self::$trait_class->getHeaders());
}
public function testGetHeader()
{
$this->assertIsArray(self::$trait_class->getHeader('A'));
$this->assertIsArray(self::$trait_class->getHeader('a'));
$this->assertEquals('B', self::$trait_class->getHeader('a')[0]);
}
public function testGetBody()
{
$this->assertInstanceOf(StreamInterface::class, self::$trait_class->getBody());
$this->assertEquals('', HttpFactory::createRequest('GET', '/')->getBody()->getContents());
$this->assertEquals('hello', self::$trait_class->getBody()->getContents());
}
public function testWithProtocolVersion()
{
$this->assertNotSame(self::$trait_class->withProtocolVersion('1.1'), self::$trait_class);
$this->assertEquals('2.0', self::$trait_class->withProtocolVersion('2.0')->getProtocolVersion());
}
public function testHasHeader()
{
$this->assertTrue(self::$trait_class->hasHeader('a'));
$this->assertTrue(self::$trait_class->hasHeader('A'));
$this->assertFalse(self::$trait_class->hasHeader('User-Agent'));
}
public function testGetProtocolVersion()
{
$this->assertEquals('1.1', self::$trait_class->getProtocolVersion());
}
public function testWithHeader()
{
$this->assertNotSame(self::$trait_class->withHeader('User-Agent', 'HEICORE'), self::$trait_class);
$this->assertEquals('HEICORE', self::$trait_class->withHeader('C', 'HEICORE')->getHeaderLine('C'));
}
public function testGetHeaderLine()
{
$this->assertEquals('B', self::$trait_class->getHeaderLine('A'));
$this->assertEquals('B', self::$trait_class->getHeaderLine('a'));
$this->assertEquals('', self::$trait_class->getHeaderLine('not-exist-header'));
$this->assertEquals('123, 456', self::$trait_class->getHeaderLine('C'));
}
public function testWithAddedHeader()
{
$this->assertNotSame(self::$trait_class->withAddedHeader('c', ['789']), self::$trait_class);
$this->assertEquals('123, 456, 789', self::$trait_class->withAddedHeader('c', ['789'])->getHeaderLine('c'));
$this->assertEquals('new', self::$trait_class->withAddedHeader('D', 'new')->getHeaderLine('D'));
// Test int header
$req = new Request('GET', '/', [132 => '123']);
$this->assertEquals('123', $req->getHeaderLine('132'));
// Test exception
$this->expectException(\InvalidArgumentException::class);
self::$trait_class->withAddedHeader(['test-array' => 'ok'], 'are you');
}
public function testWithoutHeader()
{
$this->assertNotSame(self::$trait_class->withoutHeader('Cmm'), self::$trait_class);
$this->assertEquals('', self::$trait_class->withoutHeader('c')->getHeaderLine('c'));
}
public function testWithBody()
{
$this->assertNotSame(self::$trait_class->withBody(HttpFactory::createStream('test')), self::$trait_class);
$this->assertEquals('test', self::$trait_class->withBody(HttpFactory::createStream('test'))->getBody()->getContents());
}
/**
* @dataProvider providerValidateAndTrimHeaderExceptions
* @param mixed $header
* @param mixed $value
*/
public function testValidateAndTrimHeaderExceptions($header, $value)
{
$no_throwable = false;
try {
self::$trait_class->withHeader($header, $value);
$no_throwable = true;
} catch (\Throwable $e) {
$this->assertInstanceOf(\InvalidArgumentException::class, $e);
}
$this->assertFalse($no_throwable);
}
public function providerValidateAndTrimHeaderExceptions(): array
{
return [
'header not string' => [[], []],
'value not valid' => ['www', true],
'value array empty' => ['www', []],
'value array not valid' => ['www', [true]],
];
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Tests\Choir\Http;
use Choir\Http\Request;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
class RequestTest extends TestCase
{
public function testConstruct()
{
$req = new Request('GET', '/', [], 'nihao');
$this->assertEquals('nihao', $req->getBody()->getContents());
}
}

View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Tests\Choir\Http;
use Choir\Http\HttpFactory;
use Choir\Http\Request;
use Choir\Http\ServerRequest;
use Choir\Http\Uri;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\UriInterface;
/**
* @internal
*/
class RequestTraitTest extends TestCase
{
private static RequestInterface $request;
public static function setUpBeforeClass(): void
{
self::$request = HttpFactory::createRequest(
'GET',
'/test?pwq=123',
);
}
public function testGetUri()
{
$this->assertInstanceOf(UriInterface::class, self::$request->getUri());
$this->assertEquals('/test', self::$request->getUri()->getPath());
}
public function testWithUri()
{
$this->assertNotSame(self::$request->withUri(self::$request->getUri()), self::$request);
}
public function testWithRequestTarget()
{
$this->assertNotSame(self::$request->withRequestTarget(self::$request->getRequestTarget()), self::$request);
}
public function testWithMethod()
{
$this->assertNotSame(self::$request->withMethod(self::$request->getMethod()), self::$request);
$this->expectException(\InvalidArgumentException::class);
self::$request->withMethod(123);
}
public function testGetMethod()
{
$this->assertEquals('GET', self::$request->getMethod());
}
public function testGetRequestTarget()
{
// fulfill requestTarget is not null
$req = new Request('GET', '');
$this->assertEquals('/', $req->getRequestTarget());
$req = $req->withRequestTarget('/ppp?help=123');
$this->assertEquals('/ppp?help=123', $req->getRequestTarget());
// Original uri is request target
$this->assertEquals('/test?pwq=123', self::$request->getRequestTarget());
$this->expectException(\InvalidArgumentException::class);
$req->withRequestTarget(' ');
}
public function testUpdateHostFromUri()
{
$req1 = (new ServerRequest('GET', '/p'))->withHeader('Host', 'baidu.com');
$req2 = self::$request->withUri(new Uri('https://www.evil.com'));
$uri = new Uri('http://10.0.0.1:8090/test233?param=value');
$uri2 = new Uri('/test2?p2=v2');
$req3 = $req1->withUri($uri);
$req4 = $req2->withUri($uri2);
$this->assertEquals('/test233', $req3->getUri()->getPath());
$this->assertEquals('/test2', $req4->getUri()->getPath());
}
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Tests\Choir\Http;
use Choir\Http\HttpFactory;
use Choir\Http\Response;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
class ResponseTest extends TestCase
{
private static Response $response;
public static function setUpBeforeClass(): void
{
/* @phpstan-ignore-next-line */
self::$response = HttpFactory::createResponse(
200,
'OK',
[
'X-Key' => '123fff',
],
'hahaha'
);
}
public function testCoverage()
{
$this->assertIsString((new Response('200', [], 'test', '1.1', 'OKK'))->__toString());
}
public function testGetReasonPhrase()
{
$this->assertSame('OK', self::$response->getReasonPhrase());
}
/**
* @dataProvider providerWithStatus
* @param mixed $code 状态码
* @param mixed $expected_exception 期望抛出的异常
*/
public function testWithStatus($code, $expected_exception)
{
$this->assertNotSame(self::$response->withStatus(200), self::$response);
try {
self::$response->withStatus($code);
} catch (\Throwable $e) {
$this->assertInstanceOf($expected_exception, $e);
}
}
public function providerWithStatus(): array
{
return [
'not valid code exception' => [[], \InvalidArgumentException::class],
'invalid code number exception' => [600, \InvalidArgumentException::class],
];
}
public function testToString()
{
$this->assertTrue(method_exists(self::$response, '__toString'));
$this->assertIsString((string) self::$response);
$lines = explode("\r\n", (string) self::$response);
$this->assertEquals('HTTP/1.1 200 OK', $lines[0]);
$this->assertContains('X-Key: 123fff', $lines);
$this->assertStringEndsWith("\r\n\r\nhahaha", (string) self::$response);
}
public function testGetStatusCode()
{
$this->assertSame(200, self::$response->getStatusCode());
}
}