Serializer::serializeResource()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 16
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 10
nc 3
nop 1
dl 0
loc 16
rs 9.9332
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;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, tuyakhov\jsonapi\Pagination. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
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
        } else {
100
            return $data;
101
        }
102
    }
103
104
    /**
105
     * @param array $included
106
     * @param ResourceInterface $model
107
     * @return array
108
     */
109
    protected function serializeModel(ResourceInterface $model, array $included = [])
110
    {
111
        $fields = $this->getRequestedFields();
112
        $type = $this->pluralize ? Inflector::pluralize($model->getType()) : $model->getType();
113
        $fields = isset($fields[$type]) ? $fields[$type] : [];
114
115
        $topLevel = array_map(function($item) {
116
            if (($pos = strrpos($item, '.')) !== false) {
117
                return substr($item, 0, $pos);
118
            }
119
            return $item;
120
        }, $included);
121
122
        $attributes = $model->getResourceAttributes($fields);
123
        $attributes = array_combine($this->prepareMemberNames(array_keys($attributes)), array_values($attributes));
124
125
        $data = array_merge($this->serializeIdentifier($model), [
126
            'attributes' => $attributes,
127
        ]);
128
129
        $relationships = $model->getResourceRelationships($topLevel);
130
        if (!empty($relationships)) {
131
            foreach ($relationships as $name => $items) {
132
                $relationship = [];
133
                if (is_array($items)) {
134
                    foreach ($items as $item) {
135
                        if ($item instanceof ResourceIdentifierInterface) {
136
                            $relationship[] = $this->serializeIdentifier($item);
137
                        }
138
                    }
139
                } elseif ($items instanceof ResourceIdentifierInterface) {
140
                    $relationship = $this->serializeIdentifier($items);
141
                }
142
                $memberName = $this->prepareMemberNames([$name]);
143
                $memberName = reset($memberName);
144
                if (!empty($relationship)) {
145
                    $data['relationships'][$memberName]['data'] = $relationship;
146
                }
147
                if ($model instanceof LinksInterface) {
148
                    $links = $model->getRelationshipLinks($memberName);
149
                    if (!empty($links)) {
150
                        $data['relationships'][$memberName]['links'] = Link::serialize($links);
151
                    }
152
                }
153
            }
154
        }
155
156
        if ($model instanceof Linkable) {
157
            $data['links'] = Link::serialize($model->getLinks());
158
        }
159
160
        return $data;
161
    }
162
163
    /**
164
     * @param ResourceInterface $resource
165
     * @return array
166
     */
167
    protected function serializeResource(ResourceInterface $resource)
168
    {
169
        if ($this->request->getIsHead()) {
170
            return null;
171
        } else {
172
            $included = $this->getIncluded();
173
            $data = [
174
                'data' => $this->serializeModel($resource, $included)
175
            ];
176
177
            $relatedResources = $this->serializeIncluded($resource, $included);
178
            if (!empty($relatedResources)) {
179
                $data['included'] = $relatedResources;
180
            }
181
182
            return $data;
183
        }
184
    }
185
186
    /**
187
     * Serialize resource identifier object and make type juggling
188
     * @link http://jsonapi.org/format/#document-resource-object-identification
189
     * @param ResourceIdentifierInterface $identifier
190
     * @return array
191
     */
192
    protected function serializeIdentifier(ResourceIdentifierInterface $identifier)
193
    {
194
        $result = [];
195
        foreach (['id', 'type'] as $key) {
196
            $getter = 'get' . ucfirst($key);
197
            $value = $identifier->$getter();
198
            if ($value === null || is_array($value) || (is_object($value) && !method_exists($value, '__toString'))) {
199
                throw new InvalidValueException("The value {$key} of resource object " . get_class($identifier) . ' MUST be a string.');
200
            }
201
            if ($key === 'type' && $this->pluralize) {
202
                $value = Inflector::pluralize($value);
203
            }
204
            $result[$key] = (string) $value;
205
        }
206
        return $result;
207
    }
208
209
    /**
210
     * @param ResourceInterface|array $resources
211
     * @param array $included
212
     * @param true $assoc
213
     * @return array
214
     */
215
    protected function serializeIncluded($resources, array $included = [], $assoc = false)
216
    {
217
        $resources = is_array($resources) ? $resources : [$resources];
218
        $data = [];
219
220
        $inclusion = [];
221
        foreach ($included as $path) {
222
            if (($pos = strrpos($path, '.')) === false) {
223
                $inclusion[$path] = [];
224
                continue;
225
            }
226
            $name = substr($path, $pos + 1);
227
            $key = substr($path, 0, $pos);
228
            $inclusion[$key][] = $name;
229
        }
230
231
        foreach ($resources as $resource) {
232
            if (!$resource instanceof  ResourceInterface) {
233
                continue;
234
            }
235
            $relationships = $resource->getResourceRelationships(array_keys($inclusion));
236
            foreach ($relationships as $name => $relationship) {
237
                if ($relationship === null) {
238
                    continue;
239
                }
240
                if (!is_array($relationship)) {
241
                    $relationship = [$relationship];
242
                }
243
                foreach ($relationship as $model) {
244
                    if (!$model instanceof ResourceInterface) {
245
                        continue;
246
                    }
247
                    $uniqueKey = $model->getType() . '/' . $model->getId();
248
                    if (!isset($data[$uniqueKey])) {
249
                        $data[$uniqueKey] = $this->serializeModel($model, $inclusion[$name]);
250
                    }
251
                    if (!empty($inclusion[$name])) {
252
                        $data = array_merge($data, $this->serializeIncluded($model, $inclusion[$name], true));
253
                    }
254
                }
255
            }
256
        }
257
258
        return $assoc ? $data : array_values($data);
259
    }
260
261
    /**
262
     * Serializes a data provider.
263
     * @param DataProviderInterface $dataProvider
264
     * @return null|array the array representation of the data provider.
265
     */
266
    protected function serializeDataProvider($dataProvider)
267
    {
268
        if ($this->request->getIsHead()) {
269
            return null;
270
        } else {
271
            $models = $dataProvider->getModels();
272
            $data = [];
273
274
            $included = $this->getIncluded();
275
            foreach ($models as $model) {
276
                if ($model instanceof ResourceInterface) {
277
                    $data[] = $this->serializeModel($model, $included);
278
                }
279
            }
280
281
            $result = ['data' => $data];
282
283
            $relatedResources = $this->serializeIncluded($models, $included);
284
            if (!empty($relatedResources)) {
285
                $result['included'] = $relatedResources;
286
            }
287
288
            if (($pagination = $dataProvider->getPagination()) !== false) {
289
                return array_merge($result, $this->serializePagination($pagination));
290
            }
291
292
            return $result;
293
        }
294
    }
295
296
    /**
297
     * Serializes a pagination into an array.
298
     * @param Pagination $pagination
299
     * @return array the array representation of the pagination
300
     * @see addPaginationHeaders()
301
     */
302
    protected function serializePagination($pagination)
303
    {
304
        return [
305
            $this->linksEnvelope => Link::serialize($pagination->getLinks(true)),
306
            $this->metaEnvelope => [
307
                'total-count' => $pagination->totalCount,
308
                'page-count' => $pagination->getPageCount(),
309
                'current-page' => $pagination->getPage() + 1,
310
                'per-page' => $pagination->getPageSize(),
311
            ],
312
        ];
313
    }
314
315
    /**
316
     * Serializes the validation errors in a model.
317
     * @param Model $model
318
     * @return array the array representation of the errors
319
     */
320
    protected function serializeModelErrors($model)
321
    {
322
        $this->response->setStatusCode(422, 'Data Validation Failed.');
323
        $result = [];
324
        foreach ($model->getFirstErrors() as $name => $message) {
325
            $memberName = call_user_func($this->prepareMemberName, $name);
326
            $result[] = [
327
                'source' => ['pointer' => "/data/attributes/{$memberName}"],
328
                'detail' => $message,
329
                'status' => '422'
330
            ];
331
        }
332
333
        return $result;
334
    }
335
336
    /**
337
     * @return array
338
     */
339
    protected function getRequestedFields()
340
    {
341
        $fields = $this->request->get($this->fieldsParam);
342
343
        if (!is_array($fields)) {
344
            $fields = [];
345
        }
346
        foreach ($fields as $key => $field) {
347
            $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

347
            $fields[$key] = array_map($this->formatMemberName, /** @scrutinizer ignore-type */ preg_split('/\s*,\s*/', $field, -1, PREG_SPLIT_NO_EMPTY));
Loading history...
348
        }
349
        return $fields;
350
    }
351
352
    /**
353
     * @return array|null
354
     */
355
    protected function getIncluded()
356
    {
357
        $include = $this->request->get($this->expandParam);
358
        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

358
        return is_string($include) ? array_map($this->formatMemberName, /** @scrutinizer ignore-type */ preg_split('/\s*,\s*/', $include, -1, PREG_SPLIT_NO_EMPTY)) : [];
Loading history...
359
    }
360
361
362
    /**
363
     * Format member names according to recommendations for JSON API implementations
364
     * @link http://jsonapi.org/format/#document-member-names
365
     * @param array $memberNames
366
     * @return array
367
     */
368
    protected function prepareMemberNames(array $memberNames = [])
369
    {
370
        return array_map($this->prepareMemberName, $memberNames);
371
    }
372
}
373