├── phpstan.neon.dist
├── _config
└── routes.yml
├── code-of-conduct.md
├── .github
├── ISSUE_TEMPLATE
│ ├── 3_blank_issue.md
│ ├── config.yml
│ ├── 2_feature_request.yml
│ └── 1_bug_report.yml
├── workflows
│ ├── ci.yml
│ ├── keepalive.yml
│ ├── merge-up.yml
│ ├── dispatch-ci.yml
│ ├── add-prs-to-project.yml
│ └── tag-patch-release.yml
└── PULL_REQUEST_TEMPLATE.md
├── phpcs.xml.dist
├── .editorconfig
├── src
├── RestfulServerList.php
├── RestfulServerItem.php
├── DataFormatter
│ ├── FormEncodedDataFormatter.php
│ ├── JSONDataFormatter.php
│ └── XMLDataFormatter.php
├── BasicRestfulAuthenticator.php
├── DataFormatter.php
└── RestfulServer.php
├── phpunit.xml.dist
├── composer.json
├── LICENSE
└── README.md
/phpstan.neon.dist:
--------------------------------------------------------------------------------
1 | parameters:
2 | paths:
3 | - src
4 |
--------------------------------------------------------------------------------
/_config/routes.yml:
--------------------------------------------------------------------------------
1 | ---
2 | Name: restfulserverroutes
3 | ---
4 | SilverStripe\Control\Director:
5 | rules:
6 | 'api/v1': 'SilverStripe\RestfulServer\RestfulServer'
7 |
--------------------------------------------------------------------------------
/code-of-conduct.md:
--------------------------------------------------------------------------------
1 | When having discussions about this module in issues or pull request please adhere to the [SilverStripe Community Code of Conduct](https://docs.silverstripe.org/en/contributing/code_of_conduct).
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/3_blank_issue.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Blank issue
3 | about: Only for use by maintainers
4 | ---
5 |
6 |
--------------------------------------------------------------------------------
/phpcs.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 | CodeSniffer ruleset for SilverStripe coding conventions.
4 |
5 | src
6 | tests
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | pull_request:
6 | workflow_dispatch:
7 |
8 | jobs:
9 | ci:
10 | name: CI
11 | # Do not run if this is a pull-request from same repo i.e. not a fork repo
12 | if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
13 | uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1
14 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # For more information about the properties used in this file,
2 | # please see the EditorConfig documentation:
3 | # http://editorconfig.org
4 |
5 | [*]
6 | charset = utf-8
7 | end_of_line = lf
8 | indent_size = 4
9 | indent_style = space
10 | insert_final_newline = true
11 | trim_trailing_whitespace = true
12 |
13 | [{*.yml,package.json,*.js}]
14 | indent_size = 2
15 |
16 | # The indent size used in the package.json file cannot be changed:
17 | # https://github.com/npm/npm/pull/3180#issuecomment-16336516
18 |
--------------------------------------------------------------------------------
/src/RestfulServerList.php:
--------------------------------------------------------------------------------
1 | 'handleItem',
12 | );
13 |
14 | public function __construct($list)
15 | {
16 | $this->list = $list;
17 | }
18 |
19 | public function handleItem($request)
20 | {
21 | return new RestfulServerItem($this->list->getById($request->param('ID')));
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/.github/workflows/keepalive.yml:
--------------------------------------------------------------------------------
1 | name: Keepalive
2 |
3 | on:
4 | # At 11:55 AM UTC, on day 4 of the month
5 | schedule:
6 | - cron: '55 11 4 * *'
7 | workflow_dispatch:
8 |
9 | permissions: {}
10 |
11 | jobs:
12 | keepalive:
13 | name: Keepalive
14 | # Only run cron on the silverstripe account
15 | if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
16 | runs-on: ubuntu-latest
17 | permissions:
18 | actions: write
19 | steps:
20 | - name: Keepalive
21 | uses: silverstripe/gha-keepalive@v1
22 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: Security Vulnerability
4 | url: https://docs.silverstripe.org/en/contributing/issues_and_bugs/#reporting-security-issues
5 | about: ⚠️ We do not use GitHub issues to track security vulnerability reports. Click "open" on the right to see how to report security vulnerabilities.
6 | - name: Support Question
7 | url: https://www.silverstripe.org/community/
8 | about: We use GitHub issues only to discuss bugs and new features. For support questions, please use one of the support options available in our community channels.
9 |
--------------------------------------------------------------------------------
/.github/workflows/merge-up.yml:
--------------------------------------------------------------------------------
1 | name: Merge-up
2 |
3 | on:
4 | # At 2:10 PM UTC, only on Sunday
5 | schedule:
6 | - cron: '10 14 * * 0'
7 | workflow_dispatch:
8 |
9 | permissions: {}
10 |
11 | jobs:
12 | merge-up:
13 | name: Merge-up
14 | # Only run cron on the silverstripe account
15 | if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
16 | runs-on: ubuntu-latest
17 | permissions:
18 | contents: write
19 | actions: write
20 | steps:
21 | - name: Merge-up
22 | uses: silverstripe/gha-merge-up@v1
23 |
--------------------------------------------------------------------------------
/.github/workflows/dispatch-ci.yml:
--------------------------------------------------------------------------------
1 | name: Dispatch CI
2 |
3 | on:
4 | # At 2:10 PM UTC, only on Wednesday and Thursday
5 | schedule:
6 | - cron: '10 14 * * 3,4'
7 |
8 | permissions: {}
9 |
10 | jobs:
11 | dispatch-ci:
12 | name: Dispatch CI
13 | # Only run cron on the silverstripe account
14 | if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
15 | runs-on: ubuntu-latest
16 | permissions:
17 | contents: read
18 | actions: write
19 | steps:
20 | - name: Dispatch CI
21 | uses: silverstripe/gha-dispatch-ci@v1
22 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | src/
6 |
7 |
8 | tests/
9 |
10 |
11 |
12 |
13 | tests/
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/.github/workflows/add-prs-to-project.yml:
--------------------------------------------------------------------------------
1 | name: Add new PRs to github project
2 |
3 | on:
4 | pull_request_target:
5 | types:
6 | - opened
7 | - ready_for_review
8 |
9 | permissions: {}
10 |
11 | jobs:
12 | addprtoproject:
13 | name: Add PR to GitHub Project
14 | # Only run on the silverstripe account
15 | if: github.repository_owner == 'silverstripe'
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Add PR to github project
19 | uses: silverstripe/gha-add-pr-to-project@v1
20 | with:
21 | app_id: ${{ vars.PROJECT_PERMISSIONS_APP_ID }}
22 | private_key: ${{ secrets.PROJECT_PERMISSIONS_APP_PRIVATE_KEY }}
23 |
--------------------------------------------------------------------------------
/src/RestfulServerItem.php:
--------------------------------------------------------------------------------
1 | 'handleRelation',
14 | );
15 |
16 | public function __construct($item)
17 | {
18 | $this->item = $item;
19 | }
20 |
21 | public function handleRelation($request)
22 | {
23 | $funcName = $request('Relation');
24 | $relation = $this->item->$funcName();
25 |
26 | if ($relation instanceof SS_List) {
27 | return new RestfulServerList($relation);
28 | } else {
29 | return new RestfulServerItem($relation);
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/.github/workflows/tag-patch-release.yml:
--------------------------------------------------------------------------------
1 | name: Tag patch release
2 |
3 | on:
4 | # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch
5 | workflow_dispatch:
6 | inputs:
7 | latest_local_sha:
8 | description: The latest local sha
9 | required: true
10 | type: string
11 |
12 | permissions: {}
13 |
14 | jobs:
15 | tagpatchrelease:
16 | name: Tag patch release
17 | # Only run cron on the silverstripe account
18 | if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
19 | runs-on: ubuntu-latest
20 | permissions:
21 | contents: write
22 | steps:
23 | - name: Tag release
24 | uses: silverstripe/gha-tag-release@v2
25 | with:
26 | latest_local_sha: ${{ inputs.latest_local_sha }}
27 |
--------------------------------------------------------------------------------
/src/DataFormatter/FormEncodedDataFormatter.php:
--------------------------------------------------------------------------------
1 |
11 | * curl -d "Name=This is a new record" http://host/api/v1/(DataObject)
12 | * curl -X PUT -d "Name=This is an updated record" http://host/api/v1/(DataObject)/1
13 | *
14 | *
15 | *
16 | * @author Cam Spiers
17 | */
18 | class FormEncodedDataFormatter extends XMLDataFormatter
19 | {
20 |
21 | public function supportedExtensions()
22 | {
23 | return array(
24 | );
25 | }
26 |
27 | public function supportedMimeTypes()
28 | {
29 | return array(
30 | 'application/x-www-form-urlencoded'
31 | );
32 | }
33 |
34 | public function convertStringToArray($strData)
35 | {
36 | $postArray = array();
37 | parse_str($strData ?? '', $postArray);
38 | return $postArray;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "silverstripe/restfulserver",
3 | "description": "Add a RESTful API to your SilverStripe application",
4 | "type": "silverstripe-vendormodule",
5 | "keywords": [
6 | "silverstripe",
7 | "rest",
8 | "api"
9 | ],
10 | "license": "BSD-3-Clause",
11 | "authors": [
12 | {
13 | "name": "Hamish Friedlander",
14 | "email": "hamish@silverstripe.com"
15 | },
16 | {
17 | "name": "Sam Minnee",
18 | "email": "sam@silverstripe.com"
19 | }
20 | ],
21 | "require": {
22 | "php": "^8.1",
23 | "silverstripe/framework": "^5"
24 | },
25 | "require-dev": {
26 | "phpunit/phpunit": "^9.6",
27 | "squizlabs/php_codesniffer": "^3",
28 | "silverstripe/versioned": "^2",
29 | "silverstripe/standards": "^1",
30 | "phpstan/extension-installer": "^1.3"
31 | },
32 | "autoload": {
33 | "psr-4": {
34 | "SilverStripe\\RestfulServer\\": "src",
35 | "SilverStripe\\RestfulServer\\Tests\\": "tests"
36 | }
37 | },
38 | "extra": [],
39 | "prefer-stable": true,
40 | "minimum-stability": "dev"
41 | }
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2017, Silverstripe Limited
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
5 |
6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
7 |
8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
9 |
10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
11 |
12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
13 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/2_feature_request.yml:
--------------------------------------------------------------------------------
1 | name: 🚀 Feature Request
2 | description: Submit a feature request (but only if you're planning on implementing it)
3 | body:
4 | - type: markdown
5 | attributes:
6 | value: |
7 | Please only submit feature requests if you plan on implementing the feature yourself.
8 | See the [contributing code documentation](https://docs.silverstripe.org/en/contributing/code/#make-or-find-a-github-issue) for more guidelines about submitting feature requests.
9 | - type: textarea
10 | id: description
11 | attributes:
12 | label: Description
13 | description: A clear and concise description of the new feature, and why it belongs in core
14 | validations:
15 | required: true
16 | - type: textarea
17 | id: more-info
18 | attributes:
19 | label: Additional context or points of discussion
20 | description: |
21 | *Optional: Any additional context, points of discussion, etc that might help validate and refine your idea*
22 | - type: checkboxes
23 | id: validations
24 | attributes:
25 | label: Validations
26 | description: "Before submitting the issue, please confirm the following:"
27 | options:
28 | - label: You intend to implement the feature yourself
29 | required: true
30 | - label: You have read the [contributing guide](https://docs.silverstripe.org/en/contributing/code/)
31 | required: true
32 | - label: You strongly believe this feature should be in core, rather than being its own community module
33 | required: true
34 | - label: You have checked for existing issues or pull requests related to this feature (and didn't find any)
35 | required: true
36 |
--------------------------------------------------------------------------------
/src/BasicRestfulAuthenticator.php:
--------------------------------------------------------------------------------
1 | $_SERVER['PHP_AUTH_USER'],
36 | 'Password' => $_SERVER['PHP_AUTH_PW'],
37 | ];
38 | $request = Controller::curr()->getRequest();
39 | $authenticators = Security::singleton()->getApplicableAuthenticators(Authenticator::LOGIN);
40 | $member = null;
41 | foreach ($authenticators as $authenticator) {
42 | $member = $authenticator->authenticate($data, $request);
43 | if ($member) {
44 | break;
45 | }
46 | }
47 | return $member;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
6 | ## Description
7 |
11 |
12 | ## Manual testing steps
13 |
17 |
18 | ## Issues
19 |
23 | - #
24 |
25 | ## Pull request checklist
26 |
30 | - [ ] The target branch is correct
31 | - See [picking the right version](https://docs.silverstripe.org/en/contributing/code/#picking-the-right-version)
32 | - [ ] All commits are relevant to the purpose of the PR (e.g. no debug statements, unrelated refactoring, or arbitrary linting)
33 | - Small amounts of additional linting are usually okay, but if it makes it hard to concentrate on the relevant changes, ask for the unrelated changes to be reverted, and submitted as a separate PR.
34 | - [ ] The commit messages follow our [commit message guidelines](https://docs.silverstripe.org/en/contributing/code/#commit-messages)
35 | - [ ] The PR follows our [contribution guidelines](https://docs.silverstripe.org/en/contributing/code/)
36 | - [ ] Code changes follow our [coding conventions](https://docs.silverstripe.org/en/contributing/coding_conventions/)
37 | - [ ] This change is covered with tests (or tests aren't necessary for this change)
38 | - [ ] Any relevant User Help/Developer documentation is updated; for impactful changes, information is added to the changelog for the intended release
39 | - [ ] CI is green
40 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/1_bug_report.yml:
--------------------------------------------------------------------------------
1 | name: 🪳 Bug Report
2 | description: Tell us if something isn't working the way it's supposed to
3 |
4 | body:
5 | - type: markdown
6 | attributes:
7 | value: |
8 | We strongly encourage you to [submit a pull request](https://docs.silverstripe.org/en/contributing/code/) which fixes the issue.
9 | Bug reports which are accompanied with a pull request are a lot more likely to be resolved quickly.
10 | - type: input
11 | id: affected-versions
12 | attributes:
13 | label: Module version(s) affected
14 | description: |
15 | What version of _this module_ have you reproduced this bug on?
16 | Run `composer info` to see the specific version of each module installed in your project.
17 | If you don't have access to that, check inside the help menu in the bottom left of the CMS.
18 | placeholder: x.y.z
19 | validations:
20 | required: true
21 | - type: textarea
22 | id: description
23 | attributes:
24 | label: Description
25 | description: A clear and concise description of the problem
26 | validations:
27 | required: true
28 | - type: textarea
29 | id: how-to-reproduce
30 | attributes:
31 | label: How to reproduce
32 | description: |
33 | ⚠️ This is the most important part of the report ⚠️
34 | Without a way to easily reproduce your issue, there is little chance we will be able to help you and work on a fix.
35 | - Please, take the time to show us some code and/or configuration that is needed for others to reproduce the problem easily.
36 | - If the bug is too complex to reproduce with some short code samples, please reproduce it in a public repository and provide a link to the repository along with steps for setting up and reproducing the bug using that repository.
37 | - If part of the bug includes an error or exception, please provide a full stack trace.
38 | - If any user interaction is required to reproduce the bug, please add an ordered list of steps that are required to reproduce it.
39 | - Be as clear as you can, but don't miss any steps out. Simply saying "create a page" is less useful than guiding us through the steps you're taking to create a page, for example.
40 | placeholder: |
41 |
42 | #### Code sample
43 | ```php
44 |
45 | ```
46 |
47 | #### Reproduction steps
48 | 1.
49 | validations:
50 | required: true
51 | - type: textarea
52 | id: possible-solution
53 | attributes:
54 | label: Possible Solution
55 | description: |
56 | *Optional: only if you have suggestions on a fix/reason for the bug*
57 | Please consider [submitting a pull request](https://docs.silverstripe.org/en/contributing/code/) with your solution! It helps get faster feedback and greatly increases the chance of the bug being fixed.
58 | - type: textarea
59 | id: additional-context
60 | attributes:
61 | label: Additional Context
62 | description: "*Optional: any other context about the problem: log messages, screenshots, etc.*"
63 | - type: checkboxes
64 | id: validations
65 | attributes:
66 | label: Validations
67 | description: "Before submitting the issue, please make sure you do the following:"
68 | options:
69 | - label: Check that there isn't already an issue that reports the same bug
70 | required: true
71 | - label: Double check that your reproduction steps work in a fresh installation of [`silverstripe/installer`](https://github.com/silverstripe/silverstripe-installer) (with any code examples you've provided)
72 | required: true
73 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Silverstripe RestfulServer Module
2 |
3 | [](https://github.com/silverstripe/silverstripe-restfulserver/actions/workflows/ci.yml)
4 | [](https://www.silverstripe.org/software/addons/silverstripe-commercially-supported-module-list/)
5 |
6 | ## Installation
7 |
8 | ```sh
9 | composer require silverstripe/restfulserver
10 | ```
11 |
12 | ## Overview
13 |
14 | This class gives your application a RESTful API. All you have to do is set the `api_access` configuration option to `true`
15 | on the appropriate DataObjects. You will need to ensure that all of your data manipulation and security is defined in
16 | your model layer (ie, the DataObject classes) and not in your Controllers. This is the recommended design for SilverStripe
17 | applications.
18 |
19 | ## Configuration
20 |
21 | Example DataObject with simple API access, giving full access to all object properties and relations,
22 | unless explicitly controlled through model permissions.
23 |
24 | ```php
25 | namespace Vendor\Project;
26 |
27 | use SilverStripe\ORM\DataObject;
28 |
29 | class Article extends DataObject {
30 |
31 | private static $db = [
32 | 'Title'=>'Text',
33 | 'Published'=>'Boolean'
34 | ];
35 |
36 | private static $api_access = true;
37 | }
38 | ```
39 |
40 | Example DataObject with advanced API access, limiting viewing and editing to Title attribute only:
41 |
42 | ```php
43 | namespace Vendor\Project;
44 |
45 | use SilverStripe\ORM\DataObject;
46 |
47 | class Article extends DataObject {
48 |
49 | private static $db = [
50 | 'Title'=>'Text',
51 | 'Published'=>'Boolean'
52 | ];
53 |
54 | private static $api_access = [
55 | 'view' => ['Title'],
56 | 'edit' => ['Title']
57 | ];
58 | }
59 | ```
60 |
61 | Example DataObject field mapping, allows aliasing fields so that public requests and responses display different field names:
62 |
63 | ```php
64 | namespace Vendor\Project;
65 |
66 | use SilverStripe\ORM\DataObject;
67 |
68 | class Article extends DataObject {
69 |
70 | private static $db = [
71 | 'Title'=>'Text',
72 | 'Published'=>'Boolean'
73 | ];
74 |
75 | private static $api_access = [
76 | 'view' => ['Title', 'Content'],
77 | ];
78 |
79 | private static $api_field_mapping = [
80 | 'customTitle' => 'Title',
81 | ];
82 | }
83 | ```
84 | Given a dataobject with values:
85 | ```yml
86 | ID: 12
87 | Title: Title Value
88 | Content: Content value
89 | ```
90 | which when requesting with the url `/api/v1/Vendor-Project-Article/12?fields=customTitle,Content` and `Accept: application/json` the response will look like:
91 | ```Javascript
92 | {
93 | "customTitle": "Title Value",
94 | "Content": "Content value"
95 | }
96 | ```
97 | Similarly, `PUT` or `POST` requests will have fields transformed from the alias name to the DB field name.
98 |
99 | ## Supported operations
100 |
101 | - `GET /api/v1/(ClassName)/(ID)` - gets a database record
102 | - `GET /api/v1/(ClassName)/(ID)/(Relation)` - get all of the records linked to this database record by the given reatlion
103 | - `GET /api/v1/(ClassName)?(Field)=(Val)&(Field)=(Val)` - searches for matching database records
104 | - `POST /api/v1/(ClassName)` - create a new database record
105 | - `PUT /api/v1/(ClassName)/(ID)` - updates a database record
106 | - `PUT /api/v1/(ClassName)/(ID)/(Relation)` - updates a relation, replacing the existing record(s) (NOT IMPLEMENTED YET)
107 | - `POST /api/v1/(ClassName)/(ID)/(Relation)` - updates a relation, appending to the existing record(s) (NOT IMPLEMENTED YET)
108 |
109 | - `DELETE /api/v1/(ClassName)/(ID)` - deletes a database record (NOT IMPLEMENTED YET)
110 | - `DELETE /api/v1/(ClassName)/(ID)/(Relation)/(ForeignID)` - remove the relationship between two database records, but don't actually delete the foreign object (NOT IMPLEMENTED YET)
111 | - `POST /api/v1/(ClassName)/(ID)/(MethodName)` - executes a method on the given object (e.g, publish)
112 |
113 | ## Search
114 |
115 | You can trigger searches based on the fields specified on `DataObject::searchable_fields` and passed
116 | through `DataObject::getDefaultSearchContext()`. Just add a key-value pair with the search-term
117 | to the url, e.g. /api/v1/(ClassName)/?Title=mytitle.
118 |
119 | ## Other url-modifiers
120 |
121 | - `&limit=`: Limit the result set
122 | - `&relationdepth=`: Displays links to existing has-one and has-many relationships to a certain depth (Default: 1)
123 | - `&fields=`: Comma-separated list of fields on the output object (defaults to all database-columns).
124 | Handy to limit output for bandwidth and performance reasons.
125 | - `&sort=&dir=`
126 | - `&add_fields=`: Comma-separated list of additional fields, for example dynamic getters.
127 |
128 | ## Access control
129 |
130 | Access control is implemented through the usual Member system with BasicAuth authentication only.
131 | By default, you have to bear the ADMIN permission to retrieve or send any data.
132 | You should override the following built-in methods to customize permission control on a
133 | class- and object-level:
134 |
135 | - `DataObject::canView()`
136 | - `DataObject::canEdit()`
137 | - `DataObject::canDelete()`
138 | - `DataObject::canCreate()`
139 |
140 | See `SilverStripe\ORM\DataObject` documentation for further details.
141 |
142 | You can specify the character-encoding for any input on the HTTP Content-Type.
143 | At the moment, only UTF-8 is supported. All output is made in UTF-8 regardless of Accept headers.
144 |
--------------------------------------------------------------------------------
/src/DataFormatter/JSONDataFormatter.php:
--------------------------------------------------------------------------------
1 | convertDataObjectToJSONObject($obj, $fields, $relations));
68 | }
69 |
70 | /**
71 | * Internal function to do the conversion of a single data object. It builds an empty object and dynamically
72 | * adds the properties it needs to it. If it's done as a nested array, json_encode or equivalent won't use
73 | * JSON object notation { ... }.
74 | * @param DataObjectInterface $obj
75 | * @param $fields
76 | * @param $relations
77 | * @return EmptyJSONObject
78 | */
79 | public function convertDataObjectToJSONObject(DataObjectInterface $obj, $fields = null, $relations = null)
80 | {
81 | $className = get_class($obj);
82 | $id = $obj->ID;
83 |
84 | $serobj = ArrayData::array_to_object();
85 |
86 | foreach ($this->getFieldsForObj($obj) as $fieldName => $fieldType) {
87 | // Field filtering
88 | if ($fields && !in_array($fieldName, $fields ?? [])) {
89 | continue;
90 | }
91 |
92 | $fieldValue = JSONDataFormatter::cast($obj->obj($fieldName));
93 | $mappedFieldName = $this->getFieldAlias($className, $fieldName);
94 | $serobj->$mappedFieldName = $fieldValue;
95 | }
96 |
97 | if ($this->relationDepth > 0) {
98 | foreach ($obj->hasOne() as $relName => $relClass) {
99 | if (!$relClass::config()->get('api_access')) {
100 | continue;
101 | }
102 |
103 | // Field filtering
104 | if ($fields && !in_array($relName, $fields ?? [])) {
105 | continue;
106 | }
107 | if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
108 | continue;
109 | }
110 | if ($obj->$relName() && (!$obj->$relName()->exists() || !$obj->$relName()->canView())) {
111 | continue;
112 | }
113 |
114 | $fieldName = $relName . 'ID';
115 | $rel = $this->config()->api_base;
116 | $rel .= $obj->$fieldName
117 | ? $this->sanitiseClassName($relClass) . '/' . $obj->$fieldName
118 | : $this->sanitiseClassName($className) . "/$id/$relName";
119 | $href = Director::absoluteURL($rel);
120 | $serobj->$relName = ArrayData::array_to_object(array(
121 | "className" => $relClass,
122 | "href" => "$href.json",
123 | "id" => JSONDataFormatter::cast($obj->obj($fieldName))
124 | ));
125 | }
126 |
127 | foreach ($obj->hasMany() + $obj->manyMany() as $relName => $relClass) {
128 | $relClass = RestfulServer::parseRelationClass($relClass);
129 |
130 | //remove dot notation from relation names
131 | $parts = explode('.', $relClass ?? '');
132 | $relClass = array_shift($parts);
133 |
134 | if (!$relClass::config()->get('api_access')) {
135 | continue;
136 | }
137 |
138 | // Field filtering
139 | if ($fields && !in_array($relName, $fields ?? [])) {
140 | continue;
141 | }
142 | if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
143 | continue;
144 | }
145 |
146 | $innerParts = array();
147 | $items = $obj->$relName();
148 | foreach ($items as $item) {
149 | if (!$item->canView()) {
150 | continue;
151 | }
152 | $rel = $this->config()->api_base . $this->sanitiseClassName($relClass) . "/$item->ID";
153 | $href = Director::absoluteURL($rel);
154 | $innerParts[] = ArrayData::array_to_object(array(
155 | "className" => $relClass,
156 | "href" => "$href.json",
157 | "id" => $item->ID
158 | ));
159 | }
160 | $serobj->$relName = $innerParts;
161 | }
162 | }
163 |
164 | return $serobj;
165 | }
166 |
167 | /**
168 | * Generate a JSON representation of the given {@link SS_List}.
169 | *
170 | * @param SS_List $set
171 | * @return String XML
172 | */
173 | public function convertDataObjectSet(SS_List $set, $fields = null)
174 | {
175 | $items = array();
176 | foreach ($set as $do) {
177 | if (!$do->canView()) {
178 | continue;
179 | }
180 | $items[] = $this->convertDataObjectToJSONObject($do, $fields);
181 | }
182 |
183 | $serobj = ArrayData::array_to_object(array(
184 | "totalSize" => (is_numeric($this->totalSize)) ? $this->totalSize : null,
185 | "items" => $items
186 | ));
187 |
188 | return json_encode($serobj);
189 | }
190 |
191 | /**
192 | * @param string $strData
193 | * @return array|bool|void
194 | */
195 | public function convertStringToArray($strData)
196 | {
197 | return json_decode($strData ?? '', true);
198 | }
199 |
200 | public static function cast(FieldType\DBField $dbfield)
201 | {
202 | switch (true) {
203 | case $dbfield instanceof FieldType\DBInt:
204 | return (int)$dbfield->RAW();
205 | case $dbfield instanceof FieldType\DBFloat:
206 | return (float)$dbfield->RAW();
207 | case $dbfield instanceof FieldType\DBBoolean:
208 | return (bool)$dbfield->RAW();
209 | case is_null($dbfield->RAW()):
210 | return null;
211 | }
212 | return $dbfield->RAW();
213 | }
214 | }
215 |
--------------------------------------------------------------------------------
/src/DataFormatter/XMLDataFormatter.php:
--------------------------------------------------------------------------------
1 | getResponse();
59 | if ($response) {
60 | $response->addHeader("Content-Type", "text/xml");
61 | }
62 |
63 | return "\n
64 | {$this->convertArrayWithoutHeader($array)}";
65 | }
66 |
67 | /**
68 | * @param $array
69 | * @return string
70 | * @throws \Exception
71 | */
72 | public function convertArrayWithoutHeader($array)
73 | {
74 | $xml = '';
75 |
76 | foreach ($array as $fieldName => $fieldValue) {
77 | if (is_array($fieldValue)) {
78 | if (is_numeric($fieldName)) {
79 | $fieldName = 'Item';
80 | }
81 |
82 | $xml .= "<{$fieldName}>\n";
83 | $xml .= $this->convertArrayWithoutHeader($fieldValue);
84 | $xml .= "{$fieldName}>\n";
85 | } else {
86 | $xml .= "<$fieldName>$fieldValue$fieldName>\n";
87 | }
88 | }
89 |
90 | return $xml;
91 | }
92 |
93 | /**
94 | * Generate an XML representation of the given {@link DataObject}.
95 | *
96 | * @param DataObject $obj
97 | * @param $includeHeader Include header (Default: true)
98 | * @return String XML
99 | */
100 | public function convertDataObject(DataObjectInterface $obj, $fields = null)
101 | {
102 | $response = Controller::curr()->getResponse();
103 | if ($response) {
104 | $response->addHeader("Content-Type", "text/xml");
105 | }
106 |
107 | return "\n" . $this->convertDataObjectWithoutHeader($obj, $fields);
108 | }
109 |
110 | /**
111 | * @param DataObject $obj
112 | * @param null $fields
113 | * @param null $relations
114 | * @return string
115 | */
116 | public function convertDataObjectWithoutHeader(DataObject $obj, $fields = null, $relations = null)
117 | {
118 | $className = $this->sanitiseClassName(get_class($obj));
119 | $id = $obj->ID;
120 | $objHref = Director::absoluteURL($this->config()->api_base . "$className/$obj->ID" . ".xml");
121 |
122 | $xml = "<$className href=\"$objHref\">\n";
123 | foreach ($this->getFieldsForObj($obj) as $fieldName => $fieldType) {
124 | // Field filtering
125 | if ($fields && !in_array($fieldName, $fields ?? [])) {
126 | continue;
127 | }
128 | $fieldValue = $obj->obj($fieldName)->forTemplate();
129 | if (isset($fieldValue) && !mb_check_encoding($fieldValue, 'utf-8')) {
130 | $fieldValue = "(data is badly encoded)";
131 | }
132 |
133 | if (is_object($fieldValue) && is_subclass_of($fieldValue, 'Object') && $fieldValue->hasMethod('toXML')) {
134 | $xml .= $fieldValue->toXML();
135 | } else {
136 | if ('HTMLText' == $fieldType) {
137 | // Escape HTML values using CDATA
138 | $fieldValue = sprintf('', str_replace(']]>', ']]]]>', $fieldValue ?? ''));
139 | } else {
140 | $fieldValue = Convert::raw2xml($fieldValue);
141 | }
142 | $mappedFieldName = $this->getFieldAlias(get_class($obj), $fieldName);
143 | $xml .= "<$mappedFieldName>$fieldValue$mappedFieldName>\n";
144 | }
145 | }
146 |
147 | if ($this->relationDepth > 0) {
148 | foreach ($obj->hasOne() as $relName => $relClass) {
149 | if (!singleton($relClass)::config()->get('api_access')) {
150 | continue;
151 | }
152 |
153 | // Field filtering
154 | if ($fields && !in_array($relName, $fields ?? [])) {
155 | continue;
156 | }
157 | if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
158 | continue;
159 | }
160 |
161 | $fieldName = $relName . 'ID';
162 | if ($obj->$fieldName) {
163 | $href = Director::absoluteURL($this->config()->api_base . "$relClass/" . $obj->$fieldName . ".xml");
164 | } else {
165 | $href = Director::absoluteURL($this->config()->api_base . "$className/$id/$relName" . ".xml");
166 | }
167 | $xml .= "<$relName linktype=\"has_one\" href=\"$href\" id=\"" . $obj->$fieldName
168 | . "\">$relName>\n";
169 | }
170 |
171 | foreach ($obj->hasMany() as $relName => $relClass) {
172 | //remove dot notation from relation names
173 | $parts = explode('.', $relClass ?? '');
174 | $relClass = array_shift($parts);
175 | if (!singleton($relClass)::config()->get('api_access')) {
176 | continue;
177 | }
178 | // backslashes in FQCNs kills both URIs and XML
179 | $relClass = $this->sanitiseClassName($relClass);
180 |
181 | // Field filtering
182 | if ($fields && !in_array($relName, $fields ?? [])) {
183 | continue;
184 | }
185 | if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
186 | continue;
187 | }
188 |
189 | $xml .= "<$relName linktype=\"has_many\" href=\"$objHref/$relName.xml\">\n";
190 | $items = $obj->$relName();
191 | if ($items) {
192 | foreach ($items as $item) {
193 | $href = Director::absoluteURL($this->config()->api_base . "$relClass/$item->ID" . ".xml");
194 | $xml .= "<$relClass href=\"$href\" id=\"{$item->ID}\">$relClass>\n";
195 | }
196 | }
197 | $xml .= "$relName>\n";
198 | }
199 |
200 | foreach ($obj->manyMany() as $relName => $relClass) {
201 | $relClass = RestfulServer::parseRelationClass($relClass);
202 |
203 | //remove dot notation from relation names
204 | $parts = explode('.', $relClass ?? '');
205 | $relClass = array_shift($parts);
206 | if (!singleton($relClass)::config()->get('api_access')) {
207 | continue;
208 | }
209 | // backslashes in FQCNs kills both URIs and XML
210 | $relClass = $this->sanitiseClassName($relClass);
211 |
212 | // Field filtering
213 | if ($fields && !in_array($relName, $fields ?? [])) {
214 | continue;
215 | }
216 | if ($this->customRelations && !in_array($relName, $this->customRelations ?? [])) {
217 | continue;
218 | }
219 |
220 | $xml .= "<$relName linktype=\"many_many\" href=\"$objHref/$relName.xml\">\n";
221 | $items = $obj->$relName();
222 | if ($items) {
223 | foreach ($items as $item) {
224 | $href = Director::absoluteURL($this->config()->api_base . "$relClass/$item->ID" . ".xml");
225 | $xml .= "<$relClass href=\"$href\" id=\"{$item->ID}\">$relClass>\n";
226 | }
227 | }
228 | $xml .= "$relName>\n";
229 | }
230 | }
231 |
232 | $xml .= "$className>";
233 |
234 | return $xml;
235 | }
236 |
237 | /**
238 | * Generate an XML representation of the given {@link SS_List}.
239 | *
240 | * @param SS_List $set
241 | * @return String XML
242 | */
243 | public function convertDataObjectSet(SS_List $set, $fields = null)
244 | {
245 | Controller::curr()->getResponse()->addHeader("Content-Type", "text/xml");
246 | $className = $this->sanitiseClassName(get_class($set));
247 |
248 | $xml = "\n";
249 | $xml .= (is_numeric($this->totalSize)) ? "<$className totalSize=\"{$this->totalSize}\">\n" : "<$className>\n";
250 | foreach ($set as $item) {
251 | $xml .= $this->convertDataObjectWithoutHeader($item, $fields);
252 | }
253 | $xml .= "$className>";
254 |
255 | return $xml;
256 | }
257 |
258 | /**
259 | * @param string $strData
260 | * @return array|void
261 | * @throws \Exception
262 | */
263 | public function convertStringToArray($strData)
264 | {
265 | return XMLDataFormatter::xml2array($strData);
266 | }
267 |
268 | /**
269 | * This was copied from Convert::xml2array() which is deprecated/removed
270 | *
271 | * Converts an XML string to a PHP array
272 | * See http://phpsecurity.readthedocs.org/en/latest/Injection-Attacks.html#xml-external-entity-injection
273 | *
274 | * @uses recursiveXMLToArray()
275 | * @param string $val
276 | * @param boolean $disableDoctypes Disables the use of DOCTYPE, and will trigger an error if encountered.
277 | * false by default.
278 | * @param boolean $disableExternals Does nothing because xml entities are removed
279 | * @return array
280 | * @throws Exception
281 | */
282 | private static function xml2array($val, $disableDoctypes = false, $disableExternals = false)
283 | {
284 | // Check doctype
285 | if ($disableDoctypes && strpos($val ?? '', '/', '', $val ?? '');
292 |
293 | // If there's still an present, then it would be the result of a maliciously
294 | // crafted XML document e.g. ENTITY ext SYSTEM "http://evil.com">
295 | if (strpos($val ?? '', ' before it was removed
301 | $xml = new SimpleXMLElement($val ?? '');
302 | return XMLDataFormatter::recursiveXMLToArray($xml);
303 | }
304 |
305 | /**
306 | * @param SimpleXMLElement $xml
307 | *
308 | * @return mixed
309 | */
310 | private static function recursiveXMLToArray($xml)
311 | {
312 | $x = null;
313 | if ($xml instanceof SimpleXMLElement) {
314 | $attributes = $xml->attributes();
315 | foreach ($attributes as $k => $v) {
316 | if ($v) {
317 | $a[$k] = (string) $v;
318 | }
319 | }
320 | $x = $xml;
321 | $xml = get_object_vars($xml);
322 | }
323 | if (is_array($xml)) {
324 | if (count($xml ?? []) === 0) {
325 | return (string)$x;
326 | } // for CDATA
327 | $r = [];
328 | foreach ($xml as $key => $value) {
329 | $r[$key] = XMLDataFormatter::recursiveXMLToArray($value);
330 | }
331 | // Attributes
332 | if (isset($a)) {
333 | $r['@'] = $a;
334 | }
335 | return $r;
336 | }
337 |
338 | return (string) $xml;
339 | }
340 | }
341 |
--------------------------------------------------------------------------------
/src/DataFormatter.php:
--------------------------------------------------------------------------------
1 | get($class, 'priority');
120 | }
121 | arsort($sortedClasses);
122 | foreach ($sortedClasses as $className => $priority) {
123 | $formatter = new $className();
124 | if (in_array($extension, $formatter->supportedExtensions() ?? [])) {
125 | return $formatter;
126 | }
127 | }
128 | }
129 |
130 | /**
131 | * Get formatter for the first matching extension.
132 | *
133 | * @param array $extensions
134 | * @return DataFormatter
135 | */
136 | public static function for_extensions($extensions)
137 | {
138 | foreach ($extensions as $extension) {
139 | if ($formatter = DataFormatter::for_extension($extension)) {
140 | return $formatter;
141 | }
142 | }
143 |
144 | return false;
145 | }
146 |
147 | /**
148 | * Get a DataFormatter object suitable for handling the given mimetype.
149 | *
150 | * @param string $mimeType
151 | * @return DataFormatter
152 | */
153 | public static function for_mimetype($mimeType)
154 | {
155 | $classes = ClassInfo::subclassesFor(DataFormatter::class);
156 | array_shift($classes);
157 | $sortedClasses = [];
158 | foreach ($classes as $class) {
159 | $sortedClasses[$class] = Config::inst()->get($class, 'priority');
160 | }
161 | arsort($sortedClasses);
162 | foreach ($sortedClasses as $className => $priority) {
163 | $formatter = new $className();
164 | if (in_array($mimeType, $formatter->supportedMimeTypes() ?? [])) {
165 | return $formatter;
166 | }
167 | }
168 | }
169 |
170 | /**
171 | * Get formatter for the first matching mimetype.
172 | * Useful for HTTP Accept headers which can contain
173 | * multiple comma-separated mimetypes.
174 | *
175 | * @param array $mimetypes
176 | * @return DataFormatter
177 | */
178 | public static function for_mimetypes($mimetypes)
179 | {
180 | foreach ($mimetypes as $mimetype) {
181 | if ($formatter = DataFormatter::for_mimetype($mimetype)) {
182 | return $formatter;
183 | }
184 | }
185 |
186 | return false;
187 | }
188 |
189 | /**
190 | * @param array $fields
191 | * @return $this
192 | */
193 | public function setCustomFields($fields)
194 | {
195 | $this->customFields = $fields;
196 | return $this;
197 | }
198 |
199 | /**
200 | * @return array
201 | */
202 | public function getCustomFields()
203 | {
204 | return $this->customFields;
205 | }
206 |
207 | /**
208 | * @param array $fields
209 | * @return $this
210 | */
211 | public function setCustomAddFields($fields)
212 | {
213 | $this->customAddFields = $fields;
214 | return $this;
215 | }
216 |
217 | /**
218 | * @param array $relations
219 | * @return $this
220 | */
221 | public function setCustomRelations($relations)
222 | {
223 | $this->customRelations = $relations;
224 | return $this;
225 | }
226 |
227 | /**
228 | * @return array
229 | */
230 | public function getCustomRelations()
231 | {
232 | return $this->customRelations;
233 | }
234 |
235 | /**
236 | * @return array
237 | */
238 | public function getCustomAddFields()
239 | {
240 | return $this->customAddFields;
241 | }
242 |
243 | /**
244 | * @param array $fields
245 | * @return $this
246 | */
247 | public function setRemoveFields($fields)
248 | {
249 | $this->removeFields = $fields;
250 | return $this;
251 | }
252 |
253 | /**
254 | * @return array
255 | */
256 | public function getRemoveFields()
257 | {
258 | return $this->removeFields;
259 | }
260 |
261 | /**
262 | * @return string
263 | */
264 | public function getOutputContentType()
265 | {
266 | return $this->outputContentType;
267 | }
268 |
269 | /**
270 | * @param int $size
271 | * @return $this
272 | */
273 | public function setTotalSize($size)
274 | {
275 | $this->totalSize = (int)$size;
276 | return $this;
277 | }
278 |
279 | /**
280 | * @return int
281 | */
282 | public function getTotalSize()
283 | {
284 | return $this->totalSize;
285 | }
286 |
287 | /**
288 | * Returns all fields on the object which should be shown
289 | * in the output. Can be customised through {@link DataFormatter::setCustomFields()}.
290 | *
291 | * @param DataObject $obj
292 | * @return array
293 | */
294 | protected function getFieldsForObj($obj)
295 | {
296 | $dbFields = [];
297 |
298 | // if custom fields are specified, only select these
299 | if (is_array($this->customFields)) {
300 | foreach ($this->customFields as $fieldName) {
301 | if (($obj->hasField($fieldName) && !is_object($obj->getField($fieldName)))
302 | || $obj->hasMethod("get{$fieldName}")
303 | ) {
304 | $dbFields[$fieldName] = $fieldName;
305 | }
306 | }
307 | } else {
308 | // by default, all database fields are selected
309 | $dbFields = DataObject::getSchema()->fieldSpecs(get_class($obj));
310 | // $dbFields = $obj->inheritedDatabaseFields();
311 | }
312 |
313 | if (is_array($this->customAddFields)) {
314 | foreach ($this->customAddFields as $fieldName) {
315 | if ($obj->hasField($fieldName) || $obj->hasMethod("get{$fieldName}")) {
316 | $dbFields[$fieldName] = $fieldName;
317 | }
318 | }
319 | }
320 |
321 | // add default required fields
322 | $dbFields = array_merge($dbFields, ['ID' => 'Int']);
323 |
324 | if (is_array($this->removeFields)) {
325 | $dbFields = array_diff_key(
326 | $dbFields ?? [],
327 | array_combine($this->removeFields ?? [], $this->removeFields ?? [])
328 | );
329 | }
330 |
331 | return $dbFields;
332 | }
333 |
334 | /**
335 | * Return an array of the extensions that this data formatter supports
336 | */
337 | abstract public function supportedExtensions();
338 |
339 | abstract public function supportedMimeTypes();
340 |
341 | /**
342 | * Convert a single data object to this format. Return a string.
343 | *
344 | * @param DataObjectInterface $do
345 | * @return mixed
346 | */
347 | abstract public function convertDataObject(DataObjectInterface $do);
348 |
349 | /**
350 | * Convert a data object set to this format. Return a string.
351 | *
352 | * @param SS_List $set
353 | * @return string
354 | */
355 | abstract public function convertDataObjectSet(SS_List $set);
356 |
357 | /**
358 | * Convert an array to this format. Return a string.
359 | *
360 | * @param $array
361 | * @return string
362 | */
363 | abstract public function convertArray($array);
364 |
365 | /**
366 | * @param string $strData HTTP Payload as string
367 | */
368 | public function convertStringToArray($strData)
369 | {
370 | user_error('DataFormatter::convertStringToArray not implemented on subclass', E_USER_ERROR);
371 | }
372 |
373 | /**
374 | * Convert an array of aliased field names to their Dataobject field name
375 | *
376 | * @param string $className
377 | * @param string[] $fields
378 | * @return string[]
379 | */
380 | public function getRealFields($className, $fields)
381 | {
382 | $apiMapping = $this->getApiMapping($className);
383 | if (is_array($apiMapping) && is_array($fields)) {
384 | $mappedFields = [];
385 | foreach ($fields as $field) {
386 | $mappedFields[] = $this->getMappedKey($apiMapping, $field);
387 | }
388 | return $mappedFields;
389 | }
390 | return $fields;
391 | }
392 |
393 | /**
394 | * Get the DataObject field name from its alias
395 | *
396 | * @param string $className
397 | * @param string $field
398 | * @return string
399 | */
400 | public function getRealFieldName($className, $field)
401 | {
402 | $apiMapping = $this->getApiMapping($className);
403 | return $this->getMappedKey($apiMapping, $field);
404 | }
405 |
406 | /**
407 | * Get a DataObject Field's Alias
408 | * defaults to the fieldname
409 | *
410 | * @param string $className
411 | * @param string $field
412 | * @return string
413 | */
414 | public function getFieldAlias($className, $field)
415 | {
416 | $apiMapping = $this->getApiMapping($className);
417 | $apiMapping = array_flip($apiMapping ?? []);
418 | return $this->getMappedKey($apiMapping, $field);
419 | }
420 |
421 | /**
422 | * Get the 'api_field_mapping' config value for a class
423 | * or return an empty array
424 | *
425 | * @param string $className
426 | * @return string[]|array
427 | */
428 | protected function getApiMapping($className)
429 | {
430 | $apiMapping = Config::inst()->get($className, 'api_field_mapping');
431 | if ($apiMapping && is_array($apiMapping)) {
432 | return $apiMapping;
433 | }
434 | return [];
435 | }
436 |
437 | /**
438 | * Helper function to get mapped field names
439 | *
440 | * @param array $map
441 | * @param string $key
442 | * @return string
443 | */
444 | protected function getMappedKey($map, $key)
445 | {
446 | if (is_array($map)) {
447 | if (array_key_exists($key, $map ?? [])) {
448 | return $map[$key];
449 | } else {
450 | return $key;
451 | }
452 | }
453 | return $key;
454 | }
455 | }
456 |
--------------------------------------------------------------------------------
/src/RestfulServer.php:
--------------------------------------------------------------------------------
1 | 'handleAction',
34 | '' => 'notFound'
35 | );
36 |
37 | /**
38 | * @config
39 | * @var string root of the api route, MUST have a trailing slash
40 | */
41 | private static $api_base = "api/v1/";
42 |
43 | /**
44 | * @config
45 | * @var string Class name for an authenticator to use on API access
46 | */
47 | private static $authenticator = BasicRestfulAuthenticator::class;
48 |
49 | /**
50 | * If no extension is given in the request, resolve to this extension
51 | * (and subsequently the {@link RestfulServer::$default_mimetype}.
52 | *
53 | * @config
54 | * @var string
55 | */
56 | private static $default_extension = "xml";
57 |
58 | /**
59 | * Custom endpoints that map to a specific class.
60 | * This is done to make the API have fixed endpoints,
61 | * instead of using fully namespaced classnames, as the module does by default
62 | * The fully namespaced classnames can also still be used though
63 | * Example:
64 | * ['mydataobject' => MyDataObject::class]
65 | *
66 | * @config array
67 | */
68 | private static $endpoint_aliases = [];
69 |
70 | /**
71 | * Whether or not to send an additional "Location" header for POST requests
72 | * to satisfy HTTP 1.1: https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
73 | *
74 | * Note: With this enabled (the default), no POST request for resource creation
75 | * will return an HTTP 201. Because of the addition of the "Location" header,
76 | * all responses become a straight HTTP 200.
77 | *
78 | * @config
79 | * @var boolean
80 | */
81 | private static $location_header_on_create = true;
82 |
83 | /**
84 | * If no extension is given, resolve the request to this mimetype.
85 | *
86 | * @var string
87 | */
88 | protected static $default_mimetype = "text/xml";
89 |
90 | /**
91 | * @uses authenticate()
92 | * @var Member
93 | */
94 | protected $member;
95 |
96 | private static $allowed_actions = array(
97 | 'index',
98 | 'notFound'
99 | );
100 |
101 | public function init()
102 | {
103 | /* This sets up SiteTree the same as when viewing a page through the frontend. Versioned defaults
104 | * to Stage, and then when viewing the front-end Versioned::choose_site_stage changes it to Live.
105 | */
106 | if (class_exists(SiteTree::class)) {
107 | singleton(SiteTree::class)->extend('modelascontrollerInit', $this);
108 | }
109 | parent::init();
110 | }
111 |
112 | /**
113 | * Backslashes in fully qualified class names (e.g. NameSpaced\ClassName)
114 | * kills both requests (i.e. URIs) and XML (invalid character in a tag name)
115 | * So we'll replace them with a hyphen (-), as it's also unambiguious
116 | * in both cases (invalid in a php class name, and safe in an xml tag name)
117 | *
118 | * @param string $classname
119 | * @return string 'escaped' class name
120 | */
121 | protected function sanitiseClassName($className)
122 | {
123 | return str_replace('\\', '-', $className ?? '');
124 | }
125 |
126 | /**
127 | * Convert hyphen escaped class names back into fully qualified
128 | * PHP safe variant.
129 | *
130 | * @param string $classname
131 | * @return string syntactically valid classname
132 | */
133 | protected function unsanitiseClassName($className)
134 | {
135 | return str_replace('-', '\\', $className ?? '');
136 | }
137 |
138 | /**
139 | * Parse many many relation class (works with through array syntax)
140 | *
141 | * @param string|array $class
142 | * @return string|array
143 | */
144 | public static function parseRelationClass($class)
145 | {
146 | // detect many many through syntax
147 | if (is_array($class)
148 | && array_key_exists('through', $class ?? [])
149 | && array_key_exists('to', $class ?? [])
150 | ) {
151 | $toRelation = $class['to'];
152 |
153 | $hasOne = Config::inst()->get($class['through'], 'has_one');
154 | if (empty($hasOne) || !is_array($hasOne) || !array_key_exists($toRelation, $hasOne ?? [])) {
155 | return $class;
156 | }
157 |
158 | return $hasOne[$toRelation];
159 | }
160 |
161 | return $class;
162 | }
163 |
164 | /**
165 | * This handler acts as the switchboard for the controller.
166 | * Since no $Action url-param is set, all requests are sent here.
167 | */
168 | public function index(HTTPRequest $request)
169 | {
170 | $className = $this->resolveClassName($request);
171 | $id = $request->param('ID') ?: null;
172 | $relation = $request->param('Relation') ?: null;
173 |
174 | // Check input formats
175 | if (!class_exists($className ?? '')) {
176 | return $this->notFound();
177 | }
178 | if ($id && !is_numeric($id)) {
179 | return $this->notFound();
180 | }
181 | if ($relation
182 | && !preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $relation ?? '')
183 | ) {
184 | return $this->notFound();
185 | }
186 |
187 | // if api access is disabled, don't proceed
188 | $apiAccess = Config::inst()->get($className, 'api_access');
189 | if (!$apiAccess) {
190 | return $this->permissionFailure();
191 | }
192 |
193 | // authenticate through HTTP BasicAuth
194 | $this->member = $this->authenticate();
195 |
196 | try {
197 | // handle different HTTP verbs
198 | if ($this->request->isGET() || $this->request->isHEAD()) {
199 | return $this->getHandler($className, $id, $relation);
200 | }
201 |
202 | if ($this->request->isPOST()) {
203 | return $this->postHandler($className, $id, $relation);
204 | }
205 |
206 | if ($this->request->isPUT()) {
207 | return $this->putHandler($className, $id, $relation);
208 | }
209 |
210 | if ($this->request->isDELETE()) {
211 | return $this->deleteHandler($className, $id, $relation);
212 | }
213 | } catch (\Exception $e) {
214 | return $this->exceptionThrown($this->getRequestDataFormatter($className), $e);
215 | }
216 |
217 | // if no HTTP verb matches, return error
218 | return $this->methodNotAllowed();
219 | }
220 |
221 | /**
222 | * Handler for object read.
223 | *
224 | * The data object will be returned in the following format:
225 | *
226 | *
227 | * Value
228 | * ...
229 | *
230 | * ...
231 | *
232 | *
233 | *
234 | *
235 | * ...
236 | *
237 | *
238 | *
239 | *
240 | *
241 | *
242 | * Access is controlled by two variables:
243 | *
244 | * - static $api_access must be set. This enables the API on a class by class basis
245 | * - $obj->canView() must return true. This lets you implement record-level security
246 | *
247 | * @param string $className
248 | * @param int $id
249 | * @param string $relation
250 | * @return string The serialized representation of the requested object(s) - usually XML or JSON.
251 | */
252 | protected function getHandler($className, $id, $relationName)
253 | {
254 | $sort = ['ID' => 'ASC'];
255 |
256 | if ($sortQuery = $this->request->getVar('sort')) {
257 | /** @var DataObject $singleton */
258 | $singleton = singleton($className);
259 | // Only apply a sort filter if it is a valid field on the DataObject
260 | if ($singleton && $singleton->hasDatabaseField($sortQuery)) {
261 | $sort = [
262 | $sortQuery => $this->request->getVar('dir') === 'DESC' ? 'DESC' : 'ASC',
263 | ];
264 | }
265 | }
266 |
267 | $limit = [
268 | 'start' => (int) $this->request->getVar('start'),
269 | 'limit' => (int) $this->request->getVar('limit'),
270 | ];
271 |
272 | if ($limit['limit'] === 0) {
273 | $limit = null;
274 | }
275 |
276 | $params = $this->request->getVars();
277 |
278 | $responseFormatter = $this->getResponseDataFormatter($className);
279 | if (!$responseFormatter) {
280 | return $this->unsupportedMediaType();
281 | }
282 |
283 | // $obj can be either a DataObject or a SS_List,
284 | // depending on the request
285 | if ($id) {
286 | // Format: /api/v1//
287 | $obj = $this->getObjectQuery($className, $id, $params)->First();
288 | if (!$obj) {
289 | return $this->notFound();
290 | }
291 | if (!$obj->canView($this->getMember())) {
292 | return $this->permissionFailure();
293 | }
294 |
295 | // Format: /api/v1///
296 | if ($relationName) {
297 | $obj = $this->getObjectRelationQuery($obj, $params, $sort, $limit, $relationName);
298 | if (!$obj) {
299 | return $this->notFound();
300 | }
301 |
302 | $responseFormatter = $this->getResponseDataFormatter($obj->dataClass());
303 | }
304 | } else {
305 | // Format: /api/v1/
306 | $obj = $this->getObjectsQuery($className, $params, $sort, $limit);
307 | }
308 |
309 | $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
310 |
311 | $rawFields = $this->request->getVar('fields');
312 | $realFields = $responseFormatter->getRealFields($className, explode(',', $rawFields ?? ''));
313 | $fields = $rawFields ? $realFields : null;
314 |
315 | if ($obj instanceof SS_List) {
316 | $objs = ArrayList::create($obj->toArray());
317 | foreach ($objs as $obj) {
318 | if (!$obj->canView($this->getMember())) {
319 | $objs->remove($obj);
320 | }
321 | }
322 | $responseFormatter->setTotalSize($objs->count());
323 | $this->extend('updateRestfulGetHandler', $objs, $responseFormatter);
324 |
325 | return $responseFormatter->convertDataObjectSet($objs, $fields);
326 | }
327 |
328 | if (!$obj) {
329 | $responseFormatter->setTotalSize(0);
330 | return $responseFormatter->convertDataObjectSet(new ArrayList(), $fields);
331 | }
332 |
333 | $this->extend('updateRestfulGetHandler', $obj, $responseFormatter);
334 |
335 | return $responseFormatter->convertDataObject($obj, $fields);
336 | }
337 |
338 | /**
339 | * Uses the default {@link SearchContext} specified through
340 | * {@link DataObject::getDefaultSearchContext()} to augument
341 | * an existing query object (mostly a component query from {@link DataObject})
342 | * with search clauses.
343 | *
344 | * @param string $className
345 | * @param array $params
346 | * @return SS_List
347 | */
348 | protected function getSearchQuery(
349 | $className,
350 | $params = null,
351 | $sort = null,
352 | $limit = null,
353 | $existingQuery = null
354 | ) {
355 | if (singleton($className)->hasMethod('getRestfulSearchContext')) {
356 | $searchContext = singleton($className)->{'getRestfulSearchContext'}();
357 | } else {
358 | $searchContext = singleton($className)->getDefaultSearchContext();
359 | }
360 | return $searchContext->getQuery($params, $sort, $limit, $existingQuery);
361 | }
362 |
363 | /**
364 | * Returns a dataformatter instance based on the request
365 | * extension or mimetype. Falls back to {@link RestfulServer::$default_extension}.
366 | *
367 | * @param boolean $includeAcceptHeader Determines wether to inspect and prioritize any HTTP Accept headers
368 | * @param string Classname of a DataObject
369 | * @return DataFormatter
370 | */
371 | protected function getDataFormatter($includeAcceptHeader = false, $className = null)
372 | {
373 | $extension = $this->request->getExtension();
374 | $contentTypeWithEncoding = $this->request->getHeader('Content-Type');
375 | preg_match('/([^;]*)/', $contentTypeWithEncoding ?? '', $contentTypeMatches);
376 | $contentType = $contentTypeMatches[0];
377 | $accept = $this->request->getHeader('Accept');
378 | $mimetypes = $this->request->getAcceptMimetypes();
379 | if (!$className) {
380 | $className = $this->resolveClassName($this->request);
381 | }
382 |
383 | // get formatter
384 | if (!empty($extension)) {
385 | $formatter = DataFormatter::for_extension($extension);
386 | } elseif ($includeAcceptHeader && !empty($accept) && strpos($accept ?? '', '*/*') === false) {
387 | $formatter = DataFormatter::for_mimetypes($mimetypes);
388 | if (!$formatter) {
389 | $formatter = DataFormatter::for_extension($this->config()->default_extension);
390 | }
391 | } elseif (!empty($contentType)) {
392 | $formatter = DataFormatter::for_mimetype($contentType);
393 | } else {
394 | $formatter = DataFormatter::for_extension($this->config()->default_extension);
395 | }
396 |
397 | if (!$formatter) {
398 | return false;
399 | }
400 |
401 | // set custom fields
402 | if ($customAddFields = $this->request->getVar('add_fields')) {
403 | $customAddFields = $formatter->getRealFields($className, explode(',', $customAddFields ?? ''));
404 | $formatter->setCustomAddFields($customAddFields);
405 | }
406 | if ($customFields = $this->request->getVar('fields')) {
407 | $customFields = $formatter->getRealFields($className, explode(',', $customFields ?? ''));
408 | $formatter->setCustomFields($customFields);
409 | }
410 | $formatter->setCustomRelations($this->getAllowedRelations($className));
411 |
412 | $apiAccess = Config::inst()->get($className, 'api_access');
413 | if (is_array($apiAccess)) {
414 | $formatter->setCustomAddFields(
415 | array_intersect((array)$formatter->getCustomAddFields(), (array)$apiAccess['view'])
416 | );
417 | if ($formatter->getCustomFields()) {
418 | $formatter->setCustomFields(
419 | array_intersect((array)$formatter->getCustomFields(), (array)$apiAccess['view'])
420 | );
421 | } else {
422 | $formatter->setCustomFields((array)$apiAccess['view']);
423 | }
424 | if ($formatter->getCustomRelations()) {
425 | $formatter->setCustomRelations(
426 | array_intersect((array)$formatter->getCustomRelations(), (array)$apiAccess['view'])
427 | );
428 | } else {
429 | $formatter->setCustomRelations((array)$apiAccess['view']);
430 | }
431 | }
432 |
433 | // set relation depth
434 | $relationDepth = $this->request->getVar('relationdepth');
435 | if (is_numeric($relationDepth)) {
436 | $formatter->relationDepth = (int)$relationDepth;
437 | }
438 |
439 | return $formatter;
440 | }
441 |
442 | /**
443 | * @param string Classname of a DataObject
444 | * @return DataFormatter
445 | */
446 | protected function getRequestDataFormatter($className = null)
447 | {
448 | return $this->getDataFormatter(false, $className);
449 | }
450 |
451 | /**
452 | * @param string Classname of a DataObject
453 | * @return DataFormatter
454 | */
455 | protected function getResponseDataFormatter($className = null)
456 | {
457 | return $this->getDataFormatter(true, $className);
458 | }
459 |
460 | /**
461 | * Handler for object delete
462 | */
463 | protected function deleteHandler($className, $id)
464 | {
465 | $obj = DataObject::get_by_id($className, $id);
466 | if (!$obj) {
467 | return $this->notFound();
468 | }
469 | if (!$obj->canDelete($this->getMember())) {
470 | return $this->permissionFailure();
471 | }
472 |
473 | $obj->delete();
474 |
475 | $this->getResponse()->setStatusCode(204); // No Content
476 | return true;
477 | }
478 |
479 | /**
480 | * Handler for object write
481 | */
482 | protected function putHandler($className, $id)
483 | {
484 | $obj = DataObject::get_by_id($className, $id);
485 | if (!$obj) {
486 | return $this->notFound();
487 | }
488 |
489 | if (!$obj->canEdit($this->getMember())) {
490 | return $this->permissionFailure();
491 | }
492 |
493 | $reqFormatter = $this->getRequestDataFormatter($className);
494 | if (!$reqFormatter) {
495 | return $this->unsupportedMediaType();
496 | }
497 |
498 | $responseFormatter = $this->getResponseDataFormatter($className);
499 | if (!$responseFormatter) {
500 | return $this->unsupportedMediaType();
501 | }
502 |
503 | try {
504 | /** @var DataObject|string */
505 | $obj = $this->updateDataObject($obj, $reqFormatter);
506 | } catch (ValidationException $e) {
507 | return $this->validationFailure($responseFormatter, $e->getResult());
508 | }
509 |
510 | if (is_string($obj)) {
511 | return $obj;
512 | }
513 |
514 | $this->getResponse()->setStatusCode(202); // Accepted
515 | $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
516 |
517 | // Append the default extension for the output format to the Location header
518 | // or else we'll use the default (XML)
519 | $types = $responseFormatter->supportedExtensions();
520 | $type = '';
521 | if (count($types ?? [])) {
522 | $type = ".{$types[0]}";
523 | }
524 |
525 | $urlSafeClassName = $this->sanitiseClassName(get_class($obj));
526 | $apiBase = $this->config()->api_base;
527 | $objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
528 | $this->getResponse()->addHeader('Location', $objHref);
529 |
530 | return $responseFormatter->convertDataObject($obj);
531 | }
532 |
533 | /**
534 | * Handler for object append / method call.
535 | *
536 | */
537 | protected function postHandler($className, $id, $relation)
538 | {
539 | if ($id) {
540 | if (!$relation) {
541 | $this->response->setStatusCode(409);
542 | return 'Conflict';
543 | }
544 |
545 | $obj = DataObject::get_by_id($className, $id);
546 | if (!$obj) {
547 | return $this->notFound();
548 | }
549 |
550 | $reqFormatter = $this->getRequestDataFormatter($className);
551 | if (!$reqFormatter) {
552 | return $this->unsupportedMediaType();
553 | }
554 |
555 | $relation = $reqFormatter->getRealFieldName($className, $relation);
556 |
557 | if (!$obj->hasMethod($relation)) {
558 | return $this->notFound();
559 | }
560 |
561 | if (!Config::inst()->get($className, 'allowed_actions') ||
562 | !in_array($relation, Config::inst()->get($className, 'allowed_actions') ?? [])) {
563 | return $this->permissionFailure();
564 | }
565 |
566 | $obj->$relation();
567 |
568 | $this->getResponse()->setStatusCode(204); // No Content
569 | return true;
570 | }
571 |
572 | if (!singleton($className)->canCreate($this->getMember())) {
573 | return $this->permissionFailure();
574 | }
575 |
576 | $obj = Injector::inst()->create($className);
577 |
578 | $reqFormatter = $this->getRequestDataFormatter($className);
579 | if (!$reqFormatter) {
580 | return $this->unsupportedMediaType();
581 | }
582 |
583 | $responseFormatter = $this->getResponseDataFormatter($className);
584 |
585 | try {
586 | /** @var DataObject|string $obj */
587 | $obj = $this->updateDataObject($obj, $reqFormatter);
588 | } catch (ValidationException $e) {
589 | return $this->validationFailure($responseFormatter, $e->getResult());
590 | }
591 |
592 | if (is_string($obj)) {
593 | return $obj;
594 | }
595 |
596 | $this->getResponse()->setStatusCode(201); // Created
597 | $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
598 |
599 | // Append the default extension for the output format to the Location header
600 | // or else we'll use the default (XML)
601 | $types = $responseFormatter->supportedExtensions();
602 | $type = '';
603 | if (count($types ?? [])) {
604 | $type = ".{$types[0]}";
605 | }
606 |
607 | // Deviate slightly from the spec: Helps datamodel API access restrict
608 | // to consulting just canCreate(), not canView() as a result of the additional
609 | // "Location" header.
610 | if ($this->config()->get('location_header_on_create')) {
611 | $urlSafeClassName = $this->sanitiseClassName(get_class($obj));
612 | $apiBase = $this->config()->api_base;
613 | $objHref = Director::absoluteURL($apiBase . "$urlSafeClassName/$obj->ID" . $type);
614 | $this->getResponse()->addHeader('Location', $objHref);
615 | }
616 |
617 | return $responseFormatter->convertDataObject($obj);
618 | }
619 |
620 | /**
621 | * Converts either the given HTTP Body into an array
622 | * (based on the DataFormatter instance), or returns
623 | * the POST variables.
624 | * Automatically filters out certain critical fields
625 | * that shouldn't be set by the client (e.g. ID).
626 | *
627 | * @param DataObject $obj
628 | * @param DataFormatter $formatter
629 | * @return DataObject|string The passed object, or "No Content" if incomplete input data is provided
630 | */
631 | protected function updateDataObject($obj, $formatter)
632 | {
633 | // if neither an http body nor POST data is present, return error
634 | $body = $this->request->getBody();
635 | if (!$body && !$this->request->postVars()) {
636 | $this->getResponse()->setStatusCode(204); // No Content
637 | return 'No Content';
638 | }
639 |
640 | if (!empty($body)) {
641 | $rawdata = $formatter->convertStringToArray($body);
642 | } else {
643 | // assume application/x-www-form-urlencoded which is automatically parsed by PHP
644 | $rawdata = $this->request->postVars();
645 | }
646 |
647 | $className = $obj->ClassName;
648 | // update any aliased field names
649 | $data = [];
650 | foreach ($rawdata as $key => $value) {
651 | $newkey = $formatter->getRealFieldName($className, $key);
652 | $data[$newkey] = $value;
653 | }
654 |
655 | $data = array_diff_key($data ?? [], ['ID', 'Created']);
656 |
657 | $apiAccess = singleton($className)->config()->api_access;
658 | if (is_array($apiAccess) && isset($apiAccess['edit'])) {
659 | $data = array_intersect_key($data ?? [], array_combine($apiAccess['edit'] ?? [], $apiAccess['edit'] ?? []));
660 | }
661 |
662 | $obj->update($data);
663 | $obj->write();
664 |
665 | return $obj;
666 | }
667 |
668 | /**
669 | * Gets a single DataObject by ID,
670 | * through a request like /api/v1//
671 | *
672 | * @param string $className
673 | * @param int $id
674 | * @param array $params
675 | * @return DataList
676 | */
677 | protected function getObjectQuery($className, $id, $params)
678 | {
679 | return DataList::create($className)->byIDs([$id]);
680 | }
681 |
682 | /**
683 | * @param DataObject $obj
684 | * @param array $params
685 | * @param int|array $sort
686 | * @param int|array $limit
687 | * @return SQLQuery
688 | */
689 | protected function getObjectsQuery($className, $params, $sort, $limit)
690 | {
691 | return $this->getSearchQuery($className, $params, $sort, $limit);
692 | }
693 |
694 |
695 | /**
696 | * @param DataObject $obj
697 | * @param array $params
698 | * @param int|array $sort
699 | * @param int|array $limit
700 | * @param string $relationName
701 | * @return SQLQuery|boolean
702 | */
703 | protected function getObjectRelationQuery($obj, $params, $sort, $limit, $relationName)
704 | {
705 | // The relation method will return a DataList, that getSearchQuery subsequently manipulates
706 | if ($obj->hasMethod($relationName)) {
707 | // $this->HasOneName() will return a dataobject or null, neither
708 | // of which helps us get the classname in a consistent fashion.
709 | // So we must use a way that is reliable.
710 | if ($relationClass = DataObject::getSchema()->hasOneComponent(get_class($obj), $relationName)) {
711 | $joinField = $relationName . 'ID';
712 | // Again `byID` will return the wrong type for our purposes. So use `byIDs`
713 | $list = DataList::create($relationClass)->byIDs([$obj->$joinField]);
714 | } else {
715 | $list = $obj->$relationName();
716 | }
717 |
718 | $apiAccess = Config::inst()->get($list->dataClass(), 'api_access');
719 |
720 |
721 | if (!$apiAccess) {
722 | return false;
723 | }
724 |
725 | return $this->getSearchQuery($list->dataClass(), $params, $sort, $limit, $list);
726 | }
727 | }
728 |
729 | /**
730 | * @return string
731 | */
732 | protected function permissionFailure()
733 | {
734 | // return a 401
735 | $this->getResponse()->setStatusCode(401);
736 | $this->getResponse()->addHeader('WWW-Authenticate', 'Basic realm="API Access"');
737 | $this->getResponse()->addHeader('Content-Type', 'text/plain');
738 |
739 | $response = "You don't have access to this item through the API.";
740 | $this->extend(__FUNCTION__, $response);
741 |
742 | return $response;
743 | }
744 |
745 | /**
746 | * @return string
747 | */
748 | protected function notFound()
749 | {
750 | // return a 404
751 | $this->getResponse()->setStatusCode(404);
752 | $this->getResponse()->addHeader('Content-Type', 'text/plain');
753 |
754 | $response = "That object wasn't found";
755 | $this->extend(__FUNCTION__, $response);
756 |
757 | return $response;
758 | }
759 |
760 | /**
761 | * @return string
762 | */
763 | protected function methodNotAllowed()
764 | {
765 | $this->getResponse()->setStatusCode(405);
766 | $this->getResponse()->addHeader('Content-Type', 'text/plain');
767 |
768 | $response = "Method Not Allowed";
769 | $this->extend(__FUNCTION__, $response);
770 |
771 | return $response;
772 | }
773 |
774 | /**
775 | * @return string
776 | */
777 | protected function unsupportedMediaType()
778 | {
779 | $this->response->setStatusCode(415); // Unsupported Media Type
780 | $this->getResponse()->addHeader('Content-Type', 'text/plain');
781 |
782 | $response = "Unsupported Media Type";
783 | $this->extend(__FUNCTION__, $response);
784 |
785 | return $response;
786 | }
787 |
788 | /**
789 | * @param ValidationResult $result
790 | * @return mixed
791 | */
792 | protected function validationFailure(DataFormatter $responseFormatter, ValidationResult $result)
793 | {
794 | $this->getResponse()->setStatusCode(400);
795 | $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
796 |
797 | $response = [
798 | 'type' => ValidationException::class,
799 | 'messages' => $result->getMessages(),
800 | ];
801 |
802 | $this->extend(__FUNCTION__, $response, $result);
803 |
804 | return $responseFormatter->convertArray($response);
805 | }
806 |
807 | /**
808 | * @param DataFormatter $responseFormatter
809 | * @param \Exception $e
810 | * @return string
811 | */
812 | protected function exceptionThrown(DataFormatter $responseFormatter, \Exception $e)
813 | {
814 | $this->getResponse()->setStatusCode(500);
815 | $this->getResponse()->addHeader('Content-Type', $responseFormatter->getOutputContentType());
816 |
817 | $response = [
818 | 'type' => get_class($e),
819 | 'message' => $e->getMessage(),
820 | ];
821 |
822 | $this->extend(__FUNCTION__, $response, $e);
823 |
824 | return $responseFormatter->convertArray($response);
825 | }
826 |
827 | /**
828 | * A function to authenticate a user
829 | *
830 | * @return Member|false the logged in member
831 | */
832 | protected function authenticate()
833 | {
834 | $authClass = $this->config()->authenticator;
835 | $member = $authClass::authenticate();
836 | Security::setCurrentUser($member);
837 | return $member;
838 | }
839 |
840 | /**
841 | * Return only relations which have $api_access enabled.
842 | *
843 | * @param string $class
844 | * @param Member $member
845 | * @return array
846 | */
847 | protected function getAllowedRelations($class, $member = null)
848 | {
849 | $allowedRelations = [];
850 | $obj = singleton($class);
851 | $relations = (array)$obj->hasOne() + (array)$obj->hasMany() + (array)$obj->manyMany();
852 | if ($relations) {
853 | foreach ($relations as $relName => $relClass) {
854 | $relClass = static::parseRelationClass($relClass);
855 |
856 | //remove dot notation from relation names
857 | $parts = explode('.', $relClass ?? '');
858 | $relClass = array_shift($parts);
859 | if (Config::inst()->get($relClass, 'api_access')) {
860 | $allowedRelations[] = $relName;
861 | }
862 | }
863 | }
864 | return $allowedRelations;
865 | }
866 |
867 | /**
868 | * Get the current Member, if available
869 | *
870 | * @return Member|null
871 | */
872 | protected function getMember()
873 | {
874 | return Security::getCurrentUser();
875 | }
876 |
877 | /**
878 | * Checks if given param ClassName maps to an object in endpoint_aliases,
879 | * else simply return the unsanitised version of ClassName
880 | *
881 | * @param HTTPRequest $request
882 | * @return string
883 | */
884 | protected function resolveClassName(HTTPRequest $request)
885 | {
886 | $className = $request->param('ClassName');
887 | $aliases = static::config()->get('endpoint_aliases');
888 |
889 | return empty($aliases[$className]) ? $this->unsanitiseClassName($className) : $aliases[$className];
890 | }
891 | }
892 |
--------------------------------------------------------------------------------