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

RestfulServer::getEndpointAlias()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 1
eloc 2
c 1
b 1
f 0
nc 1
nop 1
dl 0
loc 5
rs 10
1
<?php
2
3
namespace SilverStripe\RestfulServer;
4
5
use 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, instead of using fully namespaced classnames, as the module does by default
78
     * The fully namespaced classnames can also still be used though
79
     * 
80
     * Example:
81
     * 
82
     * ['mydataobject' => MyDataObject::class]
83
     *
84
     * @config array
85
     */
86
    private static $endpoint_aliases = [];
87
88
    /**
89
     * Whether or not to send an additional "Location" header for POST requests
90
     * to satisfy HTTP 1.1: https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
91
     *
92
     * Note: With this enabled (the default), no POST request for resource creation
93
     * will return an HTTP 201. Because of the addition of the "Location" header,
94
     * all responses become a straight HTTP 200.
95
     *
96
     * @config
97
     * @var boolean
98
     */
99
    private static $location_header_on_create = true;
100
101
    /**
102
     * If no extension is given, resolve the request to this mimetype.
103
     *
104
     * @var string
105
     */
106
    protected static $default_mimetype = "text/xml";
107
108
    /**
109
     * @uses authenticate()
110
     * @var Member
111
     */
112
    protected $member;
113
114
    private static $allowed_actions = array(
115
        'index',
116
        'notFound'
117
    );
118
119
    public function init()
120
    {
121
        /* This sets up SiteTree the same as when viewing a page through the frontend. Versioned defaults
122
         * to Stage, and then when viewing the front-end Versioned::choose_site_stage changes it to Live.
123
         * TODO: In 3.2 we should make the default Live, then change to Stage in the admin area (with a nicer API)
124
         */
125
        if (class_exists(SiteTree::class)) {
126
            singleton(SiteTree::class)->extend('modelascontrollerInit', $this);
127
        }
128
        parent::init();
129
    }
130
131
    /**
132
     * Backslashes in fully qualified class names (e.g. NameSpaced\ClassName)
133
     * kills both requests (i.e. URIs) and XML (invalid character in a tag name)
134
     * So we'll replace them with a hyphen (-), as it's also unambiguious
135
     * in both cases (invalid in a php class name, and safe in an xml tag name)
136
     *
137
     * @param string $classname
138
     * @return string 'escaped' class name
139
     */
140
    protected function sanitiseClassName($className)
141
    {
142
        return str_replace('\\', '-', $className);
143
    }
144
145
    /**
146
     * Convert hyphen escaped class names back into fully qualified
147
     * PHP safe variant.
148
     *
149
     * @param string $classname
150
     * @return string syntactically valid classname
151
     */
152
    protected function unsanitiseClassName($className)
153
    {
154
        return str_replace('-', '\\', $className);
155
    }
156
157
    /**
158
     * Parse many many relation class (works with through array syntax)
159
     *
160
     * @param string|array $class
161
     * @return string|array
162
     */
163
    public static function parseRelationClass($class)
164
    {
165
        // detect many many through syntax
166
        if (is_array($class)
167
            && array_key_exists('through', $class)
168
            && array_key_exists('to', $class)
169
        ) {
170
            $toRelation = $class['to'];
171
172
            $hasOne = Config::inst()->get($class['through'], 'has_one');
173
            if (empty($hasOne) || !is_array($hasOne) || !array_key_exists($toRelation, $hasOne)) {
174
                return $class;
175
            }
176
177
            return $hasOne[$toRelation];
178
        }
179
180
        return $class;
181
    }
182
183
    /**
184
     * This handler acts as the switchboard for the controller.
185
     * Since no $Action url-param is set, all requests are sent here.
186
     */
187
    public function index(HTTPRequest $request)
188
    {
189
        $endpoint = $request->param('ClassName');
190
        $className = $this->unsanitiseClassName($endpoint);
191
        $id = $request->param('ID') ?: null;
192
        $relation = $request->param('Relation') ?: null;
193
194
        if ($alias = $this->getEndpointAlias($endpoint)) {
195
            $className = $alias;
196
        }
197
198
        // Check input formats
199
        if (!class_exists($className)) {
200
            return $this->notFound();
201
        }
202
        if ($id && !is_numeric($id)) {
203
            return $this->notFound();
204
        }
205
        if ($relation
206
            && !preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $relation)
207
        ) {
208
            return $this->notFound();
209
        }
210
211
        // if api access is disabled, don't proceed
212
        $apiAccess = Config::inst()->get($className, 'api_access');
213
        if (!$apiAccess) {
214
            return $this->permissionFailure();
215
        }
216
217
        // authenticate through HTTP BasicAuth
218
        $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...
219
220
        try {
221
            // handle different HTTP verbs
222
            if ($this->request->isGET() || $this->request->isHEAD()) {
223
                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

223
                return $this->getHandler($className, /** @scrutinizer ignore-type */ $id, $relation);
Loading history...
224
            }
225
226
            if ($this->request->isPOST()) {
227
                return $this->postHandler($className, $id, $relation);
228
            }
229
230
            if ($this->request->isPUT()) {
231
                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

231
                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...
232
            }
233
234
            if ($this->request->isDELETE()) {
235
                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

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

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

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

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

887
                $parts = explode('.', /** @scrutinizer ignore-type */ $relClass);
Loading history...
888
                $relClass = array_shift($parts);
889
                if (Config::inst()->get($relClass, 'api_access')) {
890
                    $allowedRelations[] = $relName;
891
                }
892
            }
893
        }
894
        return $allowedRelations;
895
    }
896
897
    /**
898
     * Get the current Member, if available
899
     *
900
     * @return Member|null
901
     */
902
    protected function getMember()
903
    {
904
        return Security::getCurrentUser();
905
    }
906
907
    /**
908
     * @param $endpoint
909
     * @return null | string
910
     */
911
    protected function getEndpointAlias($endpoint)
912
    {
913
        $aliases = self::config()->get('endpoint_aliases');
914
915
        return $aliases[$endpoint] ?? null;
916
    }
917
}
918