Passed
Pull Request — master (#59)
by
unknown
03:19
created

Serializer::serializeRelationships()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 7
nc 3
nop 1
dl 0
loc 12
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * @author Anton Tuyakhov <[email protected]>
4
 */
5
6
namespace tuyakhov\jsonapi;
7
8
use yii\base\Component;
9
use yii\base\InvalidValueException;
10
use yii\base\Model;
11
use yii\data\DataProviderInterface;
12
use yii\data\Pagination;
13
use yii\web\Link;
14
use yii\web\Linkable;
15
use yii\web\Request;
16
use yii\web\Response;
17
18
class Serializer extends Component
19
{
20
    /**
21
     * @var string the name of the query parameter containing the information about which fields should be returned
22
     * for a [[Model]] object. If the parameter is not provided or empty, the default set of fields as defined
23
     * by [[Model::fields()]] will be returned.
24
     */
25
    public $fieldsParam = 'fields';
26
    /**
27
     * @var string the name of the query parameter containing the information about which fields should be returned
28
     * in addition to those listed in [[fieldsParam]] for a resource object.
29
     */
30
    public $expandParam = 'include';
31
    /**
32
     * @var string the name of the envelope (e.g. `_links`) for returning the links objects.
33
     * It takes effect only, if `collectionEnvelope` is set.
34
     * @since 2.0.4
35
     */
36
    public $linksEnvelope = 'links';
37
    /**
38
     * @var string the name of the envelope (e.g. `_meta`) for returning the pagination object.
39
     * It takes effect only, if `collectionEnvelope` is set.
40
     * @since 2.0.4
41
     */
42
    public $metaEnvelope = 'meta';
43
    /**
44
     * @var Request the current request. If not set, the `request` application component will be used.
45
     */
46
    public $request;
47
    /**
48
     * @var Response the response to be sent. If not set, the `response` application component will be used.
49
     */
50
    public $response;
51
    /**
52
     * @var bool whether to automatically pluralize the `type` of resource.
53
     */
54
    public $pluralize = true;
55
56
    /**
57
     * Prepares the member name that should be returned.
58
     * If not set, all member names will be converted to recommended format.
59
     * For example, both 'firstName' and 'first_name' will be converted to 'first-name'.
60
     * @var callable
61
     */
62
    public $prepareMemberName = ['tuyakhov\jsonapi\Inflector', 'var2member'];
63
64
    /**
65
     * Converts a member name to an attribute name.
66
     * @var callable
67
     */
68
    public $formatMemberName = ['tuyakhov\jsonapi\Inflector', 'member2var'];
69
70
71
    /**
72
     * @inheritdoc
73
     */
74
    public function init()
75
    {
76
        if ($this->request === null) {
77
            $this->request = \Yii::$app->getRequest();
0 ignored issues
show
Documentation Bug introduced by
It seems like Yii::app->getRequest() can also be of type yii\console\Request. However, the property $request is declared as type yii\web\Request. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
78
        }
79
        if ($this->response === null) {
80
            $this->response = \Yii::$app->getResponse();
0 ignored issues
show
Documentation Bug introduced by
It seems like Yii::app->getResponse() can also be of type yii\console\Response. However, the property $response is declared as type yii\web\Response. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
81
        }
82
    }
83
84
    /**
85
     * Serializes the given data into a format that can be easily turned into other formats.
86
     * This method mainly converts the objects of recognized types into array representation.
87
     * It will not do conversion for unknown object types or non-object data.
88
     * @param mixed $data the data to be serialized.
89
     * @return mixed the converted data.
90
     */
91
    public function serialize($data)
92
    {
93
        if ($data instanceof Model && $data->hasErrors()) {
94
            return $this->serializeModelErrors($data);
95
        } elseif ($data instanceof ResourceInterface) {
96
            return $this->serializeResource($data);
97
        } elseif ($data instanceof DataProviderInterface) {
98
            return $this->serializeDataProvider($data);
99
        } elseif ($data instanceof Relationship) {
100
            return $this->serializeRelationshipResource($data);
101
        } else {
102
            return $data;
103
        }
104
    }
105
106
    /**
107
     * @param array $included
108
     * @param ResourceInterface $model
109
     * @return array
110
     */
111
    protected function serializeModel(ResourceInterface $model, array $included = [])
112
    {
113
        $fields = $this->getRequestedFields();
114
        $type = $this->getType($model);
115
        $fields = isset($fields[$type]) ? $fields[$type] : [];
116
117
        $topLevel = array_map(function($item) {
118
            if (($pos = strrpos($item, '.')) !== false) {
119
                return substr($item, 0, $pos);
120
            }
121
            return $item;
122
        }, $included);
123
124
        $attributes = $model->getResourceAttributes($fields);
125
        $attributes = array_combine($this->prepareMemberNames(array_keys($attributes)), array_values($attributes));
126
127
        $data = array_merge($this->serializeIdentifier($model), [
128
            'attributes' => $attributes,
129
        ]);
130
131
        $relationships = $model->getResourceRelationships($topLevel);
132
        if (!empty($relationships)) {
133
            foreach ($relationships as $name => $items) {
134
                $relationship = [];
135
                if (is_array($items)) {
136
                    foreach ($items as $item) {
137
                        if ($item instanceof ResourceIdentifierInterface) {
138
                            $relationship[] = $this->serializeIdentifier($item);
139
                        }
140
                    }
141
                } elseif ($items instanceof ResourceIdentifierInterface) {
142
                    $relationship = $this->serializeIdentifier($items);
143
                }
144
                $memberName = $this->prepareMemberNames([$name]);
145
                $memberName = reset($memberName);
146
                if (!empty($relationship)) {
147
                    $data['relationships'][$memberName]['data'] = $relationship;
148
                }
149
                if ($model instanceof LinksInterface) {
150
                    $links = $model->getRelationshipLinks($memberName);
151
                    if (!empty($links)) {
152
                        $data['relationships'][$memberName]['links'] = Link::serialize($links);
153
                    }
154
                }
155
            }
156
        }
157
158
        if ($model instanceof Linkable) {
159
            $data['links'] = Link::serialize($model->getLinks());
160
        }
161
162
        return $data;
163
    }
164
165
    /**
166
     * @param ResourceInterface $resource
167
     * @return array
168
     */
169
    protected function serializeResource(ResourceInterface $resource)
170
    {
171
        if ($this->request->getIsHead()) {
172
            return null;
173
        } else {
174
            $included = $this->getIncluded();
175
            $data = [
176
                'data' => $this->serializeModel($resource, $included)
177
            ];
178
179
            $relatedResources = $this->serializeIncluded($resource, $included);
180
            if (!empty($relatedResources)) {
181
                $data['included'] = $relatedResources;
182
            }
183
184
            return $data;
185
        }
186
    }
187
188
    /**
189
     * Serialize resource identifier object and make type juggling
190
     * @link http://jsonapi.org/format/#document-resource-object-identification
191
     * @param ResourceIdentifierInterface $identifier
192
     * @return array
193
     */
194
    protected function serializeIdentifier(ResourceIdentifierInterface $identifier)
195
    {
196
        $result = [];
197
        foreach (['id', 'type'] as $key) {
198
            $getter = 'get' . ucfirst($key);
199
            $value = $identifier->$getter();
200
            if ($value === null || is_array($value) || (is_object($value) && !method_exists($value, '__toString'))) {
201
                throw new InvalidValueException("The value {$key} of resource object " . get_class($identifier) . ' MUST be a string.');
202
            }
203
            if ($key === 'type' && $this->pluralize) {
204
                $value = Inflector::pluralize($value);
205
            }
206
            $result[$key] = (string) $value;
207
        }
208
        return $result;
209
    }
210
211
    /**
212
     * @param ResourceInterface|array $resources
213
     * @param array $included
214
     * @param true $assoc
215
     * @return array
216
     */
217
    protected function serializeIncluded($resources, array $included = [], $assoc = false)
218
    {
219
        $resources = is_array($resources) ? $resources : [$resources];
220
        $data = [];
221
222
        $inclusion = [];
223
        foreach ($included as $path) {
224
            if (($pos = strrpos($path, '.')) === false) {
225
                $inclusion[$path] = [];
226
                continue;
227
            }
228
            $name = substr($path, $pos + 1);
229
            $key = substr($path, 0, $pos);
230
            $inclusion[$key][] = $name;
231
        }
232
233
        foreach ($resources as $resource) {
234
            if (!$resource instanceof  ResourceInterface) {
235
                continue;
236
            }
237
            $relationships = $resource->getResourceRelationships(array_keys($inclusion));
238
            foreach ($relationships as $name => $relationship) {
239
                if ($relationship === null) {
240
                    continue;
241
                }
242
                if (!is_array($relationship)) {
243
                    $relationship = [$relationship];
244
                }
245
                foreach ($relationship as $model) {
246
                    if (!$model instanceof ResourceInterface) {
247
                        continue;
248
                    }
249
                    $uniqueKey = $model->getType() . '/' . $model->getId();
250
                    if (!isset($data[$uniqueKey])) {
251
                        $data[$uniqueKey] = $this->serializeModel($model, $inclusion[$name]);
252
                    }
253
                    if (!empty($inclusion[$name])) {
254
                        $data = array_merge($data, $this->serializeIncluded($model, $inclusion[$name], true));
255
                    }
256
                }
257
            }
258
        }
259
260
        return $assoc ? $data : array_values($data);
261
    }
262
263
    /**
264
     * Serializes a data provider.
265
     * @param DataProviderInterface $dataProvider
266
     * @return null|array the array representation of the data provider.
267
     */
268
    protected function serializeDataProvider($dataProvider)
269
    {
270
        if ($this->request->getIsHead()) {
271
            return null;
272
        } else {
273
            $models = $dataProvider->getModels();
274
            $data = [];
275
276
            $included = $this->getIncluded();
277
            foreach ($models as $model) {
278
                if ($model instanceof ResourceInterface) {
279
                    $data[] = $this->serializeModel($model, $included);
280
                }
281
            }
282
283
            $result = ['data' => $data];
284
285
            $relatedResources = $this->serializeIncluded($models, $included);
286
            if (!empty($relatedResources)) {
287
                $result['included'] = $relatedResources;
288
            }
289
290
            if (($pagination = $dataProvider->getPagination()) !== false) {
291
                return array_merge($result, $this->serializePagination($pagination));
292
            }
293
294
            return $result;
295
        }
296
    }
297
298
    /**
299
     * Serializes a pagination into an array.
300
     * @param Pagination $pagination
301
     * @return array the array representation of the pagination
302
     * @see addPaginationHeaders()
303
     */
304
    protected function serializePagination($pagination)
305
    {
306
        return [
307
            $this->linksEnvelope => Link::serialize($pagination->getLinks(true)),
308
            $this->metaEnvelope => [
309
                'total-count' => $pagination->totalCount,
310
                'page-count' => $pagination->getPageCount(),
311
                'current-page' => $pagination->getPage() + 1,
312
                'per-page' => $pagination->getPageSize(),
313
            ],
314
        ];
315
    }
316
317
    /**
318
     * Serializes the validation errors in a model.
319
     * @param Model $model
320
     * @return array the array representation of the errors
321
     */
322
    protected function serializeModelErrors($model)
323
    {
324
        $this->response->setStatusCode(422, 'Data Validation Failed.');
325
        $result = [];
326
        foreach ($model->getFirstErrors() as $name => $message) {
327
            $memberName = call_user_func($this->prepareMemberName, $name);
328
            $result[] = [
329
                'source' => ['pointer' => "/data/attributes/{$memberName}"],
330
                'detail' => $message,
331
                'status' => '422'
332
            ];
333
        }
334
335
        return $result;
336
    }
337
338
    protected function serializeRelationshipResource($relationship)
339
    {
340
        return [
341
            'data' => $this->serializeRelationships($relationship)
342
        ];
343
    }
344
345
    public function serializeRelationships($relationship)
346
    {
347
        if (!$relationship->multiple) {
348
            if (!count($relationship->relations)) {
349
                return null;
350
            }
351
            return $this->serializeRelationship($relationship->relations[0]);
352
        }
353
354
        return array_map(function($relation) {
355
            return $this->serializeRelationship($relation);
356
        }, $relationship->relations);
357
    }
358
359
    public function serializeRelationship($relationship) 
360
    {
361
        $primaryKey = $relationship->getPrimaryKey(true);
362
        return [
363
            "id" => implode("-", $primaryKey),
364
            "type" => $this->getType($relationship),
365
        ];
366
    }
367
368
    /**
369
     * @return array
370
     */
371
    protected function getRequestedFields()
372
    {
373
        $fields = $this->request->get($this->fieldsParam);
374
375
        if (!is_array($fields)) {
376
            $fields = [];
377
        }
378
        foreach ($fields as $key => $field) {
379
            $fields[$key] = array_map($this->formatMemberName, preg_split('/\s*,\s*/', $field, -1, PREG_SPLIT_NO_EMPTY));
0 ignored issues
show
Bug introduced by
It seems like preg_split('/\s*,\s*/', ...pi\PREG_SPLIT_NO_EMPTY) can also be of type false; however, parameter $arr1 of array_map() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

379
            $fields[$key] = array_map($this->formatMemberName, /** @scrutinizer ignore-type */ preg_split('/\s*,\s*/', $field, -1, PREG_SPLIT_NO_EMPTY));
Loading history...
380
        }
381
        return $fields;
382
    }
383
384
    /**
385
     * @return array|null
386
     */
387
    protected function getIncluded()
388
    {
389
        $include = $this->request->get($this->expandParam);
390
        return is_string($include) ? array_map($this->formatMemberName, preg_split('/\s*,\s*/', $include, -1, PREG_SPLIT_NO_EMPTY)) : [];
0 ignored issues
show
Bug introduced by
It seems like preg_split('/\s*,\s*/', ...pi\PREG_SPLIT_NO_EMPTY) can also be of type false; however, parameter $arr1 of array_map() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

390
        return is_string($include) ? array_map($this->formatMemberName, /** @scrutinizer ignore-type */ preg_split('/\s*,\s*/', $include, -1, PREG_SPLIT_NO_EMPTY)) : [];
Loading history...
391
    }
392
393
394
    /**
395
     * Format member names according to recommendations for JSON API implementations
396
     * @link http://jsonapi.org/format/#document-member-names
397
     * @param array $memberNames
398
     * @return array
399
     */
400
    protected function prepareMemberNames(array $memberNames = [])
401
    {
402
        return array_map($this->prepareMemberName, $memberNames);
403
    }
404
405
    protected function getType($model)
406
    {
407
        return $this->pluralize ? Inflector::pluralize($model->getType()) : $model->getType();
408
    }
409
}
410