Test Failed
Push — related-record-fixes ( b937a6...adbd9e )
by Alex
02:41
created

JsonApiController::validateIncludableRelations()   B

Complexity

Conditions 5
Paths 4

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 14
rs 8.8571
c 0
b 0
f 0
cc 5
eloc 7
nc 4
nop 1
1
<?php
2
3
namespace Huntie\JsonApi\Http\Controllers;
4
5
use Schema;
6
use Validator;
7
use Huntie\JsonApi\Contracts\Model\IncludesRelatedResources;
8
use Huntie\JsonApi\Exceptions\HttpException;
9
use Huntie\JsonApi\Exceptions\InvalidRelationPathException;
10
use Huntie\JsonApi\Http\JsonApiResponse;
11
use Huntie\JsonApi\Serializers\CollectionSerializer;
12
use Huntie\JsonApi\Serializers\RelationshipSerializer;
13
use Huntie\JsonApi\Serializers\ResourceSerializer;
14
use Huntie\JsonApi\Support\JsonApiErrors;
15
use Illuminate\Database\QueryException;
16
use Illuminate\Database\Eloquent\Model;
17
use Illuminate\Database\Eloquent\ModelNotFoundException;
18
use Illuminate\Database\Eloquent\Relations\BelongsTo;
19
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
20
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
21
use Illuminate\Foundation\Validation\ValidatesRequests;
22
use Illuminate\Http\Request;
23
use Illuminate\Http\Response;
24
use Illuminate\Routing\Controller;
25
use Illuminate\Validation\ValidationException;
26
27
abstract class JsonApiController extends Controller
28
{
29
    use JsonApiErrors;
30
    use AuthorizesRequests;
31
    use ValidatesRequests;
32
33
    /**
34
     * Return the Eloquent Model for the resource.
35
     *
36
     * @return Model
37
     */
38
    abstract protected function getModel();
39
40
    /**
41
     * The model relationships that can be updated.
42
     *
43
     * @return array
44
     */
45
    protected function getModelRelationships()
46
    {
47
        return [];
48
    }
49
50
    /**
51
     * Return a listing of the resource.
52
     *
53
     * @param Request                                    $request
54
     * @param \Illuminate\Database\Eloquent\Builder|null $query   Custom resource query
55
     *
56
     * @return JsonApiResponse
57
     */
58
    public function indexAction(Request $request, $query = null)
59
    {
60
        $records = $query ?: $this->getModel()->newQuery();
61
        $params = $this->getRequestParameters($request);
62
        $this->validateIncludableRelations($params['include']);
63
64
        $records = $this->sortQuery($records, $params['sort']);
0 ignored issues
show
Bug introduced by
It seems like $params['sort'] can also be of type null; however, Huntie\JsonApi\Http\Cont...Controller::sortQuery() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
65
        $records = $this->filterQuery($records, $params['filter']);
66
67
        try {
68
            $pageSize = min($this->getModel()->getPerPage(), $request->input('page.size'));
69
            $pageNumber = $request->input('page.number') ?: 1;
70
71
            $records = $records->paginate($pageSize, null, 'page', $pageNumber);
0 ignored issues
show
Documentation introduced by
null is of type null, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
72
        } catch (QueryException $e) {
73
            return $this->error(Response::HTTP_BAD_REQUEST, 'Invalid query parameters');
74
        }
75
76
        return new JsonApiResponse(new CollectionSerializer($records, $params['fields'], $params['include']));
0 ignored issues
show
Documentation introduced by
$records is of type object<Illuminate\Contra...n\LengthAwarePaginator>, but the function expects a object<Illuminate\Suppor...n\LengthAwarePaginator>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
Bug introduced by
It seems like $params['fields'] can also be of type null; however, Huntie\JsonApi\Serialize...rializer::__construct() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
Bug introduced by
It seems like $params['include'] can also be of type null; however, Huntie\JsonApi\Serialize...rializer::__construct() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
77
    }
78
79
    /**
80
     * Store a new record.
81
     *
82
     * @param Request $request
83
     *
84
     * @return JsonApiResponse
85
     */
86
    public function storeAction(Request $request)
87
    {
88
        $record = $this->getModel()->create((array) $request->input('data.attributes'));
89
90
        if ($relationships = $request->input('data.relationships')) {
91
            $this->updateRecordRelationships($record, (array) $relationships);
92
        }
93
94
        return new JsonApiResponse(new ResourceSerializer($record), Response::HTTP_CREATED);
95
    }
96
97
    /**
98
     * Return a specified record.
99
     *
100
     * @param Request   $request
101
     * @param Model|int $record
102
     *
103
     * @return JsonApiResponse
104
     */
105
    public function showAction(Request $request, $record)
106
    {
107
        $record = $this->findModelInstance($record);
108
        $params = $this->getRequestParameters($request);
109
        $this->validateIncludableRelations($params['include']);
110
111
        return new JsonApiResponse(new ResourceSerializer($record, $params['fields'], $params['include']));
0 ignored issues
show
Bug introduced by
It seems like $params['fields'] can also be of type null; however, Huntie\JsonApi\Serialize...rializer::__construct() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
Bug introduced by
It seems like $params['include'] can also be of type null; however, Huntie\JsonApi\Serialize...rializer::__construct() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
112
    }
113
114
    /**
115
     * Update a specified record.
116
     *
117
     * @param Request   $request
118
     * @param Model|int $record
119
     *
120
     * @return JsonApiResponse
121
     */
122
    public function updateAction(Request $request, $record)
123
    {
124
        $record = $this->findModelInstance($record);
125
        $record->fill((array) $request->input('data.attributes'));
126
        $record->save();
127
128
        if ($relationships = $request->input('data.relationships')) {
129
            $this->updateRecordRelationships($record, (array) $relationships);
130
        }
131
132
        return new JsonApiResponse(new ResourceSerializer($record));
133
    }
134
135
    /**
136
     * Destroy a specified record.
137
     *
138
     * @param Request   $request
139
     * @param Model|int $record
140
     *
141
     * @return JsonApiResponse
142
     */
143
    public function destroyAction(Request $request, $record)
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
144
    {
145
        $record = $this->findModelInstance($record);
146
        $record->delete();
147
148
        return new JsonApiResponse(null, Response::HTTP_NO_CONTENT);
149
    }
150
151
    /**
152
     * Return a specified record relationship.
153
     *
154
     * @param Request   $request
155
     * @param Model|int $record
156
     * @param string    $relation
157
     *
158
     * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
159
     *
160
     * @return JsonApiResponse
161
     */
162
    public function relationshipAction(Request $request, $record, $relation)
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
163
    {
164
        abort_if(!array_key_exists($relation, $this->getModelRelationships()), Response::HTTP_NOT_FOUND);
165
166
        $record = $this->findModelInstance($record);
167
168
        return new JsonApiResponse(new RelationshipSerializer($record, $relation));
169
    }
170
171
    /**
172
     * Update a named many-to-one relationship association on a specified record.
173
     * http://jsonapi.org/format/#crud-updating-to-one-relationships
174
     *
175
     * @param Request     $request
176
     * @param Model|int   $record
177
     * @param string      $relation
178
     *
179
     * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
180
     *
181
     * @return JsonApiResponse
182
     */
183
    public function updateToOneRelationshipAction(Request $request, $record, $relation)
184
    {
185
        abort_if(!array_key_exists($relation, $this->getModelRelationships()), Response::HTTP_NOT_FOUND);
186
187
        $record = $this->findModelInstance($record);
188
        $relation = $this->getModelRelationships()[$relation];
189
        $data = (array) $request->input('data');
190
191
        $record->{$relation->getForeignKey()} = $data['id'];
192
        $record->save();
193
194
        return new JsonApiResponse(new RelationshipSerializer($record, $relation));
195
    }
196
197
    /**
198
     * Update named many-to-many relationship entries on a specified record.
199
     * http://jsonapi.org/format/#crud-updating-to-many-relationships
200
     *
201
     * @param Request   $request
202
     * @param Model|int $record
203
     * @param string    $relation
204
     *
205
     * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
206
     *
207
     * @return JsonApiResponse
208
     */
209
    public function updateToManyRelationshipAction(Request $request, $record, $relation)
210
    {
211
        abort_if(!array_key_exists($relation, $this->getModelRelationships()), Response::HTTP_NOT_FOUND);
212
213
        $record = $this->findModelInstance($record);
214
        $relationships = (array) $request->input('data');
215
        $items = [];
216
217
        foreach ($relationships as $item) {
218
            if (isset($item['attributes'])) {
219
                $items[$item['id']] = $item['attributes'];
220
            } else {
221
                $items[] = $item['id'];
222
            }
223
        }
224
225
        switch ($request->method()) {
226
            case 'PATCH':
227
                $record->{$relation}()->sync($items);
228
                break;
229
            case 'POST':
230
                $record->{$relation}()->sync($items, false);
231
                break;
232
            case 'DELETE':
233
                $record->{$relation}()->detach(array_keys($items));
234
        }
235
236
        return new JsonApiResponse(new RelationshipSerializer($record, $relation));
237
    }
238
239
    /**
240
     * Return existing instance of the resource or find by primary key.
241
     *
242
     * @param Model|int $record
243
     *
244
     * @throws ModelNotFoundException
245
     *
246
     * @return Model
247
     */
248
    protected function findModelInstance($record)
249
    {
250
        if ($record instanceof Model) {
251
            if (is_null($record->getKey())) {
252
                throw new ModelNotFoundException();
253
            }
254
255
            return $record;
256
        }
257
258
        return $this->getModel()->findOrFail($record);
259
    }
260
261
    /**
262
     * Return any JSON API resource parameters from a request.
263
     *
264
     * @param Request $request
265
     *
266
     * @return array
267
     */
268
    protected function getRequestParameters($request)
269
    {
270
        $enableIncluded = config('jsonapi.enable_included_resources');
271
272
        if ($request->has('include') && is_bool($enableIncluded) && !$enableIncluded) {
273
            throw new HttpException('Inclusion of related resources is not supported');
274
        }
275
276
        return [
277
            'fields' => $this->getRequestQuerySet($request, 'fields', '/^([A-Za-z]+.?)+[A-Za-z]+$/'),
278
            'include' => $this->getRequestQuerySet($request, 'include', '/^([A-Za-z]+.?)+[A-Za-z]+$/'),
279
            'sort' => $this->getRequestQuerySet($request, 'sort', '/[A-Za-z_]+/'),
280
            'filter' => (array) $request->input('filter'),
281
        ];
282
    }
283
284
    /**
285
     * Return any comma separated values in a request query field as an array.
286
     *
287
     * @param Request     $request
288
     * @param string      $key
289
     * @param string|null $validate Regular expression to test for each item
290
     *
291
     * @throws \Illuminate\Validation\ValidationException
292
     *
293
     * @return array|null
294
     */
295
    protected function getRequestQuerySet($request, $key, $validate = null)
296
    {
297
        $values = preg_split('/,/', $request->input($key), null, PREG_SPLIT_NO_EMPTY);
298
299
        $validator = Validator::make(['param' => $values], [
300
            'param.*' => 'required' . ($validate ? '|regex:' . $validate : ''),
301
        ]);
302
303
        if ($validator->fails()) {
304
            throw new ValidationException($validator, $this->error(
305
                Response::HTTP_BAD_REQUEST,
306
                sprintf('Invalid values for "%s" parameter', $key))
307
            );
308
        }
309
310
        return count($values) ? $values : null;
311
    }
312
313
    /**
314
     * Validate the requested included relationships against those that are
315
     * allowed on the requested resource type.
316
     *
317
     * @param array|null $relations
318
     *
319
     * @throws InvalidRelationPathException
320
     */
321
    protected function validateIncludableRelations($relations)
322
    {
323
        if (is_null($relations)) {
324
            return;
325
        }
326
327
        $model = $this->getModel();
328
329
        foreach ($relations as $relation) {
330
            if (!$model instanceof IncludesRelatedResources || !in_array($relation, $model->getIncludableRelations())) {
331
                throw new InvalidRelationPathException($relation);
332
            }
333
        }
334
    }
335
336
    /**
337
     * Sort a resource query by one or more attributes.
338
     *
339
     * @param \Illuminate\Database\Eloquent\Builder $query
340
     * @param array                                 $attributes
341
     *
342
     * @return \Illuminate\Database\Eloquent\Builder
343
     */
344
    protected function sortQuery($query, $attributes)
345
    {
346
        foreach ($attributes as $expression) {
347
            $direction = substr($expression, 0, 1) === '-' ? 'desc' : 'asc';
348
            $column = preg_replace('/^\-/', '', $expression);
349
            $query = $query->orderBy($column, $direction);
350
        }
351
352
        return $query;
353
    }
354
355
    /**
356
     * Filter a resource query by one or more attributes.
357
     *
358
     * @param \Illuminate\Database\Eloquent\Builder $query
359
     * @param array                                 $attributes
360
     *
361
     * @return \Illuminate\Database\Eloquent\Builder
362
     */
363
    protected function filterQuery($query, $attributes)
364
    {
365
        $searchableColumns = array_diff(
366
            Schema::getColumnListing($this->getModel()->getTable()),
367
            $this->getModel()->getHidden()
368
        );
369
370
        foreach (array_intersect_key($attributes, array_flip($searchableColumns)) as $column => $value) {
371
            if (is_numeric($value)) {
372
                // Exact numeric match
373
                $query = $query->where($column, $value);
374
            } else if (in_array(strtolower($value), ['true', 'false'])) {
375
                // Boolean match
376
                $query = $query->where($column, filter_var($value, FILTER_VALIDATE_BOOLEAN));
377
            } else {
378
                // Partial string match
379
                $query = $query->where($column, 'LIKE', '%' . $value . '%');
380
            }
381
        }
382
383
        return $query;
384
    }
385
386
    /**
387
     * Update one or more relationships on a model instance.
388
     *
389
     * @param Model $record
390
     * @param array $relationships
391
     */
392
    protected function updateRecordRelationships($record, array $relationships)
393
    {
394
        $relationships = array_intersect_key($relationships, $this->getModelRelationships());
395
396
        foreach ($relationships as $name => $relationship) {
397
            $relation = $this->getModelRelationships()[$name];
398
            $data = $relationship['data'];
399
400
            if ($relation instanceof BelongsTo) {
401
                $record->{$relation->getForeignKey()} = $data['id'];
402
                $record->save();
403
            } else if ($relation instanceof BelongsToMany) {
404
                $record->{$name}()->sync(array_pluck($data, 'id'));
405
            }
406
        }
407
    }
408
}
409