Passed
Pull Request — master (#80)
by
unknown
02:14
created

RestfulServer::getEndpointAlias()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 1
eloc 2
c 1
b 1
f 0
nc 1
nop 1
dl 0
loc 5
rs 10
1
<?php
2
3
namespace SilverStripe\RestfulServer;
4
5
use NobrainerWeb\App\DataObjects\Deviation;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\DataObjects\Deviation was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
6
use NobrainerWeb\App\Forms\Client;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\Forms\Client was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
7
use NobrainerWeb\App\Forms\Form;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\Forms\Form was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
8
use NobrainerWeb\App\Forms\Submission as FormSubmission;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\Forms\Submission was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
9
use NobrainerWeb\App\Invoices\Invoice;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\Invoices\Invoice was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
10
use NobrainerWeb\App\Logs\ErrorRequestLog;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\Logs\ErrorRequestLog was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
11
use NobrainerWeb\App\Logs\ImageRequestLog;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\Logs\ImageRequestLog was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
12
use NobrainerWeb\App\MobileConfig\MobileConfig;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\MobileConfig\MobileConfig was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
13
use NobrainerWeb\App\Projects\HealthFacility;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\Projects\HealthFacility was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
14
use NobrainerWeb\App\Projects\Order;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\Projects\Order was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
15
use NobrainerWeb\App\Projects\Project;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\Projects\Project was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
16
use NobrainerWeb\App\Troubleshooters\Troubleshooter;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\Troubleshooters\Troubleshooter was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
17
use NobrainerWeb\App\Units\LocationSubmission;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\Units\LocationSubmission was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
18
use NobrainerWeb\App\Units\Unit;
0 ignored issues
show
Bug introduced by
The type NobrainerWeb\App\Units\Unit was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
19
use SilverStripe\CMS\Model\SiteTree;
0 ignored issues
show
Bug introduced by
The type SilverStripe\CMS\Model\SiteTree was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
20
use SilverStripe\Control\Controller;
21
use SilverStripe\Control\Director;
22
use SilverStripe\Control\HTTPRequest;
23
use SilverStripe\Core\Config\Config;
24
use SilverStripe\Core\Injector\Injector;
25
use SilverStripe\ORM\ArrayList;
26
use SilverStripe\ORM\DataList;
27
use SilverStripe\ORM\DataObject;
28
use SilverStripe\ORM\SS_List;
29
use SilverStripe\ORM\ValidationException;
30
use SilverStripe\ORM\ValidationResult;
31
use SilverStripe\Security\Member;
32
use SilverStripe\Security\Security;
33
34
/**
35
 * Generic RESTful server, which handles webservice access to arbitrary DataObjects.
36
 * Relies on serialization/deserialization into different formats provided
37
 * by the DataFormatter APIs in core.
38
 *
39
 * @todo Implement PUT/POST/DELETE for relations
40
 * @todo Access-Control for relations (you might be allowed to view Members and Groups,
41
 *       but not their relation with each other)
42
 * @todo Make SearchContext specification customizeable for each class
43
 * @todo Allow for range-searches (e.g. on Created column)
44
 * @todo Filter relation listings by $api_access and canView() permissions
45
 * @todo Exclude relations when "fields" are specified through URL (they should be explicitly
46
 *       requested in this case)
47
 * @todo Custom filters per DataObject subclass, e.g. to disallow showing unpublished pages in
48
 * SiteTree/Versioned/Hierarchy
49
 * @todo URL parameter namespacing for search-fields, limit, fields, add_fields
50
 *       (might all be valid dataobject properties)
51
 *       e.g. you wouldn't be able to search for a "limit" property on your subclass as
52
 *       its overlayed with the search logic
53
 * @todo i18n integration (e.g. Page/1.xml?lang=de_DE)
54
 * @todo Access to extendable methods/relations like SiteTree/1/Versions or SiteTree/1/Version/22
55
 * @todo Respect $api_access array notation in search contexts
56
 */
57
class RestfulServer extends Controller
58
{
59
    /**
60
     * @config
61
     * @var array
62
     */
63
    private static $url_handlers = array(
64
        '$ClassName!/$ID/$Relation' => 'handleAction',
65
        '' => 'notFound'
66
    );
67
68
    /**
69
     * @config
70
     * @var string root of the api route, MUST have a trailing slash
71
     */
72
    private static $api_base = "api/v1/";
73
74
    /**
75
     * @config
76
     * @var string Class name for an authenticator to use on API access
77
     */
78
    private static $authenticator = BasicRestfulAuthenticator::class;
79
80
    /**
81
     * If no extension is given in the request, resolve to this extension
82
     * (and subsequently the {@link self::$default_mimetype}.
83
     *
84
     * @config
85
     * @var string
86
     */
87
    private static $default_extension = "xml";
88
89
    /**
90
     * Custom endpoints that map to a specific class.
91
     * This is done to make the API have fixed endpoints, instead of using fully namespaced classnames, as the module does by default
92
     * The fully namespaced classnames can also still be used though
93
     *
94
     * @config array
95
     */
96
    private static $endpoint_aliases = [];
97
98
    /**
99
     * Whether or not to send an additional "Location" header for POST requests
100
     * to satisfy HTTP 1.1: https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
101
     *
102
     * Note: With this enabled (the default), no POST request for resource creation
103
     * will return an HTTP 201. Because of the addition of the "Location" header,
104
     * all responses become a straight HTTP 200.
105
     *
106
     * @config
107
     * @var boolean
108
     */
109
    private static $location_header_on_create = true;
110
111
    /**
112
     * If no extension is given, resolve the request to this mimetype.
113
     *
114
     * @var string
115
     */
116
    protected static $default_mimetype = "text/xml";
117
118
    /**
119
     * @uses authenticate()
120
     * @var Member
121
     */
122
    protected $member;
123
124
    private static $allowed_actions = array(
125
        'index',
126
        'notFound'
127
    );
128
129
    public function init()
130
    {
131
        /* This sets up SiteTree the same as when viewing a page through the frontend. Versioned defaults
132
         * to Stage, and then when viewing the front-end Versioned::choose_site_stage changes it to Live.
133
         * TODO: In 3.2 we should make the default Live, then change to Stage in the admin area (with a nicer API)
134
         */
135
        if (class_exists(SiteTree::class)) {
136
            singleton(SiteTree::class)->extend('modelascontrollerInit', $this);
137
        }
138
        parent::init();
139
    }
140
141
    /**
142
     * Backslashes in fully qualified class names (e.g. NameSpaced\ClassName)
143
     * kills both requests (i.e. URIs) and XML (invalid character in a tag name)
144
     * So we'll replace them with a hyphen (-), as it's also unambiguious
145
     * in both cases (invalid in a php class name, and safe in an xml tag name)
146
     *
147
     * @param string $classname
148
     * @return string 'escaped' class name
149
     */
150
    protected function sanitiseClassName($className)
151
    {
152
        return str_replace('\\', '-', $className);
153
    }
154
155
    /**
156
     * Convert hyphen escaped class names back into fully qualified
157
     * PHP safe variant.
158
     *
159
     * @param string $classname
160
     * @return string syntactically valid classname
161
     */
162
    protected function unsanitiseClassName($className)
163
    {
164
        return str_replace('-', '\\', $className);
165
    }
166
167
    /**
168
     * Parse many many relation class (works with through array syntax)
169
     *
170
     * @param string|array $class
171
     * @return string|array
172
     */
173
    public static function parseRelationClass($class)
174
    {
175
        // detect many many through syntax
176
        if (is_array($class)
177
            && array_key_exists('through', $class)
178
            && array_key_exists('to', $class)
179
        ) {
180
            $toRelation = $class['to'];
181
182
            $hasOne = Config::inst()->get($class['through'], 'has_one');
183
            if (empty($hasOne) || !is_array($hasOne) || !array_key_exists($toRelation, $hasOne)) {
184
                return $class;
185
            }
186
187
            return $hasOne[$toRelation];
188
        }
189
190
        return $class;
191
    }
192
193
    /**
194
     * This handler acts as the switchboard for the controller.
195
     * Since no $Action url-param is set, all requests are sent here.
196
     */
197
    public function index(HTTPRequest $request)
198
    {
199
        $endpoint = $request->param('ClassName');
200
        $className = $this->unsanitiseClassName($endpoint);
201
        $id = $request->param('ID') ?: null;
202
        $relation = $request->param('Relation') ?: null;
203
204
        if ($alias = $this->getEndpointAlias($endpoint)) {
205
            $className = $alias;
206
        }
207
208
        // Check input formats
209
        if (!class_exists($className)) {
210
            return $this->notFound();
211
        }
212
        if ($id && !is_numeric($id)) {
213
            return $this->notFound();
214
        }
215
        if ($relation
216
            && !preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $relation)
217
        ) {
218
            return $this->notFound();
219
        }
220
221
        // if api access is disabled, don't proceed
222
        $apiAccess = Config::inst()->get($className, 'api_access');
223
        if (!$apiAccess) {
224
            return $this->permissionFailure();
225
        }
226
227
        // authenticate through HTTP BasicAuth
228
        $this->member = $this->authenticate();
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->authenticate() can also be of type false. However, the property $member is declared as type SilverStripe\Security\Member. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
229
230
        try {
231
            // handle different HTTP verbs
232
            if ($this->request->isGET() || $this->request->isHEAD()) {
233
                return $this->getHandler($className, $id, $relation);
0 ignored issues
show
Bug introduced by
It seems like $id can also be of type string; however, parameter $id of SilverStripe\RestfulServ...fulServer::getHandler() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

233
                return $this->getHandler($className, /** @scrutinizer ignore-type */ $id, $relation);
Loading history...
234
            }
235
236
            if ($this->request->isPOST()) {
237
                return $this->postHandler($className, $id, $relation);
238
            }
239
240
            if ($this->request->isPUT()) {
241
                return $this->putHandler($className, $id, $relation);
0 ignored issues
show
Unused Code introduced by
The call to SilverStripe\RestfulServ...fulServer::putHandler() has too many arguments starting with $relation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

241
                return $this->/** @scrutinizer ignore-call */ putHandler($className, $id, $relation);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
242
            }
243
244
            if ($this->request->isDELETE()) {
245
                return $this->deleteHandler($className, $id, $relation);
0 ignored issues
show
Unused Code introduced by
The call to SilverStripe\RestfulServ...Server::deleteHandler() has too many arguments starting with $relation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

245
                return $this->/** @scrutinizer ignore-call */ deleteHandler($className, $id, $relation);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
246
            }
247
        } catch (\Exception $e) {
248
            return $this->exceptionThrown($this->getRequestDataFormatter($className), $e);
249
        }
250
251
        // if no HTTP verb matches, return error
252
        return $this->methodNotAllowed();
253
    }
254
255
    /**
256
     * Handler for object read.
257
     *
258
     * The data object will be returned in the following format:
259
     *
260
     * <ClassName>
261
     *   <FieldName>Value</FieldName>
262
     *   ...
263
     *   <HasOneRelName id="ForeignID" href="LinkToForeignRecordInAPI" />
264
     *   ...
265
     *   <HasManyRelName>
266
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
267
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
268
     *   </HasManyRelName>
269
     *   ...
270
     *   <ManyManyRelName>
271
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
272
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
273
     *   </ManyManyRelName>
274
     * </ClassName>
275
     *
276
     * Access is controlled by two variables:
277
     *
278
     *   - static $api_access must be set. This enables the API on a class by class basis
279
     *   - $obj->canView() must return true. This lets you implement record-level security
280
     *
281
     * @todo Access checking
282
     *
283
     * @param string $className
284
     * @param int $id
285
     * @param string $relation
286
     * @return string The serialized representation of the requested object(s) - usually XML or JSON.
287
     */
288
    protected function getHandler($className, $id, $relationName)
289
    {
290
        $sort = ['ID' => 'ASC'];
291
292
        if ($sortQuery = $this->request->getVar('sort')) {
293
            /** @var DataObject $singleton */
294
            $singleton = singleton($className);
295
            // Only apply a sort filter if it is a valid field on the DataObject
296
            if ($singleton && $singleton->hasDatabaseField($sortQuery)) {
297
                $sort = [
298
                    $sortQuery => $this->request->getVar('dir') === 'DESC' ? 'DESC' : 'ASC',
299
                ];
300
            }
301
        }
302
303
        $limit = [
304
            'start' => (int) $this->request->getVar('start'),
305
            'limit' => (int) $this->request->getVar('limit'),
306
        ];
307
308
        $params = $this->request->getVars();
309
310
        $responseFormatter = $this->getResponseDataFormatter($className);
311
        if (!$responseFormatter) {
0 ignored issues
show
introduced by
$responseFormatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
312
            return $this->unsupportedMediaType();
313
        }
314
315
        // $obj can be either a DataObject or a SS_List,
316
        // depending on the request
317
        if ($id) {
318
            // Format: /api/v1/<MyClass>/<ID>
319
            $obj = $this->getObjectQuery($className, $id, $params)->First();
320
            if (!$obj) {
0 ignored issues
show
introduced by
$obj is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
321
                return $this->notFound();
322
            }
323
            if (!$obj->canView($this->getMember())) {
324
                return $this->permissionFailure();
325
            }
326
327
            // Format: /api/v1/<MyClass>/<ID>/<Relation>
328
            if ($relationName) {
329
                $obj = $this->getObjectRelationQuery($obj, $params, $sort, $limit, $relationName);
330
                if (!$obj) {
331
                    return $this->notFound();
332
                }
333
334
                // TODO Avoid creating data formatter again for relation class (see above)
335
                $responseFormatter = $this->getResponseDataFormatter($obj->dataClass());
336
            }
337
        } else {
338
            // Format: /api/v1/<MyClass>
339
            $obj = $this->getObjectsQuery($className, $params, $sort, $limit);
340
        }
341
342
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
343
344
        $rawFields = $this->request->getVar('fields');
345
        $realFields = $responseFormatter->getRealFields($className, explode(',', $rawFields));
346
        $fields = $rawFields ? $realFields : null;
347
348
        if ($obj instanceof SS_List) {
349
            $objs = ArrayList::create($obj->toArray());
350
            foreach ($objs as $obj) {
351
                if (!$obj->canView($this->getMember())) {
352
                    $objs->remove($obj);
353
                }
354
            }
355
            $responseFormatter->setTotalSize($objs->count());
356
            $this->extend('updateRestfulGetHandler', $objs, $responseFormatter);
357
358
            return $responseFormatter->convertDataObjectSet($objs, $fields);
359
        }
360
361
        if (!$obj) {
362
            $responseFormatter->setTotalSize(0);
363
            return $responseFormatter->convertDataObjectSet(new ArrayList(), $fields);
0 ignored issues
show
Unused Code introduced by
The call to SilverStripe\RestfulServ...:convertDataObjectSet() has too many arguments starting with $fields. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

363
            return $responseFormatter->/** @scrutinizer ignore-call */ convertDataObjectSet(new ArrayList(), $fields);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
364
        }
365
366
        $this->extend('updateRestfulGetHandler', $obj, $responseFormatter);
367
368
        return $responseFormatter->convertDataObject($obj, $fields);
369
    }
370
371
    /**
372
     * Uses the default {@link SearchContext} specified through
373
     * {@link DataObject::getDefaultSearchContext()} to augument
374
     * an existing query object (mostly a component query from {@link DataObject})
375
     * with search clauses.
376
     *
377
     * @todo Allow specifying of different searchcontext getters on model-by-model basis
378
     *
379
     * @param string $className
380
     * @param array $params
381
     * @return SS_List
382
     */
383
    protected function getSearchQuery(
384
        $className,
385
        $params = null,
386
        $sort = null,
387
        $limit = null,
388
        $existingQuery = null
389
    ) {
390
        if (singleton($className)->hasMethod('getRestfulSearchContext')) {
391
            $searchContext = singleton($className)->{'getRestfulSearchContext'}();
392
        } else {
393
            $searchContext = singleton($className)->getDefaultSearchContext();
394
        }
395
        return $searchContext->getQuery($params, $sort, $limit, $existingQuery);
396
    }
397
398
    /**
399
     * Returns a dataformatter instance based on the request
400
     * extension or mimetype. Falls back to {@link self::$default_extension}.
401
     *
402
     * @param boolean $includeAcceptHeader Determines wether to inspect and prioritize any HTTP Accept headers
403
     * @param string Classname of a DataObject
0 ignored issues
show
Bug introduced by
The type SilverStripe\RestfulServer\Classname was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
404
     * @return DataFormatter
405
     */
406
    protected function getDataFormatter($includeAcceptHeader = false, $className = null)
407
    {
408
        $extension = $this->request->getExtension();
409
        $contentTypeWithEncoding = $this->request->getHeader('Content-Type');
410
        preg_match('/([^;]*)/', $contentTypeWithEncoding, $contentTypeMatches);
411
        $contentType = $contentTypeMatches[0];
412
        $accept = $this->request->getHeader('Accept');
413
        $mimetypes = $this->request->getAcceptMimetypes();
414
        if (!$className) {
415
            $className = $this->unsanitiseClassName($this->request->param('ClassName'));
416
        }
417
418
        // get formatter
419
        if (!empty($extension)) {
420
            $formatter = DataFormatter::for_extension($extension);
421
        } elseif ($includeAcceptHeader && !empty($accept) && strpos($accept, '*/*') === false) {
422
            $formatter = DataFormatter::for_mimetypes($mimetypes);
423
            if (!$formatter) {
0 ignored issues
show
introduced by
$formatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
424
                $formatter = DataFormatter::for_extension($this->config()->default_extension);
425
            }
426
        } elseif (!empty($contentType)) {
427
            $formatter = DataFormatter::for_mimetype($contentType);
428
        } else {
429
            $formatter = DataFormatter::for_extension($this->config()->default_extension);
430
        }
431
432
        if (!$formatter) {
0 ignored issues
show
introduced by
$formatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
433
            return false;
434
        }
435
436
        // set custom fields
437
        if ($customAddFields = $this->request->getVar('add_fields')) {
438
            $customAddFields = $formatter->getRealFields($className, explode(',', $customAddFields));
439
            $formatter->setCustomAddFields($customAddFields);
440
        }
441
        if ($customFields = $this->request->getVar('fields')) {
442
            $customFields = $formatter->getRealFields($className, explode(',', $customFields));
443
            $formatter->setCustomFields($customFields);
444
        }
445
        $formatter->setCustomRelations($this->getAllowedRelations($className));
446
447
        $apiAccess = Config::inst()->get($className, 'api_access');
448
        if (is_array($apiAccess)) {
449
            $formatter->setCustomAddFields(
450
                array_intersect((array)$formatter->getCustomAddFields(), (array)$apiAccess['view'])
451
            );
452
            if ($formatter->getCustomFields()) {
453
                $formatter->setCustomFields(
454
                    array_intersect((array)$formatter->getCustomFields(), (array)$apiAccess['view'])
455
                );
456
            } else {
457
                $formatter->setCustomFields((array)$apiAccess['view']);
458
            }
459
            if ($formatter->getCustomRelations()) {
460
                $formatter->setCustomRelations(
461
                    array_intersect((array)$formatter->getCustomRelations(), (array)$apiAccess['view'])
462
                );
463
            } else {
464
                $formatter->setCustomRelations((array)$apiAccess['view']);
465
            }
466
        }
467
468
        // set relation depth
469
        $relationDepth = $this->request->getVar('relationdepth');
470
        if (is_numeric($relationDepth)) {
471
            $formatter->relationDepth = (int)$relationDepth;
472
        }
473
474
        return $formatter;
475
    }
476
477
    /**
478
     * @param string Classname of a DataObject
479
     * @return DataFormatter
480
     */
481
    protected function getRequestDataFormatter($className = null)
482
    {
483
        return $this->getDataFormatter(false, $className);
484
    }
485
486
    /**
487
     * @param string Classname of a DataObject
488
     * @return DataFormatter
489
     */
490
    protected function getResponseDataFormatter($className = null)
491
    {
492
        return $this->getDataFormatter(true, $className);
493
    }
494
495
    /**
496
     * Handler for object delete
497
     */
498
    protected function deleteHandler($className, $id)
499
    {
500
        $obj = DataObject::get_by_id($className, $id);
501
        if (!$obj) {
0 ignored issues
show
introduced by
$obj is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
502
            return $this->notFound();
503
        }
504
        if (!$obj->canDelete($this->getMember())) {
505
            return $this->permissionFailure();
506
        }
507
508
        $obj->delete();
509
510
        $this->getResponse()->setStatusCode(204); // No Content
511
        return true;
512
    }
513
514
    /**
515
     * Handler for object write
516
     */
517
    protected function putHandler($className, $id)
518
    {
519
        $obj = DataObject::get_by_id($className, $id);
520
        if (!$obj) {
0 ignored issues
show
introduced by
$obj is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
521
            return $this->notFound();
522
        }
523
524
        if (!$obj->canEdit($this->getMember())) {
525
            return $this->permissionFailure();
526
        }
527
528
        $reqFormatter = $this->getRequestDataFormatter($className);
529
        if (!$reqFormatter) {
0 ignored issues
show
introduced by
$reqFormatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
530
            return $this->unsupportedMediaType();
531
        }
532
533
        $responseFormatter = $this->getResponseDataFormatter($className);
534
        if (!$responseFormatter) {
0 ignored issues
show
introduced by
$responseFormatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
535
            return $this->unsupportedMediaType();
536
        }
537
538
        try {
539
            /** @var DataObject|string */
540
            $obj = $this->updateDataObject($obj, $reqFormatter);
541
        } catch (ValidationException $e) {
542
            return $this->validationFailure($responseFormatter, $e->getResult());
543
        }
544
545
        if (is_string($obj)) {
546
            return $obj;
547
        }
548
549
        $this->getResponse()->setStatusCode(202); // Accepted
550
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
551
552
        // Append the default extension for the output format to the Location header
553
        // or else we'll use the default (XML)
554
        $types = $responseFormatter->supportedExtensions();
555
        $type = '';
556
        if (count($types)) {
557
            $type = ".{$types[0]}";
558
        }
559
560
        $urlSafeClassName = $this->sanitiseClassName(get_class($obj));
561
        $apiBase = $this->config()->api_base;
562
        $objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
563
        $this->getResponse()->addHeader('Location', $objHref);
564
565
        return $responseFormatter->convertDataObject($obj);
566
    }
567
568
    /**
569
     * Handler for object append / method call.
570
     *
571
     * @todo Posting to an existing URL (without a relation)
572
     * current resolves in creatig a new element,
573
     * rather than a "Conflict" message.
574
     */
575
    protected function postHandler($className, $id, $relation)
576
    {
577
        if ($id) {
578
            if (!$relation) {
579
                $this->response->setStatusCode(409);
580
                return 'Conflict';
581
            }
582
583
            $obj = DataObject::get_by_id($className, $id);
584
            if (!$obj) {
0 ignored issues
show
introduced by
$obj is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
585
                return $this->notFound();
586
            }
587
588
            $reqFormatter = $this->getRequestDataFormatter($className);
589
            if (!$reqFormatter) {
0 ignored issues
show
introduced by
$reqFormatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
590
                return $this->unsupportedMediaType();
591
            }
592
593
            $relation = $reqFormatter->getRealFieldName($className, $relation);
594
595
            if (!$obj->hasMethod($relation)) {
596
                return $this->notFound();
597
            }
598
599
            if (!Config::inst()->get($className, 'allowed_actions') ||
600
                !in_array($relation, Config::inst()->get($className, 'allowed_actions'))) {
601
                return $this->permissionFailure();
602
            }
603
604
            $obj->$relation();
605
606
            $this->getResponse()->setStatusCode(204); // No Content
607
            return true;
608
        }
609
610
        if (!singleton($className)->canCreate($this->getMember())) {
611
            return $this->permissionFailure();
612
        }
613
614
        $obj = Injector::inst()->create($className);
615
616
        $reqFormatter = $this->getRequestDataFormatter($className);
617
        if (!$reqFormatter) {
0 ignored issues
show
introduced by
$reqFormatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
618
            return $this->unsupportedMediaType();
619
        }
620
621
        $responseFormatter = $this->getResponseDataFormatter($className);
622
623
        try {
624
            /** @var DataObject|string $obj */
625
            $obj = $this->updateDataObject($obj, $reqFormatter);
0 ignored issues
show
Bug introduced by
It seems like $obj can also be of type string; however, parameter $obj of SilverStripe\RestfulServ...ver::updateDataObject() does only seem to accept SilverStripe\ORM\DataObject, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

625
            $obj = $this->updateDataObject(/** @scrutinizer ignore-type */ $obj, $reqFormatter);
Loading history...
626
        } catch (ValidationException $e) {
627
            return $this->validationFailure($responseFormatter, $e->getResult());
628
        }
629
630
        if (is_string($obj)) {
631
            return $obj;
632
        }
633
634
        $this->getResponse()->setStatusCode(201); // Created
635
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
636
637
        // Append the default extension for the output format to the Location header
638
        // or else we'll use the default (XML)
639
        $types = $responseFormatter->supportedExtensions();
640
        $type = '';
641
        if (count($types)) {
642
            $type = ".{$types[0]}";
643
        }
644
645
        // Deviate slightly from the spec: Helps datamodel API access restrict
646
        // to consulting just canCreate(), not canView() as a result of the additional
647
        // "Location" header.
648
        if ($this->config()->get('location_header_on_create')) {
649
            $urlSafeClassName = $this->sanitiseClassName(get_class($obj));
650
            $apiBase = $this->config()->api_base;
651
            $objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
652
            $this->getResponse()->addHeader('Location', $objHref);
653
        }
654
655
        return $responseFormatter->convertDataObject($obj);
656
    }
657
658
    /**
659
     * Converts either the given HTTP Body into an array
660
     * (based on the DataFormatter instance), or returns
661
     * the POST variables.
662
     * Automatically filters out certain critical fields
663
     * that shouldn't be set by the client (e.g. ID).
664
     *
665
     * @param DataObject $obj
666
     * @param DataFormatter $formatter
667
     * @return DataObject|string The passed object, or "No Content" if incomplete input data is provided
668
     */
669
    protected function updateDataObject($obj, $formatter)
670
    {
671
        // if neither an http body nor POST data is present, return error
672
        $body = $this->request->getBody();
673
        if (!$body && !$this->request->postVars()) {
674
            $this->getResponse()->setStatusCode(204); // No Content
675
            return 'No Content';
676
        }
677
678
        if (!empty($body)) {
679
            $rawdata = $formatter->convertStringToArray($body);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $rawdata is correct as $formatter->convertStringToArray($body) targeting SilverStripe\RestfulServ...:convertStringToArray() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
680
        } else {
681
            // assume application/x-www-form-urlencoded which is automatically parsed by PHP
682
            $rawdata = $this->request->postVars();
683
        }
684
685
        $className = $this->unsanitiseClassName($this->request->param('ClassName'));
686
        // update any aliased field names
687
        $data = [];
688
        foreach ($rawdata as $key => $value) {
689
            $newkey = $formatter->getRealFieldName($className, $key);
690
            $data[$newkey] = $value;
691
        }
692
693
        // @todo Disallow editing of certain keys in database
694
        $data = array_diff_key($data, ['ID', 'Created']);
695
696
        $apiAccess = singleton($className)->config()->api_access;
697
        if (is_array($apiAccess) && isset($apiAccess['edit'])) {
698
            $data = array_intersect_key($data, array_combine($apiAccess['edit'], $apiAccess['edit']));
0 ignored issues
show
Bug introduced by
It seems like array_combine($apiAccess...'], $apiAccess['edit']) can also be of type false; however, parameter $array2 of array_intersect_key() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

698
            $data = array_intersect_key($data, /** @scrutinizer ignore-type */ array_combine($apiAccess['edit'], $apiAccess['edit']));
Loading history...
699
        }
700
701
        $obj->update($data);
702
        $obj->write();
703
704
        return $obj;
705
    }
706
707
    /**
708
     * Gets a single DataObject by ID,
709
     * through a request like /api/v1/<MyClass>/<MyID>
710
     *
711
     * @param string $className
712
     * @param int $id
713
     * @param array $params
714
     * @return DataList
715
     */
716
    protected function getObjectQuery($className, $id, $params)
717
    {
718
        return DataList::create($className)->byIDs([$id]);
719
    }
720
721
    /**
722
     * @param DataObject $obj
723
     * @param array $params
724
     * @param int|array $sort
725
     * @param int|array $limit
726
     * @return SQLQuery
0 ignored issues
show
Bug introduced by
The type SilverStripe\RestfulServer\SQLQuery was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
727
     */
728
    protected function getObjectsQuery($className, $params, $sort, $limit)
729
    {
730
        return $this->getSearchQuery($className, $params, $sort, $limit);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->getSearchQ...$params, $sort, $limit) returns the type SilverStripe\ORM\SS_List which is incompatible with the documented return type SilverStripe\RestfulServer\SQLQuery.
Loading history...
731
    }
732
733
734
    /**
735
     * @param DataObject $obj
736
     * @param array $params
737
     * @param int|array $sort
738
     * @param int|array $limit
739
     * @param string $relationName
740
     * @return SQLQuery|boolean
741
     */
742
    protected function getObjectRelationQuery($obj, $params, $sort, $limit, $relationName)
743
    {
744
        // The relation method will return a DataList, that getSearchQuery subsequently manipulates
745
        if ($obj->hasMethod($relationName)) {
746
            // $this->HasOneName() will return a dataobject or null, neither
747
            // of which helps us get the classname in a consistent fashion.
748
            // So we must use a way that is reliable.
749
            if ($relationClass = DataObject::getSchema()->hasOneComponent(get_class($obj), $relationName)) {
750
                $joinField = $relationName . 'ID';
751
                // Again `byID` will return the wrong type for our purposes. So use `byIDs`
752
                $list = DataList::create($relationClass)->byIDs([$obj->$joinField]);
753
            } else {
754
                $list = $obj->$relationName();
755
            }
756
757
            $apiAccess = Config::inst()->get($list->dataClass(), 'api_access');
758
759
760
            if (!$apiAccess) {
761
                return false;
762
            }
763
764
            return $this->getSearchQuery($list->dataClass(), $params, $sort, $limit, $list);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->getSearchQ..., $sort, $limit, $list) returns the type SilverStripe\ORM\SS_List which is incompatible with the documented return type SilverStripe\RestfulServer\SQLQuery|boolean.
Loading history...
765
        }
766
    }
767
768
    /**
769
     * @return string
770
     */
771
    protected function permissionFailure()
772
    {
773
        // return a 401
774
        $this->getResponse()->setStatusCode(401);
775
        $this->getResponse()->addHeader('WWW-Authenticate', 'Basic realm="API Access"');
776
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
777
778
        $response = "You don't have access to this item through the API.";
779
        $this->extend(__FUNCTION__, $response);
780
781
        return $response;
782
    }
783
784
    /**
785
     * @return string
786
     */
787
    protected function notFound()
788
    {
789
        // return a 404
790
        $this->getResponse()->setStatusCode(404);
791
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
792
793
        $response = "That object wasn't found";
794
        $this->extend(__FUNCTION__, $response);
795
796
        return $response;
797
    }
798
799
    /**
800
     * @return string
801
     */
802
    protected function methodNotAllowed()
803
    {
804
        $this->getResponse()->setStatusCode(405);
805
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
806
807
        $response = "Method Not Allowed";
808
        $this->extend(__FUNCTION__, $response);
809
810
        return $response;
811
    }
812
813
    /**
814
     * @return string
815
     */
816
    protected function unsupportedMediaType()
817
    {
818
        $this->response->setStatusCode(415); // Unsupported Media Type
819
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
820
821
        $response = "Unsupported Media Type";
822
        $this->extend(__FUNCTION__, $response);
823
824
        return $response;
825
    }
826
827
    /**
828
     * @param ValidationResult $result
829
     * @return mixed
830
     */
831
    protected function validationFailure(DataFormatter $responseFormatter, ValidationResult $result)
832
    {
833
        $this->getResponse()->setStatusCode(400);
834
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
835
836
        $response = [
837
            'type' => ValidationException::class,
838
            'messages' => $result->getMessages(),
839
        ];
840
841
        $this->extend(__FUNCTION__, $response, $result);
842
843
        return $responseFormatter->convertArray($response);
844
    }
845
846
    /**
847
     * @param DataFormatter $responseFormatter
848
     * @param \Exception $e
849
     * @return string
850
     */
851
    protected function exceptionThrown(DataFormatter $responseFormatter, \Exception $e)
852
    {
853
        $this->getResponse()->setStatusCode(500);
854
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
855
856
        $response = [
857
            'type' => get_class($e),
858
            'message' => $e->getMessage(),
859
        ];
860
861
        $this->extend(__FUNCTION__, $response, $e);
862
863
        return $responseFormatter->convertArray($response);
864
    }
865
866
    /**
867
     * A function to authenticate a user
868
     *
869
     * @return Member|false the logged in member
870
     */
871
    protected function authenticate()
872
    {
873
        $authClass = $this->config()->authenticator;
874
        $member = $authClass::authenticate();
875
        Security::setCurrentUser($member);
876
        return $member;
877
    }
878
879
    /**
880
     * Return only relations which have $api_access enabled.
881
     * @todo Respect field level permissions once they are available in core
882
     *
883
     * @param string $class
884
     * @param Member $member
885
     * @return array
886
     */
887
    protected function getAllowedRelations($class, $member = null)
888
    {
889
        $allowedRelations = [];
890
        $obj = singleton($class);
891
        $relations = (array)$obj->hasOne() + (array)$obj->hasMany() + (array)$obj->manyMany();
892
        if ($relations) {
893
            foreach ($relations as $relName => $relClass) {
894
                $relClass = static::parseRelationClass($relClass);
895
896
                //remove dot notation from relation names
897
                $parts = explode('.', $relClass);
0 ignored issues
show
Bug introduced by
It seems like $relClass can also be of type array; however, parameter $string of explode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

897
                $parts = explode('.', /** @scrutinizer ignore-type */ $relClass);
Loading history...
898
                $relClass = array_shift($parts);
899
                if (Config::inst()->get($relClass, 'api_access')) {
900
                    $allowedRelations[] = $relName;
901
                }
902
            }
903
        }
904
        return $allowedRelations;
905
    }
906
907
    /**
908
     * Get the current Member, if available
909
     *
910
     * @return Member|null
911
     */
912
    protected function getMember()
913
    {
914
        return Security::getCurrentUser();
915
    }
916
917
    /**
918
     * @param $endpoint
919
     * @return null | string
920
     */
921
    protected function getEndpointAlias($endpoint)
922
    {
923
        $aliases = self::config()->get('endpoint_aliases');
924
925
        return $aliases[$endpoint] ?? null;
926
    }
927
}
928