Merge pull request 'Third Major Router Rewrite' (#1) from router-v3 into trunk
Reviewed-on: #1
This commit is contained in:
commit
9d82faf30b
96 changed files with 6512 additions and 2161 deletions
README.mdVERSIONcomposer.jsoncomposer.lock
src
Bencode
Dependencies.phpHttp
Content
FormHttpContent.phpHtmlHttpErrorHandler.phpHttpContent.phpHttpContentHandler.phpHttpErrorHandler.phpHttpHeader.phpHttpHeaders.phpHttpHeadersBuilder.phpHttpMessage.phpHttpMessageBuilder.phpHttpParameters.phpHttpParametersCommon.phpHttpRequest.phpHttpRequestBuilder.phpHttpResponse.phpHttpResponseBuilder.phpHttpUploadedFile.phpHttpUri.phpPlainHttpErrorHandler.phpRouting
AccessControl
AccessControl.phpAccessControlHandler.phpAccessControlPreflight.phpAccessControlResult.phpSimpleAccessControlHandler.php
ErrorHandling
Filters
HandlerAttribute.phpHandlerContext.phpHttpDelete.phpHttpGet.phpHttpMiddleware.phpHttpOptions.phpHttpPatch.phpHttpPost.phpHttpPut.phpHttpRoute.phpHttpRouter.phpProcessors
After.phpBefore.phpPostprocessor.phpPreprocessor.phpProcessAttribute.phpProcessorAttribute.phpProcessorInfo.phpProcessorTarget.php
ResolvedRouteInfo.phpRouteHandlerCommon.phpRouter.phpRouterCommon.phpRouterProcessors.phpRoutes
ScopedRouter.phpUriMatchers
Streams
StringHttpContent.phpJson
MediaType.phpUrls
tests
|
@ -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
|
||||
|
||||
|
|
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
|||
0.2502.272128
|
||||
0.2503.192123
|
||||
|
|
|
@ -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
177
composer.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
18
src/Http/Content/Content.php
Normal file
18
src/Http/Content/Content.php
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
// Content.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Content;
|
||||
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* Provides a base interface for HTTP request body decoding.
|
||||
*/
|
||||
interface Content {
|
||||
/**
|
||||
* Raw request body stream.
|
||||
*/
|
||||
public StreamInterface $stream { get; }
|
||||
}
|
16
src/Http/Content/FormContent.php
Normal file
16
src/Http/Content/FormContent.php
Normal 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 {}
|
64
src/Http/Content/Multipart/FileMultipartFormData.php
Normal file
64
src/Http/Content/Multipart/FileMultipartFormData.php
Normal file
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
// FileMultipartFormData.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Content\Multipart;
|
||||
|
||||
use Index\Http\HttpHeaders;
|
||||
use Index\Http\Streams\Stream;
|
||||
use Psr\Http\Message\{StreamInterface,UploadedFileInterface};
|
||||
|
||||
/**
|
||||
* Containers for regular form values in multipart form data.
|
||||
*/
|
||||
class FileMultipartFormData implements MultipartFormData, UploadedFileInterface {
|
||||
use HttpHeaders;
|
||||
|
||||
/**
|
||||
* @param string $name Name of the form data part.
|
||||
* @param string $fileName Filename suggested by the client.
|
||||
* @param array<string, string[]> $headers Headers.
|
||||
* @param StreamInterface $stream Value stream.
|
||||
*/
|
||||
public function __construct(
|
||||
public private(set) string $name,
|
||||
public private(set) string $fileName,
|
||||
public private(set) array $headers,
|
||||
public private(set) StreamInterface $stream,
|
||||
) {}
|
||||
|
||||
public function getStream(): StreamInterface {
|
||||
return $this->stream;
|
||||
}
|
||||
|
||||
public function moveTo(string $targetPath): void {
|
||||
$target = Stream::createStreamFromFile($targetPath, 'wb');
|
||||
|
||||
$this->stream->rewind();
|
||||
while(!$this->stream->eof()) {
|
||||
$buffer = $this->stream->read(8192);
|
||||
$target->write($buffer);
|
||||
}
|
||||
}
|
||||
|
||||
public function getSize(): ?int {
|
||||
return $this->stream->getSize();
|
||||
}
|
||||
|
||||
public function getError(): int {
|
||||
return UPLOAD_ERR_OK;
|
||||
}
|
||||
|
||||
public function getClientFilename(): ?string {
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
public function getClientMediaType(): ?string {
|
||||
return $this->hasHeader('Content-Type') ? $this->getHeaderLine('Content-Type') : null;
|
||||
}
|
||||
|
||||
public function __toString(): string {
|
||||
return (string)$this->stream;
|
||||
}
|
||||
}
|
55
src/Http/Content/Multipart/MultipartFormData.php
Normal file
55
src/Http/Content/Multipart/MultipartFormData.php
Normal file
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
// MultipartFormData.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Content\Multipart;
|
||||
|
||||
use Stringable;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* Defines a common interface for multipart form data parts.
|
||||
*/
|
||||
interface MultipartFormData extends Stringable {
|
||||
/**
|
||||
* Name of the form data part.
|
||||
*/
|
||||
public string $name { get; }
|
||||
|
||||
/**
|
||||
* Form data headers.
|
||||
*
|
||||
* @var array<string, string[]>
|
||||
*/
|
||||
public array $headers { get; }
|
||||
|
||||
/**
|
||||
* Underlying stream.
|
||||
*/
|
||||
public StreamInterface $stream { get; }
|
||||
|
||||
/**
|
||||
* Checks if a header exists by the given case-insensitive name.
|
||||
*
|
||||
* @param string $name Case-insensitive header field name.
|
||||
* @return bool
|
||||
*/
|
||||
public function hasHeader(string $name): bool;
|
||||
|
||||
/**
|
||||
* Retrieves a message header value by the given case-insensitive name.
|
||||
*
|
||||
* @param string $name Case-insensitive header field name.
|
||||
* @return string[]
|
||||
*/
|
||||
public function getHeader(string $name): array;
|
||||
|
||||
/**
|
||||
* Retrieves a comma-separated string of the values for a single header.
|
||||
*
|
||||
* @param string $name Case-insensitive header field name.
|
||||
* @return string
|
||||
*/
|
||||
public function getHeaderLine(string $name): string;
|
||||
}
|
38
src/Http/Content/Multipart/ValueMultipartFormData.php
Normal file
38
src/Http/Content/Multipart/ValueMultipartFormData.php
Normal 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;
|
||||
}
|
||||
}
|
229
src/Http/Content/MultipartFormContent.php
Normal file
229
src/Http/Content/MultipartFormContent.php
Normal 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);
|
||||
}
|
||||
}
|
71
src/Http/Content/UrlEncodedFormContent.php
Normal file
71
src/Http/Content/UrlEncodedFormContent.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
87
src/Http/HttpParameters.php
Normal file
87
src/Http/HttpParameters.php
Normal 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;
|
||||
}
|
102
src/Http/HttpParametersCommon.php
Normal file
102
src/Http/HttpParametersCommon.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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
315
src/Http/HttpUri.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
170
src/Http/Routing/AccessControl/AccessControl.php
Normal file
170
src/Http/Routing/AccessControl/AccessControl.php
Normal 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);
|
||||
}
|
||||
}
|
52
src/Http/Routing/AccessControl/AccessControlHandler.php
Normal file
52
src/Http/Routing/AccessControl/AccessControlHandler.php
Normal 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;
|
||||
}
|
69
src/Http/Routing/AccessControl/AccessControlPreflight.php
Normal file
69
src/Http/Routing/AccessControl/AccessControlPreflight.php
Normal 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));
|
||||
}
|
||||
}
|
54
src/Http/Routing/AccessControl/AccessControlResult.php
Normal file
54
src/Http/Routing/AccessControl/AccessControlResult.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
20
src/Http/Routing/ErrorHandling/ErrorHandler.php
Normal file
20
src/Http/Routing/ErrorHandling/ErrorHandler.php
Normal 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;
|
||||
}
|
49
src/Http/Routing/ErrorHandling/HtmlErrorHandler.php
Normal file
49
src/Http/Routing/ErrorHandling/HtmlErrorHandler.php
Normal 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,
|
||||
]));
|
||||
}
|
||||
}
|
29
src/Http/Routing/ErrorHandling/PlainErrorHandler.php
Normal file
29
src/Http/Routing/ErrorHandling/PlainErrorHandler.php
Normal 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
|
||||
));
|
||||
}
|
||||
}
|
28
src/Http/Routing/Filters/FilterAttribute.php
Normal file
28
src/Http/Routing/Filters/FilterAttribute.php
Normal 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;
|
||||
}
|
45
src/Http/Routing/Filters/FilterInfo.php
Normal file
45
src/Http/Routing/Filters/FilterInfo.php
Normal 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);
|
||||
}
|
||||
}
|
34
src/Http/Routing/Filters/PatternFilter.php
Normal file
34
src/Http/Routing/Filters/PatternFilter.php
Normal 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);
|
||||
}
|
||||
}
|
26
src/Http/Routing/Filters/PrefixFilter.php
Normal file
26
src/Http/Routing/Filters/PrefixFilter.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
100
src/Http/Routing/HandlerContext.php
Normal file
100
src/Http/Routing/HandlerContext.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
14
src/Http/Routing/Processors/After.php
Normal file
14
src/Http/Routing/Processors/After.php
Normal 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 {}
|
14
src/Http/Routing/Processors/Before.php
Normal file
14
src/Http/Routing/Processors/Before.php
Normal 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 {}
|
26
src/Http/Routing/Processors/Postprocessor.php
Normal file
26
src/Http/Routing/Processors/Postprocessor.php
Normal 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);
|
||||
}
|
||||
}
|
26
src/Http/Routing/Processors/Preprocessor.php
Normal file
26
src/Http/Routing/Processors/Preprocessor.php
Normal 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);
|
||||
}
|
||||
}
|
77
src/Http/Routing/Processors/ProcessAttribute.php
Normal file
77
src/Http/Routing/Processors/ProcessAttribute.php
Normal 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;
|
||||
}
|
||||
}
|
28
src/Http/Routing/Processors/ProcessorAttribute.php
Normal file
28
src/Http/Routing/Processors/ProcessorAttribute.php
Normal 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;
|
||||
}
|
46
src/Http/Routing/Processors/ProcessorInfo.php
Normal file
46
src/Http/Routing/Processors/ProcessorInfo.php
Normal 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);
|
||||
}
|
||||
}
|
21
src/Http/Routing/Processors/ProcessorTarget.php
Normal file
21
src/Http/Routing/Processors/ProcessorTarget.php
Normal 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;
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
// RouteHandlerCommon.php
|
||||
// Created: 2024-03-28
|
||||
// Updated: 2025-01-18
|
||||
// Updated: 2025-03-07
|
||||
|
||||
namespace Index\Http\Routing;
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
111
src/Http/Routing/RouterProcessors.php
Normal file
111
src/Http/Routing/RouterProcessors.php
Normal 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);
|
||||
}
|
||||
}
|
28
src/Http/Routing/Routes/ExactRoute.php
Normal file
28
src/Http/Routing/Routes/ExactRoute.php
Normal 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);
|
||||
}
|
||||
}
|
34
src/Http/Routing/Routes/PatternRoute.php
Normal file
34
src/Http/Routing/Routes/PatternRoute.php
Normal 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);
|
||||
}
|
||||
}
|
28
src/Http/Routing/Routes/RouteAttribute.php
Normal file
28
src/Http/Routing/Routes/RouteAttribute.php
Normal 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;
|
||||
}
|
141
src/Http/Routing/Routes/RouteInfo.php
Normal file
141
src/Http/Routing/Routes/RouteInfo.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
24
src/Http/Routing/UriMatchers/ExactPathUriMatcher.php
Normal file
24
src/Http/Routing/UriMatchers/ExactPathUriMatcher.php
Normal 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;
|
||||
}
|
||||
}
|
41
src/Http/Routing/UriMatchers/PatternPathUriMatcher.php
Normal file
41
src/Http/Routing/UriMatchers/PatternPathUriMatcher.php
Normal 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);
|
||||
}
|
||||
}
|
29
src/Http/Routing/UriMatchers/PrefixPathUriMatcher.php
Normal file
29
src/Http/Routing/UriMatchers/PrefixPathUriMatcher.php
Normal 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);
|
||||
}
|
||||
}
|
21
src/Http/Routing/UriMatchers/UriMatcher.php
Normal file
21
src/Http/Routing/UriMatchers/UriMatcher.php
Normal 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;
|
||||
}
|
85
src/Http/Streams/NullStream.php
Normal file
85
src/Http/Streams/NullStream.php
Normal 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();
|
176
src/Http/Streams/ScopedStream.php
Normal file
176
src/Http/Streams/ScopedStream.php
Normal file
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
// ScopedStream.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Streams;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Stringable;
|
||||
use Throwable;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* Read-only stream that scopes to certain offsets on another stream.
|
||||
*/
|
||||
class ScopedStream implements StreamInterface, Stringable {
|
||||
private bool $eof = false;
|
||||
|
||||
/**
|
||||
* @param StreamInterface $stream Stream to scope on.
|
||||
* @param int $offset Offset at which to start this stream.
|
||||
* @param int $length Length of data allocated to this stream.
|
||||
*/
|
||||
final public function __construct(
|
||||
public private(set) StreamInterface $stream,
|
||||
public private(set) int $offset,
|
||||
public private(set) int $length,
|
||||
) {
|
||||
if(!$stream->isReadable())
|
||||
throw new InvalidArgumentException('$stream must be readable');
|
||||
if(!$stream->isSeekable())
|
||||
throw new InvalidArgumentException('$stream must be seekable');
|
||||
if($offset < 0)
|
||||
throw new InvalidArgumentException('$offset must be a positive integer');
|
||||
if($length < 0)
|
||||
throw new InvalidArgumentException('$length must be a positive integer');
|
||||
}
|
||||
|
||||
public function getMetadata(?string $key = null) {
|
||||
return $this->stream->getMetadata($key);
|
||||
}
|
||||
|
||||
public function getSize(): ?int {
|
||||
return $this->length;
|
||||
}
|
||||
|
||||
public function getContents(): string {
|
||||
if($this->stream instanceof Stream) {
|
||||
$contents = stream_get_contents($this->stream->getHandle(), $this->length, $this->offset);
|
||||
if($contents === false)
|
||||
throw new RuntimeException('unable to read base stream');
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
$this->rewind();
|
||||
return $this->read($this->length);
|
||||
}
|
||||
|
||||
public function isReadable(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function read(int $length): string {
|
||||
if($length < 1)
|
||||
throw new RuntimeException('$length must be greater than 0');
|
||||
|
||||
// ensure within bounds
|
||||
$tell = $this->stream->tell() - $this->offset;
|
||||
if($tell < 0) {
|
||||
$tell = 0;
|
||||
$this->stream->seek($this->offset);
|
||||
} elseif($tell >= $this->length) {
|
||||
$this->eof = true;
|
||||
return '';
|
||||
}
|
||||
|
||||
if($this->eof = ($tell + $length > $this->length))
|
||||
$length = $this->length - $tell;
|
||||
|
||||
return $this->stream->read($length);
|
||||
}
|
||||
|
||||
public function isWritable(): bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
public function write(string $string): int {
|
||||
throw new RuntimeException('scoped streams are not writable');
|
||||
}
|
||||
|
||||
public function isSeekable(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function seek(int $offset, int $whence = SEEK_SET): void {
|
||||
$this->eof = false;
|
||||
|
||||
switch($whence) {
|
||||
case SEEK_SET:
|
||||
$offset = max(0, $offset);
|
||||
if($offset > $this->length)
|
||||
$offset = $this->length;
|
||||
break;
|
||||
|
||||
case SEEK_CUR:
|
||||
$offset = $this->tell() + $offset;
|
||||
if($offset < 0)
|
||||
$offset = 0;
|
||||
if($offset > $this->length)
|
||||
$offset = $this->length;
|
||||
break;
|
||||
|
||||
case SEEK_END:
|
||||
$offset = min(0, $offset);
|
||||
if(abs($offset) > $this->length)
|
||||
$offset = 0;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new RuntimeException('mode provided to $whence is not supported');
|
||||
}
|
||||
|
||||
$this->stream->seek($this->offset + $offset);
|
||||
}
|
||||
|
||||
public function tell(): int {
|
||||
return min($this->length, max(0, $this->stream->tell() - $this->offset));
|
||||
}
|
||||
|
||||
public function rewind(): void {
|
||||
$this->seek(0);
|
||||
}
|
||||
|
||||
public function eof(): bool {
|
||||
return $this->eof || $this->stream->eof();
|
||||
}
|
||||
|
||||
public function detach() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public function close(): void {}
|
||||
|
||||
public function __toString(): string {
|
||||
try {
|
||||
return $this->getContents();
|
||||
} catch(Throwable $ex) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes to a stream.
|
||||
*
|
||||
* This method detected if the input $stream is already scoped and removes the extra layers.
|
||||
*
|
||||
* @param StreamInterface $stream Stream to scope on.
|
||||
* @param int $offset Offset to scope to.
|
||||
* @param int $length Length of the scope.
|
||||
* @return ScopedStream
|
||||
*/
|
||||
public static function scopeTo(StreamInterface $stream, int $offset, int $length): ScopedStream {
|
||||
while($stream instanceof ScopedStream) {
|
||||
// ensure length remains within bounds
|
||||
if($length + $offset > $stream->length)
|
||||
$length -= $offset;
|
||||
|
||||
$offset += $stream->offset;
|
||||
$stream = $stream->stream;
|
||||
}
|
||||
|
||||
return new ScopedStream($stream, $offset, $length);
|
||||
}
|
||||
}
|
229
src/Http/Streams/Stream.php
Normal file
229
src/Http/Streams/Stream.php
Normal 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);
|
||||
}
|
||||
}
|
96
src/Http/Streams/StreamBuffer.php
Normal file
96
src/Http/Streams/StreamBuffer.php
Normal file
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
// StreamBuffer.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
namespace Index\Http\Streams;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* Simple buffer for streams.
|
||||
*/
|
||||
class StreamBuffer {
|
||||
/**
|
||||
* Keeps track of how many bytes have been read in total
|
||||
*/
|
||||
public int $bytes = 0;
|
||||
|
||||
/**
|
||||
* Contains the current buffer.
|
||||
*/
|
||||
public string $data = '';
|
||||
|
||||
/**
|
||||
* Current offset of the buffer relative to the first read.
|
||||
*/
|
||||
public int $offset = 0;
|
||||
|
||||
/**
|
||||
* Currently available data.
|
||||
*/
|
||||
public int $available {
|
||||
get => strlen($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param StreamInterface $stream Stream to read from.
|
||||
* @param int $chunkSize Amount of data to read at once.
|
||||
*/
|
||||
public function __construct(
|
||||
public private(set) StreamInterface $stream,
|
||||
public private(set) int $chunkSize
|
||||
) {
|
||||
if(!$this->stream->isReadable())
|
||||
throw new InvalidArgumentException('$stream must be readable');
|
||||
if($this->chunkSize < 1)
|
||||
throw new InvalidArgumentException('$chunkSize must be greater than 0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends data to the buffer.
|
||||
*
|
||||
* @return int Amount of bytes read.
|
||||
*/
|
||||
public function read(): int {
|
||||
$read = $this->stream->read($this->chunkSize);
|
||||
$this->data .= $read;
|
||||
|
||||
$count = strlen($read);
|
||||
$this->bytes += $count;
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates buffer.
|
||||
*
|
||||
* @param int $amount Amount of bytes to clear.
|
||||
* @throws InvalidArgumentException if $amount is greater than the currently buffered data or less than 1.
|
||||
*/
|
||||
public function truncate(int $amount): void {
|
||||
if($amount < 1)
|
||||
throw new InvalidArgumentException('$amount must be greater than 0');
|
||||
if($amount > strlen($this->data))
|
||||
throw new InvalidArgumentException('$amount is greater than the amount of currently buffered data');
|
||||
|
||||
$this->offset += $amount;
|
||||
$this->data = substr($this->data, $amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds string in currently buffered data.
|
||||
*
|
||||
* @param string $substring String to find.
|
||||
* @param int $offset From where to start searching.
|
||||
* @return int<-1, max> Index of the value, or -1 if not present.
|
||||
*/
|
||||
public function indexOf(string $substring, int $offset = 0): int {
|
||||
$pos = strpos($this->data, $substring, $offset);
|
||||
if($pos === false)
|
||||
return -1;
|
||||
|
||||
return $pos;
|
||||
}
|
||||
}
|
|
@ -1,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');
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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'])) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
|
|
BIN
tests/HttpFormContentTest-multipart.bin
Normal file
BIN
tests/HttpFormContentTest-multipart.bin
Normal file
Binary file not shown.
176
tests/HttpFormContentTest.php
Normal file
176
tests/HttpFormContentTest.php
Normal 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
538
tests/HttpUriTest.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
1184
tests/RouterTest.php
1184
tests/RouterTest.php
File diff suppressed because it is too large
Load diff
96
tests/ScopedStreamTest.php
Normal file
96
tests/ScopedStreamTest.php
Normal file
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
// ScopedStreamTest.php
|
||||
// Created: 2025-03-12
|
||||
// Updated: 2025-03-12
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\Attributes\{CoversClass,UsesClass};
|
||||
use Index\Http\Streams\{ScopedStream,Stream};
|
||||
|
||||
#[CoversClass(ScopedStream::class)]
|
||||
#[UsesClass(Stream::class)]
|
||||
final class ScopedStreamTest extends TestCase {
|
||||
public function testScopedStream(): void {
|
||||
// why use test data when you can just use the source file
|
||||
$stream = Stream::createStreamFromFile(__FILE__, 'rb');
|
||||
$stream->seek(5, SEEK_SET);
|
||||
|
||||
// potentially account for Windows moments
|
||||
$offset = $stream->read(2) === "\r\n" ? 7 : 6;
|
||||
$length = 23;
|
||||
|
||||
$scoped = new ScopedStream($stream, $offset, $length);
|
||||
$this->assertEquals('// ScopedStreamTest.php', (string)$scoped);
|
||||
$this->assertEquals($length, $scoped->getSize());
|
||||
|
||||
// rewind the underlying stream, scoped should should account for tell() < 0
|
||||
$stream->rewind();
|
||||
$read = $scoped->read(100);
|
||||
$this->assertEquals($length, strlen($read));
|
||||
$this->assertEquals('// ScopedStreamTest.php', $read);
|
||||
|
||||
// read beyond end + eof
|
||||
$stream->seek(-50, SEEK_END);
|
||||
$read = $scoped->read(100);
|
||||
$this->assertEmpty($read);
|
||||
$this->assertTrue($scoped->eof());
|
||||
|
||||
// SEEK_SET
|
||||
$scoped->seek(5);
|
||||
$this->assertEquals($offset + 5, $stream->tell());
|
||||
$read = $scoped->read(5);
|
||||
$this->assertEquals($offset + 10, $stream->tell());
|
||||
$this->assertEquals('opedS', $read);
|
||||
|
||||
$scoped->rewind(); // calls seek(offset) internally
|
||||
$this->assertEquals($offset, $stream->tell());
|
||||
|
||||
// SEEK_CUR
|
||||
$scoped->seek(4, SEEK_CUR);
|
||||
$read = $scoped->read(4);
|
||||
$this->assertEquals('cope', $read);
|
||||
|
||||
$read = $stream->read(4);
|
||||
$this->assertEquals('dStr', $read);
|
||||
$this->assertEquals(12, $scoped->tell());
|
||||
$this->assertEquals($offset + 12, $stream->tell());
|
||||
|
||||
$scoped->seek(100, SEEK_CUR);
|
||||
$read = $scoped->read(100);
|
||||
$this->assertEmpty($read);
|
||||
$this->assertTrue($scoped->eof());
|
||||
$this->assertFalse($stream->eof());
|
||||
|
||||
$scoped->seek(-100, SEEK_CUR);
|
||||
$read = $scoped->read(5);
|
||||
$this->assertEquals('// Sc', $read);
|
||||
|
||||
// SEEK_END + eof behaviour
|
||||
$stream->seek(0, SEEK_END);
|
||||
$read = $stream->read(1);
|
||||
$this->assertTrue($scoped->eof());
|
||||
$this->assertTrue($stream->eof());
|
||||
|
||||
$scoped->seek(0, SEEK_END);
|
||||
$this->assertFalse($scoped->eof());
|
||||
$this->assertFalse($stream->eof());
|
||||
|
||||
// double wrap to test the alternate path in getContents
|
||||
$double = new ScopedStream($scoped, 5, 12);
|
||||
$double->seek(5, SEEK_CUR);
|
||||
$this->assertEquals('tre', $double->read(3));
|
||||
$this->assertEquals('opedStreamTe', (string)$double);
|
||||
|
||||
// now you're scoping with scopes
|
||||
$triple = ScopedStream::scopeTo($double, 2, 12);
|
||||
$this->assertInstanceOf(ScopedStream::class, $double->stream);
|
||||
$this->assertInstanceOf(Stream::class, $triple->stream);
|
||||
$this->assertEquals($offset + 7, $triple->offset);
|
||||
$this->assertEquals('edStreamTe', (string)$triple);
|
||||
$triple->rewind();
|
||||
$this->assertEquals('edStreamTe', $triple->read(100));
|
||||
$this->assertTrue($triple->eof());
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue