commit 1f4427b5b7c44da59963b251626c2ebdce70c93d Author: crazywhalecc Date: Mon Dec 26 19:10:28 2022 +0800 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a365576 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..d4495a4 --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,82 @@ +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); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..826a277 --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..f730a49 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +parameters: + reportUnmatchedIgnoredErrors: false + treatPhpDocTypesAsCertain: false + level: 4 + paths: + - ./src/ + ignoreErrors: + - '#OS_TYPE_(LINUX|WINDOWS) not found#' + - '#class Fiber#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..83709f0 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,33 @@ + + + + + ./tests + + + + + ./src/Choir + + + + + + + + + + diff --git a/src/Choir/Http/HttpFactory.php b/src/Choir/Http/HttpFactory.php new file mode 100644 index 0000000..4a5d57d --- /dev/null +++ b/src/Choir/Http/HttpFactory.php @@ -0,0 +1,86 @@ + $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 $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 $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); + } +} diff --git a/src/Choir/Http/MessageTrait.php b/src/Choir/Http/MessageTrait.php new file mode 100644 index 0000000..8fa0465 --- /dev/null +++ b/src/Choir/Http/MessageTrait.php @@ -0,0 +1,182 @@ + 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; + } +} diff --git a/src/Choir/Http/Request.php b/src/Choir/Http/Request.php new file mode 100644 index 0000000..2e6be69 --- /dev/null +++ b/src/Choir/Http/Request.php @@ -0,0 +1,42 @@ +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); + } + } +} diff --git a/src/Choir/Http/RequestTrait.php b/src/Choir/Http/RequestTrait.php new file mode 100644 index 0000000..f00a400 --- /dev/null +++ b/src/Choir/Http/RequestTrait.php @@ -0,0 +1,104 @@ +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; + } +} diff --git a/src/Choir/Http/Response.php b/src/Choir/Http/Response.php new file mode 100644 index 0000000..3ef6052 --- /dev/null +++ b/src/Choir/Http/Response.php @@ -0,0 +1,134 @@ + '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; + } +} diff --git a/src/Choir/Http/ServerRequest.php b/src/Choir/Http/ServerRequest.php new file mode 100644 index 0000000..51324f2 --- /dev/null +++ b/src/Choir/Http/ServerRequest.php @@ -0,0 +1,163 @@ +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; + } +} diff --git a/src/Choir/Http/Stream.php b/src/Choir/Http/Stream.php new file mode 100644 index 0000000..e89805d --- /dev/null +++ b/src/Choir/Http/Stream.php @@ -0,0 +1,312 @@ + [ + '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; + } +} diff --git a/src/Choir/Http/UploadedFile.php b/src/Choir/Http/UploadedFile.php new file mode 100644 index 0000000..c474812 --- /dev/null +++ b/src/Choir/Http/UploadedFile.php @@ -0,0 +1,136 @@ +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'); + } + } +} diff --git a/src/Choir/Http/Uri.php b/src/Choir/Http/Uri.php new file mode 100644 index 0000000..fcc153e --- /dev/null +++ b/src/Choir/Http/Uri.php @@ -0,0 +1,305 @@ + 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]); + } +} diff --git a/src/Choir/WebSocket/CloseFrame.php b/src/Choir/WebSocket/CloseFrame.php new file mode 100644 index 0000000..9334cf6 --- /dev/null +++ b/src/Choir/WebSocket/CloseFrame.php @@ -0,0 +1,37 @@ +code = $code; + $this->reason = $reason; + } + + public function getCode(): int + { + return $this->code; + } + + public function getReason(): string + { + return $this->reason; + } +} diff --git a/src/Choir/WebSocket/CloseFrameInterface.php b/src/Choir/WebSocket/CloseFrameInterface.php new file mode 100644 index 0000000..d3d1f00 --- /dev/null +++ b/src/Choir/WebSocket/CloseFrameInterface.php @@ -0,0 +1,10 @@ +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; + } +} diff --git a/src/Choir/WebSocket/FrameFactory.php b/src/Choir/WebSocket/FrameFactory.php new file mode 100644 index 0000000..007d6d0 --- /dev/null +++ b/src/Choir/WebSocket/FrameFactory.php @@ -0,0 +1,33 @@ +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()); + } +} diff --git a/tests/Choir/Http/MessageTraitTest.php b/tests/Choir/Http/MessageTraitTest.php new file mode 100644 index 0000000..b05e86c --- /dev/null +++ b/tests/Choir/Http/MessageTraitTest.php @@ -0,0 +1,139 @@ + '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]], + ]; + } +} diff --git a/tests/Choir/Http/RequestTest.php b/tests/Choir/Http/RequestTest.php new file mode 100644 index 0000000..2e5694b --- /dev/null +++ b/tests/Choir/Http/RequestTest.php @@ -0,0 +1,20 @@ +assertEquals('nihao', $req->getBody()->getContents()); + } +} diff --git a/tests/Choir/Http/RequestTraitTest.php b/tests/Choir/Http/RequestTraitTest.php new file mode 100644 index 0000000..2f2726f --- /dev/null +++ b/tests/Choir/Http/RequestTraitTest.php @@ -0,0 +1,82 @@ +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()); + } +} diff --git a/tests/Choir/Http/ResponseTest.php b/tests/Choir/Http/ResponseTest.php new file mode 100644 index 0000000..cdc7625 --- /dev/null +++ b/tests/Choir/Http/ResponseTest.php @@ -0,0 +1,78 @@ + '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()); + } +}