initial commit

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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