Skip to content

Instantly share code, notes, and snippets.

@geoidesic
Created August 3, 2020 10:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save geoidesic/f1e90c241f6758ca2f2ec2bf4e2e66e1 to your computer and use it in GitHub Desktop.
Save geoidesic/f1e90c241f6758ca2f2ec2bf4e2e66e1 to your computer and use it in GitHub Desktop.
Will invoke a custom DocumentValidator class depending on the URL format.
<?php
declare(strict_types=1);
namespace App\Listener;
use Cake\Core\Configure;
use Cake\Datasource\EntityInterface;
use Cake\Datasource\RepositoryInterface;
use Cake\Datasource\ResultSetDecorator;
use Cake\Datasource\ResultSetInterface;
use Cake\Event\EventInterface;
use Cake\Http\Exception\BadRequestException;
use Cake\Http\Response;
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 Crud\Error\Exception\CrudException;
use Crud\Event\Subject;
use Crud\Listener\ApiListener;
use CrudJsonApi\Listener\JsonApiListener as BaseListener;
use CrudJsonApi\Listener\JsonApi\DocumentValidator;
use App\Listener\JsonApi\DocumentRelationshipValidator;
use InvalidArgumentException;
/**
* 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 BaseListener
{
protected function _checkIsRelationshipsRequest(): bool
{
// if URL matches relationship regex, then use custom validator
preg_match(
'/.*{controller}\/{id}\/relationships\/{foreignTableName}/',
$this->_controller()->getRequest()->getParam('_matchedRoute'),
$match
);
$isRelationshipURL = !empty($match);
return $isRelationshipURL;
}
/**
* 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.
*
* @overridden in order to provide alternate data validation for relationships (which have a different data structure from other requests)
* @return void
*/
protected function _checkRequestData(): void
{
// Controller name: $this->_controller->getName()
$requestMethod = $this->_controller()->getRequest()->getMethod();
if ($requestMethod !== 'POST' && $requestMethod !== 'PATCH') {
return;
}
$requestData = $this->_controller()->getRequest()->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());
$isRelationshipURL = $this->_checkIsRelationshipsRequest();
if ($requestMethod === 'POST') {
if ($isRelationshipURL) {
$relationshipValidator = new DocumentRelationshipValidator($requestData, $this->getConfig());
$relationshipValidator->validateUpdateDocument();
} else {
$validator->validateCreateDocument();
}
}
if ($requestMethod === 'PATCH') {
if ($isRelationshipURL) {
$relationshipValidator = new DocumentRelationshipValidator($requestData, $this->getConfig());
$relationshipValidator->validateUpdateDocument();
} else {
$validator->validateUpdateDocument();
}
}
// decode JSON API to CakePHP array format, then call the action as usual
$decodedJsonApi = $this->_convertJsonApiDocumentArray($requestData);
$exception = false;
if ($requestMethod === 'PATCH') {
if (!$isRelationshipURL) {
// For normal 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
$exception = $this->_controller()->getRequest()->getParam('id') !== $decodedJsonApi['id'];
} else {
// For relationship PATCH operations, the `id` field need not be present in the data body
$exception = empty($this->_controller()->getRequest()->getParam('id'));
}
}
if ($exception) {
throw new BadRequestException(
'URL id does not match request data id as required for JSON API PATCH actions'
);
}
$this->_controller()->setRequest($this->_controller()->getRequest()->withParsedBody($decodedJsonApi));
}
/**
* 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.
* @overriden in order to cast emtpy nodes as arrays (see @customised)
*
* @param array $document Request data document array
* @return array
*/
protected function _convertJsonApiDocumentArray(array $document): array
{
$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(
// @customised: cast as array to avoid any problems with parsing issues when converting between JavaScript and PHP (q.v. comments on routes.php::jsonIterator)
(array)$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;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment