diff --git a/Config/Schema/schema.php b/Config/Schema/schema.php index 5834c5a..729f0f8 100644 --- a/Config/Schema/schema.php +++ b/Config/Schema/schema.php @@ -11,12 +11,15 @@ public function after($event = array()) { public $tokens = array( 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'key' => 'primary'), 'user_id' => array('type' => 'integer', 'null' => false, 'default' => null, 'key' => 'index'), + 'organization_id' => array('type' => 'integer', 'null' => false, 'default' => null, 'key' => 'index'), 'api' => array('type' => 'string', 'null' => false, 'default' => null, 'collate' => 'utf8_general_ci', 'charset' => 'utf8'), 'access_token' => array('type' => 'text', 'null' => false, 'default' => null, 'collate' => 'utf8_general_ci', 'charset' => 'utf8'), 'modified' => array('type' => 'datetime', 'null' => true, 'default' => null), 'refresh_token' => array('type' => 'text', 'null' => true, 'default' => null, 'collate' => 'utf8_general_ci', 'charset' => 'utf8'), 'token_secret' => array('type' => 'text', 'null' => true, 'default' => null, 'collate' => 'utf8_general_ci', 'charset' => 'utf8'), 'expires_in' => array('type' => 'string', 'null' => true, 'default' => null, 'collate' => 'utf8_general_ci', 'charset' => 'utf8'), + 'api_domain' => array('type' => 'string', 'null' => true, 'default' => null, 'collate' => 'utf8_general_ci', 'charset' => 'utf8'), + 'sandbox_domain' => array('type' => 'string', 'null' => true, 'default' => null, 'collate' => 'utf8_general_ci', 'charset' => 'utf8'), 'indexes' => array( 'PRIMARY' => array('column' => 'id', 'unique' => 1), 'user_id' => array('column' => array('user_id', 'api'), 'unique' => 1) diff --git a/Controller/Component/OauthComponent.php b/Controller/Component/OauthComponent.php index b0f38ad..351dd01 100644 --- a/Controller/Component/OauthComponent.php +++ b/Controller/Component/OauthComponent.php @@ -63,6 +63,16 @@ protected function _checkAuthFailure(Controller $controller) { function getOauthUri($apiName, $path, $extra = array()) { if (Configure::check('Copula.' . $apiName . '.Auth')) { $config = Configure::read('Copula.' . $apiName . '.Auth'); + + if(!empty($config['callback']) && is_array($config['callback'])){ + $config['callback'] = Router::url($config['callback']); + } + + //if a domain is passed into the options, override the default + if(!empty($extra['api_domain'])){ + $config['host'] = $extra['api_domain']; + } + if ($config['authMethod'] == 'OAuth') { return $config['scheme'] . '://' . $config['host'] . '/' . $config[$path]; } elseif ($config['authMethod'] == 'OAuthV2') { @@ -158,7 +168,7 @@ function getOauthRequestToken($apiName, $requestOptions = array()) { * @param string $apiName * @throws CakeException */ - function callback($apiName) { + function callback($apiName, $api_domain = null) { $method = $this->getOauthMethod($apiName); if ($method == 'OAuthV2') { $code = $this->controller->request->query('code'); @@ -167,9 +177,9 @@ function callback($apiName) { } $accessToken = $this->getAccessTokenV2($apiName, $code); if (!empty($accessToken)) { - return $this->_afterRequest($accessToken, $apiName, $method); + return $this->_afterRequest($accessToken, $apiName, $method, $api_domain); } else { - throw new CakeException(__('Could not get OAuthV2 Access Token from %s', $apiName)); + throw new CakeException(__('Could not get OAuthV2 Access Token from %s or Token already exists', $apiName)); } } elseif ($method == 'OAuth') { $verifier = $this->controller->request->query('oauth_verifier'); @@ -190,8 +200,8 @@ function callback($apiName) { } } - protected function _afterRequest(array $accessToken, $apiName, $version) { - if ($this->store($accessToken, $apiName, $version)) { + protected function _afterRequest(array $accessToken, $apiName, $version, $api_domain = null) { + if ($this->store($accessToken, $apiName, $version, $api_domain)) { if ($this->Session->check('Oauth.redirect')) { $redirect = $this->Session->read('Oauth.redirect'); $this->Session->delete('Oauth.redirect'); @@ -200,7 +210,7 @@ protected function _afterRequest(array $accessToken, $apiName, $version) { return $accessToken; } } else { - throw new CakeException(__('Could not store access token for API %s', $apiName)); + throw new CakeException(__('Could not store access token for API %s Possibly already in db', $apiName)); } } @@ -210,11 +220,11 @@ protected function _afterRequest(array $accessToken, $apiName, $version) { * @param string|array $accessToken * @param string $tokenSecret */ - public function store(array $accessToken, $apiName, $version) { + public function store(array $accessToken, $apiName, $version, $api_domain = null) { $storageMethod = (empty($this->controller->Apis[$apiName]['store'])) ? 'Db' : ucfirst($this->controller->Apis[$apiName]['store']); $Store = ClassRegistry::init('Copula.TokenStore' . $storageMethod); if ($Store instanceof TokenStoreInterface) { - return $Store->saveToken($accessToken, $apiName, AuthComponent::user('id'), $version); + return $Store->saveToken($accessToken, $apiName, AuthComponent::user('id'), $version, $api_domain); } else { throw new CakeException(__('Storage Method: %s not supported.', $storageMethod)); } diff --git a/Integration.md b/Integration.md index 92bd192..527c7e3 100644 --- a/Integration.md +++ b/Integration.md @@ -89,7 +89,7 @@ For the purposes of this document, the hosts configuration file is assumed to be ```php 'OAuthV2', 'scheme' => 'https', 'authorize' => 'o/oauth2/auth', @@ -99,7 +99,7 @@ For the purposes of this document, the hosts configuration file is assumed to be 'callback' => 'https://example.com/oauth2callback/' ); - $config['Copula']['cloudprint']['Api'] = array( + $config['Copula']['cloudprint']['path']['Api'] = array( 'host' => 'www.google.com/cloudprint', 'authMethod' => 'OAuthV2' ); diff --git a/Model/Behavior/OAuthConsumerBehavior.php b/Model/Behavior/OAuthConsumerBehavior.php index b89e030..88aa704 100644 --- a/Model/Behavior/OAuthConsumerBehavior.php +++ b/Model/Behavior/OAuthConsumerBehavior.php @@ -13,7 +13,8 @@ public function setup(\Model $model, $config = array()) { $this->config[$model->alias] = array_merge( $this->config[$model->alias], (array) $config); if ($this->config[$model->alias]['autoFetch'] === true) { - $this->authorize($model, AuthComponent::user('id')); + $authId = (!empty($config['user_id']))? $config['user_id'] : AuthComponent::user('id'); + $this->authorize($model, $authId); } } @@ -24,14 +25,16 @@ public function setup(\Model $model, $config = array()) { * @param TokenStoreInterface $Store * @return boolean */ - function authorize(\Model $model, $userId, TokenStoreInterface $Store = null, $apiName = null) { + function authorize(\Model $model, $userId, TokenStoreInterface $Store = null, $apiName = null, $apiDomain = null) { + if (empty($Store)) { $Store = ClassRegistry::init('Copula.TokenStoreDb'); } if (empty($apiName)) { $apiName = $model->useDbConfig; } - $token = $Store->getToken($userId, $apiName); + + $token = $Store->getToken($userId, $apiName, $apiDomain); if (!empty($token)) { ConnectionManager::getDataSource($model->useDbConfig)->setConfig($token); return TRUE; @@ -48,7 +51,8 @@ function authorize(\Model $model, $userId, TokenStoreInterface $Store = null, $a * @return void * @author Ceeram */ - public function setDbConfig(\Model $model, $source = null, $useTable = null) { + public function setDbConfig(\Model $model, $source = null, $useTable = null, $api_domain = null, $userId = null, TokenStoreInterface $Store = null) { + $datasource = $model->getDataSource(); if (method_exists($datasource, 'flushMethodCache')) { $datasource->flushMethodCache(); @@ -59,6 +63,17 @@ public function setDbConfig(\Model $model, $source = null, $useTable = null) { if ($useTable !== null) { $this->setSource($useTable); } + if($api_domain){ + if (empty($Store)) { + $Store = ClassRegistry::init('Copula.TokenStoreDb'); + } + $token = $Store->getToken($userId, $apiName, $api_domain); + if (!empty($token)) { + $model->getDataSource($model->useDbConfig)->setConfig($token); + } else { + throw new CakeException(__('Could not get access token for Api %s', $model->useDbConfig)); + } + } } else { if (!empty($this->config[$model->alias]['default'])) { $this->setDataSource($this->config[$model->alias]['default']['useDbConfig']); diff --git a/Model/Datasource/ApisSource.php b/Model/Datasource/ApisSource.php index 559a7db..2e85260 100644 --- a/Model/Datasource/ApisSource.php +++ b/Model/Datasource/ApisSource.php @@ -79,6 +79,7 @@ class ApisSource extends DataSource { * @return \HttpSocketOauth|\HttpSocket */ public function getHttpObject($authMethod, $url = null) { + switch ($authMethod) { case 'OAuth': case 'OAuthV2': @@ -96,7 +97,7 @@ public function getHttpObject($authMethod, $url = null) { * @param \Model $model * @return mixed */ - public function describe(\Model $model) { + public function describe($model) { if (!empty($model->schema)) { $schema = $model->schema; } elseif (!empty($this->_schema[$model->name])) { @@ -141,7 +142,10 @@ protected function _buildRequest($apiName, $type = 'read', $request = array()) { $this->setConfig($host); } $request['method'] = $this->restMap[$type]; - $request['uri']['host'] = $this->config['host']; + if(!empty($request['uri']['host'])){ + $request['uri']['host'] = $this->config['host']; + } + $request['auth'] = $this->_getAuth($this->config['authMethod'], $apiName); if (!empty($this->config['scheme'])) { $request['uri']['scheme'] = $this->config['scheme']; @@ -185,19 +189,53 @@ public function request(Model $model) { if (method_exists($this, 'beforeRequest')) { $model->request = $this->beforeRequest($model); } - + $Http = $this->getHttpObject($this->config['authMethod']); $t = microtime(true); - $Http->request($model->request); - + $this->took = round((microtime(true) - $t) * 1000, 0); $this->logQuery($Http); $model->response = $this->afterRequest($model, $Http->response); - return $model->response; } + +/** + * formats response into a standard CakePHP formatted array based on useTable var + * @param \Model $model + * @return array + * + */ + + public function cakeModelFormat(Model $model, $body = null){ + $modelKeyName = null; + + if(empty($body[$model->useTable])){ + if(empty($body[Inflector::singularize($model->useTable)])){ + if(!empty($body)){ + $modelKeyName = $model->alias; + $body = array($modelKeyName => $body); + }else{ + return array(); + } + }else{ + $modelKeyName = $model->alias; + + } + }else{ + $modelKeyName = $model->alias; + } + $data = array(); + if(!empty($body[$modelKeyName][0]) && is_array($body[$modelKeyName][0])){ + foreach($body[$modelKeyName] as $key=>$record){ + $data[$key][$modelKeyName] = $record; + } + }else{ + $data[$modelKeyName] = $body[$model->useTable]; + } + return $data; + } /** * @@ -233,8 +271,8 @@ protected function _getAuth($method, $apiName) { $auth = array( 'method' => 'Basic', - 'login' => $this->config['login'], - 'password' => $this->config['password'] + 'login' => !empty($this->config['login'])? $this->config['login'] : null, + 'password' => !empty($this->config['password'])? $this->config['password'] : null, ); break; case 'OAuth': @@ -259,7 +297,13 @@ protected function _getAuth($method, $apiName) { $auth = null; break; } - return array_filter($auth); + + $filtered = null; + if(!empty($auth)){ + array_filter($auth); + $filtered = $auth; + } + return $filtered; } /** @@ -269,12 +313,14 @@ protected function _getAuth($method, $apiName) { * @return array $response * @author Dean Sofer */ - public function decode(\HttpSocketResponse $response) { + public function decode(\HttpSocketResponse $response, Model $model, $contentType = null) { // Get content type header - $contentType = explode(';', $response->getHeader('Content-Type')); - + if($contentType == null){ + $contentType = explode(';', $response->getHeader('Content-Type')); + $contentType = $contentType[0]; + }; // Decode response according to content type - switch ($contentType[0]) { + switch ($contentType) { case 'application/xml': case 'application/atom+xml': case 'application/rss+xml': @@ -297,6 +343,7 @@ public function decode(\HttpSocketResponse $response) { $return = $response->body(); break; } + $return = $this->cakeModelFormat($model, $return); return $return; } @@ -333,7 +380,8 @@ protected function _scanMap($action, $section, $fields = array()) { throw new CakeException(__('Section %s not found in Copula Driver Configuration Map - ', $section) . get_class($this), 500); } else { $element = Hash::extract($map, $section); - $path = $required = $optional = null; + $path = null; + $required = $optional = array(); extract($element); if (array_intersect($fields, $required) == $required) { return compact('path', 'required', 'optional'); @@ -368,14 +416,27 @@ public function beforeRequest(Model $model) { return $model->request; } - public function afterRequest(Model &$model, HttpSocketResponse &$response) { + public function afterRequest(Model &$model, HttpSocketResponse &$response, $contentType = null) { if (!$response->isOk()) { $model->onError(); return false; } else { - return $this->decode($response); + return $this->decode($response, $model, $contentType); } } + +/** + * build a path based on request method + * + * made to be overriadable by specific apis so that urls that require a primary key id as + * a url param (site.name/34534534/?), rather than a traditional param (?item_id=432323523) + * you can parse the query here. + * + * + */ + protected function _buildPath(Model $model, $request_type = 'read', $path = null, $conditions = array(), $authMethod = 'Oauth'){ + return $path; + } /** * Uses standard find conditions. Use find('all', $params). @@ -385,7 +446,7 @@ public function afterRequest(Model &$model, HttpSocketResponse &$response) { * @return mixed * @access public */ - public function read(Model $model, $queryData = array()) { + public function read(Model $model, $queryData = array(), $recursive = null) { if (!empty($queryData['fields']) && $queryData['fields'] == 'COUNT') { return array(array(array('count' => 1))); } @@ -394,10 +455,13 @@ public function read(Model $model, $queryData = array()) { $scan = $this->_scanMap('read', $model->useTable, array_keys($queryData['conditions'])); $required = $optional = array(); extract($scan); - $model->request['uri']['path'] = $path; - $conditions = array_intersect_key($queryData['conditions'], array_flip(array_merge($required, $optional))); - $model->request['uri']['query'] = $this->_buildQuery($conditions); - return $this->request($model); + + //$conditions = array_intersect(array_keys($queryData['conditions']), array_merge($required, $optional)); + $model->request['uri']['path'] = $this->_buildPath($model, 'read', $path, $queryData['conditions']); + + $model->request['uri']['query'] = $this->_buildQuery($queryData['conditions'],$this->config['escape']); + $data = $this->request($model); + return $data; } /** @@ -411,9 +475,13 @@ public function create(Model $model, $fields = null, $values = null) { $model->request = $this->_buildRequest($model->useDbConfig, 'create'); $scan = $this->_scanMap('create', $model->useTable, $fields); extract($scan); - $model->request['uri']['path'] = $path; - $model->request['body'] = $this->_buildQuery(array_combine($fields, $values), $this->config['escape']); - return $this->request($model); + $model->request['uri']['path'] = $this->_buildPath($model, 'create', $path); + $model->request['body'] = $this->_buildQuery(array_combine($fields, $values), $this->config['escape'], 'create'); + $data = $this->request($model); + if(!empty($data[Inflector::classify($model->useTable)][$model->primaryKey])){ + $model->id = $data[Inflector::classify($model->useTable)][$model->primaryKey]; + } + return $data; } /** @@ -427,8 +495,8 @@ public function update(Model $model, $fields = null, $values = null, $conditions $model->request = $this->_buildRequest($model->useDbConfig, 'update'); $scan = $this->_scanMap('update', $model->useTable, $fields); extract($scan); - $model->request['uri']['path'] = $path; - $model->request['body'] = $this->_buildQuery(array_combine($fields, $values), $this->config['escape']); + $model->request['uri']['path'] = $this->_buildPath($model, 'update', $path); + $model->request['body'] = $this->_buildQuery(array_combine($fields, $values), $this->config['escape'], 'update'); return $this->request($model); } @@ -442,8 +510,8 @@ public function delete(Model $model, $conditions = null) { $model->request = $this->_buildRequest($model->useDbConfig, 'delete'); $scan = $this->_scanMap('delete', $model->useTable, array_keys($conditions)); extract($scan); - $model->request['uri']['path'] = $path; - $model->request['body'] = $this->_buildQuery($conditions, $this->config['escape']); + $model->request['uri']['path'] = $this->_buildPath($model, 'delete', $path);; + $model->request['body'] = $this->_buildQuery($conditions, $this->config['escape'], 'delete'); return $this->request($model); } diff --git a/Model/Datasource/PathTokenSource.php b/Model/Datasource/PathTokenSource.php new file mode 100644 index 0000000..19ef084 --- /dev/null +++ b/Model/Datasource/PathTokenSource.php @@ -0,0 +1,54 @@ +} to a path and an optional or required + * + * also can create an array for a path to change paths based on 'one' or 'many' results: + * + * eg: + * 'customers' => array( + 'path' => array( + 'one' => 'api/1.0/customer/{id}', + 'many' => 'api/customers' + ), + 'optional' => array('id') + ), + * + */ + + protected function _buildPath(Model $model, $request_type = 'read', $path = null, $conditions = array(), $authMethod = 'Oauth'){ + $token = null; + + + //need to add in a foreach loop for multiple token replacement: + + if(!empty($this->map[$request_type][$model->useTable]['optional'][0])){ + $token = $this->map[$request_type][$model->useTable]['optional'][0]; + } + + if(!empty($this->map[$request_type][$model->useTable]['required'][0])){ + $token = $this->map[$request_type][$model->useTable]['required'][0]; + } + + $value = ''; + $selectedPath = is_array($path)? $path['many'] : $path; + + if((!empty($conditions[$token]))){ + $selectedPath = is_array($path)? $path['one'] : $path; + $value = $conditions[$token]; + unset($conditions[$token]); + } + if($request_type == 'update'){ + $value = $model->data[$model->name][$token]; + } + + $path = str_replace('{'.$token.'}', $value, $selectedPath); + + return $path; + } + +} + diff --git a/Model/TokenStoreDb.php b/Model/TokenStoreDb.php index ce285c8..dfdf4cf 100755 --- a/Model/TokenStoreDb.php +++ b/Model/TokenStoreDb.php @@ -31,26 +31,54 @@ public function __construct($id = false, $table = null, $ds = null) { ) ), 'api' => array( - 'alphaNumeric' => array( - 'rule' => 'alphaNumeric', - 'message' => __('API names must be alphanumeric. In point of fact they should probably be camelcased singular.') - ) - ) + 'alphaNumeric' => array( + 'rule' => 'alphaNumeric', + 'message' => __('API names must be alphanumeric. In point of fact they should probably be camelcased singular.') + ) + ), + 'api_domain' => array( + 'multiUnique' => array( + 'rule' => 'multiUnique', + 'message' => __('This domain is already entered in') + ) + ) ); parent::__construct($id, $table, $ds); } + function multiUnique(){ + $exists = $this->find('first', array( + 'conditions' => array( + 'user_id' => $this->data[$this->alias]['user_id'], + 'api' => $this->data[$this->alias]['api'], + 'api_domain' => $this->data[$this->alias]['api_domain'], + ) + )); + if(!empty($exists)){ + return false; + } + return true; + } + /** * @param string $user_id the associated user id * @param string $apiName the name of an API to search for + * @param string $api_domain the name of an API domain to search for, useful + * if you have multiple domains for one APL * @return array token data */ - function getToken($user_id, $apiName) { + function getToken($user_id, $apiName, $api_domain = null) { + $conditions = array( + 'user_id' => $user_id, + 'api' => $apiName + ); + + if (!empty($api_domain)){ + $conditions['api_domain'] = $api_domain; + } $result = $this->find('first', array( - 'conditions' => array( - 'user_id' => $user_id, - 'api' => $apiName - ))); + 'conditions' => $conditions + )); $result = (empty($result)) ? $result : $result[$this->alias]; return $result; } @@ -68,7 +96,7 @@ function checkToken($user_id, $apiName) { * @param array $access_token * @param string $apiName */ - function saveToken(array $access_token, $apiName, $user_id, $version) { + function saveToken(array $access_token, $apiName, $user_id, $version, $api_domain = null) { $this->data = array( 'user_id' => $user_id, 'api' => $apiName @@ -76,12 +104,15 @@ function saveToken(array $access_token, $apiName, $user_id, $version) { if ($version == 'OAuth' || $version == '1.0') { $this->data['access_token'] = $access_token['oauth_token']; $this->data['token_secret'] = $access_token['oauth_token_secret']; + $this->data['api_domain'] = !empty($api_domain)?: null; } elseif ($version == 'OAuthV2' || $version == '2.0') { $this->data['access_token'] = $access_token['access_token']; - $this->data['refresh_token'] = $access_token['refresh_token']; - $this->data['expires_in'] = $access_token['expires_in']; + $this->data['refresh_token'] = !empty($access_token['refresh_token'])?: null; + $this->data['expires_in'] = !empty($access_token['expires_in'])?: null; + $this->data['api_domain'] = !empty($api_domain)? $api_domain: null; } - return $this->save($this->data); + $s = $this->save($this->data); + return $s; } /** @@ -90,11 +121,12 @@ function saveToken(array $access_token, $apiName, $user_id, $version) { * @return boolean */ function beforeSave($options = array()) { - if (!$this->isUnique(array('api', 'user_id'), false)) { + if (!$this->isUnique(array('api', 'user_id','api_domain'), false)) { $existing = $this->find('all', array( 'conditions' => array( 'user_id' => $this->data[$this->alias]['user_id'], - 'api' => $this->data[$this->alias]['api'] + 'api' => $this->data[$this->alias]['api'], + 'api_domain' => $this->data[$this->alias]['api_domain'] ), 'callbacks' => 'before' )); diff --git a/README.md b/README.md index 4d4e6c7..ef8249a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Copula is, in itself, not very useful, and primarily aimed at developers. * Supports both OAuth and OAuth v2 * Access tokens can be stored in the session, or persisted in your database * Integrates with CakePHP's built-in Authorization features +* Multiple login/sub-domain functionality - with this you can store multiple domains for a single api type (eg: multiple shopify stores) In addition, Copula is well-tested, and working towards 100% code coverage.