Skip to content

Commit

Permalink
Merge pull request #7 from grnhse/add_harvest_tools
Browse files Browse the repository at this point in the history
Add harvest tools
  • Loading branch information
tdphillipsjr committed May 27, 2016
2 parents fc2e289 + 7c15eee commit ef1bb10
Show file tree
Hide file tree
Showing 11 changed files with 1,326 additions and 2 deletions.
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,5 +121,59 @@ The Greenhouse packages requires you to do it like this:

This prevents issues that arise for systems that do not understand the array-indexed nomenclature preferred by Libcurl.

# The Harvest Service
Use this service to interact with the Harvest API in Greenhouse. Documentation for the Harvest API [can be found here.](https://developers.greenhouse.io/harvest.html/) The purpose of this service is to make interactions with the Harvest API easier. To create a Harvest Service object, you must supply an active Harvest API key. Note that these are different than Job Board API keys.
```
<?php
$harvestService = $greenhouseService->getHarvestService();
?>
```

Via the Harvest service, you can interact with any Harvest methods outlined in the Greenhouse Harvest docs. Harvest URLs fit mostly in to one of the following three formats:

1. `https://harvest.greenhouse.io/v1/<object>`: This is the most common URL format for GET methods in Greenhouse. For endpoints in this format, the method will look like `$harvestService->getObject()`. Examples of this are `$harvestService->getJobs()` or `$harvestService->getCandidates()`
2. `https://harvest.greenhouse.io/v1/<object>/<object_id>`: This will get the object with the given ID. This is expected to only return or operate on one object. The ID will always be supplied by an parameter array with a key named `id`. For instance: `$harvestService->getCandidate($parameters);`
3. `https://harvest.greenhouse.io/v1/<object>/<object_id>/<sub_object>`: URLs in this format usually mean that you want to get all the sub_objects for the object with the object id. Examples of this are `$harvestService->getJobStagesForJob(array('id' => 123))` and `$harvestService->getOffersForApplication(array('id' => 123))`
4. Some method calls and URLs do not exactly fit this format, but the methods were named as close to fitting that format as possible. These include:
* `getActivityFeedForCandidate`: [Get a candidate's activity feed](https://developers.greenhouse.io/harvest.html#retrieve-activity-feed-for-candidate)
* `postNoteForCandidate`: [Add a note to a candidate](https://developers.greenhouse.io/harvest.html#create-a-candidate-39-s-note)
* `putAnonymizeCandidate`: [Anonymize some fields on a candidate](https://developers.greenhouse.io/harvest.html#anonymize-a-candidate)
* `getCurrentOfferForApplication`: [Get only the current offer for a candidate](https://developers.greenhouse.io/harvest.html#retrieve-current-offer-for-application)
* `postAdvanceApplication`: [Advance an application to the next stage](https://developers.greenhouse.io/harvest.html#advance-an-application)
* `postMoveApplication`: [Move an application to any stage.](https://developers.greenhouse.io/harvest.html#move-an-application)
* `postRejectApplication`: [Reject an application](https://developers.greenhouse.io/harvest.html#reject-an-application)

You should use the parameters array to supply any URL parameters and headers required by the harvest methods. For any items that require a JSON body, this will also be supplied in the parameter array.


Ex: [Moving an application](https://developers.greenhouse.io/harvest.html#move-an-application)
```
$parameters = array(
'id' => $applicationId,
'headers' => array('On-Behalf-Of' => $auditUserId),
'body' => '{"from_stage_id": 123, "to_stage_id": 234}'
);
$harvestService->moveApplication($parameters);
```

Note you do not have to supply the authorization header in the `headers` array. This will be appended to the headers array automatically presuming the supplied API key is valid.

The parameters array is also used to supply any paging and filtering options that would normally be supplied as a GET query string. Anything that is not in the `id`, `headers`, or `body` key will be assumed to be a URL parameter.

Ex: [Getting a page of applications](https://developers.greenhouse.io/harvest.html#list-applications)
```
$parameters = array(
'per_page' => 100,
'page' => 2
);
$harvestService->getApplications($parameters);
// Will call https://harvest.greenhouse.io/v1/applications?per_page=100&page=2
```

If the ID key is supplied in any way, that will take precedence.

**A note on future development**: The Harvest package makes use PHP's magic `__call` method. This is to handle Greenhouse's Harvest API advancing past this package. New endpoint URLs should work automatically. If Greenhouse adds a GET `https://harvest.greenhouse.io/v1/widgets` endpoint, calling `$harvestService->getWidgets()` should be supported by this package.


# Exceptions
All exceptions raised by the Greenhouse Service library extend from `GreenhouseException`. Catch this exception to catch anything thrown from this library.
7 changes: 7 additions & 0 deletions src/Clients/ApiClientInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,11 @@ public function post(Array $postParams, Array $headers, $url);
* @return mixed Whatever method of sending POST that $this->post understands
*/
public function formatPostParameters(Array $postParameters);

/**
* Send is a catch-all method that allows you to use a magic method to catch any type of request
* and forward it on. This is based on the Guzzle send method, but can be altered to fit any other
* future client.
*/
public function send($method, $url, Array $options);
}
11 changes: 11 additions & 0 deletions src/Clients/GuzzleClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ public function post(Array $postVars, Array $headers, $url=null)
return (string) $guzzleResponse->getBody();
}

public function send($method, $url, Array $options=array())
{
try {
$guzzleResponse = $this->_client->request($method, $url, $options);
} catch (RequestException $e) {
throw new GreenhouseAPIResponseException($e->getMessage(), 0, $e);
}

return (string) $guzzleResponse->getBody();
}

/**
* Return a Guzzle post parameter array that can be entered in to the 'multipart'
* argument of a post request. For details on this, see the Guzzle
Expand Down
5 changes: 5 additions & 0 deletions src/GreenhouseService.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,9 @@ public function getJobBoardService()
{
return new \Greenhouse\GreenhouseToolsPhp\Services\JobBoardService($this->_boardToken);
}

public function getHarvestService()
{
return new \Greenhouse\GreenhouseToolsPhp\Services\HarvestService($this->_apiKey);
}
}
3 changes: 2 additions & 1 deletion src/Services/ApiService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@

namespace Greenhouse\GreenhouseToolsPhp\Services;

use Greenhouse\GreenhouseToolsPhp\Services\ApiService;
use Greenhouse\GreenhouseToolsPhp\Services\Exceptions\GreenhouseServiceException;

class ApiService
{
protected $_apiClient;
protected $_clientToken;
protected $_apiKey;
protected $_authorizationHeader;

const APPLICATION_URL = 'https://api.greenhouse.io/v1/applications/';
const API_V1_URL = 'https://api.greenhouse.io/v1/';
const HARVEST_V1_URL = 'https://harvest.greenhouse.io/v1/';

public function setClient($apiClient)
{
Expand Down
1 change: 0 additions & 1 deletion src/Services/ApplicationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
*/
class ApplicationService extends ApiService
{
private $_authorizationHeader;
private $_jobApiService;

/**
Expand Down
133 changes: 133 additions & 0 deletions src/Services/HarvestService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

namespace Greenhouse\GreenhouseToolsPhp\Services;

use Greenhouse\GreenhouseToolsPhp\Services\ApiService;
use Greenhouse\GreenhouseToolsPhp\Clients\GuzzleClient;
use Greenhouse\GreenhouseToolsPhp\Services\Exceptions\GreenhouseServiceException;
use Greenhouse\GreenhouseToolsPhp\Tools\HarvestHelper;

/**
* This class interacts with Greenhouse's Harvest API.
*/
class HarvestService extends ApiService
{
private $_harvestHelper;
private $_harvest;

public function __construct($apiKey)
{
$this->_apiKey = $apiKey;
$client = new GuzzleClient(array('base_uri' => self::HARVEST_V1_URL));
$this->setClient($client);
$this->_authorizationHeader = $this->getAuthorizationHeader($apiKey);
$this->_harvestHelper = new HarvestHelper();
}

public function getHarvest()
{
return $this->_harvest;
}

public function sendRequest()
{
$authHeader = array('Authorization' => $this->_authorizationHeader);
$allHeaders = array_merge($this->_harvest['headers'], $authHeader);
$requestUrl = $this->_harvestHelper->addQueryString($this->_harvest['url'], $this->_harvest['parameters']);
$options = array(
'headers' => $allHeaders,
'body' => $this->_harvest['body'],
);

return $this->_apiClient->send($this->_harvest['method'], $requestUrl, $options);
}

/**
* In order to keep up to date with changes to the Harvest api and not trigger a re-release of this
* package each time a new method is created, the magic Call method is used to construct URLs to the
* Harvest API. This will use the called method and the arguments provided to create the proper URL
* to request the service. This should return the response from the API on success and raise an
* exception on failure. In most cases, this should be straightforward parsing.
*
* 1) getApplications() will transform in to a Get request to "applications"
* 2) getApplications(array('id' => 12345)) will translate to a GET request to "applications/12345"
* 3) getScorecardsForApplications(array('id' => 12345)) will translate to
* "applications/12345/scorecards
*/
public function __call($name, $arguments)
{
$args = sizeof($arguments) > 0 ? $arguments[0] : array();
$this->_harvest = $this->_harvestHelper->parse($name, $args);
return $this->sendRequest();
}

/**
* All methods below this point are methods that don't fit in the standard url format. Either the
* words are not pluralized (application/12345/move instead of moves) or there is an additional word
* at the end of the URL (application/123/offers/current_offer) which we can't handle in the magic method
* above. Standard URLs that fit the common Harvest format will work automatically going forward. Any
* exceptions should go below this line.
*/

public function getActivityFeedForCandidate($parameters=array())
{
$this->_harvest = $this->_harvestHelper->parse('getActivityFeedForCandidate', $parameters);
return $this->_trimUrlAndSendRequest();
}

public function postNoteForCandidate($parameters=array())
{
$this->_harvest = $this->_harvestHelper->parse('postActivityFeedForCandidate', $parameters);
$this->_harvest['url'] = 'candidates/' . $parameters['id'] . '/activity_feed/notes';
$this->sendRequest();
}

public function putAnonymizeCandidate($parameters=array())
{
$this->_harvest = $this->_harvestHelper->parse('putAnonymizeForCandidate', $parameters);
return $this->_trimUrlAndSendRequest();
}

public function getJobPostsForJob($parameters=array())
{
$this->_harvest = $this->_harvestHelper->parse('getJobPostForJob', $parameters);
return $this->_trimUrlAndSendRequest();
}

public function getJobStagesForJob($parameters=array())
{
$this->_harvest = $this->_harvestHelper->parse('getStagesForJob', $parameters);
return $this->sendRequest();
}

public function getCurrentOfferForApplication($parameters=array())
{
$this->_harvest = $this->_harvestHelper->parse('getOffersForApplication', $parameters);
$this->_harvest['url'] = $this->_harvest['url'] . '/current_offer';
return $this->sendRequest();
}

public function postAdvanceApplication($parameters=array())
{
$this->_harvest = $this->_harvestHelper->parse('postAdvanceForApplication', $parameters);
return $this->_trimUrlAndSendRequest();
}

public function postMoveApplication($parameters=array())
{
$this->_harvest = $this->_harvestHelper->parse('postMoveForApplication', $parameters);
return $this->_trimUrlAndSendRequest();
}

public function postRejectApplication($parameters=array())
{
$this->_harvest = $this->_harvestHelper->parse('postRejectForApplication', $parameters);
return $this->_trimUrlAndSendRequest();
}

private function _trimUrlAndSendRequest()
{
$this->_harvest['url'] = substr($this->_harvest['url'], 0, -1);
return $this->sendRequest();
}
}
80 changes: 80 additions & 0 deletions src/Tools/HarvestHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace Greenhouse\GreenhouseToolsPhp\Tools;

use Greenhouse\GreenhouseToolsPhp\Services\Exceptions\GreenhouseServiceException;

/**
* Given a method call from the HarvestService, parse it in to a URL and proper parameters. This lets
* us not have to write a new method every time a Harvest endpoint is added.
*/
class HarvestHelper
{
public function parse($methodName, $parameters=array())
{
$return = array();
$pattern = '/^(get|post|patch|put)(\w+)$/i';
$matched = preg_match($pattern, $methodName, $matches);

if (!$matched) throw new GreenhouseServiceException("Harvest Service: invalid method $methodName.");

$return['method'] = $matches[1];
$return['url'] = $this->methodToEndpoint($matches[2], $parameters);

if (isset($parameters['id'])) unset($parameters['id']);
if (isset($parameters['headers'])) {
$return['headers'] = $parameters['headers'];
unset($parameters['headers']);
} else {
$return['headers'] = array();
}
if (isset($parameters['body'])) {
$return['body'] = $parameters['body'];
unset($parameters['body']);
} else {
$return['body'] = null;
}

$return['parameters'] = $parameters;

return $return;
}

public function methodToEndpoint($methodText, $parameters)
{
$id = isset($parameters['id']) ? $parameters['id'] : null;
$objects = explode('For', $methodText);

// A single object, just return the snaked version of it.
if (sizeof($objects) == 1) {
$url = $this->_decamelizeAndPluralize($objects[0]);
if ($id) $url .= "/$id";

// Double object, expect the format object/id/object
} else if (sizeof($objects) == 2) {
if (!$id) throw new GreenhouseServiceException("Harvest Service: method call $methodText must include an id parameter");
$url = $this->_decamelizeAndPluralize($objects[1]) . "/$id/" . $this->_decamelizeAndPluralize($objects[0]);
} else {
throw new GreenhouseServiceException("Harvest Service: Invalid method call $methodText.");
}

return $url;
}

public function addQueryString($url, $parameters=array())
{
if (sizeof($parameters)) {
return $url . '?' . http_build_query($parameters);
} else {
return $url;
}
}

private function _decamelizeAndPluralize($string)
{
$decamelized = strtolower(preg_replace(['/([a-z0-9])([A-Z])/', '/([^_])([A-Z][a-z])/'], '$1_$2', $string));
if (substr($decamelized, -1) != 's') $decamelized .= 's';

return $decamelized;
}
}
11 changes: 11 additions & 0 deletions tests/GreenhouseServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,15 @@ public function testGetApplicationService()
$this->assertEquals($baseUrl, $service->getJobBoardBaseUrl());
$this->assertEquals($authHeader, $service->getAuthorizationHeader());
}

public function testGetHarvestService()
{
$service = $this->greenhouseService->getHarvestService();
$this->assertInstanceOf(
'\Greenhouse\GreenhouseToolsPhp\Services\HarvestService',
$service
);
$authHeader = 'Basic ' . base64_encode($this->apiKey . ':');
$this->assertEquals($authHeader, $service->getAuthorizationHeader());
}
}
Loading

0 comments on commit ef1bb10

Please sign in to comment.