Skip to content
Commits on Source (36)
......@@ -90,9 +90,8 @@ USER user
# Install a newer composer
RUN mkdir -p /home/user/.local/bin
WORKDIR /home/user/.local/bin
RUN wget https://raw.githubusercontent.com/composer/getcomposer.org/2dce1a337ceed821c5e243bd54ca11b61e903a2a/web/installer -O - -q | php --
RUN mv composer.phar composer
ENV PATH "/home/user/.local/bin:$PATH"
RUN curl -L https://github.com/composer/getcomposer.org/raw/main/web/download/2.3.7/composer.phar -o /home/user/.local/bin/composer
RUN chmod a+x /home/user/.local/bin/composer
ENV PATH "/home/user/.local/bin:${PATH}"
WORKDIR /home/user
\ No newline at end of file
This diff is collapsed.
includes:
- vendor/phpstan/phpstan-phpunit/extension.neon
- vendor/phpstan/phpstan-symfony/extension.neon
parameters:
inferPrivatePropertyTypeFromConstructor: true
......@@ -7,9 +8,3 @@ parameters:
paths:
- src
- tests
excludePaths:
- tests/bootstrap.php
- src/Swagger/DocumentationNormalizer.php
ignoreErrors:
- message: '#.*NodeDefinition::children.*#'
path: ./src/DependencyInjection
......@@ -32,7 +32,13 @@ class ApiError extends HttpException
parent::__construct($statusCode, json_encode($decoded), $previous, $headers, $code);
}
public static function withDetails(int $statusCode, ?string $message = '', string $errorId = '', array $errorDetails = [])
/**
* @param int $statusCode The HTTP status code
* @param string|null $message The error message
* @param string $errorId The custom error id e.g. 'bundle:my-custom-error'
* @param array $errorDetails An array containing additional information, content depends on the errorId
*/
public static function withDetails(int $statusCode, ?string $message = '', string $errorId = '', array $errorDetails = []): ApiError
{
$message = [
'statusCode' => $statusCode,
......
......@@ -35,4 +35,9 @@ class Tools
$e = new \Exception();
dump($e->getTraceAsString(), $moreVars);
}
public static function isNullOrEmpty(string $str = null): bool
{
return $str === null || $str === '';
}
}
<?php
declare(strict_types=1);
namespace Dbp\Relay\CoreBundle\Http;
use Dbp\Relay\CoreBundle\Helpers\GuzzleTools;
use Dbp\Relay\CoreBundle\Helpers\Tools;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\RequestOptions;
use Kevinrob\GuzzleCache\CacheMiddleware;
use Kevinrob\GuzzleCache\Storage\Psr6CacheStorage;
use Kevinrob\GuzzleCache\Strategy\GreedyCacheStrategy;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
class Connection implements LoggerAwareInterface
{
use LoggerAwareTrait;
private $baseUri;
private $cachePool;
private $cacheTTL;
private $clientHandler;
public function __construct(string $baseUri = null)
{
$this->logger = null;
if (!Tools::isNullOrEmpty($baseUri) && substr($baseUri, -1) !== '/') {
$baseUri .= '/';
}
$this->baseUri = $baseUri;
$this->cachePool = null;
$this->cacheTTL = 0;
$this->clientHandler = null;
}
public function setCache(?CacheItemPoolInterface $cachePool, int $ttl)
{
$this->cachePool = $cachePool;
$this->cacheTTL = $ttl;
}
public function setClientHandler(?object $handler)
{
$this->clientHandler = $handler;
}
/**
* @param array $queryParameters Associative array of query parameters
* @param array $requestOptions Array of RequestOptions to apply (see \GuzzleHttp\RequestOptions)
*
* @throws GuzzleException
*/
public function get(string $uri, array $queryParameters = [], array $requestOptions = []): ResponseInterface
{
if (!empty($queryParameters)) {
$requestOptions[RequestOptions::QUERY] = array_merge($queryParameters,
$requestOptions[RequestOptions::QUERY] ?? []);
}
return $this->request('GET', $uri, $requestOptions);
}
/**
* @param array $requestOptions Array of RequestOptions to apply (see \GuzzleHttp\RequestOptions)
*
* @throws GuzzleException
*/
public function post(string $uri, array $requestOptions = []): ResponseInterface
{
return $this->request('POST', $uri, $requestOptions);
}
public function getClient(): Client
{
return $this->getClientInternal();
}
/**
* @throws GuzzleException
*/
private function request(string $method, string $uri, array $requestOptions): ResponseInterface
{
$client = $this->getClientInternal();
return $client->request($method, $uri, $requestOptions);
}
private function getClientInternal(): Client
{
$stack = HandlerStack::create($this->clientHandler);
if ($this->logger !== null) {
$stack->push(GuzzleTools::createLoggerMiddleware($this->logger));
}
if ($this->cachePool !== null) {
$cacheMiddleWare = new CacheMiddleware(
new GreedyCacheStrategy(
new Psr6CacheStorage($this->cachePool),
$this->cacheTTL
)
);
$cacheMiddleWare->setHttpMethods(['GET' => true, 'HEAD' => true]);
$stack->push($cacheMiddleWare);
}
$client_options = [
'handler' => $stack,
];
if (!Tools::isNullOrEmpty($this->baseUri)) {
$client_options['base_uri'] = $this->baseUri;
}
return new Client($client_options);
}
}
......@@ -7,9 +7,32 @@ namespace Dbp\Relay\CoreBundle\LocalData;
class LocalData
{
public const INCLUDE_PARAMETER_NAME = 'includeLocal';
public const QUERY_PARAMETER_NAME = 'queryLocal';
public static function getIncludeParameter(array $filters): ?string
public static function addOptions(array &$targetOptions, array $sourceOptions)
{
return $filters[self::INCLUDE_PARAMETER_NAME] ?? null;
if ($includeLocalParameter = self::getIncludeParameter($sourceOptions)) {
$targetOptions[self::INCLUDE_PARAMETER_NAME] = $includeLocalParameter;
}
if ($queryLocalParameter = self::getQueryParameter($sourceOptions)) {
$targetOptions[self::QUERY_PARAMETER_NAME] = $queryLocalParameter;
}
}
public static function removeOptions(array &$options)
{
unset($options[self::INCLUDE_PARAMETER_NAME]);
unset($options[self::QUERY_PARAMETER_NAME]);
}
public static function getIncludeParameter(array $options): ?string
{
return $options[self::INCLUDE_PARAMETER_NAME] ?? null;
}
public static function getQueryParameter(array $options): ?string
{
return $options[self::QUERY_PARAMETER_NAME] ?? null;
}
}
......@@ -7,11 +7,17 @@ namespace Dbp\Relay\CoreBundle\LocalData;
use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
use ApiPlatform\Core\Metadata\Resource\Factory\AnnotationResourceMetadataFactory;
use Dbp\Relay\CoreBundle\Exception\ApiError;
use Dbp\Relay\CoreBundle\Helpers\Tools;
use Doctrine\Common\Annotations\AnnotationReader;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\EventDispatcher\Event;
class LocalDataAwareEventDispatcher
{
/** @var array */
private $queryParameters;
/** @var array */
private $requestedAttributes;
......@@ -27,39 +33,21 @@ class LocalDataAwareEventDispatcher
*/
public function __construct(string $resourceClass, EventDispatcherInterface $eventDispatcher)
{
$this->queryParameters = [];
$this->requestedAttributes = [];
$this->uniqueEntityName = self::getUniqueEntityName($resourceClass);
$this->eventDispatcher = $eventDispatcher;
}
/**
* Parses the local data request parameter and extracts the list of requested attributes for this event dispatcher's entity (resource).
*
* @param ?string $includeParameter The value of the 'include' parameter as passed to a GET-operation
* To be called at the beginning of a new operation.
*/
public function initRequestedLocalDataAttributes(?string $includeParameter): void
public function onNewOperation(array &$options): void
{
$this->requestedAttributes = [];
$this->initIncludeParameters(LocalData::getIncludeParameter($options));
$this->initQueryParameters(LocalData::getQueryParameter($options));
if (!empty($includeParameter)) {
$requestedLocalDataAttributes = explode(',', $includeParameter);
foreach ($requestedLocalDataAttributes as $requestedLocalDataAttribute) {
$requestedLocalDataAttribute = trim($requestedLocalDataAttribute);
if (!empty($requestedLocalDataAttribute)) {
$requestedUniqueEntityName = null;
$requestedAttributeName = null;
if (!self::parseLocalDataAttribute($requestedLocalDataAttribute, $requestedUniqueEntityName, $requestedAttributeName)) {
throw new ApiError(400, sprintf("value of '%s' parameter has invalid format: '%s' (Example: 'ResourceName.attr,ResourceName.attr2')", LocalData::INCLUDE_PARAMETER_NAME, $requestedLocalDataAttribute));
}
if ($this->uniqueEntityName === $requestedUniqueEntityName) {
$this->requestedAttributes[] = $requestedAttributeName;
}
}
}
$this->requestedAttributes = array_unique($this->requestedAttributes);
}
LocalData::removeOptions($options);
}
/**
......@@ -76,7 +64,7 @@ class LocalDataAwareEventDispatcher
*
* @param LocalDataAwareInterface $entity The entity whose local data attributes to check
*/
public function checkRequestedAttributesIdentitcal(LocalDataAwareInterface $entity)
public function checkRequestedAttributesIdentical(LocalDataAwareInterface $entity)
{
assert(self::getUniqueEntityName(get_class($entity)) === $this->uniqueEntityName);
......@@ -89,15 +77,21 @@ class LocalDataAwareEventDispatcher
/**
* Dispatches the given event.
*/
public function dispatch(LocalDataAwareEvent $event, string $eventName): void
public function dispatch(Event $event, string $eventName): void
{
$event->setRequestedAttributes($this->requestedAttributes);
$this->eventDispatcher->dispatch($event, $eventName);
$remainingLocalDataAttributes = $event->getRemainingRequestedAttributes();
if (!empty($remainingLocalDataAttributes)) {
throw new ApiError(400, sprintf("the following requested local data attributes could not be provided for resource '%s': %s", $this->uniqueEntityName, implode(', ', $remainingLocalDataAttributes)));
if ($event instanceof LocalDataAwarePreEvent) {
$event->setQueryParameters($this->queryParameters);
$this->eventDispatcher->dispatch($event, $eventName);
} elseif ($event instanceof LocalDataAwarePostEvent) {
$event->setRequestedAttributes($this->requestedAttributes);
$this->eventDispatcher->dispatch($event, $eventName);
$remainingLocalDataAttributes = $event->getRemainingRequestedAttributes();
if (!empty($remainingLocalDataAttributes)) {
throw ApiError::withDetails(Response::HTTP_BAD_REQUEST, sprintf("the following requested local data attributes could not be provided for resource '%s': %s", $this->uniqueEntityName, implode(', ', $remainingLocalDataAttributes)));
}
} else {
$this->eventDispatcher->dispatch($event, $eventName);
}
}
......@@ -126,21 +120,104 @@ class LocalDataAwareEventDispatcher
return $uniqueName;
}
/**
* Parses the local data request parameter and extracts the list of requested attributes for this event dispatcher's entity (resource).
*
* @param ?string $includeParameter The value of the 'include' parameter as passed to a GET-operation
*/
private function initIncludeParameters(?string $includeParameter): void
{
$this->requestedAttributes = [];
if (!Tools::isNullOrEmpty($includeParameter)) {
$requestedAttributes = explode(',', $includeParameter);
foreach ($requestedAttributes as $requestedAttribute) {
$requestedAttribute = trim($requestedAttribute);
if ($requestedAttribute !== '') {
$uniqueEntityName = null;
$uniqueAttributeName = null;
if (!$this->parseLocalDataAttribute($requestedAttribute, $uniqueEntityName, $uniqueAttributeName)) {
throw new ApiError(400, sprintf("value of '%s' parameter has invalid format: '%s' (Example: 'attr,ResourceName.attr2')", LocalData::INCLUDE_PARAMETER_NAME, $requestedAttribute));
}
if ($this->uniqueEntityName === $uniqueEntityName) {
$this->requestedAttributes[] = $uniqueAttributeName;
}
}
}
$this->requestedAttributes = array_unique($this->requestedAttributes);
}
}
private function initQueryParameters(?string $queryParameter)
{
$this->queryParameters = [];
if (!Tools::isNullOrEmpty($queryParameter)) {
$localQueryParameters = explode(',', $queryParameter);
foreach ($localQueryParameters as $localQueryParameter) {
$localQueryParameter = trim($localQueryParameter);
if ($localQueryParameter !== '') {
$parameterKey = null;
$parameterValue = null;
$uniqueEntityName = null;
$uniqueAttributeName = null;
if (!$this->parseQueryParameterAssignment($localQueryParameter, $parameterKey, $parameterValue) ||
!$this->parseLocalDataAttribute($parameterKey ?? '', $uniqueEntityName, $uniqueAttributeName)) {
throw new ApiError(400, sprintf("'%s' parameter has invalid format: '%s' (Example: 'param1:val1,ResourceName.attr1:val2')", LocalData::QUERY_PARAMETER_NAME, $localQueryParameter));
}
if ($uniqueEntityName === $this->uniqueEntityName) {
$this->queryParameters[$parameterKey] = $parameterValue;
}
}
}
}
}
/**
* Parses a local data attribute of the form 'UniqueEntityName.attributeName'.
* NOTE: Due to possible performance impact, there is currently no regex check for valid entity and attribute names (i.e. PHP type/variable names).
*
* @retrun true if $localDataAttribute complies with the local attribute format, false otherwise
*/
private static function parseLocalDataAttribute(string $localDataAttribute, ?string &$uniqueEntityName, ?string &$attributeName): bool
private function parseQueryParameterAssignment(string $parameterAssignment, ?string &$parameter, ?string &$value): bool
{
$parameter = null;
$value = null;
$parts = explode(':', $parameterAssignment);
if (count($parts) === 2) {
$parameter = $parts[0];
$value = $parts[1];
}
return !Tools::isNullOrEmpty($parameter) && !Tools::isNullOrEmpty($value);
}
/**
* Parses a local data attribute of the form 'UniqueEntityName.attributeName'.
* NOTE: Due to possible performance impact, there is currently no regex check for valid entity and attribute names (i.e. PHP type/variable names).
*
* @retrun true if $localDataAttribute complies with the local attribute format, false otherwise
*/
private function parseLocalDataAttribute(string $localDataAttribute, ?string &$uniqueEntityName, ?string &$attributeName): bool
{
$uniqueEntityName = null;
$attributeName = null;
$parts = explode('.', $localDataAttribute);
if (count($parts) !== 2 || empty($parts[0]) || empty($parts[1])) {
return false;
if (count($parts) === 1) {
$uniqueEntityName = $this->uniqueEntityName;
$attributeName = $parts[0];
} elseif (count($parts) === 2) {
$uniqueEntityName = $parts[0];
$attributeName = $parts[1];
}
$uniqueEntityName = $parts[0];
$attributeName = $parts[1];
return true;
return !Tools::isNullOrEmpty($uniqueEntityName) && !Tools::isNullOrEmpty($attributeName);
}
}
......@@ -9,7 +9,7 @@ use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Contracts\EventDispatcher\Event;
class LocalDataAwareEvent extends Event implements LoggerAwareInterface
class LocalDataAwarePostEvent extends Event implements LoggerAwareInterface
{
use LoggerAwareTrait;
......
<?php
declare(strict_types=1);
namespace Dbp\Relay\CoreBundle\LocalData;
use Symfony\Contracts\EventDispatcher\Event;
class LocalDataAwarePreEvent extends Event
{
public const NAME = 'dbp.relay.relay_core.local_data_aware_event.pre';
/** @var array */
private $queryParameters;
public function __construct()
{
$this->queryParameters = [];
}
/**
* Sets the list of query parameters.
*
* @param string[] $queryParameters
*/
public function setQueryParameters(array $queryParameters): void
{
$this->queryParameters = $queryParameters;
}
/**
* Returns the list of query parameters.
*
* @return string[]
*/
public function getQueryParameters(): array
{
return $this->queryParameters;
}
}
......@@ -26,6 +26,11 @@ trait LocalDataAwareTrait
return $this->localData;
}
public function setLocalData(?array $localData)
{
$this->localData = $localData;
}
/**
* Sets the value of a local data attribute.
*
......
......@@ -15,9 +15,19 @@ class Pagination
private const CURRENT_PAGE_NUMBER_DEFAULT = 1;
private const IS_PARTIAL_PAGINATION_DEFAULT = false;
public static function addPaginationOptions(array &$options, array $filters, int $maxNumItemsPerPageDefault = self::MAX_NUM_ITEMS_PER_PAGE_DEFAULT)
public static function addOptions(array &$targetOptions, array $sourceOptions, int $maxNumItemsPerPageDefault = self::MAX_NUM_ITEMS_PER_PAGE_DEFAULT)
{
self::addPaginationOptionsInternal($options, $filters, $maxNumItemsPerPageDefault);
$targetOptions[self::CURRENT_PAGE_NUMBER_PARAMETER_NAME] = intval($sourceOptions[self::CURRENT_PAGE_NUMBER_PARAMETER_NAME] ?? self::CURRENT_PAGE_NUMBER_DEFAULT);
$targetOptions[self::MAX_NUM_ITEMS_PER_PAGE_PARAMETER_NAME] = intval($sourceOptions[self::MAX_NUM_ITEMS_PER_PAGE_PARAMETER_NAME] ?? $maxNumItemsPerPageDefault);
$targetOptions[self::IS_PARTIAL_PAGINATION_PARAMETER_NAME] = filter_var(
$sourceOptions[self::IS_PARTIAL_PAGINATION_PARAMETER_NAME] ?? self::IS_PARTIAL_PAGINATION_DEFAULT, FILTER_VALIDATE_BOOLEAN);
}
public static function removeOptions(array &$options)
{
unset($options[self::CURRENT_PAGE_NUMBER_PARAMETER_NAME]);
unset($options[self::MAX_NUM_ITEMS_PER_PAGE_PARAMETER_NAME]);
unset($options[self::IS_PARTIAL_PAGINATION_PARAMETER_NAME]);
}
public static function getCurrentPageNumber(array $options): int
......@@ -59,11 +69,4 @@ class Pagination
{
return new WholeResultPaginator($resultItems, self::getCurrentPageNumber($options), self::getMaxNumItemsPerPage($options));
}
private static function addPaginationOptionsInternal(array &$options, array $filters, int $maxNumItemsPerPageDefault)
{
$options[self::CURRENT_PAGE_NUMBER_PARAMETER_NAME] = intval($filters[self::CURRENT_PAGE_NUMBER_PARAMETER_NAME] ?? self::CURRENT_PAGE_NUMBER_DEFAULT);
$options[self::MAX_NUM_ITEMS_PER_PAGE_PARAMETER_NAME] = intval($filters[self::MAX_NUM_ITEMS_PER_PAGE_PARAMETER_NAME] ?? $maxNumItemsPerPageDefault);
$options[self::IS_PARTIAL_PAGINATION_PARAMETER_NAME] = filter_var($filters[self::IS_PARTIAL_PAGINATION_PARAMETER_NAME] ?? self::IS_PARTIAL_PAGINATION_DEFAULT, FILTER_VALIDATE_BOOLEAN);
}
}
......@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace Dbp\Relay\CoreBundle\Tests;
use Dbp\Relay\CoreBundle\Exception\ApiError;
use Dbp\Relay\CoreBundle\Serializer\ApiErrorNormalizer;
use PHPUnit\Framework\TestCase;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
use Symfony\Component\Serializer\Normalizer\ProblemNormalizer;
class ApiErrorTest extends TestCase
{
......@@ -26,4 +29,29 @@ class ApiErrorTest extends TestCase
$this->assertSame(['foo' => 'bar'], $message['errorDetails']);
$this->assertSame(424, $error->getStatusCode());
}
private function normalize(ApiError $error, string $format): array
{
$norm = new ApiErrorNormalizer();
$norm->setNormalizer(new ProblemNormalizer());
$exc = FlattenException::createFromThrowable($error, $error->getStatusCode());
return $norm->normalize($exc, $format);
}
public function testNormalizer()
{
$error = ApiError::withDetails(424, 'message', 'id', ['foo' => 'bar']);
$res = self::normalize($error, 'jsonld');
$this->assertSame($res['status'], 424);
$this->assertSame($res['hydra:description'], 'message');
$this->assertSame($res['relay:errorId'], 'id');
$this->assertSame($res['relay:errorDetails'], ['foo' => 'bar']);
$res = self::normalize($error, 'jsonproblem');
$this->assertSame($res['status'], 424);
$this->assertSame($res['detail'], 'message');
$this->assertSame($res['errorId'], 'id');
$this->assertSame($res['errorDetails'], ['foo' => 'bar']);
}
}
......@@ -2,12 +2,8 @@
declare(strict_types=1);
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (file_exists(dirname(__DIR__).'/config/bootstrap.php')) {
require dirname(__DIR__).'/config/bootstrap.php';
} elseif (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}