diff --git a/README.md b/README.md deleted file mode 120000 index e892330..0000000 --- a/README.md +++ /dev/null @@ -1 +0,0 @@ -docs/index.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..84b3d29 --- /dev/null +++ b/README.md @@ -0,0 +1,510 @@ +--- +layout: default +--- + +Introduction +============ + +At the time of implementation, there was no SDK for ProsperWorks, and we needed to do a bunch of operations to +transfer data from our old CRM and synchronize information with the other subsystems, so we designed some classes to +encapsulate the API operations. It uses Guzzle, but ended up overgrowing and we turned into a standalone library. + +This project was originally written by [igorsantos07] and is now maintained by [smith-carson] +([website](https://smithcarson.com)). + +Installation +============ + +## 1. Install in your project +You need to be running **PHP 7** _(yep, it's stable since dec/2015 and packs a bunch of useful features, upgrade now!)_. + +To add it to your project through Composer, run `composer require igorsantos07/prosperworks`. It will get the most +stable version if your `min-requirements` are "stable" or `dev-master` otherwise. + +## 2. Configure the package +There's a couple of things to configure to get the SDK running: + +### API credentials +> Currently, there's no "API User" on ProsperWorks, so you need an active user to communicate with the API. + +1. Sign in to ProsperWorks, go to **Settings > Preferences / API Keys**. There you will be able to generate a new API + Key for the logged user. Copy that key, along with the user email. + +2. Create a bootstrap file, or include the following code in the config section of your project: + `\ProsperWorks\Config::set($email, $token)`. + +### Webhooks parameters +**[Optional]** If you're going to use Webhooks to get updates from ProsperWorks, you'll also need to feed in three +more arguments on that method: +1. A _Webhooks Secret_, that will be used to avoid unintended calls to your routes. That should be a plain string. +2. A _Root URL_. That's probably the same domain/path you use for your systems, and what ProsperWorks will POST to. + More information on the [Webhooks](#webhooks) section. +3. A _Cryptography object_. It should respond to `encryptBase64()` and `decryptBase64()`, both receiving and returning a + string (it can also implement `\ProsperWorks\Interfaces\Crypt` to make things easier). It will be used to send an + encrypted secret, and decrypt it to make sure the call you receive comes from ProsperWorks (or, at least, someone + that has the encrypted secret). + +### Caching object +**[Optional]** To make some parts faster, you can also feed the sixth argument with a caching layer. It's an object that +needs to respond to `get()` and `save()`, or implement `\ProsperWorks\Interfaces\Cache`. + +It's mainly used to cache (for an hour) meta-data from the API, such as Custom Fields, Activity and Contact Types, and +so on. That's information that rarely changes so it's safe to cache, making calls much faster (otherwise, for every +resource with custom fields we would need to retrieve from the custom fields endpoint as well). + +## 3. Debug mode +During import scripts and similar tasks it could be useful to peek into the network traffic and see if what you intended +to do is being done correctly. +You can enable `echo`'s of debug information from the library by calling `ProsperWorks\Config::debugLevel()`: + +
+
ProsperWorks\Config::DEBUG_BASIC
+
will trigger some messages, such as "POST /people/search" so you know which requests are being sent. It + also warns on Rate limits being hit.
+
ProsperWorks\Config::DEBUG_COMPLETE
+
does all above plus complete requests payload.
+
null, false, 0 or ProsperWorks\Config::DEBUG_NONE
+
will stop printing messages.
+
+ +> This doesn't need to be done together with `Config::set()`; it can happen anywhere and will change behavior from that +part on. + +### Tip: "sandbox" account +After a while, when implementing this library for the first time, we spoke with a support representative about the lack +of a sandbox environment. They suggested us to create a trial account and use that instead of a user on the paying +account, and mention to the Support that was being used to test-drive the API implementation - and thus, they would +extend the trial of that solo account for as long as it was needed. + +API Communication +================= +Most of the operations are done through the `\ProsperWorks\CRM` abstract class, and the resulting objects from it (you +can consider it some sort of Factory class). The exception are Webhooks, that have a special Endpoint class to make it +easier. + +> Tip: **ProsperWorks API Documentation** +You may want to read the [REST API Docs], to get an understanding of the inner pieces that make up this SDK. + +With configurations in place, ProsperWorks API calls are done through a simple, fluent API. Most of the +endpoints behave the same way, with special cases being the Account and most meta-data endpoints. + +> On the following examples we'll consider the classes were imported in the current namespace. + +## Common endpoints +Singular, empty static calls to `CRM` give an `Endpoint` object (see [saving instances]), that allows you to run all +common operations: + +```php +find(10); + +//runs GET /people multiple times (it's paged) until all entries are retrieved +$people = CRM::person()->all(); +//there's no such operation in some endpoints; all() runs an empty /search, instead + +//runs POST /people to generate a new record +$newPerson = CRM::person()->create(['name' => 'xxx']); + +//runs PUT /people/25 to edit a given record +$person = CRM::person()->edit(25, ['name' => 'xxx']); + +//runs DELETE /people/10 to destroy that record +$bool = CRM::person()->delete(10); + +//runs POST /people/search with the given parameters until all entries are found (it's paged) +$people = CRM::person()->search(['email' => 'test@example.com']); +``` + +All success calls will return a `BareResource` object, with all information from that endpoint, or a list of those. See +[Response Objects](#response-objects) for details. +If it fails, an error message is given. _(TODO: option to raise exceptions)_ + +There are also some shortcuts, such as: +```php +all() +$tasks = CRM::tasks(); //same as CRM::task()->all() +$companies = CRM::companies(); //same as CRM::company()->all() + +//there's also two other types of magic calls +$people = CRM::person(23); //same as CRM::person()->find(23) +$people = CRM::person(['country' => 'US']); //same as CRM::person()->search(...) +``` + +## Special cases: restricted endpoints + +All meta-data resources (called _Secondary Resources_ on the docs), together with the `Account` endpoint, have only +read access. There's no verification of valid operations yet (see [#7](issue-7)). Here's a list of those read-only +endpoints, accessible through the plural call (e.g. `CRM::activityTypes()`), except for `Account` which is singular: + +- Account (the only one you have to call in the singular) +- Activity Types +- Contact Types +- Custom Fields +- Customer Sources +- Loss Reasons +- Pipelines +- Pipeline Stages + +### Meta-data shortcuts + +As those endpoints are mostly lists, you can also access that data through the cacheable `CRM::fieldList()` method, +which returns the information in a more organized fashion: +```php + Potential Customer +// [124] => Current Customer +// [125] => Uncategorized +// [126] => Former Customer +// ) + +echo CRM::fieldList('contactType', 524131); //search argument +// prints "Potential Customer". That argument searches on both sides of the array + +$actTypes = CRM::fieldList('activityType', null, true); //asks for "detailed response" +print_r($actTypes); +// gives the entire resources, still indexed by ID +// [166] => stdClass Object +// ( +// [id] => 166 +// [category] => user +// [name] => Social Media +// [is_disabled] => +// [count_as_interaction] => 1 +// ) +// +// [...] +// ) +``` + +> **Sanity warning:** those IDs there are samples; they're different for each ProsperWorks customer. + +It's also worth noting that some fields are "translated" from the API into specific objects, such as timestamps, +addresses, Custom Fields and more, so you'll probably never have to deal with the Custom Fields endpoint directly. +More information about that on the [SubResources](#subresources) and [Response Objects](#response-objects) sections. + +## Related Items +There's an unified API to created links between two resources. Thus, every Resource object has its own `related` method, +to manipulate those links. As that's a very simple API, you can only list, create and delete relationships. Take a look +on the [Documentation for Related Items] to see the relation limits - some resources allow for only one link, and not +every resource has relationships with every other. + +```php +related(10)->all(); //lists all +$task_projects = CRM::task()->related(22)->projects(); //lists specific type +$task_project = CRM::task()->related(22)->create(10, 'project'); //create one +$task_project = CRM::task()->related(22)->delete(27, 'project'); //and remove +``` + +## Batch Operations +It's also possible to run batch operations, using Guzzle's concurrency features to speed up with parallel calls. +Some single-usage methods have a *Many counterpart, such as: +
+
createMany()
+
straightforward; instead of a payload, you pass a list of payloads
+
editMany()
+
in this case, you got to pass a list of payloads, indexed by IDs.
+
delete()
+
is special, as it can handle an arbitrary number of IDs. Its response will vary on the number of + arguments.
+
+ +You can use an array, Interator or [Generator] on these, and it will take care to run as much as 10 _(future: configurable)_ HTTP calls at the same time. + +As an example, let's create a lot of Task entries, based on a query result (that also has low memory usage), and then +remove these: +```php +createMany(function() use ($thousandsOfTasksQueryResult) { + foreach ($thousandsOfTasksQueryResult as $task) { + yield [ + 'name' => $task->name, + 'due_date' => $task->dueDate->format('U'), + 'status' => $task->completed? 'Completed' : 'Open' + ]; + } +}); + +// as that's a batch operation, it seemed unsafe to throw harsh errors. +// thus, success will give an object of data, while errors return a simple message +$toDelete = []; +foreach ($allTasks as $response) { + if (is_object($response) { + $toDelete[] = $response->id; + } else { + $logger->warning("Couldn't create Task: $response"); + } +} + +//here we use a plain list of arguments: you have to unpack the array +CRM::task()->delete(...$toDelete); +} +``` + +A [generator] is specially useful in these cases as it will save you a lot of memory, by not storing a long list of +payloads/requests in-memory. + +### Batch Relationship operations +Similar to batch API calls, it's also possible to run a bunch of relation changes. To do that, use `relatedBatch()`'s +methods, with a list of ID + Type (or the `Relation` helper object), indexed by origin ID: + +```php +relatedBatch()->create(function() use ($relClientsQuery, $pwTaskId) { + foreach ($relatedClientsQuery as $client) { + // this would generate an array of Relation() objects, indexed by the same ID + // causes no error; this won't become a real array (thus, with keys conflicts) + yield new $pwTaskId => new Relation($client->id, 'company'); + + // the following would also work + //yield new $pwTaskId => ['id' => $client->id, 'type' => 'company']; + } +}); +``` + +## I don't think all those static calls are performant +Indeed, on a very small scale, they might not be. You can always use the half-way object to run common operations, as +when you're running a bunch of operations on the same endpoint. However, the static calls will save you from a couple +of config/instances on one-off calls ;) + +```php +find($clientId); +$tags = array_merge($client->tags, 'new tag'); +$peopleRes->edit($clientId, compact('tags')); +``` + +## Rate limiting +There's also a RateLimit blocker built-in to the SDK, so it will `sleep()` a bit when it notices a limit would be hit, +allowing for new operations shortly after the limit is released. That emits some notices on the CLI when +[Debug mode](#3-debug-mode) is on. This is specially useful for [Batch operations](#batch-operations). + +## Response objects +Most (all?) responses will be a `BareResource` object, or a list of those. The biggest advantage is that class's +"translation" capabilities: it makes some parts of the payload easier to use by leveraging Objects with simpler / +predictable structures, or with some data validation / translation under the hood (AKA [SubResources](#subresources)). + +- most date fields (date_created, due_date, date_last_contacted, ...) will turn from UNIX Timestamps into `DateTime` + objects. _There's a not-really-working setting to disable that, on BareResource (see [#8](issue-8))_ +- a `contact_type` field will be generated with the name related to `contact_type_id`, if any +- [Custom Fields](#custom-field) will generate two entries: + - `custom_fields_raw`, containing the original payload from the API + - `custom_fields`, containing a list of `CustomField` objects, indexed by field name +- some other complex structures will also become SubResource objects, such as: + - address: [`Address`](#address) + - socials, websites: [`URL[]`](#url) + - phone_numbers: [`Phone[]`](#phone) + + +## SubResources +There are a couple of dependant objects that are not to be used directly on API calls, but make part of the main +resources. Most of the times, those are inner documents inside the JSON payload. They're used on responses (see +[Response Objects](#response-objects)), but they're also designed to make your calls easier, "translating" some +information back and forth, and making sure you always follow the requested rules for those sub-documents. + +In special, a SubResource implementing the `TranslateResource` trait will allow read-access to some protected +fields (listed in `$altFields`). In short, when you turn an object into an array on PHP (what we do to get the final +JSON payload) it creates an array of all public fields. Thus, a `TranslateResource` is able to give read access to some +"hidden" properties while not exposing that to the API. See the list below for behavior examples: + +### Address +Accepts as the first argument either a complete line (a string called `street`, because that's how ProsperWorks does), +or an array with two address lines (called `address` and `suite`). To change between those two formats a there's a +"suite" separator (hint: if there's a "suite" on the suite part already, it won't be repeated ok?). The other arguments +are pretty standard, such as city, postal code and so on. + +```php +street; //'221B Baker St. suite 2' +echo $sherlock->address; //'221B Baker St.' +echo $sherlock->suite; //'2' + +$nemo = new Address('42 Wallaby Way', 'Sydney'); +echo $nemo->street; //'42 Wallaby Way' +echo $nemo->address; //'42 Wallaby Way' +echo $nemo->suite; //null + +//and then, use at will: +CRM::person()->create([ + 'name' => 'P. Sherman', + 'address' => $nemo +]); +``` + +### Relation +This was [shown before](#batch-relationship-operations): it's a simple holder for `id` and `type`, no extra features. + +### Custom Field +This is the biggest guy. It does the translation between a bare custom field specification (id + value, not really +meaningful for humans) and actually readable information. The original field ID (same ID for the same field, even +across different types of resources) is stored in `custom_field_definition_id`, together with the `value`. There's +always a read-only `name` property, with the field's actual name, and if it's a list, a read-only string `valueName` +will also be filled with the field's readable value. + +To create a Custom Field entry, you can use both the field ID or field name as the first argument, and either the value +ID or string on the second place. Remember to double-check casing when you use a string instead of the IDs, though! + +The following example displays both how to use the class and how it's returned in the SDK responses: +```php +create([ + 'name' => 'John Doe', + 'custom_fields' => [ + new CustomField('Alias', 'Johnny Doe') + ] +]); + +print_r($person); +// ProsperWorks\Resources\BareResource Object ( +// [id] => 12340904 +// [name] => John Doe +// [custom_fields] => Array ( +// [Alias] => ProsperWorks\SubResources\CustomField Object ( +// [custom_field_definition_id] => 128903 +// [value] => Johnny Doe +// [name:protected] => Alias +// [valueName:protected] => +// [...] +// ) +// ) +// [custom_fields_raw] => Array ( +// [0] => stdClass Object ( +// [custom_field_definition_id] => 128903 +// [value] => Johnny Doe +// ) +// [1] => stdClass Object ( +// [custom_field_definition_id] => 124953 +// [value] => +// ) +// ) +// [...] +// ) +``` + +### "Categorized" sub-resources +All of these classes inherit from `Categorized`, giving them a `category` property and a couple of constants to use as +values. If, on a child class, a constant is marked as "deprecated", it means it doesn't really work with that object. + +Their signature is the same: value as first argument, category as the second one. + +#### Email +Simplest child: has an `email` property, together with the `category`. + +#### Phone +The first argument is the string `number`. But the trick here is you can feed an extension by using something like this: +"123-4444 x123", and it will get separated into two read-only fields: `simpleNumber` and `extension`. + +#### URL +Feed a valid `url` as the first argument and a social URL `category` will be automatically filled, if any matches. +For other cases, you can give the category manually as the second argument, as usual. + +Webhooks +======== +On the other hand, if you want to get updates from ProsperWorks, you have to setup _your_ endpoints for them to call +with any changes that happen. + +> Tip: you may want to take a look on [Webhooks guide]; it's still being worked on - that's why it's still a KB article. + +## Available Events +According to the documentation, there are three types of events that you can subscribe to: +
+
Webhooks::EV_NEW
a new entry was created
+
Webhooks::EV_UPDATE
an entry got updated
+
Webhooks::EV_DELETE
an entry got deleted
+
Webhooks::EV_ALL
catch-all constant that will subscribe you to all events at once
+
+ +And these are available for any of the main endpoints, listed under `Webhooks::ENDPOINTS`: +- Company +- Lead +- Opportunity +- Person +- Project +- Task + +## How to interact with the webhooks +There are a couple of methods on the `Webhooks` class, that you should use on your project's [REPL] or CLI tool when you +configure them, or inside your Controller to manipulate the ProsperWorks calls: + +### To configure webhooks +You must first instantiate the `Webhooks` object with the root path for your application's environment, otherwise it +will use what's configured as default (if any) by `Config`. That in-place config ability will come in handy during +[testing](#how-to-develop-with-webhooks). + +Then, you can use a couple of methods to interact with the ProsperWorks Webhooks API: +
+
list(int $id = null)
+
Returns a list of webhook details, indexed by ID.
+
create(string $endpoint, $event = self::ALL)
+
Subscribes a new webhook for the given endpoint and event match, with the secret specified on `Config`. You + should use one of the CRM::RES_* constants for the first argument.
+
delete(int ...$id)
+
Removes one or more webhooks from the ProsperWorks pool.
+
+ +### To interpret webhook calls +Every time a set event happens on one of the set endpoints, ProsperWorks's servers will make an HTTPS call to an address +that is composed like this: `/prosperworks_hooks//`. Your controllers should somehow be able +to interpret that the way best suits your application, but most of that information is also repeated in the payload, so +feel free to have a single catch-all action to work on it. + +#### The webhook payload +The payload is plain and simple. It contains the affected/generated IDs (usually a single one, unless that's a batch +operation), the affected endpoint and generated event, the webhook subscription ID, a timestamp and our secret. + +Before any further operation, you should verify, for security purposes, if the secret is correct. To do that, call +`validateSecret()` with the payload, in array format. It will search for the secret, decrypt and verify it. Returns null +when the secret is not present, or false if it's there but isn't valid. + +The next step, usually, would be for you to access the ProsperWorks API as usual to gather information on the created / +updated resource, and update your database accordingly, or remove whatever needs to be removed. + +> It's worth saying that, with their current UI, every field change made by the user is automatically saved; that's good +for their users, but every of those save calls will make a new webhook call, and that might overwhelm your server. +> Thus, a nice idea would be to have some sort of **queue system** to work on those changes, and maybe even a +**deboncer** that could reduce repeated changes on the same resources - i.e. discarding payloads with the same endpoint, +event and IDs that happened in a short period of time. + +## How to develop with webhooks +An important fact here is that ProsperWorks demands all webhook calls to be encrypted, what means you must provide an +HTTPS address for them. Besides that, your webserver must be openly accessible on the web. That might not be the most +trivial settings for a development environment, right? + +Well, the tip is using [ngrok](http://ngrok.io) during development. You can run it pretty easily from the command-line +and have it open a stable HTTPS tunnel, and give you it's URL; you feed that into the `Webhooks` object and setup new +subscriptions that ProsperWorks can call when you make changes on their UI. +It's also possible to inspect the HTTP traffic using the Ngrok inspector; its URL is also displayed when you run it. +That might come in handy so you don't need to edit fields a thousand of times to verify your code is working, as it's +able to store and repeat calls it received. Neat, isn't it? + + + +[igorsantos07]: https://github.com/igorsantos07 +[smith-carson]: https://github.com/smith-carson +[REST API Docs]: https://www.prosperworks.com/developer_api +[Webhook Guide]: https://prosperworks.zendesk.com/hc/en-us/articles/217214766-ProsperWorks-Webhooks +[saving instances]: #i-dont-think-all-those-static-calls-are-performant +[Documentation for Related Items]: https://www.prosperworks.com/developer_api/related_items +[Generator]: http://php.net/manual/en/language.generators.overview.php +[REPL]: https://en.wikipedia.org/wiki/Read%E2%80%93eval%E2%80%93print_loop + +[issue-7]: https://github.com/smith-carson/prosperworks-sdk/issues/7 +[issue-8]: https://github.com/smith-carson/prosperworks-sdk/issues/8 \ No newline at end of file diff --git a/src/Endpoints/BaseEndpoint.php b/src/Endpoints/BaseEndpoint.php index 09962b2..753aa3f 100644 --- a/src/Endpoints/BaseEndpoint.php +++ b/src/Endpoints/BaseEndpoint.php @@ -111,7 +111,7 @@ protected function request(string $method, $path = '', array $options = []) foreach ($transaction['request']->getHeaders() as $header) { $error .= print_r($header, true); } - echo "Sync exception: " . $error; + //file_put_contents("/tmp/pwsync.log","Sync exception: " . $error, FILE_APPEND); } RateLimit::do()->pushRequest(); @@ -147,7 +147,7 @@ protected function requestMany(string $method, $paths) } elseif (Config::debugLevel() >= Config::DEBUG_BASIC) { echo strtoupper($method) . " $this->uri/$path\n"; } - + return $this->client->{"{$method}Async"}("$this->uri/$path", $options); }; } @@ -216,7 +216,7 @@ protected function processResponse(ResponseInterface $response) * @param ClientException $error * @return bool|mixed */ - protected function processError(ClientException $error) + protected function processError($error) { return $this->processResponse($error->getResponse()); } diff --git a/src/Endpoints/Endpoint.php b/src/Endpoints/Endpoint.php index 5640b87..d4cd014 100644 --- a/src/Endpoints/Endpoint.php +++ b/src/Endpoints/Endpoint.php @@ -80,7 +80,7 @@ public function createMany($entries) $pwlinkedRelations = []; foreach ($results as $result) { - if (!empty($relations[$result->custom_fields['TM2 ID']->getValue()])) { + if ( !empty($result->custom_fields['TM2 ID']) && !empty($relations[$result->custom_fields['TM2 ID']->getValue()]) ) { $pwlinkedRelations[$result->id] = & $relations[$result->custom_fields['TM2 ID']->getValue()]; } } diff --git a/src/RateLimit.php b/src/RateLimit.php index 4875afb..887475e 100644 --- a/src/RateLimit.php +++ b/src/RateLimit.php @@ -16,8 +16,8 @@ class RateLimit /** Information gathered from the support team, during a huge import script implementation. */ const DEFAULT_LIMITS = [ - 6 => 100, - 36 => 250 + 6 => 90, + 36 => 200 ]; /** @@ -103,7 +103,7 @@ protected function rateLimit() if (Config::debugLevel() >= Config::DEBUG_BASIC) { echo ' [Rate limit reached. Waiting...] '; } - usleep(333333); + sleep(1); $this->pushRequest(0); } }