1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace roaresearch\yii2\roa\controllers; |
4
|
|
|
|
5
|
|
|
use roaresearch\yii2\roa\{actions, FileRecord}; |
6
|
|
|
use Yii; |
7
|
|
|
use yii\{ |
8
|
|
|
base\InvalidRouteException, |
9
|
|
|
data\ActiveDataProvider, |
10
|
|
|
db\ActiveQuery, |
11
|
|
|
db\ActiveRecord, |
12
|
|
|
db\ActiveRecordInterface, |
13
|
|
|
filters\VerbFilter, |
14
|
|
|
helpers\ArrayHelper, |
15
|
|
|
web\MethodNotAllowedHttpException, |
16
|
|
|
web\NotFoundHttpException |
17
|
|
|
}; |
18
|
|
|
|
19
|
|
|
/** |
20
|
|
|
* Resource Controller with OAuth2 Support. |
21
|
|
|
* |
22
|
|
|
* @author Angel (Faryshta) Guevara <[email protected]> |
23
|
|
|
*/ |
24
|
|
|
class Resource extends \yii\rest\ActiveController |
25
|
|
|
{ |
26
|
|
|
/** |
27
|
|
|
* @var string[] list of rest actions defined by default. |
28
|
|
|
*/ |
29
|
|
|
const DEFAULT_REST_ACTIONS = [ |
30
|
|
|
'index', |
31
|
|
|
'view', |
32
|
|
|
'create', |
33
|
|
|
'update', |
34
|
|
|
'delete', |
35
|
|
|
'file-stream', // download files |
36
|
|
|
'options', |
37
|
|
|
]; |
38
|
|
|
|
39
|
|
|
/** |
40
|
|
|
* @var string name of the attribute to be used on `findModel()`. |
41
|
|
|
*/ |
42
|
|
|
public $idAttribute = 'id'; |
43
|
|
|
|
44
|
|
|
/** |
45
|
|
|
* @var string attribute name used to filter only the records associated to |
46
|
|
|
* the logged user. |
47
|
|
|
* If `null` then no filter will be added. |
48
|
|
|
*/ |
49
|
|
|
public $userAttribute; |
50
|
|
|
|
51
|
|
|
/** |
52
|
|
|
* @var string class name for the model to be used on the search. |
53
|
|
|
* Must implement `roaresearch\yii2\roa\ResourceSearchInterface` |
54
|
|
|
*/ |
55
|
|
|
public $searchClass; |
56
|
|
|
|
57
|
|
|
/** |
58
|
|
|
* @var string name of the form which will hold the GET parameters to filter |
59
|
|
|
* results on a search request. |
60
|
|
|
*/ |
61
|
|
|
public $searchFormName = ''; |
62
|
|
|
|
63
|
|
|
/** |
64
|
|
|
* @var string[] $attribute => $param pairs to filter the queries. |
65
|
|
|
*/ |
66
|
|
|
public $filterParams = []; |
67
|
|
|
|
68
|
|
|
/** |
69
|
|
|
* @var string scenario to be used when updating a record. |
70
|
|
|
*/ |
71
|
|
|
public $updateScenario = ActiveRecord::SCENARIO_DEFAULT; |
72
|
|
|
|
73
|
|
|
/** |
74
|
|
|
* @var string scenario to be used when creating a new record. |
75
|
|
|
*/ |
76
|
|
|
public $createScenario = ActiveRecord::SCENARIO_DEFAULT; |
77
|
|
|
|
78
|
|
|
/** |
79
|
|
|
* @var string[] array used in `actions\Create::fileAttributes` |
80
|
|
|
* @see actions\LoadFileTrait::$fileAttributes |
81
|
|
|
*/ |
82
|
|
|
public $createFileAttributes = []; |
83
|
|
|
|
84
|
|
|
/** |
85
|
|
|
* @var string[] array used in `actions\Update::fileAttributes` |
86
|
|
|
* @see actions\LoadFileTrait::$fileAttributes |
87
|
|
|
*/ |
88
|
|
|
public $updateFileAttributes = []; |
89
|
|
|
|
90
|
|
|
/** |
91
|
|
|
* @var string the message shown when no register is found. |
92
|
|
|
*/ |
93
|
|
|
public $notFoundMessage = 'The record "{id}" does not exists.'; |
94
|
|
|
|
95
|
|
|
/** |
96
|
|
|
* @inheritdoc |
97
|
|
|
*/ |
98
|
|
|
public function behaviors() |
99
|
|
|
{ |
100
|
|
|
return [ |
101
|
|
|
// content negotiator, autenticator, etc moved by default to |
102
|
|
|
// api container |
103
|
|
|
'verbFilter' => [ |
104
|
|
|
'class' => VerbFilter::class, |
105
|
|
|
'actions' => $this->buildAllowedVerbs(), |
106
|
|
|
], |
107
|
|
|
]; |
108
|
|
|
} |
109
|
|
|
|
110
|
|
|
/** |
111
|
|
|
* @inheritdoc |
112
|
|
|
*/ |
113
|
|
|
public function actions() |
114
|
|
|
{ |
115
|
|
|
$index = $this->searchClass |
116
|
|
|
? [ |
117
|
|
|
'class' => actions\Index::class, |
118
|
|
|
'searchClass' => $this->searchClass, |
119
|
|
|
'formName' => $this->searchFormName, |
120
|
|
|
] |
121
|
|
|
: [ |
122
|
|
|
'class' => \yii\rest\IndexAction::class, |
123
|
|
|
'modelClass' => $this->modelClass, |
124
|
|
|
'prepareDataProvider' => [$this, 'indexProvider'], |
125
|
|
|
]; |
126
|
|
|
$interfaces = class_implements($this->modelClass); |
127
|
|
|
$fileStream = isset($interfaces[FileRecord::class]) |
128
|
|
|
? [ |
129
|
|
|
'class' => actions\FileStream::class, |
130
|
|
|
'modelClass' => $this->modelClass, |
131
|
|
|
'findModel' => [$this, 'findModel'], |
132
|
|
|
] |
133
|
|
|
: null; |
134
|
|
|
|
135
|
|
|
return [ |
|
|
|
|
136
|
|
|
'index' => $index, |
137
|
|
|
'view' => [ |
138
|
|
|
'class' => actions\View::class, |
139
|
|
|
'modelClass' => $this->modelClass, |
140
|
|
|
'findModel' => [$this, 'findModel'], |
141
|
|
|
], |
142
|
|
|
'update' => [ |
143
|
|
|
'class' => actions\Update::class, |
144
|
|
|
'modelClass' => $this->modelClass, |
145
|
|
|
'findModel' => [$this, 'findModel'], |
146
|
|
|
'scenario' => $this->updateScenario, |
147
|
|
|
'fileAttributes' => $this->updateFileAttributes, |
148
|
|
|
], |
149
|
|
|
'create' => [ |
150
|
|
|
'class' => actions\Create::class, |
151
|
|
|
'modelClass' => $this->modelClass, |
152
|
|
|
'scenario' => $this->createScenario, |
153
|
|
|
'fileAttributes' => $this->createFileAttributes, |
154
|
|
|
], |
155
|
|
|
'delete' => [ |
156
|
|
|
'class' => actions\Delete::class, |
157
|
|
|
'modelClass' => $this->modelClass, |
158
|
|
|
'findModel' => [$this, 'findModel'], |
159
|
|
|
], |
160
|
|
|
'file-stream' => $fileStream, |
161
|
|
|
'options' => [ |
162
|
|
|
'class' => \yii\rest\OptionsAction::class, |
163
|
|
|
], |
164
|
|
|
]; |
165
|
|
|
} |
166
|
|
|
|
167
|
|
|
/** |
168
|
|
|
* Creates a data provider for the request. |
169
|
|
|
* |
170
|
|
|
* @return ActiveDataProvider |
|
|
|
|
171
|
|
|
*/ |
172
|
|
|
public function indexProvider(): ActiveDataProvider |
173
|
|
|
{ |
174
|
|
|
return new ActiveDataProvider(['query' => $this->indexQuery()]); |
175
|
|
|
} |
176
|
|
|
|
177
|
|
|
/** |
178
|
|
|
* Finds the record based on the provided id or throws an exception. |
179
|
|
|
* @param int $id the unique identifier for the record. |
180
|
|
|
* @return ActiveRecordInterface |
|
|
|
|
181
|
|
|
* @throws NotFoundHttpException if the record can't be found. |
182
|
|
|
*/ |
183
|
|
|
public function findModel($id): ActiveRecordInterface |
184
|
|
|
{ |
185
|
|
|
if (null === ($model = $this->findQuery($id)->one())) { |
186
|
|
|
throw new NotFoundHttpException( |
187
|
|
|
strtr($this->notFoundMessage, ['{id}' => $id]) |
188
|
|
|
); |
189
|
|
|
} |
190
|
|
|
|
191
|
|
|
return $model; |
192
|
|
|
} |
193
|
|
|
|
194
|
|
|
/** |
195
|
|
|
* Creates the query to be used by the `findOne()` method. |
196
|
|
|
* |
197
|
|
|
* @param int $id the unique identifier |
198
|
|
|
* @return ActiveQuery |
|
|
|
|
199
|
|
|
*/ |
200
|
|
|
public function findQuery($id): ActiveQuery |
201
|
|
|
{ |
202
|
|
|
return $this->baseQuery()->andWhere([$this->idAttribute => $id]); |
203
|
|
|
} |
204
|
|
|
|
205
|
|
|
/** |
206
|
|
|
* Creates the query to be used by the `index` action when `$searchClass` is |
207
|
|
|
* not set. |
208
|
|
|
* |
209
|
|
|
* @return ActiveQuery |
|
|
|
|
210
|
|
|
*/ |
211
|
|
|
public function indexQuery(): ActiveQuery |
212
|
|
|
{ |
213
|
|
|
return $this->baseQuery(); |
214
|
|
|
} |
215
|
|
|
|
216
|
|
|
/** |
217
|
|
|
* @return ActiveQuery |
|
|
|
|
218
|
|
|
*/ |
219
|
|
|
protected function baseQuery(): ActiveQuery |
220
|
|
|
{ |
221
|
|
|
$modelClass = $this->modelClass; |
222
|
|
|
|
223
|
|
|
return $modelClass::find() |
224
|
|
|
->andFilterWhere($this->filterCondition()); |
225
|
|
|
} |
226
|
|
|
|
227
|
|
|
/** |
228
|
|
|
* @return array the conditions to filter the base query to find records. |
229
|
|
|
*/ |
230
|
|
|
protected function filterCondition(): array |
231
|
|
|
{ |
232
|
|
|
$condition = []; |
233
|
|
|
foreach ($this->filterParams as $attribute => $param) { |
234
|
|
|
if (is_int($attribute)) { |
235
|
|
|
$attribute = $param; |
236
|
|
|
} |
237
|
|
|
$condition[$attribute] = Yii::$app->request->getQueryParam($param); |
238
|
|
|
} |
239
|
|
|
|
240
|
|
|
if (isset($this->userAttribute)) { |
241
|
|
|
$condition[$this->userAttribute] = Yii::$app->user->id; |
242
|
|
|
} |
243
|
|
|
|
244
|
|
|
return $condition; |
245
|
|
|
} |
246
|
|
|
|
247
|
|
|
/** |
248
|
|
|
* @inheritdoc |
249
|
|
|
*/ |
250
|
|
|
protected function verbs() |
251
|
|
|
{ |
252
|
|
|
return [ |
253
|
|
|
'index' => ['GET', 'HEAD'], |
254
|
|
|
'view' => ['GET', 'HEAD'], |
255
|
|
|
'create' => ['POST'], |
256
|
|
|
'update' => ['PUT', 'PATCH', 'POST'], |
257
|
|
|
'delete' => ['DELETE'], |
258
|
|
|
'file-stream' => ['GET'], |
259
|
|
|
'options' => ['OPTIONS'], |
260
|
|
|
]; |
261
|
|
|
} |
262
|
|
|
|
263
|
|
|
/** |
264
|
|
|
* @return string[] actions which serve a single record. |
265
|
|
|
*/ |
266
|
|
|
protected function listRecordActions(): array |
267
|
|
|
{ |
268
|
|
|
return ['view', 'update', 'delete']; |
269
|
|
|
} |
270
|
|
|
|
271
|
|
|
/** |
272
|
|
|
* @return string[] actions which serve a collection of records. |
273
|
|
|
*/ |
274
|
|
|
protected function listCollectionActions(): array |
275
|
|
|
{ |
276
|
|
|
return ['index', 'create']; |
277
|
|
|
} |
278
|
|
|
|
279
|
|
|
/** |
280
|
|
|
* Builds the HTTP Methods allowed for each action. |
281
|
|
|
* |
282
|
|
|
* Since ROA Resources differentiate routes on record routes and collection |
283
|
|
|
* rules it was needed to organize the action into record action and |
284
|
|
|
* collection actions and make sure that all record/collection actions |
285
|
|
|
* returned the same allowed verbs since they are using the same route. |
286
|
|
|
* |
287
|
|
|
* @return string[] which HTTP Methods are allowed for each action id. |
288
|
|
|
* @see VerbFilter::$verbs |
289
|
|
|
*/ |
290
|
|
|
protected function buildAllowedVerbs(): array |
291
|
|
|
{ |
292
|
|
|
$verbs = $this->verbs(); |
293
|
|
|
$recordActions = $this->listRecordActions(); |
294
|
|
|
$collectionActions = $this->listCollectionActions(); |
295
|
|
|
$recordVerbs = ['OPTIONS']; |
296
|
|
|
$collectionVerbs = ['OPTIONS']; |
297
|
|
|
|
298
|
|
|
foreach ($recordActions as $action) { |
299
|
|
|
$recordVerbs = array_merge( |
300
|
|
|
$recordVerbs, |
301
|
|
|
ArrayHelper::getValue($verbs, $action, []) |
302
|
|
|
); |
303
|
|
|
} |
304
|
|
|
|
305
|
|
|
$recordVerbs = array_values(array_unique( |
306
|
|
|
array_map('strtoupper', $recordVerbs) |
307
|
|
|
)); |
308
|
|
|
|
309
|
|
|
foreach ($collectionActions as $action) { |
310
|
|
|
$collectionVerbs = array_merge( |
311
|
|
|
$collectionVerbs, |
312
|
|
|
ArrayHelper::getValue($verbs, $action, []) |
313
|
|
|
); |
314
|
|
|
} |
315
|
|
|
|
316
|
|
|
$collectionVerbs = array_values(array_unique( |
317
|
|
|
array_map('strtoupper', $collectionVerbs) |
318
|
|
|
)); |
319
|
|
|
|
320
|
|
|
$allowedVerbs = ['options' => 'OPTIONS']; |
321
|
|
|
foreach ($verbs as $action => $defaultVerbs) { |
322
|
|
View Code Duplication |
if (in_array($action, $recordActions)) { |
|
|
|
|
323
|
|
|
$allowedVerbs[$action] = $recordVerbs; |
324
|
|
|
} elseif (in_array($action, $collectionActions)) { |
325
|
|
|
$allowedVerbs[$action] = $collectionVerbs; |
326
|
|
|
} else { |
327
|
|
|
$allowedVerbs[$action] = $defaultVerbs; |
328
|
|
|
} |
329
|
|
|
} |
330
|
|
|
|
331
|
|
|
foreach (self::DEFAULT_REST_ACTIONS as $action) { |
332
|
|
View Code Duplication |
if (!isset($allowedVerbs[$action])) { |
|
|
|
|
333
|
|
|
if (in_array($action, $recordActions)) { |
334
|
|
|
$allowedVerbs[$action] = $recordVerbs; |
335
|
|
|
} elseif (in_array($action, $collectionActions)) { |
336
|
|
|
$allowedVerbs[$action] = $collectionVerbs; |
337
|
|
|
} |
338
|
|
|
} |
339
|
|
|
} |
340
|
|
|
|
341
|
|
|
return $allowedVerbs; |
342
|
|
|
} |
343
|
|
|
} |
344
|
|
|
|
If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.
Let’s take a look at an example:
Our function
my_function
expects aPost
object, and outputs the author of the post. The base classPost
returns a simple string and outputting a simple string will work just fine. However, the child classBlogPost
which is a sub-type ofPost
instead decided to return anobject
, and is therefore violating the SOLID principles. If aBlogPost
were passed tomy_function
, PHP would not complain, but ultimately fail when executing thestrtoupper
call in its body.