Created
May 30, 2017 08:28
-
-
Save albe/c5e318685c126ffe4ce11ca3b16d68ed to your computer and use it in GitHub Desktop.
Automagic RESTful API for https://github.com/neos/flow Framework Aggregates (incomplete)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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