mirror of
https://github.com/crazywhalecc/choir-psr-http.git
synced 2026-03-17 20:24:52 +08:00
initial commit
This commit is contained in:
commit
1f4427b5b7
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal 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
82
.php-cs-fixer.php
Normal 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
70
composer.json
Normal 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
9
phpstan.neon
Normal 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
33
phpunit.xml.dist
Normal 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>
|
||||||
86
src/Choir/Http/HttpFactory.php
Normal file
86
src/Choir/Http/HttpFactory.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/Choir/Http/MessageTrait.php
Normal file
182
src/Choir/Http/MessageTrait.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/Choir/Http/Request.php
Normal file
42
src/Choir/Http/Request.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/Choir/Http/RequestTrait.php
Normal file
104
src/Choir/Http/RequestTrait.php
Normal 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
134
src/Choir/Http/Response.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/Choir/Http/ServerRequest.php
Normal file
163
src/Choir/Http/ServerRequest.php
Normal 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
312
src/Choir/Http/Stream.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/Choir/Http/UploadedFile.php
Normal file
136
src/Choir/Http/UploadedFile.php
Normal 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
305
src/Choir/Http/Uri.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/Choir/WebSocket/CloseFrame.php
Normal file
37
src/Choir/WebSocket/CloseFrame.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/Choir/WebSocket/CloseFrameInterface.php
Normal file
10
src/Choir/WebSocket/CloseFrameInterface.php
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Choir\WebSocket;
|
||||||
|
|
||||||
|
interface CloseFrameInterface extends FrameInterface
|
||||||
|
{
|
||||||
|
public function getCode(): int;
|
||||||
|
}
|
||||||
23
src/Choir/WebSocket/ConnectionInterface.php
Normal file
23
src/Choir/WebSocket/ConnectionInterface.php
Normal 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);
|
||||||
|
}
|
||||||
104
src/Choir/WebSocket/Frame.php
Normal file
104
src/Choir/WebSocket/Frame.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/Choir/WebSocket/FrameFactory.php
Normal file
33
src/Choir/WebSocket/FrameFactory.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/Choir/WebSocket/FrameInterface.php
Normal file
18
src/Choir/WebSocket/FrameInterface.php
Normal 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;
|
||||||
|
}
|
||||||
20
src/Choir/WebSocket/Opcode.php
Normal file
20
src/Choir/WebSocket/Opcode.php
Normal 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
3
src/Choir/globals.php
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
const CHOIR_PSR_HTTP_VERSION = '1.0.0';
|
||||||
47
tests/Choir/Http/HttpFactoryTest.php
Normal file
47
tests/Choir/Http/HttpFactoryTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
139
tests/Choir/Http/MessageTraitTest.php
Normal file
139
tests/Choir/Http/MessageTraitTest.php
Normal 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]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
tests/Choir/Http/RequestTest.php
Normal file
20
tests/Choir/Http/RequestTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
82
tests/Choir/Http/RequestTraitTest.php
Normal file
82
tests/Choir/Http/RequestTraitTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
78
tests/Choir/Http/ResponseTest.php
Normal file
78
tests/Choir/Http/ResponseTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user