multipart/form-data implementation
This commit is contained in:
parent
f07a3ab50e
commit
d56be84bbb
27 changed files with 1196 additions and 131 deletions
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
|||
0.2503.82034
|
||||
0.2503.122055
|
||||
|
|
18
src/Http/Content/Content.php
Normal file
18
src/Http/Content/Content.php
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
// Content.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Content;
|
||||
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* Provides a base interface for HTTP request body decoding.
|
||||
*/
|
||||
interface Content {
|
||||
/**
|
||||
* Raw request body stream.
|
||||
*/
|
||||
public StreamInterface $stream { get; }
|
||||
}
|
61
src/Http/Content/FormContent.php
Normal file
61
src/Http/Content/FormContent.php
Normal file
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
// FormContent.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Content;
|
||||
|
||||
/**
|
||||
* Provides a common interface for application/x-www-form-urlencoded and multipart/form-data.
|
||||
*/
|
||||
interface FormContent extends Content {
|
||||
/**
|
||||
* Checks if a form field is present.
|
||||
*
|
||||
* @param string $name Name of the form field.
|
||||
* @return bool
|
||||
*/
|
||||
public function hasParam(string $name): bool;
|
||||
|
||||
/**
|
||||
* Retrieves an HTTP request form field, or null if it is not present.
|
||||
*
|
||||
* @param string $name Name of the request form field.
|
||||
* @param int $filter A PHP filter extension filter constant.
|
||||
* @param mixed[]|int $options Options for the PHP filter.
|
||||
* @param mixed $default Default value to fall back on.
|
||||
* @return mixed Value of the form field, null if not present.
|
||||
*/
|
||||
public function getParam(
|
||||
string $name,
|
||||
int $filter = FILTER_DEFAULT,
|
||||
array|int $options = 0,
|
||||
mixed $default = null,
|
||||
): mixed;
|
||||
|
||||
/**
|
||||
* Retrieves an HTTP request form field, or null if it is not present.
|
||||
*
|
||||
* @param string $name Name of the request form field.
|
||||
* @param int $index Index of the parameter value.
|
||||
* @param int $filter A PHP filter extension filter constant.
|
||||
* @param mixed[]|int $options Options for the PHP filter.
|
||||
* @param mixed $default Default value to fall back on.
|
||||
* @return mixed Value of the form field, null if not present.
|
||||
*/
|
||||
public function getParamAt(
|
||||
string $name,
|
||||
int $index,
|
||||
int $filter = FILTER_DEFAULT,
|
||||
array|int $options = 0,
|
||||
mixed $default = null,
|
||||
): mixed;
|
||||
|
||||
/**
|
||||
* Retrieves how many parameters of the same name appear in the HTTP request query field.
|
||||
*
|
||||
* @param string $name Name of the request query field.
|
||||
* @return int<0, max> Amount of values.
|
||||
*/
|
||||
public function getParamCount(string $name): int;
|
||||
}
|
64
src/Http/Content/Multipart/FileMultipartFormData.php
Normal file
64
src/Http/Content/Multipart/FileMultipartFormData.php
Normal file
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
// FileMultipartFormData.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Content\Multipart;
|
||||
|
||||
use Index\Http\HttpHeaders;
|
||||
use Index\Http\Streams\Stream;
|
||||
use Psr\Http\Message\{StreamInterface,UploadedFileInterface};
|
||||
|
||||
/**
|
||||
* Containers for regular form values in multipart form data.
|
||||
*/
|
||||
class FileMultipartFormData implements MultipartFormData, UploadedFileInterface {
|
||||
use HttpHeaders;
|
||||
|
||||
/**
|
||||
* @param string $name Name of the form data part.
|
||||
* @param string $fileName Filename suggested by the client.
|
||||
* @param array<string, string[]> $headers Headers.
|
||||
* @param StreamInterface $stream Value stream.
|
||||
*/
|
||||
public function __construct(
|
||||
public private(set) string $name,
|
||||
public private(set) string $fileName,
|
||||
public private(set) array $headers,
|
||||
public private(set) StreamInterface $stream,
|
||||
) {}
|
||||
|
||||
public function getStream(): StreamInterface {
|
||||
return $this->stream;
|
||||
}
|
||||
|
||||
public function moveTo(string $targetPath): void {
|
||||
$target = Stream::createStreamFromFile($targetPath, 'wb');
|
||||
|
||||
$this->stream->rewind();
|
||||
while(!$this->stream->eof()) {
|
||||
$buffer = $this->stream->read(8192);
|
||||
$target->write($buffer);
|
||||
}
|
||||
}
|
||||
|
||||
public function getSize(): ?int {
|
||||
return $this->stream->getSize();
|
||||
}
|
||||
|
||||
public function getError(): int {
|
||||
return UPLOAD_ERR_OK;
|
||||
}
|
||||
|
||||
public function getClientFilename(): ?string {
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
public function getClientMediaType(): ?string {
|
||||
return $this->hasHeader('Content-Type') ? $this->getHeaderLine('Content-Type') : null;
|
||||
}
|
||||
|
||||
public function __toString(): string {
|
||||
return (string)$this->stream;
|
||||
}
|
||||
}
|
55
src/Http/Content/Multipart/MultipartFormData.php
Normal file
55
src/Http/Content/Multipart/MultipartFormData.php
Normal file
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
// MultipartFormData.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Content\Multipart;
|
||||
|
||||
use Stringable;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* Defines a common interface for multipart form data parts.
|
||||
*/
|
||||
interface MultipartFormData extends Stringable {
|
||||
/**
|
||||
* Name of the form data part.
|
||||
*/
|
||||
public string $name { get; }
|
||||
|
||||
/**
|
||||
* Form data headers.
|
||||
*
|
||||
* @var array<string, string[]>
|
||||
*/
|
||||
public array $headers { get; }
|
||||
|
||||
/**
|
||||
* Underlying stream.
|
||||
*/
|
||||
public StreamInterface $stream { get; }
|
||||
|
||||
/**
|
||||
* Checks if a header exists by the given case-insensitive name.
|
||||
*
|
||||
* @param string $name Case-insensitive header field name.
|
||||
* @return bool
|
||||
*/
|
||||
public function hasHeader(string $name): bool;
|
||||
|
||||
/**
|
||||
* Retrieves a message header value by the given case-insensitive name.
|
||||
*
|
||||
* @param string $name Case-insensitive header field name.
|
||||
* @return string[]
|
||||
*/
|
||||
public function getHeader(string $name): array;
|
||||
|
||||
/**
|
||||
* Retrieves a comma-separated string of the values for a single header.
|
||||
*
|
||||
* @param string $name Case-insensitive header field name.
|
||||
* @return string
|
||||
*/
|
||||
public function getHeaderLine(string $name): string;
|
||||
}
|
40
src/Http/Content/Multipart/ValueMultipartFormData.php
Normal file
40
src/Http/Content/Multipart/ValueMultipartFormData.php
Normal file
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
// ValueMultipartFormData.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Content\Multipart;
|
||||
|
||||
use Index\Http\HttpHeaders;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* Containers for regular form values in multipart form data.
|
||||
*/
|
||||
class ValueMultipartFormData implements MultipartFormData {
|
||||
use HttpHeaders;
|
||||
|
||||
/**
|
||||
* @param string $name Name of the form data part.
|
||||
* @param array<string, string[]> $headers Headers.
|
||||
* @param StreamInterface $stream Value stream.
|
||||
*/
|
||||
public function __construct(
|
||||
public private(set) string $name,
|
||||
public private(set) array $headers,
|
||||
public private(set) StreamInterface $stream,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieves the value of this part.
|
||||
*/
|
||||
public string $value {
|
||||
get {
|
||||
return (string)$this->stream;
|
||||
}
|
||||
}
|
||||
|
||||
public function __toString(): string {
|
||||
return (string)$this->stream;
|
||||
}
|
||||
}
|
222
src/Http/Content/MultipartFormContent.php
Normal file
222
src/Http/Content/MultipartFormContent.php
Normal file
|
@ -0,0 +1,222 @@
|
|||
<?php
|
||||
// MultipartFormContent.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Content;
|
||||
|
||||
use RuntimeException;
|
||||
use Index\Http\Content\Multipart\{FileMultipartFormData,MultipartFormData,ValueMultipartFormData};
|
||||
use Index\Http\Streams\{ScopedStream,Stream,StreamBuffer};
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* Implements multipart/form-data.
|
||||
*/
|
||||
class MultipartFormContent implements FormContent {
|
||||
/**
|
||||
* @param StreamInterface $stream Raw request body stream.
|
||||
* @param array<string, list<MultipartFormData>> $params Form parameters.
|
||||
* @param array<string, list<FileMultipartFormData>> $files File parameters.
|
||||
*/
|
||||
public function __construct(
|
||||
public private(set) StreamInterface $stream,
|
||||
public private(set) array $params,
|
||||
public private(set) array $files,
|
||||
) {}
|
||||
|
||||
public function hasParam(string $name): bool {
|
||||
return isset($this->params[$name]);
|
||||
}
|
||||
|
||||
public function getParam(
|
||||
string $name,
|
||||
int $filter = FILTER_DEFAULT,
|
||||
array|int $options = 0,
|
||||
mixed $default = null,
|
||||
): mixed {
|
||||
return $this->getParamAt($name, 0, $filter, $options, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves value of the form field, including additional data.
|
||||
*
|
||||
* @param string $name Name of the request form field.
|
||||
* @return MultipartFormData
|
||||
*/
|
||||
public function getParamData(string $name): ?MultipartFormData {
|
||||
return $this->getParamDataAt($name, 0);
|
||||
}
|
||||
|
||||
public function getParamAt(
|
||||
string $name,
|
||||
int $index,
|
||||
int $filter = FILTER_DEFAULT,
|
||||
array|int $options = 0,
|
||||
mixed $default = null,
|
||||
): mixed {
|
||||
if(!isset($this->params[$name]) || !isset($this->params[$name][$index]))
|
||||
return $default;
|
||||
|
||||
$value = filter_var($this->params[$name][$index], $filter, $options);
|
||||
return is_scalar($value) ? $value : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves value of the form field, including additional data.
|
||||
*
|
||||
* @param string $name Name of the request form field.
|
||||
* @param int $index Index of the parameter value.
|
||||
* @return MultipartFormData
|
||||
*/
|
||||
public function getParamDataAt(string $name, int $index): ?MultipartFormData {
|
||||
return isset($this->params[$name]) && isset($this->params[$name][$index])
|
||||
? $this->params[$name][$index] : null;
|
||||
}
|
||||
|
||||
public function getParamCount(string $name): int {
|
||||
return isset($this->params[$name]) ? count($this->params[$name]) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses multipart form data in a stream.
|
||||
*
|
||||
* @param StreamInterface $stream Stream to parse, if seekable gets rewound.
|
||||
* @param string $boundary Boundary string.
|
||||
* @throws RuntimeException if the form could not be parsed correctly
|
||||
* @return MultipartFormContent
|
||||
*/
|
||||
public static function parseStream(StreamInterface $stream, string $boundary): MultipartFormContent {
|
||||
$params = [];
|
||||
$files = [];
|
||||
|
||||
$contentDispositionKeys = ['name', 'filename'];
|
||||
$initialBoundary = sprintf("--%s", $boundary);
|
||||
$boundary = sprintf("\r\n--%s", $boundary);
|
||||
$boundaryLength = strlen($boundary);
|
||||
|
||||
$buffer = new StreamBuffer($stream, 8192);
|
||||
|
||||
// buffer enough to get a boundary
|
||||
while($buffer->available < strlen($initialBoundary))
|
||||
if($buffer->read() < 1)
|
||||
throw new RuntimeException('multipart/form-data: initial boundary expected');
|
||||
|
||||
// try to find initial boundary
|
||||
$boundaryIndex = $buffer->indexOf($initialBoundary);
|
||||
if($boundaryIndex < 0)
|
||||
throw new RuntimeException('multipart/form-data: unable to find initial boundary');
|
||||
if($boundaryIndex !== 0)
|
||||
throw new RuntimeException('multipart/form-data: initial boundary found in unexpected location');
|
||||
|
||||
// truncate initial boundary
|
||||
$buffer->truncate(strlen($initialBoundary));
|
||||
|
||||
// start looping
|
||||
for(;;) {
|
||||
while($buffer->available < 4)
|
||||
if($buffer->read() < 1)
|
||||
throw new RuntimeException('multipart/form-data: headers or trailer expected');
|
||||
if($buffer->indexOf("--") === 0)
|
||||
break;
|
||||
|
||||
if($buffer->indexOf("\r\n") !== 0)
|
||||
throw new RuntimeException('multipart/form-data: newline expected');
|
||||
|
||||
// find end of headers
|
||||
$headersStart = 2;
|
||||
while(($headersEnd = $buffer->indexOf("\r\n\r\n", $headersStart)) < 0)
|
||||
if($buffer->read() < 1)
|
||||
throw new RuntimeException('multipart/form-data: headers expected');
|
||||
|
||||
// format headers
|
||||
$headers = (function($lines) {
|
||||
$headers = [];
|
||||
foreach($lines as $line) {
|
||||
$parts = explode(':', $line, 2);
|
||||
if(count($parts) !== 2)
|
||||
throw new RuntimeException('multipart/form-data: invalid header found');
|
||||
|
||||
$name = strtolower(trim($parts[0]));
|
||||
$value = trim($parts[1]);
|
||||
if(array_key_exists($name, $headers))
|
||||
$headers[$name][] = $value;
|
||||
else
|
||||
$headers[$name] = [$value];
|
||||
}
|
||||
|
||||
return $headers;
|
||||
})(explode("\r\n", substr($buffer->data, $headersStart, $headersEnd - 2)));
|
||||
|
||||
if(!array_key_exists('content-disposition', $headers))
|
||||
throw new RuntimeException('multipart/form-data: missing Content-Disposition header');
|
||||
|
||||
$contentDisposition = (function($parts) use ($contentDispositionKeys) {
|
||||
$first = array_shift($parts);
|
||||
if($first === false || trim($first) !== 'form-data')
|
||||
throw new RuntimeException('multipart/form-data: Content-Disposition was not form-data');
|
||||
|
||||
$info = [];
|
||||
foreach($parts as $infoPart) {
|
||||
$infoParts = explode('=', $infoPart, 2);
|
||||
if(count($infoParts) !== 2)
|
||||
continue;
|
||||
|
||||
$name = urldecode(trim($infoParts[0]));
|
||||
if(!in_array($name, $contentDispositionKeys))
|
||||
continue;
|
||||
|
||||
$value = trim($infoParts[1]);
|
||||
if(!str_starts_with($value, '"') || !str_ends_with($value, '"'))
|
||||
continue;
|
||||
|
||||
$info[$name] = urldecode(substr($value, 1, -1));
|
||||
}
|
||||
|
||||
return $info;
|
||||
})(explode(';', $headers['content-disposition'][0]));
|
||||
|
||||
if(!array_key_exists('name', $contentDisposition))
|
||||
throw new RuntimeException('multipart/form-data: missing name from part');
|
||||
|
||||
// get body start offset
|
||||
$bodyStart = $buffer->offset + $headersStart + $headersEnd + 2;
|
||||
|
||||
// find boundary
|
||||
while(($boundaryIndex = $buffer->indexOf($boundary)) < 0) {
|
||||
if($buffer->available > $buffer->chunkSize * 2)
|
||||
$buffer->truncate($buffer->available - $buffer->chunkSize);
|
||||
|
||||
if($buffer->read() < 1)
|
||||
throw new RuntimeException('multipart/form-data: boundary expected');
|
||||
}
|
||||
|
||||
// get offset of body end
|
||||
$bodyEnd = $buffer->offset + $boundaryIndex;
|
||||
|
||||
// truncate boundary
|
||||
$buffer->truncate($boundaryIndex + $boundaryLength);
|
||||
|
||||
// create scoped stream
|
||||
$body = ScopedStream::scopeTo($stream, $bodyStart, $bodyEnd - $bodyStart);
|
||||
if(array_key_exists('filename', $contentDisposition)) {
|
||||
$part = new FileMultipartFormData($contentDisposition['name'], $contentDisposition['filename'], $headers, $body);
|
||||
if(array_key_exists($part->name, $files))
|
||||
$files[$part->name][] = $part;
|
||||
else
|
||||
$files[$part->name] = [$part];
|
||||
} else
|
||||
$part = new ValueMultipartFormData($contentDisposition['name'], $headers, $body);
|
||||
|
||||
if(array_key_exists($part->name, $params))
|
||||
$params[$part->name][] = $part;
|
||||
else
|
||||
$params[$part->name] = [$part];
|
||||
}
|
||||
|
||||
if($stream->isSeekable())
|
||||
$stream->rewind();
|
||||
|
||||
return new MultipartFormContent($stream, $params, $files);
|
||||
}
|
||||
}
|
68
src/Http/Content/UrlEncodedFormContent.php
Normal file
68
src/Http/Content/UrlEncodedFormContent.php
Normal file
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
// UrlEncodedFormContent.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Content;
|
||||
|
||||
use Index\Http\HttpUri;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* Implements application/x-www-form-urlencoded.
|
||||
*/
|
||||
class UrlEncodedFormContent implements FormContent {
|
||||
/**
|
||||
* @param StreamInterface $stream Raw request body stream.
|
||||
* @param array<string, list<?string>> $params Form parameters.
|
||||
*/
|
||||
public function __construct(
|
||||
public private(set) StreamInterface $stream,
|
||||
public private(set) array $params,
|
||||
) {}
|
||||
|
||||
public function hasParam(string $name): bool {
|
||||
return isset($this->params[$name]);
|
||||
}
|
||||
|
||||
public function getParam(
|
||||
string $name,
|
||||
int $filter = FILTER_DEFAULT,
|
||||
array|int $options = 0,
|
||||
mixed $default = null,
|
||||
): mixed {
|
||||
return $this->getParamAt($name, 0, $filter, $options, $default);
|
||||
}
|
||||
|
||||
public function getParamAt(
|
||||
string $name,
|
||||
int $index,
|
||||
int $filter = FILTER_DEFAULT,
|
||||
array|int $options = 0,
|
||||
mixed $default = null,
|
||||
): mixed {
|
||||
if(!isset($this->params[$name]) || !isset($this->params[$name][$index]))
|
||||
return $default;
|
||||
|
||||
$value = filter_var($this->params[$name][$index], $filter, $options);
|
||||
return is_scalar($value) ? $value : $default;
|
||||
}
|
||||
|
||||
public function getParamCount(string $name): int {
|
||||
return isset($this->params[$name]) ? count($this->params[$name]) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses URL encoded form params in a stream.
|
||||
*
|
||||
* @param StreamInterface $stream Stream to parse, if seekable gets rewound.
|
||||
* @return UrlEncodedFormContent
|
||||
*/
|
||||
public static function parseStream(StreamInterface $stream): UrlEncodedFormContent {
|
||||
$params = HttpUri::parseQueryString((string)$stream);
|
||||
if($stream->isSeekable())
|
||||
$stream->rewind();
|
||||
|
||||
return new UrlEncodedFormContent($stream, $params);
|
||||
}
|
||||
}
|
56
src/Http/HttpHeaders.php
Normal file
56
src/Http/HttpHeaders.php
Normal file
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
// HttpHeaders.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http;
|
||||
|
||||
use Index\XArray;
|
||||
|
||||
/**
|
||||
* Common implementation for HTTP header methods.
|
||||
*/
|
||||
trait HttpHeaders {
|
||||
/**
|
||||
* Retrieves all message header values.
|
||||
*
|
||||
* @return array<string, string[]>
|
||||
*/
|
||||
public function getHeaders(): array {
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a header exists by the given case-insensitive name.
|
||||
*
|
||||
* @param string $name Case-insensitive header field name.
|
||||
* @return bool
|
||||
*/
|
||||
public function hasHeader(string $name): bool {
|
||||
return XArray::any($this->headers, fn($hValue, $hName) => is_string($hName) && strcasecmp($hName, $name) === 0 && !empty($hValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a message header value by the given case-insensitive name.
|
||||
*
|
||||
* @param string $name Case-insensitive header field name.
|
||||
* @return string[]
|
||||
*/
|
||||
public function getHeader(string $name): array {
|
||||
foreach($this->headers as $hName => $hValue)
|
||||
if(strcasecmp($hName, $name) === 0)
|
||||
return $hValue;
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a comma-separated string of the values for a single header.
|
||||
*
|
||||
* @param string $name Case-insensitive header field name.
|
||||
* @return string
|
||||
*/
|
||||
public function getHeaderLine(string $name): string {
|
||||
return implode(', ', $this->getHeader($name));
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
// HttpMessage.php
|
||||
// Created: 2022-02-08
|
||||
// Updated: 2025-02-28
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http;
|
||||
|
||||
|
@ -14,6 +14,8 @@ use Psr\Http\Message\{MessageInterface,StreamInterface};
|
|||
* Represents a base HTTP message.
|
||||
*/
|
||||
abstract class HttpMessage implements MessageInterface {
|
||||
use HttpHeaders;
|
||||
|
||||
/**
|
||||
* HTTP message headers.
|
||||
*
|
||||
|
@ -83,26 +85,6 @@ abstract class HttpMessage implements MessageInterface {
|
|||
return $this->with(protocolVersion: $version);
|
||||
}
|
||||
|
||||
public function getHeaders(): array {
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
public function hasHeader(string $name): bool {
|
||||
return XArray::any($this->headers, fn($hValue, $hName) => is_string($hName) && strcasecmp($hName, $name) === 0 && !empty($hValue));
|
||||
}
|
||||
|
||||
public function getHeader(string $name): array {
|
||||
foreach($this->headers as $hName => $hValue)
|
||||
if(strcasecmp($hName, $name) === 0)
|
||||
return $hValue;
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getHeaderLine(string $name): string {
|
||||
return implode(', ', $this->getHeader($name));
|
||||
}
|
||||
|
||||
public function withHeader(string $name, $value): HttpMessage {
|
||||
if(!is_array($value))
|
||||
$value = [$value];
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
<?php
|
||||
// HttpRequest.php
|
||||
// Created: 2022-02-08
|
||||
// Updated: 2025-03-08
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http;
|
||||
|
||||
use RuntimeException;
|
||||
use InvalidArgumentException;
|
||||
use Index\MediaType;
|
||||
use Index\Http\Content\{MultipartFormContent,UrlEncodedFormContent};
|
||||
use Index\Http\Streams\Stream;
|
||||
use Index\Json\JsonHttpContent;
|
||||
use Psr\Http\Message\{ServerRequestInterface,StreamInterface,UriInterface};
|
||||
use Psr\Http\Message\{ServerRequestInterface,StreamInterface,UploadedFileInterface,UriInterface};
|
||||
|
||||
/**
|
||||
* Represents a HTTP request message.
|
||||
|
@ -33,7 +36,7 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
|
|||
* @param array<string, list<?string>> $params HTTP request query parameters.
|
||||
* @param array<string, string> $cookies HTTP request cookies.
|
||||
* @param array<string, array<object|string>>|object|null $parsedBody Parsed body contents.
|
||||
* @param ?array<string, HttpUploadedFile[]> $uploadedFiles Parsed files.
|
||||
* @param ?array<string, UploadedFileInterface[]> $uploadedFiles Parsed files.
|
||||
*/
|
||||
final public function __construct(
|
||||
string $protocolVersion,
|
||||
|
@ -112,7 +115,7 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
|
|||
* @param ?array<string, list<?string>> $params Value you'd otherwise pass to withQueryParams, null to leave unmodified.
|
||||
* @param ?array<string, string> $cookies Value you'd otherwise pass to withCookiesParams, null to leave unmodified.
|
||||
* @param null|array<string, string[]|object[]>|object|false $parsedBody Value you'd otherwise pass to withParsedBody, false to leave unmodified.
|
||||
* @param array<string, HttpUploadedFile[]>|null|false $uploadedFiles Value you'd otherwise pass to withUploadedFiles, false to leave unmodified.
|
||||
* @param array<string, UploadedFileInterface[]>|null|false $uploadedFiles Value you'd otherwise pass to withUploadedFiles, false to leave unmodified.
|
||||
* @throws InvalidArgumentException If any of the arguments are not acceptable.
|
||||
* @return static
|
||||
*/
|
||||
|
@ -319,7 +322,7 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
|
|||
array|int $options = 0,
|
||||
mixed $default = null,
|
||||
): mixed {
|
||||
if(!isset($this->params[$name]) && isset($this->params[$name][$index]))
|
||||
if(!isset($this->params[$name]) || !isset($this->params[$name][$index]))
|
||||
return $default;
|
||||
|
||||
$value = filter_var($this->params[$name][$index], $filter, $options);
|
||||
|
@ -330,27 +333,26 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
|
|||
* Retrieves how many parameters of the same name appear in the HTTP request query field.
|
||||
*
|
||||
* @param string $name Name of the request query field.
|
||||
* @return int
|
||||
* @return int<0, max> Amount of values.
|
||||
*/
|
||||
public function getParamCount(string $name): int {
|
||||
return isset($this->params[$name]) ? count($this->params[$name]) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, HttpUploadedFile[]> An array tree of UploadedFileInterface instances; an empty array MUST be returned if no data is present.
|
||||
* @return array<string, UploadedFileInterface[]> An array tree of UploadedFileInterface instances; an empty array MUST be returned if no data is present.
|
||||
*/
|
||||
public function getUploadedFiles(): array {
|
||||
// check if Content-Type is multipart/form-data and then yoink
|
||||
// cache it so withUploadedFiles can still work
|
||||
if($this->uploadedFiles === null) {
|
||||
$this->uploadedFiles = [];
|
||||
$parsedBody = $this->getParsedBody();
|
||||
$this->uploadedFiles = $parsedBody instanceof MultipartFormContent ? $parsedBody->files : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
return $this->uploadedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, HttpUploadedFile[]> $uploadedFiles An array tree of UploadedFileInterface instances.
|
||||
* @param array<string, UploadedFileInterface[]> $uploadedFiles An array tree of UploadedFileInterface instances.
|
||||
*/
|
||||
public function withUploadedFiles(array $uploadedFiles): HttpRequest {
|
||||
return $this->with(uploadedFiles: $uploadedFiles);
|
||||
|
@ -362,7 +364,20 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
|
|||
public function getParsedBody() {
|
||||
// this contains a couple hard coded body parsers but you should really be using preprocessors
|
||||
if($this->parsedBody === null) {
|
||||
$this->parsedBody = [];
|
||||
$body = $this->getBody();
|
||||
if($body->getSize() > 0 && $body->isReadable())
|
||||
try {
|
||||
$contentType = MediaType::parse($this->getHeaderLine('content-type'));
|
||||
if($contentType->equals('application/x-www-form-urlencoded')) {
|
||||
$this->parsedBody = UrlEncodedFormContent::parseStream($body);
|
||||
} elseif($contentType->equals('multipart/form-data')) {
|
||||
$this->parsedBody = MultipartFormContent::parseStream($body, $contentType->boundary);
|
||||
} else {
|
||||
$this->parsedBody = [];
|
||||
}
|
||||
} catch(RuntimeException $ex) {
|
||||
$this->parsedBody = [];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->parsedBody;
|
||||
|
@ -501,7 +516,7 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
|
|||
$build->setHeader($name, $value);
|
||||
}
|
||||
|
||||
$build->body = HttpStream::createStreamFromFile('php://input', 'rb');
|
||||
$build->body = Stream::createStreamFromFile('php://input', 'rb');
|
||||
|
||||
return $build->toRequest();
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
<?php
|
||||
// HttpRequestBuilder.php
|
||||
// Created: 2022-02-08
|
||||
// Updated: 2025-03-08
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Index\Http\Streams\NullStream;
|
||||
|
||||
/**
|
||||
* Represents a HTTP request message builder.
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
<?php
|
||||
// HttpResponseBuilder.php
|
||||
// Created: 2022-02-08
|
||||
// Updated: 2025-03-07
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http;
|
||||
|
||||
use DateTimeInterface;
|
||||
use Index\{UrlEncoding,MediaType,XDateTime};
|
||||
use Index\{MediaType,XDateTime};
|
||||
use Index\Http\Streams\NullStream;
|
||||
use Index\Performance\Timings;
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,61 +0,0 @@
|
|||
<?php
|
||||
// HttpUploadedFile.php
|
||||
// Created: 2022-02-10
|
||||
// Updated: 2025-02-28
|
||||
|
||||
namespace Index\Http;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Psr\Http\Message\{StreamInterface,UploadedFileInterface};
|
||||
|
||||
/**
|
||||
* PSR-7 compatibility object for representing a multipart/form-data file upload.
|
||||
*/
|
||||
class HttpUploadedFile implements UploadedFileInterface {
|
||||
private bool $moved = false;
|
||||
|
||||
/**
|
||||
* @param StreamInterface $stream Data stream of the uploaded file.
|
||||
* @param ?int $size Size of the file.
|
||||
* @param ?string $clientFilename Filename suggested by the client.
|
||||
* @param ?string $clientMediaType Filetype suggested by the client.
|
||||
*/
|
||||
public function __construct(
|
||||
private StreamInterface $stream,
|
||||
private ?int $size,
|
||||
private ?string $clientFilename,
|
||||
private ?string $clientMediaType,
|
||||
) {}
|
||||
|
||||
public function getStream(): StreamInterface {
|
||||
return $this->stream;
|
||||
}
|
||||
|
||||
public function getError(): int {
|
||||
return UPLOAD_ERR_OK;
|
||||
}
|
||||
|
||||
public function getSize(): ?int {
|
||||
return $this->size;
|
||||
}
|
||||
|
||||
public function getClientFilename(): ?string {
|
||||
return $this->clientFilename;
|
||||
}
|
||||
|
||||
public function getClientMediaType(): ?string {
|
||||
return $this->clientMediaType;
|
||||
}
|
||||
|
||||
public function moveTo(string $path): void {
|
||||
if(empty($path) || str_contains($path, "\0"))
|
||||
throw new InvalidArgumentException('$path is not a valid path.');
|
||||
if($this->moved)
|
||||
throw new RuntimeException('file has already been moved');
|
||||
|
||||
$this->moved = file_put_contents($path, (string)$this->stream) !== false;
|
||||
if(!$this->moved)
|
||||
throw new RuntimeException('failed to write file');
|
||||
}
|
||||
}
|
|
@ -1,12 +1,13 @@
|
|||
<?php
|
||||
// HtmlErrorHandler.php
|
||||
// Created: 2024-03-28
|
||||
// Updated: 2025-03-07
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Routing\ErrorHandling;
|
||||
|
||||
use Index\Http\{HttpResponse,HttpStream};
|
||||
use Index\Http\HttpResponse;
|
||||
use Index\Http\Routing\HandlerContext;
|
||||
use Index\Http\Streams\Stream;
|
||||
|
||||
/**
|
||||
* Represents a basic HTML error message handler for building HTTP response messages.
|
||||
|
@ -37,7 +38,7 @@ class HtmlErrorHandler implements ErrorHandler {
|
|||
if($charSet === false)
|
||||
$charSet = 'UTF-8';
|
||||
|
||||
$context->response->body = HttpStream::createStream(strtr(self::TEMPLATE, [
|
||||
$context->response->body = Stream::createStream(strtr(self::TEMPLATE, [
|
||||
':charset' => strtolower($charSet),
|
||||
':code' => sprintf('%03d', $context->response->statusCode),
|
||||
':message' => $context->response->reasonPhrase === ''
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
<?php
|
||||
// PlainErrorHandler.php
|
||||
// Created: 2024-03-28
|
||||
// Updated: 2025-03-07
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Routing\ErrorHandling;
|
||||
|
||||
use Index\Http\{HttpResponse,HttpStream};
|
||||
use Index\Http\HttpResponse;
|
||||
use Index\Http\Routing\HandlerContext;
|
||||
use Index\Http\Streams\Stream;
|
||||
|
||||
/**
|
||||
* Represents a plain text error message handler for building HTTP response messages.
|
||||
|
@ -17,7 +18,7 @@ class PlainErrorHandler implements ErrorHandler {
|
|||
return;
|
||||
|
||||
$context->response->setTypePlain();
|
||||
$context->response->body = HttpStream::createStream(sprintf(
|
||||
$context->response->body = Stream::createStream(sprintf(
|
||||
'HTTP %03d %s',
|
||||
$context->response->statusCode,
|
||||
$context->response->reasonPhrase === ''
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
// Router.php
|
||||
// Created: 2024-03-28
|
||||
// Updated: 2025-03-07
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Routing;
|
||||
|
||||
|
@ -10,11 +10,12 @@ use JsonSerializable;
|
|||
use RuntimeException;
|
||||
use Stringable;
|
||||
use Index\Bencode\{Bencode,BencodeSerializable};
|
||||
use Index\Http\{HttpRequest,HttpStream};
|
||||
use Index\Http\HttpRequest;
|
||||
use Index\Http\Routing\ErrorHandling\{ErrorHandler,HtmlErrorHandler,PlainErrorHandler};
|
||||
use Index\Http\Routing\Filters\FilterInfo;
|
||||
use Index\Http\Routing\Processors\{ProcessorInfo,ProcessorTarget};
|
||||
use Index\Http\Routing\Routes\RouteInfo;
|
||||
use Index\Http\Streams\Stream;
|
||||
use Psr\Http\Message\{ResponseInterface,ServerRequestInterface};
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
|
@ -215,7 +216,7 @@ class Router implements RequestHandlerInterface {
|
|||
$context->response->setTypePlain();
|
||||
}
|
||||
|
||||
$context->response->body = HttpStream::createStream($result);
|
||||
$context->response->body = Stream::createStream($result);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
<?php
|
||||
// NullStream.php
|
||||
// Created: 2025-02-28
|
||||
// Updated: 2025-02-28
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http;
|
||||
namespace Index\Http\Streams;
|
||||
|
||||
use RuntimeException;
|
||||
use Stringable;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* Provides a blank read-only stream.
|
||||
*/
|
||||
final class NullStream implements StreamInterface, Stringable {
|
||||
private static NullStream $instance;
|
||||
|
176
src/Http/Streams/ScopedStream.php
Normal file
176
src/Http/Streams/ScopedStream.php
Normal file
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
// ScopedStream.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Streams;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Stringable;
|
||||
use Throwable;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* Read-only stream that scopes to certain offsets on another stream.
|
||||
*/
|
||||
class ScopedStream implements StreamInterface, Stringable {
|
||||
private bool $eof = false;
|
||||
|
||||
/**
|
||||
* @param StreamInterface $stream Stream to scope on.
|
||||
* @param int $offset Offset at which to start this stream.
|
||||
* @param int $length Length of data allocated to this stream.
|
||||
*/
|
||||
final public function __construct(
|
||||
public private(set) StreamInterface $stream,
|
||||
public private(set) int $offset,
|
||||
public private(set) int $length,
|
||||
) {
|
||||
if(!$stream->isReadable())
|
||||
throw new InvalidArgumentException('$stream must be readable');
|
||||
if(!$stream->isSeekable())
|
||||
throw new InvalidArgumentException('$stream must be seekable');
|
||||
if($offset < 0)
|
||||
throw new InvalidArgumentException('$offset must be a positive integer');
|
||||
if($length < 0)
|
||||
throw new InvalidArgumentException('$length must be a positive integer');
|
||||
}
|
||||
|
||||
public function getMetadata(?string $key = null) {
|
||||
return $this->stream->getMetadata($key);
|
||||
}
|
||||
|
||||
public function getSize(): ?int {
|
||||
return $this->length;
|
||||
}
|
||||
|
||||
public function getContents(): string {
|
||||
if($this->stream instanceof Stream) {
|
||||
$contents = stream_get_contents($this->stream->getHandle(), $this->length, $this->offset);
|
||||
if($contents === false)
|
||||
throw new RuntimeException('unable to read base stream');
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
$this->rewind();
|
||||
return $this->read($this->length);
|
||||
}
|
||||
|
||||
public function isReadable(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function read(int $length): string {
|
||||
if($length < 1)
|
||||
throw new RuntimeException('$length must be greater than 0');
|
||||
|
||||
// ensure within bounds
|
||||
$tell = $this->stream->tell() - $this->offset;
|
||||
if($tell < 0) {
|
||||
$tell = 0;
|
||||
$this->stream->seek($this->offset);
|
||||
} elseif($tell >= $this->length) {
|
||||
$this->eof = true;
|
||||
return '';
|
||||
}
|
||||
|
||||
if($this->eof = ($tell + $length > $this->length))
|
||||
$length = $this->length - $tell;
|
||||
|
||||
return $this->stream->read($length);
|
||||
}
|
||||
|
||||
public function isWritable(): bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
public function write(string $string): int {
|
||||
throw new RuntimeException('scoped streams are not writable');
|
||||
}
|
||||
|
||||
public function isSeekable(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function seek(int $offset, int $whence = SEEK_SET): void {
|
||||
$this->eof = false;
|
||||
|
||||
switch($whence) {
|
||||
case SEEK_SET:
|
||||
$offset = max(0, $offset);
|
||||
if($offset > $this->length)
|
||||
$offset = $this->length;
|
||||
break;
|
||||
|
||||
case SEEK_CUR:
|
||||
$offset = $this->tell() + $offset;
|
||||
if($offset < 0)
|
||||
$offset = 0;
|
||||
if($offset > $this->length)
|
||||
$offset = $this->length;
|
||||
break;
|
||||
|
||||
case SEEK_END:
|
||||
$offset = min(0, $offset);
|
||||
if(abs($offset) > $this->length)
|
||||
$offset = 0;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new RuntimeException('mode provided to $whence is not supported');
|
||||
}
|
||||
|
||||
$this->stream->seek($this->offset + $offset);
|
||||
}
|
||||
|
||||
public function tell(): int {
|
||||
return min($this->length, max(0, $this->stream->tell() - $this->offset));
|
||||
}
|
||||
|
||||
public function rewind(): void {
|
||||
$this->seek(0);
|
||||
}
|
||||
|
||||
public function eof(): bool {
|
||||
return $this->eof || $this->stream->eof();
|
||||
}
|
||||
|
||||
public function detach() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public function close(): void {}
|
||||
|
||||
public function __toString(): string {
|
||||
try {
|
||||
return $this->getContents();
|
||||
} catch(Throwable $ex) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes to a stream.
|
||||
*
|
||||
* This method detected if the input $stream is already scoped and removes the extra layers.
|
||||
*
|
||||
* @param StreamInterface $stream Stream to scope on.
|
||||
* @param int $offset Offset to scope to.
|
||||
* @param int $length Length of the scope.
|
||||
* @return ScopedStream
|
||||
*/
|
||||
public static function scopeTo(StreamInterface $stream, int $offset, int $length): ScopedStream {
|
||||
while($stream instanceof ScopedStream) {
|
||||
// ensure length remains within bounds
|
||||
if($length + $offset > $stream->length)
|
||||
$length -= $offset;
|
||||
|
||||
$offset += $stream->offset;
|
||||
$stream = $stream->stream;
|
||||
}
|
||||
|
||||
return new ScopedStream($stream, $offset, $length);
|
||||
}
|
||||
}
|
|
@ -1,16 +1,19 @@
|
|||
<?php
|
||||
// HttpStream.php
|
||||
// Stream.php
|
||||
// Created: 2025-02-28
|
||||
// Updated: 2025-02-28
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http;
|
||||
namespace Index\Http\Streams;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Stringable;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
class HttpStream implements StreamInterface, Stringable {
|
||||
/**
|
||||
* Provides a wrapper for PHP's streams using the PSR-7 interface.
|
||||
*/
|
||||
class Stream implements StreamInterface, Stringable {
|
||||
private bool $detached = false;
|
||||
|
||||
/**
|
||||
|
@ -44,6 +47,15 @@ class HttpStream implements StreamInterface, Stringable {
|
|||
throw new InvalidArgumentException('$resource is not a stream resource');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the underlying stream handle. Exposed for ScopedStream.
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
public function getHandle() {
|
||||
return $this->handle;
|
||||
}
|
||||
|
||||
public function getMetadata(?string $key = null) {
|
||||
$metadata = stream_get_meta_data($this->handle);
|
||||
return $key === null ? $metadata : ($metadata[$key] ?? null);
|
||||
|
@ -143,15 +155,38 @@ class HttpStream implements StreamInterface, Stringable {
|
|||
return $string === false ? '' : $string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaches the stream handle from a given StreamInterface implementation and creates a Stream using it.
|
||||
* If seekable, the stream is rewound.
|
||||
* If the $stream is already a Stream, it is returned verbatim after being rewound.
|
||||
*
|
||||
* @param StreamInterface $stream Stream to be converted.
|
||||
* @return Stream
|
||||
*/
|
||||
public static function castStream(StreamInterface $stream): Stream {
|
||||
if(!($stream instanceof Stream)) {
|
||||
$handle = $stream->detach();
|
||||
if($handle === null)
|
||||
throw new RuntimeException('$stream is already in a detached state, no resource could be grabbed');
|
||||
|
||||
$stream = Stream::createStreamFromResource($handle);
|
||||
}
|
||||
|
||||
if($stream->isSeekable())
|
||||
$stream->rewind();
|
||||
|
||||
return $stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new stream from a string.
|
||||
*
|
||||
* The stream SHOULD be created with a temporary resource.
|
||||
*
|
||||
* @param string $content String content with which to populate the stream.
|
||||
* @return HttpStream
|
||||
* @return Stream
|
||||
*/
|
||||
public static function createStream(string $content = ''): HttpStream {
|
||||
public static function createStream(string $content = ''): Stream {
|
||||
$stream = self::createStreamFromFile('php://temp', 'rb+');
|
||||
$stream->write($content);
|
||||
$stream->rewind();
|
||||
|
@ -170,9 +205,9 @@ class HttpStream implements StreamInterface, Stringable {
|
|||
* @param string $mode Mode with which to open the underlying filename/stream.
|
||||
* @throws RuntimeException If the file cannot be opened.
|
||||
* @throws InvalidArgumentException If the mode is invalid.
|
||||
* @return HttpStream
|
||||
* @return Stream
|
||||
*/
|
||||
public static function createStreamFromFile(string $filename, string $mode = 'r'): HttpStream {
|
||||
public static function createStreamFromFile(string $filename, string $mode = 'r'): Stream {
|
||||
$handle = fopen($filename, $mode);
|
||||
if($handle === false)
|
||||
throw new RuntimeException('$filename could not be opened');
|
||||
|
@ -186,9 +221,9 @@ class HttpStream implements StreamInterface, Stringable {
|
|||
* The stream MUST be readable and may be writable.
|
||||
*
|
||||
* @param resource $resource PHP resource to use as basis of stream.
|
||||
* @return HttpStream
|
||||
* @return Stream
|
||||
*/
|
||||
public static function createStreamFromResource($resource): HttpStream {
|
||||
public static function createStreamFromResource($resource): Stream {
|
||||
return new static($resource);
|
||||
}
|
||||
}
|
96
src/Http/Streams/StreamBuffer.php
Normal file
96
src/Http/Streams/StreamBuffer.php
Normal file
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
// StreamBuffer.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Streams;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* Simple buffer for streams.
|
||||
*/
|
||||
class StreamBuffer {
|
||||
/**
|
||||
* Keeps track of how many bytes have been read in total
|
||||
*/
|
||||
public int $bytes = 0;
|
||||
|
||||
/**
|
||||
* Contains the current buffer.
|
||||
*/
|
||||
public string $data = '';
|
||||
|
||||
/**
|
||||
* Current offset of the buffer relative to the first read.
|
||||
*/
|
||||
public int $offset = 0;
|
||||
|
||||
/**
|
||||
* Currently available data.
|
||||
*/
|
||||
public int $available {
|
||||
get => strlen($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param StreamInterface $stream Stream to read from.
|
||||
* @param int $chunkSize Amount of data to read at once.
|
||||
*/
|
||||
public function __construct(
|
||||
public private(set) StreamInterface $stream,
|
||||
public private(set) int $chunkSize
|
||||
) {
|
||||
if(!$this->stream->isReadable())
|
||||
throw new InvalidArgumentException('$stream must be readable');
|
||||
if($this->chunkSize < 1)
|
||||
throw new InvalidArgumentException('$chunkSize must be greater than 0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends data to the buffer.
|
||||
*
|
||||
* @return int Amount of bytes read.
|
||||
*/
|
||||
public function read(): int {
|
||||
$read = $this->stream->read($this->chunkSize);
|
||||
$this->data .= $read;
|
||||
|
||||
$count = strlen($read);
|
||||
$this->bytes += $count;
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates buffer.
|
||||
*
|
||||
* @param int $amount Amount of bytes to clear.
|
||||
* @throws InvalidArgumentException if $amount is greater than the currently buffered data or less than 1.
|
||||
*/
|
||||
public function truncate(int $amount): void {
|
||||
if($amount < 1)
|
||||
throw new InvalidArgumentException('$amount must be greater than 0');
|
||||
if($amount > strlen($this->data))
|
||||
throw new InvalidArgumentException('$amount is greater than the amount of currently buffered data');
|
||||
|
||||
$this->offset += $amount;
|
||||
$this->data = substr($this->data, $amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds string in currently buffered data.
|
||||
*
|
||||
* @param string $substring String to find.
|
||||
* @param int $offset From where to start searching.
|
||||
* @return int<-1, max> Index of the value, or -1 if not present.
|
||||
*/
|
||||
public function indexOf(string $substring, int $offset = 0): int {
|
||||
$pos = strpos($this->data, $substring, $offset);
|
||||
if($pos === false)
|
||||
return -1;
|
||||
|
||||
return $pos;
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
// MediaType.php
|
||||
// Created: 2022-02-10
|
||||
// Updated: 2025-02-27
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index;
|
||||
|
||||
|
@ -79,6 +79,18 @@ class MediaType implements Stringable, Comparable, Equatable {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* boundary parameter from the media type parameters for multipart/form-data.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public string $boundary {
|
||||
get {
|
||||
$boundary = $this->getParam('boundary', FILTER_DEFAULT, FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH);
|
||||
return is_string($boundary) ? $boundary : '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the category string with another one. Can be used for sorting.
|
||||
*
|
||||
|
@ -191,9 +203,9 @@ class MediaType implements Stringable, Comparable, Equatable {
|
|||
foreach($this->params as $key => $value) {
|
||||
$string .= ';';
|
||||
if(is_string($key))
|
||||
$string .= $key . '=';
|
||||
$string .= urlencode((string)$key) . '=';
|
||||
if(is_scalar($value))
|
||||
$string .= (string)$value;
|
||||
$string .= urlencode((string)$value);
|
||||
}
|
||||
|
||||
return $string;
|
||||
|
@ -220,7 +232,11 @@ class MediaType implements Stringable, Comparable, Equatable {
|
|||
|
||||
foreach($parts as $part) {
|
||||
$paramSplit = explode('=', trim($part), 2);
|
||||
$params[trim($paramSplit[0])] = trim($paramSplit[1] ?? '', " \n\r\t\v\0\"");
|
||||
$value = trim($paramSplit[1] ?? '', " \n\r\t\v\0");
|
||||
if(str_starts_with($value, '"') && str_ends_with($value, '"'))
|
||||
$value = substr($value, 1, -1);
|
||||
|
||||
$params[urldecode(trim($paramSplit[0]))] = urldecode($value);
|
||||
}
|
||||
|
||||
return new MediaType($category, $kind, $suffix, $params);
|
||||
|
|
BIN
tests/HttpFormContentTest-multipart.bin
Normal file
BIN
tests/HttpFormContentTest-multipart.bin
Normal file
Binary file not shown.
110
tests/HttpFormContentTest.php
Normal file
110
tests/HttpFormContentTest.php
Normal file
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
// HttpFormContentTest.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\Attributes\{CoversClass,DataProvider,UsesClass};
|
||||
use Index\Http\Content\{MultipartFormContent,UrlEncodedFormContent};
|
||||
use Index\Http\Content\Multipart\{FileMultipartFormData,ValueMultipartFormData};
|
||||
use Index\Http\Streams\Stream;
|
||||
|
||||
#[CoversClass(MultipartFormContent::class)]
|
||||
#[CoversClass(UrlEncodedFormContent::class)]
|
||||
#[CoversClass(FileMultipartFormData::class)]
|
||||
#[CoversClass(ValueMultipartFormData::class)]
|
||||
#[UsesClass(Stream::class)]
|
||||
final class HttpFormContentTest extends TestCase {
|
||||
/** @return array<array{0: string, 1: array<string, list<?string>>}> */
|
||||
public static function urlEncodedStringProvider(): array {
|
||||
return [
|
||||
[
|
||||
'username=meow&password=beans',
|
||||
['username' => ['meow'], 'password' => ['beans']]
|
||||
],
|
||||
[
|
||||
'username=meow&password=beans&password=soap',
|
||||
['username' => ['meow'], 'password' => ['beans', 'soap']]
|
||||
],
|
||||
[
|
||||
'arg&arg&arg=maybe&arg&the=ok',
|
||||
['arg' => [null, null, 'maybe', null], 'the' => ['ok']]
|
||||
],
|
||||
[
|
||||
'array[]=old&array%5B%5D=syntax&array[meow]=soup',
|
||||
['array[]' => ['old', 'syntax'], 'array[meow]' => ['soup']]
|
||||
],
|
||||
[
|
||||
'plus=this+one+uses+plus+as+space&twenty=this%20uses%20percent%20encoding',
|
||||
['plus' => ['this one uses plus as space'], 'twenty' => ['this uses percent encoding']]
|
||||
],
|
||||
[
|
||||
'&&=&&=&', // there's no reason why this shouldn't be valid but it is quirky!
|
||||
['' => [null, null, '', null, '', null]]
|
||||
],
|
||||
[
|
||||
'',
|
||||
[]
|
||||
],
|
||||
[
|
||||
' ',
|
||||
[' ' => [null]]
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, string[]> $expected */
|
||||
#[DataProvider('urlEncodedStringProvider')]
|
||||
public function testUrlEncodedForm(string $formString, array $expected): void {
|
||||
$form = UrlEncodedFormContent::parseStream(Stream::createStream($formString));
|
||||
|
||||
$this->assertFalse($form->hasParam('never_has_this'));
|
||||
$this->assertEquals(0, $form->getParamCount('never_has_this_either'));
|
||||
$this->assertNull($form->getParam('this_is_not_there_either'));
|
||||
$this->assertEquals(0401, $form->getParamAt('abusing_octal_notation_for_teto_reference', 3510, default: 0401));
|
||||
|
||||
foreach($expected as $key => $values) {
|
||||
$this->assertTrue($form->hasParam($key));
|
||||
$this->assertEquals($form->getParam($key), $values[0] ?? null);
|
||||
|
||||
$count = $form->getParamCount($key);
|
||||
$this->assertEquals(count($values), $count);
|
||||
|
||||
for($i = 0; $i < $count; ++$i)
|
||||
$this->assertEquals($form->getParamAt($key, $i), $values[$i]);
|
||||
}
|
||||
}
|
||||
|
||||
public function testMultipartForm(): void {
|
||||
$form = MultipartFormContent::parseStream(
|
||||
Stream::createStreamFromFile(__DIR__ . '/HttpFormContentTest-multipart.bin', 'rb'),
|
||||
'----geckoformboundaryc23c64d876d81ed7258ada21a9eb0b06'
|
||||
);
|
||||
|
||||
// normal form api
|
||||
$this->assertFalse($form->hasParam('never_has_this'));
|
||||
$this->assertEquals(0, $form->getParamCount('never_has_this_either'));
|
||||
$this->assertNull($form->getParam('this_is_not_there_either'));
|
||||
$this->assertEquals(0401, $form->getParamAt('abusing_octal_notation_for_teto_reference', 3510, default: 0401));
|
||||
|
||||
$this->assertTrue($form->hasParam('meow'));
|
||||
$this->assertEquals(1, $form->getParamCount('meow'));
|
||||
$this->assertEquals('sfdsfs', $form->getParam('meow'));
|
||||
|
||||
$this->assertTrue($form->hasParam('mewow'));
|
||||
$this->assertEquals(1, $form->getParamCount('mewow'));
|
||||
$this->assertEquals('https://railgun.sh/sockchat', $form->getParam('mewow'));
|
||||
|
||||
$this->assertTrue($form->hasParam('"the'));
|
||||
$this->assertEquals(2, $form->getParamCount('"the'));
|
||||
$this->assertEquals('value!', $form->getParam('"the'));
|
||||
$secondValue = $form->getParamAt('"the', 1);
|
||||
$this->assertIsString($secondValue);
|
||||
$this->assertEquals('8e3b3df1ab6be7498566e795cc9fe3b8f55e084d0e1fcf3e5323269cfc1bc884', hash('sha256', $secondValue));
|
||||
|
||||
// multipart specific api
|
||||
// tbd
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
// MediaTypeTest.php
|
||||
// Created: 2024-08-18
|
||||
// Updated: 2025-01-18
|
||||
// Updated: 2025-03-12
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
@ -29,4 +29,11 @@ final class MediaTypeTest extends TestCase {
|
|||
$this->assertEquals($expectQuality, $type->quality);
|
||||
}
|
||||
}
|
||||
|
||||
public function testMultipartFormData(): void {
|
||||
$typeStr = 'multipart/form-data;boundary="----geckoformboundary747bc3d6e8355713b1f9c332a6d28650"';
|
||||
$type = MediaType::parse($typeStr);
|
||||
//$this->assertEquals($typeStr, (string)$type); it doesn't reencode properly :/, MediaType parser needs an overhaul tbh
|
||||
$this->assertEquals('----geckoformboundary747bc3d6e8355713b1f9c332a6d28650', $type->boundary);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
<?php
|
||||
// RouterTest.php
|
||||
// Created: 2022-01-20
|
||||
// Updated: 2025-03-07
|
||||
// Updated: 2025-03-12
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Index\Http\{HttpHeaders,HttpResponseBuilder,HttpRequest,HttpStream,HttpUri,NullStream};
|
||||
use Index\Http\{HttpHeaders,HttpResponseBuilder,HttpRequest,HttpUri};
|
||||
use Index\Http\Routing\{HandlerContext,RouteHandler,RouteHandlerCommon,Router};
|
||||
use Index\Http\Routing\Filters\{FilterInfo,PrefixFilter};
|
||||
use Index\Http\Routing\Processors\{After,Before,Postprocessor,Preprocessor,ProcessorInfo};
|
||||
use Index\Http\Routing\Routes\{ExactRoute,PatternRoute,RouteInfo};
|
||||
use Index\Http\Streams\{NullStream,Stream};
|
||||
|
||||
/**
|
||||
* This test isn't super representative of the current functionality
|
||||
|
@ -177,7 +178,7 @@ final class RouterTest extends TestCase {
|
|||
public function encodePost(HandlerContext $context, int $flags): void {
|
||||
$context->halt();
|
||||
$context->response->setTypeJson();
|
||||
$context->response->body = HttpStream::createStream(
|
||||
$context->response->body = Stream::createStream(
|
||||
json_encode($context->result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR | $flags)
|
||||
);
|
||||
}
|
||||
|
@ -215,11 +216,11 @@ final class RouterTest extends TestCase {
|
|||
}
|
||||
));
|
||||
|
||||
$response = $router->handle(new HttpRequest('1.1', [], HttpStream::createStream(base64_encode('mewow')), [], 'POST', HttpUri::createUri('/test'), [], []));
|
||||
$response = $router->handle(new HttpRequest('1.1', [], Stream::createStream(base64_encode('mewow')), [], 'POST', HttpUri::createUri('/test'), [], []));
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertEquals("{\n \"data\": \"mewow\"\n}", (string)$response->getBody());
|
||||
|
||||
$response = $router->handle(new HttpRequest('1.1', [], HttpStream::createStream(base64_encode('soap')), [], 'PUT', HttpUri::createUri('/alternate'), [], []));
|
||||
$response = $router->handle(new HttpRequest('1.1', [], Stream::createStream(base64_encode('soap')), [], 'PUT', HttpUri::createUri('/alternate'), [], []));
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertEquals("{\n \"path\": \"/alternate\",\n \"data\": \"soap\"\n}", (string)$response->getBody());
|
||||
|
||||
|
|
96
tests/ScopedStreamTest.php
Normal file
96
tests/ScopedStreamTest.php
Normal file
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
// ScopedStreamTest.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\Attributes\{CoversClass,UsesClass};
|
||||
use Index\Http\Streams\{ScopedStream,Stream};
|
||||
|
||||
#[CoversClass(ScopedStream::class)]
|
||||
#[UsesClass(Stream::class)]
|
||||
final class ScopedStreamTest extends TestCase {
|
||||
public function testScopedStream(): void {
|
||||
// why use test data when you can just use the source file
|
||||
$stream = Stream::createStreamFromFile(__FILE__, 'rb');
|
||||
$stream->seek(5, SEEK_SET);
|
||||
|
||||
// potentially account for Windows moments
|
||||
$offset = $stream->read(2) === "\r\n" ? 7 : 6;
|
||||
$length = 23;
|
||||
|
||||
$scoped = new ScopedStream($stream, $offset, $length);
|
||||
$this->assertEquals('// ScopedStreamTest.php', (string)$scoped);
|
||||
$this->assertEquals($length, $scoped->getSize());
|
||||
|
||||
// rewind the underlying stream, scoped should should account for tell() < 0
|
||||
$stream->rewind();
|
||||
$read = $scoped->read(100);
|
||||
$this->assertEquals($length, strlen($read));
|
||||
$this->assertEquals('// ScopedStreamTest.php', $read);
|
||||
|
||||
// read beyond end + eof
|
||||
$stream->seek(-50, SEEK_END);
|
||||
$read = $scoped->read(100);
|
||||
$this->assertEmpty($read);
|
||||
$this->assertTrue($scoped->eof());
|
||||
|
||||
// SEEK_SET
|
||||
$scoped->seek(5);
|
||||
$this->assertEquals($offset + 5, $stream->tell());
|
||||
$read = $scoped->read(5);
|
||||
$this->assertEquals($offset + 10, $stream->tell());
|
||||
$this->assertEquals('opedS', $read);
|
||||
|
||||
$scoped->rewind(); // calls seek(offset) internally
|
||||
$this->assertEquals($offset, $stream->tell());
|
||||
|
||||
// SEEK_CUR
|
||||
$scoped->seek(4, SEEK_CUR);
|
||||
$read = $scoped->read(4);
|
||||
$this->assertEquals('cope', $read);
|
||||
|
||||
$read = $stream->read(4);
|
||||
$this->assertEquals('dStr', $read);
|
||||
$this->assertEquals(12, $scoped->tell());
|
||||
$this->assertEquals($offset + 12, $stream->tell());
|
||||
|
||||
$scoped->seek(100, SEEK_CUR);
|
||||
$read = $scoped->read(100);
|
||||
$this->assertEmpty($read);
|
||||
$this->assertTrue($scoped->eof());
|
||||
$this->assertFalse($stream->eof());
|
||||
|
||||
$scoped->seek(-100, SEEK_CUR);
|
||||
$read = $scoped->read(5);
|
||||
$this->assertEquals('// Sc', $read);
|
||||
|
||||
// SEEK_END + eof behaviour
|
||||
$stream->seek(0, SEEK_END);
|
||||
$read = $stream->read(1);
|
||||
$this->assertTrue($scoped->eof());
|
||||
$this->assertTrue($stream->eof());
|
||||
|
||||
$scoped->seek(0, SEEK_END);
|
||||
$this->assertFalse($scoped->eof());
|
||||
$this->assertFalse($stream->eof());
|
||||
|
||||
// double wrap to test the alternate path in getContents
|
||||
$double = new ScopedStream($scoped, 5, 12);
|
||||
$double->seek(5, SEEK_CUR);
|
||||
$this->assertEquals('tre', $double->read(3));
|
||||
$this->assertEquals('opedStreamTe', (string)$double);
|
||||
|
||||
// now you're scoping with scopes
|
||||
$triple = ScopedStream::scopeTo($double, 2, 12);
|
||||
$this->assertInstanceOf(ScopedStream::class, $double->stream);
|
||||
$this->assertInstanceOf(Stream::class, $triple->stream);
|
||||
$this->assertEquals($offset + 7, $triple->offset);
|
||||
$this->assertEquals('edStreamTe', (string)$triple);
|
||||
$triple->rewind();
|
||||
$this->assertEquals('edStreamTe', $triple->read(100));
|
||||
$this->assertTrue($triple->eof());
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue