├── 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 | [![CI](https://github.com/silverstripe/silverstripe-restfulserver/actions/workflows/ci.yml/badge.svg)](https://github.com/silverstripe/silverstripe-restfulserver/actions/workflows/ci.yml) 4 | [![Silverstripe supported module](https://img.shields.io/badge/silverstripe-supported-0071C4.svg)](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 .= "\n"; 85 | } else { 86 | $xml .= "<$fieldName>$fieldValue\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\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 | . "\">\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}\">\n"; 195 | } 196 | } 197 | $xml .= "\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}\">\n"; 226 | } 227 | } 228 | $xml .= "\n"; 229 | } 230 | } 231 | 232 | $xml .= ""; 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 .= ""; 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 | --------------------------------------------------------------------------------