Home grown query/form string encoder.

This commit is contained in:
flash 2025-03-08 00:57:00 +00:00
parent 4f092a3259
commit 346b8a52e2
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
8 changed files with 277 additions and 55 deletions

View file

@ -1 +1 @@
0.2503.72337
0.2503.80055

View file

@ -1,7 +1,7 @@
<?php
// HttpRequest.php
// Created: 2022-02-08
// Updated: 2025-03-07
// Updated: 2025-03-08
namespace Index\Http;
@ -30,8 +30,8 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
* @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, list<?string>> $params HTTP request query parameters.
* @param array<string, string[]> $cookies HTTP request cookies.
* @param array<string, array<object|string>>|object|null $parsedBody Parsed body contents.
* @param ?array<string, HttpUploadedFile[]> $uploadedFiles Parsed files.
*/
@ -109,8 +109,8 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
* @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 ?array<string, list<?string>> $params Value you'd otherwise pass to withQueryParams, null to leave unmodified.
* @param ?array<string, string[]> $cookies Value you'd otherwise pass to withCookiesParams, null to leave unmodified.
* @param null|array<string, string[]|object[]>|object|false $parsedBody Value you'd otherwise pass to withParsedBody, false to leave unmodified.
* @param array<string, HttpUploadedFile[]>|null|false $uploadedFiles Value you'd otherwise pass to withUploadedFiles, false to leave unmodified.
* @throws InvalidArgumentException If any of the arguments are not acceptable.
@ -210,14 +210,14 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
}
/**
* @return array<string, string>
* @return array<string, string[]>
*/
public function getCookieParams(): array {
return $this->cookies;
}
/**
* @param array<string, string> $cookies Array of key/value pairs representing cookies.
* @param array<string, string[]> $cookies Array of key/value pairs representing cookies.
*/
public function withCookieParams(array $cookies): HttpRequest {
return $this->with(cookies: $cookies);
@ -236,6 +236,7 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
/**
* Retrieves an HTTP request cookie, or null if it is not present.
*
* @todo UPDATE FOR THE NEW SYNTAX THINGY WITH THE LIST<STRING>!!!!!!!!!!!
* @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.
@ -249,14 +250,14 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
}
/**
* @return array<string, string[]>
* @return array<string, list<?string>>
*/
public function getQueryParams(): array {
return $this->params;
}
/**
* @param array<string, string[]> $query Array of query string arguments, typically from $_GET.
* @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);
@ -275,38 +276,31 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
/**
* Retrieves all HTTP request query fields as a query string.
*
* @param bool $spacesAsPlus true if spaces should be represented with a +, false if %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 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);
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);
}
/**
* Retrieves an HTTP request query field, or null if it is not present.
*
* @todo UPDATE FOR THE NEW SYNTAX THINGY WITH THE LIST<?STRING>!!!!!!!!!!!
* @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.
* @param ?scalar $default Default value to fall back on.
* @return ?scalar Value of the query field, null if not present.
*/
public function getParam(string $name, int $filter = FILTER_DEFAULT, array|int $options = 0): mixed {
public function getParam(string $name, int $filter = FILTER_DEFAULT, array|int $options = 0, mixed $default = null): mixed {
if(!isset($this->params[$name]))
return null;
return filter_var($this->params[$name], $filter, $options);
}
return $default;
/**
* 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;
$value = filter_var($this->params[$name], $filter, $options);
return is_scalar($value) ? $value : $default;
}
/**
@ -366,6 +360,33 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
);
}
/**
* 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]);
$value = urldecode($parts[1]);
if(array_key_exists($name, $params))
$params[$name][] = $value;
else
$params[$name] = [$value];
}
}
}
return $params;
}
/**
* Creates an HttpRequest instance from the current request.
*
@ -396,14 +417,7 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
$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;
$build->params = HttpUri::parseQueryString((string)filter_input(INPUT_SERVER, 'QUERY_STRING'));
$contentType = null;
$contentLength = 0;
@ -418,6 +432,8 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
}
elseif($name === 'content-length')
$contentLength = (int)$value;
elseif($name === 'cookie')
$build->cookies = self::parseCookieString($value);
$build->setHeader($name, $value);
}

View file

@ -1,7 +1,7 @@
<?php
// HttpRequestBuilder.php
// Created: 2022-02-08
// Updated: 2025-03-07
// Updated: 2025-03-08
namespace Index\Http;
@ -44,25 +44,48 @@ final class HttpRequestBuilder extends HttpMessageBuilder {
/**
* HTTP request query params.
*
* @var array<string, string[]>
* @var array<string, list<?string>>
*/
public array $params = [];
/**
* HTTP request cookies.
*
* @var array<string, string>
* @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 string[] $value Value of the query field.
* @param list<?string> $values Value of the query field.
*/
public function setParam(string $name, array $value): void {
$this->params[$name] = $value;
public function setParamValues(string $name, array $values): void {
$this->params[$name] = $values;
}
/**
@ -75,13 +98,36 @@ final class HttpRequestBuilder extends HttpMessageBuilder {
}
/**
* 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.
*/
public function setCookie(string $name, string $value): void {
$this->cookies[$name] = $value;
$this->cookies[$name] = [$value];
}
/**
* Adds a value for a HTTP request cookie.
*
* @param string $name Name of the cookie.
* @param string $value Value of the cookie.
*/
public function addCookie(string $name, string $value): void {
if(array_key_exists($name, $this->cookies))
$this->cookies[$name][] = $value;
else
$this->cookies[$name] = [$value];
}
/**
* Sets values for a HTTP request cookie.
*
* @param string $name Name of the cookie.
* @param string[] $values Values of the cookie.
*/
public function setCookieValues(string $name, array $values): void {
$this->cookies[$name] = $values;
}
/**

View file

@ -1,7 +1,7 @@
<?php
// HttpUri.php
// Created: 2025-02-28
// Updated: 2025-02-28
// Updated: 2025-03-08
namespace Index\Http;
@ -258,4 +258,58 @@ class HttpUri implements UriInterface, Stringable {
return $string;
}
/**
* Parses a urlencoded query/form string.
*
* @param string $query urlencoded string.
* @return array<string, list<?string>>
*/
public static function parseQueryString(string $query): array {
$params = [];
if($query !== '') {
$paramParts = explode('&', $query);
foreach($paramParts as $paramPart) {
$parts = explode('=', $paramPart, 2);
$name = urldecode($parts[0]);
if(!array_key_exists($name, $params))
$params[$name] = [];
$params[$name][] = count($parts) > 1 ? urldecode($parts[1]) : null;
}
}
return $params;
}
/**
* Builds a query/form string from params.
*
* @param array<string, Stringable|scalar|null|list<Stringable|scalar|null>> $params
* @return string
*/
public static function buildQueryString(array $params): string {
$parts = [];
foreach($params as $name => $values) {
$base = rawurlencode($name);
if($values === null) {
$parts[] = $base;
continue;
}
if(is_scalar($values) || $values instanceof Stringable)
$values = [$values];
foreach($values as $value) {
$part = $base;
if(is_scalar($value) || $value instanceof Stringable)
$part .= sprintf('=%s', rawurlencode((string)$value));
$parts[] = $part;
}
}
return implode('&', $parts);
}
}

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
<?php
// HttpUriTest.php
// Created: 2025-02-28
// Updated: 2025-02-28
// Updated: 2025-03-08
declare(strict_types=1);
@ -402,4 +402,104 @@ final class HttpUriTest extends TestCase {
$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);
}
}