Passed
Push — master ( e0f4e5...0b734c )
by Guy
01:59
created

RestfulServer::resolveClassName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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

217
                return $this->getHandler($className, /** @scrutinizer ignore-type */ $id, $relation);
Loading history...
218
            }
219
220
            if ($this->request->isPOST()) {
221
                return $this->postHandler($className, $id, $relation);
222
            }
223
224
            if ($this->request->isPUT()) {
225
                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

225
                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...
226
            }
227
228
            if ($this->request->isDELETE()) {
229
                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

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

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

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

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

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

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

609
            $obj = $this->updateDataObject(/** @scrutinizer ignore-type */ $obj, $reqFormatter);
Loading history...
610
        } catch (ValidationException $e) {
611
            return $this->validationFailure($responseFormatter, $e->getResult());
612
        }
613
614
        if (is_string($obj)) {
615
            return $obj;
616
        }
617
618
        $this->getResponse()->setStatusCode(201); // Created
619
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
620
621
        // Append the default extension for the output format to the Location header
622
        // or else we'll use the default (XML)
623
        $types = $responseFormatter->supportedExtensions();
624
        $type = '';
625
        if (count($types)) {
626
            $type = ".{$types[0]}";
627
        }
628
629
        // Deviate slightly from the spec: Helps datamodel API access restrict
630
        // to consulting just canCreate(), not canView() as a result of the additional
631
        // "Location" header.
632
        if ($this->config()->get('location_header_on_create')) {
633
            $urlSafeClassName = $this->sanitiseClassName(get_class($obj));
634
            $apiBase = $this->config()->api_base;
635
            $objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
636
            $this->getResponse()->addHeader('Location', $objHref);
637
        }
638
639
        return $responseFormatter->convertDataObject($obj);
640
    }
641
642
    /**
643
     * Converts either the given HTTP Body into an array
644
     * (based on the DataFormatter instance), or returns
645
     * the POST variables.
646
     * Automatically filters out certain critical fields
647
     * that shouldn't be set by the client (e.g. ID).
648
     *
649
     * @param DataObject $obj
650
     * @param DataFormatter $formatter
651
     * @return DataObject|string The passed object, or "No Content" if incomplete input data is provided
652
     */
653
    protected function updateDataObject($obj, $formatter)
654
    {
655
        // if neither an http body nor POST data is present, return error
656
        $body = $this->request->getBody();
657
        if (!$body && !$this->request->postVars()) {
658
            $this->getResponse()->setStatusCode(204); // No Content
659
            return 'No Content';
660
        }
661
662
        if (!empty($body)) {
663
            $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...
664
        } else {
665
            // assume application/x-www-form-urlencoded which is automatically parsed by PHP
666
            $rawdata = $this->request->postVars();
667
        }
668
669
        $className = $obj->ClassName;
670
        // update any aliased field names
671
        $data = [];
672
        foreach ($rawdata as $key => $value) {
673
            $newkey = $formatter->getRealFieldName($className, $key);
674
            $data[$newkey] = $value;
675
        }
676
677
        // @todo Disallow editing of certain keys in database
678
        $data = array_diff_key($data, ['ID', 'Created']);
679
680
        $apiAccess = singleton($className)->config()->api_access;
681
        if (is_array($apiAccess) && isset($apiAccess['edit'])) {
682
            $data = array_intersect_key($data, array_combine($apiAccess['edit'], $apiAccess['edit']));
0 ignored issues
show
Bug introduced by
It seems like array_combine($apiAccess...'], $apiAccess['edit']) can also be of type false; however, parameter $array2 of array_intersect_key() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

682
            $data = array_intersect_key($data, /** @scrutinizer ignore-type */ array_combine($apiAccess['edit'], $apiAccess['edit']));
Loading history...
683
        }
684
685
        $obj->update($data);
686
        $obj->write();
687
688
        return $obj;
689
    }
690
691
    /**
692
     * Gets a single DataObject by ID,
693
     * through a request like /api/v1/<MyClass>/<MyID>
694
     *
695
     * @param string $className
696
     * @param int $id
697
     * @param array $params
698
     * @return DataList
699
     */
700
    protected function getObjectQuery($className, $id, $params)
701
    {
702
        return DataList::create($className)->byIDs([$id]);
703
    }
704
705
    /**
706
     * @param DataObject $obj
707
     * @param array $params
708
     * @param int|array $sort
709
     * @param int|array $limit
710
     * @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...
711
     */
712
    protected function getObjectsQuery($className, $params, $sort, $limit)
713
    {
714
        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...
715
    }
716
717
718
    /**
719
     * @param DataObject $obj
720
     * @param array $params
721
     * @param int|array $sort
722
     * @param int|array $limit
723
     * @param string $relationName
724
     * @return SQLQuery|boolean
725
     */
726
    protected function getObjectRelationQuery($obj, $params, $sort, $limit, $relationName)
727
    {
728
        // The relation method will return a DataList, that getSearchQuery subsequently manipulates
729
        if ($obj->hasMethod($relationName)) {
730
            // $this->HasOneName() will return a dataobject or null, neither
731
            // of which helps us get the classname in a consistent fashion.
732
            // So we must use a way that is reliable.
733
            if ($relationClass = DataObject::getSchema()->hasOneComponent(get_class($obj), $relationName)) {
734
                $joinField = $relationName . 'ID';
735
                // Again `byID` will return the wrong type for our purposes. So use `byIDs`
736
                $list = DataList::create($relationClass)->byIDs([$obj->$joinField]);
737
            } else {
738
                $list = $obj->$relationName();
739
            }
740
741
            $apiAccess = Config::inst()->get($list->dataClass(), 'api_access');
742
743
744
            if (!$apiAccess) {
745
                return false;
746
            }
747
748
            return $this->getSearchQuery($list->dataClass(), $params, $sort, $limit, $list);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->getSearchQ..., $sort, $limit, $list) returns the type SilverStripe\ORM\SS_List which is incompatible with the documented return type SilverStripe\RestfulServer\SQLQuery|boolean.
Loading history...
749
        }
750
    }
751
752
    /**
753
     * @return string
754
     */
755
    protected function permissionFailure()
756
    {
757
        // return a 401
758
        $this->getResponse()->setStatusCode(401);
759
        $this->getResponse()->addHeader('WWW-Authenticate', 'Basic realm="API Access"');
760
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
761
762
        $response = "You don't have access to this item through the API.";
763
        $this->extend(__FUNCTION__, $response);
764
765
        return $response;
766
    }
767
768
    /**
769
     * @return string
770
     */
771
    protected function notFound()
772
    {
773
        // return a 404
774
        $this->getResponse()->setStatusCode(404);
775
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
776
777
        $response = "That object wasn't found";
778
        $this->extend(__FUNCTION__, $response);
779
780
        return $response;
781
    }
782
783
    /**
784
     * @return string
785
     */
786
    protected function methodNotAllowed()
787
    {
788
        $this->getResponse()->setStatusCode(405);
789
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
790
791
        $response = "Method Not Allowed";
792
        $this->extend(__FUNCTION__, $response);
793
794
        return $response;
795
    }
796
797
    /**
798
     * @return string
799
     */
800
    protected function unsupportedMediaType()
801
    {
802
        $this->response->setStatusCode(415); // Unsupported Media Type
803
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
804
805
        $response = "Unsupported Media Type";
806
        $this->extend(__FUNCTION__, $response);
807
808
        return $response;
809
    }
810
811
    /**
812
     * @param ValidationResult $result
813
     * @return mixed
814
     */
815
    protected function validationFailure(DataFormatter $responseFormatter, ValidationResult $result)
816
    {
817
        $this->getResponse()->setStatusCode(400);
818
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
819
820
        $response = [
821
            'type' => ValidationException::class,
822
            'messages' => $result->getMessages(),
823
        ];
824
825
        $this->extend(__FUNCTION__, $response, $result);
826
827
        return $responseFormatter->convertArray($response);
828
    }
829
830
    /**
831
     * @param DataFormatter $responseFormatter
832
     * @param \Exception $e
833
     * @return string
834
     */
835
    protected function exceptionThrown(DataFormatter $responseFormatter, \Exception $e)
836
    {
837
        $this->getResponse()->setStatusCode(500);
838
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
839
840
        $response = [
841
            'type' => get_class($e),
842
            'message' => $e->getMessage(),
843
        ];
844
845
        $this->extend(__FUNCTION__, $response, $e);
846
847
        return $responseFormatter->convertArray($response);
848
    }
849
850
    /**
851
     * A function to authenticate a user
852
     *
853
     * @return Member|false the logged in member
854
     */
855
    protected function authenticate()
856
    {
857
        $authClass = $this->config()->authenticator;
858
        $member = $authClass::authenticate();
859
        Security::setCurrentUser($member);
860
        return $member;
861
    }
862
863
    /**
864
     * Return only relations which have $api_access enabled.
865
     * @todo Respect field level permissions once they are available in core
866
     *
867
     * @param string $class
868
     * @param Member $member
869
     * @return array
870
     */
871
    protected function getAllowedRelations($class, $member = null)
872
    {
873
        $allowedRelations = [];
874
        $obj = singleton($class);
875
        $relations = (array)$obj->hasOne() + (array)$obj->hasMany() + (array)$obj->manyMany();
876
        if ($relations) {
877
            foreach ($relations as $relName => $relClass) {
878
                $relClass = static::parseRelationClass($relClass);
879
880
                //remove dot notation from relation names
881
                $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

881
                $parts = explode('.', /** @scrutinizer ignore-type */ $relClass);
Loading history...
882
                $relClass = array_shift($parts);
883
                if (Config::inst()->get($relClass, 'api_access')) {
884
                    $allowedRelations[] = $relName;
885
                }
886
            }
887
        }
888
        return $allowedRelations;
889
    }
890
891
    /**
892
     * Get the current Member, if available
893
     *
894
     * @return Member|null
895
     */
896
    protected function getMember()
897
    {
898
        return Security::getCurrentUser();
899
    }
900
901
    /**
902
     * Checks if given param ClassName maps to an object in endpoint_aliases,
903
     * else simply return the unsanitised version of ClassName
904
     *
905
     * @param HTTPRequest $request
906
     * @return string
907
     */
908
    protected function resolveClassName(HTTPRequest $request): string
909
    {
910
        $className = $request->param('ClassName');
911
        $aliases = self::config()->get('endpoint_aliases');
912
913
        return $aliases[$className] ?? $this->unsanitiseClassName($className);
914
    }
915
}
916