Passed
Push — testing ( a7098f...bdd682 )
by Alex
04:35
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']);
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']));
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'));
0 ignored issues
show
Bug introduced by
The method create() does not exist on Illuminate\Database\Eloquent\Model. Did you maybe mean created()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
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']));
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
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 $values;
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