Completed
Push — master ( 5f7861...ee37e6 )
by Robbie
9s
created

RestfulServer::parseRelationClass()   B

Complexity

Conditions 7
Paths 3

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 9
nc 3
nop 1
dl 0
loc 18
rs 8.2222
c 0
b 0
f 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\ORM\ValidationException;
14
use SilverStripe\ORM\ValidationResult;
15
use SilverStripe\Security\Member;
16
use SilverStripe\Security\Security;
17
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...
18
19
/**
20
 * Generic RESTful server, which handles webservice access to arbitrary DataObjects.
21
 * Relies on serialization/deserialization into different formats provided
22
 * by the DataFormatter APIs in core.
23
 *
24
 * @todo Implement PUT/POST/DELETE for relations
25
 * @todo Access-Control for relations (you might be allowed to view Members and Groups,
26
 *       but not their relation with each other)
27
 * @todo Make SearchContext specification customizeable for each class
28
 * @todo Allow for range-searches (e.g. on Created column)
29
 * @todo Filter relation listings by $api_access and canView() permissions
30
 * @todo Exclude relations when "fields" are specified through URL (they should be explicitly
31
 *       requested in this case)
32
 * @todo Custom filters per DataObject subclass, e.g. to disallow showing unpublished pages in
33
 * SiteTree/Versioned/Hierarchy
34
 * @todo URL parameter namespacing for search-fields, limit, fields, add_fields
35
 *       (might all be valid dataobject properties)
36
 *       e.g. you wouldn't be able to search for a "limit" property on your subclass as
37
 *       its overlayed with the search logic
38
 * @todo i18n integration (e.g. Page/1.xml?lang=de_DE)
39
 * @todo Access to extendable methods/relations like SiteTree/1/Versions or SiteTree/1/Version/22
40
 * @todo Respect $api_access array notation in search contexts
41
 */
42
class RestfulServer extends Controller
43
{
44
    private static $url_handlers = array(
45
        '$ClassName!/$ID/$Relation' => 'handleAction',
46
        '' => 'notFound'
47
    );
48
49
    /**
50
     * @config
51
     * @var string root of the api route, MUST have a trailing slash
52
     */
53
    private static $api_base = "api/v1/";
54
55
    /**
56
     * @config
57
     * @var string Class name for an authenticator to use on API access
58
     */
59
    private static $authenticator = BasicRestfulAuthenticator::class;
60
61
    /**
62
     * If no extension is given in the request, resolve to this extension
63
     * (and subsequently the {@link self::$default_mimetype}.
64
     *
65
     * @var string
66
     */
67
    private static $default_extension = "xml";
68
69
    /**
70
     * If no extension is given, resolve the request to this mimetype.
71
     *
72
     * @var string
73
     */
74
    protected static $default_mimetype = "text/xml";
75
76
    /**
77
     * @uses authenticate()
78
     * @var Member
79
     */
80
    protected $member;
81
82
    private static $allowed_actions = array(
83
        'index',
84
        'notFound'
85
    );
86
87
    public function init()
88
    {
89
        /* This sets up SiteTree the same as when viewing a page through the frontend. Versioned defaults
90
         * to Stage, and then when viewing the front-end Versioned::choose_site_stage changes it to Live.
91
         * TODO: In 3.2 we should make the default Live, then change to Stage in the admin area (with a nicer API)
92
         */
93
        if (class_exists(SiteTree::class)) {
94
            singleton(SiteTree::class)->extend('modelascontrollerInit', $this);
95
        }
96
        parent::init();
97
    }
98
99
    /**
100
     * Backslashes in fully qualified class names (e.g. NameSpaced\ClassName)
101
     * kills both requests (i.e. URIs) and XML (invalid character in a tag name)
102
     * So we'll replace them with a hyphen (-), as it's also unambiguious
103
     * in both cases (invalid in a php class name, and safe in an xml tag name)
104
     *
105
     * @param string $classname
106
     * @return string 'escaped' class name
107
     */
108
    protected function sanitiseClassName($className)
109
    {
110
        return str_replace('\\', '-', $className);
111
    }
112
113
    /**
114
     * Convert hyphen escaped class names back into fully qualified
115
     * PHP safe variant.
116
     *
117
     * @param string $classname
118
     * @return string syntactically valid classname
119
     */
120
    protected function unsanitiseClassName($className)
121
    {
122
        return str_replace('-', '\\', $className);
123
    }
124
125
    /**
126
     * Parse many many relation class (works with through array syntax)
127
     *
128
     * @param string|array $class
129
     * @return string|array
130
     */
131
    public static function parseRelationClass($class)
132
    {
133
        // detect many many through syntax
134
        if (is_array($class)
135
            && array_key_exists('through', $class)
136
            && array_key_exists('to', $class)
137
        ) {
138
            $toRelation = $class['to'];
139
140
            $hasOne = Config::inst()->get($class['through'], 'has_one');
141
            if (empty($hasOne) || !is_array($hasOne) || !array_key_exists($toRelation, $hasOne)) {
142
                return $class;
143
            }
144
145
            return $hasOne[$toRelation];
146
        }
147
148
        return $class;
149
    }
150
151
    /**
152
     * This handler acts as the switchboard for the controller.
153
     * Since no $Action url-param is set, all requests are sent here.
154
     */
155
    public function index(HTTPRequest $request)
156
    {
157
        $className = $this->unsanitiseClassName($request->param('ClassName'));
158
        $id = $request->param('ID') ?: null;
159
        $relation = $request->param('Relation') ?: null;
160
161
        // Check input formats
162
        if (!class_exists($className)) {
163
            return $this->notFound();
164
        }
165
        if ($id && !is_numeric($id)) {
166
            return $this->notFound();
167
        }
168
        if ($relation
169
            && !preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $relation)
170
        ) {
171
            return $this->notFound();
172
        }
173
174
        // if api access is disabled, don't proceed
175
        $apiAccess = Config::inst()->get($className, 'api_access');
176
        if (!$apiAccess) {
177
            return $this->permissionFailure();
178
        }
179
180
        // authenticate through HTTP BasicAuth
181
        $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...
182
183
        try {
184
            // handle different HTTP verbs
185
            if ($this->request->isGET() || $this->request->isHEAD()) {
186
                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

186
                return $this->getHandler($className, /** @scrutinizer ignore-type */ $id, $relation);
Loading history...
187
            }
188
189
            if ($this->request->isPOST()) {
190
                return $this->postHandler($className, $id, $relation);
191
            }
192
193
            if ($this->request->isPUT()) {
194
                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

194
                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...
195
            }
196
197
            if ($this->request->isDELETE()) {
198
                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

198
                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...
199
            }
200
        } catch (\Exception $e) {
201
            return $this->exceptionThrown($this->getRequestDataFormatter($className), $e);
202
        }
203
204
        // if no HTTP verb matches, return error
205
        return $this->methodNotAllowed();
206
    }
207
208
    /**
209
     * Handler for object read.
210
     *
211
     * The data object will be returned in the following format:
212
     *
213
     * <ClassName>
214
     *   <FieldName>Value</FieldName>
215
     *   ...
216
     *   <HasOneRelName id="ForeignID" href="LinkToForeignRecordInAPI" />
217
     *   ...
218
     *   <HasManyRelName>
219
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
220
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
221
     *   </HasManyRelName>
222
     *   ...
223
     *   <ManyManyRelName>
224
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
225
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
226
     *   </ManyManyRelName>
227
     * </ClassName>
228
     *
229
     * Access is controlled by two variables:
230
     *
231
     *   - static $api_access must be set. This enables the API on a class by class basis
232
     *   - $obj->canView() must return true. This lets you implement record-level security
233
     *
234
     * @todo Access checking
235
     *
236
     * @param string $className
237
     * @param Int $id
238
     * @param string $relation
239
     * @return string The serialized representation of the requested object(s) - usually XML or JSON.
240
     */
241
    protected function getHandler($className, $id, $relationName)
242
    {
243
        $sort = '';
244
245
        if ($this->request->getVar('sort')) {
246
            $dir = $this->request->getVar('dir');
247
            $sort = array($this->request->getVar('sort') => ($dir ? $dir : 'ASC'));
248
        }
249
250
        $limit = array(
251
            'start' => $this->request->getVar('start'),
252
            'limit' => $this->request->getVar('limit')
253
        );
254
255
        $params = $this->request->getVars();
256
257
        $responseFormatter = $this->getResponseDataFormatter($className);
258
        if (!$responseFormatter) {
0 ignored issues
show
introduced by
$responseFormatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
259
            return $this->unsupportedMediaType();
260
        }
261
262
        // $obj can be either a DataObject or a SS_List,
263
        // depending on the request
264
        if ($id) {
265
            // Format: /api/v1/<MyClass>/<ID>
266
            $obj = $this->getObjectQuery($className, $id, $params)->First();
267
            if (!$obj) {
0 ignored issues
show
introduced by
$obj is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
268
                return $this->notFound();
269
            }
270
            if (!$obj->canView($this->getMember())) {
271
                return $this->permissionFailure();
272
            }
273
274
            // Format: /api/v1/<MyClass>/<ID>/<Relation>
275
            if ($relationName) {
276
                $obj = $this->getObjectRelationQuery($obj, $params, $sort, $limit, $relationName);
0 ignored issues
show
Bug introduced by
It seems like $sort can also be of type string; however, parameter $sort of SilverStripe\RestfulServ...etObjectRelationQuery() does only seem to accept integer|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

276
                $obj = $this->getObjectRelationQuery($obj, $params, /** @scrutinizer ignore-type */ $sort, $limit, $relationName);
Loading history...
277
                if (!$obj) {
278
                    return $this->notFound();
279
                }
280
281
                // TODO Avoid creating data formatter again for relation class (see above)
282
                $responseFormatter = $this->getResponseDataFormatter($obj->dataClass());
283
            }
284
        } else {
285
            // Format: /api/v1/<MyClass>
286
            $obj = $this->getObjectsQuery($className, $params, $sort, $limit);
0 ignored issues
show
Bug introduced by
It seems like $sort can also be of type string; however, parameter $sort of SilverStripe\RestfulServ...rver::getObjectsQuery() does only seem to accept integer|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

286
            $obj = $this->getObjectsQuery($className, $params, /** @scrutinizer ignore-type */ $sort, $limit);
Loading history...
287
        }
288
289
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
290
291
        $rawFields = $this->request->getVar('fields');
292
        $realFields = $responseFormatter->getRealFields($className, explode(',', $rawFields));
293
        $fields = $rawFields ? $realFields : null;
294
295
        if ($obj instanceof SS_List) {
296
            $objs = ArrayList::create($obj->toArray());
297
            foreach ($objs as $obj) {
298
                if (!$obj->canView($this->getMember())) {
299
                    $objs->remove($obj);
300
                }
301
            }
302
            $responseFormatter->setTotalSize($objs->count());
303
            return $responseFormatter->convertDataObjectSet($objs, $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

303
            return $responseFormatter->/** @scrutinizer ignore-call */ convertDataObjectSet($objs, $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...
304
        }
305
306
        if (!$obj) {
307
            $responseFormatter->setTotalSize(0);
308
            return $responseFormatter->convertDataObjectSet(new ArrayList(), $fields);
309
        }
310
311
        return $responseFormatter->convertDataObject($obj, $fields);
0 ignored issues
show
Bug introduced by
It seems like $obj can also be of type true; however, parameter $do of SilverStripe\RestfulServ...er::convertDataObject() does only seem to accept SilverStripe\ORM\DataObjectInterface, 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

311
        return $responseFormatter->convertDataObject(/** @scrutinizer ignore-type */ $obj, $fields);
Loading history...
Unused Code introduced by
The call to SilverStripe\RestfulServ...er::convertDataObject() 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

311
        return $responseFormatter->/** @scrutinizer ignore-call */ convertDataObject($obj, $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...
312
    }
313
314
    /**
315
     * Uses the default {@link SearchContext} specified through
316
     * {@link DataObject::getDefaultSearchContext()} to augument
317
     * an existing query object (mostly a component query from {@link DataObject})
318
     * with search clauses.
319
     *
320
     * @todo Allow specifying of different searchcontext getters on model-by-model basis
321
     *
322
     * @param string $className
323
     * @param array $params
324
     * @return SS_List
325
     */
326
    protected function getSearchQuery(
327
        $className,
328
        $params = null,
329
        $sort = null,
330
        $limit = null,
331
        $existingQuery = null
332
    ) {
333
        if (singleton($className)->hasMethod('getRestfulSearchContext')) {
334
            $searchContext = singleton($className)->{'getRestfulSearchContext'}();
335
        } else {
336
            $searchContext = singleton($className)->getDefaultSearchContext();
337
        }
338
        return $searchContext->getQuery($params, $sort, $limit, $existingQuery);
339
    }
340
341
    /**
342
     * Returns a dataformatter instance based on the request
343
     * extension or mimetype. Falls back to {@link self::$default_extension}.
344
     *
345
     * @param boolean $includeAcceptHeader Determines wether to inspect and prioritize any HTTP Accept headers
346
     * @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...
347
     * @return DataFormatter
348
     */
349
    protected function getDataFormatter($includeAcceptHeader = false, $className = null)
350
    {
351
        $extension = $this->request->getExtension();
352
        $contentTypeWithEncoding = $this->request->getHeader('Content-Type');
353
        preg_match('/([^;]*)/', $contentTypeWithEncoding, $contentTypeMatches);
354
        $contentType = $contentTypeMatches[0];
355
        $accept = $this->request->getHeader('Accept');
356
        $mimetypes = $this->request->getAcceptMimetypes();
357
        if (!$className) {
358
            $className = $this->unsanitiseClassName($this->request->param('ClassName'));
359
        }
360
361
        // get formatter
362
        if (!empty($extension)) {
363
            $formatter = DataFormatter::for_extension($extension);
364
        } elseif ($includeAcceptHeader && !empty($accept) && strpos($accept, '*/*') === false) {
365
            $formatter = DataFormatter::for_mimetypes($mimetypes);
366
            if (!$formatter) {
0 ignored issues
show
introduced by
$formatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
367
                $formatter = DataFormatter::for_extension($this->config()->default_extension);
368
            }
369
        } elseif (!empty($contentType)) {
370
            $formatter = DataFormatter::for_mimetype($contentType);
371
        } else {
372
            $formatter = DataFormatter::for_extension($this->config()->default_extension);
373
        }
374
375
        if (!$formatter) {
0 ignored issues
show
introduced by
$formatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
376
            return false;
377
        }
378
379
        // set custom fields
380
        if ($customAddFields = $this->request->getVar('add_fields')) {
381
            $customAddFields = $formatter->getRealFields($className, explode(',', $customAddFields));
382
            $formatter->setCustomAddFields($customAddFields);
383
        }
384
        if ($customFields = $this->request->getVar('fields')) {
385
            $customFields = $formatter->getRealFields($className, explode(',', $customFields));
386
            $formatter->setCustomFields($customFields);
387
        }
388
        $formatter->setCustomRelations($this->getAllowedRelations($className));
389
390
        $apiAccess = Config::inst()->get($className, 'api_access');
391
        if (is_array($apiAccess)) {
392
            $formatter->setCustomAddFields(
393
                array_intersect((array)$formatter->getCustomAddFields(), (array)$apiAccess['view'])
394
            );
395
            if ($formatter->getCustomFields()) {
396
                $formatter->setCustomFields(
397
                    array_intersect((array)$formatter->getCustomFields(), (array)$apiAccess['view'])
398
                );
399
            } else {
400
                $formatter->setCustomFields((array)$apiAccess['view']);
401
            }
402
            if ($formatter->getCustomRelations()) {
403
                $formatter->setCustomRelations(
404
                    array_intersect((array)$formatter->getCustomRelations(), (array)$apiAccess['view'])
405
                );
406
            } else {
407
                $formatter->setCustomRelations((array)$apiAccess['view']);
408
            }
409
        }
410
411
        // set relation depth
412
        $relationDepth = $this->request->getVar('relationdepth');
413
        if (is_numeric($relationDepth)) {
414
            $formatter->relationDepth = (int)$relationDepth;
415
        }
416
417
        return $formatter;
418
    }
419
420
    /**
421
     * @param string Classname of a DataObject
422
     * @return DataFormatter
423
     */
424
    protected function getRequestDataFormatter($className = null)
425
    {
426
        return $this->getDataFormatter(false, $className);
427
    }
428
429
    /**
430
     * @param string Classname of a DataObject
431
     * @return DataFormatter
432
     */
433
    protected function getResponseDataFormatter($className = null)
434
    {
435
        return $this->getDataFormatter(true, $className);
436
    }
437
438
    /**
439
     * Handler for object delete
440
     */
441
    protected function deleteHandler($className, $id)
442
    {
443
        $obj = DataObject::get_by_id($className, $id);
444
        if (!$obj) {
0 ignored issues
show
introduced by
$obj is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
445
            return $this->notFound();
446
        }
447
        if (!$obj->canDelete($this->getMember())) {
448
            return $this->permissionFailure();
449
        }
450
451
        $obj->delete();
452
453
        $this->getResponse()->setStatusCode(204); // No Content
454
        return true;
455
    }
456
457
    /**
458
     * Handler for object write
459
     */
460
    protected function putHandler($className, $id)
461
    {
462
        $obj = DataObject::get_by_id($className, $id);
463
        if (!$obj) {
0 ignored issues
show
introduced by
$obj is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
464
            return $this->notFound();
465
        }
466
467
        if (!$obj->canEdit($this->getMember())) {
468
            return $this->permissionFailure();
469
        }
470
471
        $reqFormatter = $this->getRequestDataFormatter($className);
472
        if (!$reqFormatter) {
0 ignored issues
show
introduced by
$reqFormatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
473
            return $this->unsupportedMediaType();
474
        }
475
476
        $responseFormatter = $this->getResponseDataFormatter($className);
477
        if (!$responseFormatter) {
0 ignored issues
show
introduced by
$responseFormatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
478
            return $this->unsupportedMediaType();
479
        }
480
481
        try {
482
            /** @var DataObject|string */
483
            $obj = $this->updateDataObject($obj, $reqFormatter);
484
        } catch (ValidationException $e) {
485
            return $this->validationFailure($responseFormatter, $e->getResult());
486
        }
487
488
        if (is_string($obj)) {
489
            return $obj;
490
        }
491
492
        $this->getResponse()->setStatusCode(200); // Success
493
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
494
495
        // Append the default extension for the output format to the Location header
496
        // or else we'll use the default (XML)
497
        $types = $responseFormatter->supportedExtensions();
498
        $type = '';
499
        if (count($types)) {
500
            $type = ".{$types[0]}";
501
        }
502
503
        $urlSafeClassName = $this->sanitiseClassName(get_class($obj));
504
        $apiBase = $this->config()->api_base;
505
        $objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
506
        $this->getResponse()->addHeader('Location', $objHref);
507
508
        return $responseFormatter->convertDataObject($obj);
509
    }
510
511
    /**
512
     * Handler for object append / method call.
513
     *
514
     * @todo Posting to an existing URL (without a relation)
515
     * current resolves in creatig a new element,
516
     * rather than a "Conflict" message.
517
     */
518
    protected function postHandler($className, $id, $relation)
519
    {
520
        if ($id) {
521
            if (!$relation) {
522
                $this->response->setStatusCode(409);
523
                return 'Conflict';
524
            }
525
526
            $obj = DataObject::get_by_id($className, $id);
527
            if (!$obj) {
0 ignored issues
show
introduced by
$obj is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
528
                return $this->notFound();
529
            }
530
531
            $reqFormatter = $this->getRequestDataFormatter($className);
532
            if (!$reqFormatter) {
0 ignored issues
show
introduced by
$reqFormatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
533
                return $this->unsupportedMediaType();
534
            }
535
536
            $relation = $reqFormatter->getRealFieldName($className, $relation);
537
538
            if (!$obj->hasMethod($relation)) {
539
                return $this->notFound();
540
            }
541
542
            if (!Config::inst()->get($className, 'allowed_actions') ||
543
                !in_array($relation, Config::inst()->get($className, 'allowed_actions'))) {
544
                return $this->permissionFailure();
545
            }
546
547
            $obj->$relation();
548
549
            $this->getResponse()->setStatusCode(204); // No Content
550
            return true;
551
        }
552
553
        if (!singleton($className)->canCreate($this->getMember())) {
554
            return $this->permissionFailure();
555
        }
556
        $obj = new $className();
557
558
        $reqFormatter = $this->getRequestDataFormatter($className);
559
        if (!$reqFormatter) {
0 ignored issues
show
introduced by
$reqFormatter is of type SilverStripe\RestfulServer\DataFormatter, thus it always evaluated to true.
Loading history...
560
            return $this->unsupportedMediaType();
561
        }
562
563
        $responseFormatter = $this->getResponseDataFormatter($className);
564
565
        try {
566
            /** @var DataObject|string $obj */
567
            $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

567
            $obj = $this->updateDataObject(/** @scrutinizer ignore-type */ $obj, $reqFormatter);
Loading history...
568
        } catch (ValidationException $e) {
569
            return $this->validationFailure($responseFormatter, $e->getResult());
570
        }
571
572
        if (is_string($obj)) {
573
            return $obj;
574
        }
575
576
        $this->getResponse()->setStatusCode(201); // Created
577
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
578
579
        // Append the default extension for the output format to the Location header
580
        // or else we'll use the default (XML)
581
        $types = $responseFormatter->supportedExtensions();
582
        $type = '';
583
        if (count($types)) {
584
            $type = ".{$types[0]}";
585
        }
586
587
        $urlSafeClassName = $this->sanitiseClassName(get_class($obj));
588
        $apiBase = $this->config()->api_base;
589
        $objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
590
        $this->getResponse()->addHeader('Location', $objHref);
591
592
        return $responseFormatter->convertDataObject($obj);
593
    }
594
595
    /**
596
     * Converts either the given HTTP Body into an array
597
     * (based on the DataFormatter instance), or returns
598
     * the POST variables.
599
     * Automatically filters out certain critical fields
600
     * that shouldn't be set by the client (e.g. ID).
601
     *
602
     * @param DataObject $obj
603
     * @param DataFormatter $formatter
604
     * @return DataObject|string The passed object, or "No Content" if incomplete input data is provided
605
     */
606
    protected function updateDataObject($obj, $formatter)
607
    {
608
        // if neither an http body nor POST data is present, return error
609
        $body = $this->request->getBody();
610
        if (!$body && !$this->request->postVars()) {
611
            $this->getResponse()->setStatusCode(204); // No Content
612
            return 'No Content';
613
        }
614
615
        if (!empty($body)) {
616
            $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...
617
        } else {
618
            // assume application/x-www-form-urlencoded which is automatically parsed by PHP
619
            $rawdata = $this->request->postVars();
620
        }
621
622
        $className = $this->unsanitiseClassName($this->request->param('ClassName'));
623
        // update any aliased field names
624
        $data = [];
625
        foreach ($rawdata as $key => $value) {
0 ignored issues
show
Bug introduced by
The expression $rawdata of type null is not traversable.
Loading history...
626
            $newkey = $formatter->getRealFieldName($className, $key);
627
            $data[$newkey] = $value;
628
        }
629
630
        // @todo Disallow editing of certain keys in database
631
        $data = array_diff_key($data, ['ID', 'Created']);
632
633
        $apiAccess = singleton($className)->config()->api_access;
634
        if (is_array($apiAccess) && isset($apiAccess['edit'])) {
635
            $data = array_intersect_key($data, array_combine($apiAccess['edit'], $apiAccess['edit']));
636
        }
637
638
        $obj->update($data);
639
        $obj->write();
640
641
        return $obj;
642
    }
643
644
    /**
645
     * Gets a single DataObject by ID,
646
     * through a request like /api/v1/<MyClass>/<MyID>
647
     *
648
     * @param string $className
649
     * @param int $id
650
     * @param array $params
651
     * @return DataList
652
     */
653
    protected function getObjectQuery($className, $id, $params)
654
    {
655
        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

655
        return DataList::create(/** @scrutinizer ignore-type */ $className)->byIDs([$id]);
Loading history...
656
    }
657
658
    /**
659
     * @param DataObject $obj
660
     * @param array $params
661
     * @param int|array $sort
662
     * @param int|array $limit
663
     * @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...
664
     */
665
    protected function getObjectsQuery($className, $params, $sort, $limit)
666
    {
667
        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...
668
    }
669
670
671
    /**
672
     * @param DataObject $obj
673
     * @param array $params
674
     * @param int|array $sort
675
     * @param int|array $limit
676
     * @param string $relationName
677
     * @return SQLQuery|boolean
678
     */
679
    protected function getObjectRelationQuery($obj, $params, $sort, $limit, $relationName)
680
    {
681
        // The relation method will return a DataList, that getSearchQuery subsequently manipulates
682
        if ($obj->hasMethod($relationName)) {
683
            // $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...
684
            // of which helps us get the classname in a consistent fashion.
685
            // So we must use a way that is reliable.
686
            if ($relationClass = DataObject::getSchema()->hasOneComponent(get_class($obj), $relationName)) {
687
                $joinField = $relationName . 'ID';
688
                // Again `byID` will return the wrong type for our purposes. So use `byIDs`
689
                $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

689
                $list = DataList::create(/** @scrutinizer ignore-type */ $relationClass)->byIDs([$obj->$joinField]);
Loading history...
690
            } else {
691
                $list = $obj->$relationName();
692
            }
693
694
            $apiAccess = Config::inst()->get($list->dataClass(), 'api_access');
695
696
697
            if (!$apiAccess) {
698
                return false;
699
            }
700
701
            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...
702
        }
703
    }
704
705
    /**
706
     * @return string
707
     */
708
    protected function permissionFailure()
709
    {
710
        // return a 401
711
        $this->getResponse()->setStatusCode(401);
712
        $this->getResponse()->addHeader('WWW-Authenticate', 'Basic realm="API Access"');
713
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
714
715
        $reponse = "You don't have access to this item through the API.";
716
        $this->extend(__FUNCTION__, $reponse);
717
718
        return $reponse;
719
    }
720
721
    /**
722
     * @return string
723
     */
724
    protected function notFound()
725
    {
726
        // return a 404
727
        $this->getResponse()->setStatusCode(404);
728
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
729
730
        $reponse = "That object wasn't found";
731
        $this->extend(__FUNCTION__, $reponse);
732
733
        return $reponse;
734
    }
735
736
    /**
737
     * @return string
738
     */
739
    protected function methodNotAllowed()
740
    {
741
        $this->getResponse()->setStatusCode(405);
742
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
743
744
        $reponse = "Method Not Allowed";
745
        $this->extend(__FUNCTION__, $reponse);
746
747
        return $reponse;
748
    }
749
750
    /**
751
     * @return string
752
     */
753
    protected function unsupportedMediaType()
754
    {
755
        $this->response->setStatusCode(415); // Unsupported Media Type
756
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
757
758
        $reponse = "Unsupported Media Type";
759
        $this->extend(__FUNCTION__, $reponse);
760
761
        return $reponse;
762
    }
763
764
    /**
765
     * @param ValidationResult $result
766
     * @return mixed
767
     */
768
    protected function validationFailure(DataFormatter $responseFormatter, ValidationResult $result)
769
    {
770
        $this->getResponse()->setStatusCode(400);
771
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
772
773
        $response = [
774
            'type' => ValidationException::class,
775
            'messages' => $result->getMessages(),
776
        ];
777
778
        $this->extend(__FUNCTION__, $response, $result);
779
780
        return $responseFormatter->convertArray($response);
781
    }
782
783
    /**
784
     * @param DataFormatter $responseFormatter
785
     * @param \Exception $e
786
     * @return string
787
     */
788
    protected function exceptionThrown(DataFormatter $responseFormatter, \Exception $e)
789
    {
790
        $this->getResponse()->setStatusCode(500);
791
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
792
793
        $response = [
794
            'type' => get_class($e),
795
            'message' => $e->getMessage(),
796
        ];
797
798
        $this->extend(__FUNCTION__, $response, $e);
799
800
        return $responseFormatter->convertArray($response);
801
    }
802
803
    /**
804
     * A function to authenticate a user
805
     *
806
     * @return Member|false the logged in member
807
     */
808
    protected function authenticate()
809
    {
810
        $authClass = $this->config()->authenticator;
811
        $member = $authClass::authenticate();
812
        Security::setCurrentUser($member);
813
        return $member;
814
    }
815
816
    /**
817
     * Return only relations which have $api_access enabled.
818
     * @todo Respect field level permissions once they are available in core
819
     *
820
     * @param string $class
821
     * @param Member $member
822
     * @return array
823
     */
824
    protected function getAllowedRelations($class, $member = null)
825
    {
826
        $allowedRelations = [];
827
        $obj = singleton($class);
828
        $relations = (array)$obj->hasOne() + (array)$obj->hasMany() + (array)$obj->manyMany();
829
        if ($relations) {
830
            foreach ($relations as $relName => $relClass) {
831
                $relClass = static::parseRelationClass($relClass);
832
833
                //remove dot notation from relation names
834
                $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

834
                $parts = explode('.', /** @scrutinizer ignore-type */ $relClass);
Loading history...
835
                $relClass = array_shift($parts);
836
                if (Config::inst()->get($relClass, 'api_access')) {
837
                    $allowedRelations[] = $relName;
838
                }
839
            }
840
        }
841
        return $allowedRelations;
842
    }
843
844
    /**
845
     * Get the current Member, if available
846
     *
847
     * @return Member|null
848
     */
849
    protected function getMember()
850
    {
851
        return Security::getCurrentUser();
852
    }
853
}
854