Skip to content
Commits on Source (46)
This diff is collapsed.
#Local Data
Local data provides a mechanism to extend entities by attributes which are not part of the entities default set of attributes. Local data can be added in custom entity (post-)event subscribers.
## Local Data requests
Local data can be requested using the `inlucde` parameter provided by entity GET operations. The format is the following:
```php
include=<ResourceName>.<attributeName>,...
```
It is a comma-separated list of 0 ... n `<ResourceName>.<attributeName>` pairs, where `ResourceName` is the `shortName` defined in the `ApiResource` annotation of an entity. The list may contain attributes form different resources.
The backend will return an error if
* The `shortName` of the entity contains `.` or `,` characters
* The format of the `include` parameter is invalid
* Any of the requested attributes could not be provided
* The backend tried to set an attribute which was not requested
##Adding Local Data Attributes to Existing Entities
Integraters have to make sure that local attributes requested by their client applications are added in the backend. This can be done in custom entity (post-)event subscribers:
```php
class EntityEventSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
EntityPostEvent::NAME => 'onPost',
];
}
public function onPost(EntityPostEvent $event)
{
$data = $event->getSourceData();
$event->trySetLocalDataAttribute('foo', $data->getFoo());
}
}
```
Events of built-in entities provide a `getSourceData()` and a `getEntity()` method by convention, where
* `getSourceData()` provides the full set of available attributes for the entity
* `getEntity()` provides the entity itself
The event's `trySetLocalDataAttribute` method provides a convient way for setting attributes without causing an error in case the attribute was not requested by the client.
Note that local data values have to be serializable to JSON.
##Creating Local Data aware Entities
You can easily add local data to your Entity (`MyEntity`) by:
* Using the `LocalDataAwareTrait` in `MyEntity`
* Implementing the `LocalDataAwareInterface` in `MyEntity`
* Adding the `LocalData:output` group to the normalization context of `MyEntity`. For example:
```php
normalizationContext={"groups" = {"MyEntity:output", "LocalData:output"}}
```
* Adding an event dispatcher member variable of type `LocalDataAwareEventDispatcher` to your entity provider
* On GET-requests, passing the value of the `include` parameter to the event dispatcher
```php
$this->eventDispatcher->initRequestedLocalDataAttributes($includeParameter);
```
* Creating a (post-)event `MyEntityPostEvent` extending `LocalDataAwareEvent`, which you pass to the event dispatcher's `dispatch` method once your entity provider is done setting up a new instance of `MyEntity`:
```php
// get some data
$mySourceData = $externalApi->getSourceData($identifier);
// craete a new instance of MyEntity
$myEntity = new MyEntity();
// first, set the entity's default attributes:
$myEntity->setIdentifier($mySourceData->getIdentifier());
$myEntity->setName($mySourceData->getName());
// now, fire the event allowing event subscribers to add local data attributes
$postEvent = new MyEntityPostEvent($myEntity, $mySourceData);
$this->eventDispatcher->dispatch($postEvent, MyEntityPostEvent::NAME);
return $myEntity;
```
In case your entity has nested entities (sub-resources), your entity provider is responsible of passing the `include` parameter to sub-resource providers.
\ No newline at end of file
<?xml version="1.0"?>
<psalm
totallyTyped="false"
errorLevel="4"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
......@@ -13,4 +12,11 @@
<directory name="vendor" />
</ignoreFiles>
</projectFiles>
<issueHandlers>
<UndefinedDocblockClass>
<errorLevel type="suppress">
<referencedClass name="UnitEnum" />
</errorLevel>
</UndefinedDocblockClass>
</issueHandlers>
</psalm>
......@@ -217,5 +217,9 @@ class DbpRelayCoreExtension extends ConfigurableExtension implements PrependExte
'lock' => $lockDsn,
]);
}
// Since the core bundle should always be called last we can use this to detect if
// things are called after this by checking if this exist.
$container->setParameter('dbp_api._prepend_done', true);
}
}
<?php
declare(strict_types=1);
namespace Dbp\Relay\CoreBundle\Extension;
use Dbp\Relay\CoreBundle\Queue\Utils;
use Symfony\Component\DependencyInjection\ContainerBuilder;
trait ExtensionTrait
{
/**
* Registers a directory to be searched for api-platform resources.
*/
public function addResourceClassDirectory(ContainerBuilder $container, string $directory): void
{
$this->extendArrayParameter(
$container, 'api_platform.resource_class_directories', [$directory]);
}
/**
* Register a resource for routing, for example a config.yaml defining extra routes.
* $resource is for example a path to a config.yaml and $type is "yaml", see LoaderInterface::load.
*/
public function addRouteResource(ContainerBuilder $container, $resource, ?string $type = null): void
{
$this->extendArrayParameter(
$container, 'dbp_api.route_resources', [[$resource, $type]]);
}
/**
* Registers a specific API path to be hidden from the API documentation.
*/
public function addPathToHide(ContainerBuilder $container, string $path): void
{
$this->extendArrayParameter($container, 'dbp_api.paths_to_hide', [$path]);
}
/**
* Registers a specific message to be routed via the global async queue.
*/
public function addQueueMessage(ContainerBuilder $container, string $messageClass)
{
$this->ensureInPrepend($container);
$this->extendArrayParameter($container, 'dbp_api.messenger_routing', [
$messageClass => Utils::QUEUE_TRANSPORT_NAME,
]);
}
/**
* Registers a specific message to be routed via the global async queue.
*/
public function addExposeHeader(ContainerBuilder $container, string $headerName)
{
$this->ensureInPrepend($container);
$this->extendArrayParameter(
$container, 'dbp_api.expose_headers', [$headerName]
);
}
/**
* Registers a specific message to be routed via the global async queue.
*/
public function addAllowHeader(ContainerBuilder $container, string $headerName)
{
$this->ensureInPrepend($container);
$this->extendArrayParameter(
$container, 'dbp_api.allow_headers', [$headerName]
);
}
private function ensureInPrepend(ContainerBuilder $container)
{
// Some things can only be called in prepend, so that the core bundle can forward them
// to other bundles in prepend() as well.
if ($container->has('dbp_api._prepend_done')) {
throw new \RuntimeException('This function can only be called in prepend(). See PrependExtensionInterface');
}
}
private function extendArrayParameter(ContainerBuilder $container, string $parameter, array $values)
{
if (!$container->hasParameter($parameter)) {
$container->setParameter($parameter, []);
}
$oldValues = $container->getParameter($parameter);
assert(is_array($oldValues));
$container->setParameter($parameter, array_merge($oldValues, $values));
}
}
......@@ -7,15 +7,20 @@ namespace Dbp\Relay\CoreBundle\HealthCheck\Checks;
use Dbp\Relay\CoreBundle\HealthCheck\CheckInterface;
use Dbp\Relay\CoreBundle\HealthCheck\CheckOptions;
use Dbp\Relay\CoreBundle\HealthCheck\CheckResult;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
class SymfonyCheck implements CheckInterface
{
private $parameters;
/** @var ContainerInterface */
private $container;
public function __construct(ParameterBagInterface $parameters)
public function __construct(ParameterBagInterface $parameters, ContainerInterface $container)
{
$this->parameters = $parameters;
$this->container = $container;
}
public function getName(): string
......@@ -23,6 +28,28 @@ class SymfonyCheck implements CheckInterface
return 'core.symfony';
}
private function checkAllServices(): CheckResult
{
$result = new CheckResult('Check if all Symfony services can be initialized');
$result->set(CheckResult::STATUS_SUCCESS);
// This catches errors like unimplemented interfaces, cyclic dependencies and so on.
// Otherwise we would only get those errors when the services are actually needed,
// on specific requests/tasks at runtime.
$container = $this->container;
assert($container instanceof Container);
foreach ($container->getServiceIds() as $id) {
try {
$container->get($id);
} catch (\Throwable $e) {
$result->set(CheckResult::STATUS_FAILURE, $e->getMessage(), ['exception' => $e]);
break;
}
}
return $result;
}
private function checkAppSecret(): CheckResult
{
$result = new CheckResult('APP_SECRET should be set');
......@@ -54,6 +81,7 @@ class SymfonyCheck implements CheckInterface
$results = [];
$results[] = $this->checkAppSecret();
$results[] = $this->checkAppDebug();
$results[] = $this->checkAllServices();
return $results;
}
......
<?php
declare(strict_types=1);
namespace Dbp\Relay\CoreBundle\LocalData;
use Dbp\Relay\CoreBundle\Exception\ApiError;
use Symfony\Contracts\EventDispatcher\Event;
class LocalDataAwareEvent extends Event
{
/** @var LocalDataAwareInterface */
private $entity;
/** @var array */
private $requestedAttributes;
protected function __construct(LocalDataAwareInterface $entity)
{
$this->entity = $entity;
}
protected function getEntityInternal(): LocalDataAwareInterface
{
return $this->entity;
}
/**
* Sets the list of requested local data attribute names for this event's entity.
*
* @param string[] $requestedAttributes
*/
public function setRequestedAttributes(array $requestedAttributes): void
{
$this->requestedAttributes = $requestedAttributes;
}
/**
* Returns the list of local data attributes names that were not yet set for this event's entity.
*
* @retrun string[]
*/
public function getRemainingRequestedAttributes(): array
{
return $this->requestedAttributes;
}
/**
* Sets a local data attribute of this event's entity and removes it from the list of requested attributes.
*
* @parem string $key The name of the attribute.
*
* @param mixed|null $value the value for the attribute
*
* @throws ApiError if attribute $key is not in the set of requested attributes
*/
public function setLocalDataAttribute(string $key, $value): void
{
$this->setLocalDataAttributeInternal($key, $value, true);
}
/**
* Tries to set a local data attribute of this event's entity and removes it from the list of requested attributes.
*
* @parem string $key The name of the attribute.
*
* @param mixed|null $value the value for the attribute
*/
public function trySetLocalDataAttribute(string $key, $value): void
{
$this->setLocalDataAttributeInternal($key, $value, false);
}
/**
* Returns, whether a given attribute was requested for this event's entity.
*
* @parem string $key The name of the attribute.
*/
public function isLocalDataAttributeRequested(string $key): bool
{
return in_array($key, $this->requestedAttributes, true);
}
/**
* Sets a local data attribute of this event's entity and removes it from the list of requested attributes.
*
* @parem string $key The name of the attribute.
*
* @param mixed|null $value the value for the attribute
*
* @throws ApiError if attribute $key is not in the set of requested attributes
*/
private function setLocalDataAttributeInternal(string $key, $value, bool $throwIfNotFound): void
{
$arrayKey = array_search($key, $this->requestedAttributes, true);
if ($arrayKey === false) {
if ($throwIfNotFound) {
throw new ApiError(500, sprintf("trying to set local data attribute '%s', which was not requested for entity '%s'", $key, LocalDataAwareEventDispatcher::getUniqueEntityName(get_class($this->entity))));
} else {
return;
}
}
// once set, remove the attribute from the list of requested attributes
array_splice($this->requestedAttributes, $arrayKey, 1);
$this->entity->setLocalDataValue($key, $value);
}
}
<?php
declare(strict_types=1);
namespace Dbp\Relay\CoreBundle\LocalData;
use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
use ApiPlatform\Core\Metadata\Resource\Factory\AnnotationResourceMetadataFactory;
use Dbp\Relay\CoreBundle\Exception\ApiError;
use Doctrine\Common\Annotations\AnnotationReader;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;
class LocalDataAwareEventDispatcher
{
/** @var array */
private $requestedAttributes;
/** @var string */
private $uniqueEntityName;
/** @var EventDispatcherInterface */
private $eventDispatcher;
public function __construct(string $resourceClass, EventDispatcherInterface $eventDispatcher)
{
$this->uniqueEntityName = self::getUniqueEntityName($resourceClass);
$this->eventDispatcher = $eventDispatcher;
}
/**
* Parses the 'include' option, if present, and extracts the list of requested attributes for $this->uniqueEntityName.
*
* @param string $includeParameter The value of the 'include' parameter as passed to a GET-operation
*/
public function initRequestedLocalDataAttributes(string $includeParameter): void
{
$this->requestedAttributes = [];
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 HttpException(400, sprintf("value of 'include' parameter has invalid format: '%s' (Example: 'ResourceName.attr,ResourceName.attr2')", $requestedLocalDataAttribute));
}
if ($this->uniqueEntityName === $requestedUniqueEntityName) {
$this->requestedAttributes[] = $requestedAttributeName;
}
}
}
$this->requestedAttributes = array_unique($this->requestedAttributes);
}
}
/**
* Dispatches the given event.
*/
public function dispatch(LocalDataAwareEvent $event, string $eventName): void
{
$event->setRequestedAttributes($this->requestedAttributes);
$this->eventDispatcher->dispatch($event, $eventName);
$remainingLocalDataAttributes = $event->getRemainingRequestedAttributes();
if (!empty($remainingLocalDataAttributes)) {
throw new HttpException(500, sprintf("the following local data attributes were not provided for resource '%s': %s", $this->uniqueEntityName, implode(', ', $remainingLocalDataAttributes)));
}
}
/**
* Returns the unique API resource name, i.e. short name, of this entity. For this to work, the ApiResource annotation
* of the entity has to include a non-empty 'shortName' attribute.
*
* @throws ApiError if the ApiResource annotation of $resourceClass doesn't have a non-empty 'shortName' attribute
*/
public static function getUniqueEntityName(string $resourceClass): string
{
$resourceMetadataFactory = new AnnotationResourceMetadataFactory(new AnnotationReader());
try {
$resourceMetadata = $resourceMetadataFactory->create($resourceClass);
} catch (ResourceClassNotFoundException $exc) {
throw new ApiError(500, $exc->getMessage());
}
$uniqueName = $resourceMetadata->getShortName() ?? '';
if (empty($uniqueName)) {
throw new ApiError(500, sprintf("'shortName' attribute missing in ApiResource annotation of resource class '%s'", $resourceClass));
} elseif (str_contains($uniqueName, '.') || str_contains($uniqueName, ',')) {
throw new ApiError(500, sprintf("'shortName' attribute of resource class '%s' must not contain '.' or ',' characters: '%s'", $resourceClass, $uniqueName));
}
return $uniqueName;
}
/**
* 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
{
$parts = explode('.', $localDataAttribute);
if (count($parts) !== 2 || empty($parts[0]) || empty($parts[1])) {
return false;
}
$uniqueEntityName = $parts[0];
$attributeName = $parts[1];
return true;
}
}
<?php
declare(strict_types=1);
namespace Dbp\Relay\CoreBundle\LocalData;
interface LocalDataAwareInterface
{
/**
* Sets the value of a local data attribute.
*
* @param string $key the attribute name
* @param ?mixed $value the attribute value
*/
public function setLocalDataValue(string $key, $value): void;
/**
* Returns the value of a local data attribute.
*
* @param string $key The attribute name
*
* @return ?mixed The value or null if the attribute is not found
*/
public function getLocalDataValue(string $key);
}
<?php
declare(strict_types=1);
namespace Dbp\Relay\CoreBundle\LocalData;
use ApiPlatform\Core\Annotation\ApiProperty;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\Ignore;
trait LocalDataAwareTrait
{
/**
* @ApiProperty(iri="https://schema.org/additionalProperty")
* @Groups({"LocalData:output"})
*
* @var array
*/
private $localData;
/**
* Returns the array of local data attributes.
*/
public function getLocalData(): array
{
return $this->localData;
}
/**
* Sets the value of a local data attribute.
*
* @param string $key the attribute name
* @param mixed|null $value the attribute value
*/
public function setLocalDataValue(string $key, $value): void
{
if (!$this->localData) {
$this->localData = [];
}
$this->localData[$key] = $value;
}
/**
* @Ignore
* Returns the value of a local data attribute.
*
* @param string $key the attribute name
*
* @return ?mixed the value or null if the attribute is not found
*/
public function getLocalDataValue(string $key)
{
return $this->localData ? ($this->localData[$key] ?? null) : null;
}
}
......@@ -124,6 +124,10 @@
<br> Build: <a href="{{ app_buildinfo_url }}">{{ app_buildinfo }}</a>
<br> Environment: {{ app_env }}
<br> Debug: {% if app_debug %}yes{% else %}no{% endif %}
<br> Host: {{ app.request.getHost() }}
<br> Port: {{ app.request.getPort() }}
<br> Scheme: {{ app.request.getScheme() }}
<br> IP: {{ app.request.getClientIp() }}
</div>
</div>
</div>
......
......@@ -2,3 +2,8 @@ api_platform:
resource: .
type: api_platform
prefix: /
dbp_relay:
resource: .
type: dbp_relay
prefix: /
......@@ -56,4 +56,15 @@ services:
Dbp\Relay\CoreBundle\Auth\ProxyAuthenticator:
autowire: true
autoconfigure: true
\ No newline at end of file
autoconfigure: true
Dbp\Relay\CoreBundle\Service\LocalDataAwareEventDispatcher:
autowire: true
autoconfigure: true
Dbp\Relay\CoreBundle\Routing\RoutingLoader:
tags: ['routing.loader']
autowire: true
autoconfigure: true
arguments:
$env: ~
<?php
declare(strict_types=1);
namespace Dbp\Relay\CoreBundle\Routing;
use Symfony\Component\Config\Loader\Loader;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Routing\RouteCollection;
class RoutingLoader extends Loader
{
/**
* @var ParameterBagInterface
*/
private $params;
public function __construct(string $env = null, ParameterBagInterface $params)
{
parent::__construct($env);
$this->params = $params;
}
/**
* @return mixed
*/
public function load($resource, string $type = null)
{
$routes = new RouteCollection();
$routeResources = [];
if ($this->params->has('dbp_api.route_resources')) {
$routeResources = $this->params->get('dbp_api.route_resources');
assert(is_array($routeResources));
}
foreach ($routeResources as [$resource, $type]) {
$importedRoutes = $this->import($resource, $type);
$routes->addCollection($importedRoutes);
}
return $routes;
}
public function supports($resource, string $type = null): bool
{
return 'dbp_relay' === $type;
}
}
<?php
declare(strict_types=1);
namespace Dbp\Relay\CoreBundle\Tests;
use Dbp\Relay\CoreBundle\Extension\ExtensionTrait;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class ExtensionTraitTest extends TestCase
{
use ExtensionTrait;
public function testAll()
{
$builder = new ContainerBuilder();
$params = $builder->getParameterBag();
$this->addQueueMessage($builder, 'foobar');
$this->assertTrue($params->has('dbp_api.messenger_routing'));
$this->addResourceClassDirectory($builder, '.');
$this->assertTrue($params->has('api_platform.resource_class_directories'));
$this->addPathToHide($builder, '/');
$this->assertTrue($params->has('dbp_api.paths_to_hide'));
$this->addRouteResource($builder, '.', null);
$this->assertTrue($params->has('dbp_api.route_resources'));
$this->addExposeHeader($builder, 'foobar');
$this->assertTrue($params->has('dbp_api.expose_headers'));
$this->addAllowHeader($builder, 'foobar');
$this->assertTrue($params->has('dbp_api.allow_headers'));
}
}