multipart/form-data implementation

This commit is contained in:
flash 2025-03-12 20:56:48 +00:00
parent f07a3ab50e
commit d56be84bbb
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
27 changed files with 1196 additions and 131 deletions

View file

@ -1 +1 @@
0.2503.82034
0.2503.122055

View 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; }
}

View 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;
}

View 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;
}
}

View 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;
}

View 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;
}
}

View 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);
}
}

View 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
View 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));
}
}

View file

@ -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];

View file

@ -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();
}

View file

@ -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.

View file

@ -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;
/**

View file

@ -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');
}
}

View 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 === ''

View file

@ -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 === ''

View file

@ -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);
}
}

View file

@ -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;

View 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);
}
}

View file

@ -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);
}
}

View 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;
}
}

View file

@ -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);

Binary file not shown.

View 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
}
}

View file

@ -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);
}
}

View file

@ -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());

View 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());
}
}