Completed
Push — master ( 852ca3...9e4b2f )
by Robbie
13s
created

RestfulServer::methodNotAllowed()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 5
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 0
1
<?php
2
3
namespace SilverStripe\RestfulServer;
4
5
use SilverStripe\ORM\ArrayList;
6
use SilverStripe\Core\Config\Config;
7
use SilverStripe\Control\Controller;
8
use SilverStripe\ORM\DataList;
9
use SilverStripe\ORM\DataObject;
10
use SilverStripe\Control\Director;
11
use SilverStripe\Control\HTTPRequest;
12
use SilverStripe\ORM\SS_List;
13
use SilverStripe\Security\Member;
14
use SilverStripe\Security\Security;
15
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...
16
17
/**
18
 * Generic RESTful server, which handles webservice access to arbitrary DataObjects.
19
 * Relies on serialization/deserialization into different formats provided
20
 * by the DataFormatter APIs in core.
21
 *
22
 * @todo Implement PUT/POST/DELETE for relations
23
 * @todo Access-Control for relations (you might be allowed to view Members and Groups,
24
 *       but not their relation with each other)
25
 * @todo Make SearchContext specification customizeable for each class
26
 * @todo Allow for range-searches (e.g. on Created column)
27
 * @todo Filter relation listings by $api_access and canView() permissions
28
 * @todo Exclude relations when "fields" are specified through URL (they should be explicitly
29
 *       requested in this case)
30
 * @todo Custom filters per DataObject subclass, e.g. to disallow showing unpublished pages in
31
 * SiteTree/Versioned/Hierarchy
32
 * @todo URL parameter namespacing for search-fields, limit, fields, add_fields
33
 *       (might all be valid dataobject properties)
34
 *       e.g. you wouldn't be able to search for a "limit" property on your subclass as
35
 *       its overlayed with the search logic
36
 * @todo i18n integration (e.g. Page/1.xml?lang=de_DE)
37
 * @todo Access to extendable methods/relations like SiteTree/1/Versions or SiteTree/1/Version/22
38
 * @todo Respect $api_access array notation in search contexts
39
 */
40
class RestfulServer extends Controller
41
{
42
    private static $url_handlers = array(
43
        '$ClassName!/$ID/$Relation' => 'handleAction',
44
        '' => 'notFound'
45
    );
46
47
    /**
48
     * @config
49
     * @var string root of the api route, MUST have a trailing slash
50
     */
51
    private static $api_base = "api/v1/";
52
53
    /**
54
     * @config
55
     * @var string Class name for an authenticator to use on API access
56
     */
57
    private static $authenticator = BasicRestfulAuthenticator::class;
58
59
    /**
60
     * If no extension is given in the request, resolve to this extension
61
     * (and subsequently the {@link self::$default_mimetype}.
62
     *
63
     * @var string
64
     */
65
    private static $default_extension = "xml";
66
67
    /**
68
     * If no extension is given, resolve the request to this mimetype.
69
     *
70
     * @var string
71
     */
72
    protected static $default_mimetype = "text/xml";
73
74
    /**
75
     * @uses authenticate()
76
     * @var Member
77
     */
78
    protected $member;
79
80
    private static $allowed_actions = array(
81
        'index',
82
        'notFound'
83
    );
84
85
    public function init()
86
    {
87
        /* This sets up SiteTree the same as when viewing a page through the frontend. Versioned defaults
88
         * to Stage, and then when viewing the front-end Versioned::choose_site_stage changes it to Live.
89
         * TODO: In 3.2 we should make the default Live, then change to Stage in the admin area (with a nicer API)
90
         */
91
        if (class_exists(SiteTree::class)) {
92
            singleton(SiteTree::class)->extend('modelascontrollerInit', $this);
93
        }
94
        parent::init();
95
    }
96
97
    /**
98
     * Backslashes in fully qualified class names (e.g. NameSpaced\ClassName)
99
     * kills both requests (i.e. URIs) and XML (invalid character in a tag name)
100
     * So we'll replace them with a hyphen (-), as it's also unambiguious
101
     * in both cases (invalid in a php class name, and safe in an xml tag name)
102
     *
103
     * @param string $classname
104
     * @return string 'escaped' class name
105
     */
106
    protected function sanitiseClassName($className)
107
    {
108
        return str_replace('\\', '-', $className);
109
    }
110
111
    /**
112
     * Convert hyphen escaped class names back into fully qualified
113
     * PHP safe variant.
114
     *
115
     * @param string $classname
116
     * @return string syntactically valid classname
117
     */
118
    protected function unsanitiseClassName($className)
119
    {
120
        return str_replace('-', '\\', $className);
121
    }
122
123
    /**
124
     * This handler acts as the switchboard for the controller.
125
     * Since no $Action url-param is set, all requests are sent here.
126
     */
127
    public function index(HTTPRequest $request)
128
    {
129
        $className = $this->unsanitiseClassName($request->param('ClassName'));
130
        $id = $request->param('ID') ?: null;
131
        $relation = $request->param('Relation') ?: null;
132
133
        // Check input formats
134
        if (!class_exists($className)) {
135
            return $this->notFound();
136
        }
137
        if ($id && !is_numeric($id)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $id of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
138
            return $this->notFound();
139
        }
140
        if ($relation
0 ignored issues
show
Bug Best Practice introduced by
The expression $relation of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
141
            && !preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $relation)
142
        ) {
143
            return $this->notFound();
144
        }
145
146
        // if api access is disabled, don't proceed
147
        $apiAccess = Config::inst()->get($className, 'api_access');
148
        if (!$apiAccess) {
149
            return $this->permissionFailure();
150
        }
151
152
        // authenticate through HTTP BasicAuth
153
        $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...
154
155
        // handle different HTTP verbs
156
        if ($this->request->isGET() || $this->request->isHEAD()) {
157
            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

157
            return $this->getHandler($className, /** @scrutinizer ignore-type */ $id, $relation);
Loading history...
158
        }
159
160
        if ($this->request->isPOST()) {
161
            return $this->postHandler($className, $id, $relation);
162
        }
163
164
        if ($this->request->isPUT()) {
165
            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

165
            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...
166
        }
167
168
        if ($this->request->isDELETE()) {
169
            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

169
            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...
170
        }
171
172
        // if no HTTP verb matches, return error
173
        return $this->methodNotAllowed();
174
    }
175
176
    /**
177
     * Handler for object read.
178
     *
179
     * The data object will be returned in the following format:
180
     *
181
     * <ClassName>
182
     *   <FieldName>Value</FieldName>
183
     *   ...
184
     *   <HasOneRelName id="ForeignID" href="LinkToForeignRecordInAPI" />
185
     *   ...
186
     *   <HasManyRelName>
187
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
188
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
189
     *   </HasManyRelName>
190
     *   ...
191
     *   <ManyManyRelName>
192
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
193
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
194
     *   </ManyManyRelName>
195
     * </ClassName>
196
     *
197
     * Access is controlled by two variables:
198
     *
199
     *   - static $api_access must be set. This enables the API on a class by class basis
200
     *   - $obj->canView() must return true. This lets you implement record-level security
201
     *
202
     * @todo Access checking
203
     *
204
     * @param string $className
205
     * @param Int $id
206
     * @param string $relation
207
     * @return string The serialized representation of the requested object(s) - usually XML or JSON.
208
     */
209
    protected function getHandler($className, $id, $relationName)
210
    {
211
        $sort = '';
212
213
        if ($this->request->getVar('sort')) {
214
            $dir = $this->request->getVar('dir');
215
            $sort = array($this->request->getVar('sort') => ($dir ? $dir : 'ASC'));
216
        }
217
218
        $limit = array(
219
            'start' => $this->request->getVar('start'),
220
            'limit' => $this->request->getVar('limit')
221
        );
222
223
        $params = $this->request->getVars();
224
225
        $responseFormatter = $this->getResponseDataFormatter($className);
226
        if (!$responseFormatter) {
227
            return $this->unsupportedMediaType();
228
        }
229
230
        // $obj can be either a DataObject or a SS_List,
231
        // depending on the request
232
        if ($id) {
233
            // Format: /api/v1/<MyClass>/<ID>
234
            $obj = $this->getObjectQuery($className, $id, $params)->First();
235
            if (!$obj) {
236
                return $this->notFound();
237
            }
238
            if (!$obj->canView($this->getMember())) {
239
                return $this->permissionFailure();
240
            }
241
242
            // Format: /api/v1/<MyClass>/<ID>/<Relation>
243
            if ($relationName) {
244
                $obj = $this->getObjectRelationQuery($obj, $params, $sort, $limit, $relationName);
245
                if (!$obj) {
246
                    return $this->notFound();
247
                }
248
249
                // TODO Avoid creating data formatter again for relation class (see above)
250
                $responseFormatter = $this->getResponseDataFormatter($obj->dataClass());
251
            }
252
        } else {
253
            // Format: /api/v1/<MyClass>
254
            $obj = $this->getObjectsQuery($className, $params, $sort, $limit);
255
        }
256
257
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
258
259
        $rawFields = $this->request->getVar('fields');
260
        $fields = $rawFields ? explode(',', $rawFields) : null;
261
262
        if ($obj instanceof SS_List) {
263
            $objs = ArrayList::create($obj->toArray());
264
            foreach ($objs as $obj) {
265
                if (!$obj->canView($this->getMember())) {
266
                    $objs->remove($obj);
267
                }
268
            }
269
            $responseFormatter->setTotalSize($objs->count());
270
            return $responseFormatter->convertDataObjectSet($objs, $fields);
271
        }
272
273
        if (!$obj) {
274
            $responseFormatter->setTotalSize(0);
275
            return $responseFormatter->convertDataObjectSet(new ArrayList(), $fields);
276
        }
277
278
        return $responseFormatter->convertDataObject($obj, $fields);
279
    }
280
281
    /**
282
     * Uses the default {@link SearchContext} specified through
283
     * {@link DataObject::getDefaultSearchContext()} to augument
284
     * an existing query object (mostly a component query from {@link DataObject})
285
     * with search clauses.
286
     *
287
     * @todo Allow specifying of different searchcontext getters on model-by-model basis
288
     *
289
     * @param string $className
290
     * @param array $params
291
     * @return SS_List
292
     */
293
    protected function getSearchQuery(
294
        $className,
295
        $params = null,
296
        $sort = null,
297
        $limit = null,
298
        $existingQuery = null
299
    ) {
300
        if (singleton($className)->hasMethod('getRestfulSearchContext')) {
301
            $searchContext = singleton($className)->{'getRestfulSearchContext'}();
302
        } else {
303
            $searchContext = singleton($className)->getDefaultSearchContext();
304
        }
305
        return $searchContext->getQuery($params, $sort, $limit, $existingQuery);
306
    }
307
308
    /**
309
     * Returns a dataformatter instance based on the request
310
     * extension or mimetype. Falls back to {@link self::$default_extension}.
311
     *
312
     * @param boolean $includeAcceptHeader Determines wether to inspect and prioritize any HTTP Accept headers
313
     * @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...
314
     * @return DataFormatter
315
     */
316
    protected function getDataFormatter($includeAcceptHeader = false, $className = null)
317
    {
318
        $extension = $this->request->getExtension();
319
        $contentTypeWithEncoding = $this->request->getHeader('Content-Type');
320
        preg_match('/([^;]*)/', $contentTypeWithEncoding, $contentTypeMatches);
321
        $contentType = $contentTypeMatches[0];
322
        $accept = $this->request->getHeader('Accept');
323
        $mimetypes = $this->request->getAcceptMimetypes();
324
        if (!$className) {
325
            $className = $this->unsanitiseClassName($this->request->param('ClassName'));
326
        }
327
328
        // get formatter
329
        if (!empty($extension)) {
330
            $formatter = DataFormatter::for_extension($extension);
331
        } elseif ($includeAcceptHeader && !empty($accept) && $accept != '*/*') {
332
            $formatter = DataFormatter::for_mimetypes($mimetypes);
333
            if (!$formatter) {
334
                $formatter = DataFormatter::for_extension(self::$default_extension);
335
            }
336
        } elseif (!empty($contentType)) {
337
            $formatter = DataFormatter::for_mimetype($contentType);
338
        } else {
339
            $formatter = DataFormatter::for_extension(self::$default_extension);
340
        }
341
342
        if (!$formatter) {
343
            return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type SilverStripe\RestfulServer\DataFormatter.
Loading history...
344
        }
345
346
        // set custom fields
347
        if ($customAddFields = $this->request->getVar('add_fields')) {
348
            $formatter->setCustomAddFields(explode(',', $customAddFields));
349
        }
350
        if ($customFields = $this->request->getVar('fields')) {
351
            $formatter->setCustomFields(explode(',', $customFields));
352
        }
353
        $formatter->setCustomRelations($this->getAllowedRelations($className));
354
355
        $apiAccess = Config::inst()->get($className, 'api_access');
356
        if (is_array($apiAccess)) {
357
            $formatter->setCustomAddFields(
358
                array_intersect((array)$formatter->getCustomAddFields(), (array)$apiAccess['view'])
359
            );
360 View Code Duplication
            if ($formatter->getCustomFields()) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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

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

Loading history...
361
                $formatter->setCustomFields(
362
                    array_intersect((array)$formatter->getCustomFields(), (array)$apiAccess['view'])
363
                );
364
            } else {
365
                $formatter->setCustomFields((array)$apiAccess['view']);
366
            }
367 View Code Duplication
            if ($formatter->getCustomRelations()) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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

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

Loading history...
368
                $formatter->setCustomRelations(
369
                    array_intersect((array)$formatter->getCustomRelations(), (array)$apiAccess['view'])
370
                );
371
            } else {
372
                $formatter->setCustomRelations((array)$apiAccess['view']);
373
            }
374
        }
375
376
        // set relation depth
377
        $relationDepth = $this->request->getVar('relationdepth');
378
        if (is_numeric($relationDepth)) {
379
            $formatter->relationDepth = (int)$relationDepth;
380
        }
381
382
        return $formatter;
383
    }
384
385
    /**
386
     * @param string Classname of a DataObject
387
     * @return DataFormatter
388
     */
389
    protected function getRequestDataFormatter($className = null)
390
    {
391
        return $this->getDataFormatter(false, $className);
392
    }
393
394
    /**
395
     * @param string Classname of a DataObject
396
     * @return DataFormatter
397
     */
398
    protected function getResponseDataFormatter($className = null)
399
    {
400
        return $this->getDataFormatter(true, $className);
401
    }
402
403
    /**
404
     * Handler for object delete
405
     */
406
    protected function deleteHandler($className, $id)
407
    {
408
        $obj = DataObject::get_by_id($className, $id);
409
        if (!$obj) {
410
            return $this->notFound();
411
        }
412
        if (!$obj->canDelete($this->getMember())) {
413
            return $this->permissionFailure();
414
        }
415
416
        $obj->delete();
417
418
        $this->getResponse()->setStatusCode(204); // No Content
419
        return true;
420
    }
421
422
    /**
423
     * Handler for object write
424
     */
425
    protected function putHandler($className, $id)
426
    {
427
        $obj = DataObject::get_by_id($className, $id);
428
        if (!$obj) {
429
            return $this->notFound();
430
        }
431
432
        if (!$obj->canEdit($this->getMember())) {
433
            return $this->permissionFailure();
434
        }
435
436
        $reqFormatter = $this->getRequestDataFormatter($className);
437
        if (!$reqFormatter) {
438
            return $this->unsupportedMediaType();
439
        }
440
441
        $responseFormatter = $this->getResponseDataFormatter($className);
442
        if (!$responseFormatter) {
443
            return $this->unsupportedMediaType();
444
        }
445
446
        /** @var DataObject|string */
447
        $obj = $this->updateDataObject($obj, $reqFormatter);
448
        if (is_string($obj)) {
449
            return $obj;
450
        }
451
452
        $this->getResponse()->setStatusCode(200); // Success
453
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
454
455
        // Append the default extension for the output format to the Location header
456
        // or else we'll use the default (XML)
457
        $types = $responseFormatter->supportedExtensions();
458
        $type = '';
459
        if (count($types)) {
460
            $type = ".{$types[0]}";
461
        }
462
463
        $urlSafeClassName = $this->sanitiseClassName(get_class($obj));
464
        $apiBase = $this->config()->api_base;
465
        $objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
466
        $this->getResponse()->addHeader('Location', $objHref);
467
468
        return $responseFormatter->convertDataObject($obj);
469
    }
470
471
    /**
472
     * Handler for object append / method call.
473
     *
474
     * @todo Posting to an existing URL (without a relation)
475
     * current resolves in creatig a new element,
476
     * rather than a "Conflict" message.
477
     */
478
    protected function postHandler($className, $id, $relation)
479
    {
480
        if ($id) {
481
            if (!$relation) {
482
                $this->response->setStatusCode(409);
483
                return 'Conflict';
484
            }
485
486
            $obj = DataObject::get_by_id($className, $id);
487
            if (!$obj) {
488
                return $this->notFound();
489
            }
490
491
            if (!$obj->hasMethod($relation)) {
492
                return $this->notFound();
493
            }
494
495
            if (!Config::inst()->get($className, 'allowed_actions') ||
496
                !in_array($relation, Config::inst()->get($className, 'allowed_actions'))) {
497
                return $this->permissionFailure();
498
            }
499
500
            $obj->$relation();
501
502
            $this->getResponse()->setStatusCode(204); // No Content
503
            return true;
504
        }
505
506
        if (!singleton($className)->canCreate($this->getMember())) {
507
            return $this->permissionFailure();
508
        }
509
        $obj = new $className();
510
511
        $reqFormatter = $this->getRequestDataFormatter($className);
512
        if (!$reqFormatter) {
513
            return $this->unsupportedMediaType();
514
        }
515
516
        $responseFormatter = $this->getResponseDataFormatter($className);
517
518
        /** @var DataObject|string $obj */
519
        $obj = $this->updateDataObject($obj, $reqFormatter);
520
        if (is_string($obj)) {
521
            return $obj;
522
        }
523
524
        $this->getResponse()->setStatusCode(201); // Created
525
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
526
527
        // Append the default extension for the output format to the Location header
528
        // or else we'll use the default (XML)
529
        $types = $responseFormatter->supportedExtensions();
530
        $type = '';
531
        if (count($types)) {
532
            $type = ".{$types[0]}";
533
        }
534
535
        $urlSafeClassName = $this->sanitiseClassName(get_class($obj));
536
        $apiBase = $this->config()->api_base;
537
        $objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
538
        $this->getResponse()->addHeader('Location', $objHref);
539
540
        return $responseFormatter->convertDataObject($obj);
541
    }
542
543
    /**
544
     * Converts either the given HTTP Body into an array
545
     * (based on the DataFormatter instance), or returns
546
     * the POST variables.
547
     * Automatically filters out certain critical fields
548
     * that shouldn't be set by the client (e.g. ID).
549
     *
550
     * @param DataObject $obj
551
     * @param DataFormatter $formatter
552
     * @return DataObject|string The passed object, or "No Content" if incomplete input data is provided
553
     */
554
    protected function updateDataObject($obj, $formatter)
555
    {
556
        // if neither an http body nor POST data is present, return error
557
        $body = $this->request->getBody();
558
        if (!$body && !$this->request->postVars()) {
559
            $this->getResponse()->setStatusCode(204); // No Content
560
            return 'No Content';
561
        }
562
563
        if (!empty($body)) {
564
            $data = $formatter->convertStringToArray($body);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $data 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...
565
        } else {
566
            // assume application/x-www-form-urlencoded which is automatically parsed by PHP
567
            $data = $this->request->postVars();
568
        }
569
570
        // @todo Disallow editing of certain keys in database
571
        $data = array_diff_key($data, ['ID', 'Created']);
572
573
        $className = $this->unsanitiseClassName($this->request->param('ClassName'));
574
        $apiAccess = singleton($className)->config()->api_access;
575
        if (is_array($apiAccess) && isset($apiAccess['edit'])) {
576
            $data = array_intersect_key($data, array_combine($apiAccess['edit'], $apiAccess['edit']));
577
        }
578
579
        $obj->update($data);
580
        $obj->write();
581
582
        return $obj;
583
    }
584
585
    /**
586
     * Gets a single DataObject by ID,
587
     * through a request like /api/v1/<MyClass>/<MyID>
588
     *
589
     * @param string $className
590
     * @param int $id
591
     * @param array $params
592
     * @return DataList
593
     */
594
    protected function getObjectQuery($className, $id, $params)
595
    {
596
        return DataList::create($className)->byIDs([$id]);
0 ignored issues
show
Bug introduced by
$className of type string is incompatible with the type array expected by parameter $args of SilverStripe\View\ViewableData::create(). ( Ignorable by Annotation )

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

596
        return DataList::create(/** @scrutinizer ignore-type */ $className)->byIDs([$id]);
Loading history...
597
    }
598
599
    /**
600
     * @param DataObject $obj
601
     * @param array $params
602
     * @param int|array $sort
603
     * @param int|array $limit
604
     * @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...
605
     */
606
    protected function getObjectsQuery($className, $params, $sort, $limit)
607
    {
608
        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...
609
    }
610
611
612
    /**
613
     * @param DataObject $obj
614
     * @param array $params
615
     * @param int|array $sort
616
     * @param int|array $limit
617
     * @param string $relationName
618
     * @return SQLQuery|boolean
619
     */
620
    protected function getObjectRelationQuery($obj, $params, $sort, $limit, $relationName)
621
    {
622
        // The relation method will return a DataList, that getSearchQuery subsequently manipulates
623
        if ($obj->hasMethod($relationName)) {
624
            // $this->HasOneName() will return a dataobject or null, neither
0 ignored issues
show
Unused Code Comprehensibility introduced by
39% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
625
            // of which helps us get the classname in a consistent fashion.
626
            // So we must use a way that is reliable.
627
            if ($relationClass = DataObject::getSchema()->hasOneComponent(get_class($obj), $relationName)) {
628
                $joinField = $relationName . 'ID';
629
                // Again `byID` will return the wrong type for our purposes. So use `byIDs`
630
                $list = DataList::create($relationClass)->byIDs([$obj->$joinField]);
0 ignored issues
show
Bug introduced by
$relationClass of type string is incompatible with the type array expected by parameter $args of SilverStripe\View\ViewableData::create(). ( Ignorable by Annotation )

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

630
                $list = DataList::create(/** @scrutinizer ignore-type */ $relationClass)->byIDs([$obj->$joinField]);
Loading history...
631
            } else {
632
                $list = $obj->$relationName();
633
            }
634
635
            $apiAccess = Config::inst()->get($list->dataClass(), 'api_access');
636
637
638
            if (!$apiAccess) {
639
                return false;
640
            }
641
642
            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 boolean|SilverStripe\RestfulServer\SQLQuery.
Loading history...
643
        }
644
    }
645
646
    protected function permissionFailure()
647
    {
648
        // return a 401
649
        $this->getResponse()->setStatusCode(401);
650
        $this->getResponse()->addHeader('WWW-Authenticate', 'Basic realm="API Access"');
651
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
652
        return "You don't have access to this item through the API.";
653
    }
654
655
    protected function notFound()
656
    {
657
        // return a 404
658
        $this->getResponse()->setStatusCode(404);
659
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
660
        return "That object wasn't found";
661
    }
662
663
    protected function methodNotAllowed()
664
    {
665
        $this->getResponse()->setStatusCode(405);
666
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
667
        return "Method Not Allowed";
668
    }
669
670
    protected function unsupportedMediaType()
671
    {
672
        $this->response->setStatusCode(415); // Unsupported Media Type
673
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
674
        return "Unsupported Media Type";
675
    }
676
677
    /**
678
     * A function to authenticate a user
679
     *
680
     * @return Member|false the logged in member
681
     */
682
    protected function authenticate()
683
    {
684
        $authClass = $this->config()->authenticator;
685
        $member = $authClass::authenticate();
686
        Security::setCurrentUser($member);
687
        return $member;
688
    }
689
690
    /**
691
     * Return only relations which have $api_access enabled.
692
     * @todo Respect field level permissions once they are available in core
693
     *
694
     * @param string $class
695
     * @param Member $member
696
     * @return array
697
     */
698
    protected function getAllowedRelations($class, $member = null)
699
    {
700
        $allowedRelations = [];
701
        $obj = singleton($class);
702
        $relations = (array)$obj->hasOne() + (array)$obj->hasMany() + (array)$obj->manyMany();
703
        if ($relations) {
704
            foreach ($relations as $relName => $relClass) {
705
                //remove dot notation from relation names
706
                $parts = explode('.', $relClass);
707
                $relClass = array_shift($parts);
708
                if (Config::inst()->get($relClass, 'api_access')) {
709
                    $allowedRelations[] = $relName;
710
                }
711
            }
712
        }
713
        return $allowedRelations;
714
    }
715
716
    /**
717
     * Get the current Member, if available
718
     *
719
     * @return Member|null
720
     */
721
    protected function getMember()
722
    {
723
        return Security::getCurrentUser();
724
    }
725
}
726