Completed
Push — master ( b59d95...c30b72 )
by Robbie
10s
created

RestfulServer::validationFailure()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 6
nc 1
nop 2
dl 0
loc 11
rs 9.4285
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
     * This handler acts as the switchboard for the controller.
127
     * Since no $Action url-param is set, all requests are sent here.
128
     */
129
    public function index(HTTPRequest $request)
130
    {
131
        $className = $this->unsanitiseClassName($request->param('ClassName'));
132
        $id = $request->param('ID') ?: null;
133
        $relation = $request->param('Relation') ?: null;
134
135
        // Check input formats
136
        if (!class_exists($className)) {
137
            return $this->notFound();
138
        }
139
        if ($id && !is_numeric($id)) {
140
            return $this->notFound();
141
        }
142
        if ($relation
143
            && !preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $relation)
144
        ) {
145
            return $this->notFound();
146
        }
147
148
        // if api access is disabled, don't proceed
149
        $apiAccess = Config::inst()->get($className, 'api_access');
150
        if (!$apiAccess) {
151
            return $this->permissionFailure();
152
        }
153
154
        // authenticate through HTTP BasicAuth
155
        $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...
156
157
        // handle different HTTP verbs
158
        if ($this->request->isGET() || $this->request->isHEAD()) {
159
            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

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

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

171
            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...
172
        }
173
174
        // if no HTTP verb matches, return error
175
        return $this->methodNotAllowed();
176
    }
177
178
    /**
179
     * Handler for object read.
180
     *
181
     * The data object will be returned in the following format:
182
     *
183
     * <ClassName>
184
     *   <FieldName>Value</FieldName>
185
     *   ...
186
     *   <HasOneRelName id="ForeignID" href="LinkToForeignRecordInAPI" />
187
     *   ...
188
     *   <HasManyRelName>
189
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
190
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
191
     *   </HasManyRelName>
192
     *   ...
193
     *   <ManyManyRelName>
194
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
195
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
196
     *   </ManyManyRelName>
197
     * </ClassName>
198
     *
199
     * Access is controlled by two variables:
200
     *
201
     *   - static $api_access must be set. This enables the API on a class by class basis
202
     *   - $obj->canView() must return true. This lets you implement record-level security
203
     *
204
     * @todo Access checking
205
     *
206
     * @param string $className
207
     * @param Int $id
208
     * @param string $relation
209
     * @return string The serialized representation of the requested object(s) - usually XML or JSON.
210
     */
211
    protected function getHandler($className, $id, $relationName)
212
    {
213
        $sort = '';
214
215
        if ($this->request->getVar('sort')) {
216
            $dir = $this->request->getVar('dir');
217
            $sort = array($this->request->getVar('sort') => ($dir ? $dir : 'ASC'));
218
        }
219
220
        $limit = array(
221
            'start' => $this->request->getVar('start'),
222
            'limit' => $this->request->getVar('limit')
223
        );
224
225
        $params = $this->request->getVars();
226
227
        $responseFormatter = $this->getResponseDataFormatter($className);
228
        if (!$responseFormatter) {
229
            return $this->unsupportedMediaType();
230
        }
231
232
        // $obj can be either a DataObject or a SS_List,
233
        // depending on the request
234
        if ($id) {
235
            // Format: /api/v1/<MyClass>/<ID>
236
            $obj = $this->getObjectQuery($className, $id, $params)->First();
237
            if (!$obj) {
238
                return $this->notFound();
239
            }
240
            if (!$obj->canView($this->getMember())) {
241
                return $this->permissionFailure();
242
            }
243
244
            // Format: /api/v1/<MyClass>/<ID>/<Relation>
245
            if ($relationName) {
246
                $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

246
                $obj = $this->getObjectRelationQuery($obj, $params, /** @scrutinizer ignore-type */ $sort, $limit, $relationName);
Loading history...
247
                if (!$obj) {
248
                    return $this->notFound();
249
                }
250
251
                // TODO Avoid creating data formatter again for relation class (see above)
252
                $responseFormatter = $this->getResponseDataFormatter($obj->dataClass());
253
            }
254
        } else {
255
            // Format: /api/v1/<MyClass>
256
            $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

256
            $obj = $this->getObjectsQuery($className, $params, /** @scrutinizer ignore-type */ $sort, $limit);
Loading history...
257
        }
258
259
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
260
261
        $rawFields = $this->request->getVar('fields');
262
        $fields = $rawFields ? explode(',', $rawFields) : null;
263
264
        if ($obj instanceof SS_List) {
265
            $objs = ArrayList::create($obj->toArray());
266
            foreach ($objs as $obj) {
267
                if (!$obj->canView($this->getMember())) {
268
                    $objs->remove($obj);
269
                }
270
            }
271
            $responseFormatter->setTotalSize($objs->count());
272
            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

272
            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...
273
        }
274
275
        if (!$obj) {
276
            $responseFormatter->setTotalSize(0);
277
            return $responseFormatter->convertDataObjectSet(new ArrayList(), $fields);
278
        }
279
280
        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

280
        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

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

527
            $obj = $this->updateDataObject(/** @scrutinizer ignore-type */ $obj, $reqFormatter);
Loading history...
528
        } catch (ValidationException $e) {
529
            return $this->validationFailure($responseFormatter, $e->getResult());
530
        }
531
532
        if (is_string($obj)) {
533
            return $obj;
534
        }
535
536
        $this->getResponse()->setStatusCode(201); // Created
537
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
538
539
        // Append the default extension for the output format to the Location header
540
        // or else we'll use the default (XML)
541
        $types = $responseFormatter->supportedExtensions();
542
        $type = '';
543
        if (count($types)) {
544
            $type = ".{$types[0]}";
545
        }
546
547
        $urlSafeClassName = $this->sanitiseClassName(get_class($obj));
548
        $apiBase = $this->config()->api_base;
549
        $objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
550
        $this->getResponse()->addHeader('Location', $objHref);
551
552
        return $responseFormatter->convertDataObject($obj);
553
    }
554
555
    /**
556
     * Converts either the given HTTP Body into an array
557
     * (based on the DataFormatter instance), or returns
558
     * the POST variables.
559
     * Automatically filters out certain critical fields
560
     * that shouldn't be set by the client (e.g. ID).
561
     *
562
     * @param DataObject $obj
563
     * @param DataFormatter $formatter
564
     * @return DataObject|string The passed object, or "No Content" if incomplete input data is provided
565
     */
566
    protected function updateDataObject($obj, $formatter)
567
    {
568
        // if neither an http body nor POST data is present, return error
569
        $body = $this->request->getBody();
570
        if (!$body && !$this->request->postVars()) {
571
            $this->getResponse()->setStatusCode(204); // No Content
572
            return 'No Content';
573
        }
574
575
        if (!empty($body)) {
576
            $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...
577
        } else {
578
            // assume application/x-www-form-urlencoded which is automatically parsed by PHP
579
            $data = $this->request->postVars();
580
        }
581
582
        // @todo Disallow editing of certain keys in database
583
        $data = array_diff_key($data, ['ID', 'Created']);
584
585
        $className = $this->unsanitiseClassName($this->request->param('ClassName'));
586
        $apiAccess = singleton($className)->config()->api_access;
587
        if (is_array($apiAccess) && isset($apiAccess['edit'])) {
588
            $data = array_intersect_key($data, array_combine($apiAccess['edit'], $apiAccess['edit']));
589
        }
590
591
        $obj->update($data);
592
        $obj->write();
593
594
        return $obj;
595
    }
596
597
    /**
598
     * Gets a single DataObject by ID,
599
     * through a request like /api/v1/<MyClass>/<MyID>
600
     *
601
     * @param string $className
602
     * @param int $id
603
     * @param array $params
604
     * @return DataList
605
     */
606
    protected function getObjectQuery($className, $id, $params)
607
    {
608
        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

608
        return DataList::create(/** @scrutinizer ignore-type */ $className)->byIDs([$id]);
Loading history...
609
    }
610
611
    /**
612
     * @param DataObject $obj
613
     * @param array $params
614
     * @param int|array $sort
615
     * @param int|array $limit
616
     * @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...
617
     */
618
    protected function getObjectsQuery($className, $params, $sort, $limit)
619
    {
620
        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...
621
    }
622
623
624
    /**
625
     * @param DataObject $obj
626
     * @param array $params
627
     * @param int|array $sort
628
     * @param int|array $limit
629
     * @param string $relationName
630
     * @return SQLQuery|boolean
631
     */
632
    protected function getObjectRelationQuery($obj, $params, $sort, $limit, $relationName)
633
    {
634
        // The relation method will return a DataList, that getSearchQuery subsequently manipulates
635
        if ($obj->hasMethod($relationName)) {
636
            // $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...
637
            // of which helps us get the classname in a consistent fashion.
638
            // So we must use a way that is reliable.
639
            if ($relationClass = DataObject::getSchema()->hasOneComponent(get_class($obj), $relationName)) {
640
                $joinField = $relationName . 'ID';
641
                // Again `byID` will return the wrong type for our purposes. So use `byIDs`
642
                $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

642
                $list = DataList::create(/** @scrutinizer ignore-type */ $relationClass)->byIDs([$obj->$joinField]);
Loading history...
643
            } else {
644
                $list = $obj->$relationName();
645
            }
646
647
            $apiAccess = Config::inst()->get($list->dataClass(), 'api_access');
648
649
650
            if (!$apiAccess) {
651
                return false;
652
            }
653
654
            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...
655
        }
656
    }
657
658
    /**
659
     * @return string
660
     */
661
    protected function permissionFailure()
662
    {
663
        // return a 401
664
        $this->getResponse()->setStatusCode(401);
665
        $this->getResponse()->addHeader('WWW-Authenticate', 'Basic realm="API Access"');
666
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
667
        return "You don't have access to this item through the API.";
668
    }
669
670
    /**
671
     * @return string
672
     */
673
    protected function notFound()
674
    {
675
        // return a 404
676
        $this->getResponse()->setStatusCode(404);
677
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
678
        return "That object wasn't found";
679
    }
680
681
    /**
682
     * @return string
683
     */
684
    protected function methodNotAllowed()
685
    {
686
        $this->getResponse()->setStatusCode(405);
687
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
688
        return "Method Not Allowed";
689
    }
690
691
    /**
692
     * @return string
693
     */
694
    protected function unsupportedMediaType()
695
    {
696
        $this->response->setStatusCode(415); // Unsupported Media Type
697
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
698
        return "Unsupported Media Type";
699
    }
700
701
    /**
702
     * @param ValidationResult $result
703
     * @return mixed
704
     */
705
    protected function validationFailure(DataFormatter $responseFormatter, ValidationResult $result)
706
    {
707
        $this->getResponse()->setStatusCode(400);
708
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
709
710
        $response = [
711
            'type' => ValidationException::class,
712
            'messages' => $result->getMessages(),
713
        ];
714
715
        return $responseFormatter->convertArray($response);
716
    }
717
718
    /**
719
     * A function to authenticate a user
720
     *
721
     * @return Member|false the logged in member
722
     */
723
    protected function authenticate()
724
    {
725
        $authClass = $this->config()->authenticator;
726
        $member = $authClass::authenticate();
727
        Security::setCurrentUser($member);
728
        return $member;
729
    }
730
731
    /**
732
     * Return only relations which have $api_access enabled.
733
     * @todo Respect field level permissions once they are available in core
734
     *
735
     * @param string $class
736
     * @param Member $member
737
     * @return array
738
     */
739
    protected function getAllowedRelations($class, $member = null)
740
    {
741
        $allowedRelations = [];
742
        $obj = singleton($class);
743
        $relations = (array)$obj->hasOne() + (array)$obj->hasMany() + (array)$obj->manyMany();
744
        if ($relations) {
745
            foreach ($relations as $relName => $relClass) {
746
                //remove dot notation from relation names
747
                $parts = explode('.', $relClass);
748
                $relClass = array_shift($parts);
749
                if (Config::inst()->get($relClass, 'api_access')) {
750
                    $allowedRelations[] = $relName;
751
                }
752
            }
753
        }
754
        return $allowedRelations;
755
    }
756
757
    /**
758
     * Get the current Member, if available
759
     *
760
     * @return Member|null
761
     */
762
    protected function getMember()
763
    {
764
        return Security::getCurrentUser();
765
    }
766
}
767