src/Rest/RequestHandler.php
<?php
declare(strict_types = 1);
/**
* /src/Rest/RequestHandler.php
*
* @author TLe, Tarmo Leppänen <tarmo.leppanen@pinja.com>
*/
namespace App\Rest;
use App\Utils\JSON;
use Closure;
use JsonException;
use LogicException;
use Symfony\Component\HttpFoundation\Request as HttpFoundationRequest;
use Symfony\Component\HttpFoundation\Response as HttpFoundationResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
use function abs;
use function array_filter;
use function array_key_exists;
use function array_unique;
use function array_values;
use function array_walk;
use function explode;
use function in_array;
use function is_array;
use function is_string;
use function mb_strtoupper;
use function mb_substr;
use function str_starts_with;
/**
* @package App\Rest
* @author TLe, Tarmo Leppänen <tarmo.leppanen@pinja.com>
*/
final class RequestHandler
{
/**
* Method to get used criteria array for 'find' and 'count' methods. Some
* examples below.
*
* Basic usage:
* ?where={"foo": "bar"} => WHERE entity.foo = 'bar'
* ?where={"bar.foo": "foobar"} => WHERE bar.foo = 'foobar'
* ?where={"id": [1,2,3]} => WHERE entity.id IN (1,2,3)
* ?where={"bar.foo": [1,2,3]} => WHERE bar.foo IN (1,2,3)
*
* Advanced usage:
* By default you cannot make anything else that described above,
* but you can easily manage special cases within your controller
* 'processCriteria' method, where you can modify this generated
* 'criteria' array as you like.
*
* Note that with advanced usage you can easily use everything that
* App\Repository\Base::getExpression method supports - and that is
* basically 99% that you need on advanced search criteria.
*
* @return array<string, mixed>
*
* @throws HttpException
*/
public static function getCriteria(HttpFoundationRequest $request): array
{
try {
$where = array_filter(
(array)JSON::decode(
(string)($request->query->get('where') ?? $request->request->get('where', '{}')),
true
),
static fn ($value): bool => $value !== null,
);
} catch (JsonException $error) {
throw new HttpException(
HttpFoundationResponse::HTTP_BAD_REQUEST,
'Current \'where\' parameter is not valid JSON.',
$error,
);
}
return $where;
}
/**
* Getter method for used order by option within 'find' method. Some
* examples below.
*
* Basic usage:
* ?order=column1 => ORDER BY entity.column1 ASC
* ?order=-column1 => ORDER BY entity.column2 DESC
* ?order=foo.column1 => ORDER BY foo.column1 ASC
* ?order=-foo.column1 => ORDER BY foo.column2 DESC
*
* Array parameter usage:
* ?order[column1]=ASC => ORDER BY entity.column1 ASC
* ?order[column1]=DESC => ORDER BY entity.column1 DESC
* ?order[column1]=foobar => ORDER BY entity.column1 ASC
* ?order[column1]=DESC&order[column2]=DESC => ORDER BY entity.column1 DESC, entity.column2 DESC
* ?order[foo.column1]=ASC => ORDER BY foo.column1 ASC
* ?order[foo.column1]=DESC => ORDER BY foo.column1 DESC
* ?order[foo.column1]=foobar => ORDER BY foo.column1 ASC
* ?order[foo.column1]=DESC&order[column2]=DESC => ORDER BY foo.column1 DESC, entity.column2 DESC
*
* @return array<string, string>
*/
public static function getOrderBy(HttpFoundationRequest $request): array
{
$key = 'order';
$input = [];
if ($request->query->has($key)) {
$input = $request->query->all()[$key];
} elseif ($request->request->has($key)) {
$input = $request->request->all()[$key];
}
if (!is_array($input)) {
$input = (array)$input;
}
$input = array_filter($input);
// Initialize output
$output = [];
// Process user input
array_walk($input, self::getIterator($output));
return $output;
}
/**
* Getter method for used limit option within 'find' method.
*
* Usage:
* ?limit=10
*/
public static function getLimit(HttpFoundationRequest $request): ?int
{
$limit = $request->query->get('limit') ?? $request->request->get('limit');
return $limit !== null ? (int)abs((float)$limit) : null;
}
/**
* Getter method for used offset option within 'find' method.
*
* Usage:
* ?offset=10
*/
public static function getOffset(HttpFoundationRequest $request): ?int
{
$offset = $request->query->get('offset') ?? $request->request->get('offset');
return $offset !== null ? (int)abs((float)$offset) : null;
}
/**
* Getter method for used search terms within 'find' and 'count' methods.
* Note that these will affect to columns / properties that you have
* specified to your resource service repository class.
*
* Usage examples:
* ?search=term
* ?search=term1+term2
* ?search={"and": ["term1", "term2"]}
* ?search={"or": ["term1", "term2"]}
* ?search={"and": ["term1", "term2"], "or": ["term3", "term4"]}
*
* @return array<mixed>
*
* @throws HttpException
*/
public static function getSearchTerms(HttpFoundationRequest $request): array
{
$search = $request->query->get('search') ?? $request->request->get('search');
return $search !== null ? self::getSearchTermCriteria((string)$search) : [];
}
/**
* Method to return search term criteria as an array that repositories can easily use.
*
* @return array<int|string, array<int, string>>
*
* @throws HttpException
*/
private static function getSearchTermCriteria(string $search): array
{
$searchTerms = self::determineSearchTerms($search);
// By default we want to use 'OR' operand with given search words.
$output = [
'or' => array_unique(array_values(array_filter(explode(' ', $search)))),
];
if ($searchTerms !== null) {
$output = self::normalizeSearchTerms($searchTerms);
}
return $output;
}
/**
* Method to determine used search terms. Note that this will first try to JSON decode given search term. This is
* for cases that 'search' request parameter contains 'and' or 'or' terms.
*
* @return array<int|string, array<int, string>>|null
*
* @throws HttpException
*/
private static function determineSearchTerms(string $search): ?array
{
try {
$searchTerms = JSON::decode($search, true);
self::checkSearchTerms($searchTerms);
} catch (JsonException | LogicException) {
$searchTerms = null;
}
return $searchTerms;
}
/**
* @throws LogicException
* @throws HttpException
*/
private static function checkSearchTerms(mixed $searchTerms): void
{
if (!is_array($searchTerms)) {
throw new LogicException('Search term is not an array, fallback to string handling');
}
if (!array_key_exists('and', $searchTerms) && !array_key_exists('or', $searchTerms)) {
throw new HttpException(
HttpFoundationResponse::HTTP_BAD_REQUEST,
'Given search parameter is not valid, within JSON provide \'and\' and/or \'or\' property.'
);
}
}
/**
* Method to normalize specified search terms. Within this we will just filter out any "empty" values and return
* unique terms after that.
*
* @param array<int|string, array<int, string>> $searchTerms
*
* @return array<int|string, array<int, string>>
*/
private static function normalizeSearchTerms(array $searchTerms): array
{
// Normalize user input, note that this support array and string formats on value
array_walk($searchTerms, static fn (array $terms): array => array_unique(array_values(array_filter($terms))));
return $searchTerms;
}
/**
* @param array<string, string> $output
*/
private static function getIterator(array &$output): Closure
{
return static function (string $value, string | int $key) use (&$output): void {
$order = in_array(mb_strtoupper($value), ['ASC', 'DESC'], true) ? mb_strtoupper($value) : 'ASC';
$column = is_string($key) ? $key : $value;
if (str_starts_with($column, '-')) {
$column = mb_substr($column, 1);
$order = 'DESC';
}
$output[$column] = $order;
};
}
}