Passed
Push — request-validation ( eee8a4...ac465a )
by Alex
03:47
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 Huntie\JsonApi\Contracts\Model\IncludesRelatedResources;
7
use Huntie\JsonApi\Exceptions\InvalidRelationPathException;
8
use Huntie\JsonApi\Http\JsonApiResponse;
9
use Huntie\JsonApi\Serializers\CollectionSerializer;
10
use Huntie\JsonApi\Serializers\RelationshipSerializer;
11
use Huntie\JsonApi\Serializers\ResourceSerializer;
12
use Huntie\JsonApi\Support\JsonApiErrors;
13
use Illuminate\Database\QueryException;
14
use Illuminate\Database\Eloquent\Model;
15
use Illuminate\Database\Eloquent\ModelNotFoundException;
16
use Illuminate\Database\Eloquent\Relations\BelongsTo;
17
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
18
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
19
use Illuminate\Foundation\Validation\ValidatesRequests;
20
use Illuminate\Http\Request;
21
use Illuminate\Http\Response;
22
use Illuminate\Routing\Controller;
23
use Illuminate\Validation\ValidationException;
24
25
abstract class JsonApiController extends Controller
26
{
27
    use JsonApiErrors;
28
    use AuthorizesRequests;
29
    use ValidatesRequests;
30
31
    /**
32
     * Return the Eloquent Model for the resource.
33
     *
34
     * @return Model
35
     */
36
    abstract protected function getModel();
37
38
    /**
39
     * The model relationships that can be updated.
40
     *
41
     * @return array
42
     */
43
    protected function getModelRelationships()
44
    {
45
        return [];
46
    }
47
48
    /**
49
     * Return a listing of the resource.
50
     *
51
     * @param Request                                    $request
52
     * @param \Illuminate\Database\Eloquent\Builder|null $query   Custom resource query
53
     *
54
     * @return JsonApiResponse
55
     */
56
    public function indexAction(Request $request, $query = null)
57
    {
58
        $records = $query ?: $this->getModel()->newQuery();
59
        $params = $this->getRequestParameters($request);
60
        $this->validateIncludableRelations($params['include']);
61
62
        $records = $this->sortQuery($records, $params['sort']);
63
        $records = $this->filterQuery($records, $params['filter']);
64
65
        try {
66
            $pageSize = min($this->getModel()->getPerPage(), $request->input('page.size'));
67
            $pageNumber = $request->input('page.number') ?: 1;
68
69
            $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...
70
        } catch (QueryException $e) {
71
            return $this->error(Response::HTTP_BAD_REQUEST, 'Invalid query parameters');
72
        }
73
74
        return new JsonApiResponse(new CollectionSerializer($records, $params['fields'], $params['include']));
75
    }
76
77
    /**
78
     * Store a new record.
79
     *
80
     * @param Request $request
81
     *
82
     * @return JsonApiResponse
83
     */
84
    public function storeAction(Request $request)
85
    {
86
        $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...
87
88
        if ($relationships = $request->input('data.relationships')) {
89
            $this->updateRecordRelationships($record, (array) $relationships);
90
        }
91
92
        return new JsonApiResponse(new ResourceSerializer($record), Response::HTTP_CREATED);
93
    }
94
95
    /**
96
     * Return a specified record.
97
     *
98
     * @param Request     $request
99
     * @param Model|mixed $record
100
     *
101
     * @return JsonApiResponse
102
     */
103
    public function showAction(Request $request, $record)
104
    {
105
        $record = $this->findModelInstance($record);
106
        $params = $this->getRequestParameters($request);
107
        $this->validateIncludableRelations($params['include']);
108
109
        return new JsonApiResponse(new ResourceSerializer($record, $params['fields'], $params['include']));
110
    }
111
112
    /**
113
     * Update a specified record.
114
     *
115
     * @param Request     $request
116
     * @param Model|mixed $record
117
     *
118
     * @return JsonApiResponse
119
     */
120
    public function updateAction(Request $request, $record)
121
    {
122
        $record = $this->findModelInstance($record);
123
        $record->fill((array) $request->input('data.attributes'));
124
        $record->save();
125
126
        if ($relationships = $request->input('data.relationships')) {
127
            $this->updateRecordRelationships($record, (array) $relationships);
128
        }
129
130
        return new JsonApiResponse(new ResourceSerializer($record));
131
    }
132
133
    /**
134
     * Destroy a specified record.
135
     *
136
     * @param Request     $request
137
     * @param Model|mixed $record
138
     *
139
     * @return JsonApiResponse
140
     */
141
    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...
142
    {
143
        $record = $this->findModelInstance($record);
144
        $record->delete();
145
146
        return new JsonApiResponse(null, Response::HTTP_NO_CONTENT);
147
    }
148
149
    /**
150
     * Return a specified record relationship.
151
     *
152
     * @param Request     $request
153
     * @param Model|mixed $record
154
     * @param string      $relation
155
     *
156
     * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
157
     *
158
     * @return JsonApiResponse
159
     */
160
    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...
161
    {
162
        abort_if(!array_key_exists($relation, $this->getModelRelationships()), Response::HTTP_NOT_FOUND);
163
164
        $record = $this->findModelInstance($record);
165
166
        return new JsonApiResponse(new RelationshipSerializer($record, $relation));
167
    }
168
169
    /**
170
     * Update a named many-to-one relationship association on a specified record.
171
     * http://jsonapi.org/format/#crud-updating-to-one-relationships
172
     *
173
     * @param Request     $request
174
     * @param Model|mixed $record
175
     * @param string      $relation
176
     *
177
     * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
178
     *
179
     * @return JsonApiResponse
180
     */
181
    public function updateToOneRelationshipAction(Request $request, $record, $relation)
182
    {
183
        abort_if(!array_key_exists($relation, $this->getModelRelationships()), Response::HTTP_NOT_FOUND);
184
185
        $record = $this->findModelInstance($record);
186
        $relation = $this->getModelRelationships()[$relation];
187
        $data = (array) $request->input('data');
188
189
        $record->{$relation->getForeignKey()} = $data['id'];
190
        $record->save();
191
192
        return new JsonApiResponse(new RelationshipSerializer($record, $relation));
193
    }
194
195
    /**
196
     * Update named many-to-many relationship entries on a specified record.
197
     * http://jsonapi.org/format/#crud-updating-to-many-relationships
198
     *
199
     * @param Request     $request
200
     * @param Model|mixed $record
201
     * @param string      $relation
202
     *
203
     * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
204
     *
205
     * @return JsonApiResponse
206
     */
207
    public function updateToManyRelationshipAction(Request $request, $record, $relation)
208
    {
209
        abort_if(!array_key_exists($relation, $this->getModelRelationships()), Response::HTTP_NOT_FOUND);
210
211
        $record = $this->findModelInstance($record);
212
        $relationships = (array) $request->input('data');
213
        $items = [];
214
215
        foreach ($relationships as $item) {
216
            if (isset($item['attributes'])) {
217
                $items[$item['id']] = $item['attributes'];
218
            } else {
219
                $items[] = $item['id'];
220
            }
221
        }
222
223
        switch ($request->method()) {
224
            case 'PATCH':
225
                $record->{$relation}()->sync($items);
226
                break;
227
            case 'POST':
228
                $record->{$relation}()->sync($items, false);
229
                break;
230
            case 'DELETE':
231
                $record->{$relation}()->detach(array_keys($items));
232
        }
233
234
        return new JsonApiResponse(new RelationshipSerializer($record, $relation));
235
    }
236
237
    /**
238
     * Return existing instance of the resource or find by primary key.
239
     *
240
     * @param Model|mixed $record
241
     *
242
     * @throws ModelNotFoundException
243
     *
244
     * @return Model
245
     */
246
    protected function findModelInstance($record)
247
    {
248
        if ($record instanceof Model) {
249
            if (is_null($record->getKey())) {
250
                throw new ModelNotFoundException();
251
            }
252
253
            return $record;
254
        }
255
256
        return $this->getModel()->findOrFail($record);
257
    }
258
259
    /**
260
     * Return any JSON API resource parameters from a request.
261
     *
262
     * @param Request $request
263
     *
264
     * @return array
265
     */
266
    protected function getRequestParameters($request)
267
    {
268
        return [
269
            'fields' => $this->getRequestQuerySet($request, 'fields', '/^([A-Za-z]+.?)+[A-Za-z]+$/'),
270
            'include' => $this->getRequestQuerySet($request, 'include', '/^([A-Za-z]+.?)+[A-Za-z]+$/'),
271
            'sort' => $this->getRequestQuerySet($request, 'sort', '/[A-Za-z_]+/'),
272
            'filter' => (array) $request->input('filter'),
273
        ];
274
    }
275
276
    /**
277
     * Return any comma separated values in a request query field as an array.
278
     *
279
     * @param Request     $request
280
     * @param string      $key
281
     * @param string|null $validate Regular expression to test for each item
282
     *
283
     * @throws \Illuminate\Validation\ValidationException
284
     *
285
     * @return array
286
     */
287
    protected function getRequestQuerySet($request, $key, $validate = null)
288
    {
289
        $values = preg_split('/,/', $request->input($key), null, PREG_SPLIT_NO_EMPTY);
290
291
        $validator = Validator::make(['param' => $values], [
292
            'param.*' => 'required' . ($validate ? '|regex:' . $validate : ''),
293
        ]);
294
295
        if ($validator->fails()) {
296
            throw new ValidationException($validator, $this->error(
297
                Response::HTTP_BAD_REQUEST,
298
                sprintf('Invalid values for "%s" parameter', $key))
299
            );
300
        }
301
302
        return $values;
303
    }
304
305
    /**
306
     * Validate the requested included relationships against those that are
307
     * allowed on the requested resource type.
308
     *
309
     * @param array|null $relations
310
     *
311
     * @throws InvalidRelationPathException
312
     */
313
    protected function validateIncludableRelations($relations)
314
    {
315
        if (is_null($relations)) {
316
            return;
317
        }
318
319
        $model = $this->getModel();
320
321
        foreach ($relations as $relation) {
322
            if (!$model instanceof IncludesRelatedResources || !in_array($relation, $model->getIncludableRelations())) {
323
                throw new InvalidRelationPathException($relation);
324
            }
325
        }
326
    }
327
328
    /**
329
     * Sort a resource query by one or more attributes.
330
     *
331
     * @param \Illuminate\Database\Eloquent\Builder $query
332
     * @param array                                 $attributes
333
     *
334
     * @return \Illuminate\Database\Eloquent\Builder
335
     */
336
    protected function sortQuery($query, $attributes)
337
    {
338
        foreach ($attributes as $expression) {
339
            $direction = substr($expression, 0, 1) === '-' ? 'desc' : 'asc';
340
            $column = preg_replace('/^\-/', '', $expression);
341
            $query = $query->orderBy($column, $direction);
342
        }
343
344
        return $query;
345
    }
346
347
    /**
348
     * Filter a resource query by one or more attributes.
349
     *
350
     * @param \Illuminate\Database\Eloquent\Builder $query
351
     * @param array                                 $attributes
352
     *
353
     * @return \Illuminate\Database\Eloquent\Builder
354
     */
355
    protected function filterQuery($query, $attributes)
356
    {
357
        $searchableColumns = array_diff(
358
            Schema::getColumnListing($this->getModel()->getTable()),
359
            $this->getModel()->getHidden()
360
        );
361
362
        foreach (array_intersect_key($attributes, array_flip($searchableColumns)) as $column => $value) {
363
            if (is_numeric($value)) {
364
                // Exact numeric match
365
                $query = $query->where($column, $value);
366
            } else if (in_array(strtolower($value), ['true', 'false'])) {
367
                // Boolean match
368
                $query = $query->where($column, filter_var($value, FILTER_VALIDATE_BOOLEAN));
369
            } else {
370
                // Partial string match
371
                $query = $query->where($column, 'LIKE', '%' . $value . '%');
372
            }
373
        }
374
375
        return $query;
376
    }
377
378
    /**
379
     * Update one or more relationships on a model instance.
380
     *
381
     * @param Model $record
382
     * @param array $relationships
383
     */
384
    protected function updateRecordRelationships($record, array $relationships)
385
    {
386
        $relationships = array_intersect_key($relationships, $this->getModelRelationships());
387
388
        foreach ($relationships as $name => $relationship) {
389
            $relation = $this->getModelRelationships()[$name];
390
            $data = $relationship['data'];
391
392
            if ($relation instanceof BelongsTo) {
393
                $record->{$relation->getForeignKey()} = $data['id'];
394
                $record->save();
395
            } else if ($relation instanceof BelongsToMany) {
396
                $record->{$name}()->sync(array_pluck($data, 'id'));
397
            }
398
        }
399
    }
400
}
401