Merge pull request 'Third Major Router Rewrite' () from router-v3 into trunk

Reviewed-on: 
This commit is contained in:
flash 2025-03-20 02:48:24 +00:00
commit 9d82faf30b
96 changed files with 6512 additions and 2161 deletions
README.mdVERSIONcomposer.jsoncomposer.lock
src
Bencode
Dependencies.php
Http
Json
MediaType.php
Urls
tests

View file

@ -30,6 +30,9 @@ This driver also works for MySQL as the dependencies would suggest, but you shou
Requires the `sqlite3` extension.
### `Index\Http\Content\MultipartFormContent`
You must set `enable_post_data_reading` to `Off` in `php.ini` or the FPM pool `.conf` files for `multipart/form-data` processing to work as expected.
## Versioning

View file

@ -1 +1 @@
0.2502.272128
0.2503.192123

View file

@ -8,7 +8,9 @@
"php": ">=8.4",
"ext-mbstring": "*",
"twig/twig": "^3.20",
"twig/html-extra": "^3.20"
"twig/html-extra": "^3.20",
"psr/http-message": "^2.0",
"psr/http-server-handler": "^1.0"
},
"require-dev": {
"phpunit/phpunit": "^12.0",

177
composer.lock generated
View file

@ -4,8 +4,117 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c068660a214ebb9d165b3ad0d1409993",
"content-hash": "2bf36789b391bbd2d2d287646ce382a9",
"packages": [
{
"name": "psr/http-message",
"version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-message/tree/2.0"
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "psr/http-server-handler",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-server-handler.git",
"reference": "84c4fb66179be4caaf8e97bd239203245302e7d4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4",
"reference": "84c4fb66179be4caaf8e97bd239203245302e7d4",
"shasum": ""
},
"require": {
"php": ">=7.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Server\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP server-side request handler",
"keywords": [
"handler",
"http",
"http-interop",
"psr",
"psr-15",
"psr-7",
"request",
"response",
"server"
],
"support": {
"source": "https://github.com/php-fig/http-server-handler/tree/1.0.2"
},
"time": "2023-04-10T20:06:20+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.5.1",
@ -867,16 +976,16 @@
},
{
"name": "phpstan/phpstan",
"version": "2.1.6",
"version": "2.1.8",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
"reference": "6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c"
"reference": "f9adff3b87c03b12cc7e46a30a524648e497758f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c",
"reference": "6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/f9adff3b87c03b12cc7e46a30a524648e497758f",
"reference": "f9adff3b87c03b12cc7e46a30a524648e497758f",
"shasum": ""
},
"require": {
@ -921,20 +1030,20 @@
"type": "github"
}
],
"time": "2025-02-19T15:46:42+00:00"
"time": "2025-03-09T09:30:48+00:00"
},
{
"name": "phpunit/php-code-coverage",
"version": "12.0.4",
"version": "12.1.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
"reference": "79e5ef5897068689c7c325554d6df905480ce942"
"reference": "d331a5ced3d9a2b917baa9841b2211e72f9e780d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/79e5ef5897068689c7c325554d6df905480ce942",
"reference": "79e5ef5897068689c7c325554d6df905480ce942",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d331a5ced3d9a2b917baa9841b2211e72f9e780d",
"reference": "d331a5ced3d9a2b917baa9841b2211e72f9e780d",
"shasum": ""
},
"require": {
@ -961,7 +1070,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "12.0.x-dev"
"dev-main": "12.1.x-dev"
}
},
"autoload": {
@ -990,7 +1099,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.0.4"
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.1.0"
},
"funding": [
{
@ -998,7 +1107,7 @@
"type": "github"
}
],
"time": "2025-02-25T13:27:48+00:00"
"time": "2025-03-17T13:56:07+00:00"
},
{
"name": "phpunit/php-file-iterator",
@ -1247,16 +1356,16 @@
},
{
"name": "phpunit/phpunit",
"version": "12.0.5",
"version": "12.0.9",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "0f177d7316ba155d36337c3811b11993b54dae32"
"reference": "7835bb4276780e0bbb385ce0a777b839e03096db"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0f177d7316ba155d36337c3811b11993b54dae32",
"reference": "0f177d7316ba155d36337c3811b11993b54dae32",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7835bb4276780e0bbb385ce0a777b839e03096db",
"reference": "7835bb4276780e0bbb385ce0a777b839e03096db",
"shasum": ""
},
"require": {
@ -1270,19 +1379,19 @@
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=8.3",
"phpunit/php-code-coverage": "^12.0.3",
"phpunit/php-code-coverage": "^12.1.0",
"phpunit/php-file-iterator": "^6.0.0",
"phpunit/php-invoker": "^6.0.0",
"phpunit/php-text-template": "^5.0.0",
"phpunit/php-timer": "^8.0.0",
"sebastian/cli-parser": "^4.0.0",
"sebastian/comparator": "^7.0.0",
"sebastian/comparator": "^7.0.1",
"sebastian/diff": "^7.0.0",
"sebastian/environment": "^8.0.0",
"sebastian/exporter": "^7.0.0",
"sebastian/global-state": "^8.0.0",
"sebastian/object-enumerator": "^7.0.0",
"sebastian/type": "^6.0.0",
"sebastian/type": "^6.0.2",
"sebastian/version": "^6.0.0",
"staabm/side-effects-detector": "^1.0.5"
},
@ -1324,7 +1433,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.0.5"
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.0.9"
},
"funding": [
{
@ -1340,7 +1449,7 @@
"type": "tidelift"
}
],
"time": "2025-02-25T06:13:04+00:00"
"time": "2025-03-19T13:47:33+00:00"
},
{
"name": "sebastian/cli-parser",
@ -1401,16 +1510,16 @@
},
{
"name": "sebastian/comparator",
"version": "7.0.0",
"version": "7.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
"reference": "18eb5a4f854dbd1d6512c459b605de2edb5a0b47"
"reference": "b478f34614f934e0291598d0c08cbaba9644bee5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/18eb5a4f854dbd1d6512c459b605de2edb5a0b47",
"reference": "18eb5a4f854dbd1d6512c459b605de2edb5a0b47",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b478f34614f934e0291598d0c08cbaba9644bee5",
"reference": "b478f34614f934e0291598d0c08cbaba9644bee5",
"shasum": ""
},
"require": {
@ -1469,7 +1578,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
"source": "https://github.com/sebastianbergmann/comparator/tree/7.0.0"
"source": "https://github.com/sebastianbergmann/comparator/tree/7.0.1"
},
"funding": [
{
@ -1477,7 +1586,7 @@
"type": "github"
}
],
"time": "2025-02-07T04:54:52+00:00"
"time": "2025-03-07T07:00:32+00:00"
},
{
"name": "sebastian/complexity",
@ -2046,16 +2155,16 @@
},
{
"name": "sebastian/type",
"version": "6.0.0",
"version": "6.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/type.git",
"reference": "533fe082889a616f330bcba6f50965135f4f2fab"
"reference": "1d7cd6e514384c36d7a390347f57c385d4be6069"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/type/zipball/533fe082889a616f330bcba6f50965135f4f2fab",
"reference": "533fe082889a616f330bcba6f50965135f4f2fab",
"url": "https://api.github.com/repos/sebastianbergmann/type/zipball/1d7cd6e514384c36d7a390347f57c385d4be6069",
"reference": "1d7cd6e514384c36d7a390347f57c385d4be6069",
"shasum": ""
},
"require": {
@ -2091,7 +2200,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/type/issues",
"security": "https://github.com/sebastianbergmann/type/security/policy",
"source": "https://github.com/sebastianbergmann/type/tree/6.0.0"
"source": "https://github.com/sebastianbergmann/type/tree/6.0.2"
},
"funding": [
{
@ -2099,7 +2208,7 @@
"type": "github"
}
],
"time": "2025-02-07T05:00:19+00:00"
"time": "2025-03-18T13:37:31+00:00"
},
{
"name": "sebastian/version",

View file

@ -1,75 +0,0 @@
<?php
// BencodeHttpContent.php
// Created: 2022-02-10
// Updated: 2025-01-18
namespace Index\Bencode;
use RuntimeException;
use Index\Http\HttpContent;
/**
* Represents Bencoded body content for a HTTP message.
*/
class BencodeHttpContent implements BencodeSerializable, HttpContent {
/**
* @param mixed $content Content to be bencoded.
*/
public function __construct(
public private(set) mixed $content
) {}
public function bencodeSerialize(): mixed {
return $this->content;
}
/**
* Encodes the content.
*
* @return string Bencoded string.
*/
public function encode(): string {
return Bencode::encode($this->content);
}
public function __toString(): string {
return $this->encode();
}
/**
* Creates an instance from encoded content.
*
* @param mixed $encoded Bencoded content.
* @return BencodeHttpContent Instance representing the provided content.
*/
public static function fromEncoded(mixed $encoded): BencodeHttpContent {
return new BencodeHttpContent(Bencode::decode($encoded));
}
/**
* Creates an instance from an encoded file.
*
* @param string $path Path to the bencoded file.
* @return BencodeHttpContent Instance representing the provided path.
*/
public static function fromFile(string $path): BencodeHttpContent {
$handle = fopen($path, 'rb');
if(!is_resource($handle))
throw new RuntimeException('$path could not be opened');
try {
return self::fromEncoded($handle);
} finally {
fclose($handle);
}
}
/**
* Creates an instance from the raw request body.
*
* @return BencodeHttpContent Instance representing the request body.
*/
public static function fromRequest(): BencodeHttpContent {
return self::fromFile('php://input');
}
}

View file

@ -1,24 +0,0 @@
<?php
// BencodeHttpContentHandler.php
// Created: 2024-03-28
// Updated: 2025-01-18
namespace Index\Bencode;
use Index\Http\{HttpContentHandler,HttpResponseBuilder};
/**
* Represents a Bencode content handler for building HTTP response messages.
*/
class BencodeHttpContentHandler implements HttpContentHandler {
public function match(mixed $content): bool {
return $content instanceof BencodeSerializable;
}
public function handle(HttpResponseBuilder $response, mixed $content): void {
if(!$response->hasContentType())
$response->setTypePlain();
$response->content = new BencodeHttpContent($content);
}
}

View file

@ -1,16 +1,17 @@
<?php
// Dependencies.php
// Created: 2025-01-18
// Updated: 2025-01-22
// Updated: 2025-03-15
namespace Index;
use Closure;
use InvalidArgumentException;
use ReflectionClass;
use ReflectionException;
use ReflectionFunction;
use ReflectionFunctionAbstract;
use ReflectionNamedType;
use ReflectionParameter;
use RuntimeException;
class Dependencies {
@ -35,15 +36,21 @@ class Dependencies {
* @return array<string|int, mixed>
*/
private function resolveArgs(ReflectionFunctionAbstract $constructor, array $args): array {
if($constructor->getNumberOfRequiredParameters() > 0 && (empty($args) || !array_is_list($args))) {
$params = XArray::where(
$constructor->getParameters(),
fn(ReflectionParameter $param) => !$param->isOptional() && $param->hasType() && !array_key_exists($param->getName(), $args)
);
foreach($params as $paramInfo) {
$realArgs = [];
$params = $constructor->getParameters();
foreach($params as $paramInfo) {
$paramName = $paramInfo->getName();
if(array_key_exists($paramName, $args)) {
$realArgs[] = $args[$paramName];
unset($args[$paramName]);
continue;
}
try {
$typeInfo = $paramInfo->getType();
$allowsNull = $typeInfo === null || $typeInfo->allowsNull();
$value = null;
$value = $paramInfo->isDefaultValueAvailable() ? $paramInfo->getDefaultValue() : null;
// maybe support intersection and union someday
if($typeInfo instanceof ReflectionNamedType && !$typeInfo->isBuiltin()) {
@ -58,11 +65,35 @@ class Dependencies {
if($value === null && !$allowsNull)
throw new RuntimeException('required parameter has unsupported type definition');
$args[$paramInfo->getName()] = $value;
$realArgs[] = $value;
continue;
} catch(RuntimeException $ex) {
if(count($args) > 0) {
$realArgs[] = array_shift($args);
continue;
}
if(!$paramInfo->isOptional())
throw $ex;
$realArgs[] = null;
}
}
return $args;
return $realArgs;
}
/**
* Calls a closure using dependencies.
*
* @param Closure $closure Function to call.
* @param mixed ...$args Additional arguments to be passed to the constructor.
* @return mixed Return value of the function or method.
*/
public function call(Closure $closure, mixed ...$args): mixed {
$function = new ReflectionFunction($closure);
return $function->invokeArgs($this->resolveArgs($function, $args));
}
/**
@ -70,7 +101,7 @@ class Dependencies {
*
* @template T of object
* @param class-string<T> $class Class string.
* @param mixed ...$args Arguments to be passed to the constructor.
* @param mixed ...$args Additional arguments to be passed to the constructor.
* @return T
*/
public function construct(string $class, mixed ...$args): object {

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,16 @@
<?php
// FormContent.php
// Created: 2025-03-12
// Updated: 2025-03-15
namespace Index\Http\Content;
use Iterator;
use Index\Http\HttpParameters;
/**
* Provides a common interface for application/x-www-form-urlencoded and multipart/form-data.
*
* @extends Iterator<string, ?list<string|\Stringable>>
*/
interface FormContent extends Content, HttpParameters, Iterator {}

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,38 @@
<?php
// ValueMultipartFormData.php
// Created: 2025-03-12
// Updated: 2025-03-15
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 => (string)$this->stream;
}
public function __toString(): string {
return (string)$this->stream;
}
}

View file

@ -0,0 +1,229 @@
<?php
// MultipartFormContent.php
// Created: 2025-03-12
// Updated: 2025-03-15
namespace Index\Http\Content;
use Iterator;
use RuntimeException;
use Index\Http\HttpParametersCommon;
use Index\Http\Content\Multipart\{FileMultipartFormData,MultipartFormData,ValueMultipartFormData};
use Index\Http\Streams\{ScopedStream,Stream,StreamBuffer};
use Psr\Http\Message\StreamInterface;
/**
* Implements multipart/form-data.
* @implements Iterator<string, ?list<MultipartFormData>>
*/
class MultipartFormContent implements FormContent, Iterator {
use HttpParametersCommon;
/** @var string[] */
private array $keys;
/** @var int<0, max> */
private int $position = 0;
/**
* @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,
) {
$this->keys = array_keys($params);
}
public function rewind(): void {
$this->position = 0;
}
public function valid(): bool {
return $this->position < count($this->keys);
}
/** @return ?list<?MultipartFormData> */
#[\ReturnTypeWillChange]
public function current() {
return $this->valid() ? $this->params[$this->keys[$this->position]] : null;
}
/** @return ?string */
#[\ReturnTypeWillChange]
public function key() {
return $this->valid() ? $this->keys[$this->position] : null;
}
public function next(): void {
++$this->position;
}
/**
* 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);
}
/**
* 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;
}
/**
* 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,71 @@
<?php
// UrlEncodedFormContent.php
// Created: 2025-03-12
// Updated: 2025-03-15
namespace Index\Http\Content;
use Index\Http\{HttpParametersCommon,HttpUri};
use Psr\Http\Message\StreamInterface;
/**
* Implements application/x-www-form-urlencoded.
*/
class UrlEncodedFormContent implements FormContent {
use HttpParametersCommon;
/** @var string[] */
private array $keys;
/** @var int<0, max> */
private int $position = 0;
/**
* @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,
) {
$this->keys = array_keys($params);
}
public function rewind(): void {
$this->position = 0;
}
public function valid(): bool {
return $this->position < count($this->keys);
}
/** @return ?list<?string> */
#[\ReturnTypeWillChange]
public function current() {
return $this->valid() ? $this->params[$this->keys[$this->position]] : null;
}
/** @return ?string */
#[\ReturnTypeWillChange]
public function key() {
return $this->valid() ? $this->keys[$this->position] : null;
}
public function next(): void {
++$this->position;
}
/**
* 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);
}
}

View file

@ -1,128 +0,0 @@
<?php
// FormHttpContent.php
// Created: 2022-02-10
// Updated: 2025-01-18
namespace Index\Http;
use RuntimeException;
/**
* Represents form body content for a HTTP message.
*/
class FormHttpContent implements HttpContent {
/**
* @param array<string, mixed> $params Form fields.
* @param array<string, HttpUploadedFile|array<string, mixed>> $files Uploaded files.
*/
public function __construct(
public private(set) array $params,
public private(set) array $files
) {}
/**
* Retrieves a form field with filtering.
*
* @param string $name Name of the form field.
* @param int $filter A PHP filter extension filter constant.
* @param array<string, mixed>|int $options Options for the PHP filter.
* @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 {
if(!isset($this->params[$name]))
return null;
return filter_var($this->params[$name] ?? null, $filter, $options);
}
/**
* Retrieves a form field with filtering and enforcing a scalar value.
*
* @param string $name Name of the form field.
* @param int $filter A PHP filter extension filter constant.
* @param array<string, mixed>|int $options Options for the PHP filter.
* @return ?scalar Value of the form field, null if not present.
*/
public function getParamScalar(string $name, int $filter = FILTER_DEFAULT, array|int $options = 0): bool|float|int|string|null {
$value = $this->getParam($name, $filter, $options);
return is_scalar($value) ? $value : null;
}
/**
* Checks if a form field is present.
*
* @param string $name Name of the form field.
* @return bool true if the field is present, false if not.
*/
public function hasParam(string $name): bool {
return isset($this->params[$name]);
}
/**
* Gets all form fields as a query string.
*
* @param bool $spacesAsPlus true if spaces should be represented with a +, false if %20.
* @return string Query string representation of form fields.
*/
public function getParamString(bool $spacesAsPlus = false): string {
return http_build_query($this->params, '', '&', $spacesAsPlus ? PHP_QUERY_RFC1738 : PHP_QUERY_RFC3986);
}
/**
* Checks if a file upload is present.
*
* @param string $name Name of the form field.
* @return bool true if the upload is present, false if not.
*/
public function hasUploadedFile(string $name): bool {
return isset($this->files[$name]);
}
/**
* Retrieves a file upload.
*
* @param string $name Name of the form field.
* @throws RuntimeException If no uploaded file with form field $name is present.
* @return HttpUploadedFile Uploaded file info.
*/
public function getUploadedFile(string $name): HttpUploadedFile {
if(!isset($this->files[$name]))
throw new RuntimeException('No file with name $name present.');
if(is_array($this->files[$name]))
throw new RuntimeException('Array based accessors are unsupported currently.');
return $this->files[$name];
}
/**
* Creates an instance from an array of form fields and uploaded files.
*
* @param array<string, mixed> $post Form fields.
* @param array<string, mixed> $files Uploaded files.
* @return FormHttpContent Instance representing the request body.
*/
public static function fromRaw(array $post, array $files): FormHttpContent {
return new FormHttpContent(
$post,
HttpUploadedFile::createFromPhpFiles($files)
);
}
/**
* Creates an instance from the $_POST and $_FILES superglobals.
*
* @return FormHttpContent Instance representing the request body.
*/
public static function fromRequest(): FormHttpContent {
/** @var array<string, mixed> $postVars */
$postVars = $_POST;
/** @var array<string, mixed> $filesVars */
$filesVars = $_FILES;
return self::fromRaw($postVars, $filesVars);
}
public function __toString(): string {
return $this->getParamString();
}
}

View file

@ -1,40 +0,0 @@
<?php
// HtmlHttpErrorHandler.php
// Created: 2024-03-28
// Updated: 2025-01-18
namespace Index\Http;
/**
* Represents a basic HTML error message handler for building HTTP response messages.
*/
class HtmlHttpErrorHandler implements HttpErrorHandler {
private const TEMPLATE = <<<HTML
<!doctype html>
<html>
<head>
<meta charset=":charset">
<title>:code :message</title>
</head>
<body>
<center><h1>:code :message</h1><center>
<hr>
<center>Index</center>
</body>
</html>
HTML;
public function handle(HttpResponseBuilder $response, HttpRequest $request, int $code, string $message): void {
$response->setTypeHtml();
$charSet = mb_preferred_mime_name(mb_internal_encoding());
if($charSet === false)
$charSet = 'UTF-8';
$response->content = strtr(self::TEMPLATE, [
':charset' => strtolower($charSet),
':code' => sprintf('%03d', $code),
':message' => $message,
]);
}
}

View file

@ -1,13 +0,0 @@
<?php
// HttpContent.php
// Created: 2022-02-08
// Updated: 2024-10-02
namespace Index\Http;
use Stringable;
/**
* Represents the body content for a HTTP message.
*/
interface HttpContent extends Stringable {}

View file

@ -1,27 +0,0 @@
<?php
// HttpContentHandler.php
// Created: 2024-03-28
// Updated: 2025-01-18
namespace Index\Http;
/**
* Represents a content handler for building HTTP response messages.
*/
interface HttpContentHandler {
/**
* Determines whether this handler is suitable for the body content.
*
* @param mixed $content Content to be judged.
* @return bool true if suitable, false if not.
*/
public function match(mixed $content): bool;
/**
* Handles body content.
*
* @param HttpResponseBuilder $response Response to apply this body to.
* @param mixed $content Body to apply to the response message.
*/
public function handle(HttpResponseBuilder $response, mixed $content): void;
}

View file

@ -1,21 +0,0 @@
<?php
// HttpErrorHandler.php
// Created: 2024-03-28
// Updated: 2025-01-18
namespace Index\Http;
/**
* Represents an error message handler for building HTTP response messages.
*/
interface HttpErrorHandler {
/**
* Applies an error message template to the provided HTTP response builder.
*
* @param HttpResponseBuilder $response HTTP Response builder to apply this error to.
* @param HttpRequest $request HTTP Request this error is a response to.
* @param int $code HTTP status code to apply.
* @param string $message HTTP status message to apply.
*/
public function handle(HttpResponseBuilder $response, HttpRequest $request, int $code, string $message): void;
}

View file

@ -1,44 +0,0 @@
<?php
// HttpHeader.php
// Created: 2022-02-14
// Updated: 2025-02-27
namespace Index\Http;
use Stringable;
/**
* Represents a generic HTTP header.
*/
class HttpHeader implements Stringable {
/**
* Lines of the header.
*
* @var string[]
*/
public private(set) array $lines;
/**
* @param string $name Name of the header.
* @param string ...$lines Lines of the header.
*/
public function __construct(
public private(set) string $name,
string ...$lines
) {
$this->lines = $lines;
}
/**
* First line of the header.
*
* @var string
*/
public string $firstLine {
get => $this->lines[0];
}
public function __toString(): string {
return implode(', ', $this->lines);
}
}

View file

@ -1,101 +1,56 @@
<?php
// HttpHeaders.php
// Created: 2022-02-08
// Updated: 2025-02-27
// Updated: 2025-03-15
namespace Index\Http;
use RuntimeException;
use Index\XArray;
/**
* Represents a collection of HTTP headers.
* Common implementation for HTTP header methods.
*/
class HttpHeaders {
trait HttpHeaders {
/**
* @param HttpHeader[] $headers HTTP header instances.
* Retrieves all message header values.
*
* @return array<string, string[]>
*/
public function __construct(
private array $headers
) {
$real = [];
foreach($headers as $header)
if($header instanceof HttpHeader)
$real[strtolower($header->name)] = $header;
$this->headers = $real;
public function getHeaders(): array {
return $this->headers;
}
/**
* Checks if a header is present.
* Checks if a header exists by the given case-insensitive name.
*
* @param string $name Name of the header.
* @return bool true if the header is present.
* @param string $name Case-insensitive header field name.
* @return bool
*/
public function hasHeader(string $name): bool {
return isset($this->headers[strtolower($name)]);
return XArray::any($this->headers, fn($hValue, $hName) => is_string($hName) && strcasecmp($hName, $name) === 0 && !empty($hValue));
}
/**
* All headers.
* Retrieves a message header value by the given case-insensitive name.
*
* @var HttpHeader[]
* @param string $name Case-insensitive header field name.
* @return string[]
*/
public array $all {
get => array_values($this->headers);
public function getHeader(string $name): array {
foreach($this->headers as $hName => $hValue)
if(strcasecmp($hName, $name) === 0)
return $hValue;
return [];
}
/**
* Retrieves a header by name.
* Retrieves a comma-separated string of the values for a single header.
*
* @param string $name Name of the header.
* @throws RuntimeException If no header with $name exists.
* @return HttpHeader Instance of the requested header.
*/
public function getHeader(string $name): HttpHeader {
$name = strtolower($name);
if(!isset($this->headers[$name]))
throw new RuntimeException('No header with that name is present.');
return $this->headers[$name];
}
/**
* Gets the contents of a header as a string.
*
* @param string $name Name of the header.
* @return string Contents of the header.
* @param string $name Case-insensitive header field name.
* @return string
*/
public function getHeaderLine(string $name): string {
if(!$this->hasHeader($name))
return '';
return (string)$this->getHeader($name);
}
/**
* Gets lines of a header.
*
* @param string $name Name of the header.
* @return string[] Header lines.
*/
public function getHeaderLines(string $name): array {
if(!$this->hasHeader($name))
return [];
return $this->getHeader($name)->lines;
}
/**
* Gets the first line of a header.
*
* @param string $name Name of the header.
* @return string First line of the header.
*/
public function getHeaderFirstLine(string $name): string {
if(!$this->hasHeader($name))
return '';
return $this->getHeader($name)->firstLine;
return implode(', ', $this->getHeader($name));
}
}

View file

@ -1,74 +0,0 @@
<?php
// HttpHeadersBuilder.php
// Created: 2022-02-08
// Updated: 2024-08-03
namespace Index\Http;
use InvalidArgumentException;
/**
* Represents a HTTP message header builder.
*/
class HttpHeadersBuilder {
/** @var array<string, string[]> */
private array $headers = [];
/**
* Adds a header to the HTTP message.
* If a header with the same name is already present, it will be appended with a new line.
*
* @param string $name Name of the header to add.
* @param string $value Value to apply for this header.
*/
public function addHeader(string $name, string $value): void {
$nameLower = strtolower($name);
if(!isset($this->headers[$nameLower]))
$this->headers[$nameLower] = [$name];
$this->headers[$nameLower][] = $value;
}
/**
* Sets a header to the HTTP message.
* If a header with the same name is already present, it will be overwritten.
*
* @param string $name Name of the header to set.
* @param string $value Value to apply for this header.
*/
public function setHeader(string $name, string $value): void {
$this->headers[strtolower($name)] = [$name, $value];
}
/**
* Removes a header from the HTTP message.
*
* @param string $name Name of the header to remove.
*/
public function removeHeader(string $name): void {
unset($this->headers[strtolower($name)]);
}
/**
* Checks if a header is already present.
*
* @param string $name Name of the header.
* @return bool true if it is present.
*/
public function hasHeader(string $name): bool {
return isset($this->headers[strtolower($name)]);
}
/**
* Create HttpHeaders instance from this builder.
*
* @return HttpHeaders Instance containing HTTP headers.
*/
public function toHeaders(): HttpHeaders {
$headers = [];
foreach($this->headers as $index => $lines)
$headers[] = new HttpHeader(array_shift($lines) ?? '', ...$lines);
return new HttpHeaders($headers);
}
}

View file

@ -1,75 +1,141 @@
<?php
// HttpMessage.php
// Created: 2022-02-08
// Updated: 2025-01-18
// Updated: 2025-03-12
namespace Index\Http;
use RuntimeException;
use InvalidArgumentException;
use Stringable;
use Index\XArray;
use Psr\Http\Message\{MessageInterface,StreamInterface};
/**
* Represents a base HTTP message.
*/
abstract class HttpMessage {
abstract class HttpMessage implements MessageInterface {
use HttpHeaders;
/**
* @param string $version HTTP message version.
* @param HttpHeaders $headers HTTP message headers.
* @param ?HttpContent $content Body contents.
* HTTP message headers.
*
* @var array<string, string[]>
*/
public private(set) array $headers;
/**
* @param string $protocolVersion HTTP message version.
* @param array<string, string[]> $headers HTTP message headers.
* @param StreamInterface $body Body contents.
* @throws InvalidArgumentException if $headers contains an unacceptable name or value.
*/
public function __construct(
public private(set) string $version,
public private(set) HttpHeaders $headers,
public private(set) ?HttpContent $content
) {}
public private(set) string $protocolVersion,
array $headers,
public private(set) StreamInterface $body,
) {
foreach($headers as $headerName => $headerValues) {
if(preg_match('/^[A-Za-z0-9!\#$%&\'*+\-.^_`|~]+$/', $headerName) !== 1)
throw new InvalidArgumentException('$headers contains a header name that is not acceptable');
/**
* Checks if a header is present.
*
* @param string $name Name of the header.
* @return bool true if the header is present.
*/
public function hasHeader(string $name): bool {
return $this->headers->hasHeader($name);
if(empty($headerValues)) {
unset($headers[$headerName]);
continue;
}
if(!array_is_list($headerValues))
$headers[$headerName] = $headerValues = array_values($headerValues);
foreach($headerValues as $key => $value) {
if(is_string($value))
continue;
if(is_scalar($value) || $value instanceof Stringable) {
$headers[$headerName][$key] = (string)$value;
continue;
}
throw new InvalidArgumentException('$headers contains a header value that is not acceptable');
}
}
$this->headers = $headers;
}
/**
* Retrieves a header by name.
* Provides a shorthand alias for all with* methods and can also combine them.
*
* @param string $name Name of the header.
* @throws RuntimeException If no header with $name exists.
* @return HttpHeader Instance of the requested header.
* @param ?string $protocolVersion Value you'd otherwise pass to withProtocolVersion, null to leave unmodified.
* @param ?array<string, string[]> $headers Value you'd otherwise pass to withHeaders (if that existed), null to leave unmodified.
* @param ?StreamInterface $body Value you'd otherwise pass to withBody, null to leave unmodified.
* @throws InvalidArgumentException If any of the arguments are not acceptable.
* @return static
*/
public function getHeader(string $name): HttpHeader {
return $this->headers->getHeader($name);
abstract public function with(
?string $protocolVersion = null,
?array $headers = null,
?StreamInterface $body = null,
): HttpMessage;
public function getProtocolVersion(): string {
return $this->protocolVersion;
}
/**
* Gets the contents of a header as a string.
*
* @param string $name Name of the header.
* @return string Contents of the header.
*/
public function getHeaderLine(string $name): string {
return $this->headers->getHeaderLine($name);
public function withProtocolVersion(string $version): HttpMessage {
return $this->with(protocolVersion: $version);
}
/**
* Gets lines of a header.
*
* @param string $name Name of the header.
* @return string[] Header lines.
*/
public function getHeaderLines(string $name): array {
return $this->headers->getHeaderLines($name);
public function withHeader(string $name, $value): HttpMessage {
if(!is_array($value))
$value = [$value];
$headers = $this->headers;
foreach($headers as $hName => $_)
if(strcasecmp($hName, $name) === 0) {
unset($headers[$hName]);
break;
}
$headers[$name] = $value;
return $this->with(headers: $headers);
}
/**
* Gets the first line of a header.
*
* @param string $name Name of the header.
* @return string First line of the header.
*/
public function getHeaderFirstLine(string $name): string {
return $this->headers->getHeaderFirstLine($name);
public function withAddedHeader(string $name, $value): HttpMessage {
if(!is_array($value))
$value = [$value];
$added = false;
$headers = $this->headers;
foreach($headers as $hName => $hValue)
if(strcasecmp($hName, $name) === 0) {
$added = true;
$headers[$hName] = array_merge($hValue, $value);
break;
}
if(!$added)
$headers[$name] = $value;
return $this->with(headers: $headers);
}
public function withoutHeader(string $name): HttpMessage {
$headers = $this->headers;
foreach($headers as $hName => $_)
if(strcasecmp($hName, $name) === 0) {
unset($headers[$hName]);
break;
}
return $this->with(headers: $headers);
}
public function getBody(): StreamInterface {
return $this->body;
}
public function withBody(StreamInterface $body): HttpMessage {
return $this->with(body: $body);
}
}

View file

@ -1,35 +1,31 @@
<?php
// HttpMessageBuilder.php
// Created: 2022-02-08
// Updated: 2025-01-18
// Updated: 2025-02-28
namespace Index\Http;
use InvalidArgumentException;
use RuntimeException;
use Stringable;
use Index\XArray;
use Psr\Http\Message\StreamInterface;
/**
* Represents a base HTTP message builder.
*/
class HttpMessageBuilder {
abstract class HttpMessageBuilder {
/**
* HTTP version of this message.
*
* @var string
*/
public string $version = '1.1';
public string $protocolVersion = '1.1';
/**
* HTTP header builder instance.
*
* @var HttpHeadersBuilder
* @var array<string, string[]>
*/
public private(set) HttpHeadersBuilder $headers;
public function __construct() {
$this->headers = new HttpHeadersBuilder;
}
public private(set) array $headers = [];
/**
* Adds a header to the HTTP message.
@ -39,7 +35,13 @@ class HttpMessageBuilder {
* @param string $value Value to apply for this header.
*/
public function addHeader(string $name, string $value): void {
$this->headers->addHeader($name, $value);
foreach($this->headers as $hName => $_)
if(strcasecmp($hName, $name) === 0) {
$this->headers[$hName][] = $value;
return;
}
$this->headers[$name] = [$value];
}
/**
@ -50,7 +52,8 @@ class HttpMessageBuilder {
* @param string $value Value to apply for this header.
*/
public function setHeader(string $name, string $value): void {
$this->headers->setHeader($name, $value);
$this->removeHeader($name);
$this->headers[$name] = [$value];
}
/**
@ -59,7 +62,11 @@ class HttpMessageBuilder {
* @param string $name Name of the header to remove.
*/
public function removeHeader(string $name): void {
$this->headers->removeHeader($name);
foreach($this->headers as $hName => $_)
if(strcasecmp($hName, $name) === 0) {
unset($this->headers[$hName]);
break;
}
}
/**
@ -69,37 +76,11 @@ class HttpMessageBuilder {
* @return bool true if it is present.
*/
public function hasHeader(string $name): bool {
return $this->headers->hasHeader($name);
return XArray::any($this->headers, fn($hValue, $hName) => is_string($hName) && strcasecmp($hName, $name) === 0 && !empty($hValue));
}
/**
* HTTP message body.
*
* @var HttpContent|Stringable|string|int|float|resource|null
*/
public mixed $content = null {
get => $this->content;
set(mixed $content) {
if($content instanceof HttpContent || $content === null) {
$this->content = $content;
return;
}
if(is_scalar($content) || $content instanceof Stringable) {
$this->content = new StringHttpContent((string)$content);
return;
}
if(is_resource($content)) {
$content = stream_get_contents($content);
if($content === false)
throw new RuntimeException('was unable to read the stream resource in $content');
$this->content = new StringHttpContent($content);
return;
}
throw new InvalidArgumentException('$content not a supported type');
}
}
public ?StreamInterface $body = null;
}

View file

@ -0,0 +1,87 @@
<?php
// HttpParameters.php
// Created: 2025-03-15
// Updated: 2025-03-15
namespace Index\Http;
/**
* Common definition for HTTP parameters.
*/
interface HttpParameters {
/**
* Checks if a parameter is present.
*
* @param string $name Name of the parameter.
* @return bool
*/
public function hasParam(string $name): bool;
/**
* 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;
/**
* Retrieves a parameter, or a default value if it is not present.
*
* @param string $name Name of the parameter.
* @param ?string $default Default value to fall back on.
* @return ?string Value of the parameter, $default if not present.
*/
public function getParam(
string $name,
?string $default = null,
): ?string;
/**
* Retrieves a parameter, or a default value if it is not present.
*
* @param string $name Name of the parameter.
* @param int $index Index of the parameter value.
* @param ?string $default Default value to fall back on.
* @return ?string Value of the parameter, null if not present.
*/
public function getParamAt(
string $name,
int $index,
?string $default = null,
): ?string;
/**
* Retrieves a filtered parameter, or a default value if it is not present.
*
* @param string $name Name of the parameter.
* @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 parameter, default value if not present.
*/
public function getFilteredParam(
string $name,
int $filter = FILTER_DEFAULT,
array|int $options = 0,
mixed $default = null,
): mixed;
/**
* Retrieves a filtered parameter, or a default value if it is not present.
*
* @param string $name Name of the parameter.
* @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 parameter, default value if not present.
*/
public function getFilteredParamAt(
string $name,
int $index,
int $filter = FILTER_DEFAULT,
array|int $options = 0,
mixed $default = null,
): mixed;
}

View file

@ -0,0 +1,102 @@
<?php
// HttpParametersCommon.php
// Created: 2025-03-15
// Updated: 2025-03-15
namespace Index\Http;
/**
* Common implementation for HTTP parameters.
*/
trait HttpParametersCommon {
/**
* Checks if a parameter is present.
*
* @param string $name Name of the parameter.
* @return bool
*/
public function hasParam(string $name): bool {
return isset($this->params[$name]);
}
/**
* 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 {
return isset($this->params[$name]) ? count($this->params[$name]) : 0;
}
/**
* Retrieves a parameter, or a default value if it is not present.
*
* @param string $name Name of the parameter.
* @param ?string $default Default value to fall back on.
* @return ?string Value of the parameter, $default if not present.
*/
public function getParam(
string $name,
?string $default = null,
): ?string {
return $this->getParamAt($name, 0, $default);
}
/**
* Retrieves a parameter, or a default value if it is not present.
*
* @param string $name Name of the parameter.
* @param int $index Index of the parameter value.
* @param ?string $default Default value to fall back on.
* @return ?string Value of the parameter, null if not present.
*/
public function getParamAt(
string $name,
int $index,
?string $default = null,
): ?string {
return isset($this->params[$name]) && isset($this->params[$name][$index])
? $this->params[$name][$index] : $default;
}
/**
* Retrieves a filtered parameter, or a default value if it is not present.
*
* @param string $name Name of the parameter.
* @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 parameter, default value if not present.
*/
public function getFilteredParam(
string $name,
int $filter = FILTER_DEFAULT,
array|int $options = 0,
mixed $default = null,
): mixed {
return $this->getFilteredParamAt($name, 0, $filter, $options, $default);
}
/**
* Retrieves a filtered parameter, or a default value if it is not present.
*
* @param string $name Name of the parameter.
* @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 parameter, default value if not present.
*/
public function getFilteredParamAt(
string $name,
int $index,
int $filter = FILTER_DEFAULT,
array|int $options = 0,
mixed $default = null,
): mixed {
return isset($this->params[$name]) && isset($this->params[$name][$index])
? filter_var($this->params[$name][$index], $filter, $options)
: $default;
}
}

View file

@ -1,105 +1,231 @@
<?php
// HttpRequest.php
// Created: 2022-02-08
// Updated: 2025-01-18
// Updated: 2025-03-15
namespace Index\Http;
use InvalidArgumentException;
use RuntimeException;
use InvalidArgumentException;
use Index\MediaType;
use Index\Http\Content\{MultipartFormContent,UrlEncodedFormContent};
use Index\Http\Streams\{NullStream,Stream};
use Index\Json\JsonHttpContent;
use Psr\Http\Message\{ServerRequestInterface,StreamInterface,UploadedFileInterface,UriInterface};
/**
* Represents a HTTP request message.
*/
class HttpRequest extends HttpMessage {
class HttpRequest extends HttpMessage implements ServerRequestInterface, HttpParameters {
use HttpParametersCommon;
/** Name of attribute containing the country code value. */
public const string ATTR_COUNTRY_CODE = 'countryCode';
/** Name of attribute containing the remote address value. */
public const string ATTR_REMOTE_ADDRESS = 'remoteAddress';
/** HTTP request method. */
public private(set) string $method;
/**
* @param string $remoteAddress Origin remote address.
* @param bool $secure true if HTTPS.
* @param string $countryCode Origin ISO 3166 country code.
* @param string $version HTTP message version.
* @param string $protocolVersion HTTP message version.
* @param array<string, string[]> $headers HTTP message headers.
* @param StreamInterface $body Body contents.
* @param array<string, mixed> $attributes Attributes derived from the request.
* @param string $method HTTP request method.
* @param string $path HTTP request path.
* @param array<string, mixed> $params HTTP request query parameters.
* @param HttpUri $uri HTTP request URI.
* @param array<string, list<?string>> $params HTTP request query parameters.
* @param array<string, string> $cookies HTTP request cookies.
* @param HttpHeaders $headers HTTP message headers.
* @param ?HttpContent $content Body contents.
* @param array<string, array<object|string>>|object|null $parsedBody Parsed body contents.
* @param ?array<string, UploadedFileInterface[]> $uploadedFiles Parsed files.
*/
public function __construct(
public private(set) string $remoteAddress,
public private(set) bool $secure,
public private(set) string $countryCode,
string $version,
public private(set) string $method,
public private(set) string $path,
final public function __construct(
string $protocolVersion,
array $headers,
StreamInterface $body,
public private(set) array $attributes,
string $method,
public private(set) HttpUri $uri,
public private(set) array $params,
public private(set) array $cookies,
HttpHeaders $headers,
?HttpContent $content
private array|object|null $parsedBody = null,
private ?array $uploadedFiles = null,
) {
parent::__construct($version, $headers, $content);
parent::__construct($protocolVersion, $headers, $body);
if(array_key_exists(self::ATTR_COUNTRY_CODE, $attributes)) {
if(!is_string($attributes[self::ATTR_COUNTRY_CODE]))
throw new InvalidArgumentException('countryCode attribute must be a string');
if(strlen($attributes[self::ATTR_COUNTRY_CODE]) !== 2)
throw new InvalidArgumentException('countryCode attribute must be two characters');
if(!ctype_alnum($attributes[self::ATTR_COUNTRY_CODE]))
throw new InvalidArgumentException('countryCode attribute must be alphanumeric characters');
}
if(array_key_exists(self::ATTR_REMOTE_ADDRESS, $attributes)) {
if(!is_string($attributes[self::ATTR_REMOTE_ADDRESS]))
throw new InvalidArgumentException('remoteAddress attribute must be a string');
if(filter_var($attributes[self::ATTR_REMOTE_ADDRESS], FILTER_VALIDATE_IP) === false)
throw new InvalidArgumentException('remoteAddress attribute must be a valid remote address');
}
$this->method = strtoupper($method);
}
/**
* Retrieves all HTTP request query fields as a query string.
*
* @param bool $spacesAsPlus true if spaces should be represented with a +, false if %20.
* @return string Query string representation of query fields.
* Request country code.
*/
public function getParamString(bool $spacesAsPlus = false): string {
return http_build_query($this->params, '', '&', $spacesAsPlus ? PHP_QUERY_RFC1738 : PHP_QUERY_RFC3986);
public string $countryCode {
get {
$countryCode = $this->getAttribute(self::ATTR_COUNTRY_CODE);
return is_string($countryCode) ? $countryCode : 'XX';
}
}
/**
* Retrieves an HTTP request query field, or null if it is not present.
*
* @param string $name Name of the request query field.
* @param int $filter A PHP filter extension filter constant.
* @param mixed[]|int $options Options for the PHP filter.
* @return ?mixed Value of the query field, null if not present.
* Request remote address.
*/
public function getParam(string $name, int $filter = FILTER_DEFAULT, array|int $options = 0): mixed {
if(!isset($this->params[$name]))
return null;
return filter_var($this->params[$name], $filter, $options);
public string $remoteAddress {
get {
$remoteAddress = $this->getAttribute(self::ATTR_REMOTE_ADDRESS);
return is_string($remoteAddress) ? $remoteAddress : '::';
}
}
/**
* Retrieves a form field with filtering and enforcing a scalar value.
*
* @param string $name Name of the form field.
* @param int $filter A PHP filter extension filter constant.
* @param array<string, mixed>|int $options Options for the PHP filter.
* @return ?scalar Value of the form field, null if not present.
* Whether the request was made over HTTPS or not.
*/
public function getParamScalar(string $name, int $filter = FILTER_DEFAULT, array|int $options = 0): bool|float|int|string|null {
$value = $this->getParam($name, $filter, $options);
return is_scalar($value) ? $value : null;
public bool $secure {
get => $this->uri->scheme === 'https';
}
/**
* Checks if a query field is present.
*
* @param string $name Name of the query field.
* @return bool true if the field is present, false if not.
* Request target, path component of the URI.
*/
public function hasParam(string $name): bool {
return isset($this->params[$name]);
public string $requestTarget {
get => $this->uri->path;
}
/**
* Retrieves an HTTP request cookie, or null if it is not present.
*
* @param string $name Name of the request cookie.
* @param int $filter A PHP filter extension filter constant.
* @param array<string, mixed>|int $options Options for the PHP filter.
* @return mixed Value of the cookie, null if not present.
* @param ?string $protocolVersion Value you'd otherwise pass to withProtocolVersion, null to leave unmodified.
* @param ?array<string, string[]> $headers Value you'd otherwise pass to withHeaders (if that existed), null to leave unmodified.
* @param ?StreamInterface $body Value you'd otherwise pass to withBody, null to leave unmodified.
* @param ?array<string, mixed> $attributes Value you'd otherwise pass to withAttributes (if that existed), null to leave unmodified.
* @param ?string $method Value you'd otherwise pass to withMethod, null to leave unmodified.
* @param ?UriInterface $uri Value you'd otherwise pass to withUri, null to leave unmodified.
* @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, 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
*/
public function getCookie(string $name, int $filter = FILTER_DEFAULT, array|int $options = 0): mixed {
if(!isset($this->cookies[$name]))
return null;
return filter_var($this->cookies[$name], $filter, $options);
public function with(
?string $protocolVersion = null,
?array $headers = null,
?StreamInterface $body = null,
?array $attributes = null,
?string $method = null,
?UriInterface $uri = null,
?array $params = null,
?array $cookies = null,
array|object|null|false $parsedBody = false,
array|null|false $uploadedFiles = false,
): HttpRequest {
return new static(
protocolVersion: $protocolVersion ?? $this->protocolVersion,
headers: $headers ?? $this->headers,
body: $body ?? $this->body,
attributes: $attributes ?? $this->attributes,
method: $method ?? $this->method,
uri: $uri === null ? $this->uri : HttpUri::castUri($uri),
params: $params ?? $this->params,
cookies: $cookies ?? $this->cookies,
parsedBody: $parsedBody === false ? $this->parsedBody : $parsedBody,
uploadedFiles: $uploadedFiles === false ? $this->uploadedFiles : $uploadedFiles,
);
}
/**
* @return array<string, string>
*/
public function getServerParams(): array {
return [
'COUNTRY_CODE' => $this->countryCode,
'HTTP_HOST' => $this->getHeaderLine('Host'),
'HTTPS' => $this->secure ? 'on' : '',
'REMOTE_ADDR' => $this->remoteAddress,
'REQUEST_METHOD' => $this->method,
'REQUEST_URI' => $this->uri->path,
'QUERY_STRING' => $this->uri->query,
'SERVER_SOFTWARE' => 'Index',
'SERVER_PROTOCOL' => sprintf('HTTP/%s', $this->protocolVersion),
];
}
/**
* @return array<string, mixed> Attributes derived from the request.
*/
public function getAttributes(): array {
return $this->attributes;
}
public function getAttribute(string $name, $default = null) {
return $this->attributes[$name] ?? $default;
}
public function withAttribute(string $name, $value): HttpRequest {
$attributes = $this->attributes;
$attributes[$name] = $value;
return $this->with(attributes: $attributes);
}
public function withoutAttribute(string $name): HttpRequest {
$attributes = $this->attributes;
unset($attributes[$name]);
return $this->with(attributes: $attributes);
}
public function getMethod(): string {
return $this->method;
}
public function withMethod(string $method): HttpRequest {
return $this->with(method: $method);
}
public function getRequestTarget(): string {
return $this->uri->path;
}
public function withRequestTarget(string $requestTarget): HttpRequest {
return $this->with(uri: $this->uri->withPath($requestTarget));
}
public function getUri(): HttpUri {
return $this->uri;
}
public function withUri(UriInterface $uri, bool $preserveHost = false): HttpRequest {
if($preserveHost)
$uri = $uri->withHost($this->uri->host);
return $this->with(uri: $uri);
}
/**
* @return array<string, string>
*/
public function getCookieParams(): array {
return $this->cookies;
}
/**
* @param array<string, string> $cookies Array of key/value pairs representing cookies.
*/
public function withCookieParams(array $cookies): HttpRequest {
return $this->with(cookies: $cookies);
}
/**
@ -112,23 +238,249 @@ class HttpRequest extends HttpMessage {
return isset($this->cookies[$name]);
}
/**
* Retrieves an HTTP request cookie, or a default value if it is not present.
*
* @param string $name Name of the request cookie.
* @param int $filter A PHP filter extension filter constant.
* @param array<string, mixed>|int $options Options for the PHP filter.
* @param mixed $default Default value.
* @return mixed
*/
public function getCookie(string $name, int $filter = FILTER_DEFAULT, array|int $options = 0, mixed $default = null): mixed {
return isset($this->cookies[$name])
? (filter_var($this->cookies[$name], $filter, $options) ?? $default)
: $default;
}
/**
* @return array<string, list<?string>>
*/
public function getQueryParams(): array {
return $this->params;
}
/**
* @param array<string, list<?string>> $query Array of query string arguments, typically from $_GET.
*/
public function withQueryParams(array $query): HttpRequest {
return $this->with(params: $query);
}
/**
* Retrieves all HTTP request query fields as a query string.
*
* @param ?bool $spacesAsPlus null to use the Index url encoder, other wise whether to represent spaces as a + instead of %20 with the legacy encoder.
* @return string Query string representation of query fields.
*/
public function getParamString(?bool $spacesAsPlus = null): string {
return $spacesAsPlus === null
? HttpUri::buildQueryString($this->params)
: http_build_query($this->params, '', '&', $spacesAsPlus ? PHP_QUERY_RFC1738 : PHP_QUERY_RFC3986);
}
/**
* @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 {
if($this->uploadedFiles === null) {
$parsedBody = $this->getParsedBody();
$this->uploadedFiles = $parsedBody instanceof MultipartFormContent ? $parsedBody->files : [];
}
return $this->uploadedFiles;
}
/**
* @param array<string, UploadedFileInterface[]> $uploadedFiles An array tree of UploadedFileInterface instances.
*/
public function withUploadedFiles(array $uploadedFiles): HttpRequest {
return $this->with(uploadedFiles: $uploadedFiles);
}
/**
* @return null|array<string, string[]|object[]>|object The deserialized body parameters, if any. These will typically be an array or object.
*/
public function getParsedBody() {
// this contains a couple hard coded body parsers but you should really be using preprocessors
if($this->parsedBody === null) {
$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;
}
/**
* @param null|array<string, string[]|object[]>|object $data The deserialized body data. This will tpyically be in an array or object.
*/
public function withParsedBody($data): HttpRequest {
return $this->with(parsedBody: $data);
}
/**
* Casts a PSR-7 ServerRequestInterface to an Index HttpRequest.
* If $request was already HttpRequest, it will be returned verbatim.
*
* @param ServerRequestInterface $request
* @return HttpRequest
*/
public static function castRequest(ServerRequestInterface $request): HttpRequest {
if($request instanceof HttpRequest)
return $request;
/** @var array<string, list<string|null>> */
$queryParams = (function(array $raw) {
$params = [];
foreach($raw as $name => $value) {
if(is_scalar($value))
$value = [$value];
if(!is_array($value))
$value = [null];
$params[(string)$name] = $value;
}
return $params;
})($request->getQueryParams());
$cookieParams = (function(array $raw) {
$params = [];
foreach($raw as $name => $value) {
if(!is_scalar($value))
continue;
$params[(string)$name] = (string)$value;
}
return $params;
})($request->getCookieParams());
return new HttpRequest(
$request->getProtocolVersion(),
// @phpstan-ignore-next-line: interface erroneously defines as string[][] instead of array<string, string[]>
$request->getHeaders(),
$request->getBody(),
[],
$request->getMethod(),
HttpUri::castUri($request->getUri()),
$queryParams,
$cookieParams,
$request->getParsedBody(), // @phpstan-ignore-line: dont care
$request->getUploadedFiles(), // @phpstan-ignore-line: dont care
);
}
/**
* Creates a HttpRequest with some given parameters, exists for testing.
*
* @param string $method Request method.
* @param string $path Request target.
* @param array<string, list<string>> $headers Request headers.
* @return HttpRequest
*/
public static function createRequestWithoutBody(
string $method,
string $path,
array $headers = [],
): HttpRequest {
return new HttpRequest(
'1.1',
$headers,
NullStream::instance(),
[],
$method,
HttpUri::createUri($path),
[],
[]
);
}
/**
* Creates a HttpRequest with some given parameters, exists for testing.
*
* @param string $method Request method.
* @param string $path Request target.
* @param array<string, list<string>> $headers Request headers.
* @param StreamInterface $body Request body.
* @return HttpRequest
*/
public static function createRequestWithBody(
string $method,
string $path,
array $headers,
StreamInterface $body,
): HttpRequest {
return new HttpRequest(
'1.1',
$headers,
$body,
[],
$method,
HttpUri::createUri($path),
[],
[]
);
}
/**
* Parses a Cookie header string.
*
* @param string $cookies Cookie header string.
* @return array<string, string>
*/
public static function parseCookieString(string $cookies): array {
$params = [];
if($cookies !== '') {
$paramParts = explode(';', $cookies);
foreach($paramParts as $paramPart) {
$parts = explode('=', ltrim($paramPart, ' '), 2);
if(count($parts) > 1) {
$name = urldecode($parts[0]);
if(!array_key_exists($name, $params))
$params[$name] = urldecode($parts[1]);
}
}
}
return $params;
}
/**
* Creates an HttpRequest instance from the current request.
*
* @param ?array<string, mixed> $server Value of the $_SERVER variable, if null the current super-global $_SERVER is used.
* @return HttpRequest An instance representing the current request.
*/
public static function fromRequest(): HttpRequest {
public static function fromRequest(?array $server = null): HttpRequest {
/** @var array<string, mixed> $server */
$server ??= $_SERVER;
$build = new HttpRequestBuilder;
$build->remoteAddress = (string)filter_input(INPUT_SERVER, 'REMOTE_ADDR');
$build->secure = filter_has_var(INPUT_SERVER, 'HTTPS');
$build->version = (string)filter_input(INPUT_SERVER, 'SERVER_PROTOCOL');
$build->method = (string)filter_input(INPUT_SERVER, 'REQUEST_METHOD');
$build->remoteAddress = (string)filter_var($server['REMOTE_ADDR'] ?? '::');
$build->secure = !empty($server['HTTPS']);
$build->protocolVersion = (string)filter_var($server['SERVER_PROTOCOL'] ?? '1.1');
$build->method = (string)filter_var($server['REQUEST_METHOD'] ?? 'GET');
if(filter_has_var(INPUT_SERVER, 'COUNTRY_CODE'))
$build->countryCode = (string)filter_input(INPUT_SERVER, 'COUNTRY_CODE');
if(!empty($server['COUNTRY_CODE']))
$build->countryCode = (string)filter_var($server['COUNTRY_CODE']);
// this currently doesn't "properly" support the scenario where a full url is specified in the http request
$path = (string)filter_input(INPUT_SERVER, 'REQUEST_URI');
$path = (string)filter_var($server['REQUEST_URI'] ?? '/');
$pathQueryOffset = strpos($path, '?');
if($pathQueryOffset !== false)
$path = substr($path, 0, $pathQueryOffset);
@ -142,19 +494,12 @@ class HttpRequest extends HttpMessage {
$path = '/' . $path;
$build->path = $path;
/** @var array<string, mixed> $getVars */
$getVars = $_GET;
$build->params = $getVars;
/** @var array<string, string> $cookieVars */
$cookieVars = $_COOKIE;
$build->cookies = $cookieVars;
$build->params = HttpUri::parseQueryString((string)filter_var($server['QUERY_STRING'] ?? ''));
$contentType = null;
$contentLength = 0;
$headers = self::getRawRequestHeaders();
$headers = self::getRawRequestHeaders($server);
foreach($headers as $name => $value) {
if($name === 'content-type')
try {
@ -164,22 +509,22 @@ class HttpRequest extends HttpMessage {
}
elseif($name === 'content-length')
$contentLength = (int)$value;
elseif($name === 'cookie')
$build->cookies = self::parseCookieString($value);
$build->setHeader($name, $value);
}
if($contentType !== null && ($contentType->equals('application/json') || $contentType->equals('application/x-json')))
$build->content = JsonHttpContent::fromRequest();
elseif($contentType !== null && ($contentType->equals('application/x-www-form-urlencoded') || $contentType->equals('multipart/form-data')))
$build->content = FormHttpContent::fromRequest();
elseif($contentLength > 0)
$build->content = StringHttpContent::fromRequest();
$build->body = Stream::createStreamFromFile('php://input', 'rb');
return $build->toRequest();
}
/** @return array<string, string> */
private static function getRawRequestHeaders(): array {
/**
* @param ?array<string, mixed> $server
* @return array<string, string>
*/
private static function getRawRequestHeaders(?array $server = null): array {
if(function_exists('getallheaders')) {
$raw = getallheaders();
$headers = [];
@ -190,9 +535,10 @@ class HttpRequest extends HttpMessage {
return $headers;
}
$server ??= $_SERVER;
$headers = [];
foreach($_SERVER as $key => $value) {
foreach($server as $key => $value) {
if(!is_string($key) || !is_scalar($value))
continue;
@ -207,16 +553,16 @@ class HttpRequest extends HttpMessage {
}
if(!isset($headers['authorization'])) {
if(filter_has_var(INPUT_SERVER, 'REDIRECT_HTTP_AUTHORIZATION')) {
$headers['authorization'] = (string)filter_input(INPUT_SERVER, 'REDIRECT_HTTP_AUTHORIZATION');
} elseif(filter_has_var(INPUT_SERVER, 'PHP_AUTH_USER')) {
if(!empty($server['REDIRECT_HTTP_AUTHORIZATION'])) {
$headers['authorization'] = (string)filter_var($server['REDIRECT_HTTP_AUTHORIZATION']);
} elseif(!empty($server['PHP_AUTH_USER'])) {
$headers['authorization'] = sprintf('Basic %s', base64_encode(sprintf(
'%s:%s',
(string)filter_input(INPUT_SERVER, 'PHP_AUTH_USER'),
(string)filter_input(INPUT_SERVER, 'PHP_AUTH_PW')
(string)filter_var($server['PHP_AUTH_USER']),
(string)filter_var($server['PHP_AUTH_PW'])
)));
} elseif(filter_has_var(INPUT_SERVER, 'PHP_AUTH_DIGEST')) {
$headers['authorization'] = (string)filter_input(INPUT_SERVER, 'PHP_AUTH_DIGEST');
} elseif(!empty($server['PHP_AUTH_DIGEST'])) {
$headers['authorization'] = (string)filter_var($server['PHP_AUTH_DIGEST']);
}
}

View file

@ -1,84 +1,92 @@
<?php
// HttpRequestBuilder.php
// Created: 2022-02-08
// Updated: 2025-01-18
// Updated: 2025-03-12
namespace Index\Http;
use InvalidArgumentException;
use Index\Http\Streams\NullStream;
/**
* Represents a HTTP request message builder.
*/
class HttpRequestBuilder extends HttpMessageBuilder {
final class HttpRequestBuilder extends HttpMessageBuilder {
/**
* Origin remote address.
*
* @var string
*/
public string $remoteAddress = '::' {
get => $this->remoteAddress;
set(string $value) {
if(filter_var($value, FILTER_VALIDATE_IP) === false)
throw new InvalidArgumentException('$remoteAddress must be a valid remote address');
$this->remoteAddress = $value;
}
}
/**
* Whether the request was made over HTTPS or not.
*
* @var bool
*/
public bool $secure = false;
public string $remoteAddress = '::';
/**
* Origin ISO 3166 country code.
*
* @var string
*/
public string $countryCode = 'XX' {
get => $this->countryCode;
set(string $value) {
if(strlen($value) !== 2)
throw new InvalidArgumentException('$countryCode must be two characters');
if(!ctype_alnum($value))
throw new InvalidArgumentException('$countryCode must be alphanumeric characters');
$this->countryCode = strtoupper($value);
}
}
public string $countryCode = 'XX';
/**
* HTTP request method.
*
* @var string
*/
public string $method = 'GET';
/**
* Whether the request was made over HTTPS or not.
*/
public bool $secure = false;
/**
* HTTP request host.
*/
public string $host = '';
/**
* HTTP request path.
*
* @var string
*/
public string $path = '/';
/**
* HTTP request query params.
*
* @var array<string, mixed>
* @var array<string, list<?string>>
*/
public array $params = [];
/**
* HTTP request cookies.
*
* @var array<string, string>
*/
public array $cookies = [];
/**
* Sets a HTTP request query param value.
*
* @param string $name Name of the query field.
* @param ?string $value Value of the query field.
*/
public function setParam(string $name, ?string $value): void {
$this->params[$name] = [$value];
}
/**
* Adds a HTTP request query param value.
*
* @param string $name Name of the query field.
* @param ?string $value Value of the query field.
*/
public function addParam(string $name, ?string $value): void {
if(array_key_exists($name, $this->params))
$this->params[$name][] = $value;
else
$this->params[$name] = [$value];
}
/**
* Sets a HTTP request query param.
*
* @param string $name Name of the query field.
* @param mixed $value Value of the query field.
* @param list<?string> $values Value of the query field.
*/
public function setParam(string $name, mixed $value): void {
$this->params[$name] = $value;
public function setParamValues(string $name, array $values): void {
$this->params[$name] = $values;
}
/**
@ -91,14 +99,7 @@ class HttpRequestBuilder extends HttpMessageBuilder {
}
/**
* HTTP request cookies.
*
* @var array<string, string>
*/
public array $cookies = [];
/**
* Sets a HTTP request cookie.
* Sets a value for a HTTP request cookie.
*
* @param string $name Name of the cookie.
* @param string $value Value of the cookie.
@ -123,12 +124,18 @@ class HttpRequestBuilder extends HttpMessageBuilder {
*/
public function toRequest(): HttpRequest {
return new HttpRequest(
$this->remoteAddress, $this->secure, $this->countryCode,
$this->version, $this->method, $this->path,
$this->protocolVersion, $this->headers, $this->body ?? NullStream::instance(),
[
HttpRequest::ATTR_COUNTRY_CODE => $this->countryCode,
HttpRequest::ATTR_REMOTE_ADDRESS => $this->remoteAddress,
],
$this->method,
new HttpUri(
scheme: $this->secure ? 'https' : 'http',
host: $this->host,
path: $this->path,
),
$this->params, $this->cookies,
$this->headers->toHeaders(),
// this is crunchy, this entire pipeline needs to be redone anyway
$this->content instanceof HttpContent ? $this->content : null
);
}
}

View file

@ -1,28 +1,206 @@
<?php
// HttpResponse.php
// Created: 2022-02-08
// Updated: 2025-01-18
// Updated: 2025-03-07
namespace Index\Http;
use InvalidArgumentException;
use Psr\Http\Message\{ResponseInterface,StreamInterface};
/**
* Represents a HTTP response message.
*/
class HttpResponse extends HttpMessage {
class HttpResponse extends HttpMessage implements ResponseInterface {
/**
* @param string $version HTTP message version.
* @param int $statusCode HTTP response status code.
* @param string $statusText HTTP response status text.
* @param HttpHeaders $headers HTTP message headers.
* @param ?HttpContent $content Body contents.
* HTTP response reason phrase.
*
* @var string
*/
public function __construct(
string $version,
public private(set) string $reasonPhrase;
/**
* @param string $protocolVersion HTTP message version.
* @param array<string, string[]> $headers HTTP message headers.
* @param StreamInterface $body Body contents.
* @param int $statusCode HTTP response status code.
* @param string $reasonPhrase HTTP response reason phrase.
* @throws InvalidArgumentException if $statusCode is out of range.
*/
final public function __construct(
string $protocolVersion,
array $headers,
StreamInterface $body,
public private(set) int $statusCode,
public private(set) string $statusText,
HttpHeaders $headers,
?HttpContent $content
string $reasonPhrase,
) {
parent::__construct($version, $headers, $content);
parent::__construct($protocolVersion, $headers, $body);
if($statusCode < 100 || $statusCode > 599)
throw new InvalidArgumentException('$statusCode is not an acceptable value, it must be equal to or greater than 100 and less than 600');
$this->reasonPhrase = $reasonPhrase === '' ? self::defaultReasonPhase($statusCode) : $reasonPhrase;
}
/**
* Whether the status code is within the informational range (1xx).
*
* @return bool
*/
public bool $informational { get => $this->statusCode >= 100 && $this->statusCode <= 199; }
/**
* Whether the status code is within the success range (2xx).
*
* @return bool
*/
public bool $success { get => $this->statusCode >= 200 && $this->statusCode <= 299; }
/**
* Whether the status code is within the redirection range (3xx).
*
* @return bool
*/
public bool $redirection { get => $this->statusCode >= 300 && $this->statusCode <= 399; }
/**
* Whether the status code is within the client error range (4xx).
*
* @return bool
*/
public bool $clientError { get => $this->statusCode >= 400 && $this->statusCode <= 499; }
/**
* Whether the status code is within the server error range (5xx).
*
* @return bool
*/
public bool $serverError { get => $this->statusCode >= 500 && $this->statusCode <= 599; }
/**
* Provides a shorthand alias for all with* methods and can also combine them.
*
* @param ?string $protocolVersion Value you'd otherwise pass to withProtocolVersion, null to leave unmodified.
* @param ?array<string, string[]> $headers Value you'd otherwise pass to withHeaders (if that existed), null to leave unmodified.
* @param ?StreamInterface $body Value you'd otherwise pass to withBody, null to leave unmodified.
* @param ?int $statusCode Value you'd otherwise pass to the first argument of withStatus, null to leave unmodified.
* @param ?string $reasonPhrase Value you'd otherwise pass to the second argument of withStatus, null to leave unmodified.
* @throws InvalidArgumentException If any of the arguments are not acceptable.
* @return static
*/
public function with(
?string $protocolVersion = null,
?array $headers = null,
?StreamInterface $body = null,
?int $statusCode = null,
?string $reasonPhrase = null,
): HttpResponse {
return new static(
protocolVersion: $protocolVersion ?? $this->protocolVersion,
headers: $headers ?? $this->headers,
body: $body ?? $this->body,
statusCode: $statusCode ?? $this->statusCode,
reasonPhrase: $reasonPhrase ?? $this->reasonPhrase,
);
}
public function getStatusCode(): int {
return $this->statusCode;
}
public function getReasonPhrase(): string {
return $this->reasonPhrase;
}
public function withStatus(int $code, string $reasonPhrase = ''): HttpResponse {
return $this->with(statusCode: $code, reasonPhrase: $reasonPhrase);
}
/**
* Gets the default reason phrase for a given status code.
*
* @param int $statusCode HTTP status code.
* @param string $fallback Fallback message, if not match was found.
* @return string
*/
public static function defaultReasonPhase(int $statusCode, string $fallback = 'Unknown Status'): string {
return array_key_exists($statusCode, self::REASON_PHRASES) ? self::REASON_PHRASES[$statusCode] : $fallback;
}
/**
* Default HTTP reason phrases.
*
* @see https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
* @var array<int, string>
*/
public const array REASON_PHRASES = [
100 => 'Continue',
101 => 'Switching Protocols',
102 => 'Processing',
103 => 'Early Hints',
104 => 'Upload Resumption Supported', // temporary, expires 2025-11-13
200 => 'OK',
201 => 'Created',
202 => 'Accepted',
203 => 'Non-Authoritative Information',
204 => 'No Content',
205 => 'Reset Content',
206 => 'Partial Content',
207 => 'Multi-Status',
208 => 'Already Reported',
226 => 'IM Used',
300 => 'Multiple Choices',
301 => 'Moved Permanently',
302 => 'Found',
303 => 'See Other',
304 => 'Not Modified',
305 => 'Use Proxy',
306 => 'Switch Proxy', // unused
307 => 'Temporary Redirect',
308 => 'Permanent Redirect',
400 => 'Bad Request',
401 => 'Unauthorized',
402 => 'Payment Required',
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
406 => 'Not Acceptable',
407 => 'Proxy Authentication Required',
408 => 'Request Timeout',
409 => 'Conflict',
410 => 'Gone',
411 => 'Length Required',
412 => 'Precondition Failed',
413 => 'Content Too Large',
414 => 'URI Too Long',
415 => 'Unsupported Media Type',
416 => 'Range Not Satisfiable',
417 => 'Expectation Failed',
418 => 'I\'m a teapot', // unused
421 => 'Misdirected Request',
422 => 'Unprocessable Content',
423 => 'Locked',
424 => 'Failed Dependency',
425 => 'Too Early',
426 => 'Upgrade Required',
428 => 'Precondition Required',
429 => 'Too Many Requests',
431 => 'Request Header Fields Too Large',
451 => 'Unavailable For Legal Reasons',
500 => 'Internal Server Error',
501 => 'Not Implemented',
502 => 'Bad Gateway',
503 => 'Service Unavailable',
504 => 'Gateway Timeout',
505 => 'HTTP Version Not Supported',
506 => 'Variant Also Negotiates',
507 => 'Insufficient Storage',
508 => 'Loop Detected',
510 => 'Not Extended', // obsolete
511 => 'Network Authentication Required',
];
}

View file

@ -1,40 +1,42 @@
<?php
// HttpResponseBuilder.php
// Created: 2022-02-08
// Updated: 2025-02-27
// 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;
/**
* Represents a HTTP response message builder.
*/
class HttpResponseBuilder extends HttpMessageBuilder {
private ?string $statusTextValue = null;
final class HttpResponseBuilder extends HttpMessageBuilder {
/** @var string[] */
private array $vary = [];
/**
* HTTP status code for the target HTTP response message.
*
* @var int
*/
public int $statusCode = 200;
/**
* Status text for this response.
*
* @var string
*/
public string $statusText {
get => $this->statusTextValue ?? self::STATUS[$this->statusCode] ?? 'Unknown Status';
set(string $value) {
$this->statusTextValue = $value === '' ? null : $value;
}
public string $reasonPhrase = '';
/**
* If true, a body still needs to be set.
*/
public bool $needsBody {
get => $this->body === null
&& $this->statusCode !== 204 && $this->statusCode !== 205
&& (
($this->statusCode >= 200 && $this->statusCode <= 299)
|| ($this->statusCode >= 400 && $this->statusCode <= 599)
);
}
/**
@ -131,7 +133,7 @@ class HttpResponseBuilder extends HttpMessageBuilder {
}
/**
* Sets an X-Powered-By header.
* Sets the X-Powered-By header.
*
* @param string $poweredBy Thing that your website is powered by.
*/
@ -140,7 +142,36 @@ class HttpResponseBuilder extends HttpMessageBuilder {
}
/**
* Sets an ETag header.
* Sets the Allow header for which HTTP methods are allowed.
*
* @param string[] $methods Allowed methods.
*/
public function setAllow(array $methods): void {
$methods = array_unique($methods);
sort($methods);
$this->setHeader('Allow', implode(', ', $methods));
}
/**
* Checks if the Content-Length header is present.
*
* @return bool
*/
public function hasContentLength(): bool {
return $this->hasHeader('Content-Length');
}
/**
* Sets the Content-Length header.
*
* @param int $bytes Length of the body in bytes.
*/
public function setContentLength(int $bytes): void {
$this->setHeader('Content-Length', (string)max(0, $bytes));
}
/**
* Sets the ETag header.
*
* @param string $eTag Unique identifier for the current state of the content.
* @param bool $weak Whether to use weak matching.
@ -155,7 +186,7 @@ class HttpResponseBuilder extends HttpMessageBuilder {
}
/**
* Sets a Server-Timing header.
* Sets the Server-Timing header.
*
* @param Timings $timings Timings to supply to the devtools.
*/
@ -185,7 +216,7 @@ class HttpResponseBuilder extends HttpMessageBuilder {
}
/**
* Sets a Content-Type header.
* Sets the Content-Type header.
*
* @param MediaType|string $mediaType Media type to set as the content type of the response body.
*/
@ -205,8 +236,12 @@ class HttpResponseBuilder extends HttpMessageBuilder {
*
* @param string $charset Character set.
*/
public function setTypePlain(string $charset = 'us-ascii'): void {
$this->setContentType('text/plain; charset=' . $charset);
public function setTypePlain(string $charset = ''): void {
$type = 'text/plain';
if($charset !== '')
$type .= sprintf(';charset=%s', $charset);
$this->setContentType($type);
}
/**
@ -214,8 +249,12 @@ class HttpResponseBuilder extends HttpMessageBuilder {
*
* @param string $charset Character set.
*/
public function setTypeHtml(string $charset = 'utf-8'): void {
$this->setContentType('text/html; charset=' . $charset);
public function setTypeHtml(string $charset = ''): void {
$type = 'text/html';
if($charset !== '')
$type .= sprintf(';charset=%s', $charset);
$this->setContentType($type);
}
/**
@ -223,8 +262,19 @@ class HttpResponseBuilder extends HttpMessageBuilder {
*
* @param string $charset Character set.
*/
public function setTypeJson(string $charset = 'utf-8'): void {
$this->setContentType('application/json; charset=' . $charset);
public function setTypeJson(string $charset = ''): void {
$type = 'application/json';
if($charset !== '')
$type .= sprintf(';charset=%s', $charset);
$this->setContentType($type);
}
/**
* Sets the Content-Type to 'application/x-bittorrent'.
*/
public function setTypeBencode(): void {
$this->setContentType('application/x-bittorrent');
}
/**
@ -232,8 +282,12 @@ class HttpResponseBuilder extends HttpMessageBuilder {
*
* @param string $charset Character set.
*/
public function setTypeXml(string $charset = 'utf-8'): void {
$this->setContentType('application/xml; charset=' . $charset);
public function setTypeXml(string $charset = ''): void {
$type = 'application/xml';
if($charset !== '')
$type .= sprintf(';charset=%s', $charset);
$this->setContentType($type);
}
/**
@ -241,8 +295,12 @@ class HttpResponseBuilder extends HttpMessageBuilder {
*
* @param string $charset Character set.
*/
public function setTypeCss(string $charset = 'utf-8'): void {
$this->setContentType('text/css; charset=' . $charset);
public function setTypeCss(string $charset = ''): void {
$type = 'text/css';
if($charset !== '')
$type .= sprintf(';charset=%s', $charset);
$this->setContentType($type);
}
/**
@ -250,12 +308,16 @@ class HttpResponseBuilder extends HttpMessageBuilder {
*
* @param string $charset Character set.
*/
public function setTypeJs(string $charset = 'utf-8'): void {
$this->setContentType('application/javascript; charset=' . $charset);
public function setTypeJs(string $charset = ''): void {
$type = 'application/javascript';
if($charset !== '')
$type .= sprintf(';charset=%s', $charset);
$this->setContentType($type);
}
/**
* Specifies an Apache Web Server X-Sendfile header.
* Specifies the Apache Web Server X-Sendfile header.
*
* @param string $absolutePath Absolute path to the content to serve.
*/
@ -264,7 +326,7 @@ class HttpResponseBuilder extends HttpMessageBuilder {
}
/**
* Specifies an NGINX X-Accel-Redirect header.
* Specifies the NGINX X-Accel-Redirect header.
*
* @param string $uri Relative URI to the content to serve.
*/
@ -317,69 +379,11 @@ class HttpResponseBuilder extends HttpMessageBuilder {
*/
public function toResponse(): HttpResponse {
return new HttpResponse(
$this->version,
$this->protocolVersion,
$this->headers,
$this->body ?? NullStream::instance(),
$this->statusCode,
$this->statusText,
$this->headers->toHeaders(),
// this is crunchy, this entire pipeline needs to be redone anyway
$this->content instanceof HttpContent ? $this->content : null
$this->reasonPhrase,
);
}
private const STATUS = [
100 => 'Continue',
101 => 'Switching Protocols',
103 => 'Early Hints',
200 => 'OK',
201 => 'Created',
202 => 'Accepted',
203 => 'Non-Authoritative Information',
204 => 'No Content',
205 => 'Reset Content',
206 => 'Partial Content',
300 => 'Multiple Choices',
301 => 'Moved Permanently',
302 => 'Found',
303 => 'See Other',
304 => 'Not Modified',
307 => 'Temporary Redirect',
308 => 'Permanent Redirect',
400 => 'Bad Request',
401 => 'Unauthorized',
402 => 'Payment Required',
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
406 => 'Not Acceptable',
407 => 'Proxy Authentication Required',
408 => 'Request Timeout',
409 => 'Conflict',
410 => 'Gone',
411 => 'Length Required',
412 => 'Precondition Required',
413 => 'Payload Too Large',
414 => 'URI Too Long',
415 => 'Unsupported Media Type',
416 => 'Range Not Satisfiable',
417 => 'Expectation Failed',
418 => 'I\'m a teapot',
422 => 'Unprocessable Entity',
425 => 'Too Early',
426 => 'Upgrade Required',
428 => 'Precondition Required',
429 => 'Too Many Requests',
431 => 'Request Header Fields Too Large',
451 => 'Unavailable For Legal Reasons',
500 => 'Internal Server Error',
501 => 'Not Implemented',
502 => 'Bad Gateway',
503 => 'Service Unavailable',
504 => 'Gateway Timeout',
505 => 'HTTP Version Not Supported',
506 => 'Variant Also Negotiates',
507 => 'Insufficient Storage',
508 => 'Loop Detected',
510 => 'Not Extended',
511 => 'Network Authentication Required',
];
}

View file

@ -1,198 +0,0 @@
<?php
// HttpUploadedFile.php
// Created: 2022-02-10
// Updated: 2025-02-27
namespace Index\Http;
use InvalidArgumentException;
use RuntimeException;
use Index\MediaType;
/**
* Represents an uploaded file in a multipart/form-data request.
*/
class HttpUploadedFile {
/**
* @param int $errorCode PHP file upload error code.
* @param int $size Size, as provided by the client.
* @param string $localFileName Filename generated by PHP on the server.
* @param string $suggestedFileName Filename included by the client.
* @param MediaType|string $suggestedMediaType Mediatype included by the client.
*/
public function __construct(
public private(set) int $errorCode,
public private(set) int $size,
public private(set) string $localFileName,
public private(set) string $suggestedFileName,
MediaType|string $suggestedMediaType
) {
$this->suggestedMediaType = $suggestedMediaType instanceof MediaType
? $suggestedMediaType
: MediaType::parse($suggestedMediaType);
}
/**
* Media type of the uploaded file.
*
* @var ?MediaType
*/
public ?MediaType $localMediaType {
get => MediaType::fromPath($this->localFileName);
}
/**
* Suggested media type for the uploaded file.
*
* @var MediaType
*/
public private(set) MediaType $suggestedMediaType;
/**
* Whether the file has been moved to its final destination.
*
* @var bool
*/
public private(set) bool $hasMoved = false;
/**
* Moves the uploaded file to its final destination.
*
* @param string $path Path to move the file to.
* @throws RuntimeException If the file has already been moved.
* @throws RuntimeException If an upload error occurred.
* @throws InvalidArgumentException If the provided $path is not valid.
* @throws RuntimeException If the file failed to move.
*/
public function moveTo(string $path): void {
if($this->hasMoved)
throw new RuntimeException('This uploaded file has already been moved.');
if($this->errorCode !== UPLOAD_ERR_OK)
throw new RuntimeException('Can\'t move file because of an upload error.');
if(empty($path))
throw new InvalidArgumentException('$path is not a valid path.');
$this->hasMoved = PHP_SAPI === 'CLI'
? rename($this->localFileName, $path)
: move_uploaded_file($this->localFileName, $path);
if(!$this->hasMoved)
throw new RuntimeException('Failed to move file to ' . $path);
$this->localFileName = $path;
}
/**
* Creates a HttpUploadedFile instance from an entry in the $_FILES superglobal.
*
* @param array<string, int|string> $file File info array.
* @return HttpUploadedFile Uploaded file info.
*/
public static function createFromPhpFilesEntry(array $file): self {
return new HttpUploadedFile(
(int)($file['error'] ?? UPLOAD_ERR_NO_FILE),
(int)($file['size'] ?? -1),
(string)($file['tmp_name'] ?? ''),
(string)($file['name'] ?? ''),
(string)($file['type'] ?? '')
);
}
/**
* Creates a collection of HttpUploadedFile instances from the $_FILES superglobal.
*
* @param array<string, mixed> $files Value of a $_FILES superglobal.
* @return array<string, HttpUploadedFile|array<string, mixed>> Uploaded files.
*/
public static function createFromPhpFiles(array $files): array {
if(empty($files))
return [];
return self::createObjectInstances(self::normalizePhpFiles($files));
}
/**
* @param array<string, mixed> $files
* @return array<string, mixed>
*/
private static function traversePhpFiles(array $files, string $keyName): array {
$arr = [];
foreach($files as $key => $val) {
$key = "_{$key}";
if(is_array($val)) {
/** @var array<string, mixed> $val */
$arr[$key] = self::traversePhpFiles($val, $keyName);
} else {
$arr[$key][$keyName] = $val;
}
}
return $arr;
}
/**
* @param array<string, mixed> $files
* @return array<string, mixed>
*/
private static function normalizePhpFiles(array $files): array {
$out = [];
foreach($files as $key => $arr) {
if(!is_array($arr) || !isset($arr['error']))
continue;
$key = '_' . $key;
if(is_int($arr['error'])) {
$out[$key] = $arr;
continue;
}
if(is_array($arr['error'])) {
$keys = array_keys($arr);
foreach($keys as $keyName) {
$source = $arr[$keyName];
if(!is_array($source))
continue;
/** @var array<string, mixed> $mergeWith */
$mergeWith = $out[$key] ?? [];
/** @var array<string, mixed> $source */
$out[$key] = array_merge_recursive($mergeWith, self::traversePhpFiles($source, (string)$keyName));
}
continue;
}
}
return $out;
}
/**
* @param array<string, mixed> $files
* @return array<string, HttpUploadedFile|array<string, mixed>>
*/
private static function createObjectInstances(array $files): array {
$coll = [];
foreach($files as $key => $val) {
if(!is_array($val))
continue;
$key = substr($key, 1);
if(isset($val['error']))
/** @var array<string, int|string> $val */
$coll[$key] = self::createFromPhpFilesEntry($val);
else
/** @var array<string, mixed> $val */
$coll[$key] = self::createObjectInstances($val);
}
return $coll;
}
}

315
src/Http/HttpUri.php Normal file
View file

@ -0,0 +1,315 @@
<?php
// HttpUri.php
// Created: 2025-02-28
// Updated: 2025-03-08
namespace Index\Http;
use InvalidArgumentException;
use Stringable;
use Psr\Http\Message\UriInterface;
/**
* Implementation of PSR-7's UriInterface with some Index flavoured extras.
*/
class HttpUri implements UriInterface, Stringable {
/**
* @param string $scheme Contains the scheme component of the URI, equivalent to calling getScheme().
* @param string $userInfo Contains the user information component of the URI, equivalent to calling getUserInfo().
* @param string $host Contains the host component of the URI, equivalent to calling getHost().
* @param ?int $port Contains the port component of the URI, equivalent to calling getPort().
* @param string $path Contains the path component of the URI, equivalent to calling getPath().
* @param string $query Contains the query component of the URI, equivalent to calling getQuery().
* @param string $fragment Contains the fragment component of the URI, equivalent to calling getFragment().
* @throws InvalidArgumentException If any component is not acceptable.
*/
final public function __construct(
public private(set) string $scheme = '',
public private(set) string $userInfo = '',
public private(set) string $host = '',
public private(set) ?int $port = null,
public private(set) string $path = '',
public private(set) string $query = '',
public private(set) string $fragment = '',
) {
if($scheme !== '' && !preg_match('/^[a-zA-Z][a-zA-Z0-9+\-.]*$/', $scheme))
throw new InvalidArgumentException('$scheme is not an acceptable scheme format');
if(str_contains($userInfo, '@') || strpos($userInfo, ':') !== strrpos($userInfo, ':'))
throw new InvalidArgumentException('$userInfo is not an acceptable userInfo format');
if($host !== '') {
$host = idn_to_ascii($host);
if($host === false || (!filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) && !self::validateIPAddress($host)))
throw new InvalidArgumentException('$host is not an acceptable host format');
if($path !== '' && !str_starts_with($path, '/'))
throw new InvalidArgumentException('$path can not be relative if a host is set');
}
if($port !== null && ($port < 1 || $port > 0xFFFF))
throw new InvalidArgumentException('$port is not an acceptable port number');
if(!preg_match('/^\/?(?:[a-zA-Z0-9\-._~!$&\'()*+,;=:@%\/]*)$/', $path))
throw new InvalidArgumentException('$path is not an acceptable path format');
if(!preg_match('/^[a-zA-Z0-9\-._~!$&\'()*+,;=:@\/?%]*$/', $query))
throw new InvalidArgumentException('$query is not an acceptable query format');
if(!preg_match('/^[a-zA-Z0-9\-._~!$&\'()*+,;=:@\/?%]*$/', $fragment))
$this->fragment = rawurlencode($fragment); // don't let it get to this, please
$this->scheme = strtolower($scheme);
$this->host = strtolower($host);
}
/**
* Contains the authority component of the URI, equivalent to calling getAuthority().
*/
public string $authority {
get {
$authority = $this->host;
if($this->userInfo !== '')
$authority = sprintf('%s@%s', $this->userInfo, $authority);
if($this->port !== null)
$authority = sprintf('%s:%d', $authority, $this->port);
return $authority;
}
}
private static function validateIPAddress(string $addr): bool {
if(str_starts_with($addr, '[') && str_ends_with($addr, ']')) {
$addr = substr($addr, 1, -1);
$options = FILTER_FLAG_IPV6;
} else {
$options = FILTER_FLAG_IPV4;
}
return !!filter_var($addr, FILTER_VALIDATE_IP, $options);
}
/**
* Creates a new URI.
*
* @param string $uri
* @throws InvalidArgumentException If the given URI cannot be parsed.
* @return static
*/
public static function createUri(string $uri = ''): HttpUri {
$parsed = parse_url($uri);
// parse_url docs say that relative urls may be dodgy, so maybe check for those in here?
if($parsed === false)
throw new InvalidArgumentException('$uri could not be parsed');
$userInfo = $parsed['user'] ?? '';
if(array_key_exists('pass', $parsed))
$userInfo .= sprintf(':%s', $parsed['pass']);
return new static(
$parsed['scheme'] ?? '',
$userInfo,
$parsed['host'] ?? '',
$parsed['port'] ?? null,
$parsed['path'] ?? '',
$parsed['query'] ?? '',
$parsed['fragment'] ?? '',
);
}
/**
* Casts another UriInterface instance to HttpUri.
*
* @param UriInterface $uri
* @throws InvalidArgumentException Data provided by the UriInterface implementation violates the specification.
* @return HttpUri
*/
public static function castUri(UriInterface $uri): HttpUri {
if($uri instanceof HttpUri)
return $uri;
return new static(
$uri->getScheme(),
$uri->getUserInfo(),
$uri->getHost(),
$uri->getPort(),
$uri->getPath(),
$uri->getQuery(),
$uri->getFragment(),
);
}
/**
* Provides a shorthand alias for all with* methods and can also combine them.
*
* @param ?string $scheme Value you'd otherwise pass to withScheme, null to leave unmodified.
* @param ?string $userInfo Combined values you'd otherwise pass to withUserInfo, null to leave unmodified.
* @param ?string $host Value you'd otherwise pass to withHost, null to leave unmodified.
* @param int|null|false $port Value you'd otherwise pass to withPort, null semantics included, false to leave unmodified.
* @param ?string $path Value you'd otherwise pass to withPath, null to leave unmodified.
* @param ?string $query Value you'd otherwise pass to withQuery, null to leave unmodified.
* @param ?string $fragment Value you'd otherwise pass to withFragment, null to leave unmodified.
* @throws InvalidArgumentException If any of the components are not acceptable.
* @return static
*/
public function with(
?string $scheme = null,
?string $userInfo = null,
?string $host = null,
int|null|false $port = false,
?string $path = null,
?string $query = null,
?string $fragment = null,
): HttpUri {
return new static(
$scheme ?? $this->scheme,
$userInfo ?? $this->userInfo,
$host ?? $this->host,
$port === false ? $this->port : $port,
$path ?? $this->path,
$query ?? $this->query,
$fragment ?? $this->fragment,
);
}
public function getScheme(): string {
return $this->scheme;
}
public function withScheme(string $scheme): HttpUri {
return $this->with(scheme: $scheme);
}
public function getAuthority(): string {
return $this->authority;
}
public function getUserInfo(): string {
return $this->userInfo;
}
public function withUserInfo(string $user, ?string $password = null): HttpUri {
return $this->with(userInfo: $user === '' || $password === null ? $user : sprintf('%s:%s', $user, $password));
}
public function getHost(): string {
return $this->host;
}
public function withHost(string $host): HttpUri {
return $this->with(host: $host);
}
public function getPort(): ?int {
return $this->port;
}
public function withPort(?int $port): HttpUri {
return $this->with(port: $port);
}
public function getPath(): string {
return $this->path;
}
public function withPath(string $path): HttpUri {
return $this->with(path: $path);
}
public function getQuery(): string {
return $this->query;
}
public function withQuery(string $query): HttpUri {
return $this->with(query: $query);
}
public function getFragment(): string {
return $this->fragment;
}
public function withFragment(string $fragment): HttpUri {
return $this->with(fragment: $fragment);
}
public function __toString(): string {
$string = '';
if($this->scheme !== '')
$string .= sprintf('%s:', $this->scheme);
if($this->host === '')
$string .= $this->path !== '/' && str_starts_with($this->path, '/') ? sprintf('/%s', ltrim($this->path, '/')) : $this->path;
else
$string .= sprintf(
'//%s%s',
$this->authority,
$this->path === '' || $this->path === '/' ? '' : (
str_starts_with($this->path, '/') ? $this->path : sprintf('/%s', $this->path)
)
);
if($this->query !== '')
$string .= sprintf('?%s', $this->query);
if($this->fragment !== '')
$string .= sprintf('#%s', $this->fragment);
return $string;
}
/**
* Parses a urlencoded query/form string.
*
* @param string $query urlencoded string.
* @return array<string, list<?string>>
*/
public static function parseQueryString(string $query): array {
$params = [];
if($query !== '') {
$paramParts = explode('&', $query);
foreach($paramParts as $paramPart) {
$parts = explode('=', $paramPart, 2);
$name = urldecode($parts[0]);
if(!array_key_exists($name, $params))
$params[$name] = [];
$params[$name][] = count($parts) > 1 ? urldecode($parts[1]) : null;
}
}
return $params;
}
/**
* Builds a query/form string from params.
*
* @param array<string, Stringable|scalar|null|list<Stringable|scalar|null>> $params
* @return string
*/
public static function buildQueryString(array $params): string {
$parts = [];
foreach($params as $name => $values) {
$base = rawurlencode($name);
if($values === null) {
$parts[] = $base;
continue;
}
if(is_scalar($values) || $values instanceof Stringable)
$values = [$values];
foreach($values as $value) {
$part = $base;
if(is_scalar($value) || $value instanceof Stringable)
$part .= sprintf('=%s', rawurlencode((string)$value));
$parts[] = $part;
}
}
return implode('&', $parts);
}
}

View file

@ -1,16 +0,0 @@
<?php
// PlainHttpErrorHandler.php
// Created: 2024-03-28
// Updated: 2025-01-18
namespace Index\Http;
/**
* Represents a plain text error message handler for building HTTP response messages.
*/
class PlainHttpErrorHandler implements HttpErrorHandler {
public function handle(HttpResponseBuilder $response, HttpRequest $request, int $code, string $message): void {
$response->setTypePlain();
$response->content = sprintf('HTTP %03d %s', $code, $message);
}
}

View file

@ -0,0 +1,170 @@
<?php
// AccessControl.php
// Created: 2025-03-19
// Updated: 2025-03-19
namespace Index\Http\Routing\AccessControl;
use Attribute;
use Closure;
use ReflectionAttribute;
use ReflectionFunction;
use RuntimeException;
use Index\XArray;
use Index\Http\Routing\Routes\RouteInfo;
/**
* Contains access control info for cross origin requests.
*/
#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)]
final class AccessControl {
/**
* Headers that are always allowed regardless of Access-Control-Allow-Headers.
* Also included the CORS ones cus they basically are anyway.
*
* @see https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header
* @var string[]
*/
public const array HEADER_SAFELIST = [
'Access-Control-Allow-Headers',
'Access-Control-Allow-Methods',
'Access-Control-Allow-Origin',
'Access-Control-Expose-Headers',
'Access-Control-Request-Headers',
'Access-Control-Request-Method',
'Cache-Control',
'Content-Language',
'Content-Length',
'Content-Type',
'Expires',
'Last-Modified',
'Pragma',
];
/**
* @param bool $allow Whether to allow cross origin requests for this route.
* If false, everything else will be ignored since there's no need to specify it.
* Provided if necessary to explicitly deny.
* @param bool $credentials Whether to permit the client to include the credentials for our domain in requests using withCredentials = true.
* @param string[]|true $allowMethods What additional methods to allow for this route. true function as wildcard.
* Setting $allow to true will already include the $method provided in the RouteInfo constructor.
* @param string[]|true $allowHeaders What headers to allow for this route. true functions as wildcard.
* @param string[]|true $exposeHeaders What headers to expose to the remote origin. true function as wildcard, false as an explicit empty list.
* In addition to Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma.
* false and empty array are seemingly swapped in terms of functionality at first glance
* but i think having false represent an explicit Nothing will be nicer in the long run since it changes the type entirely
*/
public function __construct(
public private(set) bool $allow = true,
public private(set) bool $credentials = false,
public private(set) array|true $allowMethods = [],
public private(set) array|true $allowHeaders = [],
public private(set) array|true $exposeHeaders = [],
) {}
/**
* Verifies if provided headers are allowed.
*
* @param string[] $headers Request headers.
* @return bool
*/
public function verifyHeadersAllowed(array $headers): bool {
return $this->allowHeaders === true || XArray::all($headers, fn($name) => (
XArray::any(self::HEADER_SAFELIST, fn($safeName) => strcasecmp($safeName, $name) === 0)
|| XArray::any($this->allowHeaders, fn($allowName) => strcasecmp($allowName, $name) === 0)
));
}
/**
* Reads methods on a provided Closure and creates or amends an AccessControl object.
*
* @param Closure $closure Closure to read attributes from.
* @param ?AccessControl $info Base access control info object.
* @return ?AccessControl
*/
public static function read(Closure $closure, ?AccessControl $info): ?AccessControl {
$attrs = (new ReflectionFunction($closure))->getAttributes(self::class, ReflectionAttribute::IS_INSTANCEOF);
$attr = array_shift($attrs);
if($attr === null)
return $info;
$attr = $attr->newInstance();
// explicit allow === false always takes precedence over everything else
if(!$attr->allow)
return $attr;
if($info !== null) {
// see the above
if(!$info->allow)
return $info;
// false is default so true takes precedence
// maybe not the best strat but its the one you're getting
if($info->credentials)
$attr->credentials = true;
if($attr->allowMethods !== true)
$attr->allowMethods = $info->allowMethods === true
? true : array_values(array_unique(array_merge($attr->allowMethods, $info->allowMethods)));
if($attr->allowHeaders !== true)
$attr->allowHeaders = $info->allowHeaders === true
? true : array_values(array_unique(array_merge($attr->allowHeaders, $info->allowHeaders)));
if($attr->exposeHeaders !== true)
$attr->exposeHeaders = $info->exposeHeaders === true
? true : array_values(array_unique(array_merge($attr->exposeHeaders, $info->exposeHeaders)));
}
return $attr;
}
/**
* Aggregrates access control declarations for multiple
*
* @param array<object{info: RouteInfo}|RouteInfo> $routes Routes that were caught for this preflight request.
* @param string $requestMethod Intended request method for this preflight.
* @return AccessControl Aggregated access control info.
*/
public static function aggregate(array $routes, string $requestMethod): AccessControl {
$allow = false;
$credentials = false;
$allowMethods = [];
$allowHeaders = [];
$exposeHeaders = [];
foreach($routes as $routeInfo) {
if(!($routeInfo instanceof RouteInfo)) {
if(property_exists($routeInfo, 'info') && $routeInfo->info instanceof RouteInfo)
$routeInfo = $routeInfo->info;
else continue;
}
$accessControl = $routeInfo->accessControl;
if($accessControl?->allow !== true)
continue;
$allow = true;
if($allowMethods !== true)
if($accessControl->allowMethods === true) {
$allowMethods = true;
} else {
$allowMethods[] = $routeInfo->method;
if(!empty($accessControl->allowMethods))
$allowMethods = array_merge($allowMethods, $accessControl->allowMethods);
}
if($routeInfo->method === $requestMethod) {
if($accessControl->credentials)
$credentials = true;
$allowHeaders = $accessControl->allowHeaders;
$exposeHeaders = $accessControl->exposeHeaders;
}
}
return new AccessControl($allow, $credentials, $allowMethods, $allowHeaders, $exposeHeaders);
}
}

View file

@ -0,0 +1,52 @@
<?php
// AccessControlHandler.php
// Created: 2025-03-19
// Updated: 2025-03-19
namespace Index\Http\Routing\AccessControl;
use Index\XArray;
use Index\Http\HttpUri;
use Index\Http\Routing\HandlerContext;
use Index\Http\Routing\Routes\RouteInfo;
/**
* CORS handler interface.
*/
interface AccessControlHandler {
/**
* Aggregates info needed for built-in HTTP OPTIONS method handler.
*
* @param HandlerContext $context Route handler context.
* @param AccessControl $accessControl Aggregated access control rules.
* @param RouteInfo[] $routes Routes that this OPTIONS request concern.
* @param HttpUri $requestOrigin HTTP request Origin header value.
* @param string $requestMethod Intended HTTP request method.
* @param string[]|null $requestHeaders Intended HTTP request headers.
* @return AccessControlPreflight
*/
public function handlePreflight(
HandlerContext $context,
AccessControl $accessControl,
array $routes,
HttpUri $requestOrigin,
string $requestMethod,
array|null $requestHeaders,
): AccessControlPreflight;
/**
* Adds CORS headers to a request if applicable.
*
* @param HandlerContext $context Route handler context.
* @param RouteInfo $routeInfo Route info.
* @param AccessControl $accessControl Access control for the given route info.
* @param HttpUri $requestOrigin HTTP request Origin header value.
* @return AccessControlResult
*/
public function handleRequest(
HandlerContext $context,
RouteInfo $routeInfo,
AccessControl $accessControl,
HttpUri $requestOrigin,
): AccessControlResult;
}

View file

@ -0,0 +1,69 @@
<?php
// AccessControlPreflight.php
// Created: 2025-03-19
// Updated: 2025-03-19
namespace Index\Http\Routing\AccessControl;
use Index\Http\HttpResponseBuilder;
/**
* Contains the result for a preflight request.
*/
class AccessControlPreflight {
/**
* @param string|bool $origin Origin to use in the Access-Control-Allow-Origin header, true for *, false for deny.
* @param string[]|true $methods Methods to use in the Access-Control-Allow-Methods header, true for *.
* @param string[]|true|null $headers Headers to use in the Access-Control-Allow-Headers header, true for *, null for none.
* @param bool $credentials Value for the Access-Control-Allow-Credentials header.
* @param ?int $ttl Override value for Access-Control-Max-Age header, null for default supplied in AccessControlHandler constructor.
*/
public function __construct(
public private(set) string|bool $origin = false,
public private(set) array|true $methods = [],
public private(set) array|true|null $headers = null,
public private(set) bool $credentials = false,
public private(set) ?int $ttl = null,
) {}
/**
* Applies this collection of headers to a HttpResponseBuilder.
*
* @param HttpResponseBuilder $response Response to apply to.
* @param int $ttl Default TTL value, if $ttl is specified in the constructor it should be overridden.
*/
public function applyPreflight(HttpResponseBuilder $response, int $ttl): void {
if($this->origin === false)
return;
if($this->credentials)
$response->setHeader('Access-Control-Allow-Credentials', 'true');
if($this->headers !== null) {
if($this->headers === true)
$response->setHeader('Access-Control-Allow-Headers', '*');
else {
$headers = array_unique($this->headers);
sort($headers);
$response->setHeader('Access-Control-Allow-Headers', implode(', ', $headers));
}
}
if($this->methods === true) {
$response->setHeader('Access-Control-Allow-Methods', '*');
} elseif(!empty($this->methods)) {
$methods = array_unique($this->methods);
sort($methods);
$response->setHeader('Access-Control-Allow-Methods', implode(', ', $methods));
}
if($this->origin === true) {
$response->setHeader('Access-Control-Allow-Origin', '*');
} else {
$response->addVary('Origin');
$response->setHeader('Access-Control-Allow-Origin', $this->origin);
}
$response->setHeader('Access-Control-Max-Age', (string)($this->ttl ?? $ttl));
}
}

View file

@ -0,0 +1,54 @@
<?php
// AccessControlResult.php
// Created: 2025-03-19
// Updated: 2025-03-19
namespace Index\Http\Routing\AccessControl;
use Index\Http\HttpResponseBuilder;
/**
* Contains the result for a normal request.
*/
class AccessControlResult {
/**
* @param string|bool $origin Origin to use in the Access-Control-Allow-Origin header, true for *, false for deny.
* @param string[]|bool $headers Headers to use in the Access-Control-Expose-Headers header, true for *, false for none.
* @param bool $credentials Value for the Access-Control-Allow-Credentials header.
*/
public function __construct(
public private(set) string|bool $origin = false,
public private(set) array|bool $headers = [],
public private(set) bool $credentials = false,
) {}
/**
* Applies this collection of headers to a HttpResponseBuilder.
*
* @param HttpResponseBuilder $response Response to apply to.
*/
public function applyResult(HttpResponseBuilder $response): void {
if($this->origin === false)
return;
if($this->credentials)
$response->setHeader('Access-Control-Allow-Credentials', 'true');
if($this->origin === true) {
$response->setHeader('Access-Control-Allow-Origin', '*');
} else {
$response->addVary('Origin');
$response->setHeader('Access-Control-Allow-Origin', $this->origin);
}
if($this->headers === true)
$response->setHeader('Access-Control-Expose-Headers', '*');
elseif($this->headers === false)
$response->setHeader('Access-Control-Expose-Headers', '');
elseif(!empty($this->headers)) {
$headers = array_unique($this->headers);
sort($headers);
$response->setHeader('Access-Control-Expose-Headers', implode(', ', $headers));
}
}
}

View file

@ -0,0 +1,64 @@
<?php
// SimpleAccessControlHandler.php
// Created: 2025-03-19
// Updated: 2025-03-19
namespace Index\Http\Routing\AccessControl;
use Index\XArray;
use Index\Http\HttpUri;
use Index\Http\Routing\HandlerContext;
use Index\Http\Routing\Routes\RouteInfo;
/**
* Base CORS handler.
*
* Override checkAccess to make this not function as a wildcard.
*/
class SimpleAccessControlHandler implements AccessControlHandler {
public function checkAccess(
HandlerContext $context,
AccessControl $accessControl,
HttpUri $origin,
?RouteInfo $routeInfo = null,
): string|bool {
return $accessControl->credentials ? (string)$origin : true;
}
public function handlePreflight(
HandlerContext $context,
AccessControl $accessControl,
array $routes,
HttpUri $requestOrigin,
string $requestMethod,
array|null $requestHeaders,
): AccessControlPreflight {
$requestOrigin = $this->checkAccess($context, $accessControl, $requestOrigin);
if($requestOrigin === false)
return new AccessControlPreflight;
return new AccessControlPreflight(
$requestOrigin,
$accessControl->allowMethods,
$requestHeaders === null ? null : ($accessControl->allowHeaders === true ? true : $requestHeaders),
$accessControl->credentials,
);
}
public function handleRequest(
HandlerContext $context,
RouteInfo $routeInfo,
AccessControl $accessControl,
HttpUri $requestOrigin,
): AccessControlResult {
$requestOrigin = $this->checkAccess($context, $accessControl, $requestOrigin, $routeInfo);
if($requestOrigin === false)
return new AccessControlResult;
return new AccessControlResult(
$requestOrigin,
$accessControl->exposeHeaders,
$accessControl->credentials,
);
}
}

View file

@ -0,0 +1,20 @@
<?php
// ErrorHandler.php
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing\ErrorHandling;
use Index\Http\Routing\HandlerContext;
/**
* Represents an error message handler for building HTTP response messages.
*/
interface ErrorHandler {
/**
* Applies an error message template to the provided HTTP response builder.
*
* @param HandlerContext $context Route context.
*/
public function handle(HandlerContext $context): void;
}

View file

@ -0,0 +1,49 @@
<?php
// HtmlErrorHandler.php
// Created: 2024-03-28
// Updated: 2025-03-12
namespace Index\Http\Routing\ErrorHandling;
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.
*/
class HtmlErrorHandler implements ErrorHandler {
private const TEMPLATE = <<<HTML
<!doctype html>
<html>
<head>
<meta charset=":charset">
<title>:code :message</title>
</head>
<body>
<center><h1>:code :message</h1><center>
<hr>
<center>Index</center>
</body>
</html>
HTML;
public function handle(HandlerContext $context): void {
if(!$context->response->needsBody)
return;
$context->response->setTypeHtml();
$charSet = mb_preferred_mime_name(mb_internal_encoding());
if($charSet === false)
$charSet = 'UTF-8';
$context->response->body = Stream::createStream(strtr(self::TEMPLATE, [
':charset' => strtolower($charSet),
':code' => sprintf('%03d', $context->response->statusCode),
':message' => $context->response->reasonPhrase === ''
? HttpResponse::defaultReasonPhase($context->response->statusCode)
: $context->response->reasonPhrase,
]));
}
}

View file

@ -0,0 +1,29 @@
<?php
// PlainErrorHandler.php
// Created: 2024-03-28
// Updated: 2025-03-12
namespace Index\Http\Routing\ErrorHandling;
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.
*/
class PlainErrorHandler implements ErrorHandler {
public function handle(HandlerContext $context): void {
if(!$context->response->needsBody)
return;
$context->response->setTypePlain();
$context->response->body = Stream::createStream(sprintf(
'HTTP %03d %s',
$context->response->statusCode,
$context->response->reasonPhrase === ''
? HttpResponse::defaultReasonPhase($context->response->statusCode)
: $context->response->reasonPhrase
));
}
}

View file

@ -0,0 +1,28 @@
<?php
// FilterAttribute.php
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing\Filters;
use Attribute;
use Closure;
use Index\Http\Routing\HandlerAttribute;
/**
* Provides an attribute for marking methods in a class as a filter.
*/
abstract class FilterAttribute extends HandlerAttribute {
/**
* Flags to set for the Attribute attribute.
*/
public const int FLAGS = Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE;
/**
* Creates a FilterInfo instance.
*
* @param Closure $handler Handler method.
* @return FilterInfo
*/
abstract public function createInstance(Closure $handler): FilterInfo;
}

View file

@ -0,0 +1,45 @@
<?php
// FilterInfo.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Filters;
use Closure;
use Index\Http\Routing\UriMatchers\{PatternPathUriMatcher,PrefixPathUriMatcher,UriMatcher};
/**
* Information of a filter.
*/
class FilterInfo {
/**
* @param UriMatcher $matcher URI matcher to use.
* @param Closure $handler Handler for this filter.
*/
public function __construct(
public private(set) UriMatcher $matcher,
public private(set) Closure $handler,
) {}
/**
* Creates FilterInfo instance using PrefixPathUriMatcher.
*
* @param string $path Path to match against.
* @param Closure $handler Handler for this route.
* @return FilterInfo
*/
public static function prefix(string $path, Closure $handler): FilterInfo {
return new FilterInfo(new PrefixPathUriMatcher($path), $handler);
}
/**
* Creates FilterInfo instance using PatternPathUriMatcher.
*
* @param string $pattern Path regex pattern to match against.
* @param Closure $handler Handler for this route.
* @return FilterInfo
*/
public static function pattern(string $pattern, Closure $handler): FilterInfo {
return new FilterInfo(new PatternPathUriMatcher($pattern), $handler);
}
}

View file

@ -0,0 +1,34 @@
<?php
// PatternFilter.php
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing\Filters;
use Attribute;
use Closure;
/**
* Marks a method as a pattern filter.
*/
#[Attribute(FilterAttribute::FLAGS)]
class PatternFilter extends FilterAttribute {
private string $pattern;
/**
* @param string $pattern URI path pattern.
* @param bool $raw If true, $pattern is taken as is, if false, $pattern is prefixed with #^ and suffixed with #u or $#uD
* @param bool $prefix Only has effect if $raw is false. If true is #u appended to $pattern, otherwise $#uD.
*/
public function __construct(
string $pattern,
bool $raw = false,
bool $prefix = true,
) {
$this->pattern = $raw ? $pattern : sprintf('#^%s%s', $pattern, $prefix ? '#u' : '$#uD');
}
public function createInstance(Closure $handler): FilterInfo {
return FilterInfo::pattern($this->pattern, $handler);
}
}

View file

@ -0,0 +1,26 @@
<?php
// PrefixFilter.php
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing\Filters;
use Attribute;
use Closure;
/**
* Provides an attribute for marking methods in a class as a route.
*/
#[Attribute(FilterAttribute::FLAGS)]
class PrefixFilter extends FilterAttribute {
/**
* @param string $prefix URI prefix.
*/
public function __construct(
private string $prefix
) {}
public function createInstance(Closure $handler): FilterInfo {
return FilterInfo::prefix($this->prefix, $handler);
}
}

View file

@ -1,24 +1,18 @@
<?php
// HandlerAttribute.php
// Created: 2024-03-28
// Updated: 2025-01-18
// Updated: 2025-03-07
namespace Index\Http\Routing;
use ReflectionAttribute;
use ReflectionObject;
use RuntimeException;
/**
* Provides base for attributes that mark methods in a class as handlers.
*/
abstract class HandlerAttribute {
/**
* @param string $path Target path.
*/
public function __construct(
public private(set) string $path
) {}
/**
* Reads attributes from methods in a RouteHandler instance and registers them to a given Router instance.
*
@ -26,20 +20,24 @@ abstract class HandlerAttribute {
* @param RouteHandler $handler Handler instance.
*/
public static function register(Router $router, RouteHandler $handler): void {
$objectInfo = new ReflectionObject($handler);
$methodInfos = $objectInfo->getMethods();
$object = new ReflectionObject($handler);
$methods = $object->getMethods();
foreach($methodInfos as $methodInfo) {
$attrInfos = $methodInfo->getAttributes(HandlerAttribute::class, ReflectionAttribute::IS_INSTANCEOF);
foreach($methods as $method) {
$attrs = $method->getAttributes(self::class, ReflectionAttribute::IS_INSTANCEOF);
foreach($attrInfos as $attrInfo) {
$handlerInfo = $attrInfo->newInstance();
$closure = $methodInfo->getClosure($methodInfo->isStatic() ? null : $handler);
foreach($attrs as $attr) {
$info = $attr->newInstance();
$closure = $method->getClosure($method->isStatic() ? null : $handler);
if($handlerInfo instanceof HttpRoute)
$router->add($handlerInfo->method, $handlerInfo->path, $closure);
if($info instanceof Routes\RouteAttribute)
$router->route($info->createInstance($closure));
elseif($info instanceof Filters\FilterAttribute)
$router->filter($info->createInstance($closure));
elseif($info instanceof Processors\ProcessorAttribute)
$router->processor($info->createInstance($closure));
else
$router->use($handlerInfo->path, $closure);
throw new RuntimeException('unsupported HandlerAttribute encountered');
}
}
}

View file

@ -0,0 +1,100 @@
<?php
// HandlerContext.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing;
use Closure;
use ReflectionFunction;
use Index\Dependencies;
use Index\Http\{HttpResponseBuilder,HttpRequest};
use Psr\Http\Message\ServerRequestInterface;
/**
* Contains the context for the routing pipeline.
*/
class HandlerContext {
/**
* Contains the response builder.
*/
public private(set) HttpResponseBuilder $response;
/**
* Contains the request.
*/
public private(set) HttpRequest $request;
/**
* Dependency bag for the handlers.
*/
public private(set) Dependencies $deps;
/**
* Contains arguments passed to the route handler.
* Not including ones matches on the route's path or whatever.
*
* @var mixed[]
*/
public private(set) array $args = [];
/**
* If true, the pipeline must be halted and $response must be output.
*/
public private(set) bool $stopped = false;
/**
* Value returned by the route handler.
*/
public mixed $result = null;
/**
* @param ServerRequestInterface $request Request that is being handled.
*/
public function __construct(
ServerRequestInterface $request
) {
$this->deps = new Dependencies;
$this->deps->register($this);
$this->deps->register($this->response = new HttpResponseBuilder);
$this->deps->register($this->request = HttpRequest::castRequest($request));
}
/**
* Sets $stopped to true to indicate that the handling pipeline should be stopped.
*/
public function halt(): void {
$this->stopped = true;
}
/**
* Adds an argument to be used in method calls.
*
* @param mixed $value Argument to register
*/
public function addArgument(mixed $value): void {
$this->args[] = $value;
}
/**
* Sets a named argument to be used in method calls.
*
* @param string $name Name of the argument.
* @param mixed $value Argument to register
*/
public function setArgument(string $name, mixed $value): void {
$this->args[$name] = $value;
}
/**
* Calls a closure using dependencies and arguments.
*
* @param Closure $closure Function to call.
* @param ?mixed[] $args Additional arguments to be passed to the constructor.
* @return mixed Return value of the function or method.
*/
public function call(Closure $closure, ?array $args = null): mixed {
$args = $args === null ? $this->args : array_merge($this->args, $args);
return $this->deps->call($closure, ...$args);
}
}

View file

@ -1,21 +0,0 @@
<?php
// HttpDelete.php
// Created: 2024-03-28
// Updated: 2024-08-01
namespace Index\Http\Routing;
use Attribute;
/**
* Provides an attribute for marking methods in a class as a DELETE route.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class HttpDelete extends HttpRoute {
/**
* @param string $path Path this route represents.
*/
public function __construct(string $path) {
parent::__construct('DELETE', $path);
}
}

View file

@ -1,21 +0,0 @@
<?php
// HttpGet.php
// Created: 2024-03-28
// Updated: 2024-08-01
namespace Index\Http\Routing;
use Attribute;
/**
* Provides an attribute for marking methods in a class as a GET route.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class HttpGet extends HttpRoute {
/**
* @param string $path Path this route represents.
*/
public function __construct(string $path) {
parent::__construct('GET', $path);
}
}

View file

@ -1,14 +0,0 @@
<?php
// HttpMiddleware.php
// Created: 2024-03-28
// Updated: 2024-03-28
namespace Index\Http\Routing;
use Attribute;
/**
* Provides an attribute for marking methods in a class as middleware.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class HttpMiddleware extends HandlerAttribute {}

View file

@ -1,21 +0,0 @@
<?php
// HttpOptions.php
// Created: 2024-03-28
// Updated: 2024-08-01
namespace Index\Http\Routing;
use Attribute;
/**
* Provides an attribute for marking methods in a class as a OPTIONS route.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class HttpOptions extends HttpRoute {
/**
* @param string $path Path this route represents.
*/
public function __construct(string $path) {
parent::__construct('OPTIONS', $path);
}
}

View file

@ -1,21 +0,0 @@
<?php
// HttpPatch.php
// Created: 2024-03-28
// Updated: 2024-08-01
namespace Index\Http\Routing;
use Attribute;
/**
* Provides an attribute for marking methods in a class as a POST route.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class HttpPatch extends HttpRoute {
/**
* @param string $path Path this route represents.
*/
public function __construct(string $path) {
parent::__construct('PATCH', $path);
}
}

View file

@ -1,21 +0,0 @@
<?php
// HttpPost.php
// Created: 2024-03-28
// Updated: 2024-08-01
namespace Index\Http\Routing;
use Attribute;
/**
* Provides an attribute for marking methods in a class as a POST route.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class HttpPost extends HttpRoute {
/**
* @param string $path Path this route represents.
*/
public function __construct(string $path) {
parent::__construct('POST', $path);
}
}

View file

@ -1,21 +0,0 @@
<?php
// HttpPut.php
// Created: 2024-03-28
// Updated: 2024-08-01
namespace Index\Http\Routing;
use Attribute;
/**
* Provides an attribute for marking methods in a class as a PUT route.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class HttpPut extends HttpRoute {
/**
* @param string $path Path this route represents.
*/
public function __construct(string $path) {
parent::__construct('PUT', $path);
}
}

View file

@ -1,25 +0,0 @@
<?php
// HttpRoute.php
// Created: 2024-03-28
// Updated: 2025-01-18
namespace Index\Http\Routing;
use Attribute;
/**
* Provides an attribute for marking methods in a class as a route.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class HttpRoute extends HandlerAttribute {
/**
* @param string $method
* @param string $path
*/
public function __construct(
public private(set) string $method,
string $path
) {
parent::__construct($path);
}
}

View file

@ -1,357 +0,0 @@
<?php
// HttpRouter.php
// Created: 2024-03-28
// Updated: 2025-02-27
namespace Index\Http\Routing;
use stdClass;
use InvalidArgumentException;
use Index\Bencode\BencodeHttpContentHandler;
use Index\Http\{
HtmlHttpErrorHandler,HttpContentHandler,HttpErrorHandler,HttpResponse,
HttpResponseBuilder,HttpRequest,PlainHttpErrorHandler,StringHttpContent
};
use Index\Json\JsonHttpContentHandler;
class HttpRouter implements Router {
use RouterCommon;
/** @var array{handler: callable, match?: string, prefix?: string}[] */
private array $middlewares = [];
/** @var array<string, array<string, callable>> */
private array $staticRoutes = [];
/** @var array<string, array<string, callable>> */
private array $dynamicRoutes = [];
/** @var HttpContentHandler[] */
private array $contentHandlers = [];
private string $charSetValue;
/**
* @param string $charSet Default character set to specify when none is present.
* @param HttpErrorHandler|string $errorHandler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation.
* @param bool $registerDefaultContentHandlers true to register default content handlers for JSON, Bencode, etc.
*/
public function __construct(
string $charSet = '',
HttpErrorHandler|string $errorHandler = 'html',
bool $registerDefaultContentHandlers = true,
) {
$this->charSetValue = $charSet;
$this->errorHandler = $errorHandler;
if($registerDefaultContentHandlers)
$this->registerDefaultContentHandlers();
}
/**
* Retrieves the normalised name of the preferred character set.
*
* @return string Normalised character set name.
*/
public string $charSet {
get {
if($this->charSetValue === '') {
$charSet = mb_preferred_mime_name(mb_internal_encoding());
if($charSet === false)
$charSet = 'UTF-8';
return strtolower($charSet);
}
return $this->charSetValue;
}
}
/**
* Error handler instance.
*
* @var HttpErrorHandler
*/
public HttpErrorHandler $errorHandler {
get => $this->errorHandler;
set(HttpErrorHandler|string $handler) {
if($handler instanceof HttpErrorHandler)
$this->errorHandler = $handler;
elseif($handler === 'html')
$this->setHtmlErrorHandler();
else // plain
$this->setPlainErrorHandler();
}
}
/**
* Set the error handler to the basic HTML one.
*/
public function setHtmlErrorHandler(): void {
$this->errorHandler = new HtmlHttpErrorHandler;
}
/**
* Set the error handler to the plain text one.
*/
public function setPlainErrorHandler(): void {
$this->errorHandler = new PlainHttpErrorHandler;
}
/**
* Register a message body content handler.
*
* @param HttpContentHandler $contentHandler Content handler to register.
*/
public function registerContentHandler(HttpContentHandler $contentHandler): void {
if(!in_array($contentHandler, $this->contentHandlers))
$this->contentHandlers[] = $contentHandler;
}
/**
* Register the default content handlers.
*/
public function registerDefaultContentHandlers(): void {
$this->registerContentHandler(new JsonHttpContentHandler);
$this->registerContentHandler(new BencodeHttpContentHandler);
}
/**
* Retrieve a scoped router to a given path prefix.
*
* @param string $prefix Prefix to apply to paths within the returned router.
* @return Router Scopes router proxy.
*/
public function scopeTo(string $prefix): Router {
return new ScopedRouter($this, $prefix);
}
private static function preparePath(string $path, bool $prefixMatch): string|false {
// this sucks lol
if(!str_contains($path, '(') || !str_contains($path, ')'))
return false;
// make trailing slash optional
if(!$prefixMatch && str_ends_with($path, '/'))
$path .= '?';
return sprintf('#^%s%s#su', $path, $prefixMatch ? '' : '$');
}
/**
* Registers a middleware handler.
*
* @param string $path Path prefix or regex to apply this middleware on.
* @param callable $handler Middleware handler.
*/
public function use(string $path, callable $handler): void {
$mwInfo = [];
$mwInfo['handler'] = $handler;
$prepared = self::preparePath($path, true);
if($prepared === false) {
if(str_ends_with($path, '/'))
$path = substr($path, 0, -1);
$mwInfo['prefix'] = $path;
} else
$mwInfo['match'] = $prepared;
$this->middlewares[] = $mwInfo;
}
/**
* Registers a route handler for a given method and path.
*
* @param string $method Method to use this handler for.
* @param string $path Path or regex to use this handler with.
* @param callable $handler Handler to use for this method/path combination.
* @throws InvalidArgumentException If $method is empty.
* @throws InvalidArgumentException If $method starts or ends with spaces.
*/
public function add(string $method, string $path, callable $handler): void {
if($method === '')
throw new InvalidArgumentException('$method may not be empty');
$method = strtoupper($method);
if(trim($method) !== $method)
throw new InvalidArgumentException('$method may start or end with whitespace');
$prepared = self::preparePath($path, false);
if($prepared === false) {
if(str_ends_with($path, '/'))
$path = substr($path, 0, -1);
if(array_key_exists($path, $this->staticRoutes))
$this->staticRoutes[$path][$method] = $handler;
else
$this->staticRoutes[$path] = [$method => $handler];
} else {
if(array_key_exists($prepared, $this->dynamicRoutes))
$this->dynamicRoutes[$prepared][$method] = $handler;
else
$this->dynamicRoutes[$prepared] = [$method => $handler];
}
}
/**
* Resolves middlewares and a route handler for a given method and path.
*
* @param string $method Method to resolve for.
* @param string $path Path to resolve for.
* @return ResolvedRouteInfo Resolved route information.
*/
public function resolve(string $method, string $path): ResolvedRouteInfo {
if(str_ends_with($path, '/'))
$path = substr($path, 0, -1);
$middlewares = [];
foreach($this->middlewares as $mwInfo) {
if(array_key_exists('match', $mwInfo)) {
if(preg_match($mwInfo['match'], $path, $args) !== 1)
continue;
array_shift($args);
} elseif(array_key_exists('prefix', $mwInfo)) {
if($mwInfo['prefix'] !== '' && !str_starts_with($path, $mwInfo['prefix']))
continue;
$args = [];
} else continue;
$middlewares[] = [$mwInfo['handler'], $args];
}
$methods = [];
if(array_key_exists($path, $this->staticRoutes)) {
foreach($this->staticRoutes[$path] as $sMethodName => $sMethodHandler)
$methods[$sMethodName] = [$sMethodHandler, []];
} else {
foreach($this->dynamicRoutes as $rPattern => $rMethods)
if(preg_match($rPattern, $path, $args) === 1)
foreach($rMethods as $rMethodName => $rMethodHandler)
if(!array_key_exists($rMethodName, $methods))
$methods[$rMethodName] = [$rMethodHandler, array_slice($args, 1)];
}
$method = strtoupper($method);
if(array_key_exists($method, $methods)) {
[$handler, $args] = $methods[$method];
} elseif($method === 'HEAD' && array_key_exists('GET', $methods)) {
[$handler, $args] = $methods['GET'];
} else {
$handler = null;
$args = [];
}
return new ResolvedRouteInfo($middlewares, array_keys($methods), $handler, $args);
}
/**
* Dispatches a route based on a given HTTP request message with additional prefix arguments and output to stdout.
*
* @param ?HttpRequest $request HTTP request message to handle, null to use the current request.
* @param mixed[] $args Additional arguments to prepend to the argument list sent to the middleware and route handlers.
*/
public function dispatch(?HttpRequest $request = null, array $args = []): void {
$request ??= HttpRequest::fromRequest();
$response = new HttpResponseBuilder;
$args = array_merge([$response, $request], $args);
$routeInfo = $this->resolve($request->method, $request->path);
// always run middleware regardless of 404 or 405
$result = $routeInfo->runMiddleware($args);
if($result === null) {
if(!$routeInfo->hasHandler()) {
if(empty($routeInfo->supportedMethods)) {
$result = 404;
} else {
$result = 405;
$response->setHeader('Allow', implode(', ', $routeInfo->supportedMethods));
}
} else
$result = $routeInfo->dispatch($args);
}
if(is_int($result)) {
if($result >= 100 && $result < 600)
$this->writeErrorPage($response, $request, $result);
else
$response->content = new StringHttpContent((string)$result);
} elseif(empty($response->content)) {
foreach($this->contentHandlers as $contentHandler)
if($contentHandler->match($result)) {
$contentHandler->handle($response, $result);
break;
}
if(empty($response->content) && is_scalar($result)) {
$result = (string)$result;
$response->content = new StringHttpContent($result);
if(!$response->hasContentType()) {
if(strtolower(substr($result, 0, 14)) === '<!doctype html')
$response->setTypeHtml($this->charSet);
else {
$charset = mb_detect_encoding($result);
if($charset !== false)
$charset = mb_preferred_mime_name($charset);
$charset = $charset === false ? 'utf-8' : strtolower($charset);
if(strtolower(substr($result, 0, 5)) === '<?xml')
$response->setTypeXml($charset);
else
$response->setTypePlain($charset);
}
}
}
}
self::output($response->toResponse(), $request->method !== 'HEAD');
}
/**
* Writes an error page to a given HTTP response builder.
*
* @param HttpResponseBuilder $response HTTP response builder to apply the error page to.
* @param HttpRequest $request HTTP request that triggered this error.
* @param int $statusCode HTTP status code for this error page.
*/
public function writeErrorPage(HttpResponseBuilder $response, HttpRequest $request, int $statusCode): void {
$this->errorHandler->handle(
$response,
$request,
$response->statusCode = $statusCode,
$response->statusText
);
}
/**
* Outputs a HTTP response message to stdout.
*
* @param HttpResponse $response HTTP response message to output.
* @param bool $includeBody true to include the response message body, false to omit it for HEAD requests.
*/
public static function output(HttpResponse $response, bool $includeBody): void {
header(sprintf(
'HTTP/%s %03d %s',
$response->version,
$response->statusCode,
$response->statusText
));
foreach($response->headers->all as $header)
foreach($header->lines as $line)
header(sprintf('%s: %s', $header->name, (string)$line));
if($includeBody && !empty($response->content)) {
if(is_resource($response->content))
echo stream_get_contents($response->content);
else
echo (string)$response->content;
}
}
}

View file

@ -0,0 +1,14 @@
<?php
// After.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Attribute;
/**
* Requires a preprocessor on a route handler.
*/
#[Attribute(ProcessAttribute::FLAGS)]
class After extends ProcessAttribute {}

View file

@ -0,0 +1,14 @@
<?php
// Before.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Attribute;
/**
* Requires a postprocessor on a route handler.
*/
#[Attribute(ProcessAttribute::FLAGS)]
class Before extends ProcessAttribute {}

View file

@ -0,0 +1,26 @@
<?php
// Postprocessor.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Attribute;
use Closure;
/**
* Marks a method as a post-processor.
*/
#[Attribute(ProcessorAttribute::FLAGS)]
class Postprocessor extends ProcessorAttribute {
/**
* @param string $name Name of the post-processor.
*/
public function __construct(
private string $name,
) {}
public function createInstance(Closure $handler): ProcessorInfo {
return ProcessorInfo::post($this->name, $handler);
}
}

View file

@ -0,0 +1,26 @@
<?php
// Preprocessor.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Attribute;
use Closure;
/**
* Marks a method as a pre-processor.
*/
#[Attribute(ProcessorAttribute::FLAGS)]
class Preprocessor extends ProcessorAttribute {
/**
* @param string $name Name of the pre-processor.
*/
public function __construct(
private string $name,
) {}
public function createInstance(Closure $handler): ProcessorInfo {
return ProcessorInfo::pre($this->name, $handler);
}
}

View file

@ -0,0 +1,77 @@
<?php
// ProcessAttribute.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Attribute;
use Closure;
use ReflectionAttribute;
use ReflectionFunction;
use ReflectionFunctionAbstract;
use RuntimeException;
/**
* Provides an attribute for requiring processors on route handler.
*/
abstract class ProcessAttribute {
/**
* Flags to set for the Attribute attribute.
*/
public const int FLAGS = Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE;
/**
* Arguments to pass to the handler.
*
* @var array<string|int, mixed>
*/
public private(set) array $args;
/**
* @param string $name Name of the processor to require.
* @param mixed ...$args Arguments to pass to the handler.
*/
public function __construct(
public private(set) string $name,
mixed ...$args
) {
$this->args = $args;
}
/**
* Reads attributes from methods in a RouteHandler instance and registers them to a given Router instance.
*
* @param ReflectionFunctionAbstract|Closure $function Router instance.
* @return object{after: array<string, array<string|int, mixed>>, before: array<string, array<string|int, mixed>>}
*/
public static function read(ReflectionFunctionAbstract|Closure $function): object {
if($function instanceof Closure)
$function = new ReflectionFunction($function);
// i HATE how this is considered more acceptable than a stdClass instance
$process = (object)[
'after' => [],
'before' => [],
];
$attrs = $function->getAttributes(self::class, ReflectionAttribute::IS_INSTANCEOF);
foreach($attrs as $attr) {
$handler = $attr->newInstance();
if($handler instanceof After) {
if(array_key_exists($handler->name, $process->after))
throw new RuntimeException(sprintf('postprocessor "%s" already required', $handler->name));
$process->after[$handler->name] = $handler->args;
} elseif($handler instanceof Before) {
if(array_key_exists($handler->name, $process->before))
throw new RuntimeException(sprintf('postprocessor "%s" already required', $handler->name));
$process->before[$handler->name] = $handler->args;
} else
throw new RuntimeException('unsupported ProcessAttribute encountered');
}
return $process;
}
}

View file

@ -0,0 +1,28 @@
<?php
// ProcessorAttribute.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Attribute;
use Closure;
use Index\Http\Routing\HandlerAttribute;
/**
* Provides an attribute for marking methods in a class as a processor.
*/
abstract class ProcessorAttribute extends HandlerAttribute {
/**
* Flags to set for the Attribute attribute.
*/
public const int FLAGS = Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD;
/**
* Creates a ProcessorInfo instance.
*
* @param Closure $handler Handler method.
* @return ProcessorInfo
*/
abstract public function createInstance(Closure $handler): ProcessorInfo;
}

View file

@ -0,0 +1,46 @@
<?php
// ProcessorInfo.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
use Closure;
/**
* Information of a processor.
*/
class ProcessorInfo {
/**
* @param string $name Name of the processor.
* @param ProcessorTarget $target Kind of processor.
* @param Closure $handler Handler for this filter.
*/
public function __construct(
public private(set) string $name,
public private(set) ProcessorTarget $target,
public private(set) Closure $handler,
) {}
/**
* Creates a pre-processor ProcessorInfo instance.
*
* @param string $name Name of the processor.
* @param Closure $handler Handler for this processor.
* @return ProcessorInfo
*/
public static function pre(string $name, Closure $handler): ProcessorInfo {
return new ProcessorInfo($name, ProcessorTarget::Pre, $handler);
}
/**
* Creates a post-processor ProcessorInfo instance.
*
* @param string $name Name of the processor.
* @param Closure $handler Handler for this processor.
* @return ProcessorInfo
*/
public static function post(string $name, Closure $handler): ProcessorInfo {
return new ProcessorInfo($name, ProcessorTarget::Post, $handler);
}
}

View file

@ -0,0 +1,21 @@
<?php
// ProcessorTarget.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\Processors;
/**
* Indicates the kind of processor.
*/
enum ProcessorTarget {
/**
* Processor is a preprocessor.
*/
case Pre;
/**
* Processor is a postprocessor.
*/
case Post;
}

View file

@ -1,62 +0,0 @@
<?php
// ResolvedRouteInfo.php
// Created: 2024-03-28
// Updated: 2025-01-18
namespace Index\Http\Routing;
/**
* Represents a resolved route.
*/
class ResolvedRouteInfo {
/**
* @param array{callable, mixed[]}[] $middlewares Middlewares that should be run prior to the route handler.
* @param string[] $supportedMethods HTTP methods that this route accepts.
* @param (callable(): mixed)|null $handler Route handler.
* @param mixed[] $args Argument list to pass to the middleware and route handlers.
*/
public function __construct(
private array $middlewares,
public private(set) array $supportedMethods,
private mixed $handler,
private array $args,
) {}
/**
* Run middleware handlers.
*
* @param mixed[] $args Additional arguments to pass to the middleware handlers.
* @return mixed Return value from the first middleware to return anything non-null, otherwise null.
*/
public function runMiddleware(array $args): mixed {
foreach($this->middlewares as $middleware) {
$result = $middleware[0](...array_merge($args, $middleware[1]));
if($result !== null)
return $result;
}
return null;
}
/**
* Whether this route has a handler.
*
* @return bool true if it does.
*/
public function hasHandler(): bool {
return is_callable($this->handler);
}
/**
* Dispatches this route.
*
* @param mixed[] $args Additional arguments to pass to the route handler.
* @return mixed Return value of the route handler.
*/
public function dispatch(array $args): mixed {
if(!is_callable($this->handler))
return null;
return ($this->handler)(...array_merge($args, $this->args));
}
}

View file

@ -1,7 +1,7 @@
<?php
// RouteHandlerCommon.php
// Created: 2024-03-28
// Updated: 2025-01-18
// Updated: 2025-03-07
namespace Index\Http\Routing;

View file

@ -1,105 +1,409 @@
<?php
// Router.php
// Created: 2024-03-28
// Updated: 2024-10-03
// Updated: 2025-03-19
namespace Index\Http\Routing;
use InvalidArgumentException;
use JsonSerializable;
use RuntimeException;
use Index\Http\HttpRequest;
use Stringable;
use Index\XArray;
use Index\Bencode\{Bencode,BencodeSerializable};
use Index\Http\{HttpRequest,HttpUri};
use Index\Http\Routing\AccessControl\{AccessControl,AccessControlHandler,SimpleAccessControlHandler};
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;
/**
* Provides an interface for defining HTTP routers.
* Implements a router for HTTP requests.
*
* There are four different kinds of handler you can register.
* - Filter: Always runs on a given path or prefix regardless of a route being matched.
* - Preprocessors: Must be explicitly imported by a route handler. Performs checks and can transform details of the pipeline.
* - Route: An actual proper handler for a request.
* - Postprocessors: Must be explicitly imported by a route handler. Performs transformations on the output.
*
* [HTTP Request] -> [Run Filters] -> [Run Preprocessors] -> [Run route handler] -> [Run Postprocessors] -> [HTTP Response]
*
* Preprocessors has the same abilities as filters do, but as mentioned are included explicitly.
*/
interface Router {
/**
* Creates a scoped version of this router.
*
* @param string $prefix Prefix path to prepend to all registered routes.
* @return Router Scoped router.
*/
public function scopeTo(string $prefix): Router;
class Router implements RequestHandlerInterface {
/** @var FilterInfo[] */
private array $filters = [];
/** @var RouteInfo[] */
private array $routes = [];
/** @var array<string, ProcessorInfo> */
private array $preprocs = [];
/** @var array<string, ProcessorInfo> */
private array $postprocs = [];
/**
* Apply middleware functions to a path.
*
* @param string $path Path to apply the middleware to.
* @param callable $handler Middleware function.
* Access control handler instance.
*/
public function use(string $path, callable $handler): void;
public private(set) AccessControlHandler $accessControlHandler;
/**
* Adds a new route.
*
* @param string $method Request method.
* @param string $path Request path.
* @param callable $handler Request handler.
* @throws InvalidArgumentException if $method is not a valid method name
* Error handler instance.
*/
public function add(string $method, string $path, callable $handler): void;
public private(set) ErrorHandler $errorHandler;
/**
* Resolves a route
*
* @param string $method Request method.
* @param string $path Request path.
* @return ResolvedRouteInfo Response route.
* @param ErrorHandler|'html'|'plain' $errorHandler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation.
* @param ?AccessControlHandler $accessControlHandler Handler for CORS requests.
* @param int $accessControlTtl Default Access-Control-Max-Age value.
* @param bool $registerDefaultProcessors Whether the default processors should be registered.
*/
public function resolve(string $method, string $path): ResolvedRouteInfo;
public function __construct(
ErrorHandler|string $errorHandler = 'html',
?AccessControlHandler $accessControlHandler = null,
public private(set) int $accessControlTtl = 300,
bool $registerDefaultProcessors = true
) {
$this->setErrorHandler($errorHandler);
$this->accessControlHandler = $accessControlHandler ?? new SimpleAccessControlHandler;
if($registerDefaultProcessors)
$this->register(new RouterProcessors);
}
/**
* Adds a new GET route.
* Sets an error handler.
*
* @param string $path Request path.
* @param callable $handler Request handler.
* @param ErrorHandler|'html'|'plain' $handler
*/
public function get(string $path, callable $handler): void;
public function setErrorHandler(ErrorHandler|string $handler): void {
if($handler instanceof ErrorHandler)
$this->errorHandler = $handler;
elseif($handler === 'html')
$this->errorHandler = new HtmlErrorHandler;
elseif($handler === 'plain')
$this->errorHandler = new PlainErrorHandler;
else
throw new InvalidArgumentException('$handler must be an instance of ErrorHandler or "html" or "plain"');
}
/**
* Adds a new POST route.
*
* @param string $path Request path.
* @param callable $handler Request handler.
* Set the error handler to the basic HTML one.
*/
public function post(string $path, callable $handler): void;
public function setHtmlErrorHandler(): void {
$this->setErrorHandler('html');
}
/**
* Adds a new DELETE route.
*
* @param string $path Request path.
* @param callable $handler Request handler.
* Set the error handler to the plain text one.
*/
public function delete(string $path, callable $handler): void;
public function setPlainErrorHandler(): void {
$this->setErrorHandler('plain');
}
/**
* Adds a new PATCH route.
* Registers a filter.
*
* @param string $path Request path.
* @param callable $handler Request handler.
* @param FilterInfo $filter FilterInfo instance to register.
*/
public function patch(string $path, callable $handler): void;
public function filter(FilterInfo $filter): void {
$this->filters[] = $filter;
}
/**
* Adds a new PUT route.
* Registers a route.
*
* @param string $path Request path.
* @param callable $handler Request handler.
* @param RouteInfo $route RouteInfo instance to register.
*/
public function put(string $path, callable $handler): void;
public function route(RouteInfo $route): void {
$this->routes[] = $route;
}
/**
* Adds a new OPTIONS route.
* Registers a processor.
*
* @param string $path Request path.
* @param callable $handler Request handler.
* @param ProcessorInfo $processor ProcessorInfo instance to register.
* @throws InvalidArgumentException if the processor target is not supported.
* @throws RuntimeException if the processor has already been registered.
*/
public function options(string $path, callable $handler): void;
public function processor(ProcessorInfo $processor): void {
if($processor->target === ProcessorTarget::Pre)
$procs = &$this->preprocs;
elseif($processor->target === ProcessorTarget::Post)
$procs = &$this->postprocs;
else
throw new InvalidArgumentException('$processor target is not supported');
if(array_key_exists($processor->name, $procs))
throw new RuntimeException('$processor has already been registered');
$procs[$processor->name] = $processor;
}
/**
* Registers routes in an RouteHandler implementation.
* Registers a pre-processor.
*
* @param RouteHandler $handler Routes handler.
* @param ProcessorInfo $processor ProcessorInfo instance to register.
* @throws InvalidArgumentException if the processor target is not a preprocessor.
* @throws RuntimeException if the processor has already been registered.
*/
public function register(RouteHandler $handler): void;
public function preprocessor(ProcessorInfo $processor): void {
if($processor->target !== ProcessorTarget::Pre)
throw new InvalidArgumentException('$processor is not a preprocessor');
$this->processor($processor);
}
/**
* Registers a post-processor.
*
* @param ProcessorInfo $processor ProcessorInfo instance to register.
* @throws InvalidArgumentException if the processor target is not a postprocessor.
*/
public function postprocessor(ProcessorInfo $processor): void {
if($processor->target !== ProcessorTarget::Post)
throw new InvalidArgumentException('$processor is not a postprocessor');
$this->processor($processor);
}
/**
* Registers a route handler class instance.
*
* Calls $handler->registerRoutes($this), go-to implementation is available in RouteHandlerCommon.
*
* @param RouteHandler $handler Handler to register.
*/
public function register(RouteHandler $handler): void {
$handler->registerRoutes($this);
}
/**
* Default steps that get run at the end of the pipeline, including default return value handling.
*
* @param HandlerContext $context Route pipeline context instance.
* @param mixed $result The last return value.
*/
public function defaultResultHandler(HandlerContext $context, mixed $result): void {
if($result !== null) {
if(is_int($result)) {
if($result < 100 || $result > 599) {
$context->response->statusCode = 500;
$context->response->reasonPhrase = 'Unsupported Status Code';
} else {
$context->response->statusCode = $result;
$context->response->reasonPhrase = '';
}
$this->errorHandler->handle($context);
} elseif($context->response->needsBody) {
// this is a slight step back, but should be remedied by using postprocessors
if($result instanceof JsonSerializable) {
if(!$context->response->hasContentType())
$context->response->setTypeJson();
$result = json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} elseif($result instanceof BencodeSerializable) {
if(!$context->response->hasContentType())
$context->response->setTypeBencode();
$result = Bencode::encode($result);
} elseif($result instanceof Stringable) {
$result = (string)$result;
} elseif(is_object($result) || is_array($result)) {
if(!$context->response->hasContentType())
$context->response->setTypeJson();
$result = json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} elseif(is_scalar($result)) {
$result = (string)$result;
} else {
$result = '';
}
if(!$context->response->hasContentType()) {
if(mb_stripos($result, '<!doctype html') === 0)
$context->response->setTypeHtml();
elseif(mb_stripos($result, '<?xml') === 0)
$context->response->setTypeXml();
else
$context->response->setTypePlain();
}
$context->response->body = Stream::createStream($result);
}
}
if(!$context->response->hasContentLength()) {
$bytes = $context->response->body?->getSize();
if($bytes !== null)
$context->response->setContentLength($bytes);
}
if($context->request->method === 'HEAD')
$context->response->body = null;
}
public function handle(ServerRequestInterface $request): ResponseInterface {
$context = new HandlerContext($request);
foreach($this->filters as $filterInfo) {
$match = $filterInfo->matcher->match($context->request->uri);
if($match !== false) {
$result = $context->call($filterInfo->handler, is_array($match) ? $match : null);
if($result !== null || $context->stopped) {
$this->defaultResultHandler($context, $result);
return $context->response->toResponse();
}
}
}
/** @var array<string, object{info: RouteInfo, matches: ?array<int|string, mixed>}> */
$methods = [];
foreach($this->routes as $routeInfo) {
$match = $routeInfo->matcher->match($context->request->uri);
if($match !== false) {
$routeInfo->readAccessControl();
$methods[$routeInfo->method] = (object)[
'info' => $routeInfo,
'matches' => is_array($match) ? $match : null,
];
}
}
if(empty($methods)) {
$context->response->statusCode = 404;
$context->response->reasonPhrase = '';
$this->errorHandler->handle($context);
$this->defaultResultHandler($context, null);
return $context->response->toResponse();
}
if(!array_key_exists($context->request->method, $methods)) {
if($context->request->method === 'HEAD' && array_key_exists('GET', $methods)) {
$methods['HEAD'] = $methods['GET'];
} else {
$allow = array_keys($methods);
$allow[] = 'OPTIONS';
if(in_array('GET', $allow))
$allow[] = 'HEAD';
$context->response->setAllow($allow);
$context->response->reasonPhrase = '';
if($context->request->method === 'OPTIONS') {
$context->response->statusCode = 200;
$context->response->setHeader('Content-Length', '0');
if($context->request->hasHeader('Origin')) {
$requestMethod = strtoupper($context->request->getHeaderLine('Access-Control-Request-Method'));
$routeInfos = array_map(fn($item) => $item->info, $methods);
$accessControl = AccessControl::aggregate($routeInfos, $requestMethod);
if($accessControl->allow) {
if($context->request->hasHeader('Access-Control-Request-Headers')) {
$requestHeaders = trim($context->request->getHeaderLine('Access-Control-Request-Headers'));
$requestHeaders = $requestHeaders === '' ? [] : XArray::select(
explode(',', $requestHeaders),
fn($part) => trim($part)
);
} else
$requestHeaders = null;
if($requestHeaders === null || $accessControl->verifyHeadersAllowed($requestHeaders))
$this->accessControlHandler->handlePreflight(
$context,
$accessControl,
$routeInfos,
HttpUri::createUri($context->request->getHeaderLine('Origin')),
$requestMethod,
$requestHeaders,
)->applyPreflight($context->response, $this->accessControlTtl);
}
}
} else {
$context->response->statusCode = 405;
$this->errorHandler->handle($context);
}
$this->defaultResultHandler($context, null);
return $context->response->toResponse();
}
}
$route = $methods[$context->request->method];
// if you're overriding OPTIONS you're on your own
if($context->request->method !== 'OPTIONS'
&& $context->request->hasHeader('Origin')
&& $route->info->accessControl?->allow === true)
$this->accessControlHandler->handleRequest(
$context,
$route->info,
$route->info->accessControl,
HttpUri::createUri($context->request->getHeaderLine('Origin')),
)->applyResult($context->response);
$route->info->readProcessors();
foreach($route->info->preprocs as $name => $args) {
if(!array_key_exists($name, $this->preprocs))
throw new RuntimeException(sprintf('preprocessor "%s" was not found', $name));
$result = $context->call($this->preprocs[$name]->handler, $args);
if($result !== null || $context->stopped) {
$this->defaultResultHandler($context, $result);
return $context->response->toResponse();
}
}
$context->result = $context->call($route->info->handler, $route->matches);
if(!$context->stopped)
foreach($route->info->postprocs as $name => $args) {
if(!array_key_exists($name, $this->postprocs))
throw new RuntimeException(sprintf('postprocessor "%s" was not found', $name));
$result = $context->call($this->postprocs[$name]->handler, $args);
if($result !== null || $context->stopped) // @phpstan-ignore booleanOr.rightAlwaysFalse
break;
}
$this->defaultResultHandler($context, $context->result);
return $context->response->toResponse();
}
/**
* Dispatches a route based on a given HTTP request message with additional prefix arguments and output to stdout.
*
* @param ?HttpRequest $request HTTP request message to handle, null to use the current request.
*/
public function dispatch(?HttpRequest $request = null): void {
self::output($this->handle($request ?? HttpRequest::fromRequest()));
}
/**
* Outputs a HTTP response message to stdout.
*
* @param ResponseInterface $response HTTP response message to output.
*/
public static function output(ResponseInterface $response): void {
header(sprintf(
'HTTP/%s %03d %s',
$response->getProtocolVersion(),
$response->getStatusCode(),
$response->getReasonPhrase(),
));
foreach($response->getHeaders() as $name => $lines)
foreach($lines as $line)
header(sprintf('%s: %s', $name, $line));
$stream = $response->getBody();
if($stream->isReadable())
echo (string)$stream;
}
}

View file

@ -1,39 +0,0 @@
<?php
// RouterCommon.php
// Created: 2024-03-28
// Updated: 2025-01-18
namespace Index\Http\Routing;
/**
* Contains implementations for HTTP request method handler registration.
*/
trait RouterCommon {
public function get(string $path, callable $handler): void {
$this->add('GET', $path, $handler);
}
public function post(string $path, callable $handler): void {
$this->add('POST', $path, $handler);
}
public function delete(string $path, callable $handler): void {
$this->add('DELETE', $path, $handler);
}
public function patch(string $path, callable $handler): void {
$this->add('PATCH', $path, $handler);
}
public function put(string $path, callable $handler): void {
$this->add('PUT', $path, $handler);
}
public function options(string $path, callable $handler): void {
$this->add('OPTIONS', $path, $handler);
}
public function register(RouteHandler $handler): void {
$handler->registerRoutes($this);
}
}

View file

@ -0,0 +1,111 @@
<?php
// RouterProcessors.php
// Created: 2025-03-15
// Updated: 2025-03-15
namespace Index\Http\Routing;
use RuntimeException;
use Index\MediaType;
use Index\Bencode\Bencode;
use Index\Http\Content\{MultipartFormContent,UrlEncodedFormContent};
use Index\Http\Routing\Processors\{Postprocessor,Preprocessor};
use Psr\Http\Message\RequestInterface;
/**
* Implements some standard route handlers, like parsing form data and outputting json.
*/
class RouterProcessors implements RouteHandler {
use RouteHandlerCommon;
private function handlePreInputUrlencoded(HandlerContext $context, RequestInterface $request): bool {
$contentType = MediaType::parse($request->getHeaderLine('Content-Type'));
if(!$contentType->equals('application/x-www-form-urlencoded'))
return false;
$context->deps->register(UrlEncodedFormContent::parseStream($request->getBody()));
return true;
}
#[Preprocessor('input:urlencoded')]
public function preInputUrlencoded(HandlerContext $context, RequestInterface $request, bool $required = true): void {
if(!$this->handlePreInputUrlencoded($context, $request) && $required) {
$context->response->statusCode = 400;
$context->response->reasonPhrase = '';
$context->halt();
}
}
private function handlePreInputMultipart(HandlerContext $context, RequestInterface $request): bool {
$contentType = MediaType::parse($request->getHeaderLine('Content-Type'));
if(!$contentType->equals('multipart/form-data'))
return false;
$boundary = $contentType->boundary;
if(empty($boundary))
return false;
$context->deps->register(MultipartFormContent::parseStream($request->getBody(), $boundary));
return true;
}
#[Preprocessor('input:multipart')]
public function preInputMultipart(HandlerContext $context, RequestInterface $request, bool $required = true): void {
try {
$result = $this->handlePreInputMultipart($context, $request);
} catch(RuntimeException $ex) {
$result = false;
$required = true;
}
if(!$result && $required) {
$context->response->statusCode = 400;
$context->response->reasonPhrase = '';
$context->halt();
}
}
#[Postprocessor('output:stream')]
public function postOutputStream(HandlerContext $context): void {
$context->response->setTypeStream();
}
#[Postprocessor('output:plain')]
public function postOutputPlain(HandlerContext $context, string $charset = ''): void {
$context->response->setTypePlain($charset);
}
#[Postprocessor('output:html')]
public function postOutputHtml(HandlerContext $context, string $charset = ''): void {
$context->response->setTypeHtml($charset);
}
#[Postprocessor('output:xml')]
public function postOutputXml(HandlerContext $context, string $charset = ''): void {
$context->response->setTypeXml($charset);
}
#[Postprocessor('output:css')]
public function postOutputCss(HandlerContext $context, string $charset = ''): void {
$context->response->setTypeCss($charset);
}
#[Postprocessor('output:js')]
public function postOutputJs(HandlerContext $context, string $charset = ''): void {
$context->response->setTypeJs($charset);
}
#[Postprocessor('output:json')]
public function postOutputJson(HandlerContext $context, string $charset = '', int $flags = JSON_UNESCAPED_SLASHES): void {
$context->response->setTypeJson($charset);
$context->result = json_encode($context->result, JSON_THROW_ON_ERROR | $flags);
}
#[Postprocessor('output:bencode')]
public function postOutputBencode(HandlerContext $context): void {
$context->response->setTypeBencode();
$context->result = Bencode::encode($context->result);
}
}

View file

@ -0,0 +1,28 @@
<?php
// ExactRoute.php
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing\Routes;
use Attribute;
use Closure;
/**
* Marks a method as a prefix filter.
*/
#[Attribute(RouteAttribute::FLAGS)]
class ExactRoute extends RouteAttribute {
/**
* @param string $method HTTP Method.
* @param string $path URI path.
*/
public function __construct(
private string $method,
private string $path,
) {}
public function createInstance(Closure $handler): RouteInfo {
return RouteInfo::exact($this->method, $this->path, $handler);
}
}

View file

@ -0,0 +1,34 @@
<?php
// PatternRoute.php
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing\Routes;
use Attribute;
use Closure;
/**
* Marks a method as a pattern route.
*/
#[Attribute(RouteAttribute::FLAGS)]
class PatternRoute extends RouteAttribute {
private string $pattern;
/**
* @param string $method HTTP Method.
* @param string $pattern URI path pattern.
* @param bool $raw If true, $pattern is taken as is, if false, $pattern is prefixed with #^ and suffixed with $#uD
*/
public function __construct(
private string $method,
string $pattern,
bool $raw = false
) {
$this->pattern = $raw ? $pattern : sprintf('#^%s$#uD', $pattern);
}
public function createInstance(Closure $handler): RouteInfo {
return RouteInfo::pattern($this->method, $this->pattern, $handler);
}
}

View file

@ -0,0 +1,28 @@
<?php
// RouteAttribute.php
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing\Routes;
use Attribute;
use Closure;
use Index\Http\Routing\HandlerAttribute;
/**
* Provides an attribute for marking methods in a class as a route.
*/
abstract class RouteAttribute extends HandlerAttribute {
/**
* Flags to set for the Attribute attribute.
*/
public const int FLAGS = Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE;
/**
* Creates a RouteInfo instance.
*
* @param Closure $handler Handler method.
* @return RouteInfo
*/
abstract public function createInstance(Closure $handler): RouteInfo;
}

View file

@ -0,0 +1,141 @@
<?php
// RouteInfo.php
// Created: 2025-03-02
// Updated: 2025-03-19
namespace Index\Http\Routing\Routes;
use Closure;
use InvalidArgumentException;
use Index\Http\Routing\AccessControl\AccessControl;
use Index\Http\Routing\Processors\ProcessAttribute;
use Index\Http\Routing\UriMatchers\{ExactPathUriMatcher,PatternPathUriMatcher,UriMatcher};
/**
* Information of a route.
*/
class RouteInfo {
/**
* HTTP method this route serves.
*/
public private(set) string $method;
/**
* Names of preprocessors to run before calling the method handler. Executed in order of registration.
*
* @var array<string, array<int|string, mixed>>
*/
public private(set) array $preprocs = [];
/**
* Names of postprocessors to run after calling the method handler. Executed in order of registration.
*
* @var array<string, array<int|string, mixed>>
*/
public private(set) array $postprocs = [];
/**
* CORS info for this route.
*/
public ?AccessControl $accessControl = null;
private bool $aclRead = false;
private bool $processorsRead = false;
/**
* @param string $method HTTP method this route serves.
* @param UriMatcher $matcher URI matcher to use.
* @param Closure $handler Handler for this route.
* @throws InvalidArgumentException If $method is empty or starts/ends with whitespace.
*/
public function __construct(
string $method,
public private(set) UriMatcher $matcher,
public private(set) Closure $handler,
) {
if($method === '')
throw new InvalidArgumentException('$method may not be empty');
if(trim($method) !== $method)
throw new InvalidArgumentException('$method may start or end with whitespace');
$this->method = strtoupper($method);
}
/**
* Adds a preprocessor to the pipeline after execution of the handler. Executed in order of registration.
*
* @param string $name Name of the preprocessor, implementation registered in the router.
* @param array<int|string, mixed> $args Additional arguments to call the preprocessor with.
* @throws InvalidArgumentException If $name has already been registered.
*/
public function before(string $name, array $args = []): void {
if(array_key_exists($name, $this->preprocs))
throw new InvalidArgumentException('pre-processor in $name has been registered for this route');
$this->preprocs[$name] = $args;
}
/**
* Adds a postprocessor to the pipeline after execution of the handler. Executed in order of registration.
*
* @param string $name Name of the postprocessor, implementation registered in the router.
* @param array<int|string, mixed> $args Additional arguments to call the postprocessor with.
* @throws InvalidArgumentException If $name has already been registered.
*/
public function after(string $name, array $args = []): void {
if(array_key_exists($name, $this->postprocs))
throw new InvalidArgumentException('post-processor in $name has been registered for this route');
$this->postprocs[$name] = $args;
}
/**
* Reads processor attributes and merges them with the already registered instances.
*/
public function readProcessors(): void {
if($this->processorsRead)
return;
$this->processorsRead = true;
$info = ProcessAttribute::read($this->handler);
foreach($info->after as $name => $args)
$this->after($name, $args);
foreach($info->before as $name => $args)
$this->before($name, $args);
}
/**
* Reads access control attributes and merges them with the existing declarations.
*/
public function readAccessControl(): void {
if($this->aclRead)
return;
$this->aclRead = true;
$this->accessControl = AccessControl::read($this->handler, $this->accessControl);
}
/**
* Creates RouteInfo instance using ExactPathUriMatcher.
*
* @param string $method HTTP method this route serves.
* @param string $path Path to match against.
* @param Closure $handler Handler for this route.
* @return RouteInfo
*/
public static function exact(string $method, string $path, Closure $handler): RouteInfo {
return new RouteInfo($method, new ExactPathUriMatcher($path), $handler);
}
/**
* Creates RouteInfo instance using PatternPathUriMatcher.
*
* @param string $method HTTP method this route serves.
* @param string $pattern Path regex pattern to match against.
* @param Closure $handler Handler for this route.
* @return RouteInfo
*/
public static function pattern(string $method, string $pattern, Closure $handler): RouteInfo {
return new RouteInfo($method, new PatternPathUriMatcher($pattern), $handler);
}
}

View file

@ -1,43 +0,0 @@
<?php
// ScopedRouter.php
// Created: 2024-03-28
// Updated: 2025-01-18
namespace Index\Http\Routing;
/**
* Provides a scoped router interface, automatically adds a prefix to any routes added.
*/
class ScopedRouter implements Router {
use RouterCommon;
/**
* @param Router $router Underlying router.
* @param string $prefix Base path to use as a prefix.
*/
public function __construct(
private Router $router,
private string $prefix
) {
if($router instanceof ScopedRouter)
$this->router = $router->router;
// TODO: cleanup prefix
}
public function scopeTo(string $prefix): Router {
return $this->router->scopeTo($this->prefix . $prefix);
}
public function add(string $method, string $path, callable $handler): void {
$this->router->add($method, $this->prefix . $path, $handler);
}
public function use(string $path, callable $handler): void {
$this->router->use($this->prefix . $path, $handler);
}
public function resolve(string $method, string $path): ResolvedRouteInfo {
return $this->router->resolve($method, $this->prefix . $path);
}
}

View file

@ -0,0 +1,24 @@
<?php
// ExactPathUriMatcher.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\UriMatchers;
use Psr\Http\Message\UriInterface;
/**
* Provides a matcher for a static path.
*/
class ExactPathUriMatcher implements UriMatcher {
/**
* @param string $path Path to match against.
*/
public function __construct(
private string $path
) {}
public function match(UriInterface $uri): array|bool {
return $uri->getPath() === $this->path;
}
}

View file

@ -0,0 +1,41 @@
<?php
// PatternPathUriMatcher.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\UriMatchers;
use Psr\Http\Message\UriInterface;
/**
* Provides a matcher for a regular expression path.
*/
class PatternPathUriMatcher implements UriMatcher {
/**
* @param string $pattern Path pattern to match against.
*/
public function __construct(
private string $pattern
) {}
public function match(UriInterface $uri): array|bool {
$result = preg_match($this->pattern, $uri->getPath(), $matches, PREG_UNMATCHED_AS_NULL);
if($result !== 1)
return false;
$numbered = [];
$named = [];
$keys = array_keys($matches);
// discard first entry
array_shift($keys);
foreach($keys as $key)
if(is_int($key))
$numbered[] = $matches[$key];
else
$named[$key] = $matches[$key];
return array_merge($numbered, $named);
}
}

View file

@ -0,0 +1,29 @@
<?php
// PrefixPathUriMatcher.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\UriMatchers;
use Psr\Http\Message\UriInterface;
/**
* Provides a matcher for a prefix path.
*/
class PrefixPathUriMatcher implements UriMatcher {
private string $prefix;
private string $exact;
/**
* @param string $prefix Prefix to match against.
*/
public function __construct(string $prefix) {
$this->exact = rtrim($prefix, '/');
$this->prefix = $this->exact . '/';
}
public function match(UriInterface $uri): array|bool {
$path = $uri->getPath();
return $this->exact === $path || str_starts_with($path, $this->prefix);
}
}

View file

@ -0,0 +1,21 @@
<?php
// UriMatcher.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing\UriMatchers;
use Psr\Http\Message\UriInterface;
/**
* Provides a common interface for URI matching.
*/
interface UriMatcher {
/**
* Match a URI.
*
* @param UriInterface $uri URI to match.
* @return array<int|string, mixed>|bool false if not a math, true or array with matched parameters if match.
*/
public function match(UriInterface $uri): array|bool;
}

View file

@ -0,0 +1,85 @@
<?php
// NullStream.php
// Created: 2025-02-28
// Updated: 2025-03-12
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;
public static function init(): void {
self::$instance = new static;
}
public static function instance(): NullStream {
return self::$instance;
}
public function getMetadata(?string $key = null) {
return $key === null ? [] : null;
}
public function getSize(): ?int {
return null;
}
public function getContents(): string {
throw new RuntimeException('getContents operation not supported');
}
public function isReadable(): bool {
return false;
}
public function read(int $length): string {
throw new RuntimeException('read operation not supported');
}
public function isWritable(): bool {
return false;
}
public function write(string $string): int {
throw new RuntimeException('write operation not supported');
}
public function isSeekable(): bool {
return false;
}
public function seek(int $offset, int $whence = SEEK_SET): void {
throw new RuntimeException('seek operation not supported');
}
public function tell(): int {
throw new RuntimeException('tell operation not supported');
}
public function rewind(): void {
throw new RuntimeException('rewind operation not supported');
}
public function eof(): bool {
return true;
}
public function detach() {
return null;
}
public function close(): void {}
public function __toString(): string {
return '';
}
}
NullStream::init();

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

229
src/Http/Streams/Stream.php Normal file
View file

@ -0,0 +1,229 @@
<?php
// Stream.php
// Created: 2025-02-28
// Updated: 2025-03-12
namespace Index\Http\Streams;
use InvalidArgumentException;
use RuntimeException;
use Stringable;
use Psr\Http\Message\StreamInterface;
/**
* Provides a wrapper for PHP's streams using the PSR-7 interface.
*/
class Stream implements StreamInterface, Stringable {
private bool $detached = false;
/**
* Readable modes.
*
* @var string[]
*/
public const array READABLE = [
'r', 'rb', 'r+', 'r+b', 'w+', 'w+b',
'a+', 'a+b', 'x+', 'x+b', 'c+', 'c+b',
];
/**
* Writable modes.
*
* @var string[]
*/
public const array WRITABLE = [
'r+', 'r+b', 'w', 'wb', 'w+', 'w+b',
'a', 'ab', 'a+', 'a+b', 'x', 'xb',
'x+', 'x+b', 'c', 'cb', 'c+', 'c+b',
];
/**
* @param resource $handle Stream resource.
*/
final public function __construct(
private $handle,
) {
if(!is_resource($handle) || get_resource_type($handle) !== 'stream')
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);
}
public function getSize(): ?int {
$stat = fstat($this->handle);
if($stat === false)
return null;
return array_key_exists('size', $stat) && is_int($stat['size'])
? $stat['size'] : null;
}
public function getContents(): string {
$contents = stream_get_contents($this->handle);
if($contents === false)
throw new RuntimeException('unable to read stream');
return $contents;
}
public function isReadable(): bool {
return in_array($this->getMetadata('mode'), self::READABLE);
}
public function read(int $length): string {
if($length < 1)
throw new RuntimeException('$length must be greater than 0');
$read = fread($this->handle, $length);
if($read === false)
throw new RuntimeException('read operation failed');
return $read;
}
public function isWritable(): bool {
return in_array($this->getMetadata('mode'), self::WRITABLE);
}
public function write(string $string): int {
$write = fwrite($this->handle, $string);
if($write === false)
throw new RuntimeException('write operation failed');
return $write;
}
public function isSeekable(): bool {
return $this->getMetadata('seekable') === true;
}
public function seek(int $offset, int $whence = SEEK_SET): void {
if(fseek($this->handle, $offset, $whence) !== 0)
throw new RuntimeException('seek operation failed');
}
public function tell(): int {
$tell = ftell($this->handle);
if($tell === false)
throw new RuntimeException('tell operation failed');
return $tell;
}
public function rewind(): void {
$this->seek(0);
}
public function eof(): bool {
return feof($this->handle);
}
public function detach() {
if($this->detached)
return null;
$this->detached = true;
return $this->handle;
}
public function close(): void {
if($this->detached)
return;
$this->detached = true;
fclose($this->handle);
}
public function __destruct() {
$this->close();
}
public function __toString(): string {
$string = stream_get_contents($this->handle);
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 Stream
*/
public static function createStream(string $content = ''): Stream {
$stream = self::createStreamFromFile('php://temp', 'rb+');
$stream->write($content);
$stream->rewind();
return $stream;
}
/**
* Create a stream from an existing file.
*
* The file MUST be opened using the given mode, which may be any mode
* supported by the `fopen` function.
*
* The `$filename` MAY be any string supported by `fopen()`.
*
* @param string $filename Filename or stream URI to use as basis of stream.
* @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 Stream
*/
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');
return new static($handle);
}
/**
* Create a new stream from an existing resource.
*
* The stream MUST be readable and may be writable.
*
* @param resource $resource PHP resource to use as basis of stream.
* @return Stream
*/
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,57 +0,0 @@
<?php
// StringHttpContent.php
// Created: 2022-02-10
// Updated: 2025-01-18
namespace Index\Http;
use Stringable;
/**
* Represents string body content for a HTTP message.
*/
class StringHttpContent implements HttpContent {
/**
* @param string $string String that represents this message body.
*/
public function __construct(
public private(set) string $string
) {}
public function __toString(): string {
return $this->string;
}
/**
* Creates an instance an existing object.
*
* @param Stringable|string $string Object to cast to a string.
* @return StringHttpContent Instance representing the provided object.
*/
public static function fromObject(Stringable|string $string): StringHttpContent {
return new StringHttpContent((string)$string);
}
/**
* Creates an instance from a file.
*
* @param string $path Path to the file.
* @return StringHttpContent Instance representing the provided path.
*/
public static function fromFile(string $path): StringHttpContent {
$string = file_get_contents($path);
if($string === false)
$string = '';
return new StringHttpContent($string);
}
/**
* Creates an instance from the raw request body.
*
* @return StringHttpContent Instance representing the request body.
*/
public static function fromRequest(): StringHttpContent {
return self::fromFile('php://input');
}
}

View file

@ -1,76 +0,0 @@
<?php
// JsonHttpContent.php
// Created: 2022-02-10
// Updated: 2025-01-18
namespace Index\Json;
use JsonSerializable;
use RuntimeException;
use Index\Http\HttpContent;
/**
* Represents JSON body content for a HTTP message.
*/
class JsonHttpContent implements HttpContent, JsonSerializable {
/**
* @param mixed $content Content to be JSON encoded.
*/
public function __construct(
public private(set) mixed $content
) {}
public function jsonSerialize(): mixed {
return $this->content;
}
/**
* Encodes the content.
*
* @return string JSON encoded string.
*/
public function encode(): string {
$encoded = json_encode($this->content);
if($encoded === false)
return '';
return $encoded;
}
public function __toString(): string {
return $this->encode();
}
/**
* Creates an instance from encoded content.
*
* @param string $encoded JSON encoded content.
* @return JsonHttpContent Instance representing the provided content.
*/
public static function fromEncoded(string $encoded): JsonHttpContent {
return new JsonHttpContent(json_decode($encoded));
}
/**
* Creates an instance from an encoded file.
*
* @param string $path Path to the JSON encoded file.
* @return JsonHttpContent Instance representing the provided path.
*/
public static function fromFile(string $path): JsonHttpContent {
$contents = file_get_contents($path);
if($contents === false)
throw new RuntimeException('was unable to read file at $path');
return self::fromEncoded($contents);
}
/**
* Creates an instance from the raw request body.
*
* @return JsonHttpContent Instance representing the request body.
*/
public static function fromRequest(): JsonHttpContent {
return self::fromFile('php://input');
}
}

View file

@ -1,26 +0,0 @@
<?php
// JsonHttpContentHandler.php
// Created: 2024-03-28
// Updated: 2025-01-18
namespace Index\Json;
use stdClass;
use JsonSerializable;
use Index\Http\{HttpContentHandler,HttpResponseBuilder};
/**
* Represents a JSON content handler for building HTTP response messages.
*/
class JsonHttpContentHandler implements HttpContentHandler {
public function match(mixed $content): bool {
return is_array($content) || $content instanceof JsonSerializable || $content instanceof stdClass;
}
public function handle(HttpResponseBuilder $response, mixed $content): void {
if(!$response->hasContentType())
$response->setTypeJson();
$response->content = new JsonHttpContent($content);
}
}

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

View file

@ -1,12 +1,13 @@
<?php
// ArrayUrlRegistry.php
// Created: 2024-10-03
// Updated: 2024-10-04
// Updated: 2025-03-08
namespace Index\Urls;
use InvalidArgumentException;
use Stringable;
use Index\Http\HttpUri;
/**
* Provides an array backed URL registry implementation.
@ -37,7 +38,7 @@ class ArrayUrlRegistry implements UrlRegistry {
];
}
public function format(string $name, array $vars = [], bool $spacesAsPlus = false): string {
public function format(string $name, array $vars = [], ?bool $spacesAsPlus = null): string {
if(!array_key_exists($name, $this->urls))
return '';
@ -53,7 +54,12 @@ class ArrayUrlRegistry implements UrlRegistry {
}
if(!empty($query))
$string .= '?' . http_build_query($query, '', '&', $spacesAsPlus ? PHP_QUERY_RFC1738 : PHP_QUERY_RFC3986);
$string .= sprintf(
'?%s',
$spacesAsPlus === null
? HttpUri::buildQueryString($query)
: http_build_query($query, '', '&', $spacesAsPlus ? PHP_QUERY_RFC1738 : PHP_QUERY_RFC3986)
);
}
if(!empty($format['fragment'])) {

View file

@ -1,7 +1,7 @@
<?php
// ScopedUrlRegistry.php
// Created: 2024-10-03
// Updated: 2024-10-04
// Updated: 2025-03-08
namespace Index\Urls;
@ -47,7 +47,7 @@ class ScopedUrlRegistry implements UrlRegistry {
);
}
public function format(string $name, array $vars = [], bool $spacesAsPlus = false): string {
public function format(string $name, array $vars = [], ?bool $spacesAsPlus = null): string {
return $this->registry->format(
$this->namePrefix . $name,
$vars,

View file

@ -1,7 +1,7 @@
<?php
// UrlRegistry.php
// Created: 2024-10-03
// Updated: 2025-01-18
// Updated: 2025-03-08
namespace Index\Urls;
@ -30,10 +30,10 @@ interface UrlRegistry {
*
* @param string $name Name of the URL to format.
* @param array<string, mixed> $vars Values to replace the variables in the path, query and fragment with.
* @param bool $spacesAsPlus Whether to represent spaces as a + instead of %20.
* @param ?bool $spacesAsPlus null to use the Index url encoder, other wise whether to represent spaces as a + instead of %20 with the legacy encoder.
* @return string Formatted URL.
*/
public function format(string $name, array $vars = [], bool $spacesAsPlus = false): string;
public function format(string $name, array $vars = [], ?bool $spacesAsPlus = null): string;
/**
* Creates a scoped URL registry.

View file

@ -1,7 +1,7 @@
<?php
// DependenciesTest.php
// Created: 2025-01-21
// Updated: 2025-01-22
// Updated: 2025-03-07
declare(strict_types=1);
@ -9,14 +9,43 @@ namespace {
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\{CoversClass,UsesClass};
use Index\Dependencies;
use Index\Db\DbBackend;
use Index\Db\MariaDb\MariaDbBackend;
use Index\Db\Sqlite\SqliteBackend;
#[CoversClass(Dependencies::class)]
#[UsesClass(MariaDbBackend::class)]
#[UsesClass(SqliteBackend::class)]
final class DependenciesTest extends TestCase {
public function testDependencyCall(): void {
$deps = new Dependencies;
$deps->register(\Index\Db\DbResultIterator::class, construct: fn($result) => null);
$deps->register(\Index\Db\NullDb\NullDbResult::class);
$func = function(
\Index\Db\DbResult $result,
string $soup,
?\Index\Db\DbConnection $conn,
string $named = '',
?\Index\Db\NullDb\NullDbTransaction $transaction = null
) {
return $result instanceof \Index\Db\NullDb\NullDbResult
&& $soup === 'beans'
&& $conn === null
&& $named === 'bowl'
&& $transaction === null;
};
$this->assertTrue($deps->call($func, 'beans', named: 'bowl'));
$outOfOrder = function(
string $soup,
\Index\Db\DbResult $result,
string $named = '',
?\Index\Db\DbResultIterator $iterator = null
) {
return $result instanceof \Index\Db\NullDb\NullDbResult
&& $soup === 'beans'
&& $named === 'bowl'
&& $iterator instanceof \Index\Db\DbResultIterator;
};
$this->assertTrue($deps->call($outOfOrder, 'beans', named: 'bowl'));
}
public function testDependencyResolver(): void {
$deps = new Dependencies;
$deps->register(\Index\Db\DbResultIterator::class, construct: fn($result) => null);

Binary file not shown.

View file

@ -0,0 +1,176 @@
<?php
// HttpFormContentTest.php
// Created: 2025-03-12
// Updated: 2025-03-15
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->getFilteredParamAt('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]);
}
$extracted = iterator_to_array($form);
$this->assertEquals($expected, $extracted);
}
public function testMultipartFormEmpty(): void {
$form = MultipartFormContent::parseStream(
Stream::createStream("------geckoformboundaryf31072d915603e48fe9e0e5393a89f20--\r\n"),
'----geckoformboundaryf31072d915603e48fe9e0e5393a89f20'
);
$this->assertEquals(0, count($form->params));
}
public function testMultipartForm(): void {
$form = MultipartFormContent::parseStream(
Stream::createStreamFromFile(__DIR__ . '/HttpFormContentTest-multipart.bin', 'rb'),
'----geckoformboundaryc23c64d876d81ed7258ada21a9eb0b06'
);
$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->assertNull($form->getParamData('this_is_not_there_either'));
$this->assertEquals(0401, $form->getFilteredParamAt('abusing_octal_notation_for_teto_reference', 3510, default: 0401));
$this->assertEquals(null, $form->getParamDataAt('abusing_octal_notation_for_teto_reference', 3510));
$this->assertTrue($form->hasParam('meow'));
$this->assertEquals(1, $form->getParamCount('meow'));
$this->assertEquals('sfdsfs', $form->getParam('meow'));
$meow = $form->getParamData('meow');
$this->assertInstanceOf(ValueMultipartFormData::class, $meow);
$this->assertEquals('meow', $meow->name);
$this->assertEquals('sfdsfs', (string)$meow->stream);
$this->assertEquals('form-data; name="meow"', $meow->getHeaderLine('Content-Disposition'));
$this->assertEquals('sfdsfs', $meow->value);
$this->assertTrue($form->hasParam('mewow'));
$this->assertEquals(1, $form->getParamCount('mewow'));
$this->assertEquals('https://railgun.sh/sockchat', $form->getParam('mewow'));
$mewow = $form->getParamData('mewow');
$this->assertInstanceOf(ValueMultipartFormData::class, $mewow);
$this->assertEquals('mewow', $mewow->name);
$this->assertEquals('https://railgun.sh/sockchat', (string)$mewow->stream);
$this->assertEquals('form-data; name="mewow"', $mewow->getHeaderLine('Content-Disposition'));
$this->assertEquals('https://railgun.sh/sockchat', $mewow->value);
$this->assertTrue($form->hasParam('"the'));
$this->assertEquals(2, $form->getParamCount('"the'));
$this->assertEquals('value!', $form->getParam('"the'));
$the2Value = $form->getParamAt('"the', 1);
$this->assertIsString($the2Value);
$the2Hash = hash('sha256', $the2Value);
$this->assertEquals('8e3b3df1ab6be7498566e795cc9fe3b8f55e084d0e1fcf3e5323269cfc1bc884', $the2Hash);
$the1 = $form->getParamData('"the');
$this->assertInstanceOf(ValueMultipartFormData::class, $the1);
$this->assertEquals('"the', $the1->name);
$this->assertEquals('value!', (string)$the1->stream);
$this->assertEquals('form-data; name="%22the"', $the1->getHeaderLine('Content-Disposition'));
$this->assertEquals('value!', $the1->value);
$the2 = $form->getParamDataAt('"the', 1);
$this->assertInstanceOf(FileMultipartFormData::class, $the2);
$this->assertEquals('"the', $the2->name);
$this->assertEquals('kagaglue.png', $the2->fileName);
$this->assertEquals('kagaglue.png', $the2->getClientFilename());
$this->assertEquals(766, $the2->getSize());
$this->assertEquals('form-data; name="%22the"; filename="kagaglue.png"', $the2->getHeaderLine('Content-Disposition'));
$this->assertEquals('image/png', $the2->getHeaderLine('Content-Type'));
$this->assertEquals('image/png', $the2->getClientMediaType());
$the2Path = tempnam(sys_get_temp_dir(), 'ndx-test-');
$this->assertIsString($the2Path);
$the2->moveTo($the2Path);
$this->assertEquals($the2Hash, hash_file('sha256', $the2Path));
$expected = [
'meow' => ['sfdsfs'],
'mewow' => ['https://railgun.sh/sockchat'],
'"the' => [
'value!',
base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAABkAAAAdCAYAAABfeMd1AAAABHNCSVQICAgIfAhkiAAAArVJREFUSEu1'
. 'Vj9o00EUfr8gdNFBKtVEsUvjUgfpFBQKKrR1tRlUEEwFB8U4OHXroHSqaAanSgRRI0ZRpCUGFAwKpUJF'
. 'cEqqECiJLRgHC6XT2e/0Xe/3ctekBg9+9N37833vvb67C1GLFd9JqoVLZ2YQrNQXFf7+NzIAT6bTaqFU'
. '6ogs8NVqZz42lqaL42njeize5wyrrJITz6kEwYfKohOIlUuVmhZf5/M0pfJa3p39o5NkTSQgeDJbMgQH'
. '4jEjS8FFtD6kaN/ZOtlEIRIQoDVy2a2StnaInJW0apWPaOjpGW2S1UTsgHb+F5IAe27p9SCpzV3FgL4/'
. 'jhIPjyHxEaAd3BIXga0bTiaJiWx9qBLbwOCYHs703mTGuEC2yV0DwtVEUIF9JlyZuypB1kjAZev7eFC3'
. 'jJeuZO7BafpcukF2ptADhBeDcRLInImMkyWAaHn/FY0dwTwnzj+nteoCvZyfN8AguHR8icYzGRoYHNSf'
. 'JIYO9p5o8w3QaDQ0JrBNTWhZ9+EEwZgaGaFsoUDlcpmUUnRib1YTzBRqdOf+ism3mDui5bfLKQqCgDbu'
. 'OR3Hqz71Sx/MHazQJ/TLnApih7Rjbnqaen/cNgEsXLvQY3TF3KZZEjSGV40xNF0gUrUyvX832wTeSoHW'
. 'Ig7JoRP2MpX4QNY+3dxok9uK9mE1Xoy6Hf5qtyT59nWG+nsHDMCb0bshsJPPLnvBcbXwajqMaBneCx5f'
. 'TAg+37Jt1Yf9oavfS2KDnZtYJ1SDD2BHbyWMGbJNAJ9T1W5tt6vAvukWZhSMNKri2+DRRJfNH5J94HDC'
. 'CHtJJKJ99Ujbz9TmwyarCJ0TGSj38km17fFsTdlEMrbtSmSg3KNSJuJq5DMsY/5pD6I9V2Nq16to6Gbf'
. '8pxsl0kPClpH/h8f28X0+ss3yuvYiUFO4m81NF/DgbLLlAAAAABJRU5ErkJggg=='
),
],
];
$extracted = iterator_to_array($form);
$this->assertEquals($expected, $extracted);
}
}

538
tests/HttpUriTest.php Normal file
View file

@ -0,0 +1,538 @@
<?php
// HttpUriTest.php
// Created: 2025-02-28
// Updated: 2025-03-08
declare(strict_types=1);
use Index\Http\{HttpRequest,HttpUri};
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\{CoversClass,DataProvider};
use Psr\Http\Message\UriInterface;
// based on https://github.com/bakame-php/psr7-uri-interface-tests/blob/5a556fdfe668a6c6a14772efeba6134c0b7dae34/tests/AbstractUriTestCase.php
#[CoversClass(HttpRequest::class)]
#[CoversClass(HttpUri::class)]
final class HttpUriTest extends TestCase {
private const string URI = 'http://username:pwd@secure.example.com:443/meow/soap.php?soup=beans#mewow';
/** @return string[][] */
public static function schemeProvider(): array {
return [
['HtTpS', 'https'], // normalized scheme
['http', 'http'], // simple scheme
['', ''], // no scheme
];
}
#[DataProvider('schemeProvider')]
public function testGetScheme(string $scheme, string $expected): void {
$uri = HttpUri::createUri(self::URI)->withScheme($scheme);
$this->assertInstanceOf(UriInterface::class, $uri);
$this->assertSame($expected, $uri->getScheme());
$this->assertInstanceOf(HttpUri::class, $uri);
$this->assertSame($expected, $uri->scheme);
}
/** @return array{string, ?string, string}[] */
public static function userInfoProvider(): array {
return [
['FreakyFurball', 'Express1', 'FreakyFurball:Express1'], // with userinfo
['', '', ''], // no userinfo
['Unko', '', 'Unko:'], // no pass
['flash', null, 'flash'], // pass is null
['Reemo', 'BuddyMan5', 'Reemo:BuddyMan5'], // case sensitive
];
}
#[DataProvider('userInfoProvider')]
public function testGetUserInfo(string $user, ?string $pass, string $expected): void {
$uri = HttpUri::createUri(self::URI)->withUserInfo($user, $pass);
$this->assertInstanceOf(UriInterface::class, $uri);
$this->assertSame($expected, $uri->getUserInfo());
$this->assertInstanceOf(HttpUri::class, $uri);
$this->assertSame($expected, $uri->userInfo);
}
/** @return string[][] */
public static function hostProvider(): array {
return [
['MaStEr.eXaMpLe.CoM', 'master.example.com'], // normalized host
['www.example.com', 'www.example.com'], // simple host
['[::1]', '[::1]'], // IPv6 Host
];
}
#[DataProvider('hostProvider')]
public function testGetHost(string $host, string $expected): void {
$uri = HttpUri::createUri(self::URI)->withHost($host);
$this->assertInstanceOf(UriInterface::class, $uri);
$this->assertSame($expected, $uri->getHost());
$this->assertInstanceOf(HttpUri::class, $uri);
$this->assertSame($expected, $uri->host);
}
/** @return array{string, ?int, ?int}[] */
public static function portProvider(): array {
return [
['http://www.example.com', 443, 443], // non standard port for http
['http://www.example.com', null, null], // remove port
['//www.example.com', 80, 80], // standard port on schemeless http url
];
}
#[DataProvider('portProvider')]
public function testGetPort(string $uri, ?int $port, ?int $expected): void {
$uri = HttpUri::createUri($uri)->withPort($port);
$this->assertInstanceOf(UriInterface::class, $uri);
$this->assertSame($expected, $uri->getPort());
$this->assertInstanceOf(HttpUri::class, $uri);
$this->assertSame($expected, $uri->port);
}
/** @return array{scheme: string, user: string, pass: ?string, host: string, port: ?int, expected: string}[] */
public static function authorityProvider(): array {
return [
[
'scheme' => 'http',
'user' => 'FreakyFurball',
'pass' => 'Express1',
'host' => 'example.com',
'port' => 443,
'expected' => 'FreakyFurball:Express1@example.com:443',
],
[
'scheme' => 'http',
'user' => 'Reemo',
'pass' => 'BuddyMan5',
'host' => 'example.com',
'port' => null,
'expected' => 'Reemo:BuddyMan5@example.com',
],
[
'scheme' => 'http',
'user' => 'Unko',
'pass' => 'SoapSoapSoapSoapSoap',
'host' => 'example.com',
'port' => 80,
'expected' => 'Unko:SoapSoapSoapSoapSoap@example.com:80',
],
[
'scheme' => 'http',
'user' => 'flash',
'pass' => '',
'host' => 'example.com',
'port' => null,
'expected' => 'flash:@example.com',
],
[
'scheme' => 'http',
'user' => 'Satori',
'pass' => null,
'host' => 'example.com',
'port' => null,
'expected' => 'Satori@example.com',
],
[
'scheme' => 'http',
'user' => '',
'pass' => '',
'host' => 'example.com',
'port' => null,
'expected' => 'example.com',
],
];
}
#[DataProvider('authorityProvider')]
public function testGetAuthority(string $scheme, string $user, ?string $pass, string $host, ?int $port, string $expected): void {
$uri = HttpUri::createUri()->withHost($host)->withScheme($scheme)->withUserInfo($user, $pass)->withPort($port);
$this->assertInstanceOf(UriInterface::class, $uri);
$this->assertSame($expected, $uri->getAuthority());
$this->assertInstanceOf(HttpUri::class, $uri);
$this->assertSame($expected, $uri->authority);
}
/** @return string[][] */
public static function queryProvider(): array {
return [
['foo.bar=%7evalue', 'foo.bar=%7evalue'], // normalized query
['', ''], // empty query
['foo.bar=1&foo.bar=1', 'foo.bar=1&foo.bar=1'], // same param query
['?foo=', '?foo='], // same param query, may include ?
];
}
#[DataProvider('queryProvider')]
public function testGetQuery(string $query, string $expected): void {
$uri = HttpUri::createUri(self::URI)->withQuery($query);
$this->assertInstanceOf(UriInterface::class, $uri);
$this->assertSame($expected, $uri->getQuery());
$this->assertInstanceOf(HttpUri::class, $uri);
$this->assertSame($expected, $uri->query);
}
/** @return string[][] */
public static function fragmentProvider(): array {
return [
['fragment', 'fragment'], // all components
['azAZ0-9/?-._~!$&\'()*+,;=:@', 'azAZ0-9/?-._~!$&\'()*+,;=:@'], // non-encodable
];
}
#[DataProvider('fragmentProvider')]
public function testGetFragment(string $fragment, string $expected): void {
$uri = HttpUri::createUri(self::URI)->withFragment($fragment);
$this->assertInstanceOf(UriInterface::class, $uri);
$this->assertSame($expected, $uri->getFragment());
$this->assertInstanceOf(HttpUri::class, $uri);
$this->assertSame($expected, $uri->fragment);
}
/** @return array{scheme: string, user: string, pass: ?string, host: string, port: ?int, path: string, query: string, fragment: string, expected: string}[] */
public static function stringProvider(): array {
return [
[
'scheme' => 'HtTps',
'user' => 'FreakyFurball',
'pass' => 'Express1',
'host' => 'MeWow.eXaMpLe.CoM',
'port' => 443,
'path' => '/%7ejanedoe/%a1/index.php',
'query' => 'foo.bar=%7evalue',
'fragment' => 'fragment',
'expected' => 'https://FreakyFurball:Express1@mewow.example.com:443/%7ejanedoe/%a1/index.php?foo.bar=%7evalue#fragment'
],
[
'scheme' => '',
'user' => '',
'pass' => '',
'host' => 'www.example.com',
'port' => 443,
'path' => '/foo/bar',
'query' => 'param=value',
'fragment' => 'fragment',
'expected' => '//www.example.com:443/foo/bar?param=value#fragment',
],
[
'scheme' => '',
'user' => '',
'pass' => '',
'host' => '',
'port' => null,
'path' => 'foo/bar',
'query' => '',
'fragment' => '',
'expected' => 'foo/bar',
],
];
}
#[DataProvider('stringProvider')]
public function testToString(
string $scheme,
string $user,
string $pass,
string $host,
?int $port,
string $path,
string $query,
string $fragment,
string $expected
): void {
$uri = HttpUri::createUri()->withHost($host)->withScheme($scheme)
->withUserInfo($user, $pass)->withPort($port)
->withPath($path)->withQuery($query)->withFragment($fragment);
$this->assertInstanceOf(UriInterface::class, $uri);
$this->assertInstanceOf(Stringable::class, $uri); // HttpUri also implements this
$this->assertSame($expected, (string)$uri);
}
public function testRemoveScheme(): void {
$this->assertSame(
'//example.com/this/is/a/path',
(string)HttpUri::createUri('http://example.com/this/is/a/path')->withScheme('')
);
}
public function testRemoveAuthority(): void {
$uri = HttpUri::createUri('http://user:login@example.com:82/path?q=v#doc')
->withScheme('')->withUserInfo('')->withPort(null)->withHost('');
$this->assertSame('/path?q=v#doc', (string)$uri);
}
public function testRemoveUserInfo(): void {
$this->assertSame(
'http://example.com/this/is/a/path',
(string)HttpUri::createUri('http://user:pass@example.com/this/is/a/path')->withUserInfo('')
);
}
public function testRemovePort(): void {
$this->assertSame(
'http://example.com/this/is/a/path',
(string)HttpUri::createUri('http://example.com:9001/this/is/a/path')->withPort(null)
);
}
public function testRemovePath(): void {
$uri = 'http://example.com';
$this->assertSame($uri, (string)HttpUri::createUri($uri . '/this/is/a/path')->withPath(''));
}
public function testRemoveQuery(): void {
$uri = 'http://example.com/this/is/a/path';
$this->assertSame($uri, (string)HttpUri::createUri($uri . '?name=value')->withQuery(''));
}
public function testRemoveFragment(): void {
$uri = 'http://example.com/this/is/a/path';
$this->assertSame($uri, (string)HttpUri::createUri($uri . '#soap')->withFragment(''));
}
/** @return string[][] */
public static function withSchemeFailProvider(): array {
return [
['un,acceptable'], // unacceptable char
['123'], // number string
];
}
#[DataProvider('withSchemeFailProvider')]
public function testWithSchemeFail(string $scheme): void {
$this->expectException(InvalidArgumentException::class);
HttpUri::createUri(self::URI)->withScheme($scheme);
}
/** @return string[][] */
public static function withUserInfoFailProvider(): array {
return [
['mew:ow', 'Soap'],
['be@ns', 'Soup'],
['Meow', 'ok@y'],
];
}
#[DataProvider('withUserInfoFailProvider')]
public function testWithUserInfoFail(string $user, ?string $pass): void {
$this->expectException(InvalidArgumentException::class);
HttpUri::createUri(self::URI)->withUserInfo($user, $pass);
}
/** @return string[][] */
public static function withHostFailProvider(): array {
return [
['.example.com'], // dot in front
['host.com-'], // hyphen suffix
['.......'], // multiple dot
['.'], // one dot
['tot. .coucou.com'], // empty label
['re view'], // space in the label
['_bad.host.com'], // underscore in label
[implode('', array_fill(0, 12, 'banana')).'.secure.example.com'], // label too long
[implode('.', array_fill(0, 128, 'a'))], // too many labels
['[127.0.0.1]'], // Invalid IPv4 format
['[[::1]]'], // Invalid IPv6 format
['[::1'], // Invalid IPv6 format 2
['example. com'], // space character in starting label
["examp\0le.com"], // invalid character in host label
['[127.2.0.1%253]'], // invalid IP with scope
['ab23::1234%251'], // invalid scope IPv6
['fe80::1234%25?@'], // invalid scope ID
['fe80::1234%25€'], // invalid scope ID with utf8 character
];
}
#[DataProvider('withHostFailProvider')]
public function testWithHostFail(string $host): void {
$this->expectException(InvalidArgumentException::class);
HttpUri::createUri(self::URI)->withHost($host);
}
public function testWithPathFailWithInvalidPathRelativeToAuthority(): void {
$this->expectException(InvalidArgumentException::class);
HttpUri::createUri('https://example.com')->withPath('me/ow');
}
public function testWithPathFailWithInvalidChars(): void {
$this->expectException(InvalidArgumentException::class);
HttpUri::createUri('https://example.com')->withPath('/?');
}
public function testWithQueryFailWithInvalidChars(): void {
$this->expectException(InvalidArgumentException::class);
HttpUri::createUri('https://example.com')->withQuery('#');
}
public function testWithPortFailWithTooLowPort(): void {
$this->expectException(InvalidArgumentException::class);
HttpUri::createUri('https://example.com')->withPort(0);
}
public function testWithPortFailWithTooHighPort(): void {
$this->expectException(InvalidArgumentException::class);
HttpUri::createUri('https://example.com')->withPort(0x10000);
}
public function testWithHostFailWithInvalidHost(): void {
$this->expectException(InvalidArgumentException::class);
HttpUri::createUri('https://example.com')->withHost('?');
}
/** @return string[][] */
public static function invalidUriProvider(): array {
return [
['https://user@:443'],
[':'],
];
}
#[DataProvider('invalidUriProvider')]
public function testCreateUriWithInvalidUri(string $uri): void {
$this->expectException(InvalidArgumentException::class);
HttpUri::createUri($uri);
}
public function testZeroValues(): void {
$expected = '//0:0@host/0?0#0';
$this->assertSame($expected, (string)HttpUri::createUri($expected));
}
public function testPathDetection(): void {
$expected = 'foo/bar:';
$this->assertSame($expected, HttpUri::createUri($expected)->path);
}
/** @return array<array{0: string, 1: array<string, list<?string>>}> */
public static function queryStringProvider(): 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('queryStringProvider')]
public function testParseQueryString(string $queryString, array $expected): void {
$this->assertEquals(HttpUri::parseQueryString($queryString), $expected);
}
/** @return array<array{0: array<string, Stringable|scalar|null|list<Stringable|scalar|null>>, 1: string}> */
public static function queryParamsProvider(): array {
return [
[
['username' => ['meow'], 'password' => ['beans']],
'username=meow&password=beans'
],
[
['username' => ['meow'], 'password' => ['beans', 'soap']],
'username=meow&password=beans&password=soap'
],
[
['arg' => [null, null, 'maybe', null], 'the' => ['ok']],
'arg&arg&arg=maybe&arg&the=ok'
],
[
['array[]' => ['old', 'syntax'], 'array[meow]' => ['soup']],
'array%5B%5D=old&array%5B%5D=syntax&array%5Bmeow%5D=soup'
],
[
['twenty' => ['this one always uses percent'], 'twenty only' => ['this uses percent encoding']],
'twenty=this%20one%20always%20uses%20percent&twenty%20only=this%20uses%20percent%20encoding'
],
[
['' => [null, null, '', null, '', null]],
'&&=&&=&' // there's no reason why this shouldn't be valid but it is quirky!
],
[
[],
''
],
[
[' ' => [null]],
'%20'
],
[ // scalar types
['null' => [null], 'int' => [1234], 'float' => [56.78], 'bool' => [true, false], 'string' => ['why not ig']],
'null&int=1234&float=56.78&bool=1&bool=&string=why%20not%20ig'
],
[ // stringable
['stringable' => [new class implements Stringable { public function __toString(): string { return 'a'; } }]],
'stringable=a'
],
[ // accept non-array
['null' => null, 'int' => 1234, 'float' => 56.78, 'bool' => true, 'string' => 'why not ig'],
'null&int=1234&float=56.78&bool=1&string=why%20not%20ig'
],
];
}
/** @param array<string, list<Stringable|scalar|null>> $queryParams */
#[DataProvider('queryParamsProvider')]
public function testBuildQueryString(array $queryParams, string $expected): void {
$this->assertEquals(HttpUri::buildQueryString($queryParams), $expected);
}
/** @return array<array{0: string, 1: array<string, string>}> */
public static function cookieStringProvider(): array {
return [
[
'',
[]
],
[
'soup=meow',
['soup' => 'meow']
],
[
'soup=meow;the=the1; the2=the3; the4=the5;',
['soup' => 'meow', 'the' => 'the1', 'the2' => 'the3', 'the4' => 'the5']
],
[
'test%5B%5D=%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82%E3%81%82',
['test[]' => 'あああああああ']
],
[
' =empty',
['' => 'empty']
],
];
}
/** @param array<string, string> $expected */
#[DataProvider('cookieStringProvider')]
public function testParseCookieString(string $cookieString, array $expected): void {
$this->assertEquals(HttpRequest::parseCookieString($cookieString), $expected);
}
}

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

File diff suppressed because it is too large Load diff

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