Completed
Pull Request — master (#80)
by
unknown
03:44
created

RestfulServer::getRequestDataFormatter()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
eloc 1
nc 1
nop 1
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
        $className = $this->unsanitiseClassName($this->findClassNameEndpoint($request->param('ClassName')));
190
        $id = $request->param('ID') ?: null;
191
        $relation = $request->param('Relation') ?: null;
192
193
        // Check input formats
194
        if (!class_exists($className)) {
195
            return $this->notFound();
196
        }
197
        if ($id && !is_numeric($id)) {
198
            return $this->notFound();
199
        }
200
        if ($relation
201
            && !preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $relation)
202
        ) {
203
            return $this->notFound();
204
        }
205
206
        // if api access is disabled, don't proceed
207
        $apiAccess = Config::inst()->get($className, 'api_access');
208
        if (!$apiAccess) {
209
            return $this->permissionFailure();
210
        }
211
212
        // authenticate through HTTP BasicAuth
213
        $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...
214
215
        try {
216
            // handle different HTTP verbs
217
            if ($this->request->isGET() || $this->request->isHEAD()) {
218
                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

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

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

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

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

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

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

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