Skip to content

Commit

Permalink
Merge pull request #72 from FriendsOfCake/tuning
Browse files Browse the repository at this point in the history
- adds support for setting related hasMany during PATCH request
- improves performance for GET with belongsTo relationship
- simplifies readme
  • Loading branch information
bravo-kernel committed Jul 1, 2018
2 parents be0dca0 + 1b60144 commit 1e5fda8
Show file tree
Hide file tree
Showing 9 changed files with 325 additions and 141 deletions.
111 changes: 13 additions & 98 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,106 +7,21 @@

# JSON API Crud Listener for CakePHP

Crud Listener for (rapidly) building CakePHP APIs following the JSON API specification.
Build advanced JSON API Servers with almost no code. Comes with:

[Documentation found here](https://crud-json-api.readthedocs.io/).
- Compound Documents (Deeply Nested)
- Sparse Fieldsets
- Multi-field Search (Filtering)
- Multi-field Sorting
- Multi-field Validation
- Pagination

## Installation
## How does it work?

```
composer require friendsofcake/crud-json-api
```
1. Structure your data using the powerful CakePHP ORM
2. Create (empty) Controllers
3. Let crud-json-api serve your data as JSON API

## Why use it?
## Documentation

- standardized API data fetching, data posting and (validation) errors
- automated handling of complex associations/relationships
- instant JSON API backend for tools like Ember Data, React and Vue
- tons of configurable options to manipulate the generated json

## Sample output

```json
{
"data": {
"type": "countries",
"id": "2",
"attributes": {
"code": "BE",
"name": "Belgium"
},
"relationships": {
"currency": {
"data": {
"type": "currencies",
"id": "1"
},
"links": {
"self": "/currencies/1"
}
},
"cultures": {
"data": [
{
"type": "cultures",
"id": "2"
},
{
"type": "cultures",
"id": "3"
}
],
"links": {
"self": "/cultures?country_id=2"
}
}
},
"links": {
"self": "/countries/2"
}
},
"included": [
{
"type": "currencies",
"id": "1",
"attributes": {
"code": "EUR",
"name": "Euro"
},
"links": {
"self": "/currencies/1"
}
},
{
"type": "cultures",
"id": "2",
"attributes": {
"code": "nl-BE",
"name": "Dutch (Belgium)"
},
"links": {
"self": "/cultures/2"
}
},
{
"type": "cultures",
"id": "3",
"attributes": {
"code": "fr-BE",
"name": "French (Belgium)"
},
"links": {
"self": "/cultures/3"
}
}
]
}
```

## Contribute

Before submitting a PR make sure:

- [PHPUnit](http://book.cakephp.org/3.0/en/development/testing.html#running-tests)
and [CakePHP Code Sniffer](https://github.com/cakephp/cakephp-codesniffer) tests pass
- [Codecov Code Coverage ](https://codecov.io/github/FriendsOfCake/crud-json-api) does not drop
Fully documented at [https://crud-json-api.readthedocs.io/](https://crud-json-api.readthedocs.io/)
74 changes: 74 additions & 0 deletions docs/api-usage/updating-resources.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,77 @@ produced by ``http://example.com/countries/1``:
}
}
}
Updating To-One Relationships
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

When updating a primary JSON API Resource, you can use the same PATCH request to set one or multiple To-One
(or ``belongsTo``) relationships but only as long as the following conditions are met:

- the ``id`` of the related resource MUST correspond with an EXISTING foreign key
- the related resource MUST belong to the primary resource being PATCHed

For example, a valid JSON API document structure that would set a single related
``national-capital`` for a given ``country`` would look like:

.. code-block:: json
{
"data": {
"type": "countries",
"id": "2",
"relationships": {
"national-capital": {
"data": {
"type": "national-capitals",
"id": "4"
}
}
}
}
}
.. note::

Please note that JSON API does not support updating attributes for the related resource(s) and thus
will simply ignore them if found in the request body.

Updating To-Many Relationships
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

When updating a primary JSON API Resource, you can use the same PATCH request to set one or multiple To-Many
(or ``hasMany``) relationships but only as long as the following conditions are met:

- the ``id`` of the related resource MUST correspond with an EXISTING foreign key
- the related resource MUST belong to the primary resource being PATCHed

For example, a valid JSON API document structure that would set multiple related ``cultures``
for a given ``country`` would look like:

.. code-block:: json
{
"data": {
"type": "countries",
"id": "2",
"relationships": {
"cultures": {
"data": [
{
"type": "cultures",
"id": "2"
},
{
"type": "cultures",
"id": "3"
}
]
}
}
}
}
.. note::

Please note that JSON API does not support updating attributes for the related resource(s) and thus
will simply ignore them if found in the request body.
118 changes: 78 additions & 40 deletions src/Listener/JsonApiListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Cake\Http\Exception\BadRequestException;
use Cake\ORM\Association;
use Cake\ORM\Table;
use Cake\ORM\TableRegistry;
use Cake\Utility\Hash;
use Cake\Utility\Inflector;
use CrudJsonApi\Listener\JsonApi\DocumentValidator;
Expand Down Expand Up @@ -169,7 +170,9 @@ public function afterFind($event)
}

/**
* beforeSave() event.
* 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
Expand All @@ -179,15 +182,54 @@ 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]); // hasMany
$hasManyAssociations = $this->_getAssociationsList($entity, [Association::ONE_TO_MANY]);

// stop propagation if hasMany relationship(s) are detected in the request data
// and thus the client is trying to side-post/create related records
if (empty($hasManyAssociations)) {
return;
}

// must be PATCH so verify hasMany relationships before saving
foreach ($hasManyAssociations as $associationName) {
$key = Inflector::tableize($associationName);
if (isset($entity->$key)) {
throw new BadRequestException("JSON API 1.0 does not support side-posting (hasMany relationship data detected in the request body)");

// 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.0 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');
}
}

Expand Down Expand Up @@ -540,11 +582,7 @@ protected function _sortParameter($sortFields, Subject $subject, $options)
}

/**
* Adds belongsTo data to the find() result so the 201 success response
* is able to render the jsonapi `relationships` member.
*
* Please note that we are deliberately NOT creating a new find query as
* this would not respect non-accessible fields.
* Adds belongsTo data to the find() result.
*
* @param \Cake\Event\Event $event Event
* @return void
Expand All @@ -556,38 +594,38 @@ protected function _insertBelongsToDataIntoEventFindResult($event)
$associations = $repository->associations();

foreach ($associations as $association) {
$type = $association->type();

// handle `belongsTo` and `hasOne` relationships
if ($type === Association::MANY_TO_ONE || $type === Association::ONE_TO_ONE) {
$associationTable = $association->getTarget();
$foreignKey = $association->getForeignKey();

$result = $associationTable
->find()
->select(['id'])
->where([$association->getName() . '.id' => $entity->$foreignKey])
->first();

// Unfortunately, _propertyName is protected. We have got serious reason to use it though.
$reflectedAssoc = new \ReflectionClass('Cake\ORM\Association');
$propertyNameProp = $reflectedAssoc->getProperty('_propertyName');
$propertyNameProp->setAccessible(true);
$key = $propertyNameProp->getValue($association);

// There are cases when _propertyName is not set and we go default then
if (!$key) {
$key = Inflector::tableize($association->getName());
$key = Inflector::singularize($key);
}

$entity->$key = $result;
$associationType = $association->type();
$associationTable = $association->getTarget(); // Users

// belongsTo and HasOne
if ($associationType === Association::MANY_TO_ONE || $associationType === Association::ONE_TO_ONE) {
$foreignKey = $association->getForeignKey(); // user_id
$associationId = $entity->$foreignKey; // 1234

if (!empty($associationId)) {
$associatedEntity = $associationTable->newEntity();
$associatedEntity->set('id', $associationId);

// generate key name required for neoMerx to find and use the entity data
// => ?!? => unfortunately, _propertyName is protected. We have got serious reason to use it though
$reflectedAssoc = new \ReflectionClass('Cake\ORM\Association');
$propertyNameProp = $reflectedAssoc->getProperty('_propertyName');
$propertyNameProp->setAccessible(true);
$key = $propertyNameProp->getValue($association);

if (!$key) {
$key = Inflector::singularize($association->getName()); // Users
$key = Inflector::underscore($key); // user
}

//Also insert the contained associations into the query
if (isset($event->getSubject()->query)) {
$event->getSubject()->query->contain($association->getName());
$entity->set($key, $associatedEntity);
}
}

// insert the contained associations into the query
if (!empty($event->getSubject()->query)) {
$event->getSubject()->query->contain($association->getName());
}
}

$event->getSubject()->entity = $entity;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"data": {
"type": "countries",
"id": "2",
"relationships": {
"cultures": {
"data": [
{
"type": "cultures",
"id": "2"
},
{
"type": "cultures",
"id": "3"
}
]
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"data": {
"type": "countries",
"id": "2",
"relationships": {
"cultures": {
"data": [
{
"type": "cultures",
"id": "3"
}
]
}
},
"links": {
"self": "\/countries\/2"
}
}
}

0 comments on commit 1e5fda8

Please sign in to comment.