Skip to content

Commit 1e5fda8

Browse files
authored
Merge pull request #72 from FriendsOfCake/tuning
- adds support for setting related hasMany during PATCH request - improves performance for GET with belongsTo relationship - simplifies readme
2 parents be0dca0 + 1b60144 commit 1e5fda8

File tree

9 files changed

+325
-141
lines changed

9 files changed

+325
-141
lines changed

README.md

Lines changed: 13 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -7,106 +7,21 @@
77

88
# JSON API Crud Listener for CakePHP
99

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

12-
[Documentation found here](https://crud-json-api.readthedocs.io/).
12+
- Compound Documents (Deeply Nested)
13+
- Sparse Fieldsets
14+
- Multi-field Search (Filtering)
15+
- Multi-field Sorting
16+
- Multi-field Validation
17+
- Pagination
1318

14-
## Installation
19+
## How does it work?
1520

16-
```
17-
composer require friendsofcake/crud-json-api
18-
```
21+
1. Structure your data using the powerful CakePHP ORM
22+
2. Create (empty) Controllers
23+
3. Let crud-json-api serve your data as JSON API
1924

20-
## Why use it?
25+
## Documentation
2126

22-
- standardized API data fetching, data posting and (validation) errors
23-
- automated handling of complex associations/relationships
24-
- instant JSON API backend for tools like Ember Data, React and Vue
25-
- tons of configurable options to manipulate the generated json
26-
27-
## Sample output
28-
29-
```json
30-
{
31-
"data": {
32-
"type": "countries",
33-
"id": "2",
34-
"attributes": {
35-
"code": "BE",
36-
"name": "Belgium"
37-
},
38-
"relationships": {
39-
"currency": {
40-
"data": {
41-
"type": "currencies",
42-
"id": "1"
43-
},
44-
"links": {
45-
"self": "/currencies/1"
46-
}
47-
},
48-
"cultures": {
49-
"data": [
50-
{
51-
"type": "cultures",
52-
"id": "2"
53-
},
54-
{
55-
"type": "cultures",
56-
"id": "3"
57-
}
58-
],
59-
"links": {
60-
"self": "/cultures?country_id=2"
61-
}
62-
}
63-
},
64-
"links": {
65-
"self": "/countries/2"
66-
}
67-
},
68-
"included": [
69-
{
70-
"type": "currencies",
71-
"id": "1",
72-
"attributes": {
73-
"code": "EUR",
74-
"name": "Euro"
75-
},
76-
"links": {
77-
"self": "/currencies/1"
78-
}
79-
},
80-
{
81-
"type": "cultures",
82-
"id": "2",
83-
"attributes": {
84-
"code": "nl-BE",
85-
"name": "Dutch (Belgium)"
86-
},
87-
"links": {
88-
"self": "/cultures/2"
89-
}
90-
},
91-
{
92-
"type": "cultures",
93-
"id": "3",
94-
"attributes": {
95-
"code": "fr-BE",
96-
"name": "French (Belgium)"
97-
},
98-
"links": {
99-
"self": "/cultures/3"
100-
}
101-
}
102-
]
103-
}
104-
```
105-
106-
## Contribute
107-
108-
Before submitting a PR make sure:
109-
110-
- [PHPUnit](http://book.cakephp.org/3.0/en/development/testing.html#running-tests)
111-
and [CakePHP Code Sniffer](https://github.com/cakephp/cakephp-codesniffer) tests pass
112-
- [Codecov Code Coverage ](https://codecov.io/github/FriendsOfCake/crud-json-api) does not drop
27+
Fully documented at [https://crud-json-api.readthedocs.io/](https://crud-json-api.readthedocs.io/)

docs/api-usage/updating-resources.rst

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,77 @@ produced by ``http://example.com/countries/1``:
2828
}
2929
}
3030
}
31+
32+
Updating To-One Relationships
33+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
34+
35+
When updating a primary JSON API Resource, you can use the same PATCH request to set one or multiple To-One
36+
(or ``belongsTo``) relationships but only as long as the following conditions are met:
37+
38+
- the ``id`` of the related resource MUST correspond with an EXISTING foreign key
39+
- the related resource MUST belong to the primary resource being PATCHed
40+
41+
For example, a valid JSON API document structure that would set a single related
42+
``national-capital`` for a given ``country`` would look like:
43+
44+
.. code-block:: json
45+
46+
{
47+
"data": {
48+
"type": "countries",
49+
"id": "2",
50+
"relationships": {
51+
"national-capital": {
52+
"data": {
53+
"type": "national-capitals",
54+
"id": "4"
55+
}
56+
}
57+
}
58+
}
59+
}
60+
61+
.. note::
62+
63+
Please note that JSON API does not support updating attributes for the related resource(s) and thus
64+
will simply ignore them if found in the request body.
65+
66+
Updating To-Many Relationships
67+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
68+
69+
When updating a primary JSON API Resource, you can use the same PATCH request to set one or multiple To-Many
70+
(or ``hasMany``) relationships but only as long as the following conditions are met:
71+
72+
- the ``id`` of the related resource MUST correspond with an EXISTING foreign key
73+
- the related resource MUST belong to the primary resource being PATCHed
74+
75+
For example, a valid JSON API document structure that would set multiple related ``cultures``
76+
for a given ``country`` would look like:
77+
78+
.. code-block:: json
79+
80+
{
81+
"data": {
82+
"type": "countries",
83+
"id": "2",
84+
"relationships": {
85+
"cultures": {
86+
"data": [
87+
{
88+
"type": "cultures",
89+
"id": "2"
90+
},
91+
{
92+
"type": "cultures",
93+
"id": "3"
94+
}
95+
]
96+
}
97+
}
98+
}
99+
}
100+
101+
.. note::
102+
103+
Please note that JSON API does not support updating attributes for the related resource(s) and thus
104+
will simply ignore them if found in the request body.

src/Listener/JsonApiListener.php

Lines changed: 78 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Cake\Http\Exception\BadRequestException;
88
use Cake\ORM\Association;
99
use Cake\ORM\Table;
10+
use Cake\ORM\TableRegistry;
1011
use Cake\Utility\Hash;
1112
use Cake\Utility\Inflector;
1213
use CrudJsonApi\Listener\JsonApi\DocumentValidator;
@@ -169,7 +170,9 @@ public function afterFind($event)
169170
}
170171

171172
/**
172-
* beforeSave() event.
173+
* beforeSave() event used to prevent users from sending `hasMany` relationships when POSTing and
174+
* to prevent them from sending `hasMany` relationships not belonging to this primary resource
175+
* when PATCHing.
173176
*
174177
* @param \Cake\Event\Event $event Event
175178
* @return void
@@ -179,15 +182,54 @@ public function beforeSave($event)
179182
{
180183
// generate a flat list of hasMany relationships for the current model
181184
$entity = $event->getSubject()->entity;
182-
$hasManyAssociations = $this->_getAssociationsList($entity, [Association::ONE_TO_MANY]); // hasMany
185+
$hasManyAssociations = $this->_getAssociationsList($entity, [Association::ONE_TO_MANY]);
183186

184-
// stop propagation if hasMany relationship(s) are detected in the request data
185-
// and thus the client is trying to side-post/create related records
187+
if (empty($hasManyAssociations)) {
188+
return;
189+
}
190+
191+
// must be PATCH so verify hasMany relationships before saving
186192
foreach ($hasManyAssociations as $associationName) {
187193
$key = Inflector::tableize($associationName);
188-
if (isset($entity->$key)) {
189-
throw new BadRequestException("JSON API 1.0 does not support side-posting (hasMany relationship data detected in the request body)");
194+
195+
// do nothing if association is not hasMany
196+
if (!isset($entity->$key)) {
197+
continue;
198+
}
199+
200+
// prevent clients attempting to side-post/create related hasMany records
201+
if ($this->_request()->getMethod() === 'POST') {
202+
throw new BadRequestException("JSON API 1.0 does not support sideposting (hasMany relationships detected in the request body)");
203+
}
204+
205+
// hasMany found in the entity, extract ids from the request data
206+
$primaryResourceId = $this->_controller()->request->getData('id');
207+
208+
/** @var array $hasManyIds */
209+
$hasManyIds = Hash::extract($this->_controller()->request->getData($key), '{n}.id');
210+
$hasManyTable = TableRegistry::get($associationName);
211+
212+
// query database only for hasMany that match both passed id and the id of the primary resource
213+
/** @var string $entityForeignKey */
214+
$entityForeignKey = $hasManyTable->getAssociation($entity->getSource())->getForeignKey();
215+
$query = $hasManyTable->find()
216+
->select(['id'])
217+
->where([
218+
$entityForeignKey => $primaryResourceId,
219+
'id IN' => $hasManyIds,
220+
]);
221+
222+
// throw an exception if number of database records does not exactly matches passed ids
223+
if (count($hasManyIds) !== $query->count()) {
224+
throw new BadRequestException("One or more of the provided relationship ids for $associationName do not exist in the database");
190225
}
226+
227+
// all good, replace entity data with fetched entities before saving
228+
$entity->$key = $query->toArray();
229+
230+
// lastly, set the `saveStrategy` for this hasMany to `replace` so non-matching existing records will be removed
231+
$repository = $event->getSubject()->query->getRepository();
232+
$repository->getAssociation($associationName)->setSaveStrategy('replace');
191233
}
192234
}
193235

@@ -540,11 +582,7 @@ protected function _sortParameter($sortFields, Subject $subject, $options)
540582
}
541583

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

558596
foreach ($associations as $association) {
559-
$type = $association->type();
560-
561-
// handle `belongsTo` and `hasOne` relationships
562-
if ($type === Association::MANY_TO_ONE || $type === Association::ONE_TO_ONE) {
563-
$associationTable = $association->getTarget();
564-
$foreignKey = $association->getForeignKey();
565-
566-
$result = $associationTable
567-
->find()
568-
->select(['id'])
569-
->where([$association->getName() . '.id' => $entity->$foreignKey])
570-
->first();
571-
572-
// Unfortunately, _propertyName is protected. We have got serious reason to use it though.
573-
$reflectedAssoc = new \ReflectionClass('Cake\ORM\Association');
574-
$propertyNameProp = $reflectedAssoc->getProperty('_propertyName');
575-
$propertyNameProp->setAccessible(true);
576-
$key = $propertyNameProp->getValue($association);
577-
578-
// There are cases when _propertyName is not set and we go default then
579-
if (!$key) {
580-
$key = Inflector::tableize($association->getName());
581-
$key = Inflector::singularize($key);
582-
}
583-
584-
$entity->$key = $result;
597+
$associationType = $association->type();
598+
$associationTable = $association->getTarget(); // Users
599+
600+
// belongsTo and HasOne
601+
if ($associationType === Association::MANY_TO_ONE || $associationType === Association::ONE_TO_ONE) {
602+
$foreignKey = $association->getForeignKey(); // user_id
603+
$associationId = $entity->$foreignKey; // 1234
604+
605+
if (!empty($associationId)) {
606+
$associatedEntity = $associationTable->newEntity();
607+
$associatedEntity->set('id', $associationId);
608+
609+
// generate key name required for neoMerx to find and use the entity data
610+
// => ?!? => unfortunately, _propertyName is protected. We have got serious reason to use it though
611+
$reflectedAssoc = new \ReflectionClass('Cake\ORM\Association');
612+
$propertyNameProp = $reflectedAssoc->getProperty('_propertyName');
613+
$propertyNameProp->setAccessible(true);
614+
$key = $propertyNameProp->getValue($association);
615+
616+
if (!$key) {
617+
$key = Inflector::singularize($association->getName()); // Users
618+
$key = Inflector::underscore($key); // user
619+
}
585620

586-
//Also insert the contained associations into the query
587-
if (isset($event->getSubject()->query)) {
588-
$event->getSubject()->query->contain($association->getName());
621+
$entity->set($key, $associatedEntity);
589622
}
590623
}
624+
625+
// insert the contained associations into the query
626+
if (!empty($event->getSubject()->query)) {
627+
$event->getSubject()->query->contain($association->getName());
628+
}
591629
}
592630

593631
$event->getSubject()->entity = $entity;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"data": {
3+
"type": "countries",
4+
"id": "2",
5+
"relationships": {
6+
"cultures": {
7+
"data": [
8+
{
9+
"type": "cultures",
10+
"id": "2"
11+
},
12+
{
13+
"type": "cultures",
14+
"id": "3"
15+
}
16+
]
17+
}
18+
}
19+
}
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"data": {
3+
"type": "countries",
4+
"id": "2",
5+
"relationships": {
6+
"cultures": {
7+
"data": [
8+
{
9+
"type": "cultures",
10+
"id": "3"
11+
}
12+
]
13+
}
14+
},
15+
"links": {
16+
"self": "\/countries\/2"
17+
}
18+
}
19+
}

0 commit comments

Comments
 (0)