Skip to content

Instantly share code, notes, and snippets.

@albe
Created May 30, 2017 08:28
Show Gist options
  • Save albe/c5e318685c126ffe4ce11ca3b16d68ed to your computer and use it in GitHub Desktop.
Save albe/c5e318685c126ffe4ce11ca3b16d68ed to your computer and use it in GitHub Desktop.
Automagic RESTful API for https://github.com/neos/flow Framework Aggregates (incomplete)
<?php
namespace Acme\Api\Controller;
use Acme\Api\Domain\Repository\ResourceRepository;
use Acme\Api\Utility\AggregateReflectionHelper;
use Acme\Api\Utility\ResourceTypeHelper;
use Acme\Api\Utility\ViewConfigurationHelper;
use Doctrine\Common\Inflector\Inflector;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Mvc\View\JsonView;
use Neos\Flow\ObjectManagement\ObjectManagerInterface;
use Neos\Flow\Persistence\QueryInterface;
use Neos\Flow\Property\PropertyMappingConfiguration;
use Neos\Flow\Property\TypeConverter\PersistentObjectConverter;
use Neos\Flow\Reflection\MethodReflection;
use Neos\Utility\ObjectAccess;
use Neos\Flow\Reflection\ReflectionService;
use Neos\Flow\Validation\Error;
/**
* Base class for a RESTful API controller
*
* To use this, just extend this class and override $RESOURCE_ENTITY_CLASS and optionally
* $resourceEntityCursorProperty, $resourceEntityRenderConfiguration and, if necessary because your resource
* entity has properties with conflicting name, any of
* $EMBED_ARGUMENT_NAME, $RENDER_FIELDS_ARGUMENT_NAME, $SORTING_ARGUMENT_NAME, $LIMIT_ARGUMENT_NAME and/or $OFFSET_ARGUMENT_NAME
*
* This implementation is highly inspired by http://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api
*
* @package Acme\Api\Controller
*/
abstract class AbstractRestController extends \Neos\Flow\Mvc\Controller\ActionController
{
/**
* Argument name for a comma separated list of subentities to be embedded in the output.
* This should only be changed if your entity contains a property with this name.
* See http://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api#autoloading
*
* @var string
*/
protected static $EMBED_ARGUMENT_NAME = 'embed';
/**
* Argument name for a comma separated list of fields to be output. This can only reduce the number of fields that
* will be returned, but can not force the API to return fields that are not configured via $resourceEntityRenderConfiguration.
* This should only be changed if your entity contains a property with this name.
* See http://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api#limiting-fields
*
* @var string
*/
protected static $RENDER_FIELDS_ARGUMENT_NAME = 'fields';
/**
* Argument name for a comma separated list of fields to be searched within in the search action.
* This should only be changed if your entity contains a property with this name.
* See http://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api#limiting-fields
*
* @var string
*/
protected static $SEARCH_FIELDS_ARGUMENT_NAME = 'search';
/**
* Argument name for a comma separated list of fields to sort by, optionally prefixed with a '-' sign for descending order.
* This is only used in the filterAction, and should only be changed if your entity contains a property with this name.
* See http://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api#advanced-queries - Sorting
*
* @var string
*/
protected static $SORTING_ARGUMENT_NAME = 'sort';
/**
* Argument name for the maximum number of entities to return.
* This is only used in the filterAction, and should only be changed if your entity contains a property with this name.
*
* @var string
*/
protected static $LIMIT_ARGUMENT_NAME = 'limit';
/**
* Argument name for the numeric offset to start returning entities from.
* This is only used in the filterAction, and should only be changed if your entity contains a property with this name.
*
* @var string
*/
protected static $OFFSET_ARGUMENT_NAME = 'offset';
/**
* @var string
*/
protected $defaultViewObjectName = JsonView::class;
/**
* @var array
*/
protected $supportedMediaTypes = array('application/json');
protected static $RESOURCE_ARGUMENT_NAME = 'resource';
// This will be automatically inflected from $RESOURCE_ARGUMENT_NAME
protected static $RESOURCES_ARGUMENT_NAME = 'resources';
/**
* Override this in specific resource controllers
*
* @var string
*/
protected static $RESOURCE_ENTITY_CLASS;
/**
* Override this in specific resource controllers to change the alias for the identifier when rendering and querying.
*
* @var string
*/
protected static $RESOURCE_ENTITY_IDENTIFIER = 'uuid';
/**
* The name of the property that is used for cursor pagination.
*
* Override this in specific resource controllers to set the property name of the pagination cursor.
* This should be a property of a unique steadily increasing value, like an autoincrement or a timestamp.
* Also, the table of the entity should contain an index for this property.
*
* @var string
*/
protected $resourceEntityCursorProperty = '__identity';
/**
* Array of property names to render in output by default.
*
* Override this in specific resource controllers to define the properties that are rendered.
* See JsonView::$configuration for more information. If not set, all gettable properties will be output by default.
*
* @var array
*/
protected $resourceEntityRenderConfiguration;
/**
* Array of filter property names and values that should be applied to queries.
*
* @var array
*/
protected $resourceEntityDefaultFilter = array();
/**
* @var ResourceRepository
*/
protected $repository;
/**
* @var bool
* @Flow\InjectConfiguration(package="Acme.Api",path="useAbsoluteUris")
*/
protected $useAbsoluteUris = true;
/**
* @var bool
* @Flow\InjectConfiguration(package="Acme.Api",path="normalizeResourceTypes")
*/
protected $normalizeResourceTypes = false;
/**
* Readonly configuration for the resource entity for this request.
* @var array
*/
protected $resourceEntityConfiguration;
/**
* @return string
*/
public static function resourceType()
{
return static::$RESOURCE_ENTITY_CLASS;
}
/**
* We don't want any flash messages or redirects to referrer for the REST Api.
*
* @return string
*/
protected function errorAction()
{
$this->handleTargetNotFoundError();
return $this->getFlattenedValidationErrorMessage();
}
/**
* Override this method in order to transform an error object (e.g. translate messages) before returning as JSON.
*
* @param array $errorObject
* @return array
*/
protected function transformErrorObject($errorObject)
{
return $errorObject;
}
/**
* Returns a string containing all validation errors separated by PHP_EOL.
*
* @return string
*/
protected function getFlattenedValidationErrorMessage()
{
$outputMessage = 'Validation failed while trying to call ' . get_class($this) . '->' . $this->actionMethodName . '().' . PHP_EOL;
$errorObject = array(
'message' => $outputMessage,
);
$logMessage = $outputMessage;
foreach ($this->arguments->getValidationResults()->getFlattenedErrors() as $propertyPath => $errors) {
/* @var $error Error */
foreach ($errors as $error) {
$logMessage .= 'Error for ' . $propertyPath . ': ' . $error->render() . PHP_EOL;
$errorObject['errors'][] = array('code' => $error->getCode(), 'field' => $propertyPath, 'message' => $error->render());
}
}
$this->systemLogger->log($logMessage, LOG_ERR);
$errorObject = $this->transformErrorObject($errorObject);
$this->response->setStatus(422);
$this->response->setHeader('Content-Type', 'application/json');
return json_encode($errorObject, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* Returns a map of action method names and their parameters.
*
* TODO: This is highly hacky code to make action argument validation work with the dynamic resource argument name.
*
* @param ObjectManagerInterface $objectManager
* @return array Array of method parameters by action name
* @Flow\CompileStatic
*/
public static function getActionMethodParameters($objectManager)
{
$result = parent::getActionMethodParameters($objectManager);
$result['showAction'][static::$RESOURCE_ARGUMENT_NAME] =
$result['createAction'][static::$RESOURCE_ARGUMENT_NAME] =
$result['updateAction'][static::$RESOURCE_ARGUMENT_NAME] =
$result['removeAction'][static::$RESOURCE_ARGUMENT_NAME] = array(
'position' => 0,
'optional' => false,
'type' => static::$RESOURCE_ENTITY_CLASS,
'class' => static::$RESOURCE_ENTITY_CLASS,
'array' => false,
'byReference' => false,
'allowsNull' => false,
'defaultValue' => null
);
$result['createAction'][static::$RESOURCE_ARGUMENT_NAME]['optional'] = true;
static::$RESOURCES_ARGUMENT_NAME = Inflector::pluralize(static::$RESOURCE_ARGUMENT_NAME);
$result['createAction'][static::$RESOURCES_ARGUMENT_NAME] = array(
'position' => 1,
'optional' => true,
'type' => 'array<' . static::$RESOURCE_ENTITY_CLASS . '>',
'class' => null,
'array' => true,
'byReference' => false,
'allowsNull' => false,
'defaultValue' => array()
);
$result['searchAction'][static::$SORTING_ARGUMENT_NAME] =
$result['filterAction'][static::$SORTING_ARGUMENT_NAME] = array(
'position' => 0,
'optional' => true,
'type' => 'string',
'class' => null,
'array' => false,
'byReference' => false,
'allowsNull' => true,
'defaultValue' => null
);
$result['searchAction'][static::$LIMIT_ARGUMENT_NAME] =
$result['searchAction'][static::$OFFSET_ARGUMENT_NAME] =
$result['filterAction'][static::$LIMIT_ARGUMENT_NAME] =
$result['filterAction'][static::$OFFSET_ARGUMENT_NAME] = array(
'position' => 0,
'optional' => true,
'type' => 'integer',
'class' => null,
'array' => false,
'byReference' => false,
'allowsNull' => true,
'defaultValue' => null
);
$result['showAction'][static::$RENDER_FIELDS_ARGUMENT_NAME] =
$result['listAction'][static::$RENDER_FIELDS_ARGUMENT_NAME] =
$result['searchAction'][static::$RENDER_FIELDS_ARGUMENT_NAME] =
$result['filterAction'][static::$RENDER_FIELDS_ARGUMENT_NAME] = array(
'position' => 0,
'optional' => true,
'type' => 'string',
'class' => null,
'array' => false,
'byReference' => false,
'allowsNull' => true,
'defaultValue' => null
);
$result['showAction'][static::$EMBED_ARGUMENT_NAME] =
$result['listAction'][static::$EMBED_ARGUMENT_NAME] =
$result['searchAction'][static::$EMBED_ARGUMENT_NAME] =
$result['filterAction'][static::$EMBED_ARGUMENT_NAME] = array(
'position' => 0,
'optional' => true,
'type' => 'string',
'class' => null,
'array' => false,
'byReference' => false,
'allowsNull' => true,
'defaultValue' => null
);
return $result;
}
protected function initializeAction()
{
$this->repository = new ResourceRepository(static::$RESOURCE_ENTITY_CLASS);
$this->response->setHeader('Access-Control-Allow-Origin', '*');
}
/**
* @return string
* @deprecated Use the static member $RESOURCES_ARGUMENT_NAME instead
*/
public static function getResourcesArgumentName()
{
return Inflector::pluralize(static::$RESOURCE_ARGUMENT_NAME);
}
/**
* Return the resource entity that was submitted as argument to the current request.
* @return object The current requests resource entity if specified
*/
protected function getResourceEntity()
{
return $this->arguments->getArgument(static::$RESOURCE_ARGUMENT_NAME)->getValue();
}
/**
* Return the array of resource entities that was submitted as argument to the current request.
* @return array The current requests array of resource entities if specified
*/
protected function getResources()
{
return $this->arguments->getArgument(static::$RESOURCES_ARGUMENT_NAME)->getValue();
}
/**
* @param \Neos\Flow\Mvc\View\ViewInterface $view
*/
protected function initializeView(\Neos\Flow\Mvc\View\ViewInterface $view)
{
if ($view instanceof JsonView) {
$view->setOption('jsonEncodingOptions', JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
// Build default descend configuration based on aggregate boundaries
$descendConfiguration = static::resourceEntityDescendConfiguration($this->objectManager);
if ($this->request->hasArgument(static::$EMBED_ARGUMENT_NAME)) {
/* @var $configurationHelper ViewConfigurationHelper */
$configurationHelper = $this->objectManager->get(ViewConfigurationHelper::class);
$embedPaths = $this->request->getArgument(static::$EMBED_ARGUMENT_NAME);
// Build descend configuration based on submitted embed argument
$embedDescendConfiguration = $configurationHelper->convertPropertyPathsToViewConfiguration($embedPaths);
// And merge it into the default descend configuration
$descendConfiguration = array_merge_recursive($descendConfiguration, $embedDescendConfiguration);
}
$configuration = array(
'_descend' => $descendConfiguration,
'_exposeObjectIdentifier' => true,
'_exposedObjectIdentifierKey' => static::$RESOURCE_ENTITY_IDENTIFIER,
);
if (is_array($this->resourceEntityRenderConfiguration)) {
$configuration['_only'] = $this->resourceEntityRenderConfiguration;
}
if ($this->request->hasArgument(static::$RENDER_FIELDS_ARGUMENT_NAME)) {
$fields = explode(',', $this->request->getArgument(static::$RENDER_FIELDS_ARGUMENT_NAME));
$only = array_filter($fields, function($field) {
return $field[0] !== '!';
});
if ($only !== array()) {
if (isset($configuration['_only'])) {
$configuration['_only'] = array_intersect($configuration['_only'], $only);
} else {
$configuration['_only'] = $only;
}
}
$exclude = array_filter($fields, function($field) {
return $field[0] === '!';
});
if ($exclude !== array()) {
$configuration['_exclude'] = array_map(function($field) {
return substr($field, 1);
}, $exclude);
}
}
$this->resourceEntityConfiguration = $configuration;
$view->setConfiguration(array(
'value' => $configuration,
'values' => array('_descendAll' => $configuration)
));
}
}
/**
* Call this method if you want to return a collection of entities in your action.
* In that case, you should assign the collection to the view variable 'values' instead of 'value'.
* @param string $variableName Optionally override the variable name that should be used.
*/
protected function setCollectionReturnValue($variableName = 'values')
{
if ($this->view instanceof JsonView) {
$this->view->setConfiguration(array($variableName => array('_descendAll' => $this->resourceEntityConfiguration)));
$this->view->setVariablesToRender(array($variableName));
}
}
/**
* @param ObjectManagerInterface $objectManager
* @return array
* @Flow\CompileStatic
*/
public static function resourceEntityProperties(ObjectManagerInterface $objectManager)
{
/* @var $reflectionService ReflectionService */
$reflectionService = $objectManager->get(ReflectionService::class);
return $reflectionService->getClassPropertyNames(static::$RESOURCE_ENTITY_CLASS);
}
/**
* @param ObjectManagerInterface $objectManager
* @return array
* @Flow\CompileStatic
*/
public static function resourceEntityPropertiesDescription(ObjectManagerInterface $objectManager)
{
/* @var $aggregateReflectionHelper AggregateReflectionHelper */
$aggregateReflectionHelper = $objectManager->get(AggregateReflectionHelper::class);
return $aggregateReflectionHelper->withIdentifierName(static::$RESOURCE_ENTITY_IDENTIFIER)
->reflectAggregate(static::$RESOURCE_ENTITY_CLASS);
}
/**
* @param ObjectManagerInterface $objectManager
* @return array
* @Flow\CompileStatic
*/
public static function resourceEntityDescendConfiguration(ObjectManagerInterface $objectManager)
{
/* @var $configurationHelper ViewConfigurationHelper */
$configurationHelper = $objectManager->get(ViewConfigurationHelper::class);
return $configurationHelper->withIdentifierName(static::$RESOURCE_ENTITY_IDENTIFIER)
->convertAggregateSchemaToViewConfiguration(static::resourceEntityPropertiesDescription($objectManager));
}
/**
* This action is called for CORS preflight requests (OPTIONS request method).
*
* @return string An empty body
*/
public function optionsAction()
{
return '';
}
protected function initializeOptionsAction()
{
$this->response->setHeader('Access-Control-Allow-Methods', 'HEAD, GET, POST, PUT, PATCH, DELETE, OPTIONS');
$this->response->setHeader('Access-Control-Allow-Headers', $this->request->getHttpRequest()->getHeader('Access-Control-Request-Headers'));
$this->response->setHeader('Access-Control-Max-Age', 3600);
}
/**
* Get a description of the resource entity and it's properties.
* @return void An array of property names accessible for this resource
*/
public function describeAction()
{
$resourceProperties = static::resourceEntityPropertiesDescription($this->objectManager);
if ($this->normalizeResourceTypes) {
$schemas[] = &$resourceProperties;
while (count($schemas) > 0) {
foreach ($schemas[0] as &$property) {
$property['type'] = ResourceTypeHelper::normalize($property['type']);
if (isset($property['elementType']) && $property['elementType'] !== null) {
$property['elementType'] = ResourceTypeHelper::normalize($property['elementType']);
}
if (isset($property['schema'])) {
if (is_array($property['schema'])) {
$schemas[] = &$property['schema'];
} else {
$property['schema'] = ResourceTypeHelper::normalize($property['schema']);
}
}
}
array_shift($schemas);
}
}
//$resourceProperties = array_diff($resourceProperties, array('Persistence_Object_Identifier'));
$this->view->assign('description', $resourceProperties);
if ($this->view instanceof JsonView) {
$this->view->setVariablesToRender(array('description'));
}
}
/**
* Get a description of all action entrypoints to this resource.
* @return void An array of resource action URIs and their description
*/
public function discoverAction()
{
$resourceEntryPoints = array();
$actionMethodNames = static::getPublicActionMethods($this->objectManager);
$actionMethodParameters = static::getActionMethodParameters($this->objectManager);
foreach ($actionMethodNames as $actionMethodName => $isPublic) {
if (in_array($actionMethodName, array('discoverAction', 'optionsAction'))) {
continue;
}
$actionName = str_replace('Action', '', $actionMethodName);
$arguments = array();
if (isset($actionMethodParameters[$actionMethodName][static::$RESOURCE_ARGUMENT_NAME])) {
if ($actionMethodParameters[$actionMethodName][static::$RESOURCE_ARGUMENT_NAME]['optional'] === false) {
$arguments = array(static::$RESOURCE_ARGUMENT_NAME => array('__identity' => '{identifier}'));
} else {
$arguments = array(static::$RESOURCE_ARGUMENT_NAME => array('__identity' => '({identifier})'));
}
}
// Map CRUD actions back to generic URI action
$uriActionName = in_array($actionName, array('show', 'list', 'create', 'update', 'remove')) ? 'index' : $actionName;
$actionUri = $this->uriBuilder->setCreateAbsoluteUri($this->useAbsoluteUris)->setFormat($this->request->getFormat())->uriFor($uriActionName, $arguments);
$actionReflection = new MethodReflection($this, $actionMethodName);
$parameterDescriptions = array();
if ($actionReflection->isTaggedWith('param')) {
foreach ($actionReflection->getTagValues('param') as $parameterDescription) {
$descriptionParts = preg_split('/\s/', $parameterDescription, 3);
if (isset($descriptionParts[2])) {
$parameterName = ltrim($descriptionParts[1], '$');
$parameterDescriptions[$parameterName] = $descriptionParts[2];
}
}
}
$parameters = array_map(function($parameterInfo) {
return array(
'required' => !$parameterInfo['optional'],
'type' => $this->normalizeResourceTypes ? ResourceTypeHelper::normalize($parameterInfo['type']) : $parameterInfo['type'],
'default' => $parameterInfo['defaultValue']
);
}, $actionMethodParameters[$actionMethodName]);
// PHPSadness.com: array_walk operates in place
array_walk($parameters, function(&$parameterInfo, $parameterName) use ($parameterDescriptions) {
$parameterInfo['description'] = isset($parameterDescriptions[$parameterName]) ? $parameterDescriptions[$parameterName] : '';
});
$return = '';
if ($actionReflection->isTaggedWith('return')) {
$returnTags = $actionReflection->getTagValues('return');
$returnParts = preg_split('/\s/', reset($returnTags), 2);
$return = isset($returnParts[1]) ? $returnParts[1] : '';
}
$resourceEntryPoints[$actionName] = array(
'uri' => rawurldecode($actionUri),
'parameters' => $parameters,
'description' => $actionReflection->getDescription(),
'return' => $return,
);
}
$this->view->assign('description', $resourceEntryPoints);
if ($this->view instanceof JsonView) {
$this->view->setVariablesToRender(array('description'));
}
}
/**
* Get one single resource entity
*
* Examples:
* GET /api/{resource}/{identifier}/
*
* @param string $fields A comma separated list of resource properties to include in the results
* @param string $embed A comma separated list of related resource properties to embed into the results
* @return void The resource entity
* @Flow\IgnoreValidation("$resource")
*/
public function showAction()
{
if (!$this->request->hasArgument(static::$RESOURCE_ARGUMENT_NAME)) {
$this->throwStatus(400, 'No resource specified', '');
}
$this->view->assign('value', $this->getResourceEntity());
}
/**
* Check if the given propertyPath is part of the persistence resource schema.
*
* @param string $propertyPath The dot-notation property path to check
* @param array $resourceSchema The resource schema to to check the property path against
* @param bool $onlySearchable Set to true if only searchable (string) leafs should be returned
* @return bool
*/
protected function isInPersistenceSchema($propertyPath, array $resourceSchema, $onlySearchable = false)
{
if ($propertyPath === '') return false;
$propertyPathParts = explode('.', $propertyPath);
foreach ($propertyPathParts as $pathPart) {
if ($pathPart === '__identity') {
return true;
}
if (!isset($resourceSchema[$pathPart]) || $resourceSchema[$pathPart]['transient'] === true) {
return false;
}
if (isset($resourceSchema[$pathPart]['schema'])) {
$resourceSchema = &$resourceSchema[$pathPart]['schema'];
}
}
$lastPart = end($propertyPathParts);
if ($onlySearchable && $resourceSchema[$lastPart]['type'] !== 'string') {
return false;
}
return ($resourceSchema[$lastPart]['multiValued'] === false);
}
/**
* @param array $resourceSchema The resource aggregate schema
* @param bool $onlySearchable Set to true if only searchable (string) leafs should be returned
* @return array An array of property paths inside this resources persistence schema
*/
protected function getPersistencePropertyPaths(array $resourceSchema, $onlySearchable = false)
{
$propertyPaths = array();
foreach ($resourceSchema as $propertyName => $property) {
if ($property['transient']) continue;
/*if ($property['multiValued']) {
$propertyName .= '.*';
}*/
if (isset($property['schema']) && is_array($property['schema'])) {
$subPropertyPaths = $this->getPersistencePropertyPaths($property['schema'], $onlySearchable);
foreach ($subPropertyPaths as $subPropertyPath) {
$propertyPaths[] = $propertyName . '.' . $subPropertyPath;
}
} elseif ($property['multiValued'] === false) {
if ($onlySearchable && ($property['type'] !== 'string' || $property['identity'] === true)) {
continue;
}
$propertyPaths[] = $propertyName;
}
}
return $propertyPaths;
}
/**
* Get a list of properties and the values to filter the results by, depending on the given action arguments.
* @param array $resourceProperties The allowed filter properties to use
* @return array
*/
protected function getPropertyFilters(array $resourceProperties)
{
$filters = array_merge(array(), $this->resourceEntityDefaultFilter);
// Note: This work-around is necessary, because PHP converts all dots in query parameters to underscores.
// Since we want to use dot-notation for filtering by subproperties, we need to parse the query string ourself.
// See http://ca.php.net/variables.external#example-123
$query = $this->request->getHttpRequest()->getUri()->getQuery();
if ($query == '') return $filters;
$arguments = explode('&', $query);
foreach ($arguments as $argumentString) {
list($argumentName, $argumentValue) = explode('=', urldecode($argumentString), 2);
if ($argumentName === static::$RESOURCE_ENTITY_IDENTIFIER) {
$argumentName = '__identity';
} elseif ($argumentName !== '__identity') {
$argumentName = str_replace('_', '.', $argumentName); // TODO: Check if this is really necessary (see bedf1b210814e1e6815dca4602f05cd23830069a)
$argumentName = str_replace(array('.' . static::$RESOURCE_ENTITY_IDENTIFIER), '.__identity', $argumentName);
}
if ($this->isInPersistenceSchema($argumentName, $resourceProperties)) {
$filters[$argumentName] = $argumentValue;
}
}
return $filters;
}
/**
* Get a list of properties and the values to filter the results by, depending on the given action arguments.
* @param array $resourceProperties The allowed search properties to use
* @return array
*/
protected function getPropertySearchFields(array $resourceProperties)
{
$filters = array();
// Note: This work-around is necessary, because PHP converts all dots in query parameters to underscores.
// Since we want to use dot-notation for filtering by subproperties, we need to parse the query string ourself.
// See http://ca.php.net/variables.external#example-123
$query = $this->request->getHttpRequest()->getUri()->getQuery();
if ($query == '') return $filters;
if (preg_match('/' . static::$SEARCH_FIELDS_ARGUMENT_NAME . '=([^&]*)/', $query, $matches) > 0) {
$search = explode(',', str_replace('.' . static::$RESOURCE_ENTITY_IDENTIFIER, '.__identity', urldecode($matches[1])));
$searchProperties = array();
foreach ($search as $searchProperty) {
if ($this->isInPersistenceSchema($searchProperty, $resourceProperties, true)) {
$searchProperties[] = $searchProperty;
}
}
} else {
$searchProperties = $this->getPersistencePropertyPaths($resourceProperties, true);
}
return $searchProperties;
}
/**
* Get a list of properties and the directions to sort the results by, depending on the given action arguments.
* @param array $resourceProperties The allowed sorting properties to use
* @return array
*/
protected function getPropertyOrderings(array $resourceProperties)
{
$orderings = array();
if ($this->request->hasArgument(static::$SORTING_ARGUMENT_NAME)) {
$sortColumns = explode(',', $this->request->getArgument(static::$SORTING_ARGUMENT_NAME));
foreach ($sortColumns as $columnName) {
$order = QueryInterface::ORDER_ASCENDING;
if ($columnName[0] === '-') {
$order = QueryInterface::ORDER_DESCENDING;
$columnName = substr($columnName, 1);
}
if ($this->isInPersistenceSchema($columnName, $resourceProperties)) {
$orderings[$columnName] = $order;
}
}
}
return $orderings;
}
/**
* Get a possibly paginated list of resource entities.
*
* Examples:
* GET /api/{resource}/?limit=50&last={lastSeenCursor}
* -> returns up to 50 resources starting from the entity with the cursor property {lastSeenCursor} ordered by the cursor property
* GET /api/{resource}/?limit=20&last={lastSeenCursor}&lastId[]={identifier}&dir=DESC
* -> returns up to 20 resources before the entity with identity {identifier} ordered by the cursor property
*
* This way of pagination is especially useful for showing an indefinite and probably large amount of resources,
* that can for example be scrolled through like comments or pictures in a gallery. It will also provide a stable
* pagination (no duplications/jumps), even when items are inserted or removed in between pagination requests.
*
* This method has linear performance characteristics on the number of shown items only, rather than the offset to
* start returning items from as with offset pagination (@see filterAction).
*
* @TODO: See Facebooks Graph-API for a good implementation example https://developers.facebook.com/docs/graph-api/using-graph-api#paging
*
* @param string $cursor The property to use as pagination cursor. Defaults to $resourceEntityCursorProperty. The results will be ordered by this property.
* @param string $last The cursor value of the last visible item.
* @param array $lastId The identity of the last visible item. Only needed if the cursor property is not unique.
* @param string $dir The direction to paginate, ASC for forward, DESC for backward from $last
* @param integer $limit The number of items to load.
* @param string $fields A comma separated list of resource properties to include in the results
* @param string $embed A comma separated list of related resource properties to embed into the results
* @return void An array of resource entities matching the given cursor pagination
*/
public function listAction($cursor = null, $last = null, $lastId = null, $dir = 'ASC', $limit = null)
{
$resourceProperties = static::resourceEntityPropertiesDescription($this->objectManager);
$filters = $this->getPropertyFilters($resourceProperties);
$cursorProperty = $cursor !== null ? $cursor : $this->resourceEntityCursorProperty;
if ($cursorProperty === static::$RESOURCE_ENTITY_IDENTIFIER) {
$cursorProperty = '__identity';
}
$values = $this->repository->findByCursorPagination($cursorProperty, $limit, $dir, $last, $lastId, $filters);
// Build pagination links and return them as "Link" Header
$lastResource = end($values);
$firstResource = reset($values);
if ($lastResource && ($limit === null || count($values) >= $limit)) {
$lastIdentity = $this->persistenceManager->getIdentifierByObject($lastResource);
if ($cursorProperty === '__identity') {
$lastCursor = is_array($lastIdentity) ? reset($lastIdentity) : $lastIdentity;
} else {
$lastCursor = ObjectAccess::getProperty($lastResource, $cursorProperty);
if ($lastCursor instanceof \DateTime) {
$lastCursor = $lastCursor->format(\DateTime::ATOM);
}
}
$nextPageUri = $this->uriBuilder->setCreateAbsoluteUri(true)->uriFor('index', array('cursor' => $cursor, 'limit' => $limit, 'dir' => $dir === 'ASC' ? null : $dir, 'last' => $lastCursor, 'lastId' => $lastId !== null ? $lastIdentity : null));
$this->response->getHeaders()->set('Link', sprintf('<%s>; rel="next"', $nextPageUri), false);
}
if ($firstResource) {
$lastIdentity = $this->persistenceManager->getIdentifierByObject($firstResource);
if ($cursorProperty === '__identity') {
$lastCursor = is_array($lastIdentity) ? reset($lastIdentity) : $lastIdentity;
} else {
$lastCursor = ObjectAccess::getProperty($lastResource, $cursorProperty);
if ($lastCursor instanceof \DateTime) {
$lastCursor = $lastCursor->format(\DateTime::ATOM);
}
}
$prevPageUri = $this->uriBuilder->setCreateAbsoluteUri(true)->uriFor('index', array('cursor' => $cursor, 'limit' => $limit, 'dir' => $dir === 'ASC' ? 'DESC' : null, 'last' => $lastCursor, 'lastId' => $lastId !== null ? $lastIdentity : null));
$this->response->getHeaders()->set('Link', sprintf('<%s>; rel="prev"', $prevPageUri), false);
}
$this->view->assign('values', $values);
$this->setCollectionReturnValue();
}
/**
* Build a type converter configuration to allow creation of subentities given a descend view configuration for the aggregate.
*
* @param PropertyMappingConfiguration $configuration
* @param array $descendConfiguration
*/
protected function configureTypeConverterByAggregateBoundary(PropertyMappingConfiguration $configuration, array $descendConfiguration)
{
foreach ($descendConfiguration as $propertyName => $subConfiguration)
{
if ($propertyName === '_descendAll') {
$propertyName = PropertyMappingConfiguration::PROPERTY_PATH_PLACEHOLDER;
}
if (isset($subConfiguration['_descend'])) {
$configuration->forProperty($propertyName)->allowAllProperties();
$configuration->forProperty($propertyName)->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_CREATION_ALLOWED, true);
if (isset($subConfiguration['_exposeObjectIdentifier'])) {
$configuration->forProperty($propertyName)->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_IDENTITY_CREATION_ALLOWED, true);
}
$subConfiguration = $subConfiguration['_descend'];
}
if (is_array($subConfiguration)) {
$this->configureTypeConverterByAggregateBoundary($configuration->forProperty($propertyName), $subConfiguration);
}
}
}
protected function initializeCreateAction()
{
if (!$this->request->hasArgument(static::$RESOURCE_ARGUMENT_NAME) && !$this->request->hasArgument(static::$RESOURCES_ARGUMENT_NAME)) {
$this->throwStatus(400, 'No resource specified', '');
}
/* @var $configuration PropertyMappingConfiguration */
if ($this->request->hasArgument(static::$RESOURCE_ARGUMENT_NAME)) {
$configuration = $this->arguments->getArgument(static::$RESOURCE_ARGUMENT_NAME)->getPropertyMappingConfiguration();
$configuration->allowAllProperties();
$configuration->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_CREATION_ALLOWED, true);
$configuration->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_IDENTITY_CREATION_ALLOWED, true);
$descendConfiguration = static::resourceEntityDescendConfiguration($this->objectManager);
$this->configureTypeConverterByAggregateBoundary($configuration, $descendConfiguration);
} else {
// Configuration for resources array
$configuration = $this->arguments->getArgument(static::$RESOURCES_ARGUMENT_NAME)->getPropertyMappingConfiguration();
$configuration->allowAllProperties();
$configuration = $configuration->forProperty('*')
->allowAllProperties()
->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_TARGET_TYPE, static::$RESOURCE_ENTITY_CLASS)
->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_CREATION_ALLOWED, true)
->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_IDENTITY_CREATION_ALLOWED, true);
$descendConfiguration = static::resourceEntityDescendConfiguration($this->objectManager);
$this->configureTypeConverterByAggregateBoundary($configuration, $descendConfiguration);
}
}
/**
* Create a new resource entity. This will return the created entity including the identity.
*
* The identifier can be supplied with the request:
* POST /api/{resource}/{identifier}/ resource[property1]=value1&resource[property2]=value2
*
* This allows for fully idempotent create requests and fits well with CQRS designs, where the client is responsible for creating aggregate identifiers
*
* @return null|string The created entity together with a Location header pointing to the new resource location
*/
public function createAction()
{
$resource = $this->getResourceEntity();
if ($resource === null) {
$resources = $this->getResources();
foreach ($resources as $resource) {
if (!$this->persistenceManager->isNewObject($resource)) {
$this->response->setStatus(409);
$this->response->setHeader('X-Resource-Identifier', $this->persistenceManager->getIdentifierByObject($resource));
return '';
}
$this->repository->add($resource);
}
$this->response->setStatus(201);
$resourceUri = $this->uriBuilder->reset()
->setFormat($this->request->getFormat())
->setCreateAbsoluteUri(true)
->uriFor('index');
$this->response->setHeader('Location', $resourceUri);
$this->view->assign('values', $resources);
$this->setCollectionReturnValue();
} else {
if (!$this->persistenceManager->isNewObject($resource)) {
$this->response->setStatus(409);
return '';
}
$this->repository->add($resource);
$this->response->setHeader('X-Resource-Identifier', $this->persistenceManager->getIdentifierByObject($resource));
$this->response->setStatus(201);
$resourceUri = $this->uriBuilder->reset()
->setFormat($this->request->getFormat())
->setCreateAbsoluteUri(true)
->uriFor('index', array('resource' => $resource));
$this->response->setHeader('Location', $resourceUri);
$this->view->assign('value', $resource);
}
return null;
}
protected function initializeUpdateAction()
{
if (!$this->request->hasArgument(static::$RESOURCE_ARGUMENT_NAME)) {
$this->throwStatus(400, 'No resource specified', '');
}
/* @var $configuration PropertyMappingConfiguration */
$configuration = $this->arguments->getArgument(static::$RESOURCE_ARGUMENT_NAME)->getPropertyMappingConfiguration();
$configuration->allowAllProperties();
$configuration->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_MODIFICATION_ALLOWED, true);
}
/**
* Update an existing resource entity.
*
* Examples:
* PUT|PATCH /api/{resource}/{identifier}/ resource[property1]=value1&resource[property2]=value2
*
* @return string An empty body
*/
public function updateAction()
{
$resource = $this->getResourceEntity();
$this->repository->update($resource);
return '';
}
/**
* Delete an existing resource entity.
*
* Examples:
* DELETE /api/{resource}/{identifier}/
*
* @return string An empty body with status 204
*/
public function removeAction()
{
if (!$this->request->hasArgument(static::$RESOURCE_ARGUMENT_NAME)) {
$this->throwStatus(400, 'No resource specified', '');
}
$resource = $this->getResourceEntity();
$this->repository->remove($resource);
$this->response->setStatus(204);
return '';
}
/**
* Search entities by arbitrary search query with simplified boolean logic.
* TODO: Implement functionality using elastic search
*
* @param string $query A search query that will be tokenized and searched for. May prefix terms with "+" to denote logical AND or "-" to denote logical NOT
* @param string $sort A comma separated list of resource properties to sort by, possibly prefixed with "-" to denote descending order
* @param int $limit A number limiting the amount of resources returned at once
* @param int $offset A number describing how many resources to skip at the start from the results
* @param string $fields A comma separated list of resource properties to include in the results
* @param string $embed A comma separated list of related resource properties to embed into the results
* @return void An object containing the list of terms that got tokenized, the fields that were searched in and an array of resource entities matching the given search query
*/
public function searchAction($query)
{
$resourceProperties = static::resourceEntityPropertiesDescription($this->objectManager);
$searchProperties = $this->getPropertySearchFields($resourceProperties);
preg_match_all('/([-+]?"[^"]+"|[^\s]+)\s*/', $query, $matches);
$searchTerms = array();
foreach ($matches[1] as $queryToken) {
$type = $queryToken[0] === '+' ? '+' : ($queryToken[0] === '-' ? '-' : '*');
$queryToken = ltrim($queryToken, '+-');
$searchTerms[$type][] = trim($queryToken, '"');
}
// strip duplicate terms
$searchTerms = array_map('array_unique', $searchTerms);
$orderings = $this->getPropertyOrderings($resourceProperties);
$limit = null;
$offset = null;
if ($this->request->hasArgument(static::$LIMIT_ARGUMENT_NAME)) {
$limit = $this->request->getArgument(static::$LIMIT_ARGUMENT_NAME);
}
if ($this->request->hasArgument(static::$OFFSET_ARGUMENT_NAME)) {
$offset = $this->request->getArgument(static::$OFFSET_ARGUMENT_NAME);
}
$resources = $this->repository->findBySearch($searchTerms, $searchProperties, $orderings, $limit, $offset);
$result = array(
'terms' => $searchTerms,
'fields' => $searchProperties,
'results' => $resources
);
$this->view->assign('result', $result);
if ($this->view instanceof JsonView) {
$this->view->setConfiguration(array('result' => array('results' => array('_descendAll' => $this->resourceEntityConfiguration))));
$this->view->setVariablesToRender(array('result'));
}
}
/**
* Find entities by property filters, possibly ordered by one or more properties.
* Results can optionally be paginated with limit and offset.
*
* Examples:
* GET /api/{resource}/filter/?property1=value%&sort=-property2&limit=40&offset=120
* -> get 40 items that have property1 match "value%", sorted descending by property2 and starting at position 120
*
* Attention: This is the most flexible querying method, but also the worst regarding performance,
* especially if arguments are chosen poorly. Also, offset pagination has linear performance to the offset at which
* it starts returning items. See for example http://blog.novatec-gmbh.de/art-pagination-offset-vs-value-based-paging/
*
* @param string $sort A comma separated list of resource properties to sort by, possibly prefixed with "-" to denote descending order
* @param int $limit A number limiting the amount of resources returned at once
* @param int $offset A number describing how many resources to skip at the start from the results
* @param string $fields A comma separated list of resource properties to include in the results
* @param string $embed A comma separated list of related resource properties to embed into the results
* @return void An array of resource entities matching the given property filters and sorting
*/
public function filterAction()
{
$resourceProperties = static::resourceEntityPropertiesDescription($this->objectManager);
$filters = $this->getPropertyFilters($resourceProperties);
$orderings = $this->getPropertyOrderings($resourceProperties);
$limit = null;
$offset = null;
if ($this->request->hasArgument(static::$LIMIT_ARGUMENT_NAME)) {
$limit = $this->request->getArgument(static::$LIMIT_ARGUMENT_NAME);
}
if ($this->request->hasArgument(static::$OFFSET_ARGUMENT_NAME)) {
$offset = $this->request->getArgument(static::$OFFSET_ARGUMENT_NAME);
}
$this->view->assign('values', $this->repository->findByFilter($filters, $orderings, $limit, $offset));
$this->setCollectionReturnValue();
}
/**
* Maps requests to the actual CRUD actions depending on request method.
*
* @return string The action method name
* @throws \Neos\Flow\Mvc\Exception\NoSuchActionException if the action specified in the request object does not exist (and if there's no default action either).
*/
protected function resolveActionMethodName()
{
if ($this->request->getHttpRequest()->getMethod() === 'OPTIONS') {
$this->request->setControllerActionName('options');
}
if ($this->request->getControllerActionName() === 'index') {
$actionName = 'index';
switch ($this->request->getHttpRequest()->getMethod()) {
case 'HEAD':
case 'GET':
$actionName = ($this->request->hasArgument(static::$RESOURCE_ARGUMENT_NAME)) ? 'show' : 'list';
break;
case 'POST':
$actionName = 'create';
break;
case 'PUT':
case 'PATCH':
$actionName = 'update';
break;
case 'DELETE':
$actionName = 'remove';
break;
}
$this->request->setControllerActionName($actionName);
}
return parent::resolveActionMethodName();
}
}
<?php
namespace Acme\Api\Utility;
use Neos\Flow\Reflection\ClassSchema;
use Neos\Flow\Reflection\ReflectionService;
use Neos\Utility\TypeHandling;
use Neos\Flow\Annotations as Flow;
/**
* A class for reflecting aggregates up to their boundaries
*
* @package Acme\Api\Utility
* @Flow\Scope("singleton")
*/
class AggregateReflectionHelper
{
/**
* @var ReflectionService
* @Flow\Inject
*/
protected $reflectionService;
/**
* @var string
*/
protected $identifierName = 'uuid';
/**
* Injection setter necessary for compile time usage of this class.
* @param ReflectionService $reflectionService
*/
public function injectReflectionService(ReflectionService $reflectionService)
{
$this->reflectionService = $reflectionService;
}
/**
* @param string $identifierName
* @return $this
*/
public function withIdentifierName($identifierName)
{
$this->identifierName = $identifierName;
return $this;
}
/**
* @param ClassSchema $classSchema Class schema of the current entity that is visited
* @param int $recursionDepth A counter to stop at a probably cyclic recursion
* @param array $visitedTypes Array given by reference that will hold all visited class types in order to prevent cyclic schemas
* @param array $propertyDescriptions Array given by reference that will hold the property reflection information for this class schema and it's children
*/
protected function iterateAggregateBoundaryPropertiesRecursively(ClassSchema $classSchema, $recursionDepth, array &$visitedTypes, array &$propertyDescriptions)
{
if (++$recursionDepth >= 100) {
throw new \Exception(sprintf('Cyclic references detected in schema for class "%s".', $classSchema->getClassName()));
}
$identityProperties = array_keys($classSchema->getIdentityProperties());
foreach ($classSchema->getProperties() as $propertyName => $property) {
$property['identity'] = in_array($propertyName, $identityProperties);
$property['multiValued'] = $classSchema->isMultiValuedProperty($propertyName);
$propertyType = $property['type'];
if ($classSchema->isMultiValuedProperty($propertyName)) {
$propertyType = $property['elementType'] ? : $propertyType;
}
unset($property['lazy']); // Irrelevant for structural schema
if ($propertyName === 'Persistence_Object_Identifier') {
$propertyName = $this->identifierName;
$property['identity'] = true;
}
$propertyDescriptions[$propertyName] = $property;
if (TypeHandling::isSimpleType($propertyType)) {
continue;
}
$propertyClassSchema = $this->reflectionService->getClassSchema($propertyType);
if ($propertyClassSchema === null) {
continue;
}
if ($propertyClassSchema->getModelType() === ClassSchema::MODELTYPE_ENTITY &&
(isset($visitedTypes[$propertyType]) || $propertyClassSchema->isAggregateRoot())) {
$propertyDescriptions[$propertyName]['schema'] = $propertyType;
continue;
}
$propertyDescriptions[$propertyName]['schema'] = array();
$visitedTypes[$propertyType] = true;
$this->iterateAggregateBoundaryPropertiesRecursively($propertyClassSchema, $recursionDepth, $visitedTypes, $propertyDescriptions[$propertyName]['schema']);
unset($visitedTypes[$propertyType]);
}
}
/**
* Compile a class schema up to the Aggregate boundaries.
* This will traverse objects deeply and create a cyclic schema for non-aggregates.
*
* @param string $className The name of the class to get the configuration for from the class schema
* @return array The class schema for the whole Aggregate
*/
public function reflectAggregate($className)
{
$classSchema = $this->reflectionService->getClassSchema($className);
if ($classSchema === null) {
return array();
}
$propertyDescriptions = array();
$visitedTypes = array($className => true);
$this->iterateAggregateBoundaryPropertiesRecursively($classSchema, 0, $visitedTypes, $propertyDescriptions);
return $propertyDescriptions;
}
}
<?php
namespace Acme\Api\Domain\Repository;
use Neos\Flow\Core\Bootstrap;
use Neos\Flow\Persistence\QueryInterface;
use Neos\Flow\Persistence\Repository;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Reflection\ReflectionService;
/**
* A generic REST resource entity repository, that allows for easily paginating through the entities with cursor-based pagination
* See http://blog.novatec-gmbh.de/art-pagination-offset-vs-value-based-paging/ for more information.
*
* @package Acme\Api\Domain\Repository
* @Flow\Scope("singleton")
*/
class ResourceRepository extends Repository
{
/**
* Array of property names that belong to the identity of the resource.
*
* Override this if you want to avoid reflection usage and have a custom identity on your entity.
*
* @var array
*/
protected $identityProperties;
/**
* @param string $resourceEntityClassName
*/
public function __construct($resourceEntityClassName)
{
parent::__construct();
$this->entityClassName = $resourceEntityClassName;
if ($this->identityProperties === null) {
if (property_exists($resourceEntityClassName, 'Persistence_Object_Identifier')) {
$this->identityProperties = array('Persistence_Object_Identifier');
} else {
/* @var $reflectionService ReflectionService */
$reflectionService = Bootstrap::$staticObjectManager->get(ReflectionService::class);
$classSchema = $reflectionService->getClassSchema($this->entityClassName);
$this->identityProperties = $classSchema->getIdentityProperties();
}
}
}
/**
* Build a constraint that will correctly paginate the next results starting from $lastCursor in the given direction.
* If $lastItem is given, then the cursorProperty is assumed to not be unique and the identity of the last item has to be taken into account.
* If the entity has a compound identity, $lastItemIdentity needs to be provided as associative array of all identity properties.
*
* @param QueryInterface $query
* @param string $cursorProperty
* @param string $lastCursor
* @param array|string|null $lastItemIdentity
* @param string $dir
* @return object
* @throws RepositoryException
*/
protected function buildConstraintForCursorPagination($query, $cursorProperty, $lastCursor, $lastItemIdentity, $dir)
{
$comparator = ($dir === 'ASC') ? 'greaterThan' : 'lessThan';
if ($lastItemIdentity !== null) {
$constraints[] = ($dir === 'ASC') ? $query->greaterThanOrEqual($cursorProperty, $lastCursor) : $query->lessThanOrEqual($cursorProperty, $lastCursor);
if (count($this->identityProperties) === 1) {
if (is_array($lastItemIdentity)) {
$lastItemIdentity = reset($lastItemIdentity);
}
$constraints[] = $query->logicalOr($query->$comparator($cursorProperty, $lastCursor), $query->$comparator(reset($this->identityProperties), $lastItemIdentity));
} else {
// Compound identity case... ugh
if (!is_array($lastItemIdentity) || count($lastItemIdentity) < count($this->identityProperties)) {
throw new RepositoryException('Resource entity "' . $this->entityClassName . '" has a compound identity, but the given identity is a scalar (' . var_export($lastItemIdentity, true) . ')');
}
$subConstraints = array();
foreach ($this->identityProperties as $identity) {
$subConstraints[] = $query->$comparator($identity, $lastItemIdentity[$identity]);
}
$constraints[] = $query->logicalOr($query->$comparator($cursorProperty, $lastCursor), $query->logicalAnd($subConstraints));
}
return $query->logicalAnd($constraints);
} else {
// If the cursor is a unique property, everything is easy
return $query->$comparator($cursorProperty, $lastCursor);
}
}
/**
* @param QueryInterface $query
* @param string $property The property name to query for
* @param mixed $value The property value(s) to check against
* @return object
* @throws RepositoryException If the entity identifier is incompatible with the provided identity
*/
protected function buildIdentityMatchingConstraint(QueryInterface $query, $property, $value) {
if (substr($property, -10) !== '__identity') {
return null;
}
// TODO: Check if we can just replace with "IDENTITY(${substr($property, 0, -10)})", ie. if "IDENTITY()" works
if (strlen($property) > 10) {
// If we match against a subentity we can just directly compare and strip the '__identity'
return $query->equals(str_replace('__identity', '', $property), $value);
}
// If we try to match the main entity, we need to add constraints for all identity properties specifically
// because otherwise we would end up with a query "'' EQUALS {$someIdentity}"
if (count($this->identityProperties) === 1) {
if (is_array($value)) {
$value = reset($value);
}
return $query->equals(reset($this->identityProperties), $value);
}
if (!is_array($value) || count($value) < count($this->identityProperties)) {
throw new RepositoryException('Resource entity "' . $this->entityClassName . '" has a compound identity, but the given identity is a scalar (' . var_export($value, true) . ')');
}
$subConstraints = array();
foreach ($this->identityProperties as $identity) {
if (!isset($value[$identity])) {
throw new RepositoryException('Resource entity "' . $this->entityClassName . '" has a compound identity, but the given identity misses the identity property "' . $identity . '"."');
}
$subConstraints[] = $query->equals($identity, $value[$identity]);
}
return $query->logicalAnd($subConstraints);
}
/**
* Paginate results by the given cursor property. For optimal performance, an index ($cursorProperty, $identity) should exist on the target entity table.
* Note that on InnoDB tables, the primary key (identity) will automatically be appended to any secondary index.
*
* @param string $cursorProperty The property that should be sorted and paginated by. If this is a unique property, $lastItem is not needed and will degrade performance.
* @param integer|null $limit The max number of elements to return.
* @param string $dir The direction to paginate. ASC will return the next items following $lastCursor, DESC will return the previous items before $lastCursor.
* @param string $lastCursor The last cursor value that was visible.
* @param string|array $lastItem If cursorProperty is not unique, the last items identity property should be given too to avoid duplicate results.
* @param array $filters Array of properties and values to filter by.
* @param array $orderings
* @return array An array of resource entities
*/
public function findByCursorPagination($cursorProperty, $limit = null, $dir = 'ASC', $lastCursor = null, $lastItem = null, array $filters = array(), array $orderings = array())
{
if ($cursorProperty === null || $cursorProperty === '__identity') {
$cursorProperty = reset($this->identityProperties);
}
$query = $this->createQuery();
$constraints = array();
foreach ($filters as $filterProperty => $filterValue) {
if (substr($filterProperty, -10) === '__identity') {
$constraints[] = $this->buildIdentityMatchingConstraint($query, $filterProperty, $filterValue);
} elseif (is_numeric($filterValue) || is_bool($filterValue)) {
$constraints[] = $query->equals($filterProperty, $filterValue);
} else {
$constraints[] = $query->like($filterProperty, $filterValue, false);
}
}
if ($lastCursor !== null) {
$constraints[] = $this->buildConstraintForCursorPagination($query, $cursorProperty, $lastCursor, $lastItem, $dir);
$orderings = array($cursorProperty => $dir);
foreach ($this->identityProperties as $identity) {
$orderings[$identity] = $dir;
}
} else {
$orderings = array($cursorProperty => $dir);
foreach ($this->identityProperties as $identity) {
$orderings[$identity] = $dir;
}
}
if ($constraints !== array()) {
$query->matching($query->logicalAnd($constraints));
}
$query->setOrderings($orderings);
if ($limit > 0) {
$query->setLimit($limit);
}
$result = $query->execute()->toArray();
if ($lastItem !== null && $dir !== 'ASC') {
return array_reverse($result);
}
return $result;
}
/**
* Filter and sort results by custom values.
*
* @param array $filters Array of properties and values to filter by. All filters must match.
* @param array $orderings Array of properties and direction to sort by.
* @param integer|null $limit The max number of elements to return.
* @param integer|null $offset The element number to start from.
* @return array
*/
public function findByFilter(array $filters, array $orderings = array(), $limit = null, $offset = null)
{
$query = $this->createQuery();
$constraints = array();
foreach ($filters as $filterProperty => $filterValue) {
if (substr($filterProperty, -10) === '__identity') {
$constraints[] = $this->buildIdentityMatchingConstraint($query, $filterProperty, $filterValue);
} elseif (is_numeric($filterValue) || is_bool($filterValue)) {
$constraints[] = $query->equals($filterProperty, $filterValue);
} else {
$constraints[] = $query->like($filterProperty, $filterValue, false);
}
}
if ($constraints !== array()) {
$query->matching($query->logicalAnd($constraints));
}
if ($orderings !== array()) {
$query->setOrderings($orderings);
}
if ($limit > 0) {
$query->setLimit($limit);
}
if ($offset !== null && $offset >= 0) {
$query->setOffset($offset);
}
$result = $query->execute()->toArray();
return $result;
}
/**
* Search and sort results by a list of search terms and search properties.
*
* @param array $searchTerms Array of terms to search for. Any filter must match.
* @param array $searchProperties Array of properties to search in.
* @param array $orderings Array of properties and direction to sort by.
* @param integer|null $limit The max number of elements to return.
* @param integer|null $offset The element number to start from.
* @return array
*/
public function findBySearch(array $searchTerms, array $searchProperties, array $orderings = array(), $limit = null, $offset = null)
{
$query = $this->createQuery();
// Search for identity should be done with a filter instead of search
$searchProperties = array_filter($searchProperties, function($searchProperty) {
return substr($searchProperty, -11) !== '.__identity';
});
$requiredConstraints = array();
$optionalConstraints = array();
if (isset($searchTerms['*'])) {
foreach ($searchTerms['*'] as $searchTerm) {
$constraints = array();
foreach ($searchProperties as $searchProperty) {
$constraints[] = $query->like($searchProperty, '%' . $searchTerm . '%', false);
}
// Search term may occur in any property
$optionalConstraints[] = $query->logicalOr($constraints);
}
}
if (isset($searchTerms['+'])) {
foreach ($searchTerms['+'] as $searchTerm) {
$constraints = array();
foreach ($searchProperties as $searchProperty) {
$constraints[] = $query->like($searchProperty, '%' . $searchTerm . '%', false);
}
// Search term must occur in any property
$requiredConstraints[] = $query->logicalOr($constraints);
}
}
if (isset($searchTerms['-'])) {
foreach ($searchTerms['-'] as $searchTerm) {
$constraints = array();
foreach ($searchProperties as $searchProperty) {
// We'd need to coalesce the like statement, because "NOT(NULL LIKE '%')" will be NULL and hence never match.
// Unfortunately, Doctrine won't parse the resulting DQL correctly and error out and Flow currently doesn't provide
// a method to coalesce properties (https://github.com/neos/flow-development-collection/issues/616).
//$like = new \Doctrine\ORM\Query\Expr\Func('COALESCE', array($query->like($searchProperty, '%' . $searchTerm . '%', false), "''"));
$like = $query->like($searchProperty, '%' . $searchTerm . '%', false);
// The workaround for now is to add a ORed NULL check
$constraints[] = $query->logicalOr($query->equals($searchProperty, null), $query->logicalNot($like));
}
// Search term may not occur in any properties
$requiredConstraints[] = $query->logicalAnd($constraints);
}
}
if ($optionalConstraints !== array()) {
// Any search constraint must match
$requiredConstraints[] = $query->logicalOr($optionalConstraints);
}
if ($requiredConstraints !== array()) {
// All search constraints must match
$query->matching($query->logicalAnd($requiredConstraints));
}
if ($orderings !== array()) {
$query->setOrderings($orderings);
}
if ($limit > 0) {
$query->setLimit($limit);
}
if ($offset !== null && $offset >= 0) {
$query->setOffset($offset);
}
$result = $query->execute()->toArray();
return $result;
}
}
<?php
namespace Acme\Api\Utility;
use Neos\Flow\Reflection\ClassSchema;
use Neos\Flow\Reflection\ReflectionService;
use Neos\Utility\TypeHandling;
use Neos\Flow\Annotations as Flow;
/**
* A class for basic utility methods to create JSON view configurations
*
* @package Acme\Api\Utility
* @Flow\Scope("singleton")
*/
class ViewConfigurationHelper
{
/**
* @var ReflectionService
* @Flow\Inject
*/
protected $reflectionService;
/**
* @var string
*/
protected $identifierName = 'uuid';
/**
* Injection setter necessary for compile time usage of this class.
* @param ReflectionService $reflectionService
*/
public function injectReflectionService(ReflectionService $reflectionService)
{
$this->reflectionService = $reflectionService;
}
/**
* @param string $identifierName
* @return $this
*/
public function withIdentifierName($identifierName)
{
$this->identifierName = $identifierName;
return $this;
}
/**
* Converts a property path list into a JSON view configuration.
* Example:
* "some.property,some.other" => array( 'some' => array( '_descend' => array( 'property' => array( '_descend' => array() ), 'other' => array( '_descend' => array() ) ) )
*
* @param string $pathsString A list of dot-notation property paths, separated by ","
* @return array The view configuration matching the given property paths to visit
*/
public function convertPropertyPathsToViewConfiguration($pathsString)
{
if ($pathsString[0] === '*') {
throw new \Exception('Invalid path. Path may not start with wildcard.');
}
$descendConfiguration = array();
$propertyPaths = explode(',', $pathsString);
foreach ($propertyPaths as $descendPath) {
$pathParts = explode('.', $descendPath);
$currentPathConfiguration = &$descendConfiguration;
foreach ($pathParts as $pathPart) {
$descend = '_descend';
if (isset($currentPathConfiguration['_descendAll']) || $pathPart === '*') {
$descend = '_descendAll';
}
if (!isset($currentPathConfiguration[$descend])) {
$currentPathConfiguration[$descend] = array();
}
if ($pathPart === '*') {
$currentPathConfiguration = &$currentPathConfiguration[$descend];
continue;
}
if (!isset($currentPathConfiguration[$descend][$pathPart])) {
$currentPathConfiguration[$descend][$pathPart] = array();
}
$currentPathConfiguration = &$currentPathConfiguration[$descend][$pathPart];
}
$currentPathConfiguration['_descend'] = array();
}
return $descendConfiguration['_descend'];
}
/**
* @param array $classSchema Class schema of the current entity that is visited
* @return array $configuration Array that will hold the created view configuration
*/
protected function iterateAggregateSchemaRecursively(array $classSchema)
{
$configuration = array();
foreach ($classSchema as $propertyName => $property) {
$propertyConfiguration = &$configuration;
$propertyType = $property['type'];
if ($property['multiValued']) {
$propertyType = $property['elementType'] ?: $propertyType;
$propertyConfiguration[$propertyName] = array('_descendAll' => array());
$propertyConfiguration = &$propertyConfiguration[$propertyName];
$propertyName = '_descendAll';
}
if (TypeHandling::isSimpleType($propertyType)) {
continue;
}
if (!isset($property['schema'])) {
$propertyConfiguration[$propertyName]['_descend'] = array();
continue;
}
$propertyConfiguration[$propertyName] = array(
'_exposeObjectIdentifier' => true,
'_exposedObjectIdentifierKey' => $this->identifierName
);
if (!is_array($property['schema'])) {
$propertyConfiguration[$propertyName]['_only'] = array();
continue;
}
$propertyConfiguration[$propertyName]['_descend'] = $this->iterateAggregateSchemaRecursively($property['schema']);
}
return $configuration;
}
/**
* Convert class schema up to the aggregate boundaries to a JSON view configuration.
* This will traverse non-entity objects deeply and non aggregate roots once per class name.
* Aggregate roots and revisited entity classes are only referenced by identifier.
*
* @param array $aggregateSchema An Aggregate schema as it is provided by the AggregateReflectionHelper
* @return array The view configuration matching the given Aggregate schema
*/
public function convertAggregateSchemaToViewConfiguration(array $aggregateSchema)
{
return $this->iterateAggregateSchemaRecursively($aggregateSchema);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment