Issues (97)

src/RestfulServer.php (2 issues)

1
<?php
2
3
namespace SilverStripe\RestfulServer;
4
5
use SilverStripe\CMS\Model\SiteTree;
0 ignored issues
show
The type SilverStripe\CMS\Model\SiteTree was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
6
use SilverStripe\Control\Controller;
7
use SilverStripe\Control\Director;
8
use SilverStripe\Control\HTTPRequest;
9
use SilverStripe\Core\Config\Config;
10
use SilverStripe\Core\Injector\Injector;
11
use SilverStripe\ORM\ArrayList;
12
use SilverStripe\ORM\DataList;
13
use SilverStripe\ORM\DataObject;
14
use SilverStripe\ORM\SS_List;
15
use SilverStripe\ORM\ValidationException;
16
use SilverStripe\ORM\ValidationResult;
17
use SilverStripe\Security\Member;
18
use SilverStripe\Security\Security;
19
20
/**
21
 * Generic RESTful server, which handles webservice access to arbitrary DataObjects.
22
 * Relies on serialization/deserialization into different formats provided
23
 * by the DataFormatter APIs in core.
24
 *
25
 * @todo Implement PUT/POST/DELETE for relations
26
 * @todo Access-Control for relations (you might be allowed to view Members and Groups,
27
 *       but not their relation with each other)
28
 * @todo Make SearchContext specification customizeable for each class
29
 * @todo Allow for range-searches (e.g. on Created column)
30
 * @todo Filter relation listings by $api_access and canView() permissions
31
 * @todo Exclude relations when "fields" are specified through URL (they should be explicitly
32
 *       requested in this case)
33
 * @todo Custom filters per DataObject subclass, e.g. to disallow showing unpublished pages in
34
 * SiteTree/Versioned/Hierarchy
35
 * @todo URL parameter namespacing for search-fields, limit, fields, add_fields
36
 *       (might all be valid dataobject properties)
37
 *       e.g. you wouldn't be able to search for a "limit" property on your subclass as
38
 *       its overlayed with the search logic
39
 * @todo i18n integration (e.g. Page/1.xml?lang=de_DE)
40
 * @todo Access to extendable methods/relations like SiteTree/1/Versions or SiteTree/1/Version/22
41
 * @todo Respect $api_access array notation in search contexts
42
 */
43
class RestfulServer extends Controller
44
{
45
    /**
46
     * @config
47
     * @var array
48
     */
49
    private static $url_handlers = array(
50
        '$ClassName!/$ID/$Relation' => 'handleAction',
51
        '' => 'notFound'
52
    );
53
54
    /**
55
     * @config
56
     * @var string root of the api route, MUST have a trailing slash
57
     */
58
    private static $api_base = "api/v1/";
59
60
    /**
61
     * @config
62
     * @var string Class name for an authenticator to use on API access
63
     */
64
    private static $authenticator = BasicRestfulAuthenticator::class;
65
66
    /**
67
     * If no extension is given in the request, resolve to this extension
68
     * (and subsequently the {@link self::$default_mimetype}.
69
     *
70
     * @config
71
     * @var string
72
     */
73
    private static $default_extension = "xml";
74
75
    /**
76
     * Custom endpoints that map to a specific class.
77
     * This is done to make the API have fixed endpoints,
78
     * instead of using fully namespaced classnames, as the module does by default
79
     * The fully namespaced classnames can also still be used though
80
     * Example:
81
     * ['mydataobject' => MyDataObject::class]
82
     *
83
     * @config array
84
     */
85
    private static $endpoint_aliases = [];
86
87
    /**
88
     * Whether or not to send an additional "Location" header for POST requests
89
     * to satisfy HTTP 1.1: https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
90
     *
91
     * Note: With this enabled (the default), no POST request for resource creation
92
     * will return an HTTP 201. Because of the addition of the "Location" header,
93
     * all responses become a straight HTTP 200.
94
     *
95
     * @config
96
     * @var boolean
97
     */
98
    private static $location_header_on_create = true;
99
100
    /**
101
     * If no extension is given, resolve the request to this mimetype.
102
     *
103
     * @var string
104
     */
105
    protected static $default_mimetype = "text/xml";
106
107
    /**
108
     * @uses authenticate()
109
     * @var Member
110
     */
111
    protected $member;
112
113
    private static $allowed_actions = array(
114
        'index',
115
        'notFound'
116
    );
117
118
    public function init()
119
    {
120
        /* This sets up SiteTree the same as when viewing a page through the frontend. Versioned defaults
121
         * to Stage, and then when viewing the front-end Versioned::choose_site_stage changes it to Live.
122
         * TODO: In 3.2 we should make the default Live, then change to Stage in the admin area (with a nicer API)
123
         */
124
        if (class_exists(SiteTree::class)) {
125
            singleton(SiteTree::class)->extend('modelascontrollerInit', $this);
126
        }
127
        parent::init();
128
    }
129
130
    /**
131
     * Backslashes in fully qualified class names (e.g. NameSpaced\ClassName)
132
     * kills both requests (i.e. URIs) and XML (invalid character in a tag name)
133
     * So we'll replace them with a hyphen (-), as it's also unambiguious
134
     * in both cases (invalid in a php class name, and safe in an xml tag name)
135
     *
136
     * @param string $classname
137
     * @return string 'escaped' class name
138
     */
139
    protected function sanitiseClassName($className)
140
    {
141
        return str_replace('\\', '-', $className);
142
    }
143
144
    /**
145
     * Convert hyphen escaped class names back into fully qualified
146
     * PHP safe variant.
147
     *
148
     * @param string $classname
149
     * @return string syntactically valid classname
150
     */
151
    protected function unsanitiseClassName($className)
152
    {
153
        return str_replace('-', '\\', $className);
154
    }
155
156
    /**
157
     * Parse many many relation class (works with through array syntax)
158
     *
159
     * @param string|array $class
160
     * @return string|array
161
     */
162
    public static function parseRelationClass($class)
163
    {
164
        // detect many many through syntax
165
        if (is_array($class)
166
            && array_key_exists('through', $class)
167
            && array_key_exists('to', $class)
168
        ) {
169
            $toRelation = $class['to'];
170
171
            $hasOne = Config::inst()->get($class['through'], 'has_one');
172
            if (empty($hasOne) || !is_array($hasOne) || !array_key_exists($toRelation, $hasOne)) {
173
                return $class;
174
            }
175
176
            return $hasOne[$toRelation];
177
        }
178
179
        return $class;
180
    }
181
182
    /**
183
     * This handler acts as the switchboard for the controller.
184
     * Since no $Action url-param is set, all requests are sent here.
185
     */
186
    public function index(HTTPRequest $request)
187
    {
188
        $className = $this->resolveClassName($request);
189
        $id = $request->param('ID') ?: null;
190
        $relation = $request->param('Relation') ?: null;
191
192
        // Check input formats
193
        if (!class_exists($className)) {
194
            return $this->notFound();
195
        }
196
        if ($id && !is_numeric($id)) {
197
            return $this->notFound();
198
        }
199
        if ($relation
200
            && !preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $relation)
201
        ) {
202
            return $this->notFound();
203
        }
204
205
        // if api access is disabled, don't proceed
206
        $apiAccess = Config::inst()->get($className, 'api_access');
207
        if (!$apiAccess) {
208
            return $this->permissionFailure();
209
        }
210
211
        // authenticate through HTTP BasicAuth
212
        $this->member = $this->authenticate();
213
214
        try {
215
            // handle different HTTP verbs
216
            if ($this->request->isGET() || $this->request->isHEAD()) {
217
                return $this->getHandler($className, $id, $relation);
218
            }
219
220
            if ($this->request->isPOST()) {
221
                return $this->postHandler($className, $id, $relation);
222
            }
223
224
            if ($this->request->isPUT()) {
225
                return $this->putHandler($className, $id, $relation);
226
            }
227
228
            if ($this->request->isDELETE()) {
229
                return $this->deleteHandler($className, $id, $relation);
230
            }
231
        } catch (\Exception $e) {
232
            return $this->exceptionThrown($this->getRequestDataFormatter($className), $e);
233
        }
234
235
        // if no HTTP verb matches, return error
236
        return $this->methodNotAllowed();
237
    }
238
239
    /**
240
     * Handler for object read.
241
     *
242
     * The data object will be returned in the following format:
243
     *
244
     * <ClassName>
245
     *   <FieldName>Value</FieldName>
246
     *   ...
247
     *   <HasOneRelName id="ForeignID" href="LinkToForeignRecordInAPI" />
248
     *   ...
249
     *   <HasManyRelName>
250
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
251
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
252
     *   </HasManyRelName>
253
     *   ...
254
     *   <ManyManyRelName>
255
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
256
     *     <ForeignClass id="ForeignID" href="LinkToForeignRecordInAPI" />
257
     *   </ManyManyRelName>
258
     * </ClassName>
259
     *
260
     * Access is controlled by two variables:
261
     *
262
     *   - static $api_access must be set. This enables the API on a class by class basis
263
     *   - $obj->canView() must return true. This lets you implement record-level security
264
     *
265
     * @todo Access checking
266
     *
267
     * @param string $className
268
     * @param int $id
269
     * @param string $relation
270
     * @return string The serialized representation of the requested object(s) - usually XML or JSON.
271
     */
272
    protected function getHandler($className, $id, $relationName)
273
    {
274
        $sort = ['ID' => 'ASC'];
275
276
        if ($sortQuery = $this->request->getVar('sort')) {
277
            /** @var DataObject $singleton */
278
            $singleton = singleton($className);
279
            // Only apply a sort filter if it is a valid field on the DataObject
280
            if ($singleton && $singleton->hasDatabaseField($sortQuery)) {
281
                $sort = [
282
                    $sortQuery => $this->request->getVar('dir') === 'DESC' ? 'DESC' : 'ASC',
283
                ];
284
            }
285
        }
286
287
        $limit = [
288
            'start' => (int) $this->request->getVar('start'),
289
            'limit' => (int) $this->request->getVar('limit'),
290
        ];
291
292
        $params = $this->request->getVars();
293
294
        $responseFormatter = $this->getResponseDataFormatter($className);
295
        if (!$responseFormatter) {
296
            return $this->unsupportedMediaType();
297
        }
298
299
        // $obj can be either a DataObject or a SS_List,
300
        // depending on the request
301
        if ($id) {
302
            // Format: /api/v1/<MyClass>/<ID>
303
            $obj = $this->getObjectQuery($className, $id, $params)->First();
304
            if (!$obj) {
305
                return $this->notFound();
306
            }
307
            if (!$obj->canView($this->getMember())) {
308
                return $this->permissionFailure();
309
            }
310
311
            // Format: /api/v1/<MyClass>/<ID>/<Relation>
312
            if ($relationName) {
313
                $obj = $this->getObjectRelationQuery($obj, $params, $sort, $limit, $relationName);
314
                if (!$obj) {
315
                    return $this->notFound();
316
                }
317
318
                // TODO Avoid creating data formatter again for relation class (see above)
319
                $responseFormatter = $this->getResponseDataFormatter($obj->dataClass());
320
            }
321
        } else {
322
            // Format: /api/v1/<MyClass>
323
            $obj = $this->getObjectsQuery($className, $params, $sort, $limit);
324
        }
325
326
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
327
328
        $rawFields = $this->request->getVar('fields');
329
        $realFields = $responseFormatter->getRealFields($className, explode(',', $rawFields));
330
        $fields = $rawFields ? $realFields : null;
331
332
        if ($obj instanceof SS_List) {
333
            $objs = ArrayList::create($obj->toArray());
334
            foreach ($objs as $obj) {
335
                if (!$obj->canView($this->getMember())) {
336
                    $objs->remove($obj);
337
                }
338
            }
339
            $responseFormatter->setTotalSize($objs->count());
340
            $this->extend('updateRestfulGetHandler', $objs, $responseFormatter);
341
342
            return $responseFormatter->convertDataObjectSet($objs, $fields);
343
        }
344
345
        if (!$obj) {
346
            $responseFormatter->setTotalSize(0);
347
            return $responseFormatter->convertDataObjectSet(new ArrayList(), $fields);
348
        }
349
350
        $this->extend('updateRestfulGetHandler', $obj, $responseFormatter);
351
352
        return $responseFormatter->convertDataObject($obj, $fields);
353
    }
354
355
    /**
356
     * Uses the default {@link SearchContext} specified through
357
     * {@link DataObject::getDefaultSearchContext()} to augument
358
     * an existing query object (mostly a component query from {@link DataObject})
359
     * with search clauses.
360
     *
361
     * @todo Allow specifying of different searchcontext getters on model-by-model basis
362
     *
363
     * @param string $className
364
     * @param array $params
365
     * @return SS_List
366
     */
367
    protected function getSearchQuery(
368
        $className,
369
        $params = null,
370
        $sort = null,
371
        $limit = null,
372
        $existingQuery = null
373
    ) {
374
        if (singleton($className)->hasMethod('getRestfulSearchContext')) {
375
            $searchContext = singleton($className)->{'getRestfulSearchContext'}();
376
        } else {
377
            $searchContext = singleton($className)->getDefaultSearchContext();
378
        }
379
        return $searchContext->getQuery($params, $sort, $limit, $existingQuery);
380
    }
381
382
    /**
383
     * Returns a dataformatter instance based on the request
384
     * extension or mimetype. Falls back to {@link self::$default_extension}.
385
     *
386
     * @param boolean $includeAcceptHeader Determines wether to inspect and prioritize any HTTP Accept headers
387
     * @param string Classname of a DataObject
388
     * @return DataFormatter
389
     */
390
    protected function getDataFormatter($includeAcceptHeader = false, $className = null)
391
    {
392
        $extension = $this->request->getExtension();
393
        $contentTypeWithEncoding = $this->request->getHeader('Content-Type');
394
        preg_match('/([^;]*)/', $contentTypeWithEncoding, $contentTypeMatches);
395
        $contentType = $contentTypeMatches[0];
396
        $accept = $this->request->getHeader('Accept');
397
        $mimetypes = $this->request->getAcceptMimetypes();
398
        if (!$className) {
399
            $className = $this->resolveClassName($this->request);
400
        }
401
402
        // get formatter
403
        if (!empty($extension)) {
404
            $formatter = DataFormatter::for_extension($extension);
405
        } elseif ($includeAcceptHeader && !empty($accept) && strpos($accept, '*/*') === false) {
406
            $formatter = DataFormatter::for_mimetypes($mimetypes);
407
            if (!$formatter) {
408
                $formatter = DataFormatter::for_extension($this->config()->default_extension);
409
            }
410
        } elseif (!empty($contentType)) {
411
            $formatter = DataFormatter::for_mimetype($contentType);
412
        } else {
413
            $formatter = DataFormatter::for_extension($this->config()->default_extension);
414
        }
415
416
        if (!$formatter) {
417
            return false;
418
        }
419
420
        // set custom fields
421
        if ($customAddFields = $this->request->getVar('add_fields')) {
422
            $customAddFields = $formatter->getRealFields($className, explode(',', $customAddFields));
423
            $formatter->setCustomAddFields($customAddFields);
424
        }
425
        if ($customFields = $this->request->getVar('fields')) {
426
            $customFields = $formatter->getRealFields($className, explode(',', $customFields));
427
            $formatter->setCustomFields($customFields);
428
        }
429
        $formatter->setCustomRelations($this->getAllowedRelations($className));
430
431
        $apiAccess = Config::inst()->get($className, 'api_access');
432
        if (is_array($apiAccess)) {
433
            $formatter->setCustomAddFields(
434
                array_intersect((array)$formatter->getCustomAddFields(), (array)$apiAccess['view'])
435
            );
436
            if ($formatter->getCustomFields()) {
437
                $formatter->setCustomFields(
438
                    array_intersect((array)$formatter->getCustomFields(), (array)$apiAccess['view'])
439
                );
440
            } else {
441
                $formatter->setCustomFields((array)$apiAccess['view']);
442
            }
443
            if ($formatter->getCustomRelations()) {
444
                $formatter->setCustomRelations(
445
                    array_intersect((array)$formatter->getCustomRelations(), (array)$apiAccess['view'])
446
                );
447
            } else {
448
                $formatter->setCustomRelations((array)$apiAccess['view']);
449
            }
450
        }
451
452
        // set relation depth
453
        $relationDepth = $this->request->getVar('relationdepth');
454
        if (is_numeric($relationDepth)) {
455
            $formatter->relationDepth = (int)$relationDepth;
456
        }
457
458
        return $formatter;
459
    }
460
461
    /**
462
     * @param string Classname of a DataObject
463
     * @return DataFormatter
464
     */
465
    protected function getRequestDataFormatter($className = null)
466
    {
467
        return $this->getDataFormatter(false, $className);
468
    }
469
470
    /**
471
     * @param string Classname of a DataObject
472
     * @return DataFormatter
473
     */
474
    protected function getResponseDataFormatter($className = null)
475
    {
476
        return $this->getDataFormatter(true, $className);
477
    }
478
479
    /**
480
     * Handler for object delete
481
     */
482
    protected function deleteHandler($className, $id)
483
    {
484
        $obj = DataObject::get_by_id($className, $id);
485
        if (!$obj) {
486
            return $this->notFound();
487
        }
488
        if (!$obj->canDelete($this->getMember())) {
489
            return $this->permissionFailure();
490
        }
491
492
        $obj->delete();
493
494
        $this->getResponse()->setStatusCode(204); // No Content
495
        return true;
496
    }
497
498
    /**
499
     * Handler for object write
500
     */
501
    protected function putHandler($className, $id)
502
    {
503
        $obj = DataObject::get_by_id($className, $id);
504
        if (!$obj) {
505
            return $this->notFound();
506
        }
507
508
        if (!$obj->canEdit($this->getMember())) {
509
            return $this->permissionFailure();
510
        }
511
512
        $reqFormatter = $this->getRequestDataFormatter($className);
513
        if (!$reqFormatter) {
514
            return $this->unsupportedMediaType();
515
        }
516
517
        $responseFormatter = $this->getResponseDataFormatter($className);
518
        if (!$responseFormatter) {
519
            return $this->unsupportedMediaType();
520
        }
521
522
        try {
523
            /** @var DataObject|string */
524
            $obj = $this->updateDataObject($obj, $reqFormatter);
525
        } catch (ValidationException $e) {
526
            return $this->validationFailure($responseFormatter, $e->getResult());
527
        }
528
529
        if (is_string($obj)) {
530
            return $obj;
531
        }
532
533
        $this->getResponse()->setStatusCode(202); // Accepted
534
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
535
536
        // Append the default extension for the output format to the Location header
537
        // or else we'll use the default (XML)
538
        $types = $responseFormatter->supportedExtensions();
539
        $type = '';
540
        if (count($types)) {
541
            $type = ".{$types[0]}";
542
        }
543
544
        $urlSafeClassName = $this->sanitiseClassName(get_class($obj));
545
        $apiBase = $this->config()->api_base;
546
        $objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
547
        $this->getResponse()->addHeader('Location', $objHref);
548
549
        return $responseFormatter->convertDataObject($obj);
550
    }
551
552
    /**
553
     * Handler for object append / method call.
554
     *
555
     * @todo Posting to an existing URL (without a relation)
556
     * current resolves in creatig a new element,
557
     * rather than a "Conflict" message.
558
     */
559
    protected function postHandler($className, $id, $relation)
560
    {
561
        if ($id) {
562
            if (!$relation) {
563
                $this->response->setStatusCode(409);
564
                return 'Conflict';
565
            }
566
567
            $obj = DataObject::get_by_id($className, $id);
568
            if (!$obj) {
569
                return $this->notFound();
570
            }
571
572
            $reqFormatter = $this->getRequestDataFormatter($className);
573
            if (!$reqFormatter) {
574
                return $this->unsupportedMediaType();
575
            }
576
577
            $relation = $reqFormatter->getRealFieldName($className, $relation);
578
579
            if (!$obj->hasMethod($relation)) {
580
                return $this->notFound();
581
            }
582
583
            if (!Config::inst()->get($className, 'allowed_actions') ||
584
                !in_array($relation, Config::inst()->get($className, 'allowed_actions'))) {
585
                return $this->permissionFailure();
586
            }
587
588
            $obj->$relation();
589
590
            $this->getResponse()->setStatusCode(204); // No Content
591
            return true;
592
        }
593
594
        if (!singleton($className)->canCreate($this->getMember())) {
595
            return $this->permissionFailure();
596
        }
597
598
        $obj = Injector::inst()->create($className);
599
600
        $reqFormatter = $this->getRequestDataFormatter($className);
601
        if (!$reqFormatter) {
602
            return $this->unsupportedMediaType();
603
        }
604
605
        $responseFormatter = $this->getResponseDataFormatter($className);
606
607
        try {
608
            /** @var DataObject|string $obj */
609
            $obj = $this->updateDataObject($obj, $reqFormatter);
610
        } catch (ValidationException $e) {
611
            return $this->validationFailure($responseFormatter, $e->getResult());
612
        }
613
614
        if (is_string($obj)) {
615
            return $obj;
616
        }
617
618
        $this->getResponse()->setStatusCode(201); // Created
619
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
620
621
        // Append the default extension for the output format to the Location header
622
        // or else we'll use the default (XML)
623
        $types = $responseFormatter->supportedExtensions();
624
        $type = '';
625
        if (count($types)) {
626
            $type = ".{$types[0]}";
627
        }
628
629
        // Deviate slightly from the spec: Helps datamodel API access restrict
630
        // to consulting just canCreate(), not canView() as a result of the additional
631
        // "Location" header.
632
        if ($this->config()->get('location_header_on_create')) {
633
            $urlSafeClassName = $this->sanitiseClassName(get_class($obj));
634
            $apiBase = $this->config()->api_base;
635
            $objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
636
            $this->getResponse()->addHeader('Location', $objHref);
637
        }
638
639
        return $responseFormatter->convertDataObject($obj);
640
    }
641
642
    /**
643
     * Converts either the given HTTP Body into an array
644
     * (based on the DataFormatter instance), or returns
645
     * the POST variables.
646
     * Automatically filters out certain critical fields
647
     * that shouldn't be set by the client (e.g. ID).
648
     *
649
     * @param DataObject $obj
650
     * @param DataFormatter $formatter
651
     * @return DataObject|string The passed object, or "No Content" if incomplete input data is provided
652
     */
653
    protected function updateDataObject($obj, $formatter)
654
    {
655
        // if neither an http body nor POST data is present, return error
656
        $body = $this->request->getBody();
657
        if (!$body && !$this->request->postVars()) {
658
            $this->getResponse()->setStatusCode(204); // No Content
659
            return 'No Content';
660
        }
661
662
        if (!empty($body)) {
663
            $rawdata = $formatter->convertStringToArray($body);
664
        } else {
665
            // assume application/x-www-form-urlencoded which is automatically parsed by PHP
666
            $rawdata = $this->request->postVars();
667
        }
668
669
        $className = $obj->ClassName;
670
        // update any aliased field names
671
        $data = [];
672
        foreach ($rawdata as $key => $value) {
673
            $newkey = $formatter->getRealFieldName($className, $key);
674
            $data[$newkey] = $value;
675
        }
676
677
        // @todo Disallow editing of certain keys in database
678
        $data = array_diff_key($data, ['ID', 'Created']);
679
680
        $apiAccess = singleton($className)->config()->api_access;
681
        if (is_array($apiAccess) && isset($apiAccess['edit'])) {
682
            $data = array_intersect_key($data, array_combine($apiAccess['edit'], $apiAccess['edit']));
683
        }
684
685
        $obj->update($data);
686
        $obj->write();
687
688
        return $obj;
689
    }
690
691
    /**
692
     * Gets a single DataObject by ID,
693
     * through a request like /api/v1/<MyClass>/<MyID>
694
     *
695
     * @param string $className
696
     * @param int $id
697
     * @param array $params
698
     * @return DataList
699
     */
700
    protected function getObjectQuery($className, $id, $params)
701
    {
702
        return DataList::create($className)->byIDs([$id]);
703
    }
704
705
    /**
706
     * @param DataObject $obj
707
     * @param array $params
708
     * @param int|array $sort
709
     * @param int|array $limit
710
     * @return SQLQuery
711
     */
712
    protected function getObjectsQuery($className, $params, $sort, $limit)
713
    {
714
        return $this->getSearchQuery($className, $params, $sort, $limit);
715
    }
716
717
718
    /**
719
     * @param DataObject $obj
720
     * @param array $params
721
     * @param int|array $sort
722
     * @param int|array $limit
723
     * @param string $relationName
724
     * @return SQLQuery|boolean
725
     */
726
    protected function getObjectRelationQuery($obj, $params, $sort, $limit, $relationName)
727
    {
728
        // The relation method will return a DataList, that getSearchQuery subsequently manipulates
729
        if ($obj->hasMethod($relationName)) {
730
            // $this->HasOneName() will return a dataobject or null, neither
731
            // of which helps us get the classname in a consistent fashion.
732
            // So we must use a way that is reliable.
733
            if ($relationClass = DataObject::getSchema()->hasOneComponent(get_class($obj), $relationName)) {
734
                $joinField = $relationName . 'ID';
735
                // Again `byID` will return the wrong type for our purposes. So use `byIDs`
736
                $list = DataList::create($relationClass)->byIDs([$obj->$joinField]);
737
            } else {
738
                $list = $obj->$relationName();
739
            }
740
741
            $apiAccess = Config::inst()->get($list->dataClass(), 'api_access');
742
743
744
            if (!$apiAccess) {
745
                return false;
746
            }
747
748
            return $this->getSearchQuery($list->dataClass(), $params, $sort, $limit, $list);
749
        }
750
    }
751
752
    /**
753
     * @return string
754
     */
755
    protected function permissionFailure()
756
    {
757
        // return a 401
758
        $this->getResponse()->setStatusCode(401);
759
        $this->getResponse()->addHeader('WWW-Authenticate', 'Basic realm="API Access"');
760
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
761
762
        $response = "You don't have access to this item through the API.";
763
        $this->extend(__FUNCTION__, $response);
764
765
        return $response;
766
    }
767
768
    /**
769
     * @return string
770
     */
771
    protected function notFound()
772
    {
773
        // return a 404
774
        $this->getResponse()->setStatusCode(404);
775
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
776
777
        $response = "That object wasn't found";
778
        $this->extend(__FUNCTION__, $response);
779
780
        return $response;
781
    }
782
783
    /**
784
     * @return string
785
     */
786
    protected function methodNotAllowed()
787
    {
788
        $this->getResponse()->setStatusCode(405);
789
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
790
791
        $response = "Method Not Allowed";
792
        $this->extend(__FUNCTION__, $response);
793
794
        return $response;
795
    }
796
797
    /**
798
     * @return string
799
     */
800
    protected function unsupportedMediaType()
801
    {
802
        $this->response->setStatusCode(415); // Unsupported Media Type
803
        $this->getResponse()->addHeader('Content-Type', 'text/plain');
804
805
        $response = "Unsupported Media Type";
806
        $this->extend(__FUNCTION__, $response);
807
808
        return $response;
809
    }
810
811
    /**
812
     * @param ValidationResult $result
813
     * @return mixed
814
     */
815
    protected function validationFailure(DataFormatter $responseFormatter, ValidationResult $result)
816
    {
817
        $this->getResponse()->setStatusCode(400);
818
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
819
820
        $response = [
821
            'type' => ValidationException::class,
822
            'messages' => $result->getMessages(),
823
        ];
824
825
        $this->extend(__FUNCTION__, $response, $result);
826
827
        return $responseFormatter->convertArray($response);
828
    }
829
830
    /**
831
     * @param DataFormatter $responseFormatter
832
     * @param \Exception $e
833
     * @return string
834
     */
835
    protected function exceptionThrown(DataFormatter $responseFormatter, \Exception $e)
836
    {
837
        $this->getResponse()->setStatusCode(500);
838
        $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
839
840
        $response = [
841
            'type' => get_class($e),
842
            'message' => $e->getMessage(),
843
        ];
844
845
        $this->extend(__FUNCTION__, $response, $e);
846
847
        return $responseFormatter->convertArray($response);
848
    }
849
850
    /**
851
     * A function to authenticate a user
852
     *
853
     * @return Member|false the logged in member
854
     */
855
    protected function authenticate()
856
    {
857
        $authClass = $this->config()->authenticator;
858
        $member = $authClass::authenticate();
859
        Security::setCurrentUser($member);
860
        return $member;
861
    }
862
863
    /**
864
     * Return only relations which have $api_access enabled.
865
     * @todo Respect field level permissions once they are available in core
866
     *
867
     * @param string $class
868
     * @param Member $member
869
     * @return array
870
     */
871
    protected function getAllowedRelations($class, $member = null)
872
    {
873
        $allowedRelations = [];
874
        $obj = singleton($class);
875
        $relations = (array)$obj->hasOne() + (array)$obj->hasMany() + (array)$obj->manyMany();
876
        if ($relations) {
877
            foreach ($relations as $relName => $relClass) {
878
                $relClass = static::parseRelationClass($relClass);
879
880
                //remove dot notation from relation names
881
                $parts = explode('.', $relClass);
0 ignored issues
show
It seems like $relClass can also be of type array; however, parameter $string of explode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

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