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

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

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

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

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

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

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

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