1 | <?php |
||||||
2 | /** |
||||||
3 | * BEdita, API-first content management framework |
||||||
4 | * Copyright 2016 ChannelWeb Srl, Chialab Srl |
||||||
5 | * |
||||||
6 | * This file is part of BEdita: you can redistribute it and/or modify |
||||||
7 | * it under the terms of the GNU Lesser General Public License as published |
||||||
8 | * by the Free Software Foundation, either version 3 of the License, or |
||||||
9 | * (at your option) any later version. |
||||||
10 | * |
||||||
11 | * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details. |
||||||
12 | */ |
||||||
13 | namespace BEdita\API\Controller\Component; |
||||||
14 | |||||||
15 | use BEdita\API\Network\Exception\UnsupportedMediaTypeException; |
||||||
16 | use BEdita\API\Utility\JsonApi; |
||||||
17 | use Cake\Controller\Component; |
||||||
18 | use Cake\Network\Exception\BadRequestException; |
||||||
19 | use Cake\Network\Exception\ConflictException; |
||||||
20 | use Cake\Network\Exception\ForbiddenException; |
||||||
21 | use Cake\Routing\Router; |
||||||
22 | use Cake\Utility\Hash; |
||||||
23 | |||||||
24 | /** |
||||||
25 | * Handles JSON API data format in input and in output |
||||||
26 | * |
||||||
27 | * @since 4.0.0 |
||||||
28 | * |
||||||
29 | * @property \Cake\Controller\Component\RequestHandlerComponent $RequestHandler |
||||||
30 | */ |
||||||
31 | class JsonApiComponent extends Component |
||||||
32 | { |
||||||
33 | /** |
||||||
34 | * JSON API content type. |
||||||
35 | * |
||||||
36 | * @var string |
||||||
37 | */ |
||||||
38 | const CONTENT_TYPE = 'application/vnd.api+json'; |
||||||
39 | |||||||
40 | /** |
||||||
41 | * {@inheritDoc} |
||||||
42 | */ |
||||||
43 | public $components = ['RequestHandler']; |
||||||
44 | |||||||
45 | /** |
||||||
46 | * {@inheritDoc} |
||||||
47 | */ |
||||||
48 | protected $_defaultConfig = [ |
||||||
49 | 'contentType' => null, |
||||||
50 | 'checkMediaType' => true, |
||||||
51 | 'resourceTypes' => null, |
||||||
52 | 'clientGeneratedIds' => false, |
||||||
53 | ]; |
||||||
54 | |||||||
55 | /** |
||||||
56 | * {@inheritDoc} |
||||||
57 | */ |
||||||
58 | public function initialize(array $config) |
||||||
59 | { |
||||||
60 | $contentType = self::CONTENT_TYPE; |
||||||
61 | if (!empty($config['contentType'])) { |
||||||
62 | $contentType = $this->getController()->response->getMimeType($config['contentType']) ?: $config['contentType']; |
||||||
0 ignored issues
–
show
|
|||||||
63 | } |
||||||
64 | $this->getController()->response->type([ |
||||||
0 ignored issues
–
show
The function
Cake\Http\Response::type() has been deprecated: 3.5.5 Use getType() or withType() instead.
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This function has been deprecated. The supplier of the function has supplied an explanatory message. The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.
Loading history...
array('jsonapi' => $contentType) of type array is incompatible with the type null|string expected by parameter $contentType of Cake\Http\Response::type() .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
Loading history...
|
|||||||
65 | 'jsonapi' => $contentType, |
||||||
66 | ]); |
||||||
67 | |||||||
68 | $this->RequestHandler->setConfig('inputTypeMap.jsonapi', [[$this, 'parseInput']]); // Must be lowercase because reasons. |
||||||
69 | $this->RequestHandler->setConfig('viewClassMap.jsonapi', 'BEdita/API.JsonApi'); |
||||||
70 | } |
||||||
71 | |||||||
72 | /** |
||||||
73 | * Input data parser for JSON API format. |
||||||
74 | * |
||||||
75 | * @param string $json JSON string. |
||||||
76 | * @return array JSON API input data array |
||||||
77 | * @throws \Cake\Network\Exception\BadRequestException When the request is malformed |
||||||
78 | */ |
||||||
79 | public function parseInput($json) |
||||||
80 | { |
||||||
81 | if (empty($json)) { |
||||||
82 | return []; |
||||||
83 | } |
||||||
84 | try { |
||||||
85 | $json = json_decode($json, true); |
||||||
86 | if (json_last_error() || !is_array($json) || !array_key_exists('data', $json)) { |
||||||
87 | throw new BadRequestException(__d('bedita', 'Invalid JSON input')); |
||||||
88 | } |
||||||
89 | |||||||
90 | return JsonApi::parseData((array)$json['data']); |
||||||
91 | } catch (\InvalidArgumentException $e) { |
||||||
92 | throw new BadRequestException([ |
||||||
0 ignored issues
–
show
array('title' => __d('be...l' => $e->getMessage()) of type array<string,null|string> is incompatible with the type null|string expected by parameter $message of Cake\Network\Exception\B...xception::__construct() .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
Loading history...
|
|||||||
93 | 'title' => __d('bedita', 'Bad JSON input'), |
||||||
94 | 'detail' => $e->getMessage(), |
||||||
95 | ]); |
||||||
96 | } |
||||||
97 | } |
||||||
98 | |||||||
99 | /** |
||||||
100 | * Set occurred error. |
||||||
101 | * |
||||||
102 | * @param int $status HTTP error code. |
||||||
103 | * @param string $title Brief description of error. |
||||||
104 | * @param string $detail Long description of error |
||||||
105 | * @param string $code Application specific error code. |
||||||
106 | * @param array|null $meta Additional metadata about error. |
||||||
107 | * @return void |
||||||
108 | */ |
||||||
109 | public function error($status, $title, $detail = null, $code = null, array $meta = null) |
||||||
110 | { |
||||||
111 | $controller = $this->getController(); |
||||||
112 | |||||||
113 | $status = (string)$status; |
||||||
114 | $code = (string)$code; |
||||||
115 | |||||||
116 | $error = compact('status', 'title', 'detail', 'code', 'meta'); |
||||||
117 | $error = array_filter($error); |
||||||
118 | |||||||
119 | $controller->set('_error', $error); |
||||||
120 | } |
||||||
121 | |||||||
122 | /** |
||||||
123 | * Get links according to JSON API specifications. |
||||||
124 | * |
||||||
125 | * @return array |
||||||
126 | */ |
||||||
127 | public function getLinks() |
||||||
128 | { |
||||||
129 | $request = $this->getController()->request->withParam('pass', []); |
||||||
0 ignored issues
–
show
The method
withParam() does not exist on null .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces. This is most likely a typographical error or the method has been renamed.
Loading history...
|
|||||||
130 | $links = [ |
||||||
131 | 'self' => Router::reverse($request, true), |
||||||
132 | 'home' => Router::url(['_name' => 'api:home'], true), |
||||||
133 | ]; |
||||||
134 | |||||||
135 | $paging = $request->getParam('paging'); |
||||||
136 | if (!empty($paging) && is_array($paging)) { |
||||||
137 | $paging = reset($paging); |
||||||
138 | $query = $request->getQueryParams(); |
||||||
139 | |||||||
140 | $query['page'] = null; |
||||||
141 | $links['first'] = Router::reverse($request->withQueryParams($query), true); |
||||||
142 | |||||||
143 | $query['page'] = ($paging['pageCount'] > 1) ? $paging['pageCount'] : null; |
||||||
144 | $links['last'] = Router::reverse($request->withQueryParams($query), true); |
||||||
145 | |||||||
146 | $links['prev'] = null; |
||||||
147 | if ($paging['prevPage']) { |
||||||
148 | $query['page'] = ($paging['page'] > 2) ? $paging['page'] - 1 : null; |
||||||
149 | $links['prev'] = Router::reverse($request->withQueryParams($query), true); |
||||||
150 | } |
||||||
151 | |||||||
152 | $links['next'] = null; |
||||||
153 | if ($paging['nextPage']) { |
||||||
154 | $query['page'] = $paging['page'] + 1; |
||||||
155 | $links['next'] = Router::reverse($request->withQueryParams($query), true); |
||||||
156 | } |
||||||
157 | } |
||||||
158 | |||||||
159 | return $links; |
||||||
160 | } |
||||||
161 | |||||||
162 | /** |
||||||
163 | * Get common metadata. |
||||||
164 | * |
||||||
165 | * @return array |
||||||
166 | */ |
||||||
167 | public function getMeta() |
||||||
168 | { |
||||||
169 | $meta = []; |
||||||
170 | |||||||
171 | $paging = $this->getController()->request->getParam('paging'); |
||||||
172 | if (!empty($paging) && is_array($paging)) { |
||||||
173 | $paging = reset($paging); |
||||||
174 | $paging += [ |
||||||
175 | 'current' => null, |
||||||
176 | 'page' => null, |
||||||
177 | 'count' => null, |
||||||
178 | 'perPage' => null, |
||||||
179 | 'pageCount' => null, |
||||||
180 | ]; |
||||||
181 | |||||||
182 | $meta['pagination'] = [ |
||||||
183 | 'count' => $paging['count'], |
||||||
184 | 'page' => $paging['page'], |
||||||
185 | 'page_count' => $paging['pageCount'], |
||||||
186 | 'page_items' => $paging['current'], |
||||||
187 | 'page_size' => $paging['perPage'], |
||||||
188 | ]; |
||||||
189 | } |
||||||
190 | |||||||
191 | return $meta; |
||||||
192 | } |
||||||
193 | |||||||
194 | /** |
||||||
195 | * Check if given resource types are allowed. |
||||||
196 | * |
||||||
197 | * @param mixed $types One or more allowed types to check resources array against. |
||||||
198 | * @param array|null $data Data to be checked. By default, this is taken from the request. |
||||||
199 | * @return void |
||||||
200 | * @throws \Cake\Network\Exception\ConflictException Throws an exception if a resource has a non-supported `type`. |
||||||
201 | */ |
||||||
202 | protected function allowedResourceTypes($types, array $data = null) |
||||||
203 | { |
||||||
204 | $data = ($data === null) ? $this->getController()->request->getData() : $data; |
||||||
205 | if (!$data || !$types) { |
||||||
0 ignored issues
–
show
The expression
$data of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent. Consider making the comparison explicit by using
Loading history...
|
|||||||
206 | return; |
||||||
207 | } |
||||||
208 | $data = (array)$data; |
||||||
209 | $types = (array)$types; |
||||||
210 | |||||||
211 | if (Hash::numeric(array_keys($data))) { |
||||||
212 | foreach ($data as $item) { |
||||||
213 | if (!is_array($item)) { |
||||||
214 | continue; |
||||||
215 | } |
||||||
216 | |||||||
217 | $this->allowedResourceTypes($types, $item); |
||||||
218 | } |
||||||
219 | |||||||
220 | return; |
||||||
221 | } |
||||||
222 | |||||||
223 | if (empty($data['type']) || !in_array($data['type'], $types)) { |
||||||
224 | throw new ConflictException('Unsupported resource type'); |
||||||
225 | } |
||||||
226 | } |
||||||
227 | |||||||
228 | /** |
||||||
229 | * Check that no resource includes a client-generated ID, if this feature is unsupported. |
||||||
230 | * |
||||||
231 | * @param bool $allow Should client-generated IDs be allowed? |
||||||
232 | * @param array|null $data Data to be checked. By default, this is taken from the request. |
||||||
233 | * @return void |
||||||
234 | * @throws \Cake\Network\Exception\ForbiddenException Throws an exception if a resource has a client-generated |
||||||
235 | * ID, but this feature is not supported. |
||||||
236 | */ |
||||||
237 | protected function allowClientGeneratedIds($allow = true, array $data = null) |
||||||
238 | { |
||||||
239 | $data = ($data === null) ? $this->getController()->request->getData() : $data; |
||||||
240 | if (!$data || $allow) { |
||||||
0 ignored issues
–
show
The expression
$data of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent. Consider making the comparison explicit by using
Loading history...
|
|||||||
241 | return; |
||||||
242 | } |
||||||
243 | $data = (array)$data; |
||||||
244 | |||||||
245 | if (Hash::numeric(array_keys($data))) { |
||||||
246 | foreach ($data as $item) { |
||||||
247 | if (!is_array($item)) { |
||||||
248 | continue; |
||||||
249 | } |
||||||
250 | |||||||
251 | $this->allowClientGeneratedIds($allow, $item); |
||||||
252 | } |
||||||
253 | |||||||
254 | return; |
||||||
255 | } |
||||||
256 | |||||||
257 | if (!empty($data['id'])) { |
||||||
258 | throw new ForbiddenException('Client-generated IDs are not supported'); |
||||||
259 | } |
||||||
260 | } |
||||||
261 | |||||||
262 | /** |
||||||
263 | * Perform preliminary checks and operations. |
||||||
264 | * |
||||||
265 | * @return void |
||||||
266 | * @throws \BEdita\API\Network\Exception\UnsupportedMediaTypeException Throws an exception if the `Accept` header |
||||||
267 | * does not comply to JSON API specifications and `checkMediaType` configuration is enabled. |
||||||
268 | * @throws \Cake\Network\Exception\ConflictException Throws an exception if a resource in the payload has a |
||||||
269 | * non-supported `type`. |
||||||
270 | * @throws \Cake\Network\Exception\ForbiddenException Throws an exception if a resource in the payload includes a |
||||||
271 | * client-generated ID, but the feature is not supported. |
||||||
272 | */ |
||||||
273 | public function startup() |
||||||
274 | { |
||||||
275 | $controller = $this->getController(); |
||||||
276 | |||||||
277 | $this->RequestHandler->renderAs($controller, 'jsonapi'); |
||||||
278 | |||||||
279 | if ($this->getConfig('checkMediaType') && trim($controller->request->getHeaderLine('accept')) !== self::CONTENT_TYPE) { |
||||||
0 ignored issues
–
show
The method
getHeaderLine() does not exist on null .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces. This is most likely a typographical error or the method has been renamed.
Loading history...
|
|||||||
280 | // http://jsonapi.org/format/#content-negotiation-servers |
||||||
281 | throw new UnsupportedMediaTypeException( |
||||||
282 | __d('bedita', 'Bad request content type "{0}"', $controller->request->getHeaderLine('Accept')) |
||||||
283 | ); |
||||||
284 | } |
||||||
285 | |||||||
286 | if ($controller->request->is(['post', 'patch'])) { |
||||||
287 | $this->allowedResourceTypes($this->getConfig('resourceTypes')); |
||||||
288 | } |
||||||
289 | |||||||
290 | if ($controller->request->is('post') && !$this->getConfig('clientGeneratedIds')) { |
||||||
291 | $this->allowClientGeneratedIds(false); |
||||||
292 | } |
||||||
293 | } |
||||||
294 | |||||||
295 | /** |
||||||
296 | * Perform operations before view rendering. |
||||||
297 | * |
||||||
298 | * @return void |
||||||
299 | */ |
||||||
300 | public function beforeRender() |
||||||
301 | { |
||||||
302 | $controller = $this->getController(); |
||||||
303 | |||||||
304 | $links = []; |
||||||
305 | if (isset($controller->viewVars['_links'])) { |
||||||
306 | $links = (array)$controller->viewVars['_links']; |
||||||
307 | } |
||||||
308 | $links += $this->getLinks(); |
||||||
309 | |||||||
310 | $meta = []; |
||||||
311 | if (isset($controller->viewVars['_meta'])) { |
||||||
312 | $meta = (array)$controller->viewVars['_meta']; |
||||||
313 | } |
||||||
314 | $meta += $this->getMeta(); |
||||||
315 | |||||||
316 | $controller->set([ |
||||||
317 | '_links' => $links, |
||||||
318 | '_meta' => $meta, |
||||||
319 | ]); |
||||||
320 | } |
||||||
321 | } |
||||||
322 |
This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.
This is most likely a typographical error or the method has been renamed.