Completed
Push — 4-cactus ( 9ca9b2...23b6ed )
by Alberto
21s queued 10s
created

API/src/Controller/Component/JsonApiComponent.php (8 issues)

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
The method getMimeType() 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 ignore-call  annotation

62
            $contentType = $this->getController()->response->/** @scrutinizer ignore-call */ getMimeType($config['contentType']) ?: $config['contentType'];

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...
63
        }
64
        $this->getController()->response->type([
0 ignored issues
show
Deprecated Code introduced by
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 ignore-deprecated  annotation

64
        /** @scrutinizer ignore-deprecated */ $this->getController()->response->type([

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 ignore-type  annotation

64
        $this->getController()->response->type(/** @scrutinizer ignore-type */ [
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 ignore-type  annotation

92
            throw new BadRequestException(/** @scrutinizer ignore-type */ [
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 ignore-call  annotation

129
        /** @scrutinizer ignore-call */ 
130
        $request = $this->getController()->request->withParam('pass', []);

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
Bug Best Practice introduced by
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 empty(..) or ! empty(...) instead.

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
Bug Best Practice introduced by
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 empty(..) or ! empty(...) instead.

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 ignore-call  annotation

279
        if ($this->getConfig('checkMediaType') && trim($controller->request->/** @scrutinizer ignore-call */ getHeaderLine('accept')) !== self::CONTENT_TYPE) {

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