<?php
namespace CrudJsonApi\Listener;
use Cake\Core\Configure;
use Cake\Datasource\RepositoryInterface;
use Cake\Datasource\ResultSetDecorator;
use Cake\Datasource\ResultSetInterface;
use Cake\Event\Event;
use Cake\Http\Exception\BadRequestException;
use Cake\ORM\Association;
use Cake\ORM\Query;
use Cake\ORM\ResultSet;
use Cake\ORM\Table;
use Cake\ORM\TableRegistry;
use Cake\Utility\Hash;
use Cake\Utility\Inflector;
use CrudJsonApi\Listener\JsonApi\DocumentValidator;
use Crud\Error\Exception\CrudException;
use Crud\Event\Subject;
use Crud\Listener\ApiListener;
/**
* Extends Crud ApiListener to respond in JSON API format.
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
*/
class JsonApiListener extends ApiListener
{
/**
* Default configuration
*
* @var array
*/
protected $_defaultConfig = [
'detectors' => [
'jsonapi' => ['ext' => false, 'accepts' => 'application/vnd.api+json'],
],
'exception' => [
'type' => 'default',
'class' => 'Cake\Http\Exception\BadRequestException',
'message' => 'Unknown error',
'code' => 0,
],
'exceptionRenderer' => 'CrudJsonApi\Error\JsonApiExceptionRenderer',
'setFlash' => false,
'withJsonApiVersion' => false, // true or array/hash with additional meta information (will add top-level member `jsonapi` to the response)
'meta' => [], // array or hash with meta information (will add top-level node `meta` to the response)
'links' => [], // array or hash with link information (will add top-level node `links` to the response)
'absoluteLinks' => false, // false to generate relative links, true will generate fully qualified URL prefixed with http://domain.name
'jsonApiBelongsToLinks' => false, // false to generate JSONAPI links (requires custom Route, included)
'jsonOptions' => [], // array with predefined JSON constants as described at http://php.net/manual/en/json.constants.php
'debugPrettyPrint' => true, // true to use JSON_PRETTY_PRINT for generated debug-mode response
'include' => [],
'fieldSets' => [], // hash to limit fields shown (can be used for both `data` and `included` members)
'docValidatorAboutLinks' => false, // true to show links to JSON API specification clarifying the document validation error
'queryParameters' => [
'include' => [
'whitelist' => true,
'blacklist' => false,
]
], //Array of query parameters and associated transformers
'inflect' => 'dasherize'
];
/**
* True if the Controller has set a contain statement.
*
* @var bool
*/
protected $_ControllerHasSetContain;
/**
* Returns a list of all events that will fire in the controller during its lifecycle.
* You can override this function to add you own listener callbacks
*
* We attach at priority 10 so normal bound events can run before us
*
* @return array
*/
public function implementedEvents()
{
$this->setupDetectors();
// make sure the listener does absolutely nothing unless
// the application/vnd.api+json Accept header is used.
if (!$this->_checkRequestType('jsonapi')) {
return [];
}
// accept body data posted with Content-Type `application/vnd.api+json`
$this->_controller()->RequestHandler->setConfig('inputTypeMap', [
'jsonapi' => ['json_decode', true]
]);
return [
'Crud.beforeHandle' => ['callable' => [$this, 'beforeHandle'], 'priority' => 10],
'Crud.setFlash' => ['callable' => [$this, 'setFlash'], 'priority' => 5],
'Crud.beforeSave' => ['callable' => [$this, 'beforeSave'], 'priority' => 20],
'Crud.afterSave' => ['callable' => [$this, 'afterSave'], 'priority' => 90],
'Crud.afterDelete' => ['callable' => [$this, 'afterDelete'], 'priority' => 90],
'Crud.beforeRender' => ['callable' => [$this, 'respond'], 'priority' => 100],
'Crud.beforeRedirect' => ['callable' => [$this, 'beforeRedirect'], 'priority' => 100],
'Crud.beforePaginate' => ['callable' => [$this, 'beforeFind'], 'priority' => 10],
'Crud.beforeFind' => ['callable' => [$this, 'beforeFind'], 'priority' => 10],
'Crud.afterFind' => ['callable' => [$this, 'afterFind'], 'priority' => 50],
'Crud.afterPaginate' => ['callable' => [$this, 'afterFind'], 'priority' => 50],
];
}
/**
* setup
*
* Called when the listener is created
*
* @return void
*/
public function setup()
{
if (!$this->_checkRequestType('jsonapi')) {
return;
}
$appClass = Configure::read('App.namespace') . '\Application';
// If `App\Application` class exists it means Cake 3.3's PSR7 middleware
// implementation is used and it's too late to register new error handler.
if (!class_exists($appClass, false)) {
$this->registerExceptionHandler();
}
}
/**
* beforeHandle
*
* Called before the crud action is executed.
*
* @param \Cake\Event\Event $event Event
* @return void
*/
public function beforeHandle(Event $event)
{
$this->_checkRequestMethods();
$this->_validateConfigOptions();
$this->_checkRequestData();
}
/**
* afterFind() event used to make sure belongsTo relationships are shown when requesting
* a single primary resource. Does NOT execute when either a Controller has set `contain` or the
* `?include=` query parameter was passed because that would override/break previously generated data.
*
* @param \Cake\Event\Event $event Event
* @return null
*/
public function afterFind($event)
{
if (!$this->_request()->isGet()) {
return null;
}
// set property so we can check inside `_renderWithResources()`
if (!empty($event->getSubject()->query->getContain())) {
$this->_ControllerHasSetContain = true;
return null;
}
if ($this->getConfig('include')) {
return null;
}
}
/**
* beforeSave() event used to prevent users from sending `hasMany` relationships when POSTing and
* to prevent them from sending `hasMany` relationships not belonging to this primary resource
* when PATCHing.
*
* @param \Cake\Event\Event $event Event
* @return void
* @throws \Cake\Http\Exception\BadRequestException
*/
public function beforeSave($event)
{
// generate a flat list of hasMany relationships for the current model
$entity = $event->getSubject()->entity;
$hasManyAssociations = $this->_getAssociationsList($entity, [Association::ONE_TO_MANY]);
if (empty($hasManyAssociations)) {
return;
}
// must be PATCH so verify hasMany relationships before saving
foreach ($hasManyAssociations as $associationName) {
$key = Inflector::tableize($associationName);
// do nothing if association is not hasMany
if (!isset($entity->$key)) {
continue;
}
// prevent clients attempting to side-post/create related hasMany records
if ($this->_request()->getMethod() === 'POST') {
throw new BadRequestException("JSON API 1.1 does not support sideposting (hasMany relationships detected in the request body)");
}
// hasMany found in the entity, extract ids from the request data
$primaryResourceId = $this->_controller()->request->getData('id');
/** @var array $hasManyIds */
$hasManyIds = Hash::extract($this->_controller()->request->getData($key), '{n}.id');
$hasManyTable = TableRegistry::get($associationName);
// query database only for hasMany that match both passed id and the id of the primary resource
/** @var string $entityForeignKey */
$entityForeignKey = $hasManyTable->getAssociation($entity->getSource())->getForeignKey();
$query = $hasManyTable->find()
->select(['id'])
->where([
$entityForeignKey => $primaryResourceId,
'id IN' => $hasManyIds,
]);
// throw an exception if number of database records does not exactly matches passed ids
if (count($hasManyIds) !== $query->count()) {
throw new BadRequestException("One or more of the provided relationship ids for $associationName do not exist in the database");
}
// all good, replace entity data with fetched entities before saving
$entity->$key = $query->toArray();
// lastly, set the `saveStrategy` for this hasMany to `replace` so non-matching existing records will be removed
$repository = $event->getSubject()->query->getRepository();
$repository->getAssociation($associationName)->setSaveStrategy('replace');
}
}
/**
* afterSave() event.
*
* @param \Cake\Event\Event $event Event
* @return false|null
*/
public function afterSave($event)
{
if (!$event->getSubject()->success) {
return false;
}
// `created` will be set for add actions, `id` for edit actions
if (!$event->getSubject()->created && !$event->getSubject()->id) {
return false;
}
// The `add`action (new Resource) MUST respond with HTTP Status Code 201,
// see http://jsonapi.org/format/#crud-creating-responses-201
if ($event->getSubject()->created) {
$this->_controller()->response = $this->_controller()->response->withStatus(201);
}
/** @var \Crud\Event\Subject $subject */
$subject = $event->getSubject();
$this->render($subject);
}
/**
* afterDelete() event used to respond with 402 code and empty body.
*
* Please note that the JSON API spec allows for a 200 response with
* only meta node after a successful delete as well but this has not
* been implemented here yet. http://jsonapi.org/format/#crud-deleting
*
* @param \Cake\Event\Event $event Event
* @return false|null
*/
public function afterDelete(Event $event)
{
if (!$event->getSubject()->success) {
return false;
}
$this->_controller()->response = $this->_controller()->response->withStatus(204);
}
/**
* beforeRedirect() event used to stop the event and thus redirection.
*
* @param \Cake\Event\Event $event Event
* @return void
*/
public function beforeRedirect(Event $event)
{
$event->stopPropagation();
}
/**
* @param \Cake\ORM\Table $repository Repository
* @param string $include The association include path
* @return \Cake\ORM\Association|null
*/
protected function _getAssociation(Table $repository, $include)
{
$delimiter = '-';
if (strpos($include, '_') !== false) {
$delimiter = '_';
}
$associationName = Inflector::camelize($include, $delimiter);
if ($repository->hasAssociation($associationName)) {//First check base name
return $repository->getAssociation($associationName);
}
//If base name doesn't work, try to pluralize it
$associationName = Inflector::pluralize($associationName);
if ($repository->hasAssociation($associationName)) {
return $repository->getAssociation($associationName);
}
return null;
}
/**
* Takes a "include" string and converts it into a correct CakePHP ORM association alias
*
* @param array $includes The relationships to include
* @param array|bool $blacklist Blacklisted includes
* @param array|bool $whitelist Whitelisted options
* @param \Cake\ORM\Table|null $repository The repository
* @param array $path Include path
* @return array
* @throws \Cake\Http\Exception\BadRequestException
*/
protected function _parseIncludes($includes, $blacklist, $whitelist, Table $repository = null, $path = [])
{
$wildcard = implode('.', array_merge($path, ['*']));
$wildcardWhitelist = Hash::get((array)$whitelist, $wildcard);
$wildcardBlacklist = Hash::get((array)$blacklist, $wildcard);
$contains = [];
foreach ($includes as $include => $nestedIncludes) {
$nestedContains = [];
$includePath = array_merge($path, [$include]);
$includeDotPath = implode('.', $includePath);
if ($blacklist === true || ($blacklist !== false && ($wildcardBlacklist === true || Hash::get($blacklist, $includeDotPath) === true))) {
continue;
}
if ($whitelist === false || (
$whitelist !== true &&
!$wildcardWhitelist &&
Hash::get($whitelist, $includeDotPath) === null
)) {
continue;
}
$association = null;
if ($repository !== null) {
$association = $this->_getAssociation($repository, $include);
if ($association === null) {
throw new BadRequestException("Invalid relationship path '{$includeDotPath}' supplied in include parameter");
}
}
if (!empty($nestedIncludes)) {
$nestedContains = $this->_parseIncludes($nestedIncludes, $blacklist, $whitelist, $association ? $association->getTarget() : null, $includePath);
}
if (!empty($nestedContains)) {
if (!empty($association)) {
$contains[$association->getAlias()] = $nestedContains;
}
} else {
if (!empty($association)) {
$contains[] = $association->getAlias();
}
}
}
return $contains;
}
/**
* Parses out include query parameter into a containable array, and contains the query.
*
* Supported options is "Whitelist" and "Blacklist"
*
* @param string|array $includes The query data
* @param \Crud\Event\Subject $subject The subject
* @param array $options Array of options for includes.
* @return void
*/
protected function _includeParameter($includes, Subject $subject, $options)
{
if (is_string($includes)) {
$includes = explode(',', $includes);
}
$includes = Hash::filter((array)$includes);
if (empty($includes)) {
return;
}
if ($options['blacklist'] === true || $options['whitelist'] === false) {
throw new BadRequestException("The include parameter is not supported");
}
$this->setConfig('include', []);
$includes = Hash::expand(Hash::normalize($includes));
$blacklist = is_array($options['blacklist']) ? Hash::expand(Hash::normalize(array_fill_keys($options['blacklist'], true))) : $options['blacklist'];
$whitelist = is_array($options['whitelist']) ? Hash::expand(Hash::normalize(array_fill_keys($options['whitelist'], true))) : $options['whitelist'];
$contains = $this->_parseIncludes($includes, $blacklist, $whitelist, $subject->query->getRepository());
$subject->query->contain($contains);
$this->setConfig('include', []);
$associations = $this->_getContainedAssociations($subject->query->getRepository(), $contains);
$include = $this->_getIncludeList($associations);
$this->setConfig('include', $include);
}
/**
* Parses out fields query parameter and apply it to the query
*
* @param string|array|null $fieldSets The query data
* @param \Crud\Event\Subject $subject The subject
* @param array $options Array of options for includes.
* @return void
*/
protected function _fieldSetsParameter($fieldSets, Subject $subject, $options)
{
// could be null for e.g. using integration tests
if ($fieldSets === null) {
return;
}
// format $fieldSets to array acceptable by listener config()
$fieldSets = array_map(function ($val) {
return explode(',', $val);
}, $fieldSets);
$repository = $subject->query->getRepository();
$associations = $repository->associations();
$nodeName = Inflector::tableize($repository->getAlias());
if (empty($fieldSets[$nodeName])) {
$selectFields = [];
} else {
$selectFields = [$repository->aliasField($repository->getPrimaryKey())];
}
$columns = $repository->getSchema()->columns();
$contains = [];
foreach ($fieldSets as $include => $fields) {
if ($include === $nodeName) {
$aliasFields = array_map(function ($val) use ($repository, $columns) {
if (!in_array($val, $columns)) {
return null;
}
return $repository->aliasField($val);
}, $fields);
$selectFields = array_merge($selectFields, array_filter($aliasFields));
}
$association = $associations->get($include);
if (!empty($association)) {
$contains[$association->getAlias()] = [
'fields' => $fields,
];
}
}
$subject->query->select($selectFields);
if (!empty($contains)) {
$subject->query->contain($contains);
}
$this->setConfig('fieldSets', $fieldSets);
}
/**
* BeforeFind event listener to parse any supplied query parameters
*
* @param \Cake\Event\Event $event Event
* @return void
*/
public function beforeFind(Event $event)
{
//Inject default query handlers
$queryParameters = Hash::merge($this->getConfig('queryParameters'), [
'sort' => [
'callable' => [$this, '_sortParameter'],
],
'include' => [
'callable' => [$this, '_includeParameter']
],
'fields' => [
'callable' => [$this, '_fieldSetsParameter']
]
]);
foreach ($queryParameters as $parameter => $options) {
if (is_callable($options)) {
$options = [
'callable' => $options
];
}
if (!is_callable($options['callable'])) {
throw new \InvalidArgumentException('Invalid callable supplied for query parameter ' . $parameter);
}
$options['callable']($this->_request()->getQuery($parameter), $event->getSubject(), $options);
}
}
/**
* Add 'sort' capability
*
* @see http://jsonapi.org/format/#fetching-sorting
* @param string|array $sortFields Field sort request
* @param \Crud\Event\Subject $subject The subject
* @param array $options Array of options for includes.
* @return void
*/
protected function _sortParameter($sortFields, Subject $subject, $options)
{
if (is_string($sortFields)) {
$sortFields = explode(',', $sortFields);
}
$sortFields = array_filter((array)$sortFields);
$order = [];
$includes = $this->getConfig('include');
$repository = $subject->query->getRepository();
foreach ($sortFields as $sortField) {
$direction = 'ASC';
if ($sortField[0] == '-') {
$direction = 'DESC';
$sortField = substr($sortField, 1);
}
if ($this->getConfig('inflect') === 'dasherize') {
$sortField = Inflector::underscore($sortField); // e.g. currency, national-capitals
}
if (strpos($sortField, '.') !== false) {
list ($include, $field) = explode('.', $sortField);
if ($include === Inflector::tableize($repository->getAlias())) {
$order[$repository->aliasField($field)] = $direction;
continue;
}
if (!in_array($include, $includes)) {
continue;
}
$associations = $repository->associations();
foreach ($associations as $association) {
if ($association->getProperty() !== $include) {
continue;
}
$subject->query->contain([
$association->getAlias() => [
'sort' => [
$association->aliasField($field) => $direction,
],
'strategy' => 'select',
]
]);
$subject->query->leftJoinWith($association->getAlias());
$order[$association->aliasField($field)] = $direction;
}
continue;
} else {
$order[$repository->aliasField($sortField)] = $direction;
}
}
$subject->query->order($order);
}
/**
* Set required viewVars before rendering the JsonApiView.
*
* @param \Crud\Event\Subject $subject Subject
* @return \Cake\Http\Response
*/
public function render(Subject $subject)
{
$controller = $this->_controller();
$controller->viewBuilder()->setClassName('CrudJsonApi.JsonApi');
// render a JSON API response with resource(s) if data is found
if (isset($subject->entity) || isset($subject->entities)) {
return $this->_renderWithResources($subject);
}
return $this->_renderWithoutResources();
}
/**
* Renders a resource-less JSON API response.
*
* @return \Cake\Http\Response
*/
protected function _renderWithoutResources()
{
$this->_controller()->set([
'_withJsonApiVersion' => $this->getConfig('withJsonApiVersion'),
'_meta' => $this->getConfig('meta'),
'_links' => $this->getConfig('links'),
'_absoluteLinks' => $this->getConfig('absoluteLinks'),
'_jsonApiBelongsToLinks' => $this->getConfig('jsonApiBelongsToLinks'),
'_jsonOptions' => $this->getConfig('jsonOptions'),
'_debugPrettyPrint' => $this->getConfig('debugPrettyPrint'),
'_serialize' => true,
]);
return $this->_controller()->render();
}
/**
* Renders a JSON API response with top-level data node holding resource(s).
*
* @param \Crud\Event\Subject $subject Subject
* @return \Cake\Http\Response
*/
protected function _renderWithResources($subject)
{
$repository = $this->_controller()->loadModel(); // Default model class
$usedAssociations = [];
if (isset($subject->query)) {
$usedAssociations += $this->_getContainedAssociations($repository, $subject->query->getContain());
}
if (isset($subject->entities)) {
$entity = $this->_getSingleEntity($subject);
$usedAssociations += $this->_extractEntityAssociations($repository, $entity);
}
if (isset($subject->entity)) {
$usedAssociations += $this->_extractEntityAssociations($repository, $subject->entity);
}
// only generate the `included` node if the option is set by query parameter or config
// (which will not be the case when viewing a single Resource without parameters).
if ($this->getConfig('include') || $this->_ControllerHasSetContain === true) {
$include = $this->_getIncludeList($usedAssociations);
} else {
$include = [];
}
// Set data before rendering the view
$this->_controller()->set([
'_withJsonApiVersion' => $this->getConfig('withJsonApiVersion'),
'_meta' => $this->getConfig('meta'),
'_links' => $this->getConfig('links'),
'_absoluteLinks' => $this->getConfig('absoluteLinks'),
'_jsonApiBelongsToLinks' => $this->getConfig('jsonApiBelongsToLinks'),
'_jsonOptions' => $this->getConfig('jsonOptions'),
'_debugPrettyPrint' => $this->getConfig('debugPrettyPrint'),
'_repositories' => $this->_getRepositoryList($repository, $usedAssociations),
'_include' => $include,
'_fieldSets' => $this->getConfig('fieldSets'),
Inflector::tableize($repository->getAlias()) => $this->_getFindResult($subject),
'_serialize' => true,
'_inflect' => $this->getConfig('inflect')
]);
return $this->_controller()->render();
}
/**
* Make sure all configuration options are valid.
*
* @throws \Crud\Error\Exception\CrudException
* @return void
*/
protected function _validateConfigOptions()
{
if ($this->getConfig('withJsonApiVersion')) {
if (!is_bool($this->getConfig('withJsonApiVersion')) && !is_array($this->getConfig('withJsonApiVersion'))) {
throw new CrudException('JsonApiListener configuration option `withJsonApiVersion` only accepts a boolean or an array');
}
}
if (!is_array($this->getConfig('meta'))) {
throw new CrudException('JsonApiListener configuration option `meta` only accepts an array');
}
if (!is_bool($this->getConfig('absoluteLinks'))) {
throw new CrudException('JsonApiListener configuration option `absoluteLinks` only accepts a boolean');
}
if (!is_bool($this->getConfig('jsonApiBelongsToLinks'))) {
throw new CrudException('JsonApiListener configuration option `jsonApiBelongsToLinks` only accepts a boolean');
}
if (!is_array($this->getConfig('include'))) {
throw new CrudException('JsonApiListener configuration option `include` only accepts an array');
}
if (!is_array($this->getConfig('fieldSets'))) {
throw new CrudException('JsonApiListener configuration option `fieldSets` only accepts an array');
}
if (!is_array($this->getConfig('jsonOptions'))) {
throw new CrudException('JsonApiListener configuration option `jsonOptions` only accepts an array');
}
if (!is_bool($this->getConfig('debugPrettyPrint'))) {
throw new CrudException('JsonApiListener configuration option `debugPrettyPrint` only accepts a boolean');
}
if (!is_array($this->getConfig('queryParameters'))) {
throw new CrudException('JsonApiListener configuration option `queryParameters` only accepts an array');
}
}
/**
* Override ApiListener method to enforce required JSON API request methods.
*
* @throws \Cake\Http\Exception\BadRequestException
* @return bool
*/
protected function _checkRequestMethods()
{
if ($this->_request()->is('put')) {
throw new BadRequestException('JSON API does not support the PUT method, use PATCH instead');
}
if (!$this->_request()->contentType()) {
return true;
}
$jsonApiMimeType = $this->_response()->getMimeType('jsonapi');
if ($this->_request()->contentType() !== $jsonApiMimeType) {
throw new BadRequestException("JSON API requests with data require the \"$jsonApiMimeType\" Content-Type header");
}
return true;
}
/**
* Deduplicate resultset from rows that might have come from joins
*
* @param \Crud\Event\Subject $subject Subject
* @return \Cake\Datasource\ResultSetInterface
*/
protected function _deduplicateResultSet($subject): ResultSetInterface
{
$ids = [];
$entities = [];
$keys = (array)$subject->query->getRepository()->getPrimaryKey();
foreach ($subject->entities as $entity) {
$id = $entity->extract($keys);
if (!in_array($id, $ids)) {
$entities[] = $entity;
$ids[] = $id;
}
}
if ($subject->entities instanceof ResultSet) {
$resultSet = clone $subject->entities;
$resultSet->unserialize(serialize($entities));
} else {
$resultSet = new ResultSetDecorator($entities);
}
return $resultSet;
}
/**
* Helper function to easily retrieve `find()` result from Crud subject
* regardless of current action.
*
* @param \Crud\Event\Subject $subject Subject
* @return mixed Single Entity or ORM\ResultSet
*/
protected function _getFindResult($subject)
{
if (!empty($subject->entities)) {
if (isset($subject->query)) {
$subject->entities = $this->_deduplicateResultSet($subject);
}
return $subject->entities;
}
return $subject->entity;
}
/**
* Helper function to easily retrieve a single entity from Crud subject
* find result regardless of current action.
*
* @param \Crud\Event\Subject $subject Subject
* @return \Cake\ORM\Entity
*/
protected function _getSingleEntity($subject)
{
if (!empty($subject->entities) && $subject->entities instanceof Query) {
return (clone $subject->entities)->first();
} elseif (!empty($subject->entities)) {
return $subject->entities->first();
}
return $subject->entity;
}
/**
* Creates a nested array of all associations used in the query
*
* @param \Cake\ORM\Table $repository Repository
* @param array $contains Array of contained associations
* @return array Array with \Cake\ORM\AssociationCollection
*/
protected function _getContainedAssociations($repository, $contains)
{
$associationCollection = $repository->associations();
$associations = [];
foreach ($contains as $contain => $nestedContains) {
if (is_string($nestedContains)) {
$contain = $nestedContains;
$nestedContains = [];
}
$association = $associationCollection->get($contain);
if ($association === null) {
continue;
}
$associationKey = strtolower($association->getName());
$associations[$associationKey] = [
'association' => $association,
'children' => []
];
if (!empty($nestedContains)) {
$associations[$associationKey]['children'] = $this->_getContainedAssociations($association->getTarget(), $nestedContains);
}
}
return $associations;
}
/**
* Removes all associated models not detected (as the result of a contain
* query) in the find result from the entity's AssociationCollection to
* prevent `null` entries appearing in the json api `relationships` node.
*
* @param \Cake\ORM\Table $repository Repository
* @param \Cake\ORM\Entity $entity Entity
* @return array
*/
protected function _extractEntityAssociations($repository, $entity)
{
$associationCollection = $repository->associations();
$associations = [];
foreach ($associationCollection as $association) {
$associationKey = strtolower($association->getName());
$entityKey = $association->getProperty();
if (!empty($entity->$entityKey)) {
$associations[$associationKey] = [
'association' => $association,
'children' => $this->_extractEntityAssociations($association->getTarget(), $entity->$entityKey)
];
}
}
return $associations;
}
/**
* Get a flat list of all repositories indexed by their registry alias.
*
* @param RepositoryInterface $repository Current repository
* @param array $associations Nested associations to get repository from
* @return array Used repositories indexed by registry alias
* @internal
*/
protected function _getRepositoryList(RepositoryInterface $repository, $associations)
{
$repositories = [
$repository->getRegistryAlias() => $repository
];
foreach ($associations as $association) {
$association += [
'association' => null,
'children' => []
];
if ($association['association'] === null) {
throw new \InvalidArgumentException("Association does not have an association object set");
}
$associationRepository = $association['association']->getTarget();
$repositories += $this->_getRepositoryList($associationRepository, $association['children'] ?: []);
}
return $repositories;
}
/**
* Generates a list for with all associated data (as produced by Containable
* and thus) present in the entity to be used for filling the top-level
* `included` node in the json response UNLESS user has specified listener
* config option 'include'.
*
* @param array $associations Array with \Cake\ORM\AssociationCollection(s)
* @param bool $last Is this the "top-level"/entry point for the recursive function
* @return array
* @throws \InvalidArgumentException
*/
protected function _getIncludeList($associations, $last = true)
{
if (!empty($this->getConfig('include'))) {
return $this->getConfig('include');
}
$result = [];
foreach ($associations as $name => $association) {
$association += [
'association' => null,
'children' => []
];
if ($association['association'] === null) {
throw new \InvalidArgumentException("Association {$name} does not have an association object set");
}
$property = $association['association']->getProperty();
if ($this->getConfig('inflect') === 'dasherize') {
$property = Inflector::dasherize($property); // e.g. currency, national-capitals
}
$result[$property] = $this->_getIncludeList($association['children'], false);
}
return $last ? array_keys(Hash::flatten($result)) : $result;
}
/**
* Checks if data was posted to the Listener. If so then checks if the
* array (already converted from json) matches the expected JSON API
* structure for resources and if so, converts that array to CakePHP
* compatible format so it can be processed as usual from there.
*
* @return void
*/
protected function _checkRequestData()
{
$requestMethod = $this->_controller()->request->getMethod();
if ($requestMethod !== 'POST' && $requestMethod !== 'PATCH') {
return;
}
$requestData = $this->_controller()->request->getData();
if (empty($requestData)) {
throw new BadRequestException('Missing request data required for POST and PATCH methods. Make sure that you are sending a request body and that it is valid JSON.');
}
$validator = new DocumentValidator($requestData, $this->getConfig());
if ($requestMethod === 'POST') {
$validator->validateCreateDocument();
}
if ($requestMethod === 'PATCH') {
$validator->validateUpdateDocument();
}
# decode JSON API to CakePHP array format, then call the action as usual
$decodedJsonApi = $this->_convertJsonApiDocumentArray($requestData);
// For PATCH operations the `id` field in the request data MUST match the URL id
// because JSON API considers it immutable. https://github.com/json-api/json-api/issues/481
if (($requestMethod === 'PATCH') && ($this->_controller()->request->getParam('id') !== $decodedJsonApi['id'])) {
throw new BadRequestException("URL id does not match request data id as required for JSON API PATCH actions");
}
$this->_controller()->request = $this->_controller()->request->withParsedBody($decodedJsonApi);
}
/**
* Returns a flat array list with the names of all associations for the given
* entity, optionally limited to only matching associationTypes.
*
* @param \Cake\ORM\Entity $entity Entity
* @param array $associationTypes Array with any combination of Cake\ORM\Association types
* @return array
*/
protected function _getAssociationsList($entity, array $associationTypes = [])
{
$table = $this->_controller()->loadModel();
$associations = $table->associations();
$result = [];
foreach ($associations as $association) {
$associationType = $association->type();
if (empty($associationTypes)) {
array_push($result, $association->getName());
continue;
}
if (in_array($association->type(), $associationTypes)) {
array_push($result, $association->getName());
}
}
return $result;
}
/**
* Converts (already json_decoded) request data array in JSON API document
* format to CakePHP format so it be processed as usual. Should only be
* used with already validated data/document or things will break.
*
* Please note that decoding hasMany relationships has not yet been implemented.
*
* @param array $document Request data document array
* @return bool
*/
protected function _convertJsonApiDocumentArray($document)
{
$result = [];
// convert primary resource
if (array_key_exists('id', $document['data'])) {
$result['id'] = $document['data']['id'];
};
if (array_key_exists('attributes', $document['data'])) {
$result = array_merge_recursive($result, $document['data']['attributes']);
// dasherize all attribute keys directly below the primary resource if need be
if ($this->getConfig('inflect') === 'dasherize') {
foreach ($result as $key => $value) {
$underscoredKey = Inflector::underscore($key);
if (!array_key_exists($underscoredKey, $result)) {
$result[$underscoredKey] = $value;
unset($result[$key]);
}
}
}
}
// no further action if there are no relationships
if (!array_key_exists('relationships', $document['data'])) {
return $result;
}
// translate relationships into CakePHP array format
foreach ($document['data']['relationships'] as $key => $details) {
if ($this->getConfig('inflect') === 'dasherize') {
$key = Inflector::underscore($key); // e.g. currency, national-capitals
}
// allow empty/null data node as per the JSON API specification
if (empty($details['data'])) {
continue;
}
// handle belongsTo relationships
if (!isset($details['data'][0])) {
$belongsToForeignKey = $key . '_id';
$belongsToId = $details['data']['id'];
$result[$belongsToForeignKey] = $belongsToId;
continue;
}
// handle hasMany relationships
if (isset($details['data'][0])) {
$relationResults = [];
foreach ($details['data'] as $relationData) {
$relationResult = [];
if (array_key_exists('id', $relationData)) {
$relationResult['id'] = $relationData['id'];
};
if (array_key_exists('attributes', $relationData)) {
$relationResult = array_merge_recursive($relationResult, $relationData['attributes']);
// dasherize attribute keys if need be
if ($this->getConfig('inflect') === 'dasherize') {
foreach ($relationResult as $resultKey => $value) {
$underscoredKey = Inflector::underscore($resultKey);
if (!array_key_exists($underscoredKey, $relationResult)) {
$relationResult[$underscoredKey] = $value;
unset($relationResult[$resultKey]);
}
}
}
};
$relationResults[] = $relationResult;
}
$result[$key] = $relationResults;
}
}
return $result;
}
}