diff --git a/.upgrade.yml b/.upgrade.yml new file mode 100644 index 0000000..6e6d02f --- /dev/null +++ b/.upgrade.yml @@ -0,0 +1,5 @@ +mappings: + WorkableTest: SilverStripe\Workable\WorkableTest + Workable: SilverStripe\Workable\Workable + Workable_Result: SilverStripe\Workable\WorkableResult + WorkableRestfulServiceFactory: SilverStripe\Workable\WorkableRestfulServiceFactory diff --git a/README.md b/README.md index 8569145..da0a4b5 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,17 @@ # Workable for SilverStripe -Adds Workable API integration to SilverStripe projects. +Adds Workable API integration to SilverStripe projects. See https://workable.readme.io/ for API docs. ## Configuration -First, add your API key using a constant, preferably in your `_ss_environment.php` file. +First, add your API key using a constant, preferably in your `.env` file. -```php -define('WORKABLE_API_KEY','your_api_key'); ``` - -Alternatively, you can add your API key in the config, although this is not recommended. - -```yaml -Workable: - apiKey: your_api_key +WORKABLE_API_KEY="your_api_key" ``` Then, just add your subdomain to the config. -``` -Workable: +```yml +SilverStripe\Workable\Workable: subdomain: example ``` @@ -35,7 +28,7 @@ This returns an `ArrayList`, so you can iterate over it on the template. ```html <% loop $Jobs %> -$Title, $Url + $Title, $Url <% end_loop %> ``` @@ -43,7 +36,7 @@ For nested properties, you can use the dot-separated syntax. ```html <% loop $Jobs %> -$Title ($Location.City) + $Title ($Location.City) <% end_loop %> ``` diff --git a/_config/config.yml b/_config/config.yml index a32cc72..ff3dfca 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -1,12 +1,19 @@ --- Name: workable --- -Workable: - cache_expiry: 3600 -Injector: - Workable: +SilverStripe\Core\Injector\Injector: + Psr\SimpleCache\CacheInterface.workable: + factory: SilverStripe\Core\Cache\CacheFactory constructor: - 0: %$WorkableRestfulService - WorkableRestfulService: - class: RestfulService - factory: WorkableRestfulServiceFactory \ No newline at end of file + namespace: 'workable' + defaultLifetime: 3600 + SilverStripe\Workable\Workable: + constructor: + 0: '%$GuzzleHttp\ClientInterface.workable' + 1: '%$Psr\SimpleCache\CacheInterface.workable' + SilverStripe\Workable\WorkableRestfulServiceFactory: + constructor: + apikey: '`WORKABLE_API_KEY`' + GuzzleHttp\ClientInterface.workable: + class: GuzzleHttp\Client + factory: SilverStripe\Workable\WorkableRestfulServiceFactory diff --git a/code/Workable.php b/code/Workable.php index 630383f..2f11339 100644 --- a/code/Workable.php +++ b/code/Workable.php @@ -1,113 +1,236 @@ - */ -class Workable extends Object { - - /** - * Reference to the RestfulService dependency - * @var RestfulService - */ - protected $restulService; - - /** - * Constructor, inject the restful service dependency - * @param RestfulService $restfulService - */ - public function __construct($restfulService) { - $this->restfulService = $restfulService; - - parent::__construct(); - } - - /** - * Gets all the jobs from the Workable API - * @param array $params Array of params, e.g. ['state' => 'published'] - * @return ArrayList - */ - public function getJobs($params = []) { - $list = ArrayList::create(); - $response = $this->callRestfulService('jobs', $params); - - if($response && isset($response['jobs']) && is_array($response['jobs'])) { - foreach($response['jobs'] as $record) { - $list->push(Workable_Result::create($record)); - } - } - - return $list; - } - - /** - * Wrapper method to configure the RestfulService, make the call, and handle errors - * @param string $url - * @param array $params - * @param string $method - * @return array JSON - */ - protected function callRestfulService($url, $params = [], $method = 'GET') { - $this->restfulService->setQueryString($params); - $response = $this->restfulService->request($url, $method, $params); - - if(!$response) { - SS_Log::log('No response from workable API endpoint ' . $url, SS_Log::WARN); - - return false; - } - else if($response->getStatusCode() !== 200) { - SS_Log::log("Received non-200 status code {$response->getStatusCode()} from workable API", SS_Log::WARN); - - return false; - } - - return Convert::json2array($response->getBody()); - } - +namespace SilverStripe\Workable; + +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\RequestException; +use Psr\Log\LoggerInterface; +use SilverStripe\ORM\ArrayList; +use SilverStripe\Core\Flushable; +use SilverStripe\Core\Extensible; +use Psr\SimpleCache\CacheInterface; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\Core\Config\Configurable; +use SilverStripe\Core\Injector\Injectable; + +class Workable implements Flushable +{ + use Extensible; + use Injectable; + use Configurable; + + /** + * Reference to the HTTP Client dependency + * @var ClientInterface + */ + private $httpClient; + + /** + * Reference to the Cache dependency + * @var CacheInterface + */ + private $cache; + + /** + * Subdomain for Workable API call (e.g. $subdomain.workable.com) + * @config + */ + private static $subdomain; + + /** + * Constructor, inject the restful service dependency + * @param ClientInterface $httpClient + * @param CacheInterface $cache + */ + public function __construct(ClientInterface $httpClient, CacheInterface $cache) + { + $this->httpClient = $httpClient; + $this->cache = $cache; + } + + /** + * Gets all the jobs from the Workable API + * @param array $params Array of params, e.g. ['state' => 'published']. + * see https://workable.readme.io/docs/jobs for full list of query params + * @return ArrayList + */ + public function getJobs(array $params = []): ArrayList + { + $cacheKey = 'Jobs' . implode('-', $params); + if ($this->cache->has($cacheKey)) { + return $this->cache->get($cacheKey); + } + + $list = ArrayList::create(); + $response = $this->callWorkableApi('jobs', $params); + + if (!$response) { + return $list; + } + + $jobs = $response['jobs'] ?? []; + foreach ($jobs as $record) { + $list->push(WorkableResult::create($record)); + } + + $this->cache->set($cacheKey, $list); + + return $list; + } + + /** + * Gets information on a specific job form the Workable API + * @param string $shortcode Workable shortcode for the job, e.g. 'GROOV005' + * @param array $params Array of params, e.g. ['state' => 'published']. + * see https://workable.readme.io/docs/jobs for full list of query params + * @return WorkableResult|null + */ + public function getJob(string $shortcode, array $params = []): ?WorkableResult + { + $cacheKey = 'Job-' . $shortcode . implode('-', $params); + + if ($this->cache->has($cacheKey)) { + return $this->cache->get($cacheKey); + } + + $job = null; + $response = $this->callWorkableApi('jobs/' . $shortcode, $params); + + if ($response && isset($response['id'])) { + $job = WorkableResult::create($response); + $this->cache->set($cacheKey, $job); + } + + return $job; + } + + /** + * Gets all the jobs from the workable API, populating each job with its full data + * Note: This calls the API multiple times so should be used with caution + * @param array $params Array of params, e.g. ['state' => 'published']. + * see https://workable.readme.io/docs/jobs for full list of query params + * @return ArrayList + */ + public function getFullJobs($params = []) + { + $cacheKey = 'FullJobs' . implode('-', $params); + + if ($this->cache->has($cacheKey)) { + return $this->cache->get($cacheKey); + } + + $list = ArrayList::create(); + $response = $this->callWorkableApi('jobs', $params); + + if (!$response) { + return $list; + } + + $jobs = $response['jobs'] ?? []; + foreach ($jobs as $record) { + $job = $this->getJob($record['shortcode'], $params); + $list->push($job); + } + + $this->cache->set($cacheKey, $list); + + return $list; + } + + /** + * Sends request to Workable API. + * Should it exceed the rate limit, this is caught and put to sleep until the next interval. + * The interval duration is provided by Workable via a header. + * When its awaken, it will call itself again, this repeats until its complete. + * This returns a json body from the response. + * + * Note: See rate limit docs from Workable https://workable.readme.io/docs/rate-limits + * @param string $url + * @param array $params + * @param string $method + * + * @throws RequestException if client is not configured correctly, handles 429 error + + * @return array JSON as array + */ + public function callWorkableApi(string $url, array $params = [], string $method = 'GET'): array + { + try { + $response = $this->httpClient->request($method, $url, ['query' => $params]); + return json_decode($response->getBody(), true); + } + catch(RequestException $e){ + if($e->hasResponse()){ + $errorResponse = $e->getResponse(); + $statusCode = $errorResponse->getStatusCode(); + + if($statusCode === 429) { + Injector::inst()->get(LoggerInterface::class)->info( + 'Rate limit exceeded - sleeping until next interval' + ); + + $this->sleepUntil($errorResponse->getHeader('X-Rate-Limit-Reset')); + + return $this->callWorkableApi($url, $params, $method); + } + else { + Injector::inst()->get(LoggerInterface::class)->warning( + 'Failed to retrieve valid response from workable', + ['exception' => $e] + ); + + throw $e; + } + } + } + } + + /** + * Sleeps until the next interval. + * Should the interval header be empty, the script sleeps for 10 seconds - Workable's default interval. + * @param array $resetIntervalHeader + */ + private function sleepUntil($resetIntervalHeader){ + $defaultSleepInterval = 10; + + if(!empty($resetIntervalHeader)){ + time_sleep_until($resetIntervalHeader[0]); + } + else { + sleep($defaultSleepInterval); + } + } + + /** + * Flush any cached data + */ + public static function flush() + { + Injector::inst()->get(CacheInterface::class . '.workable')->clear(); + } + + /** + * Gets any cached data. If there is no cached data, a blank cache is created. + * @return CacheInterface + */ + public function getCache(): CacheInterface + { + if (!$this->cache) { + $this->setCache(Injector::inst()->get(CacheInterface::class . '.workable')); + } + + return $this->cache; + } + + /** + * Sets the cache. + * @param CacheInterface $cache + * @return self + */ + public function setCache(CacheInterface $cache): self + { + $this->cache = $cache; + + return $this; + } } - - -/** - * Defines the renderable Workable data for the template. Converts UpperCamelCase properties - * to the snake_case that comes from the API - */ -class Workable_Result extends ViewableData { - - /** - * Raw data from the API - * @var array - */ - protected $apiData; - - /** - * Magic getter that converts SilverStripe $UpperCamelCase to snake_case - * e.g. $FullTitle gets full_title. You can also use dot-separated syntax, e.g. $Location.City - * @param string $prop - * @return mixed - */ - public function __get($prop) { - $snaked = ltrim(strtolower(preg_replace('/[A-Z]/', '_$0', $prop)), '_'); - - if(!isset($this->apiData[$snaked])) { - return null; - } - $data = $this->apiData[$snaked]; - - if(is_array($this->apiData[$snaked])) { - return new Workable_Result($data); - } - - return $data; - } - - /** - * constructor - * @param array $apiData - */ - public function __construct($apiData = []) { - $this->apiData = $apiData; - } -} \ No newline at end of file diff --git a/code/WorkableRestfulServiceFactory.php b/code/WorkableRestfulServiceFactory.php index 6dc4ece..2f4ac5b 100644 --- a/code/WorkableRestfulServiceFactory.php +++ b/code/WorkableRestfulServiceFactory.php @@ -1,48 +1,59 @@ subdomain; - - if(!$subdomain) { - throw new RuntimeException('You must set a Workable subdomain in the config (Workable.subdomain)'); - } - - $rest = new $service( - sprintf('https://www.workable.com/spi/v3/accounts/%s/',$subdomain), - $config->cache_expiry - ); - - if(defined('WORKABLE_API_KEY')) { - $apiKey = WORKABLE_API_KEY; - } - else { - $apiKey = Config::inst()->get('Workable','apiKey'); +class WorkableRestfulServiceFactory implements Factory +{ + /** + * Set via ENV variable WORKABLE_API_KEY (see config.yml) + * @var string + */ + private $apiKey; + + public function __construct(?string $apiKey) + { + $this->apiKey = $apiKey; + } + /** + * Create the RestfulService (or whatever dependency you've injected) + * + * @throws RuntimeException + * + * @return ClientInterface + */ + public function create($service, array $params = []) + { + + if (!$this->apiKey) { + throw new RuntimeException('WORKABLE_API_KEY Environment variable not set'); } - if(!$apiKey) { - throw new RuntimeException('You must define an API key for Workable. Either use the WORKABLE_API_KEY constant or set Workable.apiKey in the config'); + $subdomain = Workable::config()->subdomain; + + if (!$subdomain) { + throw new RuntimeException( + 'You must set a Workable subdomain in the config (SilverStripe\Workable\Workable.subdomain)' + ); } - $rest->httpHeader("Authorization:Bearer $apiKey"); - $rest->httpHeader("Content-Type: application/json"); - - return $rest; + return new $service([ + 'base_uri' => sprintf('https://%s.workable.com/spi/v3/', $subdomain), + 'headers' => [ + 'Authorization' => sprintf('Bearer %s', $this->apiKey), + ], + ]); } -} \ No newline at end of file +} diff --git a/code/WorkableResult.php b/code/WorkableResult.php new file mode 100644 index 0000000..2b7bd04 --- /dev/null +++ b/code/WorkableResult.php @@ -0,0 +1,46 @@ +apiData[$snaked] ?? null; + + if (is_array($data)) { + return new WorkableResult($data); + } + + return $data; + } + + /** + * constructor + * @param array $apiData + */ + public function __construct($apiData = []) + { + $this->apiData = $apiData; + } +} diff --git a/composer.json b/composer.json index 5fd8ae8..016444f 100644 --- a/composer.json +++ b/composer.json @@ -1,20 +1,25 @@ { - "name":"silverstripe/workable", - "type": "silverstripe-module", - "description": "Adds Workable API integration to SilverStripe projects", - "keywords": ["silverstripe"], - "license": "BSD-3-Clause", - "homepage": "https://github.com/silverstripe/workable/", - "require": { - "silverstripe/framework": ">=3.1.0", - "php": ">=5.4" - }, - "authors":[ - { - "name": "Aaron Carlino", - "homepage": "http://leftandmain.com", - "email" : "aaron@silverstripe.com" - } - - ] + "name":"silverstripe/workable", + "type": "silverstripe-vendormodule", + "description": "Adds Workable API integration to SilverStripe projects", + "keywords": ["silverstripe"], + "license": "BSD-3-Clause", + "homepage": "https://github.com/silverstripe/workable/", + "require": { + "silverstripe/framework": "^4", + "guzzlehttp/guzzle": "^6", + "php": ">=7.1" + }, + "authors":[ + { + "name": "Aaron Carlino", + "homepage": "http://leftandmain.com", + "email" : "aaron@silverstripe.com" + }, + { + "name": "Torque Foxes", + "homepage": "https://github.com/torque-foxes", + "email" : "torque-foxues@silverstripe.com" + } + ] } diff --git a/tests/TestWorkableLogger.php b/tests/TestWorkableLogger.php deleted file mode 100644 index d885286..0000000 --- a/tests/TestWorkableLogger.php +++ /dev/null @@ -1,16 +0,0 @@ -event = $event; - } - - - public static function factory ($config) { - return new TestWorkableLogger(); - } -} \ No newline at end of file diff --git a/tests/TestWorkableRestfulService.php b/tests/TestWorkableRestfulService.php index 29d0c3b..a50a243 100644 --- a/tests/TestWorkableRestfulService.php +++ b/tests/TestWorkableRestfulService.php @@ -1,41 +1,78 @@ params = $params; - } - - - public function request($subURL = '', $method = "GET", $data = null, $headers = null, $curlOptions = array()) { - switch($subURL) { - case 'jobs': - if($this->params['state'] === 'published') { - return new RestfulService_Response( - json_encode(['jobs' => [ - ['title' => 'Published Job 1'], - ['title' => 'Published Job 2'], - ['title' => 'Published Job 3'] - ]]), - 200 - ); - } - if($this->params['state'] === 'draft') { - return new RestfulService_Response( - json_encode(['jobs' => [ - ['title' => 'Draft Job 1'] - ]]), - 200 - ); - } - if($this->params['state'] === 'fail') { - return new RestfulService_Response('FAIL', 404); - } - break; - } - - } +namespace SilverStripe\Workable\Tests; +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\Response; + +class TestWorkableRestfulService extends Client +{ + public function request($method, $url = '', $params = []) + { + switch ($url) { + case 'jobs': + return $this->getMockJobs($params); + case 'jobs/GROOV001': + case 'jobs/GROOV002': + return $this->getMockJob($url, $params); + } + } + + protected function getMockJobs($params) + { + $state = isset($params['query']['state']) ? $params['query']['state'] : ''; + $response = []; + + switch ($state) { + case 'draft': + $response = ['jobs' => [ + [ + 'title' => 'draft job', + 'shortcode' => 'GROOV001', + ], + ]]; + break; + default: + $response = ['jobs' => [ + [ + 'title' => 'Job 1', + 'shortcode' => 'GROOV001', + ], + [ + 'title' => 'Job 2', + 'shortcode' => 'GROOV002', + ], + ]]; + break; + } + + return new Response(200, [], json_encode($response)); + } + + protected function getMockJob($url, $params) + { + $state = isset($params['query']['state']) ? $params['query']['state'] : ''; + $response = []; + + switch ($state) { + case 'draft': + $response = [ + 'title' => 'Draft Job x', + 'test' => 'full draft data', + 'id' => 1, + 'shortcode' => substr($url, 5), + ]; + break; + default: + $response = [ + 'title' => 'Job x', + 'test' => 'full data', + 'id' => 1, + 'shortcode' => substr($url, 5), + ]; + break; + } + + return new Response(200, [], json_encode($response)); + } } diff --git a/tests/WorkableTest.php b/tests/WorkableTest.php index 0a3e224..2127ed0 100644 --- a/tests/WorkableTest.php +++ b/tests/WorkableTest.php @@ -1,67 +1,123 @@ get('Injector','WorkableRestfulService'); - $config['class'] = 'TestWorkableRestfulService'; - Config::inst()->update('Injector','WorkableRestfulService', $config); - - Config::inst()->update('Workable', 'apiKey', 'test'); - Config::inst()->update('Workable', 'subdomain', 'example'); - } - - public function testThrowsIfNoSubdomain () { - Config::inst()->remove('Workable','subdomain'); - $this->setExpectedException('RuntimeException'); - - Workable::create(); - } - - public function testWillUseAPIKeyConstant () { - Config::inst()->remove('Workable','apiKey'); - if(!defined('WORKABLE_API_KEY')) { - define('WORKABLE_API_KEY','test'); - } - - Workable::create(); - } - - public function testGetsPublishedJobs () { - $result = Workable::create()->getJobs(['state' => 'published']); - - $this->assertEquals(3, $result->count()); - $this->assertEquals('Published Job 1', $result->first()->Title); - } - - public function testGetsUnpublishedJobs () { - $result = Workable::create()->getJobs(['state' => 'draft']); - - $this->assertEquals(1, $result->count()); - $this->assertEquals('Draft Job 1', $result->first()->Title); - } - - public function testLogsError () { - $logger = new TestWorkableLogger(); - SS_Log::add_writer($logger); - $result = Workable::create()->getJobs(['state' => 'fail']); - - $this->assertNotNull($logger->event); - - SS_Log::remove_writer($logger); - } - - public function testConvertsSnakeCase () { - $data = new Workable_Result(['snake_case' => 'foo']); - - $this->assertEquals('foo', $data->SnakeCase); - } - - public function testAcceptsDotSyntax () { - $data = new Workable_Result(['snake_case' => ['nested_property' => 'foo']]); - $result = $data->SnakeCase; - $this->assertInstanceOf('Workable_Result', $result); - $this->assertEquals('foo', $result->NestedProperty); - } -} \ No newline at end of file +namespace SilverStripe\Workable\Tests; + +use GuzzleHttp\ClientInterface; +use Psr\Log\LoggerInterface; +use Psr\SimpleCache\CacheInterface; +use SilverStripe\Config\Collections\CachedConfigCollection; +use SilverStripe\Core\Cache\DefaultCacheFactory; +use SilverStripe\Core\Environment; +use SilverStripe\Core\Injector\InjectorLoader; +use SilverStripe\Dev\SapphireTest; +use SilverStripe\Versioned\Caching\VersionedCacheAdapter; +use SilverStripe\Workable\Tests\TestWorkableRestfulService; +use SilverStripe\Workable\Workable; +use SilverStripe\Core\Config\Config; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\Workable\WorkableRestfulServiceFactory; +use SilverStripe\Workable\WorkableResult; + +class WorkableTest extends SapphireTest +{ + public static function setUpBeforeClass() + { + parent::setUpBeforeClass(); + Workable::config()->set('subdomain', 'example'); + $config = Config::inst()->get(Injector::class, 'GuzzleHttp\ClientInterface.workable'); + $config['class'] = TestWorkableRestfulService::class; + Config::inst()->merge(Injector::class, 'GuzzleHttp\ClientInterface.workable', $config); + } + + protected function setUp() + { + parent::setUp(); + Environment::setEnv('WORKABLE_API_KEY', 'test'); + } + + public function testThrowsIfNoSubdomain() + { + Config::inst()->remove(Workable::class, 'subdomain'); + $this->setExpectedException('RuntimeException'); + + Workable::create()->callHttpClient('test'); + } + + public function testThrowsIfNoApiKey() + { + Environment::setEnv('WORKABLE_API_KEY', null); + $this->setExpectedException('RuntimeException'); + + Workable::create()->callHttpClient('test'); + } + + public function testConvertsSnakeCase() + { + $data = WorkableResult::create(['snake_case' => 'foo']); + + $this->assertEquals('foo', $data->SnakeCase); + } + + public function testAcceptsDotSyntax() + { + $data = WorkableResult::create(['snake_case' => ['nested_property' => 'foo']]); + $result = $data->SnakeCase; + $this->assertInstanceOf(WorkableResult::class, $result); + $this->assertEquals('foo', $result->NestedProperty); + } + + public function testGetJobs() + { + $data = Workable::create()->getJobs(); + + $this->assertCount(2, $data); + $this->assertEquals('Job 1', $data[0]->title); + $this->assertEquals('Job 2', $data[1]->title); + } + + public function testGetJobsWithDraftState() + { + $data = Workable::create()->getJobs(['state' => 'draft']); + + $this->assertCount(1, $data); + } + + public function testGetJob() + { + $data = Workable::create()->getJob('GROOV001'); + + $this->assertNotNull($data); + $this->assertEquals('Job x', $data->title); + $this->assertEquals('GROOV001', $data->shortcode); + } + + public function testGetJobWithDraftState() + { + $data = Workable::create()->getJob('GROOV001', ['state' => 'draft']); + + $this->assertNotNull($data); + $this->assertEquals('Draft Job x', $data->title); + $this->assertEquals('GROOV001', $data->shortcode); + } + + public function testFullJobs() + { + $data = Workable::create()->getFullJobs(); + + $this->assertCount(2, $data); + $this->assertEquals('full data', $data[0]->test); + $this->assertEquals('GROOV001', $data[0]->shortcode); + $this->assertEquals('full data', $data[1]->test); + $this->assertEquals('GROOV002', $data[1]->shortcode); + } + + public function testFullJobsWithDraftState() + { + $data = Workable::create()->getFullJobs(['state' => 'draft']); + + $this->assertCount(1, $data); + $this->assertEquals('Draft Job x', $data[0]->title); + $this->assertEquals('full draft data', $data[0]->test); + $this->assertEquals('GROOV001', $data[0]->shortcode); + } +}