<?php declare(strict_types=1);
namespace App\Framework\Controller;
use App\Authentication\Entity\User;
use App\Framework\Exception\APIException;
use App\Framework\Exception\InvalidRequestException;
use App\Framework\Entity\PaginationInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Contracts\Service\Attribute\Required;
abstract class APIController extends AbstractController
{
private ?RequestStack $requestStack = null;
private ?AuthorizationCheckerInterface $authChecker = null;
#[Required]
public function setAuthChecker(AuthorizationCheckerInterface $authChecker): void
{
$this->authChecker = $authChecker;
}
#[Required]
public function setRequestStack(RequestStack $requestStack): void
{
$this->requestStack = $requestStack;
}
protected function getRequest(): ?Request
{
return $this->requestStack->getCurrentRequest();
}
protected function getRequestBody()
{
return json_decode($this->getRequest()->getContent(), true);
}
protected function getRequestBodyData(string $field, bool $required = false, $default = null)
{
// TODO: Cache the decode
$data = json_decode($this->getRequest()->getContent(), true);
if (!is_array($data)) {
if ($required) {
throw new InvalidRequestException([$field => 'error.field_required']);
}
return $default;
}
if (array_key_exists($field, $data)) {
return $data[$field];
}
if ($required) {
throw new InvalidRequestException([$field => 'error.field_required']);
}
return $default;
}
protected function getRequestParameterData(string $field, bool $required = false, $default = null)
{
$data = $this->getRequest()->request->all();
if (array_key_exists($field, $data)) {
return $data[$field];
}
if ($required) {
throw new InvalidRequestException([$field => 'Field is required']);
}
return $default;
}
protected function getRequestFileData(string $field, bool $required = false)
{
return $this->getRequest()->files->get($field);
}
/**
* @return mixed[]
*/
protected function getRequestData(array $rules = []): array
{
$requestContent = $this->getRequest()->getContent();
$src = json_decode($requestContent, true);
$dst = [];
foreach ($rules as $field => $rule) {
if (!array_key_exists($field, $src) && $rule) {
throw new InvalidRequestException(['Missing field: ' . $field]);
}
if (!array_key_exists($field, $src)) {
$dst[$field] = '';
}
if (array_key_exists($field, $src)) {
$dst[$field] = $src[$field];
}
}
return $dst;
}
/**
* @return mixed[]
*/
protected function getRequestFilters(): array
{
return $this->getRequest()->query->all();
}
/**
* @return mixed[]
*/
protected function getCollectionFilters(): array
{
$query = $this->getRequest()->query->all();
return array_filter($query, fn ($k) => !in_array(
$k,
['fields', 'q', 'sortBy', 'sortDirection', 'offset', 'limit', 'lastId', 'search'],
true
), ARRAY_FILTER_USE_KEY);
}
protected function getRequestFilterBool($key, $required = false, ?bool $default = null): ?bool {
$uncasted = $this->getRequestFilter(key: $key, required: $required, default: $default);
if ($uncasted == null) {
return null;
}
return $uncasted == 'true';
}
protected function getRequestFilterInt($key, $required = false, ?int $default = null): ?int {
$uncasted = $this->getRequestFilter(key: $key, required: $required, default: $default);
if ($uncasted == null) {
return null;
}
return (int) $uncasted;
}
protected function getRequestFilter($key, $required = false, $default = null)
{
$filters = $this->getRequest()->query->all();
if (array_key_exists($key, $filters)) {
return $filters[$key];
}
if ($required) {
throw new InvalidRequestException(['Missing field: ' . $key]);
}
return $default;
}
/**
* Returns the pagination arguments if specified in the request.
*
* @return array<string, mixed>
*/
protected function getRequestPagination(): array
{
$first = (int) $this->getRequestFilter('offset', false, 0);
$limit = (int) $this->getRequestFilter('limit', false, 1000);
$lastId = (int) $this->getRequestFilter('lastId', false, 0);
return [
'first' => $first,
'limit' => $limit,
'lastId' => $lastId,
];
}
/**
* Returns the pagination input.
*
* @return PaginationInput
*/
protected function getRequestPaginationInput(): PaginationInput {
$offset = (int) $this->getRequestFilter(key: 'offset', required: false, default: 0);
$limit = (int) $this->getRequestFilter(key: 'limit', required: false, default: 1000);
return new PaginationInput(limit: $limit, offset: $offset);
}
/**
* Return the sort arguments if specified in the request.
*
* @return array<int|string, mixed>
*/
protected function getRequestSorting(): array
{
$sortBy = $this->getRequestFilter('sortBy', false, 'id');
$sortDirection = $this->getRequestFilter('sortDirection', false, 'desc');
return [
$sortBy => $sortDirection ? $sortDirection : 'asc',
];
}
protected function getRequestFields(): ?array
{
$requestFiltersQuery = $this->getRequestFilters();
if (!array_key_exists('fields', $requestFiltersQuery)) {
return null;
}
$fields = explode(',', $requestFiltersQuery['fields']);
foreach ($fields as $key => $value) {
$subFields = explode('.', $value);
if (count($subFields) <= 1) {
continue;
}
// remove the key and start populating with arrays
unset($fields[$key]);
$arrRef = &$fields;
$subFieldsCount = count($subFields);
for ($i = 0; $i < $subFieldsCount - 1; ++$i) {
$subField = $subFields[$i];
if (!array_key_exists($subField, $arrRef)) {
$arrRef[$subField] = [];
}
$arrRef = &$arrRef[$subField];
}
$arrRef[] = $subFields[count($subFields) - 1];
}
return $fields;
}
protected function assert($condition, $errorCode, $debugMessage): void
{
if (!$condition) {
throw new APIException(400, $errorCode, $debugMessage);
}
}
protected function assertHasRequestFilter($key, $message): void
{
$filters = $this->getRequestFilters();
if (!array_key_exists($key, $filters)) {
throw new InvalidRequestException([$key => $message]);
}
}
protected function denyAccessUnlessOwner($entity): void
{
if (!$this->authChecker->isGranted('IS_AUTHENTICATED_FULLY')) {
throw new AccessDeniedException('Access denied for ' . $entity::class);
}
/** @var User $user */
$user = $this->getUser();
if (!$user) {
throw new AccessDeniedException('Access denied for ' . $entity::class);
}
// If admin, then we can query anything
if ($this->authChecker->isGranted('ROLE_ADMIN')) {
return;
}
// Kinda like a hack, but if the entities have a user id or a customer id, we can check the ownership
if (method_exists($entity, 'getCustomer')) {
if ($user !== $entity->getCustomer()->getUser()) {
throw new AccessDeniedException('Access denied for ' . $entity::class);
}
} elseif (method_exists($entity, 'getUser')) {
if ($user !== $entity->getUser()) {
throw new AccessDeniedException('Access denied for ' . $entity::class);
}
}
}
protected function denyAccessUnlessOwnerOrAdministrator($entity): void
{
if ($this->isGranted('ROLE_ADMIN')) {
return;
}
$this->denyAccessUnlessOwner($entity);
}
}