From edcb6cb2f8fff59fd319bf4fa1e59ab056a2e2d4 Mon Sep 17 00:00:00 2001 From: Rob Ingram Date: Mon, 17 Dec 2018 20:29:01 +1300 Subject: [PATCH] SS4 Compatibility (#93) * Update config.yml * Ss4 (#1) * Update SS Framework dependency * WIP: SS4 upgrade legwork * WIP Namespace yml and Config references * Update phpunit.xml & add composer autoload * Update phpunit.xml * Namespacing & formatting updates * Update: Rename namespace to colymba * Namespacing fixes * Fix; correct yml array config * Update config.yml * FEATURE: Upgrade to SS4 * FIX: consistent formatting of composer file * Fix incomplete namespace references * Allow maping of URL segment to class name This allows us to use fully qualified namespaced classes in the API config. * Update PHPUnit config Fix path to framework bootstrap. Exclude tests that perform CORS pre-flight request. * Fix configuration defaults * Fix test class namespaces * Ensure test records are generated * Fix header check * Add model mapping for query handler * Fix deprecation notice * Fix test fixture setup * Fix more issues with authenticator tests * Fix permission manager tests * Downgrade framework version * Fix query handler tests * Update capitalisation of namespace * Fix basic serializer tests * Fix ember serializer tests * Update travis config * Update class names to remove RESTfulAPI prefix * Update documantation * Document the `models` mapping * Test fallback to stardard model name mapping * Rename Basic serializers to Default * Remove ember data serializers * Fix password validation error on Travis --- .travis.yml | 9 +- README.md | 54 +- _config/config.yml | 11 +- code/RESTfulAPI.php | 576 ------------------ .../RESTfulAPI_Authenticator.php | 31 - .../RESTfulAPI_TokenAuthenticator.php | 452 -------------- .../RESTfulAPI_DefaultPermissionManager.php | 56 -- .../RESTfulAPI_GroupExtension.php | 103 ---- .../RESTfulAPI_PermissionManager.php | 25 - .../RESTfulAPI_DefaultQueryHandler.php | 493 --------------- .../queryHandlers/RESTfulAPI_QueryHandler.php | 31 - .../RESTfulAPI_EmberDataDeSerializer.php | 118 ---- .../RESTfulAPI_EmberDataSerializer.php | 294 --------- composer.json | 45 +- doc/DefaultPermissionManager.md | 6 +- doc/DefaultQueryHandler.md | 27 +- ...asicSerializer.md => DefaultSerializer.md} | 4 +- doc/EmberDataSerializer.md | 34 -- doc/RESTfulAPI.md | 34 +- doc/TokenAuthenticator.md | 18 +- phpunit.xml | 47 +- src/Authenticators/Authenticator.php | 35 ++ src/Authenticators/TokenAuthenticator.php | 454 ++++++++++++++ src/Extensions/GroupExtension.php | 111 ++++ .../Extensions/TokenAuthExtension.php | 19 +- .../DefaultPermissionManager.php | 61 ++ src/PermissionManagers/PermissionManager.php | 28 + src/QueryHandlers/DefaultQueryHandler.php | 518 ++++++++++++++++ src/QueryHandlers/QueryHandler.php | 35 ++ src/RESTfulAPI.php | 560 +++++++++++++++++ .../RESTfulAPIError.php | 35 +- .../Serializers/DeSerializer.php | 18 +- .../Serializers/DefaultDeSerializer.php | 37 +- .../Serializers/DefaultSerializer.php | 42 +- .../Serializers/Serializer.php | 19 +- .../ThirdParty}/Inflector/Inflector.php | 33 +- .../ThirdParty}/Inflector/LICENSE.txt | 0 tests/.upgrade.yml | 12 + tests/API/RESTfulAPITest.php | 231 +++++++ tests/ApiTest_fixtures.php | 81 --- .../Authenticators/TokenAuthenticatorTest.php | 222 +++++++ tests/Fixtures/ApiTestAuthor.php | 35 ++ tests/Fixtures/ApiTestBook.php | 52 ++ tests/Fixtures/ApiTestLibrary.php | 54 ++ tests/Fixtures/ApiTestProduct.php | 43 ++ tests/Fixtures/ApiTestWidget.php | 24 + .../DefaultPermissionManagerTest.php | 207 +++++++ .../QueryHandlers/DefaultQueryHandlerTest.php | 359 +++++++++++ tests/RESTfulAPITester.php | 170 ++++++ tests/RESTfulAPI_Tester.php | 149 ----- tests/Serializers/DefaultDeSerializerTest.php | 90 +++ .../DefaultSerializerTest.php} | 83 +-- tests/api/RESTfulAPI_Test.php | 246 -------- .../RESTfulAPI_TokenAuthenticator_Test.php | 212 ------- ...STfulAPI_DefaultPermissionManager_Test.php | 187 ------ .../RESTfulAPI_DefaultQueryHandler_Test.php | 350 ----------- .../RESTfulAPI_BasicDeSerializer_Test.php | 81 --- .../RESTfulAPI_EmberDataDeSerializer_Test.php | 81 --- .../RESTfulAPI_EmberDataSerializer_Test.php | 127 ---- 59 files changed, 3588 insertions(+), 3981 deletions(-) delete mode 100644 code/RESTfulAPI.php delete mode 100644 code/authenticator/RESTfulAPI_Authenticator.php delete mode 100644 code/authenticator/RESTfulAPI_TokenAuthenticator.php delete mode 100644 code/permissionManager/RESTfulAPI_DefaultPermissionManager.php delete mode 100644 code/permissionManager/RESTfulAPI_GroupExtension.php delete mode 100644 code/permissionManager/RESTfulAPI_PermissionManager.php delete mode 100644 code/queryHandlers/RESTfulAPI_DefaultQueryHandler.php delete mode 100644 code/queryHandlers/RESTfulAPI_QueryHandler.php delete mode 100644 code/serializers/EmberData/RESTfulAPI_EmberDataDeSerializer.php delete mode 100644 code/serializers/EmberData/RESTfulAPI_EmberDataSerializer.php rename doc/{BasicSerializer.md => DefaultSerializer.md} (92%) delete mode 100644 doc/EmberDataSerializer.md create mode 100644 src/Authenticators/Authenticator.php create mode 100644 src/Authenticators/TokenAuthenticator.php create mode 100644 src/Extensions/GroupExtension.php rename code/authenticator/RESTfulAPI_TokenAuthExtension.php => src/Extensions/TokenAuthExtension.php (70%) create mode 100644 src/PermissionManagers/DefaultPermissionManager.php create mode 100644 src/PermissionManagers/PermissionManager.php create mode 100644 src/QueryHandlers/DefaultQueryHandler.php create mode 100644 src/QueryHandlers/QueryHandler.php create mode 100644 src/RESTfulAPI.php rename code/RESTfulAPI_Error.php => src/RESTfulAPIError.php (87%) rename code/serializers/RESTfulAPI_DeSerializer.php => src/Serializers/DeSerializer.php (90%) rename code/serializers/Basic/RESTfulAPI_BasicDeSerializer.php => src/Serializers/DefaultDeSerializer.php (84%) rename code/serializers/Basic/RESTfulAPI_BasicSerializer.php => src/Serializers/DefaultSerializer.php (92%) rename code/serializers/RESTfulAPI_Serializer.php => src/Serializers/Serializer.php (91%) rename {code/thirdparty => src/ThirdParty}/Inflector/Inflector.php (98%) rename {code/thirdparty => src/ThirdParty}/Inflector/LICENSE.txt (100%) create mode 100644 tests/.upgrade.yml create mode 100644 tests/API/RESTfulAPITest.php delete mode 100644 tests/ApiTest_fixtures.php create mode 100644 tests/Authenticators/TokenAuthenticatorTest.php create mode 100644 tests/Fixtures/ApiTestAuthor.php create mode 100644 tests/Fixtures/ApiTestBook.php create mode 100644 tests/Fixtures/ApiTestLibrary.php create mode 100644 tests/Fixtures/ApiTestProduct.php create mode 100644 tests/Fixtures/ApiTestWidget.php create mode 100644 tests/PermissionManagers/DefaultPermissionManagerTest.php create mode 100644 tests/QueryHandlers/DefaultQueryHandlerTest.php create mode 100644 tests/RESTfulAPITester.php delete mode 100644 tests/RESTfulAPI_Tester.php create mode 100644 tests/Serializers/DefaultDeSerializerTest.php rename tests/{serializers/Basic/RESTfulAPI_BasicSerializer_Test.php => Serializers/DefaultSerializerTest.php} (58%) delete mode 100644 tests/api/RESTfulAPI_Test.php delete mode 100644 tests/authenticator/RESTfulAPI_TokenAuthenticator_Test.php delete mode 100644 tests/permissionManager/RESTfulAPI_DefaultPermissionManager_Test.php delete mode 100644 tests/queryHandler/RESTfulAPI_DefaultQueryHandler_Test.php delete mode 100644 tests/serializers/Basic/RESTfulAPI_BasicDeSerializer_Test.php delete mode 100644 tests/serializers/EmberData/RESTfulAPI_EmberDataDeSerializer_Test.php delete mode 100644 tests/serializers/EmberData/RESTfulAPI_EmberDataSerializer_Test.php diff --git a/.travis.yml b/.travis.yml index 77effb2..5b3d1d4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,16 +1,15 @@ language: php php: -- 5.6 +- 7.1 env: matrix: - - DB=MYSQL CORE_RELEASE=3 + - DB=MYSQL CORE_RELEASE=4 global: secure: Le917O5p+3nccje9JNHyvFuQk44wkoXmfDYTV5tyfqH1yvTOS9aD2zUkSORbGBcxwFKbXxJxlhSH/TBub/ZjXoAlURw10oS8uzG5T4LVPkyKUNcph54Mbgs4E05K6IzOg78VlRZ6IOjBsXh/8NI51uEstgJZ/dajjPdERgjrd+k= before_script: - phpenv rehash -- git clone git://github.com/silverstripe-labs/silverstripe-travis-support.git ~/travis-support +- git clone git://github.com/silverstripe/silverstripe-travis-support.git ~/travis-support - php ~/travis-support/travis_setup.php --source `pwd` --target ~/builds/ss - cd ~/builds/ss script: -- cd ~/builds/ss/silverstripe-restfulapi -- phpunit \ No newline at end of file +- vendor/bin/phpunit vendor/colymba/silverstripe-restfulapi/tests/ --exclude-group CORSPreflight diff --git a/README.md b/README.md index 7910fe1..c2a352d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ This module implements a RESTful API for read/write access to your SilverStripe * `api/Book?title__StartsWith=Henry&__rand=123456&__limit=1` * `api/Book?title__StartsWith=Henry&__rand=123456&__limit[]=10&__limit[]=5` -The allowed `/auth/$Action` must be defined on the used `RESTfulAPI_Authenticator` class via the `$allowed_actions` config. +The allowed `/auth/$Action` must be defined on the used `Authenticator` class via the `$allowed_actions` config. ## Requirements @@ -54,33 +54,33 @@ If CORS are enabled (true by default), the right headers are taken care of too. ### Components The `RESTfulAPI` uses 4 types of components, each implementing a different interface: -* Authetication (`RESTfulAPI_Authenticator`) -* Permission Management (`RESTfulAPI_PermissionManager`) -* Query Handler (`RESTfulAPI_QueryHandler`) -* Serializer (`RESTfulAPI_Serializer`) +* Authetication (`Authenticator`) +* Permission Management (`PermissionManager`) +* Query Handler (`QueryHandler`) +* Serializer (`Serializer`) ### Default components This API comes with defaults for each of those components: -* `RESTfulAPI_TokenAuthenticator` handles authentication via a token in an HTTP header or variable -* `RESTfulAPI_DefaultPermissionManager` handles DataObject permission checks depending on the HTTP request -* `RESTfulAPI_DefaultQueryHandler` handles all find, edit, create or delete for models -* `RESTfulAPI_BasicSerializer` / `RESTfulAPI_BasicDeSerializer` serialize query results into JSON and deserialize client payloads -* `RESTfulAPI_EmberDataSerializer` / `RESTfulAPI_EmberDataDeSerializer` same as the `Basic` version but with specific fomatting fo Ember Data. +* `TokenAuthenticator` handles authentication via a token in an HTTP header or variable +* `DefaultPermissionManager` handles DataObject permission checks depending on the HTTP request +* `DefaultQueryHandler` handles all find, edit, create or delete for models +* `DefaultSerializer` / `DefaultDeSerializer` serialize query results into JSON and deserialize client payloads +* `EmberDataSerializer` / `EmberDataDeSerializer` same as the `Default` version but with specific fomatting fo Ember Data. -You can create you own classes by implementing the right interface or extending the existing components. When creating you own components, any error should be return as a `RESTfulAPI_Error` object to the `RESTfulAPI`. +You can create you own classes by implementing the right interface or extending the existing components. When creating you own components, any error should be return as a `RESTfulAPIError` object to the `RESTfulAPI`. ### Token Authentication Extension -When using `RESTfulAPI_TokenAuthenticator` you must add the `RESTfulAPI_TokenAuthExtension` `DataExtension` to a `DataObject` and setup `RESTfulAPI_TokenAuthenticator` with the right config. +When using `TokenAuthenticator` you must add the `TokenAuthExtension` `DataExtension` to a `DataObject` and setup `TokenAuthenticator` with the right config. **By default, API authentication is disabled.** ### Permissions management -DataObject API access control can be managed in 2 ways. Through the `api_access` [YML config](doc/RESTfulAPI.md#authentication-and-api-access-control) allowing for simple configurations, or via [DataObject permissions](http://doc.silverstripe.org/framework/en/reference/dataobject#permissions) through a `RESTfulAPI_PermissionManager` component. +DataObject API access control can be managed in 2 ways. Through the `api_access` [YML config](doc/RESTfulAPI.md#authentication-and-api-access-control) allowing for simple configurations, or via [DataObject permissions](http://doc.silverstripe.org/framework/en/reference/dataobject#permissions) through a `PermissionManager` component. -A sample `Group` extension `RESTfulAPI_GroupExtension` is also available with a basic set of dedicated API permissions. This can be enabled via [config](code/_config/config.yml#L11) or you can create your own. +A sample `Group` extension `GroupExtension` is also available with a basic set of dedicated API permissions. This can be enabled via [config](code/_config/config.yml#L11) or you can create your own. **By default, the API only performs access control against the `api_access` YML config.** @@ -91,14 +91,16 @@ See individual component configuration file for mode details * [TokenAuthenticator](doc/TokenAuthenticator.md) handles query authentication via token * [DefaultPermissionManager](doc/DefaultPermissionManager.md) handles DataObject level permissions check * [DefaultQueryHandler](doc/DefaultQueryHandler.md) where most of the logic happens -* [BasicSerializer](doc/BasicSerializer.md) BasicSerializer and DeSerializer for everyday use +* [DefaultSerializer](doc/DefaultSerializer.md) DefaultSerializer and DeSerializer for everyday use * [EmberDataSerializer](doc/EmberDataSerializer.md) EmberDataSerializer and DeSerializer speicifrcally design for use with Ember Data and application/vnd.api+json Here is what a site's `config.yml` file could look like: ```yaml --- Name: mysite -After: 'framework/*','cms/*' +After: + - 'framework/*' + - 'cms/*' --- # API access Artwork: @@ -120,14 +122,14 @@ File: Page: api_access: false # RestfulAPI config -RESTfulAPI: +Colymba\RESTfulAPI\RESTfulAPI: authentication_policy: true access_control_policy: 'ACL_CHECK_CONFIG_AND_MODEL' dependencies: - authenticator: '%$RESTfulAPI_TokenAuthenticator' - authority: '%$RESTfulAPI_DefaultPermissionManager' - queryHandler: '%$RESTfulAPI_DefaultQueryHandler' - serializer: '%$RESTfulAPI_EmberDataSerializer' + authenticator: '%$Colymba\RESTfulAPI\Authenticators\TokenAuthenticator' + authority: '%$Colymba\RESTfulAPI\PermissionManagers\DefaultPermissionManager' + queryHandler: '%$Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler' + serializer: '%$Colymba\RESTfulAPI\Serializers\EmberData\EmberDataSerializer' cors: Enabled: true Allow-Origin: 'http://mydomain.com' @@ -135,10 +137,10 @@ RESTfulAPI: Allow-Methods: 'OPTIONS, GET' Max-Age: 86400 # Components config -RESTfulAPI_DefaultQueryHandler: +Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler\DefaultQueryHandler: dependencies: - deSerializer: '%$RESTfulAPI_EmberDataDeSerializer' -RESTfulAPI_EmberDataSerializer: + deSerializer: '%$Colymba\RESTfulAPI\Serializers\EmberData\EmberDataDeSerializer' +Colymba\RESTfulAPI\Serializers\EmberData\EmberDataSerializer: sideloaded_records: Artwork: - 'Visuals' @@ -152,7 +154,7 @@ RESTfulAPI_EmberDataSerializer: ## Todo * API access IP throttling (limit request per minute for each IP or token) -* Check components interface implementation +* Check components interface implementation ## License (BSD Simplified) @@ -166,5 +168,5 @@ Redistribution and use in source and binary forms, with or without modification, * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * 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. * Neither the name of Thierry Francois, colymba nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. - + 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. diff --git a/_config/config.yml b/_config/config.yml index 09dac14..75a4365 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -1,14 +1,15 @@ --- Name: restfulapi -After: 'framework/*','cms/*' +After: + - rootroutes --- # --------------------------------- # Routing -Director: +SilverStripe\Control\Director: rules: - 'api': 'RESTfulAPI' + 'api': 'Colymba\RESTfulAPI\RESTfulAPI' # --------------------------------- # Permissions / Uncomment or create your own -#Group: +#SilverStripe\Security\Group: # extensions: -# - RESTfulAPI_GroupExtension \ No newline at end of file +# - Colymba\RESTfulAPI\Extensions\GroupExtension diff --git a/code/RESTfulAPI.php b/code/RESTfulAPI.php deleted file mode 100644 index 143fa30..0000000 --- a/code/RESTfulAPI.php +++ /dev/null @@ -1,576 +0,0 @@ - '%$RESTfulAPI_TokenAuthenticator', - 'authority' => '%$RESTfulAPI_DefaultPermissionManager', - 'queryHandler' => '%$RESTfulAPI_DefaultQueryHandler', - 'serializer' => '%$RESTfulAPI_BasicSerializer' - ); - - - /** - * Embedded records setting - * Specify which relation ($has_one, $has_many, $many_many) model data should be embedded into the response - * - * Map of relations to embed for specific record classname - * 'RequestedClass' => array('RelationNameToEmbed', 'Another') - * - * Non embedded response: - * { - * 'member': { - * 'name': 'John', - * 'favourites': [1, 2] - * } - * } - * - * Response with embedded record: - * { - * 'member': { - * 'name': 'John', - * 'favourites': [{ - * 'id': 1, - * 'name': 'Mark' - * },{ - * 'id': 2, - * 'name': 'Maggie' - * }] - * } - * } - * - * @var array - * @config - */ - private static $embedded_records; - - - /** - * Cross-Origin Resource Sharing (CORS) - * API settings for cross domain XMLHTTPRequest - * - * Enabled true|false enable/disable CORS - * Allow-Origin String|Array '*' to allow all, 'http://domain.com' to allow single domain, array('http://domain.com', 'http://site.com') to allow multiple domains - * Allow-Headers String '*' to allow all or comma separated list of headers - * Allow-Methods String comma separated list of allowed methods - * Max-Age Integer Preflight/OPTIONS request caching time in seconds (NOTE has no effect if Authentification is enabled => custom header = always preflight) - * - * @var array - * @config - */ - private static $cors = array( - 'Enabled' => true, - 'Allow-Origin' => '*', - 'Allow-Headers' => '*', - 'Allow-Methods' => 'OPTIONS, POST, GET, PUT, DELETE', - 'Max-Age' => 86400 - ); - - - /** - * URL handler allowed actions - * - * @var array - */ - private static $allowed_actions = array( - 'index', - 'auth', - 'acl' - ); - - - /** - * URL handler definition - * - * @var array - */ - private static $url_handlers = array( - 'auth/$Action' => 'auth', - 'acl/$Action' => 'acl', - '$ClassName/$ID' => 'index' - ); - - - /** - * Returns current query handler instance - * - * @return RESTfulAPI_QueryHandler QueryHandler instance - */ - public function getqueryHandler() - { - return $this->queryHandler; - } - - - /** - * Returns current serializer instance - * - * @return RESTfulAPI_Serializer Serializer instance - */ - public function getserializer() - { - return $this->serializer; - } - - - /** - * Current RESTfulAPI instance - * - * @var RESTfulAPI - */ - protected static $instance; - - - /** - * Constructor.... - */ - public function __construct() - { - parent::__construct(); - - //save current instance in static var - self::$instance = $this; - } - - - /** - * Controller inititalisation - * Catches CORS preflight request marked with HTTPMethod 'OPTIONS' - */ - public function init() - { - parent::init(); - - //catch preflight request - if ($this->request->httpMethod() === 'OPTIONS') { - $answer = $this->answer(null, true); - $answer->output(); - exit; - } - } - - - /** - * Handles authentications methods - * get response from API Authenticator - * then passes it on to $answer() - * - * @param SS_HTTPRequest $request HTTP request - */ - public function auth(SS_HTTPRequest $request) - { - $action = $request->param('Action'); - - if ($this->authenticator) { - $className = get_class($this->authenticator); - $allowedActions = Config::inst()->get($className, 'allowed_actions'); - if (!$allowedActions) { - $allowedActions = array(); - } - - if (in_array($action, $allowedActions)) { - if (method_exists($this->authenticator, $action)) { - $response = $this->authenticator->$action($request); - $response = $this->serializer->serialize($response); - return $this->answer($response); - } else { - //let's be shady here instead - return $this->error(new RESTfulAPI_Error(403, - "Action '$action' not allowed." - )); - } - } else { - return $this->error(new RESTfulAPI_Error(403, - "Action '$action' not allowed." - )); - } - } - } - - - /** - * Handles Access Control methods - * get response from API PermissionManager - * then passes it on to $answer() - * - * @param SS_HTTPRequest $request HTTP request - */ - public function acl(SS_HTTPRequest $request) - { - $action = $request->param('Action'); - - if ($this->authority) { - $className = get_class($this->authority); - $allowedActions = Config::inst()->get($className, 'allowed_actions'); - if (!$allowedActions) { - $allowedActions = array(); - } - - if (in_array($action, $allowedActions)) { - if (method_exists($this->authority, $action)) { - $response = $this->authority->$action($request); - $response = $this->serializer->serialize($response); - return $this->answer($response); - } else { - //let's be shady here instead - return $this->error(new RESTfulAPI_Error(403, - "Action '$action' not allowed." - )); - } - } else { - return $this->error(new RESTfulAPI_Error(403, - "Action '$action' not allowed." - )); - } - } - } - - - /** - * Main API hub switch - * All requests pass through here and are redirected depending on HTTP verb and params - * - * @todo move authentication check to another methode - * - * @param SS_HTTPRequest $request HTTP request - * @return string json object of the models found - */ - public function index(SS_HTTPRequest $request) - { - //check authentication if enabled - if ($this->authenticator) { - $policy = $this->config()->authentication_policy; - $authALL = $policy === true; - $authMethod = is_array($policy) && in_array($request->httpMethod(), $policy); - - if ($authALL || $authMethod) { - $authResult = $this->authenticator->authenticate($request); - - if ($authResult instanceof RESTfulAPI_Error) { - //Authentication failed return error to client - return $this->error($authResult); - } - } - } - - //pass control to query handler - $data = $this->queryHandler->handleQuery($request); - //catch + return errors - if ($data instanceof RESTfulAPI_Error) { - return $this->error($data); - } - - //serialize response - $json = $this->serializer->serialize($data); - //catch + return errors - if ($json instanceof RESTfulAPI_Error) { - return $this->error($json); - } - - //all is good reply normally - return $this->answer($json); - } - - - /** - * Output the API response to client - * then exit. - * - * @param string $json Response body - * @param boolean $corsPreflight Set to true if this is a XHR preflight request answer. CORS shoud be enabled. - */ - public function answer($json = null, $corsPreflight = false) - { - $answer = new SS_HTTPResponse(); - - //set response body - if (!$corsPreflight) { - $answer->setBody($json); - } - - //set CORS if needed - $answer = $this->setAnswerCORS($answer); - - $answer->addHeader('Content-Type', $this->serializer->getcontentType()); - - // save controller's response then return/output - $this->response = $answer; - - return $answer; - } - - - /** - * Handles formatting and output error message - * then exit. - * - * @param RESTfulAPI_Error $error Error object to return - */ - public function error(RESTfulAPI_Error $error) - { - $answer = new SS_HTTPResponse(); - - $body = $this->serializer->serialize($error->body); - $answer->setBody($body); - - $answer->setStatusCode($error->code, $error->message); - $answer->addHeader('Content-Type', $this->serializer->getcontentType()); - - $answer = $this->setAnswerCORS($answer); - - // save controller's response then return/output - $this->response = $answer; - - return $answer; - } - - - /** - * Apply the proper CORS response heardes - * to an SS_HTTPResponse - * - * @param SS_HTTPResponse $answer The updated response if CORS are neabled - */ - private function setAnswerCORS(SS_HTTPResponse $answer) - { - $cors = Config::inst()->get('RESTfulAPI', 'cors'); - - // skip if CORS is not enabled - if (!$cors['Enabled']) { - return $answer; - } - - //check if Origin is allowed - $allowedOrigin = $cors['Allow-Origin']; - $requestOrigin = $this->request->getHeader('Origin'); - if ($requestOrigin) { - if ($cors['Allow-Origin'] === '*') { - $allowedOrigin = $requestOrigin; - } elseif (is_array($cors['Allow-Origin'])) { - if (in_array($requestOrigin, $cors['Allow-Origin'])) { - $allowedOrigin = $requestOrigin; - } - } - } - $answer->addHeader('Access-Control-Allow-Origin', $allowedOrigin); - - //allowed headers - $allowedHeaders = ''; - $requestHeaders = $this->request->getHeader('Access-Control-Request-Headers'); - if ($cors['Allow-Headers'] === '*') { - $allowedHeaders = $requestHeaders; - } else { - $allowedHeaders = $cors['Allow-Headers']; - } - $answer->addHeader('Access-Control-Allow-Headers', $allowedHeaders); - - //allowed method - $answer->addHeader('Access-Control-Allow-Methods', $cors['Allow-Methods']); - - //max age - $answer->addHeader('Access-Control-Max-Age', $cors['Max-Age']); - - return $answer; - } - - - /** - * Checks a class or model api access - * depending on access_control_policy and the provided model. - * - 1st config check - * - 2nd permission check if config access passes - * - * @param string|DataObject $model Model's classname or DataObject - * @param string $httpMethod API request HTTP method - * @return boolean true if access is granted, false otherwise - */ - public static function api_access_control($model, $httpMethod = 'GET') - { - $policy = self::config()->access_control_policy; - if ($policy === false) { - return true; - } // if access control is disabled, skip - else { - $policy = constant('self::'.$policy); - } - - if ($policy === self::ACL_CHECK_MODEL_ONLY) { - $access = true; - } else { - $access = false; - } - - if ($policy === self::ACL_CHECK_CONFIG_ONLY || $policy === self::ACL_CHECK_CONFIG_AND_MODEL) { - if (!is_string($model)) { - $className = $model->className; - } else { - $className = $model; - } - - $access = self::api_access_config_check($className, $httpMethod); - } - - - if ($policy === self::ACL_CHECK_MODEL_ONLY || $policy === self::ACL_CHECK_CONFIG_AND_MODEL) { - if ($access) { - $access = self::model_permission_check($model, $httpMethod); - } - } - - return $access; - } - - /** - * Checks a model's api_access config. - * api_access config can be: - * - unset|false, access is always denied - * - true, access is always granted - * - comma separated list of allowed HTTP methods - * - * @param string $className Model's classname - * @param string $httpMethod API request HTTP method - * @return boolean true if access is granted, false otherwise - */ - private static function api_access_config_check($className, $httpMethod = 'GET') - { - $access = false; - $api_access = singleton($className)->stat('api_access'); - - if (is_string($api_access)) { - $api_access = explode(',', strtoupper($api_access)); - if (in_array($httpMethod, $api_access)) { - $access = true; - } else { - $access = false; - } - } elseif ($api_access === true) { - $access = true; - } - - return $access; - } - - /** - * Checks a Model's permission for the currently - * authenticated user via the Permission Manager dependency. - * - * For permissions to actually be checked, this means the RESTfulAPI - * must have both authenticator and authority dependencies defined. - * - * If the authenticator component does not return an instance of the Member - * null will be passed to the authority component. - * - * This default to true. - * - * @param string|DataObject $model Model's classname or DataObject to check permission for - * @param string $httpMethod API request HTTP method - * @return boolean true if access is granted, false otherwise - */ - private static function model_permission_check($model, $httpMethod = 'GET') - { - $access = true; - $apiInstance = self::$instance; - - if ($apiInstance->authenticator && $apiInstance->authority) { - $request = $apiInstance->request; - $member = $apiInstance->authenticator->getOwner($request); - - if (!$member instanceof Member) { - $member = null; - } - - $access = $apiInstance->authority->checkPermission($model, $member, $httpMethod); - if (!is_bool($access)) { - $access = true; - } - } - - return $access; - } -} diff --git a/code/authenticator/RESTfulAPI_Authenticator.php b/code/authenticator/RESTfulAPI_Authenticator.php deleted file mode 100644 index a5fb1ed..0000000 --- a/code/authenticator/RESTfulAPI_Authenticator.php +++ /dev/null @@ -1,31 +0,0 @@ -get('RESTfulAPI_TokenAuthenticator', 'tokenLife'); - $config['header'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'tokenHeader'); - $config['queryVar'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'tokenQueryVar'); - $config['owner'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'tokenOwnerClass'); - $config['autoRefresh'] = $configInstance->get('RESTfulAPI_TokenAuthenticator', 'autoRefreshLifetime'); - - $tokenDBColumns = $configInstance->get('RESTfulAPI_TokenAuthExtension', 'db'); - $tokenDBColumn = array_search('Varchar(160)', $tokenDBColumns); - $expireDBColumn = array_search('Int', $tokenDBColumns); - - if ($tokenDBColumn !== false) { - $config['DBColumn'] = $tokenDBColumn; - } else { - $config['DBColumn'] = 'ApiToken'; - } - - if ($expireDBColumn !== false) { - $config['expireDBColumn'] = $expireDBColumn; - } else { - $config['expireDBColumn'] = 'ApiTokenExpire'; - } - - $this->tokenConfig = $config; - } - - - /** - * Login a user into the Framework and generates API token - * Only works if the token owner is a Member - * - * @param SS_HTTPRequest $request HTTP request containing 'email' & 'pwd' vars - * @return array login result with token - */ - public function login(SS_HTTPRequest $request) - { - $response = array(); - - if ($this->tokenConfig['owner'] === 'Member') { - $email = $request->requestVar('email'); - $pwd = $request->requestVar('pwd'); - $member = false; - - - if ($email && $pwd) { - $member = MemberAuthenticator::authenticate(array( - 'Email' => $email, - 'Password' => $pwd - )); - if ($member) { - $tokenData = $this->generateToken(); - - $tokenDBColumn = $this->tokenConfig['DBColumn']; - $expireDBColumn = $this->tokenConfig['expireDBColumn']; - - $member->{$tokenDBColumn} = $tokenData['token']; - $member->{$expireDBColumn} = $tokenData['expire']; - $member->write(); - $member->login(); - } - } - - if (!$member) { - $response['result'] = false; - $response['message'] = 'Authentication fail.'; - $response['code'] = self::AUTH_CODE_LOGIN_FAIL; - } else { - $response['result'] = true; - $response['message'] = 'Logged in.'; - $response['code'] = self::AUTH_CODE_LOGGED_IN; - $response['token'] = $tokenData['token']; - $response['expire'] = $tokenData['expire']; - $response['userID'] = $member->ID; - } - } - - return $response; - } - - - /** - * Logout a user from framework - * and update token with an expired one - * if token owner class is a Member - * - * @param SS_HTTPRequest $request HTTP request containing 'email' var - */ - public function logout(SS_HTTPRequest $request) - { - $email = $request->requestVar('email'); - $member = Member::get()->filter(array('Email' => $email))->first(); - - if ($member) { - //logout - $member->logout(); - - if ($this->tokenConfig['owner'] === 'Member') { - //generate expired token - $tokenData = $this->generateToken(true); - - //write - $tokenDBColumn = $this->tokenConfig['DBColumn']; - $expireDBColumn = $this->tokenConfig['expireDBColumn']; - - $member->{$tokenDBColumn} = $tokenData['token']; - $member->{$expireDBColumn} = $tokenData['expire']; - $member->write(); - } - } - } - - - /** - * Sends password recovery email - * - * @param SS_HTTPRequest $request HTTP request containing 'email' vars - * @return array 'email' => false if email fails (Member doesn't exist will not be reported) - */ - public function lostPassword(SS_HTTPRequest $request) - { - $email = Convert::raw2sql($request->requestVar('email')); - $member = DataObject::get_one('Member', "\"Email\" = '{$email}'"); - - if ($member) { - $token = $member->generateAutologinTokenAndStoreHash(); - - $e = Member_ForgotPasswordEmail::create(); - $e->populateTemplate($member); - $e->populateTemplate(array( - 'PasswordResetLink' => Security::getPasswordResetLink($member, $token) - )); - $e->setTo($member->Email); - $e->send(); - } - - return array( 'done' => true ); - } - - - /** - * Return the stored API token for a specific owner - * - * @param integer $id ID of the token owner - * @return string API token for the owner - */ - public function getToken($id) - { - if ($id) { - $ownerClass = $this->tokenConfig['owner']; - $owner = DataObject::get_by_id($ownerClass, $id); - - if ($owner) { - $tokenDBColumn = $this->tokenConfig['DBColumn']; - return $owner->{$tokenDBColumn}; - } else { - user_error("API Token owner '$ownerClass' not found with ID = $id", E_USER_WARNING); - } - } else { - user_error("RESTfulAPI_TokenAuthenticator::getToken() requires an ID as argument.", E_USER_WARNING); - } - } - - - /** - * Reset an owner's token - * if $expired is set to true the owner's will have a new invalidated/expired token - * - * @param integer $id ID of the token owner - * @param boolean $expired if true the token will be invalidated - */ - public function resetToken($id, $expired = false) - { - if ($id) { - $ownerClass = $this->tokenConfig['owner']; - $owner = DataObject::get_by_id($ownerClass, $id); - - if ($owner) { - //generate token - $tokenData = $this->generateToken($expired); - - //write - $tokenDBColumn = $this->tokenConfig['DBColumn']; - $expireDBColumn = $this->tokenConfig['expireDBColumn']; - - $owner->{$tokenDBColumn} = $tokenData['token']; - $owner->{$expireDBColumn} = $tokenData['expire']; - $owner->write(); - } else { - user_error("API Token owner '$ownerClass' not found with ID = $id", E_USER_WARNING); - } - } else { - user_error("RESTfulAPI_TokenAuthenticator::resetToken() requires an ID as argument.", E_USER_WARNING); - } - } - - - /** - * Generates an encrypted random token - * and an expiry date - * - * @param boolean $expired Set to true to generate an outdated token - * @return array token data array('token' => HASH, 'expire' => EXPIRY_DATE) - */ - private function generateToken($expired = false) - { - $life = $this->tokenConfig['life']; - - if (!$expired) { - $expire = time() + $life; - } else { - $expire = time() - ($life * 2); - } - - $generator = new RandomGenerator(); - $tokenString = $generator->randomToken(); - - $e = PasswordEncryptor::create_for_algorithm('blowfish'); //blowfish isn't URL safe and maybe too long? - $salt = $e->salt($tokenString); - $token = $e->encrypt($tokenString, $salt); - - return array( - 'token' => substr($token, 7), - 'expire' => $expire - ); - } - - - /** - * Returns the DataObject related to the token - * that sent the authenticated request - * - * @param SS_HTTPRequest $request HTTP API request - * @return null|DataObject null if failed or the DataObject token owner related to the request - */ - public function getOwner(SS_HTTPRequest $request) - { - $owner = null; - - //get the token - $token = $request->getHeader($this->tokenConfig['header']); - if (!$token) { - $token = $request->requestVar($this->tokenConfig['queryVar']); - } - - if ($token) { - $SQL_token = Convert::raw2sql($token); - - $owner = DataObject::get_one( - $this->tokenConfig['owner'], - "\"".$this->tokenConfig['DBColumn']."\"='" . $SQL_token . "'", - false - ); - - if (!$owner) { - $owner = null; - } - } - - return $owner; - } - - - /** - * Checks if a request to the API is authenticated - * Gets API Token from HTTP Request and return Auth result - * - * @param SS_HTTPRequest $request HTTP API request - * @return true|RESTfulAPI_Error True if token is valid OR RESTfulAPI_Error with details - */ - public function authenticate(SS_HTTPRequest $request) - { - //get the token - $token = $request->getHeader($this->tokenConfig['header']); - if (!$token) { - $token = $request->requestVar($this->tokenConfig['queryVar']); - } - - if ($token) { - //check token validity - return $this->validateAPIToken($token); - } else { - //no token, bad news - return new RESTfulAPI_Error(403, - 'Token invalid.', - array( - 'message' => 'Token invalid.', - 'code' => self::AUTH_CODE_TOKEN_INVALID - ) - ); - } - } - - - /** - * Validate the API token - * - * @param string $token Authentication token - * @return true|RESTfulAPI_Error True if token is valid OR RESTfulAPI_Error with details - */ - private function validateAPIToken($token) - { - //get owner with that token - $SQL_token = Convert::raw2sql($token); - $tokenColumn = $this->tokenConfig['DBColumn']; - - $tokenOwner = DataObject::get_one( - $this->tokenConfig['owner'], - "\"".$this->tokenConfig['DBColumn']."\"='" . $SQL_token . "'", - false - ); - - if ($tokenOwner) { - //check token expiry - $tokenExpire = $tokenOwner->{$this->tokenConfig['expireDBColumn']}; - $now = time(); - $life = $this->tokenConfig['life']; - - if ($tokenExpire > ($now - $life)) { - // check if token should automatically be updated - if ($this->tokenConfig['autoRefresh']) { - $tokenOwner->setField($this->tokenConfig['expireDBColumn'], $now + $life); - $tokenOwner->write(); - } - //all good, log Member in - if (is_a($tokenOwner, 'Member')) { - # $tokenOwner->logIn(); - # this is a login without the logging - $tokenOwner::session_regenerate_id(); - Session::set("loggedInAs", $tokenOwner->ID); - } - - return true; - } else { - //too old - return new RESTfulAPI_Error(403, - 'Token expired.', - array( - 'message' => 'Token expired.', - 'code' => self::AUTH_CODE_TOKEN_EXPIRED - ) - ); - } - } else { - //token not found - //not sure it's wise to say it doesn't exist. Let's be shady here - return new RESTfulAPI_Error(403, - 'Token invalid.', - array( - 'message' => 'Token invalid.', - 'code' => self::AUTH_CODE_TOKEN_INVALID - ) - ); - } - } -} diff --git a/code/permissionManager/RESTfulAPI_DefaultPermissionManager.php b/code/permissionManager/RESTfulAPI_DefaultPermissionManager.php deleted file mode 100644 index a8489e5..0000000 --- a/code/permissionManager/RESTfulAPI_DefaultPermissionManager.php +++ /dev/null @@ -1,56 +0,0 @@ -canView($member); - break; - - case 'POST': - return $model->canCreate($member); - break; - - case 'PUT': - return $model->canEdit($member); - break; - - case 'DELETE': - return $model->canDelete($member); - break; - - default: - return true; - break; - } - } -} diff --git a/code/permissionManager/RESTfulAPI_GroupExtension.php b/code/permissionManager/RESTfulAPI_GroupExtension.php deleted file mode 100644 index d3e1fe0..0000000 --- a/code/permissionManager/RESTfulAPI_GroupExtension.php +++ /dev/null @@ -1,103 +0,0 @@ - ALL ACCESS - * - API Editor => VIEW + EDIT + CREATE - * - API Reader => VIEW - * - * @author Thierry Francois @colymba thierry@colymba.com - * @copyright Copyright (c) 2013, Thierry Francois - * - * @license http://opensource.org/licenses/BSD-3-Clause BSD Simplified - * - * @package RESTfulAPI - * @subpackage Permission - */ -class RESTfulAPI_GroupExtension extends DataExtension implements PermissionProvider -{ - /** - * Basic RESTfulAPI Permission set - * - * @return Array Default API permission set - */ - public function providePermissions() - { - return array( - 'RESTfulAPI_VIEW' => array( - 'name' => 'Access records through the RESTful API', - 'category' => 'RESTful API Access', - 'help' => 'Allow for a user to access/view record(s) through the API' - ), - 'RESTfulAPI_EDIT' => array( - 'name' => 'Edit records through the RESTful API', - 'category' => 'RESTful API Access', - 'help' => 'Allow for a user to submit a record changes through the API' - ), - 'RESTfulAPI_CREATE' => array( - 'name' => 'Create records through the RESTful API', - 'category' => 'RESTful API Access', - 'help' => 'Allow for a user to create a new record through the API' - ), - 'RESTfulAPI_DELETE' => array( - 'name' => 'Delete records through the RESTful API', - 'category' => 'RESTful API Access', - 'help' => 'Allow for a user to delete a record through the API' - ) - ); - } - - - /** - * Create the default Groups - * and add default admin to admin group - */ - public function requireDefaultRecords() - { - // Readers - $readersGroup = DataObject::get('Group')->filter(array( - 'Code' => 'restfulapi-readers' - )); - - if (!$readersGroup->count()) { - $readerGroup = new Group(); - $readerGroup->Code = 'restfulapi-readers'; - $readerGroup->Title = 'RESTful API Readers'; - $readerGroup->Sort = 0; - $readerGroup->write(); - Permission::grant($readerGroup->ID, 'RESTfulAPI_VIEW'); - } - - // Editors - $editorsGroup = DataObject::get('Group')->filter(array( - 'Code' => 'restfulapi-editors' - )); - - if (!$editorsGroup->count()) { - $editorGroup = new Group(); - $editorGroup->Code = 'restfulapi-editors'; - $editorGroup->Title = 'RESTful API Editors'; - $editorGroup->Sort = 0; - $editorGroup->write(); - Permission::grant($editorGroup->ID, 'RESTfulAPI_VIEW'); - Permission::grant($editorGroup->ID, 'RESTfulAPI_EDIT'); - Permission::grant($editorGroup->ID, 'RESTfulAPI_CREATE'); - } - - // Admins - $adminsGroup = DataObject::get('Group')->filter(array( - 'Code' => 'restfulapi-administrators' - )); - - if (!$adminsGroup->count()) { - $adminGroup = new Group(); - $adminGroup->Code = 'restfulapi-administrators'; - $adminGroup->Title = 'RESTful API Administrators'; - $adminGroup->Sort = 0; - $adminGroup->write(); - Permission::grant($adminGroup->ID, 'RESTfulAPI_VIEW'); - Permission::grant($adminGroup->ID, 'RESTfulAPI_EDIT'); - Permission::grant($adminGroup->ID, 'RESTfulAPI_CREATE'); - Permission::grant($adminGroup->ID, 'RESTfulAPI_DELETE'); - } - } -} diff --git a/code/permissionManager/RESTfulAPI_PermissionManager.php b/code/permissionManager/RESTfulAPI_PermissionManager.php deleted file mode 100644 index 4e49337..0000000 --- a/code/permissionManager/RESTfulAPI_PermissionManager.php +++ /dev/null @@ -1,25 +0,0 @@ - '%$RESTfulAPI_BasicDeSerializer' - ); - - - /** - * Search Filter Modifiers Separator used in the query var - * i.e. ?column__EndsWith=value - * - * @var string - * @config - */ - private static $searchFilterModifiersSeparator = '__'; - - - /** - * Query vars to skip (uppercased) - * - * @var array - * @config - */ - private static $skipedQueryParameters = array('URL', 'FLUSH', 'FLUSHTOKEN', 'TOKEN'); - - - /** - * Set a maximum numbers of records returned by the API. - * Only affectects "GET All". Useful to avoid returning millions of records at once. - * - * Set to -1 to disable. - * - * @var integer - * @config - */ - private static $max_records_limit = 100; - - - /** - * Stores the currently requested data - * - * @var array - */ - public $requestedData = array( - 'model' => null, - 'id' => null, - 'params' => null - ); - - - /** - * Return current RESTfulAPI DeSerializer instance - * - * @return RESTfulAPI_DeSerializer DeSerializer instance - */ - public function getdeSerializer() - { - return $this->deSerializer; - } - - - /** - * All requests pass through here and are redirected depending on HTTP verb and params - * - * @param SS_HTTPRequest $request HTTP request - * @return DataObjec|DataList DataObject/DataList result or stdClass on error - */ - public function handleQuery(SS_HTTPRequest $request) - { - //get requested model(s) details - $model = $request->param('ClassName'); - $id = $request->param('ID'); - $response = false; - $queryParams = $this->parseQueryParameters($request->getVars()); - - //validate Model name + store - if ($model) { - $model = $this->deSerializer->unformatName($model); - if (!class_exists($model)) { - return new RESTfulAPI_Error(400, - "Model does not exist. Received '$model'." - ); - } else { - //store requested model data and query data - $this->requestedData['model'] = $model; - } - } else { - //if model missing, stop + return blank object - return new RESTfulAPI_Error(400, - "Missing Model parameter." - ); - } - - //check API access rules on model - if (!RESTfulAPI::api_access_control($model, $request->httpMethod())) { - return new RESTfulAPI_Error(403, - "API access denied." - ); - } - - //validate ID + store - if (($request->isPUT() || $request->isDELETE()) && !is_numeric($id)) { - return new RESTfulAPI_Error(400, - "Invalid or missing ID. Received '$id'." - ); - } elseif ($id !== null && !is_numeric($id)) { - return new RESTfulAPI_Error(400, - "Invalid ID. Received '$id'." - ); - } else { - $this->requestedData['id'] = $id; - } - - //store query parameters - if ($queryParams) { - $this->requestedData['params'] = $queryParams; - } - - //map HTTP word to module method - switch ($request->httpMethod()) { - case 'GET': - return $this->findModel($model, $id, $queryParams, $request); - break; - case 'POST': - return $this->createModel($model, $request); - break; - case 'PUT': - return $this->updateModel($model, $id, $request); - break; - case 'DELETE': - return $this->deleteModel($model, $id, $request); - break; - default: - return new RESTfulAPI_Error(403, - "HTTP method mismatch." - ); - break; - } - } - - - /** - * Parse the query parameters to appropriate Column, Value, Search Filter Modifiers - * array( - * array( - * 'Column' => ColumnName, - * 'Value' => ColumnValue, - * 'Modifier' => ModifierType - * ) - * ) - * - * @param array $params raw GET vars array - * @return array formatted query parameters - */ - public function parseQueryParameters(array $params) - { - $parsedParams = array(); - $searchFilterModifiersSeparator = Config::inst()->get('RESTfulAPI_DefaultQueryHandler', 'searchFilterModifiersSeparator'); - - foreach ($params as $key__mod => $value) { - // skip url, flush, flushtoken - if (in_array(strtoupper($key__mod), Config::inst()->get('RESTfulAPI_DefaultQueryHandler', 'skipedQueryParameters'))) { - continue; - } - - $param = array(); - - $key__mod = explode( - $searchFilterModifiersSeparator, - $key__mod - ); - - $param['Column'] = $this->deSerializer->unformatName($key__mod[0]); - - $param['Value'] = $value; - - if (isset($key__mod[1])) { - $param['Modifier'] = $key__mod[1]; - } else { - $param['Modifier'] = null; - } - - array_push($parsedParams, $param); - } - - return $parsedParams; - } - - - /** - * Finds 1 or more objects of class $model - * - * Handles column modifiers: :StartsWith, :EndsWith, - * :PartialMatch, :GreaterThan, :LessThan, :Negation - * and query modifiers: sort, rand, limit - * - * @param string $model Model(s) class to find - * @param boolean|integr $id The ID of the model to find or false - * @param array $queryParams Query parameters and modifiers - * @param SS_HTTPRequest $request The original HTTP request - * @return DataObject|DataList Result of the search (note: DataList can be empty) - */ - public function findModel($model, $id = false, $queryParams, SS_HTTPRequest $request) - { - if ($id) { - $return = DataObject::get_by_id($model, $id); - - if (!$return) { - return new RESTfulAPI_Error(404, - "Model $id of $model not found." - ); - } elseif (!RESTfulAPI::api_access_control($return, $request->httpMethod())) { - return new RESTfulAPI_Error(403, - "API access denied." - ); - } - } else { - $return = DataList::create($model); - $singletonModel = singleton($model); - - if (count($queryParams) > 0) { - foreach ($queryParams as $param) { - if ($param['Column'] && $singletonModel->hasDatabaseField($param['Column'])) { - // handle sorting by column - if ($param['Modifier'] === 'sort') { - $return = $return->sort(array( - $param['Column'] => $param['Value'] - )); - } - // normal modifiers / search filters - elseif ($param['Modifier']) { - $return = $return->filter(array( - $param['Column'].':'.$param['Modifier'] => $param['Value'] - )); - } - // no modifier / search filter - else { - $return = $return->filter(array( - $param['Column'] => $param['Value'] - )); - } - } else { - // random - if ($param['Modifier'] === 'rand') { - // rand + seed - if ($param['Value']) { - $return = $return->sort('RAND('.$param['Value'].')'); - } - // rand only >> FIX: gen seed to avoid random result on relations - else { - $return = $return->sort('RAND('.time().')'); - } - } - // limits - elseif ($param['Modifier'] === 'limit') { - // range + offset - if (is_array($param['Value'])) { - $return = $return->limit($param['Value'][0], $param['Value'][1]); - } - // range only - else { - $return = $return->limit($param['Value']); - } - } - } - } - } - - //sets default limit if none given - $limits = $return->dataQuery()->query()->getLimit(); - $limitConfig = Config::inst()->get('RESTfulAPI_DefaultQueryHandler', 'max_records_limit'); - - if (is_array($limits) && !array_key_exists('limit', $limits) && $limitConfig >= 0) { - $return = $return->limit($limitConfig); - } - } - - return $return; - } - - - /** - * Create object of class $model - * - * @param string $model - * @param SS_HTTPRequest $request - * @return DataObject - */ - public function createModel($model, SS_HTTPRequest $request) - { - if (!RESTfulAPI::api_access_control($model, $request->httpMethod())) { - return new RESTfulAPI_Error(403, - "API access denied." - ); - } - - $newModel = Injector::inst()->create($model); - - return $this->updateModel($newModel, $newModel->ID, $request); - } - - - /** - * Update databse record or $model - * - * @param String|DataObject $model the model or class to update - * @param Integer $id The ID of the model to update - * @param SS_HTTPRequest the original request - * - * @return DataObject The updated model - */ - public function updateModel($model, $id, $request) - { - if (is_string($model)) { - $model = DataObject::get_by_id($model, $id); - } - - if (!$model) { - return new RESTfulAPI_Error(404, - "Record not found." - ); - } - - if (!RESTfulAPI::api_access_control($model, $request->httpMethod())) { - return new RESTfulAPI_Error(403, - "API access denied." - ); - } - - $rawJson = $request->getBody(); - - // Before deserialize hook - if (method_exists($model, 'onBeforeDeserialize')) { - $model->onBeforeDeserialize($rawJson); - } - $model->extend('onBeforeDeserialize', $rawJson); - - $payload = $this->deSerializer->deserialize($rawJson); - if ($payload instanceof RESTfulAPI_Error) { - return $payload; - } - - // After deserialize hook - if (method_exists($model, 'onAfterDeserialize')) { - $model->onAfterDeserialize($payload); - } - $model->extend('onAfterDeserialize', $payload); - - if ($model && $payload) { - $has_one = Config::inst()->get($model->ClassName, 'has_one'); - $has_many = Config::inst()->get($model->ClassName, 'has_many'); - $many_many = Config::inst()->get($model->ClassName, 'many_many'); - $belongs_many_many = Config::inst()->get($model->ClassName, 'belongs_many_many'); - - $many_many_extraFields = array(); - - if (isset($payload['ManyManyExtraFields'])) { - $many_many_extraFields = $payload['ManyManyExtraFields']; - unset($payload['ManyManyExtraFields']); - } - - $hasChanges = false; - $hasRelationChanges = false; - - foreach ($payload as $attribute => $value) { - if (!is_array($value)) { - if (is_array($has_one) && array_key_exists($attribute, $has_one)) { - $relation = $attribute . 'ID'; - $model->$relation = $value; - $hasChanges = true; - } elseif ($model->{$attribute} != $value) { - $model->{$attribute} = $value; - $hasChanges = true; - } - } else { - //has_many, many_many or $belong_many_many - if ((is_array($has_many) && array_key_exists($attribute, $has_many)) - || (is_array($many_many) && array_key_exists($attribute, $many_many)) - || (is_array($belongs_many_many) && array_key_exists($attribute, $belongs_many_many)) - ) { - $hasRelationChanges = true; - $ssList = $model->{$attribute}(); - $ssList->removeAll(); //reset list - foreach ($value as $id) { - // check if there is extraFields - if (array_key_exists($attribute, $many_many_extraFields)) { - if (isset($many_many_extraFields[$attribute][$id])) { - $ssList->add($id, $many_many_extraFields[$attribute][$id]); - continue; - } - } - - $ssList->add($id); - } - } - } - } - - if ($hasChanges || !$model->ID) { - try { - $id = $model->write(false, false, false, $hasRelationChanges); - } catch (ValidationException $exception) { - $error = $exception->getResult(); - return new RESTfulAPI_Error(400, - $error->message() - ); - } - - if (!$id) { - return new RESTfulAPI_Error(500, - "Error writting data." - ); - } else { - return DataObject::get_by_id($model->ClassName, $id); - } - } else { - return $model; - } - } else { - return new RESTfulAPI_Error(400, - "Missing model or payload." - ); - } - } - - - /** - * Delete object of Class $model and ID $id - * - * @todo Respond with a 204 status message on success? - * - * @param string $model Model class - * @param integer $id Model ID - * @param SS_HTTPRequest $request Model ID - * @return NULL|array NULL if successful or array with error detail - */ - public function deleteModel($model, $id, SS_HTTPRequest $request) - { - if ($id) { - $object = DataObject::get_by_id($model, $id); - - if ($object) { - if (!RESTfulAPI::api_access_control($object, $request->httpMethod())) { - return new RESTfulAPI_Error(403, - "API access denied." - ); - } - - $object->delete(); - } else { - return new RESTfulAPI_Error(404, - "Record not found." - ); - } - } else { - //shouldn't happen but just in case - return new RESTfulAPI_Error(400, - "Invalid or missing ID. Received '$id'." - ); - } - - return null; - } -} diff --git a/code/queryHandlers/RESTfulAPI_QueryHandler.php b/code/queryHandlers/RESTfulAPI_QueryHandler.php deleted file mode 100644 index 063a769..0000000 --- a/code/queryHandlers/RESTfulAPI_QueryHandler.php +++ /dev/null @@ -1,31 +0,0 @@ -unformatPayloadData($data); - } else { - return new RESTfulAPI_Error(400, - 'Malformed JSON payload.' - ); - } - - return $data; - } - - - /** - * Process payload data from client - * and unformats columns/values recursively - * - * @param array $data Payload data (decoded JSON) - * @return array Paylaod data with all keys/values unformatted - */ - protected function unformatPayloadData(array $data) - { - $unformattedData = array(); - - foreach ($data as $key => $value) { - $newKey = $this->deserializeColumnName($key); - - if (is_array($value)) { - $newValue = $this->unformatPayloadData($value); - } else { - $newValue = $value; - } - - $unformattedData[$newKey] = $newValue; - } - - return $unformattedData; - } - - - /** - * Format a ClassName or Field name sent by client API - * to be used by SilverStripe - * - * @param string $name ClassName of Field name - * @return string Formatted name - */ - public function unformatName($name) - { - $class = Inflector::singularize($name); - $class = ucfirst($class); - - if (ClassInfo::exists($class)) { - return $class; - } else { - $name = $this->deserializeColumnName($name); - } - - return $name; - } - - - /** - * Format a DB Column name or Field name - * sent from client API to be used by SilverStripe - * - * @param string $name Field name - * @return string Formatted name - */ - private function deserializeColumnName($name) - { - $name = preg_replace('/(.*)ID(s)?$/i', '$1ID', $name); - $name = ucfirst($name); - - return $name; - } -} diff --git a/code/serializers/EmberData/RESTfulAPI_EmberDataSerializer.php b/code/serializers/EmberData/RESTfulAPI_EmberDataSerializer.php deleted file mode 100644 index 1e8af38..0000000 --- a/code/serializers/EmberData/RESTfulAPI_EmberDataSerializer.php +++ /dev/null @@ -1,294 +0,0 @@ - array('RelationNameToSideLoad', 'Another') - * - * Non sideloaded response: - * { - * 'member': { - * 'name': 'John', - * 'favourites': [1, 2] - * } - * } - * - * Response with sideloaded records: - * { - * 'member': { - * 'name': 'John', - * 'favourites': [1, 2] - * }, - * - * favourites': [{ - * 'id': 1, - * 'name': 'Mark' - * },{ - * 'id': 2, - * 'name': 'Maggie' - * }] - * } - * - * Try not to use in conjunction with {@link RESTfulAPI} $embedded_records - * with the same settings. - * - * @var array - * @config - */ - private static $sideloaded_records; - - - /** - * Stores the current $sideloaded_records config - * - * @var array - */ - protected $sideloadedRecords; - - - /** - * Construct and set current config - */ - public function __construct() - { - parent::__construct(); - - $sideloaded_records = Config::inst()->get('RESTfulAPI_EmberDataSerializer', 'sideloaded_records'); - if (is_array($sideloaded_records)) { - $this->sideloadedRecords = $sideloaded_records; - } else { - $this->sideloadedRecords = array(); - } - } - - - /** - * Convert raw data (DataObject or DataList) to JSON - * ready to be consumed by the client API - * - * @param DataObject|DataList $data Data to serialize - * @return string JSON representation of data - */ - public function serialize($data) - { - $json = ''; - $formattedData = null; - - if ($data instanceof DataObject) { - $dataClass = $data->ClassName; - $rootClassName = $this->formatName($dataClass); - $formattedData = $this->formatDataObject($data); - } elseif ($data instanceof DataList) { - $dataClass = $data->dataClass; - $rootClassName = $this->formatName($data->dataClass); - $rootClassName = Inflector::pluralize($rootClassName); - $formattedData = $this->formatDataList($data); - } - - if ($formattedData !== null) { - $root = new stdClass(); - $root->{$rootClassName} = $formattedData; - - // if it's not an empty response >> check if we should be sideloading some data - if ($formattedData && $this->hasSideloadedRecords($dataClass)) { - $root = $this->insertSideloadData($root, $data); - } - - $json = $this->jsonify($root); - } else { - //fallback: convert non array to object then encode - if (!is_array($data)) { - $data = (object) $data; - } - $json = $this->jsonify($data); - } - - return $json; - } - - - /** - * Format a SilverStripe ClassName or Field name - * to be used by the client API - * - * @param string $name ClassName of DBField name - * @return string Formatted name - */ - public function formatName($name) - { - $class = Inflector::singularize($name); - - if (ClassInfo::exists($class)) { - $name = lcfirst($class); - } else { - $name = $this->serializeColumnName($name); - } - - return $name; - } - - - /** - * Format a DB Column name or Field name - * to be used by the client API - * - * @param string $name Field name - * @return string Formatted name - */ - protected function serializeColumnName($name) - { - if ($name === 'ID') { - $name = strtolower($name); - } else { - $name = lcfirst($name); - } - - return $name; - } - - - /** - * Check if a specific class requires data to be sideloaded. - * - * @param string $classname Requested data classname - * @return boolean True if some relations should be sideloaded - */ - protected function hasSideloadedRecords($classname) - { - return array_key_exists($classname, $this->sideloadedRecords); - } - - - /** - * Fetches and return all the data that need to be sideloaded - * for a specific source DataObject or DataList. - * - * @param DataObject|DataList $dataSource The source data to fetch sideloaded records for - * @return array A map of relation names with their data - */ - protected function getSideloadData($dataSource) - { - $data = array(); - - if ($dataSource instanceof DataObject) { - $has_one = $dataSource->stat('has_one'); - $has_many = $dataSource->stat('has_many'); - $many_many = $dataSource->stat('many_many'); - $belongs_many_many = $dataSource->stat('belongs_many_many'); - - $relationsMap = array(); - if (is_array($has_one)) { - $relationsMap = array_merge($relationsMap, $has_one); - } - if (is_array($has_many)) { - $relationsMap = array_merge($relationsMap, $has_many); - } - if (is_array($many_many)) { - $relationsMap = array_merge($relationsMap, $many_many); - } - if (is_array($belongs_many_many)) { - $relationsMap = array_merge($relationsMap, $belongs_many_many); - } - - // if a single DataObject get the data for each relation - foreach ($this->sideloadedRecords[$dataSource->ClassName] as $relationName) { - $newData = $this->getEmbedData($dataSource, $relationName); - - if ($newData !== null) { - // has_one are only simple array and we want arrays or array - if (is_array($has_one) && in_array($relationName, $has_one)) { - $newData = array($newData); - } - - $relationClass = $relationsMap[$relationName]; - $data[$relationClass] = $newData; - } - } - } elseif ($dataSource instanceof DataList) { - // if a list of DataObject, loop through each and merge all the data together - foreach ($dataSource as $dataObjectSource) { - $sideloadData = $this->getSideloadData($dataObjectSource); - - //combine sideloaded records - foreach ($sideloadData as $class => $extraData) { - if (!isset($data[$class])) { - $data[$class] = array(); - } - - // merge for list of records, push for individual record - $arrayOfArrays = array_filter($extraData, 'is_array'); - if (count($arrayOfArrays) == count($extraData)) { - //print_r('array_merge'); - $data[$class] = array_merge($data[$class], $extraData); - } else { - //print_r('array_push'); - array_push($data[$class], $extraData); - } - } - } - - // remove duplicates - foreach ($data as $relationClass => $relationData) { - $data[$relationClass] = array_unique($relationData, SORT_REGULAR); - } - } - - return $data; - } - - - /** - * Take a root object ready to be converted into JSON - * and an original data source (DataObject OR DataList) - * and insorts into the root object all relation records - * that should be sideloaded. - * - * @param stdClass $root Root object ready to become JSON - * @param DataObject|DataList $dataSource The original data set from the root object - * @return stdClass The updated root object sith the sideloaded data attached - */ - protected function insertSideloadData(stdClass $root, $dataSource) - { - // get the extra data - $sideloadData = $this->getSideloadData($dataSource); - - // attached those to the root - foreach ($sideloadData as $relationClass => $relationData) { - $rootRelationClass = $this->formatName($relationClass); - - // pluralize only set of records - $allArrays = array_filter($relationData, 'is_array'); - if (count($allArrays) == count($relationData)) { - $rootRelationClass = Inflector::pluralize($rootRelationClass); - } - - // attach to root - $root->{$rootRelationClass} = $relationData; - } - - return $root; - } -} diff --git a/composer.json b/composer.json index a857f6f..27d1567 100644 --- a/composer.json +++ b/composer.json @@ -1,19 +1,28 @@ { - "name": "colymba/silverstripe-restfulapi", - "type": "silverstripe-module", - "description": "SilverStripe 3 RESTful API with a default JSON serializer.", - "homepage": "https://github.com/colymba/silverstripe-model-serializer", - "keywords": ["silverstripe", "api", "REST", "REST API", "RESTful", "json api", "model serializer", "REST server", "RESTful server"], - "license": "BSD Simplified", - "authors": [{ - "name": "Thierry Francois", - "homepage": "http://colymba.com" - }], - "repositories": [{ - "type": "vcs", - "url": "git@github.com:colymba/silverstripe-restfulapi.git" - }], - "require": { - "silverstripe/framework": "~3.1" - } -} \ No newline at end of file + "name": "colymba/silverstripe-restfulapi", + "type": "silverstripe-vendormodule", + "description": "SilverStripe 3 RESTful API with a default JSON serializer.", + "homepage": "https://github.com/colymba/silverstripe-model-serializer", + "keywords": ["silverstripe", "api", "REST", "REST API", "RESTful", "json api", "model serializer", "REST server", "RESTful server"], + "license": "BSD Simplified", + "authors": [{ + "name": "Thierry Francois", + "homepage": "http://colymba.com" + }], + "repositories": [{ + "type": "vcs", + "url": "git@github.com:colymba/silverstripe-restfulapi.git" + }], + "require": { + "silverstripe/framework": "~4.1" + }, + "require-dev": { + "phpunit/phpunit": "~5.7@stable" + }, + "autoload": { + "psr-4": { + "Colymba\\RESTfulAPI\\": "src/", + "Colymba\\RESTfulAPI\\Tests\\": "tests/" + } + } +} diff --git a/doc/DefaultPermissionManager.md b/doc/DefaultPermissionManager.md index c8110cf..ce5db3c 100644 --- a/doc/DefaultPermissionManager.md +++ b/doc/DefaultPermissionManager.md @@ -1,10 +1,10 @@ -# RESTfulAPI_DefaultPermissionManager +# PermissionManagers\DefaultPermissionManager -This component will check access permission against a DataObject for a given Member. The request HTTP method, will be match against a DataObject's `can()` method. +This component will check access permission against a DataObject for a given Member. The request HTTP method, will be match against a DataObject's `can()` method. Config | Type | Info | Default --- | :---: | --- | --- `n/a` | `n/a` | n/a | n/a -Permission checks should be implemented on your DataObject with the `canView`, `canCreate`, `canEdit`, `canDelete` methods. See SilverStripe [documentation](http://doc.silverstripe.org/framework/en/reference/dataobject#permissions) for more information. \ No newline at end of file +Permission checks should be implemented on your DataObject with the `canView`, `canCreate`, `canEdit`, `canDelete` methods. See SilverStripe [documentation](http://doc.silverstripe.org/framework/en/reference/dataobject#permissions) for more information. diff --git a/doc/DefaultQueryHandler.md b/doc/DefaultQueryHandler.md index 8054120..5ba4967 100644 --- a/doc/DefaultQueryHandler.md +++ b/doc/DefaultQueryHandler.md @@ -1,14 +1,14 @@ -# RESTfulAPI_DefaultQueryHandler +# QueryHandlers\DefaultQueryHandler This component handles database queries, utilize the deserializer to figure out models and column names and returns the data to the RESTfulAPI. Config | Type | Info | Default --- | :---: | --- | --- -`dependencies` | `array` | key => value pair specifying which deserializer to use | 'deSerializer' => '%$RESTfulAPI_BasicDeSerializer' -`searchFilterModifiersSeparator` | `string` | Separator used in HTTP params between the column name and the search filter modifier (e.g. ?name__StartsWith=Henry will find models with the column name that starts with 'Henry'. ORM equivalent *->filter(array('name::StartsWith' => 'Henry'))* ) | '__' -`skipedQueryParameters` | `array` | Uppercased query params that would not parsed as column names (uppercased) | 'URL', 'FLUSH', 'FLUSHTOKEN' -`max_records_limit` | `int` | specify the maximum number of records to return by default (avoid the api returning millions...) | 100 - +| `dependencies` | `array` | key => value pair specifying which deserializer to use | 'deSerializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultDeSerializer' +| `searchFilterModifiersSeparator` | `string` | Separator used in HTTP params between the column name and the search filter modifier (e.g. ?name__StartsWith=Henry will find models with the column name that starts with 'Henry'. ORM equivalent *->filter(array('name::StartsWith' => 'Henry'))* ) | '__' +| `skipedQueryParameters` | `array` | Uppercased query params that would not parsed as column names (uppercased) | 'URL', 'FLUSH', 'FLUSHTOKEN' +| `max_records_limit` | `int` | specify the maximum number of records to return by default (avoid the api returning millions...) | 100 +| `models` | `array` | Array of mappings of URL segments to class names | [] ## Search filter modifiers This also accept search filter modifiers in HTTP variables (see [Search Filter Modifiers](http://doc.silverstripe.org/framework/en/topics/datamodel#search-filter-modifiers)) like: @@ -23,13 +23,26 @@ As well as special modifiers `sort`, `rand` and `limit` with these possible form Search filter modifiers are recognised/extracted thanks to the `searchFilterModifiersSeparator` config. The above examples assume the default `searchFilterModifiersSeparator` is in use. +## Model mappings + +Using the `models` configuration option it is possible to map the URL segment that follows `/api/` to a particular model class name. This can be used to override the default behaviour which will use a lower cased version of the model name, for example `Member` will become `/api/member`. + +It is a requirement to use this mapping when exposing namespaced classes because they do not map to a single URL segment. + +Example: +```yaml +Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler: + models: + member: SilverStripe\Security\Member +``` + ## Hooks Model hooks are available on both serialization and deserialization. These can be used to control what of the model data gets serialized (eg. sent to the client) or what will gets written into the model after deserialization. Here are the available callbacks (can be directly implemented on the `DataObject` or in a `DataExtension`) -Signature | Parameter type | Info +Signature | Parameter type | Info --- | :---: | --- `onBeforeSerialize()` | `void` | Called before the model is being serialized. You can set fields to `null` or use `unset` if you don't want them to be serialized. `onAfterSerialize(&$data)` | `array` | Called after the model has been serialized. This is the complete dataset that will be converted to JSON and sent to the client. You can use `unset` and/or add fields to the data, just like with a regular array. diff --git a/doc/BasicSerializer.md b/doc/DefaultSerializer.md similarity index 92% rename from doc/BasicSerializer.md rename to doc/DefaultSerializer.md index 03efd3f..89330bd 100644 --- a/doc/BasicSerializer.md +++ b/doc/DefaultSerializer.md @@ -1,4 +1,4 @@ -# RESTfulAPI_BasicSerializer & RESTfulAPI_BasicDeSerializer +# Serializers\DefaultSerializer & Serializers\DefaultDeSerializer This component will serialize the data returned by the QueryHandler into JSON. No special formatting is performed on the JSON output (column names are returned as is), DataObject are returns as objects {} and DataLists as array or objects [{},{}]. @@ -55,7 +55,7 @@ class BookAuthorApiExtension extends DataExtension */ public function onBeforeSerialize() { - Config::inst()->update('Author', 'api_fields', array('Name')); + Config::inst()->update(Author::class, 'api_fields', array('Name')); } } ``` diff --git a/doc/EmberDataSerializer.md b/doc/EmberDataSerializer.md deleted file mode 100644 index d934ab0..0000000 --- a/doc/EmberDataSerializer.md +++ /dev/null @@ -1,34 +0,0 @@ -# RESTfulAPI_EmberDataSerializer & RESTfulAPI_EmberDataDeSerializer - -**This component was specificly create to work with Ember.js EmberData out of the box.** - -These components are extension of the `BasicSerializer` and `BasicDeSerializer`, they perform the same actions but follow different formatting conventions: -* SilverStripe class names and column name are UpperCamelCase -* the client consumes lowerCamelCase models and columns - -Config | Type | Info | Default ---- | :---: | --- | --- -`sideloaded_records` | array` | key => value pairs like for embedded records, but this time specifies which relations should have its records sideloaded | n/a - - -## Embedded records - -This serializer will use the `RESTfulAPI` `embedded_records` config. - - -## Sideloaded records - -This as the same advantage as embedded records and is configured in the same way and uses some of its logic. The different is that the requested model JSON object will still have IDs in its relations value, and the relation records will be added to the JSON root under their own key. - -[See source comment](https://github.com/colymba/silverstripe-restfulapi/blob/master/code/serializers/EmberData/RESTfulAPI_EmberDataSerializer.php#L61) for more details. - - -## Hooks - -You can define an `onBeforeSerialize()` function on your model to add/remove field to your model before being serialized (i.e. remove Password from Member). - - -## See also -* [JSON API](http://jsonapi.org) -* [Ember JS](https://github.com/emberjs/ember.js) -* [Ember Data](https://github.com/emberjs/data) \ No newline at end of file diff --git a/doc/RESTfulAPI.md b/doc/RESTfulAPI.md index ee84b02..a85f24b 100644 --- a/doc/RESTfulAPI.md +++ b/doc/RESTfulAPI.md @@ -9,19 +9,19 @@ Director: 'restapi': 'RESTfulAPI' ``` -Config | Type | Info | Default ---- | :---: | --- | --- -`authentication_policy` | `boolean`/`array` | If true, the API will use authentication, if false|null no authentication required. Or an array of HTTP methods that require authentication | false -`access_control_policy` | `boolean`/`string` | Lets you select which access control checks the API will perform or none at all. | 'ACL_CHECK_CONFIG_ONLY' -`dependencies` | `array` | key => value pairs sepcifying the components classes used for the `'authenticator'`, `'queryHandler'` and `'serializer'` | 'authenticator' => '%$RESTfulAPI_TokenAuthenticator', 'queryHandler' => '%$RESTfulAPI_DefaultQueryHandler', 'serializer' => '%$RESTfulAPI_BasicSerializer' -`embedded_records` | `array` | key => value pairs sepcifying which relation names to embed in the response and for which model this applies (i.e. 'RequestedClass' => array('RelationNameToEmbed')) | n/a -- | - | - | - -`cors` | `array` | Cross-Origin Resource Sharing (CORS) API settings | -`cors.Enabled` | `boolean` | If true the API will add CORS HTTP headers to the response | true -`cors.Allow-Origin` | `string` or `array` | '\*' allows all, 'http://domain.com' allows a specific domain, array('http://domain.com', 'http://site.com') allows a list of domains | '\*' -`cors.Allow-Headers` | `string` | '\*' allows all, 'header1, header2' coman separated list allows a list of headers | '\*' -`cors.Allow-Methods` | `string` | 'HTTPMETHODE1, HTTPMETHODE12' coma separated list of HTTP methodes to allow | 'OPTIONS, POST, GET, PUT, DELETE' -`cors.Max-Age` | `integer` | Preflight/OPTIONS request caching time in seconds | 86400 +| Config | Type | Info | Default +| --- | :---: | --- | --- +| `authentication_policy` | `boolean`/`array` | If true, the API will use authentication, if false|null no authentication required. Or an array of HTTP methods that require authentication | false +| `access_control_policy` | `boolean`/`string` | Lets you select which access control checks the API will perform or none at all. | 'ACL_CHECK_CONFIG_ONLY' +| `dependencies` | `array` | key => value pairs sepcifying the components classes used for the `'authenticator'`, `'queryHandler'` and `'serializer'` | 'authenticator' => '%$Colymba\RESTfulAPI\Authenticators\TokenAuthenticator', 'queryHandler' => '%$Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler', 'serializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultSerializer' +| `embedded_records` | `array` | key => value pairs sepcifying which relation names to embed in the response and for which model this applies (i.e. 'RequestedClass' => array('RelationNameToEmbed')) | n/a +| - | - | - | - +| `cors` | `array` | Cross-Origin Resource Sharing (CORS) API settings | +| `cors.Enabled` | `boolean` | If true the API will add CORS HTTP headers to the response | true +| `cors.Allow-Origin` | `string` or `array` | '\*' allows all, 'http://domain.com' allows a specific domain, array('http://domain.com', 'http://site.com') allows a list of domains | '\*' +| `cors.Allow-Headers` | `string` | '\*' allows all, 'header1, header2' coman separated list allows a list of headers | '\*' +| `cors.Allow-Methods` | `string` | 'HTTPMETHODE1, HTTPMETHODE12' coma separated list of HTTP methodes to allow | 'OPTIONS, POST, GET, PUT, DELETE' +| `cors.Max-Age` | `integer` | Preflight/OPTIONS request caching time in seconds | 86400 ## CORS (Cross-Origin Resource Sharing) @@ -43,14 +43,14 @@ See the [api_access_control()](../code/RESTfulAPI.php#L519) function for more de ### Access control considerations -The API (with default components) will call the `api_access_control` method (making any configured checks) for each `find`, `create`, `update`, `delete` operations as well as during serialization of the data. Using a `RESTfulAPI_PermissionManager` may impact performace and you should concider carefully your permission sets to avoid unexpected results. +The API (with default components) will call the `api_access_control` method (making any configured checks) for each `find`, `create`, `update`, `delete` operations as well as during serialization of the data. Using a `PermissionManager` may impact performace and you should concider carefully your permission sets to avoid unexpected results. -Note that when coonfiguring `api_access_control` to do checks on the DataObject level via a `RESTfulAPI_PermissionManager`, the Member model passed to the Permission Manager is the one returned by the `RESTfulAPI_Authenticator` `getOwner()` method. If the returned owner isn't an instance of Member, `null` will be passed instead. +Note that when configuring `api_access_control` to do checks on the DataObject level via a `PermissionManager`, the Member model passed to the Permission Manager is the one returned by the `Authenticator` `getOwner()` method. If the returned owner isn't an instance of Member, `null` will be passed instead. -A sample `Group` extension `RESTfulAPI_GroupExtension` is also available with a basic set of dedicated API permissions and User Groups. This can be enabled via [config](../code/_config/config.yml#L11) or you can create your own. +A sample `Group` extension `Colymba\RESTfulAPI\Extensions\GroupExtension` is also available with a basic set of dedicated API permissions and User Groups. This can be enabled via [config](../code/_config/config.yml#L11) or you can create your own. ## Embedded records By default on the IDs of relations (has_one, has_many...) are returned to the client. To save HTTP request, these relation can be embedded into the payload, this is defined by the `embedded_records` config and used by the serializers. -For more details about embeded records, [see the source comment](../code/RESTfulAPI.php#L106) on the config var. \ No newline at end of file +For more details about embeded records, [see the source comment](../code/RESTfulAPI.php#L106) on the config var. diff --git a/doc/TokenAuthenticator.md b/doc/TokenAuthenticator.md index de6f03b..48b078a 100644 --- a/doc/TokenAuthenticator.md +++ b/doc/TokenAuthenticator.md @@ -1,12 +1,12 @@ -# RESTfulAPI_TokenAuthenticator +# Authenticators\TokenAuthenticator This component takes care of authenticating all API requests against a token stored in a HTTP header or a query var as fallback. The authentication token is returned by the `login` function. Also available, a `logout` function and `lostpassword` function that will email a password reset link to the user. -The token can also be retrieved with an `RESTfulAPI_TokenAuthenticator` instance calling the method `getToken()` and it can be reset via `resetToken()`. +The token can also be retrieved with an `TokenAuthenticator` instance calling the method `getToken()` and it can be reset via `resetToken()`. -The `RESTfulAPI_TokenAuthExtension` `DataExtension` must be applied to a `DataObject` and the `tokenOwnerClass` config updated with the correct classname. +The `TokenAuthExtension` `DataExtension` must be applied to a `DataObject` and the `tokenOwnerClass` config updated with the correct classname. Config | Type | Info | Default --- | :---: | --- | --- @@ -17,19 +17,19 @@ Config | Type | Info | Default `autoRefreshLifetime` | `boolean` | Whether or not token lifetime should be updated with every request | false -## Token Authentication Data Extension `RESTfulAPI_TokenAuthExtension` -This extension **MUST** be applied to a `DataObject` to use `RESTfulAPI_TokenAuthenticator` and update the `tokenOwnerClass` config accordingly. e.g. +## Token Authentication Data Extension `Colymba\RESTfulAPI\Extensions\TokenAuthExtension` +This extension **MUST** be applied to a `DataObject` to use `TokenAuthenticator` and update the `tokenOwnerClass` config accordingly. e.g. ```yaml Member: extensions: - - RESTfulAPI_TokenAuthExtension + - Colymba\RESTfulAPI\Extensions\TokenAuthExtension ``` ```yaml ApiUser: extensions: - - RESTfulAPI_TokenAuthExtension -RESTfulAPI_TokenAuthenticator: + - Colymba\RESTfulAPI\Extensions\TokenAuthExtension +TokenAuthenticator: tokenOwnerClass: 'ApiUser' ``` -The `$db` keys can be changed to anything you want but keep the types to `Varchar(160)` and `Int`. \ No newline at end of file +The `$db` keys can be changed to anything you want but keep the types to `Varchar(160)` and `Int`. diff --git a/phpunit.xml b/phpunit.xml index f7dc03d..4867919 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,47 +1,26 @@ - - - - - tests - - - - - - - - - sanitychecks - - - \ No newline at end of file + + + tests + + + + CORSPreflight + + + diff --git a/src/Authenticators/Authenticator.php b/src/Authenticators/Authenticator.php new file mode 100644 index 0000000..58d11e1 --- /dev/null +++ b/src/Authenticators/Authenticator.php @@ -0,0 +1,35 @@ +get(self::class, 'tokenLife'); + $config['header'] = $configInstance->get(self::class, 'tokenHeader'); + $config['queryVar'] = $configInstance->get(self::class, 'tokenQueryVar'); + $config['owner'] = $configInstance->get(self::class, 'tokenOwnerClass'); + $config['autoRefresh'] = $configInstance->get(self::class, 'autoRefreshLifetime'); + + $tokenDBColumns = $configInstance->get(TokenAuthExtension::class, 'db'); + $tokenDBColumn = array_search('Varchar(160)', $tokenDBColumns); + $expireDBColumn = array_search('Int', $tokenDBColumns); + + if ($tokenDBColumn !== false) { + $config['DBColumn'] = $tokenDBColumn; + } else { + $config['DBColumn'] = 'ApiToken'; + } + + if ($expireDBColumn !== false) { + $config['expireDBColumn'] = $expireDBColumn; + } else { + $config['expireDBColumn'] = 'ApiTokenExpire'; + } + + $this->tokenConfig = $config; + } + + /** + * Login a user into the Framework and generates API token + * Only works if the token owner is a Member + * + * @param HTTPRequest $request HTTP request containing 'email' & 'pwd' vars + * @return array login result with token + */ + public function login(HTTPRequest $request) + { + $response = array(); + + if ($this->tokenConfig['owner'] === Member::class) { + $email = $request->requestVar('email'); + $pwd = $request->requestVar('pwd'); + $member = false; + + if ($email && $pwd) { + $member = Injector::inst()->get(MemberAuthenticator::class)->authenticate( + array( + 'Email' => $email, + 'Password' => $pwd, + ), + $request + ); + if ($member) { + $tokenData = $this->generateToken(); + + $tokenDBColumn = $this->tokenConfig['DBColumn']; + $expireDBColumn = $this->tokenConfig['expireDBColumn']; + + $member->{$tokenDBColumn} = $tokenData['token']; + $member->{$expireDBColumn} = $tokenData['expire']; + $member->write(); + $member->login(); + } + } + + if (!$member) { + $response['result'] = false; + $response['message'] = 'Authentication fail.'; + $response['code'] = self::AUTH_CODE_LOGIN_FAIL; + } else { + $response['result'] = true; + $response['message'] = 'Logged in.'; + $response['code'] = self::AUTH_CODE_LOGGED_IN; + $response['token'] = $tokenData['token']; + $response['expire'] = $tokenData['expire']; + $response['userID'] = $member->ID; + } + } + + return $response; + } + + /** + * Logout a user from framework + * and update token with an expired one + * if token owner class is a Member + * + * @param HTTPRequest $request HTTP request containing 'email' var + */ + public function logout(HTTPRequest $request) + { + $email = $request->requestVar('email'); + $member = Member::get()->filter(array('Email' => $email))->first(); + + if ($member) { + //logout + $member->logout(); + + if ($this->tokenConfig['owner'] === Member::class) { + //generate expired token + $tokenData = $this->generateToken(true); + + //write + $tokenDBColumn = $this->tokenConfig['DBColumn']; + $expireDBColumn = $this->tokenConfig['expireDBColumn']; + + $member->{$tokenDBColumn} = $tokenData['token']; + $member->{$expireDBColumn} = $tokenData['expire']; + $member->write(); + } + } + } + + /** + * Sends password recovery email + * + * @param HTTPRequest $request HTTP request containing 'email' vars + * @return array 'email' => false if email fails (Member doesn't exist will not be reported) + */ + public function lostPassword(HTTPRequest $request) + { + $email = Convert::raw2sql($request->requestVar('email')); + $member = DataObject::get_one(Member::class, "\"Email\" = '{$email}'"); + + if ($member) { + $token = $member->generateAutologinTokenAndStoreHash(); + + $link = Security::lost_password_url(); + $lostPasswordHandler = new LostPasswordHandler($link); + + $lostPasswordHandler->sendEmail($member, $token); + } + + return array('done' => true); + } + + /** + * Return the stored API token for a specific owner + * + * @param integer $id ID of the token owner + * @return string API token for the owner + */ + public function getToken($id) + { + if ($id) { + $ownerClass = $this->tokenConfig['owner']; + $owner = DataObject::get_by_id($ownerClass, $id); + + if ($owner) { + $tokenDBColumn = $this->tokenConfig['DBColumn']; + return $owner->{$tokenDBColumn}; + } else { + user_error("API Token owner '$ownerClass' not found with ID = $id", E_USER_WARNING); + } + } else { + user_error("TokenAuthenticator::getToken() requires an ID as argument.", E_USER_WARNING); + } + } + + /** + * Reset an owner's token + * if $expired is set to true the owner's will have a new invalidated/expired token + * + * @param integer $id ID of the token owner + * @param boolean $expired if true the token will be invalidated + */ + public function resetToken($id, $expired = false) + { + if ($id) { + $ownerClass = $this->tokenConfig['owner']; + $owner = DataObject::get_by_id($ownerClass, $id); + + if ($owner) { + //generate token + $tokenData = $this->generateToken($expired); + + //write + $tokenDBColumn = $this->tokenConfig['DBColumn']; + $expireDBColumn = $this->tokenConfig['expireDBColumn']; + + $owner->{$tokenDBColumn} = $tokenData['token']; + $owner->{$expireDBColumn} = $tokenData['expire']; + $owner->write(); + } else { + user_error("API Token owner '$ownerClass' not found with ID = $id", E_USER_WARNING); + } + } else { + user_error("TokenAuthenticator::resetToken() requires an ID as argument.", E_USER_WARNING); + } + } + + /** + * Generates an encrypted random token + * and an expiry date + * + * @param boolean $expired Set to true to generate an outdated token + * @return array token data array('token' => HASH, 'expire' => EXPIRY_DATE) + */ + private function generateToken($expired = false) + { + $life = $this->tokenConfig['life']; + + if (!$expired) { + $expire = time() + $life; + } else { + $expire = time() - ($life * 2); + } + + $generator = new RandomGenerator(); + $tokenString = $generator->randomToken(); + + $e = PasswordEncryptor::create_for_algorithm('blowfish'); //blowfish isn't URL safe and maybe too long? + $salt = $e->salt($tokenString); + $token = $e->encrypt($tokenString, $salt); + + return array( + 'token' => substr($token, 7), + 'expire' => $expire, + ); + } + + /** + * Returns the DataObject related to the token + * that sent the authenticated request + * + * @param HTTPRequest $request HTTP API request + * @return null|DataObject null if failed or the DataObject token owner related to the request + */ + public function getOwner(HTTPRequest $request) + { + $owner = null; + + //get the token + $token = $request->getHeader($this->tokenConfig['header']); + if (!$token) { + $token = $request->requestVar($this->tokenConfig['queryVar']); + } + + if ($token) { + $SQLToken = Convert::raw2sql($token); + + $owner = DataObject::get_one( + $this->tokenConfig['owner'], + "\"" . $this->tokenConfig['DBColumn'] . "\"='" . $SQLToken . "'", + false + ); + + if (!$owner) { + $owner = null; + } + } + + return $owner; + } + + /** + * Checks if a request to the API is authenticated + * Gets API Token from HTTP Request and return Auth result + * + * @param HTTPRequest $request HTTP API request + * @return true|RESTfulAPIError True if token is valid OR RESTfulAPIError with details + */ + public function authenticate(HTTPRequest $request) + { + //get the token + $token = $request->getHeader($this->tokenConfig['header']); + if (!$token) { + $token = $request->requestVar($this->tokenConfig['queryVar']); + } + + if ($token) { + //check token validity + return $this->validateAPIToken($token, $request); + } else { + //no token, bad news + return new RESTfulAPIError(403, + 'Token invalid.', + array( + 'message' => 'Token invalid.', + 'code' => self::AUTH_CODE_TOKEN_INVALID, + ) + ); + } + } + + /** + * Validate the API token + * + * @param string $token Authentication token + * @param HTTPRequest $request HTTP API request + * @return true|RESTfulAPIError True if token is valid OR RESTfulAPIError with details + */ + private function validateAPIToken($token, $request) + { + //get owner with that token + $SQL_token = Convert::raw2sql($token); + $tokenColumn = $this->tokenConfig['DBColumn']; + + $tokenOwner = DataObject::get_one( + $this->tokenConfig['owner'], + "\"" . $this->tokenConfig['DBColumn'] . "\"='" . $SQL_token . "'", + false + ); + + if ($tokenOwner) { + //check token expiry + $tokenExpire = $tokenOwner->{$this->tokenConfig['expireDBColumn']}; + $now = time(); + $life = $this->tokenConfig['life']; + + if ($tokenExpire > ($now - $life)) { + // check if token should automatically be updated + if ($this->tokenConfig['autoRefresh']) { + $tokenOwner->setField($this->tokenConfig['expireDBColumn'], $now + $life); + $tokenOwner->write(); + } + //all good, log Member in + if (is_a($tokenOwner, Member::class)) { + # $tokenOwner->logIn(); + # this is a login without the logging + Config::inst()->set(Member::class, 'session_regenerate_id', true); + $request->getSession()->set("loggedInAs", $tokenOwner->ID); + } + + return true; + } else { + //too old + return new RESTfulAPIError(403, + 'Token expired.', + array( + 'message' => 'Token expired.', + 'code' => self::AUTH_CODE_TOKEN_EXPIRED, + ) + ); + } + } else { + //token not found + //not sure it's wise to say it doesn't exist. Let's be shady here + return new RESTfulAPIError(403, + 'Token invalid.', + array( + 'message' => 'Token invalid.', + 'code' => self::AUTH_CODE_TOKEN_INVALID, + ) + ); + } + } +} diff --git a/src/Extensions/GroupExtension.php b/src/Extensions/GroupExtension.php new file mode 100644 index 0000000..43ba3f9 --- /dev/null +++ b/src/Extensions/GroupExtension.php @@ -0,0 +1,111 @@ + ALL ACCESS + * - API Editor => VIEW + EDIT + CREATE + * - API Reader => VIEW + * + * @author Thierry Francois @colymba thierry@colymba.com + * @copyright Copyright (c) 2013, Thierry Francois + * + * @license http://opensource.org/licenses/BSD-3-Clause BSD Simplified + * + * @package RESTfulAPI + * @subpackage Permission + */ +class GroupExtension extends DataExtension implements PermissionProvider +{ + /** + * Basic RESTfulAPI Permission set + * + * @return Array Default API permission set + */ + public function providePermissions() + { + return array( + 'RESTfulAPI_VIEW' => array( + 'name' => 'Access records through the RESTful API', + 'category' => 'RESTful API Access', + 'help' => 'Allow for a user to access/view record(s) through the API', + ), + 'RESTfulAPI_EDIT' => array( + 'name' => 'Edit records through the RESTful API', + 'category' => 'RESTful API Access', + 'help' => 'Allow for a user to submit a record changes through the API', + ), + 'RESTfulAPI_CREATE' => array( + 'name' => 'Create records through the RESTful API', + 'category' => 'RESTful API Access', + 'help' => 'Allow for a user to create a new record through the API', + ), + 'RESTfulAPI_DELETE' => array( + 'name' => 'Delete records through the RESTful API', + 'category' => 'RESTful API Access', + 'help' => 'Allow for a user to delete a record through the API', + ), + ); + } + + /** + * Create the default Groups + * and add default admin to admin group + */ + public function requireDefaultRecords() + { + // Readers + $readersGroup = DataObject::get(Group::class)->filter(array( + 'Code' => 'restfulapi-readers', + )); + + if (!$readersGroup->count()) { + $readerGroup = new Group(); + $readerGroup->Code = 'restfulapi-readers'; + $readerGroup->Title = 'RESTful API Readers'; + $readerGroup->Sort = 0; + $readerGroup->write(); + Permission::grant($readerGroup->ID, 'RESTfulAPI_VIEW'); + } + + // Editors + $editorsGroup = DataObject::get(Group::class)->filter(array( + 'Code' => 'restfulapi-editors', + )); + + if (!$editorsGroup->count()) { + $editorGroup = new Group(); + $editorGroup->Code = 'restfulapi-editors'; + $editorGroup->Title = 'RESTful API Editors'; + $editorGroup->Sort = 0; + $editorGroup->write(); + Permission::grant($editorGroup->ID, 'RESTfulAPI_VIEW'); + Permission::grant($editorGroup->ID, 'RESTfulAPI_EDIT'); + Permission::grant($editorGroup->ID, 'RESTfulAPI_CREATE'); + } + + // Admins + $adminsGroup = DataObject::get(Group::class)->filter(array( + 'Code' => 'restfulapi-administrators', + )); + + if (!$adminsGroup->count()) { + $adminGroup = new Group(); + $adminGroup->Code = 'restfulapi-administrators'; + $adminGroup->Title = 'RESTful API Administrators'; + $adminGroup->Sort = 0; + $adminGroup->write(); + Permission::grant($adminGroup->ID, 'RESTfulAPI_VIEW'); + Permission::grant($adminGroup->ID, 'RESTfulAPI_EDIT'); + Permission::grant($adminGroup->ID, 'RESTfulAPI_CREATE'); + Permission::grant($adminGroup->ID, 'RESTfulAPI_DELETE'); + } + } +} diff --git a/code/authenticator/RESTfulAPI_TokenAuthExtension.php b/src/Extensions/TokenAuthExtension.php similarity index 70% rename from code/authenticator/RESTfulAPI_TokenAuthExtension.php rename to src/Extensions/TokenAuthExtension.php index 9d93f12..bd77c28 100644 --- a/code/authenticator/RESTfulAPI_TokenAuthExtension.php +++ b/src/Extensions/TokenAuthExtension.php @@ -1,22 +1,29 @@ 'Varchar(160)', - 'ApiTokenExpire' => 'Int' + 'ApiToken' => 'Varchar(160)', + 'ApiTokenExpire' => 'Int', ); public function updateCMSFields(FieldList $fields) diff --git a/src/PermissionManagers/DefaultPermissionManager.php b/src/PermissionManagers/DefaultPermissionManager.php new file mode 100644 index 0000000..bf5f4b1 --- /dev/null +++ b/src/PermissionManagers/DefaultPermissionManager.php @@ -0,0 +1,61 @@ +canView($member); + break; + + case 'POST': + return $model->canCreate($member); + break; + + case 'PUT': + return $model->canEdit($member); + break; + + case 'DELETE': + return $model->canDelete($member); + break; + + default: + return true; + break; + } + } +} diff --git a/src/PermissionManagers/PermissionManager.php b/src/PermissionManagers/PermissionManager.php new file mode 100644 index 0000000..1f8acc7 --- /dev/null +++ b/src/PermissionManagers/PermissionManager.php @@ -0,0 +1,28 @@ + '%$Colymba\RESTfulAPI\Serializers\DefaultDeSerializer', + ); + + /** + * Search Filter Modifiers Separator used in the query var + * i.e. ?column__EndsWith=value + * + * @var string + * @config + */ + private static $searchFilterModifiersSeparator = '__'; + + /** + * Query vars to skip (uppercased) + * + * @var array + * @config + */ + private static $skipedQueryParameters = array('URL', 'FLUSH', 'FLUSHTOKEN', 'TOKEN'); + + /** + * Set a maximum numbers of records returned by the API. + * Only affectects "GET All". Useful to avoid returning millions of records at once. + * + * Set to -1 to disable. + * + * @var integer + * @config + */ + private static $max_records_limit = 100; + + /** + * Map of model references from URL to class names for exposed models + * + * @var array + * @config + */ + private static $models = []; + + /** + * Stores the currently requested data + * + * @var array + */ + public $requestedData = array( + 'model' => null, + 'id' => null, + 'params' => null, + ); + + /** + * Return current RESTfulAPI DeSerializer instance + * + * @return DeSerializer DeSerializer instance + */ + public function getdeSerializer() + { + return $this->deSerializer; + } + + /** + * All requests pass through here and are redirected depending on HTTP verb and params + * + * @param HTTPRequest $request HTTP request + * @return DataObjec|DataList DataObject/DataList result or stdClass on error + */ + public function handleQuery(HTTPRequest $request) + { + //get requested model(s) details + $model = $request->param('ModelReference'); + + $modelMap = Config::inst()->get(self::class, 'models'); + + if (array_key_exists($model, $modelMap)) { + $model = $modelMap[$model]; + } + + $id = $request->param('ID'); + $response = false; + $queryParams = $this->parseQueryParameters($request->getVars()); + + //validate Model name + store + if ($model) { + $model = $this->deSerializer->unformatName($model); + if (!class_exists($model)) { + return new RESTfulAPIError(400, + "Model does not exist. Received '$model'." + ); + } else { + //store requested model data and query data + $this->requestedData['model'] = $model; + } + } else { + //if model missing, stop + return blank object + return new RESTfulAPIError(400, + "Missing Model parameter." + ); + } + + //check API access rules on model + if (!RESTfulAPI::api_access_control($model, $request->httpMethod())) { + return new RESTfulAPIError(403, + "API access denied." + ); + } + + //validate ID + store + if (($request->isPUT() || $request->isDELETE()) && !is_numeric($id)) { + return new RESTfulAPIError(400, + "Invalid or missing ID. Received '$id'." + ); + } elseif ($id !== null && !is_numeric($id)) { + return new RESTfulAPIError(400, + "Invalid ID. Received '$id'." + ); + } else { + $this->requestedData['id'] = $id; + } + + //store query parameters + if ($queryParams) { + $this->requestedData['params'] = $queryParams; + } + + //map HTTP word to module method + switch ($request->httpMethod()) { + case 'GET': + return $this->findModel($model, $id, $queryParams, $request); + break; + case 'POST': + return $this->createModel($model, $request); + break; + case 'PUT': + return $this->updateModel($model, $id, $request); + break; + case 'DELETE': + return $this->deleteModel($model, $id, $request); + break; + default: + return new RESTfulAPIError(403, + "HTTP method mismatch." + ); + break; + } + } + + /** + * Parse the query parameters to appropriate Column, Value, Search Filter Modifiers + * array( + * array( + * 'Column' => ColumnName, + * 'Value' => ColumnValue, + * 'Modifier' => ModifierType + * ) + * ) + * + * @param array $params raw GET vars array + * @return array formatted query parameters + */ + public function parseQueryParameters(array $params) + { + $parsedParams = array(); + $searchFilterModifiersSeparator = Config::inst()->get(self::class, 'searchFilterModifiersSeparator'); + + foreach ($params as $key__mod => $value) { + // skip url, flush, flushtoken + if (in_array(strtoupper($key__mod), Config::inst()->get(self::class, 'skipedQueryParameters'))) { + continue; + } + + $param = array(); + + $key__mod = explode( + $searchFilterModifiersSeparator, + $key__mod + ); + + $param['Column'] = $this->deSerializer->unformatName($key__mod[0]); + + $param['Value'] = $value; + + if (isset($key__mod[1])) { + $param['Modifier'] = $key__mod[1]; + } else { + $param['Modifier'] = null; + } + + array_push($parsedParams, $param); + } + + return $parsedParams; + } + + /** + * Finds 1 or more objects of class $model + * + * Handles column modifiers: :StartsWith, :EndsWith, + * :PartialMatch, :GreaterThan, :LessThan, :Negation + * and query modifiers: sort, rand, limit + * + * @param string $model Model(s) class to find + * @param boolean|integr $id The ID of the model to find or false + * @param array $queryParams Query parameters and modifiers + * @param HTTPRequest $request The original HTTP request + * @return DataObject|DataList Result of the search (note: DataList can be empty) + */ + public function findModel($model, $id = false, $queryParams, HTTPRequest $request) + { + if ($id) { + $return = DataObject::get_by_id($model, $id); + + if (!$return) { + return new RESTfulAPIError(404, + "Model $id of $model not found." + ); + } elseif (!RESTfulAPI::api_access_control($return, $request->httpMethod())) { + return new RESTfulAPIError(403, + "API access denied." + ); + } + } else { + $return = DataList::create($model); + $singletonModel = singleton($model); + + if (count($queryParams) > 0) { + foreach ($queryParams as $param) { + if ($param['Column'] && $singletonModel->hasDatabaseField($param['Column'])) { + // handle sorting by column + if ($param['Modifier'] === 'sort') { + $return = $return->sort(array( + $param['Column'] => $param['Value'], + )); + } + // normal modifiers / search filters + elseif ($param['Modifier']) { + $return = $return->filter(array( + $param['Column'] . ':' . $param['Modifier'] => $param['Value'], + )); + } + // no modifier / search filter + else { + $return = $return->filter(array( + $param['Column'] => $param['Value'], + )); + } + } else { + // random + if ($param['Modifier'] === 'rand') { + // rand + seed + if ($param['Value']) { + $return = $return->sort('RAND(' . $param['Value'] . ')'); + } + // rand only >> FIX: gen seed to avoid random result on relations + else { + $return = $return->sort('RAND(' . time() . ')'); + } + } + // limits + elseif ($param['Modifier'] === 'limit') { + // range + offset + if (is_array($param['Value'])) { + $return = $return->limit($param['Value'][0], $param['Value'][1]); + } + // range only + else { + $return = $return->limit($param['Value']); + } + } + } + } + } + + //sets default limit if none given + $limits = $return->dataQuery()->query()->getLimit(); + $limitConfig = Config::inst()->get(self::class, 'max_records_limit'); + + if (is_array($limits) && !array_key_exists('limit', $limits) && $limitConfig >= 0) { + $return = $return->limit($limitConfig); + } + } + + return $return; + } + + /** + * Create object of class $model + * + * @param string $model + * @param HTTPRequest $request + * @return DataObject + */ + public function createModel($model, HTTPRequest $request) + { + if (!RESTfulAPI::api_access_control($model, $request->httpMethod())) { + return new RESTfulAPIError(403, + "API access denied." + ); + } + + $newModel = Injector::inst()->create($model); + + return $this->updateModel($newModel, $newModel->ID, $request); + } + + /** + * Update databse record or $model + * + * @param String|DataObject $model the model or class to update + * @param Integer $id The ID of the model to update + * @param HTTPRequest the original request + * + * @return DataObject The updated model + */ + public function updateModel($model, $id, $request) + { + if (is_string($model)) { + $model = DataObject::get_by_id($model, $id); + } + + if (!$model) { + return new RESTfulAPIError(404, + "Record not found." + ); + } + + if (!RESTfulAPI::api_access_control($model, $request->httpMethod())) { + return new RESTfulAPIError(403, + "API access denied." + ); + } + + $rawJson = $request->getBody(); + + // Before deserialize hook + if (method_exists($model, 'onBeforeDeserialize')) { + $model->onBeforeDeserialize($rawJson); + } + $model->extend('onBeforeDeserialize', $rawJson); + + $payload = $this->deSerializer->deserialize($rawJson); + if ($payload instanceof RESTfulAPIError) { + return $payload; + } + + // After deserialize hook + if (method_exists($model, 'onAfterDeserialize')) { + $model->onAfterDeserialize($payload); + } + $model->extend('onAfterDeserialize', $payload); + + if ($model && $payload) { + $has_one = Config::inst()->get(get_class($model), 'has_one'); + $has_many = Config::inst()->get(get_class($model), 'has_many'); + $many_many = Config::inst()->get(get_class($model), 'many_many'); + $belongs_many_many = Config::inst()->get(get_class($model), 'belongs_many_many'); + + $many_many_extraFields = array(); + + if (isset($payload['ManyManyExtraFields'])) { + $many_many_extraFields = $payload['ManyManyExtraFields']; + unset($payload['ManyManyExtraFields']); + } + + $hasChanges = false; + $hasRelationChanges = false; + + foreach ($payload as $attribute => $value) { + if (!is_array($value)) { + if (is_array($has_one) && array_key_exists($attribute, $has_one)) { + $relation = $attribute . 'ID'; + $model->$relation = $value; + $hasChanges = true; + } elseif ($model->{$attribute} != $value) { + $model->{$attribute} = $value; + $hasChanges = true; + } + } else { + //has_many, many_many or $belong_many_many + if ((is_array($has_many) && array_key_exists($attribute, $has_many)) + || (is_array($many_many) && array_key_exists($attribute, $many_many)) + || (is_array($belongs_many_many) && array_key_exists($attribute, $belongs_many_many)) + ) { + $hasRelationChanges = true; + $ssList = $model->{$attribute}(); + $ssList->removeAll(); //reset list + foreach ($value as $id) { + // check if there is extraFields + if (array_key_exists($attribute, $many_many_extraFields)) { + if (isset($many_many_extraFields[$attribute][$id])) { + $ssList->add($id, $many_many_extraFields[$attribute][$id]); + continue; + } + } + + $ssList->add($id); + } + } + } + } + + if ($hasChanges || !$model->ID) { + try { + $id = $model->write(false, false, false, $hasRelationChanges); + } catch (ValidationException $exception) { + $error = $exception->getResult(); + $messages = []; + foreach ($error->getMessages() as $message) { + $fieldName = $message['fieldName']; + if ($fieldName) { + $messages[] = "{$fieldName}: {$message['message']}"; + } else { + $messages[] = $message['message']; + } + } + return new RESTfulAPIError(400, + implode("\n", $messages) + ); + } + + if (!$id) { + return new RESTfulAPIError(500, + "Error writting data." + ); + } else { + return DataObject::get_by_id($model->ClassName, $id); + } + } else { + return $model; + } + } else { + return new RESTfulAPIError(400, + "Missing model or payload." + ); + } + } + + /** + * Delete object of Class $model and ID $id + * + * @todo Respond with a 204 status message on success? + * + * @param string $model Model class + * @param integer $id Model ID + * @param HTTPRequest $request Model ID + * @return NULL|array NULL if successful or array with error detail + */ + public function deleteModel($model, $id, HTTPRequest $request) + { + if ($id) { + $object = DataObject::get_by_id($model, $id); + + if ($object) { + if (!RESTfulAPI::api_access_control($object, $request->httpMethod())) { + return new RESTfulAPIError(403, + "API access denied." + ); + } + + $object->delete(); + } else { + return new RESTfulAPIError(404, + "Record not found." + ); + } + } else { + //shouldn't happen but just in case + return new RESTfulAPIError(400, + "Invalid or missing ID. Received '$id'." + ); + } + + return null; + } +} diff --git a/src/QueryHandlers/QueryHandler.php b/src/QueryHandlers/QueryHandler.php new file mode 100644 index 0000000..ee22ed7 --- /dev/null +++ b/src/QueryHandlers/QueryHandler.php @@ -0,0 +1,35 @@ + '%$Colymba\RESTfulAPI\Authenticators\TokenAuthenticator', + 'authority' => '%$Colymba\RESTfulAPI\PermissionManagers\DefaultPermissionManager', + 'queryHandler' => '%$Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler', + 'serializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultSerializer', + ); + + /** + * Embedded records setting + * Specify which relation ($has_one, $has_many, $many_many) model data should be embedded into the response + * + * Map of relations to embed for specific record classname + * 'RequestedClass' => array('RelationNameToEmbed', 'Another') + * + * Non embedded response: + * { + * 'member': { + * 'name': 'John', + * 'favourites': [1, 2] + * } + * } + * + * Response with embedded record: + * { + * 'member': { + * 'name': 'John', + * 'favourites': [{ + * 'id': 1, + * 'name': 'Mark' + * },{ + * 'id': 2, + * 'name': 'Maggie' + * }] + * } + * } + * + * @var array + * @config + */ + private static $embedded_records; + + /** + * Cross-Origin Resource Sharing (CORS) + * API settings for cross domain XMLHTTPRequest + * + * Enabled true|false enable/disable CORS + * Allow-Origin String|Array '*' to allow all, 'http://domain.com' to allow single domain, array('http://domain.com', 'http://site.com') to allow multiple domains + * Allow-Headers String '*' to allow all or comma separated list of headers + * Allow-Methods String comma separated list of allowed methods + * Max-Age Integer Preflight/OPTIONS request caching time in seconds (NOTE has no effect if Authentification is enabled => custom header = always preflight) + * + * @var array + * @config + */ + private static $cors = array( + 'Enabled' => true, + 'Allow-Origin' => '*', + 'Allow-Headers' => '*', + 'Allow-Methods' => 'OPTIONS, POST, GET, PUT, DELETE', + 'Max-Age' => 86400, + ); + + /** + * URL handler allowed actions + * + * @var array + */ + private static $allowed_actions = array( + 'index', + 'auth', + 'acl', + ); + + /** + * URL handler definition + * + * @var array + */ + private static $url_handlers = array( + 'auth/$Action' => 'auth', + 'acl/$Action' => 'acl', + '$ModelReference/$ID' => 'index', + ); + + /** + * Returns current query handler instance + * + * @return QueryHandler QueryHandler instance + */ + public function getqueryHandler() + { + return $this->queryHandler; + } + + /** + * Returns current serializer instance + * + * @return Serializer Serializer instance + */ + public function getserializer() + { + return $this->serializer; + } + + /** + * Current RESTfulAPI instance + * + * @var RESTfulAPI + */ + protected static $instance; + + /** + * Constructor.... + */ + public function __construct() + { + parent::__construct(); + + //save current instance in static var + self::$instance = $this; + } + + /** + * Controller inititalisation + * Catches CORS preflight request marked with HTTPMethod 'OPTIONS' + */ + public function init() + { + parent::init(); + + //catch preflight request + if ($this->request->httpMethod() === 'OPTIONS') { + $answer = $this->answer(null, true); + $answer->output(); + exit; + } + } + + /** + * Handles authentications methods + * get response from API Authenticator + * then passes it on to $answer() + * + * @param HTTPRequest $request HTTP request + */ + public function auth(HTTPRequest $request) + { + $action = $request->param('Action'); + + if ($this->authenticator) { + $className = get_class($this->authenticator); + $allowedActions = Config::inst()->get($className, 'allowed_actions'); + if (!$allowedActions) { + $allowedActions = array(); + } + + if (in_array($action, $allowedActions)) { + if (method_exists($this->authenticator, $action)) { + $response = $this->authenticator->$action($request); + $response = $this->serializer->serialize($response); + return $this->answer($response); + } else { + //let's be shady here instead + return $this->error(new RESTfulAPIError(403, + "Action '$action' not allowed." + )); + } + } else { + return $this->error(new RESTfulAPIError(403, + "Action '$action' not allowed." + )); + } + } + } + + /** + * Handles Access Control methods + * get response from API PermissionManager + * then passes it on to $answer() + * + * @param HTTPRequest $request HTTP request + */ + public function acl(HTTPRequest $request) + { + $action = $request->param('Action'); + + if ($this->authority) { + $className = get_class($this->authority); + $allowedActions = Config::inst()->get($className, 'allowed_actions'); + if (!$allowedActions) { + $allowedActions = array(); + } + + if (in_array($action, $allowedActions)) { + if (method_exists($this->authority, $action)) { + $response = $this->authority->$action($request); + $response = $this->serializer->serialize($response); + return $this->answer($response); + } else { + //let's be shady here instead + return $this->error(new RESTfulAPIError(403, + "Action '$action' not allowed." + )); + } + } else { + return $this->error(new RESTfulAPIError(403, + "Action '$action' not allowed." + )); + } + } + } + + /** + * Main API hub switch + * All requests pass through here and are redirected depending on HTTP verb and params + * + * @todo move authentication check to another methode + * + * @param SS_HTTPRequest $request HTTP request + * @return string json object of the models found + */ + public function index(HTTPRequest $request) + { + //check authentication if enabled + if ($this->authenticator) { + $policy = $this->config()->authentication_policy; + $authALL = $policy === true; + $authMethod = is_array($policy) && in_array($request->httpMethod(), $policy); + + if ($authALL || $authMethod) { + $authResult = $this->authenticator->authenticate($request); + + if ($authResult instanceof RESTfulAPIError) { + //Authentication failed return error to client + return $this->error($authResult); + } + } + } + + //pass control to query handler + $data = $this->queryHandler->handleQuery($request); + //catch + return errors + if ($data instanceof RESTfulAPIError) { + return $this->error($data); + } + + //serialize response + $json = $this->serializer->serialize($data); + //catch + return errors + if ($json instanceof RESTfulAPIError) { + return $this->error($json); + } + + //all is good reply normally + return $this->answer($json); + } + + /** + * Output the API response to client + * then exit. + * + * @param string $json Response body + * @param boolean $corsPreflight Set to true if this is a XHR preflight request answer. CORS shoud be enabled. + */ + public function answer($json = null, $corsPreflight = false) + { + $answer = new HTTPResponse(); + + //set response body + if (!$corsPreflight) { + $answer->setBody($json); + } + + //set CORS if needed + $answer = $this->setAnswerCORS($answer); + + $answer->addHeader('Content-Type', $this->serializer->getcontentType()); + + // save controller's response then return/output + $this->response = $answer; + + return $answer; + } + + /** + * Handles formatting and output error message + * then exit. + * + * @param RESTfulAPIError $error Error object to return + */ + public function error(RESTfulAPIError $error) + { + $answer = new HTTPResponse(); + + $body = $this->serializer->serialize($error->body); + $answer->setBody($body); + + $answer->setStatusCode($error->code, $error->message); + $answer->addHeader('Content-Type', $this->serializer->getcontentType()); + + $answer = $this->setAnswerCORS($answer); + + // save controller's response then return/output + $this->response = $answer; + + return $answer; + } + + /** + * Apply the proper CORS response heardes + * to an HTTPResponse + * + * @param HTTPResponse $answer The updated response if CORS are neabled + */ + private function setAnswerCORS(HTTPResponse $answer) + { + $cors = Config::inst()->get(self::class, 'cors'); + + // skip if CORS is not enabled + if (!$cors['Enabled']) { + return $answer; + } + + //check if Origin is allowed + $allowedOrigin = $cors['Allow-Origin']; + $requestOrigin = $this->request->getHeader('Origin'); + if ($requestOrigin) { + if ($cors['Allow-Origin'] === '*') { + $allowedOrigin = $requestOrigin; + } elseif (is_array($cors['Allow-Origin'])) { + if (in_array($requestOrigin, $cors['Allow-Origin'])) { + $allowedOrigin = $requestOrigin; + } + } + } + $answer->addHeader('Access-Control-Allow-Origin', $allowedOrigin); + + //allowed headers + $allowedHeaders = ''; + $requestHeaders = $this->request->getHeader('Access-Control-Request-Headers'); + if ($cors['Allow-Headers'] === '*') { + $allowedHeaders = $requestHeaders; + } else { + $allowedHeaders = $cors['Allow-Headers']; + } + $answer->addHeader('Access-Control-Allow-Headers', $allowedHeaders); + + //allowed method + $answer->addHeader('Access-Control-Allow-Methods', $cors['Allow-Methods']); + + //max age + $answer->addHeader('Access-Control-Max-Age', $cors['Max-Age']); + + return $answer; + } + + /** + * Checks a class or model api access + * depending on access_control_policy and the provided model. + * - 1st config check + * - 2nd permission check if config access passes + * + * @param string|DataObject $model Model's classname or DataObject + * @param string $httpMethod API request HTTP method + * @return boolean true if access is granted, false otherwise + */ + public static function api_access_control($model, $httpMethod = 'GET') + { + $policy = self::config()->access_control_policy; + if ($policy === false) { + return true; + } // if access control is disabled, skip + else { + $policy = constant('self::' . $policy); + } + + if ($policy === self::ACL_CHECK_MODEL_ONLY) { + $access = true; + } else { + $access = false; + } + + if ($policy === self::ACL_CHECK_CONFIG_ONLY || $policy === self::ACL_CHECK_CONFIG_AND_MODEL) { + if (!is_string($model)) { + $className = get_class($model); + } else { + $className = $model; + } + + $access = self::api_access_config_check($className, $httpMethod); + } + + if ($policy === self::ACL_CHECK_MODEL_ONLY || $policy === self::ACL_CHECK_CONFIG_AND_MODEL) { + if ($access) { + $access = self::model_permission_check($model, $httpMethod); + } + } + + return $access; + } + + /** + * Checks a model's api_access config. + * api_access config can be: + * - unset|false, access is always denied + * - true, access is always granted + * - comma separated list of allowed HTTP methods + * + * @param string $className Model's classname + * @param string $httpMethod API request HTTP method + * @return boolean true if access is granted, false otherwise + */ + private static function api_access_config_check($className, $httpMethod = 'GET') + { + $access = false; + $api_access = singleton($className)->stat('api_access'); + + if (is_string($api_access)) { + $api_access = explode(',', strtoupper($api_access)); + if (in_array($httpMethod, $api_access)) { + $access = true; + } else { + $access = false; + } + } elseif ($api_access === true) { + $access = true; + } + + return $access; + } + + /** + * Checks a Model's permission for the currently + * authenticated user via the Permission Manager dependency. + * + * For permissions to actually be checked, this means the RESTfulAPI + * must have both authenticator and authority dependencies defined. + * + * If the authenticator component does not return an instance of the Member + * null will be passed to the authority component. + * + * This default to true. + * + * @param string|DataObject $model Model's classname or DataObject to check permission for + * @param string $httpMethod API request HTTP method + * @return boolean true if access is granted, false otherwise + */ + private static function model_permission_check($model, $httpMethod = 'GET') + { + $access = true; + $apiInstance = self::$instance; + + if ($apiInstance->authenticator && $apiInstance->authority) { + $request = $apiInstance->request; + $member = $apiInstance->authenticator->getOwner($request); + + if (!$member instanceof Member) { + $member = null; + } + + $access = $apiInstance->authority->checkPermission($model, $member, $httpMethod); + if (!is_bool($access)) { + $access = true; + } + } + + return $access; + } +} diff --git a/code/RESTfulAPI_Error.php b/src/RESTfulAPIError.php similarity index 87% rename from code/RESTfulAPI_Error.php rename to src/RESTfulAPIError.php index b6939df..04591f0 100644 --- a/code/RESTfulAPI_Error.php +++ b/src/RESTfulAPIError.php @@ -1,72 +1,71 @@ code = $code; + $this->code = $code; $this->message = $message; if ($body !== null) { $this->body = $body; } else { $this->body = array( - 'code' => $code, - 'message' => $message - ); + 'code' => $code, + 'message' => $message, + ); } } - /** * Check for the latest JSON parsing error * and return the message if any * * More available for PHP >= 5.3.3 * http://www.php.net/manual/en/function.json-last-error.php - * + * * @return false|string Returns false if no error or a string with the error detail. */ public static function get_json_error() @@ -95,7 +94,7 @@ public static function get_json_error() break; default: - $error .= 'Unknown error ('.json_last_error().').'; + $error .= 'Unknown error (' . json_last_error() . ').'; break; } diff --git a/code/serializers/RESTfulAPI_DeSerializer.php b/src/Serializers/DeSerializer.php similarity index 90% rename from code/serializers/RESTfulAPI_DeSerializer.php rename to src/Serializers/DeSerializer.php index 295de3e..35b2b1e 100644 --- a/code/serializers/RESTfulAPI_DeSerializer.php +++ b/src/Serializers/DeSerializer.php @@ -1,32 +1,34 @@ unformatPayloadData($data); } else { - return new RESTfulAPI_Error(400, - "No data received." - ); + return new RESTfulAPIError(400, + "No data received." + ); } return $data; } - /** * Process payload data from client * and unformats columns/values recursively - * + * * @param array $data Payload data (decoded JSON) * @return array Paylaod data with all keys/values unformatted */ @@ -75,11 +80,10 @@ protected function unformatPayloadData(array $data) return $unformattedData; } - /** * Format a ClassName or Field name sent by client API * to be used by SilverStripe - * + * * @param string $name ClassName of Field name * @return string Formatted name */ @@ -93,11 +97,10 @@ public function unformatName($name) } } - /** * Format a DB Column name or Field name * sent from client API to be used by SilverStripe - * + * * @param string $name Field name * @return string Formatted name */ diff --git a/code/serializers/Basic/RESTfulAPI_BasicSerializer.php b/src/Serializers/DefaultSerializer.php similarity index 92% rename from code/serializers/Basic/RESTfulAPI_BasicSerializer.php rename to src/Serializers/DefaultSerializer.php index 93cc4e2..502768b 100644 --- a/code/serializers/Basic/RESTfulAPI_BasicSerializer.php +++ b/src/Serializers/DefaultSerializer.php @@ -1,6 +1,16 @@ get('RESTfulAPI', 'embedded_records'); + $embedded_records = Config::inst()->get('colymba\\RESTfulAPI\\RESTfulAPI', 'embedded_records'); if (is_array($embedded_records)) { $this->embeddedRecords = $embedded_records; } else { @@ -53,7 +63,6 @@ public function __construct() } } - /** * Convert data into a JSON string * @@ -68,15 +77,14 @@ protected function jsonify($data) $json = json_encode($data); //catch JSON parsing error - $error = RESTfulAPI_Error::get_json_error(); + $error = RESTfulAPIError::get_json_error(); if ($error !== false) { - return new RESTfulAPI_Error(400, $error); + return new RESTfulAPIError(400, $error); } return $json; } - /** * Convert raw data (DataObject or DataList) to JSON * ready to be consumed by the client API @@ -108,7 +116,6 @@ public function serialize($data) return $json; } - /** * Format a DataObject keys and values * ready to be turned into JSON @@ -133,15 +140,16 @@ protected function formatDataObject(DataObject $dataObject) $formattedDataObjectMap = array(); // get DataObject config - $db = Config::inst()->get($dataObject->ClassName, 'db'); - $has_one = Config::inst()->get($dataObject->ClassName, 'has_one'); - $has_many = Config::inst()->get($dataObject->ClassName, 'has_many'); - $many_many = Config::inst()->get($dataObject->ClassName, 'many_many'); - $belongs_many_many = Config::inst()->get($dataObject->ClassName, 'belongs_many_many'); + $class = get_class($dataObject); + $db = Config::inst()->get($class, 'db'); + $has_one = Config::inst()->get($class, 'has_one'); + $has_many = Config::inst()->get($class, 'has_many'); + $many_many = Config::inst()->get($class, 'many_many'); + $belongs_many_many = Config::inst()->get($class, 'belongs_many_many'); // Get a possibly defined list of "api_fields" for this DataObject. If defined, they will be the only fields // for this DataObject that will be returned, including related models. - $apiFields = (array) Config::inst()->get($dataObject->ClassName, 'api_fields'); + $apiFields = (array) Config::inst()->get($class, 'api_fields'); //$many_many_extraFields = $dataObject->many_many_extraFields(); $many_many_extraFields = $dataObject->stat('many_many_extraFields'); @@ -178,7 +186,7 @@ protected function formatDataObject(DataObject $dataObject) $serializedColumnName = $this->serializeColumnName($columnName); // convert foreign ID to integer - $relationID = intVal($dataObject->{$columnName.'ID'}); + $relationID = intVal($dataObject->{$columnName . 'ID'}); // skip empty relations if ($relationID === 0) { continue; @@ -316,7 +324,6 @@ protected function formatDataList(DataList $dataList) return $formattedDataListMap; } - /** * Format a SilverStripe ClassName or Field name to be used by the client API * @@ -328,7 +335,6 @@ public function formatName($name) return $name; } - /** * Format a DB Column name or Field name to be used by the client API * @@ -340,7 +346,6 @@ protected function serializeColumnName($name) return $name; } - /** * Returns a DataObject relation's data formatted and ready to embed. * @@ -362,7 +367,6 @@ protected function getEmbedData(DataObject $record, $relationName) return null; } - /** * Checks if a speicific model's relation should have its records embedded. * diff --git a/code/serializers/RESTfulAPI_Serializer.php b/src/Serializers/Serializer.php similarity index 91% rename from code/serializers/RESTfulAPI_Serializer.php rename to src/Serializers/Serializer.php index 976fa62..aa4538e 100644 --- a/code/serializers/RESTfulAPI_Serializer.php +++ b/src/Serializers/Serializer.php @@ -1,41 +1,42 @@ array( '/(s)tatus$/i' => '\1\2tatuses', @@ -57,7 +60,7 @@ class Inflector '/$/' => 's', ), 'uninflected' => array( - '.*[nrlm]ese', '.*deer', '.*fish', '.*measles', '.*ois', '.*pox', '.*sheep', 'people' + '.*[nrlm]ese', '.*deer', '.*fish', '.*measles', '.*ois', '.*pox', '.*sheep', 'people', ), 'irregular' => array( 'atlas' => 'atlases', @@ -91,8 +94,8 @@ class Inflector 'soliloquy' => 'soliloquies', 'testis' => 'testes', 'trilby' => 'trilbys', - 'turf' => 'turfs' - ) + 'turf' => 'turfs', + ), ); /** @@ -135,16 +138,16 @@ class Inflector '/(n)ews$/i' => '\1\2ews', '/eaus$/' => 'eau', '/^(.*us)$/' => '\\1', - '/s$/i' => '' + '/s$/i' => '', ), 'uninflected' => array( - '.*[nrlm]ese', '.*deer', '.*fish', '.*measles', '.*ois', '.*pox', '.*sheep', '.*ss' + '.*[nrlm]ese', '.*deer', '.*fish', '.*measles', '.*ois', '.*pox', '.*sheep', '.*ss', ), 'irregular' => array( 'foes' => 'foe', 'waves' => 'wave', - 'curves' => 'curve' - ) + 'curves' => 'curve', + ), ); /** @@ -164,7 +167,7 @@ class Inflector 'proceedings', 'rabies', 'rice', 'rhinoceros', 'salmon', 'Sarawakese', 'scissors', 'sea[- ]bass', 'series', 'Shavese', 'shears', 'siemens', 'species', 'swine', 'testes', 'trousers', 'trout', 'tuna', 'Vermontese', 'Wenchowese', 'whiting', 'wildebeest', - 'Yengeese' + 'Yengeese', ); /** @@ -222,7 +225,7 @@ class Inflector '/IJ/' => 'IJ', '/ij/' => 'ij', '/Œ/' => 'OE', - '/ƒ/' => 'f' + '/ƒ/' => 'f', ); /** @@ -312,7 +315,7 @@ public static function rules($type, $rules, $reset = false) } else { self::$_transliteration = $rules + self::$_transliteration; } - break; + break; default: foreach ($rules as $rule => $pattern) { @@ -338,7 +341,7 @@ public static function rules($type, $rules, $reset = false) } } self::${$var}['rules'] = $rules + self::${$var}['rules']; - break; + break; } } diff --git a/code/thirdparty/Inflector/LICENSE.txt b/src/ThirdParty/Inflector/LICENSE.txt similarity index 100% rename from code/thirdparty/Inflector/LICENSE.txt rename to src/ThirdParty/Inflector/LICENSE.txt diff --git a/tests/.upgrade.yml b/tests/.upgrade.yml new file mode 100644 index 0000000..d171ac1 --- /dev/null +++ b/tests/.upgrade.yml @@ -0,0 +1,12 @@ +mappings: + DefaultPermissionManagerTest: colymba\RESTfulAPI\Tests\PermissionManagers\DefaultPermissionManagerTest + DefaultQueryHandlerTest: colymba\RESTfulAPI\Tests\QueryHandlers\DefaultQueryHandlerTest + BasicDeSerializerTest: colymba\RESTfulAPI\Tests\Serializers\DefaultDeSerializerTest + BasicSerializerTest: colymba\RESTfulAPI\Tests\Serializers\DefaultSerializerTest + RESTfulAPITest: colymba\RESTfulAPI\Tests\API\RESTfulAPITest + ApiTestBook: colymba\RESTfulAPI\Tests\Fixtures\ApiTestBook + ApiTestLibrary: colymba\RESTfulAPI\Tests\Fixtures\ApiTestLibrary + ApiTestProduct: colymba\RESTfulAPI\Tests\Fixtures\ApiTestProduct + ApiTestAuthor: colymba\RESTfulAPI\Tests\Fixtures\ApiTestAuthor + RESTfulAPITester: colymba\RESTfulAPI\Tests\RESTfulAPITester + TokenAuthenticatorTest: colymba\RESTfulAPI\Tests\Authenticators\TokenAuthenticatorTest diff --git a/tests/API/RESTfulAPITest.php b/tests/API/RESTfulAPITest.php new file mode 100644 index 0000000..6f4ac3c --- /dev/null +++ b/tests/API/RESTfulAPITest.php @@ -0,0 +1,231 @@ +update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_CONFIG_ONLY'); + // ---------------- + // Method Calls + + // Disabled by default + $enabled = RESTfulAPI::api_access_control(ApiTestAuthor::class); + $this->assertFalse($enabled, 'Access control should return FALSE by default'); + + // Enabled + Config::inst()->update(ApiTestAuthor::class, 'api_access', true); + $enabled = RESTfulAPI::api_access_control(ApiTestAuthor::class); + $this->assertTrue($enabled, 'Access control should return TRUE when api_access is enbaled'); + + // Method specific + Config::inst()->update(ApiTestAuthor::class, 'api_access', 'GET,POST'); + + $enabled = RESTfulAPI::api_access_control(ApiTestAuthor::class); + $this->assertTrue($enabled, 'Access control should return TRUE when api_access is enbaled with default GET method'); + + $enabled = RESTfulAPI::api_access_control(ApiTestAuthor::class, 'POST'); + $this->assertTrue($enabled, 'Access control should return TRUE when api_access match HTTP method'); + + $enabled = RESTfulAPI::api_access_control(ApiTestAuthor::class, 'PUT'); + $this->assertFalse($enabled, 'Access control should return FALSE when api_access does not match method'); + + // ---------------- + // API Calls + /* + // Access authorised + $response = Director::test('api/ApiTestAuthor/1', null, null, 'GET'); + $this->assertEquals( + $response->getStatusCode(), + 200 + ); + + // Access denied + Config::inst()->update(ApiTestAuthor::class, 'api_access', false); + $response = Director::test('api/ApiTestAuthor/1', null, null, 'GET'); + $this->assertEquals( + $response->getStatusCode(), + 403 + ); + + // Access denied + Config::inst()->update(ApiTestAuthor::class, 'api_access', 'POST'); + $response = Director::test('api/ApiTestAuthor/1', null, null, 'GET'); + $this->assertEquals( + $response->getStatusCode(), + 403 + ); + */ + } + + /* ********************************************************************** + * CORS + * */ + + /** + * Check that CORS headers aren't set + * when disabled via config + * + * @group CORSPreflight + */ + public function testCORSDisabled() + { + Config::inst()->update(RESTfulAPI::class, 'cors', array( + 'Enabled' => false, + )); + + $requestHeaders = $this->getOPTIONSHeaders(); + $response = Director::test('api/ApiTestBook/1', null, null, 'OPTIONS', null, $requestHeaders); + $headers = $response->getHeaders(); + + $this->assertFalse(array_key_exists('Access-Control-Allow-Origin', $headers), 'CORS ORIGIN header should not be present'); + $this->assertFalse(array_key_exists('Access-Control-Allow-Headers', $headers), 'CORS HEADER header should not be present'); + $this->assertFalse(array_key_exists('Access-Control-Allow-Methods', $headers), 'CORS METHOD header should not be present'); + $this->assertFalse(array_key_exists('Access-Control-Max-Age', $headers), 'CORS AGE header should not be present'); + } + + /** + * Checks default allow all CORS settings + * + * @group CORSPreflight + */ + public function testCORSAllowAll() + { + $corsConfig = Config::inst()->get(RESTfulAPI::class, 'cors'); + $requestHeaders = $this->getOPTIONSHeaders('GET', 'http://google.com'); + $response = Director::test('api/ApiTestBook/1', null, null, 'OPTIONS', null, $requestHeaders); + $responseHeaders = $response->getHeaders(); + + $this->assertEquals( + $requestHeaders['Origin'], + $responseHeaders['Access-Control-Allow-Origin'], + 'CORS headers should have same ORIGIN' + ); + + $this->assertEquals( + $corsConfig['Allow-Methods'], + $responseHeaders['Access-Control-Allow-Methods'], + 'CORS headers should have same METHOD' + ); + + $this->assertEquals( + $requestHeaders['Access-Control-Request-Headers'], + $responseHeaders['Access-Control-Allow-Headers'], + 'CORS headers should have same ALLOWED HEADERS' + ); + + $this->assertEquals( + $corsConfig['Max-Age'], + $responseHeaders['Access-Control-Max-Age'], + 'CORS headers should have same MAX AGE' + ); + } + + /** + * Checks CORS only allow HTTP methods specify in config + */ + public function testCORSHTTPMethodFiltering() + { + Config::inst()->update(RESTfulAPI::class, 'cors', array( + 'Enabled' => true, + 'Allow-Origin' => '*', + 'Allow-Headers' => '*', + 'Allow-Methods' => 'GET', + 'Max-Age' => 86400, + )); + + // Seding GET request, GET should be allowed + $requestHeaders = $this->getRequestHeaders(); + $response = Director::test('api/ApiTestBook/1', null, null, 'GET', null, $requestHeaders); + $responseHeaders = $response->getHeaders(); + + $this->assertEquals( + 'GET', + $responseHeaders['access-control-allow-methods'], + 'Only HTTP GET method should be allowed in access-control-allow-methods HEADER' + ); + + // Seding POST request, only GET should be allowed + $response = Director::test('api/ApiTestBook/1', null, null, 'POST', null, $requestHeaders); + $responseHeaders = $response->getHeaders(); + + $this->assertEquals( + 'GET', + $responseHeaders['access-control-allow-methods'], + 'Only HTTP GET method should be allowed in access-control-allow-methods HEADER' + ); + } + + /* ********************************************************************** + * API REQUESTS + * */ + + public function testFullBasicAPIRequest() + { + Config::inst()->update(RESTfulAPI::class, 'authentication_policy', false); + Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_CONFIG_ONLY'); + Config::inst()->update(ApiTestAuthor::class, 'api_access', true); + + // Default serializer + Config::inst()->update(RESTfulAPI::class, 'dependencies', array( + 'authenticator' => null, + 'authority' => null, + 'queryHandler' => '%$Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler', + 'serializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultSerializer', + )); + Config::inst()->update(RESTfulAPI::class, 'dependencies', array( + 'deSerializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultDeSerializer', + )); + + $response = Director::test('api/apitestauthor/1', null, null, 'GET'); + + $this->assertEquals( + 200, + $response->getStatusCode(), + "API request for existing record should resolve" + ); + + $json = json_decode($response->getBody()); + $this->assertEquals( + JSON_ERROR_NONE, + json_last_error(), + "API request should return valid JSON" + ); + } +} diff --git a/tests/ApiTest_fixtures.php b/tests/ApiTest_fixtures.php deleted file mode 100644 index ff1ddf8..0000000 --- a/tests/ApiTest_fixtures.php +++ /dev/null @@ -1,81 +0,0 @@ - 'Varchar(255)' - ); - - private static $many_many = array( - 'Books' => 'ApiTest_Book' - ); - - public function canView($member = null) - { - return Permission::check('RESTfulAPI_VIEW', 'any', $member); - } - - public function canEdit($member = null) - { - return Permission::check('RESTfulAPI_EDIT', 'any', $member); - } - - public function canCreate($member = null) - { - return Permission::check('RESTfulAPI_CREATE', 'any', $member); - } - - public function canDelete($member = null) - { - return Permission::check('RESTfulAPI_DELETE', 'any', $member); - } -} - -class ApiTest_Book extends DataObject -{ - private static $db = array( - 'Title' => 'Varchar(255)', - 'Pages' => 'Int' - ); - - private static $has_one = array( - 'Author' => 'ApiTest_Author' - ); - - private static $belongs_many_many = array( - 'Libraries' => 'ApiTest_Library' - ); - - public function validate() - { - if ($this->pages > 100) { - $result = ValidationResult::create(false, 'Too many pages'); - } else { - $result = ValidationResult::create(true); - } - - return $result; - } -} - -class ApiTest_Author extends DataObject -{ - private static $db = array( - 'Name' => 'Varchar(255)', - 'IsMan' => 'Boolean' - ); - - private static $has_many = array( - 'Books' => 'ApiTest_Book' - ); -} diff --git a/tests/Authenticators/TokenAuthenticatorTest.php b/tests/Authenticators/TokenAuthenticatorTest.php new file mode 100644 index 0000000..1df8b20 --- /dev/null +++ b/tests/Authenticators/TokenAuthenticatorTest.php @@ -0,0 +1,222 @@ + array(TokenAuthExtension::class), + ); + + protected function getAuthenticator() + { + $injector = new Injector(); + $auth = new TokenAuthenticator(); + + $injector->inject($auth); + + return $auth; + } + + public static function setUpBeforeClass() + { + parent::setUpBeforeClass(); + + Member::create(array( + 'Email' => 'test@test.com', + 'Password' => 'Test$password1', + ))->write(); + } + + /* ********************************************************** + * TESTS + * */ + + /** + * Checks that the Member gets logged in + * and a token is returned + */ + public function testLogin() + { + $member = Member::get()->filter(array( + 'Email' => 'test@test.com', + ))->first(); + + $auth = $this->getAuthenticator(); + $request = new HTTPRequest( + 'GET', + 'api/auth/login', + array( + 'email' => 'test@test.com', + 'pwd' => 'Test$password1', + ) + ); + $request->setSession(new Session([])); + + $result = $auth->login($request); + + $this->assertEquals( + Member::currentUserID(), + $member->ID, + "TokenAuth successful login should login the user" + ); + + $this->assertTrue( + is_string($result['token']), + "TokenAuth successful login should return token as string" + ); + } + + /** + * Checks that the Member is logged out + */ + public function testLogout() + { + $auth = $this->getAuthenticator(); + $request = new HTTPRequest( + 'GET', + 'api/auth/logout', + array( + 'email' => 'test@test.com', + ) + ); + $request->setSession(new Session([])); + + $result = $auth->logout($request); + + $this->assertNull( + Member::currentUser(), + "TokenAuth successful logout should logout the user" + ); + } + + /** + * Checks that a string token is returned + */ + public function testGetToken() + { + $member = Member::get()->filter(array( + 'Email' => 'test@test.com', + ))->first(); + + $auth = $this->getAuthenticator(); + $result = $auth->getToken($member->ID); + + $this->assertTrue( + is_string($result), + "TokenAuth getToken should return token as string" + ); + } + + /** + * Checks that a new toekn is generated + */ + public function testResetToken() + { + $member = Member::get()->filter(array( + 'Email' => 'test@test.com', + ))->first(); + + $auth = $this->getAuthenticator(); + $oldToken = $auth->getToken($member->ID); + + $auth->resetToken($member->ID); + $newToken = $auth->getToken($member->ID); + + $this->assertThat( + $oldToken, + $this->logicalNot( + $this->equalTo($newToken) + ), + "TokenAuth reset token should generate a new token" + ); + } + + /** + * Checks authenticator return owner + */ + public function testGetOwner() + { + $member = Member::get()->filter(array( + 'Email' => 'test@test.com', + ))->first(); + + $auth = $this->getAuthenticator(); + $auth->resetToken($member->ID); + $token = $auth->getToken($member->ID); + + $request = new HTTPRequest( + 'GET', + 'api/ApiTestBook/1' + ); + $request->addHeader('X-Silverstripe-Apitoken', $token); + $request->setSession(new Session([])); + + $result = $auth->getOwner($request); + + $this->assertEquals( + 'test@test.com', + $result->Email, + "TokenAuth should return owner when passed valid token." + ); + } + + /** + * Checks authentication works with a generated token + */ + public function testAuthenticate() + { + $member = Member::get()->filter(array( + 'Email' => 'test@test.com', + ))->first(); + + $auth = $this->getAuthenticator(); + $request = new HTTPRequest( + 'GET', + 'api/ApiTestBook/1' + ); + $request->setSession(new Session([])); + + $auth->resetToken($member->ID); + $token = $auth->getToken($member->ID); + $request->addHeader('X-Silverstripe-Apitoken', $token); + + $result = $auth->authenticate($request); + + $this->assertTrue( + $result, + "TokenAuth authentication success should return true" + ); + + $auth->resetToken($member->ID); + $result = $auth->authenticate($request); + + $this->assertContainsOnlyInstancesOf( + RESTfulAPIError::class, + array($result), + "TokenAuth authentication failure should return a RESTfulAPIError" + ); + } +} diff --git a/tests/Fixtures/ApiTestAuthor.php b/tests/Fixtures/ApiTestAuthor.php new file mode 100644 index 0000000..1773181 --- /dev/null +++ b/tests/Fixtures/ApiTestAuthor.php @@ -0,0 +1,35 @@ + 'Varchar(255)', + 'IsMan' => 'Boolean', + ); + + private static $has_many = array( + 'Books' => ApiTestBook::class, + ); +} diff --git a/tests/Fixtures/ApiTestBook.php b/tests/Fixtures/ApiTestBook.php new file mode 100644 index 0000000..3b5f650 --- /dev/null +++ b/tests/Fixtures/ApiTestBook.php @@ -0,0 +1,52 @@ + 'Varchar(255)', + 'Pages' => 'Int', + ); + + private static $has_one = array( + 'Author' => ApiTestAuthor::class, + ); + + private static $belongs_many_many = array( + 'Libraries' => ApiTestLibrary::class, + ); + + public function validate() + { + if ($this->Pages > 100) { + $result = ValidationResult::create()->addError('Too many pages'); + } else { + $result = ValidationResult::create(); + } + + return $result; + } +} diff --git a/tests/Fixtures/ApiTestLibrary.php b/tests/Fixtures/ApiTestLibrary.php new file mode 100644 index 0000000..d2b114d --- /dev/null +++ b/tests/Fixtures/ApiTestLibrary.php @@ -0,0 +1,54 @@ + 'Varchar(255)', + ); + + private static $many_many = array( + 'Books' => ApiTestBook::class, + ); + + public function canView($member = null) + { + return Permission::check('RESTfulAPI_VIEW', 'any', $member); + } + + public function canEdit($member = null) + { + return Permission::check('RESTfulAPI_EDIT', 'any', $member); + } + + public function canCreate($member = null, $context = []) + { + return Permission::check('RESTfulAPI_CREATE', 'any', $member); + } + + public function canDelete($member = null) + { + return Permission::check('RESTfulAPI_DELETE', 'any', $member); + } +} diff --git a/tests/Fixtures/ApiTestProduct.php b/tests/Fixtures/ApiTestProduct.php new file mode 100644 index 0000000..770b458 --- /dev/null +++ b/tests/Fixtures/ApiTestProduct.php @@ -0,0 +1,43 @@ + 'Varchar(64)', + 'Soldout' => 'Boolean', + ); + + private static $api_access = true; + + public function onAfterDeserialize(&$payload) + { + // don't allow setting `Soldout` via REST API + unset($payload['Soldout']); + } + + public function onBeforeDeserialize(&$rawJson) + { + self::$rawJSON = $rawJson; + } +} diff --git a/tests/Fixtures/ApiTestWidget.php b/tests/Fixtures/ApiTestWidget.php new file mode 100644 index 0000000..39cea62 --- /dev/null +++ b/tests/Fixtures/ApiTestWidget.php @@ -0,0 +1,24 @@ + 'Varchar(255)', + ); +} diff --git a/tests/PermissionManagers/DefaultPermissionManagerTest.php b/tests/PermissionManagers/DefaultPermissionManagerTest.php new file mode 100644 index 0000000..d51f46e --- /dev/null +++ b/tests/PermissionManagers/DefaultPermissionManagerTest.php @@ -0,0 +1,207 @@ + array(TokenAuthExtension::class), + ); + + protected static $extra_dataobjects = array( + ApiTestLibrary::class, + ); + + public static function setUpBeforeClass() + { + parent::setUpBeforeClass(); + + Member::create(array( + 'Email' => 'admin@api.com', + 'Password' => 'Admin$password1', + ))->write(); + + $member = Member::get()->filter(array( + 'Email' => 'admin@api.com', + ))->first(); + + $member->addToGroupByCode('restfulapi-administrators'); + + Member::create(array( + 'Email' => 'stranger@api.com', + 'Password' => 'Stranger$password1', + ))->write(); + } + + protected function getAdminToken() + { + $response = Director::test('api/auth/login?email=admin@api.com&pwd=Admin$password1'); + $json = json_decode($response->getBody()); + return $json->token; + } + + protected function getStrangerToken() + { + $response = Director::test('api/auth/login?email=stranger@api.com&pwd=Stranger$password1'); + $json = json_decode($response->getBody()); + return $json->token; + } + + /* ********************************************************** + * TESTS + * */ + + /** + * Test READ permissions are honoured + */ + public function testReadPermissions() + { + Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_MODEL_ONLY'); + Config::inst()->update(RESTfulAPI::class, 'cors', array( + 'Enabled' => false, + )); + + // GET with permission = OK + $requestHeaders = $this->getRequestHeaders(); + $requestHeaders['X-Silverstripe-Apitoken'] = $this->getAdminToken(); + $response = Director::test('api/apitestlibrary/1', null, null, 'GET', null, $requestHeaders); + + $this->assertEquals( + $response->getStatusCode(), + 200, + "Member of 'restfulapi-administrators' Group should be able to READ records." + ); + + // GET with NO Permission = BAD + $requestHeaders = $this->getRequestHeaders(); + $requestHeaders['X-Silverstripe-Apitoken'] = $this->getStrangerToken(); + $response = Director::test('api/apitestlibrary/1', null, null, 'GET', null, $requestHeaders); + + $this->assertEquals( + $response->getStatusCode(), + 403, + "Member without permission should NOT be able to READ records." + ); + } + + /** + * Test EDIT permissions are honoured + */ + public function testEditPermissions() + { + Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_MODEL_ONLY'); + Config::inst()->update(RESTfulAPI::class, 'cors', array( + 'Enabled' => false, + )); + + // PUT with permission = OK + $requestHeaders = $this->getRequestHeaders(); + $requestHeaders['X-Silverstripe-Apitoken'] = $this->getAdminToken(); + $response = Director::test('api/apitestlibrary/1', null, null, 'PUT', '{"Name":"Api"}', $requestHeaders); + + $this->assertEquals( + $response->getStatusCode(), + 200, + "Member of 'restfulapi-administrators' Group should be able to EDIT records." + ); + + // PUT with NO Permission = BAD + $requestHeaders = $this->getRequestHeaders(); + $requestHeaders['X-Silverstripe-Apitoken'] = $this->getStrangerToken(); + $response = Director::test('api/apitestlibrary/1', null, null, 'PUT', '{"Name":"Api"}', $requestHeaders); + + $this->assertEquals( + $response->getStatusCode(), + 403, + "Member without permission should NOT be able to EDIT records." + ); + } + + /** + * Test CREATE permissions are honoured + */ + public function testCreatePermissions() + { + Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_MODEL_ONLY'); + Config::inst()->update(RESTfulAPI::class, 'cors', array( + 'Enabled' => false, + )); + + // POST with permission = OK + $requestHeaders = $this->getRequestHeaders(); + $requestHeaders['X-Silverstripe-Apitoken'] = $this->getAdminToken(); + $response = Director::test('api/apitestlibrary', null, null, 'POST', '{"Name":"Api"}', $requestHeaders); + + $this->assertEquals( + $response->getStatusCode(), + 200, + "Member of 'restfulapi-administrators' Group should be able to CREATE records." + ); + + // POST with NO Permission = BAD + $requestHeaders = $this->getRequestHeaders(); + $requestHeaders['X-Silverstripe-Apitoken'] = $this->getStrangerToken(); + $response = Director::test('api/apitestlibrary', null, null, 'POST', '{"Name":"Api"}', $requestHeaders); + + $this->assertEquals( + $response->getStatusCode(), + 403, + "Member without permission should NOT be able to CREATE records." + ); + } + + /** + * Test DELETE permissions are honoured + */ + public function testDeletePermissions() + { + Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_MODEL_ONLY'); + Config::inst()->update(RESTfulAPI::class, 'cors', array( + 'Enabled' => false, + )); + + // DELETE with permission = OK + $requestHeaders = $this->getRequestHeaders(); + $requestHeaders['X-Silverstripe-Apitoken'] = $this->getAdminToken(); + $response = Director::test('api/apitestlibrary/1', null, null, 'DELETE', null, $requestHeaders); + + $this->assertEquals( + $response->getStatusCode(), + 200, + "Member of 'restfulapi-administrators' Group should be able to DELETE records." + ); + + // DELETE with NO Permission = BAD + $requestHeaders = $this->getRequestHeaders(); + $requestHeaders['X-Silverstripe-Apitoken'] = $this->getStrangerToken(); + $response = Director::test('api/apitestlibrary/1', null, null, 'DELETE', null, $requestHeaders); + + $this->assertEquals( + $response->getStatusCode(), + 403, + "Member without permission should NOT be able to DELETE records." + ); + } +} diff --git a/tests/QueryHandlers/DefaultQueryHandlerTest.php b/tests/QueryHandlers/DefaultQueryHandlerTest.php new file mode 100644 index 0000000..e2450c8 --- /dev/null +++ b/tests/QueryHandlers/DefaultQueryHandlerTest.php @@ -0,0 +1,359 @@ +update(ApiTestBook::class, 'api_access', true); + Config::inst()->update(ApiTestWidget::class, 'api_access', true); + + $widget = ApiTestWidget::create(['Name' => 'TestWidget1']); + $widget->write(); + $widget = ApiTestWidget::create(['Name' => 'TestWidget2']); + $widget->write(); + } + + protected function getHTTPRequest($method = 'GET', $class = ApiTestBook::class, $id = '', $params = array()) + { + $request = new HTTPRequest( + $method, + 'api/' . $class . '/' . $id, + $params + ); + $request->match($this->url_pattern); + $request->setRouteParams(array( + 'Controller' => 'RESTfulAPI', + )); + + return $request; + } + + protected function getQueryHandler() + { + $injector = new Injector(); + $qh = new DefaultQueryHandler(); + + $injector->inject($qh); + + return $qh; + } + + public static function setUpBeforeClass() + { + parent::setUpBeforeClass(); + + $product = ApiTestProduct::create(array( + 'Title' => 'Sold out product', + 'Soldout' => true, + )); + $product->write(); + } + + /* ********************************************************** + * TESTS + * */ + + /** + * Checks that query parameters are parsed properly + */ + public function testQueryParametersParsing() + { + $qh = $this->getQueryHandler(); + $request = $this->getHTTPRequest('GET', ApiTestBook::class, '1', array('Title__StartsWith' => 'K')); + $params = $qh->parseQueryParameters($request->getVars()); + $params = array_shift($params); + + $this->assertEquals( + $params['Column'], + 'Title', + 'Column parameter name mismatch' + ); + $this->assertEquals( + $params['Value'], + 'K', + 'Value parameter mismatch' + ); + $this->assertEquals( + $params['Modifier'], + 'StartsWith', + 'Modifier parameter mismatch' + ); + } + + /** + * Checks that access to DataObject with api_access config disabled return error + */ + public function testAPIDisabled() + { + Config::inst()->update(ApiTestBook::class, 'api_access', false); + + $qh = $this->getQueryHandler(); + $request = $this->getHTTPRequest('GET', ApiTestBook::class, '1'); + $result = $qh->handleQuery($request); + + $this->assertContainsOnlyInstancesOf( + RESTfulAPIError::class, + array($result), + 'Request for DataObject with api_access set to false should return a RESTfulAPIError' + ); + } + + /** + * Checks single record requests + */ + public function testFindSingleModel() + { + $qh = $this->getQueryHandler(); + $request = $this->getHTTPRequest('GET', ApiTestBook::class, '1'); + $result = $qh->handleQuery($request); + + $this->assertContainsOnlyInstancesOf( + ApiTestBook::class, + array($result), + 'Single model request should return a DataObject of class model' + ); + $this->assertEquals( + 1, + $result->ID, + 'IDs mismatch. DataObject is not the record requested' + ); + } + + /** + * Checks multiple records requests + */ + public function testFindMultipleModels() + { + $qh = $this->getQueryHandler(); + $request = $this->getHTTPRequest('GET', ApiTestBook::class); + $result = $qh->handleQuery($request); + + $this->assertContainsOnlyInstancesOf( + DataList::class, + array($result), + 'Request for multiple models should return a DataList' + ); + + $this->assertGreaterThan( + 1, + $result->toArray(), + 'Request should return more than 1 result' + ); + } + + /** + * Checks fallback for models without explicit mapping + */ + public function testModelMappingFallback() + { + $qh = $this->getQueryHandler(); + $request = $this->getHTTPRequest('GET', ApiTestWidget::class, '1'); + $result = $qh->handleQuery($request); + + $this->assertContainsOnlyInstancesOf( + ApiTestWidget::class, + array($result), + 'Unmapped model should fall back to standard mapping' + ); + } + + /** + * Checks max record limit config + */ + public function testMaxRecordsLimit() + { + Config::inst()->update(DefaultQueryHandler::class, 'max_records_limit', 1); + + $qh = $this->getQueryHandler(); + $request = $this->getHTTPRequest('GET', ApiTestBook::class); + $result = $qh->handleQuery($request); + + $this->assertCount( + 1, + $result->toArray(), + 'Request for multiple models should implement limit set by max_records_limit config' + ); + } + + /** + * Checks new record creation + */ + public function testCreateModel() + { + $existingRecords = ApiTestBook::get()->toArray(); + + $qh = $this->getQueryHandler(); + $request = $this->getHTTPRequest('POST', ApiTestBook::class); + + $body = json_encode(array('Title' => 'New Test Book')); + $request->setBody($body); + + $result = $qh->createModel(ApiTestBook::class, $request); + $rewRecords = ApiTestBook::get()->toArray(); + + $this->assertContainsOnlyInstancesOf( + DataObject::class, + array($result), + 'Create model should return a DataObject' + ); + + $this->assertEquals( + count($existingRecords) + 1, + count($rewRecords), + 'Create model should create a database entry' + ); + + $this->assertEquals( + 'New Test Book', + $result->Title, + "Created model title doesn't match" + ); + + // failing tests return error? + } + + /** + * Checks new record creation + */ + public function testModelValidation() + { + $qh = $this->getQueryHandler(); + $request = $this->getHTTPRequest('POST', ApiTestBook::class); + + $body = json_encode(array('Title' => 'New Test Book', 'Pages' => 101)); + $request->setBody($body); + + $result = $qh->createModel(ApiTestBook::class, $request); + + $this->assertEquals( + 'Too many pages', + $result->message, + "Model with validation error should return the validation error" + ); + } + + /** + * Checks record update + */ + public function testUpdateModel() + { + $firstRecord = ApiTestBook::get()->first(); + + $qh = $this->getQueryHandler(); + $request = $this->getHTTPRequest('PUT', ApiTestBook::class); + + $newTitle = $firstRecord->Title . ' UPDATED'; + $body = json_encode(array('Title' => $newTitle)); + $request->setBody($body); + + $result = $qh->updateModel(ApiTestBook::class, $firstRecord->ID, $request); + $updatedRecord = DataObject::get_by_id(ApiTestBook::class, $firstRecord->ID); + + $this->assertContainsOnlyInstancesOf( + DataObject::class, + array($result), + 'Update model should return a DataObject' + ); + + $this->assertEquals( + $newTitle, + $updatedRecord->Title, + "Update model didn't update database record" + ); + + // failing tests return error? + } + + /** + * Checks record deletion + */ + public function testDeleteModel() + { + $firstRecord = ApiTestBook::get()->first(); + + $qh = $this->getQueryHandler(); + $request = $this->getHTTPRequest('DELETE', ApiTestBook::class); + $result = $qh->deleteModel(ApiTestBook::class, $firstRecord->ID, $request); + + $deletedRecord = DataObject::get_by_id(ApiTestBook::class, $firstRecord->ID); + + $this->assertNull( + $deletedRecord, + 'Delete model should delete a database record' + ); + } + + public function testAfterDeserialize() + { + $product = ApiTestProduct::get()->first(); + $qh = $this->getQueryHandler(); + $request = $this->getHTTPRequest('PUT', ApiTestProduct::class, $product->ID); + $body = json_encode(array( + 'Title' => 'Making product available', + 'Soldout' => false, + )); + $request->setBody($body); + + $updatedProduct = $qh->handleQuery($request); + + $this->assertContainsOnlyInstancesOf( + DataObject::class, + array($updatedProduct), + 'Update model should return a DataObject' + ); + + $this->assertEquals( + ApiTestProduct::$rawJSON, + $body, + "Raw JSON passed into 'onBeforeDeserialize' should match request payload" + ); + + $this->assertTrue( + $updatedProduct->Soldout == 1, + "Product should still be sold out, because 'onAfterDeserialize' unset the data bafore writing" + ); + } +} diff --git a/tests/RESTfulAPITester.php b/tests/RESTfulAPITester.php new file mode 100644 index 0000000..e49aa20 --- /dev/null +++ b/tests/RESTfulAPITester.php @@ -0,0 +1,170 @@ + 'Peter', + 'IsMan' => true, + )); + $marie = ApiTestAuthor::create(array( + 'Name' => 'Marie', + 'IsMan' => false, + )); + + $bible = ApiTestBook::create(array( + 'Title' => 'The Bible', + 'Pages' => 60, + )); + $kamasutra = ApiTestBook::create(array( + 'Title' => 'Kama Sutra', + 'Pages' => 70, + )); + + $helsinki = ApiTestLibrary::create(array( + 'Name' => 'Helsinki', + )); + $paris = ApiTestLibrary::create(array( + 'Name' => 'Paris', + )); + + // write to DB + $peter->write(); + $marie->write(); + $bible->write(); + $kamasutra->write(); + $helsinki->write(); + $paris->write(); + + // relations + $peter->Books()->add($bible); + $marie->Books()->add($kamasutra); + + $helsinki->Books()->add($bible); + $helsinki->Books()->add($kamasutra); + $paris->Books()->add($kamasutra); + + // since it doesn't seem to be called automatically + $ext = new GroupExtension(); + $ext->requireDefaultRecords(); + } + + public function setDefaultApiConfig() + { + Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_CONFIG_ONLY'); + + Config::inst()->update(RESTfulAPI::class, 'dependencies', array( + 'authenticator' => '%$Colymba\RESTfulAPI\Authenticators\TokenAuthenticator', + 'authority' => '%$Colymba\RESTfulAPI\PermissionManagers\DefaultPermissionManager', + 'queryHandler' => '%$Colymba\RESTfulAPI\QueryHandlers\DefaultQueryHandler', + 'serializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultSerializer', + )); + + Config::inst()->update(RESTfulAPI::class, 'cors', array( + 'Enabled' => true, + 'Allow-Origin' => '*', + 'Allow-Headers' => '*', + 'Allow-Methods' => 'OPTIONS, POST, GET, PUT, DELETE', + 'Max-Age' => 86400, + )); + + Config::inst()->update(DefaultQueryHandler::class, 'dependencies', array( + 'deSerializer' => '%$Colymba\RESTfulAPI\Serializers\DefaultDeSerializer' + )); + + Config::inst()->update(DefaultQueryHandler::class, 'models', array( + 'apitestauthor' => 'Colymba\RESTfulAPI\Tests\Fixtures\ApiTestAuthor', + 'apitestlibrary' => 'Colymba\RESTfulAPI\Tests\Fixtures\ApiTestLibrary', + ) + ); + } + + public function getOPTIONSHeaders($method = 'GET', $site = null) + { + if (!$site) { + $site = Director::absoluteBaseURL(); + } + $host = parse_url($site, PHP_URL_HOST); + + return array( + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip,deflate,sdch', + 'Accept-Language' => 'en-GB,fr;q=0.8,en-US;q=0.6,en;q=0.4', + 'Access-Control-Request-Headers' => 'accept, x-silverstripe-apitoken', + 'Access-Control-Request-Method' => $method, + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'Host' => $host, + 'Origin' => 'http://' . $host, + 'Pragma' => 'no-cache', + 'Referer' => 'http://' . $host . '/', + 'User-Agent' => 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36', + ); + } + + public function getRequestHeaders($site = null) + { + if (!$site) { + $site = Director::absoluteBaseURL(); + } + $host = parse_url($site, PHP_URL_HOST); + + return array( + 'Accept' => 'application/json, text/javascript, */*; q=0.01', + 'Accept-Encoding' => 'gzip,deflate,sdch', + 'Accept-Language' => 'en-GB,fr;q=0.8,en-US;q=0.6,en;q=0.4', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'Host' => $host, + 'Origin' => 'http://' . $host, + 'Pragma' => 'no-cache', + 'Referer' => 'http://' . $host . '/', + 'User-Agent' => 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36', + 'X-Silverstripe-Apitoken' => 'secret key', + ); + } + + public static function setUpBeforeClass() + { + parent::setUpBeforeClass(); + + if (self::getExtraDataobjects()) { + self::generateDBEntries(); + } + } + + public function setUp() + { + parent::setUp(); + + $this->setDefaultApiConfig(); + + Config::inst()->update(Director::class, 'alternate_base_url', 'http://mysite.com/'); + } +} diff --git a/tests/RESTfulAPI_Tester.php b/tests/RESTfulAPI_Tester.php deleted file mode 100644 index 0c4d156..0000000 --- a/tests/RESTfulAPI_Tester.php +++ /dev/null @@ -1,149 +0,0 @@ - 'Peter', - 'IsMan' => true - )); - $marie = ApiTest_Author::create(array( - 'Name' => 'Marie', - 'IsMan' => false - )); - - $bible = ApiTest_Book::create(array( - 'Title' => 'The Bible', - 'Pages' => 2000 - )); - $kamasutra = ApiTest_Book::create(array( - 'Title' => 'Kama Sutra', - 'Pages' => 1000 - )); - - $helsinki = ApiTest_Library::create(array( - 'Name' => 'Helsinki' - )); - $paris = ApiTest_Library::create(array( - 'Name' => 'Paris' - )); - - // write to DB - $peter->write(); - $marie->write(); - $bible->write(); - $kamasutra->write(); - $helsinki->write(); - $paris->write(); - - // relations - $peter->Books()->add($bible); - $marie->Books()->add($kamasutra); - - $helsinki->Books()->add($bible); - $helsinki->Books()->add($kamasutra); - $paris->Books()->add($kamasutra); - - // since it doesn't seem to be called automatically - $ext = new RESTfulAPI_GroupExtension(); - $ext->requireDefaultRecords(); - } - - public function setDefaultApiConfig() - { - Config::inst()->update('RESTfulAPI', 'access_control_policy', 'ACL_CHECK_CONFIG_ONLY'); - - Config::inst()->update('RESTfulAPI', 'dependencies', array( - 'authenticator' => '%$RESTfulAPI_TokenAuthenticator', - 'authority' => '%$RESTfulAPI_DefaultPermissionManager', - 'queryHandler' => '%$RESTfulAPI_DefaultQueryHandler', - 'serializer' => '%$RESTfulAPI_BasicSerializer' - )); - - Config::inst()->update('RESTfulAPI', 'cors', array( - 'Enabled' => true, - 'Allow-Origin' => '*', - 'Allow-Headers' => '*', - 'Allow-Methods' => 'OPTIONS, POST, GET, PUT, DELETE', - 'Max-Age' => 86400 - )); - - Config::inst()->update('RESTfulAPI_DefaultQueryHandler', 'dependencies', array( - 'deSerializer' => '%$RESTfulAPI_BasicDeSerializer' - )); - } - - public function getOPTIONSHeaders($method = 'GET', $site = null) - { - if (!$site) { - $site = Director::absoluteBaseURL(); - } - $host = parse_url($site, PHP_URL_HOST); - - return array( - 'Accept' => '*/*', - 'Accept-Encoding' => 'gzip,deflate,sdch', - 'Accept-Language' => 'en-GB,fr;q=0.8,en-US;q=0.6,en;q=0.4', - 'Access-Control-Request-Headers' => 'accept, x-silverstripe-apitoken', - 'Access-Control-Request-Method' => $method, - 'Cache-Control' => 'no-cache', - 'Connection' => 'keep-alive', - 'Host' => $host, - 'Origin' => 'http://'.$host, - 'Pragma' => 'no-cache', - 'Referer' => 'http://'.$host.'/', - 'User-Agent' => 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36' - ); - } - - public function getRequestHeaders($site = null) - { - if (!$site) { - $site = Director::absoluteBaseURL(); - } - $host = parse_url($site, PHP_URL_HOST); - - return array( - 'Accept' => 'application/json, text/javascript, */*; q=0.01', - 'Accept-Encoding' => 'gzip,deflate,sdch', - 'Accept-Language' => 'en-GB,fr;q=0.8,en-US;q=0.6,en;q=0.4', - 'Cache-Control' => 'no-cache', - 'Connection' => 'keep-alive', - 'Host' => $host, - 'Origin' => 'http://'.$host, - 'Pragma' => 'no-cache', - 'Referer' => 'http://'.$host.'/', - 'User-Agent' => 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36', - 'X-Silverstripe-Apitoken' => 'secret key' - ); - } - - public function setUpOnce() - { - parent::setUpOnce(); - - if ($this->extraDataObjects) { - $this->generateDBEntries(); - } - - Config::inst()->update('Director', 'alternate_base_url', 'http://mysite.com/'); - } - - public function setUp() - { - parent::setUp(); - - $this->setDefaultApiConfig(); - } -} diff --git a/tests/Serializers/DefaultDeSerializerTest.php b/tests/Serializers/DefaultDeSerializerTest.php new file mode 100644 index 0000000..6d75a50 --- /dev/null +++ b/tests/Serializers/DefaultDeSerializerTest.php @@ -0,0 +1,90 @@ +inject($deserializer); + + return $deserializer; + } + + /* ********************************************************** + * TESTS + * */ + + /** + * Checks payload deserialization + */ + public function testDeserialize() + { + $deserializer = $this->getDeSerializer(); + $json = json_encode(array('Name' => 'Some name')); + $result = $deserializer->deserialize($json); + + $this->assertTrue( + is_array($result), + "Default DeSerialize should return an array" + ); + + $this->assertEquals( + "Some name", + $result['Name'], + "Default DeSerialize should not change values" + ); + } + + /** + * Checks payload column/class names unformatting + */ + public function testUnformatName() + { + $deserializer = $this->getDeSerializer(); + + $column = 'Name'; + $class = 'Colymba\RESTfulAPI\Tests\Fixtures\ApiTestAuthor'; + + $this->assertEquals( + $column, + $deserializer->unformatName($column), + "Default DeSerialize should not change name formatting" + ); + + $this->assertEquals( + ApiTestAuthor::class, + $deserializer->unformatName($class), + "Default DeSerialize should return ucfirst class name" + ); + } +} diff --git a/tests/serializers/Basic/RESTfulAPI_BasicSerializer_Test.php b/tests/Serializers/DefaultSerializerTest.php similarity index 58% rename from tests/serializers/Basic/RESTfulAPI_BasicSerializer_Test.php rename to tests/Serializers/DefaultSerializerTest.php index 88636a0..9248a8a 100644 --- a/tests/serializers/Basic/RESTfulAPI_BasicSerializer_Test.php +++ b/tests/Serializers/DefaultSerializerTest.php @@ -1,6 +1,21 @@ inject($serializer); return $serializer; } - /*********************************************************** * TESTS **/ @@ -38,95 +52,92 @@ protected function getSerializer() */ public function testContentType() { - $serializer = $this->getSerializer(); + $serializer = $this->getSerializer(); $contentType = $serializer->getcontentType(); $this->assertTrue( is_string($contentType), - 'Basic Serializer getcontentType() should return string' + 'Default Serializer getcontentType() should return string' ); } - /** * Checks data serialization */ public function testSerialize() { - Config::inst()->update('RESTfulAPI', 'access_control_policy', false); + Config::inst()->update(RESTfulAPI::class, 'access_control_policy', false); $serializer = $this->getSerializer(); // test single dataObject serialization - $dataObject = ApiTest_Author::get()->filter(array('Name' => 'Peter'))->first(); + $dataObject = ApiTestAuthor::get()->filter(array('Name' => 'Peter'))->first(); $jsonString = $serializer->serialize($dataObject); $jsonObject = json_decode($jsonString); $this->assertEquals( JSON_ERROR_NONE, json_last_error(), - 'Basic Serialize dataObject should return valid JSON' + 'Default Serialize dataObject should return valid JSON' ); $this->assertEquals( $dataObject->Name, $jsonObject->Name, - 'Basic Serialize should return an object and not modify values' + 'Default Serialize should return an object and not modify values' ); // test datalist serialization - $dataList = ApiTest_Author::get(); + $dataList = ApiTestAuthor::get(); $jsonString = $serializer->serialize($dataList); - $jsonArray = json_decode($jsonString); + $jsonArray = json_decode($jsonString); $this->assertEquals( JSON_ERROR_NONE, json_last_error(), - 'Basic Serialize dataList should return valid JSON' + 'Default Serialize dataList should return valid JSON' ); $this->assertTrue( is_array($jsonArray), - 'Basic Serialize dataObject should return an object' + 'Default Serialize dataObject should return an object' ); } - /** * Checks embedded records config */ public function testEmbeddedRecords() { - Config::inst()->update('RESTfulAPI', 'access_control_policy', 'ACL_CHECK_CONFIG_ONLY'); - Config::inst()->update('ApiTest_Library', 'api_access', true); - Config::inst()->update('RESTfulAPI', 'embedded_records', array( - 'ApiTest_Library' => array('Books') + Config::inst()->update(RESTfulAPI::class, 'access_control_policy', 'ACL_CHECK_CONFIG_ONLY'); + Config::inst()->update(ApiTestLibrary::class, 'api_access', true); + Config::inst()->update(RESTfulAPI::class, 'embedded_records', array( + 'Colymba\RESTfulAPI\Tests\Fixtures\ApiTestLibrary' => array('Books'), )); $serializer = $this->getSerializer(); - $dataObject = ApiTest_Library::get()->filter(array('Name' => 'Helsinki'))->first(); + $dataObject = ApiTestLibrary::get()->filter(array('Name' => 'Helsinki'))->first(); // api access disabled - Config::inst()->update('ApiTest_Book', 'api_access', false); + Config::inst()->update(ApiTestBook::class, 'api_access', false); $result = $serializer->serialize($dataObject); $result = json_decode($result); $this->assertEmpty( $result->Books, - 'Basic Serialize should return empty array for DataObject without permission' + 'Default Serialize should return empty array for DataObject without permission' ); // api access enabled - Config::inst()->update('ApiTest_Book', 'api_access', true); + Config::inst()->update(ApiTestBook::class, 'api_access', true); $result = $serializer->serialize($dataObject); $result = json_decode($result); $this->assertTrue( is_numeric($result->Books[0]->ID), - 'Basic Serialize should return a full record for embedded records' + 'Default Serialize should return a full record for embedded records' ); } - /** * Checks column name formatting */ @@ -139,7 +150,7 @@ public function testFormatName() $this->assertEquals( $column, $serializer->formatName($column), - 'Basic Serialize should not change name formatting' + 'Default Serialize should not change name formatting' ); } @@ -148,13 +159,13 @@ public function testFormatName() */ public function testReturnDefinedApiFieldsOnly() { - Config::inst()->update('ApiTest_Author', 'api_access', true); + Config::inst()->update(ApiTestAuthor::class, 'api_access', true); $serializer = $this->getSerializer(); - $dataObject = ApiTest_Author::get()->filter(array('Name' => 'Marie'))->first(); + $dataObject = ApiTestAuthor::get()->filter(array('Name' => 'Marie'))->first(); - Config::inst()->update('ApiTest_Author', 'api_fields', array('Name')); + Config::inst()->update(ApiTestAuthor::class, 'api_fields', array('Name')); $result = $serializer->serialize($dataObject); $result = json_decode($result); @@ -169,7 +180,7 @@ public function testReturnDefinedApiFieldsOnly() 'You should be able to exclude related models by not including them in api_fields.' ); - Config::inst()->update('ApiTest_Author', 'api_fields', array('IsMan', 'Books')); + Config::inst()->update(ApiTestAuthor::class, 'api_fields', array('IsMan', 'Books')); $result = $serializer->serialize($dataObject); $result = json_decode($result); diff --git a/tests/api/RESTfulAPI_Test.php b/tests/api/RESTfulAPI_Test.php deleted file mode 100644 index 2b1022a..0000000 --- a/tests/api/RESTfulAPI_Test.php +++ /dev/null @@ -1,246 +0,0 @@ -update('RESTfulAPI', 'access_control_policy', 'ACL_CHECK_CONFIG_ONLY'); - // ---------------- - // Method Calls - - // Disabled by default - $enabled = RESTfulAPI::api_access_control('ApiTest_Author'); - $this->assertFalse($enabled, 'Access control should return FALSE by default'); - - // Enabled - Config::inst()->update('ApiTest_Author', 'api_access', true); - $enabled = RESTfulAPI::api_access_control('ApiTest_Author'); - $this->assertTrue($enabled, 'Access control should return TRUE when api_access is enbaled'); - - // Method specific - Config::inst()->update('ApiTest_Author', 'api_access', 'GET,POST'); - - $enabled = RESTfulAPI::api_access_control('ApiTest_Author'); - $this->assertTrue($enabled, 'Access control should return TRUE when api_access is enbaled with default GET method'); - - $enabled = RESTfulAPI::api_access_control('ApiTest_Author', 'POST'); - $this->assertTrue($enabled, 'Access control should return TRUE when api_access match HTTP method'); - - $enabled = RESTfulAPI::api_access_control('ApiTest_Author', 'PUT'); - $this->assertFalse($enabled, 'Access control should return FALSE when api_access does not match method'); - - // ---------------- - // API Calls - /* - // Access authorised - $response = Director::test('api/ApiTest_Author/1', null, null, 'GET'); - $this->assertEquals( - $response->getStatusCode(), - 200 - ); - - // Access denied - Config::inst()->update('ApiTest_Author', 'api_access', false); - $response = Director::test('api/ApiTest_Author/1', null, null, 'GET'); - $this->assertEquals( - $response->getStatusCode(), - 403 - ); - - // Access denied - Config::inst()->update('ApiTest_Author', 'api_access', 'POST'); - $response = Director::test('api/ApiTest_Author/1', null, null, 'GET'); - $this->assertEquals( - $response->getStatusCode(), - 403 - ); - */ - } - - - /* ********************************************************************** - * CORS - * */ - - /** - * Check that CORS headers aren't set - * when disabled via config - */ - public function testCORSDisabled() - { - Config::inst()->update('RESTfulAPI', 'cors', array( - 'Enabled' => false - )); - - $requestHeaders = $this->getOPTIONSHeaders(); - $response = Director::test('api/ApiTest_Book/1', null, null, 'OPTIONS', null, $requestHeaders); - $headers = $response->getHeaders(); - - $this->assertFalse(array_key_exists('Access-Control-Allow-Origin', $headers), 'CORS ORIGIN header should not be present'); - $this->assertFalse(array_key_exists('Access-Control-Allow-Headers', $headers), 'CORS HEADER header should not be present'); - $this->assertFalse(array_key_exists('Access-Control-Allow-Methods', $headers), 'CORS METHOD header should not be present'); - $this->assertFalse(array_key_exists('Access-Control-Max-Age', $headers), 'CORS AGE header should not be present'); - } - - - /** - * Checks default allow all CORS settings - */ - public function testCORSAllowAll() - { - $corsConfig = Config::inst()->get('RESTfulAPI', 'cors'); - $requestHeaders = $this->getOPTIONSHeaders('GET', 'http://google.com'); - $response = Director::test('api/ApiTest_Book/1', null, null, 'OPTIONS', null, $requestHeaders); - $responseHeaders = $response->getHeaders(); - - $this->assertEquals( - $requestHeaders['Origin'], - $responseHeaders['Access-Control-Allow-Origin'], - 'CORS headers should have same ORIGIN' - ); - - $this->assertEquals( - $corsConfig['Allow-Methods'], - $responseHeaders['Access-Control-Allow-Methods'], - 'CORS headers should have same METHOD' - ); - - $this->assertEquals( - $requestHeaders['Access-Control-Request-Headers'], - $responseHeaders['Access-Control-Allow-Headers'], - 'CORS headers should have same ALLOWED HEADERS' - ); - - $this->assertEquals( - $corsConfig['Max-Age'], - $responseHeaders['Access-Control-Max-Age'], - 'CORS headers should have same MAX AGE' - ); - } - - - /** - * Checks CORS only allow HTTP methods specify in config - */ - public function testCORSHTTPMethodFiltering() - { - Config::inst()->update('RESTfulAPI', 'cors', array( - 'Enabled' => true, - 'Allow-Origin' => '*', - 'Allow-Headers' => '*', - 'Allow-Methods' => 'GET', - 'Max-Age' => 86400 - )); - - // Seding GET request, GET should be allowed - $requestHeaders = $this->getRequestHeaders(); - $response = Director::test('api/ApiTest_Book/1', null, null, 'GET', null, $requestHeaders); - $responseHeaders = $response->getHeaders(); - - $this->assertEquals( - 'GET', - $responseHeaders['Access-Control-Allow-Methods'], - 'Only HTTP GET method should be allowed in Access-Control-Allow-Methods HEADER' - ); - - // Seding POST request, only GET should be allowed - $response = Director::test('api/ApiTest_Book/1', null, null, 'POST', null, $requestHeaders); - $responseHeaders = $response->getHeaders(); - - $this->assertEquals( - 'GET', - $responseHeaders['Access-Control-Allow-Methods'], - 'Only HTTP GET method should be allowed in Access-Control-Allow-Methods HEADER' - ); - } - - - /* ********************************************************************** - * API REQUESTS - * */ - - public function testFullBasicAPIRequest() - { - Config::inst()->update('RESTfulAPI', 'authentication_policy', false); - Config::inst()->update('RESTfulAPI', 'access_control_policy', 'ACL_CHECK_CONFIG_ONLY'); - Config::inst()->update('ApiTest_Author', 'api_access', true); - - // Basic serializer - Config::inst()->update('RESTfulAPI', 'dependencies', array( - 'authenticator' => null, - 'authority' => null, - 'queryHandler' => '%$RESTfulAPI_DefaultQueryHandler', - 'serializer' => '%$RESTfulAPI_BasicSerializer' - )); - Config::inst()->update('RESTfulAPI', 'dependencies', array( - 'deSerializer' => '%$RESTfulAPI_BasicDeSerializer' - )); - - $response = Director::test('api/ApiTest_Author/1', null, null, 'GET'); - - $this->assertEquals( - 200, - $response->getStatusCode(), - "API request for existing record should resolve" - ); - - $json = json_decode($response->getBody()); - $this->assertEquals( - JSON_ERROR_NONE, - json_last_error(), - "API request should return valid JSON" - ); - - - // EmberData serializer - Config::inst()->update('RESTfulAPI', 'dependencies', array( - 'authenticator' => null, - 'authority' => null, - 'queryHandler' => '%$RESTfulAPI_DefaultQueryHandler', - 'serializer' => '%$RESTfulAPI_EmberDataSerializer' - )); - Config::inst()->update('RESTfulAPI', 'dependencies', array( - 'deSerializer' => '%$RESTfulAPI_EmberDataDeSerializer' - )); - - $response = Director::test('api/ApiTest_Author/1', null, null, 'GET'); - - $this->assertEquals( - 200, - $response->getStatusCode(), - "API request for existing record should resolve" - ); - - $json = json_decode($response->getBody()); - $this->assertEquals( - JSON_ERROR_NONE, - json_last_error(), - "API request should return valid JSON" - ); - } -} diff --git a/tests/authenticator/RESTfulAPI_TokenAuthenticator_Test.php b/tests/authenticator/RESTfulAPI_TokenAuthenticator_Test.php deleted file mode 100644 index 94464e3..0000000 --- a/tests/authenticator/RESTfulAPI_TokenAuthenticator_Test.php +++ /dev/null @@ -1,212 +0,0 @@ - array('RESTfulAPI_TokenAuthExtension') - ); - - protected function getAuthenticator() - { - $injector = new Injector(); - $auth = new RESTfulAPI_TokenAuthenticator(); - - $injector->inject($auth); - - return $auth; - } - - - public function setUpOnce() - { - parent::setUpOnce(); - - Member::create(array( - 'Email' => 'test@test.com', - 'Password' => 'test' - ))->write(); - } - - - /* ********************************************************** - * TESTS - * */ - - - /** - * Checks that the Member gets logged in - * and a token is returned - */ - public function testLogin() - { - $member = Member::get()->filter(array( - 'Email' => 'test@test.com' - ))->first(); - - $auth = $this->getAuthenticator(); - $request = new SS_HTTPRequest( - 'GET', - 'api/auth/login', - array( - 'email' => 'test@test.com', - 'pwd' => 'test' - ) - ); - - $result = $auth->login($request); - - $this->assertEquals( - Member::currentUserID(), - $member->ID, - "TokenAuth successful login should login the user" - ); - - $this->assertTrue( - is_string($result['token']), - "TokenAuth successful login should return token as string" - ); - } - - - /** - * Checks that the Member is logged out - */ - public function testLogout() - { - $auth = $this->getAuthenticator(); - $request = new SS_HTTPRequest( - 'GET', - 'api/auth/logout', - array( - 'email' => 'test@test.com' - ) - ); - - $result = $auth->logout($request); - - $this->assertNull( - Member::currentUser(), - "TokenAuth successful logout should logout the user" - ); - } - - - /** - * Checks that a string token is returned - */ - public function testGetToken() - { - $member = Member::get()->filter(array( - 'Email' => 'test@test.com' - ))->first(); - - $auth = $this->getAuthenticator(); - $result = $auth->getToken($member->ID); - - $this->assertTrue( - is_string($result), - "TokenAuth getToken should return token as string" - ); - } - - - /** - * Checks that a new toekn is generated - */ - public function testResetToken() - { - $member = Member::get()->filter(array( - 'Email' => 'test@test.com' - ))->first(); - - $auth = $this->getAuthenticator(); - $oldToken = $auth->getToken($member->ID); - - $auth->resetToken($member->ID); - $newToken = $auth->getToken($member->ID); - - $this->assertThat( - $oldToken, - $this->logicalNot( - $this->equalTo($newToken) - ), - "TokenAuth reset token should generate a new token" - ); - } - - - /** - * Checks authenticator return owner - */ - public function testGetOwner() - { - $member = Member::get()->filter(array( - 'Email' => 'test@test.com' - ))->first(); - - $auth = $this->getAuthenticator(); - $auth->resetToken($member->ID); - $token = $auth->getToken($member->ID); - - $request = new SS_HTTPRequest( - 'GET', - 'api/ApiTest_Book/1' - ); - $request->addHeader('X-Silverstripe-Apitoken', $token); - - $result = $auth->getOwner($request); - - $this->assertEquals( - 'test@test.com', - $result->Email, - "TokenAuth should return owner when passed valid token." - ); - } - - - /** - * Checks authentication works with a generated token - */ - public function testAuthenticate() - { - $member = Member::get()->filter(array( - 'Email' => 'test@test.com' - ))->first(); - - $auth = $this->getAuthenticator(); - $request = new SS_HTTPRequest( - 'GET', - 'api/ApiTest_Book/1' - ); - - $auth->resetToken($member->ID); - $token = $auth->getToken($member->ID); - $request->addHeader('X-Silverstripe-Apitoken', $token); - - $result = $auth->authenticate($request); - - $this->assertTrue( - $result, - "TokenAuth authentication success should return true" - ); - - $auth->resetToken($member->ID); - $result = $auth->authenticate($request); - - $this->assertContainsOnlyInstancesOf( - 'RESTfulAPI_Error', - array($result), - "TokenAuth authentication failure should return a RESTfulAPI_Error" - ); - } -} diff --git a/tests/permissionManager/RESTfulAPI_DefaultPermissionManager_Test.php b/tests/permissionManager/RESTfulAPI_DefaultPermissionManager_Test.php deleted file mode 100644 index d1ffa08..0000000 --- a/tests/permissionManager/RESTfulAPI_DefaultPermissionManager_Test.php +++ /dev/null @@ -1,187 +0,0 @@ - 'admin@api.com', - 'Password' => 'admin' - ))->write(); - $member = Member::get()->filter(array( - 'Email' => 'admin@api.com' - ))->first(); - $member->addToGroupByCode('restfulapi-administrators'); - - Member::create(array( - 'Email' => 'stranger@api.com', - 'Password' => 'stranger' - ))->write(); - } - - protected function getAdminToken() - { - $response = Director::test('api/auth/login?email=admin@api.com&pwd=admin'); - $json = json_decode($response->getBody()); - return $json->token; - } - - protected function getStrangerToken() - { - $response = Director::test('api/auth/login?email=stranger@api.com&pwd=stranger'); - $json = json_decode($response->getBody()); - return $json->token; - } - - /* ********************************************************** - * TESTS - * */ - - /** - * Test READ permissions are honoured - */ - public function testReadPermissions() - { - Config::inst()->update('RESTfulAPI', 'access_control_policy', 'ACL_CHECK_MODEL_ONLY'); - Config::inst()->update('RESTfulAPI', 'cors', array( - 'Enabled' => false - )); - - // GET with permission = OK - $requestHeaders = $this->getRequestHeaders(); - $requestHeaders['X-Silverstripe-Apitoken'] = $this->getAdminToken(); - $response = Director::test('api/ApiTest_Library/1', null, null, 'GET', null, $requestHeaders); - - $this->assertEquals( - $response->getStatusCode(), - 200, - "Member of 'restfulapi-administrators' Group should be able to READ records." - ); - - // GET with NO Permission = BAD - $requestHeaders = $this->getRequestHeaders(); - $requestHeaders['X-Silverstripe-Apitoken'] = $this->getStrangerToken(); - $response = Director::test('api/ApiTest_Library/1', null, null, 'GET', null, $requestHeaders); - - $this->assertEquals( - $response->getStatusCode(), - 403, - "Member without permission should NOT be able to READ records." - ); - } - - /** - * Test EDIT permissions are honoured - */ - public function testEditPermissions() - { - Config::inst()->update('RESTfulAPI', 'access_control_policy', 'ACL_CHECK_MODEL_ONLY'); - Config::inst()->update('RESTfulAPI', 'cors', array( - 'Enabled' => false - )); - - // PUT with permission = OK - $requestHeaders = $this->getRequestHeaders(); - $requestHeaders['X-Silverstripe-Apitoken'] = $this->getAdminToken(); - $response = Director::test('api/ApiTest_Library/1', null, null, 'PUT', '{"Name":"Api"}', $requestHeaders); - - $this->assertEquals( - $response->getStatusCode(), - 200, - "Member of 'restfulapi-administrators' Group should be able to EDIT records." - ); - - // PUT with NO Permission = BAD - $requestHeaders = $this->getRequestHeaders(); - $requestHeaders['X-Silverstripe-Apitoken'] = $this->getStrangerToken(); - $response = Director::test('api/ApiTest_Library/1', null, null, 'PUT', '{"Name":"Api"}', $requestHeaders); - - $this->assertEquals( - $response->getStatusCode(), - 403, - "Member without permission should NOT be able to EDIT records." - ); - } - - /** - * Test CREATE permissions are honoured - */ - public function testCreatePermissions() - { - Config::inst()->update('RESTfulAPI', 'access_control_policy', 'ACL_CHECK_MODEL_ONLY'); - Config::inst()->update('RESTfulAPI', 'cors', array( - 'Enabled' => false - )); - - // POST with permission = OK - $requestHeaders = $this->getRequestHeaders(); - $requestHeaders['X-Silverstripe-Apitoken'] = $this->getAdminToken(); - $response = Director::test('api/ApiTest_Library', null, null, 'POST', '{"Name":"Api"}', $requestHeaders); - - $this->assertEquals( - $response->getStatusCode(), - 200, - "Member of 'restfulapi-administrators' Group should be able to CREATE records." - ); - - // POST with NO Permission = BAD - $requestHeaders = $this->getRequestHeaders(); - $requestHeaders['X-Silverstripe-Apitoken'] = $this->getStrangerToken(); - $response = Director::test('api/ApiTest_Library', null, null, 'POST', '{"Name":"Api"}', $requestHeaders); - - $this->assertEquals( - $response->getStatusCode(), - 403, - "Member without permission should NOT be able to CREATE records." - ); - } - - /** - * Test DELETE permissions are honoured - */ - public function testDeletePermissions() - { - Config::inst()->update('RESTfulAPI', 'access_control_policy', 'ACL_CHECK_MODEL_ONLY'); - Config::inst()->update('RESTfulAPI', 'cors', array( - 'Enabled' => false - )); - - // DELETE with permission = OK - $requestHeaders = $this->getRequestHeaders(); - $requestHeaders['X-Silverstripe-Apitoken'] = $this->getAdminToken(); - $response = Director::test('api/ApiTest_Library/1', null, null, 'DELETE', null, $requestHeaders); - - $this->assertEquals( - $response->getStatusCode(), - 200, - "Member of 'restfulapi-administrators' Group should be able to DELETE records." - ); - - // DELETE with NO Permission = BAD - $requestHeaders = $this->getRequestHeaders(); - $requestHeaders['X-Silverstripe-Apitoken'] = $this->getStrangerToken(); - $response = Director::test('api/ApiTest_Library/1', null, null, 'DELETE', null, $requestHeaders); - - $this->assertEquals( - $response->getStatusCode(), - 403, - "Member without permission should NOT be able to DELETE records." - ); - } -} diff --git a/tests/queryHandler/RESTfulAPI_DefaultQueryHandler_Test.php b/tests/queryHandler/RESTfulAPI_DefaultQueryHandler_Test.php deleted file mode 100644 index 64984df..0000000 --- a/tests/queryHandler/RESTfulAPI_DefaultQueryHandler_Test.php +++ /dev/null @@ -1,350 +0,0 @@ -match($this->url_pattern); - $request->setRouteParams(array( - 'Controller' => 'RESTfulAPI' - )); - - return $request; - } - - protected function getQueryHandler() - { - $injector = new Injector(); - $qh = new RESTfulAPI_DefaultQueryHandler(); - - $injector->inject($qh); - - return $qh; - } - - public function generateDBEntries() - { - parent::generateDBEntries(); - - $product = ApiTest_Product::create(array( - 'Title' => 'Sold out product', - 'Soldout' => true - )); - $product->write(); - } - - - /* ********************************************************** - * TESTS - * */ - - - /** - * Checks that query parameters are parsed properly - */ - public function testQueryParametersParsing() - { - $qh = $this->getQueryHandler(); - $request = $this->getHTTPRequest('GET', 'ApiTest_Book', '1', array('Title__StartsWith' => 'K')); - $params = $qh->parseQueryParameters($request->getVars()); - $params = array_shift($params); - - $this->assertEquals( - $params['Column'], - 'Title', - 'Column parameter name mismatch' - ); - $this->assertEquals( - $params['Value'], - 'K', - 'Value parameter mismatch' - ); - $this->assertEquals( - $params['Modifier'], - 'StartsWith', - 'Modifier parameter mismatch' - ); - } - - - /** - * Checks that access to DataObject with api_access config disabled return error - */ - public function testAPIDisabled() - { - Config::inst()->update('ApiTest_Book', 'api_access', false); - - $qh = $this->getQueryHandler(); - $request = $this->getHTTPRequest('GET', 'ApiTest_Book', '1'); - $result = $qh->handleQuery($request); - - $this->assertContainsOnlyInstancesOf( - 'RESTfulAPI_Error', - array($result), - 'Request for DataObject with api_access set to false should return a RESTfulAPI_Error' - ); - } - - - /** - * Checks single record requests - */ - public function testFindSingleModel() - { - Config::inst()->update('ApiTest_Book', 'api_access', true); - - $qh = $this->getQueryHandler(); - $request = $this->getHTTPRequest('GET', 'ApiTest_Book', '1'); - $result = $qh->handleQuery($request); - - $this->assertContainsOnlyInstancesOf( - 'ApiTest_Book', - array($result), - 'Single model request should return a DataObject of class model' - ); - $this->assertEquals( - 1, - $result->ID, - 'IDs mismatch. DataObject is not the record requested' - ); - } - - - /** - * Checks multiple records requests - */ - public function testFindMultipleModels() - { - Config::inst()->update('ApiTest_Book', 'api_access', true); - - $qh = $this->getQueryHandler(); - $request = $this->getHTTPRequest('GET', 'ApiTest_Book'); - $result = $qh->handleQuery($request); - - $this->assertContainsOnlyInstancesOf( - 'DataList', - array($result), - 'Request for multiple models should return a DataList' - ); - - $this->assertGreaterThan( - 1, - $result->toArray(), - 'Request should return more than 1 result' - ); - } - - - /** - * Checks max record limit config - */ - public function testMaxRecordsLimit() - { - Config::inst()->update('ApiTest_Book', 'api_access', true); - Config::inst()->update('RESTfulAPI_DefaultQueryHandler', 'max_records_limit', 1); - - $qh = $this->getQueryHandler(); - $request = $this->getHTTPRequest('GET', 'ApiTest_Book'); - $result = $qh->handleQuery($request); - - $this->assertCount( - 1, - $result->toArray(), - 'Request for multiple models should implement limit set by max_records_limit config' - ); - } - - - /** - * Checks new record creation - */ - public function testCreateModel() - { - $existingRecords = ApiTest_Book::get()->toArray(); - - $qh = $this->getQueryHandler(); - $request = $this->getHTTPRequest('POST', 'ApiTest_Book'); - - $body = json_encode(array('Title' => 'New Test Book')); - $request->setBody($body); - - $result = $qh->createModel('ApiTest_Book', $request); - $rewRecords = ApiTest_Book::get()->toArray(); - - $this->assertContainsOnlyInstancesOf( - 'DataObject', - array($result), - 'Create model should return a DataObject' - ); - - $this->assertEquals( - count($existingRecords) + 1, - count($rewRecords), - 'Create model should create a database entry' - ); - - $this->assertEquals( - 'New Test Book', - $result->Title, - "Created model title doesn't match" - ); - - // failing tests return error? - } - - - /** - * Checks new record creation - */ - public function testModelValidation() - { - $qh = $this->getQueryHandler(); - $request = $this->getHTTPRequest('POST', 'ApiTest_Book'); - - $body = json_encode(array('Title' => 'New Test Book', 'Pages' => 101)); - $request->setBody($body); - - $result = $qh->createModel('ApiTest_Book', $request); - - $this->assertEquals( - 'Too many pages', - $result->message, - "Model with validation error should return the validation error" - ); - } - - - /** - * Checks record update - */ - public function testUpdateModel() - { - $firstRecord = ApiTest_Book::get()->first(); - - $qh = $this->getQueryHandler(); - $request = $this->getHTTPRequest('PUT', 'ApiTest_Book'); - - $newTitle = $firstRecord->Title . ' UPDATED'; - $body = json_encode(array('Title' => $newTitle)); - $request->setBody($body); - - $result = $qh->updateModel('ApiTest_Book', $firstRecord->ID, $request); - $updatedRecord = DataObject::get_by_id('ApiTest_Book', $firstRecord->ID); - - - $this->assertContainsOnlyInstancesOf( - 'DataObject', - array($result), - 'Update model should return a DataObject' - ); - - $this->assertEquals( - $newTitle, - $updatedRecord->Title, - "Update model didn't update database record" - ); - - // failing tests return error? - } - - - /** - * Checks record deletion - */ - public function testDeleteModel() - { - $firstRecord = ApiTest_Book::get()->first(); - - $qh = $this->getQueryHandler(); - $request = $this->getHTTPRequest('DELETE', 'ApiTest_Book'); - $result = $qh->deleteModel('ApiTest_Book', $firstRecord->ID, $request); - - $deletedRecord = DataObject::get_by_id('ApiTest_Book', $firstRecord->ID); - - $this->assertFalse( - $deletedRecord, - 'Delete model should delete a database record' - ); - } - - public function testAfterDeserialize() - { - $product = ApiTest_Product::get()->first(); - $qh = $this->getQueryHandler(); - $request = $this->getHTTPRequest('PUT', 'ApiTest_Product', $product->ID); - $body = json_encode(array( - 'Title' => 'Making product available', - 'Soldout' => false - )); - $request->setBody($body); - - $updatedProduct = $qh->handleQuery($request); - - $this->assertContainsOnlyInstancesOf( - 'DataObject', - array($updatedProduct), - 'Update model should return a DataObject' - ); - - $this->assertEquals( - ApiTest_Product::$rawJSON, - $body, - "Raw JSON passed into 'onBeforeDeserialize' should match request payload" - ); - - $this->assertTrue( - $updatedProduct->Soldout == 1, - "Product should still be sold out, because 'onAfterDeserialize' unset the data bafore writing" - ); - } -} - -/** - * Class to test deserialize hooks - */ -class ApiTest_Product extends DataObject -{ - public static $rawJSON; - - private static $db = array( - 'Title' => 'Varchar(64)', - 'Soldout' => 'Boolean' - ); - - private static $api_access = true; - - public function onAfterDeserialize(&$payload) - { - // don't allow setting `Soldout` via REST API - unset($payload['Soldout']); - } - - public function onBeforeDeserialize(&$rawJson) - { - self::$rawJSON = $rawJson; - } -} diff --git a/tests/serializers/Basic/RESTfulAPI_BasicDeSerializer_Test.php b/tests/serializers/Basic/RESTfulAPI_BasicDeSerializer_Test.php deleted file mode 100644 index 35a70aa..0000000 --- a/tests/serializers/Basic/RESTfulAPI_BasicDeSerializer_Test.php +++ /dev/null @@ -1,81 +0,0 @@ -inject($deserializer); - - return $deserializer; - } - - - /* ********************************************************** - * TESTS - * */ - - - /** - * Checks payload deserialization - */ - public function testDeserialize() - { - $deserializer = $this->getDeSerializer(); - $json = json_encode(array('Name' => 'Some name')); - $result = $deserializer->deserialize($json); - - $this->assertTrue( - is_array($result), - "Basic DeSerialize should return an array" - ); - - $this->assertEquals( - "Some name", - $result['Name'], - "Basic DeSerialize should not change values" - ); - } - - - /** - * Checks payload column/class names unformatting - */ - public function testUnformatName() - { - $deserializer = $this->getDeSerializer(); - - $column = 'Name'; - $class = 'apiTest_Author'; - - $this->assertEquals( - $column, - $deserializer->unformatName($column), - "Basic DeSerialize should not change name formatting" - ); - - $this->assertEquals( - 'ApiTest_Author', - $deserializer->unformatName($class), - "Basic DeSerialize should return ucfirst class name" - ); - } -} diff --git a/tests/serializers/EmberData/RESTfulAPI_EmberDataDeSerializer_Test.php b/tests/serializers/EmberData/RESTfulAPI_EmberDataDeSerializer_Test.php deleted file mode 100644 index d2bb27e..0000000 --- a/tests/serializers/EmberData/RESTfulAPI_EmberDataDeSerializer_Test.php +++ /dev/null @@ -1,81 +0,0 @@ -inject($deserializer); - - return $deserializer; - } - - - /* ********************************************************** - * TESTS - * */ - - - /** - * Checks payload deserialization - */ - public function testDeserialize() - { - $deserializer = $this->getDeSerializer(); - $json = json_encode(array('Name' => 'Some name')); - $result = $deserializer->deserialize($json); - - $this->assertTrue( - is_array($result), - "Basic DeSerialize should return an array" - ); - - $this->assertEquals( - "Some name", - $result['Name'], - "Basic DeSerialize should not change values" - ); - } - - - /** - * Checks payload column/class names unformatting - */ - public function testUnformatName() - { - $deserializer = $this->getDeSerializer(); - - $column = 'Name'; - $class = 'apiTest_Author'; - - $this->assertEquals( - $column, - $deserializer->unformatName($column), - "Basic DeSerialize should not change name formatting" - ); - - $this->assertEquals( - 'ApiTest_Author', - $deserializer->unformatName($class), - "Basic DeSerialize should return ucfirst class name" - ); - } -} diff --git a/tests/serializers/EmberData/RESTfulAPI_EmberDataSerializer_Test.php b/tests/serializers/EmberData/RESTfulAPI_EmberDataSerializer_Test.php deleted file mode 100644 index 636efd4..0000000 --- a/tests/serializers/EmberData/RESTfulAPI_EmberDataSerializer_Test.php +++ /dev/null @@ -1,127 +0,0 @@ -inject($serializer); - - return $serializer; - } - - - /* ********************************************************** - * TESTS - * */ - - - /** - * Checks serializer content type access - */ - public function testContentType() - { - $serializer = $this->getSerializer(); - $contentType = $serializer->getcontentType(); - - $this->assertTrue( - is_string($contentType), - 'EmberData Serializer getcontentType() should return string' - ); - } - - - /** - * Checks data serialization - */ - public function testSerialize() - { - $serializer = $this->getSerializer(); - - // test single dataObject serialization - $dataObject = ApiTest_Author::get()->filter(array('Name' => 'Peter'))->first(); - $jsonString = $serializer->serialize($dataObject); - $jsonObject = json_decode($jsonString); - - $this->assertEquals( - 1, - $jsonObject->apiTest_Author->id, - "EmberData Serialize should wrap result in an object in JSON root" - ); - } - - - /** - * Checks sideloading records config - */ - public function testSideloadedRecords() - { - Config::inst()->update('RESTfulAPI_EmberDataSerializer', 'sideloaded_records', array( - 'ApiTest_Library' => array('Books') - )); - - Config::inst()->update('ApiTest_Book', 'api_access', true); - - $serializer = $this->getSerializer(); - $dataObject = ApiTest_Library::get()->filter(array('Name' => 'Helsinki'))->first(); - - - $jsonString = $serializer->serialize($dataObject); - $jsonObject = json_decode($jsonString); - - $booksRoot = $serializer->formatName('ApiTest_Book'); - $booksRoot = Inflector::pluralize($booksRoot); - - $this->assertFalse( - is_null($jsonObject->$booksRoot), - "EmberData Serialize should sideload records in an object in JSON root" - ); - - $this->assertTrue( - is_array($jsonObject->$booksRoot), - "EmberData Serialize should sideload records as array" - ); - } - - - /** - * Checks column name formatting - */ - public function testFormatName() - { - $serializer = $this->getSerializer(); - - $column = 'UpperCamelCase'; - $class = 'ApiTest_Library'; - - $this->assertEquals( - 'upperCamelCase', - $serializer->formatName($column), - "EmberData Serializer should return lowerCamel case columns" - ); - - $this->assertEquals( - 'apiTest_Library', - $serializer->formatName($class), - "EmberData Serializer should return lowerCamel case class" - ); - } -}