Passed
Push — master ( 883956...b69e20 )
by Tom
01:03 queued 10s
created

AbstractRestfulController::options()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 10
rs 9.9332
c 0
b 0
f 0
cc 2
nc 2
nop 1
1
<?php
2
3
namespace TomHart\Restful;
4
5
use BadMethodCallException;
6
use Exception;
7
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
8
use Illuminate\Contracts\Routing\ResponseFactory;
9
use Illuminate\Contracts\View\Factory;
10
use Illuminate\Database\Eloquent\Builder;
11
use Illuminate\Database\Eloquent\Collection;
12
use Illuminate\Database\Eloquent\Model;
13
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
14
use Illuminate\Database\Eloquent\Relations\HasMany;
15
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
16
use Illuminate\Database\Eloquent\Relations\MorphMany;
17
use Illuminate\Database\Eloquent\Relations\MorphOneOrMany;
18
use Illuminate\Database\Eloquent\Relations\MorphToMany;
19
use Illuminate\Database\Eloquent\Relations\Relation;
20
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
21
use Illuminate\Foundation\Validation\ValidatesRequests;
22
use Illuminate\Http\JsonResponse;
23
use Illuminate\Http\RedirectResponse;
24
use Illuminate\Http\Request;
25
use Illuminate\Http\Response;
26
use Illuminate\Routing\Controller as BaseController;
27
use Illuminate\Routing\Redirector;
28
use Illuminate\Routing\Route;
29
use Illuminate\Support\Str;
30
use Illuminate\View\View;
31
use InvalidArgumentException;
32
use Symfony\Component\HttpFoundation\Response as SymResponse;
33
use TomHart\Restful\Concerns\HasLinks;
34
35
abstract class AbstractRestfulController extends BaseController
36
{
37
    use AuthorizesRequests, ValidatesRequests;
38
39
40
    /**
41
     * The views to render.
42
     * @var string[]
43
     */
44
    protected $views = [];
45
46
    /**
47
     * What Model class to search for entities.
48
     * @return string
49
     */
50
    abstract protected function getModelClass(): string;
51
52
    /**
53
     * Return a list of matching models.
54
     * @param Request $request
55
     * @return JsonResponse|RedirectResponse|ResponseFactory|Response|Redirector
56
     */
57
    public function index(Request $request)
58
    {
59
        $builder = $this->createModelQueryBuilder();
60
61
        foreach (collect($request->input())->except('page')->toArray() as $column => $value) {
62
            $this->filterValue($builder, $column, $value);
63
        }
64
65
        $data = $builder->paginate();
66
67
        return $this->return($request, $data, 'index');
68
    }
69
70
    /**
71
     * Handles creating a model. The C of CRUD
72
     * @param Request $request
73
     * @return JsonResponse|RedirectResponse|ResponseFactory|Response|Redirector
74
     */
75 View Code Duplication
    public function store(Request $request)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
76
    {
77
        $model = $this->newModelInstance();
78
79
        foreach ((array)$request->input() as $column => $value) {
80
            $model->$column = $value;
81
        }
82
83
        $model->save();
84
85
86
        return $this->return($request, $this->findModel($model->getAttribute('id')), 'store');
87
    }
88
89
    /**
90
     * Shows a model. The R of CRUD.
91
     * @param Request $request
92
     * @param int $id
93
     * @return JsonResponse|RedirectResponse|ResponseFactory|Response|Redirector
94
     */
95
    public function show(Request $request, $id)
96
    {
97
        $model = $this->findModel($id, $request);
98
99
        $model = $this->iterateThroughChildren($model, $request);
100
101
        return $this->return($request, $model, 'show');
102
    }
103
104
    /**
105
     * Update a record. The U of CRUD.
106
     * @param Request $request
107
     * @param int $id
108
     * @return JsonResponse|RedirectResponse|ResponseFactory|Response|Redirector
109
     */
110 View Code Duplication
    public function update(Request $request, $id)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
111
    {
112
        $model = $this->findModel($id);
113
114
        foreach ((array)$request->input() as $column => $value) {
115
            $model->$column = $value;
116
        }
117
118
        $model->save();
119
120
        return $this->return($request, $model, 'update');
121
    }
122
123
    /**
124
     * Destroy a model. The D of CRUD.
125
     * @param Request $request
126
     * @param int $id
127
     * @return ResponseFactory|Response
128
     * @throws Exception
129
     */
130
    public function destroy(Request $request, $id)
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...
131
    {
132
        $model = $this->findModel($id);
133
134
        $model->delete();
135
136
        return response(null, SymResponse::HTTP_NO_CONTENT);
137
    }
138
139
140
    /**
141
     * Return the _links
142
     * @param Request $request
143
     * @return ResponseFactory|JsonResponse|RedirectResponse|Response|Redirector
144
     */
145
    public function options(Request $request)
146
    {
147
        $class = $this->newModelInstance();
148
149
        if (!($class instanceof HasLinks)) {
150
            throw new InvalidArgumentException('OPTIONS only works for models implementing HasLinks');
151
        }
152
153
        return $this->return($request, $class->buildLinks(), 'options');
154
    }
155
156
    /**
157
     * Generate a new query builder for the model.
158
     * @return Builder
159
     */
160
    private function createModelQueryBuilder(): Builder
161
    {
162
        $class = $this->newModelInstance();
163
164
        return $class->newQuery();
165
    }
166
167
    /**
168
     * Creates a new model instance.
169
     * @return Model
170
     */
171
    private function newModelInstance(): Model
172
    {
173
        $classFQDN = $this->getModelClass();
174
175
        return app($classFQDN);
176
    }
177
178
    /**
179
     * Looks for an "extra" param in the route, and if it exists, looks for relationships
180
     * based on that route.
181
     * @param Model $model
182
     * @param Request $request
183
     * @return LengthAwarePaginator|Collection|Model|mixed
184
     * @throws BadMethodCallException
185
     */
186
    private function iterateThroughChildren(Model $model, Request $request)
187
    {
188
189
        // If there's no route or extra param, just return.
190
        if (!$request->route() ||
191
            !($request->route() instanceof Route) ||
192
            !$request->route()->parameter('extra')) {
193
            return $model;
194
        }
195
196
        $parts = array_filter(explode('/', (string)$request->route()->parameter('extra')));
197
198
        // Loop through the parts.
199
        foreach ($parts as $part) {
200
            // Look for an array accessor, "children[5]" for example.
201
            preg_match('/\[(\d+)]$/', $part, $matches);
202
            $offset = false;
203
204
            // If one was found, save the offset and remove it from $part.
205
            if (!empty($matches[0])) {
206
                $part = str_replace(array_shift($matches), '', $part);
207
                $offset = array_shift($matches);
208
            }
209
210
            $model = $model->$part();
211
212
            // If it's a relationship, see if it's paginate-able.
213
            if (stripos(get_class($model), 'Many') !== false) {
214
                /** @var BelongsToMany|HasMany|HasOneOrMany|MorphMany|MorphOneOrMany|MorphToMany $model */
215
                $model = $model->paginate();
0 ignored issues
show
Bug introduced by
The method paginate does only exist in Illuminate\Database\Eloq...Relations\BelongsToMany, but not in Illuminate\Database\Eloq...\Relations\HasOneOrMany.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
216
            } elseif ($model instanceof Relation) {
217
                $model = $model->getResults();
218
            }
219
220
            // If there is an offset, get it.
221
            if ($offset !== false) {
222
                $model = $model[$offset];
223
            }
224
        }
225
226
        return $model;
227
    }
228
229
    /**
230
     * Finds the model instance.
231
     * @param int $id
232
     * @param Request|null $request
233
     * @return Model
234
     */
235
    private function findModel(int $id, Request $request = null): Model
236
    {
237
        /** @var Model|Builder $class */
238
        $class = $this->newModelInstance();
239
240
        if ($request) {
241
            $this->preloadRelationships($class, $request);
0 ignored issues
show
Bug introduced by
It seems like $class can also be of type object<Illuminate\Database\Eloquent\Builder>; however, TomHart\Restful\Abstract...:preloadRelationships() does only seem to accept object<Illuminate\Database\Eloquent\Model>, 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...
242
        }
243
244
        $class = $class->findOrFail($id);
0 ignored issues
show
Bug introduced by
The method findOrFail does only exist in Illuminate\Database\Eloquent\Builder, but not in Illuminate\Database\Eloquent\Model.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
245
        return $class;
246
    }
247
248
    /**
249
     * Apply causes to the builder.
250
     * @param Builder $builder
251
     * @param string $column
252
     * @param mixed $value
253
     */
254
    private function filterValue(Builder $builder, string $column, $value): void
255
    {
256
        $builder->where($column, $value);
257
    }
258
259
    /**
260
     * Build and return a response.
261
     * @param Request $request
262
     * @param mixed $data
263
     * @param string $method
264
     * @return JsonResponse|ResponseFactory|Response|RedirectResponse|Redirector
265
     */
266
    private function return(Request $request, $data, string $method)
267
    {
268
269
        $status = SymResponse::HTTP_OK;
270
        switch ($method) {
271
            case 'store':
272
                $status = SymResponse::HTTP_CREATED;
273
                break;
274
        }
275
276
        if ($request->wantsJson()) {
277
            return app(ResponseFactory::class)->json($data, $status);
278
        }
279
280
        if (isset($this->views[$method]) && app(Factory::class)->exists($this->views[$method])) {
281
            /** @var View $view */
282
            $view = view($this->views[$method], [
283
                'data' => $data
284
            ]);
285
286
            return response($view, $status);
287
        }
288
289
        switch ($method) {
290
            case 'store':
291
            case 'update':
292
                // If it's store/update, and the user isn't asking for JSON, we want to
293
                // try and redirect them to the related show record page.
294
                if (($redirect = $this->redirectToShowRoute($request, $data))) {
295
                    return $redirect;
296
                }
297
        }
298
299
        return app(ResponseFactory::class)->json($data, $status);
300
    }
301
302
    /**
303
     * Redirects to the show route for the model if one exists.
304
     * @param Request $request
305
     * @param mixed $data
306
     * @return RedirectResponse|Redirector|null
307
     */
308
    private function redirectToShowRoute(Request $request, $data)
309
    {
310
        /** @var Route|null $route */
311
        $route = $request->route();
312
        if (!$route) {
313
            return null;
314
        }
315
316
        $name = $route->getName();
317
        if (!$name) {
318
            return null;
319
        }
320
321
        $exploded = explode('.', $name);
322
        array_pop($exploded);
323
        $topLevel = array_pop($exploded);
324
325
        if (!$topLevel) {
326
            return null;
327
        }
328
329
        $key = Str::singular(str_replace('-', '_', $topLevel));
330
331
        return redirect(route("$topLevel.show", [
332
            $key => $data->id
333
        ]));
334
    }
335
336
    /**
337
     * Preload any relationships required.
338
     * @param Model $class
339
     * @param Request $request
340
     * @return void
341
     */
342
    private function preloadRelationships(Model &$class, Request $request): void
343
    {
344
        $header = $request->headers->get('X-Load-Relationship');
345
        if (!$header || empty($header)) {
346
            return;
347
        }
348
349
        $relationships = array_filter(explode(',', $header));
350
351
        $class = $class->with($relationships);
352
    }
353
}
354