src/HttpMessage/AssertionTrait.php
<?php
/**
* This file is part of HttpMessage
*
* @package bdk/http-message
* @author Brad Kent <bkfake-github@yahoo.com>
* @license http://opensource.org/licenses/MIT MIT
* @copyright 2014-2024 Brad Kent
* @since 1.0
*/
namespace bdk\HttpMessage;
use InvalidArgumentException;
use Psr\Http\Message\UploadedFileInterface;
use RecursiveArrayIterator;
use RecursiveIteratorIterator;
/**
* Assertions for Message, Request, ServerRequest, & Response
*/
trait AssertionTrait
{
/**
* Valid HTTP version numbers.
*
* @var numeric-string[]
*/
protected $validProtocolVers = [
'0.9',
'1.0',
'1.1',
'2',
'2.0',
'3',
'3.0',
];
/**
* Test that value is a string (or optionally numeric)
*
* @param mixed $value The value to check.
* @param string $what The name of the value.
* @param bool $allowNumeric Allow float or int?
* @param bool $allowNull Allow null?
*
* @return void
*
* @throws InvalidArgumentException
*
* @psalm-assert string $value
*/
protected function assertString($value, string $what = '', bool $allowNumeric = false, $allowNull = false): void
{
if (\is_string($value)) {
return;
}
if ($allowNull && $value === null) {
return;
}
if ($allowNumeric && \is_numeric($value)) {
return;
}
throw new InvalidArgumentException(\sprintf(
'%s must be a string, %s provided.',
\ucfirst($what),
$this->getDebugType($value)
));
}
/**
* Gets the type name of a variable in a way that is suitable for debugging
*
* @param mixed $value Value to inspect
*
* @return string
*/
protected static function getDebugType($value): string
{
return \is_object($value)
? \get_class($value)
: \gettype($value);
}
/*
Message assertions
*/
/**
* Test valid header name
*
* @param mixed $name header name
*
* @return void
* @throws InvalidArgumentException
*
* @psalm-assert non-empty-string $name
*/
private function assertHeaderName($name): void
{
$this->assertString($name, 'Header name', true);
if ($name === '') {
throw new InvalidArgumentException('Header name can not be empty.');
}
/*
see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6
alpha => a-zA-Z
digit => 0-9
others => !#$%&\'*+-.^_`|~
*/
if (\preg_match('/^[a-zA-Z0-9!#$%&\'*+-.^_`|~]+$/', $name) !== 1) {
throw new InvalidArgumentException(\sprintf(
'"%s" is not valid header name, it must be an RFC 7230 compatible string.',
$name
));
}
}
/**
* Test valid header value
*
* @param mixed $value Value to test
*
* @return void
* @throws InvalidArgumentException
*
* @psalm-assert non-empty-string|int|float|string[]|int[]|float[] $value
*/
private function assertHeaderValue($value): void
{
if (\is_scalar($value) && \is_bool($value) === false) {
$value = [(string) $value];
}
if (\is_array($value) === false) {
throw new InvalidArgumentException(\sprintf(
'The header field value only accepts string and array, %s provided.',
self::getDebugType($value)
));
}
if (empty($value)) {
throw new InvalidArgumentException(
'Header value can not be empty array.'
);
}
foreach ($value as $item) {
$this->assertHeaderValueLine($item);
}
}
/**
* Validate header value
*
* @param mixed $value Header value to test
*
* @return void
*
* @throws InvalidArgumentException
*
* @psalm-assert string $value
*/
private function assertHeaderValueLine($value): void
{
if ($value === '') {
return;
}
$this->assertString($value, 'Header value', true);
/*
https://www.rfc-editor.org/rfc/rfc7230.txt (page.25)
field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
field-vchar = VCHAR / obs-text
obs-text = %x80-FF (character range outside ASCII.)
NOT ALLOWED
SP = space
HTAB = horizontal tab
VCHAR = any visible [USASCII] character. (x21-x7e)
*/
if (\preg_match('/^[ \t\x21-\x7e]+$/', $value) !== 1) {
throw new InvalidArgumentException(\sprintf(
'"%s" is not valid header value, it must contains visible ASCII characters only.',
$value
));
}
}
/**
* Check out whether a protocol version number is supported.
*
* @param mixed $version HTTP protocol version.
*
* @return void
*
* @throws InvalidArgumentException
*
* @psalm-assert numeric-string $version
*/
private function assertProtocolVersion($version): void
{
if (\is_numeric($version) === false) {
throw new InvalidArgumentException(\sprintf(
'Unsupported HTTP protocol version number. %s provided.',
self::getDebugType($version)
));
}
if (\in_array((string) $version, $this->validProtocolVers, true) === false) {
throw new InvalidArgumentException(\sprintf(
'Unsupported HTTP protocol version number. "%s" provided.',
$version
));
}
}
/*
Request assertions
*/
/**
* Assert valid method
*
* @param mixed $method Value to assert
*
* @return void
*
* @throws InvalidArgumentException
*
* @psalm-assert non-empty-string $method
*/
protected function assertMethod($method): void
{
$this->assertString($method, 'HTTP method');
if ($method === '') {
throw new InvalidArgumentException('Method must be a non-empty string.');
}
if (\preg_match('/^[a-z]+$/i', $method) !== 1) {
throw new InvalidArgumentException('Method name must contain only ASCII alpha characters');
}
}
/*
ServerRequest assertions
*/
/**
* Assert valid attribute name
*
* @param string $name Attribute name
* @param bool $throw (false) Whether to throw InvalidArgumentException
*
* @return bool
* @throws InvalidArgumentException if $throw === true
*/
protected function assertAttributeName($name, bool $throw = true): bool
{
try {
$this->assertString($name, 'Attribute name', true);
} catch (InvalidArgumentException $e) {
// for versions with string type hint (2.x & 3.x), this will never be reached
if ($throw) {
throw $e;
}
return false;
}
return true;
}
/**
* Assert valid cookie parameters
*
* @param array $cookies Cookie parameters
*
* @return void
* @throws InvalidArgumentException
*
* @see https://httpwg.org/http-extensions/draft-ietf-httpbis-rfc6265bis.html#name-syntax
*/
protected function assertCookieParams(array $cookies): void
{
$nameRegex = '/^[!#-+\--:<-[\]-~]+$/';
\array_walk(
$cookies,
/**
* @param string $value cookie value
* @param string $name cookie name
*/
function ($value, $name) use ($nameRegex) {
if (\preg_match($nameRegex, (string) $name) !== 1) {
throw new InvalidArgumentException(\sprintf(
'Invalid cookie name specified: %s',
$name
));
}
$this->assertString($value, 'Cookie value', true);
}
);
}
/**
* Assert valid query parameters
*
* typically $_GET and parse_str will only return arrays of strings.
* We'll allow numeric, bool, and null values
*
* @param array $get Query parameters
*
* @return void
* @throws InvalidArgumentException
*/
protected function assertQueryParams(array $get): void
{
$iterator = new RecursiveIteratorIterator(
new RecursiveArrayIterator($get),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $value) {
// treat object as an invalid leaf
$hasChildren = $iterator->hasChildren() && \is_object($value) === false;
if ($hasChildren || $value === null || \is_scalar($value)) {
continue;
}
throw new InvalidArgumentException(\sprintf(
'Query params must only contain scalar values, %s contains %s.',
$this->iteratorPath($iterator),
$this->getDebugType($value)
));
}
}
/**
* Throw an exception if an unsupported argument type is provided.
*
* @param array|object|null $data The deserialized body data. This will
* typically be in an array or object.
*
* @return void
*
* @throws InvalidArgumentException
*/
protected function assertParsedBody($data): void
{
if (
$data === null ||
\is_array($data) ||
\is_object($data)
) {
return;
}
throw new InvalidArgumentException(\sprintf(
'ParsedBody must be array, object, or null. %s provided.',
self::getDebugType($data)
));
}
/**
* Recursively validate the structure in an uploaded files array.
*
* @param array $uploadedFiles uploaded files tree
*
* @return void
*
* @throws InvalidArgumentException if any leaf is not an UploadedFileInterface instance.
*/
protected function assertUploadedFiles(array $uploadedFiles): void
{
\array_walk_recursive($uploadedFiles, static function ($val) {
if (!($val instanceof UploadedFileInterface)) {
throw new InvalidArgumentException(\sprintf(
'Invalid file in uploaded files structure. Expected UploadedFileInterface, %s provided',
self::getDebugType($val)
));
}
});
}
/*
Response assertions
*/
/**
* Validate reason phrase
*
* @param mixed $phrase Reason phrase to test
*
* @return void
*
* @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2
*
* @throws InvalidArgumentException
*
* @psalm-assert string $phrase
*/
protected function assertReasonPhrase($phrase): void
{
if ($phrase === '') {
return;
}
$this->assertString($phrase, 'Reason-phrase');
// Don't allow control characters (incl \r & \n)
if (\preg_match('#[^\P{C}\t]#u', $phrase, $matches, PREG_OFFSET_CAPTURE) === 1) {
throw new InvalidArgumentException(\sprintf(
'Reason phrase contains a prohibited character at position %s.',
$matches[0][1]
));
}
}
/**
* Validate status code
*
* @param int|string $code Status Code
*
* @return void
*
* @throws InvalidArgumentException
*
* @psalm-assert numeric $code
*/
protected function assertStatusCode($code): void
{
if (\is_string($code) && \preg_match('/^\d+$/', $code)) {
$code = (int) $code;
}
if (\is_int($code) === false) {
throw new InvalidArgumentException(\sprintf(
'Status code must to be an integer, %s provided.',
self::getDebugType($code)
));
}
if ($code < 100 || $code > 599) {
throw new InvalidArgumentException(\sprintf(
'Status code has to be an integer between 100 and 599. A status code of %d was given',
$code
));
}
}
/*
Helper methods
*/
/**
* Get the path to the current position of an iterator
*
* @param RecursiveIteratorIterator $iterator Iterator instance
*
* @return string Path to current position as a string
*/
private function iteratorPath(RecursiveIteratorIterator $iterator): string
{
$path = [];
for ($i = 0, $depth = $iterator->getDepth(); $i <= $depth; $i++) {
$key = $iterator->getSubIterator($i)->key();
$path[] = $i > 0
? '["' . \addslashes($key) . '"]'
: $key;
}
return \implode('', $path);
}
}