469 lines
18 KiB
PHP
469 lines
18 KiB
PHP
<?php
|
|
// HttpRequest.php
|
|
// Created: 2022-02-08
|
|
// Updated: 2025-03-02
|
|
|
|
namespace Index\Http;
|
|
|
|
use InvalidArgumentException;
|
|
use Index\MediaType;
|
|
use Index\Json\JsonHttpContent;
|
|
use Psr\Http\Message\{ServerRequestInterface,StreamInterface,UriInterface};
|
|
|
|
/**
|
|
* Represents a HTTP request message.
|
|
*/
|
|
class HttpRequest extends HttpMessage implements ServerRequestInterface {
|
|
/** 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';
|
|
|
|
/**
|
|
* @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 HttpUri $uri HTTP request URI.
|
|
* @param array<string, string[]> $params HTTP request query parameters.
|
|
* @param array<string, string> $cookies HTTP request cookies.
|
|
* @param array<string, array<object|string>>|object|null $parsedBody Parsed body contents.
|
|
* @param ?array<string, HttpUploadedFile[]> $uploadedFiles Parsed files.
|
|
*/
|
|
final public function __construct(
|
|
string $protocolVersion,
|
|
array $headers,
|
|
StreamInterface $body,
|
|
public private(set) array $attributes,
|
|
public private(set) string $method,
|
|
public private(set) HttpUri $uri,
|
|
public private(set) array $params,
|
|
public private(set) array $cookies,
|
|
private array|object|null $parsedBody = null,
|
|
private ?array $uploadedFiles = null,
|
|
) {
|
|
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');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request country code.
|
|
*/
|
|
public string $countryCode {
|
|
get {
|
|
$countryCode = $this->getAttribute(self::ATTR_COUNTRY_CODE);
|
|
return is_string($countryCode) ? $countryCode : 'XX';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request remote address.
|
|
*/
|
|
public string $remoteAddress {
|
|
get {
|
|
$remoteAddress = $this->getAttribute(self::ATTR_REMOTE_ADDRESS);
|
|
return is_string($remoteAddress) ? $remoteAddress : '::';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether the request was made over HTTPS or not.
|
|
*/
|
|
public bool $secure {
|
|
get => $this->uri->scheme === 'https';
|
|
}
|
|
|
|
/**
|
|
* Request target, path component of the URI.
|
|
*/
|
|
public string $requestTarget {
|
|
get => $this->uri->path;
|
|
}
|
|
|
|
/**
|
|
* @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, string[]> $params Value you'd otherwise pass to withQueryParams, null to leave unmodified.
|
|
* @param ?array<string, string> $cookies Value you'd otherwise pass to withCookiesParams, null to leave unmodified.
|
|
* @param null|array<string, string[]|object[]>|object|false $parsedBody Value you'd otherwise pass to withParsedBody, false to leave unmodified.
|
|
* @param array<string, HttpUploadedFile[]>|null|false $uploadedFiles Value you'd otherwise pass to withUploadedFiles, false to leave unmodified.
|
|
* @throws InvalidArgumentException If any of the arguments are not acceptable.
|
|
* @return static
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Checks if a cookie is present.
|
|
*
|
|
* @param string $name Name of the cookie.
|
|
* @return bool true if the cookie is present, false if not.
|
|
*/
|
|
public function hasCookie(string $name): bool {
|
|
return isset($this->cookies[$name]);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string[]>
|
|
*/
|
|
public function getQueryParams(): array {
|
|
return $this->params;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string[]> $query Array of query string arguments, typically from $_GET.
|
|
*/
|
|
public function withQueryParams(array $query): HttpRequest {
|
|
return $this->with(params: $query);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
public function hasParam(string $name): bool {
|
|
return isset($this->params[$name]);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
public function getParamString(bool $spacesAsPlus = false): string {
|
|
return http_build_query($this->params, '', '&', $spacesAsPlus ? PHP_QUERY_RFC1738 : PHP_QUERY_RFC3986);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, HttpUploadedFile[]> An array tree of UploadedFileInterface instances; an empty array MUST be returned if no data is present.
|
|
*/
|
|
public function getUploadedFiles(): array {
|
|
// check if Content-Type is multipart/form-data and then yoink
|
|
// cache it so withUploadedFiles can still work
|
|
if($this->uploadedFiles === null) {
|
|
$this->uploadedFiles = [];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, HttpUploadedFile[]> $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 middleware
|
|
if($this->parsedBody === null) {
|
|
$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);
|
|
}
|
|
|
|
public static function castRequest(ServerRequestInterface $request): HttpRequest {
|
|
if($request instanceof HttpRequest)
|
|
return $request;
|
|
|
|
return new HttpRequest(
|
|
$request->getProtocolVersion(),
|
|
$request->getHeaders(), // @phpstan-ignore-line: dont care
|
|
$request->getBody(),
|
|
[],
|
|
$request->getMethod(),
|
|
HttpUri::castUri($request->getUri()),
|
|
$request->getQueryParams(), // @phpstan-ignore-line: dont care
|
|
$request->getCookieParams(), // @phpstan-ignore-line: dont care
|
|
$request->getParsedBody(), // @phpstan-ignore-line: dont care
|
|
$request->getUploadedFiles(), // @phpstan-ignore-line: dont care
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Creates an HttpRequest instance from the current request.
|
|
*
|
|
* @return HttpRequest An instance representing the current request.
|
|
*/
|
|
public static function fromRequest(): HttpRequest {
|
|
$build = new HttpRequestBuilder;
|
|
$build->remoteAddress = (string)filter_input(INPUT_SERVER, 'REMOTE_ADDR');
|
|
$build->secure = filter_has_var(INPUT_SERVER, 'HTTPS');
|
|
$build->protocolVersion = (string)filter_input(INPUT_SERVER, 'SERVER_PROTOCOL');
|
|
$build->method = (string)filter_input(INPUT_SERVER, 'REQUEST_METHOD');
|
|
|
|
if(filter_has_var(INPUT_SERVER, 'COUNTRY_CODE'))
|
|
$build->countryCode = (string)filter_input(INPUT_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');
|
|
$pathQueryOffset = strpos($path, '?');
|
|
if($pathQueryOffset !== false)
|
|
$path = substr($path, 0, $pathQueryOffset);
|
|
else {
|
|
$pathHashOffset = strpos($path, '#');
|
|
if($pathHashOffset !== false)
|
|
$path = substr($path, 0, $pathHashOffset);
|
|
}
|
|
|
|
if(!str_starts_with($path, '/'))
|
|
$path = '/' . $path;
|
|
|
|
$build->path = $path;
|
|
|
|
/** @var array<string, string[]> $getVars */
|
|
$getVars = $_GET;
|
|
$build->params = $getVars;
|
|
|
|
/** @var array<string, string> $cookieVars */
|
|
$cookieVars = $_COOKIE;
|
|
$build->cookies = $cookieVars;
|
|
|
|
$contentType = null;
|
|
$contentLength = 0;
|
|
|
|
$headers = self::getRawRequestHeaders();
|
|
foreach($headers as $name => $value) {
|
|
if($name === 'content-type')
|
|
try {
|
|
$contentType = MediaType::parse($value);
|
|
} catch(InvalidArgumentException $ex) {
|
|
$contentType = null;
|
|
}
|
|
elseif($name === 'content-length')
|
|
$contentLength = (int)$value;
|
|
|
|
$build->setHeader($name, $value);
|
|
}
|
|
|
|
$build->body = null;
|
|
|
|
return $build->toRequest();
|
|
}
|
|
|
|
/** @return array<string, string> */
|
|
private static function getRawRequestHeaders(): array {
|
|
if(function_exists('getallheaders')) {
|
|
$raw = getallheaders();
|
|
$headers = [];
|
|
foreach($raw as $name => $value)
|
|
if(is_string($name) && is_string($value))
|
|
$headers[strtolower($name)] = $value;
|
|
|
|
return $headers;
|
|
}
|
|
|
|
$headers = [];
|
|
|
|
foreach($_SERVER as $key => $value) {
|
|
if(!is_string($key) || !is_scalar($value))
|
|
continue;
|
|
|
|
if(substr($key, 0, 5) === 'HTTP_') {
|
|
$key = str_replace(' ', '-', strtolower(str_replace('_', ' ', substr($key, 5))));
|
|
$headers[$key] = (string)$value;
|
|
} elseif($key === 'CONTENT_TYPE') {
|
|
$headers['content-type'] = (string)$value;
|
|
} elseif($key === 'CONTENT_LENGTH') {
|
|
$headers['content-length'] = (string)$value;
|
|
}
|
|
}
|
|
|
|
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')) {
|
|
$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')
|
|
)));
|
|
} elseif(filter_has_var(INPUT_SERVER, 'PHP_AUTH_DIGEST')) {
|
|
$headers['authorization'] = (string)filter_input(INPUT_SERVER, 'PHP_AUTH_DIGEST');
|
|
}
|
|
}
|
|
|
|
return $headers;
|
|
}
|
|
}
|